背景
随着云原生技术的风行,越来越多的利用抉择容器化,容器化的话题天然离不开 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 信号了。
参考
- 极客工夫《容器实战高手课》