美文网首页
Docker 中的应用为什么没有 Graceful Shutdo

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

作者: 读书学习看报 | 来源:发表于2021-05-05 21:43 被阅读0次

    下面是两个 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 ~。

    相关文章

      网友评论

          本文标题:Docker 中的应用为什么没有 Graceful Shutdo

          本文链接:https://www.haomeiwen.com/subject/fdvldltx.html