Chips

Docker 网络排查手册:容器连不上时按什么顺序看

· 约 8 分钟 · 2854 字

容器连不上其实是三条互不相干的路径:容器间靠嵌入式 DNS 解析服务名,宿主访问靠端口发布加 DNAT,外部访问再叠一层防火墙。按网络成员、监听地址、NAT 规则的顺序查,多数问题在前两步就能定位。

先分清三种「连不上」

同样是 connection refused / timeout,甚至同样是 502,流量走的可能是三条完全不同的路:

谁连谁 实际路径 决定成败的机制
容器 → 容器 veth → 网桥 → veth(同一网络内) 服务名能否被解析;应用是否监听可达地址
宿主 → 容器 宿主协议栈 → nat/PREROUTING(DNAT)→ FORWARD 端口是否发布;应用监听地址
外网 → 容器 公网 → 宿主网卡 → DNAT → FORWARD 上面全部,再叠宿主防火墙与上游安全组

症状相似,排查命令却完全不重叠。所以第一步不是猜,而是先确定故障落在哪一段。三条便宜的判别式:

路径:网桥、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容器自己的回环,不是宿主的。应用只听这个地址时,两个方向同时断:

一条命令就能定性:

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-recreatedocker rm 后重跑,容器回到创建时的网络集合,手工 connect 的那一份消失,故障原样复现。所以用它验证假设可以,修完必须把结果落到 compose 的 networks: 声明里,否则只是把问题藏到下次重建。

宿主访问已发布端口也走 FORWARD

这一条反直觉但很关键。目的地址在 nat/PREROUTING 被 DNAT 改写之后,这个包对内核来说就是「转发给另一个地址」,因此源是 127.0.0.1 的流量也从 FORWARD 走,不经过 INPUT。

于是任何按来源过滤的规则(ufwdeny 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.jsonbip 换了网桥网段;或者容器从 bridge 改成 network_mode: host(此时宿主的服务就落在容器自己的 127.0.0.1 上,网关地址不再有意义)。别名把地址解析交给 Docker,配置里不再出现会被改动的字面 IP。

见过一种典型的残留配置:容器已经切到 host 网络模式,客户端的连接 URL 却还是 ws://172.17.0.1:9000,于是怎么改服务端都连不上;改成 127.0.0.1 立即恢复。写死 IP 的代价就是这类「只改了一半」的配置没人找得到。

两个反向约束值得记住:

判断顺序

  1. 容器内 getent hosts <服务名>。失败 → 网络成员问题,看 docker network inspect 的成员列表;成功 → 网络这一段可以排除。
  2. docker exec <容器> ss -ltnp 看监听地址。是 127.0.0.1 就先改绑定,此时查防火墙是浪费时间。
  3. 宿主 ss -ltnp 看发布端口落在哪个地址,0.0.0.0 才有对外可能。
  4. iptables -t nat -S DOCKER 确认 DNAT 存在;再看 iptables -L DOCKER-USER -n -v,用计数器而不是状态命令判断是否被守卫拦掉。
  5. 任何网络配置改动之后一律重建容器(docker compose up -d --force-recreate),不要指望 restart
  6. 容器要连宿主上的服务,写 host.docker.internalextra_hosts: host-gateway,不要写死宿主 IP。

经验上,第 2 步就能判掉相当一部分「连不上」——它们根本没走到 DNAT,而是应用自己绑错了地址。

← 全部文章