背景
进程标识符 (PID) 是Linux 内核为每个进程提供的唯一标识符。熟悉docker的同学都知道, 所有的进程 PID都属于某一个PID namespaces, 也就是说容器具有一组自己的 PID,这些 PID 映射到主机系统上的 PID。启动Linux内核时启动的第一个进程具有 PID 1,一般来说该进程就是 init 进程,例如 systemd 或 SysV。同样,在容器中启动的第一个进程也会获得该PID namespaces内的 PID 1。Docker 和 Kubernetes 使用信号与容器内的进程通信,来终止容器的运行, 只能向容器内 PID 1 的进程发送信号。
在容器的环境中,PID 和 Linux 信号会产生两个需要考虑的问题。
问题 1:Linux 内核如何处理信号
对于具有 PID 1 的进程,Linux 内核处理信号的方式与其他进程有所不同。系统不会自动为此进程注册信号处理函数,SIGTERM 或 SIGINT 等信号默认被忽略,必须使用 SIGKILL 来终止进程。使用 SIGKILL 可能会导致应用程序无法平滑退出,例如正在写入的数据出现不一致或正在处理的请求异常结束。
问题 2:经典 init 系统如何处理孤立进程
宿主机上的init进程(如 systemd)也用来回收孤儿进程。孤儿进程(其父级已结束的进程)会重新附加到 PID 1 的进程,PID 1进程会在这些进程结束时回收它们。但在容器中,这一职责由具有 PID 1 的进程承担,如果该进程无法正确处理回收,则可能会出现耗尽内存或一些其他资源的风险。
常见的解决方案
上述问题对于一些应用程序可能无足轻重,并不需要关注,但是对于一些面向用户或者处理数据的应用程序却极为关键。需要严格防止。 对此有以下几种解决方案:
解决方案 1:作为 PID 1 运行并注册信号处理程序
最简单方法是使用 Dockerfile 中的 CMD 或 ENTRYPOINT 指令来启动进程。例如,在以下 Dockerfile 中,nginx 是第一个也是唯一一个要启动的进程。
FROM debian:9
RUN apt-get update && \
apt-get install -y nginx
EXPOSE 80
CMD [ "nginx", "-g", "daemon off;" ]
nginx 进程会注册自己的信号处理程序。如果是我们自己写的程序则需要自己在代码中执行相同操作。
因为我们的进程就是PID 1进程,所以可以保证能够正确的收到并处理信号。 这种方式可以轻松地解决了第一个问题,但是对于第二个问题却无法解决。 如果你的应用程序不会产生多余的子进程,则第二个问题也不存在。 可以直接采用这种相对简单的解决方案。
此处需要注意,有时候我们可能一不小心就让我们的进程不是容器内首进程了,例如如下Dockerfile:
FROM tagedcentos:7
ADD command /usr/bin/command
CMD cd /usr/bin/ && ./command
我们只是想执行启动命令而已,却发现此时首进程变为了shell:
[root@425523c23893 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 1 07:05 pts/0 00:00:00 /bin/sh -c cd /usr/bin/ && ./command
root 6 1 0 07:05 pts/0 00:00:00 ./command
docker会自动地判断你当前启动命令是否由多个命令组成,如果是多个命令则会用shell来解释。如果是单个命令则就算外面包了一层shell容器内首进程也直接是业务进程。例如如果将dockerfile写成CMD bash -c "/usr/bin/command"
,容器内首进程还是业务进程,如下:
[root@c380600ce1c4 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 2 13:09 ? 00:00:00 /usr/bin/command
所以正确地书写Dockerfile也可以让我们避免掉很多问题。
有时,我们可能需要在容器中准备环境,以便进程能够正常运行。在此情况下,一般我们会让容器在启动时执行一个 shell 脚本。此 shell 脚本的任务是准备环境和启动主进程。但是,如果采用此方法,shell脚本将是PID 1 而不是我们的进程。因此必须使用内置的 exec 命令从 shell 脚本启动进程。exec 命令会将脚本替换为我们所需的程序, 这样我们的业务进程将成为 PID 1。
解决方案 2:使用专用 init 进程
正如在传统宿主机所做的那样,还可以使用init进程来处理这些问题。但是, 传统的init进程(例如 systemd 或 SysV)太过复杂而庞大,建议使用专为容器创建的init进程(例如 tini)。
如果使用专用 init 进程,则 init 进程具有 PID 1 并执行以下操作:
- 注册正确的信号处理程序。init进程会将信号传递给业务进程
- 回收僵尸进程
可以通过使用 docker run 命令的 --init 选项在 Docker 中使用此解决方案。但是目前kubernetes还不支持直接使用该方案,需要在启动命令前手动指定。
落地的难题
上面两种解决方案看似美好,实则在实施的过程中还是存在很多弊端。
方案一需要严格保证用户进程是首进程
并且不能fork出多余的其他进程
。 有时候我们在启动的时候需要执行一个shell脚本准备环境, 或者需要运行多个命令,例如'sleep 10 && cmd', 此时容器内首进程便为shell,就会碰到问题一, 无法转发信号。 如果我们限制用户的启动命令不能包含shell语法, 对用户体验也不太好。 并且作为PASS平台,我们需要为用户提供一个简单友好的接入环境,帮用户处理好相关的问题。 从另外一方面考虑, 在容器环境下多进程在所难免,即使我们在启动时确保只运行一个进程,有时候在运行时过程中也会fork出进程。 我们无法确保我们所使用的第三方组件或者开源的方案不会产生子进程, 我们稍不注意就会碰到第二个问题,僵尸进程无法回收的囧境。
方案二中需要在容器中有一个init进程负责完成所有的这些任务, 当前业务普遍的做法是, 在构建镜像的时候里面自带init进程,负责处理上面所有的问题。 这种方案固然可行,但是需要让所有人都使用这种方式似乎有点难以接受。首先对用户镜像有侵入,用户必须修改已有的Dockerfile, 专门增加init进程 或者 只能在包含有该init进程的基础镜像上面进行构建。 其次管理起来比较麻烦,如果init进程升级,意味着全部镜像都得重新build,这似乎无法接受。即使使用docker默认支持的tini,也有一些其他问题,我们后面会谈到。
归根结底, 作为PASS平台,我们想给用户提供一个便捷的接入环境,帮助用户解决这些问题:
- 用户进程能够收到信号, 进行一些优雅的退出
- 允许用户产生多进程,并且在多进程的情况下帮助用户回收僵尸进程。
- 不对用户的运行命令做约束,允许用户填写各种shell格式的命令,都能够解决上述1和2问题
解决方案
如果我们想要对用户无侵入,则最好使用docker或kubernetes原生支持的方案。
上面已经介绍过了docker run --init选项, docker原生提供的init进程实则为tini。tini支持给进程组传递信号, 通过-g
参数或者TINI_KILL_PROCESS_GROUP
来进行开启该功能。 开启该功能后我们就可以将tini作为首进程,然后让它传递信号给所有的子进程。问题一就可以轻松解决。 例如我们执行 docker run -d --init ubuntu:14.04 bash -c "cd /home/ && sleep 100"
就会发现容器内的进程视图如下:
root@24cc26039c4d:/# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 2 14:50 ? 00:00:00 /sbin/docker-init -- bash -c cd /home/ && sleep 100
root 6 1 0 14:50 ? 00:00:00 bash -c cd /home/ && sleep 100
root 7 6 0 14:50 ? 00:00:00 sleep 100
此时1号docker-init进程,也就是tini进程, 负责转发信号到所有的子进程,并且回收僵尸进程, tini的子进程为6号bash进程, 它负责执行shell命令,可以执行多个命令。这里有一个问题就是: tini进程只会监听他的直接子进程,如果直接子进程退出则整个容器就视为退出了, 也就是本例中的6号bash进程。 如果我们往容器中发送SIGTERM,可能用户进程注册了信号处理函数, 收到信号后处理需要一定的时间完成,但是由于bash没有注册SIGTERM信号处理函数,会直接退出,进而导致tini退出,整个容器退出。用户进程的信号处理函数还没有执行完毕就被强制退出了。我们需要想办法让bash忽略掉这个信号,同事提到bash在交互模式下不会处理SIGTERM信号, 可以一试。 在启动命令前面加上bash -ci
即可。发现使用bash交互模式启动用户进程就可以使bash忽略掉SIGTERM,然后等待业务的信号处理函数执行完毕整个容器再退出。
如此便完美解决了上述相关问题。 同时还收获了另外一个微不足道的好处:容器退出时更加快速。我们知道kubernetes中容器退出的逻辑和docker一样,先发送SIGTEMR 然后再发送SIGKILL, 对于大部分用户来说,都不会处理SIGTERM信号,容器内1号进程收到该信号后默认的行为是忽略该信号, 于是SIGTERM信号白白地被浪费掉,需要等待terminationGracePeriodSeconds
之后才被删除。既然用户不处理SIGTERM,为什么不直接在收到SIGTERM之后就退出呐? 在当前我们的解决方案下如果用户有注册该信号处理函数,则能正常处理。 如果没有注册则容器在收到SIGTERM之后就马上退出,可以加快退出速度。
目前由于kubernetes中CRI并没有直接提供可以设置docker tini的方法,所以要想在kubernetes中使用tini就只能改代码了,笔者的集群中就是通过改代码来实现的。为了解决用户的痛点,我们有能力也有义务为合理的需求改代码,况且这个改动足够小,非常简单。
后记
在容器落地的过程中会碰到各种实际的问题,开源的方案可能无法覆盖到我们所有的需求,需要我们在精通社区的实现基础上进行轻微的变形即可完美适应企业内部的场景。