本文要点
- 回环不是「一个地址」,而是整整一段 127.0.0.0/8:本机
ip route show table local里是local 127.0.0.0/8 dev lo,所以127.0.0.2、127.1.2.3不用任何配置就能连——它们全都是本机 - 绑定地址决定「谁连得上」,而不是「服务开没开」:实测只绑
127.0.0.1:18080时,连127.0.0.2:18080直接被拒(Connection refused);而绑0.0.0.0:18081的同一个服务,从127.0.0.2和网卡地址172.16.0.84都能连上(都是 200) localhost不一定等于127.0.0.1:本机getent hosts localhost返回的第一条是::1(/etc/hosts里 IPv6 那行在后面,解析顺序仍可能先给 v6)。服务只监听 IPv4 时,curl -6 http://localhost:18082/直接失败,curl -4才通——很多客户端不做回退,报的就是这个「明明起来了却连不上」- curl 默认能通是因为它自己回退了:实测不带
-4/-6的curl http://localhost:18083/返回 200,且实际用的是127.0.0.1。换个不做回退的客户端(或 Java、部分 SDK),同样的服务就是连不上 0.0.0.0只通配 IPv4:本机 sshd 同时在0.0.0.0:22与[::]:22两条 LISTEN 上,说明它把两族都占了;而 systemd-resolved 只出现在127.0.0.53%lo:53——%lo表示绑定被限制在回环接口,外部根本连不到- 绑定一个不属于本机的地址会失败:
bind('8.8.8.8')报OSError: [Errno 99] Cannot assign requested address;net.ipv4.ip_nonlocal_bind=1之后才能绑(VIP / keepalived 场景会用到),本文演示后已还原为 0
Linux 服务监听地址:从 0.0.0.0 与 127.0.0.1 的区别到 localhost 连不上的坑
「服务明明起来了,ps 里能看到进程,日志也打印了 listening on port 8080,可客户端就是连不上」——这类问题查到最后,经常是绑定地址那一行。
日常写的 --bind 0.0.0.0、--host 127.0.0.1、listen_addresses = 'localhost',看起来只是「让服务监听哪个 IP」,实际决定的是内核对哪些目的地址的连接会交给这个进程。理解这一层,能同时解释两种相反的故障:一种是该连的连不上,另一种是不该连的也能连上。
本文在一台 1 核 1.9G 的 Ubuntu 22.04 演示机(内核 5.15,内网地址 172.16.0.84)上把这件事实测一遍:先看回环到底是一段还是一个地址,再对比绑 127.0.0.1 与绑 0.0.0.0 的实际差别,最后复现那个经典的 localhost 连不上的坑。全程只用系统自带命令,测试用的临时目录和进程都会当场清理。
回环不是一个地址,是一整段 127.0.0.0/8
先从最容易被忽略的事实开始:lo 接口上配的是 127.0.0.1/8,不是 127.0.0.1/32。
ip -4 addr show lo
echo "--- 本地路由表:127.0.0.0/8 整段都算本机 ---"
ip route show table local
/8 这个前缀长度是关键:
local 127.0.0.0/8 dev lo意味着整个 127 网段都被视为「本机」,数据包不需要离开网卡。所以ping 127.0.0.2、ping 127.1.2.3都能通,不需要ip addr add加任何地址。- 本机还有一个现成的例子:
/etc/hosts里 cloud-init 写的那行是127.0.1.1 MFY001832765701——主机名解析到127.0.1.1,同样是这一段里的地址,所以ping $(hostname)才通。 - 对比一下 eth0:它是
local 172.16.0.84 dev eth0,只认这一个地址。你没法凭空ping 172.16.0.85得到回应——除非真给它配上。
但「回环整段都是本机」不等于「服务在整段上都能连」,这两件事经常被混为一谈,也是下一个故障的根源。
绑定 127.0.0.1 与绑定 0.0.0.0 的实际差别
用一个 python3 -m http.server 做对照实验:同一个目录、同一台机器,一个只绑回环,一个绑通配地址。
mkdir -p /tmp/lo-demo && printf '<h1>loopback demo</h1>\n' > /tmp/lo-demo/index.html
python3 -m http.server 18080 --bind 127.0.0.1 --directory /tmp/lo-demo >/dev/null 2>&1 & P1=$!
python3 -m http.server 18081 --bind 0.0.0.0 --directory /tmp/lo-demo >/dev/null 2>&1 & P2=$!
sleep 1.3
ss -lntp | grep -E '1808[01]'
curl -sS -o /dev/null -w "127.0.0.1:18080 -> %{http_code}\n" http://127.0.0.1:18080/ 2>&1
curl -sS -o /dev/null -w "127.0.0.2:18080 -> %{http_code}\n" --connect-timeout 2 http://127.0.0.2:18080/ 2>&1
curl -sS -o /dev/null -w "127.0.0.2:18081 -> %{http_code}\n" http://127.0.0.2:18081/ 2>&1
curl -sS -o /dev/null -w "172.16.0.84:18081 -> %{http_code}\n" http://172.16.0.84:18081/ 2>&1
kill $P1 $P2; sleep 0.3
ss -lnt | grep -E "1808[01]" || echo "两个测试服务已关闭"
四个请求的结果可以总结成一句话:绑定的地址就是「服务愿意接收的目的地址集合」。
| 监听地址 | 目的地址是 127.0.0.1 | 目的地址是 127.0.0.2 | 目的地址是 172.16.0.84 |
|---|---|---|---|
127.0.0.1:18080 | 200 | Connection refused | Connection refused |
0.0.0.0:18081 | 200 | 200 | 200 |
几个延伸结论:
0.0.0.0的含义是「本机所有 IPv4 地址」,不是「监听外网」也不是「监听回环」。它同时也接受 127.0.0.1 的连接,所以本地调试不会失效。- 被拒是因为没有任何套接字匹配这个地址,内核直接回 RST(
Connection refused),而不是超时。如果换成防火墙丢包,症状会变成「卡住不响应」——两者的排查方向完全不同:前者看绑定,后者看规则。 ss -lntp里的 Local Address 那一列就是全部答案。看到127.0.0.1:PORT就知道外部连不上是设计如此;看到0.0.0.0:PORT就要立刻想到暴露面。
顺便看一眼这台机器上真实存在的三种监听形态(ss -lnt 实测输出):
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:*
LISTEN 0 128 0.0.0.0:22 0.0.0.0:*
LISTEN 0 128 [::]:22 [::]:*127.0.0.53%lo:53:systemd-resolved 的本地 DNS,只绑回环。%lo是 scope 标记,把监听限制在 lo 接口上,外部机器连不到,这也是它敢不用认证的原因。0.0.0.0:22与[::]:22:sshd 同时监听 IPv4 通配和 IPv6 通配,两族都收。注意0.0.0.0只管 IPv4——想要双栈,要么像 sshd 这样开两条,要么用[::]配合net.ipv6.bindv6only=0。- 如果你在容器里看到
0.0.0.0:8080却从宿主机连不上,那是映射的问题(-p 127.0.0.1:8080:8080只映射到宿主机回环),不是容器内绑定的问题。
localhost 连不上的坑:解析先给了 IPv6
上面实验用的是裸 IP,所以结果干净。真实项目里写的是 localhost 或者配置项里的 localhost,这时会多一层——名字解析。
getent hosts localhost
python3 -m http.server 18082 --bind 127.0.0.1 --directory /tmp/lo-demo >/dev/null 2>&1 & P=$!
sleep 1.2
ss -lnt | grep 18082
curl -sS -o /dev/null -w "curl -4 http://localhost:18082 -> %{http_code}\n" -4 http://localhost:18082/ 2>&1
curl -sS -o /dev/null -w "curl -6 http://localhost:18082 -> %{http_code}\n" -6 --connect-timeout 2 http://localhost:18082/ 2>&1
kill $P
现象很清楚:同一个 localhost:18082,强制走 IPv4 成功,强制走 IPv6 失败。因为服务绑的是 127.0.0.1,根本没在 ::1 上监听。
/etc/hosts 里两行都在:
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback而 getent hosts localhost 返回的第一条是 ::1。这不是「hosts 文件写错了」,而是解析器按地址族排序的结果(IPv6 通常优先)。于是:
- 不做回退的客户端:拿到
::1就连,连不上就报错。Java 的老版本、部分 SDK、一些写死的连接池都属于这一类,症状是「服务明明在跑、ss也有 LISTEN,但客户端说 connection refused」。 - 做回退的客户端:尝试
::1失败后立刻改用127.0.0.1。curl 就是这样——实测最普通的curl http://localhost:18083/返回200,%{remote_ip}显示它最终用的是127.0.0.1:
curl -sS -o /dev/null -w "curl http://localhost:18083/ (默认) -> %{http_code}, 用了 %{remote_ip}\n" http://localhost:18083/
# curl http://localhost:18083/ (默认) -> 200, 用了 127.0.0.1所以「curl 能通」不能证明客户端就一定没问题——排查时用你要用的那个客户端去试,别用 curl 的成功当结论。
三种修法,按推荐顺序:
- 让服务双栈监听:绑
::(或[::])并确认net.ipv6.bindv6only=0,这样一个套接字同时接 v4 和 v6;或者明确开两个监听分别绑0.0.0.0与::。sshd 默认就是后者。 - 客户端显式指定:配置文件里写
127.0.0.1而不是localhost,或给客户端加-4之类的参数。改动最小,但每个客户端都要改。 - 调整解析顺序:改
/etc/hosts、或在gai.conf里调优先级。影响面最大,云主机上/etc/hosts还可能被 cloud-init 覆写(本机 hosts 开头那几行注释就是在说这件事),所以要配也得配在 cloud-init 的模板里。
绑定一个不属于本机的地址
最后补一个反直觉的实测:给 bind() 传一个不属于本机的地址会怎样。
# 默认 net.ipv4.ip_nonlocal_bind = 0
python3 -c "import socket; s=socket.socket(); s.bind(('8.8.8.8', 18099)); print('bind 成功')"
# OSError: [Errno 99] Cannot assign requested address
sysctl -w net.ipv4.ip_nonlocal_bind=1
python3 -c "import socket; s=socket.socket(); s.bind(('8.8.8.8', 18099)); print('开启后 bind 成功:', s.getsockname())"
# 开启后 bind 成功: ('8.8.8.8', 18099)
sysctl -w net.ipv4.ip_nonlocal_bind=0 # 演示完还原- 报的是
Cannot assign requested address(EADDRNOTAVAIL),不是权限问题——内核在绑定阶段就检查这个地址是否属于本机。 ip_nonlocal_bind=1之后可以绑任意 IPv4 地址。这是 keepalived / VIP 漂移场景需要的能力:备机在接管虚拟 IP 之前就要先绑上它,否则服务起不来。- 这是个内核级开关,不要随手在生产上打开:绑不存在的地址会让流量彻底黑洞化,排查起来比现在这个报错麻烦得多。本文演示后已还原为
0(cat /proc/sys/net/ipv4/ip_nonlocal_bind确认)。
排查清单
按这个顺序走,绑定类问题基本一轮定位:
ss -lntp—— 先看服务到底绑在哪个地址、哪个端口、哪个进程。Local Address 那一列决定一切。ss -lntp '( sport = :8080 )'—— 端口多的时候直接过滤(sport是源端口,服务端视角就是本地端口)。getent hosts <名字>—— 客户端写的是域名或localhost时,先确认解析给了哪个地址、顺序如何。- 从真实客户端发起测试:
curl -v、nc -vz host port,注意-4/-6各试一次。在本机自测永远测不出外部的解析和路由问题。 - 拒绝 vs 超时:
Connection refused说明包到了但没人接(看绑定、看端口);一直卡住说明包没到或被丢(看防火墙、看路由)。 - 容器场景额外确认端口映射的方向:
-p 127.0.0.1:8080:8080和-p 8080:8080的暴露范围完全不同。
安全上记住一句就够:只有本机用的服务(数据库、缓存、管理接口、本地 DNS)一律绑 127.0.0.1。绑 0.0.0.0 再靠防火墙兜底,等于把「不该暴露」这件事交给另一份配置去保证——它总会有忘改的一天。
小结
lo上是127.0.0.1/8,整段 127/8 都是本机;127.0.0.2能 ping 通不需要任何配置。- 绑定地址决定内核把哪些目的地址的连接交给这个进程:
127.0.0.1只收回环那一个地址,0.0.0.0收本机所有 IPv4 地址(含回环),[::]管 IPv6。 localhost的解析顺序可能先给::1,服务只监听 IPv4 时,不做回退的客户端就会失败——而 curl 的默认回退会掩盖这个问题。- 绑不在本机的地址报
EADDRNOTAVAIL,ip_nonlocal_bind可以放开,但那是 VIP 场景的专用开关。 - 排查第一步永远是
ss -lntp看 Local Address,再用真实客户端按-4/-6各测一次。
相关阅读:
- Linux TCP 连接诊断:从 ss 到内核计数器——连上之后的问题怎么继续往下查
- Linux 网络配置:从 ip 命令到 netplan 静态 IP——地址是怎么配到接口上的
- Linux DNS 解析排查:从 resolv.conf 到 dig 与 systemd-resolved——
localhost之外的名字解析问题 - Linux 防火墙:从 iptables 到 nftables 完整指南——「拒绝」与「超时」的另一种成因
- SSH 端口转发:一条命令打通内网服务访问与穿透——绑在 127.0.0.1 的服务怎么安全地临时开放
- netcat 网络调试工具:从端口探测到文件传输——
nc -vz快速验证某个地址端口通不通
评论 (0)
暂无评论,快来抢沙发吧!