Kubernetes容器:为什么我的进程收不到SIGTERM信号?

1年前 (2022) 程序员胖胖胖虎阿
217 0 0

背景

随着云原生技术的流行,越来越多的应用选择容器化,容器化的话题自然离不开 Kubernetes 。Pod 是 Kubernetes 中创建和管理的、最小的可部署的计算单元,一个 Pod 中有多个容器,容器是一组进程的集合。当然,容器本质上是 Linux 的 Namespace 和 Ggroups 技术的应用,Namespace 负责资源隔离,Cgroups 负责资源限制。

在使用 Kubernetes 部署应用的过程中,是否有产生这样的疑问:为什么有的pod删除很快,有的pod删除要等很久?容器退出时,以为进程会收到 SIGTERM 信号做优雅退出,结果反而被 SIGKILL 给杀死了?这也是本文想和大家探讨的几个问题。

环境

Ubuntu 20.04.2、Kernel 5.4.0-73-generic 、Kubernetes 1.20.7

实验

实验代码如下:

main.go

package main

import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    fmt.Println("app running...")
    sc := make(chan os.Signal, 1)
    signal.Notify(sc, syscall.SIGTERM)
    sig := <-sc
    fmt.Printf("接收到信号[%s]\n", sig.String())
    switch sig {
    case syscall.SIGTERM:
        // 释放资源
        fmt.Println("优雅退出")
    }
}

编译生成二进制用于下面例子中的Dockerfile

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o demoapp main.go

start.sh

#!/bin/sh
echo "do something before start"
./demoapp

Dockerfile

FROM alpine
WORKDIR /app
COPY demoapp start.sh ./
RUN chmod +x ./start.sh
CMD ["./start.sh"]

pod.yaml

apiVersion: v1
kind: Pod
metadata:
  name: demoapp
spec:
  containers:
    - name: demoapp
      imagePullPolicy: IfNotPresent
      image: demoapp:v1

执行下面的命令

# 打包镜像
$ docker build -t demoapp:v1 -f Dockerfile  .

# 创建pod
$ kubectl apply -f pod.yaml

# 查看容器里的进程
$ kubectl exec -it demoapp -c demoapp -- ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 {start.sh} /bin/sh ./start.sh
    8 root      0:00 ./demoapp
   35 root      0:00 ps aux

$ kubectl exec -it demoapp -c demoapp -- pstree
start.sh---demoapp---6*[{demoapp}]

从上面可以看到start.sh是容器里的init进程(1号进程),dempapp是它的子进程。查看容器的实时日志

$ kubectl logs -f demoapp -c demoapp 
do something before start
app running...

执行下面的删除pod命令

kubectl delete pod demoapp

pod 的状态变成 Terminating,并持续了30s左右pod才真正消失,同时容器日志并没有看到"优雅退出"的输出,证明demoapp进程确实没收到 SIGTERM 信号。到底哪里出了问题?

于是在删除pod的过程,观察容器里的 init 进程(1号进程)和子进程 demoapp 到底收到了什么信号。在容器里面,我们看到是容器所在的 PID Namespace 下的进程PID,PID编号从1开始。而在宿主机上的 Host PID Namespace,它是其他 Namespace 的父亲 Namespace,可以看到在这台机器上的所有进程,不过进程 PID 编号不是容器所在 PID Namespace 里的编号了,而是把所有在宿主机运行的进程放在一起,再进行编号。在宿主机中我们通过 ps 命令找出它们并用 strace 工具观察它们收到的信号

$ ps -ef | grep start.sh
root       72433   72412  0 20:36 ?        00:00:00 /bin/sh ./start.sh
chen       74415   43973  0 20:40 pts/4    00:00:00 grep --color=auto start.sh

$ ps -ef | grep demoapp
root       72463   72433  0 20:36 ?        00:00:00 ./demoapp
chen       74492   43973  0 20:40 pts/4    00:00:00 grep --color=auto demoapp

$ strace -p 72433
strace: Process 72433 attached
wait4(-1, 0x7ffc512a367c, 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,  <unfinished ...>)            = ?
+++ killed by SIGKILL +++

$ strace -p 72463
strace: Process 72463 attached
futex(0x554bd0, FUTEX_WAIT_PRIVATE, 0, NULL) = ?
+++ killed by SIGKILL +++

执行删除 pod 那一刻,容器的init进程 start.sh 先收到了 SIGTERM ,过了30s收到 SIGKILL ,紧接着demoapp进程收到了SIGKILL。

从上面的结果来看,有两大疑问:

  • 为什么容器里的init进程收到 SIGTERM 信号不响应,而是等到30s后收到 SIGKILL 信号才被杀掉呢,信号是谁发给它的?
  • 为什么容器里的demoapp进程收到是 SIGKILL 信号,信号是谁发给它的?

为了回答第一个疑问,重做实验并进入容器查看 1 号进程状态中 SigCgt Bitmap(在Host PID Namespace下查看也是一样的)

$ kubectl exec -it demoapp -c demoapp -- /bin/sh

$ cat /proc/1/status |grep -i SigCgt
SigCgt:    0000000000010002

上面的十六进制转换成二进制是 1 0000 0000 0000 0010 ,可以看到 start.sh 注册了两个信号handler,bit 2和bit 17,也就是 SIGNIT(2) 和 SIGCHLD(17),但是没有注册 SIGTERM(15)。

进程对每种信号的处理,包括三个选择: 调用系统缺省行为、捕获、忽略。两个特权信号 SIGKILL 和 SIGSTOP不能被捕获和忽略。

缺省就是如果我们在代码中对某个信号,比如 SIGTERM 信号,不做任何 signal() 相关的系统调用,那么在进程运行的时候,如果接收到信号 SIGTERM,进程就会执行内核中 SIGTERM 信号的缺省代码。对于 SIGTERM 这个信号来说,它的缺省行为就是进程退出(terminate)。在Linux下可以通过 man 7 signal 查看每个信号的缺省行为。

捕获指的就是我们在代码中为某个信号,调用 signal() 注册自己的 handler。这样进程在运行的时候,一旦接收到信号,就不会再去执行内核中的缺省代码,而是会执行通过 signal() 注册的 handler。

忽略就是通过 signal() 这个系统调用,为这个信号注册一个特殊的 handler,也就是 SIG_IGN。在程序运行的时候,如果收到 SIGTERM 信号,什么反应也没有,就像完全没有收到这个信号一样。

注意的是: D state (uninterruptible) 进程还有 Zombie 进程都是不能接受任何信号的。

回答这两个疑问

容器的 init 进程start.sh收到的 SIGTERM 是 containerd 调用runc发送给它的,它收到后因为没有注册 SIGTERM 的handler,按道理是缺省行为(terminate)。

不使用容器,直接在宿主机执行 start.sh,进程起来后执行 kill ${pid} 命令发送SIGTERM给 start.sh 进程,start.sh进程收到后直接退出,demoapp变成孤儿进程被1号进程收养。

使用容器后,在容器外面(host namespace下)或者进入容器里面发送SIGTERM信号给容器的 init 进程都没有响应,是因为在linux内核代码中有对init进程的的保护逻辑,毕竟init进程随便就能杀死的话会让系统混乱和难以调试。

没有注册SIGTERM信号的handler所以没有响应,containerd 过了30秒(时间是由pod.spec.terminationGracePeriodSeconds 这个字段决定,默认是30),发送 SIGKILL 给 init 进程,init 进程退出是do_exit(),退出的时候同样给子进程 demoapp 发送了 SIGKILL 而不是 SIGTERM。哪怕 demoapp 的代码里注册了 SIGTERM 的handler,也没有机会使用。

疑问2的补充资料:

Linux 内核对处理进程退出的入口点就是 do_exit() 函数,do_exit() 函数中会释放进程的相关资源,比如内存,文件句柄,信号量等等。

在做完这些工作之后,它会调用一个 exit_notify() 函数,用来通知和这个进程相关的父子进程等。

对于容器来说,还要考虑 Pid Namespace 里的其他进程。这里调用的就是 zap_pid_ns_processes() 这个函数,而在这个函数中,如果是处于退出状态的 init 进程,它会向 Namespace 中的其他进程都发送一个 SIGKILL 信号。

Kubernetes容器:为什么我的进程收不到SIGTERM信号?

看完上面,怎么让demoapp进程收到SIGTERM,最简单的方案就是demoapp成为容器里的 init 进程。

方法一

修改start.sh

#!/bin/sh
echo "do something before start"
exec ./demoapp

和原来的 start.sh 相比,多了 exec 。exec是以新的进程去代替原来的进程,但进程的PID保持不变。可以这样认为,exec系统调用并没有创建新的进程,只是替换了原来进程上下文的内容。

按照上面的步骤重新打包成 demoapp:v2 镜像,并修改 pod.yaml 中的image,重新创建pod,进入容器查看,可以看到 1 号进程不再是 start.sh,而是demoapp。

$ kubectl exec -it demoapp -c demoapp -- ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 ./demoapp
   13 root      0:00 ps aux

删除pod的时候根据下面容器的日志确实看到了优雅退出。

$ kubectl logs -f demoapp -c demoapp 
do something before start
app running...
接收到信号[terminated]
优雅退出

因为 demeapp 进程能响应 SIGTERM 并快速退出,pod 在 Terminating 状态持续很短的时间就消失了,根本不用等待30s给pod里面的容器的init进程发送 SIGKILL。

见过有人在容器里面用shell脚本去启动supervisord,然后supervisord去管理应用进程,如果实在非要这么做,可以用exec命令让supervisord成为容器里的init进程,supervisord会转发信号给子进程,原因下面有说到。

方法二(推荐)

除了修改 start.sh 让 demoapp 变成容器里的 init 进程以外,如果方便的话把start.sh里面的准备工作放进代码里,去掉start.sh,直接启动demoapp,这样生来就是init进程了。

Dockerfile

FROM alpine
WORKDIR /app
COPY demoapp ./
CMD ["./demoapp"]

方案三

上面的两种方法是让 demoapp 成为容器的 init 进程从而收到 SIGTERM,不成为 init 进程能不能收到 SIGTERM 呢?其实是可以的,init 进程转发收到的信号给子进程。这里使用 tini 作为容器的 init 进程。tini 的代码中就会调用 sigtimedwait() 这个函数来查看自己收到的信号,然后调用 kill() 把信号发给子进程。

tini安装参考: https://github.com/krallin/tini

tinti的用法如下

# tini -h
tini (tini version 0.19.0)
Usage: tini [OPTIONS] PROGRAM -- [ARGS] | --version

Execute a program under the supervision of a valid init process (tini)

Command line options:

  --version: Show version and exit.
  -h: Show this help message and exit.
  -s: Register as a process subreaper (requires Linux >= 3.4).
  -p SIGNAL: Trigger SIGNAL when parent dies, e.g. "-p SIGKILL".
  -v: Generate more verbose output. Repeat up to 3 times.
  -w: Print a warning when processes are getting reaped.
  -g: Send signals to the child's process group.
  -e EXIT_CODE: Remap EXIT_CODE (from 0 to 255) to 0.
  -l: Show license and exit.

Environment variables:

  TINI_SUBREAPER: Register as a process subreaper (requires Linux >= 3.4).
  TINI_VERBOSITY: Set the verbosity level (default: 1).
  TINI_KILL_PROCESS_GROUP: Send signals to the child's process group.

start.sh

#!/bin/sh
echo "do something before start"
./demoapp

Dockerfile

FROM alpine
WORKDIR /app
RUN apk add --no-cache tini
COPY demoapp start.sh ./
RUN chmod +x ./start.sh
ENTRYPOINT ["tini", "--"]
CMD ["./start.sh"]

重新打包创建pod,容器里面是这样的

# kubectl exec -it demoapp -c demoapp -- ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 tini -- ./start.sh
    7 root      0:00 {start.sh} /bin/sh ./start.sh
    8 root      0:00 ./demoapp
   15 root      0:00 ps aux

# kubectl exec -it demoapp -c demoapp -- pstree
tini---start.sh---demoapp---6*[{demoapp}]

执行删除pod命令,用 strace 工具发现 start.sh 进程收到了 SIGTERM,demoapp 收到的却是 SIGKILL,到底哪里出了问题?

不难发现,原因就是 tini 把 SIGTERM 转发给它的子进程 start.sh,而 demoapp 是 start.sh 的子进程。tini 没把信号转发给 demoapp,start.sh 则是没能力把收到的 SIGTERM 转发给它的子进程 demoapp。那怎么办?一种做法就是在start.sh里面使用exec,demoapp 直接变成了 tini 的子进程。那不想改start.sh怎么办?还记得 tini 有一个 -g 的的参数吗?

-g: Send signals to the child's process group.

字面意思是发送信号到子进程所属的进程组,也就是发送信号到 start.sh 所属的进程组。

在宿主机查看 tini、start.sh、demoapp 这几个进程所属的进程组。start.sh 和 demoapp 同属一个进程组,进程组id正是 start.sh 进程的 pid。tini 属另一个进程组,进程组id是它本身的pid。

再次修改Dockerfile如下

FROM alpine
WORKDIR /app
RUN apk add --no-cache tini
COPY demoapp start.sh ./
RUN chmod +x ./start.sh
ENTRYPOINT ["tini", "-g", "--"]
CMD ["./start.sh"]

重新打包创建pod,容器里面的 tini 进程多了 -g 的参数

$ kubectl exec -it demoapp -c demoapp -- ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 tini -g -- ./start.sh
    6 root      0:00 {start.sh} /bin/sh ./start.sh
    7 root      0:00 ./demoapp
   20 root      0:00 ps aux

执行删除pod命令,这次同属一个进程组的 start.sh 和 demoapp 进程都收到了 SIGTERM。

总结

容器里的进程要想正确接收到 SIGTERM 信号,要么成为容器的1号进程,要么 init 进程可以对收到的信号做转发,发送到容器中的其他子进程或所属进程组。这样容器中的所有进程在停止时,都会收到 SIGTERM,而不是 SIGKILL 信号了。

参考

  • 极客时间《容器实战高手课》

相关文章

暂无评论

暂无评论...