凌晨一点,systemctl restart msg-gateway 回了句 listen tcp :8090: bind: address already in use。这个报错我见得太多了,多到我的第一反应不是慌,而是敲 ss -lntp | grep 8090。这篇把这次和之前几次的经历记一遍——"端口被占用"从来不是一种问题,而是三类:你自己残留的进程、Docker 的端口映射,以及最阴的第三类——端口配错到别人身上,健康检查永远是 200。
一、先看清到底是谁在 LISTEN
ss -lntp | grep 8090
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 127.0.0.1:8090 0.0.0.0:* users:(("msg-gateway",pid=28411,fd=6))
这一行里我要看三件事:
State必须是LISTEN,不是TIME-WAIT、也不是ESTAB。只有LISTEN的 socket 才会挡住bind。Local Address:Port的地址部分。127.0.0.1:8090只对本机可见——本机curl通、外面连不上,这是最容易误判成"服务正常"的一种写法;0.0.0.0:8090是所有 IPv4 网卡;[::]:8090是 IPv6 通配,在某些内核配置下也会占住 IPv4 的同一个端口。Process列给出 PID 和程序名,但它需要 root。普通用户看不到别人的进程,那一列会显示成-。所以"ss里没有 Process 列"不等于"没人占用",只等于"你没权限看"。
更省的写法是不用管道过滤,让内核帮你筛:
ss -lntp 'sport = :8090' # -n 不做服务名解析,否则 8090 会显示成 http-alt
sudo ss -lntp 'sport = :8090' # 看别人的进程必须 sudo
同类工具,我按顺手程度排:
lsof -i:8090,加上-P -n关掉端口名和主机名解析会快很多;lsof -iTCP:8090 -sTCP:LISTEN,只列监听中的;fuser -n tcp 8090只吐 PID,fuser -vn tcp 8090更详细,同样需要 root 才能看到别人的进程;netstat -tlnp,老机器上只有它。
拿到 PID 之后第一句话是看它的年龄和归属:
ps -o pid,ppid,user,etime,cmd -p 28411
etime 那一列特别有用。如果它显示 4-07:22:13(四天前启动),基本可以断定是上次部署没退干净的残留进程,而不是"别的服务抢占"。这次就是——四天前我手动 nohup 起过一个老版本,后来 systemd 换了新 unit,老进程谁都没管。
二、TIME_WAIT 不是"被占用"
"服务起不来,ss 里一堆 TIME-WAIT,所以端口被占了"——这个因果链是错的,但它每年都会让我在群里解释几次。
ss -tan state time-wait 'sport = :8090' | head
看清楚:TIME-WAIT 那几行里,Local Address 是本机的临时端口(你的客户端端口),Peer Address 才是 :8090。它占的是"四元组",不是监听端口,不会阻止 bind。
为什么不会?因为 Linux 上 SO_REUSEADDR 允许在地址处于 TIME_WAIT 时继续 bind(它原本的用途就是让服务端重启不被旧连接卡住),而 Go 的 net.Listen("tcp", ...) 在 Unix 上默认就会设置它。所以"服务重启偶尔报 address already in use"通常不是 TIME_WAIT 造成的,而是真的有别的进程在 LISTEN。SO_REUSEPORT 是另一码事:它允许同端口上同时存在多个监听的 socket,由内核做负载均衡——那是给多进程模型用的,不是拿来消 TIME_WAIT 的。
那 TIME_WAIT 什么时候真会变成问题?当你的程序是客户端、高频连同一个下游时:本机临时端口(net.ipv4.ip_local_port_range)被 TIME_WAIT 占住,新连接报 cannot assign requested address。这跟"服务起不来"是两码事,处理方向完全不同:
ss -s
sysctl net.ipv4.ip_local_port_range
sysctl net.ipv4.tcp_tw_reuse
tcp_tw_reuse=1 对主动发起连接的一侧是安全的(允许复用到 TIME_WAIT 的端口);tcp_tw_recycle 在新内核里已经被移除,而且它在 NAT 后面会丢包,任何场景都不要开。
ss 里的状态 | 会不会挡住 bind | 该怎么办 |
|---|---|---|
LISTEN | 会 | 找到进程,杀它或者换端口 |
TIME-WAIT | 不会(有 SO_REUSEADDR) | 什么都不用做;若是客户端侧临时端口耗尽,调 ip_local_port_range 或开 tcp_tw_reuse |
大量 CLOSE-WAIT | 不会 | 应用没关连接(泄漏),去查代码,不是查端口 |
大量 SYN-SENT | 不会 | 对端不可达或被防火墙 DROP,去查网络 |
三、Docker:ss 里那个进程叫 docker-proxy
如果占用者那一列写的不是你的应用,而是 docker-proxy,那答案就换成另一套命令了:
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' | grep 8090
docker ps -a --filter "publish=8090" # 连已停止的一起看
docker port msg-gateway
{{.Ports}} 那一列要看的就是 0.0.0.0:8090->8090/tcp 这种形状:箭头左边是宿主端口,右边是容器端口。只要宿主侧出现你要的端口,你就起不来了。
我在这条路上踩过两个分支:
一是容器停了,docker-proxy 还在。 早期 Docker 上 docker stop 之后 userland proxy 偶尔不退,宿主端口一直占着,ss 里那一行长这样:
LISTEN 0 128 0.0.0.0:8090 0.0.0.0:* users:(("docker-proxy",pid=3112,fd=4))
处理办法是 sudo systemctl restart docker(会清干净),或者在 daemon.json 里关掉它:
{ "userland-proxy": false }
关掉之后端口转发交给 iptables DNAT,好处是少一跳、少一类残留进程,代价是"从本机访问 127.0.0.1:8090"这类行为要重新确认一遍(DNAT 和老版本的 hairpin 行为不完全一样)。
二是同一个 compose 文件被用不同项目名起了两份。 docker compose ls -a 一眼能看出来:msg-gateway 和 msg_gateway 两个 project 各自映射了 8090,第二个 up 会失败,或者其中一个悄悄换了端口。这类问题在第一个人离职之后特别常见。
还有一个容易搞混的场景:容器用 --network host 时,ss 里看到的是容器内进程在宿主 PID 命名空间里的 PID,可以直接对上号:
docker inspect -f '{{.State.Pid}} {{.Name}}' $(docker ps -q)
反过来,如果报错发生在容器内部,宿主的 ss 是看不见的——那是另一个网络命名空间:
docker exec -it msg-gateway ss -lntp
nsenter -t $(docker inspect -f '{{.State.Pid}}' msg-gateway) -n ss -lntp
四、最隐蔽的一类:端口配错,健康检查打到别人的服务上
这是我最想写下来的一段,因为它的现象是"监控全绿",比 502 难发现得多。
消息服务项目的网关 msg-gateway 对外监听 8090,监控系统每 30 秒探一次 /healthz,返回 200 就算健康。某个下午,网关因为一个配置解析的问题没起来——但监控面板一直是绿的。等我收到用户投诉"消息发不出去",已经过去大概四十分钟。
复盘时才发现:探针的地址是几年前写进监控模板的 http://10.0.1.7:8080/healthz,而网关在某次端口调整时从 8080 改成了 8090。8080 后来被另一个服务(一台旧推送服务)占用了,那个服务也有 /healthz,也返回 200。于是:
- 探测成功,告警不响;
- 探测的根本不是我,可它没有任何理由失败。
这类问题真正麻烦的地方在于:告警系统的前提是"失败才响",而这个场景里不存在任何一次失败可供它看见。 加再多重试、再加十条告警规则都没用——问题出在"成功"的定义上。
我现在的防护有三条,从便宜到贵:
1. 健康检查带身份参数。 探针请求 /healthz?expect=<实例ID>,实例 ID 从环境变量或启动参数读,不匹配就返回非 200:
var instanceID = os.Getenv("INSTANCE_ID") // 如 msg-gateway-01
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if want := r.URL.Query().Get("expect"); want != "" && want != instanceID {
w.WriteHeader(http.StatusConflict) // 409
_ = json.NewEncoder(w).Encode(map[string]string{
"expect": want, "got": instanceID, "listening": listenAddr,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true, "instance_id": instanceID,
"build": buildVersion, "started_at": startedAt,
})
})
要点是带 expect 时严格、不带时宽松:人用浏览器点 /healthz 依然能一眼看到它是谁,而探针永远带上 expect,于是"打到别人身上"从"静默成功"变成"确定失败"。代价只是探针模板里多一个参数,收益是这个故障模式被彻底消灭。
2. 探针不能只看状态码。 顺手把响应体里的身份也校验掉,用 curl 加 jq 就够:
curl -sS -o /tmp/hz.json -w '%{http_code}\n' \
"http://10.0.1.7:8090/healthz?expect=msg-gateway-01" \
&& jq -e '.instance_id == "msg-gateway-01" and .ok == true' /tmp/hz.json
jq -e 在条件不成立时返回非零,正好就是这条检查的退出码,直接塞进任何探针框架都能用。
3. 启动日志必须自报家门。 现在我的服务起来一定会打这么一行:
level=INFO msg="listening" addr=0.0.0.0:8090 instance=msg-gateway-01 build=v1.8.3-9f2c1
这一行让"端口配错了"从"要推理半小时"变成"grep listening 一眼看到"。部署脚本现在会等这一行,等不到就当启动失败——比"进程还在就认为起来了"可靠得多。
另外补一句健康检查本身的设计:liveness 和 readiness 要分开。/healthz 只回答"我这个进程还能不能干活",绝对不要在里面查数据库——否则数据库抖一下,全集群被重启,这是我去年自己制造的一次故障。查依赖(DB ping、Redis ping)放进 /readyz,失败就从负载均衡摘掉,但不重启。两个端点都带上 expect 参数。
五、我现在的固定排查顺序
- 看清是谁在听:
sudo ss -lntp 'sport = :8090'。如果没有LISTEN行却依然报占用,直接跳到第 4 步。 - 看 PID 的年龄:
ps -o pid,ppid,user,etime,cmd -p <pid>。etime是几天前 → 残留进程,先SIGTERM等十秒,再考虑-9。 - 占用者是
docker-proxy→docker ps --filter "publish=8090",再-a看一眼已停止的容器,然后决定是docker rm -f还是重启 daemon。 - 没有
LISTEN却报占用 → 查 TIME_WAIT(ss -tan state time-wait 'sport = :8090')、查是不是容器内的 netns、查监听地址是不是被别的 netns 占了。 - 要换端口 → 别改代码里的常量,改环境变量,并核对那份端口分配表;同时确认监听地址是
0.0.0.0还是127.0.0.1。 - 起来之后立刻自己打一次健康检查(这一步千万别省):
curl -sS -m 3 "http://127.0.0.1:8090/healthz?expect=${INSTANCE_ID}" | jq .
- 核对探针和负载均衡那边的端口与实例 ID,跟第 6 步的输出对齐——服务端说自己是谁,和探针以为自己在探谁,必须永远是同一个答案。
六、现象对照表
| 现象 | 最可能的原因 | 一条命令验证 |
|---|---|---|
bind: address already in use,且 ss 里有 LISTEN | 残留进程,或另一个实例 | sudo ss -lntp 'sport = :8090' |
同样报错,但 ss 里看不到 LISTEN | 权限不够看不到,或占用者在容器内/别的 netns | sudo ss -lntp;docker ps --filter publish=8090 |
ss 的进程列是 docker-proxy | 宿主端口被容器映射走了 | docker ps --format 'table {{.Names}}\t{{.Ports}}' | grep 8090 |
容器已 Exited,端口仍被占 | userland proxy 残留 | ss -lntp | grep 8090,然后 systemctl restart docker |
本机 curl 通、外部不通 | 只监听了 127.0.0.1 | ss -lntp | grep 8090,看 Local Address 列 |
ss 里一堆 TIME-WAIT | 不是占用,是已关闭的连接 | ss -tan state time-wait 'sport = :8090' | wc -l;ss -s |
报 cannot assign requested address | 本机临时端口用尽 | sysctl net.ipv4.ip_local_port_range;ss -s |
| 监控全绿,用户说不可用 | 探针打到了别的服务(假健康) | curl -sS "http://host:port/healthz?expect=<实例ID>",看是否返回 409 |
| 探针长期 200,重启才短暂恢复 | 探针只判状态码,没校验身份和依赖 | curl .../healthz | jq -e '.instance_id == "<实例ID>"' |
| 部署后脚本认为服务"已启动",实际没起来 | 只看进程存在,没等自报家门的那一行日志 | grep -m1 'msg="listening"' app.log,核对 addr 与 instance |
凌晨那次最后是怎么结的?ss 里 8090 的占用者,是一个 etime 显示 4-07:22:13 的老 msg-gateway,四天前部署改端口之前手动起的。杀掉之后服务一秒就起来了。
但真正让我在意的不是它,而是前一天那次"监控全绿、用户说不可用"。端口冲突会大声报错,配错的探针不会——它安安静静地给你一个 200,然后什么都不告诉你。 所以现在我看到"端口"两个字,第一个想到的不是 lsof,而是"这个端口上回答我的,到底是不是我以为的那个东西"。