Skip to content

필사 모드: Docker 网络模式与连接问题 — bridge、host、overlay,以及 127.0.0.1 的陷阱

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 容器起来了,却连不上

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 的端口。

현재 단락 (1/176)

`docker ps` 说是 Up。日志里也打出了 `Server listening on port 3000`。可是用浏览器打开却连接被拒。从旁边那个容器执行 `curl http://api:3...

작성 글자: 0원문 글자: 8,475작성 단락: 0/176