容器里的服务自己跑得好好的,健康检查也过,可同宿主机上另一个容器拿服务名去连,死活连不上;换成一个固定 IP 立刻通。这种现场我碰到过不止一次,第一次遇到时足足查了两个小时,最后发现是两个容器根本不在同一个网络里——名字解析走的是 Docker 内嵌的 DNS,而默认的 bridge 网络压根不提供这个能力。Docker 网络这块知识,文档上写的都是参数说明,真正让人卡住的往往是参数背后那条包从容器出来、穿过网桥、被规则改写、最后落到宿主网卡上的完整链路。这篇东西就是把我这些年攒下来的 Docker 网络实操经验摊开讲:默认网络到底怎么拼出来的,五种网络模式各自的边界在哪,-p 参数究竟动了几张表,跨主机组网为什么总在 MTU 上翻车,以及排错时该怎么一层一层往下剥。不管你是刚把第一个容器跑起来的新手,还是已经在维护多服务拓扑的老手,下面这些内容应该都能对上你踩过的某个坑。
1. 一个连不上的容器,把 Docker 网络的组成拆开了
1.1 容器网络由哪几块拼成
很多人对 Docker 网络的第一印象就是docker network ls里那几个名字,觉得网络就是一个配置对象。实际上容器能通网,靠的是四样东西叠在一起:一是network namespace,它给容器提供了一套独立的网卡、路由表、iptables 规则和端口空间;二是veth pair,一对虚拟网线,一头插在容器的 namespace 里,另一头插在宿主的 namespace 里;三是网桥,宿主上的 docker0 或者自定义网桥,负责把这些虚拟网线连成一张二层网络;四是iptables 规则,负责地址转换和转发放行。四者缺一,容器就是个孤岛。
理解这个分层很关键,因为它决定了排错的方向。容器 ping 不通外网,问题可能在路由表(第三层),可能在网桥转发(第二层),也可能在 NAT 规则(iptables),还可能是宿主内核的ip_forward没开。你在容器里ping一个域名失败,看见的是同一个现象,但根因差得远。所以我自己养成一个习惯:遇到网络问题,先在宿主机上ip link看一眼 veth 是否存在,再docker network inspect看容器挂在哪张网桥上,最后iptables -t nat -L看规则,三分钟能定位大半。
顺带说一个容易忽略的点:veth pair 是成对存在的,容器一停,宿主上对应那根 veth 也就消失了。所以你不要用ip link里 veth 的数量去长期统计容器数,那个数字是实时变化的,脚本里依赖它会出问题。
1.2 docker0 网桥和 veth pair 长什么样
装完 Docker 之后,宿主机上一定会多出一块叫docker0的网卡,它默认拿到172.17.0.1/16,状态是 UP 但通常没有物理上联。这块网卡就是默认 bridge 网络的网关。容器启动时,Docker 会创建一对 veth,容器内那端通常叫eth0,宿主那端叫veth加一串随机字符,然后把宿主端 attach 到 docker0 上。
你在容器里ip addr看到eth0的地址是172.17.0.2/16,在宿主上ip link show能看到vethxxxx@if3这种形式,@if3后面的数字就是容器内那端的 interface index。这个对应关系在抓包时特别有用:你想看某个容器的流量,就在宿主上tcpdump -i vethxxxx,或者在 docker0 上抓全部。我排一个丢包问题时就是这么干的,先在 docker0 上抓,发现包根本没出来,往回一查是容器内的路由表把目标网段指向了错误的网关。
还有一个细节:默认 bridge 的容器 IP 是从172.17.0.0/16里顺序分配的,容器删了地址可能被别的容器复用。如果你在配置里硬编码了容器 IP,重启之后大概率出事。这也是我后面强烈推荐自定义网络的原因之一——自定义网络虽然也会复用地址,但至少你能通过名称解析绕开 IP 硬编码这个坑。
1.3 出网这一步:NAT 链上的顺序
容器访问外网,包先到 docker0,宿主内核发现目标不是本地网段,查路由表决定从物理网卡出去,然后进POSTROUTING链做源地址转换,把源 IP 从172.17.0.2改成宿主网卡的 IP,回包再反向转换回来。这条 MASQUERADE 规则在iptables -t nat -L POSTROUTING里能看到,条件是源地址属于172.17.0.0/16且出口不是 docker0。
问题经常出在FORWARD链上。有些系统的防火墙默认策略是 DROP,Docker 启动时会往里插自己的规则,但如果规则顺序被别的工具打乱,或者有人手动清过链,容器就出不了网,现象是容器内 ping 网关通、ping 外网不通。这时候你去看iptables -L FORWARD -n -v,会看到 Docker 相关的链没有被引用。修复方式不是暴力iptables -F,而是重启 Docker 服务让它重新插入规则,或者在防火墙配置里显式放行 docker0 的转发。
我个人的经验是:永远不要在装了 Docker 的机器上随手清 iptables。那些规则不是垃圾,是容器通网的命脉。要定制规则,走DOCKER-USER链,这条链是 Docker 专门留给用户的前置钩子,位置在 Docker 自己的规则之前,改它不会被容器重启冲掉。
2. 五种默认网络模式:它们不是选项列表,是五个答案
2.1 bridge、host、none、container 的边界
docker network ls里默认能看到 bridge、host、none 三个,第四个 container 模式不在列表里,是通过--network container:xxx引用的。这四个加自定义网络,构成了容器网络的全部基础形态。它们的区别不在于"哪个更好",而在于"你要解决什么问题"。
bridge 是默认值,容器有独立网络栈,通过 NAT 出网,需要端口映射才能被外部访问。host 模式下容器直接共享宿主的网络命名空间,容器里监听 80 就是宿主监听 80,不需要-p,也不会有 NAT 开销。none 模式只给容器一个 lo,完全没有对外网卡。container 模式让新容器复用另一个已存在容器的网络栈,两者共享 IP 和端口空间。
| 模式 | 独立网络栈 | 需要端口映射 | 典型用途 |
|---|---|---|---|
| bridge(默认) | 是 | 是 | 绝大多数普通服务 |
| 自定义 bridge | 是 | 是 | 多容器协作,需要名称解析 |
| host | 否 | 否 | 高性能网络、需要拿到真实客户端 IP |
| none | 是(仅 lo) | 不适用 | 纯计算任务、离线批处理 |
| container | 否(共享) | 否 | 边车容器、共享网络栈的辅助进程 |
这张表看着简单,实际选型时的判断依据只有两条:这个容器需不需要独立 IP,以及它需不需要和别的容器按名字互相找。第一条决定用不用 host,第二条决定用不用自定义网络。默认 bridge 在这两个问题上都是"都不太行"的答案,所以我几乎不在正式环境里用它。
2.2 host 模式被低估的地方与被高估的地方
host 模式最实在的好处是省掉了一层 NAT。高并发短连接的场景下,NAT 带来的 conntrack 表压力和端口耗尽风险是真实存在的,我之前维护的一个采集服务,QPS 上去之后经常在 conntrack 上报警,换成 host 模式后这个现象明显缓解。另一个好处是容器里看到的源 IP 就是真实客户端 IP,不用在应用层去解析X-Forwarded-For这类头部。
但 host 模式的代价也很明确:容器不再有独立 IP,端口直接和宿主共享,两个容器都想监听 8080 就必然冲突。它还会破坏容器"可随意迁移"的前提——如果宿主上 80 端口已经被别的服务占了,这个容器就起不来。更要命的是可观测性变差,你在宿主上ss -lntp看到的进程归属会变得混乱,排查端口占用时得额外区分是宿主进程还是容器进程。
还有一个常被误解的点:host 模式下-p参数不是报错,而是被静默忽略并给一条 warning。我就见过有人写了-p 8080:80 --network host,然后发现服务监听的其实是 80,-p完全没生效。这种"参数写了但不起作用"的情况最容易误导排查方向。
我的判断标准很直接:需要拿到真实源 IP、或者对网络性能极其敏感、且端口不会冲突的服务,用 host;其余一律用自定义 bridge。中间地带的服务,宁可多一层 NAT,也别为了省那点开销牺牲部署灵活性。
2.3 none 与 container 模式的真实用途
none 模式听起来很边缘,其实在安全敏感的场景里挺有用。给容器一个完全隔离的网络环境,只留 lo,意味着它连 DNS 都查不了,任何外联都出不去。做数据处理的批任务、跑纯计算的模型推理、执行一次性的脚本,用 none 模式能从网络层面掐断意外外联的可能。我自己在做一些需要严格隔离的数据清洗任务时就会用--network none,跑完把结果通过挂载卷写出去,整个过程不碰网络。
container 模式的价值在于共享网络栈。最典型的用法是边车:主容器跑业务,边车容器跑一个本地转发或者采集程序,两者共享同一个网络命名空间,边车可以直接监听127.0.0.1上的端口,主容器访问127.0.0.1就等同于访问边车。这种模式下两个容器连 IP 都一样,端口自然也不能冲突。
不过 container 模式有个隐蔽的坑:网络栈的生命周期跟着被引用的那个容器走。如果主容器重启,网络命名空间会重建,边车容器虽然还在跑,但它引用的那块网络栈已经变了,可能出现"进程活着但网络断了"的诡异状态。所以这个模式我不建议用在长时间运行的服务上,用在短生命周期的辅助任务里更合适。
3. 自定义 bridge 网络:正式环境绕不开它的三个理由
3.1 内嵌 DNS 是真正的分水岭
默认 bridge 网络最让人难受的地方是容器之间只能用 IP 互访。早年有--link参数能做名称关联,但那个机制本质是往容器的 hosts 文件里写死记录,容器重启 IP 一变就失效,而且 link 是单向的,A link B 之后 B 看不见 A。这个参数现在已经被标记为遗留特性,新项目不该再用。
自定义 bridge 网络解决这个问题的方式是内嵌一个 DNS 解析器,监听在127.0.0.11:53。容器内的/etc/resolv.conf里 nameserver 会指向这个地址,Docker 通过 iptables 规则把 53 端口的查询重定向到内嵌 DNS。这个 DNS 认识同一网络里所有容器的名字、服务名和--network-alias指定的别名,还负责把不属于容器名的查询转发给宿主配置的上游 DNS。
这个机制带来的直接好处是:容器名就是主机名。你docker run --name mysql-db --network appnet,同网络里另一个容器ping mysql-db就能通,不用关心它实际分到的是172.18.0.2还是172.18.0.7。在 compose 里更是如此,服务名自动成为可解析的主机名,这也是为什么 compose 项目里写depends_on加服务名当连接串能直接跑通。
有个细节值得记一下:内嵌 DNS 只在自定义网络里生效,默认 bridge 上的容器用的是宿主机的 DNS 配置,解析不了容器名。这就是开头那个"换 IP 就通"问题的根因。判断方法很简单,进容器看一眼/etc/resolv.conf,如果 nameserver 是127.0.0.11,说明在自定义网络里;如果是宿主那几个 DNS 地址,那就是默认 bridge。
3.2 网络隔离与容器生命周期
自定义网络天然带隔离。不同自定义 bridge 网络的容器之间默认完全不通,哪怕它们在同一个宿主上、IP 段相邻。这个特性在做多租户或者多环境共存时特别有用:把前端、后端、数据库划到三张网络里,只有需要通信的两张网络才把容器同时接进去,其余的组合天然隔离,比写一堆 iptables 规则靠谱得多。
生命周期这块也值得说。自定义网络是独立对象,容器删了网络还在,重新起容器挂上去,之前接入的其它容器不受影响。默认 bridge 虽然也是长期存在的,但你没法给它加参数,也没法控制它的子网,更没法在 compose 里声明。而自定义网络可以在创建时指定子网、网关、IP 范围、MTU,甚至指定--internal让它完全不能出网。
--internal这个参数我要单独提一句,它创建出来的网络没有默认路由,容器只能和同网络的容器通信,出不了外网。做"只有数据库层能互相访问、绝对不能连外网"这种架构时,直接建一个 internal 网络把数据库容器塞进去,比事后配防火墙简单得多,而且不会因为规则疏漏被绕过。
3.3 从零创建一个自定义网络并验证
操作本身不复杂,但每一步都有验证手段,我习惯一步步确认而不是一把梭。
# 创建带明确子网的自定义网络 docker network create \ --driver bridge \ --subnet 172.28.0.0/16 \ --gateway 172.28.0.1 \ --opt com.docker.network.bridge.name=br-app \ appnet这里指定com.docker.network.bridge.name是为了让宿主上的网桥有个可读的名字。不指定的话,宿主上会多出一块br-加 12 位随机字符的网卡,抓包和排查时你得先docker network inspect去查它对应哪张网络,很麻烦。指定成br-app之后,tcpdump -i br-app就能直接抓这张网络的流量。
# 起两个容器接入同一网络 docker run -d --name svc-a --network appnet alpine sleep 3600 docker run -d --name svc-b --network appnet alpine sleep 3600 # 验证名称解析和连通性 docker exec svc-a ping -c 2 svc-b docker exec svc-a nslookup svc-b # 确认容器挂载情况 docker network inspect appnet --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'docker network inspect的输出里有个Containers字段,列出所有接入的容器和它们的地址,这个比一个个docker inspect快得多。我平时排查"某个容器到底在不在这个网络里",就用这条命令,一眼就能看出来。
还有一个容易踩的点:容器运行中不能直接切换网络,只能用docker network connect追加一张网卡。一个容器可以同时接入多张自定义网络,容器内的eth0、eth1分别对应不同的网络。这个能力在需要跨网络访问的场景里很有用,比如让一个网关容器同时接前端网络和后端网络,但要注意接多张网络之后容器的默认路由会指向第一张网络,出网走哪条链路需要确认清楚。
4. 端口映射的完整链路:-p 到底改了几张表
4.1 从参数到 iptables 规则的对应
写-p 8080:80的时候,大部分人的心智模型是"宿主 8080 转发到容器 80",这个理解没错但太粗。实际发生的事情分几步:Docker 先在nat表的DOCKER链里插一条 DNAT 规则,把目标为宿主 8080 的包改写成容器 IP 的 80 端口;同时在filter表的DOCKER链里插一条放行规则;另外还会启动一个用户态转发进程监听宿主 8080,作为兜底路径。
你可以自己验证:
# 查看 DNAT 规则 sudo iptables -t nat -nL DOCKER # 查看转发放行规则 sudo iptables -nL DOCKER # 看用户的转发进程 sudo ss -lntp | grep 8080iptables -t nat -nL DOCKER的输出里能看到类似DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.28.0.2:80的规则。这条规则解释了为什么容器重启换了 IP 之后端口映射还能继续工作——Docker 会同步更新这条规则。但这个同步依赖 Docker 守护进程正常工作,如果有别的工具在管理 iptables,两边打架就会出现"规则还在但指向了不存在的地址"的情况,现象是连接被拒绝或者超时。
4.2 用户态转发进程与回环地址的处理
默认情况下 Docker 会起一个用户态转发进程。很多人不理解为什么有了 iptables 还需要它,原因在于nat表的 DNAT 只在 PREROUTING 和 OUTPUT 链生效,本机进程访问127.0.0.1:8080时走的是 OUTPUT 链,而 Docker 为了兼容这种情况,会额外加一条针对回环来源的规则,同时保留用户态进程作为兜底。
这个进程有时候会带来麻烦。比如你把 Docker 的转发进程关掉(配置里"userland-proxy": false),性能会好一点,但某些特殊网络配置下从本机访问映射端口可能就不通了。反过来,如果你在宿主上用ss看到一堆转发进程占着端口,而对应容器已经删了,那就是规则和进程没清理干净,需要重启 Docker 服务。
端口绑定地址也值得注意。-p 8080:80默认绑定所有网卡,包括公网网卡;-p 127.0.0.1:8080:80只绑定回环。后者在只允许本机其他服务访问、不允许外部直连的场景里应该成为默认选择。我见过不少配置直接写-p 3306:3306把数据库暴露到公网,然后靠数据库密码强度硬扛,这个习惯得改。
4.3 端口冲突与 DOCKER-USER 链的正确用法
端口冲突的报错信息很明确:Bind for 0.0.0.0:8080 failed: port is already allocated。但有时候端口明明没被别的容器占用,还是报这个错,那多半是宿主的其它进程占了,或者之前删掉的容器残留了转发进程。查法就是ss -lntp | grep 8080,看清楚是谁占的。
如果要限制谁能访问映射出来的端口,别去改DOCKER链,那是 Docker 自己维护的,重启动就没了。正确的位置是DOCKER-USER链,Docker 会在FORWARD链的最前面跳转到它,你在这里加的规则对所有容器转发流量生效,而且容器重启不会覆盖。典型用法是只允许某个网段访问某个端口:
# 只允许 10.10.0.0/16 访问宿主 3306 映射端口 sudo iptables -I DOCKER-USER -p tcp --dport 3306 -s 10.10.0.0/16 -j RETURN sudo iptables -A DOCKER-USER -p tcp --dport 3306 -j DROP注意这里第一条用-I插到最前面,第二条用-A追加到末尾,顺序不能反。RETURN表示放行并结束这条链的匹配,走回 Docker 自己的规则;DROP则直接丢弃。这个顺序搞反了会把所有请求都干掉,包括白名单里的。改完记得用iptables-save把规则持久化,不然重启就丢。
5. 跨主机组网:overlay 与 macvlan 的适用边界
5.1 VXLAN 封装带来的 MTU 账
单机上的网络问题都好办,一旦容器要跨主机通信,复杂度立刻上一个台阶。overlay 网络是 Docker 原生支持的跨主机方案,原理是把容器的二层帧封装进 VXLAN,通过宿主之间的 UDP 隧道传输。容器以为自己在同一个二层网络里,实际上包在宿主之间被套了一层壳。
这层壳是有成本的。VXLAN 头部加外层以太头、IP 头、UDP 头,一共约 50 字节。宿主网卡 MTU 是 1500 的话,隧道里能过的大包就被压到 1450;如果容器网卡还按 1500 配,一个 1500 字节的包封装后变成 1550,超过物理链路 MTU,就会被丢弃或者分片。表现出来就是:小请求一切正常,一旦返回大数据量就卡死,TCP 连接建得起来但传不完。
这个坑我踩过一次,排查了很久。当时一个接口在小数据量下完全正常,一旦查询结果超过几 KB 就超时。最后用ping -M do -s 1472一步步试出来的:1472 加 28 字节头部正好 1500,本地通;换成 overlay 网络后 1472 就不通了,得降到 1422。结论是 MTU 没配对。
解决办法是在创建网络时显式指定 MTU:
docker network create \ --driver overlay \ --opt com.docker.network.driver.mtu=1450 \ myoverlay或者直接在 Docker 守护进程配置里全局设"mtu": 1450。别指望自动探测,Docker 不会帮你做路径 MTU 发现,这个值必须自己算清楚。
5.2 macvlan 让容器直接拿到二层地址
macvlan 是另一条路:不封包,直接给容器分配一个和宿主同网段的 IP,每个容器有自己独立的 MAC 地址,在物理交换机看来就是一台独立的机器。好处是性能几乎无损,容器可以直接被同网段其它设备访问,不需要端口映射,也不需要 NAT。
代价是配置要求高。宿主网卡必须支持混杂模式,很多虚拟化平台的默认网卡不支持;容器的 IP 需要从物理网段里划一块出来,还得确保不和 DHCP 池冲突;最麻烦的是宿主和容器之间默认不通,因为 macvlan 的设计里同一张网卡上的父子接口不能直接通信,需要在宿主上再建一个 macvlan 子接口才能打通。
# 创建 macvlan 网络 docker network create -d macvlan \ --subnet=192.168.10.0/24 \ --gateway=192.168.10.1 \ --ip-range=192.168.10.128/25 \ -o parent=eth0 \ macnet这里的--ip-range很关键,它把容器的地址限制在网段的后半段,避免和物理机、DHCP 分配冲突。-o parent=eth0指定挂在哪块物理网卡上。这套配置在机房环境里效果很好,但在云主机上经常行不通,因为云平台的网卡通常不允许随意改 MAC 或者开启混杂模式。
5.3 三种跨主机方案的选型对照
| 方案 | 性能开销 | 配置复杂度 | 适用场景 | 主要限制 |
|---|---|---|---|---|
| overlay(VXLAN) | 中等,有封装开销 | 中,需要管理集群 | 多主机服务互访,动态扩缩容 | MTU 需手动对齐,隧道端口要放通 |
| macvlan | 很低,几乎无损 | 高,依赖底层网络 | 需要容器直接暴露在物理网段 | 宿主与容器通信需额外配置 |
| host 模式 + 外部路由 | 最低 | 中,需要网络侧配合 | 高性能、固定拓扑 | 端口冲突,失去隔离性 |
选型的核心判断是:你需要的是逻辑上的一张网,还是物理上的一台机器。前者用 overlay,容器之间按名字互相找,扩缩容无感;后者用 macvlan,容器在网络设备眼里就是一台真实主机。两者都不完美,实际项目里我更多是 overlay 打底,个别对延迟极敏感的服务单独用 macvlan 或者 host 模式。
6. 排错链路:从名字解析到丢包的分层定位
6.1 第一层:名字到底能不能解析
网络不通,先分清楚是"解析不了"还是"连不上",这两个问题的排查路径完全不同。进容器先看三样东西:
docker exec -it svc-a sh -c 'cat /etc/resolv.conf; echo ---; cat /etc/hosts; echo ---; nslookup svc-b'如果/etc/resolv.conf里的 nameserver 不是127.0.0.11,说明容器不在自定义网络里,容器名解析必然失败,这时候要么换网络,要么加--add-host手动写 hosts(不推荐,IP 一变就废)。如果 nameserver 正确但nslookup查不到,那要看两个容器是否真在同一张网络上,docker network inspect一查就知道。
还有一种情况是解析出来的地址不对。比如容器名和某个外部域名撞了,内嵌 DNS 优先返回容器地址,导致本该访问外部服务的请求打到了容器上。这种问题极难发现,现象是"偶尔连到奇怪的服务"。规避办法是容器命名时加项目前缀,别用redis、mysql这种通用词当容器名。
/etc/hosts里 Docker 也会写几条记录,包括容器自己的主机名。如果你手动改过 hosts,注意这些记录在容器重启后会被重写,任何手改都不是持久方案。
6.2 第二层:包出不出去,路由对不对
解析通了还连不上,下一步看路由和连通性。在容器里依次做这几件事:
# 看接口和地址 ip addr show # 看路由表,确认默认网关 ip route show # 测网关通不通 ping -c 2 172.28.0.1 # 测目标端口通不通(不依赖应用层协议) nc -zv svc-b 6379ip route的输出能看出很多问题。正常情况下应该有一条default via 172.28.0.1 dev eth0。如果没有默认路由,容器出不了网;如果默认路由指向了错误的网关,包发出去就石沉大海。这种情况在多网络接入的容器上特别常见——接了eth0和eth1两张网络,默认路由只指向其中一张,访问另一张网络里的服务就得靠静态路由或者明确指定出口。
nc -zv比ping更有价值,因为很多环境禁了 ICMP 但 TCP 是通的。端口通说明链路层和传输层没问题,问题在应用;端口不通就往网络层查。这个习惯帮我省了很多时间,别一上来就ping,先测 TCP。
宿主机侧也别忽略。tcpdump -i br-app -nn在自定义网桥的网卡上抓包,能看到容器发出来的原始帧;tcpdump -i any port 6379能看全链路。如果容器发出来了但目标没收到,问题在转发规则;如果容器压根没发,问题在容器内部。
6.3 第三层:大包黑洞与 conntrack 表满
前面两层都通,但业务还是偶发失败,这时候要看两个隐蔽问题。
大包黑洞:表现是小请求正常,大响应卡死。判断方法是在容器里用不同大小的包做不分片测试:
# 1472 + 28 = 1500,标准以太网 MTU ping -M do -s 1472 -c 2 目标地址 # overlay 环境下应该能通的最大值 ping -M do -s 1422 -c 2 目标地址如果小包通、大包不通,几乎可以确定是 MTU 问题。修复方式是把容器网络的 MTU 调成和隧道匹配的值,或者让应用层控制单次传输的数据量。TCP 有 MSS 协商机制,理论上能自适应,但如果中间有设备丢弃了 ICMP 的"需要分片"消息,协商就会失败。
conntrack 表满:现象是服务运行一段时间后,新建连接开始随机失败,内核日志里出现nf_conntrack: table full, dropping packet。查看当前用量:
cat /proc/sys/net/netfilter/nf_conntrack_count cat /proc/sys/net/netfilter/nf_conntrack_max高并发短连接的场景很容易把表打满。临时调大是sysctl -w net.netfilter.nf_conntrack_max=1048576,同时把nf_conntrack_tcp_timeout_time_established从默认的 5 天调小。但根治还是从架构上减少不必要的连接数,比如开启连接池、把高频服务改到 host 模式绕开 NAT。
7. 一套贴近业务的 compose 网络划分模板
7.1 分区的思路:把网络当权限边界用
很多人写 compose 的时候把服务全塞一个默认网络里,省事,但等于放弃了网络层面的隔离。我更倾向把网络当权限边界来用:前端网络只放对外暴露的服务和接入层,后端网络放业务服务和缓存,数据网络放数据库,每条边界上只放必须跨界的那个容器。
这样做的直接收益是爆炸半径变小。万一某个前端服务被攻破,它所在的网络里只有接入层组件,碰不到数据库。而且这种隔离不需要额外配置防火墙规则,靠 Docker 网络默认不通的特性就能实现。
划分粒度也不用太细,一个中等规模的系统三到四张网络足够。网络太多反而增加维护成本,每次加服务都得想清楚该挂哪张网。我的习惯是按"是否需要互相通信"分组,而不是按"技术栈"分组——同一个业务域的服务放一张网络,哪怕它们是不同语言写的。
7.2 完整的 compose 网络配置
services: gateway: image: nginx:alpine networks: - frontend - backend ports: - "127.0.0.1:8080:80" api: image: myapp/api:latest networks: - backend - data environment: DB_HOST: db CACHE_HOST: cache cache: image: redis:7-alpine networks: - backend command: ["redis-server", "--maxmemory", "256mb"] db: image: postgres:16-alpine networks: - data volumes: - pgdata:/var/lib/postgresql/data networks: frontend: driver: bridge ipam: config: - subnet: 172.30.0.0/24 backend: driver: bridge ipam: config: - subnet: 172.30.1.0/24 data: driver: bridge internal: true ipam: config: - subnet: 172.30.2.0/24 volumes: pgdata:这份配置有几个设计意图值得说明。gateway同时挂frontend和backend,它是唯一能同时看见两边的容器,负责转发;api挂backend和data,能访问数据库;cache只挂backend,数据库访问不到它;db单独在data网络上,而且data网络声明了internal: true,意味着这个网络里的容器没有默认路由,出不了外网。
internal: true这一条是我最看重的。数据库容器最常见的风险不是被外部直接攻击,而是容器内的进程被诱导去访问外部地址(比如拉取恶意依赖、回连某个地址)。把网络封死之后,这类路径直接断掉。需要给数据库做备份之类的外联操作时,临时用docker network connect接一张外网网络,操作完再断开,比长期开着口子好得多。
子网显式指定也很有必要。不指定的话 Docker 会自己从172.18.0.0/16开始分配,宿主机上多跑几个项目就可能撞上网段冲突,尤其是和公司内网或者家庭网络的网段重叠时,现象是容器内访问某个内网地址莫名其妙地打到了容器上。显式规划成172.30.x.0/24这种不常用的段,能省掉很多后续麻烦。
compose 启动之后,容器之间的解析直接用服务名。注意depends_on只保证启动顺序,不保证服务就绪,连接还得在应用层做重试。这一点跟网络配置无关但经常一起出问题,我索性在这里一并提一句。
最后补一个实用技巧:排查 compose 网络问题时,docker compose exec api getent hosts db比nslookup更接近应用的真实解析路径,因为很多精简镜像里根本没装nslookup,但getent基本都有。这个命令能同时告诉你解析结果和走的是哪条路径,排查 DNS 问题时比装一堆工具快。
我在实际使用中最大的体会是,Docker 网络的复杂度不在配置本身,而在于它把好几层抽象的边界藏起来了——容器里的localhost、宿主的localhost、网桥上的地址、物理网卡的地址,四者关系不清就会陷入反复试错的循环。把这四层在心里画成一张图,遇到问题先定位在哪一层,绝大多数故障几分钟内就能收敛。