← 全部笔记

Docker · 7 底层原理

手搓一个“容器”

给普通进程换一套文件、隔离系统视图,再限制资源。它仍由同一个 Linux 内核执行。

约 15 分钟 · 截至 2026-11-03,Docker Engine 29.x · 实验未实测 · unshare + chroot · Linux / cgroup v2

隔离进程仍然调用宿主内核 namespace 隔离进程视图,chroot 改变文件根目录,cgroup 控制资源。进程通过系统调用使用共享的 Linux 内核。 namespace:单独的系统视图 /bin/sh 进程 chroot → 新的 / cgroup → 内存限额 rootfs 提供文件 系统调用 共享的 Linux 内核
图 1 隔离视图、文件根目录和资源限额是三件不同的事。
学完能做什么

01 / 准备文件

先备 rootfs

rootfs(根文件系统)是一棵目录树。里面要有 shell、工具和依赖库。仅创建一个空目录,无法运行 /bin/sh。

如果需要复习,已有页面解释了容器为何共享内核、镜像与容器的关系,以及docker run 的启动流程。

实验环境

需要 root 的步骤:在虚拟机里做。用可丢弃的 Linux 虚拟机,开两个终端 A、B,连接同一台虚拟机。以下“宿主”都指这台虚拟机。

需要本机 Docker Engine(普通 rootful 模式)、Bash、util-linux 的 unshare、chroot、tar 和 sudo 权限。宿主使用 cgroup v2,根层已向子组启用 memory 控制器。不要在 Docker 容器里套做,也不要把 macOS / Windows 终端当作 Linux 宿主。

操作会解包 rootfs、创建 namespace、挂载 procfs,并在宿主 cgroup 树下新建实验组。此实验不修改宿主网桥或 iptables;新网络里没有外网连接。这里只运行自己的 shell,不运行不可信代码。

未实测

本页编写环境没有 Docker,也没有专用实验虚拟机。以下所有 Docker 与特权实验均未实测,等待维护者补实测记录。命令后的“预期”是按文档推导的核对项,不是终端实录;不提供虚构的 PID、镜像大小或输出表格。

终端 A · 先用 Docker 取得文件

这一步只借 Docker 导出文件,后面的进程由 Linux 工具启动。alpine:latest 是可变标签;本次先拉取,再把本地镜像 ID 保存到 IMAGE,让导出与对照实验使用同一份镜像。镜像需匹配虚拟机 CPU 架构。

LAB=$(mktemp -d /tmp/scratch-container.XXXXXX)
printf '%s\n' "$LAB"  # 记下此路径,终端 B 会用到
mkdir "$LAB/rootfs"
docker pull alpine:latest
IMAGE=$(docker image inspect --format '{{.Id}}' alpine:latest)
printf '%s\n' "$IMAGE" > "$LAB/image.id"
CID=$(docker create "$IMAGE" /bin/sh)
docker export --output="$LAB/rootfs.tar" "$CID"
docker rm "$CID"

预期(未实测):rootfs.tar 是容器文件系统的 tar 归档。docker create 没有启动 shell;docker rm 只移除这个用于导出的容器。镜像与归档仍保留。任一命令失败,先解决错误再继续。

root · 在虚拟机里做 在终端 A 解包可信镜像导出的归档,保留其文件属主:

sudo tar --numeric-owner -xpf "$LAB/rootfs.tar" -C "$LAB/rootfs"
sudo mkdir -p "$LAB/rootfs/proc"
sudo test -x "$LAB/rootfs/bin/sh"

预期(未实测):最后一条检查成功时不打印内容。它只确认 shell 路径可执行,不替代后续启动检查。export 不包含卷里的数据,也不保存镜像的层历史;不要用 docker save 的归档直接充当 rootfs。[1]

宿主中的目录

$LAB/rootfs/ ├─ bin/sh ├─ lib/… └─ proc/(空目录)

chroot 后的路径

改变绝对路径的查找起点 →

/ ├─ bin/sh ├─ lib/… └─ proc/(还未挂载)
图 2 chroot 把同一棵目录树作为新的 /,不会复制文件或安装内核。

只把宿主的 /bin/sh 复制进空目录就 chroot,为什么可能仍然报“找不到文件”?

shell 可能是动态链接程序,还需要加载器和共享库;路径存在不代表依赖齐全。完整 rootfs 提供这一组文件。CPU 架构不匹配也会导致启动失败,不能靠复制更多目录解决。

Docker 对照:Engine 根据镜像准备容器的根文件系统及可写层;export 得到的是文件树,不会保留这套分层。[2]

02 / 创建视图

unshare 建边界

namespace(命名空间)决定进程看到哪一组系统对象。unshare 创建指定的 namespace,再运行程序。这里分出五种视图;它不会自动准备 rootfs 或限内存。[3]

参数本次隔离什么Docker 对照
--mount挂载点的视图;再用 --propagation private 阻止挂载事件向外传播准备容器自己的挂载布局
--uts主机名(UTS)设置容器 hostname
--ipcSystem V 进程间通信、POSIX 消息队列安排容器 IPC 视图及相关挂载
--net网卡、路由、端口等网络对象按网络模式接线;本实验不接网桥
--pid --fork子进程的 PID(进程编号)视图;第一个子进程为 PID 1启动容器主进程作为内部 PID 1
  1. ① rootfs 就绪得到 /bin/sh 和依赖文件
    Docker:准备镜像文件和可写层
  2. ② unshare为子进程分出五种视图
    Docker:运行时创建隔离环境
  3. ③ chroot将 / 指向 rootfs,执行 shell
    Docker:运行时设置根文件系统
  4. ④ mount /proc让工具读取内部进程视图
    Docker:启动前安排必要挂载
  5. ⑤ cgroup把 shell 纳入内存限额
    Docker:按资源参数配置控制组
  6. ⑥ exit 与清理结束进程,移除空控制组
    Docker:管理退出和资源清理
  1. 文件已经齐了,尚未有隔离进程。猜:下一步会复制一份 Linux 内核吗?
  2. unshare 只创建视图,仍使用宿主内核。猜:它会把 / 自动改成 rootfs 吗?
  3. chroot 改变路径查找起点,shell 已运行。猜:此时空的 /proc 能列出进程吗?
  4. 挂载新的 procfs,ps 才能读取这个 PID namespace 的进程。
  5. 将宿主视角下的 shell PID 写入 cgroup.procs,后续子进程继承所属组。
  6. 退出 PID 1 后,该 PID namespace 中的进程被终止;清理空组与文件。
图 3 每一步增加一项能力;教学顺序不等同于 Docker 运行时的精确调用顺序。

root · 在虚拟机里做 终端 A 运行下面这段完整命令。它会停在内部 shell,先别退出。宿主的 Bash 负责记录一个 PID,然后用 exec 替换为 chroot 和内部 shell,不额外留下中间 shell。

sudo unshare --mount --uts --ipc --net --pid --fork \
  --propagation private /bin/bash -c '
    set -eu
    read -r outer_pid rest < /proc/self/stat
    printf "%s\n" "$outer_pid" > "$1/host.pid"
    exec chroot "$1/rootfs" /bin/sh
  ' _ "$LAB"

预期(未实测):进入 rootfs 的 shell;此时 /proc 仍只是空目录。host.pid 记录 shell 在宿主 PID 视图中的编号,终端 B 限内存时会用到它;不要将它猜成固定值。

为什么这样记录 PID?为什么没有 --user?

新 PID namespace 只影响子进程,所以需要 --fork。子进程在里面是 PID 1,在宿主有另一个编号。chroot 之前,继承的宿主 /proc 尚未被重挂;Bash 内置 read 读取 /proc/self/stat,第一字段就是该宿主 /proc 视图中的 PID。exec 保留这个进程身份。[4]

本实验没有创建 user namespace(用户与组 ID 的映射视图),也没有降低权限。内部 root 仍具有宿主上的高权限。--cgroup 只隔离 cgroup 路径的视图,并不设置内存限额;本实验也不需要它。--time 不在本页范围内。

为什么 --mount 和 --propagation private 要一起考虑?用一句话解释它们各负责什么。

mount namespace 提供独立的挂载视图;private 传播设置阻止挂载事件沿共享挂载关系传播到外部。两者一起,才适合在实验中挂载自己的 /proc。

  • 提到“挂载视图”
  • 提到“阻止挂载事件传播”

03 / 让工具看见进程

挂好 /proc

chroot 改的是 /,procfs 提供的是进程信息。procfs 是内核生成的虚拟文件系统,通常挂在 /proc。新挂载的 procfs 对应挂载者所在的 PID namespace。[5]

root · 在虚拟机里做 在终端 A 的内部 shell执行:

hostname scratch-lab
printf 'shell pid: %s\n' "$$"
mount -t proc proc /proc
hostname
ps
ip link
ip route

预期(未实测):主机名变为你设置的 scratch-lab,shell 的 PID 应为 1。ps 应只列内部 shell、ps 等内部进程,不出现宿主服务。网络应只有回环接口 lo、没有默认路由;它尚未接线,不能访问外网。不要据此排查 DNS。

只有 chroot

/ → rootfs

/proc 是空目录 → ps 无进程信息可读

挂载新的 procfs

/proc → 当前 PID 视图

内核生成内部进程信息 → ps 可读取

图 4 PID 隔离决定进程编号;正确的 /proc 挂载让 ps 展示这个视图。

切到终端 B,在宿主运行 hostname 和 ps -e。预期宿主主机名不变,进程列表仍含宿主服务。宿主能看到内部进程;内部看不到外层进程。这不是双向隐藏。

Docker 对照:运行时设置根文件系统、procfs 等挂载,再执行应用。默认 bridge 网络通常还要连接虚拟网卡与网桥;本实验的空网络更接近 --network none。[6]

仅执行 chroot rootfs /bin/sh,随后把宿主 /proc 绑定进来,能保证 ps 只看到内部进程吗?

chroot 改变路径解析,不创建 PID namespace。绑定宿主 /proc 仍沿用外部视图。

cgroup 管资源用量,不负责 ps 读取的进程视图。

先通过 PID namespace 隔离进程编号,再在其中挂载新的 procfs,ps 才能读取内部进程信息。单独改 / 或绑定宿主 /proc 都不能替代这两个动作。

04 / 限制资源

cgroup 限内存

cgroup(控制组)把一组进程纳入共同的资源约束。本实验使用 cgroup v2 的 memory.max,设为 64 MiB(67,108,864 字节),并用 memory.swap.max 禁止交换空间。它们不会改变 namespace。[7]

032 MiB64 MiB 上限

刻度示意,非实时占用。组内 shell 和后续子进程共享总预算。

图 5 限额施加给组内进程合计用量,不是每个进程各有一份。

只建好新 cgroup、写入 memory.max,却没将 shell 加入组。shell 会受到这个新限额吗?

不会。文件配置了这个组,但 shell 仍在原组。将它的宿主 PID 写入新组的 cgroup.procs 后,它及之后创建的子进程才属于新组。已经创建的子进程不会自动跟着迁移;所以本实验不在这之前启动后台任务。

root · 在虚拟机里做 终端 A 保持 shell 空闲。终端 B 设置 LAB 为 A 第一段打印的实际路径,例如用 LAB='你复制的路径',再运行整段:

sudo bash -s -- "$LAB" <<'HOST'
set -eu
lab_dir=$1
test -f /sys/fs/cgroup/cgroup.controllers
if ! grep -qw memory /sys/fs/cgroup/cgroup.subtree_control; then
  printf '%s\n' '根层未向子组启用 memory;停止本步骤。' >&2
  exit 1
fi
host_pid=$(cat "$lab_dir/host.pid")
# 先核对 PID 仍指向本次实验,避免用错或复用的 PID。
test "$(readlink "/proc/$host_pid/root")" = "$lab_dir/rootfs"
cg_dir="/sys/fs/cgroup/scratch-container-$host_pid"
mkdir "$cg_dir"
printf '%s\n' "$cg_dir" > "$lab_dir/cgroup.path"
# 缺少控制文件会停止;不要在失败后继续加入进程。
test -f "$cg_dir/memory.max"
test -f "$cg_dir/memory.swap.max"
printf '67108864\n' > "$cg_dir/memory.max"
printf '0\n' > "$cg_dir/memory.swap.max"
printf '%s\n' "$host_pid" > "$cg_dir/cgroup.procs"
cat "$cg_dir/memory.max" "$cg_dir/memory.swap.max"
cat "$cg_dir/cgroup.procs"
cat "/proc/$host_pid/cgroup"
HOST

预期(未实测):前两个读回值分别等于写入的字节上限和交换空间上限;成员列表包含 host.pid 的编号,进程的 cgroup 路径指向本次实验组。只有读回配置且成员归属正确,才继续。若控制器不可用,先停在此处,换满足条件的虚拟机;不要盲目修改 systemd 管理的父组。

这验证了配置与成员关系,没有实测触发 OOM。OOM(内存不足)时,内核先尝试回收;无法满足限额内的分配时可能杀死组内进程或使分配失败。不保证某条命令一定输出“被杀死”。memory.max 也不是预先分配一块物理内存。[7]

终端 B · 用 Docker 对照同一限额

这条独立实验在宿主运行,不需要 --privileged。读取两个 cgroup v2 文件;使用的仍是刚才保存的镜像 ID:

IMAGE=$(cat "$LAB/image.id")
docker run --rm --network none --memory 64m --memory-swap 64m \
  "$IMAGE" sh -c 'cat /sys/fs/cgroup/memory.max; cat /sys/fs/cgroup/memory.swap.max'

预期(未实测):在上述 cgroup v2 环境中,两个读回值应与手动设置相同。若宿主或 Docker 使用 cgroup v1,路径不同,此命令不适用。

Docker 对照:--memory 设置内存限额;--memory-swap 表示内存与 swap 的总额度。两者相等才表示不允许 swap;--memory-swap 0 会被视为未设置。Docker 默认不替你设定内存上限。[8]

保持 --memory 64m,又要禁止 swap,补上参数:

--memory-swap

内存加 swap 的总额等于内存限额,留给 swap 的额度为零。因此填 64m;此处的 0 不等于禁止 swap。

为什么不在内部用 free 验证?

资源统计工具可能读取宿主视角的信息,不能把显示的总内存直接当成当前 cgroup 的额度。应结合宿主的控制文件、成员归属和 memory.events 事件检查。进程加入新组之前已经记账的内存也不会全部随进程迁移;真实运行时会在应用开始工作之前安排资源约束。

05 / 收尾与边界

退出并清理

终端 A 输入 exit,先结束内部 shell。PID 1 退出时,内核会终止该 PID namespace 中余下的进程。没有其他引用时,新 namespace 及其中的 proc 挂载随之释放,不需要在宿主上卸载 /proc。[5]

exit

root · 在虚拟机里做 终端 B 只移除本次记录的空控制组。即使前一步设置失败、只创建了空组,这段也可清理:

sudo bash -s -- "$LAB" <<'HOST'
set -eu
lab_dir=$1
if test -f "$lab_dir/cgroup.path"; then
  cg_dir=$(cat "$lab_dir/cgroup.path")
  host_pid=$(cat "$lab_dir/host.pid")
  test "$cg_dir" = "/sys/fs/cgroup/scratch-container-$host_pid"
  test -z "$(cat "$cg_dir/cgroup.procs")"
  rmdir "$cg_dir"
  rm -- "$lab_dir/cgroup.path"
fi
HOST

预期(未实测):空组被移除;若仍有成员或子组,命令停止,先检查终端 A 是否已退出,不强删或迁移不明进程。文件目录 $LAB、归档和镜像仍保留供核对;确认退出且没有残留挂载后,可从虚拟机文件管理器以管理员权限删除这个明确路径,或直接丢弃实验虚拟机。

现象先核对
unshare 提示权限不足是否在有 sudo 权限的专用 Linux 虚拟机内?外层容器或安全策略可能禁止创建 namespace,改用合适的虚拟机。
chroot 无法执行 /bin/shrootfs 路径、镜像 CPU 架构、加载器和依赖是否齐全?不要从宿主随意补库。
ps 没有信息或看到宿主服务是否进入了新 PID namespace?是否在其中新挂载 procfs,而非绑定宿主 /proc?
网络不通本实验的新网络未连接虚拟网卡、网桥和路由。这是预期边界。
没有 memory.max / 写入被拒绝核对 cgroup v2、父组 memory 控制器和写权限;不要把换文件名当成 v1/v2 的完整迁移。
教学边界

这只是高权限的隔离实验。它没有配置 capabilities(拆分后的内核特权)、seccomp(系统调用过滤),也没有完整安排设备、只读挂载和访问控制策略。chroot 本身不是安全沙箱。[9]

Docker 的运行时还要处理这些安全配置、信号与进程生命周期。默认 seccomp 策略会限制允许的系统调用;不要把这里的 shell 当作等价的生产容器。[10]

把动作按它直接改变的东西分类:

  • chroot:改变 / 的查找起点
  • --uts:独立设置 hostname
  • --net:独立网卡与路由表
  • memory.max:给组设置内存上限
文件根目录
系统视图
资源约束

rootfs 提供文件,chroot 改根;namespace 隔离对象的视图;cgroup 控制一组进程的资源。三种机制互补,不能互相代替。

06 / 检索练习

记住三条线

rootfs、PID namespace、/proc 都准备好了,程序仍可能用掉很多宿主内存。缺少哪一步?

再次改变根目录不会产生资源额度。

hostname 属于 UTS 视图,不决定内存分配。

看到不同的 / 和 PID 不代表资源已经受限。要配置 cgroup 的内存控制器,并核对进程归属。Docker 的 --memory 参数替你表达这个约束;不设参数时默认没有这项上限。

来源

核对日期:2026-11-03。Docker 行为以官方文档为准;Linux 机制与控制文件以 man 页及内核文档为准。实验等待虚拟机实测。

  1. Docker:docker container export — 文件归档、卷不随 export 导出。
  2. Docker:docker container create — 创建可写层、创建后尚未启动。
  3. util-linux:unshare(1) — namespace、fork 与挂载传播参数。
  4. Linux:proc_pid_stat(5) — stat 第一字段是 PID。
  5. Linux:pid_namespaces(7) — PID 1、procfs 视图和退出行为。
  6. Docker:docker container run — hostname、网络模式和启动参数。
  7. Linux 内核:Control Group v2 — cgroup.procs、memory.max、memory.swap.max、进程继承与内存记账。
  8. Docker:Resource constraints — 默认无资源约束、--memory 与 --memory-swap 的关系。
  9. Linux:chroot(2) — 路径根的含义及安全边界。
  10. Docker:Seccomp security profiles — 默认系统调用过滤策略。