Docker 中的应用为什么没有 Graceful Shutdown

下面是两个 Dockerfile 文件,我们来看看他们之间的区别是什么?会对运行中的容器产生什么样的影响?

第一个,执行入口以 exec 形式启动一个 Spring Boot 应用程序。

FROM frolvlad/alpine-java:jdk8-slim

RUN set -eux && mkdir -p /home/
RUN set -eux && mkdir -p /home/auth-server
RUN set -eux && mkdir -p /opt/logs/auth-server
RUN set -eux && touch /opt/logs/auth-server/auth-server.log
ADD auth-server.jar /home/auth-server/auth-server.jar

ENV TZ=Asia/Shanghai
ENV JAVA_ENV="-Denv=docker"
ENV JAVA_OPTS="-server -Xmx256m -Xms256m -XX:+UseG1GC"
ENTRYPOINT [ "sh", "-c", "java $JAVA_ENV $JAVA_OPTS -Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom -jar /home/auth-server/auth-server.jar" ]

第二个,执行入口以 exec 形式执行一个 shell 脚本,因为容器中可能还需要运行一些日志收集、链路监控的程序。所以容器运行时通过执行一个 shell 脚本来一起启动这些程序。

FROM frolvlad/alpine-java:jdk8-slim

RUN set -eux && mkdir -p /home/
RUN set -eux && mkdir -p /home/auth-server
RUN set -eux && mkdir -p /opt/logs/auth-server
RUN set -eux && touch /opt/logs/auth-server/auth-server.log
ADD auth-server.jar /home/auth-server/auth-server.jar

COPY entrypoint.sh /home/auth-server/entrypoint.sh
RUN chmod +x /home/auth-server/entrypoint.sh

ENTRYPOINT ["/home/auth-server/entrypoint.sh"]

entrypoint.sh

#!/bin/sh
ENV="-Denv=docker"

export JAVA_OPTS="-server -Xmx256m -Xms256m -XX:+UseG1GC"
export JAVA_OPTS="$JAVA_OPTS -Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom"

java ${ENV} $JAVA_OPTS -jar /home/auth-server/auth-server.jar

echo "java ${ENV} $JAVA_OPTS -jar /home/auth-server/auth-server.jar"
echo "start success"

区别就在于 Dockerfile 中的执行入口 ENTRYPOINT 的参数不同,通过这两个 Dockerfile 制作的镜像,在容器运行时又有什么区别呢?下面是两个镜像,启动容器后里面的进程信息。

  • ENTRYPOINT 执行 java -jar 启动应用程序
/ # ps aux
PID   USER   TIME  COMMAND
    1 root   0:10 java -Denv=docker -server -Xmx512m -Xms512m -XX:+UseG1GC -Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom -jar /home/auth-server/auth-server.jar
   25 root   0:00 /bin/sh
   34 root   0:00 ps aux
  • ENTRYPOINT 执行 shell 脚本启动应用程序
/ # ps aux
PID   USER   TIME  COMMAND
    1 root   0:00 /bin/sh /home/auth-server/entrypoint.sh
    6 root   0:15 java -Denv=docker -server -Xmx512m -Xms512m -XX:+UseG1GC -Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom -jar /home/auth-server/auth-server.jar
   76 root   0:00 /bin/sh
   81 root   0:00 ps aux

区别就在于谁是容器里的 1 号进程。 容器里的 1 号进程和非 1 号进程又有什么区别?

如果使用过 Kubernetes,应该知道 Kubernetes 并没有提供重启 Pod 的命令,只能通过 kubectl apply 来重建 Pod,而一般研发的操作发布入口,都是通过 Jenkins 工具自动构建一个新的镜像到 Harbor,然后再自动发布到 Kubernetes 平台来重建应用程序。

想重启一下应用程序,而且使用的是单进程模式,应用进程就是容器里的 1 号进程,在不让运维介入的情况下,那你可能必须走上面的 Jenkins 发布流程。

如果你们的 Dockerfile 镜像模板使用的是上述第二种方式,也就是说应用进程并不是容器中的 1 号进程,则还有另外一种方式,就是通过在 Dashboard 界面进入到 Pod 里,手动 kill 掉应用进程,这时配合 Kubernetes 的存活探针 livenessProbe 可以达到重启 Pod 的效果,而且这个 Pod 的 IP 不会变化。

livenessProbe:
  tcpSocket:
    port: 9096
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 3
  successThreshold: 1
  timeoutSeconds: 10   

Liveness 指针是存活探针,它用来判断容器是否存活、判断 pod 是否 running。如果 Liveness 指针判断容器不健康,此时会通过 kubelet 杀掉相应的 pod,并根据重启策略来判断是否重启这个容器。如果默认不配置 Liveness 指针,则默认情况下认为它这个探测默认返回是成功的。

单进程模式下,应用进程就是容器中的 1 号进程,不能通过 kill 1 来实现吗?你可以尝试一下,不管是通过 kill -9 还是 kill -15,这个 1 号进程都是杀不死的。

生产环境建议容器都是单进程,应用进程既是容器的主进程(1号进程)。

上述两种容器运行的方式,对于 Kubernetes 平台中 Pod 的滚动更新,或者仅仅用 Docker 时,容器的 Restart 会对服务产生什么样的影响?

先来介绍下在 linux 中两个终止进程的命令:kill -9 pid 和 kill -15 pid,代表两种信号 SIGKILL 和 SIGTERM。

[root@ ~]# kill -l
 1) SIGHUP   2) SIGINT   3) SIGQUIT  4) SIGILL   5) SIGTRAP
 6) SIGABRT  7) SIGBUS   8) SIGFPE   9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG  24) SIGXCPU 25) SIGXFSZ
......略

SIGTERM 是软终止,SIGKILL 用于立即终止进程,他们都可以用于终止程序,但是是有区别的:

  1. SIGTERM 优雅的终止进程,而 SIGKILL 会立即终止进程。
  2. SIGTERM 信号可以处理、忽略和阻止,而 SIGKILL 不能被处理或阻止。
  3. SIGTERM 不会杀死子进程。SIGKILL 会杀死子进程。

我们以一个 Spring Cloud 服务举例,服务启动后注册到 Eureka Server,当服务停止时通知 Eureka Server 下线注销实例(这个有一个前提,服务必须是优雅停止 graceful shutdown),服务下线通知是怎么实现的。如下:

@Singleton
public class DiscoveryClient implements EurekaClient {

   /**
     * Shuts down Eureka Client. Also sends a deregistration request to the
     * eureka server.
     */
    @PreDestroy
    @Override
    public synchronized void shutdown() {
        if (isShutdown.compareAndSet(false, true)) {
            logger.info("Shutting down DiscoveryClient ...");

            ......
            logger.info("Completed shut down of DiscoveryClient");
        }
    }
}

就是通过 @PreDestroy 注解,在 Bean 实例销毁之前做一些操作,对于 DiscoverClient 来说就是在 shutdown 时发送一个 HTTP 请求Sending request: DELETE /eureka/apps/API-GATEWAY/{instance-id} HTTP/1.1 主动通知一下 Eureka Server 自己要下线了。接下来经过多次同步之后,其它客户端感知到服务下线。

如果通过 kill -9 来强制杀死应用,Spring Boot 应用就来不及做这些善后工作,直接被终止了。

Docker 提供了有两种方式来停止容器:docker stop 和 docker kill

  • docker stop:容器内的主进程(PID为1的进程)将收到 SIGTERM 信号,如果在宽限时间后(默认 10s)进程还没有退出,将发送 SIGKILL 信号。使用 docker stop 时,docker 守护进程在发送 SIGKILL 信号之前等待的秒数是可以控制的,参数如下:

    Name, shorthand Default Description
    --time , -t 10 Seconds to wait for stop before killing it
  • docker kill:默认向容器内的主进程(1号进程)发送 SIGKILL 信号,或者用 --signal 选项指定的信号。也就是说默认情况下,docker kill 命令不会给容器进程一个优雅地退出的机会,它只是发出一个 SIGKILL 信号来终止容器。但是,它有一个 --signal 入参,可以向容器进程发送 SIGKILL 以外的信号。

    Name, shorthand Default Description
    --signal , -s KILL Signal to send to the container

对于 Kubernetes 平台来说,Pod 销毁的宽限时间默认是 30s。通过 terminationGracePeriodSeconds: 30 参数设置 。如果容器在宽限期后仍在运行,SIGKILL 将强制移除 Pod,终止操作完成。

下面通过命令看下上面两种 Dockerfile 文件构建的镜像,在容器停止时,容器内进程接收到的信号有什么区别。

使用 docker top auth-server 命令可以查看容器内进程在宿主机上的 PID 号。

第一种方式启动的容器:应用进程在宿主机上的 PID = 6184

[root@ dockerfile]# docker top auth-server
UID    PID   PPID    C    STIME    TTY   TIME       CMD
root  6184   6168    22   00:26    ?     00:00:15   java -Denv=docker -server -Xmx512m -Xms512m -XX:+UseG1GC -Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom -jar /home/auth-server/auth-server.jar

第二种脚本方式启动的容器:应用进程在宿主机上的 PID = 6716

[root@ dockerfile]# docker top auth-server
UID    PID    PPID   C    STIME    TTY   TIME       CMD
root   6698   6682   0    00:30    ?     00:00:00   /bin/sh /home/eureka-server/entrypoint.sh
root   6716   6698   95   00:30    ?     00:00:11   java -Denv=docker -server -Xmx512m -Xms512m -XX:+UseG1GC -Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom -jar /home/auth-server/auth-server.jar

docker stop auth-server 在停止容器的同时,使用 strace -p PID 来观察容器内进程接收到的信号情况。

  • strace -p 6184(ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Dfile......")
[root@ eureka]# strace -p 6184
strace: Process 6184 attached
futex(0x7fbd15b2a9d0, FUTEX_WAIT, 6, NULL) = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0} ---
futex(0x7fbd14ef1580, FUTEX_WAKE_PRIVATE, 1) = 1
rt_sigreturn({mask=[]})                 = 202
futex(0x7fbd15b2a9d0, FUTEX_WAIT, 6, NULL <unfinished ...>
+++ exited with 143 +++
  • strace -p 6698(ENTRYPOINT ["/home/auth-server/entrypoint.sh"])
[root@ eureka-server]# strace -p 6698
strace: Process 6698 attached
wait4(-1, 0x7fffd3f084cc, 0, NULL)      = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0} ---
wait4(-1, 0x7fffd3f084cc, 0, NULL)      = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
+++ killed by SIGKILL +++
  • strace -p 6716(ENTRYPOINT ["/home/auth-server/entrypoint.sh"])
[root@ eureka]# strace -p 6716
strace: Process 6716 attached
futex(0x7fa3569749d0, FUTEX_WAIT, 7, NULL <unfinished ...>
+++ killed by SIGKILL +++
PID SIGTERM SIGKILL
6184(容器内 PID = 1)
6698(容器内 PID = 1 )/bin/sh /home/auth-server/entrypoint.sh
6716(容器内 PID = 6)

6184 接收到了 SIGTERM 信号,而 6716 只接收到了 SIGKILL 信号被强制杀死,应用进程没有机会去做一些@PreDestroy 的善后操作;但是 6698 先是接收到了 SIGTERM 而后通过 SIGKILL 被杀死。

也就是说 docker stop 向容器发送信号时,SIGTERM 信号仅发送到 PID = 1 的容器进程。

由于 /bin/sh 不将信号转发给任何子进程,应用进程接收不到 docker stop <container>发出的 SIGTERM 信号,只能等宽限时间结束后被强制终止,这样我们的应用程序就不能 graceful shutdown 优雅终止。</container>

对于 Eureka Client 来说,会造成在更严重的下线通知延迟。

在非 graceful shutdown 情况下,客户端不会调用 Eureka API 来更新 registry 注册列表,而是只能等 Eureka Server 定时清理无效节点,这个周期默认是 60s,续约超时的时间默认是 90s,也就是说服务下线后,可能需要延迟 150s 之后,Eureka Server 中的 registry 对象才会被更新。而后还要经过多轮同步,客户端才能感知到。

如果不可避免的要在容器里运行多个进程,能让 1 号 init 进程在收到 SIGTERM 信号的同时,转发给其它进程,就可以解决应用非 graceful shutdown 的问题。

下面介绍一种方法来解决上面这个问题,既要保证应用进程可以接收到 SIGTERM 信号,还要可以在容器内手动 kill 掉应用进程(这样可以配合 Kubernetes 的存活探针达到重启 Pod 的效果)。

使用 Tini 作为 init 进程。tini 会把它接收到的所有信号都转发给它的子进程,这正是我们想要的。

将 Dockerfile 文件 和 entrypoint.sh 脚本稍微改造一下:

Dcokerfile 中添加安装 tini 语句,ENTRYPOINT 使用 Tini 作为 init 进程。

FROM frolvlad/alpine-java:jdk8-slim

RUN set -eux && mkdir -p /home/
RUN set -eux && mkdir -p /home/auth-server
RUN set -eux && mkdir -p /opt/logs/auth-server
RUN set -eux && touch /opt/logs/auth-server/auth-server.log
ADD auth-server.jar /home/auth-server/auth-server.jar

COPY entrypoint.sh /home/auth-server/entrypoint.sh
RUN chmod +x /home/auth-server/entrypoint.sh

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

ENTRYPOINT ["/tini", "--", "/home/auth-server/entrypoint.sh"]

使用 exec 方式启动可执行程序,它会替换掉当前 /bin/sh 进程,并保持 PID 不变。

#!/bin/sh
ENV="-Denv=docker"

export JAVA_OPTS="-server -Xmx256m -Xms256m -XX:+UseG1GC"
export JAVA_OPTS="$JAVA_OPTS -Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom"

exec java ${ENV} $JAVA_OPTS -jar /home/auth-server/auth-server.jar

echo "java ${ENV} $JAVA_OPTS -jar /home/auth-server/auth-server.jar"
echo "start success"

下面是改造之后的容器内的进程信息:

[root@ dockerfile]# docker exec -it auth-server /bin/sh
/ # ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 /tini -- /home/auth-server/entrypoint.sh
    6 root      0:12 java -Denv=docker -server -Xmx512m -Xms512m -XX:+UseG1GC -Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom -jar /home/auth-server/auth-server.jar
   30 root      0:00 /bin/sh
   35 root      0:00 ps aux

再用 strace 监测下 docker stop auth-server 时应用进程收到的信号,下面可以看到应用进程收到了 SIGTERM 信号。

[root@iZ2zece2l8yr2f8qhrnr3lZ ~]# strace -p 1336
strace: Process 1336 attached
futex(0x7f56967149d0, FUTEX_WAIT, 7, NULL) = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=1, si_uid=0} ---
futex(0x7f5695adb580, FUTEX_WAKE_PRIVATE, 1) = 1
rt_sigreturn({mask=[]})                 = 202
futex(0x7f56967149d0, FUTEX_WAIT, 7, NULL <unfinished ...>
+++ exited with 143 +++

应用收到 SIGTERM 信号后,就会做一些终止时的善后操作,下面就是通知 Eureka Server 服务下线。

INFO  [c.n.eureka.DefaultEurekaServerContext  ] - Shutting down ...
INFO  [c.n.eureka.DefaultEurekaServerContext  ] - Shut down
INFO  [com.netflix.discovery.DiscoveryClient  ] - Shutting down DiscoveryClient ...
INFO  [com.netflix.discovery.DiscoveryClient  ] - Completed shut down of DiscoveryClient

很多开源项目的官方镜像中都使用了这种方式,例如:

使用 tini 的基础镜像:

注意: 编写 shell 脚本时需注意,要让脚本始终处于运行状态,因为 Docker 容器仅在 1 号进程运行时才保持运行 ,1 号进程退出,Docker 容器也将退出。如果配置了 restart: always,你会发现容器一直在尝试重启。

微服务演示项目中:auth-server 服务我是采用 tini 作为 init 进程来构建的镜像,你可以在 Kubernetes 平台或者 Docker Compose 中尝试上面所描述的问题。

~ END ~。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,366评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,521评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,689评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,925评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,942评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,727评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,447评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,349评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,820评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,990评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,127评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,812评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,471评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,017评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,142评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,388评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,066评论 2 355

推荐阅读更多精彩内容