容器里的 1 号进程:为什么 kill 不掉?僵尸进程又从哪来?

容器里的 1 号进程:为什么 kill 不掉?僵尸进程又从哪来?

实验环境:Ubuntu 24.04.4 LTS / 内核 6.8.0-106-generic / Cgroup v2(unified hierarchy)/ 华为云 FlexusX 实例 8 vCPU 16GB / Docker 29.1.3


引子:一个让很多人栽过跟头的问题

你一定在某个深夜遇到过这样的场景:

  • 进到容器里想"重启一下里面的服务",顺手kill -9 1,结果命令返回0,服务却还好端端地跑着,仿佛什么都没发生;
  • 执行docker stop myapp,终端卡了整整 10 秒才退出,日志里什么优雅退出的痕迹都没有;
  • 或者更隐蔽的:监控告警"容器里进程数异常",ps一看,一堆STAT列写着Z的僵尸进程,日积月累把pids打满,新的进程fork直接报Resource temporarily unavailable,连exec一个排障 shell 都进不去。

这三个现象,分别对应了容器进程模型的三个核心知识点:PID 1 的特殊性、信号在 PID namespace 里的语义、僵尸进程与 pids cgroup 限制。它们看起来零散,其实底层全部由 Linux 内核的几个机制串在一起。本文不堆概念,全部用真实命令和真实输出说话。


一、PID 1 为什么"杀不掉":先复现

先起一个最常见的 nginx 容器,然后进到容器内部,对 1 号进程发SIGKILL

# 宿主机上dockerrun-d--namenginx-init nginx:alpinesleep3# 在容器内部对 PID 1 执行 kill -9dockerexecnginx-initsh-c"kill -9 1; echo kill_exit=$?"

真实输出:

=== 容器内 kill -9 1 (从容器内部发送) === kill_exit=0 === 容器进程状态 === Up 3 seconds === /proc/1/status 信号位图 === SigQ: 3/60201 SigPnd: 0000000000000000 SigBlk: 0000000000000000 SigIgn: 0000000040001000 SigCgt: 0000000018016a07

注意两个反直觉的点:

  1. kill -9 1的返回值是0(成功),但容器依然是Up状态——nginx 根本没死。
  2. 内核并没有真的把信号"送达"给 nginx,而是在信号投递阶段就直接丢弃了。

那为什么我们平时docker kill nginx-init又能把它干掉?从**宿主机(也就是容器的祖先 PID namespace)**再发一次:

dockerkillnginx-initdockerps-a--filtername=nginx-init--format"{{.Status}}"

真实输出:

=== 从宿主机(祖先namespace) docker kill 杀 init === nginx-init docker kill exit=0 Exited (137) 1 second ago

Exited (137)就是128 + 9,说明 SIGKILL 这次真的生效了,容器干净退出。

同一个SIGKILL,容器内发不掉、宿主机发得掉——差别就在"信号的来源处在哪个 PID namespace"。这正是内核SIGNAL_UNKILLABLE机制在起作用,下面展开。


二、内核原理:SIGNAL_UNKILLABLE 与 sig_task_ignored

每个 PID namespace 都有自己的 1 号进程(child reaper)。内核在创建 namespace 的 init 时,会给它打上SIGNAL_UNKILLABLE标志(kernel/fork.c):

if(is_child_reaper(pid))p->signal->flags|=SIGNAL_UNKILLABLE;

信号投递路径(kernel/signal.c)里有一个关键判断sig_task_ignored(),它决定"这条信号对 init 要不要被忽略":

staticboolsig_task_ignored(structtask_struct*t,intsig,bool force){/* SIGKILL 且目标是某 PID namespace 的 init: 除非信号来自 init 自己的 namespace(force==true),否则忽略 */if(unlikely(sig==SIGKILL)&&unlikely(is_child_reaper(task_pid(t)))){if(force)returnfalse;returntrue;}/* 凡是 init 进程,来自"同一 namespace 内"的信号(!force)一律忽略 */if(unlikely(is_child_reaper(task_pid(t)))&&!force)returntrue;returnfalse;}

这里的forcefrom_ancestor_ns决定:当kill()的发送者和接收者不在同一个 PID namespace时(比如你在宿主机杀容器进程,宿主机是容器的祖先 namespace),from_ancestor_ns=1force=1,于是sig_task_ignored返回false,信号被正常投递——这就是为什么docker kill能成功。

而当你在容器里kill -9 1时,发送者(你的 shell)和接收者(nginx,PID 1)处于同一个 namespacefrom_ancestor_ns=0force=0sig_task_ignored直接返回true,信号在内核里被静默丢弃,kill()系统调用仍返回 0(因为"成功入队"的判定在更前面,而丢信号发生在投递阶段)。于是你看到的就是"命令成功、进程不死"。

用一张图概括:

PID namespace A(宿主机 / 祖先) ┌──────────────────────────────┐ │ dockerd / 你的 shell │ │ │ kill -9 1 │ │ │ from_ancestor_ns = 1 │ │ │ (force=1 → 真的杀) │ └──────┼───────────────────────┘ │ clone(CLONE_NEWPID) PID namespace B(容器) ┌──────┴──────────────────────┐ │ PID 1: nginx / 你的 app │◄── kill -9 1 (容器内) │ SIGNAL_UNKILLABLE │ from_ancestor_ns = 0 │ sig_task_ignored=true │ (force=0 → 丢弃!) │ → 进程不死 │ └──────────────────────────────┘

一个常见误区:/proc/1/status 的 SigIgn 里并没有 SIGKILL

很多人会去翻/proc/1/statusSigIgn位图,想找 SIGKILL 的影子。我们用之前抓到的真实位图,用脚本解码一下:

SigIgn: 0000000040001000 SigCgt: 0000000018016a07

解码结果(信号编号 → 名称):

SigIgn 0x40001000 -> ['13(SIGPIPE)', '31(SIGSYS)'] SigCgt 0x18016a07 -> ['1(SIGHUP)', '2(SIGINT)', '3(SIGQUIT)', '10(SIGUSR1)', '12(SIGUSR2)', '14(SIGALRM)', '15(SIGTERM)', '17(SIGCHLD)', '28(SIGWINCH)', '29(SIGIO)']

可以看到,SigIgn里只有SIGPIPESIGSYS——这是 nginx主动signal(SIGPIPE, SIG_IGN)设置的,并不包含 SIGKILL。换句话说,SIGKILL对 init 的"不可投递"是内核层面的SIGNAL_UNKILLABLE标志在兜底,跟用户态的SigIgn位图完全是两回事。SigCgt则清清楚楚地告诉我们 nginx 捕获了SIGHUP/SIGINT/SIGQUIT/SIGTERM/SIGCHLD等信号,所以它能优雅重载、优雅退出、回收 worker。


三、bash 当 PID 1 vs 应用直接当 PID 1:信号行为差异

把"杀不掉"和"停不下来"分清很重要。kill -9 1杀不掉,是因为 init 的SIGNAL_UNKILLABLE;而docker stop卡 10 秒,是另一个问题——1 号进程没有正确处理SIGTERM

docker stop的默认动作是:先发SIGTERM,等10 秒宽限期,超时再发SIGKILL。我们用一个sh当 1 号进程、且循环sleep的容器来感受:

dockerrun-d--namebash-init alpine:3.20sh-c"while true; do sleep 1; done"date+%T;timedockerstop bash-init

真实输出:

start: 00:14:40 bash-init real 0m10.190s user 0m0.003s sys 0m0.012s 停止后: Exited (137) Less than a second ago

整整10.19 秒,最后以137(被 SIGKILL 强杀)收场。原因:sh作为 1 号进程时,对SIGTERM是默认行为(terminate),但 busyboxsh在作为 PID 1 的非交互场景下,并不会像普通 shell 那样立刻退出,于是宽限期耗尽被强杀。

对比一个正确捕获了 SIGTERM 的应用(nginx):

dockerrun-d--namengx-g nginx:alpinedate+%T;timedockerstop ngx-g

真实输出:

start: 00:25:25 ngx-g real 0m0.177s

nginx注册了SIGTERM处理器,收到后立刻优雅停掉 worker、Exited (0)0.177 秒就退出了,根本走不到 10 秒宽限。

结论:作为 1 号进程,"能不能被 kill -9 杀掉"由内核SIGNAL_UNKILLABLE决定(都杀不掉);"停不停得优雅"由你有没有处理SIGTERM决定。把sleep/sh直接当入口,是线上最常见的"stop 卡 10 秒"元凶。


四、僵尸进程:从哪来,为什么可怕

上面都在聊"1 号进程本人"。但 1 号进程还有一项没人替它干的活:回收子进程。

当一个进程退出,它的父进程还没来得及wait(),它就会变成僵尸进程(Z 状态):进程已经死了,但内核里它的task_struct还不能释放——因为要保留退出码、CPU 时间等,等父进程来读取。僵尸进程不占 CPU、不占内存,但占着进程号(PID)。一旦数量失控,整个 PID namespace 的 PID 被耗尽,新进程fork()直接失败。

复现:一个"只生不养"的父进程

下面这个 C 程序(编译成静态二进制后作为容器的 PID 1 运行)fork 出 8 个子进程后立刻退出,但父进程从不wait()

intmain(intargc,char*argv[]){intn=(argc>1)?atoi(argv[1]):6;for(inti=0;i<n;i++){pid_tpid=fork();if(pid==0){printf(" 子进程 #%d PID=%d 退出\n",i,getpid());_exit(0);}}while(1)sleep(5);/* 父进程死循环,永不 wait */}

以它为 PID 1 起容器,docker execps

dockerrun-d--namezombie-c-v/root/zombie:/zombie alpine:3.20 /zombie8dockerexeczombie-cps-eopid,ppid,stat,comm

真实输出:

PID PPID STAT COMMAND 1 0 S zombie 7 1 Z zombie 8 1 Z zombie 9 1 Z zombie 10 1 Z zombie 11 1 Z zombie 12 1 Z zombie 13 1 Z zombie 14 1 Z zombie 15 0 R ps

8 个Z(zombie)状态进程,全部PPID=1。这些task_struct会一直挂在那里,直到父进程退出——而父进程是死循环,所以它们会永远存在。

更危险的:pids cgroup 被僵尸打满

PID 不是无限的。容器受pidscgroup 约束,宿主机默认上限约 1.8 万,但你可以(也应该)用--pids-limit收口。我们故意把上限设成 64,再让程序试图 fork 200 个:

dockerrun --pids-limit64-d--namepids-test-v/root/zombie:/zombie alpine:3.20 /zombie200dockerlogs--tail4pids-test

真实输出:

父进程进入死循环,子进程将长期保持 Z 状态。 fork: Resource temporarily unavailable fork: Resource temporarily unavailable fork: Resource temporarily unavailable

fork: Resource temporarily unavailable正是 pids cgroup 触顶后内核返回的EAGAIN。此时连docker exec进容器都进不去——因为 exec 也要 fork 一个新进程:

dockerexecpids-testsh-c"echo hi"# sh: can't fork: Resource temporarily unavailable

从宿主机直接读这个容器的 pids cgroup,能看到当前值已经顶到上限:

ID=$(dockerinspect-f'{{.Id}}'pids-test)cat/sys/fs/cgroup/system.slice/docker-${ID}.scope/pids.currentcat/sys/fs/cgroup/system.slice/docker-${ID}.scope/pids.max
pids.current=64 pids.max=64

pids.current等于pids.max,容器彻底失去 fork 能力。这就是"僵尸进程耗尽 PID"的真实后果——容器没 OOM、没 CPU 爆,却直接假死


五、排查思路:现场怎么看

日常排障,记住三步:

  1. 看状态docker exec <c> ps -eo pid,ppid,stat,commSTAT列出现Z就是僵尸;PPID告诉你它的"爹"是谁,僵尸的爹通常就是那个不回收的 1 号进程或某个常驻父进程。
  2. 看数量cat /sys/fs/cgroup/system.slice/docker-<id>.scope/pids.currentpids.max对比,逼近上限就是风险信号。
  3. 看来源:僵尸的根因是"父进程不wait"。PPID指向的父进程,就是需要修的代码或需要加的 init。

小提示:D状态(不可中断睡眠)进程不是僵尸,它占着 CPU 调度位、会让 load average 升高,详见本系列第三篇。


六、解决方案:三板斧

方案 1:应用自己回收(治本)

在父进程里安装SIGCHLD处理器,用waitpid(-1, &st, WNOHANG)循环回收所有退出的子进程。改完的版本:

staticvoidreap_children(intsig){(void)sig;intstatus;pid_tpid;while((pid=waitpid(-1,&status,WNOHANG))>0){/* 回收 */}}intmain(...){structsigactionsa;sa.sa_handler=reap_children;sigemptyset(&sa.sa_mask);sa.sa_flags=SA_RESTART|SA_NOCLDSTOP;sigaction(SIGCHLD,&sa,NULL);...fork 子进程...while(1)sleep(5);}

同样的/zf 8,起容器后ps

dockerrun-d--nameoz-fixed-v/root/zombie_fixed:/zf alpine:3.20 /zf8dockerexecoz-fixedps-eopid,ppid,stat,comm
PID PPID STAT COMMAND 1 0 S zf 15 0 R ps

零僵尸。这是最根本、最推荐的做法——僵尸本来就应该是应用自己的责任。

方案 2:tini /--init(兜底孤儿)

很多业务代码你改不动(第三方镜像、历史脚本),这时docker run --init会给容器注入tini作为 1 号进程。tini做了两件事:

  • SIGTERM/SIGINT转发给真正的业务子进程,并等待它退出(顺便解决第三节的 10 秒卡顿);
  • 调用prctl(PR_SET_CHILD_SUBREAPER, 1),成为"次级回收者"——任何父进程死掉后留下的孤儿进程,都会被 reparent 到 tini 并被它回收

用一个"中间进程死亡、留下孤儿子进程"的程序验证(oz.c:父进程 fork 出 manager,manager fork 出 8 个 worker 后退出,worker 立即退出变僵尸):

不加--init(app 自己当 PID 1,不回收):

PID PPID STAT COMMAND 1 0 S oz 7 1 Z oz 8 1 Z oz 9 1 Z oz 10 1 Z oz 11 1 Z oz 12 1 Z oz 13 1 Z oz 14 1 Z oz 15 1 Z oz

连 manager 带 8 个 worker,共9 个僵尸

--inittini当 PID 1):

PID PPID STAT COMMAND 1 0 S docker-init 7 1 S oz 8 7 Z oz

注意:8 个 worker 被 tini回收干净了(不再有 Z),但 manager(PID 8PPID 7依然是僵尸。为什么?因为 manager 是ozPID 7)的直接子进程,而oz还活着、又没wait它——tini 只能回收"孤儿"(父进程已死的),回收不了别人家还活着的父进程手里的直接子进程

这恰好点出--init的能力边界:它解决"父进程意外死亡留下的孤儿僵尸",但解决不了"父进程还活着却懒得 wait"的僵尸。所以 tini 是兜底网,不是免死金牌;最关键的 manager 这类直接子进程,仍要业务自己wait

方案 3:优雅退出 + 合理 pids 限制

  • 入口进程务必处理SIGTERMdocker stop的 10 秒宽限不是给你浪费的);
  • 给每个容器设--pids-limit,把爆炸半径圈住,避免一个容器的 PID 泄漏拖垮整台宿主机;
  • tini兜底孤儿,但别指望它替你养孩子。

七、小结与思考题

本文用五个真实实验串起了容器进程模型的核心:

  1. 容器内kill -9 1杀不掉,是因为内核给 namespace 的 init 打了SIGNAL_UNKILLABLE,来自同一 namespace 的信号在sig_task_ignored()里被静默丢弃;来自祖先 namespace(宿主机docker kill)的force=1信号照杀不误。
  2. /proc/1/statusSigIgn位图不含 SIGKILL,"不可杀"是内核标志的活,不是用户态信号掩码。
  3. docker stop卡 10 秒,是因为 1 号进程没处理SIGTERM;正确捕获的应用 0.18 秒就优雅退出了。
  4. 僵尸进程来自"父进程不wait",占 PID 不占资源,但能靠 pids cgroup 把容器 fork 能力打满,连 exec 都进不去。
  5. 治本靠应用内waitpid;兜底靠tini/--init(只收孤儿);再配--pids-limit限制爆炸半径。

思考题(欢迎在评论区讨论):

  1. 既然SIGKILL在容器内杀不掉 1 号进程,那SIGSTOP(暂停)呢?它和 SIGKILL 在sig_task_ignored()里的待遇有何异同?
  2. 如果容器里的 1 号进程就是一个普通应用(非 shell),它没有注册SIGCHLD处理器,会像 shell 一样留下僵尸吗?为什么?
  3. PR_SET_CHILD_SUBREAPER和"传统 init 回收所有孤儿"在嵌套 PID namespace(如 Docker in Docker)下会怎样层层 reparent?
  4. Kubernetes 里shareProcessNamespace: true时,PID 1 是谁?僵尸会 reparent 到它吗?