作者:谢文欣(风敬)
为什么要做流量隔离
源于一个 EDAS 客户遇到的棘手情况:他们线上的一个 Pod CPU 指标异常,为了进一步诊断问题,客户希望在不重建此 Pod 的情况下保留现场,但诊断期间流量还会经过这个异常 Pod,导致影响服务质量,于是询问我们有没有办法可以把流入异常节点的流量摘除掉,形成一个隔离的诊断环境。经诊断后,如果异常可以修复,待修复完成后,再解除流量隔离,节点恢复正常工作。
除了在诊断场景需要对所有输入流量进行隔离外,在一些线上演练中还需对特定流量进行隔离以实现模拟演练效果。面对这类流量隔离问题时,我们首先考虑的是全链路流量控制。目前,EDAS 上的全链路流控能够在不重启应用节点的情况下控制流量走向。然而,全链路流控仅能控制微服务框架流量,无法满足隔离所有或特定流量的需求。
为此,我们进行了深入研究,实现了一套开箱即用的流量隔离工具,能够动态隔离特定流量,并在隔离后可随时恢复,以满足各种场景下的流量隔离需求。
隔离哪些流量
流量隔离的目的是阻断应用节点的流入流量,首先明确下微服务应用节点流入的流量有哪些。
流入微服务应用节点的流量大致可以分为两大类:服务流量、事件流量。以常见的微服务应用为例,其流量组成如下图所示。
服务流量指一个微服务应用的所有节点作为一个网络实体,对外提供一组服务,被其他系统、服务或用户发起请求产生的调用。对于服务流量,节点本身不直接决定流量的流入与否,而是由一套服务注册与发现机制维护流量路径的逻辑关系。节点经过注册,成为服务的一个端点。调用方对服务发起请求时,被调用方是服务的逻辑地址,经过转发和地址转换,请求被路由到服务端点的实体节点。隔离服务流量的一个可选方案是破坏服务调用的通信连接,但这种方法势必会影响服务质量。在保持服务整体功能正常运行的同时,一个更优雅的方案是破坏服务与实体节点之间的映射关系。这样,在路由过程中,流量将按照预期避开特定节点,而被引导至其他节点。服务流量主要涵盖了 K8s Service 以及使用 Nacos 等注册中心发布的由 Spring Cloud、Dubbo 等微服务框架构建的服务。
事件流量指应用内部的事件驱动架构产生的流量,包括由中间件传递至应用节点的事件或消息,这类通信通常是异步的,例如来自消息队列 RocketMQ 的消息流量,来自调度框架 SchedulerX 触发调度的事件流量。中间件和应用节点之间通常遵循 client-server 通信,因此可以考虑通过破坏通信连接来隔离中间件发来的消息或事件流量。
服务流量隔离
K8s Service
对于使用 K8s Service 暴露服务的应用,Service 声明的服务与应用 Pod 之间的映射关系由 Endpoints 对象维护。Endpoints 对象的 subsets 字段表示 Serivce 的一组端点,每个端点代表一个应用 Pod 的网络地址,即一个实际提供服务的 Pod 实例。subsets 字段包含了这些端点的详细信息,如 IP 地址和端口。Endpoints 控制器通过 API Server 监听 Pod 的变更情况,并随后同步更新 Endpoints 的端点列表。因此,要隔离 K8s Service 的流量,需要破坏 Endpoints 对 Pod 的指向,将待隔离的 Pod 网络地址从 Endpoints 的端点列表中移除。同时,需要通过 Informer 机制监听 Endpoints 对象的变化,以保证 Endpoints 在后续变更或控制器 Reconcile 过程中也能维持预期状态。
Dubbo
对于使用注册中心暴露服务的应用,注册中心负责管理服务节点。只要注册关系存在且应用节点存活,注册中心会将流量调度到该应用节点。而破坏服务注册关系的操作被称为服务注销,应用节点进行服务注销之后,注册中心便不会将流量导入到注销节点,也就形成了流量隔离。
要实现 Dubbo 微服务的动态注销,首先需要从源码级别了解 Dubbo 服务注册原理。以 Dubbo 2.7.0 为例,其服务注册模块的大致结构如下:
- Dubbo 应用中存在一个 AbstractRegistryFactory 单例,负责注册中心 Registry 的容器初始化。类属性 REGISTRIES 维护了微服务列表与注册中心实例的映射关系。
- AbstractRegistry 实现了 Registry 接口,作为一个模板,实现了特定的公共方法,如服务注册(register)、服务注销(unregister)等。它还维护了已注册服务 URL 列表。
- FailbackRegistry 基于 AbstractRegistry,提供了失败重试机制。同时,它提供了注册中心的 doRegister 和 doUnregister 抽象方法。当执行 register/unregister 时,会调用 doRegister/doUnregister 方法。
- 注册中心(如 NacosRegistry、RedisRegistry)实现了具体的服务注册(doRegister)和服务注销(doUnregister)逻辑。
由源码可见,Dubbo 的服务注册模块已经内置了可动态注销/重注册服务的方法。因此, Dubbo 微服务隔离可通过主动触发其注册中心对象的服务注销方法来实现。同理,如果需要恢复服务节点,主动触发服务注册方法,更新注册中心的服务映射关系。
在确定「触发注册中心对象的服务注销方法」这一技术方向之后,需要解决如何获取对象和触发方法这两个问题。在 Java 环境中,我们很容易想到使用 Agent 技术对进程行为进行干预。然而,常规的基于字节码埋点的 Agent 无法满足随时启用的需求,因为它依赖于应用代码的具体执行路径。只有当执行路径触及埋点时,Agent 代码才会被触发,从而从上下文中获取对象并通过反射调用相关方法。然而,与注册中心相关的埋点通常设置在程序启动初期,此时会执行注册中心初始化、服务注册等操作,比较容易找到合适的埋点。在程序对外提供服务期间,程序主动发起的注册中心操作较少,因此很难找到合适的埋点来获取预期的上下文。在需要隔离应用流量时,此时动态挂入 Agent,由于执行路径中没有能获取注册中心上下文的埋点,Agent 代码将无法生效。
因此,我们需要一个能够主动获取对象并触发对象方法的即开即用的 Agent 工具。在这里,我们引入了 JVMTI 技术。JVMTI(JVM Tool Interface)是一种虚拟机提供的原生编程接口,允许开发人员创建 Agent 以探查 JVM 内部的运行状态,甚至控制 JVM 应用程序的执行。JVMTI 能够从 Java 堆中获取特定类和对象信息,然后通过反射触发方法,完美地满足了我们的需求。
由于 JVMTI 是一套 JVM 原生编程接口,需要使用 C/C++ 进行编写。编译后的产物是动态链接库(.so 或 .dll 文件)。Java 运行环境通过 JNI(Java Native Interface)与 JVMTI 进行交互。整体作为一个 Java Agent,通过 Attach API 动态地挂载到目标 JVM 中。
得益于 JVMTI Agent 的强大功能,我们能够在 Java 应用内相对简便地实施某些控制逻辑。为实现 Dubbo 服务流量隔离,首先需要获取 AbstractRegistryFactory 类的静态属性 REGISTRIES,它包含应用当前已注册服务的服务列表以及相应的注册中心 Registry 实例。对于特定的微服务,仅需调用其注册中心 Registry 的 register/unregister 方法,便可实现服务的动态摘除和恢复。这一方案直接在较高抽象层级上操作,而无需依赖具体的注册中心 Registry 实现类,使其兼容所有注册中心。
Spring CLoud
Spring Cloud 服务流量隔离方法类似于 Dubbo,在了解 Spring Cloud 服务注册原理后,获得服务注册/注销方法路径,然后通过 JVMTI 干预应用的服务注册/注销行为。
Spring Cloud 的服务注册原理较为简单。在 Spring 容器启动时,AbstractAutoServiceRegistration 监听启动事件,并调用 ServiceRegistry 的 register 方法将 Registration(服务实例数据)注册到注册中心。例如,Nacos服务注册类 NacosServiceRegistry 实现了 ServiceRegistry 接口,通过重载 register/deregister 方法完成服务在注册中心的注册和注销。
// 服务注册类
public abstract class AbstractAutoServiceRegistration...{
// 注册中心实例
private final ServiceRegistry serviceRegistry;
// 服务注册
protected void register() {
this.serviceRegistry.register(getRegistration());
}
// 服务注销
protected void deregister() {
this.serviceRegistry.deregister(getRegistration());
}
}
在处理 Spring Cloud 服务流量隔离时,首先获取 AbstractAutoServiceRegistration 的服务注册实例,然后调用 register/deregister 方法以在注册中心上完成服务的注销和重注册。这种方法同样不依赖于某个特定注册中心的具体实现类,兼容所有注册中心。
事件流量隔离
应用节点和中间件通常采用 client-server 模式通信,像 RocketMQ 和 SchedulerX 使用了 Netty 作为底层网络框架完成客户端和服务端通信 。在此,我们以 RocketMQ 为例,来说明如何实现类似的事件驱动中间件的流量隔离。
RocketMQ client 端的主要实现类是 NettyRemotingClient。如下图所示,NettyRemotingClient 类中的属性 channelTables 存储了用于传输数据的 Channel,而 lockChannelTables 是用于控制 channelTables 更新的锁。与此同时,有几个 invoke 方法负责处理通信过程。
通信处理流程如下图。首先,尝试从 channelTables 中获取用于通信的 Channel。如果没有可用的 Channel,则重新连接 server 端以创建 Channel。为了保证线程间同步,新 Channel 更新到 channelTables 时需要获得 lockChannelTables 锁。如果在指定时间窗口内 lockChannelTables 一直被占用,将会抛出连接异常。
根据以上的原理分析,我们可以通过占用 lockChannelTables 锁来阻止 Channel 的建立,再把现存的 Channel 关闭,则 client 端在 lockChannelTables 被释放之前都无法与 server 端建立通信连接。若要恢复流量,仅需释放 lockChannelTables 锁,client 端将自动重建 Channel 并恢复通信。由于这种管控是在网络客户端层进行的,因此它不受应用消息模型的影响,既适用于同步消息也适用于异步消息;同时也与 client 角色无关,既适用于消费者也适用于生产者。
结语
目前流量隔离工具在 EDAS-云原生工具箱 可试用体验。如果对流量隔离以及更多云原生工具感兴趣,欢迎留言或加入钉群:21958624 与我们进行沟通与交流。