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

6次阅读

共计 7292 个字符,预计需要花费 19 分钟才能阅读完成。

背景

随着云原生技术的风行,越来越多的利用抉择容器化,容器化的话题天然离不开 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 信号。

看完下面,怎么让 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 信号, 要么成为容器的 init 过程,要么 init 过程能够对收到的信号做转发,发送到容器中的其余子过程或所属过程组。这样容器中的所有过程在进行时,都会收到 SIGTERM,而不是 SIGKILL 信号了。

参考

  • 极客工夫《容器实战高手课》
正文完
 0