kill -9 之后端口还被占着:Linux 信号机制、进程优雅退出与 CLOSE_WAIT 堆积排查实战

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 能被对方收到。

正常情况下的优雅关闭:

  1. 进程收到 SIGTERM,停止接受新连接。
  2. 处理完进行中的请求。
  3. 主动关闭监听 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 -rn

systemd 是怎么控制进程退出的: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 秒)让正在进行的请求跑完,超过宽限期就强制关闭。

三、先停止接入,再排空,最后退出

顺序很重要。典型的优雅关闭流程:

  1. 收到 SIGTERM,立刻停止接受新的连接(关闭监听 socket,或者从负载均衡摘除自己)。
  2. 把「正在运行的任务数」归零作为退出条件,同时启动一个宽限期计时器。
  3. 宽限期内:等在途请求完成,等缓冲区刷盘。
  4. 宽限期到或任务归零:关闭剩余连接,释放资源,退出,返回 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 是无效的,唯一可行的路径是:

  1. 找出它卡在哪个 IO 上(cat /proc/<pid>/stack、dmesg 看内核报错)。
  2. 如果是网络文件系统,尝试恢复底层连接(重启 NFS、拔掉故障设备)。
  3. 实在不行,只能重启机器。

D 状态进程是「系统性问题」的症状,不是进程本身的毛病,不要去纠结怎么杀它,要去修它等的东西。这类排查在前面讲 NFS 挂死的文章里有详细展开。

实操建议汇总

把上面这些浓缩成几条可以直接用的规则:

  1. 能用 systemctl restart 就别手动 kill。systemd 会按 KillMode 和 TimeoutStopSec 处理,比你手动操作规范得多。
  2. 停止服务首选 SIGTERM,把 SIGKILL 当成最后手段。强杀意味着放弃所有善后机会——日志没刷盘、缓存没写入、连接没关闭。
  3. 数据库、消息队列这类有状态服务,绝不能 kill -9。MySQL 被 SIGKILL 后下次启动可能要做崩溃恢复,严重时数据损坏。Redis 被强杀虽然有 AOF 兜底,但仍可能丢最后一段数据。
  4. 重启后端口占用,先用 ss -antp | grep :端口 看状态。是 TIME_WAIT 就等,是 CLOSE_WAIT 就去查程序。
  5. 自己写服务一定要处理 SIGTERM 并设置宽限期,宽限期要覆盖最长的请求处理时间。
  6. 所有服务的 TimeoutStopSec 都要根据业务调整,默认的 90 秒不一定适合你,太短会导致强杀,太长会拖慢部署。

小结

信号不是「杀进程」这么简单,它是进程与系统之间的一套协作协议。SIGTERM 是可协商的礼貌请求,SIGKILL 是内核的强制裁决。理解这个区别,你才能理解为什么优雅重启和强杀会导致完全不同的后果。

端口在 kill -9 后还被占,是这套机制的必然后果;CLOSE_WAIT 堆积是程序 bug 的信号;D 状态杀不掉是系统 IO 出了状况。这三件事看起来都像「进程杀不掉」,但根因完全不同,处理方法也完全不同。学会用 ss 看状态、用 /proc/<pid>/status 看信号掩码、用 strace 看阻塞点,这些「莫名其妙」的故障就会变得有迹可循。

Last modification:September 30th, 2026 at 01:28 pm

Leave a Comment