端口明明没人用,为什么 bind 还是失败:从 Socket 到 TIME_WAIT

#Linux#TCP#Socket 共 6,401 字 约 21 分钟

写 Web 项目时,我遇到过一个很烦的报错:服务刚停掉,再启动就说端口被占用。

Text
UTF-8|1 Line|
OSError: [Errno 98] Address already in use

我先查进程,没有看到刚才的程序;又等了几秒,还是起不来。最直接的想法当然是把相关进程全杀掉,实在不行就换个端口。这个办法有时能解决问题,但它没有回答最奇怪的地方:进程都退出了,谁还在“占着”这个端口?

后来我发现,“某个进程占用了某个端口”只是方便人理解的说法。内核真正管理的是 Socket,一个 Socket 也不只由端口号决定。监听地址、传输层协议、连接两端的 IP 和端口、TCP 当前状态,都会影响一次 bind 能不能成功。

这篇文章就从一次常见的端口冲突开始,把这些容易混在一起的概念拆开。

端口不是插座,进程也不会直接拥有端口

我以前把端口理解成电脑背后的一排虚拟插座:一个程序插进 8000,其他程序就不能再插。这个比喻适合入门,却解释不了下面几种现象:

  • TCP 服务和 UDP 服务可以同时使用数字 53
  • 同一台机器的两个不同 IP 可以分别监听 8000
  • 一个 Web 服务器只有一个 443 监听端口,却能同时服务几千条连接
  • 程序已经退出,ss 里仍然能看到与它有关的连接状态

更准确的说法是:程序创建 Socket,内核为 Socket 保存协议、地址、状态和缓冲区等信息。程序拿到的只是一个文件描述符,然后通过这个描述符要求内核执行 bindlistenacceptconnectsendrecv

下面是一段最小的 TCP 服务器:

Python
UTF-8|14 Lines|
# server.py
import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("127.0.0.1", 8000))
server.listen(16)

print("listening on 127.0.0.1:8000")

while True:
    client, address = server.accept()
    with client:
        print("accepted", address)
        client.sendall(b"hello\n")

运行它:

Bash
UTF-8|1 Line|
python3 server.py

这里的 8000 不是直接塞给进程的。socket() 先让内核创建一个 TCP Socket,bind() 再申请把本地地址 127.0.0.1:8000 绑定到它上面,listen() 最后把它变成监听 Socket。

因此,报错发生在 bind() 时,意思不是“数字 8000 永远只能属于一个进程”,而是“你申请的这组本地地址与内核中已有的 Socket 冲突,而且当前复用规则不允许这样绑定”。

先别急着杀进程,用 ss 看内核到底记录了什么

Linux 上排查端口,我现在先用 ss

Bash
UTF-8|1 Line|
ss -lntp

几个参数分别是:

参数含义
-l只看监听中的 Socket
-n直接显示数字地址和端口,不做名称解析
-t只看 TCP
-p显示关联进程,需要权限时加 sudo

只查 8000 可以写:

Bash
UTF-8|1 Line|
ss -lntp 'sport = :8000'

也可以用 lsof 从文件描述符的角度查:

Bash
UTF-8|1 Line|
sudo lsof -nP -iTCP:8000

如果输出里有 LISTEN,那通常确实有服务正在监听。先确认它是谁,再决定停止服务、修改配置还是换端口。不要一看到 PID 就 kill -9,因为它可能是由 systemd、Docker 或进程管理器拉起的。你刚杀掉,管理器下一秒又把它启动,看起来就像端口会“复活”。

比较温和的处理方式是先查进程:

Bash
UTF-8|3 Lines|
ps -fp 进程号
sudo systemctl status 服务名
docker ps --format 'table {{.ID}}\t{{.Ports}}\t{{.Names}}'

确认来源之后,用对应的管理方式停止它。kill -9 不给程序清理资源、写完日志和保存状态的机会,应该留给普通终止信号失效的情况。

为什么 netstat 还能用,但更推荐 ss

netstat 来自较老的 net-tools 工具集,很多系统仍然装着它。ss 属于 iproute2,直接读取内核网络信息,过滤表达式也更好用。排查思路相同,不必为了工具争论,能看清 Socket 状态才是目的。

只盯着端口号,会漏掉协议和监听地址

数字相同,不代表一定冲突。最简单的例子是 TCP 和 UDP。

DNS 经常使用 53 端口,但 UDP 53 和 TCP 53 是两套不同的 Socket 空间。UDP 适合多数普通查询,TCP 会用于响应较大、区域传送等场景。一个 DNS 服务可以同时监听两者:

Text
UTF-8|2 Lines|
udp   0.0.0.0:53
tcp   0.0.0.0:53

因此排查时要先问:报错的是 TCP 还是 UDP?

Bash
UTF-8|2 Lines|
ss -lnup 'sport = :53'   # UDP
ss -lntp 'sport = :53'   # TCP

第二个容易漏掉的是本地 IP。下面两次绑定并不完全相同:

Python
UTF-8|2 Lines|
server.bind(("127.0.0.1", 8000))
server.bind(("0.0.0.0", 8000))

127.0.0.1 只接收本机回环接口上的连接,其他机器访问不了。0.0.0.0 不是一个让客户端访问的真实地址,它表示监听本机所有 IPv4 接口,包括回环地址、局域网地址和之后可能新增的地址。

假设进程 A 已经监听 0.0.0.0:8000,进程 B 再申请 127.0.0.1:8000,通常会冲突。因为 A 申请的“所有地址”已经覆盖了 127.0.0.1。反过来也要看绑定顺序、Socket 选项和操作系统规则,不能只凭端口数字下结论。

一台服务器如果有多个明确的 IP,则可以让不同服务分别绑定:

Text
UTF-8|2 Lines|
192.168.1.10:8000  -> 服务 A
192.168.1.11:8000  -> 服务 B

它们端口相同,本地地址不同,所以可以共存。这也是反向代理、多网卡服务器和网络命名空间中经常出现的情况。

IPv6 的双栈行为,会制造一种很隐蔽的冲突

有一次我查 0.0.0.0:3000 什么也没看到,程序还是报占用。后来在 ss 输出里发现了:

Text
UTF-8|1 Line|
[::]:3000

[::] 对 IPv6 的意义类似 IPv4 的 0.0.0.0,表示所有 IPv6 地址。麻烦在于,IPv6 Socket 是否顺便接收 IPv4 连接,取决于系统的 IPV6_V6ONLY 设置和程序行为。

在允许 IPv4 映射的双栈模式下,一个监听 [::]:3000 的 Socket 可能已经覆盖 IPv4 的 3000。此时再绑定 0.0.0.0:3000 就会失败。另一些系统默认只让它处理 IPv6,两者又能同时存在。

所以端口冲突不能只查一条命令:

Bash
UTF-8|2 Lines|
ss -lntp 'sport = :3000'
sysctl net.ipv6.bindv6only

写跨平台服务时,也不要假设 Linux、Windows 和 macOS 的默认双栈行为完全一样。明确创建 IPv4、IPv6 Socket,或者显式设置 IPV6_V6ONLY,比依赖默认值更可靠。

一个监听端口,为什么能同时接待很多客户端

如果端口真是独占插座,那么 Nginx 监听 443 后应该只能接一个浏览器。现实当然不是这样,因为 TCP 连接靠四元组区分:

Text
UTF-8|1 Line|
(源 IP, 源端口, 目的 IP, 目的端口)

假设服务器是 10.0.0.8:443,两个客户端来访问:

Text
UTF-8|2 Lines|
192.168.1.20:51001 -> 10.0.0.8:443
192.168.1.21:49812 -> 10.0.0.8:443

服务器端口都是 443,但源 IP 和源端口不同,四元组不同,内核就能把收到的数据交给正确的连接 Socket。

监听过程大致是:

Text
UTF-8|10 Lines|
客户端 connect


[监听 Socket: 0.0.0.0:443]
      │ accept 返回新的文件描述符

[连接 Socket: 192.168.1.20:51001 <-> 10.0.0.8:443]

      ├── 下一位客户端会得到另一个连接 Socket
      └── 原监听 Socket 继续等待新连接

accept() 不会把监听 Socket 交出去。它为已经建立的连接返回一个新的 Socket,原来的监听 Socket 继续守在 443。这就是一个端口服务大量连接的基础。

客户端的源端口通常由内核从临时端口范围里自动挑选。Linux 可以这样查看范围:

Bash
UTF-8|1 Line|
cat /proc/sys/net/ipv4/ip_local_port_range

常见输出类似:

Text
UTF-8|1 Line|
32768 60999

这也解释了为什么浏览器访问网站时,服务器看到的客户端端口总在变化。客户端不是在“监听”这些端口,它们只是连接四元组的一部分。

临时端口也会耗尽,问题看起来却像服务端坏了

我第一次知道客户端也会受端口限制,是在写一个并发请求脚本时。脚本短时间建立大量连接,运行一阵后,新请求开始随机失败。我去查目标服务器,CPU、内存和监听端口都正常,最后才发现本机积累了大量短连接状态。

客户端调用 connect() 时,如果没有手动绑定源端口,内核会从临时端口范围中挑一个。单看前面的示例范围,3276860999 大约有 2.8 万个数字,似乎很多。但并发连接数高、连接关闭频繁,又不断访问同一个目标地址时,这些端口会被活动连接和 TIME_WAIT 暂时占住。

Bash
UTF-8|3 Lines|
ss -s
ss -ant state time-wait | wc -l
cat /proc/sys/net/ipv4/ip_local_port_range

端口是否真的耗尽,不能只拿 TIME_WAIT 数量和临时端口总数做减法。不同目标 IP、目标端口对应不同四元组,内核还会考虑地址复用和连接状态。不过,如果压测机、爬虫或代理机持续创建短连接,这个方向值得检查。

更现实的瓶颈有时在 NAT 网关。局域网里很多机器访问外网时,会被转换成同一个公网 IP。对外部服务器来说,它们共享这个出口地址,需要靠不同源端口区分连接。单台电脑看起来连接不多,整个宿舍、公司或云上子网叠加后,出口设备的转换表和可用端口可能先撑不住。

解决这类问题,第一反应不该是缩短所有 TCP 状态时间。先看应用为什么反复建连接。HTTP Keep-Alive、数据库连接池、合理的并发上限,都能减少握手、关闭和临时端口消耗。连接池也不是越大越好,它只是把“无限创建连接”改成“有限复用连接”,池大小仍要结合下游承受能力。

压测时还要确认失败发生在哪一侧。服务端拒绝连接、客户端没有临时端口、DNS 查询超时、NAT 表满,表面上都可能表现为“请求发不出去”。把异常类型、ss 状态和抓包结果放在一起看,才不会把客户端资源问题误判成服务器端口冲突。

TIME_WAIT 不是残留进程,而是 TCP 故意留下的状态

现在回到最开始的问题。程序退出后,ss -lntp 可能已经没有监听者,但执行下面的命令还能看到记录:

Bash
UTF-8|1 Line|
ss -ant state time-wait

这些记录没有 PID,因为拥有文件描述符的进程可能早已结束。TIME_WAIT 是内核维护的 TCP 状态,不是某个偷偷活着的进程。

要理解它,先看一次正常关闭。TCP 是全双工的,双方发送方向要分别关闭。假设客户端主动关闭:

Text
UTF-8|7 Lines|
客户端                                      服务器
  │  FIN  ------------------------------->   │
  │  <------------------------------- ACK    │
  │  <------------------------------- FIN    │
  │  ACK  ------------------------------->   │
  │                                           │
TIME_WAIT                                  CLOSED

最后发出 ACK 的一方进入 TIME_WAIT。更准确地说,通常是主动关闭连接的一方进入这个状态。它要等待一段时间,传统描述是 2 倍 MSL,也就是报文在网络中允许存在的最大时间的两倍。具体持续多久由系统实现决定,Linux 上经常能观察到大约一分钟,但不要把这个数字当成所有系统的固定常量。

内核保留 TIME_WAIT 主要有两个原因。

第一个原因是保证最后一个 ACK 丢失时还能重发。假如客户端发出的最终 ACK 在路上丢了,服务器会再次发送 FIN。客户端仍保存连接状态,就知道应该补发 ACK。如果客户端立刻忘掉一切,服务器只能反复重试。

第二个原因是让旧连接中的延迟报文自然消失。假设四元组刚关闭就立刻被新连接完全复用,网络中迟到的旧数据包可能撞进新连接。TCP 序列号能挡住很多错误,但等待旧报文过期是更稳妥的一层保护。

TIME_WAIT 多不等于一定有故障

短连接很多的 HTTP 服务上,出现大量 TIME_WAIT 很常见。先看它在哪一端、增长速度、临时端口是否耗尽、连接是否应该复用,再判断是否需要优化。只因为数量看起来大就修改内核参数,容易把正常保护机制一起关掉。

为什么服务重启有时受 TIME_WAIT 影响,有时完全没事

我最初以为,只要看到 TIME_WAIT,它就一定阻止服务重新监听。这个说法不准确。

是否冲突要看旧连接的本地地址、端口、当前状态,新 Socket 的绑定地址,以及程序是否设置了地址复用选项。成熟的服务器通常会在 bind() 前设置 SO_REUSEADDR,这样服务重启时可以重新绑定监听地址,不必傻等所有旧连接消失。

Python 可以这样设置:

Python
UTF-8|6 Lines|
import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", 8000))
server.listen(16)

SO_REUSEADDR 的名字很容易让人误会成“允许任意两个程序抢同一个端口”。它并没有这么宽松。对 TCP 服务来说,它主要帮助处理重启和残留连接状态,具体规则仍与操作系统有关。一个没有设置兼容复用选项的活动监听 Socket,通常仍会阻止另一个普通 Socket 绑定相同地址。

另一个选项 SO_REUSEPORT 更接近“多个 Socket 同时监听同一个地址和端口”。Linux 可以把新连接分发给这些 Socket,常用于多进程服务器减少单个接受队列的竞争:

Text
UTF-8|3 Lines|
                 0.0.0.0:8080
                /      |      \
          worker 1  worker 2  worker 3

但它要求参与者都按规则设置选项,也带来负载分配、版本兼容和安全方面的考虑。为了逃避一次端口冲突就随手打开它,不是好办法。

用一个小实验观察连接状态

可以用前面的 server.py,再开一个终端连接:

Bash
UTF-8|1 Line|
nc 127.0.0.1 8000

如果没有 nc,也可以用 Python:

Bash
UTF-8|1 Line|
python3 -c "import socket; s=socket.create_connection(('127.0.0.1',8000)); print(s.recv(100)); s.close()"

同时观察 8000 相关的全部 TCP Socket:

Bash
UTF-8|1 Line|
watch -n 0.5 "ss -antp 'sport = :8000 or dport = :8000'"

你可能依次看到 LISTENESTABFIN-WAIT-1FIN-WAIT-2CLOSE-WAITLAST-ACKTIME-WAIT。具体顺序取决于哪一端先关闭、程序何时退出以及命令采样有没有赶上短暂状态。

这里最值得观察的是:监听 Socket 和连接 Socket 可以同时存在,它们只是共享了本地端口。关闭一条已建立连接,也不等于关闭监听 Socket。

如果想看是谁主动关闭,可以抓回环接口上的包:

Bash
UTF-8|1 Line|
sudo tcpdump -i lo -nn 'tcp port 8000'

重点找第一个带 FIN 的报文。先发 FIN 的一方通常会在最终确认后进入 TIME_WAIT。把 ss 的状态和 tcpdump 的报文对上,比死记 TCP 状态图有效得多。

CLOSE_WAIT 才更像是程序忘了收尾

排查时还会见到另一个名字很像的状态:CLOSE_WAIT。它和 TIME_WAIT 的含义完全不同。

CLOSE_WAIT 表示对方已经发来 FIN,本机内核也确认了,但本机程序还没有调用 close() 关闭自己的方向。换句话说,网络对端说“我发完了”,本地程序却还握着 Socket 文件描述符不放。

如果少量出现并很快消失,可能只是正常处理过程。如果大量堆积且长时间不消失,常见原因是代码遗漏关闭、异常分支提前返回、线程卡死,或者连接对象没有按预期释放。

Bash
UTF-8|2 Lines|
ss -antp state close-wait
sudo lsof -nP -iTCP | grep CLOSE_WAIT

这时调小 TCP 超时通常治不好根。内核正在等本地应用表态,应该回到程序里检查连接生命周期。Python 里的 with client:、Go 里的 defer conn.Close()、Java 的 try-with-resources,本质上都是为了让异常路径也能关闭资源。

Docker 里的端口冲突,可能根本不在容器内部

容器又给端口问题加了一层地址转换。执行:

Bash
UTF-8|1 Line|
docker run -p 8080:80 nginx

含义是把宿主机 8080 映射到容器 80。容器内部 Nginx 监听的是 80,但宿主机上发生冲突的是 8080。

Text
UTF-8|1 Line|
浏览器 -> 宿主机 0.0.0.0:8080 -> Docker 转发 -> 容器 IP:80 -> Nginx

因此,在容器里执行 ss -lntp 只会看到 80,无法解释为什么宿主机 8080 绑定失败。要在正确的网络命名空间里查:

Bash
UTF-8|3 Lines|
docker ps
docker port 容器名
ss -lntp 'sport = :8080'

反过来也一样。宿主机没有监听某个端口,不代表容器内部就没有服务。网络命名空间让容器拥有独立的接口、路由表和 Socket 表,“这台机器的端口”到底指哪个空间,必须先说清楚。

127.0.0.1、0.0.0.0 和局域网 IP,决定谁能连进来

端口问题还有一种常见情况:服务明明显示 LISTEN,浏览器却访问不了。很多时候不是端口没开,而是监听地址和访问路径对不上。

如果开发服务器只绑定:

Text
UTF-8|1 Line|
127.0.0.1:5173

那么只有同一网络命名空间里的本机程序能访问。手机用电脑的局域网 IP 连接、另一台虚拟机访问、容器从自己的网络空间访问,都不一定能到达这个回环地址。

把服务改成监听:

Text
UTF-8|1 Line|
0.0.0.0:5173

它才会接收所有 IPv4 接口上的连接。然后用 ip addr 找到电脑真实的局域网地址,例如 192.168.1.30,手机访问的是 http://192.168.1.30:5173,不是 http://0.0.0.0:5173。后者只是服务端绑定时使用的通配含义。

Bash
UTF-8|4 Lines|
ip addr
ss -lntp 'sport = :5173'
curl http://127.0.0.1:5173
curl http://192.168.1.30:5173

这里还隔着防火墙。程序监听成功,只能说明内核愿意把到达该 Socket 的连接交给它;防火墙可能在报文到达 Socket 之前就丢掉。云服务器还有安全组,家庭网络还有路由器和 NAT。它们与 bind 属于不同层次。

几种现象可以提供线索:

现象常见方向
Connection refused目标主机可达,但对应地址没有监听,或防火墙主动拒绝
一直等待后超时报文或响应可能被防火墙、路由、安全组丢弃
本机能访问,其他机器不能只监听回环地址,或主机防火墙未放行
宿主机能访问,容器不能网络命名空间、容器 DNS 或路由配置需要检查

为了方便调试就监听 0.0.0.0 也有代价。一个原本只打算本机使用的数据库、调试器或管理后台,可能因此暴露到校园网、公司网甚至公网。绑定地址是访问边界的一部分,改成所有接口前要确认认证和防火墙配置。

端口扫描不到服务,不等于 bind 一定失败

我还踩过一个思维上的坑:用扫描工具看不到端口,就认定端口是空闲的。扫描是从某个网络位置向目标地址发包,它只能观察“这条路径上的访问结果”,不能完整读取目标主机内核里的 Socket 表。

服务可能只监听 127.0.0.1,远程扫描当然看不到;防火墙可能丢包,扫描结果会显示 filtered;服务也可能监听 IPv6,而扫描只测了 IPv4。反过来,某个端口从远程看起来开放,也可能是反向代理、NAT 转发或负载均衡器在回应,不代表你当前登录的这台机器上有对应进程。

排查 bind 失败时,以本机 ss、程序错误和 Socket 配置为主。排查“别人为什么连不上”时,再沿着监听地址、主机防火墙、路由、NAT 和上游安全策略逐层看。两个问题都提到端口,但观察角度完全不同。

一套比换端口更可靠的排查顺序

在真正开始排查前,还要把错误文字看完整。Address already in use 对应的重点是地址冲突;Cannot assign requested address 往往表示你绑定的 IP 并不存在于当前网络命名空间,例如把另一台机器的 IP 写进了配置;Permission denied 则可能来自低端口权限、安全模块或运行环境限制。它们都会让服务“起不来”,处理方向却完全不同。

Text
UTF-8|3 Lines|
Address already in use       -> 查已有 Socket、状态和复用规则
Cannot assign requested address -> 查本机接口、容器网络和配置中的 IP
Permission denied            -> 查用户权限、能力与安全策略

Linux 上普通进程绑定 1024 以下端口通常需要额外权限。相比直接用 root 运行整个 Web 服务,更常见的做法是让 Nginx、Caddy 等入口服务监听 80 和 443,再转发到普通用户运行的高端口;也可以在明确风险后为程序配置 CAP_NET_BIND_SERVICE。权限问题和端口占用问题都可能发生在 bind(),但不要因为调用位置一样就把它们混成一类。

程序日志如果只打印“启动失败”,排查会很痛苦。保留系统错误码、协议、本地地址和端口,能让问题少绕很多路。例如“TCP bind 192.168.1.30

failed: EADDRNOTAVAIL”比“端口 8000 启动失败”有用得多。写服务端程序时,错误信息本身也是可维护性的一部分。

现在再遇到 Address already in use,我会按下面的顺序查。

  1. 确认报错发生在哪个协议、地址和端口,不只记一个数字。
  2. ss -lntpss -lnup 查活动监听,注意 0.0.0.0127.0.0.1、具体网卡 IP 和 [::]
  3. lsofpssystemctldocker ps 找到真正的管理者,不急着强杀进程。
  4. 没有监听者时,用 ss -ant 查看 TIME_WAITCLOSE_WAIT 等连接状态。
  5. 检查程序是否正确关闭 Socket,服务器是否在 bind() 前合理设置 SO_REUSEADDR
  6. 如果问题只在容器或 WSL 中出现,确认自己查的是哪个网络命名空间。
  7. 仍然解释不通时,用 tcpdump 看握手和关闭报文,不靠猜。

换一个端口当然最快,但它会把原因留在原地。等项目里同时出现开发服务器、数据库、反向代理和容器映射时,端口迟早还会撞上。

我后来重新看那句 Address already in use,觉得它其实没有撒谎,只是说得太省略了。被使用的不是一个孤零零的数字,而是一组协议、地址和内核状态。把这组信息补齐,端口问题就从玄学变成了几条可以验证的记录。