nc -z host port 大概是运维里被用得最多的一条判断命令。它的退出码只有 0 和 1,而"端口通不通"这件事至少有四种不同的失败方式。下面四个场景都是我在一台 Ubuntu 24.04、内核 6.8 的机器上实际跑出来的。

场景一:拒绝和黑洞,退出码一样

一个健康监听的端口,和一个什么都没有的端口,和一个包被丢掉的端口:

time nc -z -w2 127.0.0.1 8080 ; echo "exit=$?"
time nc -z -w2 127.0.0.1 9999 ; echo "exit=$?"
time nc -z -w2 10.255.255.1 80 ; echo "exit=$?"

8080 上是本机跑着的 nginx,9999 没人监听,10.255.255.1 是一个不可路由的地址(包发出去没有任何回应)。

Connection to 127.0.0.1 8080 port [tcp/http-alt] succeeded!
real	0m0.003s
exit=0

real	0m0.002s
exit=1

real	0m2.004s
exit=1

后两条退出码都是 1。区分它们的是时间:9999 在 2 毫秒内返回,因为内核直接回了 RST;10.255.255.1 耗满了 -w2 指定的 2 秒超时,因为没有任何回应。

这两种情况指向完全不同的方向。RST 意味着路径是通的、对端在线、只是那个端口没有监听,问题在服务端配置。超时意味着包在中间被丢了,可能是防火墙 DROP 规则、路由不通,或者主机整个下线了。

只看退出码,这两条会被归成同一类"连不上",然后往错的方向查。

顺带一个坑:这台机器上的 nc 不带 -w 时不设超时。

time timeout 5 nc -z 10.255.255.1 80 ; echo "exit=$?"
real	0m5.002s
exit=124

退出码 124 是外层 timeout 命令给的,说明五秒内 nc 自己没结束。写脚本探测不可达地址时不显式给 -w,脚本会挂在那里。

场景二:握手成功,服务是坏的

起一个只做 accept 然后立刻关闭的 TCP 服务:

node -e 'const s=require("net").createServer(c=>c.end()); s.listen(9101,"127.0.0.1"); setTimeout(()=>s.close(()=>process.exit(0)),6000)' &
sleep 0.8
nc -z -w2 127.0.0.1 9101 ; echo "nc exit=$?"
curl -sv http://127.0.0.1:9101/ 2>&1 | grep -E '^\*|^curl'
echo "curl exit=$(curl -s -o /dev/null http://127.0.0.1:9101/ ; echo $?)"
Connection to 127.0.0.1 9101 port [tcp/bacula-dir] succeeded!
nc exit=0
* Connected to 127.0.0.1 (127.0.0.1) port 9101
* Empty reply from server
* Closing connection
curl exit=52

nc 说这个端口是开的,从传输层的角度看它没说错——三次握手确实完成了。但服务在握手之后立刻断开,一个字节的应用层数据都没给。curl 的退出码 52 是 CURLE_GOT_NOTHING。

生产里对应的场景是:进程还在、accept 还在,但它依赖的东西挂了——后端连不上数据库、配置读不到、线程池耗尽。健康检查只做到 connect 这一层的话,这些情况全部报"正常"。

场景三:进程在监听,但连接进不来

监听 socket 有一个 backlog 队列。ss 在 LISTEN 状态下,Recv-Q 是当前等待被 accept 的连接数,Send-Q 是 backlog 上限:

ss -ltn '( sport = :8080 )'
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0      511          0.0.0.0:8080      0.0.0.0:*

Recv-Q 是 0,说明 nginx 的 accept 跟得上。如果这个数字长期贴着 Send-Q,说明应用 accept 太慢,队列满了之后新连接的 SYN 会被丢掉——从客户端看就是超时,而 ss -ltn 明明显示进程在监听。

内核为这种情况准备了计数器:

nstat -az | grep -iE 'ListenDrops|ListenOverflows|TCPReqQFull'
TcpExtListenOverflows           0                  0.0
TcpExtListenDrops               0                  0.0
TcpExtTCPReqQFullDoCookies      0                  0.0
TcpExtTCPReqQFullDrop           0                  0.0

这四个是累计值,从开机算起。判断的时候要看增量而不是绝对值:

nstat -az | grep -iE 'ListenDrops|ListenOverflows' > /tmp/n1
sleep 60
nstat -az | grep -iE 'ListenDrops|ListenOverflows' > /tmp/n2
diff /tmp/n1 /tmp/n2

ListenOverflows 增长说明 accept 队列溢出过,ListenDrops 增长范围更广(还包含 SYN 队列的问题)。这两个计数器增长而进程状态看起来正常,是"端口通不通"这个问题最难受的一种答案:有时通,有时不通,取决于当时的队列水位。

场景四:端口通,协议不对

同一个端口,用错的协议去连:

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/ ; echo "exit=$?"
curl -s -o /dev/null https://127.0.0.1:8080/ ; echo "exit=$?"
200
exit=0
exit=35

8080 上跑的是明文 HTTP。用 https:// 去连,TCP 握手成功,然后 TLS 握手失败,curl 退出码 35 是 CURLE_SSL_CONNECT_ERROR。

从"端口通不通"看它是通的,从"这个地址能不能用"看它不能用。故障工单里这两句常被混为一谈:一方说"我 telnet 是通的",另一方说"我的程序连不上",两边都没说谎。

一套判定顺序

connect 只是四层里的第二层。我排查时按这个顺序走,哪一层出问题就停在哪一层。

第一层:本地事实。 在目标机器上直接看内核的视角,不经过网络。

ss -ltnup

这一层回答:有没有进程在监听,绑在哪个地址上,backlog 水位如何。绑在 127.0.0.1 的服务从外部探测一定不通,这不是故障。这一层必须用 root 跑,否则 ss 看不到别人的进程名。

第二层:传输层可达。 从客户端发起 connect,同时记录耗时。

time nc -z -w3 HOST PORT; echo "exit=$?"

耗时接近 0 的失败是 RST,耗时等于超时的失败是丢包。两者结论不同,必须记时间。

bash 内置的 /dev/tcp 也能做这件事,在没有 nc 的最小环境里很有用:

timeout 3 bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080' && echo OK || echo FAIL

第三层:应用层可用。 用真实的协议发一个真实的请求,检查返回。

curl -s -o /dev/null -w 'code=%{http_code} connect=%{time_connect} appconnect=%{time_appconnect} total=%{time_total}\n' http://HOST:PORT/

time_connect 是 TCP 握手耗时,time_appconnect 是 TLS 握手耗时,time_total 是拿到完整响应的耗时。三个数分开看,能直接定位是传输层慢还是应用层慢。

第四层:语义正确。 返回 200 不等于服务正常。一个总是返回 200 但内容是错误页的应用,在第三层是通的。健康检查端点应该反映真实依赖状态,而不是一个静态的 OK。

我的做法

写探活脚本的时候我不再用 nc -z。理由是它的信息量太少——一个 0 或 1,没有耗时,没有区分失败原因。

我的做法是直接用 curl 打到应用层的健康端点,同时把 time_connect 和 time_appconnect 一起记下来。这样一条命令覆盖了第二到第四层,而且失败时的输出自带诊断信息。只有在探测的不是 HTTP 服务、或者环境里没有 curl 的时候才退回到 nc,并且一定带 -w。

分情况的一点是跨网络边界探测。从客户端到服务器之间如果有 NAT、负载均衡、云安全组,第二层的结论只能说明"这条路径上有人回应了",不能说明目标进程在监听。云上的 LB 常常自己就完成 TCP 握手,后端全挂时探测依然显示端口开放。这种情况下第一层和第三层必须分别在两端做,中间那段单独判断。

还有一个我不确定的边界:某些防火墙会对 SYN 回 ICMP unreachable 而不是静默丢弃,connect 会立刻失败,看起来像 RST。我遇到过但没能稳定复现,所以"快速失败 = 端口没监听"这条规则,跨了防火墙就得打个问号。