kill -9 之后端口还被占着:Linux 信号机制与进程优雅退出的完整实战
有个场景几乎每个站长都碰到过:改完配置要重启服务,systemctl restart 报超时,于是你 kill -9 把进程强杀了,结果再启动时提示「端口已被占用」。你去 ps 里找那个进程,它已经不见了,可 ss -lntp 一看,端口确实还在被占着。再等几十秒,端口自己又释放了。
这个现象的答案藏在 Linux 的信号机制和 TCP 连接的生命周期里。把这件事讲透,不只是解决端口占用——它决定了你在生产环境里做重启、回滚、停机维护时,到底是「优雅」还是「粗暴」,也决定了你的服务在重启瞬间会不会丢请求、丢数据。
信号到底是什么:从一个误解说起
很多人把 kill 理解成「杀死进程的命令」。这是错的。kill 的本义是给进程发送一个信号,「9」只是众多信号中的一个。信号是内核提供的一种进程间通信机制,本质是一个整数:
kill -l # 列出所有信号及其编号
常见的几个,必须分清:
- SIGTERM(15):请求进程终止。这是「请你收工」的礼貌说法。进程收到后可以自己决定怎么响应——可以先保存数据、关闭连接、清理临时文件,再退出。这是优雅关闭的标准信号,也是
kill不带参数时的默认行为。 - SIGKILL(9):无条件立即终止。这个信号不能被进程捕获、不能被忽略、不能被阻塞,由内核直接执行。进程没有任何机会做善后——没有保存、没有清理、没有关闭连接。
- SIGHUP(1):挂起。传统上在终端断开时发送,但很多守护进程(Nginx、sshd、rsyslog)把它重新定义成「重新加载配置」。
- SIGINT(2):中断,就是你在终端按 Ctrl+C 发的信号。
- SIGQUIT(3):退出并生成 core dump,常用于调试。
- SIGUSR1 / SIGUSR2:用户自定义信号,各个程序含义不同。Nginx 用 SIGUSR1 重开日志文件、SIGUSR2 平滑升级二进制。
关键区别就在这一句:SIGTERM 是「请求」,进程可以不同意或者慢慢来;SIGKILL 是「命令」,由内核强制执行,进程毫无还手之力。
为什么 kill -9 会导致端口还被占着
这就要说到 TCP 连接的关闭过程。一个主动关闭连接的进程,会进入 TIME_WAIT 状态,持续 2 倍 MSL(Maximum Segment Lifetime),在 Linux 上默认是 60 秒。这个状态的存在不是 bug,而是协议设计——它保证网络上延迟的旧数据包不会污染新连接、保证最后的 ACK 能被对方收到。
正常情况下的优雅关闭:
- 进程收到 SIGTERM,停止接受新连接。
- 处理完进行中的请求。
- 主动关闭监听 socket 和已建立的连接,然后退出。
但有些服务(尤其是没写好信号处理的程序)收到 SIGTERM 后并不会主动关闭监听 socket,这时候轮到内核对它执行关闭。问题在于,kill -9 走的是内核强制终止路径:进程的 socket 虽然被内核回收了,但如果这个 socket 上有已建立的连接,这些连接可能进入 TIME_WAIT 或者更隐蔽的 CLOSE_WAIT 状态,导致新进程去 bind 同一个端口时拿到 Address already in use。
用 ss 看清楚到底是什么状态在占端口,这是排查的第一步:
# 看谁在监听这个端口 ss -lntp | grep :8080 # 看这个端口上所有连接的详细状态 ss -antp | grep :8080 # -e 显示更详细的信息,比如 socket inode 和计时器 ss -antpe 'sport = :8080'
如果你看到大量 TIME-WAIT,那是正常的协议行为,等 60 秒就好,或者用 SO_REUSEADDR 让新进程能复用。如果你看到 CLOSE_WAIT 堆积,那就是另一个故事了——说明对端已经关闭连接,但本端进程没来得及调用 close 就没了,CLOSE_WAIT 是应用层的问题,不是内核的。
CLOSE_WAIT 堆积:比 TIME_WAIT 更危险的信号
必须把这两个状态分清楚,因为它们的处理方法完全不同:
- TIME_WAIT:本端主动关闭后进入的状态。这是健康的表现,说明你的程序正确关闭了连接。堆积了不用慌,是内核在自己的节奏里回收。
- CLOSE_WAIT:对端主动关闭后进入的状态,等本端调用 close。这个状态只能由应用程序自己调 close 才会离开,内核不会替你回收。如果它一直堆积,说明你的程序有 bug——连接泄漏,或者是程序卡住了没法处理事件循环。
所以,看到 CLOSE_WAIT 不要重启服务了事,要去查程序为什么不 close。常见原因:程序在处理请求时阻塞(比如同步调用外部接口没有超时)、文件描述符泄漏、事件循环被 CPU 密集任务阻塞。重启只能掩盖,过一会儿又会堆起来。
# 统计各状态的连接数量,快速看出有没有异常堆积
ss -ant | awk 'NR>1 {state[$1]++} END {for (s in state) print state[s], s}' | sort -rnsystemd 是怎么控制进程退出的:KillMode
如果你用 systemd 管理服务,那么 systemctl stop 的实际行为并不是简单地发 SIGTERM,而是由 KillMode 决定的。这是很多人配置服务时完全忽视、但极其重要的一个参数:
# 查看某个服务的 KillMode systemctl show nginx -p KillMode -p KillSignal -p TimeoutStopSec
KillMode 的取值和含义:
- control-group(默认):停止时,向该服务所在 cgroup 里的所有进程发送 KillSignal(默认 SIGTERM),等待 TimeoutStopSec 后,如果还有进程存活,再对剩余的进程发 SIGKILL。这是目前推荐的做法,能保证整个进程组一起被清理干净。
- mixed:对主进程发 SIGTERM,对其他进程发 SIGKILL。注意这里主进程收到的是 SIGTERM。这个模式有一个著名的坑:很多服务的主进程和子进程共享同一个 PID 命名空间但不是同一个进程组,mixed 下信号只发给主进程,子进程可能被 SIGKILL 直接杀掉。
- process:只向主进程发 KillSignal,不管其他进程。
- none:systemd 不发任何信号,只等进程自己退出(配合 TimeoutStopSec 生效)。
这里的关键是理解一个常见坑:很多老式服务的主进程只是个「启动器」——主进程 fork 出真正干活的子进程后自己退出或者不管子进程。如果 KillMode 配成 process 或 mixed,停止时信号发给了主进程,真正干活的子进程收不到,于是要么留成孤儿进程,要么被 systemd 用 SIGKILL 直接干掉,两种情况都不是优雅关闭。
所以对这类服务,应该在 unit 里显式写:
[Service] KillMode=control-group KillSignal=SIGTERM TimeoutStopSec=30 SendSIGKILL=yes
TimeoutStopSec=30 的意思是:发出 SIGTERM 后,等 30 秒。这 30 秒就是留给进程优雅关闭的时间窗口——处理完手头的请求、写完日志、关闭连接。超过 30 秒 systemd 才会升级到 SIGKILL。这个值的设定要跟你的业务匹配:如果你的单个请求最长要跑 60 秒,那 30 秒的窗口显然不够,进程会被强杀,正在处理的请求全部失败。
自己写服务时,应该怎么正确处理信号
如果你在写一个常驻服务(比如 Go、Python、Node 写的后台程序),正确处理信号是基本素养。核心原则有三条:
一、捕获 SIGTERM,不要捕获 SIGKILL
SIGKILL 是捕获不了的,别白费力气。要捕获的是 SIGTERM(以及可作为重载信号的 SIGHUP):
import signal
import sys
def handle_sigterm(signum, frame):
# 收到信号后不要立刻退出,先做善后
shutdown_flag = True
signal.signal(signal.SIGTERM, handle_sigterm)Python 里要注意:信号处理函数是在主线程的字节码间隙执行的,如果主线程正阻塞在一个 time.sleep() 或者 recv() 里,信号处理函数要等到那个调用返回才会被执行。更稳妥的做法是用 signal 模块只设置一个标志位,由主循环去轮询这个标志位,而不是在信号处理函数里做复杂逻辑。
二、关闭要有超时,不要无限等
优雅关闭的常见错误是「无限等待所有连接处理完」。如果某个连接卡住了(对端不发数据也不断开),进程就永远退不出去,最后被 SIGKILL 强杀,反而不如一开始就干脆点。
正确的做法是:收到 SIGTERM 后,停止接受新连接,然后给一个宽限期(比如 10 秒)让正在进行的请求跑完,超过宽限期就强制关闭。
三、先停止接入,再排空,最后退出
顺序很重要。典型的优雅关闭流程:
- 收到 SIGTERM,立刻停止接受新的连接(关闭监听 socket,或者从负载均衡摘除自己)。
- 把「正在运行的任务数」归零作为退出条件,同时启动一个宽限期计时器。
- 宽限期内:等在途请求完成,等缓冲区刷盘。
- 宽限期到或任务归零:关闭剩余连接,释放资源,退出,返回 0。
用系统手段看清一个进程在干什么
排查「进程为什么不退出」,这几个工具最有用:
# 看进程当前的状态和信号掩码(SigBlk、SigIgn、SigCgt) cat /proc/<pid>/status | grep -i sig # 看进程正在阻塞在哪个系统调用 cat /proc/<pid>/wchan cat /proc/<pid>/stack # 需要 root 且有内核调试支持 # 用 strace 跟踪信号和系统调用 strace -p <pid> -e trace=signal # 看进程打开的文件和网络连接 ls -l /proc/<pid>/fd | wc -l
/proc/<pid>/status 里的三个信号字段要会读:SigIgn 是被忽略的信号掩码,SigCgt 是被捕获(安装了处理器)的信号掩码。如果 SIGTERM(对应信号 15,位掩码 0x4000)出现在 SigIgn 里,说明这个程序主动忽略了 SIGTERM——这往往就是 systemctl stop 卡住、最后被强杀的原因。
如果进程处于 D 状态,什么都杀不掉
还有一种更棘手的情况:进程状态显示为 D(不可中断睡眠)。ps 里显示 D 或者 D+,这时候连 SIGKILL 都杀不掉它,因为它卡在内核态等一个 IO 操作返回,而这个 IO 可能永远不会返回(比如 NFS 挂载点失联、故障磁盘)。
这种情况下 kill -9 是无效的,唯一可行的路径是:
- 找出它卡在哪个 IO 上(
cat /proc/<pid>/stack、dmesg看内核报错)。 - 如果是网络文件系统,尝试恢复底层连接(重启 NFS、拔掉故障设备)。
- 实在不行,只能重启机器。
D 状态进程是「系统性问题」的症状,不是进程本身的毛病,不要去纠结怎么杀它,要去修它等的东西。这类排查在前面讲 NFS 挂死的文章里有详细展开。
实操建议汇总
把上面这些浓缩成几条可以直接用的规则:
- 能用
systemctl restart就别手动 kill。systemd 会按 KillMode 和 TimeoutStopSec 处理,比你手动操作规范得多。 - 停止服务首选 SIGTERM,把 SIGKILL 当成最后手段。强杀意味着放弃所有善后机会——日志没刷盘、缓存没写入、连接没关闭。
- 数据库、消息队列这类有状态服务,绝不能
kill -9。MySQL 被 SIGKILL 后下次启动可能要做崩溃恢复,严重时数据损坏。Redis 被强杀虽然有 AOF 兜底,但仍可能丢最后一段数据。 - 重启后端口占用,先用
ss -antp | grep :端口看状态。是 TIME_WAIT 就等,是 CLOSE_WAIT 就去查程序。 - 自己写服务一定要处理 SIGTERM 并设置宽限期,宽限期要覆盖最长的请求处理时间。
- 所有服务的
TimeoutStopSec都要根据业务调整,默认的 90 秒不一定适合你,太短会导致强杀,太长会拖慢部署。
小结
信号不是「杀进程」这么简单,它是进程与系统之间的一套协作协议。SIGTERM 是可协商的礼貌请求,SIGKILL 是内核的强制裁决。理解这个区别,你才能理解为什么优雅重启和强杀会导致完全不同的后果。
端口在 kill -9 后还被占,是这套机制的必然后果;CLOSE_WAIT 堆积是程序 bug 的信号;D 状态杀不掉是系统 IO 出了状况。这三件事看起来都像「进程杀不掉」,但根因完全不同,处理方法也完全不同。学会用 ss 看状态、用 /proc/<pid>/status 看信号掩码、用 strace 看阻塞点,这些「莫名其妙」的故障就会变得有迹可循。