Joe Blog

随着Docker技术的不断成熟,越来越多的企业开始考虑使用Docker。其持续集成、版本控制、可移植性、隔离性等,都是我们谈论的优势。而在很多的Docker教程中提到多数是教你如何启动容器,很少讨论如何优雅停止容器化的应用程序。

Docker容器和PID1

通常我们在Dockerfile的编写过程中,需要以CMD或者ENTRYPOINT 来执行一个shell脚本,当你在容器中执行这个bash脚本时,当前的bash进程将获得PID为1,而实际的应用程序将作为PID1的子进程。这里就会有一个问题: 容器的停止指令信号SIGTERM首先传给PID为1的bash进程,而bash并不会转发到实际的应用程序,然后Docker会因为响应超时10s之后在内核级别杀死该容器。

响应超时时间可以调整,默认是10s的配置置,它的作用是给应用程序更多的时间来正常停止

我们可以通过简单RedisDockerfile来做演示

FROM debian:buster-slim
LABEL maintainer="JaeGerW2016"

RUN \
  apt-get update && \
  apt-get install -y --no-install-recommends redis-server procps && \
  rm -rf /var/lib/apt/lists/*

COPY start.sh start.sh
RUN chmod +x start.sh

EXPOSE 6379

ENTRYPOINT ["/start.sh"]

以下是start.sh脚本内容,包含的内容是更改内核相关参数并启动Redis服务

#!/usr/bin/env bash

# Disable THP Support in kernel
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# TCP backlog setting (defaults to 128)
sysctl -w net.core.somaxconn=16384
#---------------------------------------------------------------
/usr/bin/redis-server

现在我们基于以上的2个文件来构建并运行此容器(由于涉及到宿主机上的内核参数的调整,需要给容器以特权模式--privileged运行)

docker build -t demo/redis .
docker run -d --privileged --name demo demo/redis

然后我们进容器查看demo容器正在运行的进程

docker exec demo ps -ef
UID         PID   PPID  C STIME TTY          TIME CMD
root          1      0  0 06:12 ?        00:00:00 bash /start.sh
root          7      1  0 06:12 ?        00:00:00 /usr/bin/redis-server *:6379

可以看出redis-server进程号是7在运行,这里我们尝试docker stop demo去正常停止该容器

docker stop demo ## 这里等待10s来做停止操作
demo
docker logs demo
net.core.somaxconn = 16384
7:C 13 Aug 2020 06:12:36.092 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
7:C 13 Aug 2020 06:12:36.092 # Redis version=5.0.3, bits=64, commit=00000000, modified=0, pid=7, just started
7:C 13 Aug 2020 06:12:36.092 # Warning: no config file specified, using the default config. In order to specify a config file use /usr/bin/redis-server /path/to/redis.conf
7:M 13 Aug 2020 06:12:36.093 * Running mode=standalone, port=6379.
7:M 13 Aug 2020 06:12:36.093 # Server initialized
7:M 13 Aug 2020 06:12:36.093 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
7:M 13 Aug 2020 06:12:36.093 * Ready to accept connections

以上的日志内容可以看到最后一行的内容是Ready to accept connections 表示Redis没有收到正常终止推出的信号,Docker 就会绕过容器应用直接向内核发送 SIGKILL,内核会强行杀死应用,从而终止容器。

解决方案:

使用exec命令

正常停止Redis容器的方法是将start.sh脚本中最后一行改为exec /usr/bin/redis-server

#!/usr/bin/env bash

# Disable THP Support in kernel
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# TCP backlog setting (defaults to 128)
sysctl -w net.core.somaxconn=16384
#---------------------------------------------------------------
exec /usr/bin/redis-server

重新构建redis镜像并启动新容器,然后再次检查redis-server的进程PID

docker exec demo ps -ef
UID         PID   PPID  C STIME TTY          TIME CMD
root          1      0  0 07:56 ?        00:00:00 /usr/bin/redis-server *:6379

如上所见,redis-serverPID1的进程运行

接下来我们来测试下docker stop demo能否可以正常优雅退出

docker stop demo
demo
docker logs demo
net.core.somaxconn = 16384
1:C 13 Aug 2020 07:56:40.133 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 13 Aug 2020 07:56:40.134 # Redis version=5.0.3, bits=64, commit=00000000, modified=0, pid=1, just started
1:C 13 Aug 2020 07:56:40.134 # Warning: no config file specified, using the default config. In order to specify a config file use /usr/bin/redis-server /path/to/redis.conf
1:M 13 Aug 2020 07:56:40.136 * Running mode=standalone, port=6379.
1:M 13 Aug 2020 07:56:40.136 # Server initialized
1:M 13 Aug 2020 07:56:40.136 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
1:M 13 Aug 2020 07:56:40.136 * Ready to accept connections
1:signal-handler (1597305566) Received SIGTERM scheduling shutdown...
1:M 13 Aug 2020 07:59:26.342 # User requested shutdown...
1:M 13 Aug 2020 07:59:26.342 * Saving the final RDB snapshot before exiting.
1:M 13 Aug 2020 07:59:26.343 * DB saved on disk
1:M 13 Aug 2020 07:59:26.343 # Redis is now ready to exit, bye bye...

使用init系统

Tini所做的一切都是衍生出一个单独的子进程(Tini是在一个容器中运行的),等待它退出所有的时候,然后杀死僵尸进程和执行信号转发

Tini是你能想到的最简单的init

github地址: https://github.com/krallin/tini

之前的redis-serverDockerfile改动如下:

FROM debian:buster-slim
LABEL maintainer="JaeGerW2016"

# Add Tini
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini

RUN \
  apt-get update && \
  apt-get install -y --no-install-recommends redis-server procps && \
  rm -rf /var/lib/apt/lists/*

EXPOSE 6379

ENTRYPOINT ["/tini", "--", "/usr/bin/redis-server"]

重新构建redis镜像并启动新容器,然后再次检查redis-server的进程PID

docker exec demo5 ps -ef
UID         PID   PPID  C STIME TTY          TIME CMD
root          1      0  0 09:11 ?        00:00:00 /tini -- /usr/bin/redis-server
root          6      1  0 09:11 ?        00:00:00 /usr/bin/redis-server *:6379

docker stop demo5
demo5
# docker logs demo5
6:C 13 Aug 2020 09:11:15.906 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
6:C 13 Aug 2020 09:11:15.906 # Redis version=5.0.3, bits=64, commit=00000000, modified=0, pid=6, just started
6:C 13 Aug 2020 09:11:15.906 # Warning: no config file specified, using the default config. In order to specify a config file use /usr/bin/redis-server /path/to/redis.conf
6:M 13 Aug 2020 09:11:15.911 * Running mode=standalone, port=6379.
6:M 13 Aug 2020 09:11:15.911 # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128.
6:M 13 Aug 2020 09:11:15.911 # Server initialized
6:M 13 Aug 2020 09:11:15.911 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
6:M 13 Aug 2020 09:11:15.911 * Ready to accept connections
6:signal-handler (1597309905) Received SIGTERM scheduling shutdown...
6:M 13 Aug 2020 09:11:45.277 # User requested shutdown...
6:M 13 Aug 2020 09:11:45.277 * Saving the final RDB snapshot before exiting.
6:M 13 Aug 2020 09:11:45.278 * DB saved on disk
6:M 13 Aug 2020 09:11:45.278 # Redis is now ready to exit, bye bye...

以上的进程显示虽然redis-server是以PID为6的进程在容器里运行,然后我们用docker stop demo5来给tini发送SIGTERM终止信号 是会被转发给应用程序redis-server,日志显示redis-server在接收到tini转发的SIGTERM信号正常优雅退出。

Tini的优势

非root用户身份运行

USER 指令和 WORKDIR 相似,都是改变环境状态并影响以后的层。WORKDIR 是改变工作目录,USER 则是改变之后层的执行 RUN, CMD 以及 ENTRYPOINT 这类命令的身份。

示例:

...
RUN groupadd -r -g 999 redis && useradd -r -g redis -u 999 redis
USER redis
RUN [ "redis-server" ]
...

USER <用户名>指令有其局限性,是改变环境状态并影响以后的层,在执行一些需要root权限的操作时,需要su或者sudo重新切换到root权限,这些都比较麻烦,尤其是在TTY缺失的环境i下容易出错

github地址:https://github.com/tianon/gosu

示例:(来自redisdockerhub官网文档)

...
RUN groupadd -r -g 999 redis && useradd -r -g redis -u 999 redis

ENV GOSU_VERSION 1.12
RUN set -eux; \
	savedAptMark="$(apt-mark showmanual)"; \
	apt-get update; \
	apt-get install -y --no-install-recommends ca-certificates dirmngr gnupg wget; \
	rm -rf /var/lib/apt/lists/*; \
	dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
	wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
	wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
	export GNUPGHOME="$(mktemp -d)"; \
	gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
	gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
	gpgconf --kill all; \
	rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
	apt-mark auto '.*' > /dev/null; \
	[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; \
	apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
	chmod +x /usr/local/bin/gosu; \
	gosu --version; \
	gosu nobody true
...	

验证

Dockerfile如下:

FROM debian:buster-slim
LABEL maintainer="JaeGerW2016"

RUN groupadd -r -g 999 redis && useradd -r -g redis -u 999 redis
ENV GOSU_VERSION 1.12
RUN set -eux; \
        savedAptMark="$(apt-mark showmanual)"; \
        apt-get update; \
        apt-get install -y --no-install-recommends ca-certificates dirmngr gnupg wget; \
        rm -rf /var/lib/apt/lists/*; \
        dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
        wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
        wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
        export GNUPGHOME="$(mktemp -d)"; \
        gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
        gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
        gpgconf --kill all; \
        rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
        apt-mark auto '.*' > /dev/null; \
        [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; \
        apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
        chmod +x /usr/local/bin/gosu; \
        gosu --version; \
        gosu nobody true

RUN \
  apt-get update && \
  apt-get install -y --no-install-recommends redis-server procps && \
  rm -rf /var/lib/apt/lists/*

EXPOSE 6379

CMD ["gosu","redis","redis-server"]

redis-serverPID1和 redis用户运行

docker exec demo11 ps -ef
UID         PID   PPID  C STIME TTY          TIME CMD
redis         1      0  0 10:14 ?        00:00:00 redis-server *:6379
root         13      0  0 10:14 ?        00:00:00 ps -ef