Linux 下这种需求实在太常见了。很多时候我们解决服务异常的办法并不是体系化的,而是“想起来一个杀一个”,端口被占就 lsof 找 PID 再 kill,服务起不来就把看起来相关的进程全列为嫌疑犯。可一旦需求变成“把 ps 输出中所有包含 xx 字段的进程都杀掉”,事情就没那么简单了:你要匹配的 xx 到底是进程名、完整命令行、还是启动时的某个参数?批量 kill 怎么防止误杀同名的其他服务?为什么有时候 kill 完了进程还在?这篇文章我要把这个高频操作从头到尾拆干净,从 ps 字段定位、PID 提取到安全终止,每一步都给可以直接抄的写法,顺便把容易翻车的细节全部点出来。无论你是刚接触命令行的新手,还是已经踩过几次坑的运维开发,按这条路走都能少折腾一下午。
1. 先搞清楚:你想匹配的到底是 ps 里的哪一个“字段”
1.1 大多数人第一步就错了:只记得 ps aux
很多人一上来就是 ps aux | grep xxx,然后从输出里肉眼找 PID。这方法在进程少的时候没毛病,但一旦进程列表上百条,就会遇到一个很要命的问题:你根本不知道 grep 匹配到的是整行的哪个位置。
ps aux 和 ps -ef 的输出列名不一样,但本质上都是在读 /proc 目录下的进程快照。ps aux 是 BSD 风格,输出格式如下:
bash复制USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 168236 11472 ? Ss Mar10 0:47 /usr/lib/systemd/systemd
ps -ef 是 System V 风格:
bash复制UID PID PPID C STIME TTY TIME CMD
root 1 0 0 Mar10 ? 00:00:47 /usr/lib/systemd/systemd
两者关键区别在于最后列:COMMAND 和 CMD。管你是 ps aux 还是 ps -ef,最后那列都是“完整启动命令行”。注意,这里有个隐藏坑:当输出到终端时,ps 会自动截断超长命令行,你看到的 CMD 可能只有一小段,真正干活的参数根本没显示出来。想看完整命令要用 ps auxww 或者 ps -efww,多加两个 w。写脚本做匹配时尤其要加,否则你以为字段没匹配到,实际只是显示被截断了。
1.2 pick 自定义 -o 输出,脚本才不靠猜
既然要在脚本里做字段匹配,与其对着不固定的列数猜,不如直接告诉 ps 你要哪些列。这是我的习惯用法:
bash复制ps -eo pid=,ppid=,user=,stat=,etime=,args=
-e表示所有进程,等价于-A-o后面是自定义输出字段,逗号分隔- 字段名后面跟着
=,作用是去掉表头行。处理结果时不用再tail -n +2,省一步 args就是完整命令行,和cmd、command是同一个东西
推荐在脚本里优先用这组字段:
| 字段名 | 含义 | 使用场景 |
|---|---|---|
| pid | 进程号 | kill 的对象 |
| ppid | 父进程号 | 排查父子关系、守护进程 |
| pgid | 进程组号 | 按进程组批量终止 |
| user | 启动用户 | 共享机器上确认归属 |
| comm | 可执行文件名 | 只看“程序叫什么” |
| args | 完整命令行 | 匹配 JAR 路径、启动参数 |
| stat | 进程状态 | 过滤僵尸进程、休眠进程 |
| etime | 已运行时长 | 判断是旧进程还是新进程 |
| lstart | 启动时间点 | 人工确认使用 |
1.3 进程名、命令行、可执行路径不是一回事
这里必须展开说一个概念,否则后面匹配一定会翻车:comm 和 args 是两个完全不同的字段。
comm 是内核记录的进程可执行名,它有一定长度限制,而且不含路径和参数。你执行 bash run.sh,comm 大概率是 bash,而不是 run.sh;你用 java -jar app.jar,comm 是 java。所以如果目标是“把所有名字里带 java 的进程杀光”,ps -C java 就够了,但如果你要找的是某个特定 JAR 包实例,单看 comm 根本区分不了,因为 10 个 Java 进程的 comm 全部是 java。
args 才是你真正要的“完整命令行”。进程启动时传进去的参数、JAR 包路径、Spring profile、端口号,全都在这里。这就是题目里说的“包含 xx 字段”的真正含义:你拿一个关键字去匹配 args,而不是去匹配进程名。
举个例子,我本地跑一个服务:
bash复制/usr/bin/java -jar /opt/app/app.jar --spring.profiles.active=dev --server.port=8080
用 ps -eo pid=,comm=,args= 看到的输出是:
text复制12345 java /usr/bin/java -jar /opt/app/app.jar --spring.profiles.active=dev --server.port=8080
如果你想杀的是所有 --spring.profiles.active=dev 的实例,光用 pgrep java 或 pkill java 会把生产环境的实例也一起送走。这类教训我见过不止一次。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从“看见字段”到“得到 PID”:三种抓取方式怎么选
2.1 最原始的 ps -ef | grep 有个经典自匹配问题
最朴素的方法是:
bash复制ps -ef | grep app.jar
跑出来你会看到两条记录,一条是自己要找的 Java 进程,另一条是 grep 命令自己。因为 grep app.jar 这条命令本身也带 app.jar 字样,在进程列表里同样能被匹配到。
传统解决办法是再加一层过滤:
bash复制ps -ef | grep app.jar | grep -v grep
用 grep -v grep 把包含 grep 的那行扔掉。这一招能用,但我一直不太推荐,因为如果目标字段碰巧就是 grep,你会把真正想找的进程也一起过滤掉。更优雅的做法是“正则中括号技巧”:
bash复制ps -ef | grep '[a]pp.jar'
原理很简单:grep 这条命令的命令行里包含的是 [a]pp.jar,而不是 app.jar,正则表达式 [a]pp.jar 只能匹配到真实的 app.jar,自然就把自己排除掉了。这个方法理解起来稍微绕,但比 grep -v grep 干净。
2.2 pgrep/pkill:匹配的是整条命令行,而且自带正则语义
比 ps | grep 更省事的是专门的进程查询命令 pgrep。它不需要先 ps 再抓 PID,直接输出符合条件的 PID:
bash复制pgrep -f 'app.jar'
-f 表示匹配完整命令行,等价于拿你给的模式去和 args 字段做正则匹配。默认它只输出 PID,如果你想知道匹配到的进程究竟长什么样,加 -a:
bash复制pgrep -af 'app.jar'
这命令会输出 PID 和完整命令行,适合做预览。pkill 则是 pgrep 的“立即处决版”,pkill -f 'app.jar' 会找到匹配进程并发送信号,默认发送 SIGTERM。
这里必须强调一个很多人没意识到的点:pgrep/pkill 的 -f 模式遵循的是扩展正则表达式(ERE),不是单纯的字符串包含匹配。 意思是你在模式里写的 .、*、[、]、(、) 都会按正则语意去解释。例如:
bash复制pgrep -af 'app-1.0.jar'
这里的 . 会匹配任意一个字符,所以 app-1X0.jar 这种理论上也能被匹配到,虽然实际中很少遇到,但这是个隐患。如果字段里有正则特殊字符,最稳妥的办法是放弃 regex,改用 grep 的 -F 固定字符串模式,这点我后面细讲。
与之相对的,pgrep -x nginx 和 ps -C nginx 匹配的是 comm 字段,也就是进程的可执行名。它们不带参数,不走正则,可以严格限定程序名,但代价是没有办法区分同程序的不同参数实例。
2.3 ps + awk:真要逐字段过滤,就得手工写明位置
当 pgrep -f 的“全命令行正则匹配”和 ps -C 的“只匹配进程名”都不够用时,就要用 ps -o 指定字段,再交给 awk 做列级判断。比如我想找出所有进程名是 python3、同时命令行里带 train.py 的进程:
bash复制ps -eo pid=,user=,comm=,args= | awk '$3 == "python3" && index($0, "train.py") {print $1, $2, $4}'
这条命令的含义是:第二列是进程名,要求它精确等于 python3,同时整行里包含 train.py 才输出。注意 args 是最后一列,中间可能包含空格,awk 默认按空白切分后没法直接用 $4 表示“完整命令行”,所以在这种时候用 index($0, "...") 判断整行文本反而是更可控的做法。
awk 的 index(s, sub) 函数执行的是纯字面匹配,不会把正则符号当作特殊字符。假如你的关键字是 config.ini,index 只会找 config.ini 这个字符串本身,绝不会匹配 configXini,这是它比 pgrep 正则匹配更可靠的地方。
2.4 抓取方式对比选型
| 方式 | 匹配范围 | 是否走正则 | 适合场景 | 风险点 |
|---|---|---|---|---|
ps -ef | grep |
默认整行,列不可控 | grep 基本正则,加 -F 可变字面量 | 临时人工看一眼 | 需要排除 grep 自身,截断隐患 |
pgrep -f |
完整命令行 args | 是(ERE) | 按启动参数/路径找进程 | 正则符号误匹配,可能命中自身 |
pgrep -x |
可执行名 comm | 否 | 只按程序名找 | 区分不了不同参数的同类进程 |
ps -C name |
可执行名 comm | 否 | 找所有某名字的进程 | 同上 |
ps -o + awk |
自由指定字段 | awk 可用 index 做字面量 | 需要精确的字段条件 | 命令长,要理解列语义 |
我的选型建议很简单:临时排查用 pgrep -af 先看预览;写正式脚本时优先用 ps -eo pid=,user=,comm=,args= 加 awk 条件,因为每一步都透明可查,比一行花哨管道更不容易误杀。
3. 别上来就 kill -9:安全批量终止进程的标准流程
3.1 动手之前,先想清楚是谁在拉起它
我见过最多的“杀不掉”案例,不是 kill 命令写错,而是没处理进程的上层守护关系。很多服务是由 supervisor、pm2、systemd 或者一个简单的 while 循环脚本拉起来的,你只 kill 掉子进程,外层守护会立刻把它重新拉起。
所以在批量处理前,先查一层父进程:
bash复制ps -eo pid=,ppid=,pgid=,user=,comm=,args= | awk '$2 != 1 {print}'
重点关注那些父进程是脚本 shell、pid 1 之外的特殊管理进程。如果有外层管理,应该先用管理工具停止,例如 supervisorctl stop、pm2 delete、systemctl stop。使用裸 kill 只是最后手段,不是第一手段。
3.2 标准执行序列
我把一次安全批量 kill 拆成下面六步,几乎适用所有场景:
- 明确目标字段,写成不产生歧义的匹配串,比如
--spring.profiles.active=dev这种长参数。 - 用
pgrep -af或ps -o输出候选进程列表,人工确认数量和身份。 - 用
ps -o pid=,user=,etime=,args=检查这几个 PID 的启动用户和运行时长,确认不是别人的任务。 - 先发 SIGTERM(kill 默认信号),给进程优雅退出的机会。
- 等待几秒,再次检查进程是否还在、端口是否释放。
- 只有确认进程没有响应退出时才升级到 SIGKILL(kill -9)。
这套流程写进脚本,就是可以长期使用的安全工具。每次跑完,我心里是踏实的。
3.3 信号选择:为什么默认信号不是 KILL
很多人以为 kill = 强制结束,实际上 kill 只是“发送信号”而已。不带参数执行 kill 1234 发送的是 SIGTERM(15),进程收到后可以选择自行退出,做资源清理、状态保存等善后工作。kill -9 发送的是 SIGKILL,这个信号不可被捕获,内核会直接终止进程,进程没有任何机会做清理。
| 信号 | 编号 | 含义 | 使用建议 |
|---|---|---|---|
| SIGTERM | 15 | 请求正常终止 | 默认首选 |
| SIGHUP | 1 | 挂断,常用于通知重载配置 | 按需使用 |
| SIGINT | 2 | 终端中断,等同 Ctrl+C | 交互式进程可用 |
| SIGKILL | 9 | 强制杀死 | 仅当 TERM 无效时使用 |
为什么建议先 TERM 而不是直接 KILL?因为很多进程在 SIGTERM 能做的事,SIGKILL 做不到。例如 Java 应用可能要释放数据库连接池、刷新日志缓冲、把内存里的关键状态写完;Nginx 可能在处理完当前请求后平滑退出。你直接 SIGKILL,轻则留下脏数据,重则端口还处于 TIME_WAIT 状态,导致新进程起不来。
一个实用检查命令是 kill -0 $pid,它不发送任何实际信号,只做存在性探测:
bash复制kill -0 12345 && echo "进程还活着" || echo "进程已消失"
在脚本里可以用它判断等待终止是否完成。
3.4 一个可直接复制使用的安全批量杀脚本
下面这个脚本是我日常会用的模板,适合在共享开发机上手动执行,不搞任何花哨:
bash复制#!/usr/bin/env bash
# safe-kill-by-pattern.sh —— 带预览和确认的批量终止脚本
# 用法: ./safe-kill-by-pattern.sh '要匹配的关键字'
set -euo pipefail
pattern="${1:?请传入匹配串,例如 ./safe-kill-by-pattern.sh \"app.jar\"}"
me=$$
# 用 pgrep -f 做初步匹配,过滤掉脚本自身
pids=$(pgrep -f "$pattern" | grep -vw "$me" || true)
if [ -z "$pids" ]; then
echo "没有匹配到任何进程。"
exit 0
fi
echo "以下进程将被终止:"
ps -o pid=,ppid=,user=,stat=,etime=,args= -p $pids
echo
read -r -p "确认请输入大写 Y,取消直接回车: " answer
if [ "$answer" != "Y" ]; then
echo "已取消。"
exit 1
fi
# 第一轮发送 SIGTERM
echo "$pids" | xargs -r kill -TERM
# 等待 5 秒后检查残留
sleep 5
left=$(pgrep -f "$pattern" | grep -vw "$me" || true)
if [ -n "$left" ]; then
echo "以下进程未正常退出,升级为 SIGKILL:"
ps -o pid=,user=,args= -p $left
echo "$left" | xargs -r kill -KILL
else
echo "全部进程已正常退出。"
fi
脚本里有两个容易被忽略的细节。一是 pgrep -f "$pattern" 匹配的是完整命令行,而脚本自身的命令行也包含这个 pattern,比如你执行的是 bash safe-kill-by-pattern.sh "app.jar",bash 进程的命令行里就有 app.jar,不排除自身就会把自己也列入待杀名单。所以我用 grep -vw "$me" 把当前 shell 的 PID 去掉。二是必须设计人工确认流程,批量 kill 这种事,哪怕多花十秒确认,也比误杀之后花两小时恢复强。
4. 容易误杀的几个典型坑,我把它们整理成了清单
4.1 字段太短或太泛,一把梭全带走
有一次同事让我“把 test 相关的进程都杀了”,我直接把 pgrep -af test 输出摆到他面前,他才发现这台开发机上跑着 pytest 测试框架、一个叫 latest-test-data 的数据同步服务、还有几个 Java 服务的 classpath 路径里带着 test 目录。用 pkill -f test 会把这一锅全端了。
一个字段能唯一定位进程的前提是它足够独特。好的匹配串至少要有两个以上特征,例如“程序名 + 参数片段”,不要只用单个英文单词。如果你要只杀自己启动的,还应该加上用户限制:
bash复制pgrep -u "$USER" -af 'app.jar'
-u 作用是按启动用户过滤。共享开发机上,这叫基本礼貌。
4.2 正则符号导致匹配范围悄悄扩大
pgrep -f 里的“字段”是正则表达式,这意味着你若匹配 v1.2,它不光会命中 v1.2,也会命中 v1X2(点号通配任意字符)。字段里出现 [job]、(dev)、+ 这样的符号时,更要警惕。
举一个实际翻车现场:某算法同学想清理所有命令带 train_100ep.py 的进程,他写成 pkill -f "train_100ep.py",点号把第 5 个字符变成“任意字符”,结果把另一个 train_100Xp.py 的实验进程也杀了。虽然实际情况里恰好匹配的概率很低,但这种不确定性在批量 kill 里是不可接受的。
所以我的原则是:如果字段里含有正则特殊字符,就不要用 pgrep/pkill 了,改用固定字符串匹配。Linux grep 的 -F 选项能把模式当作字面量处理:
bash复制ps -eo pid=,user=,args= | grep -F 'train_100ep.py'
或者用前面提到过的 awk index:
bash复制ps -eo pid=,user=,args= | awk -v p='train_100ep.py' 'index($0,p) {print $1, $2}'
4.3 杀了父进程,子进程成了孤儿继续跑
这是另一个高频坑。假设有一个调度脚本启动了多个后台 worker:
bash复制./dispatch.sh &
dispatch.sh 作为父进程,下面挂了十几个 worker。你只 kill dispatch.sh,子 worker 并不会跟着退出,而是会成为孤儿进程被 pid 1 收养,继续
