背景
随着云原生技术的风行,越来越多的利用抉择容器化,容器化的话题天然离不开 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 mainimport ( "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/shecho "do something before start"./demoapp
Dockerfile
FROM alpineWORKDIR /appCOPY demoapp start.sh ./RUN chmod +x ./start.shCMD ["./start.sh"]
pod.yaml
apiVersion: v1kind: Podmetadata: name: demoappspec: 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 auxPID 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 -- pstreestart.sh---demoapp---6*[{demoapp}]
从下面能够看到start.sh是容器里的init过程(1号过程),dempapp是它的子过程。查看容器的实时日志
$ kubectl logs -f demoapp -c demoapp do something before startapp 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.shroot 72433 72412 0 20:36 ? 00:00:00 /bin/sh ./start.shchen 74415 43973 0 20:40 pts/4 00:00:00 grep --color=auto start.sh$ ps -ef | grep demoapproot 72463 72433 0 20:36 ? 00:00:00 ./demoappchen 74492 43973 0 20:40 pts/4 00:00:00 grep --color=auto demoapp$ strace -p 72433strace: Process 72433 attachedwait4(-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 72463strace: Process 72463 attachedfutex(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 SigCgtSigCgt: 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/shecho "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 auxPID USER TIME COMMAND 1 root 0:00 ./demoapp 13 root 0:00 ps aux
删除pod的时候依据上面容器的日志的确看到了优雅退出。
$ kubectl logs -f demoapp -c demoapp do something before startapp 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 alpineWORKDIR /appCOPY demoapp ./CMD ["./demoapp"]
计划三
下面的两种办法是让 demoapp 成为容器的 init 过程从而收到 SIGTERM,不成为 init 过程能不能收到 SIGTERM 呢?其实是能够的,init 过程转发收到的信号给子过程。这里应用 tini 作为容器的 init 过程。tini 的代码中就会调用 sigtimedwait() 这个函数来查看本人收到的信号,而后调用 kill() 把信号发给子过程。
tini装置参考: https://github.com/krallin/tini
tinti的用法如下
# tini -htini (tini version 0.19.0)Usage: tini [OPTIONS] PROGRAM -- [ARGS] | --versionExecute 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/shecho "do something before start"./demoapp
Dockerfile
FROM alpineWORKDIR /appRUN apk add --no-cache tiniCOPY demoapp start.sh ./RUN chmod +x ./start.shENTRYPOINT ["tini", "--"]CMD ["./start.sh"]
从新打包创立pod,容器外面是这样的
# kubectl exec -it demoapp -c demoapp -- ps auxPID 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 -- pstreetini---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 alpineWORKDIR /appRUN apk add --no-cache tiniCOPY demoapp start.sh ./RUN chmod +x ./start.shENTRYPOINT ["tini", "-g", "--"]CMD ["./start.sh"]
从新打包创立pod,容器外面的 tini 过程多了 -g 的参数
$ kubectl exec -it demoapp -c demoapp -- ps auxPID 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 信号了。
参考
- 极客工夫《容器实战高手课》