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]
宿主中的目录
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 |
--ipc | System V 进程间通信、POSIX 消息队列 | 安排容器 IPC 视图及相关挂载 |
--net | 网卡、路由、端口等网络对象 | 按网络模式接线;本实验不接网桥 |
--pid --fork | 子进程的 PID(进程编号)视图;第一个子进程为 PID 1 | 启动容器主进程作为内部 PID 1 |
- ① rootfs 就绪得到 /bin/sh 和依赖文件Docker:准备镜像文件和可写层
- ② unshare为子进程分出五种视图Docker:运行时创建隔离环境
- ③ chroot将 / 指向 rootfs,执行 shellDocker:运行时设置根文件系统
- ④ mount /proc让工具读取内部进程视图Docker:启动前安排必要挂载
- ⑤ cgroup把 shell 纳入内存限额Docker:按资源参数配置控制组
- ⑥ exit 与清理结束进程,移除空控制组Docker:管理退出和资源清理
- 文件已经齐了,尚未有隔离进程。猜:下一步会复制一份 Linux 内核吗?
- unshare 只创建视图,仍使用宿主内核。猜:它会把 / 自动改成 rootfs 吗?
- chroot 改变路径查找起点,shell 已运行。猜:此时空的 /proc 能列出进程吗?
- 挂载新的 procfs,ps 才能读取这个 PID namespace 的进程。
- 将宿主视角下的 shell PID 写入 cgroup.procs,后续子进程继承所属组。
- 退出 PID 1 后,该 PID namespace 中的进程被终止;清理空组与文件。
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
/proc 是空目录 → ps 无进程信息可读
挂载新的 procfs
内核生成内部进程信息 → 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]
刻度示意,非实时占用。组内 shell 和后续子进程共享总预算。
只建好新 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/sh | rootfs 路径、镜像 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 有程序及依赖,chroot 选择新的路径根。
- 视图线:unshare 创建 namespace;新的 procfs 配合 PID 视图供 ps 读取。
- 资源线:cgroup 先写限额,再核对成员;网络隔离和内存限制各自独立。
- 三条线都由共享内核实现;实验 root 没有变成低权限用户。
rootfs、PID namespace、/proc 都准备好了,程序仍可能用掉很多宿主内存。缺少哪一步?
再次改变根目录不会产生资源额度。
hostname 属于 UTS 视图,不决定内存分配。
看到不同的 / 和 PID 不代表资源已经受限。要配置 cgroup 的内存控制器,并核对进程归属。Docker 的 --memory 参数替你表达这个约束;不设参数时默认没有这项上限。
来源
- Docker:docker container export — 文件归档、卷不随 export 导出。
- Docker:docker container create — 创建可写层、创建后尚未启动。
- util-linux:unshare(1) — namespace、fork 与挂载传播参数。
- Linux:proc_pid_stat(5) — stat 第一字段是 PID。
- Linux:pid_namespaces(7) — PID 1、procfs 视图和退出行为。
- Docker:docker container run — hostname、网络模式和启动参数。
- Linux 内核:Control Group v2 — cgroup.procs、memory.max、memory.swap.max、进程继承与内存记账。
- Docker:Resource constraints — 默认无资源约束、--memory 与 --memory-swap 的关系。
- Linux:chroot(2) — 路径根的含义及安全边界。
- Docker:Seccomp security profiles — 默认系统调用过滤策略。