2024 年我们有个后台服务,隔三差五就会"凭空消失"一次。运维早上来看,进程没了,服务也没了,但日志的最后一行干干净净,没有任何报错、没有异常堆栈,就好像它自己安安静静地走掉了。一开始我们都以为是程序自己崩了,翻遍了应用日志却一无所获。后来我才明白,一个 Linux 进程的"死法"远不止"自己崩溃"这一种——它可能是被信号杀掉的,可能是被内核的 OOM Killer 选中处决的,也可能是父进程退出后被连带带走的。这一次排查,逼着我把 Linux 进程与信号这套机制从头理了一遍。本文复盘这次实战。
问题背景
环境:CentOS 7,一个常驻的 Java 后台数据处理服务
事故现象:
- 服务进程隔几天就消失一次,毫无规律
- 应用日志最后一行很干净,没有任何报错或异常
- 进程没了,服务自然也停了,要人工重启
- top / ps 都找不到它,像凭空蒸发
现场排查(一开始的困惑):
# 1. 进程还在不在?
$ ps -ef | grep data-service
# —— 不在了,连个影子都没有
# 2. 翻应用日志,想找崩溃堆栈
$ tail -100 /var/log/data-service/app.log
# —— 日志正常结束,没有 Exception,没有 OOM 字样
# 应用层面看,它就是"正常地"停了
# 3. 关键转折:去看【内核日志】,而不是应用日志
$ dmesg | grep -i kill
# —— 一行刺眼的记录跳了出来
根因(后来定位到的):
这台机器上还跑着别的服务,某些时段总内存吃紧,
内核的 OOM Killer 触发了,
它挑中了占内存较多的这个 Java 进程,把它杀掉。
应用日志当然干净 —— 进程是被内核从外部"处决"的,
它根本没机会写下任何遗言。
修复 1:先看清进程——ps 与 pstree
# === 排查进程问题,先把"进程长什么样"看清楚 ===
# === ps:看进程快照,最常用的两种写法 ===
$ ps -ef
# -e 所有进程 -f 全格式(含 PPID 父进程、启动时间、命令)
$ ps aux
# a 所有用户 u 带 CPU/内存占用 x 含没有终端的进程
# 两种都常用,aux 多了 %CPU/%MEM,排查资源问题更顺手
# === 找特定进程 ===
$ ps -ef | grep data-service | grep -v grep
# grep -v grep:把 grep 自己这一行过滤掉
# 更省事的是 pgrep:
$ pgrep -a java # -a 连命令行一起显示
$ pgrep -f data-service.jar # -f 匹配整条命令行
# === pstree:看进程的【父子关系树】 ===
$ pstree -p 12345
# -p 显示 PID。能看清这个进程是谁的孩子、又有哪些孩子。
# 这一点排查时很重要 —— 父进程一死,
# 它的孩子可能也会跟着受影响。
# === 看进程的关键字段 ===
$ ps -o pid,ppid,stat,etime,rss,cmd -p 12345
# PID PPID STAT ELAPSED RSS CMD
# 12345 1 Sl 2-13:40:11 1820M java -jar ...
# PPID:父进程。PPID=1 表示父进程是 init/systemd
# STAT:进程状态(下一节细讲)
# etime:已经运行了多久
# rss:实际占用的物理内存(KB)
修复 2:看懂进程状态 STAT
=== ps 里的 STAT 列,每个字母都有含义 ===
主状态(第一个字母):
R Running : 正在运行,或在运行队列里等 CPU
S Sleeping : 可中断睡眠 —— 最常见,在等某个事件
(等网络、等定时、等锁),能被信号唤醒
D Disk sleep : 不可中断睡眠 —— ★ 通常在等磁盘 IO,
这种状态【连 kill -9 都杀不动】,
D 状态进程一多,往往是 IO 出了问题
T Stopped : 被暂停了(收到 STOP 信号,或被调试器停住)
Z Zombie : 僵尸进程 —— 进程已经死了,但它的
父进程还没"收尸"(没调用 wait 回收)
后缀修饰(后面的字母):
s : 是会话首进程(session leader)
l : 是多线程进程
+ : 在前台进程组
N : 优先级被调低了(nice 值为正)
=== 排查时最该警惕的两个状态 ===
【Z 僵尸进程】
僵尸本身不占 CPU、不占内存,它只是占着一个进程表项。
少量、短暂的僵尸是正常的。但如果僵尸越积越多,
说明【父进程有 BUG】—— 父进程 fork 了子进程,
却一直不去 wait() 回收它们。
解法:重启那个【父进程】。杀僵尸本身没用 ——
它已经死了,你杀不死一个死人。
【D 不可中断睡眠】
进程卡在内核态等一个无法被打断的操作(几乎都是磁盘 IO)。
大量 D 状态进程 = 磁盘 IO 严重阻塞。
而且 D 状态的进程 kill -9 也杀不掉,
只能去解决底层的 IO 问题。
# === 快速找出僵尸进程 ===
$ ps -ef | awk '$2 ~ /defunct/ || $0 ~ /defunct/'
# 或直接看 STAT 列带 Z 的
$ ps aux | awk '$8 ~ /Z/ {print}'
# === 找出僵尸进程的"父进程"(真正要处理的对象)===
$ ps -o ppid= -p <僵尸PID>
# 拿到 PPID,去重启 / 修复那个父进程
修复 3:信号——进程是被"谁"杀的
# === 进程间靠"信号"通信,kill 本质就是发信号 ===
# === 几个必须分清的信号 ===
# SIGTERM(15):礼貌地请进程退出 —— kill 默认发的就是它。
# 进程能捕获它,做完清理(关连接、刷盘)再优雅退出。
# SIGKILL(9) :强制处决 —— 不可被捕获、不可被忽略,
# 内核直接干掉进程,它没有任何机会做清理。
# SIGHUP(1) :终端挂断 —— 常被服务用来表示"重载配置"。
# SIGINT(2) :就是你按 Ctrl+C 发出的那个。
# SIGSTOP/SIGCONT:暂停 / 恢复进程。
# === 正确的杀进程姿势:先礼后兵 ===
$ kill 12345 # 先发 SIGTERM,给它优雅退出的机会
# 等几秒,还不退,再强制:
$ kill -9 12345 # 发 SIGKILL,强制
# 一上来就 kill -9 是坏习惯 —— 进程来不及刷盘、
# 来不及关连接,可能丢数据、留下脏状态。
# === 查所有信号 ===
$ kill -l
# === 关键问题:我的进程到底是被什么信号杀的 ===
# 如果服务用 systemd 管理,日志里会明确记录:
$ journalctl -u data-service --no-pager | tail -20
# 会看到类似:
# data-service.service: Main process exited,
# code=killed, status=9/KILL
# code=killed、status=9 —— 它是被 SIGKILL(9)杀的!
# 这就把"自己崩溃(code=exited)"和
# "被信号杀死(code=killed)"区分开了。
# === 进程退出码也能透露死因 ===
# 一个进程如果是被信号 N 杀掉的,
# 它的退出码通常是 128 + N。
# 128 + 9 = 137 -> 被 KILL 杀
# 128 + 15 = 143 -> 被 TERM 杀
# 在脚本 / docker logs / k8s 里看到 137,
# 第一反应就该是:它是被 KILL 杀的(常常就是 OOM)。
修复 4:OOM Killer——内核的"处决者"
# === 我们这次的真凶:OOM Killer ===
# 当系统内存严重不足、又腾不出来时,
# 内核为了自保不至于整个宕机,会启动 OOM Killer,
# 从现有进程里【挑一个】杀掉来释放内存。
# === 去内核日志里找它的处决记录 ===
$ dmesg -T | grep -i -E 'oom|kill'
[Tue May 14 03:21:08] Out of memory: Killed process 12345
(java) total-vm:6291456kB, anon-rss:3984000kB...
# -T 把时间戳转成人能读的格式
# 真相大白:03:21,内核因为内存不足,杀了 PID 12345 的 java
# === 也可以查 messages 日志 ===
$ grep -i 'killed process' /var/log/messages
# === OOM Killer 是怎么挑"倒霉蛋"的 ===
# 内核给每个进程算一个 oom_score(分数越高越该死),
# 大致是"占内存越多,分越高"。可以查看:
$ cat /proc/12345/oom_score
# === 保护关键进程:调低它的 oom_score_adj ===
# oom_score_adj 范围 -1000 ~ 1000,值越小越不容易被选中
$ echo -800 > /proc/12345/oom_score_adj
# 设成 -1000 基本就是"豁免",但要谨慎用 ——
# 别让内核没得选,最后只能杀更要命的进程。
# === 根治 OOM 的几个方向 ===
# 1. 内存确实不够 -> 加内存,或把挤在一起的服务拆开
# 2. 某个进程内存泄漏 -> 修代码(治本)
# 3. 给 Java 这类进程设好上限(-Xmx),别让它无节制涨
# 4. systemd 里给服务设 MemoryMax,超了先在 cgroup 内
# 被限制,而不是惊动全局的 OOM Killer
# 我们这次:① 给 Java 设了合理的 -Xmx
# ② 把抢内存的服务迁走 ③ 加了内存告警
修复 5:让服务"死了能自己爬起来"——systemd
# === 退一步:就算进程意外死了,也不该靠人手动重启 ===
# 用 systemd 托管服务,让它具备自愈能力。
# === 写一个 systemd service 单元 ===
$ cat /etc/systemd/system/data-service.service
[Unit]
Description=Data Processing Service
After=network.target
[Service]
Type=simple
User=appuser
ExecStart=/usr/bin/java -Xmx2g -jar /opt/app/data-service.jar
# ★ 关键:进程死了自动拉起
Restart=always
RestartSec=5
# 内存超过 3G,先在 cgroup 内被管控
MemoryMax=3G
# 标准输出/错误都进 journal,方便 journalctl 查
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
# === 让配置生效并开机自启 ===
$ systemctl daemon-reload # 改了单元文件后必须执行
$ systemctl enable data-service # 开机自启
$ systemctl start data-service # 立即启动
# === 日常操作 ===
$ systemctl status data-service # 看状态(运行中?重启过几次?)
$ systemctl restart data-service
$ journalctl -u data-service -f # 实时跟该服务的日志
$ journalctl -u data-service --since "1 hour ago"
# === Restart=always 的价值 ===
# 配了它之后,哪怕进程再被 OOM 杀掉,
# systemd 也会在 5 秒后自动把它拉起来,
# 服务的"消失时间"从几小时缩短到几秒。
# 但记住:这是兜底,不是免死金牌 ——
# 它治的是"死了没人管",治不了"为什么会死"。
修复 6:别再让任务因终端关闭而被带走
# === 一个相关的常见坑:进程随终端关闭而消失 ===
# 你 SSH 上去,手动跑了个长任务,然后关掉了 SSH 窗口,
# 回来一看任务没了 —— 这也是"进程凭空消失"的一种。
# === 原因:SIGHUP ===
# 终端(会话)关闭时,会给它名下的进程发 SIGHUP 信号,
# 默认行为是终止。所以你的任务被"挂断"带走了。
# === 解法 1:nohup —— 让进程忽略 SIGHUP ===
$ nohup ./long_task.sh > task.log 2>&1 &
# nohup 让进程对 SIGHUP 免疫
# > task.log 2>&1 把输出重定向到文件(否则输出会丢)
# 末尾的 & 让它在后台跑
# === 解法 2:已经跑起来了才想起来,用 disown ===
$ ./long_task.sh &
$ disown -h %1 # 把后台作业 1 从当前 shell 的管理里摘掉
# 这样关掉终端也不会波及它
# === 解法 3(推荐长任务用):tmux / screen ===
$ tmux new -s mytask
# 在 tmux 里跑任务,然后 Ctrl+b d 脱离(detach)
# 终端关了,tmux 会话还在后台活着
$ tmux attach -t mytask # 下次回来重新接上
# tmux 的好处:任务还在交互式会话里,
# 随时能回去看输出、继续操作,比 nohup 灵活得多
# === 验证进程是否真的脱离了终端 ===
$ ps -o pid,ppid,tpgid,cmd -p <pid>
# 看 PPID 是不是 1(被 init/systemd 收养了),
# 这说明它已经和你的终端脱钩了
命令速查
场景 命令
=============================================================
看所有进程 ps -ef / ps aux
找特定进程 pgrep -af 关键字
看进程父子树 pstree -p <pid>
看进程详细字段 ps -o pid,ppid,stat,rss,cmd -p <pid>
找僵尸进程 ps aux | awk '$8 ~ /Z/'
优雅杀进程 kill <pid> (SIGTERM)
强制杀进程 kill -9 <pid> (SIGKILL)
查进程被什么信号杀 journalctl -u 服务 | grep code=
查 OOM 处决记录 dmesg -T | grep -i oom
看进程 OOM 分数 cat /proc/<pid>/oom_score
保护进程不被 OOM echo -800 > /proc/<pid>/oom_score_adj
服务自动重启 systemd 里设 Restart=always
跟服务日志 journalctl -u 服务 -f
后台跑任务不被挂断 nohup cmd > log 2>&1 & / tmux
口诀:日志干净就查内核 -> dmesg 找 OOM -> 退出码 137 = 被 KILL
根治内存 + systemd 自愈兜底
避坑清单
- 进程消失但应用日志干净,八成是被外部杀掉的,要去查内核日志和 systemd 日志
- ps -ef 看父子和命令,ps aux 看资源占用,pgrep -af 找进程比 grep 更省事
- STAT 列带 Z 是僵尸,根因是父进程没 wait 回收,要重启父进程而不是杀僵尸
- STAT 列带 D 是不可中断睡眠,通常卡在磁盘 IO,kill -9 都杀不动,要查 IO
- kill 默认发 SIGTERM 让进程优雅退出,别上来就 kill -9,会丢数据留脏状态
- 进程退出码 128+N 表示被信号 N 杀,看到 137 就是被 SIGKILL,常常意味着 OOM
- dmesg -T grep oom 能找到 OOM Killer 的处决记录,精确到时间和被杀进程
- OOM 治本要修内存:给 Java 设 -Xmx、迁走抢内存的服务、修内存泄漏
- 用 systemd 托管服务并设 Restart=always,让进程死了能在几秒内自愈
- 手动跑长任务用 nohup 或 tmux,否则终端一关 SIGHUP 会把任务带走
总结
这次"进程凭空消失"的排查,纠正了我一个根深蒂固的思维定式:进程没了,第一反应一定是去翻应用日志找崩溃堆栈。这个习惯在大多数时候没错,但它有一个致命的盲区——它默认了进程是"自己"死的。可一个 Linux 进程的死法,远不止"自己崩溃"这一种。它可能被另一个进程发信号杀掉,可能被内核的 OOM Killer 在内存危急时选中处决,可能因为父进程退出而被连带回收,也可能因为你关掉了 SSH 终端、被一个 SIGHUP 信号悄无声息地带走。这些死法有一个共同的特征:进程是被"外部"干掉的,它根本没有机会在自己的应用日志里写下任何一句遗言。所以这次排查真正的转折点,不是我在应用日志里找到了什么,而是我终于意识到该去看应用日志之外的地方——内核日志 dmesg 和 systemd 的 journal。这是我想记住的第一条:当一个进程消失了,而它的应用日志却干干净净、没有任何异常时,这种"干净"本身就是一条极重要的线索,它几乎是在明确地告诉你,这个进程不是自己死的,是被外部处决的,你该去查的是内核和 systemd,而不是继续在应用堆栈里打转。顺着这条线索,我重新认识了几样东西。一是进程的状态,ps 输出里那个不起眼的 STAT 列,其实信息量很大,尤其是 Z 和 D 这两个状态特别值得警惕:Z 是僵尸,它代表进程已经死了但父进程没去"收尸",僵尸本身无害,可它一旦越积越多,矛头就直指父进程的 BUG,而且你要处理的是那个父进程,你没法杀死一个已经死了的僵尸;D 是不可中断睡眠,它代表进程卡在内核态等一个打断不了的操作,几乎总是磁盘 IO,这种进程连 kill -9 都奈何不了它,大量的 D 状态就是磁盘 IO 阻塞的强烈信号。二是信号,kill 这个命令的本质是"发信号",而 SIGTERM 和 SIGKILL 是必须分清的两个:SIGTERM 是礼貌的请求,进程能捕获它、做完刷盘关连接的清理再优雅退出;SIGKILL 是强制处决,不可捕获、不可忽略,进程没有任何机会善后。一上来就 kill -9 是个坏习惯,它可能让进程来不及刷盘而丢数据。而信号还藏着一条破案线索:一个被信号 N 杀掉的进程,退出码通常是 128 加 N,所以当你在脚本里、在 docker logs 里、在 k8s 的事件里看到那个 137,你就该立刻反应过来——这是 128 加 9,它是被 SIGKILL 杀的,而能悄无声息发出 SIGKILL 的,十有八九就是 OOM Killer。OOM Killer 正是我们这次的真凶:当系统内存严重不足、又实在腾不出空间时,内核为了不让整台机器宕机,会启动这个机制,从现有进程里按内存占用高低算分,挑一个分最高的杀掉来自救,而我们那个吃内存较多的 Java 进程,就这样在某个内存吃紧的凌晨被选中处决了——它的应用日志当然干净,因为它是被内核从外部一击毙命的。明白了真凶,根治的方向就清楚了:OOM 的本质是内存不够,治本要么是给 Java 这类进程设好 -Xmx 上限别让它无节制地涨,要么是把挤在一台机器上互相抢内存的服务拆开,要么是去修真正的内存泄漏。但我还想强调最后一层:就算根因一时半会儿治不彻底,你也完全可以让"进程意外死亡"这件事的破坏力降到最低,办法就是用 systemd 托管服务并配上 Restart=always,这样哪怕进程再次被 OOM 杀掉,systemd 也会在几秒之内把它重新拉起来,服务的中断时间就从过去那种"几小时无人值守"压缩成了"几秒钟的自愈"。不过我也时刻提醒自己,Restart=always 是兜底,不是免死金牌,它治的是"进程死了没人管"这个运维问题,治不了"进程为什么会死"这个根本问题,这两件事必须同时做、缺一不可。从最初对着干净的应用日志百思不得其解,到最后理清"日志干净就转查内核、dmesg 锁定 OOM、看懂退出码 137、根治内存再加 systemd 自愈兜底"这样一条完整的链路,这次排查让我真正记住的是:排查进程消失,你首先要问的不是"它报了什么错",而是"它到底是怎么死的"——是自杀,还是他杀。问对了这个问题,你才会知道,答案往往不在应用日志里,而在你一开始压根没想到去看的那些地方。
—— 别看了 · 2026