线上服务起不来,最常见的报错之一是 bind: Address already in use。这时运维第一反应是 lsof -i:8080,但你是否遇到过:ss 明明显示端口被占用,lsof 却空空如也;或者反过来,lsof 列出一堆进程,ss 只给一个 LISTEN。两个工具走的是完全不同的内核数据源,理解差异才能写出靠谱的排查与清理脚本。本文给出一套可直接落地的 Shell 脚本,覆盖端口归属查询、两种工具输出差异比对、安全的进程清理以及常见故障兜底。
为什么 lsof 和 ss 会给出不一样的答案
先把底层搞清楚,否则脚本写得再花哨也是在猜。
lsof遍历的是/proc/<pid>/fd/下的文件描述符,逐个 stat 判断是否为 socket inode,再把 inode 反查回/proc/net/tcp。它回答的是「哪个进程打开了这个端口」。它的盲区是:没有权限查看的目标进程(非 root 运行、hidepid 挂载的 /proc)、内核线程、已经处于 CLOSE 状态但 fd 尚未释放的连接。ss直接读取/proc/net/tcp、/proc/net/tcp6、/proc/net/udp以及 netlink sock_diag 接口,回答的是「内核里有哪些 socket 处于什么状态」。它不关心进程,除非你加-p参数,而-p同样需要权限才能显示其他用户的进程。
由此产生三类典型的不一致:
| 现象 | 根因 | 验证方式 |
|---|---|---|
| ss 有记录,lsof 无输出 | 非 root 执行 lsof,无法读取他人 fd;或 socket 处于 TIME_WAIT,已无 fd 归属 | sudo lsof -i:PORT 对比;ss -tan state time-wait |
| lsof 有进程,ss 看不到 | 端口在 127.0.0.1 与 0.0.0.0 上分别监听,ss 过滤条件写窄了;或用了 -l 只看 LISTEN |
ss -tanp | grep :PORT 不加状态过滤 |
| 都看不到,但 bind 失败 | 端口落在 ip_local_port_range 内被内核临时占用;或处于 SO_REUSEADDR 未生效的 TIME_WAIT |
sysctl net.ipv4.ip_local_port_range |
一个容易踩的坑:
lsof -i:8080里的-i默认会做端口号或服务名的双重匹配。/etc/services里若有http-alt 8080/tcp之类的条目,某些发行版上行为会有细微差别,显式写-iTCP:8080更稳妥。
基础排查:两条命令的正确姿势
lsof 侧
# 只看 TCP,只看该端口,输出 Pid/User/Command 三列
lsof -nP -iTCP:8080 -sTCP:LISTEN
# 参数说明:
# -n 不解析主机名(避免 DNS 卡顿,排查时非常重要)
# -P 不把端口号翻译成服务名
# -iTCP:8080 协议+端口
# -sTCP:LISTEN 只看监听态,排除 ESTABLISHED 干扰
# 若怀疑是 UDP
lsof -nP -iUDP:53
如果返回 lsof: WARNING: can't stat() fuse.gvfsd-fuse file system 这类噪音,用 lsof -nP -iTCP:8080 2>/dev/null 或加 -w 抑制警告。若返回空但确实起不来,先 sudo 重试再下结论。
ss 侧
# -t TCP -a 全部状态 -n 数字端口 -p 显示进程 -l 只看 LISTEN
ss -tanp | grep -w ':8080'
# 只看监听态,且输出更紧凑
ss -tlnp 'sport = :8080'
# 统计该端口各状态连接数(排查连接堆积很有用)
ss -tan 'sport = :8080' | awk 'NR>1{print $1}' | sort | uniq -c | sort -rn
ss 的过滤表达式语法是 sport/dport 加比较符,比 grep 精确,能避免 :8080 误匹配到 :18080。注意 sport 指本地端口,dport 指对端端口,排查服务监听时用 sport。
核心脚本:双工具比对与归属确认
目标:输入端口号,输出「ss 看到的状态集合 + lsof 看到的进程集合 + 两者差异提示」,并且在没有 root 权限时明确告知结论不可信。
#!/usr/bin/env bash
# port_probe.sh —— 端口占用排查,lsof/ss 交叉验证
set -uo pipefail
PORT="${1:-}"
if [[ -z "$PORT" || ! "$PORT" =~ ^[0-9]+$ ]]; then
echo "用法: $0 <port> (例: $0 8080)" >&2
exit 64
fi
IS_ROOT=0
[[ $(id -u) -eq 0 ]] && IS_ROOT=1
echo "=== 端口 $PORT 排查 $(date '+%F %T') 用户=$(id -un) ==="
# ---------- 1. ss 视角 ----------
echo
echo "--- [ss] 内核 socket ---"
SS_OUT=$(ss -tanp "sport = :$PORT" 2>/dev/null | tail -n +2)
if [[ -z "$SS_OUT" ]]; then
echo "ss: 无匹配记录"
else
printf '%s\n' "$SS_OUT"
echo
echo "状态分布:"
awk '{print $1}' <<<"$SS_OUT" | sort | uniq -c | sort -rn | sed 's/^/ /'
fi
# ---------- 2. lsof 视角 ----------
echo
echo "--- [lsof] 进程归属 ---"
LSOF_OUT=$(lsof -nP -w -iTCP:"$PORT" 2>/dev/null | tail -n +2)
if [[ -z "$LSOF_OUT" ]]; then
echo "lsof: 无匹配记录"
else
printf '%s\n' "$LSOF_OUT"
fi
# ---------- 3. 差异判定 ----------
echo
echo "--- [对比结论] ---"
SS_HAS=0; [[ -n "$SS_OUT" ]] && SS_HAS=1
LSOF_HAS=0; [[ -n "$LSOF_OUT" ]] && LSOF_HAS=1
if [[ $SS_HAS -eq 1 && $LSOF_HAS -eq 0 ]]; then
echo "ss 有记录但 lsof 无结果。可能原因:"
echo " 1) 无权限读取目标进程 fd(当前 root=$IS_ROOT)"
echo " 2) 仅为 TIME_WAIT/CLOSE_WAIT,无 fd 归属"
echo " 3) socket 属于已退出的进程残留 inode"
elif [[ $SS_HAS -eq 0 && $LSOF_HAS -eq 1 ]]; then
echo "lsof 有结果但 ss 无记录,检查是否端口/协议过滤写错(UDP/6)。"
elif [[ $SS_HAS -eq 1 && $LSOF_HAS -eq 1 ]]; then
echo "两者均有记录,结论一致:端口确实被进程占用。"
else
echo "两者均无记录。若 bind 仍失败,检查:"
echo " - 端口是否在 ip_local_port_range 内被临时占用"
echo " - 是否 IPv6-only 监听(试 ss -tanp6 'sport = :$PORT')"
echo " - SELinux/AppArmor 拦截"
fi
# ---------- 4. TIME_WAIT 专项 ----------
TW=$(ss -tan state time-wait "sport = :$PORT" 2>/dev/null | tail -n +2 | wc -l)
[[ "$TW" -gt 0 ]] && echo "提示: 存在 $TW 个 TIME_WAIT 连接(通常 60s 后自动回收)"
exit 0
几个实现细节值得说明:
set -uo pipefail而不加-e:排查脚本里 lsof 无结果返回 1 是正常分支,加-e会误退出。tail -n +2去掉表头,便于后续 awk 处理。- 用
"sport = :$PORT"交给 ss 自己解析,比 grep:8080精确得多,能避免端口号子串误伤。 - 退出码 64 表示用法错误,符合
sysexits.h约定。
自动清理:先分类,再动手
直接 kill -9 是运维事故的常见起点。合理的清理流程是:识别进程 → 判断是否可安全终止 → 优雅终止 → 超时升级 → 复核释放。默认应该只做 dry-run。
#!/usr/bin/env bash
# port_release.sh —— 端口占用进程清理(默认 dry-run)
set -uo pipefail
PORT="${1:-}"
MODE="${2:-check}" # check | apply
GRACE="${GRACE:-10}" # 优雅终止等待秒数
PROTECT="sshd|systemd|systemd-journal|dbus-daemon|containerd|dockerd"
[[ -z "$PORT" || ! "$PORT" =~ ^[0-9]+$ ]] && { echo "用法: $0 <port> [check|apply]"; exit 64; }
[[ "$MODE" != "check" && "$MODE" != "apply" ]] && { echo "MODE 只能是 check 或 apply"; exit 64; }
if [[ $(id -u) -ne 0 ]]; then
echo "警告: 非 root 运行,可能读不全进程归属,建议 sudo。" >&2
fi
# 取占用该端口的 PID 列表(去重、去空)
PIDS=$(lsof -nP -w -t -iTCP:"$PORT" -sTCP:LISTEN 2>/dev/null | sort -u)
if [[ -z "$PIDS" ]]; then
# lsof 拿不到时用 ss 兜底
PIDS=$(ss -tlnp "sport = :$PORT" 2>/dev/null \
| grep -oP 'pid=\K[0-9]+' | sort -u)
fi
if [[ -z "$PIDS" ]]; then
echo "端口 $PORT 未发现监听进程,无需清理。"
exit 0
fi
echo "端口 $PORT 上的监听进程:"
CLEAN=()
for pid in $PIDS; do
[[ -d "/proc/$pid" ]] || continue
name=$(tr -d '\0' < "/proc/$pid/comm" 2>/dev/null)
cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | cut -c1-120)
if [[ -z "$name" ]]; then
echo " PID $pid <已退出>"
continue
fi
printf ' PID %-7s %-18s %s\n' "$pid" "$name" "$cmdline"
if [[ "$name" =~ ^($PROTECT)$ ]]; then
echo " ↑ 命中保护名单,跳过"
continue
fi
CLEAN+=("$pid")
done
if [[ ${#CLEAN[@]} -eq 0 ]]; then
echo "没有符合条件的可清理进程。"
exit 0
fi
if [[ "$MODE" == "check" ]]; then
echo
echo "[dry-run] 将终止以下 PID: ${CLEAN[*]}"
echo "确认无误后执行: $0 $PORT apply"
exit 0
fi
# ---- apply ----
for pid in "${CLEAN[@]}"; do
kill -TERM "$pid" 2>/dev/null || { echo "PID $pid 发送 TERM 失败"; continue; }
done
# 等待优雅退出
for _ in $(seq 1 "$GRACE"); do
alive=0
for pid in "${CLEAN[@]}"; do [[ -d "/proc/$pid" ]] && alive=1; done
[[ $alive -eq 0 ]] && break
sleep 1
done
# 超时补刀
for pid in "${CLEAN[@]}"; do
if [[ -d "/proc/$pid" ]]; then
echo "PID $pid 未在 ${GRACE}s 内退出,发送 KILL"
kill -KILL "$pid" 2>/dev/null
fi
done
sleep 1
# 复核
if ss -tln "sport = :$PORT" 2>/dev/null | tail -n +2 | grep -q .; then
echo "清理后端口 $PORT 仍处于监听状态,请人工介入。"
ss -tlnp "sport = :$PORT"
exit 1
fi
echo "端口 $PORT 已释放。"
关键设计点:
- 默认 dry-run:不带
apply参数只打印计划,避免手滑。生产环境建议把这个脚本包一层审批。 - 保护名单:
sshd、systemd、dockerd这类进程被误杀会导致整机失联或容器全挂。名单按你的环境调整,但底线是绝不动 init 与远程登录。 - TERM 优先,超时 KILL:给应用机会执行优雅关闭释放资源、flush 日志;
kill -9只在超时后作为兜底。 - 复核而非假设:清理后重新
ss -tln确认,因为可能存在多个 PID 同时监听同一端口(SO_REUSEPORT),也可能有 systemd 自动拉起。 - ss 兜底:当 lsof 因权限拿不到 PID 时,从
ss -p的pid=字段提取。
真实报错与处置速查
| 报错/输出 | 含义 | 处置 |
|---|---|---|
bind: Address already in use |
端口已被占用或处于不可复用状态 | 先用 port_probe.sh 确认归属,再决定 kill 或改端口 |
lsof: command not found |
最小化镜像未装 lsof | Debian/Ubuntu: apt install -y lsof;RHEL/CentOS: yum install -y lsof;或干脆只用 ss |
lsof: WARNING: can't stat() ... |
挂载了 fuse 文件系统,lsof 遍历 fd 时告警 | 加 -w 抑制,或 2>/dev/null |
ss: Invalid expression |
过滤表达式语法错,如 port = 8080 少了 s 前缀 |
改用 sport = :8080 |
ss 显示 *:8080 |
监听在 0.0.0.0(IPv4 全部地址) | 注意与 [::]:8080(IPv6)区分,ss -tanp6 单独看 |
Permission denied(ss -p) |
非 root 无法读他人进程信息 | sudo ss -tlnp |
| 进程杀了又起 | systemd / supervisor 自动重启 | systemctl stop xxx 而非 kill,或先 systemctl status $(systemctl status | ...) 查归属 |
| TIME_WAIT 堆积到数万 | 短连接过多 | net.ipv4.tcp_tw_reuse=1(仅影响出站连接),并考虑改服务端连接复用;不要开 tcp_tw_recycle,新内核已移除 |
把它做成一条运维流水线
两个脚本分开用略显笨重,实际落地时可以这样串联:
# 1) 发布失败时自动探测
./port_probe.sh 8080 > /tmp/port_8080.log 2>&1
# 2) 人工确认后清理
./port_release.sh 8080 check # 先看计划
sudo ./port_release.sh 8080 apply # 再执行
# 3) 在部署脚本里加前置检查(不自动杀,只fail fast)
if ss -tln 'sport = :8080' | tail -n +2 | grep -q .; then
echo "端口 8080 被占用,中止部署:" >&2
ss -tlnp 'sport = :8080' >&2
exit 1
fi
另外,把「端口 → 所属服务」的映射沉淀成配置文件(例如 /etc/opsman/ports.yaml),在保护名单基础上再做一层业务白名单,比纯 PID 判断更安全。清理动作建议接入审计日志,记录操作人、时间、PID、cmdline,事后能回溯。
小结
lsof 回答「谁打开了它」,ss 回答「内核里它是什么状态」,两者数据源不同、权限要求不同,单看任何一个都可能误判。可靠的排查脚本必须做交叉验证并明确告知「结论是否可信」;可靠的清理脚本必须默认 dry-run、有保护名单、先 TERM 后 KILL、清理后复核。把这两件事拆成 probe 与 release 两个脚本,比写一个大而全的「一键修复」工具更容易维护,也更不容易出事。
FAQ
Q1:为什么 sudo lsof 和 ss 都看不到占用,但服务 bind 还是失败?
三种可能:一是端口在 net.ipv4.ip_local_port_range(默认常见 32768–60999)范围内,被内核分配给某个出站连接临时用了,用 sysctl net.ipv4.ip_local_port_range 确认并避开该区间;二是 IPv6-only 监听,需要 ss -tanp6 'sport = :8080';三是 SELinux/AppArmor 拒绝了 bind,用 ausearch -m avc -ts recent 或 dmesg | grep -i denied 查。
Q2:SO_REUSEPORT 下多个进程监听同一端口,脚本只杀一个怎么办?
本文脚本取的是 lsof -sTCP:LISTEN 的全部 PID 并去重,会全部列出。若你的环境用 SO_REUSEPORT 做多进程负载均衡,注意逐个 TERM 后 kernel 会重新分配,确认全部退出再复核端口是否释放。
Q3:kill -TERM 后进程变成僵尸(<defunct>),算清理成功吗?
算。僵尸进程的 fd 与端口已释放,/proc/<pid> 仍存在只是因为父进程还没 wait。用 ps -o stat= -p <pid> 看到 Z 即可确认,此时 ss 不会再显示监听。要彻底消除僵尸,只能处理父进程。
Q4:脚本在生产上跑,怎样防止误杀?
四层防护:默认 dry-run 必须显式 apply;PROTECT 正则保护关键进程;结合业务配置白名单,只允许清理登记过的服务;apply 前把计划写审计日志并做一次人工确认(或走变更工单)。另外避免在脚本里用 pkill -f 按命令行模糊匹配,极易误伤。