我们在很多地方都使用Docker容器:
在此介绍一下dumb-init
,这是一个用C语言编写的用于容器中的简易版init系统。
轻量级容器的运行思路是在容器中启动单个进程而不需要像systemd
或sysvinit
这样的普通init系统。但是,省略一个init系统常常会导致对容器进程和系统信号的错误处理。并且可能导致如无法正常停止容器或者泄露本应该被销毁的容器等问题。
dumb-init
易于使用且解决了许多上述问题。
这有助于避免作用于PID=1进程的特殊内核行为,并且同时还能承担init系统的常规职责(如回收孤儿僵尸进程)。
某些时候,我们真正想要的是能够像处理常规进程那样处理Docker容器,这样我们就可以慢慢地将我们的工具和基础架构迁移到Docker。我们可以将各个命令移动到容器中,这样的迁移使得开发人员都不会意识到他们的每一次调用都产生了一个Docker容器。
会有一些Docker容器的使用方式就像是在使用常规的进程(如需要使用一个容器操作系统)。
为了实现这一目标,我们希望进程的行为就像它们没有在容器内运行一样。这意味着以相同的方式响应信号来处理用户输入,并且在预期的情况下进程退出。特别是,当我们发出docker run
命令信号时,我们希望容器内部进程接收相同的信号。
将容器建模为普通进程的探索过程,发现了比我们预想中更多的关于Linux内核处理进程、会话和信号的信息。
容器的独特之处在于它们通常只运行一个进程,与传统服务器不同,即使是最小化安装的服务器通常至少运行复杂的init系统,cron,syslog和SSH守护进程。
虽然单进程容器可以快速启动并充分利用资源,但更重要的是,在大多数情况下,我们希望这些容器都是完整的Linux系统。在容器内部,作为PID=1运行的进程具有作为init系统的特殊规则和职责。
什么是容器内的PID=1的进程?
通常有两种常见的情况。
Dockerfiles的一个怪癖是如果你在不使用推荐的JSON语法的情况下指定容器的命令,它会将你的命令提供给shell来执行。可参考文末附录内容。
这种情况下,从一棵进程树看起来如下:
docker run(宿主机)
├─/bin/sh(PID 1, 容器内)
└─python my_server.py(PID ~2,容器内)
将shell作为PID=1的进程实际上使运行的进程几乎不可能发出信号。发送到shell的信号不会被转发到子进程,并且在进程退出后shell也不会退出。杀死容器的唯一方法是发送SIGKILL(或者如果容器内的进程发生死亡)。
因此,应该始终尽量避免生成shell。如果不能轻易避免生成shell(例如,如果你想生成两个进程),应该执行最后一个进程,以便它替换shell。
当在Dockerfile中使用推荐的语法时,进程会立即启动并充当容器的init系统,从而生成如下所示的进程树:
docker run(宿主机)
└─python my_server.py(PID 1,容器内)
这比第一种情况要好,运行的进程将实际接收发送的信号。但是,作为PID 1,它可能不会像期望的那样对信号作出反应。
Linux内核将PID 1
视为特殊情况,并对其处理信号的方式应用不同的规则。这种特殊处理通常会打破程序或研发人员的假设。
在很多情况下,任何进程都可以将它自己的信号处理器注册到
TREM
中并且在它们退出之前使用信号处理器来清理然后再退出。如果一个进程没有注册它自定义的信号处理器,内核通常会回退到TERM
信号的默认行为:终止进程。
但是,对于PID 1
,内核在转发TERM
时不会回退到任何默认行为。如果进程尚未注册自己的处理器(大多数进程没有),则TERM
将不会对该进程产生任何影响。
由于我们将容器建模为进程,因此我们只想将SIGTERM
发送到docker run
命令并让容器停止。不幸的是,这通常不起作用。当docker run
命令接收到SIGTERM
信号,它会将这个信号转发到容器然后退出(即使容器本身并没有死掉)。实际上,由于PID 1
的特殊情况,TERM
信号通常会在不停止进程的情况下从进程中反弹出。
即使使用命令
docker stop
也不会执行预期的行为;它发送TERM
(容器内的Python进程不会注意到),等待十秒,然后在进程仍然没有停止时发送KILL
,立即停止它而没有任何机会进行清理。
无法正确发信号通知Docker容器内运行的服务在开发和生产中都有很多影响。例如,在部署新版本的应用程序时,它可能必须终止以前的服务版本而不让它清理(可能在提供请求的过程中死亡,或者让连接对数据库保持打开状态)。它还引起CI系统(例如Jenkins)中的一个常见问题,当中止的测试时,Docker容器仍然在后台运行。
同样的问题适用于其他信号。最值得注意的情况是SIGINT
,即在终端中按ctrl + C
时生成的信号。由于此信号的捕获频率甚至低于SIGTERM
,因此尝试手动终止在开发环境中运行的服务器可能会特别麻烦。
为了满足这种需求,创建了dumb-init,一个旨在用于Linux容器的最小init系统。不是直接执行服务器进程,而是在Dockerfile中使用dumb-init
作为前缀,例如CMD ["dumb-init","python","my_server.py"]
。这将创建一个如下所示的进程树:
docker run(宿主机)
├─dumb-init(PID 1, 容器内)
└─python my_server.py(PID ~2,容器内)
dumb-init为每个可捕获的信号注册信号处理器,并将这些信号转发到以进程为根的会话。由于Python进程不再作为PID 1
运行,当dumb-init转发像TERM
这样的信号时,如果内核仍没有注册任何其他处理程序,它仍会应用默认行为(终止进程)。
使用常规init系统也可以解决这些问题,但代价是增加了复杂性和资源使用。
dumb-init
是一种更简单的方法:它将你的进程作为唯一的子进程生成,并向其发出代理信号。dumb-init
实际上不会死,直到你的进程死掉,允许你做适当的清理。
dumb-init
被部署为静态链接的二进制文件,没有额外的依赖关系;它非常适合用作简单的init系统,通常可以添加到任何容器中。建议在任何Docker容器中使用它;dumb-init
不仅可以改善信号处理,还可以处理init系统的其他功能,例如捕获孤儿的僵尸进程。
RUN有2种形式:
RUN <command>
: shell 形式,这些指令会运行在一个shell中。/bin/sh -c
cmd /s /c
RUN ["executable","param1","param2"]
: exec 形式RUN指令将在当前image之上的新层中执行任何指令并提交结果,这个结果所提交的image将会用于Dockerfile中的下一步。
分层运行指令并且提交执行结果,这样提交结果的成本很小且符合Docker的核心理念,并且容器可以从image构建历史中的任何一点上被创建,就像源代码控制。
可以使用shell命令来更换默认的shell。
在shell形式中,可以使用\
(反斜杠)将单个RUN指令继续到下一行。例如,请考虑以下两行:
RUN /bin/bash -c 'source $HOME/.bashrc; \
echo $HOME'
# 不使用反斜杠,相当于如下这一行
RUN /bin/bash -c 'source $HOME/.bashrc; echo $HOME'
注意:要使用除
/bin/sh
之外的其他shell,请传入所需shell的exec形式。例如
RUN ["/bin/bash", "-c", "echo hello"]
在下一次构建期间,RUN指令的缓存不会自动失效。像RUN apt-get dist-upgrade -y
这样的指令的缓存将在下一次构建期间重用。可以使用--no-cache
标志使RUN指令的高速缓存无效,例如docker build --no-cache
。
ADD指令也可以使RUN指令的高速缓存无效。
exec 形式可以避免shell字符串重写,并且使用不包含特定shell可执行文件的基础image来运行RUN指令。
注意:exec形式被解析为JSON数组,这意味着必须使用双引号(
"
)来围绕单词而不是单引号('
)
在JSON形式中,必须转义反斜杠(这在反斜杠是路径分隔符的Windows上尤为重要)。
# 由于不是有效的JSON,以下行将被视为shell形式,并以意外方式失败
RUN ["c:\windows\system32\tasklist.exe"]
# 此示例的正确语法是
RUN ["C:\\windows\\system32\\tasklist.exe"]
与shell形式不同,exec形式不会调用命令shell。这意味着不会发生正常的shell处理。例如:
# 不会对`$HOME`执行变量替换
RUN ["echo", "$HOME"]
# 想要shell处理,要么使用shell形式(上一节),要么直接执行shell
RUN ["sh", "-c", "echo $HOME"]
# 当使用exec形式执行shell时(如shell形式的情况),它是shell执行环境变量扩展而不是docker执行