端口被占用:一次服务起不来的排查记录

从 ss -lntp 到 docker ps 的定位顺序,TIME_WAIT 与 LISTEN 的区别,以及一个隐蔽案例:端口配错导致健康检查打到别人的服务上、对方也返 200 造成「假健康」。

凌晨一点,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))

这一行里我要看三件事:

  1. State 必须是 LISTEN,不是 TIME-WAIT、也不是 ESTAB。只有 LISTEN 的 socket 才会挡住 bind。
  2. Local Address:Port 的地址部分。127.0.0.1:8090 只对本机可见——本机 curl 通、外面连不上,这是最容易误判成"服务正常"的一种写法;0.0.0.0:8090 是所有 IPv4 网卡;[::]:8090 是 IPv6 通配,在某些内核配置下也会占住 IPv4 的同一个端口。
  3. 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 参数。

五、我现在的固定排查顺序

  1. 看清是谁在听:sudo ss -lntp 'sport = :8090'。如果没有 LISTEN 行却依然报占用,直接跳到第 4 步。
  2. 看 PID 的年龄:ps -o pid,ppid,user,etime,cmd -p <pid>。etime 是几天前 → 残留进程,先 SIGTERM 等十秒,再考虑 -9。
  3. 占用者是 docker-proxy → docker ps --filter "publish=8090",再 -a 看一眼已停止的容器,然后决定是 docker rm -f 还是重启 daemon。
  4. 没有 LISTEN 却报占用 → 查 TIME_WAIT(ss -tan state time-wait 'sport = :8090')、查是不是容器内的 netns、查监听地址是不是被别的 netns 占了。
  5. 要换端口 → 别改代码里的常量,改环境变量,并核对那份端口分配表;同时确认监听地址是 0.0.0.0 还是 127.0.0.1。
  6. 起来之后立刻自己打一次健康检查(这一步千万别省):
curl -sS -m 3 "http://127.0.0.1:8090/healthz?expect=${INSTANCE_ID}" | jq .
  1. 核对探针和负载均衡那边的端口与实例 ID,跟第 6 步的输出对齐——服务端说自己是谁,和探针以为自己在探谁,必须永远是同一个答案。

六、现象对照表

现象最可能的原因一条命令验证
bind: address already in use,且 ss 里有 LISTEN残留进程,或另一个实例sudo ss -lntp 'sport = :8090'
同样报错,但 ss 里看不到 LISTEN权限不够看不到,或占用者在容器内/别的 netnssudo 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.1ss -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,而是"这个端口上回答我的,到底是不是我以为的那个东西"。

返回博客列表