接触Docker的人,多半都躲不开这样一个问题:容器里的服务明明跑得好好的,可容器之间的网络到底是怎么串起来的?我早年在虚拟机上折腾网络时,总觉得Docker的网络模式不过是几张配置表,直到自己把生产环境里的容器从单机搬到集群,才发现“理解docker容器的网络”这件事,远比想象中更考验底层功底。这篇文章不讲那种照着文档念的科普,而是结合我实际部署、排障时的经验,把这套网络机制拆开揉碎,从模式选型到自定义bridge实操,再到真实故障排查,尽量让即使刚入门的人看完也能上手。
先说清楚这东西解决了什么问题:容器网络决定了容器之间、容器与宿主机之间、以及容器与外部世界之间如何通信。说白了,容器只是个隔离的进程,如果没有网络,它就只能孤零零地待在宿主机里,什么都连不上。理解了这一点,你就能明白为什么网上有那么多关于Docker端口映射、跨主机通信的教程,因为网络配置不当,往往不是少部署一个容器,而是整条业务链路直接断掉。这篇文章适合三类人:刚学Docker不久、想弄明白默认网络工作机制的开发者;已经在用Docker Compose但遇到容器互访问题的运维;以及对容器网络模型有兴趣、想自己搭建跨主机网络的技术爱好者。
1. 容器网络到底在解决什么问题
1.1 没有网络时的困境
我最早接触Docker的时候,对网络的理解非常肤浅:把容器启动起来,端口映射一下,外部能访问就行。直到有一次,我在宿主机上启动了一个Nginx容器和一个后端API容器,打算让Nginx反向代理到后端的8080端口,结果发现怎么配都访问不通。跑进容器里 curl 一下,发现容器之间连最基本的内网IP都ping不到。那一刻我才意识到,容器默认情况下并不是天然就能互相通信的,或者说,Docker给了每个容器独立的网络命名空间,它们各自有自己的一套网络协议栈,外表的IP地址看起来在同一个网段,但实际上的通信路径比你想象的更复杂。
在没有网络的情况下,每个容器内部只有一个“孤独”的回环地址,也就是127.0.0.1,它只能访问自己容器里的进程。这听起来很安全,但也意味着容器变成了一个完全封闭的沙盒,既无法被外部访问,也无法访问外部。对于大多数应用来说,这种完全隔离显然不太现实。比如你要跑一个数据库容器,肯定希望另一个应用容器能连上它;你要跑一个微服务,必然需要多个服务互相调用。所以,Docker设计了一整套网络栈,让容器在保留一定隔离性的同时,还能安全可靠地通信。
1.2 Docker网络模型的核心思路
Docker网络的设计核心,其实是围绕Linux内核的网络命名空间、虚拟网桥、veth对和iptables规则展开的。你可以把网络命名空间理解成一套独立的网络环境,里面有自己独立的网卡、路由表、防火墙规则。容器启动时,Docker会为它创建一个全新的网络命名空间,但为了让它与外界通信,Docker又做了一层“桥接”,把容器内部的虚拟网卡和宿主机上的一个虚拟网桥连接起来。
这个虚拟网桥在Linux里通常叫做docker0,默认情况下,所有容器都连接到这个网桥上,网桥再通过NAT规则把流量转发到宿主机外网。整个过程可以类比成小区里的住宅楼:每栋楼(容器)有一个独立的门牌号(IP),楼与楼之间通过小区内部道路(bridge网络)可以互相走动,而要走出小区到外面的大马路(外部网络),就必须经过小区大门(宿主机网卡)并且换上汽车的牌照(NAT映射)。
这些机制组合起来,听起来不算复杂,但实际配置比我想象中灵活得多。Docker为我们预置了多种网络驱动,不同驱动之间的网络隔离、跨主机通信能力差别很大。理解了这套核心思想,后面的模式选择才有依据,否则你只是机械地执行命令,遇到问题还是无从下手。
2. 五种核心网络模式逐个拆解
2.1 bridge网络:默认选择,隔离与通信的平衡
绝大多数刚接触Docker的人,用的都是bridge模式,因为 docker run 的时候如果不指定网络,Docker会默认把容器挂到docker0这个网桥上。我第一次手动查看 docker network ls 的时候,看到有个叫bridge的网络,心里想的还是“哦,默认网络”,但后来才意识到这个默认bridge和自定义bridge网络之间有个极大的差异,这个差异我后面专门讲,这里先聊聊bridge本身的原理。
bridge模式会给每个容器分配一个独立的IP地址,范围一般是172.17.0.0/16,容器之间可以通过这个IP直接通信。同时,它通过端口映射实现从宿主机外部访问容器:比如你运行docker run -p 8080:80 nginx,Docker会在宿主机上添加一条端口转发规则,把宿主机8080端口收到的流量转给这个容器的80端口。宿主机上的iptables规则会把数据包的目标地址改写为容器的IP,完成转发。
这种模式最大的好处是隔离性不错,每个容器有自己相对独立的网络栈,安全性比后面要说的host模式高。但坏处是性能有一点额外损耗,因为流量要经过虚拟网桥和iptables双重处理。对于绝大多数中小应用,这个损耗可以忽略不计,可如果追求极致的网络性能,那就得考虑host模式了。
我记得有一次在压测环境里,用bridge模式跑一个高并发的Web服务,发现网络延迟有2到3毫秒的抖动,换了host模式之后抖动基本消失了。不过这种优化不是没有代价的,下面接着讲。
2.2 host网络:性能优先,但别随便用
host模式其实很好理解:容器不再拥有自己的网络命名空间,而是直接使用宿主机的网络命名空间。也就是说,容器内看到的IP就是宿主机的IP,端口也直接绑定在宿主机网卡上。你启动一个Nginx容器并监听80端口,那就相当于在宿主机上直接启动了一个Nginx监听80端口,不需要做端口映射,外部访问宿主机IP的80端口就能直达容器。
听起来很爽,但带来的问题也很明显:端口冲突。你不能同时启动两个都监听80的容器,因为宿主机的80端口只有一个。另外,容器的网络隔离几乎为零,容器和宿主机共享同一个网络栈,一旦业务容器被入侵,攻击面直接扩展到宿主机网络,安全性明显下降。我在实际部署中,只在两种场景下用 host 模式:一是对网络性能极其敏感,丢包和延迟会直接影响业务的场景;二是容器数量少、端口管理可控的场景,比如在同一台机器上跑一两个Prometheus Exporter或日志采集器。
host模式的另一个隐含问题是,你没法轻易迁移容器到其他机器。因为容器绑定的端口和宿主机网络环境强相关,在不同的机器上可能存在完全不同的网络配置,脚本里一旦硬编码了宿主机IP,整条链路就会变得脆弱。
2.3 none网络:完全隔离,越简单越安全
none模式说是没有网络,其实更准确地说是不为容器创建任何网络接口。容器启动之后,里面只有一个回环接口,无法访问外网,其他容器也访问不到它。我第一次看到这个模式时,觉得这种设计简直反人类,后来在跑一些离线计算任务时才体会到它的用处:某些任务本身不需要联网,数据通过volume挂载进去,计算结果再通过volume写出来,网络对它而言反而是多余的风险。
在这种完全隔离的模式下,哪怕容器内部有安全漏洞,攻击者也无法通过网络进一步渗透。你可以把 none 模式理解成保险箱里的保险箱,虽然笨重,但在处理敏感数据或运行不可信代码时,这种极简策略反而最稳妥。我一般建议,凡是能用none模式的项目,都可以尝试使用,毕竟减少攻击面总是好的。
2.4 container模式:共享另一个容器的网络空间
container模式比较特殊,它允许新容器直接复用已有容器的网络命名空间。也就是说,两个容器共享同一个IP和同一套网络端口,看起来像两个进程跑在同一台机器上。我把这种模式叫作“网络空间分身”,因为容器之间没有网络隔离,但它们与外部其他容器之间依然保持原有隔离。
这种模式在需要多个容器协作、共享网络配置时非常有用。我最早接触它是在调试一些网络抓包工具的时候:我希望一个辅助容器里的工具能够抓到另一个业务容器的网络流量,那只需要让辅助容器复用业务容器的网络命名空间,就能直接tcpdump看到同一个网络接口的数据包,而不必在业务容器里安装一堆额外的调试工具。这比在宿主机上用 nsenter 折腾要方便得多。
不过,使用container模式时要注意容器启动顺序,必须先有“基础容器”存在,后来的容器才能去复用它的网络空间。如果基础容器被停止或删除,依附于它的容器网络也会受影响。
2.5 overlay网络:跨主机的分布式网络
单机环境下,bridge网络已经够用,但当你把容器部署在多台宿主机上,问题就来了:容器A在宿主机1,容器B在宿主机2,它们怎么直接通过内网IP互相访问?这就需要overlay网络。
overlay网络可以理解成在物理网络之上再铺一层虚拟网络层,它把每台宿主机上的容器网络打通,让所有容器像在同一个大局域网里一样。Docker默认会在初始化Swarm集群后提供ingress网络,也可以手动创建overlay网络并指定子网。数据包在宿主机之间的传输,通常通过VXLAN隧道封装,物理网络只负责搬运外层UDP包,内层IP还是容器自己的地址。
我第一次搭跨主机网络时,踩了不少坑。最典型的就是没有开启宿主机之间的必要防火墙端口,导致overlay网络里的容器可以创建,但跨主机通信一直断断续续。后来查下来发现,VXLAN使用的UDP端口被防火墙挡住了,放行之后才恢复。如果你将来要跨主机部署容器,一定要提前确认宿主机之间允许对应隧道协议通信,否则上面所有配置都是白搭。
3. 从零搭建一个自定义bridge网络(实操部分)
3.1 创建自定义网络,配置子网与网关
自定义bridge网络是Docker最灵活的一种网络配置方式,也是我在生产环境里最常用到的。我举一个比较典型的场景:同一台宿主机上,我要运行一组Web服务和一组数据库服务,Web服务可以访问数据库,但数据库之间最好保持隔离,外部容器想访问数据库只能走特定服务。
开始之前,先看一条命令:
docker network create \ --driver bridge \ --subnet=192.168.50.0/24 \ --gateway=192.168.50.1 \ my-net这条命令创建了一个名为my-net的自定义bridge网络,网段是192.168.50.0/24,网关是192.168.50.1。这里我刻意避开了docker0默认的172.17.0.0/16网段,这样在定位问题时,能够一眼分辨容器连的是哪个网络,是好习惯。
创建完网络之后,可以用docker network ls查看,也可以docker network inspect my-net查看详细配置。如果宿主机上已经存在其他网段,建议你提前规划好IP网段,别跟现有网络冲突。
3.2 容器接入网络并验证通信
网络创建好了,接下来就是跑容器。比如我想启动两个容器:
docker run -d --name web --network my-net nginx docker run -d --name db --network my-net mysql:8容器启动后,可以分别查看它们的IP地址:
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web一般来说,web会分配到192.168.50.2,db会分配到192.168.50.3。此时你可以进入web容器,尝试ping一下db:
docker exec -it web ping db这里有个非常重要的细节:在自定义bridge网络里,Docker内置DNS解析,你直接ping容器名(db)就能拿到对应IP。但在默认bridge网络里,容器之间只能通过IP互访,不能通过容器名。这是我早期排障时浪费了大量时间才发现的问题,所以如果你用自定义网络,务必善用这个服务发现特性。
如果想让外部访问web容器,依然是老办法,用-p做端口映射:
docker run -d --name web --network my-net -p 8080:80 nginx这样宿主机8080端口就会被映射到web容器的80端口,浏览器访问宿主机IP:8080就能看到Nginx页面。
3.3 网络隔离的实际测试记录
自定义bridge网络还有一个很重要的特性:不同网络之间的容器默认无法互通。这给了你在同一台宿主机上做多租户隔离的能力。我实际测试过:先创建两个自定义网络,net-a和net-b,然后把两个容器分别挂在各自网络里,就算它们IP在同一网段,ping也完全不通。
如果想实现“跨网络”通信,办法不是直接打通两个网络,而是创建一个额外的容器,同时加入两个网络,充当网关角色。这个做法类似现实中的路由器,把两个区隔的网段联系起来。不过这种设计会增加网络链路的复杂度,非必要我不推荐使用。
4. 网络排查与常见问题实录
4.1 容器之间ping不通的排查路径
网络问题最磨人,但只要你理顺排查路径,大部分问题都能快速定位。我这几年总结了一套经验,按照从外到内、从接口到路由的顺序排查。
第一步,先确认容器网络模式。docker inspect <容器名>看 NetworkSettings 里的配置,确认它挂在哪个网络下。第二步,确认源容器和目标容器之间的网络归属:如果两个容器不在同一个bridge网络上,那天然就ping不通,这不一定是你配置错了,而是隔离机制在起作用。第三步,宿主机上执行iptables -L -n查看FORWARD链是否允许转包。Docker服务在启动时会自动插入相关规则,但如果你在启动Docker之前就配置了一堆自定义防火墙规则,有可能把Docker需要的FORWARD规则挡掉,导致容器间通信失败。
还有一次,我碰到的是MTU问题。宿主机物理网卡MTU是1500,但底层网络实际支持的包更小,容器里的大包发不出去,小包却能通。表现为ping通了但实际业务流量大量丢包,这种排查不到最后很不容易发现。
4.2 DNS解析异常与端口映射问题
容器之间用服务名访问时,偶尔会遇到DNS无法解析。最常见的原因是容器使用的DNS服务器配置不对。Docker默认会把宿主机的/etc/resolv.conf里的DNS配置继承进容器,但如果你的宿主机用的是systemd-resolved,容器的DNS往往会指向127.0.0.53这个本地地址,而这个地址在容器网络里没有对应的监听服务,就会解析失败。
我常用的解决办法是在创建网络时指定DNS,或者在容器启动时加上--dns参数:
docker run -d --name web --network my-net --dns 223.5.5.5 nginx端口映射问题就更常见了,尤其是“端口明明映射了,外部还是访问不了”这种。你第一反应应该是去宿主机上ss -tlnp | grep <端口>,确认宿主机端口是否已经在监听;如果监听了,但外部还是访问不了,请检查云安全组或本地防火墙。很多时候,问题并不在Docker本身,而在宿主机对外防火墙策略上。
4.3 删除网络失败及占用问题的处理
当你想删除一个自定义网络时,可能会出现错误提示:network has active endpoints。意思是这个网络里还有容器在占用。解决办法很简单:先停掉并删除占用该网络的容器,或者把容器从网络里断开:
docker network disconnect my-net web然后再次删除网络。
还有一种比较隐蔽的情况:网络删除不掉,但docker network ls确实看不到容器占用,查询之后发现有一个网络插件或僵尸代理在占用。这种情况下,需要重启Docker服务,或者检查是否存在处于僵死状态的容器占用了网络命名空间。遇到这种问题,不要慌,先docker network inspect看看里面有哪些容器,能断开的就断开,实在不行再重启Docker。
5. 我踩过的几个坑与建议
5.1 自定义网络的名称解析规则与默认bridge的差异
前面提到,自定义bridge网络支持基于容器名的DNS解析,默认bridge却不支持。这个差异直接导致了一个生产事故:一次我用docker-compose部署了一套服务,因为没指定网络,所有容器都跑到了默认bridge上,服务之间通过服务名互相调用,结果后端服务一启动就报“域名无法解析”,业务直接不可用。
后来我把compose文件里的网络配置改成自定义bridge,问题马上消失。这里也多说一句,如果用 Docker Compose,最好显式声明自定义网络,哪怕只有一个网络,也别偷懒省掉。否则Compose会自动创建一个以项目名命名的自定义网络,但如果你手工写了network_mode: bridge,那就又回到默认bridge网络去了,服务名解析会失效。
5.2 端口映射时端口冲突的教训
还有一次,我把两个不同项目的Nginx容器同时映射到宿主机的80端口,结果第二个容器启动失败,提示Bind for 0.0.0.0:80 failed: port is already allocated。当时我以为改一下外部端口就行,但忽略了Nginx容器内部还监听着80,导致流量进来之后,通过映射关系跑到第一个容器,而第二个容器的反代规则永远不生效。
这种事最坑的地方在于,容器日志可能完全正常,但你访问宿主机IP,发现页面明显不对。后来我养成一个习惯:每次映射端口前,都要用ss -lntp确认宿主机端口没有被占用,同时把外部端口、容器内部端口、反代目标端口三者之间的关系写在项目README里,防止自己两三天后回来看忘掉。
5.3 多主机通信的网络选型建议
如果你要去部署多台宿主机的容器集群,网络选型一定要提前决策。我个人的经验是:中小规模、服务数量不多的情况下,用Overlay网络加Docker Swarm模式就已经足够了;大规模生产环境则直接走Kubernetes的CNI方案,因为它把网络插件化,可以灵活选择Calico、Flannel等项目。真要自己用overlay硬撑大规模集群,后面在网络排障上花费的精力会远超你的想象。
另外,无论选什么网络,一定要把监控做起来。别再等到用户反馈“访问卡顿”时才想起来去看网络。我在生产环境里,会把节点的网络流量、TCP连接数、丢包率都纳入监控,一旦某个指标异常,立马能定位到是哪个宿主机的问题。网络故障的修复重点往往不在“修”,而在“找到它”,有监控就有方向。
说到最后,我对Docker容器网络的理解是:它并不难,但很抽象。当你把它想象成一座城市里的道路系统,容器就是一个一个独立的房子,网络模式则是不同的连接方式,有共用一根水管、有门对门相连、也有靠地下隧道跨城互联。多跑几次实验,多看看数据包是怎么走的,比背诵任何命令都可靠。我自己的建议很简单:精力允许的情况下,把每种网络模式都亲手搭一遍,故意制造一些故障再排查一遍,这个过程里学到的细节,比任何教程都来得持久。