优雅退出,业务侧需要做的任务是处理SIGTERM信号

如果 Pod 正在处理大量请求(比如 1000 QPS+)时,因为节点故障或「竞价节点」被回收等原因被重新调度,
可能会观察到在容器被 terminate 的一段时间内出现少量 502/504。

为了搞清楚这个问题,需要先理解清楚 terminate 一个 Pod 的流程:

1
2
3
4
5
1、Pod 被删除,状态置为 Terminating。kube-proxy 更新转发规则,将 Pod 从 service 的 endpoint 列表中摘除掉,新的流量不再转发到该 Pod。
2、如果 Pod 配置了 preStop Hook ,将会执行。
3、kubelet 对 Pod 中各个 container 发送 SIGTERM 信号以通知容器进程开始优雅停止。
4、等待容器进程完全停止,如果在 terminationGracePeriodSeconds 内 (默认 30s) 还未完全停止,就发送 SIGKILL 信号强制杀死进程。
5、所有容器进程终止,清理 Pod 资源。

注意:1和2 两个工作是异步发生的,所以在未设置 preStop 时,可能会出现「Pod 还在 Service Endpoints 中,但是 SIGTERM 已经被发送给 Pod 导致容器都挂掉」的情况,我们需要考虑到这种状况的发生。

了解了上面的流程后,我们就能分析出两种错误码出现的原因:

  • 502:应用程序在收到 SIGTERM 信号后直接终止了运行,导致部分还没有被处理完的请求直接中断,代理层返回 502 表示这种情况
  • 504:Service Endpoints 移除不够及时,在 Pod 已经被终止后,仍然有个别请求被路由到了该 Pod,得不到响应导致 504

主进程是服务本身

通常的解决方案是,在 Pod 的 preStop 步骤加一个 15s 的等待时间。
其原理是:在 Pod 处理 terminating 状态的时候,就会被从 Service Endpoints 中移除,也就不会再有新的请求过来了。
preStop 等待 15s,基本就能保证所有的请求都在容器死掉之前被处理完成

一个简单的示例如下,它使 Pod 被 Terminate 时,总是在 stop 前先等待 15s,再发送 SIGTERM 信号给容器:

1
2
3
4
5
6
7
8
9
containers:
- name: my-app
# 添加下面这部分
lifecycle:
preStop:
exec:
command:
- /bin/sleep
- "15"

更好的解决办法,是直接等待所有 tcp 连接都关闭(需要镜像中有 netstat):

1
2
3
4
5
6
7
8
9
10
11
containers:
- name: my-app
# 添加下面这部分
lifecycle:
preStop:
exec:
# 显示所有监听端口的守护进程和本地计算机上所有空闲的开放端口
command:
- /bin/sh
- -c
- "while [ $(netstat -plunt | grep tcp | wc -l | xargs) -ne 0 ]; do sleep 1; done"

主进程不是服务本身, 比如使用shell启动业务进程, 需要做额外配置, 然后再结合preStop进行处理

理想情况下,一个容器只有一个进程,但是在现实场景下很难做到,比如,我会用一个 shell 脚本去管理和启动 Java 进程,业务进程是在 shell 中启动的,就成为了 shell 进程的子进程

系统底层默认会向主进程发送 SIGTERM 信号,而对剩余子进程发送 SIGKILL 信号

1、如果shell启动的是单进程,可以在shell 中启动二进制的命令前面加一个exec,这个命令可以让二进制启动的进程代替shell成为主进程,从而业务进程可以接收到SIGTERM
1
2
3
4
#! /bin/bash
# 脚本中执行二进制
exec /bin/myapp
# docker提供的exec模式也是利用了exec, 如: CMD [ "/bin/myapp" ]
2、shell启动的是多个进程,则不能用exec来解决了,因为exec只能让一个进程成为主进程。可以使用trap或init系统实现多进程启动传递SIGTERM信号。

trap:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#! /bin/bash

/bin/myapp & pid1="$!" # 启动第一个业务进程并记录 pid
echo "app started with pid $pid1"

/bin/myclient & pid2="$!" # 启动第二个业务进程并记录 pid
echo "myclient started with pid $pid2"

handle_sigterm() {
echo "[INFO] Received SIGTERM"
kill -SIGTERM $pid1 $pid2 # 传递 SIGTERM 给业务进程
wait $pid1 $pid2 # 等待所有业务进程完全终止
}
trap handle_sigterm SIGTERM # 捕获 SIGTERM 信号并回调 handle_sigterm 函数

wait # 等待回调执行完,主进程再退出

init:

dumb-inittini 都可以作为 init 进程,作为主进程 (PID 1) 在容器中启动,然后它再运行 shell 来执行我们指定的脚本 (shell 作为子进程),shell 中启动的业务进程也成为它的子进程,当它收到信号时会将其传递给所有的子进程,从而也能完美解决 SHELL 无法传递信号问题,并且还有回收僵尸进程的能力

制作包含init系统的业务镜像:

1
2
3
4
5
6
7
FROM ubuntu:latest
RUN apt-get update && apt-get install -y dumb-init
ADD start.sh /
ADD myapp /bin/myapp
ADD myclient /bin/myclient
ENTRYPOINT ["dumb-init", "--"]
CMD ["/start.sh"]

start.sh:

1
2
3
4
#!/bin/bash
/bin/app1 &
/bin/app2 &
wait

利用preStop执行pod退出前的操作

例如:查找/user/src/app下是否包含size大于0的且以.heapsnapshot结尾的文件,如果有则将其拷贝到/user/src/app/dump/下 ,然后再把/user/src/app/dump/挂载在宿主机上

必要的时候再结合terminationGracePeriodSeconds字段加以延长优雅退出的时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
template:
......
spec:
terminationGracePeriodSeconds: 120
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- >
while true; do
if [ -n "$(find /user/src/app -maxdepth 1 -type f -name '*.heapsnapshot' -size +0c)" ]; then
cp /user/src/app/*.heapsnapshot /user/src/app/dump/;
# 一些堆快照文件是持续写入,所以可以按需配置break
# break;
fi
sleep 5
done &
......
volumeMounts:
- mountPath: /user/src/app/dump
name: snapshot
......
volumes:
- name: snapshot
hostPath:
path: /var/log/dump/xxx/snapshot
type: DirectoryOrCreate

为了在 Pod 被终止时立即停止循环,使用 & 将循环放入后台进程中。这样,在 preStop 钩子完成后,循环将自动终止。