1.16.2 应用优雅停止

优质
小牛编辑
128浏览
2023-12-01

不论是什么类型的应用,都会希望在服务停止前能够收到停止通知,有一定的时间做退出前的释放资源、关闭连接、不再接收外部请求等工作。我们对所有应用的优雅停止配置提供了一个全面指导,也提供了每种类型服务,从开发到部署到应用引擎v2的测试优雅停止功能的完整流程。

容器优雅停止最简单办法

容器优雅停止最简单配置

当退出容器前,会将容器从服务提供列表中移除,使得外部请求不再打在其上,同时执行退出前可执行Hook。

在这里,我们设置在停止容器前在容器内执行sleep 10命令,这时容器就不再接收外部请求,并且有10秒时间完成正在处理的请求,到达10秒后,容器会强制被删除。

Linux常见信号

要了解应用优雅停止方法,我们先回顾一下与容器相关的Linux常见信号。

信号是一种进程间通信的形式。一个信号就是内核发送给进程的一个消息,告诉进程发生了某种事件。进程需要为自己感兴趣的信号注册处理程序,举例:

  1. 为了能让程序优雅的退出(接到退出的信号后,对资源进行清理),一般程序都会处理SIGTERM信号。与SIGTERM信号不同,SIGKILL信号会粗暴的结束一个进程;
  2. 许多守护进程会通过处理SIGHUP信号实现热加载配置文件。

使用 kill -l 命令会显示Linux支持的信号列表。其中编号为1 ~ 31的信号为传统UNIX支持的信号,是不可靠信号(非实时的),编号为32 ~ 63的信号是后来扩充的,称做可靠信号(实时信号)。

下面对常用的信号进行说明:

  1. SIGHUP(1)

    当用户终端连接结束时,系统会像所有运行中的进程发出这个信号;通常在热加载配置文件时候也会使用该信号。wget命令就注册了SIGHUP(1)信号,这样就算你退出了Linux登录,wget也能继续下载文件。同样的,如Docker/Nginx/LVS等服务也会注册SIGHUP(1)信号,实现服务的热加载配置文件功能。

  2. SIGINT(2)

    程序终止(interrupt)信号,在用户键入INTR字符(通常是Ctrl+C)时发出,用于通知前台进程组终止进程。

  3. SIGQUIT(3)

    SIGINT类似,但由QUIT字符(通常是Ctrl+反斜杠)来控制。Nginx就是通过注册这个信号来实现优雅停止服务的。

  4. SIGKILL(9)

    立刻结束程序。该信号不能被阻塞、处理和忽略,不能在程序中被获取到。

  5. SIGTERM(15)

    程序结束(Terminate)信号,又叫请求退出信号,与SIGKILL不同的是该信号可以被阻塞和处理,我们可以通过在程序中注册该信号来实现服务的优雅停止。使用kill命令缺省会发出这个信号。

  6. SIGCHLD(17)

    子进程结束时,一般会向父进程发送这个信号。Nginx是个多进程程序,master进程和worker进程通信就使用的这个信号。

想要了解更多关于Linux Signal,可以看文档man signal。

Docker容器服务对信号的支持

Docker对Linux Signal也做了很多的支持。

  • 1) docker stop命令信号支持

当我们用docker stop命令来停掉容器的时候,docker默认会允许容器中的应用程序有10秒的时间用以终止运行。我们可以通过在执行docker stop命令时手动指定--time/-t参数来自定义一个stop时间长度。

→ docker stop --help
Usage:  docker stop [OPTIONS] CONTAINER [CONTAINER…]
Stop one or more running containers
Options:
      --help      Print usage
  -t, --time int  Seconds to wait for stop before killing it (default 10)

docker stop命令执行的时候,会先向容器中PID为1的进程(main process)发送系统信号SIGTERM,然后等待容器中的应用程序终止执行,如果等待时间达到设定的超时时间,如默认的10秒,会继续发送SIGKILL的系统信号强行kill掉进程。在容器中的应用程序,可以选择忽略和不处理SIGTERM信号,不过一旦达到超时时间,程序就会被系统强行kill掉。

  • 2) docker kill命令信号支持

默认情况下,docker kill命令不会给容器中的应用程序有任何gracefully shutdown的机会,它会直接发出SIGKILL的系统信号以强行终止容器中程序的运行。

查看docker kill命令的帮助我们看到,除了默认发送SIGKILL信号外,还允许我们发送一些自定义的系统信号:

→ docker kill --help
Usage:  docker kill [OPTIONS] CONTAINER [CONTAINER…]
Kill one or more running containers
Options:
      --help            Print usage
  -s, --signal string  Signal to send to the container (default "KILL")

比如,如果我们想向docker中的程序发送SIGINT信号,我们可以这样来实现:

docker kill --signal=SIGINT container_name

与docker stop命令不一样的地方在于,docker kill没有任何的超时时间设置,它会直接发送SIGKILL信号,或者用户指定的其他信号。

  • 3) docker rm命令信号支持

docker rm命令用于删除已经停止运行的容器,我们可以添加--force-f参数强行删除正在运行的容器。使用这个参数后,docker会先给运行中的容器发送SIGKILL信号,强制停掉容器,然后再做删除。

例如,强制删除正在运行的名称为web容器

docker rm -fv web
  • 4) docker daemon进程对信号支持

docker daemon进程会接收SIGHUP信号,接收后会重新reload daemon.json配置文件。

我们为dockerd进程发送一个SIGHUP信号:

root@vm10-1-1-28:~# kill -SIGHUP $(pidof dockerd)
root@vm10-1-1-28:~# 或者
root@vm10-1-1-28:~# systemctl reload docker

查看docker daemon的日志可以看到,docker daemon接收这个信号并重新reload daemon.json配置文件

root@vm10-1-1-28:~# journalctl -u docker.service -f
-- Logs begin at Sun 2018-01-07 09:17:01 CST. --
Jan 18 16:20:11 vm10-1-1-28.ksc.com dockerd[26668]: time="2018-01-18T16:20:11.262904839+08:00" level=info msg="Got signal to reload configuration, reloading from: /etc/docker/daemon.json"
Jan 18 16:21:41 vm10-1-1-28.ksc.com systemd[1]: Reloading Docker Application Container Engine.

所以在你修改完/etc/docker/daemon.json文件后,可以直接给Docker发送一个SIGHUP信号实现配置文件的reload,而不需要重启docker daemon。

应用引擎v2服务对信号支持

应用引擎v2使用Kubernetes做为编排引擎,目前对运行中的容器也提供了优雅终止支持。

当用户通过界面或者命令行请求删除一个应用、应用扩容缩容,只要是有Pod被删除,都会触发下面的流程来终止并移除Pod:

  1. 服务收到删除Pod请求后,根据等待时间更新Pod的优雅退出时间;
  2. 由于Pod已经设置了优雅退出时间,界面、客户端命令行上看到Pod的状态都变为"Terminating(退出中)"状态;
  3. (与第2步同时)后台服务kubelet开始进入关闭Pod的流程:
    1. 如果在Pod中定义了preStop hook,其会在Pod内部被调用。如果钩子在优雅退出时间段超时后仍然在运行,又会再增加2秒的优雅时间;
    2. Pod中PID为1的进程被发送SIGTERM信号;
  4. (与第2步同时)后台服务Servce manager将Pod从service的列表中删除,不再被认为是运行着的Pod的一部分。缓慢关闭的Pod还可以继续对外提供服务,直到 load balancer 将他们轮流移除;
  5. 当到达优雅退出时间后,任何Pod中正在运行的进程会被发送SIGKILL信号以强行杀死;
  6. 后台服务kubelet会完成Pod的删除,将优雅退出的时间设置为0(表示立即删除)。Pod从API中删除,不再对客户端可见。

通过上面的流程我们可以看出,要对运行在应用引擎v2中的应用添加优雅退出有两种方式:

  1. 假如容器内PID为1的进程是我们的应用程序,我们可以在程序中接收并处理SIGTERM信号来实现优雅退出;
  2. 我们也可以设置一个preStop hook,在hook中指定怎么优雅停止容器。preStop hook目前支持两种方式:向容器内发送一个HTTP请求在容器内执行一个命令

注意,上面提到了等待时间时间,如果我们不设置,默认为30秒。如果设置为0,将立刻发送SIGKILL信号来杀死Pod内所有进程。如果要设置的话,请根据服务情况酌情设置,避免因为程序内有死锁或者其他原因带来的其他问题。

服务优雅停止案例

不论什么服务,要想实现优雅停止,都是希望在停止前告诉程序,让程序能有一定的时间处理、保存程序执行现场,优雅的退出程序。下面我们准备了两个通用案例,基本涵盖了所有服务的应用场景,包括:在程序中接收并处理TERM信号指定preStop hook

下面两个案例都提供了详细的使用流程,请在实际使用中根据自己情况,选择适合方式使用。

1. 在程序内接收并处理信号

通过了解上面Docker容器服务对信号的支持我们知道,docker kill命令适用于强行终止程序并实现快速停止容器。而如果希望程序能够gracefully shutdown的话,docker stop才是不二之选,这样我们可以让程序在接收到SIGTERM信号后,有一定的时间处理、保存程序执行现场,优雅的退出程序。

接下来我们写一个简单的Go程序来实现信号的接收与处理。程序在启动过后,会一直阻塞并监听系统信号,直到监测到对应的系统信号后,输出到控制台并退出执行。

// main.go
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
func main() {
    fmt.Println("Program started…")
    ch := make(chan os.Signal, 1)
    // notify signal SIGTERM(15)
    signal.Notify(ch, syscall.SIGTERM)
    // notify signal SIGINT(2)
    signal.Notify(ch, syscall.SIGINT)
    s := <-ch
    switch {
    case s == syscall.SIGINT:
        fmt.Println("SIGINT received!")
        //Do something…
    case s == syscall.SIGTERM:
        fmt.Println("SIGTERM received!")
        //Do something…
    }
    fmt.Println("Exiting…")
}

接下来使用交叉编译的方式来编译程序,让程序可以在Linux下运行:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o graceful

编译好之后。我们做测试:

  • 1) 测试接收SIGTNT信号。在前台启动进程,然后输入Ctrl + C发送SIGINT(2)信号。
lynzabo@ubuntu ~/r/g/s/edit> ./graceful
Program started…
^CSIGINT received!
Exiting…
lynzabo@ubuntu ~/r/g/s/edit>
  • 2) 测试接收SIGTERM信号
lynzabo@ubuntu ~/r/g/s/edit> ./graceful &
Program started…
lynzabo@ubuntu ~/r/g/s/edit> ps -ef | grep graceful
lynzabo  21223  21082  0 15:57 pts/21  00:00:00 ./graceful
lynzabo  21287  21082  0 15:57 pts/21  00:00:00 grep --color=auto graceful
lynzabo@ubuntu ~/r/g/s/edit> kill 21223
SIGTERM received!
Exiting…
“./graceful &” has ended
lynzabo@ubuntu ~/r/g/s/edit>
  • 3) 将上面程序打包到容器中运行。

Dockerfile

FROM alpine:latest

LABEL maintainer "opl-xws@xiaomi.com"

ADD graceful /graceful

CMD ["/graceful"]

在处理SIGTERM信号常见的一个坑

我们都知道,通过在Dockerfle中使用CMD、ENTRYPOINT命令可以定义容器启动命令,关于这两个命令的区别这里就不讲了,我们只讲在使用时候一定要注意的问题。

这两个命令都支持下面几种格式:

  • shell 格式:CMD <命令>
  • exec 格式:CMD ["可执行文件", "参数1", "参数2"...]
  • 参数列表格式:CMD ["参数1", "参数2"...]。在指定了 ENTRYPOINT 指令后,用 CMD 指定具体的参数。

一般推荐使用 exec格式,这类格式在解析时会被解析为 JSON 数组,因此一定要使用双引号 ",而不要使用单引号'

如果使用 shell 格式的话,实际的命令会被包装为 sh -c 的参数的形式进行执行。比如:

CMD echo $HOME

在实际执行中,会将其变更为:

CMD [ "sh", "-c", "echo $HOME" ]

因此容器的主进程是sh,当给容器发送信号,接收信号的是sh进程,sh进程收到信号后会直接退出,自然就会令容器退出。我们的程序永远收不到信号。

镜像打包过程:

lynzabo@ubuntu ~/r/g/s/edit> docker build -t cnbj6-repo.cloud.mi.com/k8s/graceful-golang-case:1.0.0 .
Sending build context to Docker daemon 1.953 MB
Step 1/4 : FROM alpine:latest
---> 3fd9065eaf02
Step 2/4 : LABEL maintainer "opl-xws@xiaomi.com"
---> Using cache
---> 6cc05b3f0ed0
Step 3/4 : ADD graceful /graceful
---> Using cache
---> 4a47b371a124
Step 4/4 : CMD /graceful
---> Using cache
---> f1841c0035af
Successfully built f1841c0035af
lynzabo@ubuntu ~/r/g/s/edit>
  • 4) 启动容器:
lynzabo@ubuntu ~/r/g/s/edit> docker run -d --name graceful cnbj6-repo.cloud.mi.com/k8s/graceful-golang-case:1.0.0
08d871007b58e55e9552cff23960c80faf51bf8637014a745dec060b80ac9a6f
lynzabo@ubuntu ~/r/g/s/edit> docker ps
CONTAINER ID        IMAGE                                                    COMMAND            CREATED            STATUS              PORTS                    NAMES
08d871007b58        cnbj6-repo.cloud.mi.com/k8s/graceful-golang-case:1.0.0  "/graceful"        10 seconds ago      Up 9 seconds                                graceful
lynzabo@ubuntu ~/r/g/s/edit>
  • 5) 查看容器输出,能看到程序已经正常启动:
lynzabo@ubuntu ~/r/g/s/edit> docker logs graceful
Program started…
lynzabo@ubuntu ~/r/g/s/edit>
  • 6) 接着我们要使用docker stop看程序能否响应SIGTERM信号。

我们都知道docker stop默认在发出SIGTERM信号后的10秒钟,再发送SIGKILL信号强制停掉容器内所有进程,删除容器,假如我的程序处理很复杂,10秒内干不完清理工作,所以我在执行docker stop时自定义让2分钟后再强制kill掉我的容器:

lynzabo@ubuntu ~/r/g/s/edit> docker stop --time=120 graceful
graceful
lynzabo@ubuntu ~/r/g/s/edit> docker logs graceful
Program started…
SIGTERM received!
Exiting…
lynzabo@ubuntu ~/r/g/s/edit>

查看上面日志,我们可以看到,我们程序确实可以对Docker发来的SIGTERM信号进行处理。

  • 7) 下面讲解如何将接收信号的程序应用到应用引擎v2上,让应用引擎v2来控制Pod的优雅停止。

    • i. 创建新应用,填写应用信息,设置我们应用优雅退出时间为2分钟,在界面上填写优雅退出时间为120秒。创建成功。
    • ii. 应用启动成功
    • iii. 查看应用启动日志,并保持一直在查看日志。
    Program started…
    

    • iii. 我们在界面上点击删除应用,可以看到日志中已经有应用引擎v2发送给我们程序的SIGTERM信号日志。
    Program started…
    SIGTERM received!
    Exiting…
    

上面整个步骤就演示了如何通过我们的程序设置服务的优雅的停止办法,接下来我们要讲怎么通过preStop hook来优雅停止服务。

2. 使用应用引擎preStop hook停止服务

有时候我们也想在服务停止前,通过执行一条命令或者发送一个HTTP请求来优雅的停掉服务。

举例:

  • 比如对于Spring boot应用,Spring Boot Actuator提供了服务优雅停止办法,当要停止服务时,可以向服务发送一个post方法的shutdown HTTP请求。
  • 比如对于Nginx服务,当要停止服务时,可以执行命令kill -QUIT Nginx主进程号来停止服务。

下面我们将以Nginx服务来讲解如何使用preStop hook来停止服务:

先回顾一下有关Nginx的基础知识:

Nginx是一个多进程服务,master进程和一堆worker进程,master进程只负责校验配置文件语法,创建worker进程,真正的执行、接收客户请求、处理配置文件中指令都是由worker进程来完成的。master进程与worker进程之间主要是通过Linux Signal来实现交互。Nginx提供了大量的命令和处理信号来实现对配置文件的语法检查,服务优雅停止,进程平滑重启、升级等功能,我们这里仅简单介绍与nginx优雅停止相关命令触发的Linux Signal执行过程和执行原理。

nginx 的停止方法有很多,一般通过发送系统信号给 nginx 的master进程的方式来停止 nginx。

  • i. 优雅停止 nginx
[root@localhost ~]# nginx -s quit
[root@localhost ~]# kill -QUIT 【Nginx主进程号】
[root@localhost ~]# kill -QUIT /usr/local/nginx/logs/nginx.pid

master进程接到SIGQUIT信号时,将此信号转发给所有工作进程。工作进程随后关闭监听端口以便不再接收新的连接请求,并闭空闲连接,等待活跃连接全部正常结速后,调用 ngx_worker_process_exit 退出。而 master 进程在所有工作进程都退出后,调用 ngx_master_process_exit 函数退出;

  • ii. 快速停止 nginx
[root@localhost ~]# nginx -s stop
[root@localhost ~]# kill -TERM 【Nginx主进程号】
[root@localhost ~]# kill -INT 【Nginx主进程号】

TERM信号在Linux系统可以称为优雅的退出信号,INT信号是系统SIGINT信号,Nginx对这两个信号的处理方式有所不同。Nginx用SIGQUIT(3)信号来优雅停止服务。

master进程接收到SIGTERM或者SIGINT信号时,将信号转发给工作进程,工作进程直接调用ngx_worker_process_exit 函数退出。master进程在所有工作进程都退出后,调用 ngx_master_process_exit 函数退出。另外,如果工作进程未能正常退出,master进程会等待1秒后,发送SIGKILL信号强制终止工作进程。

iii. 强制停止所有 nginx 进程

[root@localhost ~]# nginx -s stop
[root@localhost ~]# pkill -9 nginx

直接给所有的nginx进程发送SIGKILL信号。

Nginx服务使用应用引擎v2 preStop hook功能流程如下:

  • 1) 运行Docker hub官方提供的Nginx镜像。

官方提供的Nginx Dockerfile中提供的默认的启动Nginx命令如下

Dockerfile

...
CMD ["nginx", "-g", "daemon off;"]

上面CMD指定直接在前端启动nginx。

为了查看nginx能输出SIGNAL日志,我们设置nginx日志级别到notice。修改nginx.conf文件,将error_log属性值由error改成notice

nginx.conf

user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
}

http {
    include      /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush    on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/*.conf;
}
  • 2) 我们在本地启动运行容器
root@vm10-1-1-28:~/nginx# docker run -v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro nginx
2018/01/26 04:53:51 [notice] 1#1: using the "epoll" event method
2018/01/26 04:53:51 [notice] 1#1: nginx/1.13.8
2018/01/26 04:53:51 [notice] 1#1: built by gcc 6.3.0 20170516 (Debian 6.3.0-18)
2018/01/26 04:53:51 [notice] 1#1: OS: Linux 4.4.0-62-generic
2018/01/26 04:53:51 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576
2018/01/26 04:53:51 [notice] 1#1: start worker processes
2018/01/26 04:53:51 [notice] 1#1: start worker process 5
  • 3) 我们新启动一个终端,为容器发送QUIT信号,优雅停止Nginx
root@vm10-1-1-28:~# docker exec -ti bc0a0272448a nginx -s quit
root@vm10-1-1-28:~# 或者
root@vm10-1-1-28:~# docker kill --signal=SIGQUIT bc0a0272448a
bc0a0272448a
root@vm10-1-1-28:~#

再次查看容器日志

2018/01/26 04:55:04 [notice] 1#1: signal 3 (SIGQUIT) received, shutting down
2018/01/26 04:55:04 [notice] 5#5: gracefully shutting down
2018/01/26 04:55:04 [notice] 5#5: exiting
2018/01/26 04:55:04 [notice] 5#5: exit
2018/01/26 04:55:04 [notice] 1#1: signal 17 (SIGCHLD) received from 5
2018/01/26 04:55:04 [notice] 1#1: worker process 5 exited with code 0
2018/01/26 04:55:04 [notice] 1#1: exit
root@vm10-1-1-28:~/nginx#
root@vm10-1-1-28:~/nginx#

我们看到,nginx收到SIGQUIT信号后,然后做优雅的停止服务操作。

  • 4) 下面讲解如何让这个服务使用应用引擎v2提供的preStop hook来控制Pod的优雅停止。
    • i. 创建新应用,填写Nginx应用信息,设置一个Nginx应用优雅退出时间为2分钟,在界面上填写优雅退出时间为120秒。
    • ii. 选择preStop hook类型为Exec类型,填写优雅停止Nginx服务命令为["nginx","-s","quit"]
    • iii. 点击保存应用,应用就创建完成。

这样,我们的Nginx应用就已经配置上优雅停止功能了。当有Pod停止的时候,应用引擎v2会先调用该preStop hook停止服务。

Q&A

Q1:在程序里注册了SIGTERM信号,如何确定是否执行了程序中信号注册函数?

A:可以直接查看容器执行日志。

Q2:我定义了preStop hook配置,如何确定是否执行了这个hook,执行结果怎么看?

A:我们平台不提供查看执行结果功能,如果是执行preStop失败,在界面Events中可以看到FailedPreStopHook的Event。如下面输出Event:

Warning  FailedPreStopHook      1s    kubelet, 10.1.0.105  Exec lifecycle hook ([1nginx -s quit]) for Container "nginx" in Pod "nginx-7df4f86d64-z69cx_default(712f57eb-0274-11e8-a931-06ff9d8abab7)" failed - error: command '/1nginx -s quit' exited with 126: , message: "rpc error: code = 2 desc = \"oci runtime error: exec failed: exec: \\\"/usr/sbin/1nginx\\\": stat /usr/sbin/1nginx: no such file or directory\""
 Normal  Killing                1s    kubelet, 10.1.0.105  Killing container with id docker://nginx:Need to kill Pod