Kubernetes中Kitex服务的优雅停机实践
Kubernetes中Kitex服务的优雅停机实践
在Kubernetes(K8s)环境中实现服务的优雅停机是一个复杂但至关重要的任务。本文将详细介绍如何基于K8s的原理以及Kitex的优雅停机功能,搭建一个具备优雅停机的服务架构。通过深入理解服务注册、服务发现和Pod销毁过程中的关键环节,我们将提供具体的解决方案,帮助开发者在K8s架构下更合理地使用Kitex的优雅停机能力。
背景
K8s当前是社区内最主流的容器部署平台,过去社区时常会遇到在K8s集群中,部署的Kitex服务优雅停机功能不符合预期的情况。然而优雅停机与部署环境的服务注册与发现机制,以及服务自身的特性强耦合,框架和容器调度平台只是提供了「优雅停机」的能力,要真正做到优雅停机还依赖整个服务架构上的规划与配合。
出于上述目的,我们希望就这类问题从原理出发,给出一些相应的解决方案,供社区在K8s架构下更为合理的使用Kitex的优雅停机能力。
由于K8s架构中每一个组件都是可插拔的,不同公司尤其是实例数量上了规模的公司,对K8s的服务注册与发现改造程度都有所差异,所以这里我们只讨论默认的也是使用相对最广泛的K8s服务注册与发现模式。
K8s 服务注册
以下是一个常见的线上Pod的Yaml配置示例:
apiVersion: v1
kind: Pod
metadata:
name: xxx-service
spec:
containers:
- name: xxx-service
ports:
- containerPort: 8080
readinessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
原理
K8s在Pod启动后,会通过readinessProbe
的配置,在initialDelaySeconds
秒过后,以periodSeconds
的周期定期检查Pod是否已经准备就绪提供服务,当满足readinessProbe
定义的要求后,才会将容器IP真正注册到Service所属的健康Endpoints之中,从而能够被顺利服务发现。
示例中便是在8080端口能够被建立连接后,即认为服务属于「健康」的状态。
问题与解决方案
可访问 != 可提供服务
在上面的YAML示例中,我们把业务进程建立Listener等同于认为该进程可以提供服务。然而在复杂的业务中,这个等式不一定成立。比如一些服务可能要启动后做一系列初始化操作才能正常提供服务,如果维持上述配置,很可能出现下游滚动更新时,上游出现诸如timeout之类的错误,从而在现象上观察到下游其实并没有做到「优雅停机」。
针对这种情况,我们就需要定制化我们的健康检查函数:
func healthCheck() {
// 缓存预热逻辑
cache.Warming()
// 确保数据库连接池就绪
mysqlDB.Select(*).Where(...).Limit(1)
// 确保外部依赖配置中心获取到了所有必要配置
config.Ready()
// ... 其他必要依赖项检查
return
}
健康检查结果成功返回的前提,必须是确定服务已经可以正常服务请求了。
然后我们可以将该健康检查函数注册到服务中,假设是HTTP协议,则可以配置readinessProbe
为:
readinessProbe:
httpGet:
scheme: HTTP
path: /health
port: 8080
如果是一些RPC协议(如Kitex默认是Thrift协议)或是其他更小众的协议,可以让检测程序与应用程序通过命令沟通健康状态。业务进程在启动后,只有当健康检查通过,才会去创建/tmp/healthy
文件,以告诉K8s服务已经可以被访问:
readinessProbe:
exec:
command:
- cat
- /tmp/healthy
K8s 服务发现
K8s使用Service来作为服务的标识名称,以下是一个常见的Service定义:
apiVersion: v1
kind: Service
metadata:
name: my-app
namespace: my-ns
spec:
type: ClusterIP
clusterIP: 10.1.1.101
原理
K8s提供了通过DNS把一个ServiceName转换为一个ClusterIP的能力。默认情况下,ClusterIP全局唯一且不会随着后端的Pod变化而变化。也就是说,Client侧服务发现结果永远只有一个IP地址。
当上游通过DNS拿到ClusterIP后,直接发起对ClusterIP的连接,由底层操作系统通过iptables劫持到真正的PodIP上。
问题与解决方案
无法严格负载均衡
在iptables机制下,Client对Server的负载均衡这一步是在创建连接时候保持均衡,而Client看到的又只有一个固定的ClusterIP,所以Client仅能做到请求在不同连接上的均衡并不能控制到在不同对端IP上的均衡。
而此时如果Client侧是长连接,那么当下游节点增加了新的Pod时,上游如果一直没有建立新连接,此时新节点可能会迟迟无法接受到足够多的流量,从而无法真正负载均衡。
解决方案
- 使用K8s headless service:
不再使用默认的ClusterIP,要求每个service服务发现返回全量真实的PodIP。
解决了上游对下游节点更新的感知能力的问题,实现在下游实例间真正做到流量均衡。 - 使用Kitex短连接
每一个请求独立创建一个新的短连接,可以利用上iptables的负载均衡能力,顺带也解决了下游销毁时上游能够平滑做到不访问到即将被销毁Pod的问题。 - 使用Service Mesh
依赖数据面实现负载均衡,将问题下放给数据面处理。
K8s 销毁 Pod
K8s提供了一些用以控制销毁Pod时行为的Hooks:
apiVersion: v1
kind: Pod
spec:
containers:
- name: xxx-service
preStop:
exec:
command: ["sleep", "5"]
terminationGracePeriodSeconds: 30
原理
某个Pod将要被销毁时,K8s会以此做以下事情:
- kube-proxy删除上游iptables中的目标IP
这一步虽然一般来说会是一个相对比较快的操作,不会像图里所示这么夸张,但它的执行时间依然是不受保障的,取决于集群的实例规模,变更繁忙程度等多重因素影响,所以用了虚线表示。
这一步执行完毕后,只能确保新建立的连接不再连接到老容器IP上,但是已经存在的连接不会受影响。 - kubelet执行preStop操作
由于后一步操作会立刻关闭listener,所以这一步,我们最好是在preStop中,sleep N秒的时间(这个时间取决于你集群规模),以确保kube-proxy能够及时通知所有上游不再对该Pod建立新连接。 - kubelet发送TERM信号
此时才会真正进入到Kitex能够控制的优雅关闭流程:- 停止接受新连接:Kitex会立刻关闭当前监听的端口,此时新进来的连接会被拒绝,已经建立的连接不影响。所以务必确保前面preStop中配置了足够长的等待服务发现结果更新的时间。
- 等待处理完毕旧连接:
- 非多路复用下(短连接/长连接池):
- 每隔1s检查所有连接是否已经都处理完毕,直到没有正在处理的连接则直接退出。
- 多路复用:
- 立即对所有连接发送一个seqID为0的thrift回包(控制帧),并且等待1s(等待对端Client收到该控制帧)
- Client接收到该消息后标记当前连接为无效,不再复用它们(而当前正在发送和接收的操作并不会受到影响)。这个操作的目的是,client已经存在的连接不再继续发送请求。
- 每隔1s检查所有存量连接是否已经都处理完毕,直到没有活跃连接则直接退出
- 非多路复用下(短连接/长连接池):
- 达到Kitex退出等待超时时间(ExitWaitTime,默认5s)则直接退出,不管旧连接是否处理完毕。
- 达到K8sterminationGracePeriodSeconds设置的超时时间(从Pod进入Termination状态开始算起,即包含了执行PreStop的时间),则直接发送KILL信号强杀进程,不管进程是否处理完毕。
问题与解决方案
Client无法感知即将关闭的连接
这部分问题与前面服务发现时负载均衡的问题根源上是相似的。
当下游节点开始销毁时,Client完全依赖kube-proxy即时删除机器上iptables中的对应PodIP。Client自身只管不停对这个ClusterIP创建连接和发送请求。
所以,如果kube-proxy没有及时删除已经销毁的PodIP,此时就有可能创建连接出现问题。即便kube-proxy删除了PodIP对应的规则,已经建立的连接也不会受到影响,依然会被用于发送新的请求(长连接模式时)。此时这个旧连接的下一次被使用很可能撞上对端Pod正在被关闭的情况,进而出现例如connection reset的错误。
解决方案
- 使用K8s headless service:
当下线的IP被取消注册,上游Kitex Client能自动不再分配新请求给该IP。随着后续对端Server关闭,会自然将上游连接也关闭。 - 使用Service Mesh:
在Mesh模式下,控制面会接管整个服务发现机制,而数据面会接管优雅停机部分的工作,上游应用可以从服务治理细节中解放出来。
Server无法在销毁前处理完所有请求
这个问题本质上取决于几个时间:
- 上游服务发现结果更新时间 (client.DiscoverLatency)
- K8s的preStop等待时间(k8s.PreStopTime)
- 下游服务请求最大处理时间(server.MaxLatency)
- Kitex框架的退出等待时间(kitex.ExitWaitTime)
- K8s的强杀等待时间(k8s.TerminationGracePeriodSeconds)
这几个时间必须严格遵循以下时序图表示的大小关系,否则便可能出现无法优雅关闭的情况:
总结
通过以上繁杂的流程描述,可能就会发现,K8s与Kitex框架都仅仅只是提供了一些参数,来让用户能够实现优雅停机,而并非保证默认的优雅停机相关配置对所有类型的服务在所有的部署环境下,都能够自动实现优雅停机的能力。事实上也不可能做到如此。
此外,虽然严格来说针对每一个具体的服务,做到接近100%的优雅停机是可行的,但从整体全局的服务治理而言,很少能够有足够的人力去针对每一个服务case by case配置相应的参数。所以在真实情况中,我们建议在了解整个系统每一个环节的原理基础上,结合业务自身总体的特点,配置一个相对宽泛安全的优雅停机默认参数即可。对于极端重要且延迟情况比较特殊的服务,再去考虑单独配置特定的参数。
最后,上游也不应当完全假设下游一定能够做到100%的优雅停机,也应当做一些诸如连接失败换节点重试之类的能力,以屏蔽下游小范围的节点抖动,实现更高的SLA。