中文 English

Docker 容器跑得好好的,端口死活访问不了:一次 HostIp 绑定'半残'的完整复盘

发布时间: 2026-06-13
Docker 容器 网络 端口映射 iptables docker-proxy libnetwork 排错 故障复盘

一句话总结:

docker ps 看着一切正常,docker inspectHostConfig.PortBindings 也写好了 203.0.113.14:3001:3000——但 host 上没有 docker-proxy 在监听、iptables nat/DOCKER 链里没有 DNAT 规则NetworkSettings.NetworksPorts 两个字段都是空 {}。这种"容器活着、端口死了"的诡异状态,本质是 libnetwork 在 attach 网络时因为目标接口尚未就绪而静默回滚了 endpoint 创建,但容器进程已经起好,docker 也没把"端口没生效"这件事主动告诉我们。30 秒一行命令就能让端口重新回来docker network connect <net> <ctr>

故障现场:docker ps 显示一切正常,端口就是访问不了

图 1:典型的"半残"现场——容器进程在跑、HostConfig 里端口映射写得清清楚楚,但 host 上没有任何进程监听 3001。


一、问题背景:一个看似不可能的故障

我有一台对外提供 AI 网关服务的机器,上面用 Docker Compose 起了一组服务:new-api、dockhand、headroom 等。它们的 compose 里写了这样一段:

ports:
  - "203.0.113.14:3001:3000"   # new-api
  - "203.0.113.14:3003:3000"   # dockhand
  - "203.0.113.14:3005:3000"   # headroom

也就是说,所有容器的宿主机端口都绑到同一块辅助网卡的固定 IP 上——203.0.113.14 是这块网卡上额外配置的一个 secondary IP,外部访问走这个 IP。所有的内网互联、防火墙白名单、DNS 解析也都只认这个 IP。

一个普通的下午,我尝试从同网段其他节点去访问 http://203.0.113.14:3001/,按预期应该看到 new-api 的登录页。结果:

curl: (7) Failed to connect to 203.0.113.14 port 3001
      after 0 ms: Couldn't connect to server

不只 3001。我又试了 3003、3005:

nc -z -v 203.0.113.14 3001   → Connection refused
nc -z -v 203.0.113.14 3003   → Connection refused
nc -z -v 203.0.113.14 3005   → Connection refused

三个端口同时失联。这不像是某个容器自己挂了,更像是"宿主这一侧"出了什么共性问题。

可诡异的是 docker ps 看起来一切正常——

NAME       STATUS                      PORTS
new-api    Up 4 hours                  ─
dockhand   Up 4 hours (healthy)        ─
headroom   Up 4 hours (healthy)        ─

PORTS 列全是空的,连端口映射都没有显示出来。Up 4 hours 说明它们不是刚刚才启动的,不是重启过程中断。容器是活着的,只是对外不可达。


二、问题分析:把"看不见"的网络状态挖出来

容器没挂、端口没映射、宿主侧又没人监听——这种"沉默的失败"是最难定位的一类。我按下面这个顺序把 host 状态挖了一遍。

1. 容器里自己 curl 一下,先排除"进程死了"

docker exec new-api curl -sS -m 5 -o /dev/null \
  -w 'HTTP %{http_code}\n' http://127.0.0.1:3000/
# HTTP 200

容器内部的 3000 是好的。进程没问题,怀疑点完全落在 host 侧。

2. host 上有没有进程在监听?

ss -tlnp | grep -E '3001|3003|3005'
# 空

什么都没在监听。这是非常关键的信号——正常的 docker 端口映射会启动一个叫 docker-proxy 的子进程作为 userland proxy,host 上一定能看到它。现在它不在,proxy 根本没起。

3. iptables 的 NAT 链里有没有规则?

iptables -t nat -L DOCKER -n -v
# Chain DOCKER (2 references)
#  pkts bytes target prot opt source destination
#  (没有任何规则)

iptables nat 表的 DOCKER 链是 docker 专门用来做端口 DNAT 的地方。这里也是空的。

两个关键位置同时为空,告诉我们一件事:端口映射这条链在某个环节被跳过了。

4. 回头看 docker inspect 的"两个字段"

docker inspect new-api --format '{{json .NetworkSettings}}'
# {
#   "SandboxID": "",
#   "SandboxKey": "",
#   "Ports": {},
#   "Networks": {},
#   "NetworkID": "",
#   "EndpointID": "",
#   "MacAddress": "",
#   "IPAddress": ""
# }

——Networks: {}Ports: {} 同时为空

但另一边呢?

docker inspect new-api --format '{{json .HostConfig}}' | head -c 400
# {"Binds":["/docker/newapi_data/data:/data:rw"],
#  "NetworkMode":"newapi_data_default",
#  "PortBindings":{"3000/tcp":[
#     {"HostIp":"203.0.113.14","HostPort":"3001"}]},
#  "RestartPolicy":{"Name":"always",...}}

PortBindings 还在——“我想把容器的 3000 映射到 203.0.113.14:3001”,这条意图写得清清楚楚。

NetworkMode 也还在——“我希望它挂到 newapi_data_default 这个 bridge 上”。

HostConfig 记得一切,但 NetworkSettings 已经"忘记"了。这是典型的"意图和现实不一致"——docker 把"我应该怎么做"写在了 HostConfig 里,但"我做到了哪一步"在 NetworkSettings 里是空白。

5. 上游的 bridge 网络上有没有人

docker network inspect newapi_data_default --format '{{json .Containers}}'
# {}

newapi_data_default 这个 bridge 上,Containers: {}——它觉得没有任何容器挂上来。 但容器本身在跑、还能 curl 自己、还能 restart。

这说明容器和它声明要加入的 bridge 网络之间,attach 这个动作根本没生效,但 docker 也没把这个失败显式地告诉用户。

半残映射:HostConfig 与 NetworkSettings 之间的"裂缝"

图 2:左侧的 HostConfig 完整写着端口映射和网络名,右侧的 NetworkSettings 却是空 {}。意图还在,现状丢了。

6. 顺手扫一眼其他容器:果然不是个例

for c in $(docker ps --format '{{.Names}}'); do
  nets=$(docker inspect $c --format '{{len .NetworkSettings.Networks}}')
  ports=$(docker inspect $c --format '{{len .NetworkSettings.Ports}}')
  printf '%-25s | networks=%s | ports=%s\n' "$c" "$nets" "$ports"
done

# dockhand   | networks=0 | ports=0
# new-api    | networks=0 | ports=0
# easytier   | networks=1 (host) | ports=0   ← 这个正常,因为它用 host 网络
# headroom   | networks=0 | ports=0

三个非 host 网络的容器同时 networks=0, ports=0。共性只有一个:它们的 HostConfig.NetworkMode 各自指向了不同的自定义 bridge 网络。

到这里,根因的大方向已经基本锁死:容器"想要"加入的 bridge 网络没成功 attach

五分钟诊断清单:从症状到根因的六步走

图 4:把上面六步压缩成「抄作业清单」,下次再遇到「容器活着、端口死了」30 秒就能判断。


三、问题根因:被静默回滚的 endpoint 创建

接下来要回答两个问题:

  1. 为什么不一致?(HostConfig 有,NetworkSettings 没)
  2. 为什么会"同时"出在三个容器上?

3.1 完整的发布链路里,少了哪一截

docker 把容器端口"对外暴露"需要走这条链路:

完整的发布链路与断裂点

图 3:从 VPN 节点到 new-api 0.0.0.0:3000 的完整路径,标 ✕ 的两处是这次断掉的位置。

它大致是这样的:

  1. 容器内new-api 进程 listen 在 0.0.0.0:3000(正常);
  2. libnetwork:创建 network sandbox,把容器接到 newapi_data_default 这个 bridge 上,分配 172.20.0.2 这种内部 IP;
  3. 端口映射:为 HostIp:HostPort 调用 StartProxy 启动 docker-proxy 子进程;
  4. iptables:在 nat 表的 DOCKER 链里插一条 DNAT 203.0.113.14:3001 → 172.20.0.2:3000
  5. 结果上报:填充 NetworkSettings.PortsNetworkSettings.Networks

第 5 步是"落盘"步骤。我们看到的"Ports: {}Networks: {}"——意思是从第 1 步到第 4 步,至少其中一步在沙箱创建阶段就被回滚了libnetwork 在回滚时会清空 NetworkSettings.NetworksNetworkSettings.Ports,但不会去修改 HostConfig.PortBindings,所以你看到的就是现在这个状态。

3.2 这一类 bug 早就被官方记录在案

不是猜的。Moby 的 issue tracker 上早就有一批标题几乎一模一样的工单:

为什么 docker-proxy 没起来、为什么 iptables 没插规则、为什么 NetworkSettings 是空的——这一切都是 libnetwork 在 sandbox 创建失败时静默执行的"局部回滚”

3.3 既然是共因,那"是什么"先于 docker 启动?

这一步是关键:为什么三个非 host 网络的容器同时出问题?它们在 docker 启动时挂载到了一个还不存在的"接口"上。

203.0.113.14 这块 IP 在我的机器上属于辅助网卡——它不是物理网卡的 primary IP,而是用 ip addr add 配上去的 secondary IP。更要命的是这块网卡本身开机时并不存在

docker 启动容器、试图 attach bridge 网络时,目标 HostIp(也就是 203.0.113.14在那一刻还不存在。Moby 在 daemon/libnetwork/portmapper/proxy_linux.goStartProxy()没有校验 HostIP 是否在本机接口上——它把这个地址直接透传给 docker-proxy 子进程的 -host-ip 参数。

// proxy_linux.go (摘要)
cmd := reexec.Command("docker-proxy",
    "-host-ip", p.Binding.HostIP,
    "-host-port", p.Binding.HostPort,
    "-container-ip", p.Binding.IP,
    "-container-port", strings.ToLower(p.Port),
)

启动失败也好、iptables setChildHostIP 判定该 binding 不可用也好——libnetwork 拿到错误就直接回滚 endpoint 创建。回滚动作会把 NetworkSettings 里这一段时间的尝试清空,但不会动 HostConfig,也不会向用户报告"端口映射没生效"。

宿主进程不退出,容器进程不退出,docker 守护进程也还在,但端口从这一刻起就再也没有真正对外暴露过。谁也没报错。

类似的问题在 WireGuard、Tailscale、eBPF-based VPN 这类用户态 TUN 隧道场景下都出现过,moby/moby#39559 这条 issue 的评论里就有人吐槽:“我把整个服务器搬进 docker 了,但 docker 启动比 WireGuard 早,结果 WireGuard 配置好的 IP 上的端口永远绑不上。”

3.4 一句话根因

docker 在 attach 容器到用户自定义 bridge 网络时,如果 HostIp 在那一刻还没绑定到本机接口上,libnetwork 会静默地回滚 sandbox 创建,导致 docker-proxy 不启、iptables DNAT 不插、NetworkSettings.Networks/Ports 被清空,但 HostConfig.PortBindings 仍然写着"我想这样做"——容器进程在跑,端口却对外面不可见。


四、解决问题:一行命令让"半残"变回完整

知道了根因,修复就两步。

4.1 临时治标:docker network connect

最直接的命令——重新走一遍 libnetwork 的 attach 流程,这次 TUN 早就 ready 了:

docker network connect newapi_data_default new-api
docker network connect dockhand_data_default dockhand
docker network connect headroom_default headroom

每跑一行,NetworkSettings 就会从 {} 重新填充到完整的结构,docker-proxy 子进程也会被拉起,iptables DOCKER 链会多出对应的 DNAT 规则。

提示:docker network connect 是一条官方命令,文档原话是 “You can connect a container to one or more networks. The networks need not be the same type."(docs.docker.com)——它本身就是给"挂在某个网络"这个动作做的。

修复后再看:

ss -tlnp | grep 3001
# LISTEN 0  4096  203.0.113.14:3001  docker-proxy

curl -sS -m 5 -o /dev/null \
  -w 'HTTP %{http_code}\n' http://203.0.113.14:3001/
# HTTP 200

回来了。

4.2 治本:让 docker 等 TUN 就绪再起来

上面这个一行命令只能救火,机器下次重启 / TUN 重连之后还会复发。要把这种"半残"挡在启动链路里,更稳的做法是修改开机顺序。

方案 A:systemd 依赖(最干净)

docker.service 加一个 After=,明确告诉 systemd 必须在 TUN 服务起来之后才启动 docker:

[Unit]
After=network-online.target tun-up.service
Wants=network-online.target

network-online.target 自身不能解决"用户态 TUN 还没握手"的问题,但搭配一个 wait-online 的小服务一起用就够稳了。

方案 B:compose 里去掉 HostIp,绑 0.0.0.0(最暴力)

把 compose 里的:

ports:
  - "203.0.113.14:3001:3000"

改成:

ports:
  - "3001:3000"

这样 docker 不挑接口,所有可用 IP 都尝试绑定。代价是失去了"只有这块 IP 才能访问"的天然隔离——你得在 host 防火墙里手动把 3001 的入站白名单配置好。

方案 C:开机探针 + 重连(最稳的兜底)

写一个开机时跑的脚本,等到接口 ready 之后再去 docker network connect

#!/usr/bin/env bash
set -euo pipefail
# 等待 203.0.113.14 出现
until ip -4 addr show | grep -q '203.0.113.14'; do
  sleep 1
done
# 给每个挂掉的容器重连网络
for c in new-api dockhand headroom; do
  net=$(docker inspect "$c" --format '{{.HostConfig.NetworkMode}}')
  docker network connect "$net" "$c" || true
done

挂到 systemd 里、After=docker.service 即可。

治标 vs 治本:临时重连 vs 启动顺序改造

图 4:左侧"治标"那一行 docker network connect 30 秒解决;右侧"治本"是让 docker 在 attach 时接口一定就绪。两者并不互斥,组合使用最稳。

4.3 修复前 vs 修复后(同一个 docker inspect 命令)

修复前后的 docker inspect 与 ss 对比

图 5:修复前 NetworkSettings 全是空 {}、ss 看不到 docker-proxy;一行 docker network connect 之后,两个字段全部填满、docker-proxy 出现、curl 直接 200。


五、Q&A

Q1. 怎么一眼区分"容器进程死了"和"端口没暴露"这两种情况?

:在 host 上直接 curl 容器名:端口 是行不通的——docker network 看不见 host 网络。最干净的一招是进容器自己 curl 一下

docker exec <ctr> curl -sf 127.0.0.1:<port> && echo OK || echo DEAD

Q2. docker restart 能不能修好?

:能,但不一定。我实测下来,如果 host IP 这时候已经 ready(系统跑了一段时间),docker restart 顺带会重新走一遍 sandbox 创建流程,端口可能就回来了。但如果 host IP 还没起,restart 也不会让它"等一会儿再 attach”——它会以同样的失败方式再次进入半残状态。所以 restart 是"碰运气"方案,docker network connect 才是"定向治疗"。

Q3. 为什么 docker port <ctr> 也看不出来?

docker port 命令读的是**NetworkSettings.Ports** 这个字段。但这个字段在这次故障里本身就是空的——docker port 显示为空是"症状"的一部分,不是诊断手段。要看"意图",得用 docker inspect <ctr> --format '{{json .HostConfig.PortBindings}}'

Q4. 三个容器同时出问题,是不是只能一个个重连?

:是的,每个容器只能单独 docker network connect,没有批量子命令。但实际操作上 3 个命令 30 秒不到就完了。要再省事,写个 for 循环就行:

for c in new-api dockhand headroom; do
  net=$(docker inspect "$c" --format '{{.HostConfig.NetworkMode}}')
  docker network connect "$net" "$c" || true
done

Q5. 治本方案 A 里,network-online.target 真的够用吗?

:默认的 network-online.target 只在 systemd-networkd 或 NetworkManager 视角下"主线"网络起来时就触发,对用户态 TUN/WireGuard 这类接口不会自动感知。生产里推荐写一个 1-2 行的小 systemd unit,自己 ip -4 addr show | grep <vpn-ip> 轮询,成功后再 systemctl start docker.service。这比依赖 network-online.target 靠谱得多。

Q6. 还有没有别的情况下会出现这种"半残"?

:有,但**入口都是"host IP 当时不可达"**这一类:

排查思路都一样:先看 HostConfig 是不是"想这样",再看 NetworkSettings 是不是"已经这样",两者不一致 = libnetwork 的局部回滚留下的痕迹

Q7. 那 docker 28 之后会不会"修好"这个 bug?

:截至本文写作时间,Moby issue tracker 上有 #51758 “PortBindings shows binding but NetworkSettings.Ports is empty”issue 链接)这种近期的同类报告仍在被打开,配套修复 PR 是 #52480 但也还在等合入主分支。也就是说,这是个长期遗留的边角问题——理解它,比等它修好更现实。


六、总结

这次故障的复盘路径,核心其实就三件事:

  1. docker inspect 的两个字段要分开看——HostConfig 是"我打算做什么",NetworkSettings 是"我真的做到了什么";两者不一致 = 半残;
  2. ssiptables 反向验证——没有 docker-proxy 进程 + 没有 DNAT 规则 = 100% 命中本次场景;
  3. docker network connect 是治标的最小动作,治本要去改开机顺序,让 docker 跑在用户态 TUN 后面。

ss -tlnpiptables -t nat -L DOCKER 这两条命令刻进肌肉记忆,下次遇到"容器活着、端口死了"的诡异问题,30 秒就能定位、3 个命令就能救火


参考资料