我们在很多地方都使用Docker容器: 在容器中进行测试 在容器中构建工具 将容器部署到生产环境中 在此介绍一下dumb-init,这是一个用C语言编写的用于容器中的简易版init系统。 轻量级容器的运行思路是在容器中启动单个进程而不需要像systemd或sysvinit这样的普通init系统。但是,省略一个init系统常常会导致对容器进程和系统信号的错误处理。并且可能导致如无法正常停止容器或者泄露本应该被销毁的容器等问题。 dumb-init易于使用且解决了许多上述问题。 只需要将它添加到任何容器指令之前,它将扮演PID=1角色。 它会立即将你的进程生成为PID=2,然后代理它收到的任何信号。 这有助于避免作用于PID=1进程的特殊内核行为,并且同时还能承担init系统的常规职责(如回收孤儿僵尸进程)。 动机:将Docker容器内的环境构建为常规进程 某些时候,我们真正想要的是能够像处理常规进程那样处理Docker容器,这样我们就可以慢慢地将我们的工具和基础架构迁移到Docker。我们可以将各个命令移动到容器中,这样的迁移使得开发人员都不会意识到他们的每一次调用都产生了一个Docker容器。 会有一些Docker容器的使用方式就像是在使用常规的进程(如需要使用一个容器操作系统)。 为了实现这一目标,我们希望进程的行为就像它们没有在容器内运行一样。这意味着以相同的方式响应信号来处理用户输入,并且在预期的情况下进程退出。特别是,当我们发出docker run命令信号时,我们希望容器内部进程接收相同的信号。 将容器建模为普通进程的探索过程,发现了比我们预想中更多的关于Linux内核处理进程、会话和信号的信息。 Docker容器内的进程行为 容器的独特之处在于它们通常只运行一个进程,与传统服务器不同,即使是最小化安装的服务器通常至少运行复杂的init系统,cron,syslog和SSH守护进程。 虽然单进程容器可以快速启动并充分利用资源,但更重要的是,在大多数情况下,我们希望这些容器都是完整的Linux系统。在容器内部,作为PID=1运行的进程具有作为init系统的特殊规则和职责。 什么是容器内的PID=1的进程? 通常有两种常见的情况。 场景1:shell作为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。 场景2:目标进程(想要容器运行的那个进程)作为PID=1的进程 当在Dockerfile中使用推荐的语法时,进程会立即启动并充当容器的init系统,从而生成如下所示的进程树: docker run(宿主机) └─python my_server.py(PID 1,容器内) 这比第一种情况要好,运行的进程将实际接收发送的信号。但是,作为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 来拯救这些情况 为了满足这种需求,创建了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系统的其他功能,例如捕获孤儿的僵尸进程。 附录 Dockerfile 推荐的JSON语法 RUN有2种形式: RUN <command>: shell 形式,这些指令会运行在一个shell中。 Linux默认的shell是/bin/sh -c Windows默认的Shell是cmd /s /c RUN ["executable","param1","param2"]: exec 形式 RUN指令将在当前image之上的新层中执行任何指令并提交结果,这个结果所提交的image将会用于Dockerfile中的下一步。 分层运行指令并且提交执行结果,这样提交结果的成本很小且符合Docker的核心理念,并且容器可以从image构建历史中的任何一点上被创建,就像源代码控制。 shell形式 可以使用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形式 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执行
dumb-init是一个简单的进程管理器和init系统,设计用于在最小容器环境(如Docker)中作为PID 1运行。它被部署为一个用C编写的小型静态链接二进制文件。 轻量级容器已经普及了运行单个进程或服务的想法,而没有像systemd或sysvinit这样的普通初始化系统。但是,省略init系统通常会导致对进程和信号的错误处理,并且可能导致诸如无法正常停止容器或泄漏应该被销毁的容器之类的问题。 dumb-init使你可以以简单的命令前缀的方式使用它,来充当PID 1并立即将你的命令作为子进程生成,并在收到信号时正确处理和转发信号。 为何需要一个init系统 通常,当你启动Docker容器时,你正在执行的进程将变为PID 1,从而赋予它作为容器的init系统所带来的怪癖和责任。这提出了两个常见问题: 在大多数情况下,信号将无法正确处理 当进程在普通Linux系统上发送信号时,内核首先检查进程为该信号注册的自定义信号处理器,如果不存在则回退到默认行为(在SIGTERM上终止进程)。 Linux内核对作为PID 1运行的进程应进行特殊的信号处理。如果接收信号的进程是PID 1,内核会进行特殊处理;如果它没有为信号注册处理器,内核将不会回退到默认行为且不做任何反应。换句话说,如果你的进程没有明确处理这些信号的信号处理器,发送SIGTERM信号给它将完全没有效果。 一个常见的例子是使用docker运行my-container脚本的CI作业:将SIGTERM发送到docker run进程通常会终止docker run命令,但容器却还在后台运行着而没有被终止。 孤儿僵尸进程无法被适当的方式捕获 通常,父进程会立即调用wait()系统调用来避免产生常驻僵尸进程。 如果父进程在其子进程之前退出,则该子进程就变成“孤儿”,并会在PID 1下重新挂载其他父进程。 因此,init系统负责调动wait()处理孤儿僵尸进程。因为,大多数进程都不会碰巧被重新挂载的随机父进程调用wait(),因此,容器通常以几个根植于PID 1的僵尸进程结束。 进程在退出时变为僵尸,并且在其父进程调用wait()系统调用(或wait()调用的某些变体)之前保持僵尸状态(作为“已停止”的进程保留在进程表中)。 dumb-init能做什么 dumb-init作为PID 1运行,就像一个简单的init系统。它启动一个进程,然后将所有收到的信号代理到以该子进程为根的会话。 由于你的进程不再是PID 1,当它从dumb-init接收信号时,将应用默认的信号处理程序,并且你的进程将按照预期运行。如果你的进程死了,dumb-init也会死掉,逐一清理仍然存在的任何其他进程。 会话行为 在默认模式下,dumb-init建立以子进程为根的会话,并将信号发送到整个进程组。如果你有一个表现不佳的子进程(比如一个shell脚本),这个子进程在死亡前通常不会给它的子进程发出信号。 这实际上可以在常规进程监视器(如daemontools或supervisord)中的Docker容器之外用于监视shell脚本。通常,shell接收到的SIGTERM之类的信号不会转发给它的子进程;只有shell进程死掉才会发送信号。如果使用dumb-init,你可以在Shebang中使用dumb-init编写shell脚本: #!/usr/bin/dumb-init /bin/sh my-web-server & # launch a process in the background my-other-server # launch another process in the foreground 通常,发送到shell的SIGTERM会杀死shell,但这些进程仍然会处于运行状态(不论是后台还是前台进程)。使用dumb-init,你的子进程将收到与shell所执行的相同的信号。 如果你希望仅将信号发送到直接子进程,则可以使用--single-child参数运行,或者在运行dumb-init时设置环境变量DUMB_INIT_SETSID = 0。在这种模式下,dumb-init完全透明;甚至可以把多个串起来(比如dumb-init dumb-init echo 'oh,hi')。 信号重写 dumb-init允许在代理进程之前重写输入信号。这在始终发送标准信号(例如SIGTERM)的Docker 容器管理程序(如Mesos或Kubernetes)中非常有用。 某些应用程序需要不同的停止信号才能进行优雅的清理退出。例如,要将信号SIGTERM(编号15)重写为SIGQUIT(编号3),只需在命令行中添加--rewrite 15:3即可。要完全丢弃信号,可以将其重写为特殊数字0。 信号重写特例 在setsid模式下运行时,在大多数情况下转发SIGTSTP/SIGTTIN/SIGTTOU是不够的,因为,如果进程没有为这些信号添加自定义信号处理器,内核将不会应用默认信号处理行为(这将暂停进程),因为它是孤儿进程组的成员。因此,将这三个信号的默认重写设置为SIGSTOP。如果需要,可以通过将信号重写回原始值来选择不使用此行为。 有一点需要注意:对于作业控制信号(SIGTSTP,SIGTTIN,SIGTTOU),dumb-init在收到信号后总是会自动挂起,即使你把它重写为其他东西也是如此。 在Docker容器内安装 五种在容器中安装dumb-init的方式: 方法1 :从发行版的软件包存储库(Debian,Ubuntu等)安装 许多主流的Linux发行版(包括Dabian从stretch版本开始)和Debian的衍生版如Ubuntu(从bionic版本开始)都已经在官方的仓库中包含了dumb-init安装包。 基于Debian的发行版,可以运行apt install dumb-init来安装,就像安装其他的软件包一样。 大多数发行版提供的dumb-init不是静态链接文件。一般来说,这没什么毛病,但意味着这些版本的dumb-init在复制到其他Linux发行版时就不能用了,这与静态链接版本不同。 方法2:通过apt网络服务器(Debian/Ubuntu)安装 如果有内部apt服务器,将dumb-init以.deb的格式上传到服务器然后在使用它。在Dockerfiles中,可以通过apt install dumb-init,它就可以使用了。 可以从GIthub的Release页面下载Debian安装包,也可以执行nake builddeb自行生成。 方法3:手动安装.deb安装包(Debian/Ubuntu) 如果没有apt网络服务器,可以手动执行dpkg -i指令来安装.deb安装包,可以选择两种方式将.deb放到容器中: 挂载一个目录 通过wget命令下载 如下所示: RUN wget https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_amd64.debRUN dpkg -i dumb-init_*.deb 方法4:直接下载二进制包 由于dumb-init是作为静态链接的二进制文件发布的,因此通常只需将其放入image即可。在Dockerfile中执行如下所示的操作: RUN wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_amd64RUN chmod +x /usr/local/bin/dumb-init 方法5:通过PyPI安装 虽然dumb-init完全用C语言编写,但还提供了一个Python包来编译和安装二进制文件。它可以使用pip从PyPI安装。首先安装一个C编译器(在Debian/Ubuntu上执行apt-get install gcc),然后执行pip install dumb-init。 从1.2.0开始,PyPI的软件包可作为预编译的存档文件使用,无需在常见的Linux发行版上进行编译。 使用方式 一旦安装在Docker容器中,只需在命令前加上dumb-init(确保按照docker推荐的JSON语法格式编写命令,查看附录部分)。 在Dockerfile中,使用dumb-init作为容器的入口点是一个好习惯。“entrypoint”是一个局部指令,它被添加到的CMD指令之前,使其非常适合dumb-init: # Runs "/usr/bin/dumb-init -- /my/script --with --args"ENTRYPOINT ["/usr/bin/dumb-init", "--"]# or if you use --rewrite or other cli flags# ENTRYPOINT ["dumb-init", "--rewrite", "2:3", "--"]CMD ["/my/script", "--with", "--args"] 如果在基础镜像中声明入口点,那么任何以它为基础的镜像都不需要再声明dumb-init。他们可以像往常一样只设置CMD。 对于交互式一次性使用,可以手动添加它: $ docker run my_container dumb-init python -c 'while True: pass' # 在没有dumb-init的情况下运行同样的命令将导致无法在没有SIGKILL的情况下停止容器,但是使用dumb-init,可以发送更多人性化的信号,如SIGTERM 对于CMD和ENTRYPOINT使用JSON语法非常重要。否则,Docker会调用shell来运行你的命令,从而导致shell为PID 1而不是dumb-init。 使用shell进行预启动挂钩 容器通常需要做一些在开始构建期间无法完成的预启动工作。例如,可能希望根据环境变量模板化一些配置文件。将它与dumb-init集成的最佳方法如下: ENTRYPOINT ["/usr/bin/dumb-init", "--"]CMD ["bash", "-c", "do-some-pre-start-thing && exec my-server"] 通过仍然使用dumb-init作为入口点,你可以始终拥有适当的init系统。 bash命令的exec部分很重要,因为它将bash进程替换为你的服务,因此shell仅在启动时暂时存在。 编译 dumb-init 构建dumb-init二进制文件需要一个有效的编译器和libc头文件,默认为glibc。 $ make 通过musl编译 由于glibc,静态编译的dumb-init超过700KB,现在musl是一个可选选项。在Debian/Ubuntu apt-get install musl-tools上安装源代码和包装器,然后只需: $ CC=musl-gcc make 当用musl静态编译时,二进制大小约为20KB。 编译Debian安装包 我们使用标准的Debian约束来指定编译依赖关系(查看debian/control)。一个简单的入门方法是apt-get install build-essential devscripts equivs,然后sudo mk-build-deps -i --remove自动安装所有缺少的编译依赖项。然后,可以使用make builddeb来编译dumb-init Debian软件包。 如果你更喜欢使用Docker自动编译Debian软件包,只需运行make builddeb-docker即可。这更容易,但要求在计算机上运行Docker。