- Published on
Docker 网络模式与连接问题 — bridge、host、overlay,以及 127.0.0.1 的陷阱
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 容器起来了,却连不上
- 五种模式实际都在做什么
- 默认 bridge 下容器为什么无法用名字互相找到
- 端口发布的真面目 — iptables DNAT 与绑定地址
- 容器里的 127.0.0.1 不是宿主机
- 当应用只绑定回环时
- 防火墙、MTU、overlay — 运维中会遇到的那些
- 结语 — 连接问题就是四个阶段里断在哪一步的问题
引言 — 容器起来了,却连不上
docker ps 说是 Up。日志里也打出了 Server listening on port 3000。可是用浏览器打开却连接被拒。从旁边那个容器执行 curl http://api:3000,名字解析不出来。在容器里用 curl http://127.0.0.1:5432 想连宿主机上的数据库,得到的答复是那里什么也没有。
这三个症状都不是应用的 bug。它们全部由同一个事实推导而来:容器拥有属于自己的网络命名空间。网络命名空间复制的不只是接口和路由表,还连回环地址和端口号空间一起整个复制。容器的 127.0.0.1 与宿主机的 127.0.0.1 是完全不同的地址。
本文先梳理各模式的行为,再在此基础上逐个解剖上面三个症状。
五种模式实际都在做什么
docker run --network 接受的值有五种。光背名字容易搞混,不如按它们各自如何处理网络命名空间来理解。
bridge 是默认值。它为每个容器新建一个命名空间,把 veth 对的一端放进容器,另一端接到宿主机的网桥上。
docker run -d --name web nginx:1.27
ip -br link show type bridge
ip -br link | grep veth
docker0 UP 02:42:8f:1c:03:aa <BROADCAST,MULTICAST,UP,LOWER_UP>
veth3a91c7e@if12 UP ba:19:7c:2e:44:81 <BROADCAST,MULTICAST,UP,LOWER_UP>
docker exec web ip -br addr
lo UNKNOWN 127.0.0.1/8
eth0@if13 UP 172.17.0.2/16
host 干脆不建命名空间,直接原样使用宿主机的网络栈。没有 NAT,开销消失了,但端口冲突照样发生,-p 选项会被忽略。它只在 Linux 上有意义,在 Docker Desktop 上行为不同。
docker run --rm --network host alpine:3.20 ip -br addr | head -3
lo UNKNOWN 127.0.0.1/8
enp5s0 UP 10.0.4.17/24
docker0 UP 172.17.0.1/16
none 会建命名空间,但里面只留一个回环。适合不需要网络的批处理作业,或者隔离的执行环境。
container:NAME 会加入另一个容器的命名空间。两个容器共享同一个接口和同一个回环,所以彼此可以用 127.0.0.1 互相称呼。
docker run -d --name app myapp:v1
docker run -d --name sidecar --network container:app envoyproxy/envoy:v1.31-latest
docker exec sidecar ip -br addr
lo UNKNOWN 127.0.0.1/8
eth0@if17 UP 172.17.0.4/16
Kubernetes 的 Pod 正是这个结构。pause 容器持有命名空间,其余的加入进来,所以同一个 Pod 里的容器之间可以走回环通信。
overlay 是横跨多台宿主机的虚拟二层网络。它用 VXLAN 封装数据包在节点之间穿行,需要 Swarm 或者外部的键值存储。
| 模式 | 命名空间 | 容器 IP | 名字解析 | 端口发布 | 主要用途 |
|---|---|---|---|---|---|
默认 bridge | 新建 | 172.17.0.0/16 | 不行 | 需要 | 单个容器 |
用户自定义 bridge | 新建 | 各网络的子网 | 内置 DNS | 需要 | 单主机多服务 |
host | 与宿主机共享 | 宿主机 IP | 宿主机配置 | 被忽略 | 高性能、端口扫描器 |
none | 新建 | 只有回环 | 没有 | 不可 | 完全隔离 |
container:NAME | 与目标共享 | 与目标相同 | 与目标相同 | 由目标持有 | 边车 |
overlay | 新建 | overlay 子网 | 内置 DNS + VIP | 路由网格 | 多主机 |
默认 bridge 下容器为什么无法用名字互相找到
这是最常见的第一次挫败。
docker run -d --name db postgres:16
docker run --rm alpine:3.20 ping -c1 db
ping: bad address 'db'
两个容器都在同一个 docker0 网桥上,用 IP 是能通的。不行的只有名字解析。因为默认 bridge 网络上不会挂 Docker 的内置 DNS。以前是用 --link 往 /etc/hosts 里塞条目来解决的,但这个功能早就被标为遗留了。
建一个用户自定义网络,情况就不一样了。
docker network create app-net
docker run -d --name db --network app-net postgres:16
docker run --rm --network app-net alpine:3.20 ping -c1 db
PING db (172.19.0.2): 56 data bytes
64 bytes from 172.19.0.2: seq=0 ttl=64 time=0.089 ms
差别直接体现在容器内的解析器配置上。
docker run --rm --network app-net alpine:3.20 cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0
127.0.0.11 是只存在于容器命名空间内部的地址,该命名空间的 NAT 规则会把这个请求转交给 Docker 守护进程的解析器。守护进程知道属于同一网络的容器名和别名,于是给出答案;不认识的名字则转给宿主机的上游 DNS。
还可以挂多个别名。在改服务名的过渡期里保留旧名字时很有用。
docker run -d --name api-v2 \
--network app-net \
--network-alias api \
--network-alias backend \
api:v312
用 Compose 的话这一切都是自动的。Compose 会给每个项目建一个用户自定义网络,并把服务名注册为别名。所以才会出现在 Compose 下好好的,搬到 docker run 就不行了的情况。
services:
api:
image: api:v312
environment:
DATABASE_URL: postgres://app@db:5432/app # 'db' 是服务名
db:
image: postgres:16
结论很简单。默认 bridge 是为了向后兼容才留着的,实务上永远建一个用户自定义网络来用。
端口发布的真面目 — iptables DNAT 与绑定地址
-p 8080:80 做的事不是魔法,就是一行 NAT 规则。
docker run -d --name web -p 8080:80 nginx:1.27
sudo iptables -t nat -S DOCKER
-N DOCKER
-A DOCKER -i docker0 -j RETURN
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
进到宿主机 8080 的 TCP 包,目的地被改成了 172.17.0.2:80。除此之外,Docker 还会一并起一个叫 docker-proxy 的用户空间进程。
ps -eo pid,args | grep docker-proxy | head -1
52104 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80
这个进程负责处理那些 NAT 规则用不上的路径,比如来自宿主机自身的回环流量。绝大部分流量由 iptables 处理,docker-proxy 只是辅助角色。
-host-ip 0.0.0.0 应该引起注意。默认的发布会绑定到宿主机的所有接口上。不管是内网还是公网 IP,统统打开。把本地开发用的数据库这样敞着结果暴露到互联网上的案例,至今仍在不断出现。
# 危险:暴露在所有接口上
docker run -d -p 5432:5432 postgres:16
# 安全:只能从宿主机回环访问
docker run -d -p 127.0.0.1:5432:5432 postgres:16
ss -tlnp | grep -E '5432'
LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy",pid=52310,fd=4))
如果是远程开发服务器,在此之上再叠一层 SSH 转发才是常规做法。
ssh -N -L 5432:127.0.0.1:5432 dev@build-01.internal
只需要容器之间互通的服务,压根就不该发布。在同一个用户自定义网络里可以按名字连接,所以不需要 -p。Compose 的 expose 只是文档用途,并不会在宿主机上开端口。
容器里的 127.0.0.1 不是宿主机
这是新手踩得最多的地方。本地开发时数据库跑在宿主机上、只把应用放进容器,那条用惯了的连接串就会原样失败。
docker run --rm --network app-net api:v312 \
sh -c 'nc -zv 127.0.0.1 5432'
nc: 127.0.0.1 (127.0.0.1:5432): Connection refused
容器的回环就是容器自己。要连宿主机,需要一个指向宿主机的别的地址。
如果是 Docker Desktop,特殊名字已经准备好了。
docker run --rm alpine:3.20 \
sh -c 'apk add -q bind-tools && dig +short host.docker.internal'
192.168.65.254
在 Linux 上不会自动提供,所以要显式添加。
docker run --rm --add-host=host.docker.internal:host-gateway \
alpine:3.20 getent hosts host.docker.internal
172.17.0.1 host.docker.internal
host-gateway 是 Docker 会替换成网桥网关地址的保留字。在 Compose 里也是同样的写法。
services:
api:
image: api:v312
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
DATABASE_URL: postgres://app@host.docker.internal:5432/app
还有一点要确认。如果宿主机上的服务只绑定在 127.0.0.1 上,那么通过网桥网关地址是访问不到的。PostgreSQL 的话要调整 listen_addresses,或者改 pg_hba.conf 只放行容器的子网。而一旦把宿主机服务开到 0.0.0.0,就必须同时检查防火墙。
当应用只绑定回环时
这是第二常见的症状。容器起来了,端口也发布了,连接却被拒绝。
docker run -d --name api -p 3000:3000 api:v312
curl -sS -m 3 http://localhost:3000/healthz
curl: (52) Empty reply from server
到容器内部确认真正的监听地址。
docker exec api sh -c 'ss -tlnp'
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 511 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=1,fd=20))
应用只绑定到了容器的回环上。从宿主机进来的包会到达 eth0,也就是 172.17.0.2,因此够不着这个套接字。正确的值是 0.0.0.0。
LISTEN 0 511 0.0.0.0:3000 0.0.0.0:* users:(("node",pid=1,fd=20))
各框架的默认值不同。为了开发方便而把回环设为默认的工具很多,所以搬进容器时必定会被绊住。
# Node / Express
node -e "require('express')().listen(3000, '0.0.0.0')"
# Flask
flask run --host=0.0.0.0 --port=5000
# Django
python manage.py runserver 0.0.0.0:8000
# Rails
bin/rails server -b 0.0.0.0 -p 3000
# Vite
vite --host 0.0.0.0
# Next.js
next start -H 0.0.0.0 -p 3000
# Go: net/http
# http.ListenAndServe(":8080", nil) <- 已经是所有接口
这里常有的担心是"开在 0.0.0.0 上不危险吗"。容器内部的 0.0.0.0 只意味着那个容器的整个网络命名空间,是否对外暴露由上一节讲的发布绑定地址决定。应用在容器里绑定 0.0.0.0,暴露范围则由宿主机一侧的绑定地址来控制。把这两层混在一起,两边都会错。
防火墙、MTU、overlay — 运维中会遇到的那些
有三类问题本地不出现,只在服务器上出现。
第一,宿主机防火墙拦不住 Docker 的端口。明明用 UFW 封了 8080,外面却连得上。
sudo ufw status | head -4
Status: active
To Action From
-- ------ ----
8080/tcp DENY Anywhere
curl -sS -o /dev/null -w '%{http_code}\n' http://203.0.113.42:8080/
200
原因在于链上的位置。UFW 的规则大多进的是 INPUT 链。可是发往容器的包并不以宿主机为最终目的地,所以走的是 FORWARD 链而不是 INPUT,而且在那之前,nat 表的 PREROUTING 就已经用 DNAT 改掉了目的地。UFW 根本没有检查的机会。
解法有三个。最安全也最简单的,就是前面讲的限制绑定地址。确实需要对外暴露时,就把规则直接放进 Docker 为用户规则留空的 DOCKER-USER 链。
sudo iptables -I DOCKER-USER -i enp5s0 ! -s 10.0.0.0/8 -p tcp --dport 8080 -j DROP
sudo iptables -t filter -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -i enp5s0 ! -s 10.0.0.0/8 -p tcp -m tcp --dport 8080 -j DROP
-A DOCKER-USER -j RETURN
DOCKER-USER 在 Docker 重启后仍会保留,并且比 Docker 自动生成的规则更早被求值。第三个选项 iptables: false 会让 Docker 完全不创建 NAT 规则,但连容器的出站也得自己配置,所以不推荐。近来的 Docker 版本在不断强化默认过滤,因此实际行为务必在你正在使用的版本上亲自确认。
第二,MTU 问题。症状非常有特征。TCP 连接建立得起来,小请求也成功,但大响应或者 TLS 握手会卡住。
docker exec api curl -sS -o /dev/null -w '%{http_code}\n' https://api.partner.example.com/v1/ping
200
docker exec api curl -sS -o /dev/null -w '%{http_code}\n' https://api.partner.example.com/v1/bulk
curl: (28) Operation timed out after 30001 milliseconds with 0 bytes received
原因是封装。overlay 网络的 VXLAN 头要多占 50 字节,于是实际可用的 MTU 降到 1450。再叠上 IPsec 或 WireGuard 还会更小。可容器接口如果仍然是 1500,大包就得分片,而路径上要是把 ICMP 拦掉了,发送方无从得知,于是就那样卡死。这就是黑洞式的 MTU 问题。
诊断用带上禁止分片标志的 ping 来做。
docker exec api ping -M do -s 1472 -c 2 api.partner.example.com
PING api.partner.example.com (203.0.113.90) 1472(1500) bytes of data.
ping: local error: message too long, mtu=1450
docker exec api ping -M do -s 1422 -c 2 api.partner.example.com
1430 bytes from 203.0.113.90: icmp_seq=1 ttl=52 time=11.8 ms
1422 字节能过而 1472 过不去,所以实际 MTU 是 1450。在创建网络时显式指定 MTU 就能解决。
docker network create \
--driver bridge \
--opt com.docker.network.driver.mtu=1450 \
app-net
// /etc/docker/daemon.json — 应用到默认网桥
{
"mtu": 1450
}
第三,是 overlay 网络本身的要求。Swarm 的 overlay 需要节点之间开放三类通信:集群管理用 2377/tcp,节点间的 gossip 通信用 7946/tcp 和 7946/udp,VXLAN 数据平面用 4789/udp。安全组里光是堵了一个 4789/udp,就会陷入服务发现正常、偏偏实际流量不通的那种令人困惑的状态。
docker network create --driver overlay --attachable --opt encrypted app-mesh
docker service create --name api --network app-mesh --replicas 3 api:v312
docker network inspect app-mesh --format '{{json .Containers}}' | jq 'keys | length'
3
打开 --opt encrypted 后,节点间的流量会用 IPsec 加密,但头部一多,有效 MTU 又会减小。前面那笔 MTU 账要重新算一遍。
缩小问题范围的顺序永远一样。先在容器里看监听地址,再从同一网络的另一个容器分别用名字和 IP 去连,然后从宿主机连已发布的端口,最后从外部尝试。在哪一步断掉,哪里就是原因。
docker exec api ss -tlnp # 1. 监听地址
docker run --rm --network app-net alpine:3.20 \
sh -c 'nc -zv api 3000; nc -zv 172.19.0.3 3000' # 2. 名字/IP 解析
curl -sS -m 3 http://127.0.0.1:3000/healthz # 3. 从宿主机
curl -sS -m 3 http://203.0.113.42:3000/healthz # 4. 从外部
结语 — 连接问题就是四个阶段里断在哪一步的问题
Docker 网络只需记住一句话。容器拥有属于自己的网络栈,所以,地址永远要带上"从谁的视角看"再去读。容器的 127.0.0.1、宿主机的 127.0.0.1、网桥网关,以及从外部看到的宿主机 IP,全都是不同的东西。
实务上的默认做法也能简短概括。建一个用户自定义网络来获得名字解析,应用在容器里绑定 0.0.0.0,对外暴露只通过宿主机一侧的绑定地址来控制,只在容器之间使用的端口不要发布。而且绝对不要假设宿主机防火墙会替你拦住 Docker 的端口。