暗色模式

Linux 服务监听地址:从 0.0.0.0 与 127.0.0.1 的区别到 localhost 连不上的坑

技术教程
2026-10-03
8
0
本文要点
  • 回环不是「一个地址」,而是整整一段 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

终端截图:lo 接口的 inet 是 127.0.0.1/8;本地路由表里 local 127.0.0.0/8 dev lo,另有一条 local 127.0.0.1 和广播地址 127.255.255.255,最后两行是 eth0 的 172.16.0.84

/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 "两个测试服务已关闭"

终端截图:ss 显示 127.0.0.1:18080 与 0.0.0.0:18081 两条 LISTEN;curl 127.0.0.1:18080 返回 200,curl 127.0.0.2:18080 报 Connection refused,curl 127.0.0.2:18081 与 172.16.0.84:18081 都返回 200,最后确认两个测试服务已关闭

四个请求的结果可以总结成一句话:绑定的地址就是「服务愿意接收的目的地址集合」。

监听地址目的地址是 127.0.0.1目的地址是 127.0.0.2目的地址是 172.16.0.84
127.0.0.1:18080200Connection refusedConnection refused
0.0.0.0:18081200200200

几个延伸结论:

  • 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

终端截图:getent hosts localhost 返回 ::1 优先;ss 显示服务只监听 127.0.0.1:18082;curl -4 返回 200,curl -6 报 Couldn't connect to server 并返回 000

现象很清楚:同一个 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 的成功当结论。

三种修法,按推荐顺序:

  1. 让服务双栈监听:绑 ::(或 [::])并确认 net.ipv6.bindv6only=0,这样一个套接字同时接 v4 和 v6;或者明确开两个监听分别绑 0.0.0.0 与 ::。sshd 默认就是后者。
  2. 客户端显式指定:配置文件里写 127.0.0.1 而不是 localhost,或给客户端加 -4 之类的参数。改动最小,但每个客户端都要改。
  3. 调整解析顺序:改 /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 确认)。

排查清单

按这个顺序走,绑定类问题基本一轮定位:

  1. ss -lntp —— 先看服务到底绑在哪个地址、哪个端口、哪个进程。Local Address 那一列决定一切。
  2. ss -lntp '( sport = :8080 )' —— 端口多的时候直接过滤(sport 是源端口,服务端视角就是本地端口)。
  3. getent hosts <名字> —— 客户端写的是域名或 localhost 时,先确认解析给了哪个地址、顺序如何。
  4. 从真实客户端发起测试:curl -v、nc -vz host port,注意 -4/-6 各试一次。在本机自测永远测不出外部的解析和路由问题。
  5. 拒绝 vs 超时:Connection refused 说明包到了但没人接(看绑定、看端口);一直卡住说明包没到或被丢(看防火墙、看路由)。
  6. 容器场景额外确认端口映射的方向:-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 各测一次。

相关阅读:

发表评论

暂无评论,快来抢沙发吧!