Docker 网络排查手册:容器连不上时按什么顺序看
容器连不上其实是三条互不相干的路径:容器间靠嵌入式 DNS 解析服务名,宿主访问靠端口发布加 DNAT,外部访问再叠一层防火墙。按网络成员、监听地址、NAT 规则的顺序查,多数问题在前两步就能定位。
先分清三种「连不上」
同样是 connection refused / timeout,甚至同样是 502,流量走的可能是三条完全不同的路:
| 谁连谁 | 实际路径 | 决定成败的机制 |
|---|---|---|
| 容器 → 容器 | veth → 网桥 → veth(同一网络内) | 服务名能否被解析;应用是否监听可达地址 |
| 宿主 → 容器 | 宿主协议栈 → nat/PREROUTING(DNAT)→ FORWARD |
端口是否发布;应用监听地址 |
| 外网 → 容器 | 公网 → 宿主网卡 → DNAT → FORWARD | 上面全部,再叠宿主防火墙与上游安全组 |
症状相似,排查命令却完全不重叠。所以第一步不是猜,而是先确定故障落在哪一段。三条便宜的判别式:
- 容器内
getent hosts <服务名>失败 → 第一类,网络成员或 DNS 问题。 - 服务名能解析、端口连不上 → 应用监听地址问题,去容器里看
ss。 - 容器之间一切正常,只有从外面连不上 → 第三类,去 NAT 表和防火墙看。
路径:网桥、veth 与 127.0.0.11
bridge 模式下的每个容器都拿到一对 veth:一端在容器自己的 network namespace 里叫 eth0,另一端挂在宿主的网桥上(默认 docker0,用户自定义网络则是 br-<网络 ID 前 12 位>)。
容器 netns 宿主
eth0 ───── veth pair ─────► br-xxxxxxxxxxxx
└ /etc/resolv.conf → nameserver 127.0.0.11
127.0.0.11 是 dockerd 提供的嵌入式 DNS,在容器的 netns 内监听。理解容器间解析的关键就在这一行:它只回答同一网络内、有名字的容器。跨网络的容器名查不到,报错长这样:
dial tcp: lookup api on 127.0.0.11:53: no such host
另外,默认的 bridge 网络不提供自动的服务名解析(历史上要靠已废弃的 --link),用户自定义网络才提供。所以「服务名能不能用」几乎等价于「这两个容器是否在同一个用户自定义网络上」。
--net=host 的容器不参与这套机制:它没有独立 netns,/etc/resolv.conf 用的是宿主的,容器名解析和端口发布对它都不适用。混合使用 host 与 bridge 两种模式的部署,最容易在这里绕不出来。
命令序列:从最能区分的开始
1. 有哪些网络,谁在里面
docker network ls
docker network inspect app-net \
--format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
docker network inspect app-net --format '{{(index .IPAM.Config 0).Subnet}}'
NAME DRIVER SCOPE
bridge bridge local
app-net bridge local
api 172.18.0.4/16
frontend 172.18.0.5/16
172.18.0.0/16
2. 这个容器实际挂在哪些网络上
一个容器可以同时属于多个网络,看的是创建时的状态,不是配置文件的意图:
docker inspect api \
--format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{$v.IPAddress}}{{"\n"}}{{end}}'
# app-net 172.18.0.4
3. 容器内验 DNS
用 getent hosts:glibc 与 busybox 都有,比 nslookup 可靠(很多精简镜像里没装)。
docker exec api getent hosts frontend
# 172.18.0.5 frontend
这一条失败就别往下测了,后面的端口探测必然也失败,只会浪费时间。
4. 探端口,并区分 refused 与 timeout
docker exec api sh -c 'nc -z -w2 frontend 3000 && echo open || echo closed'
目标容器里没工具时,用一次性容器从同一网络里探,不必 exec 进对方:
docker run --rm --network app-net curlimages/curl:latest \
-s -o /dev/null -w '%{http_code}\n' http://frontend:3000/
输出 200 是通了,000 是没通。进一步用 curl -v 看是哪种没通:Connection refused 说明包到了、但那个地址上没人监听(或监听在别的地址上);超时则多半是防火墙丢包或路由不通。这个区分决定了下一步去看 ss 还是去看防火墙。
5. 宿主侧看监听范围
ss -ltnp | grep -E ':(3000|8080)\b'
# LISTEN 0 4096 0.0.0.0:3000 0.0.0.0:* users:(("docker-proxy",pid=1234,fd=4))
# LISTEN 0 4096 127.0.0.1:8080 127.0.0.1:* users:(("node",pid=5678,fd=22))
第一行是发布到全网卡的容器端口,第二行是只在本机可达的宿主服务。两种地址在这个输出里一眼可辨,这是后面两个坑的共同证据来源。
6. 看 DNAT 规则是否真的存在
iptables -t nat -S DOCKER | grep -E '3000|8080'
# -A DOCKER ! -i br-xxxxxxxxxxxx -p tcp -m tcp --dport 3000 \
# -j DNAT --to-destination 172.18.0.4:3000
找不到对应行,说明这个端口根本没发布到宿主;若规则里带 -d 127.0.0.1/32,说明发布时绑了回环地址,只有本机能进。
坑一:应用绑在 127.0.0.1
容器里的 127.0.0.1 是容器自己的回环,不是宿主的。应用只听这个地址时,两个方向同时断:
- 同网络的其他容器连不上——它们的目标地址是容器 IP,落在
eth0上没人听; - 宿主的发布端口也连不上——DNAT 把目的地址改写成容器
IP:端口后,同样落到eth0上。
一条命令就能定性:
docker exec api ss -ltnp
# LISTEN 0 2048 127.0.0.1:3000 0.0.0.0:* users:(("app",pid=7,fd=6))
127.0.0.1:3000 只在本 netns 内可达;0.0.0.0:3000、*:3000、[::]:3000 才是全部网卡。看到前者就直接改绑定,不用再查防火墙。
这里有个真实取舍:把监听地址收紧到回环本身是一种安全手段,它能让端口对同一网络里的其他容器不可见(metrics、pprof 这类端口尤其该这样)。但它和「让别的容器或宿主来连」是直接冲突的需求。判断依据不是哪个更安全,而是这个端口由谁消费:
| 消费方 | 应绑地址 |
|---|---|
| 同 netns 的进程(host 网络模式的场景) | 127.0.0.1,更安全 |
| 同网络的其他容器 | 0.0.0.0 或该网络上的容器 IP |
| 外部经发布端口 | 0.0.0.0,绑定错了任何发布都无效 |
还有一条容易混的:ports: - "127.0.0.1:8080:8080" 限制的是宿主侧的入口地址,容器内的应用仍然必须绑到 0.0.0.0 才能被发布出来。两者作用在不同的一跳上。
坑二:网络变更要重建容器,重启无效
docker restart 只重启容器内的进程。network namespace 和网络成员关系在容器创建时就定了,重启不会重新计算。改 compose 的 networks:、给 docker run 换 --network,都必须让容器重建:
docker compose up -d --force-recreate api
docker network connect / disconnect 是例外,可以在运行中热增热删:
docker network connect app-net api
docker network inspect app-net --format '{{range .Containers}}{{.Name}} {{end}}'
热加之后,容器名解析立刻可用(同一网络成员身份就是解析权限)。但这份成员关系不写进任何配置:下一次 up -d --force-recreate 或 docker rm 后重跑,容器回到创建时的网络集合,手工 connect 的那一份消失,故障原样复现。所以用它验证假设可以,修完必须把结果落到 compose 的 networks: 声明里,否则只是把问题藏到下次重建。
宿主访问已发布端口也走 FORWARD
这一条反直觉但很关键。目的地址在 nat/PREROUTING 被 DNAT 改写之后,这个包对内核来说就是「转发给另一个地址」,因此源是 127.0.0.1 的流量也从 FORWARD 走,不经过 INPUT。
于是任何按来源过滤的规则(ufw 的 deny incoming、自定义的 DOCKER-USER 白名单)都会把本机调试的流量一并算进去。漏掉回环放行,就会出现「公网访问正常、localhost 反而连不上」这种反直觉现象:
-A DOCKER-USER -m conntrack --ctstate NEW -s 127.0.0.0/8 -j RETURN
-A DOCKER-USER -m conntrack --ctstate NEW -j DROP
判断流量是否真被拦在这条链上,看计数器而不是看状态命令:
iptables -L DOCKER-USER -n -v
# pkts bytes target prot opt in out source destination
# 3 180 DROP all -- * * 0.0.0.0/0 0.0.0.0/0
计数在涨,说明这条规则确实在生效;恒为 0,说明它匹配不到任何流量(很可能写在了一条根本不被经过的链上)。ufw status 显示 active,和它管不管得着容器,是两件事——完整的守卫写法与因果验证见 UFW 管不到 Docker 发布的端口。
跨网络:容器怎么访问宿主上的服务
宿主上的服务不在任何容器网络里,容器用 127.0.0.1 连它必然失败——那不是宿主。
Linux 上 host.docker.internal 不是开箱即用的(那原本是 Docker Desktop 的特性),要在 compose 里显式注入:
services:
api:
image: example/api:latest
extra_hosts:
- "host.docker.internal:host-gateway"
host-gateway 是 Docker 的特殊值,解析成宿主的网关地址(默认是默认网桥 docker0 的地址,可用 dockerd 的 --host-gateway-ip 覆盖)。容器内验证:
docker exec api getent hosts host.docker.internal
# 172.17.0.1 host.docker.internal
docker exec api sh -c 'nc -z -w2 host.docker.internal 8080 && echo open || echo closed'
比写死宿主 IP 好在哪。 写死 172.17.0.1 这类地址,会在两种常见变更后失效:改 /etc/docker/daemon.json 的 bip 换了网桥网段;或者容器从 bridge 改成 network_mode: host(此时宿主的服务就落在容器自己的 127.0.0.1 上,网关地址不再有意义)。别名把地址解析交给 Docker,配置里不再出现会被改动的字面 IP。
见过一种典型的残留配置:容器已经切到 host 网络模式,客户端的连接 URL 却还是 ws://172.17.0.1:9000,于是怎么改服务端都连不上;改成 127.0.0.1 立即恢复。写死 IP 的代价就是这类「只改了一半」的配置没人找得到。
两个反向约束值得记住:
host-gateway只解决地址怎么写,不解决宿主服务绑在哪。宿主的服务必须监听0.0.0.0或那个网关地址;只听127.0.0.1的宿主服务,容器照样连不上,除非容器也用 host 网络模式与宿主共享 netns。注意host-gateway默认给的是docker0的地址,「容器在自定义网络上时它是否仍适用」最好按自己的环境实测一遍。- host 网络模式直接省掉这一层,代价是失去网络隔离与容器名解析(容器不再属于任何用户自定义网络)。取舍点在于:这个服务是否需要被别的容器按名字访问。
判断顺序
- 容器内
getent hosts <服务名>。失败 → 网络成员问题,看docker network inspect的成员列表;成功 → 网络这一段可以排除。 docker exec <容器> ss -ltnp看监听地址。是127.0.0.1就先改绑定,此时查防火墙是浪费时间。- 宿主
ss -ltnp看发布端口落在哪个地址,0.0.0.0才有对外可能。 iptables -t nat -S DOCKER确认 DNAT 存在;再看iptables -L DOCKER-USER -n -v,用计数器而不是状态命令判断是否被守卫拦掉。- 任何网络配置改动之后一律重建容器(
docker compose up -d --force-recreate),不要指望restart。 - 容器要连宿主上的服务,写
host.docker.internal加extra_hosts: host-gateway,不要写死宿主 IP。
经验上,第 2 步就能判掉相当一部分「连不上」——它们根本没走到 DNAT,而是应用自己绑错了地址。