人们在应用实例关闭或者销毁的时候总会留下一个 Graceful Shutdown 的操作,来避免中断当前实例正在处理的事务,这个特性对于处理机尤其重要。那么在迁移到容器化的平台上也会有同样的操作需要处理,但常常这个点会有一些坑。可能有人会说我的代码有做 Signal Notification,还会有什么坑呢?不错,物理机上通过 Signal 可以通知到进程做 Graceful Shutdown,但在容器内,稍有不慎,你的 Signal 就不一定会传到进程上,换个说话:你的进程最后是被 kill (SIGKILL)掉的。
本文将基于 Docker 进行分析这个问题中隐藏的坑。
停止容器过程
我们先来过一下容器停止(指如 docker stop
)的主体流程
- dockerd 收到 stop 容器的请求
- 向容器 PID 1 发送 SIGTERM
- 等待 PID 1 退出(默认是 10 秒,可以通过参数指定)
- 等待超时,发送 SIGKILL 强制退出
流程看起来没毛病,那我们继续
Dockerfile
Docker 容器的基础是 Docker Image 由 Dockerfile 构建得到,那我们平时都怎么写?假设有一个处理机的代码(进程里也做了 Signal 监听处理 Graceful Shutdown),那么我们可能都是这么写的
FROM alpine
COPY my-processor /
CMD /my-processor
或者是
FROM alpine
COPY my-processor /
ENTRYPOINT /my-processor
亦或者,咱们的二进制启动的操作比较复杂涉及 ENV、配置文件等,那我们写一个脚本套起来可以吧?
FROM alpine
COPY run.sh /
COPY my-processor /
RUN /run.sh
# 或者 ENTRYPOINT /run.sh
然后 docker build -t xxxxx .
或者其他姿势,然后镜像就好了,开始丢上跑起来了,完美!
然后过了一段时间,发现总有任务没处理完,查来查去代码没问题啊。懵逼死循环 while true; do confused;done
启动命令中的坑
我们常常写 CMD /binary
或者 ENTRYPOINT /script.sh
之类的命名,但这两者最终在执行的时候前面会被套上 /bin/sh -c
,然后我们的脚本或者代码就变成了非 PID 1 的进程。当有信号发过来的时候,就收不到了。自然也就不存在所谓的信号处理
推荐姿势
使用 JSON Array 的写法,写成 CMD ["/binary"]
或者 ENTRYPOINT ["/script.sh"]
。这样启动后,我们期望的执行进程就会是 PID 1,信号发出的时候也就能够收到并处理掉了。
启动脚本坑
总是有比较复杂的代码需要比较复杂的启动过程或者参数,所以有人就会用脚本的方式来写这个启动步骤作为镜像的 CMD 或者 ENTRYPOINT。
大概是这么写的
#!/bin/sh
// blah blah blah....
/my-binary/agent -arg1 a -arg2 b -arg3 c
然后写下 Dockerfile 看了上面的坑,我知道这边要用 JSON Array 来写入口
FROM alpine
COPY my-binary /
ENTRYPOINT ["/my-binary/entrypoint.sh"]
一顿操作猛如虎,部署完跑了一段时间发现还是在 Graceful Shutdown 炸了。
是的,还是信号的锅。因为脚本启动的 /my-binary/agent
启动的进程 PID 并不是 1,我们发信号就是往 PID 1 也就是 /my-binary/entrypoint.sh
而这个脚本并没有对信号做任何处理,所以再次扑街。
推荐姿势
改改启动脚本,用 exec
来解决 PID 问题
#!/bin/sh
// blah blah blah....
exec /my-binary/agent -arg1 a -arg2 b -arg3 c
总结
- 注意进程是否以 PID 1 进程执行、注意进程信号传递
- Dockerfile 中尽量使用 JSON Array 来写
CMD
和ENTRYPOINT