16怪兽:手搓迷你容器

第 16 关 手搓迷你容器
本关的产出:不用 Docker,用几条内核命令拼出一个「有自己的主机名、自己的 PID 视图、自己的根目录、内存还被限住」的东西。拼完再把每一块对回前面各关,深入理解容器的原理。
16.1 作业台
整关要在一台 Linux 上做,而且得是 root:unshare、chroot、mount 都是内核接口,macOS、Windows 上没有,两种任选其一:
- 手边有 Linux(云服务器、虚拟机都算):ssh 上去,
sudo -i。 - 只有 macOS / Windows:借 Docker Desktop 虚拟机里的那个 Linux,开一个特权容器当作业台。
docker run --rm -it --privileged --cgroupns=host debian bashapt-get update && apt-get install -y procps busybox-static本关不用 Docker:上面那两行只是借它替你备一台 Linux。两个参数都有明确用途——--privileged 给容器 root 的全部 capabilities、并去掉默认的 seccomp 白名单,因为接下来每一步都要碰内核:新建 namespace、往 /tmp/mini/proc 挂 proc、改主机名、写 /sys/fs/cgroup,Docker 默认那两道闸会拦住(第 13 关);--cgroupns=host 让它看到宿主机整棵 cgroup 树,而不是被裁到只剩自己那一个。
开工前确认两样:
id -u # 0stat -fc %T /sys/fs/cgroup # cgroup2fs第一条是 root;第二条确认是 cgroup v2——16.5 那些路径都是 v2 的写法,v1 在 /sys/fs/cgroup/memory/。
装工具(Debian / Ubuntu):
apt-get install -y busybox-static procpsbusybox 是什么:一个可执行文件里塞了几百条 Unix 命令。启动时它知道自己是被哪个名字调起来的(
argv[0]):叫ls就跑ls的逻辑,叫ps就跑ps的。所以sh、ls、ps这些名字都只是指向它的符号链接,不是一个个独立的程序。16.3 用得上的是两点:它约 1 MB;能静态链接,不依赖任何
.so。拿一个空目录当根时,动态链接的程序得连同libc.so一起搬进去才跑得动,静态的 busybox 放它一个就够。另:Alpine 是一个精简发行版,整套命令就是 busybox——第 9 关
nginx:alpine比nginx小,一部分原因在这儿。代价是每条命令都是简化版,选项比 GNU 版少。
16.2 第一步:换一套 PID 视图
unshare --fork --pid --mount-proc ps auxunshare 干的就是第 15 关 15.2 那一行:给自己新建一个 namespace,再在里面跑一条命令。
| 参数 | 做什么 | 不写会怎样 |
|---|---|---|
--pid | 新建一个 pid namespace,里面的进程从头编号 | 没有隔离,ps 列出的还是宿主机的全部进程 |
--fork | 先 fork 一个子进程,让它进新 namespace,再执行命令 | 命令由 unshare 自己执行,而它早就存在、进不去新 namespace——等于没建 |
--mount-proc | 顺带新建 mount namespace,重新挂一份 /proc | 编号是新的,ps 读的却还是宿主机那份 /proc,照样列出几百个进程——最会骗人 |
跑完看到:ps 自己就是 1 号,往下只有它和它的子进程。
对照:这就是第 8 关那个「容器里 PID 1」的来源。1 号不是 Docker 给的,是内核在新建 namespace 时编的。
16.3 第二步:给自己一个根目录
namespace 换的是视角,/ 里有什么还是宿主机的。要换根,先得有个能当根的目录。
造一个最小的。程序和它依赖的库都得在里面;用静态链接的 busybox,就不用操心库:
# 造2个空目录当新根:bin/ 放程序,proc/ 留给后面挂 /procmkdir -p /tmp/mini/bin /tmp/mini/proc
# 新根里唯一的可执行文件,就是这上面装的静态 busyboxcp /bin/busybox /tmp/mini/bin/
# 让每个命令名都成为指向同目录 busybox 的相对软链cd /tmp/mini/bin && for a in $(./busybox --list); do ln -sf busybox "$a" 2>/dev/null; done最后这条循环在做什么:
./busybox --list—— 让它把自己内置的全部命令名打出来(sh、ls、ps、ip……)。for a in ...—— 把这些名字挨个取出来。ln -sf busybox "$a"—— 在同一个目录里建一条同名软链,指向旁边的 busybox。
跑完 bin/ 里就是:
busyboxsh -> busyboxls -> busyboxps -> busybox...链接要写相对路径(busybox,不是 /tmp/mini/bin/busybox)。软链里存的只是一个字符串,绝对路径从「根」开始算——而等会儿 chroot 一执行,「根」就换成 /tmp/mini 了,同一个字符串会被解释成 /tmp/mini/tmp/mini/bin/busybox,指空。写相对的,它就永远指「我旁边那个 busybox」,根换到哪都跟着。
顺带记一个会骗人的报错:No such file or directory 不等于文件不在。软链悬空、动态链接的程序找不到 ld-linux 与 libc.so,exec 报的都是这一句。
把 /proc 挂进去。/proc 不是磁盘上的目录,是内核挂在那里的「进程表」,空目录里当然没有。所以在换根之前,先挂好:
mount -t proc proc /tmp/mini/proc这一步在宿主机上做,挂进去的就是宿主机那份 /proc——先记住,等下有用。
换根:
chroot /tmp/mini /bin/shchroot 是什么:一条命令,底层是系统调用 chroot(2)。用法就是上面那样——chroot <新根> <程序>:把进程记录里的「根目录」换成新根,再执行后面的程序。
怎么实现的:内核替每个进程记着一条「我的根在哪个目录」。所有绝对路径都从这条记录开始解析,.. 走到它就算到头,再往上没有;fork 出来的子进程继承这条记录。所以换根只影响这一棵进程树,宿主机上别的进程照旧。
现在 ls / 只剩新根里的东西,ps 也能列出进程。但列出来的是宿主机的全部进程,因为挂进去的正是宿主机那份 /proc。而 chroot 本身只做了「换根」这一件事——进程号、网卡、资源限额一样没动,这就是它比 namespace 早得多、却顶不上容器隔离的原因。
所以还缺 namespace:这条挂载留在宿主机的挂载表里,退出 chroot 也不会消失,得自己 umount /tmp/mini/proc;顺带还把宿主机的进程表带了进来。下一步的 mount namespace 解决的正是这个——挂载只在 namespace 里可见,里面的进程退干净,namespace 连同挂载一起消失,不用收拾。
16.4 串起来
一个脚本,一次进四样 namespace,再 chroot:
#!/bin/bash# mini.sh —— 拼一个最小容器exec unshare \ --fork --pid --mount --uts --net --propagation private \ chroot /tmp/mini /bin/sh -c ' hostname mini mount -t proc proc /proc ip link set lo up echo "主机名: $(hostname)" echo "网卡:" ip link echo "进程:" ps exec sh '| 参数 | 给了什么 | 对应第 15 关 |
|---|---|---|
--pid | 自己的进程号 | pid namespace |
--mount --propagation private | 自己的挂载表,且改动不会外泄到宿主机 | mnt namespace |
--uts | 自己的主机名 | uts(--hostname) |
--net | 自己的网卡和路由 | net namespace |
里面的三件事:
hostname mini——uts 换了,改的才是自己的主机名;不换,改的是宿主机。mount -t proc proc /proc——在 mount namespace 里挂,宿主机不受影响,靠的就是--propagation private。ip link set lo up——新 net namespace 里默认只有一块lo,还是关着的。Docker 会再插一根veth到网桥上(第 15 关 15.4),那要多敲几条ip,本关不铺。
跑起来看到三样:主机名是 mini,进程表第一行是 1 号,网卡只有 lo。exit 退出。
16.5 第三步:限额
前面做的全是「看得见什么」,还没碰「用得了多少」。
这一步在宿主机上做,不在那个「容器」里。 cgroups 是套在进程外面的一本账:谁在哪一组、用了多少、超了多少,都是外面记的,里面的进程自己不管这件事。Docker 也一样——--memory 是引擎在宿主机上建好 cgroup、写好上限,再把容器进程放进去。
所以它跟 chroot 无关,是另一步在宿主机:
mkdir -p /sys/fs/cgroup/miniecho 50M > /sys/fs/cgroup/mini/memory.maxecho $$ > /sys/fs/cgroup/mini/cgroup.procs如果第二步报找不到文件(memory.max 不存在):先跑这一句将内存纳入控制器,再重来第二步。
echo +memory > /sys/fs/cgroup/cgroup.subtree_control从这一刻起,这个 shell 和它的子孙都算在 mini 组里,内存总量超 50M 就被 OOM killer 杀(第 14 关的 137)。
验证:
cat /proc/self/cgroup # 0::/minicat /sys/fs/cgroup/mini/memory.max # 52428800收尾:退出那个 shell(组就空了),再 rmdir /sys/fs/cgroup/mini。不删也不影响什么,只是它会一直留在那台机器上。
和 Docker 的差别:Docker 还会开一个 cgroup namespace(第 15 关那张表最后一行),让容器里的 /sys/fs/cgroup 只剩自己那一个。我们没做,所以在里面看到的仍是宿主机的全局视图。
16.6 对照
| 迷你容器里做的 | Docker 里对应的 |
|---|---|
unshare --pid --mount --uts --net | 建各类 namespace |
chroot | 用 overlayfs 把镜像各层加一个可写层拼成根(15.5) |
mount -t proc | 挂 /proc、/dev、/sys 等特殊文件系统 |
ip link(以及没写的 veth + 网桥 + iptables) | 第 5 关的网络,底层见 15.4 |
cgroup 里写 memory.max | --memory(第 14 关) |
hostname mini | --hostname |
exec sh 当 1 号 | ENTRYPOINT / CMD(第 8 关) |
16.7 还差什么
迷你容器说明的只有一件事:容器 = 内核的几样机制 + 一个根目录 + 一个进程,没有更多。
Docker 往上加的是工程化那一层:
- 镜像:根目录得有人造、有人存、有人分发——overlayfs 分层 + registry(第 3、9、10 关)。我们的
/tmp/mini是手搓的,也没法共享、没法回滚。 - 特殊文件系统:Docker 替你挂好
/proc、/dev、/sys、/dev/shm,还生成/etc/hosts、/etc/resolv.conf。迷你容器里一个都没有,连/dev/null都没有。 - 安全:capabilities、seccomp、user namespace(第 13、15 关)。我们这个是 root + 全能力,比 Docker 的默认还松。
- 运维:日志驱动(第 11 关)、卷(第 4 关)、健康检查、重启策略、
docker stats(第 14 关)、docker inspect(第 12 关)。
这一关的产出:能自己拼一个容器,也就知道了那些参数各自落在内核的哪一块上。下一关换成收网——把前面所有东西用在一个真项目上。
本关命令速查
本关用的是内核工具(unshare、chroot、mount、ip、cgroup),不是 Docker 命令,不进速查表。Docker 侧的对照见 命令速查表 · 资源限制、命令速查表 · 网络。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





