K8s有损发布问题探究

问题提出

流量有损是在应用发布时的常见问题,其现象通常会反馈到流量监控上,如下图所示,发布过程中服务RT突然升高,造成部分业务响应变慢,给用户的最直观体验就是卡顿;或是请求的500错误数突增,在用户侧可能感受到服务降级或服务不可用,从而影响用户体验。
因为应用发布会伴随流量有损,所以我们往往需要将发布计划移到业务低谷期,并严格限制应用发布的持续时间,尽管如此,还是不能完全避免发布带来的风险,有时甚至不得不选择停机发布。EDAS作为一个通用应用管理系统,应用发布是其最基本的功能之一,而K8s 应用是EDAS中最普遍的应用的形态,下文将通过对EDAS客户真实场景的归纳,从K8s的流量路径入手,分析有损发布产生的原因,并提供实用的解决方案。

流量路径分析

K8s中,流量通常可以从以下几种路径进入到应用Pod中,每条路径大相径庭,流量损失的原因各不相同。我们将分情况探究每种路径的路由机制,以及Pod变更对流量路径的影响。

LB Service流量

通过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更新负载均衡器后端的行为会有所不同:
• Local模式:CCM 仅会将目标服务所在节点添加入负载均衡后端地址列表。流量到达该节点后仅会转发到本节点的Pod中。
• Cluster模式:CCM会将所有节点都添加到负载均衡后端地址列表。流量到达该节点后允许被转发到其他节点的Pod中。

Nginx Ingress流量

通过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上下线配合出现问题时,就会出现流量损失。我们可以将应用发布中造成的流量损失归类为上线有损和下线有损,总的来看,上线和下线有损的原因如下,后文将分情况做更深入讨论:
• 上线有损:新Pod上线后过早被加入路由后端,流量被过早路由到了未准备好的Pod。
• 下线有损:旧Pod下线后路由规则没有及时将后端移除,流量仍路由到了停止中的Pod。

上线有损分析与对策

K8s中Pod上线流程如下图所示:
如果在Pod上线时,不对Pod中服务进行可用性检查,这会使得Pod启动后过早被添加到Endpoint后端,后被其他网关控制器添加到网关路由规则中,那么流量被转发到该Pod后就会出现连接被拒绝的错误。因此,健康检查尤为重要,我们需要确保Pod启动完成再让其分摊线上流量,避免流量损失。K8s为Pod提供了readinessProbe用于校验新Pod是否就绪,设置合理的就绪探针对应用实际的启动状态进行检查,进而能够控制其在Service后端Endpoint中上线的时机。

基于Endpoint流量场景

对于基于Endpoint控制流量路径的场景,如LB Service流量和Nginx Ingress流量,配置合适的就绪探针检查就能够保证服务健康检查通过后,才将其添加到Endpoint后端分摊流量,以避免流量损失。例如,在Spring Boot 2.3.0以上版本中增加了健康检查接口/actuator/health/readiness和/actuator/health/liveness以支持配置应用部署在K8S环境下的就绪探针和存活探针配置:

1
2
3
4
5
readinessProbe: 
...
httpGet:
path: /actuator/health/readiness
port: ${server.port}

微服务流量场景

对于微服务应用,服务的注册和发现由注册中心管理,而注册中心并没有如K8s就绪探针的检查机制。并且由于JAVA应用通常启动较慢,服务注册成功后所需资源均仍然可能在初始化中,比如数据库连接池、线程池、JIT编译等。如果此时有大量微服务请求涌入,那么很可能造成请求RT过高或超时等异常。
针对上述问题,Dubbo提供了延迟注册、服务预热的解决方案,功能概述如下:
• 延迟注册功能允许用户指定一段时长,程序在启动后,会先完成设定的等待,再将服务发布到注册中心,在等待期间,程序有机会完成初始化,避免了服务请求的涌入。
• 服务预热功能允许用户设定预热时长,Provider在向注册中⼼注册服务时,将⾃身的预热时⻓、服务启动时间通过元数据的形式注册到注册中⼼中,Consumer在注册中⼼订阅相关服务实例列表,根据实例的预热时长,结合Provider启动时间计算调用权重,以控制刚启动实例分配更少的流量。通过小流量预热,能够让程序在较低负载的情况下完成类加载、JIT编译等操作,以支持预热结束后让新实例稳定均摊流量。
我们可以通过为程序增加如下配置来开启延迟注册和服务预热功能:

1
2
3
4
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更新时机不符合预期顺序可能会导致的流量有损问题,在应用接入了多种类型网关后,流量路径的复杂度增加了,在其他路由环节也会出现流量损失的可能。

LB Service流量场景

在使用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以达到上述效果,