与容器相关的那些事——底层原理探秘

Updated on with 0 views and 0 comments

前面我们提到过容器两大核心技术——Namespace和Cgroup,这篇分享将使用这些底层原理带着大家直观感受一下容器的玩法。需要说明的是,前几篇关于容器化技术的分享只是开胃菜,说白了,就是想替同学们扫清一些学习障碍,更多相关知识点请自行查阅官方文档。

1 Namespace

Linux Namespace 有如下种类:

7418afe90b194b83bd1f503c06af0ea9.png

注意,后面的模拟代码会用到相关的系统调用参数

1.1 相关系统调用

与 Linux Namespace 相关的系统调用如下:

  • chroot(),即 change root directory(更改root目录),在Linux系统中,系统默认的目录结构都是以 /,即以根(root)开始的,而在使用chroot之后,系统的目录结构将以指定的位置作为 / 位置
    • Linux Namespace 在此基础上,提供了对UTS、IPC、mount、PID、network、User等的隔离机制
    • Docker优先使用 pivot_root 系统调用,如果系统不支持,才会使用chroot
  • clone(),实现线程的系统调用,用来创建一个新的进程,并可以通过设计上述参数达到隔离
  • unshare(),使某进程脱离某个namespace
  • setns(),把某进程加入到某个namespace
  • execv(),这个系统调用会把当前子进程的进程空间全部覆盖掉
    • 容器初始化进程 dockerinit 需要在容器内完成一系列初始化操作,比如完成根目录的准备、挂载设备和目录、配置 hostname 等
    • 然后,通过 execv() 系统调用,让应用进程(最后在Dockerfile里执行的ENTRYPOINT)取代自己,成为容器里的 PID=1 的进程

注意,后面的模拟代码会用到相关的系统调用!

1.2 模拟Docker镜像

一个最常见的 rootfs,或者说容器镜像,会包括如下所示的目录:

bb570693adbe4e87b9da5fa1531636c3.png

因为我们只是模拟,所以只创建几个目录:

992372591f9c4329b9f19e399f6552ea.png

拷贝命令到新创建的bin目录:

6781a60cab4a446793a3bd8dc0d4e6f5.png

注意,还需要把命令依赖的so文件拷贝到lib目录,查找so文件需要用到 ldd 命令:

295840fef953404981564e7f8429b249.png

还有些文件我们不希望写死在镜像中,如下图所示:

b7d800277526485e822925a4b40cc88f.png

因此,我们在rootfs外面再创建一个conf目录,把上面3个文件放进去,然后把这个conf目录mount进容器,具体操作看后面的代码。

关于mount再多说一嘴,其主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响

简单来说,绑定挂载实际上是一个inode(不清楚inode是啥的自行google吧)替换的过程,如下图所示:

e0c2084991e34dc18665b7b08ea15709.png

最后一步,执行chroot命令,告诉操作系统,我们将使用 $HOME/test 目录作为 /bin/bash 进程的根目录:

6e941eca41bd4c8f9049858312cc4a7b.png

这个挂载在容器根目录上,用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。至于很多同学都知道的 UnionFS,说白了,就是基于旧的rootfs以增量的方式来维护而已。

有兴趣的同学可以用下面的代码再来模拟一遍Docker:

0670cec970764624af1f15f6c9f58d7f.png

1.3 模拟Docker网络

容器有自己的 Network Namespace 和与之对应的网络接口eth0,宿主机上也有自己的eth0,而宿主机上的eth0对应着真正的物理网卡,也就是说,宿主机上的eth0才可以和外面通讯。那么容器是怎么跟外面通讯的呢?

091fb09aad164b4a93e45da25c3a9186.png

如上图所示,要解决容器与外面通讯需要解决两个问题:

  • 让数据包从容器的 Network Namespace 发送到宿主机的 Network Namespace 上,一般有两种方案,我们重点讲解使用得最多的 veth 方案,至于 macvlan/ipvlan 会简单提一下
  • 数据包发到宿主机 Network Namespace 后,还要解决数据包怎么从宿主机上的eth0发送出去的问题

首先,启动一个不带网络配置的容器:

0101a2766baa4f87ab37ef4bdab8e79e.png

找到这个容器进程的pid:

8aeab079a9a3483c91c4a56a6269eb87.png

通过 /proc/$pid/ns/net 这个文件得到容器 Network Namespace 的ID,然后在 /var/run/netns/ 目录下建立一个符号链接,指向这个容器的 Network Namespace:

66cf1ec270bb4cfca02678d76b9d3a0a.png

完成这步操作之后,在后面的 ip netns 操作里,就可以用pid的值作为这个容器的 Network Namesapce 的标识了。

接下来,用 ip link 命令来建立一对veth的虚拟设备接口,分别是veth_container和veth_host:

c23de621857a4080ae8fd66407d26416.png

把veth_container这个接口放入到容器的 Network Namespace 中:

3b69b072ee5440c99ae0c9ddb6d0376f.png

把veth_container重新命名为eth0,因为这时候接口已经在容器的 Network Namesapce 里了,所以不会和宿主机上的eth0命名冲突:

f47d5703924e44c9a61c8e6a907aff65.png

接着对容器内的eth0做基本的网络IP和缺省路由配置:

0e0a2753446f40e381e7068cec102f0d.png

完成这一步,就解决了让数据包从容器的 Network Namespace 发送到宿主机的 Network Namespace 上的问题,如下图所示:

25214ad2f64049d4a10dfa7408e55104.png

将veth_host这个设备接入到docker0这个bridge上:

00f58456348e495a8d561d78fd8abf32.png

至此,容器和docker0组成了一个子网,docker0上的IP就是这个子网的网关IP,如下图所示:

a496038fbc344e529fd5ebe3f80ca8bf.png

要让子网可以通过宿主机上eth0去访问外网,还需要添加下面的iptables规则:

fd60971cb0ef4d5f99f6c07c62fc3936.png

最终的veth网络配置如下图所示:

7523a263901a4674ab18137ab794a0ea.png

最后测试一下是否能ping通外网:

03e7810cf0884ebfb67609e8e5ad6693.png

如果发现ping不通,先检查下是否打开IP转发功能:

ee47c453e64b47598ab02d8f3aca3f91.png

这里简单提一嘴 macvlan/ipvlan 方案:对于macvlan,每个虚拟网络接口都有自己独立的mac地址;而ipvlan的虚拟网络接口是和物理网络接口共享同一个mac地址。同时它们都有自己的 L2/L3 的配置方式。有兴趣的同学可以按照如下步骤为容器手动配置上ipvlan的网络接口:

docker run --init --name ipvlan-test --network none -d busybox sleep 36000
 
pid1=$(docker inspect ipvlan-test | grep -i Pid | head -n 1 | awk '{print $2}' | awk -F "," '{print $1}')
echo $pid1
ln -s /proc/$pid1/ns/net /var/run/netns/$pid1
 
ip link add link eth0 ipvt1 type ipvlan mode l2
ip link set dev ipvt1 netns $pid1
 
ip netns exec $pid1 ip link set ipvt1 name eth0
ip netns exec $pid1 ip addr add 172.17.3.2/16 dev eth0
ip netns exec $pid1 ip link set eth0 up

完成上面的操作后,ipvlan网络二层的连接如下图所示:

58db7dffd4e647a0acdfd062a879e962.png

前面几篇分享简单提到过容器网络相关内容,出门右转看 容器网络性能分析浅析容器跨主机通信,有兴趣的同学可以再深入思考下Kubernetes相关的网络问题:

  • Pod内的网络通信
  • 同节点Pod之间的网络通信
  • 跨节点Pod通信
  • Pod与非Pod网络的实体通信

2 Cgroup

使用Cgroup可以根据具体情况来控制系统资源的分配、优先顺序、拒绝、管理和监控,提高总体效率。

2.1 Cgroup子系统

下面列举的是 Cgroup V1 相关子系统:

  • blkio,这个子系统为块设备设定输入/输出限制,比如物理设备(磁盘、固态硬盘、USB 等)
  • cpu,这个子系统使用调度程序提供对 CPU 的 cgroup 任务访问
  • cpuacct,这个子系统自动生成 cgroup 中任务所使用的 CPU 报告
  • cpuset,这个子系统为 cgroup 中的任务分配独立 CPU(在多核系统)和内存节点
  • devices,这个子系统可允许或者拒绝 cgroup 中的任务访问设备。
  • freezer,这个子系统挂起或者恢复 cgroup 中的任务
  • memory,这个子系统设定 cgroup 中任务使用的内存限制,并自动生成内存资源使用报告
  • net_cls,这个子系统使用等级识别符(classid)标记网络数据包,可允许 Linux 流量控制程序(tc)识别从具体 cgroup 中生成的数据包
  • net_prio,这个子系统用来设计网络流量的优先级
  • hugetlb,这个子系统主要针对于HugeTLB系统进行限制,这是一个大页文件系统

Linux Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下,可以通过 mount 命令查看:

770ca02ad63b4b21811fbc98535b9d0c.png

如果没有看到上述目录,可以自行做mount,如下所示:

mkdir cgroup
mount -t tmpfs cgroup_root ./cgroup
mkdir cgroup/cpuset
mount -t cgroup -ocpuset cpuset ./cgroup/cpuset/
mkdir cgroup/cpu
mount -t cgroup -ocpu cpu ./cgroup/cpu/
mkdir cgroup/memory
mount -t cgroup -omemory memory ./cgroup/memory/

一旦mount成功,那些mount的目录下面就会有很多相关文件,如下图所示:

a42ed29fef424673b55bc96e7362c10b.png

2.2 限制CPU的栗子

在使用 CPU Cgroup 前,先介绍三个重要的参数:

  • cpu.cfs_period_us,用来配置时间周期长度,一般它的值是 100000,单位是微秒(us)
  • cpu.cfs_quota_us,用来配置当前Cgroup在设置的周期长度内所能使用的CPU时间数,单位也是微秒(us),通常不会修改
  • cpu.shares,用来设置CPU的相对值,并且是针对所有的CPU(内核),默认值是1024。假设系统中有两个Cgroup,分别是A和B,A的shares值是1024,B的shares值是512,那么A将获得1024/(1204+512)=66%的CPU资源,而B将获得33%的CPU资源,注意相对值这个概念:
    • 如果A不忙,没有使用到66%的CPU时间,那么剩余的CPU时间将会被系统分配给B,即B的CPU使用率可以超过33%
    • 如果又添加了一个新的Cgroup C,且它的shares值是1024,那么,A的限额变成了1024/(1204+512+1024)=40%,B的变成了20%

了解了上面的参数,那么应该能看懂下面这条docker命令:

1e7fa824b46d4710a74f3accaed65d23.png

接下来,我们使用原生的Cgroup来实现上面的命令。

首先,创建一个 CPU Cgroup:

b644786611624e1187a0b79f6236f03f.png

设置 cpu.cfs_quota_us 为 20000:

d1cb22d4f89f40d6a5834c527c6a0a56.png

查看当前Shell进程的PID:

10493ebac3ec4687b0254c47ff3cb992.png

然后,在bash中启动一个死循环来消耗cpu,正常情况下应该使用100%的CPU,即消耗一个内核:

f16325fc3e094cf0b43b413e40d326c7.png

将这个测试Shell进程的PID加入 CPU Cgroup:

1236c46b61af4299ad15f55cb8b0a716.png

加入之后可以看到CPU使用率降到了20%:

b18486b467d04d8e87909fd9a0ac177f.png

关于Kubernetes的CPU限制出门右转看 Kubernetes资源limits和requests的栗子

2.3 Cgroup V2

容器IO性能分析 中提到过Cgroup有V1和V2两个版本,V1版本中的 blkio Cgroup 只能限制 Direct IO,不能限制 Buffered IO。

这是因为 Buffered IO 会把数据先写入到内存 Page Cache 中,然后由内核线程把数据写入磁盘,而 Cgroup V1 的子系统都是独立的,即 Cgroup V1 blkio 的子系统是独立于 memory 子系,所以无法统计到由 Page Cache 刷入到磁盘的数据量。

这个 Buffered IO 无法被限速的问题,在 Cgroup V2 里被解决了。Cgroup V2 从架构上允许一个控制组里有多个子系统协同运行,这样在一个控制组里只要同时有 io 和 memory 子系统,就可以对 Buffered IO 作磁盘读写的限速,如下图所示:

0fff409124074a4485d3a6870643f6c8.png

虽然 Cgroup V2 能解决 Buffered IO 磁盘读写限速的问题,但是目前 runC、containerd以及Kubernetes都是刚刚开始支持 Cgroup V2,所以还需要等待一段时间。

骚年,都看到最后了,不晓得你是否慢慢找到了学习容器的赶脚,更多好玩的有趣的内容敬请期待下篇分享!


标题:与容器相关的那些事——底层原理探秘
作者:yanghao
地址:http://solo.fancydigital.com.cn/articles/2022/05/04/1651627192787.html