侧边栏壁纸
  • 累计撰写 149 篇文章
  • 累计收到 2 条评论

kill -9 为什么杀不掉进程?排查 Linux 僵尸进程与 D 状态内核卡死我踩过的几个坑

2026-10-3 / 0 评论 / 9 阅读
AI 摘要由 AI 生成

文章讲述了在Linux系统中,进程无法被杀掉的原因及排查方法。主要涉及僵尸进程(Zombie)和D状态内核卡死问题,指出僵尸进程实际上已退出,但内核保留其信息,无法通过kill -9杀掉。解决方法包括给父进程发送SIGCHLD信号或直接重启父进程。同时,文章还探讨了Docker容器中由于单进程运行导致宿主机PID槽位耗尽的问题。

半夜接值班电话报警,一台跑批量数据导出的 Linux 服务器负载一路飙到了平时十倍。

我登上去用 ps -ef | grep sync_worker 抓出问题进程,拿到 PID 是 31422。按照平时的肌肉记忆,顺手敲了一行 kill -9 31422。

终端毫无提示,直接返回了命令提示符。通常到这一步,进程就已经退出了。但我随手补了一条 ps -p 31422 确认,这个进程依然安安静静地挂在进程表里。连续补了三次 kill -9,它就像被固定在内存里一样,纹丝不动。

Linux 的手册里写得很清楚:9 号信号(SIGKILL)不能被捕获,也不能被忽略。既然无法捕获又无法忽略,为什么进程还会杀不掉?

折腾了半宿我才搞明白,Linux 内核处理信号有它自己的生命周期。遇到杀不掉的进程,问题往往不在于信号有没有发出去,而在于进程当前所处的内核状态。

坑一:把已经死透的僵尸进程当成活人杀

第一类杀不掉的进程,名字往往带一个括号后缀,类似 [sync_worker] <defunct>。

当时我抓出 31422 的进程详情:

ps -o pid,ppid,state,cmd -p 31422

终端打印出来的状态列(state)是一个大写的 Z:

  PID  PPID S CMD
31422 28901 Z [sync_worker] <defunct>

状态为 Z 的进程就是常说的僵尸进程(Zombie)。这种进程其实早就执行完毕退出了,它的代码段、堆栈内存、打开的文件描述符都已经全被内核回收释放掉了。

它之所以还占着一个 PID,是因为 Linux 进程退出后,内核必须在进程表中保留它的 task_struct 结构体,里面记录着退出码、运行时间和资源统计信息,等着它的父进程调用 wait() 或 waitpid() 系统调用来读取。

父进程读完这层数据,内核才会彻底抹掉它的 PID 条目。

这就是为什么 kill -9 对它毫无作用。一个在内核里已经死亡的进程,根本没有正在运行的代码或者活跃线程来接收并处理 SIGKILL 信号。对着僵尸进程发 kill -9,等于对着墓碑开枪。

如果它只是零星几个,对系统其实没有性能影响,既不吃 CPU 也不占内存。真正的麻烦在于,如果父进程一直在不停 fork 子进程却从不收尸,系统的进程号总数就会被慢慢耗尽,直到新的正常进程报 fork: Resource temporarily unavailable 无法启动。

怎么正确清理僵尸进程

既然子进程已经死了,解决办法就只能去找它的父进程(PPID)。

先看它的父进程是谁:

ps -o pid,ppid,user,cmd -p 28901

或者用 pstree 把整条调用树串起来:

pstree -aps 31422

通常有两种处理办法:

第一种,尝试给父进程补发一个 SIGCHLD 信号,提醒它去收尸:

kill -s SIGCHLD 28901

如果父进程的代码写得比较规范,里面注册了信号处理函数只是漏了主动轮询,收到通知后就会触发一次 waitpid(),僵尸进程随即消失。

第二种,如果父进程本身有逻辑 bug,根本没写回收逻辑,那就只能直接重启或者杀死父进程:

kill 28901

父进程一旦终止,Linux 内核会把所有没被回收的子进程托管给 1 号进程(也就是 systemd 或 init)。1 号进程内建了自动收尸循环,会在接管的瞬间清理掉这批僵尸条目。

坑二:Docker 容器里跑单进程,把宿主机的 PID 槽位打满

在虚拟机里遇到僵尸进程还不算最麻烦,在 Docker 容器里踩这个坑才让人头大。

有一次我们的数据爬虫服务跑在容器里,几天之后宿主机突然无法 SSH 登录,已有的容器也在疯狂报错。排查发现宿主机系统的 PID 数量触顶了,达到了 /proc/sys/kernel/pid_max 默认上限的 32768。

进容器一查,里面挂着几万个 <defunct> 进程。

问题的根源在于 Docker 容器的命名空间隔离。在普通 Linux 系统里,1 号进程是 systemd,专门负责收养孤儿进程。而在容器里,Dockerfile 里的 CMD ["node", "app.js"] 或者 CMD ["python", "main.py"] 会直接让你的业务脚本成为容器内的 1 号进程。

标准的业务语言运行时很少会专门去捕获 SIGCHLD 信号。如果爬虫脚本在跑的过程中不断调用 subprocess.Popen 或者外部 shell 命令,子进程挂了以后,父进程没有主动调用 wait,容器里就会以极快的速度堆满僵尸进程。

容器防僵尸的配置

在运行 Docker 容器时,最直接的防御方案是启用内置的 init 系统。

用 docker run 命令启动时加上 --init 参数:

docker run -d --init --name crawler-worker my-crawler:latest

如果是 Docker Compose,在服务定义里加上一行:

services:
  crawler:
    image: my-crawler:latest
    init: true

这个开关会在容器内启动一个专门的轻量级 init 进程(通常是 tini),由它充当 PID 1 去转发信号和自动收尸,你的业务脚本则退居为它的子进程,容器内就不会再积压无法释放的僵尸条目。

坑三:撞上 D 状态进程,盲目发 kill 甚至导致关机卡死

除了僵尸进程,另一种更棘手的杀不掉进程是 D 状态进程。

在 top 或者 ps 查状态时,如果看到 STAT 列显示 D,代表进程处于 TASK_UNINTERRUPTIBLE(不可中断睡眠状态)。

root  14920  0.0  0.1  125400  4120 ?  D  03:15  0:00 /usr/bin/rsync -avz /data/backup/ /mnt/nfs/

当时我们的备份脚本挂载了内网的一台 NFS 网络存储,凌晨交换机做端口维护网络抖动,NFS 挂载点断联。rsync 正在往里面写入数据,直接卡死在了系统调用里。

为什么 kill -9 对 D 状态进程没用?

Linux 设计这种状态的初衷是为了保护数据完整性。当进程调用内核函数与慢速硬件交互时(比如向磁盘驱动器提交写入块、向网络文件系统同步元数据),内核必须确保硬件操作有了明确的返回结果,才能修改进程的上下文。

如果在内核态写磁盘的半途中强行让进程退出,硬件缓冲区和文件系统的内部结构就很可能陷入不可逆的数据损坏。所以只要进程处在不可中断睡眠中,内核就会把所有外部信号(包括 SIGKILL)全部挂起,不予投递。

直到硬件操作完成或者返回错误,进程重回可调度状态时,内核才会去翻看信号队列。

查看进程到底卡在哪一行内核调用

遇到 D 状态进程,不要盲目去敲各种 kill。直接读取内核给该进程暴露的调用栈:

cat /proc/14920/stack

终端会直接把当前卡住的内核函数链条打印出来:

[<0>] nfs_wait_bit_uninterruptible+0x33/0x50 [nfs]
[<0>] nfs_wait_on_request+0x2f/0x40 [nfs]
[<0>] nfs_updatepage+0x388/0x4e0 [nfs]
[<0>] generic_perform_write+0x10a/0x1c0
[<0>] nfs_file_write+0xa1/0x170 [nfs]
[<0>] new_sync_write+0x121/0x170
[<0>] vfs_write+0xb2/0x1b0
[<0>] ksys_write+0x5f/0xe0
[<0>] do_syscall_64+0x3b/0x90
[<0>] entry_SYSCALL_64_after_hwframe+0x44/0xae

栈底第一行清晰地指明了卡死位置:nfs_wait_bit_uninterruptible。这就排除了本地硬盘故障的可能,问题百分之百出在 NFS 网络链路或者远端服务端。

同时再看一下系统内核日志:

dmesg -T | grep -i "nfs: server"

通常能看到类似 nfs: server 192.168.10.20 not responding, still trying 的长连重试日志。

怎么安全解决 D 状态

第一步是修复底层阻塞的来源。如果是网络文件系统断开,优先尝试恢复对端服务或者网卡链路。对端恢复响应的瞬间,进程拿到 I/O 结果,之前积攒的 kill -9 信号就会被立刻执行,进程随即干净退出。

第二步,如果远端存储彻底坏掉无法恢复,千万不要直接执行普通的 umount /mnt/nfs,这同样会卡进 D 状态。可以使用强制懒卸载(lazy unmount):

umount -f -l /mnt/nfs

-l 参数会立刻把该挂载点从系统的目录树中摘除,新发起的文件请求不会再被路由到坏掉的存储,已有的句柄等到当前内核操作走完后自动脱钩。

如果是因为本地坏盘导致的块设备 I/O 阻塞,通常 dmesg 会狂刷 I/O error。遇到这种情况,切忌直接在终端执行 reboot。因为关机过程中 systemd 会尝试卸载所有文件系统并 sync 脏页,卡在坏盘上的 D 状态进程会导致关机流程无限挂起。

正确的处理顺序是先备份还能读取的数据,必要时利用 SysRq 机制进行紧急同步和重启:

echo s > /proc/sysrq-trigger
echo u > /proc/sysrq-trigger
echo b > /proc/sysrq-trigger

坑四:多线程里有个子线程卡死,导致整个进程无法退出

还有一次排查 Java 服务,发了 kill -9 后用 ps 查进程依然健在,状态也不是 Z,甚至不是常规的 D。

仔细用线程级视角查看:

ps -T -p 18204

或者直接遍历进程的任务目录:

ls /proc/18204/task/

终端打印出这个进程所属的几十个轻量级线程 ID(LWP)。主线程虽然收到了退出指令,但只要其中有一个子线程通过 JNI 调用了 C 扩展模块,卡在了底层硬件的不可中断系统调用里,整个线程组在内核的会计统计里就无法完成最终注销。

这种情况在排查时容易产生误判。单看主进程状态,会以为它在用户态活着,翻看全部线程的状态才能揪出卡在 D 状态的具体线程号。

定位到具体的线程号后,同样通过 cat /proc/18204/task/<LWP>/stack 去查调用栈,才能看明白究竟是哪个本地库在与外部驱动交互时阻塞了流程。

进程异常排查的四步检查顺序

后来我给团队整理了一份遇到杀不掉进程时的标准排查顺序,不需要一上来就漫无目的地乱试命令:

第一步,查状态码。用 ps -o pid,ppid,state,cmd -p <PID> 看 STAT 列。是 Z 还是 D,直接决定了接下来往哪个方向排查。

第二步,如果是 Z,找 PPID。看父进程为什么没有 waitpid。尝试给父进程发 SIGCHLD,解决不了再考虑重启父进程把孤儿转给 1 号进程收敛。

第三步,如果是 D,查内核栈。用 cat /proc/<PID>/stack 定位具体的系统调用点,判断是网络文件系统挂死还是本地磁盘底层坏块,严禁无脑 reboot 导致关机卡死。

第四步,如果是多线程应用,用 ps -T -p <PID> 展开全部线程状态,逐个排查卡在不可中断调用里的具体轻量级线程。

评论一下?

OωO
取消