这篇是服务器运维笔记,和 DNS 无关。站内的加密 DNS 服务在dns.23-4.cn。
启动服务报 bind: Address already in use,或者 EADDRINUSE——你要查「谁占了这个端口」。
答案通常给三个:ss、lsof、netstat。但它们各自有盲区,而且在特定的情况下会给出「端口没被占用」这种完全错误的结论,让你去改代码。
这篇把三条路都走一遍,重点是什么时候它们会骗你。下面所有输出都是我在一台 Ubuntu 20.04 上实际跑出来的。
一、先看工具在不在
for t in ss netstat lsof fuser; do
printf "%-10s " $t
command -v $t >/dev/null && echo yes || echo NO
done实测这台机器:
ss yes
netstat NO
lsof yes
fuser yesnetstat 已经不在默认安装里了(net-tools 包)。这不是意外——ss 是它的替代品,而且更快。所以下面主要讲 ss。
二、ss:默认选择,但有三个盲区
最常用的一条:
ss -tlnp参数含义:-t TCP、-l 只听 LISTEN、-n 不做名字解析、-p 显示进程。
实测输出(截前几行):
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 511 0.0.0.0:2096 0.0.0.0:* users:(("nginx",pid=1253931,fd=27),("nginx",pid=1253930,fd=27),...)
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1253931,fd=6),...)
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=895990,fd=13))
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=886753,fd=4))注意 0.0.0.0:2096 那一行有五个 nginx pid——master + 4 个 worker 都继承了同一个 fd。这很正常,不是重复占用。
盲区 1:-p 需要权限
不加 sudo 时,别人进程的 Process 列是空的:
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=886753,fd=4)) ← root 跑的,能看
LISTEN 0 100 0.0.0.0:9000 0.0.0.0:* ← 别的用户跑的,pid 看不到你看到端口被占了,但看不到是谁。先加 sudo 再下结论。
盲区 2:UDP 和 TCP 是两个独立命名空间
ss -tlnp 只查 TCP。如果你要查的端口是 UDP 的(DNS、QUIC、syslog 都可能是),这条命令会告诉你「没被占用」,而实际上是占着的。
# UDP 要单独查
ss -ulnp这是最常见的误判来源:一个服务同时监听 TCP 和 UDP 的 53 端口,你只查了 TCP,以为端口空着,然后 bind UDP 失败。
盲区 3:IPv4 和 IPv6 是两个独立 socket(除非开了 dual-stack)
ss -tlnp | grep ':443 '可能只返回 0.0.0.0:443(纯 IPv4),而 [::]:443(IPv6)是另一个 socket。如果你 bind 的时候没指定地址族,可能碰到「IPv4 空着但 IPv6 被占」。
ss -4 / ss -6 可以分开看:
ss -4 -tlnp | grep ':443 '
ss -6 -tlnp | grep ':443 '三、lsof:信息更全,但更慢
lsof -iTCP -sTCP:LISTEN -P -n-iTCP只看 TCP;-sTCP:LISTEN只看监听状态;-P不把端口号转成服务名(必须加,否则 443 会显示成https,grep 不到);-n不做 IP 反解(不加会很慢,因为要对每个 IP 查 DNS)。
实测:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
nginx 2715 root 6u IPv4 38108 0t0 TCP *:80 (LISTEN)
nginx 2715 root 7u IPv6 38109 0t0 TCP *:80 (LISTEN)
nginx 2715 root 11u IPv4 10248389 0t0 TCP *:8443 (LISTEN)lsof 的优势:能看到同一个进程占的所有端口和文件,而且 IPv4/IPv6 分得清清楚楚(上面 *:80 出现了两次,一次 IPv4 一次 IPv6)。
它的劣势:慢。lsof 会遍历 /proc 下所有进程的所有 fd,机器上进程多的时候要几秒。
踩坑点:不加 -P 是新手最常见的错误。
lsof -iTCP -sTCP:LISTEN -n | grep 443 # ← 可能一条都匹配不到
lsof -iTCP -sTCP:LISTEN -P -n | grep 443 # ← 正确因为默认会把端口 443 显示成 https,你 grep 443 自然匹配不上。
四、/proc:不用任何工具,也不受权限限制(部分)
这一条最值得学,因为它在容器里、精简系统里、ss 和 lsof 都没有的时候仍然可用。
原理:/proc/net/tcp 里记录了所有 TCP socket,其中有一列是 inode。而每个进程的 /proc/PID/fd/* 是符号链接,指向 socket:[inode]。两边一对,就知道谁持有这个 socket。
第 1 步:从 /proc/net/tcp 找到端口的 inode
/proc/net/tcp 里端口是十六进制的。443 = 0x01BB,80 = 0x0050。
换算命令(注意别踩下面的坑):
for p in 80 443 853 53; do
printf "%-5s -> %s\n" "$p" "$(awk -v p="$p" 'BEGIN{printf "%04X", p}')"
done实测:
80 -> 0050
443 -> 01BB
853 -> 0355
53 -> 0035踩坑点(我实际踩到的):不要写 printf "%04X" $p。
$ printf "%04X" 443
0001 ← 错!sh 的 printf 把 443 当成十进制解析后没有按 %X 处理
$ awk 'BEGIN{printf "%04X", 443}'
01BB ← 对sh(dash)的内置 printf 处理 %X 时行为和 bash 不一致,会把数字截断成奇怪的结果(443 变成 0001,80 变成 0028)。用 awk 算十六进制最稳。
反方向验证:
$ echo $((0x01BB))
443
$ echo $((0x0050))
80第 2 步:反查谁持有这个 inode
inode=$(awk 'NR>1 && $2 ~ /:01BB$/ {print $10; exit}' /proc/net/tcp)
echo "inode=$inode"inode=142089然后拿它去反查:
for p in /proc/[0-9]*; do
for fd in $p/fd/*; do
[ -L "$fd" ] && [ "$(readlink $fd)" = "socket:[$inode]" ] && \
echo "持有者: $p $(cat $p/comm 2>/dev/null)"
done
done 2>/dev/null实测输出:
持有者: /proc/1253928 nginx
持有者: /proc/1253929 nginx
持有者: /proc/1253930 nginx
持有者: /proc/1253931 nginx
持有者: /proc/2715 nginx五个 nginx 进程——master 加四个 worker,和 ss 的输出完全对得上。
这条路的盲区
/proc/PID/fd 里,别人进程的 fd 你读不到(需要 root)。所以非 root 跑这段,只会看到自己的进程。但和 ss -p 不同的是:你能确定「端口确实被占着」(inode 存在),只是不知道是谁。
这已经足够区分「端口没被占」和「端口被占但我没权限看」——而这正是排查里最关键的一步。
五、还有两种「端口被占」的假象
假象 1:TIME_WAIT 让你以为端口还在用
ss -tan | grep ':443 ' | awk '{print $1}' | sort | uniq -c如果输出里大量 TIME_WAIT,说明连接刚关闭,内核还在等 2MSL。这通常不影响 bind(除非你用了 SO_REUSEADDR 之外的选项),但会让 ss 的输出看起来很吓人。
TIME_WAIT 不是「被占用」,是「等超时」。区别很重要:前者要杀进程,后者等 60 秒自己就没了。
假象 2:SO_REUSEPORT 允许多个进程监听同一端口
ss -tlnp | grep ':80 '如果看到多个不同的进程名都监听 :80,那不是异常,是用了 SO_REUSEPORT(nginx 多进程、某些负载均衡器会这么干)。内核会把新连接分给其中一个。
这时「谁占着端口」这个问题的答案就是「不止一个,它们是设计好的」。
六、对照表:什么时候用哪个
| 场景 | 用哪个 | 理由 |
|---|---|---|
| 快速看一眼 | ss -tlnp | 最快,默认就有 |
| 看不到 pid | 加 sudo | -p 需要权限 |
| 查 UDP | ss -ulnp | -tlnp 完全不覆盖 UDP |
| 查 IPv6 | ss -6 -tlnp | v4/v6 是独立 socket |
| 要完整上下文 | lsof -iTCP -sTCP:LISTEN -P -n | 能看进程的所有资源 |
| 精简系统 / 容器 | /proc/net/tcp + /proc/*/fd | 不依赖任何工具 |
| 只想知道端口通不通 | nc -z host port | 不关心是谁,只关心能不能连 |
七、一页排查脚本
PORT=443
HEX=$(awk -v p="$PORT" 'BEGIN{printf "%04X", p}')
echo "PORT=$PORT HEX=$HEX"
echo "=== TCP 监听 ==="
ss -tlnp | grep ":$PORT "
echo "=== UDP 监听(别漏)==="
ss -ulnp | grep ":$PORT " || echo "(UDP 无)"
echo "=== IPv4 / IPv6 分开看 ==="
echo -n "v4: "; ss -4 -tlnp | grep -c ":$PORT "
echo -n "v6: "; ss -6 -tlnp | grep -c ":$PORT "
echo "=== /proc 反查(不依赖工具)==="
for inode in $(awk -v p=":$HEX" 'NR>1 && index($2,p) {print $10}' /proc/net/tcp); do
for pr in /proc/[0-9]*; do
for fd in $pr/fd/*; do
[ -L "$fd" ] && [ "$(readlink "$fd")" = "socket:[$inode]" ] && \
echo " inode=$inode -> $pr $(cat $pr/comm 2>/dev/null)"
done
done 2>/dev/null
done
echo "=== 状态分布(TIME_WAIT 不是占用)==="
ss -tan | grep ":$PORT " | awk '{print $1}' | sort | uniq -c实测这台机器对 443 跑完整脚本的输出:
PORT=443 HEX=01BB
=== TCP 监听 ===
LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=1253931,fd=17),...,("nginx",pid=2715,fd=17))
LISTEN 0 511 [::]:443 [::]:* users:(("nginx",pid=1253931,fd=18),...,("nginx",pid=2715,fd=18))
=== UDP 监听(别漏)===
(UDP 无)
=== IPv4 / IPv6 分开看 ===
v4: 1
v6: 1
=== 状态分布 ===
43 ESTAB
2 FIN-WAIT-2
2 LISTEN
7 TIME-WAIT注意 v4: 1 和 v6: 1——同一个 443 端口有两个独立的 LISTEN socket(0.0.0.0:443 和 [::]:443)。如果只查 IPv4,你会以为「只有一个」,而实际 bind 失败的可能是 IPv6 那一个。
验证做对了没有:ss 和 /proc 两条路给出的进程列表应该完全一致(这里都是那 5 个 nginx pid)。如果不一致,说明你漏了 UDP 或者漏了 IPv6——那才是真正需要继续查的地方。
八、一句话总结
ss -tlnp 是起点,但它只覆盖 TCP + IPv4 + 你自己有权限看的进程。这三个限制之外的每一种情况,都会让它给你一个「端口空着」的错误答案。加 sudo、加 -u、加 -6,或者退回 /proc 自己查——这四步能覆盖几乎所有场景。
最后补一个容易忽略的点:别拿 netstat 的肌肉记忆直接套 ss。两者参数名看着一样(netstat -tulnp / ss -tulnp),但 ss 对权限更敏感,而且参数的语义有细微差别。如果你在网上抄来的命令跑出来是空的,先 ss --help 确认这个参数在你的版本里存在——老版本 ss 遇到不认识的参数时,有些情况是静默返回空结果而不是报错。这又是一个「看起来端口没被占用、其实是工具骗了你」的来源。