Kubernetes 中优雅停机和零宕机部署
设置一个preStop hook
,在 hook 中指定怎么优雅停止容器在 K8S 中,创建 pod、删除 pod 是最频繁的操作,不论是新增还是升级都会触发。对于新增或者重建我们最关心的是什么时候提供服务,对于删除我们关心的是什么时候不提供服务。那么对于这个临界点在 K8S 中是如何判定的呢?
在讨论这个临界点之前,我们先看看创建或删除 pod 的流程。
创建 Pod 的过程
当 API 收到创建 Pod 的请求,然后会将 Pod 的定义存储到 etcd 中,然后 scheduler 会将 pod 加入到调度队列中(如果没有做调度优先级配置,默认是放在队列最后),然后 scheduler 会根据预选、优选策略给 pod 分配一个最有的 node 节点,然后这个 pod 会被标记为Scheduled
,并将其状态存储到 etcd 中。
到目前为止 pod 还并没有被创建,因为创建 Pod 需要通过 kubelet 组件来完成。kubelet 组件会通过 apiserver 来获取 pod 的状态,同样也会上报 pod 的状态。当某个节点检测到该 pod 是调度到自己节点的时候,就会在本节点创建这个 pod,不过创建 pod 并不是 kubelet 自己动手,而是交给下面三个组件来完成。
- 容器运行时接口(CRI):为 Pod 创建容器的组件。
- 容器网络接口(CNI):将容器连接到集群网络并分配 IP 地址的组件。
- 容器存储接口(CSI):在容器中装载卷的组件。
到现在 pod 创建完成了,然后会将该 pod 的状态上报给 apiserver 并存储在 etcd 中。
现在 pod 创建完成了,但是在 k8s 中,pod 并不适合直接提供服务,如果在集群内部是通过 service 来提供服务,如果集群外部需要访问,是通过 ingress 来提供访问入口。那如果我 ingress 以及 service 的某个 pod 发生了变化,它们又该如何更新呢?
在这之前先简单介绍一下 service 和 pod 的关系。
service 和 pod 是通过 label selector(标签选择器)来进行关联的,只要符合 service 中定义的 label selector,就会将其地址和端口维护到 Endpoints 中,如下:
# kubectl describe svc website
Name: website
Namespace: default
Labels: <none>
Annotations: kubectl.kubernetes.io/last-applied-configuration:
{"apiVersion":"v1","kind":"Service","metadata":{"annotations":{},"name":"website","namespace":"default"},"spec":{"ports":[{"name":"http","...
Selector: app=website
Type: ClusterIP
IP: 10.101.58.163
Port: http 80/TCP
TargetPort: 80/TCP
Endpoints: 192.168.4.9:80
Session Affinity: None
Events: <none>
Endpoints 对象会从 Pod 中收集所有的 IP 地址和端口,而且不仅一次。在以下情况中,Endpoint 对象将更新一个 endpiont 新列表:
- Pod 创建时。
- Pod 删除时。
- 在 Pod 上修改标签时。
当 pod 通过Readiness
探针后,才标识这个 pod 真正可用。当 pod 可用过后,service 会通过 label selector 找到所有匹配的 Pod,然后通过 k8s 更新 endpoint,Endpoints 也会做相应的更新。
除了 service,还有 kube-proxy,ingress 都会使用到 endpoint,它们也会进行相应的更新,kube-proxy 会通过 endpoint 来更新 iptables 或者 ipvs 规则,ingress 更新 endpoint 是为了让 pod 接入外部流量。
所以创建 pod 的过程以及 pod 创建完成后的一系列变化可以总结如下:
1、apiserver 收到创建 pod 的请求(可以是直接创建 pod 的定义,也可以是通过其他控制器来完成的)。
2、Pod 的定义存储在 etcd 中。
3、scheduler 参与调度 Pod,为其分配最优节点,并把相关信息存储到 etcd 中。
4、kubelet 监听到 pod 的信息,在节点上创建 pod,分配资源以及 IP 等,将信息存储到 etcd 中。
5、kubelet 等待 pod 的 Readiness 探针成功,并对相关的 Endpoints 对象更改进行通知。
6、Endpoints 将新的 endpoint 添加到列表中。
7、其他组件控制器根据 Endpoints 做相应的更改配置,比如 kube-proxy 会重新创建或者更改 iptables/ipvs 规则等。
删除 Pod 的过程
删除 pod 的主要流程如下:
- 用户发送命令删除 Pod,使用的是默认的宽限期(30 秒)
- API 服务器中的 Pod 会随着宽限期规定的时间进行更新,过了这个时间 Pod 就会被认为已 “死亡”。
- 当使用客户端命令查询 Pod 状态时,Pod 显示为 “Terminating”。
- (和第 3 步同步进行)当 Kubelet 看到 Pod 由于步骤 2 中设置的时间而被标记为 terminating 状态时,它就开始执行关闭 Pod 流程。
- 如果 Pod 定义了 preStop 钩子,就在 Pod 内部调用它。如果宽限期结束了,但是
preStop
钩子还在运行,那么就用小的(2 秒)扩展宽限期调用步骤 2。 - 给 Pod 内的进程发送 TERM 信号。请注意,并不是所有 Pod 中的容器都会同时收到 TERM 信号,如果它们关闭的顺序很重要,则每个容器可能都需要一个
preStop
钩子。
- 如果 Pod 定义了 preStop 钩子,就在 Pod 内部调用它。如果宽限期结束了,但是
- (和第 3 步同步进行)从服务的端点列表中删除 Pod,Pod 也不再被视为副本控制器的运行状态的 Pod 集的一部分。因为负载均衡器(如服务代理)会将其从轮换中删除,所以缓慢关闭的 Pod 无法继续为流量提供服务。
- 当宽限期到期时,仍在 Pod 中运行的所有进程都会被 SIGKILL 信号杀死。
- kubelet 将通过设置宽限期为 0 (立即删除)来完成在 API 服务器上删除 Pod 的操作。该 Pod 从 API 服务器中消失,并且在客户端中不再可见。
在这里就有一个不确定因素,那就是你无法判断到底是 pod 先终止还是 endpoints 列表先更新。这里简单说两种情况。
1、pod 删除了 endpoints 还未更新,这种情况下会导致丢包。不管是从 ingress 还是从 service 来的流量,由于它们的 endpoints 并未及时更新,就会导致调度到已经不存在的 Pod 上,这样就会导致请求丢失。
2、endpoints 更新了,pod 还未删除,这种情况下也会丢包。虽然前面流量进不来了,但是自己还未处理完的请求也响应不了。当然,如果 pod 的停止时间超过了默认的宽限期,就会被强制终止。
鉴于此,就需要使用优雅退出来处理这种情况。
优雅退出
优雅退出有两种常见的解决方法:
- 应用本身可以处理 SIGTERM 信号。
- 设置一个 preStop hook,在 hook 中指定怎么优雅停止容器
在这之前先简单介绍一下 SIGTERM 和 SIGKILL 这两个信号。
- SIGKILL:立刻结束程序。该信号不能被阻塞、处理和忽略,不能在程序中被获取到。
- SIGTERM:程序结束(Terminate)信号,又叫请求退出信号,与 SIGKILL 不同的是该信号可以被阻塞和处理,我们可以通过在程序中注册该信号来实现服务的优雅停止。使用 kill 命令缺省会发出这个信号。
那么具体应该如何做呢?
其大概思路如下:当 Pod 收到 SIGTERM 信号的时候,先等待一段时间再退出。在这等待的过程中可以继续处理流量,等待时间过后再关闭长连接,关闭进程退出。当然如果超过等待时间,会直接被 kill。
上面提到了
等待时间
时间,如果我们不设置,默认为 30 秒。如果设置为 0,将立刻发送 SIGKILL 信号来杀死 Pod 内所有进程。如果要设置的话,请根据服务情况酌情设置,避免因为程序内有死锁或者其他原因带来的其他问题。
应用处理 SIGTERM 信号
在应用中处理 SIGTERM 信号的思路如下:程序在启动过后,会一直阻塞并监听系统信号,直到监测到对应的系统信号后,输出到控制台并退出执行。
我们知道在容器中 pid 为 1 的进程是容器的主进程,这个进程退出则代表容器就退出了。
在这我们需要注意一个问题,通过在 Dockerfle 中使用 CMD、ENTRYPOINT 命令可以定义容器启动命令,关于这两个命令的区别这里就不讲了,我们只讲在使用时候一定要注意的问题。
这两个命令都支持下面几种格式:
- shell 格式:CMD <命令>
- exec 格式:CMD ["可执行文件", "参数 1", "参数 2"...]
- 参数列表格式:CMD ["参数 1", "参数 2"...]。在指定了 ENTRYPOINT 指令后,用 CMD 指定具体的参数。
一般推荐使用 exec
格式,这类格式在解析时会被解析为 JSON 数组,因此一定要使用双引号 "
,而不要使用单引号'
。
如果使用 shell 格式的话,实际的命令会被包装为 sh -c 的参数的形式进行执行。比如:
CMD java -jar demo.jar
在实际执行中,会将其变更为:
CMD [ "sh", "-c", "java -jar demo.jar" ]
因此容器的主进程是 sh,当给容器发送信号,接收信号的是 sh 进程,sh 进程收到信号后会直接退出,自然就会令容器退出。我们的程序永远收不到信号。
使用 preStop Hook 来停止服务
preStop Hook 是 Pod 资源定义中的一个参数,它支持 http 和 exec,简单的 demo 如下:
spec:
contaienrs:
- name: my-container
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 20"]
用该方法的主要思路如下:当 pod 收到 SIGTERM 信号后,会调用 preStop 然后等待一段时间,比如 15s,这 15s 的时间留给 kube-proxy、ingress 等来更新 endpoints,等它们更新完后再开始停 pod。