背景
随着云原生技术的流行,越来越多的应用选择容器化,容器化的话题自然离不开 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 信号,要么成为容器的1号进程,要么 init 进程可以对收到的信号做转发,发送到容器中的其他子进程或所属进程组。这样容器中的所有进程在停止时,都会收到 SIGTERM,而不是 SIGKILL 信号了。
参考
- 极客时间《容器实战高手课》