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

发布时间:2026/7/25 3:52:43
容器里的 1 号进程:为什么 kill 不掉?僵尸进程又从哪来? 容器里的 1 号进程为什么 kill 不掉僵尸进程又从哪来实验环境Ubuntu 24.04.4 LTS / 内核 6.8.0-106-generic / Cgroup v2unified 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-ckill -9 1; echo kill_exit$?真实输出 容器内 kill -9 1 (从容器内部发送) kill_exit0 容器进程状态 Up 3 seconds /proc/1/status 信号位图 SigQ: 3/60201 SigPnd: 0000000000000000 SigBlk: 0000000000000000 SigIgn: 0000000040001000 SigCgt: 0000000018016a07注意两个反直觉的点kill -9 1的返回值是0成功但容器依然是Up状态——nginx 根本没死。内核并没有真的把信号送达给 nginx而是在信号投递阶段就直接丢弃了。那为什么我们平时docker kill nginx-init又能把它干掉从**宿主机也就是容器的祖先 PID namespace**再发一次dockerkillnginx-initdockerps-a--filternamenginx-init--format{{.Status}}真实输出 从宿主机(祖先namespace) docker kill 杀 init nginx-init docker kill exit0 Exited (137) 1 second agoExited (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.cif(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 自己的 namespaceforcetrue否则忽略 */if(unlikely(sigSIGKILL)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;}这里的force由from_ancestor_ns决定当kill()的发送者和接收者不在同一个 PID namespace时比如你在宿主机杀容器进程宿主机是容器的祖先 namespacefrom_ancestor_ns1、force1于是sig_task_ignored返回false信号被正常投递——这就是为什么docker kill能成功。而当你在容器里kill -9 1时发送者你的 shell和接收者nginxPID 1处于同一个 namespacefrom_ancestor_ns0、force0sig_task_ignored直接返回true信号在内核里被静默丢弃kill()系统调用仍返回 0因为成功入队的判定在更前面而丢信号发生在投递阶段。于是你看到的就是命令成功、进程不死。用一张图概括PID namespace A宿主机 / 祖先 ┌──────────────────────────────┐ │ dockerd / 你的 shell │ │ │ kill -9 1 │ │ │ from_ancestor_ns 1 │ │ │ (force1 → 真的杀) │ └──────┼───────────────────────┘ │ clone(CLONE_NEWPID) PID namespace B容器 ┌──────┴──────────────────────┐ │ PID 1: nginx / 你的 app │◄── kill -9 1 (容器内) │ SIGNAL_UNKILLABLE │ from_ancestor_ns 0 │ sig_task_ignoredtrue │ (force0 → 丢弃!) │ → 进程不死 │ └──────────────────────────────┘一个常见误区/proc/1/status 的 SigIgn 里并没有 SIGKILL很多人会去翻/proc/1/status的SigIgn位图想找 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里只有SIGPIPE和SIGSYS——这是 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-cwhile true; do sleep 1; donedate%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 的应用nginxdockerrun-d--namengx-g nginx:alpinedate%T;timedockerstop ngx-g真实输出start: 00:25:25 ngx-g real 0m0.177snginx注册了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(argc1)?atoi(argv[1]):6;for(inti0;in;i){pid_tpidfork();if(pid0){printf( 子进程 #%d PID%d 退出\n,i,getpid());_exit(0);}}while(1)sleep(5);/* 父进程死循环永不 wait */}以它为 PID 1 起容器docker exec里psdockerrun-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 ps8 个Zzombie状态进程全部PPID1。这些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 unavailablefork: Resource temporarily unavailable正是 pids cgroup 触顶后内核返回的EAGAIN。此时连docker exec进容器都进不去——因为 exec 也要 fork 一个新进程dockerexecpids-testsh-cecho hi# sh: cant 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.maxpids.current64 pids.max64pids.current等于pids.max容器彻底失去 fork 能力。这就是僵尸进程耗尽 PID的真实后果——容器没 OOM、没 CPU 爆却直接假死。五、排查思路现场怎么看日常排障记住三步看状态docker exec c ps -eo pid,ppid,stat,commSTAT列出现Z就是僵尸PPID告诉你它的爹是谁僵尸的爹通常就是那个不回收的 1 号进程或某个常驻父进程。看数量cat /sys/fs/cgroup/system.slice/docker-id.scope/pids.current与pids.max对比逼近上限就是风险信号。看来源僵尸的根因是父进程不wait。PPID指向的父进程就是需要修的代码或需要加的 init。小提示D状态不可中断睡眠进程不是僵尸它占着 CPU 调度位、会让 load average 升高详见本系列第三篇。六、解决方案三板斧方案 1应用自己回收治本在父进程里安装SIGCHLD处理器用waitpid(-1, st, WNOHANG)循环回收所有退出的子进程。改完的版本staticvoidreap_children(intsig){(void)sig;intstatus;pid_tpid;while((pidwaitpid(-1,status,WNOHANG))0){/* 回收 */}}intmain(...){structsigactionsa;sa.sa_handlerreap_children;sigemptyset(sa.sa_mask);sa.sa_flagsSA_RESTART|SA_NOCLDSTOP;sigaction(SIGCHLD,sa,NULL);...fork 子进程...while(1)sleep(5);}同样的/zf 8起容器后psdockerrun-d--nameoz-fixed-v/root/zombie_fixed:/zf alpine:3.20 /zf8dockerexecoz-fixedps-eopid,ppid,stat,commPID PPID STAT COMMAND 1 0 S zf 15 0 R ps零僵尸。这是最根本、最推荐的做法——僵尸本来就应该是应用自己的责任。方案 2tini /--init兜底孤儿很多业务代码你改不动第三方镜像、历史脚本这时docker run --init会给容器注入tini作为 1 号进程。tini做了两件事把SIGTERM/SIGINT转发给真正的业务子进程并等待它退出顺便解决第三节的 10 秒卡顿调用prctl(PR_SET_CHILD_SUBREAPER, 1)成为次级回收者——任何父进程死掉后留下的孤儿进程都会被 reparent 到 tini 并被它回收。用一个中间进程死亡、留下孤儿子进程的程序验证oz.c父进程 fork 出 managermanager fork 出 8 个 worker 后退出worker 立即退出变僵尸不加--initapp 自己当 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 1PID PPID STAT COMMAND 1 0 S docker-init 7 1 S oz 8 7 Z oz注意8 个 worker 被 tini回收干净了不再有 Z但 managerPID 8PPID 7依然是僵尸。为什么因为 manager 是ozPID 7的直接子进程而oz还活着、又没wait它——tini 只能回收孤儿父进程已死的回收不了别人家还活着的父进程手里的直接子进程。这恰好点出--init的能力边界它解决父进程意外死亡留下的孤儿僵尸但解决不了父进程还活着却懒得 wait的僵尸。所以 tini 是兜底网不是免死金牌最关键的 manager 这类直接子进程仍要业务自己wait。方案 3优雅退出 合理 pids 限制入口进程务必处理SIGTERMdocker stop的 10 秒宽限不是给你浪费的给每个容器设--pids-limit把爆炸半径圈住避免一个容器的 PID 泄漏拖垮整台宿主机用tini兜底孤儿但别指望它替你养孩子。七、小结与思考题本文用五个真实实验串起了容器进程模型的核心容器内kill -9 1杀不掉是因为内核给 namespace 的 init 打了SIGNAL_UNKILLABLE来自同一 namespace 的信号在sig_task_ignored()里被静默丢弃来自祖先 namespace宿主机docker kill的force1信号照杀不误。/proc/1/status的SigIgn位图不含 SIGKILL不可杀是内核标志的活不是用户态信号掩码。docker stop卡 10 秒是因为 1 号进程没处理SIGTERM正确捕获的应用 0.18 秒就优雅退出了。僵尸进程来自父进程不wait占 PID 不占资源但能靠 pids cgroup 把容器 fork 能力打满连 exec 都进不去。治本靠应用内waitpid兜底靠tini/--init只收孤儿再配--pids-limit限制爆炸半径。思考题欢迎在评论区讨论既然SIGKILL在容器内杀不掉 1 号进程那SIGSTOP暂停呢它和 SIGKILL 在sig_task_ignored()里的待遇有何异同如果容器里的 1 号进程就是一个普通应用非 shell它没有注册SIGCHLD处理器会像 shell 一样留下僵尸吗为什么PR_SET_CHILD_SUBREAPER和传统 init 回收所有孤儿在嵌套 PID namespace如 Docker in Docker下会怎样层层 reparentKubernetes 里shareProcessNamespace: true时PID 1 是谁僵尸会 reparent 到它吗

相关新闻

最新新闻

日新闻

周新闻

月新闻