Docker 容器跑得好好的,端口死活访问不了:一次 HostIp 绑定'半残'的完整复盘
一句话总结:
docker ps看着一切正常,docker inspect的HostConfig.PortBindings也写好了203.0.113.14:3001:3000——但 host 上没有 docker-proxy 在监听、iptablesnat/DOCKER链里没有 DNAT 规则、NetworkSettings.Networks和Ports两个字段都是空{}。这种"容器活着、端口死了"的诡异状态,本质是 libnetwork 在 attach 网络时因为目标接口尚未就绪而静默回滚了 endpoint 创建,但容器进程已经起好,docker 也没把"端口没生效"这件事主动告诉我们。30 秒一行命令就能让端口重新回来:docker network connect <net> <ctr>。
图 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 也没把这个失败显式地告诉用户。
图 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 创建
接下来要回答两个问题:
- 为什么不一致?(
HostConfig有,NetworkSettings没) - 为什么会"同时"出在三个容器上?
3.1 完整的发布链路里,少了哪一截
docker 把容器端口"对外暴露"需要走这条链路:
图 3:从 VPN 节点到 new-api 0.0.0.0:3000 的完整路径,标 ✕ 的两处是这次断掉的位置。
它大致是这样的:
- 容器内:
new-api进程 listen 在0.0.0.0:3000(正常); - libnetwork:创建 network sandbox,把容器接到
newapi_data_default这个 bridge 上,分配172.20.0.2这种内部 IP; - 端口映射:为
HostIp:HostPort调用StartProxy启动docker-proxy子进程; - iptables:在
nat表的DOCKER链里插一条DNAT 203.0.113.14:3001 → 172.20.0.2:3000; - 结果上报:填充
NetworkSettings.Ports和NetworkSettings.Networks。
第 5 步是"落盘"步骤。我们看到的"Ports: {} 和 Networks: {}"——意思是从第 1 步到第 4 步,至少其中一步在沙箱创建阶段就被回滚了。libnetwork 在回滚时会清空 NetworkSettings.Networks 和 NetworkSettings.Ports,但不会去修改 HostConfig.PortBindings,所以你看到的就是现在这个状态。
3.2 这一类 bug 早就被官方记录在案
不是猜的。Moby 的 issue tracker 上早就有一批标题几乎一模一样的工单:
- moby/moby#9818 “Container port not expose; neither iptables rules added nor userland proxy started”——2014 年的老 issue,标题直接就是这次的症状;
- moby/moby#44137 “docker network connect removes/resets dynamically published/exposed ports”——明确指出
docker network connect在某些条件下会"重置"端口映射; - moby/moby#52480 的描述里直接写了:“the conflicting port goes silently unbound…
HostConfig.PortBindingsis preserved but never applied."——意图保留、现实被放弃,正是这次的精确翻版。
为什么 docker-proxy 没起来、为什么 iptables 没插规则、为什么 NetworkSettings 是空的——这一切都是 libnetwork 在 sandbox 创建失败时静默执行的"局部回滚”。
3.3 既然是共因,那"是什么"先于 docker 启动?
这一步是关键:为什么三个非 host 网络的容器同时出问题?它们在 docker 启动时挂载到了一个还不存在的"接口"上。
203.0.113.14 这块 IP 在我的机器上属于辅助网卡——它不是物理网卡的 primary IP,而是用 ip addr add 配上去的 secondary IP。更要命的是这块网卡本身开机时并不存在:
- 它由一个用户态 TUN 服务在系统起来之后才创建;
- TUN 服务跑起来后,TUN 接口 up、
203.0.113.14才会被挂上; - 这中间有一个不可避免的时序窗口——docker 守护进程跑得比 TUN 早。
docker 启动容器、试图 attach bridge 网络时,目标 HostIp(也就是 203.0.113.14)在那一刻还不存在。Moby 在 daemon/libnetwork/portmapper/proxy_linux.go 的 StartProxy() 里没有校验 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 即可。
图 4:左侧"治标"那一行 docker network connect 30 秒解决;右侧"治本"是让 docker 在 attach 时接口一定就绪。两者并不互斥,组合使用最稳。
4.3 修复前 vs 修复后(同一个 docker inspect 命令)
图 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
OK→ 进程没事,怀疑 host 侧(按本文继续);DEAD→ 进程自己挂了,去看docker logs <ctr>,与端口映射无关。
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 当时不可达"**这一类:
- 浮动 IP(floating IP / elastic IP)漂移之后,docker 没感知到;
- bond 网卡切换 master、IP 跟着走时老 master 已经被释放;
- DHCP 续约失败、IP 临时被收回再下发;
- compose 文件里
network_mode: host和-p同时用,旧版本 docker 行为不一致。
排查思路都一样:先看 HostConfig 是不是"想这样",再看 NetworkSettings 是不是"已经这样",两者不一致 = libnetwork 的局部回滚留下的痕迹。
Q7. 那 docker 28 之后会不会"修好"这个 bug?
答:截至本文写作时间,Moby issue tracker 上有 #51758 “PortBindings shows binding but NetworkSettings.Ports is empty”(issue 链接)这种近期的同类报告仍在被打开,配套修复 PR 是 #52480 但也还在等合入主分支。也就是说,这是个长期遗留的边角问题——理解它,比等它修好更现实。
六、总结
这次故障的复盘路径,核心其实就三件事:
docker inspect的两个字段要分开看——HostConfig是"我打算做什么",NetworkSettings是"我真的做到了什么";两者不一致 = 半残;- 用
ss和iptables反向验证——没有 docker-proxy 进程 + 没有 DNAT 规则 = 100% 命中本次场景; docker network connect是治标的最小动作,治本要去改开机顺序,让 docker 跑在用户态 TUN 后面。
把 ss -tlnp 和 iptables -t nat -L DOCKER 这两条命令刻进肌肉记忆,下次遇到"容器活着、端口死了"的诡异问题,30 秒就能定位、3 个命令就能救火。
参考资料
- Moby 源码 · daemon/libnetwork/portmapper/proxy_linux.go ——
StartProxy直接以-host-ip形式把 HostIP 传给 docker-proxy - Moby 源码 · daemon/libnetwork/portmappers/nat/mapper_linux.go ——
setChildHostIP在 rootless 之外的判定逻辑 - Moby 源码 · daemon/libnetwork/portmapperapi/api.go ——
PortBinding/StopProxy的结构与生命周期 - moby/moby #9818 — Container port not expose; neither iptables rules added nor userland proxy started —— 2014 年起的同症状工单
- moby/moby #39559 — Container does not start (–restart always) at boot if port bind fails —— 跟 TUN/WireGuard 场景最接近的工单
- moby/moby #44137 — docker network connect removes/resets dynamically published/exposed ports —— 端口映射在某些条件下被
network connect重置 - moby/moby #51758 — PortBindings shows binding but NetworkSettings.Ports is empty —— 2025 年仍在报告的同类问题
- moby/moby #52480 — connectToNetwork: keep Networks on failure —— 同问题修复 PR
- Docker Docs — Networking overview (engine/network) ——
-p创建防火墙规则的官方描述 - Docker Docs — Docker with iptables (engine/network/firewall-iptables) —— iptables
DOCKER链的权威定义 - Docker Docs — Port publishing and mapping ——
HostIP形式-p的细节 - Docker Docs — docker network connect reference —— “把容器连到网络上"的官方命令
- Docker Blog — Docker Engine v28: Hardening Container Networking by Default —— 理解 v28 之后容器网络行为变化
- systemd 文档 — systemd.unit(5), After=/Wants= 关系