作者: 魁予
流量有损是在应用发布时的常见问题,其现象通常会反馈到流量监控上,如下图所示,发布过程中服务 RT 突然升高,造成部分业务响应变慢,给用户的最直观体验就是卡顿;或是请求的 500 错误数突增,在用户侧可能感受到服务降级或服务不可用,从而影响用户体验。
因为应用发布会伴随流量有损,所以我们往往需要将发布计划移到业务低谷期,并严格限制应用发布的持续时间,尽管如此,还是不能完全避免发布带来的风险,有时甚至不得不选择停机发布。EDAS 作为一个通用应用管理系统,应用发布是其最基本的功能之一,而 K8s 应用是 EDAS 中最普遍的应用的形态,下文将通过对 EDAS 客户真实场景的归纳,从 K8s 的流量路径入手,分析有损发布产生的原因,并提供实用的解决方案。
K8s 中,流量通常可以从以下几种路径进入到应用 Pod 中,每条路径大相径庭,流量损失的原因各不相同。我们将分情况探究每种路径的路由机制,以及 Pod 变更对流量路径的影响。
通过 LoadBalancer 类型 Service 访问应用时,流量路径中核心组件是 LoadBalancer 和 ipvs/iptables。LoadBalancer 负责接收 K8s 集群外部流量并转发到 Node 节点上,ipvs/iptables 负责将节点接收到的流量转发到 Pod 中。核心组件的动作由 CCM(cloud-controller-manager)和 kube-proxy 驱动,分别负责更新 LoadBalancer 后端和 ipvs/iptables 规则。
在应用发布时,就绪的 Pod 会被添加到 Endpoint 后端,Terminating 状态的 Pod 会从 Endpoint 中移除。kube-proxy 组件会更新各节点的 ipvs/iptables 规则,CCM 组件监听到了 Endpoint 的变更后会调用云厂商 API 更新负载均衡器后端,将 Node IP 和端口更新到后端列表中。流量进入后,会根据负载均衡器配置的监听后端列表转发到对应的节点,再由节点 ipvs/iptables 转发到实际 Pod。
Service 支持设置 externalTrafficPolicy,根据该参数的不同,节点 kube-proxy 组件更新 ipvs/iptables 列表及 CCM 更新负载均衡器后端的行为会有所不同:
通过 Nginx Ingress 提供的 SLB 访问应用时,流量路径核心组件为 Ingress Controller,它不但作为代理服务器负责将流量转发到后端服务的 Pod 中,还负责根据 Endpoint 更新网关代理的路由规则。
在应用发布时,Ingress Controller 会监听 Endpoint 的变化,并更新 Ingress 网关路由后端,流量进入后会根据流量特征转发到匹配规则上游,并根据上游后端列表选择一个后端将请求转发过去。
默认情况下,Controller 在监听到 Service 的 Endpoint 变更后,会调用 Nginx 中的动态配置后端接口,更新 Nginx 网关上游后端列表为服务 Endpoint 列表,即 Pod 的 IP 和端口列表。因此,流量进入 Ingress Controller 后会被直接转发到后端 Pod IP 和端口。
使用微服务方式访问应用时,核心组件为注册中心。Provider 启动后会将服务注册到注册中心,Consumer 会订阅注册中心中服务的地址列表。
在应用发布时,Provider 启动后会将 Pod IP 和端口注册到注册中心,下线的 Pod 会从注册中心移除。服务端列表的变更会被消费者订阅,并更新缓存的服务后端 Pod IP 和端口列表。流量进入后,消费者会根据服务地址列表由客户端负载均衡转发到对应的 Provider Pod 中。
应用发布过程其实是新 Pod 上线和旧 Pod 下线的过程,当流量路由规则的更新与应用 Pod 上下线配合出现问题时,就会出现流量损失。我们可以将应用发布中造成的流量损失归类为上线有损和下线有损,总的来看,上线和下线有损的原因如下,后文将分情况做更深入讨论:
K8s 中 Pod 上线流程如下图所示:
如果在 Pod 上线时,不对 Pod 中服务进行可用性检查,这会使得 Pod 启动后过早被添加到 Endpoint 后端,后被其他网关控制器添加到网关路由规则中,那么流量被转发到该 Pod 后就会出现连接被拒绝的错误。因此,健康检查尤为重要,我们需要确保 Pod 启动完成再让其分摊线上流量,避免流量损失。K8s 为 Pod 提供了 readinessProbe 用于校验新 Pod 是否就绪,设置合理的就绪探针对应用实际的启动状态进行检查,进而能够控制其在 Service 后端 Endpoint 中上线的时机。
对于基于 Endpoint 控制流量路径的场景,如 LB Service 流量和 Nginx Ingress 流量,配置合适的就绪探针检查就能够保证服务健康检查通过后,才将其添加到 Endpoint 后端分摊流量,以避免流量损失。例如,在 Spring Boot 2.3.0 以上版本中增加了健康检查接口/actuator/health/readiness 和/actuator/health/liveness 以支持配置应用部署在 K8s 环境下的就绪探针和存活探针配置:
readinessProbe:
...
httpGet:
path: /actuator/health/readiness
port: ${server.port}
对于微服务应用,服务的注册和发现由注册中心管理,而注册中心并没有如 K8s 就绪探针的检查机制。并且由于 JAVA 应用通常启动较慢,服务注册成功后所需资源均仍然可能在初始化中,比如数据库连接池、线程池、JIT编译等。如果此时有大量微服务请求涌入,那么很可能造成请求 RT 过高或超时等异常。
针对上述问题,Dubbo 提供了延迟注册、服务预热的解决方案,功能概述如下:
延迟注册功能允许用户指定一段时长,程序在启动后,会先完成设定的等待,再将服务发布到注册中心,在等待期间,程序有机会完成初始化,避免了服务请求的涌入。
服务预热功能允许用户设定预热时长,Provider 在向注册中⼼注册服务时,将⾃身的预热时⻓、服务启动时间通过元数据的形式注册到注册中⼼中,Consumer 在注册中⼼订阅相关服务实例列表,根据实例的预热时长,结合 Provider 启动时间计算调用权重,以控制刚启动实例分配更少的流量。通过小流量预热,能够让程序在较低负载的情况下完成类加载、JIT 编译等操作,以支持预热结束后让新实例稳定均摊流量。
我们可以通过为程序增加如下配置来开启延迟注册和服务预热功能:
dubbo:
provider:
warmup: 120000
delay: 5000
配置以上参数后,我们通过为 Provider 应用扩容一个 Pod,来查看新 Pod 启动过程中的 QPS 曲线以验证流量预热效果。QPS 数据如下图所示:
根据 Pod 接收流量的 QPS 曲线可以看出,在 Pod 启动后没有直接均摊线上的流量,而是在设定的预热时长 120 秒内,每秒处理的流量呈线性增长趋势,并在 120 秒后趋于稳定,符合流量预热的预期效果。
在 K8s 中,Pod 下线流程如下图所示:
从图中我们可以看到,Pod 被删除后,状态被 endpoint-controller 和 kubelet 订阅,并分别执行移除 Endpoint 和删除 Pod 操作。而这两个组件的操作是同时进行的,并非我们预期的按顺序先移除 Endpoint 后再删除 Pod,因此有可能会出现在 Pod 已经接收到了 SIGTERM 信号,但仍然有流量进入的情况。
K8s 在 Pod 下线流程中提供了 preStop Hook 机制,可以让 kubelet 在发现 Pod 状态为 Terminating 时,不立即向容器发送 SIGTERM 信号,而允许其做一些停止前操作。对于上述问题的通用方案,可以在 preStop 中设置 sleep 一段时间,让 SIGTERM 延迟一段时间再发送到应用中,可以避免在这段时间内流入的流量损失。此外,也能允许已被 Pod 接收的流量继续处理完成。
上面介绍了在变更时,由于 Pod 下线和 Endpoint 更新时机不符合预期顺序可能会导致的流量有损问题,在应用接入了多种类型网关后,流量路径的复杂度增加了,在其他路由环节也会出现流量损失的可能。
在使用 LoadBalancer 类型 Service 访问应用时,配置 Local 模式的 externalTrafficPolicy 可以避免流量被二次转发并且能够保留请求包源 IP 地址。
应用发布过程中,Pod 下线并且已经从节点的 ipvs 列表中删除,但是由 CCM 监听 Endpoint 变更并调用云厂商 API 更新负载均衡器后端的操作可能会存在延迟。如果新 Pod 被调度到了其他的节点,且原节点不存在可用 Pod 时,若负载均衡器路由规则没有及时更新,那么负载均衡器仍然会将流量转发到原节点上,而这条路径没有可用后端,导致流量有损。
此时,在 Pod 的 preStop 中配置 sleep 虽然能够让 Pod 在 LoadBalancer 后端更新前正常运行一段时间,但却无法保证 kube-proxy 在 CCM 移除 LoadBalancer 后端前不删除节点中 ipvs/iptables 规则的。场景如上图所示,在 Pod 下线过程中,请求路径 2 已经删除,而请求路径 1 还没有及时更新,即使 sleep 能够让 Pod 继续提供一段时间服务,但由于转发规则的不完整,流量没有被转发到 Pod 就已经被丢弃了。
解决方案:
设置 externalTrafficPolicy 为 Cluster 能够避免流量下线损失。因为 Cluster 模式下集群所有节点均被加入负载均衡器后端,且节点中 ipvs 维护了集群所有可用 Pod 列表,当本节点中不存在可用 Pod 时,可以二次转发到其他节点上的 Pod 中,但是会导致二次转发损耗,并且无法保留源 IP 地址。
设置 Pod 原地升级,通过为 Node 打特定标签的方式,设置新 Pod 仍然被调度到本节点上。那么该流程无需调用云厂商 API 进行负载均衡器后端更新,流量进入后会转发到新 Pod 中。
Nginx Ingress 流量场景
对于 Nginx Ingress,默认情况下流量是通过网关直接转发到后端 PodIP 而非 Service 的 ClusterIP。在应用发布过程中,Pod 下线,并由 Ingress Controller 监听 Endpoint 变更并更新到 Nginx 网关的操作存在延迟,流量进入后仍然可能被转发到已下线的 Pod,此时就会出现流量损失。
解决方案:
Ingress 注解“nginx.ingress.kubernetes.io/service-upstream”支持配置为“true”或“false”,默认为“false”。设置该注解为“true”时,路由规则使用 ClusterIP 为 Ingress 上游服务地址;设置为“false”时,路由规则使用 Pod IP 为 Ingress 上游服务地址。由于 Service 的 ClusterIP 总是不变的,当 Pod 上下线时,无需考虑 Nginx 网关配置的变更,不会出现上述流量下线有损问题。但需要注意的是,当使用该特性时流量负载均衡均由 K8s 控制,一些 Ingress Controller 特性将会失效,如会话保持、重试策略等。
在 Pod 的 preStop 设置 sleep 一段时间,让 Pod 接收 SIGTERM 信号前等待一段时间,允许接收并处理这段时间内的流量,避免流量损失。
微服务流量场景
在 Provider 应用发布的过程中,Pod 下线并从注册中心注销,但消费者订阅服务端列表变更存在一定的延迟,此时流量进入 Consumer 后,若 Consumer 仍没有刷新 serverList,仍然可能会访问到已下线的 Pod。
对于微服务应用 Pod 的下线,服务注册发现是通过注册中心而非不依赖于 Endpoint,上述 endpoint-controller 移除 Endpoint 并不能实现 Pod IP 从注册中心下线。仅仅在 preStop 中 sleep 仍然无法解决消费者 serverList 缓存刷新延迟问题。为了旧 Pod 能够优雅下线,在 preStop 中需要首先从注册中心下线,并能够处理已经接收的流量,还需要保证下线前消费者已经将其客户端缓存的 Provider 实例列表刷新。下线实例可以通过调用注册中心接口,或在程序中调用服务框架所提供的接口并设置到 preStop 以达到上述效果,在 EDAS 中可以直接使用http://localhost:54199/offline:
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- curl http://localhost:54199/offline; sleep 30;
上面我们对应用发布过程中三种常用流量路径的流量有损问题进行了原因分析并给出了解决方案。总的来说,为了保证流量无损,需要从网关参数和 Pod 生命周期探针和钩子来保证流量路径和 Pod 上下线的默契配合。EDAS 在面对上述问题时,提供了无侵入式的解决方案,无需更改程序代码或参数配置,在 EDAS 控制台即可实现应用无损上下线。如下图所示:
nginx.ingress.kubernetes.io/service-upstream
除此之外,EDAS 还提供了多种流量网关管理方式,如 Nginx Ingress、ALB Ingress、云原生网关,也为应用的发布提供了多种部署方式,如分批发布、金丝雀发布,还提供了不同维度的可观测手段,如 Ingress 监控、应用监控。在 EDAS 平台管理应用,能够轻松实现多种部署场景下的无损上下线。