16怪兽:手搓迷你容器

2503 字
13 分钟
16怪兽:手搓迷你容器

第 16 关 手搓迷你容器#

本关的产出:不用 Docker,用几条内核命令拼出一个「有自己的主机名、自己的 PID 视图、自己的根目录、内存还被限住」的东西。拼完再把每一块对回前面各关,深入理解容器的原理。

16.1 作业台#

整关要在一台 Linux 上做,而且得是 root:unshare、chroot、mount 都是内核接口,macOS、Windows 上没有,两种任选其一:

  • 手边有 Linux(云服务器、虚拟机都算):ssh 上去,sudo -i。
  • 只有 macOS / Windows:借 Docker Desktop 虚拟机里的那个 Linux,开一个特权容器当作业台。
Terminal window
docker run --rm -it --privileged --cgroupns=host debian bash
apt-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 # 0
stat -fc %T /sys/fs/cgroup # cgroup2fs

第一条是 root;第二条确认是 cgroup v2——16.5 那些路径都是 v2 的写法,v1 在 /sys/fs/cgroup/memory/。

装工具(Debian / Ubuntu):

Terminal window
apt-get install -y busybox-static procps

busybox 是什么:一个可执行文件里塞了几百条 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 aux

unshare 干的就是第 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,就不用操心库:

Terminal window
# 造2个空目录当新根:bin/ 放程序,proc/ 留给后面挂 /proc
mkdir -p /tmp/mini/bin /tmp/mini/proc
# 新根里唯一的可执行文件,就是这上面装的静态 busybox
cp /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/ 里就是:

busybox
sh -> busybox
ls -> busybox
ps -> 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 不是磁盘上的目录,是内核挂在那里的「进程表」,空目录里当然没有。所以在换根之前,先挂好:

Terminal window
mount -t proc proc /tmp/mini/proc

这一步在宿主机上做,挂进去的就是宿主机那份 /proc——先记住,等下有用。

换根:

Terminal window
chroot /tmp/mini /bin/sh

chroot 是什么:一条命令,底层是系统调用 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

里面的三件事:

  1. hostname mini——uts 换了,改的才是自己的主机名;不换,改的是宿主机。
  2. mount -t proc proc /proc——在 mount namespace 里挂,宿主机不受影响,靠的就是 --propagation private。
  3. 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 无关,是另一步在宿主机:

Terminal window
mkdir -p /sys/fs/cgroup/mini
echo 50M > /sys/fs/cgroup/mini/memory.max
echo $$ > /sys/fs/cgroup/mini/cgroup.procs

如果第二步报找不到文件(memory.max 不存在):先跑这一句将内存纳入控制器,再重来第二步。

Terminal window
echo +memory > /sys/fs/cgroup/cgroup.subtree_control

从这一刻起,这个 shell 和它的子孙都算在 mini 组里,内存总量超 50M 就被 OOM killer 杀(第 14 关的 137)。

验证:

cat /proc/self/cgroup # 0::/mini
cat /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 侧的对照见 命令速查表 · 资源限制、命令速查表 · 网络。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
16怪兽:手搓迷你容器
https://hecloud.top/posts/tech/docker/16手搓迷你容器/
作者
贺小云
发布于
2026-11-01
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
贺小云
热爱技术与折腾的博客 Dancing with uncertainty. 与不确定性共舞
公告
出于成本考虑,贺云已全面转型Serverless架构
分类
标签
碎碎念
站点统计
文章
95
分类
5
标签
44
总字数
110,484
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Netlify CI
博客版本
Firefly v6.16.5
文章许可
CC BY-NC-SA 4.0