实现真正优雅的容器应用

进程的优雅退出(Gracefully Exiting) 看似是个不足为奇的小事,一般情况下只要捕获 SIGTERM 等退出信号,执行完必要的工作再退出进程就好了,但是放到容器环境里,会有些意想不到的问题。本文简单探讨在容器内实现优雅退出会碰到的一系列连环坑。


首先声明一点,这里说的优雅可不是什么 elegant,作为一个小码农,不敢妄自评判什么是优雅,翻译成平稳可能更合适,但我们还是使用惯常翻译。

什么是优雅退出

先来介绍一下优雅退出的定义以及简单的实现,对这部分比较熟悉的同学可以跳过。

在服务器上运行的程序难免遇到需要退出的情况,比如发布新版本需要退出旧版本进程,机器资源不够需要迁移到另外一台主机上运行。在收到退出信号那一刻难免会有没处理完的任务,为了避免造成数据丢失,或是客户端的请求意外终止造成不好的体验,就需要把手头上剩下的事情处理完再退出。这个过程被称为优雅退出(Graceful exiting)

以一个普通的 Nodejs 程序为例,要实现优雅退出很简单,node 等程序通常有个默认的退出信号 handler,我们在代码里替换这个 handler,手动捕获退出的信号(通常是docker 或其他进程管理程序发出的 SIGTERM,或是在 terminal 里按下ctrl + c 发出的 SIGINT。

const handler = async () => {
  console.log('Start cleanup')
  await doCleanUpWork()
  console.log('Exiting')
  process.exit(0)  
}
process.on('SIGTERM', handler)
process.on('SIGINT', handler)

实际测试的时候需要让这段代码一直运行,否则还等不到退出信号,程序自己就执行完退出了。比如在生产环境上通常有个 web 服务持续运行,为了测试简单,我们加一个循环任务。

// app.js
async function doCleanUpWork() {
  return new Promise((resolve) => {
    setTimeout(resolve, 3000)
  })
}
const handler = async () => {
  console.log('Start cleanup')
  await doCleanUpWork()
  console.log('Exiting')
  process.exit(0)  
}
process.on('SIGTERM', handler)
process.on('SIGINT', handler)

// keep running
setInterval(() => {
  console.log('Working')
}, 1000)

上面这段代码在命令行里执行:node app.js然后按下 ctrl + c 会观察到过了 3 秒之后程序退出,输出:

Working
Working
^CStart cleanup
Working
Working
Working
Exiting

容器内进程的生命周期

为了把上面这个实现了优雅退出的程序放到容器里运行,我们先了解一下容器的运作机制。

在一个容器启动的时候,CMD 或者 ENTRYPOINT 里定义的命令会作为容器的主进程(main process)启动,pid 为 1,一旦这个主进程退出了,容器也会被销毁,容器内其他进程会被 kernel 直接 kill。

到这里应该能想到,如果应用进程是被某个其他进程启动的,可能等不到执行完 doCleanUpWork 里面的任务,容器的主进程退出了就会被强行中止。

UNIX 系统有个规定,父进程应该等待子进程结束并收集其退出的状态码。大部分程序也是遵守这个约定的。所以理想情况下我们用 npm 或者其他程序来启动的程序,在 npm 收到退出命令时,会转发信号给子进程并等待其退出,然后自己才会退出,从而终止容器的运行。

容器中的 NPM 程序

我们看看实际情况,还是以一个 Nodejs 应用举例,一个常见的做法是把启动命令写到 npm script 里,然后这个 npm script 作为 docker 镜像的 COMMAND,容器启动会把 npm 作为 pid=1 的进程启动,然后 npm script 其实是启动一个 shell 进程,执行 script 里定义的命令。这样进程树会是这样:

npm
\__ sh
      \__ node

docker stop 被执行,首先 npm 进程会收到 SIGTERM 信号,然后 把 SIGTERM 转发给 sh 进程并等待其退出。然后 sh 进程的行为有些出乎意料了,它没有转发信号给 node 进程,然后自己直接退出了!

奇怪的 shell

sh 作为操作系统一个重要组件,为什么会不遵守 UNIX 进程的规范呢,其实不止 sh 这个 shell 程序,所有其他的 shell 程序,bash,zsh 都是这样设计的。

一个原因是我们经常需要用 shell 来启动程序,有时候会是后台进程,如果 shell 退出会导致子进程全部退出,应该会是个大麻烦。

实际上 shell 程序除了不转发 signals,还有个更可气的特性是不响应退出信号。这在日常使用中不是问题,因为 kernel 会为每个进程加上默认的 signal handler,例外的是 pid=1 的进程,被 kernel 当作一个 init 角色,不会给他加上默认的 handler,可如果在容器中启动 shell,占据了 pid=1 的位置,这个容器就无法正常退出了,只能等 docker 引擎在超时后强行杀死进程。

所以我们平常碰到 docker stop 一个容器很慢,很有可能是因为这个容器的启动程序是一个 shell 脚本,或者定义 Dockerfile 的启动命令不是 json 数组的 exec 形式 CMD ["executable","param1","param2"] ,而是 CMD command param1 param2 这种 shell form

关于 shell 的这两个费解行为有一段 bash 源码中的注释作为参考:

/* Ignore interrupts while waiting for a job run without job control
    to finish. We don't want the shell to exit if an interrupt is
    received, only if one of the jobs run is killed via SIGINT. 
  ...


可是既然 shell 不转发退出信号,我们平常在命令行终端中执行程序之后,按下 ctrl + c 就能退出程序又是什么原理呢。这就需要具体到 session 和控制终端(terminal)的概念了。

终端系统简介(shell + terminal + tty)

为了更好的解释 shell 的行为,这里简单介绍一下 shell 以及整个终端系统的结构,对这部分比较熟悉的也可以跳过。

shell 和 终端(terminal)是两个经常一起使用并且很容易被混淆的概念。我们日常使用的 shell 工具,比如 bash 或者 zsh,其实是一个脚本解释器,而连接键盘(输入)和显示器(输出)设备的是终端(terminal),比如耳熟能详的 putty,iTerm,都是远程终端工具。而终端通过 tty 系统与进程打交道。

在 Linux 系统中,当一个 shell 被启动的时候,同时也创建了一个 session,这个 session 下的所有进程共用这个终端(terminal),同时 tty 系统会为这个终端创建一个虚拟 tty 设备用于将终端命令转发给 tty 系统。实际上 ctrl +c是控制 tty 的特殊信号,收到这个控制字符串,tty 会向这个终端的所有前台进程发送 SIGINT 中断信号,而 shell 本身不响应信号,所以按下 ctrl + c 只会退出在 shell 中启动的前台进程。

通过 ps 命令查看进程的 session 和 绑定的 tty 设备:

  PID  PPID TT        SESS COMMAND
    1     0 pts/0        1 bash
    8     1 pts/0        1 npm
   24     8 pts/0        1  \_ sh
   25    24 pts/0        1      \_ node


关于终端和 tty 的概念不再赘述,这里有篇很详细的文章可以作为扩展阅读。

多种启动方案

既然使用 npm 通过 sh 启动程序会产生上面进程收不到退出信号的问题,就需要尝试一下其他方案了。

dumb-int + npm

正好有个开源项目看似是为解决这个问题而诞生的:dumb-init

他声称,通过 dumb-init 作为容器的主进程,在收到退出信号的时候,会将退出信号转发给进程组所有进程。

于是修改启动命令为 CMD: ["dumb-init", "--", "npm", "run", "start"]

这个时候可以看到容器内的进程树结构如下:

dumb-init
\__ npm
    \__ sh
          \__ node

退出容器后看到的日志是:

Working
Working
Start cleanup

可以看到 node 进程顺利的收到了 SIGTERM 退出信号。

可是等等。。。后面应该还有 Exiting 这行输出才对呀,看起来虽然收到了信号,但是退出前的清理任务并没有被执行完。查看 dumb-init 源码发现,这个程序在直接子进程退出后,自己也会退出,他的假设是每个子进程应该等待自己的子进程退出,可是前面我们已经知道这个假设在 shell 进程上并不成立。

所以 dumb-init 要真正起作用,除了转发信号,他还应该等待所有子进程退出才能自己退出。也确实看到有个没被合并的 PR 在处理这件事情。

所以在这个 PR 被合并之前,dumb-init 还不能解决我们碰到的问题。除非让 shell 进程从我们的进程树中消失,这点应该还是可以做到的。

消除进程树中的 shell 进程

一个办法是我们可以修改启动命令为:["dumb-init", "--", "node", "app"],可是这样和我们直接把 node 进程作为容器的主进程差别不大了。不使用 npm script 或者 shell 脚本定义一些复杂的启动命令就会比较麻烦了。

当然还有另外一个方案,要消除中间的 sh 进程,我们可以修改 npm script:node app => exec node app,多加了一个 exec ,shell 就会在当前进程中执行 node 进程,node 启动后原本的 shell 进程就会消失了。启动后的进程树是这样的:

npm
\__ node
The exec() family of functions replaces the current process image with a new process image.

总结

到这里我们在容器里要实现优雅退出的坑已经算是踩完了,也知道要达到目的应该怎么做了。不过要时刻避免在进程树中产生 shell 进程未免是个让人睡不好觉的事情。而比较热门的 dumb-init 项目作为一个 init 程序也没能达到帮助我们解决问题的目的。所以理想的解决方案是什么样的呢,我的设想是 dumb-init 已经实现的部分,再加上等待容器中所有进程退出,如果还有其他要求就是占用体积和内存尽可能的少,因为他需要添加到每个应用镜像中启动。

对比 dumb-init 和一些其他的方案

  • dumb-init:不会等待所有子进程退出
  • docker --init 参数:不会给所有子进程转发信号,也不会等待子进程退出
  • baseimage 项目:提供一个 ubuntu 基础镜像,内含一个 init 程序,使用 python 编写,会转发信号和等待子进程退出,但是体积比较庞大。
  • smell-baron 项目:一个 c 写的小程序,体积小,会转发 signals 和等待所有子进程退出

smell-baron 这个小程序基本满足我们的所有要求,只不过这个冷门的项目可能还需要更多的验证。


refs:

engineeringblog.yelp.com

blog.phusion.nl/2015/01

vidarholen.net/contents

编辑于 2019-01-07

文章被以下专栏收录