Jan's Blog

Docker 里的进程为什么没有处理 TERM 信号

· Zhen Zhijian

现象

在一个部署在 Kubernetes 的项目里,每次重新部署的都会发现有两个 pods 处于 terminating 状态的时间特别长,于是着手分析这个问题。

Kubernetes 是如何关闭一个容器的?文档 Pods - Kubernetes 介绍,Kubernetes 会向容器里的进程发 TERM 信号,如果进程在一定时间(默认30秒)内没有关闭,Kubernetes 会发 KILL 信号。

猜想是因为进程没有处理 TERM 信号,在30秒超时后才被 KILL 信号关闭。验证猜想:

$ time kubectl delete pod POD_NAME

可以看到用时大概是30秒,一定程度上验证了这个猜想。再进一步,直接向进程发 TERM 信号:

$ kubectl exec -it POD_NAME -- kill -TERM 1
$ kubectl get pods

Pod 还在,没有任何反应,可以验证进程没有处理 TERM 信号。

然而,shell 里直接运行程序,再发 TERM 信号,进程会马上关闭。那么现在问题是为什么进程在 Docker 里就不处理 TERM 信号了?

到这里,直觉告诉我们,在程序里显式处理 TERM 信号大概能解决问题。然而,我还是想知道为什么到了 Docker 环境情况会变得不一样。我们先来看看信号是如何工作的。

信号如何工作

信号的处理有以下3种情况:

  1. 如果进程注册了信号的处理函数,那么内核会调用相应的函数来处理;
  2. 如果进程设置了忽略该信号,则内核直接忽略;
  3. 如果进程设置了屏蔽该信号,则内核会放到队列里;
  4. 对于部分信号,如果既没有注册处理函数,也没有忽略和屏蔽,则内核会有相应的默认处理,通过 man 7 signal 可以看到,TERM 的默认处理是关闭进程

我们可以在 /proc/ 文件系统里观察进程的信号设置:

$ kubectl exec POD_NAME -- grep Sig /proc/1/status

SigQ:	0/15733
SigPnd:	0000000000000000
SigBlk:	0000000000000000
SigIgn:	0000000001001000
SigCgt:	0000000180000002

SigBlk、SigIgn、SigCgt 分别是屏蔽、忽略和捕捉(即注册了处理函数)的mask,十六进制,转成二进制后,每一位对应一个信号编号。kill -l 可以列出信号编号对应的信号。

**通过上面的信息可以发现编号15的 TERM 信号在3组 mask 里都没有,应该属于情况4,由内核默认处理,也就是关闭进程。**但是实际上并不符合预期。我们做一些实验分析。

分析

为了简化问题,做了一些尝试后发现只需要在 docker 环境里跑 sleep 进程就能重现。

$ docker run -it --rm -d alpine /bin/sleep 3600 # 创建一个容器,跑 sleep 进程

44c2fa4ba13066e3b10f78484144fdc06bb87d132a87006a697521fa629e6f74

$ docker exec -it 44c2fa4ba13066 /bin/sh  # 进入容器,跑 shell
/ # grep Sig /proc/1/status # sleep 没有任何信号处理的设置

SigQ:	0/7866
SigPnd:	0000000000000000
SigBlk:	0000000000000000
SigIgn:	0000000000000000
SigCgt:	0000000000000000

/ # kill -TERM 1 # 向 sleep 进程发信号
/ # ps -ef # 没有默认处理,没有关闭

PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sleep 3600
    7 root      0:00 /bin/sh
   13 root      0:00 ps -ef

是 Docker 在信号处理上做了一些特殊处理吗?从原理上分析是没有的,我们通过实验来验证,还是在上面的环境里,我们在 shell 后台跑一个 sleep 进程,再发信号试试。

/ # sleep 1800 &
/ # ps -ef

PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sleep 3600
    7 root      0:00 /bin/sh
   16 root      0:00 sleep 1800
   17 root      0:00 ps -ef

/ # kill -TERM 16
/ #
[1]+  Terminated                 sleep 1800

这次 sleep 进程符合预期正常关闭了。那么原因很明显的,特殊的是 PID 1

特殊的 PID 1

man 2 kill 告诉我们:

The only signals that can be sent to process ID 1, the init process,
are those for which init has explicitly installed signal handlers.
This is done to assure the system is not brought down accidentally.

PID 1 进程通常是 systemd、init,扮演着重要的角色,对信号的处理是极其谨慎的,防止意外退出导致 Kernel panic。然而在 docker 里,PID 1成了我们要运行的程序,但却享受着内核的特殊“保护”。

结论

在 Docker 里,一定要显式处理 TERM 信号。