问题提出
流量有损是在应用发布时的常见问题,其现象通常会反馈到流量监控上,如下图所示,发布过程中服务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环境下的就绪探针和存活探针配置:
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更新时机不符合预期顺序可能会导致的流量有损问题,在应用接入了多种类型网关后,流量路径的复杂度增加了,在其他路由环节也会出现流量损失的可能。
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以达到上述效果,在EDAS中可以直接使用http://localhost:54199/offline:
lifecycle: preStop: exec: command: - /bin/sh - -c - curl http://localhost:54199/offline; sleep 30;
企业级一站式解决方案
上面我们对应用发布过程中三种常用流量路径的流量有损问题进行了原因分析并给出了解决方案。总的来说,为了保证流量无损,需要从网关参数和Pod生命周期探针和钩子来保证流量路径和Pod上下线的默契配合。EDAS在面对上述问题时,提供了无侵入式的解决方案,无需更改程序代码或参数配置,在EDAS控制台即可实现应用无损上下线。如下图所示:
- LB Service支持配置外部流量策略(externalTrafficPolicy)
- Nginx Ingress支持配置“nginx.ingress.kubernetes.io/service-upstream”注解
- 微服务应用配置无损上线参数
除此之外,EDAS还提供了多种流量网关管理方式,如Nginx Ingress、ALB Ingress、云原生网关,也为应用的发布提供了多种部署方式,如分批发布、金丝雀发布,还提供了不同维度的可观测手段,如Ingress监控、应用监控。在EDAS平台管理应用,能够轻松实现多种部署场景下的无损上下线。