写点什么

Nacos 1.1.4 与微服务的实践经验记录

用户头像
itfinally
关注
发布于: 2020 年 05 月 03 日

Nacos 是阿里近年发布的一款基于 Java 实现的服务注册与配置管理中间件. 从观望到实施距今已有半年的时间, 本文将从实践的角度记录并分析 Nacos 在实际落地的过程.



首先将时间拨回 2018 年之前, Dubbo 从发布伊始便通过简单易用与性能稳定的口碑迅速成为国内 RPC 框架的不二之选. 但由于当时 Dubbo 本身的生态并不完善, 且 Dubbo 本身只解决了服务间 RPC 调用问题, 像网关这种流量控制与转发的组件当时只有 Spring Cloud 封装的 Netflix Zuul 组件, 目光自然便转到了 Spring Cloud 生态, 希望通过 Spring Cloud 的生态补充 Dubbo 本身的不足.



既然同时需要用到 Dubbo & Spring Cloud 组件, 那么自然便要同时使用 Spring Cloud Eureka Server 与 Zookeeper 作为注册中心. 彼时阿里尚未提出 Nacos 项目, 甚至当时也没发现 Spring Cloud Zookeeper 这类组件, 双中心注册的情况便应运而生.



双中心下的关系拓扑图

虽然从拓扑图上这种微服务的组织算不上完美, 配置上也稍微复杂, 但的确成功把两种不同流派的微服务组件揉杂在一起并解决掉当时的问题, 从实践的角度而言也相对稳定.



之所以说相对稳定, 是因为两者均存在心跳检测的缺陷, 如 Eureka 存在服务下线后无法立即反馈到注册中心导致流量跑到已下线的服务而报错, Zookeeper 有时候又因为心跳延迟偶尔会找不到服务, 但如果把时间拉长来看, 也的确称得上相对稳定. 而这种情况一直保持, 直到 2019 年后端服务重新做技术架构调整, 而同年年底 Nacos 从各种厂的反馈来看也趋于稳定, 便决定用 Nacos 实现统一中心的想法, 顺便实现配置的统一管理.



本文涉及到的 Nacos 及其组件版本情况如下:

  • Nacos 1.1.4

  • com.alibaba.cloud.spring-cloud-starter-alibaba-nacos-config:2.2.0.RELEASE

  • com.alibaba.cloud.spring-cloud-starter-alibaba-nacos-discovery:2.1.1.RELEASE



: 心跳延迟是因为 Eureka 与 Zookeeper 均为服务端发出信号, 客户端被动响应而产生的延时. 基于这种设计, 个人推测可能客户端下线时并不会主动发出信号通知中心, 这部分暂未看相关源码因此未证实. 而 Nacos 的设计属于客户端主动发出信号, 服务端被动接收, 且服务下线时会主动发出信号, 因此不存在延迟.



配置管理的另一种方式

Nacos 无论从官网给出的构想还是从源码来看, 的确都算得上街上最靓的仔. 但是家家都有本难念的经, 要将其本地化还是要多做实验. 为了与 Springboot 的最佳实践保持一致, 在配置方面我们选择了统一使用 YAML 格式.



首先描述一下当时我们对环境划分的设定, 为了实现多分支的并行开发与测试, 我们在内网划分了多个环境. 可以将内网理解为一个大泳池, 而不同的环境就是这个泳池里的泳道, 各泳道之间互不干扰.



内网环境的划分情况

不同的环境在 Nacos 开发团队预设的构想里, 其实是按照不同的 namespace 进行划分的, 各个环境之间的配置互不干扰. 但构想总会出现不符合现实场景的情况, 这也是为什么基础架构设计时总要反复权衡的缘故. 在真正落地时我们发现这种按 namespace 划分虽然很符合环境隔离的构想, 但是在实际操作中这种操作反而不如使用 profiles 级别来得便捷, 而两者的效果却是相同的.



# 通用的项目配置
spring:
application:
name: demo-application
project:
custom-config:
...
---
# dev1 环境下的项目配置

spring:
profiles: dev1
redis:
host: dev1 环境的 redis 地址
port: dev1 环境的 redis 地址
password: dev1 环境的 redis 密码
---
# dev2 环境下的项目配置

spring:
profiles: dev2
redis:
host: dev2 环境的 redis 地址
port: dev2 环境的 redis 地址
password: dev2 环境的 redis 密码

以上是一个简单的配置例子, 假设使用 namespace 的方式划分配置, 那么配置文件的数量 = 环境数量 * 服务数量. 虽然这对于跟环境强相关的配置而言其实还好, 如 Redis / 数据库这类与环境绑定的配置, 但对于项目的通用配置而言却显得过于繁琐. 而根据当时演练后运维的反馈, 这种 namespace 划分的配置导致工作量暴增, 并且如果改的是通用配置, 稍有不慎就有可能出现漏改.



基于 namespace 构想的配置管理



基于对以上情况的考虑, 最终我们选择了使用 profile 级别划分的方式, 将同一个项目的所有配置( 通用配置 + 非通用配置 )集中在一份配置文件上. 但事情并没有那么顺利, Nacos 客户端的 Springboot 组件并不支持这个 profile 级别划分的特性, 而这个问题曾多次在 Github 上反馈却均无进展, 最终无奈只能自己利用 Java 反射修改了 Nacos 的组件, 相当于打补丁的方式.



public <T extends Map<?, ?>> void parse(String yamlContent, T container) {
// 根据 YAML 的 '---' 符号进行文本分割
List<String> configs = splitYamlContent(yamlContent);
@SuppressWarnings("unchecked")
Map<String, String> castedContainer = (Map<String, String>) container;
// 迭代所有 profiles 块, 选择被激活的配置
for (String config : configs) {
YamlPropertiesFactoryBean yamlFactory = new YamlPropertiesFactoryBean();
yamlFactory.setResources(new ByteArrayResource(config.getBytes()));
Properties subProperties = yamlFactory.getObject();
if (Objects.isNull(subProperties)) {
continue;
}
Map<String, String> subConfig = subProperties.entrySet().stream().collect(Collectors.toMap(
entry -> Objects.toString(entry.getKey()),
entry -> Objects.toString(entry.getValue())
));
if (!subConfig.containsKey("spring.profiles")) {
// 没有声明 spring.profiles 的文本块为通用配置
castedContainer.putAll(subConfig);
} else {
String profiles = subConfig.get("spring.profiles");
boolean isActive = Arrays.stream(profiles.split(",")).anyMatch(activeProfiles::contains);
if (isActive) {
castedContainer.putAll(subConfig);
}
}
}
}

以上是根据目前 Springboot 对 YAML 的支持情况重新实现的 YAML 解析流程, 但实现后仍需手动在 Springboot 环境加载之前使用反射将其注入到 Nacos 对象内, 详细代码在 com.alibaba.cloud.nacos.parser.NacosDataParserHandler 内, 其中 parser 对应的 com.alibaba.cloud.nacos.parser.AbstractNacosDataParser 类型是一个链表结构的设计. 感兴趣的小伙伴可以自行阅读源码.



至于具体如何植入代码, 什么时候植入才生效, 为什么默认的 NacosDataYamlParser 无法实现多 profile 的特性, 由于涉及太多具体实现, 本文篇幅有限就不作叙述, 留给有兴趣的小伙伴自行挖掘.



先易后难, 步步为营

在解决配置问题之后, 便要选择首先改造的对象. 由于在我们服务内使用了 Zuul 作为网关, 因此需要 Eureka 为其提供服务信息. 反过来说就是跟 Eureka 相关的只有网关组件. 而 Dubbo 由于涉及太多 RPC 接口, 一旦出现问题影响面较大, 因此我们决定将 Eureka 改为 Nacos 看看实际效果.



这个阶段的改造过程比较愉快, 基本按照 Nacos 官网上 Spring Cloud 部分的配置来就不会出现什么错误. 但在我们以为一切顺利时, 生产却出现了网络问题. TIME_WAIT 状态的 socket 数莫名地飙升, 虽然重启服务后下降下来但一段时间后又重新飙升. 生产服务器一度因为 socket 的缓冲区打满而拒绝服务, 全靠蓝绿切换重启服务这种蹩脚的手段才勉强应付业务的响应.



由于当时上线夹杂的别的技术更新, 如 Web 容器的更换( Tomcat 切换为 Undertow ), JDK 发行版的更换( oracleJDK 换成 openJDK ), 以至于当时各种怀疑却查不出任何问题, 最终只能在生产使用排除法来寻找问题. (虽然所有的 TIME_WAIT socket 均指向 Nacos 服务的端口, 但当时我们并没有真正怀疑 Nacos)



在各种排查无果之后重新分析了 TIME_WAIT 状态, 由于 TCP 链接的关闭是四次握手确认, 而 TIME_WAIT 状态本身是在描述请求方主动关闭链接而响应方没有响应. 因为 Nacos 本身是在客户端通过 HTTP 协议主动对 Nacos 服务端发起请求, 因此我们开始对阿里的服务与 Nacos 客户端各种怀疑各种排查.



Nacos Client 导致 TIME_WAIT 的代码

当服务本身使用了 Nacos 作为注册中心, 那么服务才是请求方, 而 Nacos 服务才是响应方, 重新定位角色是排查这个问题最关键的一步. 通过多次源码对比与求证, 最终排查出来的诱因如下:

  • Java 本身的 HttpURLConnection 对象会主动对链接进行重用.

  • Nacos Client 在发起请求前均加入了 "Connection: Keep-Alive" 请求头保持长链接.

  • 在请求流程结束后主动对 HttpURLConnection 执行 disconnect 方法关闭连接, 而非 getInputStream().close() 单纯关闭数据输入流.



之所以会出现这次事故除了 Nacos 本身的确有瑕疵以外, 也是因为疏忽没有对其进行压测. 这个 Bug 还有个特性是 TIME_WAIT 数量与用户请求数成正比, TIME_WAIT 暴增的情况主要集中在网关与访问量较大的业务服务上. 但无论是发生在哪里, 消耗的都是服务器本身的资源, 最终导致全服拒绝访问.



以上种种因素聚集在一起便形成本次线上事故, 一句话总结便是 "服务端以为客户端会一起没羞没臊, 结果客户端完事了就提裤子走人". 根据这些已知问题我们自行修改了 Nacos Client 的相关代码并重新打版到私服, 至此困扰了整整一周的问题最终得以解决.



: 该问题在官方 1.2.0 发行版中已修复, 但由于新版本引入了权限管理, 而我们测试后认为该特性尚未成熟因此并无采用该版本.



花明柳暗又一村

为什么花明柳暗又一村? 因为过了这一村还有下一村, 现实可不会这么轻易放过任何人. 在 Eureka 成功改造之后, 我们继而将目光转向 Zookeeper. 有了之前 Eureka 改造的经验, Dubbo 服务去 Zookeeper 化的落地可以说得是四平八稳, 但这次我们却重新回到了配置的问题.



到了这一阶段, 配置文件已经完成格式与规范的制定, 并且交由 Nacos 进行管理, 自然我们也希望 Dubbo 也是这么做. 当时考虑到 Dubbo 与 Nacos 的兼容性, 便大胆地从 Dubbo 2.6.5 升级为 Dubbo 2.7.4.1 ( Dubbo 2.5.0 之后整个中间件架构给调整了还有瑕疵, 建议暂时观望 ). 之所以能直接升级, 一方面是业务系统对 Dubbo 专有特性几乎没有什么依赖, 另一方面也是因为从 Dubbo 2.7.0+ 开始的版本才真正支持动态配置中心, 因此 Dubbo 升级成了必然事件, 否则配置要独立维护就比较麻烦.



这次的问题在于 Dubbo 所谓的动态配置中心流程里, 在 Dubbo 与 Nacos 配置中心对接测试时发现, YAML 配置并没有按预定设想生效, 项目启动后多次提示没有找到 application name. 再一次无奈之后便调试 Dubbo 启动流程并找到了如下源码:



AbstractInterfaceConfig 类的 prepareEnvironment 方法源码

上图的代码是 Dubbo 初始化时执行的流程( 前提是提供了动态配置中心配置 ), 代码里明显看出无论配置中心给出什么格式的配置文本, Dubbo 都会当作 Properties 格式进行解析, 这也就不难理解为什么无论在 Nacos 怎么配置都无法启动 Dubbo 了.



所幸的是, Dubbo 远程配置中心组件是通过 Dubbo SPI 进行注入, 只需要自定义一个新的远程配置中心协议即可让 Dubbo 使用新的组件, 甚至让 Dubbo 重新读取 Spring 上下文的配置, 具体实现如下:



public class SpringEnvironmentDynamicConfigurationFactory extends AbstractDynamicConfigurationFactory {
@Override
protected DynamicConfiguration createDynamicConfiguration(URL url) {
return new SpringEnvironmentDynamicConfiguration();
}
public static final class SpringEnvironmentDynamicConfiguration implements DynamicConfiguration {
@Delegate(excludes = IgnoreMethods.class)
private final NopDynamicConfiguration dynamicConfiguration = new NopDynamicConfiguration(null);
@Override
public String getProperties(String key, String group) throws IllegalStateException {
return getProperties(key, group, -1L);
}
@Override
public String getProperties(String key, String group, long timeout) throws IllegalStateException {
ApplicationContext context = getSpringContext();
Map<String, String> properties = fetchDubboVariables(context);
return properties.entrySet().stream()
.map(it -> it.getKey() + "=" + it.getValue())
.collect(Collectors.joining("\n"));
}
private ApplicationContext getSpringContext() {
Set<ApplicationContext> contexts = SpringExtensionFactory.getContexts();
if (contexts.isEmpty()) {
throw new NullPointerException("没有在 dubbo 内找到 spring 上下文");
}
return new ArrayList<>(contexts).get(0);
}
private Map<String, String> fetchDubboVariables(ApplicationContext context) {
AbstractEnvironment environment = (AbstractEnvironment) context.getEnvironment();
Map<String, String> environmentProperties = new HashMap<>(64);
for (PropertySource<?> source : environment.getPropertySources()) {
if (!(source instanceof EnumerablePropertySource)) {
continue;
}
if (!(source.getSource() instanceof Map)) {
continue;
}
@SuppressWarnings("unchecked")
Map<String, Object> unknownSource = (Map<String, Object>) source.getSource();
// 获取 Dubbo 配置
unknownSource.forEach((key, value) -> {
boolean isNotDubboVariables = !key.startsWith("dubbo");
// 未匹配的变量需要从 Spring environment 获取
boolean isUnCompletedVariables = value.toString().contains("${");
if (isNotDubboVariables || environmentProperties.containsKey(key)) {
return;
}
environmentProperties.put(key, isUnCompletedVariables
? environment.getProperty(key)
: Objects.toString(value));
});
}
return environmentProperties;
}
}
private interface IgnoreMethods {
String getProperties(String key, String group) throws IllegalStateException;
String getProperties(String key, String group, long timeout) throws IllegalStateException;
}
}

无论组件内如何处理, 最终还是要将所有配置按照 Properties 格式的拼接成文本形式并返回给 Dubbo, 让 Dubbo 正确读取到配置, 至此便完成对 Dubbo 本地化的工作.



最后

以上便是 Nacos 在落地过程中遇到的大部分问题, 其中有较多具体细节由于篇幅有限此处不展开描述. 一个中间件的落地最终还是要结合团队的实际情况, 或多或少做一点本地化工作. 下图是改造完成后的服务关系图:



Nacos 落地后的服务关系



此外, 上文涉及到的代码片段, 流程片段均需要读者自行调试摸索一次. 其中很多地方为什么能这么做, 为什么要这么做, 这些思考过程在本文均没有提出, 个人认为这些观点留给读者自行摸索感悟会比较好, 而非一昧地接收他人认为的观点.

发布于: 2020 年 05 月 03 日阅读数: 244
用户头像

itfinally

关注

Step back, look at the bigger picture. 2019.07.15 加入

共产主义接班人, 感动中国2008年度人物特别奖获奖人, 百布搬砖小能手

评论

发布
暂无评论
Nacos 1.1.4 与微服务的实践经验记录