驳斥《把数据库放入Docker是一个好主意吗?》埋了一个坑,就是需要讲一讲用了容器技术和不用容器技术,运行一组进程到底有什么区别。

Linux 内核

那就让我回顾一下 Linux 内核的各个子系统吧,我们先从最右边的进程管理子系统说起。今天的话题就是容器中的0 号和 1 号进程。

Linux 0/进程

大家都知道对 Linux 系统来说 1 号进程为 init 进程,是由 0 号进程(内核进程)通过调用系统 init 函数创建的第一个用户进程 1 进程,主要做用户态进程的管理,垃圾回收等动作。

0 号进程

0 号进程,通常也被称为 idle 进程,或者也称为 swapper 进程。0 号进程是 Linux 启动的第一个进程,它的 task_struct 的 comm 字段为 “swapper”,所以也称为 swpper 进程。当系统中所有的进程起来后,0 号进程也就蜕化为 idle 进程,当一个 core 上没有任务可运行时就会去运行 idle 进程。

1 号进程

我们通常将 init 称为 1 号进程,Systemd 是目前使用最广泛的 init 进程,它会作为 1 号进程出现在操作系统中。

2 号进程

2号进程,是由 0 号进程创建的,它是所有内核线程父进程。

问题:那容器中是否存在 0 号进程和 1 号进程呢?它们有什么用?

Docker 的进程管理

Docker 进程管理的基础是 Linux 内核中的 PID 命名空间技术:

  • 在不同 PID 名空间中,进程 ID 是独立的

  • 即在两个不同名空间下的进程可以有相同的 PID。

在 Docker 中,每个 Container 都是 Docker Daemon 的子进程,每个 Container 进程缺省都具有不同的 PID 名空间。通过命名空间技术,Docker 实现容器间的进程隔离。
当创建一个 Docker 容器的时候,就会新建一个PID名空间。容器启动进程在该名空间内 PID 为 1。当 PID 1 进程结束之后,Docker 会销毁对应的 PID 名空间,并向容器内所有其它的子进程发送 SIGKILL。

由于 1 号进程的特殊性,Linux 内核为它做了特殊处理(无法在容器中使用 kill -9 杀死 1 号进程,主机上是可以的)。如果它没有提供某个信号的处理逻辑,那么与其在同一个 PID 名空间下的进程发送给它的该信号都会被屏蔽。这个功能的主要作用是防止 init 进程被误杀。

Docker 提供了两个命令 docker stop 和 docker kill 来向容器中的PID1进程发送信号:

  • docker stop:docker 会首先向容器的 PID 1 进程发送一个 SIGTERM 信号,用于容器内程序的退出。如果容器在收到 SIGTERM 后没有结束, 那么 Docker Daemon 会在等待一段时间(默认是10s)后,再向容器发送 SIGKILL 信号,将容器杀死变为退出状态。也就是说如果我在 1 号进程实现了 SIGTERM(15) 信号处理,就实现了优雅停止。

  • docker kill:可以向容器内 PID 1 进程发送任何信号,缺省是发送 SIGKILL 信号来强制退出应用(这里在宿主机上对 1 号进程下发的 kill)。

总结:

进程号 进程来源 作用
0 Docker daemon 进程 负责容器中进程的管理
1 可以被 Dockerfile 中的 ENTRYPOINT 或 CMD 指令所指明;也可以被 docker run 命令的启动参数所覆盖 负责启动业务进程

准备工作

我们准备做一下验证工作

Dockerfile

from CentOS

WORKDIR /home/app

ADD source-test.sh .
ADD test.sh .
ADD prometheus-test-demo-0.0.1-SNAPSHOT.jar .

EXPOSE 19991 ENTRYPOINT ["/home/app/source-test.sh"]

相关脚本

$ cat source-test.sh

#!/bin/bash

echo "开始执行"

path=$(pwd)

echo "$path"

su - yarn -c /bin/bash -c "$path/test.sh"

echo "执行结束"
#----------------------------------------------
$ cat test.sh

#!/bin/bash

echo "Start to run java app!"

# 切换用户需要切换目录
cd /home/app

path=$(pwd)

echo "$path"

java -jar -Dserver.port=19991 "$path/prometheus-test-demo-0.0.1-SNAPSHOT.jar"

echo "End!"

查看进程

容器中查看进程

容器化启动一个进程,然后通过 docker exec 进入到容器中。

ps -elf
F S UID PID PPID C PRI NI ADDR SZ WCHAN STIME TTY TIME CMD
4 S root 1 0 0 80 0 - 3708 do_wai 16:39 ? 00:00:00 /bin/bash /home/app/source-test.sh
4 S root 8 1 0 80 0 - 26191 do_wai 16:39 ? 00:00:00 su - yarn -c /bin/bash -c /home/app/test.sh
4 S yarn 9 8 0 80 0 - 3177 do_wai 16:39 ? 00:00:00 /bin/bash /home/app/test.sh
4 S yarn 29 9 99 80 0 - 9806596 futex_ 16:39 ? 00:00:49 java -jar -Dserver.port=19991 /home/app/prometheus-test-demo-0.0.1-SNAPSHOT.jar
4 S root 109 0 1 80 0 - 3741 do_wai 16:40 pts/0 00:00:00 /bin/bash
4 R root 125 109 0 80 0 - 11896 - 16:40 pts/0 00:00:00 ps -elf

以下为进程的父子关系(由于镜像中没有安装 pstree,所以就手工描述一下):

0 → 1 → 8 → 9 → 29
→ 109 → 125

由此我们可以看出:

  • 存在 0 号进程,它是容器中所有进程的祖先

  • 存在 1 号进程,它的父进程是 0 号进程

主机上查看进程

因为 Java 进程监听了一个 19991 端口,所以就比较容易找到它的进程号,从而可以通过 pstree 查看进程关系

ps -ef | grep 19991
ituser 1797120 1797100 14 10:13 ? 00:00:51 java -jar -Dserver.port=19991 /home/app/prometheus-test-demo-0.0.1-SNAPSHOT.jar
root 1825278 1794967 0 10:19 pts/0 00:00:00 grep --color=auto 19991

pstree -psla 1797120
systemd,1 --system --deserialize 26
└─containerd-shim,1797064 -namespace moby -id b215bb19322776ce3498c278ce992fdf3d65a086a4ffcbfd0df03a7f8475fd81 -address /run/containerd/containerd.sock
└─source-test.sh,1797086 /home/app/source-test.sh
└─su,1797099 - yarn -c /bin/bash -c /home/app/test.sh
└─test.sh,1797100 /home/app/test.sh
└─java,1797120 -jar -Dserver.port=19991 /home/app/prometheus-test-demo-0.0.1-SNAPSHOT.jar

pstree -psla 1797064
systemd,1 --system --deserialize 26
└─containerd-shim,1797064 -namespace moby -id b215bb19322776ce3498c278ce992fdf3d65a086a4ffcbfd0df03a7f8475fd81 -address /run/containerd/containerd.sock
├─bash,1804934
├─source-test.sh,1797086 /home/app/source-test.sh
│ └─su,1797099 - yarn -c /bin/bash -c /home/app/test.sh
│ └─test.sh,1797100 /home/app/test.sh
│ └─java,1797120 -jar -Dserver.port=19991 /home/app/prometheus-test-demo-0.0.1-SNAPSHOT.jar
│ ├─{java},1797121
│ ├─{java},1797122
│ ....
├─{containerd-shim},1797065
├─{containerd-shim},1797066
├─{containerd-shim},1797067
├─{containerd-shim},1797068
├─{containerd-shim},1797069
├─{containerd-shim},1797070
├─{containerd-shim},1797071
├─{containerd-shim},1797072
├─{containerd-shim},1797073
└─{containerd-shim},1797074

docker,1804892 exec -it b215bb19322776c /bin/bash
systemd,1 --system --deserialize 26
└─sshd,1416 -D
└─sshd,1804089
└─bash,1804201
└─docker,1804892 exec -it b215bb19322776c /bin/bash

我们可以看出,容器中的 0 号进程实际上就是 containerd-shim,它负责容器进程的管理。

containerd-shim,1797064 -namespace moby -id b215bb19322776ce3498c278ce992fdf3d65a086a4ffcbfd0df03a7f8475fd81 -address /run/containerd/containerd.sock

另外我们可以看到,通过 docker exec 启动的 bash 进程,它并不是 containerd-shim 的子进程,而是通过某种方式 attach 到了容器进程上(另外讲解)。

Kill 操作

容器内

在容器中执行 kill -9 8 或者 kill -9 9 都会让容器退出。

ps -elf
F S UID PID PPID C PRI NI ADDR SZ WCHAN STIME TTY TIME CMD
4 S root 1 0 0 80 0 - 3708 do_wai 16:39 ? 00:00:00 /bin/bash /home/app/source-test.sh
4 S root 8 1 0 80 0 - 26191 do_wai 16:39 ? 00:00:00 su - yarn -c /bin/bash -c /home/app/test.sh
4 S yarn 9 8 0 80 0 - 3177 do_wai 16:39 ? 00:00:00 /bin/bash /home/app/test.sh
4 S yarn 29 9 99 80 0 - 9806596 futex_ 16:39 ? 00:00:49 java -jar -Dserver.port=19991 /home/app/prometheus-test-demo-0.0.1-SNAPSHOT.jar
4 S root 109 0 1 80 0 - 3741 do_wai 16:40 pts/0 00:00:00 /bin/bash
4 R root 125 109 0 80 0 - 11896 - 16:40 pts/0 00:00:00 ps -elf

kill -9 8 #直接退出了

[root@1767079587ea app]# [rootps -a | grep Exited | grep "/home/app/source"
1767079587ea cmdexec:v1 "/home/app/source-te…" About a minute ago Exited (0) 16 seconds ago wonderful_cerf


#再来一遍

docker exec -it f1d62a60c24 /bin/bash
[root@f1d62a60c242 app]# ps -elf
F S UID PID PPID C PRI NI ADDR SZ WCHAN STIME TTY TIME CMD
4 S root 1 0 0 80 0 - 3708 do_wai 16:43 ? 00:00:00 /bin/bash /home/app/source-test.sh
4 S root 8 1 0 80 0 - 26191 do_wai 16:43 ? 00:00:00 su - yarn -c /bin/bash -c /home/app/test.sh
4 S yarn 9 8 0 80 0 - 3177 do_wai 16:43 ? 00:00:00 /bin/bash /home/app/test.sh
4 S yarn 29 9 99 80 0 - 9806596 futex_ 16:43 ? 00:00:48 java -jar -Dserver.port=19991 /home/app/prometheus-test-demo-0.0.1-SNAPSHOT.jar
4 S root 109 0 1 80 0 - 3741 do_wai 16:44 pts/0 00:00:00 /bin/bash
4 R root 125 109 0 80 0 - 11896 - 16:44 pts/0 00:00:00 ps -elf

kill -9 9 #直接退出了

[root@f1d62a60c242 app]docker ps -a | grep Exited | grep "/home/app/source"
f1d62a60c242 cmdexec:v1 "/home/app/source-te…" About a minute ago Exited (0) 6 seconds ago exciting_heisenberg

#在主机上杀也一样效果

看起来 containerd-shim 进程处理的逻辑就是:

  • 1 号进程主要就是用于启动业务进程(当然自己就是业务进程就最好)

  • 1 号进程结束(正常结束或者被杀),整个容器就会退出,这个符合容器不可变特性(此处需要研究一下 contained shim 进程管理的逻辑)

    • 子进程被杀,父进程正常结束,最终 1 号进程也正常结束了

    • containerd-shim 并不会关注还有其他子进程是否还在运行,只要1号进程结束,整个容器就被清理

  • 即使容器中有僵尸进程,容器退出的时候,都会做清理(僵尸进程的 PPID 是 1 号进程,最终会由 containerd-shim 进行清理

容器内进程管理的行为与主机上进程管理的因为略有不同,我们在主机上来验证一下。

主机上

非容器化运行,如果杀死父进程时,子进程没有接收到相关信号(代码中没有指定),则子进程被 1 号进程接管。

systemd,1 --system --deserialize 26
└─sshd,1416 -D
└─sshd,753789
└─bash,754008
└─source-test.sh,943606 ./source-test.sh
└─su,943608 - mysql -c /bin/bash -c /home/grissom/test.sh
└─test.sh,943609 /home/grissom/test.sh
└─java,943631 -jar -Dserver.port=19991 /home/grissom/prometheus-test-demo-0.0.1-SNAPSHOT.jar

kill -15 943608 #除了 Java 进程以外,其他 shell 进程都被杀死,Java 进程被 systemd 接管

systemd,1 --system --deserialize 26
└─java,943631 -jar -Dserver.port=19991 /home/grissom/prometheus-test-demo-0.0.1-SNAPSHOT.jar


Session terminated, killing shell... ...已杀死。
执行结束

#bash 进程情况(shell 没有退出)

pstree -p 754008 -sla
systemd,1 --system --deserialize 26
└─sshd,1416 -D
└─sshd,753789
└─bash,754008

#再来一次

systemd,1 --system --deserialize 26
└─sshd,1416 -D
└─sshd,753789
└─bash,754008
└─source-test.sh,966904 ./source-test.sh
└─su,966906 - mysql -c /bin/bash -c /home/grissom/test.sh
└─test.sh,966907 /home/grissom/test.sh
└─java,966935 -jar -Dserver.port=19991 /home/grissom/prometheus-test-demo-0.0.1-SNAPSHOT.jar


kill -9 966904 #看起来 kill -9 使得进程没有时间做清理

systemd,1 --system --deserialize 26
└─su,966906 - mysql -c /bin/bash -c /home/grissom/test.sh
└─test.sh,966907 /home/grissom/test.sh
└─java,966935 -jar -Dserver.port=19991 /home/grissom/prometheus-test-demo-0.0.1-SNAPSHOT.jar

写的脚本均没有用 trap 来对信号做处理,我们可以观测到 kill -15 和 kill -9 的差异如下:

  • kill -15 后,除了 Java 进程以外,包括它的父进程,其他 shell 进程都被终止了,Java 进程被 systemd 接管

    • 父进程 - source-test.sh 正常结束(因为它等待的子进程结束了)

    • su 进程被优雅终止了,它执行的 shell 进程 - test.sh 也被终止了(应该是收到了信号)

    • 但是 sigterm 信号并没有发送给到 Java 进程

  • kill -9 后,它没有将信号发送给到子进程,从而该进程的子进程都会被 systemd 接管。

总结

  • 容器内 pid 1 与宿主机 pid 1 有所不同

  • Docker 1.11 版本之前孤儿进程是由容器内 pid 为 1 的进程接收,而 1.11 版本后是由 docker-containerd-shim 进程接收,可以减少因 1 号进程没有子进程处理导致的僵尸进程(只是接收孤儿进程,而不是处理僵尸进程),这里有个前提就是该进程不是由 1 号进程创建的。

image-20240824202312717

这里的 PID 为 7 进程的 PPID 是 0,不是 1。因此杀死 parnt 之后,孤儿进程会被 0 号进程接收。

image-20240824202341194

  • 容器 pid 1 进程的启动也有两种不同,鼓励使用 exec 避免造成了不符合预期的现象(另外讨论)。

  • 容器 exec 产生的进程的父进程是 0 号进程。

  • 容器 pid 1 进程得实现下信号处理,不然无法在同 PID 名空间内向其发送信号退出。

  • 容器 pid 1 进程需要实现下子进程清理,避免出现僵尸进程(因此强烈不建议在容器中动态创建进程!!!)。

参考