手把手带你写个最精简的 docker
闪客
《Linux 源码趣读》作者,公号「无聊的闪客」作者。
87 人已学习
立即订阅
手把手带你写个最精简的 docker
15
15
1.0x
00:00/00:00
登录|注册

06 | 让人闻风丧胆的容器网络(bridge)

闪客 · 手把手带你写个最精简的 docker
在上一讲中,我们终于实现了一个天衣无缝的容器。
你的两个用户此时已经基本完全分辨不出自己是在使用一台独立的机器,还是处在被你设计的容器中了。
这时我们回过头来看看我们的代码,数数看有效的代码量也才十几行而已,当然这一切都建立在 Linux 内核已有的支持上。
那我们现在看看,我们所实现的这个玩具级别的 docker,现在有什么比较严重的功能上的问题呢?
那就是无法上网!
我们之前花了好大功夫,让两个用户互相感知不到对方的存在,不互相影响。但是各自的功能完整性还没有完全补齐,上网就是一个重要的问题。

什么叫无法上网

容器网络一直是让很多人闻风丧胆的东西,以至于很多讲解容器技术的资料都很少提及网络相关的内容。
但我想,容器网络的第一个问题不应该是什么叫容器网络,应该问问什么叫无法上网?怎么创建一个容器之后网络就成了一个需要考虑的问题了呢?这或许才是首先会产生困惑的问题。
别废话,直接看现象。
用我们上一版的代码启动一个容器。
[shanke ~] # ./skdocker busybox /bin/sh
先 ping 一下百度,失败。
/ # ping www.baidu.com
ping: bad address 'www.baidu.com'
再 ping 一个已知的 ip,失败。
/ # ping 110.242.68.3
PING 110.242.68.3 (110.242.68.3): 56 data bytes
ping: sendto: Network is unreachable
甚至 ping 本地回环地址,还是失败。
/ # ping 127.0.0.1
PING 127.0.0.1 (127.0.0.1): 56 data bytes
ping: sendto: Network is unreachable
很简单,这些就是无法上网,大白话就是网络相关的通信哪哪都不通。

最快的方式打通网络

我们稍稍修改下代码,把 clone 创建新进程时的 net 命名空间隔离的参数去掉。
源代码在 demos/06-01-net-test.c
// 原来是这样的
int flags = CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWIPC;
// 去掉 net 命名空间后
int flags = CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWIPC;
重新编译运行,发现 ping 一个已知的 ip 和 ping 本地回环地址,都通了。
/ # ping www.baidu.com
ping: bad address 'www.baidu.com'
/ # ping 110.242.68.3
PING 110.242.68.3 (110.242.68.3): 56 data bytes
64 bytes from 110.242.68.3: seq=0 ttl=251 time=17.183 ms
64 bytes from 110.242.68.3: seq=1 ttl=251 time=17.196 ms
...
/ # ping 127.0.0.1
PING 127.0.0.1 (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: seq=0 ttl=64 time=0.078 ms
64 bytes from 127.0.0.1: seq=1 ttl=64 time=0.048 ms
...
但是 ping 域名还是不行。
如果再把宿主机上 /etc/resolv.conf 里的内容原封不动写到容器里的同一个文件上(即容器根目录下的 /etc/resolv.conf 文件)。
[shanke ~] # cat /etc/resolv.conf
; generated by /usr/sbin/dhclient-script
nameserver 183.60.83.19
nameserver 183.60.82.98
那么此时在容器里 ping 百度也立刻没问题了。
/ # ping www.baidu.com
PING www.a.shifen.com (110.242.68.3) 56(84) bytes of data.
64 bytes from 110.242.68.3 (110.242.68.3): icmp_seq=1 ttl=251 time=17.2 ms
64 bytes from 110.242.68.3 (110.242.68.3): icmp_seq=2 ttl=251 time=17.1 ms
...
可以看出,最快的方式打通容器网络,就是把 net 命名空间的隔离去掉。这时容器的网络就和宿主机已经配好的网络是一样的了,也就自然能上网了。
当然,这是个取巧的办法,咱肯定不是要通过这样的方式解决网络。但它告诉我们几个事实:
宿主机能上网并不是自然而然的事情,而是背后做了一些网络的配置工作。
容器的网络通过 net 命名空间与宿主机隔离,一个干净的初始状态的网络环境,是几乎啥功能都没有的。
那接下来我们研究的目标就很简单了,宿主机为了能上网做了哪些事情?容器中的干净的网络环境和宿主机相比少了哪些内容?

解决 ping 127.0.0.1 的问题

老方法,上下打开两个窗口,分别是容器和宿主机。
首先用 ip addr 命令查看一下系统中的所有网络接口信息。
网络接口表示系统中所有可以进行网络通信的组件,包括我们最熟悉的物理硬件设备(比如这里的 eth0 以太网卡,以及可以连 WIFI 的无线网卡)、虚拟设备(比如 veth、VPN、bridge 等,上图中还没有这类设备)、逻辑接口(比如这里的 lo 回环接口,就是我们常说的 localhost,数据不通过网络传出机器,而是在本地直接路由给自己)。
诶?那既然容器中也有 lo 回环接口,为什么 ping 127.0.0.1 还不行呢?
答案简单的很,因为没开启。
直接通过命令 ip link set lo up 开启一下,瞬间就好了。
为了加深对网络接口的理解,我们用 tcpdump 命令抓取一下流经 lo 回环接口的数据包。
按理说,本地回环接口 lo 根本不会走出本机通过网线传到外面去,那还能抓到流经它的数据包么?
很显然,是可以的。
可见,在 Linux 内核的角度看,不论你是真实的物理网卡,还是软件层面虚构出来的虚拟网卡,甚至是不走网络的本地回环接口 lo,都一样会像模像样地"流经"过去,被抓包工具发现(实际上是触发了钩子函数)。

解决 ping 外部网络的问题

回环接口打通了,只是需要开启一下就搞定了。
接下来想要解决访问外部网络的问题,就必须得有个实实在在的物理网卡可以让我们把数据发出去,经过真实的网线(或者 WiFi)传播向远方。
对比刚刚容器和宿主机区别也能看出,宿主机上有个叫 eth0 的东西,就是一张真实的物理网卡,这是容器中没有的。
eth 表示 ethernet,翻译过来就是以太网,后面的 0 仅仅是个编号,宿主机上只有一张以太网卡,那就只有个 0 了。
那最直接的想法,就是给这个宿主机上再插一些物理网卡,比如叫 eth1 eth2 等等。每次创建一个容器,就分配给这个容器一颗真实的网卡。如果你这个电脑上开了 1000 个容器,那就得插 1000 个网卡。
好家伙,这么搞下去你的电脑就成筛子了。
所以很自然想到,必须有一种虚拟化的手段,即便主机上只插了一块物理网卡,但可以搞出多个虚拟网卡,让每个容器都以为自己占用了一个独立的物理网卡一样。
容器里的网络数据都发给自己的这个虚拟的 eth0,在容器层面就以为是从真实物理网卡发出去了。
然后在宿主机层面,通过软件的方式把每个容器的 eth0 收到的数据包,真真正正通过插在主机上的网卡发出去,整个过程就结束了。
上面只是我们的构想,具体还得看 Linux 内核老大哥有没有提供支持。
目前主流的虚拟网卡方案有 tun/tap 和 veth 两种,容器一般是使用 veth 来实现网络通信的,所以我们也使用 veth 来实现我们的目标。
过程有一丢丢复杂,我先把宿主机和容器中需要的命令写出来,从行数看就没多少步骤了。
如果对网络完全不懂的同学,可以看我的一篇文章《你管这破玩意叫网络》快速直观地了解下。
--- 宿主机 ---
# 添加 veth 设备,一端在宿主机(veth-host),另一端在容器(veth-container)
ip link add veth-host type veth peer name veth-container
ip link set veth-container netns 容器PID
# 给宿主机一端的 veth-host 配置 IP 并开启
ip addr add 172.16.0.1/16 dev veth-host
ip link set veth-host up
# 开启 nat 转换
iptables -t nat -A POSTROUTING -s 172.16.0.2/16 -o eth0 -j MASQUERADE
sysctl -w net.ipv4.ip_forward=1
--- 容器 ---
# 给容器一端的 veth-container 配置 IP 并开启
ip addr add 172.16.0.2/16 dev veth-container
ip link set veth-container up
# 配置默认网关
ip route add default via 172.16.0.1
我们一点点来解读。
首先在宿主机上添加一对儿 veth 设备,一端自然就连在宿主机上(相当于在宿主机的网络命名空间里),另一端放进容器的网络命名空间里。
--- 宿主机 ---
# 添加 veth 设备,一端在宿主机(veth-host),另一端在容器(veth-container)
ip link add veth-host type veth peer name veth-container
ip link set veth-container netns 容器PID
那么在宿主机上会多出 veth-host 这样一个网络接口,在容器中会多出 veth-container 这样一个网络接口,他们之间彼此相连、互通。
接下来分别给这两个设备配置 IP 地址和子网掩码,让 veth-host 和 veth-container 处于同一个子网内,但不要和 eth0 处于同一个子网内。
--- 宿主机 ---
# 给宿主机一端的 veth-host 配置 IP 并开启
ip addr add 172.16.0.1/16 dev veth-host
ip link set veth-host up
--- 容器 ---
# 给容器一端的 veth-container 配置 IP 并开启
ip addr add 172.16.0.2/16 dev veth-container
ip link set veth-container up
宿主机和容器上的 veth 设备以及宿主机上的物理网卡 eth0 设备的 IP 分别如下。
这里我特意把子网设置为 172.16.xx.xx,避开了 172.17.xx.xx,因为后面的地址是 docker0 网桥的默认地址,如果你机器上安装并启动了 docker 那么俩子网容易互相影响所以干脆避开,这个后面再说,不重要。
这一步完成后,宿主机可以 ping 通容器,容器也能 ping 通宿主机了!
但此时容器还不能 ping 通宿主机上的 eth0 这块真实的物理网卡,更不要说 ping 通外网了。具体来说错误信息是网络不可达,即系统无法找到到达目标 IP 地址的路由。
/ # ping 10.24.0.5
PING 10.24.0.5 (10.24.0.5): 56 data bytes
ping: sendto: Network is unreachable
原因是目前容器中的路由表信息,只能路由到 172.16.0.0/16 这个子网内的 IP。
/ # ip route
172.16.0.0/16 dev veth-container scope link src 172.16.0.2
所以我们又给容器添加了一个默认网关。
--- 容器 ---
# 配置默认网关
/ # ip route add default via 172.16.0.1
/ # ip route
default via 172.16.0.1 dev veth-container
172.16.0.0/16 dev veth-container scope link src 172.16.0.2
可以看到在路由表信息中增加了一条记录,表示如果所有路由表信息都不可达,那么就把数据包发给默认网关 172.16.0.1,这个就是宿主机中的 veth-host 这张虚拟网卡的 IP 地址。
10.24.0.5 这个地址没有命中路由表的任何规则,所以自然就发给了默认网关。
我们再尝试 ping 一下。
/ # ping 10.24.0.5
PING 10.24.0.5 (10.24.0.5): 56 data bytes
^C
--- 10.24.0.5 ping statistics ---
4 packets transmitted, 0 packets received, 100% packet loss
发现仍然 ping 不通。但注意看,这里和上面的网络不可达报错不一样,这里的信息表示虽然数据包成功发送到了网络,但没有收到来自目标主机的任何响应。
所以可以证明我们的路由规则成功被触发了,包已经发出去了,只不过没有任何响应。
为什么目标主机没有任何响应呢?这里我和大家开了个小玩笑,因为我的 ip 地址写错了,应该是 10.0.24.5 而我写成了 10.24.0.5。但也正因为这个错误,让我们学到了 ping 不通的多种原因。
好了,我们修正下地址,可以看到成功 ping 通了 eth0。
/ # ping 10.0.24.5
PING 10.0.24.5 (10.0.24.5): 56 data bytes
64 bytes from 10.0.24.5: seq=0 ttl=64 time=0.058 ms
64 bytes from 10.0.24.5: seq=1 ttl=64 time=0.061 ms
^C
--- 10.0.24.5 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
现在还差最后一步,就是访问外部的网络还是不行,不过不是网络不可达(因为我们已经配置了默认网关,只要不知道该发到哪的通通都会发到默认网关,一定能发出去),而是收不到响应。
/ # ping 220.181.38.152
PING 220.181.38.152 (220.181.38.152) 56 bytes of data.
... 卡在这一行不动,或者弹出 timeout ...
这是因为你使用的 veth-container 这个网卡的地址是 172.16.0.2,这是个内网地址。往出发包可以找到目的 IP,但是响应回来的时候公网上可找不到你这个内网地址。所以这里我们需要进行 NAT 转换技术,简单说就是先把你的内网 IP 转变成网卡的公网 IP,发出去,等响应数据包回来时,再把公网 IP 转变成内网 IP,在主机内流转到你的容器中的虚拟网卡上。
NAT 的详细原理这里不展开了,实际上我的这台云服务器,网卡的 ip 地址 10.0.24.5 仍然是个内网 ip,需要经过云服务提供商的一个更大层面的路由器 NAT 之后才能走到真正的外网环境。
配置好 NAT 规则后,再把 /etc/resolv.conf 配置完毕以便可以进行域名解析。此时再 ping 百度发现可以了!
/ # ping www.baidu.com
PING www.baidu.com (220.181.38.150): 56 data bytes
64 bytes from 220.181.38.150: seq=0 ttl=250 time=3.566 ms
64 bytes from 220.181.38.150: seq=1 ttl=250 time=3.629 ms
64 bytes from 220.181.38.150: seq=2 ttl=250 time=3.557 ms
^C
--- www.baidu.com ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
这就解决了容器上网的难题,同时容器的网络和宿主机的网络也是隔离开的(如网卡的 IP 不同)。

解决容器间互 ping 的问题

通过上面的这种 veth 的方案,解决容器间网络互通的问题很简单,只需要给每两个容器之间都建立一对儿 veth 设备即可。
但相信你也看出问题了,如果这样下去,容器多了起来之后 veth 设备可能会混乱不堪。而且每启动一个容器,还需要关注宿主机上有哪些其他容器,和他们一一建立 veth 设备对儿,这肯定不是个好办法。
这还没考虑容器和宿主机的通信问题。
那这可怎么办呢?
开脑洞想一下,如果这个 veth 设备的一端可以接入多个其他 veth 设备就好了。
但很可惜,veth 没有这样的功能。
不过你有没有发现,这个需求似乎和物理世界的一个需求类似。就是当我们有很多台电脑想要两两相连组成一个局域网时,最笨的方法就是每两台电脑都用网线连起来,但这样很混乱,而且电脑上也开不了那么多网口。
这时候人们想到了可以把他们都连到一个额外的设备上。
只在物理层无脑转发的设备就叫集线器。
稍稍带点智能,在二层数据链路层转发的设备就叫交换机,根据 mac 地址把数据包精准交给指定设备。
所以在我们的虚拟世界里,容器和宿主机的 veth 设备,也可以连接到一个共同的虚拟设备上,实现多方互连。
这个设备就叫做 bridge,中文名叫网桥,但实际上它使用起来就像是一个虚拟的交换机。

bridge 模式

相信聪明的你已经可以把图画出来了。
那实现这个图的效果,只需要简单几行命令即可。
# veth-host 不需要 ip 地址了删掉它
ip addr delete 172.16.0.1/16 dev veth-host
# 建立网桥设备 br0
ip link add name br0 type bridge
# 把 veth-host 插入网桥
ip link set veth-host master br0
# 给网桥设置 ip 地址
ip addr add 172.16.0.1/16 dev br0
# 最后别忘了开启它
ip link set br0 up
这里我们把 veth-host 的 ip 地址去掉了,给了网桥设备 br0,相当于 veth 的一端不再需要一个设备来承载,直接变成一根啥也不是的网线插在了 br0 上,剩下的一切由 br0 去承担。
接下来我们通过设置 veth-host 的 master 为 br0,实现了插在 br0 上这个动作,也叫做桥接。
PS:想想看我们平时使用的虚拟机软件,配置网络时是不是要选择主机模式还是桥接模式,现在就明白什么意思了吧~
搞完上述的配置后,赶紧容器中 ping 一下百度,发现依然可以 ping 通!大功告成!
/ # ping www.baidu.com
PING www.baidu.com (110.242.68.3): 56 data bytes
64 bytes from 110.242.68.3: seq=0 ttl=250 time=17.044 ms
64 bytes from 110.242.68.3: seq=1 ttl=250 time=17.018 ms
^C
--- www.baidu.com ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss

用代码实现网络功能

由于所有的容器都依赖宿主机上的网桥设备,所以需要先在宿主机上初始化网桥,代码在 init_net.sh 中,很简单。
# 建立网桥设备 br0
ip link add name br0 type bridge
# 给网桥设置 ip 地址
ip addr add 172.16.0.1/16 dev br0
# 最后别忘了开启它
ip link set br0 up
然后修改容器代码实现,在 demos/06-02-net-bridge.c 中,稍稍复杂了点。这里我只把要点列出,感兴趣期望你去代码仓库看看完整版,有不少细节。
// 直接运行 ./a.out empty /bin/bash
int main(int argc, char *argv[]) {
...
// 设置网络
cfnet(pid);
...
}
int child(void *arg) {
// 设置容器的网络
child_cfnet();
...
}
void cfnet(pid_t container_pid) {
systemf("ip link add veth-host-%d type veth peer name veth-container", container_pid);
systemf("ip link set veth-container netns %d", container_pid);
systemf("ip link set veth-host-%d up", container_pid);
systemf("ip link set veth-host-%d master br0", container_pid);
printf("父进程设置网络完毕,设备为 veth-host-%d\n", container_pid);
}
void child_cfnet() {
srand(time(NULL));
int random_num = rand() % (254 - 2 + 1) + 2;
system("ip link set lo up");
system("ip link set veth-container up");
systemf("ip addr add 172.16.0.%d/16 dev veth-container", random_num);
system("ip route add default via 172.16.0.1");
printf("echo 容器设置网络完毕,设备为 veth-container:172.16.0.%d/16\n", random_num);
}
编译运行一下,可以发现我们最新版的容器,可以访问百度,也可以访问同宿主机上的其他容器了。
每个容器有自己独立的 IP 地址,可以互联互通和访问外网,所有网络配置的变更也不会影响其他容器。这可真是个不小的成就呢!

docker 的网络

你可能觉得我们天马行空想了很多种办法实现网络,但实际上你已经学会了 docker 网络最本质的原理。
docker 在启动一个容器时,可以通过 --network 指定网络驱动,也就是选择网络配置的方式。
如 --network=none 表示隔离网络命名空间但不进行任何网络配置,和我们一开始没进行任何网络配置时实现的容器代码一样。
--network=host 表示不隔离网络命名空间,即我们一开始的使用宿主机网络实现上网的原理一样。
--network=bridge 表示网桥模式,和我们最后通过 veth 虚拟网卡 + bridge 网桥实现的网络一样,也是 docker 默认的网络配置。
官方文档可以查阅:https://docs.docker.com/network/
那么通过 docker 启动一个容器后(默认使用 bridge 驱动),分别查看宿主机和容器的网络配置,你就不再陌生了。
可以清晰地看到宿主机上有个网桥叫 docker0,这是在你启动 docker 时就创建好的(systemctl start docker),和我们初始化宿主机网络的脚本类似。
然后容器和宿主机上有一对儿 veth 设备,容器端叫 eth0@if81,宿主机端叫 vetha562d7a@if80,同时宿主机端的 veth 插到了 docker0 上。
这就是 veth + bridge 技术的容器网络实现方案,和我们实现的完全一样!
那么恭喜你,你已经掌握了容器网络的本质。可以看出,容器网络很多知识同样也并不是新东西,如果你熟练掌握 Linux 网络相关的原理,那么容器网络自然也就可以秒懂了。
本讲涉及到一些网络知识,过多展开可能会影响主线,也不是一句话两句话能说得清的,所以本文抓大放小,希望把整体逻辑先讲通。
大家如果有兴趣详细听听这里的门道,我可以出一篇加餐内容来详细讲解下。正文中就不展开了。
文中也提到了,如果对网络完全不懂的同学,可以看我的一篇文章《你管这破玩意叫网络》快速直观地了解下。
好了,现在我们既做到了容器的隔离,又做到了功能的完善,用户基本上被我们欺骗成功了!
但我们似乎还没有考虑过我们这个容器工具 skdocker 本身是否有什么问题,是否还有优化空间?
我们下讲再一起来探索。
确认放弃笔记?
放弃后所记笔记将不保留。
新功能上线,你的历史笔记已初始化为私密笔记,是否一键批量公开?
批量公开的笔记不会为你同步至部落
公开
同步至部落
取消
完成
0/2000
荧光笔
直线
曲线
笔记
复制
AI
  • 深入了解
  • 翻译
    • 英语
    • 中文简体
    • 法语
    • 德语
    • 日语
    • 韩语
    • 俄语
    • 西班牙语
  • 解释
  • 总结
仅可试看部分内容,如需阅读全部内容,请付费购买文章所属专栏
《手把手带你写个最精简的 docker》
立即购买
登录 后留言

精选留言

由作者筛选后的优质留言将会公开显示,欢迎踊跃留言。
收起评论
大纲
固定大纲
什么叫无法上网
最快的方式打通网络
解决 ping 127.0.0.1 的问题
解决 ping 外部网络的问题
解决容器间互 ping 的问题
bridge 模式
用代码实现网络功能
docker 的网络
显示
设置
留言
收藏
沉浸
阅读
分享
手机端
快捷键
回顶部
文章页面操作
MAC
windows
作用
esc
esc
退出沉浸式阅读
shift + f
f11
进入/退出沉浸式
command + ⬆️
home
滚动到页面顶部
command + ⬇️
end
滚动到页面底部
⬅️ (仅针对订阅)
⬅️ (仅针对订阅)
上一篇
➡️ (仅针对订阅)
➡️ (仅针对订阅)
下一篇
command + j
page up
向下滚动一屏
command + k
page down
向上滚动一屏
p
p
音频播放/暂停
j
j
向下滚动一点
k
k
向上滚动一点
空格
空格
向下滚动一屏
播放器操作
MAC
windows
作用
esc
esc
退出全屏
⬅️
⬅️
快退
➡️
➡️
快进
空格
空格
视频播放/暂停(视频全屏时生效)