(4)500代码行代码手写docker-设置网络命名空间

本系列教程主要是为了弄清楚容器化的原理,纸上得来终觉浅,绝知此事要躬行,理论始终不及动手实践来的深刻,所以这个系列会用go语言实现一个类似docker的容器化功能,最终能够容器化的运行一个进程。

本章的源码已经上传到github,地址如下:

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网络号+ip地址偏移

所以关键就是要得到一个ip的ip网络号,用ipv4举例,在golang里面ip类型本质上就是一个长度为4字节数组

type IP []byte

所以现在要把这个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

这里的具体代码我就不再展示了。

创建网络设备实现容器互联

知道如何为容器分配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()
}
}

500代码行代码手写docker-设置网络命名空间的更多相关文章

  1. Tensorflow快餐教程(1) - 30行代码搞定手写识别

    版权声明:本文为博主原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明. 本文链接:https://blog.csdn.net/lusing/article/details ...

  2. Tinyhttpd for Windows(学习型的项目,才500多行代码)

    前言 TinyHTTPd是一个开源的简易学习型的HTTP服务器,项目主页在:http://tinyhttpd.sourceforge.net/,源代码下载:https://sourceforge.ne ...

  3. 常见的JS手写函数汇总(代码注释、持续更新)

    最近在复习面试中常见的JS手写函数,顺便进行代码注释和总结,方便自己回顾也加深记,内容也会陆陆续续进行补充和改善. 一.手写深拷贝 <script> const obj1 = { name ...

  4. java面试:手写代码

    二分查找法. /** * 二分查找法:给定一组有序的数组,每次都从一半中查找.直到找到要求的数据. * 主要是得找到下标的表示方法. */ public class BinaryFind { /** ...

  5. Linux下统计代码行数

    使用wc统计代码行数 最近写了一些代码,想统计一下代码的行数,在eclipse中好像没这功能,网上搜了一下才发现原来Linux有一个统计文件行数的命令wc.使用wc可以打印出每个文件和总文件的行数.字 ...

  6. C基础 带你手写 redis adlist 双向链表

    引言 - 导航栏目 有些朋友可能对 redis 充满着数不尽的求知欲, 也许是 redis 属于工作, 交流(面试)的大头戏, 不得不 ... 而自己当下对于 redis 只是停留在会用层面, 细节层 ...

  7. TensorFlow 入门之手写识别(MNIST) 数据处理 一

    TensorFlow 入门之手写识别(MNIST) 数据处理 一 MNIST Fly softmax回归 准备数据 解压 与 重构 手写识别入门 MNIST手写数据集 图片以及标签的数据格式处理 准备 ...

  8. Android集成并开启手写笔识别

    1.首先,需要下载Pdf xchange View SDK,然后将其集成到Android项目中: 2.在Android项目中,添加以下依赖: implementation 'com.github.PD ...

  9. caffe_手写数字识别Lenet模型理解

    这两天看了Lenet的模型理解,很简单的手写数字CNN网络,90年代美国用它来识别钞票,准确率还是很高的,所以它也是一个很经典的模型.而且学习这个模型也有助于我们理解更大的网络比如Imagenet等等 ...

  10. vue10行代码实现上拉翻页加载更多数据,纯手写js实现下拉刷新上拉翻页不引用任何第三方插件

    vue10行代码实现上拉翻页加载更多数据,纯手写js实现下拉刷新上拉翻页不引用任何第三方插件/库 一提到移动端的下拉刷新上拉翻页,你可能就会想到iScroll插件,没错iScroll是一个高性能,资源 ...

随机推荐

  1. University of Toronto Faculty of Arts and Science MAT344– Final Assessment Combinatorics Instructors: Stanislav Balchev and Max Klambauer 19 August 2020

    目录 随便找的一份测试题 T7 T9 T6 T5 solution to (a) solution to (b) solution to (c) solution to (d) T1 T2 T3 T4 ...

  2. 2020寒假学习笔记12------Python基础语法学习(一)

    代码的组织和缩进 Python 语言直接通过缩进来组织 代码块."缩进"成为了 Python 语法强制的规定.缩进时,几个空格都是允许的,但是数目必须统一.我们通常采用" ...

  3. 移动端测试辅助工具 - adb

    1. 概念: adb(android debug bridge)是android提供的基于CS架构的命令行调试工具,使PC与安卓设备之间实现通信 2. 基础原理: 交互图: 主要由三部分组成: adb ...

  4. 基于express、node与mongodb写后端接口

    1.首先运行mongodb,建立一个数据库.(前提是你的电脑已经装了mongodb) 先打开一个命令窗口打开mongodb cd /usr/localcd mongodb/bin./mongod -- ...

  5. GKCTF X DASCTF应急挑战杯-Maple_root-Writeup

    GKCTF X DASCTF应急挑战杯-Maple_root-Writeup 参赛队员: b4tteRy, x0r, f1oat 最终成绩:2285 最终排名:27 总结 经过最近几次类线下的演练,感 ...

  6. vue之数组与对象的检测与更新

    目录 说明 语法 示例 说明 MVVM会自动检测变量的变化,当变量改变,页面也会对应的变化,但是有一点需要注意,如果有一个对象增加值的时候,不能直接修改,需要使用Vue.set()方法 语法 Vue. ...

  7. tkinter的标签和按钮以及输入和文本

    一.标签和文本 import tkinter as tk #1.定义tk的实例对象,也就是窗口对象 window = tk.TK() #2.设置窗口大小无法缩小和放大 window.resiable( ...

  8. 好奇心驱使下试验了 chatGPT 的 js 代码的能力

    手边的项目中有个函数,主要实现图片分片裁剪功能.可以优化一下. 也想看看 chatGPT 的代码理解能力,优化能力,实现能力,用例能力. 于是有了这篇文章. 实验结果总结: chatGPT 确实强大, ...

  9. MySQL(九)InnoDB行格式

    InnoDB行格式 查看默认行格式: select @@innodb_default_row_format; 查看数据库表使用的行格式 mysql> use atguigudb; Reading ...

  10. 彻底解决VSCode无法远程ssh,提示The remote host may not meet VS Code Server‘s prerequisites for glibc and libstdc++

    彻底解决VSCode无法远程ssh,提示The remote host may not meet VS Code Server's prerequisites for glibc and libstd ...