(4)500 代码行代码手写 docker-设置网络命名空间
本系列教程主要是为了弄清楚容器化的原理,纸上得来终觉浅,绝知此事要躬行,理论始终不及动手实践来的深刻,所以这个系列会用 go 语言实现一个类似 docker 的容器化功能,最终能够容器化的运行一个进程。
本章的源码已经上传到 githuhub,地址如下:
https://github.com/HobbyBear/tinydocker/tree/chapter4
复制代码
前文我们已经为容器替换了新的根文件系统,但是由于我们启动容器的时候是在一个新的网络命名空间,目前的容器还不能访问外部网络,我们需要在这一节,让容器能够访问外部网络,并且能够实现同一个主机上的容器能够网络互通。
这节代码运行效果
容器互通的原理
在正式开始编码之前,我将基于最简单的情况,则同一个主机上的容器能够通过 ip 互相访问的情况,简单的介绍下,容器网络互联的原理,我们是在一个新的网络命名空间 启动的子进程,不同网络命名空间拥有自己的防火墙,路由表,网络设备,所以需要对新生成的网络命名空间进行配置。让网络命名空间内部的网络包能够从网络命名空间内部出去到达主机上。
在 linux 上,可以用 veth 虚拟网络设备去连接两个不同网络命名空间,veth 设备是成队出现,分别连接到不同的命名空间中, 从 veth 设备一端进入的网络包能够到达 veth 设备的另一端, 但在配置容器网络时并不是将 veth 设备直接连接在另一端的网络命名空间内,因为如果主机上容器过多的话,采用直接两两相连的方式,将会让网络拓扑过于复杂。所以一般是将 veth 设备连接到一个叫做网桥 bridge 的虚拟网络设备上,通过它对网络包进行转发。
关于 veth 设备和 bridge 的原理和使用,我之前出过一期视频讲解,可以去哪里深入的学习下:
容器网络原理之包流转路径
之前也出过许多对容器网络讲解的系列视频,如果有对容器网络不熟悉的同学,请看这里:
k8s容器互联 flannel host-gw 原理
k8s容器互联 flannel vxlan 模式原理
容器系列 笔记分享
实现 ipam(ip 地址分配管理)
现在,来让我们实现下关于容器网络配置的逻辑,首先容器在创建的时候,得先为它分配一个 ip 地址,本质上就是为它内部的 veth 设备分配一个 ip 地址。这就涉及到如何分配 ip 地址的问题,这里有两个问题需要解决:1,当知道一个网络的网段后,如何知道网段内部哪个 ip 进行了分配,哪个 ip 没有进行分配。2,如果知道了这个网段内某个 ip 没有被分配,如何根据偏移量计算最终没有被分配的 ip,比如我知道第 8 个 ip 没有被分配,网络网段为 192.168.0.0/24 ,那么第 8 个 ip 是多少?
首先来看下 ip 存储的问题,也就是看哪些 ip 进行了分配,哪些没有进行分配。
bitmap 存储 ip 地址分配记录
这是一个看某个值是否存在的问题,可以通过 bitmap 去存储,这在快速判断 ip 是否存在的前提下,也能极大的降低存储成本。
如下所示,如果第一个字节的第 1 位和第 3 位被置为 1 了,说明在这个网段内,第一个 ip 和第 3 个 ip 都被占用了。
一个 byte 是 8 个 bit,也就可以表示 8 个 ip 是否被占用,而一个网段中的 ip 个数=2 的 N 次方个,其中 N=32-网段位数。
用一个实际的例子举例,比如子网掩码是 255.255.0.0,说明网段是前面的 16 位,那么 ip 个数就是由后 16 位 bit 数表示,排除掉其中主机号全为 0 的网络号和主机号全为 1 的广播号,可用 ip 数=2 的 16 次方-2 ,要表示那么多的 ip 数就需要 (2 的 16 次方-2)/8 大小的字节 约等于 8kb,转换成字节数组长度就是 8192。
具体实现如下:
一个 bitmap 用一个 byte 数组表示
type bitMap struct {
Bitmap []byte
}
复制代码
bitmap 的方法也就 3 个,
1, 查看第 n 个 ip 是不是被分配。
func (b *bitMap) BitExist(pos int) bool {
aIndex := arrIndex(pos)
bIndex := bytePos(pos)
return 1 == 1&(b.Bitmap[aIndex]>>bIndex)
}
复制代码
arrIndex 和 bytePos 方法实现如下:
func arrIndex(pos int) int {
return pos / 8
}
func bytePos(pos int) int {
return pos % 8
}
复制代码
我们最终是要找到这地 n 个 ip 所在的 bit 位,然后查找该 bit 位是否被置为 1,置为 1 就代表这第 n 个 ip 是被分配了。用 n/8 得到的就是第 n 个 ip 所在 bit 位的字节数组的索引,用 n%8 得到的余数就是在字节里的第几个 bit 位,如何取出对应的 bit 位呢?
首先是 b.Bitmap[aIndex]得到对应的字节,然后将该字节右移对应的 bit 位数,这样第 n 个 ip 的 bit 位就变到了第一个 bit 位上。整个过程像下面这样:
与运算是双 1 结果才是 1,所以如果最后一个 bit 位是 1 则最后与运算的结果就是数字 1,如果最后一位 bit 位是 0,则最后运算的结果就是 0。
2,设置第 n 个 ip 被分配。
设置第 n 个 ip 被分配,即设置它对应的 bit 位为 1,首先还是要找到这第 n 个 ip 在数组中的位置,然后取出对应字节 byte,通过位运算设置其对应的 bit 位。
func (b *bitMap) BitSet(pos int) {
aIndex := arrIndex(pos)
bIndex := bytePos(pos)
b.Bitmap[aIndex] = b.Bitmap[aIndex] | (1 << bIndex)
}
复制代码
零 bit 的或位运算不会改变原 bit 位值大小,而 1 的 bit 的或位运算会将原来 bit 位置为 1,利用这个特性便可以很容易的写出来上面的代码。
整个过程如图所示:
3,释放第 n 个 ip 的分配记录。
释放第 n 个 ip 原理和前面类似,设置第 n 个 ip 对应的 bit 位为 0。
func (b *bitMap) BitClean(pos int) {
aIndex := arrIndex(pos)
bIndex := bytePos(pos)
b.Bitmap[aIndex] = b.Bitmap[aIndex] & (^(1 << bIndex))
}
复制代码
零 bit 的与位运算会让原 bit 位置为 0,而 1 的与位运算不会改变原 bit 位的值,知道了这个特性再看上述代码应该就很容易了,其中^ 运算符为取反的意思,这样 00000100 就会变为 11111011,这样与原 bit 位进行与位运算就能将索引为 2 的 bit 位置为 0 了。
通过 ip 偏移计算 ip 地址
通过上述 bitmap 的实现可以解决 ip 分配的存储问题,但还有一个问题要解决,那就是目前只知道了第几个 ip 没有分配,如何通过这个 ip 偏移量获取到具体的 ip 地址?现在来解决这个问题。
一个网段里第一个 ip 的主机号全为 0,被称为网络号,其 ip 偏移为 0,拿 192.168.0.0/16 网段举例,第一个 ip 就是 192.168.0.0,第二个 ip 地址其 ip 偏移量为 1,ip 地址是 192.168.0.1,以此类推,可以得到下面的公式:
所以关键就是要得到一个 ip 的 ip 网络号,用 ipv4 举例,在 golang 里面 ip 类型本质上就是一个长度为 4 字节数组
所以现在要把这个 4 字节的数组转换为 32 位整形,可以像下面这样转换
func ipToUint32(ip net.IP) uint32 {
if ip == nil {
return 0
}
ip = ip.To4()
if ip == nil {
return 0
}
return binary.BigEndian.Uint32(ip)
}
func (bigEndian) Uint32(b []byte) uint32 {
_ = b[3] // bounds check hint to compiler; see golang.org/issue/14808
return uint32(b[3]) | uint32(b[2])<<8 | uint32(b[1])<<16 | uint32(b[0])<<24
}
复制代码
由于 ip 地址是大端排序,网段号排在字节数组前面,所以 binary.BigEndian 进行转换。
这样获取 ip 的逻辑就是一个简单的加法了
firstIP := ipToUint32(ip.Mask(cidr.Mask))
ip = uint32ToIP(firstIP + uint32(pos))
复制代码
通过 ip 地址计算 ip 偏移
关于 ip 的分配还有最后一个比较关键的点,那就是释放 ip,前面已经提到我们已经可以办到释放第 n 个 ip 了,其中 n 就是 ip 的偏移量,那么如何通过 ip 地址去计算 ip 的偏移量呢?其实很容易,拿当前 ip 减去网络号就是 ip 偏移量了
这里的具体代码我就不再展示了。
创建网络设备实现容器互联
知道如何为容器分配 ip 地址了,还需要在网络命名空间内 创建新的网络设备,然后设置上这个 ip。为了让整个逻辑变的简单,我们创建一个默认的网络,让容器创建的时候自动在这个默认的网络下,并为其分配 ip。
整个过程分为两个阶段,一个是程序启动的时候,会去检查主机上是否存在这个默认网络需要的配置,如果有则不再创建相关网络设备,我将这个阶段称为网络初始化阶段,第二个阶段是容器创建时候,需要为容器创建相关网络配置的阶段。我们挨个来看看。
🐮🐮🐮 当你写这部分代码时,最好用我这章源码上的根文件系统,因为原始的根文件系统其实是不包含 ifconfig,ping 命令的,到时候不好调试路由以及测试网络联通信,源码上的根文件系统我把这两个命令都下载好了。 ❗️❗️不过,你也可以用 nsenter 命令进入容器的网络命名空间然后使用主机上的 ping ifconfig 命令进行网络调试,类似这样 nsenter -t 容器进程号 -n ping www.baidu.com
网络初始化阶段
前面提到同一台主机上实现容器之间的网络互联,其本质是实现不同网络命名空间之间的互联,实现方式则是不同网络命名空间都用 veth 设备连接到一个网桥虚拟网络设备上,通过它们则可以实现网络互联。
所以在初始化阶段,首先就看看主机上的网桥设备是不是创建好了,并且设置一个网段作为默认网络将会使用的网段。
代码如下:
func Init() error {
// 对默认网络进行初试化
err := NetMgr.LoadConf()
if err != nil {
return fmt.Errorf("load netMgr conf err=%s", err)
}
if NetMgr.Storage[defaultNetName] == nil {
if err := BridgeDriver.CreateNetwork(defaultNetName, defaultSubnet, BridgeNetworkType); err != nil {
return fmt.Errorf("err=%s", err)
}
if err := IpAmfs.SetIpUsed(defaultSubnet); err != nil {
return err
}
}
return nil
}
复制代码
为了实现主机上对默认网络的存储,我将默认网络的信息通过 json 序列化方式存储到了主机文件上。NetMgr.LoadConf 则是对主机上存储的默认网络信息进行加载。加载之后判断是否存在默认网络,不存在则去创建一个默认网络,而创建一个默认网络的步骤本质上就是创建网桥设备,并设置网桥 ip 为 defaultSubnet,然后将 defaultSubnet 的 ip 标记为使用。以便后续为容器分配 ip 地址时,不会占用网桥的 ip 地址。
创建容器时的网络配置逻辑
接着着重来看下创建容器时如何进行相关的网络配置,我们需要在主机上创建一个 veth 设备,然后将这个 veth 设备一端连接到主机的网桥上,然后将另一端连接到容器的网络命名空间内部。
需要注意的是,在主机为容器配置好网络连接前,容器的子进程还必须进行等待,当主机为容器配置好网络设备后,子进程才真正的开始执行用户将要执行的程序。这里父子进程间的通信,我采用发送信号的方式,子进程等待父进程发送 SIGUSR2 信号,其代码如下
func main(){
.....
log.Info("Wait SIGUSR2 signal arrived ....")
// 等待父进程网络命名空间设置完毕
network.WaitParentSetNewNet()
........
err := syscall.Exec(cmd, os.Args[3:], os.Environ())
if err != nil {
log.Error("exec proc fail %s", err)
return
}
....
}
func WaitParentSetNewNet() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGUSR2)
<-sigs
log.Info("Received SIGUSR2 signal, prepare run container")
}
复制代码
而父进程在用 cmd.start 命名启动子进程后则开始为容器子进程配置网络设备,代码如下
func ConfigDefaultNetworkInNewNet(pid int) error {
// 为容器分配ip
ip, err := IpAmfs.AllocIp(defaultSubnet)
if err != nil {
return fmt.Errorf("ipam alloc ip fail %s", err)
}
// 主机上创建 veth 设备,并连接到网桥上
vethLink, networkConf, err := BridgeDriver.CrateVeth(defaultNetName)
if err != nil {
return fmt.Errorf("create veth fail err=%s", err)
}
// 主机上设置子进程网络命名空间 配置
if err := BridgeDriver.setContainerIp(vethLink.PeerName, pid, ip, networkConf.BridgeIp); err != nil {
return fmt.Errorf("setContainerIp fail err=%s peername=%s pid=%d ip=%v conf=%+v", err, vethLink.PeerName, pid, ip, networkConf)
}
// 通知子进程设置完毕
return noticeSunProcessNetConfigFin(pid)
}
复制代码
着重看下 BridgeDriver.setContainerIp 方法如何为对容器的网络命名空间进程配置。
func (b *bridgeDriver) setContainerIp(peerName string, pid int, containerIp net.IP, gateway *net.IPNet) error {
peerLink, err := netlink.LinkByName(peerName)
if err != nil {
return fmt.Errorf("fail config endpoint: %v", err)
}
loLink, err := netlink.LinkByName("lo")
if err != nil {
return fmt.Errorf("fail config endpoint: %v", err)
}
// 进入容器的网络命名空间
defer enterContainerNetns(&peerLink, pid)()
containerVethInterfaceIP := *gateway
containerVethInterfaceIP.IP = containerIp
// 为容器内部的veth设备设置ip
if err = setInterfaceIP(peerName, containerVethInterfaceIP.String()); err != nil {
return fmt.Errorf("%v,%s", containerIp, err)
}
// 将veth设备设置为up状态
if err := netlink.LinkSetUp(peerLink); err != nil {
return fmt.Errorf("netlink.LinkSetUp fail name=%s err=%s", peerName, err)
}
if err := netlink.LinkSetUp(loLink); err != nil {
return fmt.Errorf("netlink.LinkSetUp fail name=%s err=%s", peerName, err)
}
_, cidr, _ := net.ParseCIDR("0.0.0.0/0")
defaultRoute := &netlink.Route{
LinkIndex: peerLink.Attrs().Index,
Gw: gateway.IP,
Dst: cidr,
}
// 在容器的网络命名空间内配置路由信息
if err = netlink.RouteAdd(defaultRoute); err != nil {
return fmt.Errorf("router add fail %s", err)
}
return nil
}
复制代码
整个过程其实比较清晰,通过 veth 设备名找到 veth 设备后,就进入了容器的网络命名空间,然后设备 veth 设备 ip,设置路由表信息。关键在主机上要如何才能进入容器的网络命名空间呢?
答案是采用 setns 系统调用,setns 系统调用可以让当前线程进程进入指定的命名空间内部,这段逻辑是在上述 enterContainerNetns 方法中,解释如下:
func enterContainerNetns(vethLink *netlink.Link, pid int) func() {
// 根据子进程pid查询网络命名空间的fd文件描述符
f, err := os.OpenFile(fmt.Sprintf("/proc/%d/ns/net", pid), os.O_RDONLY, 0)
if err != nil {
fmt.Println(fmt.Errorf("error get container net namespace, %v", err))
}
nsFD := f.Fd()
// 锁住当前线程,因为setns是让当前线程进指定命名空间,go的协程在运行时可以被挂载到不同的线程上去进行执行,为了规避这种情况,将当前协程与当前线程进行了绑定。
runtime.LockOSThread()
// 修改veth peer 另外一端移到容器的namespace中
if err = netlink.LinkSetNsFd(*vethLink, int(nsFD)); err != nil {
log.Error("error set link netns , %v", err)
}
// 获取当前的网络namespace
origns, err := netns.Get()
if err != nil {
log.Error("error get current netns, %v", err)
}
// 设置当前线程到新的网络namespace,并在函数执行完成之后再恢复到之前的namespace
if err = netns.Set(netns.NsHandle(nsFD)); err != nil {
log.Error("error set netns, %v", err)
}
return func() {
netns.Set(origns)
origns.Close()
runtime.UnlockOSThread()
f.Close()
}
}
复制代码
评论