很多人跑了好几个月 Docker,docker run、docker build、docker-compose up用得飞起,但被问到“容器到底是怎么隔离的、容器之间又是怎么通信的”,反而容易卡壳。这篇是 Docker 系列里偏内核的一篇,我不打算把命令手册再抄一遍,而是围绕两个核心问题展开:容器凭什么能各看各的进程和文件?容器里的网络请求又是怎么走到宿主机外面去的?
如果你已经掌握了 Docker 的常用操作,想往底层走一步,这篇会非常对胃口。镜像、容器、网络、存储这四块里面,网络最抽象也最容易出问题,单独讲透很有必要。后面无论你是要调生产环境的问题,还是要去学容器编排平台,这篇文章讲的原理都是最底层的地基。
1. 从 Namespace 到 cgroups:容器的“独立”到底独立在哪
很多人以为容器就是“轻量虚拟机”,这个说法从使用角度讲没问题,但从原理上讲非常误导。虚拟机的隔离是硬件级别的:虚拟机有自己独立的虚拟 CPU、独立内存、独立磁盘设备,宿主机的问题通常不会直接蔓延到虚拟机。而容器恰恰相反,它和宿主机共享同一个内核,没有独立的硬件抽象层。容器里跑着的进程,本质上就是宿主机上的一个普通进程,只是被一批内核机制“包装”得看起来像独立系统。这一层包装的主要成分,就是 Linux 命名空间。
1.1 七种命名空间逐一拆解:进程的障眼法
Linux 命名空间(Namespace)是一种将内核全局资源包装成独立实例的机制。常见的有 Mount、PID、Network、UTS、IPC、User、Cgroup 七种。创建容器时,Docker 会为容器主进程申请一套全新的命名空间,然后在这个命名空间内启动进程。进程以为自己看到了完整的系统,实际上看到的只是内核为它虚拟出来的一角。
PID 命名空间对“容器是普通进程”这一点体现得最直接。你在容器内执行ps aux,会看到 PID 1 是容器主进程,进程列表非常干净;但回到宿主机上执行docker top <容器名>,会看到这个进程的真实 PID,可能是个几百甚至几千的数值。同一个进程,在不同命名空间里呈现出来的身份不同,这就是命名空间的障眼法。
再往下看,Mount 命名空间管挂载点视图,每个容器能看到独立的文件系统挂载树,容器内改/etc/hosts、/etc/hostname不会影响宿主机。Network 命名空间让容器拥有独立的网卡、路由表、iptables 规则。UTS 命名空间管主机名,IPC 命名空间管进程间通信,User 命名空间则允许容器内用非 root 用户映射到宿主机普通用户,属于安全加固的重要方向。
Docker 在创建容器进程时,通过 clone 系统调用传入一组CLONE_NEW*标志位,一次性创建出多个命名空间。之后容器内派生的进程都会继承这套命名空间,所以容器内所有进程共享同一套“隔离视角”。这套机制解释了一个常见现象:容器内 PID 1 退出后,整个容器就结束了,因为 PID 1 进程是这套命名空间的持有者,它没了,命名空间也没了。
1.2 cgroups 资源限制:容器内存和 CPU 是如何被卡住的
命名空间解决的是“看得见/看不见”的问题,cgroups 解决的是“能用多少”的问题。Namespace 给每个容器一套独立视图,但这不代表容器可以无限占用宿主机资源。如果没有资源限制,一个容器就可能吃满全部 CPU 和内存,把宿主机和其他容器一起拖死。cgroups 把这些进程按层级分组,对 CPU、内存、IO、网络带宽等资源做配额和统计。
内存限制的原理值得细说。给容器设置-m 512m时,cgroup 的内存子系统会为该容器关联一个内存控制组。当容器内进程累计使用超过阈值,内核会触发回收,回收不掉就进入 OOM 流程,把占用最高的进程杀掉。这里有一个容易误读的点:在容器里执行free -m,看到的依然是宿主机全部内存,因为/proc/meminfo里的数字没有按 cgroup 重算。要查看真实限制,得去读/sys/fs/cgroup/memory/memory.limit_in_bytes这类 cgroup 文件。这也是很多新手在容器里用free判断内存,结果和预期对不上的原因。
CPU 限制则分两种:权重型和限额型。docker run -c 512设置的是 CPU 份额,同一宿主机上如果有多个容器争抢 CPU,份额高者分得更多;但当 CPU 空闲时,低份额容器也能跑满。而--cpus=1.5这类参数是硬性的使用上限,无论空闲与否都不允许超过。生产环境如果要保证单个容器占用不失控,通常建议用--cpus这类硬限制,而不是只靠份额约束。
1.3 rootfs 与写时复制:文件系统层面的隔离真相
文件系统隔离除了 Mount 命名空间保证挂载视图独立,还有一层关键依赖:rootfs。每个容器拥有自己的根文件系统,这个根文件系统是从镜像的一堆只读层叠加出来的,最上层挂一个可写层。修改文件时,文件会先按需复制到可写层再修改,这就是写时复制。容器删除时,可写层被丢弃,镜像本身不受影响。
这里最常见的误区,是以为容器里的文件系统是“完整独立的磁盘”,于是放心地在容器里保存关键数据。实际上容器可写层依赖宿主机磁盘,容器一删数据就没了。持久化要么挂卷,要么挂宿主机目录,否则数据只能活在容器生命周期内。这也是隔离和安全的典型错位:隔离的是视图,不等同于数据永久性。
最后必须强调一句:命名空间带来的隔离是“逻辑隔离”,不是“安全边界”。所有容器共享宿主机内核,一旦某个容器拿到了特权能力,理论上存在影响宿主机的可能。所以生产环境要避免使用特权模式启动容器,也不要随意把宿主机的/proc、/sys或 Docker socket 挂进容器。这个点在容器环境里是首要检查项,没有之一。
2. Docker 网络通信链路:先彻底搞懂 veth、bridge 与 iptables
网络是容器里最贴近使用直觉的部分,但问题一旦出现,没有原理支撑,排查基本靠蒙。我会从默认 bridge 模式讲起,把一条请求从容器内到容器外、从外部到容器内的完整链路拆开,每一步都讲清楚为什么这么设计。
2.1 默认 bridge 模式:veth pair 与 docker0 的完整链路
在默认安装下,Docker daemon 会在宿主机上创建一个名为 docker0 的网桥,默认网段通常是 172.17.0.0/16,docker0 自己的地址是 172.17.0.1。每启动一个容器,Docker 会创建一对 veth 虚拟网卡,一头放进容器的 Network Namespace,命名为 eth0;另一头挂在 docker0 上。veth 是成对出现的虚拟以太网卡,数据从一头进去必然从另一头出来,可以理解成一根虚拟网线。
容器里的 eth0 会被分配 172.17.0.2 之类的 IP,默认路由指向 172.17.0.1,也就是 docker0。当容器发起对外访问时,数据包从容器 eth0 出发,经 veth 对到达 docker0。docker0 连接着宿主机内核的网络协议栈,数据包再走上层路由与 iptables 处理。如果目标是外部 IP,宿主机启用 IP 转发后,将包转发到物理网卡出去。
这里有个容易被忽略的设计细节:Docker 桥接模式用的是 NAT,不是纯二层桥接。容器默认网段是私有网段,外部网络根本不知道 172.17.0.2 这个地址。数据包出去前,iptables 的地址伪装规则会把源地址改写为宿主机物理网卡地址,回来后 NAT 表反向记录映射关系,再把应答包送回容器。整个机制与家用路由器让多台设备共享一个公网 IP 上网完全一样。理解了这一点,你就知道为什么容器默认 IP 在外网“不存在”,也就能理解端口映射的必要性了。
2.2 端口映射与 DNAT:外部流量如何进入容器
外部网络要访问容器,无法直接用 172.17.0.2 这种私有地址,只能通过宿主机端口转发。docker run -p 8080:80本质上是向 iptables 的 nat 表 DOCKER 链插入一条 DNAT 规则:凡是到达宿主机 8080 端口的 TCP 包,目标地址被改成容器 IP 的 80 端口。
这条规则生效后,外部访问链路是:请求到宿主机 IP:8080,iptables DNAT 把目标改写为 172.17.0.2:80,宿主机路由到 docker0,经过 veth 对进入容器 eth0,最终交给容器内服务。容器发出的响应包,经过 NAT 反向转换后,对客户端表现为宿主机 8080 端口发出的数据。
实操中你可以用iptables -t nat -vnL DOCKER直接看到这些规则。有个容易卡住的点:如果在宿主机手动清空了 iptables 规则,Docker 的端口映射、容器间通信、容器外网访问,三件事几乎同时失效。Docker daemon 启动时会重建自己的规则链,但如果你不是通过重启 Docker 来恢复,就需要手动加载相关规则或干脆重启容器。这个坑我见过太多次,后面排查部分会专门讲。
2.3 host 模式与 none 模式:两种极端场景的适用边界
host 模式直接把容器放进宿主机的 Network Namespace,不分配独立 IP,也不做 NAT。它的性能开销最小,但端口冲突风险很高:容器里监听 80,宿主机就不能再监听 80。适合对网络延迟敏感、且端口规划完全可控的场景,比如一些高性能网关或对时延有要求的中间件。我一般不建议默认选 host,除非你能确定端口不会撞车。
none 模式则不给容器配置任何网络接口,除了 loopback。容器完全与外界隔离,应用无法对外提供服务,也无法访问外网。它的价值主要在两类场景:一是对网络敏感的批量计算任务,完全不希望容器偷偷联网;二是自定义网络时,先把网络功能全部拆掉,再手动用底层命令配置,避免 Docker 的自动配置干扰你的方案。
从使用频率来说,bridge 模式占绝大多数,host 模式偶尔用,none 模式更多出现在特殊网络方案里。一个常见认知误区是“host 模式更快,所以应该默认用”,实际上大部分应用不是网络瓶颈,bridge 模式多出来的 NAT 开销微乎其微。为了那一点点性能提升去承担端口冲突和隔离缺失的风险,不划算。
3. 动手验证:不用 Docker 也能复现容器隔离和网络原理
理论讲完,我们来做几组实操。这三组操作不依赖任何第三方工具,在你自己的 Linux 机器上就能完成。它们能帮你把前面讲过的概念全部落一遍,也让后续排查问题时有更清晰的直觉。
3.1 亲手拉开一个隔离空间:unshare 与 ip netns
Linux 的 util-linux 提供了一个unshare命令,可以在不启动 Docker 的情况下直接创建新的命名空间。执行:
sudo unshare --pid --fork --mount-proc bash这会创建一个新的 PID 命名空间,并把/proc挂载为新的实例。你在这个 shell 里执行ps aux,会看到进程列表非常短,PID 1 就是这个 shell 自身。如果此时在另一个终端用pstree -p观察,会发现这个 shell 在宿主机里有另一个真实 PID。这正是 PID 命名空间的直观体验。
想退出就执行exit,所有命名空间会随最后一个进程退出而清理。这也是容器生命周期的缩影:容器主进程退出,容器即结束,原理完全相同。注意一下,普通用户运行unshare往往会因为权限不足而失败,sudo 是必要前提。
ip netns是另一套方便的工具,专门管理网络命名空间。先建一个命名空间:
sudo ip netns add ns1 sudo ip netns exec ns1 ip addr执行后看到 ns1 里只有 loopback,且状态是 down,说明一个新的网络命名空间默认是空的。这与创建一个未配置网络的容器非常相似。
3.2 手工搭建 veth 与 bridge:把 Docker 的封装拆开看
再创建一对 veth 和一个网桥:
sudo ip link add br-test type bridge sudo ip link add veth0 type veth peer name veth1 sudo ip link set veth1 netns ns1 sudo ip link set veth0 master br-test sudo ip addr add 172.20.0.1/24 dev br-test sudo ip link set br-test up sudo ip link set veth0 up sudo ip netns exec ns1 ip addr add 172.20.0.2/24 dev veth1 sudo ip netns exec ns1 ip link set veth1 up sudo ip netns exec ns1 ip route add default via 172.20.0.1解释每一条:br-test网桥相当于 Docker 的 docker0。veth0是半根虚拟网线,veth1是另半根。把veth1塞进ns1后,命名空间里就出现了一个新的网络接口;把veth0挂到br-test,相当于把网线插到交换机端口上。再给网桥分配 172.20.0.1,给ns1里的接口分配 172.20.0.2,添加默认路由,一个三层互通的最小网络就成型了。
测试一下:
sudo ip netns exec ns1 ping 172.20.0.1能通。这条链路:ns1 → veth1 → veth0 → br-test → 宿主机协议栈,和 Docker 的默认 bridge 模式几乎同构。区别只是 Docker 把这套流程集成化、自动化了。到这里,你已经手动实现了 Docker 网络模块百分之八十的核心逻辑,剩下的 DNS、负载均衡、端口映射,都是在这个基础上封装出来的。
3.3 用一条 NAT 规则,让“容器”访问外网
到这里ns1只能和宿主机互通。要让ns1访问外部网络,需要打开 IP 转发,并加入一条地址伪装规则:
sudo sysctl -w net.ipv4.ip_forward=1 sudo iptables -t nat -A POSTROUTING -s 172.20.0.0/24 ! -o br-test -j MASQUERADE这条 iptables 规则的意思:凡是源地址属于 172.20.0.0/24、且不是从 br-test 出去的包,出去前都把源地址改成宿主机出口网卡的地址。加上这条后,在ns1里ping 外部IP,如果网络放行,就能通了。
如果这时候不通,优先检查宿主机外网网卡的流量走向,以及系统防火墙是否拦截了转发。还有一个隐蔽的坑:你在宿主机上执行过iptables -F的话,这条规则会被清掉,转发链路直接断开,ip_forward即使开着也没用。这就是 Docker 网络最容易踩的雷:系统防火墙、NAT 规则、内核转发三个环节,任何一环被外部改动,容器网络都会出问题。
4. 从单机到跨主机:overlay 网络与 macvlan 方案怎么选
默认 bridge 模式解决单机容器通信没问题,但一旦进入多主机部署,问题立刻出现:宿主机 A 上的容器无法直接通过容器 IP 访问宿主机 B 上的容器,因为彼此的 docker0 网桥不在同一个二层网络。于是有了真正的 overlay 网络和 macvlan 方案。
4.1 overlay 与 VXLAN 封装:跨主机通信的原理
overlay 网络本质上是在宿主机物理网络之上叠一层虚拟网络。容器 IP 不参与实际路由,数据包要先经过封装,再通过宿主机网络传输。Docker 默认使用 VXLAN 技术,把容器发出的完整报文封装进宿主机 UDP 报文里,通过宿主机网络在节点之间转发。
理解封装最简单的方式是类比快递:容器 A 要发数据给容器 B,内部报文写的是 A 的容器 IP 到 B 的容器 IP。宿主机把这封信塞进一个信封,信封上写的是宿主机 A 的物理 IP 到宿主机 B 的物理 IP。B 宿主机收到信封后拆封,再投递给容器 B。对容器 A 和 B 来说,完全感知不到中间发生了封装过程,只觉得自己和对方在同一个二层网络里。
这里必须注意 MTU 问题。VXLAN 封装的报文头额外占用几十字节,如果宿主机物理 MTU 是 1500,那么容器网络 MTU 必须下调到 1450 或更低,否则大包直接丢掉。很多跨主机容器网络不通的案例,最后定位到 MTU 不匹配。Docker 在 overlay 驱动上会自动适配部分场景,但如果你自建 VXLAN 或与其他网络设备联动,一定要核对两侧 MTU。
4.2 macvlan:让容器直接拥有物理地址
macvlan 是另一种跨主机网络方案,思路完全不同:它不封装,而是让宿主机物理网卡在二层模式下虚拟出多个 MAC 地址,每个容器分配其中一个 MAC,并直接使用外部局域网的同网段 IP。容器就像直接插在交换机上的一台独立主机,外部设备可以直接访问容器 IP。
macvlan 性能比 veth 加 NAT 的模式好,因为少了 NAT 转换,转发路径更短,适合对时延敏感且需要容器 IP 直接被外部网络看到的场景。但它有两个明显的坑。第一,宿主机无法通过 macvlan 接口直接访问容器,因为物理网卡被虚拟出多个 MAC 后,宿主机自身的 MAC 不再绑定。第二,很多基础网络环境不允许一个物理口出现大量虚拟 MAC,交换机的安全策略会直接封端口。另外,macvlan 依赖的网卡必须支持混杂模式,一般物理网卡支持,无线网卡多数不行。
选型结论很直接:单机且需要端口映射,用 bridge 最省心;多机且对网络隔离和扩展有要求,overlay 优先;容器需要参与外部二三层路由、追求低延迟,再看 macvlan。macvlan 这类方案不是每个人都有条件踩坑,先把 overlay 吃透,已经能覆盖绝大多数生产场景。
4.3 网络选型对比与安全加固建议
三种模式放在一起看更清楚:
| 网络模式 | 适用场景 | 跨主机 | 性能特点 | 主要风险 |
|---|---|---|---|---|
| bridge | 单机容器、端口映射 | 否 | 有少量 NAT 开销 | iptables 被外部改动后故障 |
| host | 延迟敏感、端口可控 | 否 | 性能最好 | 端口冲突、隔离弱 |
| overlay | 多机容器编排 | 是 | 有封装开销 | MTU 不匹配、调试成本 |
| macvlan | 容器需直连外部网络 | 是 | 接近物理性能 | 宿主机无法访问容器、交换机限制 |
容器网络的安全问题,一半出在“默认太开放”。在默认 bridge 网络里,同一网络的所有容器默认可以互相访问。生产环境如果多个服务跑在同一台机器,又不希望某个容器能扫其他容器端口,最简单的做法就是创建多个自定义 bridge 网络,按业务拆分,让不同业务的容器处于不同网络。
自定义 bridge 网络还带来一个极好用的能力:DNS 解析。Docker 会向自定义网络内的容器注入一个内嵌 DNS 服务,容器之间可以直接用容器名互访。默认 docker0 没有这个能力,只能用旧机制手动指定别名,体验差很多。所以只要是正式项目,我都建议创建自定义网络,不要让容器直接依赖默认 docker0。
安全加固层面有几个动作值得长期坚持:避免使用特权模式启动容器;不要挂载 Docker socket;容器对外端口尽量收敛,需要对外访问的服务通过统一的入口网关暴露;敏感服务使用 internal 网络完全去掉外网出口;宿主机上的防火墙规则先备份再变更。这一套组合下来,能挡掉绝大多数利用容器网络弱配置发起的攻击。
5. 容器网络问题排查指南:高频故障与修复实录
网络问题在容器环境里出现频率很高。我把这些年实际遇到的高频问题整理成一份排查清单,每个问题都附带定位思路,能省下大量试错时间。
5.1 容器无法访问外网怎么办
现象:容器内访问外部 IP 不通,但容器之间通信正常。第一步检查宿主机 IP 转发:sysctl net.ipv4.ip_forward,如果输出为 0,改成 1:
echo 1 > /proc/sys/net/ipv4/ip_forward第二步检查 iptables。最常见的坑是手动执行过iptables -F清空了规则,导致 NAT 链断裂。清空后容器失去外网能力,但 docker0 内的通信仍然正常,这个现象非常有迷惑性。恢复方式是重启 Docker 服务,或者直接重启受影响容器,让 daemon 重新生成策略链。如果业务不允许重启,至少要重建地址伪装规则,把源地址转换补回去。
5.2 端口映射失效的完整排查步骤
现象:宿主机上访问宿主机端口没反应,容器内直接访问服务端口正常。先确认端口是否被其他进程占用,用ss -lntp | grep 8080查一下。再确认容器状态:docker ps -a,有时候端口绑定没报错,但容器已经退出,映射自然不生效。
如果这两项正常,继续看 iptables:
iptables -t nat -vnL DOCKER找到对应的 DNAT 规则是否存在。如果不存在,很可能是规则链被外部工具覆盖。还有一种常见情况:启动容器时用的是-p 8080:80,但容器内服务实际监听的是 IPv6 或不同端口,先用docker exec <容器> ss -lnt核对容器内的监听状态。排查要按“宿主端口 → 规则链 → 容器内监听”的顺序一层层走,跳步容易漏。
5.3 容器内 DNS 解析异常的处理路径
现象:容器里访问域名报无法解析。先看容器内/etc/resolv.conf,自定义网络里应该指向 127.0.0.11,这是 Docker 内置 DNS 服务。如果文件内容异常,指向了宿主机不通的 DNS 或残留旧配置,多半是挂载了宿主机 resolv.conf 或容器创建后宿主 DNS 被改过。
排查思路按顺序走:检查容器网络模式;确认宿主机 DNS 配置;在容器内用nslookup或dig解析,看请求是否到达 127.0.0.11。如果是自定义网络且容器内访问外部域名失败,还要确认容器是否有外网路由,DNS 请求和业务流量走的是同一张网。不要忽略一个细节:桥接网络出去的 DNS 请求也会被 NAT 转换,如果部署在特殊网络环境里,需要把宿主机 DNS 与容器 DNS 的一致性一起检查。
5.4 容器 IP 动态变化与固定 IP 方案
现象:容器重启后 IP 变了,配置文件里写死的旧 IP 失效。Docker 容器默认不保证固定 IP,要固定 IP 必须在创建时指定。自定义网络场景下可以这样做:
docker network create --subnet=172.30.0.0/24 app-net docker run --network app-net --ip 172.30.0.10 ...千万别想在生产环境靠容器 IP 做服务发现,IP 本身就不该成为依赖。正确的做法是依赖 Docker 内置 DNS,通过容器名互访,或者接入服务注册中心。这一套在自定义 bridge 网络里已经支持得很完整,没必要去绕远路。
5.5 高频问题速查表
| 现象 | 优先排查项 | 常用命令/动作 |
|---|---|---|
| 容器无法访问外网 | 宿主机 ip_forward、iptables NAT 链 | sysctl net.ipv4.ip_forward; iptables -t nat -vnL POSTROUTING |
| 端口映射不生效 | 宿主端口占用、DNAT 规则、容器内监听 | ss -lntp; iptables -t nat -vnL DOCKER; docker exec 容器 ss -lnt |
| 容器间无法通信 | 是否同网络、自定义网络 DNS、路由 | docker network inspect; docker exec 容器 ping 容器名 |
| 容器 DNS 解析失败 | /etc/resolv.conf、宿主 DNS、外网路由 | cat /etc/resolv.conf; nslookup 域名 |
| 容器 IP 重启变化 | 未指定固定 IP、依赖 IP 访问 | docker network create 指定 subnet; docker run --ip; 改用容器名 |
| 手动清 iptables 后网络全断 | 规则链被破坏 | 备份原规则; 重启 Docker 或重建规则链 |
这张表覆盖了我遇到过的绝大多数容器网络问题。如果你的现象不在表里,回到原理层去推:容器内到容器外,关键在 veth、网桥、路由、NAT;外部到容器内,关键在端口映射、DNAT。顺着这条路径一段一段测,定位速度一定比乱猜快。
最后说点个人习惯。我每次排查容器网络问题,都先看宿主机四个地方:网卡地址、路由表、iptables NAT 规则、内核转发开关,十秒跑完,一半问题已经暴露。然后再进容器看网卡配置、路由、DNS 配置。按这个顺序走,很少做无用功。Docker 的隔离和网络这两层原理,看起来门槛高,其实动手玩一遍 unshare 和 veth 就通了。理解到这里,后面再看容器编排里的网络插件、服务发现和入口网关,你会发现它们解决的都是同一批底层问题的更高层抽象。