人们在应用实例关闭或者销毁的时候总会留下一个 Graceful Shutdown 的操作,来避免中断当前实例正在处理的事务,这个特性对于处理机尤其重要。那么在迁移到容器化的平台上也会有同样的操作需要处理,但常常这个点会有一些坑。可能有人会说我的代码有做 Signal Notification,还会有什么坑呢?不错,物理机上通过 Signal 可以通知到进程做 Graceful Shutdown,但在容器内,稍有不慎,你的 Signal 就不一定会传到进程上,换个说话:你的进程最后是被 kill (SIGKILL)掉的。

本文将基于 Docker 进行分析这个问题中隐藏的坑。

停止容器过程

我们先来过一下容器停止(指如 docker stop)的主体流程

  1. dockerd 收到 stop 容器的请求
  2. 向容器 PID 1 发送 SIGTERM
  3. 等待 PID 1 退出(默认是 10 秒,可以通过参数指定)
  4. 等待超时,发送 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

总结