手动实现一个Linux容器

容器

容器技术的出现,彻底颠覆了传统的应用交付部署方式,交付的边界不再仅限于代码,而是一整套能 run everywhere 的基础设施,正如容器化领域的集大成者 Docker 的标语所言,这是一个新的时代了:

Accelerate how you build, share and run modern applications.

容器本质上就是一个软件包,包含了业务服务及其依赖组件,比如我有一个Java服务,依赖了jdk 14.0.1以及几个外部 jar包,同时我们还依赖发行版的一些特性,需要运行在 debian buster发行版上,那么我们可以把这些通通打入一个镜像中,或者叫一个静态的容器,任何会影响运行时的东西我们都可以打入镜像中,这使得其具有非常高的可移植性。

容器运行起来的时候,会被彻底和底层机器以及其他的容器隔离起来,这又避免了许多可能的冲突。正是可移植性隔离性这两个核心特性使得现代服务越来不越离不开容器化技术。

容器领域现在一片欣欣向荣,docker、containerd、kata各种各样的容器运行时产品层出不穷,而为其提供支撑的核心技术,其实一直都万变不离其宗。

Snipaste_2021-04-13_18-20-32

本文会介绍用纯手动敲命令的方式,基于一个普通的 Linux 系统(注:基于 centos 7.9,其他发行版均可,注意替换包管理相关命令),以 alpine:3.11 这个镜像为例,一步步运行起来一个“五脏俱全”的容器,将容器底层相关的核心技术都串联起来。

下载镜像

镜像是静态的容器,本质上就是一堆文件打包在一起,而关于镜像具体如何组织,则出现了好几种镜像格式,目前主流镜像格式有三种:

  • Docker V2 Schema 1:主要为了与老版本兼容
  • Docker V2 Schema 2:目前新版本 Docker 使用中
  • OCI:开放容器组织 (Open Container Initiative) 基于 Docker V2 Schema 2发布的业内统一镜像标准格式

这里以最常见的 Docker V2 Schema 2镜像格式为例,一个镜像会存在一个 manifest.json镜像描述文件用于描述诸如文件层数、容器入口命令、版本等信息,想要了解详细细节可以参考官方定义:https://docs.docker.com/registry/spec/manifest-v2-2/。

我们可以直接拼出alpine:3.11镜像对应的描述文件地址,值得注意的是这里需要通过 Header 声明对新版镜像格式的支持,不然出于兼容考虑镜像服务默认只会下发 v1版本的描述文件,另外这里使用了网易的镜像,因为直接去 Docker Hub 拉需要先获取 JWT Token,稍稍麻烦一点:

$ curl -H "Accept: application/vnd.docker.distribution.manifest.v2+json" https://hub-mirror.c.163.com/v2/library/alpine/manifests/3.11

​```
{
   "schemaVersion": 2, // manifest格式的版本,2.2版本固定为2
   "mediaType": "application/vnd.docker.distribution.manifest.v2+json", // MIME格式,2.2版本的manifest对应的值也是固定的
   "config": { // 描述了镜像配置文件
      "mediaType": "application/vnd.docker.container.image.v1+json",
      "size": 1470,
      "digest": "sha256:44dc5a8658dc159bb1c78f9285feead8e0d375d13300f10e647152abf5f3c329" // 镜像配置文件的摘要,同时也是地址
   },
   "layers": [
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", // 也基本是固定的
         "size": 2815346,   // 该层的大小,单位字节
         "digest": "sha256:9b794450f7b6db7c944ba1f4161edb68cb535052fe7db8ac06e613516c4a658d" // 该层文件系统的摘要,同时也是地址
      }
   ]
}
​```

镜像描述文件中的 layers则包含了容器对应每一层的文件对象地址,对应一个 tar 压缩包,容器运行时会通过联合文件系统将这些层关联起来,这里我们的 alpine:3.11是个基础镜像,只有一层。

我们可以直接拼接出 URL 下载这一层并解压到 alpine 目录:

wget -O alpine.tar \
https://hub-mirror.c.163.com/v2/library/alpine/blobs/\
sha256:9b794450f7b6db7c944ba1f4161edb68cb535052fe7db8ac06e613516c4a658d

mkdir -p alpine && tar xf alpine.tar -C alpine

:如果网易镜像失效了,直接去 alpine 官网或者其他途径下载 rootfs 也是可以的:

官网地址:

https://dl-cdn.alpinelinux.org/alpine/v3.11/releases/x86_64/alpine-minirootfs-3.11.0-x86_64.tar.gz

对应清华镜像:

https://mirrors.tuna.tsinghua.edu.cn/alpine/v3.11/releases/x86_64/alpine-minirootfs-3.11.0-x86_64.tar.gz

简单运行

我们查看上文解压的结果,会发现这就是 alpinerootfs

image-20210408171753062

我们可以使用 chroot 修改根目录的地址,实现对文件视图的隔离,然后再启动一个alpine镜像中带的 shell

chroot . /bin/sh --login

image-20210408172830119

是不是瞬间感觉有模有样了,跟我们平常通过 shell 进入一个容器一样?

不过这个时候还差的远呢,还有很多其他类型资源,需要我们进一步处理:

  • 文件系统df -h需要只能看到容器自己的挂载点
  • 网络:ifconfig需要只能看到容器自己的网卡
  • 进程信息:ps aux需要只能看到容器自己的进程
  • CPU、进程:只分配指定配额给这个容器使用避免影响到其他容器

下面我们来继续一步步的实现。

创建挂载点

虽然我们可以直接在镜像上执行我们的业务操作,但是如果要再基于这个镜像启动一个容器的话就需要再拉取一份镜像下来,所以容器产品通常会使用一种叫做 联合文件系统 (Union File System) 的技术,可以将多个文件层联合起来,呈现出一层的视图供使用者使用,但使用者所有的修改删除新增等操作全部是只会实际对最上层产生影响的,底层全部是是只读的,这样有利于多个容器间共用镜像,减少文件传输和存储成本。

docker-filesystems

就像普通文件系统一样,联合文件系统也有很多种,比如:aufs、devicemapper、zfs等,这里以 docker 推荐的 OverlayFS 为例,我们创建一个新的目录做为上层,底层则是我们的 alpine 镜像所在的目录,合并成挂载一个文件系统:

# 确保 overlay 内核模块开启
modprobe overlay
# 创建上层目录,同时 overlayfs 还需要一个临时目录
mkdir ./alpine-workdir ./tmp
# 联合挂载到 ./alpine 目录
 mount -t overlay -o lowerdir=./alpine,upperdir=./alpine-workdir,workdir=./tmp overlay ./alpine

这里我们先给容器准备好这个挂载点,下文我们会把这个挂载点再给隔离出来。

注: 如果系统不支持 overlay 文件系统的话,也可以使用其他文件系统,或者简单通过 mount --bind ./alpine ./alpine将这个子目录挂到它当前的位置来创建一个挂载点。

隔离

进程&主机名

Linux 社区为了支持容器化技术实现了一种资源视图分区机制:namespace,可以将操作系统管理的资源按进程组进行隔离, 使得进程能只看到跟它相关联的资源,支持如下六种资源:

image-20210413200038969

在代码层面,内核提供了cloneunsharesetns三个系统调用来提供命名空间的功能,不过我们今天并不是要直接写代码来实现,util-linux 提供了 unshare命令行工具能让我们手动直接使用这种能力,几乎所有的发行版都内置了这个包。

unshare 的用法如下:

$ unshare --help

命令格式:
 unshare [选项] <程序> [<参数>...]

启动一个新的命名空间运行指定的程序,不再共享当前命名空间。

选项
 -m, --mount               隔离文件挂载系统
 -u, --uts                 隔离主机名
 -i, --ipc                 隔离 System V 进程通信机制
 -n, --net                 隔离网络空间
 -p, --pid                 隔离进程号
 -U, --user                隔离用户
 -f, --fork                fork一个子进程运行程序而不是直接运行
     --mount-proc[=<dir>]  在隔离完成后,运行程序之前,挂载 proc 系统(隔离后的)到指定位置
 -r, --map-root-user       将当前用户映射成 root

那么我们可以通过 unshare 隔离了资源之后再通过上文的 chroot 隔离文件视图:

unshare -muinp -f chroot . /bin/sh --login

这里需要注意的是,我们需要通过 -f参数让我们的 shell 在新 fork 的进程中执行才能进入新的命名空间,因为这个命令的设计是调用 unshare 的进程会仍然留在原来的命名空间下,子进程才会被置入新空间。

这个时候我们新的进程就已经隔离好了,不过我们还需要再手动挂载一下 /proc文件系统,让内核把当前的进程、CPU、内存等系统运行时信息通过文件的方式暴露出来,供ps、df 等工具使用:

/bin/mount -t proc proc /proc

现在我们就已经把新开的这个 shell进程隔离出来了,不过还有两个小问题需要再进一步处理:

  1. 文件挂载表虽然隔离了,但是新的命名空间下会把老命名空间的挂载表原样复制过来,这意味着容器里还是能看到之前所有的挂载点,只是以后的修改两个空间不会互相影响罢了
  2. 网络空间虽然隔离了,但是新的空间下是什么设备都没有的,需要进一步进行网络配置

文件挂载

Linux 提供了 pivot_root 系统调用可以将整个系统的挂载点以指定的目录为轴,进行“旋转”,子目录旋转到根的位置,而原来的根目录则会旋转到子目录的位置,然后我们再解除掉原来根目录的挂载,就能得到一个”纯净”的文件挂载表,只有我们子目录下的挂载点,同时也省去使用 chroot 来完成文件视图隔离的操作了。

cd alpine/
# 在新的命名空间下启动宿主机 bash
unshare -umpn -f bash
# 创建一个目录用于挂载旧 root
mkdir -p old-root
# 修改 root 挂载点,将原来的 root 挂载到 old-root 下
pivot_root . old-root/
# 用 alpine 下的 sh 替换当前 bash 进程启动
exec /bin/sh --login
# 挂载 proc 系统
/bin/mount -t proc proc /proc
# 解除旧 root 挂载
umount -l old-root/

我们可以再用 df 命令查看一下,会发现容器里面已经只剩我们 alpine目录对应的挂载点了,符合预期。

网络配置

容器网络模型中存在一种 host网络模式,即容器和宿主机共用网络空间,不进行隔离,所以理论上我们不进行下面的操作也算勉强实现一个容器了,不过如果我们想要实现一个常规的使用 bridge网络模式具有自己独立 IP 的容器的话,那我们还有最后一步要做。

利用 linux namespace 的功能,我们可以隔离出一个具有独立网络设备、IP协议栈、路由表、防火墙规则的网络空间,但是就像我们上文提到的,这个新的网络空间创建出来之后是空空如也的,需要我们配置。

物理层

我们首先需要创建一个网络空间,然后给它创建网卡,这个时候理论上我们可以直接把默认网络空间下的物理网卡搬到新的网络空间下,这样容器就能上网,但是宿主机的网络就瘫痪了,不过有多张物理网卡的话就没有这个问题。

Linux 提供了 veth pairveth - Virtual Ethernet Device)的虚拟网卡对机制,可以在两个网络空间间建立一条隧道,使得网络流量可以跨越不同的网络空间,我们可以创建一对 veth,一张放在默认网络空间,一张放在我们新的网络空间,连通两个网络空间。

# 创建网络空间
ip netns add ns0
# 创建 veht pair
ip link add veth0 type veth peer name veth1
# 将 veth1 放入新创建的网络空间中
ip link set veth1 netns ns0

image-20210413204603899

链路层

现在两个网络空间还只能视为“物理层”已连通,物理网卡之间链路层的打通是通过交换机,而我们的虚拟网卡,则也需要一个虚拟交换机——网桥,在 docker 的容器网络中,会把所有的容器都挂到 docker0网桥上,这样这些虚拟设备们就像串在一个交换机之下互相直接不需要经过路由即可直连。

# 安装网桥操作工具
yum install -y bridge-utils
# 创建一个网桥
brctl addbr bridge0
# 将veth0加入到网桥中
brctl addif bridge0 veth0

网络层

下面我们需要继续来打通网络层,首先需要给容器配置 IP 地址,同时网桥也需要一个 IP 地址,我们可以随意选择一个不冲突的网段即可,另外还需要配置容器的路由表,让容器将流量全部转发到网桥上。

# 配置网桥 IP 地址,使用 172.18.0.0/16 网段
ifconfig bridge0 172.18.0.1
# 配置容器 IP 
ip netns exec ns0 ifconfig veth1 172.18.0.2
# 配置容器路由表默认转发地址
ip netns exec ns0 ip route add default via 172.18.0.1
# 启动网卡
ip link set dev veth0 up

为了便于理解,简单画了一下如果有多个容器,即多个 namespace 之间的网络是如何连通的,可以看到每个 namesapce 和带物理网卡的默认 namespace 之间都会有一对 veth pair,默认 namespace 上的 veth pari 会被 bridge0 网桥接管,从而打通各个容器之间的网络,网桥和物理网卡之间在一个 namespace 内本来在链路层就是可以彼此可见的,而网络层的话,物理网卡和网桥会通过直接路由的方式进行连通,路由表在我们给网桥配置 IP 的时候会自动生成。

image-20210413221130507

现在理论上我们就能在容器里 ping 通宿主机了:ping 172.18.0.1,但是访问外部网络的话还是不行的,因为我们这只是一个局域网 IP,包出去之后外网的机器没有我们的路由不能把包传回来,需要利用 iptables 等工具进行 nat 转换,在包发出时修改 IP 包源地址为宿主机地址再发出去:

# 开启宿主机 IP 包转发功能
sysctl -w net.ipv4.ip_forward=1
# NAT 转换,MASQUERADE是一种动态源IP 的 NAT 转换
iptables -t nat -A POSTROUTING -s 172.18.0.0/16 ! -o bridge0 -j MASQUERADE

应用层

这个时候我们就能 ping 通外部机器了,但是还有一个小问题需要解决:DNS还没有配置,我们在隔离文件视图之前需要复制宿主机的 DNS 配置到容器中:

# 复制宿主机 DNS 配置到容器
cp /etc/resolv.conf alpine/

到这里,我们的网络配置就全部完成了。

资源限制

作为一个五脏俱全的容器,资源限制这个问题是无论如何也绕不过的,不然比如一个容器出了 bug 死循环跑满了 CPU 导致其他容器全部异常,甚至影响到了宿主机的安全,这种的没有资源限制机制容器肯定是没人敢用的。

Linux 提供 cgroups (Control Groups) 机制用来限制、控制与分离一个进程组的资源,其实现原理也不难猜测,每一份资源都是以进程为最小单元分配的,资源的用量内核也是能统计到的,那么在分配 CPU 和 内存等资源分配时,内核只需要稍微留意一下这个进程是否受限即可。

cgroups 的用户接口层实现为了一个文件系统,挂载在/sys/fs/cgroup路径下,这下面的一级子目录都对应一种资源控制子系统:

$ ls /sys/fs/cgroup
blkio  cpu  cpuacct  cpu,cpuacct  cpuset  devices  freezer hugetlb
memory  net_cls  net_cls,net_prio  net_prio  perf_event  pids  systemd

而限制要一组进程的资源的话,只需要在资源控制子系统下创建一个子目录,然后把进程号写到 tasks文件中 即可,找 PID 这个事情不太好自动化,我们可以配合 cgroups 相关工具使用:

# 限制CPU使用
cd /sys/fs/cgroup/cpu

# 创建目录,cgroups 会帮我们自动生成很多信息
mkdir -p my-container
# 限制 20% 的资源利用率,默认总值100000
echo 20000 > my-container/cpu.cfs_quota_us 

# 限制内存使用
cd /sys/fs/cgroup/memory/
mkdir -p my-container
# 限制内存 200M
echo "200M" > memory.limit_in_bytes

# 安装 cgroups 工具集
yum install -y libcgroup libcgroup-tools

# 配合 cgroups 工具免去手动写入 PID 的过程
cgexec -g cpu,memory:my-container bash

总结

我们实现了一个具有独立网络、进程、文件空间并能物理隔离于宿主机的容器,容器所依赖的底层技术基本就是这些了:

  • 通过 namespace 机制隔离进程、IPC、主机号、网络设备、网络协议栈
  • pivot_root 隔离文件挂载点 & 文件视图
  • veth pair 连通网络空间 & 网桥网络配置& iptalbes 做 nat 转换实现外网访问
  • cgroups 实现资源限制
  • 联合文件系统实现底层镜像不变可共享

最终效果演示:

image-20210414162224423

所有命令汇总:

######################### 镜像下载解压
wget -O alpine.tar \
https://hub-mirror.c.163.com/v2/library/alpine/blobs/\
sha256:9b794450f7b6db7c944ba1f4161edb68cb535052fe7db8ac06e613516c4a658d

# 或者:wget -O alpine.tar \
# https://mirrors.tuna.tsinghua.edu.cn\
#/alpine/v3.11/releases/x86_64/alpine-minirootfs-3.11.0-x86_64.tar.gz

mkdir -p alpine && tar xf alpine.tar -C alpine


########################## 网络配置

# 创建网络空间
ip netns add ns0
# 创建 veht pair
ip link add veth0 type veth peer name veth1
# 将 veth1 放入新创建的网络空间中
ip link set veth1 netns ns0

# 安装网桥操作工具
yum install -y bridge-utils
# 创建一个网桥
brctl addbr bridge0
# 将veth0加入到网桥中
brctl addif bridge0 veth0

# 配置网桥 IP 地址,使用 172.18.0.0/16 网段
ifconfig bridge0 172.18.0.1
# 配置容器 IP 
ip netns exec ns0 ifconfig veth1 172.18.0.2
# 配置容器路由表默认转发地址
ip netns exec ns0 ip route add default via 172.18.0.1
# 启动网卡
ip link set dev veth0 up

# 开启宿主机 IP 包转发功能
sysctl -w net.ipv4.ip_forward=1
# NAT 转换,MASQUERADE是一种动态源IP 的 NAT 转换
iptables -t nat -A POSTROUTING -s 172.18.0.0/16 ! -o bridge0 -j MASQUERADE

# 复制宿主机 DNS 配置到容器
cp /etc/resolv.conf alpine/

########################## 联合文件系统挂载

# 确保 overlay 内核模块开启
modprobe overlay
# 创建上层目录,同时 overlayfs 还需要一个临时目录
mkdir ./alpine-workdir ./tmp
# 联合挂载到 ./alpine 目录
 mount -t overlay -o lowerdir=./alpine,upperdir=./alpine-workdir,workdir=./tmp overlay ./alpine


########################### 资源限制

# 限制CPU使用
cd /sys/fs/cgroup/cpu

# 创建目录,cgroups 会帮我们自动生成很多信息
mkdir -p my-container
# 限制 20% 的资源利用率,默认总值100000
echo 20000 > my-container/cpu.cfs_quota_us 

# 限制内存使用
cd /sys/fs/cgroup/memory/
mkdir -p my-container
# 限制内存 200M
echo "200M" > memory.limit_in_bytes

# 安装 cgroups 工具集
yum install -y libcgroup libcgroup-tools

# 配合 cgroups 工具免去手动写入 PID 的过程
cgexec -g cpu,memory:my-container bash


######################### 文件挂载点隔离 && 启动

cd alpine/

# 资源限制-网络隔离-其他资源隔离启动 bash shell
cgexec -g cpu,memory:my-container ip netns exec ns0 unshare -muip -f /bin/bash

# 创建一个目录用于挂载旧 root
mkdir -p old-root
# 修改 root 挂载点,将原来的 root 挂载到 old-root 下
pivot_root . old-root/
# 用 alpine 下的 sh 替换当前 bash 进程启动
exec /bin/sh --login
# 挂载 proc 系统
/bin/mount -t proc proc /proc
# 解除旧 root 挂载
umount -l old-root/

######################### 验证
pwd
ls
df -Th
netstat -a
ifconfig
ping -c 2 172.18.0.1
ping -c 2 baidu.com

参考

  1. Bocker https://github.com/p8952/bocker
  2. Linux Namespace https://lwn.net/Articles/531114/
  3. unshare https://man7.org/linux/man-pages/man7/namespaces.7.html
  4. MyDocker https://github.com/xianlubird/mydocker
  5. Docker 核心技术与实现原理 https://draveness.me/docker/
  6. UnionFS https://en.wikipedia.org/wiki/UnionFS
  7. containerd https://github.com/containerd/containerd