容器运行时深潜:我的迷你容器引擎进化史(runc → containerd)(1)
目录
引子
docker run -d nginx 到底替我们做了什么?
敲下这条命令,几秒钟以后一个 Nginx 容器就起来了。镜像自动拉取、rootfs 自动准备、网络自动配置、进程自动启动,整个过程顺滑得几乎让人意识不到后面还有一大堆东西。
但也正因为太顺滑了,很多问题反而被我们跳过去了。
容器的隔离到底是谁做的?Namespace、Cgroups 到底在什么时候被创建?容器里的 PID 1 是怎么来的?rootfs 又是怎么从一个镜像变成进程眼前那个 / 的?网络是谁接上的?Docker、containerd、runc,到底谁负责创建容器,谁负责管理容器,谁又只是负责把一个进程启动起来?
这些问题,平时写业务根本不需要知道。docker run 把所有细节都替你藏好了。
于是我开始意识到一个有点尴尬的问题:
我用了这么多年容器,却可能根本没真正理解“容器”是什么。
于是这一次,我决定反过来。
不要 Docker,也不要先看各种高层封装。先从最底层的 runc 开始,自己把一个容器一点一点拼出来。
先让一个普通 Linux 进程拥有独立的 Namespace,再给它挂上 rootfs;再研究 Cgroups 怎么限制资源;发现容器有了文件系统,却连网络都没有,于是继续把 CNI 接进来。等这些东西终于能工作以后,再回头看看 containerd 究竟解决了什么问题,以及 Docker 为什么最终没有直接和 runc 打交道。
整个过程其实没什么“造轮子”的意义。
我并不打算重新实现一个 Docker,也不打算做一个可以投入生产的容器运行时。我要做的,只是把 Docker 帮我们隐藏掉的那些东西,一层一层重新暴露出来。
我想知道,一个容器如果剥掉 Docker、containerd 这些层层叠叠的抽象,最后究竟还剩下什么。
所以这篇文章更像一次反向拆解。
从 docker run 一路往下拆,拆到 runc,再拆到 Namespace、Cgroups、rootfs 和 Linux 内核;然后再反过来,一层层把这些能力重新组合起来。
不是为了学会“怎么用容器”,而是想看看,容器究竟是怎么被造出来的。
先把这群“兄弟”认清楚
一张图:
Docker CLI
│
▼
Docker Engine
│
┌────────────┴────────────┐
│ │
▼ ▼
containerd Docker 其他能力
│
│ 管理容器生命周期
▼
runc
│
│ 创建/启动一个容器进程
▼
Linux Kernel
┌────┼────┐
│ │ │
namespace cgroup mount
网络则是另一条线:
Docker / containerd
│
▼
CNI / libnetwork
│
▼
Linux network namespace
│
▼
veth / bridge / route / iptables
先把一句话记住:
Docker 是平台,containerd 是容器生命周期管理层,runc 是低层容器运行时;CNI 是网络插件规范,而 Docker 自己历史上走的是 libnetwork 这套网络栈。
这几个东西根本不是同一层。
最开始的 Docker:其实就是一个“大杂烩”
2013 年 Docker 刚出来的时候,它更像一个完整的容器平台,不是今天我们看到的这种组件化架构。
Docker 早期自己处理:
镜像
容器
生命周期
存储
网络
runtime
连低层容器能力都曾经直接放在 Docker 项目里。
最开始依赖的是 LXC,后来 Docker 自己做了 libcontainer。
Docker 官方在介绍 runC 时直接提到,当时 Docker 自己使用了大量 Linux、LXC、AUFS、LVM、iptables、VXLAN、systemd 等“基础设施 plumbing”;而 libcontainer 就是他们后来自己做出来的一部分。(Docker)
所以早期可以粗暴理解为:
Docker
├── image
├── storage
├── network
├── lifecycle
├── libcontainer
└── 一大坨东西
这时候 Docker 非常爽:
docker run nginx
一个命令全包。
但 Docker 自己很快意识到:
“我为什么要把底层容器 runtime 和上层 Docker 平台绑死?”
于是开始拆。
libcontainer → runC:Docker 第一次“拆家”
libcontainer 的作用其实很朴素:
调用 Linux 的能力,把一个普通进程变成一个容器进程。
它下面碰的是:
namespace
cgroup
mount
rootfs
capability
seccomp
...
也就是:
libcontainer
↓
Linux kernel
2015 年 Docker 把整个 libcontainer 项目,包括 nsinit 等代码,捐给了新成立的 OCI。这个脱离 Docker 后的代码库就成为 runc。(开放容器倡议)
一个很有意思的“改名史”:
Docker container engine
↓
libcontainer
↓
独立 runtime
↓
runC
↓
OCI 参考实现
注意:
runC 不是某种“新的容器技术”,它主要是把 OCI Runtime Specification 翻译成实际的 Linux 容器操作。
OCI 后来明确把 Runtime Specification 标准化,描述一个容器应该如何创建、rootfs 在哪里、要执行什么命令、环境变量是什么等等。(开放容器倡议)
至于为什么 Docker 要“捐掉”自己的核心代码?
你可以把当年的局面想象成:
Docker:
“容器不能只有我这一家实现。”
CoreOS:
“我们有自己的 App Container 方案。”
Google:
“我们也在生产环境大量使用容器。”
Red Hat:
“我不想被 Docker API 绑死。”
Microsoft / IBM / AWS / VMware / ...
“最好有个统一标准。”
于是大家共同推动 OCI。
2015 年成立 OCI 的核心目标就是:
让容器格式和运行时变成行业标准,而不是 Docker 私有标准。(开放容器倡议)
Docker 当时实际上是在用自己的现有技术换一个更大的生态:
Docker 私有 runtime
↓
捐
↓
OCI 标准
↓
Docker / Podman / CRI-O / 其他 runtime 都能用
Docker 自己后来也解释过:当 Docker 的容器格式和 runtime 已经快速成为事实标准时,他们认为交给中立治理组织有利于避免生态碎片化,同时推动标准化和创新。(Docker)
然后 containerd 又从哪里冒出来了?
这一步特别关键。
Docker 把 runtime 拆成了 runc,问题来了:
runc 是不是已经够了?
答案是:远远不够。
因为 runc 的职责非常低:
给我一个 OCI bundle,我把容器进程启动起来。
可现实世界里 Docker 还要管理:
几百个容器
容器启动/停止
容器状态
stdio
checkpoint
重启
镜像
snapshot
生命周期
事件
你不可能让 Docker Engine 每次都直接手搓 runc。
所以 Docker 又拆出一层:
Docker Engine
↓
containerd
↓
runc
Docker 当时自己解释得非常清楚:containerd 是为了把容器 supervision / lifecycle management从 Docker Engine 中拆出来,并给 Docker 和 runc 之间提供一个干净的集成点。(Docker)
所以:
runc关心:
“这个容器怎么被创建出来?”
containerd关心:
“我现在有 500 个容器,我怎么管理它们?”
这就非常容易理解了。
所以 containerd 不是“更高级版 runc”,这是很多初学者最容易混淆的。
runc:
“启动这个东西。”
containerd:
“管理这些东西。”
Docker:
“给用户一个很好用的平台。”
换成工程语言:
Docker
→ API / CLI / Image / Network / Volume / UX
containerd
→ lifecycle / task / image / snapshot / event
runc
→ OCI runtime / process / namespace / cgroup
Docker 官方后来把 containerd 定位为更偏“boring infrastructure”的核心 runtime component:让更上层的平台可以建立在一个稳定、可替换的容器基础设施上。(Docker)
Docker 现在其实自己也越来越不像“容器运行时”。
现代 Docker 大概:
docker CLI
↓
Docker Engine
↓
containerd
↓
runc
↓
Linux Kernel
所以今天你敲:
docker run nginx
其实是一个非常长的调用链。
这个时候再加入 CNI,你就会发现它其实不是 runc 的兄弟。
CNI:
Container Network Interface
它是一个规范 + 插件机制。
核心思想:
容器 runtime 创建好了 network namespace 以后,谁负责把网络接进去?
例如:
container network namespace
│
├── eth0
├── IP
├── route
└── DNS
这些怎么来的?CNI 插件。
比如:
bridge
loopback
host-local
ipvlan
macvlan
以及 Kubernetes 生态里的:
Calico
Cilium
Flannel
...
它们都围绕 CNI 工作。
所以:
runc
→ “我创建 namespace”
CNI
→ “我把网络接进去”
这个分工特别漂亮。
那 Docker 自己为什么有 libnetwork?
这里又是另一个历史分叉。Docker 自己并没有一开始就采用 CNI 来解决自己的网络。
Docker 有:
libnetwork
它是 Docker 自己的网络栈 / 网络抽象,负责:
network
endpoint
sandbox
driver
所以历史上:
Docker
↓
libnetwork
而 Kubernetes 这边更偏:
Container Runtime
↓
CNI
↓
网络插件
于是出现:
Linux Network Namespace
↑
┌───────────┴───────────┐
│ │
Docker K8s
│ │
libnetwork CNI
这也是为什么你以后看到:
Docker network
CNI
Calico
Cilium
不要把它们当成同一个东西。
一个特别容易混淆的:go-runc
go-runc: 不是 runc 的 Go 版本。
它实际上是:
一个从 Go 程序里调用
runcCLI 的 binding / wrapper。
官方仓库自己写得非常直白:
“a package for consuming the runc binary in your Go applications”。(GitHub)
所以:
你的 Go 程序
↓
go-runc
↓
exec runc
↓
runc binary
↓
Linux kernel
不是:
Go
↓
go-runc
↓
直接进入 namespace
这区别很重要。而且 go-runc 本身是 containerd 的子项目。(GitHub)
containerd 自己都不一定直接“链接 runc”,它还可以通过 go-runc 这种包去调用 runc。
最后把这些东西放在一张图

Docker CLI
│
▼
Docker Engine
│
┌────────────────┴────────────────┐
│ │
▼ ▼
containerd libnetwork
│ │
│ ├── bridge
│ ├── veth
│ ├── iptables
│ └── ...
│
▼
runc
│
├── Namespace
├── cgroup
├── mount
├── rootfs
├── capability
└── seccomp
│
▼
Linux Kernel
另一边 Kubernetes:
Kubernetes
│
▼
CRI
│
▼
containerd / CRI-O
│
▼
runc
│
▼
Linux Kernel
网络:
container runtime
│
▼
CNI
│
├── Calico
├── Cilium
├── Flannel
└── ...
Docker、containerd、runc、CNI、libnetwork,看起来像是五个互相竞争的技术,实际上它们解决的是五个完全不同的问题。
甚至可以把它们人格化:
Docker:
“用户只管输入 docker run。”
containerd:
“容器的生老病死我负责。”
runc:
“给我 OCI Spec,我负责把进程造出来。”
CNI:
“你们把 namespace 建好了?网络我来接。”
libnetwork:
“这是 Docker 自己的一套网络抽象。”
Linux Kernel:
“你们说的这些隔离能力,最后还不是我提供?”
最有意思的是,Docker 当年亲手把 libcontainer → runc 捐进了 OCI,后来又把 containerd 从 Docker Engine 中拆出来,整个行业就逐渐从“Docker 是容器”变成了“Docker 只是容器生态的一层产品”。
Docker 1.11 就已经是基于 containerd + runc 的 Docker Engine;到 2016 年,Docker 又正式宣布把核心 runtime functionality 继续抽成独立的 containerd。(Docker)
抛弃 Docker,从裸 runc 开始运行第一个容器
上一章我们已经把 Docker、containerd、runc、CNI 这些名字大致理清楚了。
简单来说:
Docker
↓
containerd
↓
runc
↓
Linux Kernel
Docker 负责的是一整套容器产品和上层体验,containerd 负责容器生命周期等管理工作,而 runc 则已经非常靠近 Linux 内核:它真正负责按照 OCI 规范创建和启动容器进程。
于是问题来了。
平时我们输入:
docker run -d nginx
Docker 帮我们把一大堆事情都做完了。
那么现在把 Docker 和 containerd 全部拿掉,只剩下一个裸 runc:
runc run ...
容器还能不能跑起来?
答案当然是能。
但你马上会发现,runc 和 Docker 的使用体验根本不是一个世界。
Docker 的目标是:
“你告诉我想运行什么,我帮你把一切准备好。”
而 runc 的态度更像:
“你自己把东西准备好,然后告诉我怎么运行。”
这也是理解容器运行时非常关键的一步。
在认识 runc 之前:先用 unshare 创建一个容器
在正式研究 runc 之前,我们不妨先退一步。
如果把 Docker、containerd、runc 这些组件全部拿掉,仅仅依靠 Linux 本身提供的能力,我们能不能创建一个“容器”?
答案是可以。
而且这个过程并没有想象中的那么复杂。Linux 内核本身并没有所谓的“容器”这个概念,它提供的是一系列用于隔离和限制进程的底层机制,例如 Namespace、Cgroup、Mount、Capability 和 Seccomp 等。
所谓的“容器”,本质上就是利用这些 Linux 内核能力,将一个普通的进程放进一个相对独立的运行环境中。
在 Linux 中,有一个非常适合我们进行实验的命令:unshare。
unshare 是 Linux 提供的一个命令行工具,用于让进程脱离当前正在共享的 Namespace,并创建或进入新的 Namespace。
它对应 Linux 内核提供的 unshare() 系统调用。
可以简单理解为:
unshare 命令
│
▼
unshare() 系统调用
│
▼
Linux Kernel
│
▼
创建 / 隔离 Namespace
这里的“unshare”可以直译成“取消共享”。
Linux 中的多个进程通常共享同一个 Namespace。例如,宿主机上的所有普通进程通常都处于宿主机自己的 PID Namespace 中,因此它们可以看到相同的进程列表。
如果我们创建一个新的 PID Namespace,那么进入这个 Namespace 的进程就会拥有一套独立的进程视图。
例如宿主机可能看到:
PID
1
500
1000
1234
...
而新的 PID Namespace 中可能看到:
PID
1
2
3
...
这两个 Namespace 中的 PID 是彼此独立的。
因此,我们可以通过 unshare 直观地观察 Linux Namespace 到底是怎么工作的。
要创建一个新的 PID Namespace,首先执行:
sudo unshare --pid --fork --mount-proc /bin/bash
这里几个参数非常重要。
--pid:
创建新的 PID Namespace
--fork:
让新的 Bash 在子进程中运行
--mount-proc:
在新的 Mount Namespace 中重新挂载 /proc
最后的:
/bin/bash
表示进入新的 Namespace 后执行 Bash。
进入之后,可以执行:
echo $$
再执行:
ps aux
你会发现,这个 Bash 在自己的 PID Namespace 中可能成为:
PID 1
而宿主机上的进程列表并不会因此发生变化。
这意味着,同一个 Linux 系统中,现在已经存在了两套不同的进程视图:
宿主机 PID Namespace
PID 1
├── systemd
├── sshd
├── ...
└── bash
新的 PID Namespace
PID 1
└── bash
这就是 Namespace 隔离最直观的体现。
这里需要注意一个容易产生误解的地方。
创建 PID Namespace 并不是把一个进程“复制”了一份,而是让进程在不同的 Namespace 中拥有不同的 PID 视图。
例如:
宿主机:
PID 12345
│
└── bash
容器内部:
PID 1
│
└── bash
宿主机看到这个 Bash 是 12345,而它自己看到的却可能是 1。
这也是为什么容器中的第一个进程通常被称为:
PID 1
它并不是宿主机真正的 PID 1,而是容器所在 PID Namespace 中的 PID 1。
PID Namespace 只是 Linux Namespace 中的一种。
Linux 目前提供了多种 Namespace,用于隔离不同类型的系统资源:
| Namespace | 作用 |
|---|---|
| PID | 隔离进程 ID |
| Mount | 隔离挂载点和文件系统视图 |
| UTS | 隔离 hostname 和 domain name |
| IPC | 隔离进程间通信资源 |
| Network | 隔离网络设备、路由、端口等 |
| User | 隔离用户和用户组 ID |
| Cgroup | 隔离 Cgroup 视图 |
因此,一个真正的容器通常不会只创建 PID Namespace,而是会根据需要组合多个 Namespace。
例如:
Container
│
┌─────────┼─────────┐
│ │ │
PID Mount Network
│ │ │
进程隔离 文件系统隔离 网络隔离
这也是我们理解容器时非常重要的一点:
容器并不是 Linux 内核中的一种特殊进程,而是一组 Linux 隔离机制的组合。
到这里,我们甚至还没有安装 Docker,也没有使用 containerd,更没有使用 runc。
仅仅通过 Linux 自带的 unshare 命令,我们就已经可以创建一个具有进程隔离能力的运行环境。
当然,这还不能算一个完整的容器。
因为一个真正的容器还需要解决很多问题。
例如:
Namespace
↓
隔离进程、网络、挂载点等
Mount
↓
构造独立的文件系统视图
Cgroup
↓
限制 CPU、内存等资源
Capability
↓
限制进程拥有的内核权限
Seccomp
↓
限制进程可以使用的系统调用
rootfs
↓
提供容器自己的根文件系统
因此,我们现在得到的只是一个非常简陋的“容器”。
但这恰恰是我们想要的结果。
因为我们终于可以把 Docker 那一层层复杂的封装全部拿掉,直接看到容器最底层的本质:
普通 Linux 进程
│
▼
Namespace
│
▼
Mount / rootfs
│
▼
Cgroup
│
▼
Capability / Seccomp
│
▼
execve()
│
▼
容器进程
既然 Linux 自己已经提供了这些能力,我们甚至可以通过 unshare 手工创建 Namespace,那么为什么还需要 runc?
答案就在这里。
一个真正的容器,需要同时配置大量 Linux 内核能力,而且还需要定义一套标准的配置格式。
例如:
创建哪些 Namespace?
使用什么 rootfs?
容器启动什么程序?
使用哪个用户?
有哪些 Capability?
是否启用 Seccomp?
使用什么 Cgroup?
需要挂载哪些目录?
是否执行 Hook?
如果每一个容器工具都自己定义一套配置和实现方式,那么 Docker、Podman、containerd 等不同容器生态之间很难形成统一标准。
因此,OCI 定义了 Container Runtime Specification。
而 runc 就是一个 OCI Runtime 的实现。
换句话说:
Linux Kernel
↑
│ 系统调用
│
runc
↑
│ OCI Runtime Specification
│
containerd / 其他上层容器工具
runc 并不是凭空创造了“容器”这个东西。
它真正做的事情,是把我们刚才通过 unshare、mount 等 Linux 原语手工完成的事情,按照 OCI 规范系统化、标准化地完成。
所以接下来再去看 runc,就会有一个非常明确的问题:
刚才我们用
unshare手工创建了一个隔离环境,那么 runc 又是如何把 Namespace、rootfs、Cgroup、Capability、Seccomp 等这些 Linux 原语组合起来,最终创建出一个真正的 OCI 容器呢?
这也正是我们接下来研究 runc 源码,以及最终自己实现 mini-runc 时需要回答的问题。
理解 Linux 进程、Namespace 与权限:我们登录 Linux 后到底发生了什么?
在刚才的实验中,我们通过:
sudo unshare --pid --fork --mount-proc /bin/bash
创建了一个新的 PID Namespace,并进入了其中的 Bash。
这时候很容易产生几个问题:
- 我刚才创建的 Namespace 怎么删除?
- Linux 中的 PID 到底有什么范围?
- 我平时登录 Linux 后看到的 Bash 是什么?
- 每登录一个用户,系统都会创建一个新的 Namespace 吗?
- 不同用户之间能不能看到彼此的文件?
这些问题看起来互不相关,但实际上都指向了 Linux 最基础的几个概念:进程、Namespace、用户身份以及权限控制。
首先,刚才通过 unshare 创建的 Namespace 不需要手动删除。
执行:
sudo unshare --pid --fork --mount-proc /bin/bash
之后,我们实际上启动了一个新的 Bash,并让它运行在新的 PID Namespace 中。
因此,退出这个 Namespace 最简单的方法就是退出 Bash:
exit
或者直接按:
Ctrl + D
即可。
Namespace 本身并不是一个普通的目录或者文件,因此并不存在类似:
rm namespace
这样的删除操作。
对于我们刚才创建的 PID Namespace 来说,只要其中已经没有进程继续持有它,它就会随着生命周期结束而被回收。
可以简单理解成:
unshare
│
▼
创建 Namespace
│
▼
启动 bash
│
▼
bash exit
│
▼
Namespace 没有进程继续使用
│
▼
Namespace 生命周期结束
这也是 Linux Namespace 一个非常重要的特点:
Namespace 的生命周期通常与引用它的进程密切相关。
当然,在更复杂的场景下,也可以通过其他方式让 Namespace 被进程持续持有,例如通过 /proc/<pid>/ns/ 中的 namespace 文件描述符,这也是后面研究容器运行时的时候会遇到的内容。
Linux 中的 PID 到底有多大?
Linux 中的 PID 并不是无限增长的,它受到内核参数 pid_max 的限制。
可以通过:
cat /proc/sys/kernel/pid_max
查看当前系统的最大 PID。
例如某些现代 Linux 系统可能返回:
4194304
也就是:
4,194,304
需要注意的是,这并不意味着系统只能同时存在这么多个进程。
PID 是一个由 Linux 内核分配的进程标识符,当一个进程退出之后,它使用过的 PID 在满足一定条件后可以重新分配给新的进程。
例如:
进程 A
PID 1234
│
▼
进程退出
│
▼
以后某个新进程
│
▼
可能再次获得 PID 1234
因此,PID 更准确地说是:
Linux 内核用来标识进程的一个整数 ID。
如果开始研究 Linux 进程,就绕不开两个特殊的 PID:0 和 1。
PID 0 在 Linux 内核中具有特殊意义,与系统启动以及 idle/swapper 相关,并不是我们通常意义上的用户空间进程。
而 PID 1 就非常重要了。
在普通 Linux 系统中,启动完成后通常可以看到:
PID 1
│
▼
systemd
systemd 作为宿主机用户空间的第一个主要进程,负责启动和管理大量系统服务。
但是,PID 1 并不只有宿主机才有。
每一个 PID Namespace 都可以拥有自己的 PID 1。
例如,我们刚才执行:
sudo unshare --pid --fork --mount-proc /bin/bash
进入新的 PID Namespace 后,Bash 可能看到自己是:
PID 1
而宿主机可能看到这个 Bash 是:
PID 12345
于是就形成了:
宿主机 PID Namespace
PID 1
└── systemd
PID 12345
└── bash
新的 PID Namespace
PID 1
└── bash
所以:
PID 1 并不意味着“整个 Linux 系统的第一个进程”,它也可能只是当前 PID Namespace 中的第一个进程。
这个概念对于理解容器尤其重要,我们经常看到容器内部:
ps
显示:
PID
1
这个 1 并不是宿主机上的 PID 1,而是容器所在 PID Namespace 中的 PID 1。
现在回到我们平时使用 Linux 的场景。
假设机器刚刚启动。
Linux 内核启动之后,会启动用户空间中的第一个主要进程:
Linux Kernel
│
▼
PID 1
systemd
随后,systemd 会启动各种系统服务,例如 SSH 服务。
如果我们通过 SSH 登录:
SSH Client
│
▼
sshd
│
▼
用户认证
│
▼
创建 Shell
│
▼
bash
最终,我们看到的那个命令行窗口,本质上就是一个普通的 Bash 进程。
整个关系可以简化成:
PID 1
systemd
│
├── PID 820
│ sshd
│ │
│ └── PID 15320
│ sshd
│ │
│ └── PID 15321
│ bash
│ │
│ ├── PID 15380
│ │ ls
│ │
│ ├── PID 15402
│ │ vim
│ │
│ ├── PID 15435
│ │ gcc
│ │
│ └── PID 15501
│ runc
│
├── PID 900
│ other-service
│
└── ...
因此:
Bash 本质上就是一个普通的 Linux 用户空间进程。
它只不过是一种 Shell 程序,负责读取我们输入的命令,然后创建新的进程去执行这些命令。
例如我们输入:
ls
Bash 并不是自己实现了 ls。
通常情况下,它会创建一个新的进程,然后执行:
/bin/ls
大致可以理解成:
bash
│
├── fork/clone
│
└── execve("/bin/ls")
│
▼
ls
fork() 可以非常粗暴地理解成:
复制当前进程,创建一个新的进程。
例如现在:
bash
PID 1000
Bash 调用:
fork();
内核就会创建一个新的进程:
bash
PID 1000
│
└── bash
PID 1001
注意:这里不是复制一个 Bash 窗口,而是内核创建了一个新的进程。
新的进程一开始和原来的进程非常像。
可以粗略理解成:
父进程
PID 1000
│
│ fork()
▼
子进程
PID 1001
为什么 Bash 要 fork?
因为 Bash 自己不能直接变成 ls。
假设你输入:
ls
Bash 自己是:
/bin/bash
而 ls 是:
/usr/bin/ls
如果 Bash 直接:
execve("/usr/bin/ls", ...)
那么会发生什么?
Bash 自己就变成 ls 了。
也就是说:
执行前:
PID 1000
bash
execve("/usr/bin/ls")
执行后:
PID 1000
ls
Bash 没了,这样当然不行。
因为 ls 执行完之后,你还得回到 Bash:
$ ls
...
$
所以 Bash 通常采用:
fork()
↓
创建子进程
↓
子进程 execve()
↓
变成 ls
于是:
原来的 Bash
PID 1000
│
└── 子进程
PID 1001
│
└── execve("/usr/bin/ls")
│
▼
ls
最终:
bash
PID 1000
│
└── ls
PID 1001
ls 执行完:
ls
PID 1001
│
▼
退出
然后 Bash 继续:
bash
PID 1000
│
▼
等待下一条命令
这就是整个过程。
那 clone 又是什么?
Linux 创建进程的时候,现在更核心的是 clone()。
简单理解:
fork()
↓
创建一个子进程
clone()
↓
更灵活地创建一个子进程,并且可以指定哪些资源共享
例如:
clone(CLONE_NEWPID | CLONE_NEWNS | ...);
可以要求:
创建这个子进程的时候,让它进入新的 PID Namespace、Mount Namespace……
现在把 Bash 执行 ls 的过程和 runc 放在一起,你就会发现一个非常漂亮的对应关系。
普通 Bash:
bash
│
│ fork()
▼
子进程
│
│ execve("/bin/ls")
▼
ls
而 runc:
runc
│
│ clone()
▼
子进程
│
├── 创建 Namespace
├── 设置 rootfs
├── 设置 cgroup
├── 设置 capabilities
├── 设置 seccomp
│
│ execve("/bin/sh")
▼
容器进程
所以你可以把它们对比起来:
普通程序启动 容器启动
父进程 bash runc
│ │
│ fork() │ clone()
▼ ▼
子进程 子进程
│ │
│ execve() │ 配置容器环境
▼ │
ls │ execve()
▼
/bin/sh
这就是 runc 的核心思想开始露出来了。
runc 最终还是要创建一个子进程,然后让这个子进程 execve() 用户指定的程序。
只不过普通 Bash 在 fork() 后什么都不做,直接执行 ls:
fork
↓
exec
而 runc 在 clone() 后,要先把这个子进程“改造成一个容器进程”:
clone
↓
Namespace
↓
Mount
↓
rootfs
↓
Cgroup
↓
Capability
↓
Seccomp
↓
execve
所以现在看到的这个关系非常重要:
在这个过程中,clone() 是 Linux 创建容器进程时涉及的重要系统调用之一。实际的 runc 并不是简单地执行一次 clone() 就完成所有工作,而是会根据具体的 OCI 配置,通过 clone/clone3、unshare、setns、mount 等机制逐步构造容器所需要的运行环境。
容器并不是一个特殊的“容器对象”。最终它仍然是一个 Linux 进程。runc 做的事情,就是在这个进程
execve()之前,把它的运行环境准备好。
登录 Linux 时,会创建一个新的 Namespace 吗?
通常不会。
这是非常容易产生误解的地方。
我们执行:
ssh user@server
并不意味着:
登录用户
│
▼
创建一个新的 Namespace
│
▼
创建 Bash
更常见的情况是:
Linux 启动
│
▼
系统已有 Namespace
│
▼
systemd
│
▼
sshd
│
▼
bash
也就是说,我们登录以后启动的 Bash 通常只是运行在系统已经存在的 Namespace 中。
而我们刚才执行的:
unshare --pid --fork --mount-proc /bin/bash
则是明确告诉 Linux:
我要创建一个新的 PID Namespace,并在其中启动一个 Bash。
所以两者完全不同。
可以把 Namespace 理解成:
进程所处的一个“视角”。
普通 Bash 和宿主机上的其他进程共享宿主机的 PID Namespace,因此它们看到的是同一套进程视图。
而进入新的 PID Namespace 后,Bash 就拥有了一套不同的进程视图。
现在再回头看容器,就会发现它其实并没有那么神秘。
一个容器中的进程,可能同时处于多个 Namespace 中:
Container
│
├── PID Namespace
│ └── 进程隔离
│
├── Mount Namespace
│ └── 文件系统视图隔离
│
├── Network Namespace
│ └── 网络环境隔离
│
├── UTS Namespace
│ └── hostname 隔离
│
└── User Namespace
└── UID/GID 隔离
同时,它还可能受到:
Cgroup
↓
CPU / Memory / IO 等资源限制
Capability
↓
限制进程拥有的内核权限
Seccomp
↓
限制进程能够调用的系统调用
因此,一个容器本质上可以抽象成:
Linux Process
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
Namespace UID/GID Cgroup
│ │ │
“我能看到什么” “我是谁/能做什么” “我能用多少资源”
而这恰恰是我们研究 runc 最重要的切入点。
runc 并没有创造一个叫“容器”的特殊 Linux 进程。
它做的事情,本质上是:
创建一个普通 Linux 进程,然后利用 Namespace、Mount、Cgroup、Capability、Seccomp 等内核机制,为这个进程构造出一个隔离的运行环境。
到这里,我们就可以重新审视之前的 unshare 实验。
我们已经能够手工完成:
普通 Bash
│
▼
unshare
│
├── 创建 PID Namespace
├── 创建 Mount Namespace
└── 启动新的 Bash
一个容器在 runc 眼里是什么

在 AlmaLinux 8/9/10 中,可以通过 sed 命令直接修改 /etc/yum.repos.d/almalinux*.repo 中的 baseurl 来切换国内镜像源。
📦 常用国内镜像源
| 镜像站 | URL |
|---|---|
| 阿里云 | https://mirrors.aliyun.com |
| 上海交通大学 | https://mirror.sjtu.edu.cn(别名 mirrors.sjtug.sjtu.edu.cn) |
| 浙江大学 | https://mirrors.zju.edu.cn |
| 南京大学 | https://mirrors.nju.edu.cn |
🔧 以阿里云镜像为例
-
切换到 root 用户
sudo su - -
修改 AlmaLinux 所有 repo 为阿里云源
sed -e 's|^mirrorlist=|#mirrorlist=|g' \ -e 's|^# baseurl=https://repo.almalinux.org|baseurl=https://mirrors.aliyun.com|g' \ -i.bak /etc/yum.repos.d/almalinux*.repo注意:AlmaLinux 的 repo 文件中
# baseurl前面有一个空格,这是专有格式,替换时务必保留。 -
刷新缓存
sudo dnf makecache -
验证是否生效
sudo dnf repolist
如果需要切回 AlmaLinux 官方源,执行以下命令:
sed -e 's|^#mirrorlist=|mirrorlist=|g' \
-e 's|^baseurl=https://mirrors.aliyun.com|# baseurl=https://repo.almalinux.org|g' \
-i.bak /etc/yum.repos.d/almalinux*.repo
📌 操作建议
-
备份:执行前建议先手动备份原文件:
cp /etc/yum.repos.d/almalinux*.repo /etc/yum.repos.d/almalinux*.repo.bak
-
测试:修改后务必运行
sudo dnf makecache确认可正常获取包。 -
选源:根据网络速度、地理位置选择镜像,高校镜像通常更稳定。
这样即可快速在 AlmaLinux 中完成 dnf 源切换,并保留恢复能力。
如果dnf install runc时候出现 No match for argument: runc 的报错,通常是因为 runc 所在的软件源(repository)没有在系统中启用。
这个错误信息表明,dnf 在已启用的软件源中找不到名为 runc 的软件包。虽然在 AlmaLinux 的官方源中确实存在 runc 的二进制包,但它可能没有被包含在默认启用的基础源里,或者被 podman 等预装软件取代了。
runc 通常包含在 EPEL (Extra Packages for Enterprise Linux) 这个额外的软件源中。启用它是最标准的做法。
-
启用 CRB/PowerTools 源(这是安装 EPEL 的前提):
# AlmaLinux 9 或更新版本 sudo dnf config-manager --set-enabled crb # AlmaLinux 8 sudo dnf config-manager --set-enabled powertools -
安装 EPEL 源:
sudo dnf install epel-release -
刷新缓存并重试安装:
sudo dnf makecache sudo dnf install runc
在你准备执行 dnf install runc 之前,请先停一下,检查你的系统是否已经存在 runc。
很多情况下,runc 已经作为依赖被其他容器工具悄悄安装好了。比如:
- 如果你的系统上已经安装了 Docker,那么
runc一定已经存在(因为 Docker 的调用链底层就是dockerd→containerd→runc)。 - 如果安装了 Podman 或 Buildah,它们同样会依赖
runc(或crun)作为容器运行时。
执行以下命令,看看 runc 是否已经在你的系统上:
# 查看 runc 是否在系统路径中
which runc
# 查看已安装的 runc 版本
runc --version
# 通过 rpm 查询是否已安装
rpm -qa | grep runc
💡 为什么需要先检查
避免版本冲突或不必要的重复安装。虽然 dnf 会处理依赖关系,不会强制覆盖已安装的版本,但:
- 从不同源(如 EPEL 与 Docker 官方源)安装的
runc可能存在版本差异。 - 重复安装可能导致你的系统存在多个
runc二进制文件,而$PATH中的优先级决定了实际使用的是哪一个,这可能会带来意想不到的行为。
如果你已经通过 Docker 官方源安装了 Docker,那么 Docker 自带的 runc 通常位于 /usr/bin/runc。此时直接 dnf install runc 不会覆盖它(因为版本可能相同或 dnf 会判断已满足依赖),但明确知道自己已有 runc,可以避免无谓的操作。
先看看我们的实验环境。我这里使用的是一台独立的 Linux 虚拟机,安装好 runc 后,先执行:
runc --version
可以看到类似:
runc version 1.3.x
commit: ...
spec: 1.2.x
go: ...
libseccomp: ...
然后执行:
runc list
如果你在执行 runc list 时遇到了这样的报错:
ERRO[0000] open /run/runc: no such file or directory
不要慌张——这其实是个好现象。
它说明你的 runc 命令本身已经安装好了,而且能够正常运行。只不过,它默认去寻找运行时状态目录 /run/runc,但这个目录在你的系统上并不存在。
runc list 的作用不是"列出所有 Docker 容器",而是:
列出当前这个
runc自己管理的容器。
它默认去 /run/runc/ 这个路径下寻找容器状态文件。当这个目录不存在时,就会报出上面的错误。
为什么 Docker 在运行,runc list 却看不到?
这是理解容器运行时架构的一个关键点。回顾一下 Docker 的调用链:
Docker
↓
containerd
↓
runc
Docker 的容器虽然最终由 runc 创建,但 containerd 在调用 runc 时,会指定使用自己的 runtime root,而不是你手工执行 runc list 时默认使用的 /run/runc。
换句话说:
| 命令 | 查询的目录 | 管理范围 |
|---|---|---|
runc list |
/run/runc(默认) |
手工或直接由 runc 管理的容器 |
docker ps |
通过 Docker → containerd 获取 | Docker 管理的所有容器 |
这两个列表本来就可能不是一回事。
可以做这样一个对比实验:
-
先用 Docker 启动一个容器:
docker run -d --name test-nginx nginx -
查看 Docker 管理的容器:
docker ps你会看到 nginx 容器正在运行。
-
再执行
runc list:runc list
大概率仍然看不到这个容器,或者报目录不存在的错误。
这个实验正好可以引出你博客里的一个核心疑问:
“明明 Docker 的容器最后是 runc 启动的,为什么我直接执行
runc list却找不到它?”
答案就是:
containerd 和手工调用的 runc,不一定使用同一个 runtime root、namespace 或生命周期管理上下文。
可以继续执行这几个命令,深入了解你当前系统的运行时配置:
# 查看 runc 版本和路径
runc --version
which runc
# 查看 containerd 版本
containerd --version
# 查看 Docker 当前使用的运行时
docker info | grep -i runtime
# 查看某个容器的具体运行时配置
docker inspect test-nginx --format '{{json .HostConfig.Runtime}}'
还有一个很有用的命令,可以帮你找到系统上所有的 runc/containerd 相关目录:
find /run -maxdepth 3 -type d \( -name '*runc*' -o -name '*containerd*' \) 2>/dev/null
/run/containerd
/run/containerd/io.containerd.runtime.v2.task
/run/docker/containerd
/run/docker/runtime-runc
执行查询containerd创建出来的namespace name:
ctr namespaces list
通常你会看到类似:
NAME
moby
然后:
ctr -n moby containers list

以及:
ctr -n moby tasks list
你应该能够看到:
CONTAINER
e233aec59673...

以及对应的 task。
这里非常重要:
Docker
│
▼
containerd
│
│ namespace = moby
▼
container
│
▼
task
│
▼
runc
再看 containerd 的 runtime 状态目录
/run/containerd/io.containerd.runtime.v2.task
进去看看:
ls -lah /run/containerd/io.containerd.runtime.v2.task/
再:
find /run/containerd/io.containerd.runtime.v2.task -maxdepth 3 -type d
很可能会看到类似:
/run/containerd/io.containerd.runtime.v2.task/
└── moby/
└── <container-id>/
这时候就非常有意思了。

因为你会发现:
runc list
看的可能是:
/run/runc
而 containerd 使用的 runtime state 则可能在:
/run/containerd/io.containerd.runtime.v2.task/moby/<container-id>
所以:
你手工执行的 runc
│
▼
/run/runc
│
└── 没有容器
而:
Docker
│
▼
containerd
│
▼
runtime v2
│
▼
/run/containerd/io.containerd.runtime.v2.task/...
│
▼
你的 nginx
完全是两套状态管理环境。
还有一个非常有意思的实验,你可以直接看看 nginx 到底是谁的子进程:
ps -ef --forest | grep -A 20 -B 5 nginx
或者:
ps aux | grep nginx

你应该能看到 nginx 的 master/worker 进程。
这恰好说明:
Docker 容器中的进程仍然是真实存在于宿主机进程表中的 Linux 进程,只不过它处在自己的 PID Namespace 中。
这时候你可能会产生一个疑问:
docker run nginx是告诉 Docker “我要一个 nginx 容器”,那runc run又应该告诉 runc 什么?
答案是:
runc 并不认识 Docker Image。
它甚至不关心:
nginx:latest
ubuntu:24.04
alpine:latest
这些东西。
runc 真正关心的是一个 OCI Bundle。
可以把它理解成:
一个目录
+
一个 config.json
+
一个 rootfs
也就是说,runc 看到的不是:
nginx:latest
而是:
“这是容器应该长什么样,以及它应该运行什么程序。”
这就是 OCI Runtime Specification 存在的意义。
OCI Bundle:一个容器的“说明书”
先创建一个目录:
mkdir mycontainer
cd mycontainer
然后:
runc spec
执行完之后,你会发现当前目录多了一个:
config.json
这个文件非常重要。
打开它:
cat config.json
你会看到大量配置,大致包括:
{
"ociVersion": "...",
"process": {
"terminal": true,
"args": [
"sh"
],
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
]
},
"root": {
"path": "rootfs",
"readonly": true
},
"hostname": "runc",
"mounts": [
...
],
"linux": {
"resources": {
...
},
"namespaces": [
{
"type": "pid"
},
{
"type": "network"
},
{
"type": "mount"
},
{
"type": "ipc"
},
{
"type": "uts"
}
]
}
}
第一次看到这里的时候,我觉得特别有意思。
因为我们平时所谓的:
“启动一个容器”
在 runc 的世界里,实际上已经被拆成了非常具体的描述:
我要运行什么程序?
↓
我要什么环境变量?
↓
我的 rootfs 在哪里?
↓
我需要哪些 Namespace?
↓
需要什么资源限制?
↓
容器的 hostname 是什么?
这就是 config.json。
它不是镜像。
它也不是容器本身。
它更像是:
“请按照这些要求创建一个容器”的说明书。
现在还有一个问题!
我们的配置里写了:
"root": {
"path": "rootfs"
}
也就是说:
runc 准备把
rootfs/当成容器的根文件系统。
然而我们现在根本没有这个目录。
执行:
ls
大概只有:
config.json
如果现在直接:
runc run mycontainer
基本只会收获一个错误。
因为 runc 找不到:
rootfs/
于是第二个关键概念出现了:rootfs
容器里的 / 到底是什么?
我们平时进入 Linux:
ls /
可能看到:
bin
boot
dev
etc
home
lib
proc
sys
tmp
usr
var
那容器里面呢?
其实它也必须有自己的:
/
否则:
/bin/sh
去哪找?
/etc/hosts
去哪读?
/lib
所以,一个最基本的容器必须有自己的根文件系统。
也就是:
rootfs/
├── bin/
├── etc/
├── lib/
├── lib64/
├── proc/
├── sys/
├── tmp/
├── usr/
└── var/
容器只是把这个目录变成了进程眼中的:
/
注意这里已经出现了一个非常关键的认知:
容器并不是凭空生成了一个新的操作系统。
它甚至不需要一个完整的 Linux 内核。
它只需要:
一个 rootfs
+
一组 Linux 隔离机制
+
一个普通进程
Linux 内核依然是宿主机自己的内核。
为了实验,我们需要一个最小的 Linux root filesystem。
既然我们的目标是研究 runc,最简单的方法就是直接使用 Alpine 官方提供的 root filesystem tarball。
rootfs/
├── bin/
├── etc/
├── lib/
├── sbin/
├── usr/
└── ...
下载 Alpine rootfs:
curl -LO https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/x86_64/alpine-minirootfs-3.24.1-x86_64.tar.gz
然后创建 rootfs:
mkdir rootfs
tar -xzf alpine-minirootfs-3.22.1-x86_64.tar.gz -C rootfs
mycontainer/
├── config.json
└── rootfs/
├── bin/
├── etc/
├── lib/
└── ...
这时候我们的 OCI Bundle 才算基本成型。

你会发现:
rootfs/
├── bin/
├── etc/
├── lib/
├── usr/
└── ...
里面甚至已经有:
rootfs/bin/sh
你可以检查:
file rootfs/bin/sh
以及:
ls -l rootfs/bin/sh
这意味着:
runc 并不需要知道什么叫
alpine:latest。
它只需要知道:
rootfs/
在哪里。
然后 config.json 告诉它:
我要在这个 rootfs 里面执行:
/bin/sh
于是整个关系就变成:
Alpine rootfs
│
▼
rootfs/
│
│
├── bin/sh
├── etc/
├── lib/
└── usr/
+
OCI config.json
│
│
└── process.args = ["/bin/sh"]
↓
runc
│
▼
配置容器环境
│
▼
execve("/bin/sh")
这就是为什么 OCI Bundle 是理解 runc 的关键。
严格来说,runc 并不关心你这个 rootfs 是 Alpine、Ubuntu 还是 Debian。
只要它里面存在能够执行的程序,以及程序运行所需要的动态库等文件,就可以作为 rootfs。
例如:
rootfs/
├── bin/
│ └── sh
├── lib/
│ └── ...
├── usr/
│ └── ...
└── etc/
甚至理论上你可以自己构造一个极其简陋的 rootfs。
这时候你会发现:
Linux 容器并不是“一个小型虚拟机”。
你没有在 rootfs 里面运行一个 Linux kernel。
因为:
宿主机
│
└── Linux Kernel
│
├── 宿主机进程
│
└── 容器进程
│
└── /bin/sh
容器和宿主机共享同一个 Linux Kernel。
Alpine rootfs 里面只有用户空间文件:
/bin
/etc
/lib
/usr
...
没有一个独立的 Linux Kernel。
这时候再看 Docker 就特别清楚了,Docker 做的事情其实比裸 runc 多很多:
docker run alpine
│
▼
Docker
│
├── 镜像管理
├── Layer 管理
├── rootfs 准备
├── 网络
├── volume
├── container lifecycle
│
▼
containerd
│
▼
runc
│
├── Namespace
├── Mount
├── Cgroup
├── Capability
├── Seccomp
│
▼
execve()
│
▼
/bin/sh
第一次运行裸 runc
现在:
runc run mycontainer

这时候再打开一个会话,然后执行runc list,就可以看到我们自己使用runc启动的容器了:

如果一切配置正确,你会进入容器。
例如:
/ #
注意这个 / 已经不是宿主机那个 / 了。
执行:
hostname
你会得到容器自己的 hostname。
然后我们来创建一个目录,结果发现报错了:
/ # mkdir worspace
mkdir: can't create directory 'worspace': Read-only file system
关键原因通常不是 Alpine,而是 config.json 里的 rootfs 被配置成了只读挂载。
在宿主机退出容器:
exit
然后:
grep -n -A 10 '"root"' config.json
你大概率会看到:
"root": {
"path": "rootfs",
"readonly": true
}
这里"readonly": true就是罪魁祸首,它告诉 runc:
把容器的 root filesystem 以只读方式挂载。
所以你进入容器后:
mkdir /workspace
实际上是在尝试修改:
容器 /
↓
rootfs/
但是 rootfs 是只读的,于是:
mkdir
↓
尝试创建目录
↓
Filesystem read-only
↓
失败
把"readonly": true改成"readonly": false或者直接删除这一行。
然后再次:
runc run mycontainer
进入:
/ # mkdir workspace
/ # ls
应该就能看到:
workspace
你可能会产生一个疑问:
我明明只是把 rootfs 解压到了
rootfs/,为什么 runc 还能决定它是只读还是可写?
因为rootfs/只是宿主机上的一个普通目录。
再执行:
ps
通常会发现一个非常有意思的现象。
容器里面的进程数量非常少。
甚至:
PID USER COMMAND
1 root /bin/sh
...
这里出现了:PID 1
为什么容器里的 Shell 是 PID 1?
这个问题非常重要。
在宿主机上:
ps aux
你可能看到:
PID 1
systemd
然后:
PID 1000
某个服务
而容器里:
ps
却可能变成:
PID 1
/bin/sh
难道 runc 把宿主机的 systemd 杀掉了吗?
当然没有。
实际上这里使用的是:
PID Namespace
Linux 可以让不同进程看到不同的 PID 世界。
宿主机:
PID 1
systemd
PID 2456
runc
PID 2457
container process
容器 Namespace 里:
PID 1
/bin/sh
同一个进程,在不同 Namespace 中甚至可以拥有不同的 PID。
比如:
宿主机看到:
PID = 2457
容器看到:
PID = 1
这就是 Namespace 最漂亮的地方之一。
它并不是“把进程藏起来”。
而是:
给进程创造了一个不同的观察世界。
Namespace 到底隔离了什么?
打开刚才的 config.json,你会发现里面有:
"namespaces": [
{
"type": "pid"
},
{
"type": "network"
},
{
"type": "mount"
},
{
"type": "ipc"
},
{
"type": "uts"
}
]
这几个 Namespace 分别控制不同的“世界”。
PID Namespace
决定:
你能看到哪些进程?
Network Namespace
决定:
你看到的是哪套网卡、路由和网络栈?
Mount Namespace
决定:
你看到的是哪套挂载点?
UTS Namespace
决定:
你的 hostname 是什么?
IPC Namespace
决定:
你使用的是哪套 IPC 资源?
这时候我们再回头看 runc,就会发现:
runc 其实没有那么神秘。
它更像一个“翻译器”。
把:
OCI Specification
翻译成:
Linux Kernel primitives
但这个容器还远远不像 Docker
到这里,我们似乎已经成功了。
runc → rootfs → Namespace → 容器运行
但是仔细想想,问题越来越多。
首先,执行 ps 命令确实能看到进程了,但网络呢?如果你在容器内执行 ip addr,可能会发现没有 eth0,或者根本没有你期待的网络连接。
这是为什么呢?
Network Namespace 只负责隔离网络世界,它不会自动帮你造出一个能访问互联网的网络。
这其实是一个非常关键的区别:
Namespace = 隔离,而不是配置。
你创建一个独立的 Network Namespace,只是得到了一张"空白的网络世界"。至于 veth、bridge、IP、route、NAT、iptables、DNS 这些事情,还得另外解决。
这也就是为什么后来会出现 CNI、libnetwork 等网络方案——它们专门负责在 Namespace 隔离的基础上,把网络"配置"好。
网络插件负责把网络接进去:
CNI
于是:
runc
↓
创建 Network Namespace
CNI
↓
创建 veth pair
↓
连接 bridge
↓
配置 IP
↓
配置 route
↓
配置 NAT
这两件事情本来就是不同的职责。
到这里,我们终于看到了 Docker 为什么需要那么多组件
我们现在的“容器”看起来只有:
rootfs
+
config.json
+
runc
可你真的自己折腾以后马上会发现:
谁管理网络?
谁管理镜像?
谁下载镜像?
谁解压 layer?
谁管理容器生命周期?
谁管理日志?
谁处理 exec?
谁处理事件?
谁管理 snapshot?
这就是后来 containerd、CNI、网络驱动、snapshotter 等组件存在的原因。
到这里,我已经可以不依赖 Docker,直接通过裸 runc 启动一个容器。
但仔细一想,我其实只是:
准备 rootfs
准备 config.json
调用 runc
真正做底层工作的,还是:
runc
于是新的问题又冒出来了:
如果连 runc 都不要呢?
如果我自己写一个 Go 程序:
cmd := exec.Command(...)
然后手动调用:
PID Namespace
Mount Namespace
UTS Namespace
Network Namespace
cgroup
rootfs
能不能也创建出一个容器?
这个章节现在的定位就很舒服了:不是“我要挑战 runc”,而是“我已经理解了 runc 在做什么,现在把这些 Linux 原语亲手组合起来”。
我建议前序不要一上来就讲代码,而是先把这一章和上一章的关系交代清楚。
从 Linux 原语实现一个 mini-runc
上一章我们一直在观察 runc。
我们知道了 OCI Bundle 是什么,知道了 config.json 如何描述一个容器,也知道了 rootfs、Namespace、Cgroup、Capability、Seccomp 等东西分别承担什么职责。
但到这里,其实还停留在一个比较高的层次:
我们知道 runc 要做什么,却还没有亲手做过。
如果继续停留在 Docker、containerd 和 runc 的调用链上,很容易产生一种错觉:容器运行时似乎是一个非常复杂的东西,背后有大量神秘的机制。
实际上,剥掉 OCI、containerd、Docker 等上层包装之后,容器运行时最终还是建立在 Linux 提供的一系列基础能力之上。
例如:
创建进程
↓
Namespace
↓
Mount
↓
rootfs
↓
Cgroup
↓
Capability
↓
Seccomp
↓
execve()
这些并不是什么“容器专用 API”,而是 Linux 本身提供的进程、文件系统和安全机制。
所以这一章不打算重新实现一个完整的 runc。
真正的目标是:
使用 Go,把构成容器运行时的几个核心 Linux 原语一个个拆出来,再重新组合成一个最小的容器运行时。
我们不会考虑镜像仓库、镜像 Layer、网络、Volume、日志、容器生命周期管理等复杂问题,而是尽可能把问题缩小到最核心的一条链路:
Go
│
├── 创建进程
├── 创建 Namespace
├── 隔离 Mount
├── 准备 rootfs
├── 切换根文件系统
│
└── execve()
│
▼
容器进程
这也是为什么这里叫 mini-runc。
它不是为了替代 runc,更不是因为 runc 有什么“不好”,而是为了学习。
把一个已经高度工程化的成熟项目拆开,用最少的代码重新走一遍它背后的核心流程,往往是理解系统软件最有效的方式之一。
从 Go exec.Cmd 开始:启动一个 Linux 进程
在真正接触 Namespace 之前,我们先从最简单的事情开始:
让 Go 启动一个 Linux 进程。
因为无论最后是 Docker、containerd、runc,还是我们自己的 mini-runc,容器最终都必须落到一个真实存在的 Linux 进程上。
在 Go 中,最直接的方式就是:
package main
import (
"fmt"
"os"
"os/exec"
)
func main() {
cmd := exec.Command("/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
fmt.Println("error:", err)
}
}
交叉编译:
GOOS=linux GOARCH=amd64 go build -o mini-runc .

然后拖入linux执行:
./mini-runc

你会直接进入一个新的 Shell:
[root@localhost ~]# ./mini-runc
/ #
看起来似乎没什么特别的。
但实际上,这里面已经发生了一件非常重要的事情:
mini-runc
│
│ exec.Command("/bin/sh")
▼
子进程
│
│ execve()
▼
/bin/sh
我们可以通过另一个终端观察它。
假设当前 Shell 是:
[root@localhost ~]# ./mini-runc
在另一个终端执行:
ps -ef --forest
就能够看到类似:
systemd
├── sshd
│ └── sshd
│ └── bash
│ └── mini-runc
│ └── sh
│
└── ...
这时候我们的 mini-runc 实际上什么“容器”都没有创建。
它只是做了一件非常普通的事情:
Go 程序
↓
创建子进程
↓
execve("/bin/sh")
↓
Shell
而这正是我们需要的起点。
现在执行一次pwd,会获得如下输出:
sh-5.2# pwd
/workspace/myrunc/mini-runc
这说明你启动的 /bin/sh 继承了父进程的当前工作目录(cwd)。
execve() 不会自动改变当前目录
假设你的程序是:
/workspace/myrunc/mini-runc
然后执行:
cmd := exec.Command("/bin/sh")
最终发生的事情可以简化成:
mini-runc
│
│ 创建子进程
▼
子进程
│
│ execve("/bin/sh")
▼
/bin/sh
这里非常重要:
execve()只是把“当前进程运行的程序”替换成/bin/sh,并不会把这个进程的其他运行环境全部重置。
所以原来:
cwd = /workspace/myrunc/mini-runc
执行:
execve("/bin/sh")
以后仍然是:
cwd = /workspace/myrunc/mini-runc
于是你看到:
sh-5.2# pwd
/workspace/myrunc/mini-runc
完全正常。
实际上,进程创建 + exec 的时候,会涉及大量可以继承的东西。
例如:
父进程
│
├── cwd
├── environment
├── file descriptors
├── UID / GID
├── umask
├── resource limits
├── namespace
├── cgroup
├── capabilities
└── ...
│
│ 创建子进程
▼
子进程
然后:
execve("/bin/sh")
只是把:
程序代码
地址空间中的用户态映像
换成 /bin/sh。
所以:
mini-runc
变成:
/bin/sh
但它不是重新创建了一个“干净的 Linux 环境”。
这正是为什么我们现在的程序还根本不能叫容器运行时。
因为这个 /bin/sh 还是一个完完全全的宿主机进程。
cwd → 宿主机目录
root filesystem → 宿主机 /
PID Namespace → 宿主机
Mount Namespace → 宿主机
UTS Namespace → 宿主机
Network Namespace→ 宿主机
Cgroup → 宿主机
Capabilities → 宿主机
也就是说:
mini-runc
│
▼
/bin/sh
│
└── 几乎完全继承宿主机环境
而真正的 runc 要做的是:
mini-runc
│
▼
创建子进程
│
├── PID Namespace
├── UTS Namespace
├── Mount Namespace
├── Network Namespace
├── rootfs
├── pivot_root
├── Cgroup
├── Capability
├── Seccomp
└── ...
│
▼
execve("/bin/sh")
│
▼
容器进程
exec.Command() 很容易让人误以为:
“Go 帮我启动了一个
/bin/sh。”
但从操作系统的角度来看,它最终还是要依赖 Linux 的进程创建和 execve() 机制。
可以把它理解成:
Go
│
│ exec.Command("/bin/sh")
▼
os/exec
│
▼
创建子进程
│
▼
execve("/bin/sh")
│
▼
/bin/sh
这里有一个非常关键的概念:
execve()并不会创建新的进程。
它做的是:
让当前进程放弃原来的程序代码和用户态地址空间,加载另一个可执行程序。
例如:
原来的进程
PID = 1234
程序 = mini-runc
│
│ execve("/bin/sh")
▼
PID = 1234
程序 = /bin/sh
注意:PID 没有改变。
改变的是这个 PID 当前所执行的程序。
这也是为什么我们前面分析 Bash 时会看到:
bash
│
│ fork()
▼
子进程
│
│ execve("/bin/ls")
▼
ls
fork() 负责:
创建新的进程。
而 execve() 负责:
让这个进程执行另一个程序。
这两个概念一定要分开。
那我们的 mini-runc 现在和普通 Bash 有什么区别?
没有区别。
至少现在没有。
我们的程序:
mini-runc
│
▼
/bin/sh
和 Bash 执行:
sh
本质上没有什么不同。
但这恰恰是我们想要的。
因为接下来,我们会一点一点往这个流程里面添加东西:
PID Namespace:让进程拥有自己的进程世界
Linux 提供了 clone() 系统调用,而 Go 的 os/exec 也提供了一个非常方便的入口:
SysProcAttr
我们可以通过它告诉 Go:
创建子进程的时候,让这个子进程进入一个新的 PID Namespace。
代码修改成:
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func main() {
cmd := exec.Command("/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID,
}
if err := cmd.Run(); err != nil {
fmt.Println("error:", err)
}
}
这里最关键的一行:
Cloneflags: syscall.CLONE_NEWPID,
它对应 Linux 的:
CLONE_NEWPID
意思就是:
创建子进程时,为它创建一个新的 PID Namespace。
如果你在windows上编辑代码,不要搭理编辑器的字段飘红,因为我们下载的是windows环境的go sdk,实际上是有这个字段属性的,你只管编译,我不会骗你们:

重新编译:
GOOS=linux GOARCH=amd64 go build -o mini-runc .
然后执行:
./mini-runc
进入 Shell 后:
echo $$
应该可以看到:
1
这时候事情就开始有意思了。
宿主机可能看到:
PID 1701
│
└── /bin/sh
但容器进程自己看到:
PID 1
│
└── /bin/sh
也就是说,我们刚才通过:
syscall.CLONE_NEWPID
成功创建了一个新的 PID Namespace。
PID Namespace 一个非常重要的规则:
新 PID Namespace 中创建的第一个进程,会成为这个 Namespace 中的 PID 1。
我们的流程是:
mini-runc
│
│ 创建子进程
│ CLONE_NEWPID
▼
新的 PID Namespace
│
└── PID 1
│
│ execve("/bin/sh")
▼
/bin/sh
这里也再次体现出了 fork/clone 和 execve 的区别:
clone()
↓
创建新的进程
↓
进入新的 PID Namespace
↓
获得 PID 1
↓
execve("/bin/sh")
↓
这个 PID 1 开始执行 /bin/sh
execve() 并没有创建 PID 1。
真正创建这个进程的是前面的进程创建操作,而 execve() 只是让这个进程开始执行 /bin/sh。
这时候你可能会迫不及待地执行:
ps
然后发现:
PID
1
2
3
...
1700
1701
...
甚至直接看到宿主机的完整进程列表。
先别奇怪,这是正常的。
因为我们目前只完成了:
PID Namespace
却还没有处理:
/proc
PID Namespace 和 /proc 是两回事,这是学习 Linux Namespace 时非常容易混淆的一点。
PID Namespace 决定的是:
进程属于哪个 PID 世界,以及在这个世界中拥有什么 PID。
而 ps 又是怎么知道系统里有哪些进程的?
它主要是通过:
/proc
获取进程信息。
问题就在这里:
我们目前创建了新的 PID Namespace,但还没有创建新的 Mount Namespace,也没有重新挂载 /proc。
因此现在实际上是:
新的 PID Namespace
│
│
▼
/proc
│
▼
宿主机的 proc filesystem
│
▼
宿主机进程信息
所以ps命令仍然可能看到宿主机的进程。
这并不意味着 PID Namespace 创建失败。
我们真正应该验证的是当前进程所处的 PID Namespace:
readlink /proc/self/ns/pid
然后在宿主机另一个终端执行:
readlink /proc/self/ns/pid
两个终端应该会得到不同的 Namespace ID,例如:
宿主机:
pid:[4026531836]
容器:
pid:[402653xxxx]
这才是 PID Namespace 是否创建成功的直接证据。
为什么 unshare --pid --fork --mount-proc 可以直接看到正确的 ps?
还记得我们之前做过的实验:
unshare --pid --fork --mount-proc /bin/bash
这里其实做了两件事情:
--pid
│
▼
创建新的 PID Namespace
--mount-proc
│
▼
重新挂载 /proc
所以它能够得到:
新的 PID Namespace
│
+
新的 /proc
│
▼
ps
│
▼
只看到当前 Namespace 中的进程
而我们的 mini-runc 目前只完成了第一步:
mini-runc
│
├── PID Namespace ✅
│
└── /proc ❌
因此这一节我们故意不解决 /proc 的问题。
UTS Namespace:让容器拥有自己的 hostname
UTS 是 Unix Time-sharing System 的缩写,在 Linux 中,UTS Namespace 主要负责隔离:
hostname
domain name
其中我们最常接触的就是:
hostname
因此,创建一个新的 UTS Namespace 后,我们就可以让容器拥有一个和宿主机不同的 hostname。
例如:
宿主机:
hostname
↓
localhost
而容器:
hostname
↓
mycontainer
这就是 UTS Namespace 最直观的效果。
上一节我们的代码是:
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID,
}
现在只需要再增加一个:
syscall.CLONE_NEWUTS
完整代码:
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func main() {
cmd := exec.Command("/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS,
}
if err := cmd.Run(); err != nil {
fmt.Println("error:", err)
}
}
这里:
syscall.CLONE_NEWUTS
就是告诉 Linux:
创建子进程时,为它创建一个新的 UTS Namespace。
现在我们的进程已经同时拥有两个 Namespace:
mini-runc
│
│ clone()
▼
子进程
│
├── PID Namespace
└── UTS Namespace
│
▼
/bin/sh
这里有一个和 PID Namespace 很相似的地方。
我们只是创建了:
新的 UTS Namespace
但新的 Namespace 默认并不会自动变成:
hostname = mycontainer
它最开始仍然会继承父 Namespace 的 hostname。
所以我们还需要主动修改它。
在执行 /bin/sh 之前增加:
if err := syscall.Sethostname([]byte("mycontainer")); err != nil {
fmt.Println("set hostname error:", err)
return
}
于是完整代码变成:
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func main() {
cmd := exec.Command("/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS,
}
if err := syscall.Sethostname([]byte("mycontainer")); err != nil {
fmt.Println("set hostname error:", err)
return
}
if err := cmd.Run(); err != nil {
fmt.Println("error:", err)
}
}
不过这里有一个非常重要的问题。
这段代码其实还不能按照我们想象的方式工作。
为什么?
因为syscall.Sethostname(...)是在 cmd.Run() 之前执行的。
也就是说:
mini-runc
│
├── 创建 Namespace 的配置
│
├── Sethostname()
│
▼
cmd.Run()
此时调用 Sethostname() 的还是父进程,也就是宿主机 Namespace 中的 mini-runc。
如果直接这么写,我们反而可能修改了宿主机的 hostname。
这显然不是我们想要的。
真正的问题:谁来设置 hostname?
我们需要的是:
mini-runc
│
│ clone()
▼
子进程
│
├── 已经进入 PID Namespace
├── 已经进入 UTS Namespace
│
├── sethostname("mycontainer")
│
└── execve("/bin/sh")
也就是说:
sethostname()必须在子进程进入新的 UTS Namespace 之后执行。
因此,我们把程序稍微调整一下:让子进程负责初始化自己
最简单的方式,是让程序支持一个特殊参数:
./mini-runc
负责创建容器进程;
而:
./mini-runc child
负责执行容器内部初始化。
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "child" {
child()
return
}
parent()
}
func parent() {
cmd := exec.Command("/proc/self/exe", "child")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS,
}
if err := cmd.Run(); err != nil {
fmt.Println("error:", err)
}
}
func child() {
if err := syscall.Sethostname([]byte("mycontainer")); err != nil {
fmt.Println("set hostname error:", err)
return
}
cmd := exec.Command("/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println("error:", err)
}
}
这里的 /proc/self/exe 非常有意思。
它表示:
当前正在运行的这个可执行文件。
所以:
exec.Command("/proc/self/exe", "child")
相当于:
重新启动自己
也就是:
mini-runc
│
│ exec /proc/self/exe child
▼
mini-runc child
而父进程在创建它的时候指定:
Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS
于是:
mini-runc
│
│ clone()
▼
mini-runc child
│
├── PID Namespace
├── UTS Namespace
│
├── sethostname("mycontainer")
│
└── execve("/bin/sh")
│
▼
/bin/sh
这其实已经开始接近真正的 runc 结构了。
进入 Shell 后执行:
hostname
应该得到:
mycontainer
再执行:
echo $$
应该得到:
1
于是现在我们已经实现了:
PID Namespace
↓
容器内部 PID = 1
UTS Namespace
↓
容器内部 hostname = mycontainer
而在另一个宿主机终端执行:
hostname
仍然应该是:
localhost
这就证明:
我们修改的是容器自己的 hostname,而没有修改宿主机的 hostname。
到这里,我们已经创建了两个 Namespace:
Linux 进程
│
┌────────────┴────────────┐
│ │
PID Namespace UTS Namespace
│ │
PID = 1 hostname = mycontainer
但是仔细观察,我们现在仍然存在一个非常明显的问题:
pwd
依然可能是:
/workspace/myrunc/mini-runc
而且:
ls /
看到的依然是宿主机的根文件系统。
甚至刚才 ps 的问题也还没有解决。
这说明:
我们虽然改变了进程世界和 hostname,但文件系统世界依然属于宿主机。
Mount Namespace:隔离文件系统视图
Mount Namespace 用来隔离进程看到的 Mount Tree(挂载树)。
简单来说:
不同 Mount Namespace 中的进程,可以看到不同的文件系统挂载关系。
例如宿主机:
/
├── /proc
├── /sys
├── /dev
├── /home
├── /var
└── ...
而容器可以拥有自己的:
/
├── /proc
├── /sys
├── /dev
└── ...
注意,这里说的是:
“看到的挂载关系不同”
而不是:
“Linux Kernel 真的创建了一套新的磁盘。”
这和前面的 PID Namespace 是非常类似的。
PID Namespace 改变:
进程世界
Mount Namespace 改变:
文件系统视图
Linux 对应的 Flag 是:
CLONE_NEWNS
其中 NS 可以理解成 Namespace。
所以现在我们的:
Cloneflags
需要增加:
syscall.CLONE_NEWNS
变成:
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS,
}
现在我们的进程拥有三个 Namespace:
容器进程
│
├── PID Namespace
│ └── PID = 1
│
├── UTS Namespace
│ └── hostname = mycontainer
│
└── Mount Namespace
└── 独立的 Mount Tree
但是仅仅创建 Mount Namespace 还不够。
这是这一节最重要的地方。
我们现在执行:
CLONE_NEWNS
之后,确实拥有了新的 Mount Namespace。
但是如果我们什么都不做:
ls /
依然会看到宿主机的文件。
为什么?
因为:
新的 Mount Namespace 默认会从父 Namespace 复制一份当前的 Mount Tree。
可以把它想象成:
创建之前:
宿主机 Mount Namespace
│
└── Mount Tree
├── /
├── /proc
├── /sys
├── /dev
└── ...
创建之后:
宿主机 Mount Namespace
│
└── Mount Tree A
容器 Mount Namespace
│
└── Mount Tree B
├── /
├── /proc
├── /sys
├── /dev
└── ...
一开始:
Mount Tree A ≈ Mount Tree B
所以:
创建 Mount Namespace 并不会自动给你一个“干净的容器文件系统”。
它只是让你拥有了一棵可以独立修改的 Mount Tree。
这就像:
复制一张地图
你现在有了自己的地图。
接下来你可以在自己的地图上:
添加挂载
删除挂载
修改挂载
而不会直接影响宿主机的地图。
我们可以先不急着搞 rootfs。
先验证一件事情:
在新的 Mount Namespace 中进行 Mount 操作,是否真的不会影响宿主机?
修改 child():
func child() {
if err := syscall.Sethostname([]byte("mycontainer")); err != nil {
fmt.Println("set hostname error:", err)
return
}
if err := syscall.Mount(
"tmpfs",
"/tmp",
"tmpfs",
0,
"",
); err != nil {
fmt.Println("mount error:", err)
return
}
cmd := exec.Command("/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println("error:", err)
}
}
现在我们在容器进程中执行(tmpfs 是 Linux 提供的一种基于内存的临时文件系统):
syscall.Mount(
"tmpfs", // source:挂载源
"/tmp", // target:挂载到哪里
"tmpfs", // filesystem type:文件系统类型
0, // mount flags
"", // data:额外参数
)
意思是:
把一个 tmpfs 挂载到 /tmp
进入容器:
mount | grep '/tmp'
应该能够看到类似:
tmpfs on /tmp type tmpfs
然后在宿主机另一个终端执行:
mount | grep '/tmp'
如果没有对应的挂载:
没有输出
那么就说明:
容器中的 Mount 操作没有影响宿主机。
这就是 Mount Namespace 的意义。
现在回头看上一节遗留下来的问题:
ps
为什么会看到宿主机进程?
因为:
PID Namespace
+
宿主机 /proc
现在我们已经拥有:
PID Namespace
+
Mount Namespace
接下来就可以在这个独立的 Mount Namespace 中重新挂载 /proc。
也就是:
容器 Mount Namespace
│
▼
mount -t proc proc /proc
│
▼
新的 /proc
│
▼
ps
│
▼
容器自己的进程视图
所以我们把 child() 再修改一下:
func child() {
if err := syscall.Sethostname([]byte("mycontainer")); err != nil {
fmt.Println("set hostname error:", err)
return
}
if err := syscall.Mount(
"proc",
"/proc",
"proc",
0,
"",
); err != nil {
fmt.Println("mount proc error:", err)
return
}
cmd := exec.Command("/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println("error:", err)
}
}
此时:
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS,
然后进入容器:
./mini-runc
执行:
echo $$
得到:
1
执行:
hostname
得到:
mycontainer
再执行:
ps
这一次,我们终于可以看到类似:
PID USER TIME COMMAND
1 root 0:00 /bin/sh
而不是宿主机完整的进程列表。
为什么 /proc 必须重新挂载?
这里值得停下来理解一下。
/proc 并不是普通磁盘目录。
它是 Linux Kernel 提供的一个 proc filesystem:
/proc
│
└── Linux Kernel
│
├── 进程
├── 内存
├── CPU
└── ...
当 ps 执行时,它实际上是在读取:
/proc
中的进程信息。
因此:
宿主机:
/proc
↓
宿主机 PID Namespace
↓
宿主机进程
容器:
/proc
↓
容器 PID Namespace
↓
容器进程
我们需要重新挂载 /proc,就是为了让容器里的 /proc 与当前的 PID Namespace 对应。
这也是为什么之前的命令:
unshare --pid --fork --mount-proc /bin/bash
会同时使用:
--pid
--mount-proc
它们实际上分别解决了两个不同的问题:
--pid
↓
创建 PID Namespace
--mount-proc
↓
让 /proc 反映新的 PID Namespace
rootfs:给容器准备一个根文件系统
上一节,我们已经解决了一个非常重要的问题:
Mount Namespace 让容器拥有了一棵独立的 Mount Tree。
现在我们的 mini-runc 已经具备:
mini-runc
│
│ clone()
▼
容器进程
│
├── PID Namespace
│ └── PID = 1
│
├── UTS Namespace
│ └── hostname = mycontainer
│
└── Mount Namespace
└── 独立的 Mount Tree
但是如果现在进入容器:
ls /
你仍然会看到宿主机的:
bin
boot
dev
etc
home
lib
opt
root
usr
var
workspace
...
这说明:
Mount Namespace 虽然给了我们一棵独立的 Mount Tree,但这棵树最开始仍然是从宿主机复制过来的。
我们现在需要给它换上一套真正属于容器的文件系统。
rootfs,就是 root filesystem,也就是一个进程看到的根文件系统:
/
例如我们准备一个 Alpine Linux 的 rootfs:
rootfs/
├── bin/
├── dev/
├── etc/
├── home/
├── lib/
├── media/
├── mnt/
├── proc/
├── root/
├── run/
├── sbin/
├── sys/
├── tmp/
├── usr/
└── var/
注意:
这里的 rootfs 并不是一个完整的 Linux 操作系统。
它没有自己的:
Linux Kernel
systemd
硬件驱动
因为容器直接共享宿主机的 Linux Kernel。
所以可以把容器理解成:
宿主机
│
┌───────┴────────┐
│ │
Linux Kernel │
│ │
┌────┴────┐ ┌───┴────┐
│ │ │ │
容器进程 宿主进程 容器rootfs 宿主rootfs
Kernel 是共享的,用户空间文件系统可以不同。
这也是为什么:
Alpine 容器
Ubuntu 容器
Debian 容器
可以同时运行在同一个 Linux Kernel 上。
rootfs 本质上就是:
一组普通的 Linux 文件。
比如:
rootfs/
├── bin/sh
├── etc/passwd
├── lib/...
└── usr/...
这些文件和你宿主机上的文件没有什么本质区别。
真正特殊的地方在于:
我们后面会把这个目录设置成容器进程看到的
/。
假设我们的项目目录:
mini-runc/
├── main.go
├── mini-runc
└── rootfs/
├── bin/
├── etc/
├── lib/
└── usr/
宿主机看到的是:
/workspace/myrunc/mini-runc/rootfs
但是容器最终应该看到:
/
也就是:
宿主机:
/workspace/myrunc/mini-runc/rootfs
│
▼
rootfs/
容器:
/
├── bin
├── etc
├── lib
└── usr
为了让实验尽可能简单,我们继续使用 Alpine Linux。
前面已经下载过:
alpine-minirootfs-3.22.1-x86_64.tar.gz
这个文件名字里的:
minirootfs
非常重要。
它不是完整的 Alpine 安装镜像,而是:
一个可以直接作为容器 rootfs 使用的最小用户空间。
其中最重要的是:
rootfs/bin/sh
因为我们的 mini-runc 最终需要:
exec.Command("/bin/sh")
这个 /bin/sh 必须存在于容器自己的 rootfs 中。
你可能会想:
“既然 rootfs 里面有
/bin/sh,那我直接执行rootfs/bin/sh不就行了吗?”
例如:
./rootfs/bin/sh
确实可以启动。
但是这和容器完全不是一回事。
此时:
当前进程
│
└── /rootfs/bin/sh
这个 Shell 仍然处于宿主机的文件系统环境中。
执行:
pwd
依然是宿主机路径。
执行:
ls /
仍然看到宿主机的 /。
所以:
执行 rootfs/bin/sh
只是:
运行了 rootfs 里面的一个程序。
而我们真正想要的是:
让 rootfs 变成 /
也就是:
宿主机:
/workspace/myrunc/mini-runc/rootfs
│
│ 变成容器的 /
▼
容器:
/
├── bin
├── etc
├── lib
└── usr
这就涉及一个非常关键的操作:
pivot_root
不过在进入 pivot_root 之前,我们先把 rootfs 和 Mount Namespace 的关系彻底搞清楚。
现在我们的 Mount Namespace 是:
容器 Mount Namespace
│
▼
Mount Tree
│
└── /
│
└── 宿主机 rootfs
我们希望把它变成:
容器 Mount Namespace
│
▼
Mount Tree
│
└── /
│
└── Alpine rootfs
也就是说:
Mount Namespace
+
rootfs
↓
容器自己的文件系统视图
所以这两个概念不要混在一起:
Mount Namespace负责:
让我拥有一棵可以独立修改的 Mount Tree。
rootfs负责:
给这棵 Mount Tree 提供容器真正使用的根文件系统。
这也是为什么我们之前必须先创建:
syscall.CLONE_NEWNS
如果没有 Mount Namespace,我们就不应该直接修改宿主机的 Mount Tree。
在还没有 pivot_root 之前,我们可以先利用 chroot 来直观理解 rootfs。
在宿主机执行:
chroot rootfs /bin/sh
如果成功,你会进入 Alpine 的 Shell。
然后:
ls /
你看到的就不再是宿主机的:
boot
home
workspace
...
而是 Alpine rootfs 中的:
bin
dev
etc
home
lib
proc
root
run
sbin
sys
tmp
usr
var
这时候你就能非常直观地理解:
rootfs 真正被当成了
/。
退出:
exit
但是 chroot 不是我们最终要用的,这里一定要注意:
chroot≠ 容器。
虽然:
chroot rootfs /bin/sh
可以让进程看到新的 /,但它本身并没有提供完整的容器隔离。
例如它没有自动提供:
PID Namespace
UTS Namespace
Mount Namespace
Network Namespace
Cgroup
Capabilities
Seccomp
而我们的 mini-runc 已经在逐步实现这些东西。
所以我们真正想做的是:
PID Namespace
+
UTS Namespace
+
Mount Namespace
+
rootfs
+
pivot_root
↓
真正的容器进程
为什么不用 chroot,而要学习 pivot_root?
这正是下一节存在的意义。
chroot() 做的事情非常直接:
当前进程
│
│ chroot(rootfs)
▼
新的 /
但它并没有真正把旧的根文件系统从 Mount Tree 中移除。
而容器运行时通常希望:
旧 rootfs
↓
从容器的 Mount Tree 中脱离
最终得到:
容器 Mount Namespace
/
├── bin
├── etc
├── lib
├── usr
└── ...
而不是:
容器 /
│
├── Alpine rootfs
│
└── 某种方式仍然能够访问宿主机 rootfs
所以我们下一步要学习:
pivot_root
它的名字其实已经非常直白:
pivot
↓
旋转 / 切换
root
↓
根文件系统
也就是:
把当前 Mount Namespace 的根文件系统“旋转”到我们指定的新 rootfs 上。
pivot_root:真正切换容器的根目录
pivot_root() 是 Linux 提供的系统调用,用来:
交换当前 Mount Namespace 中的根文件系统和指定的新根文件系统。
也就是:
旧 /
│
│ pivot_root()
▼
新 /
但它和 chroot() 有一个非常重要的区别。
前面我们做过:
chroot rootfs /bin/sh
它确实可以让:
ls /
看到 Alpine rootfs。
所以你可能会问:
既然 chroot 已经可以把 rootfs 变成
/,为什么还要学习 pivot_root?
因为 chroot() 做的事情主要是:
改变进程对
/的解析方式。
而 pivot_root() 操作的是:
当前 Mount Namespace 的根挂载。
这两个概念并不完全相同。
可以简单理解成:
chroot:
进程
│
└── “以后你把这个目录当成 / 看”
pivot_root:
Mount Namespace
│
└── “以后这棵 Mount Tree 的根就是这个 rootfs”
对于容器运行时来说,后者更加符合我们真正想做的事情。
Linux 的 pivot_root() 大致是:
pivot_root(new_root, put_old)
也就是说,它需要告诉 Kernel 两件事情:
new_root
↓
新的根文件系统在哪里?
put_old
↓
旧的根文件系统放在哪里?
例如我们希望:
/rootfs
成为新的 /。
那么可以设计成:
new_root = /rootfs
put_old = /rootfs/old_root
执行之后:
执行之前:
/
├── rootfs/
│ └── old_root/
└── ...
执行之后:
/
├── bin/
├── etc/
├── lib/
├── usr/
└── old_root/
其中:
/
已经变成了原来的:
/rootfs
而原来的宿主机根文件系统被移动到了:
/old_root
为什么还要保存 old_root?
这里非常关键。
你可能会想:
“直接把旧的
/扔掉不就行了吗?”
实际上,pivot_root() 的设计就是:
/rootfs
│
│ pivot_root
▼
/
│
└── /old_root ← 暂时放着原来的 /
│
│ umount
▼
消失
先交换,然后再删除,甚至可以类比成 Go 里的变量交换:
old := root
root = newRoot
old = nil
所以真正的容器初始化过程通常是:
pivot_root
↓
新的 / 已经生效
↓
进入 /
↓
卸载 /old_root
↓
删除 /old_root
这时候容器就真正摆脱了原来的 rootfs。
Go 标准库的 syscall 包并没有在所有环境下直接提供我们想要的:
syscall.PivotRoot()
所以实际开发中,我们通常使用golang.org/x/sys/unix这个子包。
安装:
go get golang.org/x/sys
然后:
import "golang.org/x/sys/unix"
调用:
unix.PivotRoot(newRoot, putOld)
例如:
err := unix.PivotRoot("/rootfs", "/rootfs/old_root")
if err != nil {
fmt.Println("pivot_root error:", err)
return
}
还有最重要的一个要求是:
new_root必须是一个挂载点。
也就是说,我们不能简单地:
rootfs/
然后直接:
unix.PivotRoot("/rootfs", "/rootfs/old_root")
我们需要先把 rootfs bind mount 到自己身上。
听起来很奇怪:
rootfs
↓
bind mount
↓
rootfs
为什么?因为我们需要让 Kernel 明确知道:
/rootfs是一个独立的 Mount Point。
Linux 提供了一种非常有用的 Mount:
bind mount
例如:
mount --bind rootfs rootfs
它的意思不是复制文件。
而是:
把一个已经存在的目录作为另一个 Mount Point 挂载。
所以:
rootfs/
├── bin
├── etc
└── usr
执行:
mount --bind rootfs rootfs
之后,文件本身并没有复制。
只是 Mount Tree 变成:
rootfs/
│
└── Mount Point
这正好满足 pivot_root() 的要求。
这里还有一个容易踩坑的地方。
Linux 的 Mount Namespace 默认可能存在 mount propagation。
为了避免我们后面的 mount 操作传播回宿主机,容器运行时通常会把当前 Mount Tree 设置成:
MS_PRIVATE
例如:
err := unix.Mount(
"",
"/",
"",
unix.MS_PRIVATE|unix.MS_REC,
"",
)
这里:
MS_PRIVATE
表示:
当前 Mount Tree 不再向其他 Namespace 传播 Mount 事件。
而:
MS_REC
表示递归处理下面的 Mount。
所以:
unix.Mount(
"",
"/",
"",
unix.MS_PRIVATE|unix.MS_REC,
"",
)
可以理解成:
把当前 Mount Tree 设置为 private
我们把之前的 child() 继续完善。
假设目录结构:
mini-runc/
├── main.go
├── mini-runc
└── rootfs/
├── bin/
├── etc/
├── lib/
├── sbin/
└── usr/
首先:
func child() {
// ...
}
进入子进程之后,我们先设置 hostname:
if err := syscall.Sethostname([]byte("mycontainer")); err != nil {
fmt.Println("set hostname error:", err)
return
}
然后设置 Mount Tree 为 private:
if err := unix.Mount(
"",
"/",
"",
unix.MS_PRIVATE|unix.MS_REC,
"",
); err != nil {
fmt.Println("make mount private error:", err)
return
}
接下来,把 rootfs bind mount 到自己:
rootfs := "/workspace/myrunc/mini-runc/rootfs"
if err := unix.Mount(
rootfs,
rootfs,
"",
unix.MS_BIND|unix.MS_REC,
"",
); err != nil {
fmt.Println("bind mount rootfs error:", err)
return
}
现在:
/rootfs
已经成为一个 Mount Point。
然后创建:
/rootfs/old_root
oldRoot := filepath.Join(rootfs, "old_root")
if err := os.MkdirAll(oldRoot, 0700); err != nil {
fmt.Println("mkdir old_root error:", err)
return
}
现在就可以执行:
if err := unix.PivotRoot(rootfs, oldRoot); err != nil {
fmt.Println("pivot_root error:", err)
return
}
执行成功之后:
rootfs
↓
新的 /
而旧的根:
old root
↓
/old_root
pivot_root 之后为什么要 chdir(“/”)?
这是一个非常容易忽略的细节。
假设我们执行:
unix.PivotRoot(rootfs, oldRoot)
此时 Mount Tree 已经发生变化。
但是:
当前进程的工作目录并不会自动变成新的
/。
所以我们应该主动:
if err := os.Chdir("/"); err != nil {
fmt.Println("chdir error:", err)
return
}
然后我们卸载旧的 rootfs:
if err := unix.Unmount("/old_root", unix.MNT_DETACH); err != nil {
fmt.Println("unmount old root error:", err)
return
}
然后:
if err := os.RemoveAll("/old_root"); err != nil {
fmt.Println("remove old root error:", err)
return
}
这里使用:
MNT_DETACH
可以理解成“延迟卸载”,对于容器初始化来说,这是比较常见的处理方式。
现在再挂载 /proc
注意顺序。
我们现在已经完成:
pivot_root
↓
新的 /
所以现在挂载 /proc:
if err := unix.Mount(
"proc",
"/proc",
"proc",
0,
"",
); err != nil {
fmt.Println("mount proc error:", err)
return
}
这时候:
/
├── bin
├── etc
├── proc ← 当前 PID Namespace 对应的 proc
├── root
├── sys
├── tmp
└── usr
这才是我们真正想要的结构。
把前面的步骤串起来:
func child() {
// 1. 设置 hostname
if err := syscall.Sethostname([]byte("mycontainer")); err != nil {
fmt.Println("set hostname error:", err)
return
}
// 2. 设置 Mount Namespace 为 private
if err := unix.Mount(
"",
"/",
"",
unix.MS_PRIVATE|unix.MS_REC,
"",
); err != nil {
fmt.Println("make mount private error:", err)
return
}
// 3. rootfs
rootfs := "/workspace/myrunc/mini-runc/rootfs"
oldRoot := filepath.Join(rootfs, "old_root")
// 4. 将 rootfs bind mount 到自己
if err := unix.Mount(
rootfs,
rootfs,
"",
unix.MS_BIND|unix.MS_REC,
"",
); err != nil {
fmt.Println("bind mount rootfs error:", err)
return
}
// 5. 创建 old_root
if err := os.MkdirAll(oldRoot, 0700); err != nil {
fmt.Println("mkdir old_root error:", err)
return
}
// 6. 切换根文件系统
if err := unix.PivotRoot(rootfs, oldRoot); err != nil {
fmt.Println("pivot_root error:", err)
return
}
// 7. 切换当前工作目录
if err := os.Chdir("/"); err != nil {
fmt.Println("chdir error:", err)
return
}
// 8. 卸载旧 rootfs
if err := unix.Unmount("/old_root", unix.MNT_DETACH); err != nil {
fmt.Println("unmount old root error:", err)
return
}
// 9. 删除 old_root
if err := os.RemoveAll("/old_root"); err != nil {
fmt.Println("remove old root error:", err)
return
}
// 10. 挂载 proc
if err := unix.Mount(
"proc",
"/proc",
"proc",
0,
"",
); err != nil {
fmt.Println("mount proc error:", err)
return
}
// 11. 使用 execve 将当前进程替换为 Shell
if err := syscall.Exec(
"/bin/sh",
[]string{"/bin/sh"},
os.Environ(),
); err != nil {
fmt.Println("exec error:", err)
return
}
}
这里execve() 的作用是用新的程序替换当前进程正在运行的程序,他不会创建新的进程,如果使用fork/clone的话会导致容器内我们的程序PID不是1:
当然,这段代码还需要:
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"golang.org/x/sys/unix"
)
完整代码:
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"golang.org/x/sys/unix"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "child" {
child()
return
}
parent()
}
func parent() {
cmd := exec.Command("/proc/self/exe", "child")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS,
}
if err := cmd.Run(); err != nil {
fmt.Println("error:", err)
}
}
func child() {
// 1. 设置 hostname
if err := syscall.Sethostname([]byte("mycontainer")); err != nil {
fmt.Println("set hostname error:", err)
return
}
// 2. 设置 Mount Namespace 为 private
if err := unix.Mount(
"",
"/",
"",
unix.MS_PRIVATE|unix.MS_REC,
"",
); err != nil {
fmt.Println("make mount private error:", err)
return
}
// 3. rootfs
rootfs := "/workspace/myrunc/mini-runc/rootfs"
oldRoot := filepath.Join(rootfs, "old_root")
// 4. 将 rootfs bind mount 到自己
if err := unix.Mount(
rootfs,
rootfs,
"",
unix.MS_BIND|unix.MS_REC,
"",
); err != nil {
fmt.Println("bind mount rootfs error:", err)
return
}
// 5. 创建 old_root
if err := os.MkdirAll(oldRoot, 0700); err != nil {
fmt.Println("mkdir old_root error:", err)
return
}
// 6. 切换根文件系统
if err := unix.PivotRoot(rootfs, oldRoot); err != nil {
fmt.Println("pivot_root error:", err)
return
}
// 7. 切换当前工作目录
if err := os.Chdir("/"); err != nil {
fmt.Println("chdir error:", err)
return
}
// 8. 卸载旧 rootfs
if err := unix.Unmount("/old_root", unix.MNT_DETACH); err != nil {
fmt.Println("unmount old root error:", err)
return
}
// 9. 删除 old_root
if err := os.RemoveAll("/old_root"); err != nil {
fmt.Println("remove old root error:", err)
return
}
// 10. 挂载 proc
if err := unix.Mount(
"proc",
"/proc",
"proc",
0,
"",
); err != nil {
fmt.Println("mount proc error:", err)
return
}
// 11. 使用 execve 将当前进程替换为 Shell
if err := syscall.Exec(
"/bin/sh",
[]string{"/bin/sh"},
os.Environ(),
); err != nil {
fmt.Println("exec error:", err)
return
}
}
进入容器后,我们可以做几个验证:
echo $$
输出的进程号应该是 1。
再执行:
hostname
应该显示我们之前设置的 mycontainer。
然后执行最关键的一步:
ls /
现在看到的应该是 Alpine rootfs 的目录结构,如下所示:
bin
dev
etc
home
lib
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
而不应该再看到宿主机特有的目录(比如 workspace 或其他宿主机的目录)。
最后再试试:
pwd
应该输出 /。
现在我们终于可以说:
这个 Shell 已经真正站在 Alpine rootfs 上了。
现在执行:
echo "hello from container" > /hello.txt
然后:
cat /hello.txt
应该得到:
hello from container
此时再打开宿主机的另一个终端:
ls /
你应该找不到:
/hello.txt
再执行:
ls /workspace/myrunc/mini-runc/rootfs
你会发现:
hello.txt
确实存在。
这时候你应该能够真正理解我们之前讨论的:
容器:
/
└── hello.txt
实际上对应宿主机上的:
/workspace/myrunc/mini-runc/rootfs/hello.txt
只是对于容器进程来说:
/workspace/myrunc/mini-runc/rootfs
已经成为了:
/
这就是 rootfs 的核心。
刚才我们使用:
syscall.Exec(
"/bin/sh",
[]string{"/bin/sh"},
os.Environ(),
)
把当前进程替换成了 /bin/sh。
这里的:
os.Environ()
有一个很容易被忽略的问题:
它传递的是当前 mini-runc 进程的环境变量,而不是我们为容器准备的环境变量。
环境变量本质上就是一组:
KEY=VALUE
形式的字符串,例如:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/root
TERM=xterm
Linux 在执行 execve() 时,会把我们传入的 envp 作为新程序的环境。也就是说,我们完全可以决定 /bin/sh 启动以后看到什么环境。(man7.org)
我们第一次运行时遇到了一个很有意思的问题,进入容器以后:
echo $PATH
得到的是:
/root/.local/bin:/root/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
注意这里:
没有 /bin
没有 /sbin
可是我们的 Alpine rootfs 中:
/bin/ls
/bin/ps
/bin/sh
实际上都是 BusyBox 的符号链接:
/bin/ls -> /bin/busybox
/bin/ps -> /bin/busybox
/bin/sh -> /bin/busybox
因此:
/bin/busybox ls /
可以正常执行。
但是直接ls的话,Shell 会根据 PATH 去寻找 ls:
/root/.local/bin/ls ❌
/root/bin/ls ❌
/usr/local/sbin/ls ❌
/usr/local/bin/ls ❌
/usr/sbin/ls ❌
/usr/bin/ls ❌
最后自然只能得到:
/bin/sh: ls: not found
这里的 not found 并不是说:
/bin/ls
这个文件真的不存在。
而是:
Shell 按照当前
PATH搜索时,没有找到它。
这也解释了为什么:
/bin/busybox ls /
可以运行——因为我们直接指定了完整路径,根本不需要搜索 PATH。
为什么容器会继承宿主机的 PATH?
因为我们写的是:
os.Environ()
相当于:
宿主机环境变量
│
│ os.Environ()
▼
mini-runc
│
│ execve()
▼
容器 /bin/sh
这在普通程序中通常没什么问题,但对于容器来说却不一定合适。
因为:
容器已经拥有自己的 rootfs,那么它里面的程序环境也应该尽可能针对这个 rootfs 进行设置。
宿主机可能存在:
/root/.local/bin
/usr/local/bin
某些宿主机专用目录
但这些路径在容器的 rootfs 中可能根本不存在。
因此,一个真正的容器运行时不会简单地把宿主机环境变量全部原样塞给容器,而是会根据容器配置构造合适的环境。
我们可以把:
os.Environ()
修改成我们自己的环境:
env := []string{
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"HOME=/root",
"TERM=xterm",
}
然后:
if err := syscall.Exec(
"/bin/sh",
[]string{"/bin/sh"},
env,
); err != nil {
fmt.Println("exec error:", err)
return
}
这样,整个过程就变成:
mini-runc
│
│ 创建 Namespace
│
│ 准备 rootfs
│
│ pivot_root
│
│ mount proc
│
│ 准备环境变量
▼
execve("/bin/sh", argv, envp)
│
▼
容器中的 /bin/sh
│
├── PATH=/usr/local/sbin:...:/bin
├── HOME=/root
└── TERM=xterm
重新进入容器:
echo $PATH
应该得到:
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
此时:
ls
ps
pwd
都应该可以正常执行。
到这里,我们又理解了一层,现在回头看 execve() 的三个参数:
syscall.Exec(
"/bin/sh",
[]string{"/bin/sh"},
env,
)
就非常清楚了:
execve()
│
┌────────────┼────────────┐
▼ ▼ ▼
executable argv envp
│ │ │
/bin/sh 参数列表 环境变量
其中:
executable
决定:
运行什么程序?
argv
决定:
给这个程序什么命令行参数?
envp
决定:
这个程序启动时处在什么环境中?
而这三个东西加起来,才构成了一个完整的进程启动过程。Linux 的 execve() 正是按照这种方式加载新的程序映像,并将参数和环境传递给它。(man7.org)
容器有了,为什么资源还是无限的?
到了这里,我们已经亲手把一个最基本的容器运行环境搭了出来。
回顾一下第二章,我们没有再依赖 Docker、containerd 或 runc,而是直接从 Linux 提供的原语出发,逐步完成了:
clone()
↓
PID Namespace
↓
UTS Namespace
↓
Mount Namespace
↓
rootfs
↓
pivot_root()
↓
mount /proc
↓
设置环境变量
↓
execve()
↓
/bin/sh
最终,我们已经可以得到这样一个容器:
宿主机
│
│
┌────────┴────────┐
│ │
宿主机进程 容器进程
│
PID Namespace
│
Mount Namespace
│
rootfs
│
/bin/sh
从进程的视角来看,它已经拥有了一个和宿主机不同的“世界”。
例如:
hostname
看到的是容器自己的 hostname。
ps
看到的是容器自己的进程视图。
ls /
看到的是我们准备好的 Alpine rootfs。
甚至:
echo $$
得到的就是:
1
到这里,我们可能会产生一种错觉:
既然容器已经隔离出来了,那它是不是就和一台独立的小型 Linux 机器差不多了?
答案是:还差得远。
我们现在运行一个容器:
宿主机
│
├── 普通进程
├── 普通进程
├── mini-runc
│ │
│ └── 容器
│ ├── PID Namespace
│ ├── Mount Namespace
│ └── /bin/sh
│
└── ...
虽然容器已经拥有自己的 Namespace,但有一个问题:
容器里的进程到底能够使用多少 CPU?
答案是:
只要没有额外限制,它理论上可以使用宿主机能够提供给它的 CPU。
那么内存呢?
容器进程
│
├── 申请 100 MB
├── 申请 500 MB
├── 申请 1 GB
└── 继续申请……
Namespace 并不会因为它是“容器”就自动阻止它,甚至进程数量也是一样。
我们已经通过PID Namespace让容器看到一个独立的 PID 世界:
容器:
PID 1
PID 2
PID 3
...
但这并不意味着: “这个容器最多只能创建 10 个进程”,如果容器中的程序不断 fork():
PID 1
├── PID 2
├── PID 3
├── PID 4
├── PID 5
├── ...
└── PID 10000
只要宿主机资源允许,它就可以继续创建。
所以这里出现了一个非常重要的区别:
Namespace
↓
解决“你能看到什么”
而我们现在需要解决的是:
Cgroup
↓
解决“你能使用多少”
Cgroups:Linux 的资源控制器
Cgroup 是 Linux 内核提供的一套进程资源管理机制。
它的核心思想非常简单:
一组进程
│
▼
Cgroup
│
├── CPU
├── Memory
├── PIDs
└── IO
我们可以把多个进程放进同一个 Cgroup,然后由 Linux Kernel 对这一组进程统一进行资源控制。
例如:
Cgroup
│
├── nginx
├── worker
└── helper
然后规定:
CPU ≤ 50%
Memory ≤ 512 MB
PIDs ≤ 100
那么这个 Cgroup 中的进程就会受到这些限制。
Cgroup 不是进程,也不会创建新的进程。
一个进程本来就存在于 Linux 的进程树中:
systemd
│
└── containerd
│
└── nginx
同时,它还可以属于某个 Cgroup:
Cgroup
│
└── nginx
所以:
进程树描述的是“谁创建了谁”,Cgroup 描述的是“哪些进程受到同一组资源限制”。
这两套关系可以同时存在。
Linux 通过一个特殊的虚拟文件系统提供 Cgroup 接口:
/sys/fs/cgroup/
我们后面并不是通过某个专门的“Cgroup API”去操作它,而是会直接看到各种控制文件,例如:
cpu.max
memory.max
pids.max
cgroup.procs
这些文件分别用于:
cpu.max
→ CPU 限制
memory.max
→ 内存限制
pids.max
→ 进程数量限制
cgroup.procs
→ 哪些进程属于这个 Cgroup
也就是说,Cgroup 对用户空间暴露的是一种非常直观的:
“创建控制组 → 设置控制参数 → 把进程加入控制组”
Linux 早期使用的是 Cgroup v1。它允许不同的资源控制器使用不同的 hierarchy,例如 CPU、Memory 可以分别挂在不同的层级中。
这种设计虽然灵活,但也带来了比较复杂的管理方式。
而 Cgroup v2 对此进行了重新设计,最大的变化就是:
使用一棵统一的层级树管理所有 Cgroup。 (Linux 内核档案)
因此可以简单理解成:
Cgroup v1
CPU hierarchy
├── A
└── B
Memory hierarchy
├── A
└── B
PIDs hierarchy
├── A
└── B
而 Cgroup v2:
Cgroup v2
└── 统一 hierarchy
├── container-a
├── container-b
└── container-c
不同的资源控制器都工作在这棵统一的 Cgroup 树上。
在现代 Linux 系统中,Cgroup v2 通常挂载在:
/sys/fs/cgroup
可以先检查:
mount | grep cgroup
如果看到类似:
cgroup2 on /sys/fs/cgroup type cgroup2

就说明当前使用的是 Cgroup v2,也可以:
ls /sys/fs/cgroup
通常会看到一些以 cgroup. 开头的文件,例如:
cgroup.controllers
cgroup.procs
cgroup.subtree_control
cgroup.events
这些就是 Cgroup v2 提供的核心接口。Linux 内核文档也将 cgroup.controllers、cgroup.procs 和 cgroup.subtree_control 作为 v2 hierarchy 的基础接口。 (Linux 内核档案)
我们可以把它简单理解成:
/sys/fs/cgroup/
│
├── cgroup.controllers
├── cgroup.procs
├── cgroup.subtree_control
│
└── mycontainer/
├── cgroup.procs
├── cpu.max
├── memory.max
└── pids.max
比如我们创建:
mkdir /sys/fs/cgroup/mycontainer
就得到一个新的 Cgroup:
/sys/fs/cgroup/mycontainer/
然后把某个进程的 PID 写入:
cgroup.procs
这个进程就加入了该 Cgroup。
为什么有些控制文件一开始看不到?
这是 Cgroup v2 一个非常重要的特点。
比如你创建:
mkdir /sys/fs/cgroup/mycontainer
之后,可能发现里面并没有:
cpu.max
memory.max
pids.max
这并不奇怪,因为 Cgroup v2 中,控制器需要在父 Cgroup 的 cgroup.subtree_control 中启用后,才能向子层级提供对应的控制接口。 (Linux 内核档案)
例如:
cat /sys/fs/cgroup/cgroup.controllers
可能得到:
cpuset cpu io memory pids
表示当前系统支持这些 controller。
然后:
cat /sys/fs/cgroup/cgroup.subtree_control
看看当前已经启用了哪些。
可以通过:
echo "+cpu +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control
启用它们。
之后子 Cgroup 中才会出现对应的:
cpu.max
memory.max
pids.max
这里有一个特别重要的层级关系
Cgroup v2 是一个自上而下的资源控制层级。
例如:
root
└── containers
├── nginx
└── redis
父 Cgroup 给子 Cgroup 分配了资源控制能力以后,子 Cgroup 才能进一步对自己的子层级进行限制;子层级的限制不能突破父层级已经施加的限制。 (Linux 内核档案)
所以可以理解成:
父 Cgroup
│
│ 资源边界
▼
子 Cgroup
│
│ 进一步限制
▼
容器进程
这也是 Cgroup v2 能够统一管理大量容器的基础。
回到我们的 mini-runc,之前:
mini-runc
│
└── 容器进程
接下来变成:
mini-runc
│
├── 创建容器进程
│
└── 创建 Cgroup
│
└── 把容器进程加入
最终:
Cgroup v2
└── mini-runc
└── container
├── nginx
└── ...
然后我们就可以开始真正设置:
cpu.max
memory.max
pids.max
让这个容器从:
“可以随便使用宿主机资源”
变成:
“只能在规定的资源边界内运行”
CPU:限制容器的 CPU 使用
Cgroup v2 的 CPU controller 提供了一个非常重要的文件:
cpu.max
它用来限制一个 Cgroup 在单位时间内最多能够使用多少 CPU 时间。cpu.max 的格式是:
$MAX $PERIOD
例如:
50000 100000
表示:
每 100000 微秒的时间周期内,最多使用 50000 微秒的 CPU 时间。
也就是大约 50% 的一个 CPU 时间份额。Linux 内核文档规定 cpu.max 默认值为 max 100000,其中 max 表示不设置带宽上限。(Linux Kernel Archives)
所以可以简单理解:
cpu.max = max 100000
↓
不限制
cpu.max = 50000 100000
↓
限制 CPU 带宽
需要注意,这里的“50%”不是:
永远只能使用某个 CPU 核心的 50%。
而是按照这个时间周期限制该 Cgroup 可以获得的 CPU 时间。具体调度仍然由 Linux scheduler 完成。(Linux Kernel Archives)
我们先手工创建一个 Cgroup。
确认当前系统使用的是 Cgroup v2:
mount | grep cgroup
如果看到:
cgroup2 on /sys/fs/cgroup type cgroup2
就可以继续。
进入 Cgroup 根目录:
/sys/fs/cgroup
创建一个测试 Cgroup:
mkdir cpu-test
然后:
ls cpu-test
如果 CPU controller 已经对这个子层级可用,里面应该能够看到:
cpu.max
cpu.weight
...

先启动一个非常简单的 CPU 密集型程序:
while true; do :; done
由于它一直执行空循环,会尽可能占用一个 CPU 核心。
更好的办法是直接把这个实验进程单独启动,例如:
while true; do :; done &
echo $!
假设得到:
12345
这个 12345 就是我们刚刚启动的 CPU 密集型进程。
然后查看它的 CPU 使用率:
ps -p 12345 -o pid,pcpu,comm
可能看到:
PID %CPU COMMAND
12345 99.9 bash
这里已经能确认:
这个进程正在吃掉一个 CPU 核心附近的 CPU 时间。
创建:
mkdir /sys/fs/cgroup/cpu-test
确认 CPU controller:
cat /sys/fs/cgroup/cpu.controllers
如果包含:
cpu
说明 CPU controller 可用。
然后把进程加入:
echo 12345 > /sys/fs/cgroup/cpu-test/cgroup.procs
现在:
cat /sys/fs/cgroup/cpu-test/cgroup.procs
应该看到:
12345
这一步验证的是:
进程已经进入
cpu-testCgroup。
但到这里,还没有验证 CPU 限制。
现在:
echo "50000 100000" > /sys/fs/cgroup/cpu-test/cpu.max
意思是:
每 100000 微秒
最多使用 50000 微秒 CPU 时间
也就是:
CPU bandwidth ≈ 50%
然后:
cat /sys/fs/cgroup/cpu-test/cpu.max
应该得到:
50000 100000
重新观察:
ps -p 12345 -o pid,pcpu,comm
你应该会发现:
PID %CPU COMMAND
12345 49.x bash
相比限制前:
12345 99.x bash
CPU 使用率明显下降。
也就是:
没有 Cgroup:
CPU
████████████████████ ~100%
cpu.max = 50000 100000:
CPU
██████████ ~50%
这才证明:
Cgroup 的 CPU 限制真正作用到了这个进程。
为什么不是精确的 50.0%?
这里也值得说明一下。
你看到的可能是:
49.7
50.1
48.9
而不是永远:
50.0
这是正常的。
因为 ps 显示的是一段时间内统计出来的 CPU 使用率,而 Linux scheduler 本身也是动态调度的。
我们要观察的是:
限制前 ≈ 100%
限制后 ≈ 50%
这个数量级上的明显变化,而不是追求一个精确到小数点后一位的数字。
甚至可以直接使用 top
你也可以:
top -p 12345
限制前可能:
PID %CPU
12345 99.9
然后结束测试进程:
kill 12345
然后:
rmdir /sys/fs/cgroup/cpu-test
所以我们的 mini-runc 后面真正需要做的,也不过是把这几步程序化:
mini-runc
│
├── 创建容器进程
│
├── 创建 Cgroup
│
├── 设置 cpu.max
│
├── 将容器 PID 加入 cgroup.procs
│
└── execve()
到这里,一个非常重要的认识就建立起来了:
Cgroup 并不是通过“监控 CPU 使用率,然后超过阈值就杀掉进程”来限制 CPU。
CPU controller 直接参与 Linux 的 CPU 时间分配,通过 cpu.max 对 Cgroup 的 CPU bandwidth 设置上限。(Linux Kernel Archives)
创建 Cgroup
在 Cgroup v2 中,创建一个新的 Cgroup,本质上就是创建一个目录:
mkdir /sys/fs/cgroup/mini-runc
在 Go 中就是:
const cgroupPath = "/sys/fs/cgroup/mini-runc"
if err := os.MkdirAll(cgroupPath, 0755); err != nil {
fmt.Println("create cgroup error:", err)
return
}
设置 CPU 限制
然后向:
/sys/fs/cgroup/mini-runc/cpu.max
写入:
50000 100000
Go 中:
if err := os.WriteFile(
filepath.Join(cgroupPath, "cpu.max"),
[]byte("50000 100000"),
0644,
); err != nil {
fmt.Println("set cpu limit error:", err)
return
}
现在这个 Cgroup 的 CPU 带宽就被限制为大约 50%。
把容器进程加入 Cgroup
我们前面已经知道:
cmd.Process.Pid
可以拿到我们创建出来的子进程 PID。
所以在 cmd.Start() 成功以后:
if err := os.WriteFile(
filepath.Join(cgroupPath, "cgroup.procs"),
[]byte(strconv.Itoa(cmd.Process.Pid)),
0644,
); err != nil {
fmt.Println("add process to cgroup error:", err)
return
}
需要增加:
import "strconv"
这样流程就变成:
mini-runc
│
│ 创建 child
▼
container process
│
│ 获取 PID
▼
cmd.Process.Pid
│
│ 写入 cgroup.procs
▼
Cgroup
│
└── cpu.max = 50000 100000
这里有一个必须修改的地方
func parent() {
cmd := exec.Command("/proc/self/exe", "child")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS,
}
if err := cmd.Run(); err != nil {
fmt.Println("error:", err)
}
}
这里不能继续使用:
cmd.Run()
因为我们现在需要:
先启动子进程 → 得到 PID → 加入 Cgroup → 让它继续执行。
所以需要改成:
func parent() {
cmd := exec.Command("/proc/self/exe", "child")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS,
}
// 1. 启动容器进程
if err := cmd.Start(); err != nil {
fmt.Println("start container error:", err)
return
}
// 2. 创建 Cgroup
const cgroupPath = "/sys/fs/cgroup/mini-runc"
if err := os.MkdirAll(cgroupPath, 0755); err != nil {
fmt.Println("create cgroup error:", err)
return
}
// 3. 设置 CPU 限制
if err := os.WriteFile(
filepath.Join(cgroupPath, "cpu.max"),
[]byte("50000 100000"),
0644,
); err != nil {
fmt.Println("set cpu limit error:", err)
return
}
// 4. 将容器进程加入 Cgroup
if err := os.WriteFile(
filepath.Join(cgroupPath, "cgroup.procs"),
[]byte(strconv.Itoa(cmd.Process.Pid)),
0644,
); err != nil {
fmt.Println("add process to cgroup error:", err)
return
}
// 5. 等待容器进程退出
if err := cmd.Wait(); err != nil {
fmt.Println("error:", err)
}
}
为什么这里必须使用 Start()?
因为:
cmd.Run()
内部相当于:
Start()
↓
等待进程退出
一旦调用:
cmd.Run()
父进程就一直等着子进程运行结束。
我们根本没有机会在中间:
Start()
↓
拿 PID
↓
加入 Cgroup
↓
Wait()
所以这里必须拆成:
cmd.Start()
cmd.Process.Pid
cmd.Wait()
不过这里还有一个实验问题,现在我们的容器启动之后直接执行:
/bin/sh
它不会主动消耗大量 CPU。
所以即使我们设置:
cpu.max = 50000 100000
进入容器后执行:
ps
也看不出什么明显区别。
因此我们可以直接在容器里运行一个 CPU 密集型程序:
while true; do :; done
然后从宿主机观察:
ps -p <容器PID> -o pid,pcpu,comm
此时应该看到 CPU 使用率被限制在大约 50% 左右。
你也可以进入容器后直接运行:
while true; do :; done
然后另开一个终端,根据宿主机 PID 查看:
ps -p <PID> -o pid,pcpu,comm
Memory:限制容器的内存使用
CPU 限制解决之后,我们继续看一个更直观的问题:
如果容器里的程序疯狂申请内存,会发生什么?
默认情况下,容器中的进程和普通宿主机进程一样,都可以继续申请内存,直到受到宿主机整体内存、OOM 等机制的影响。
也就是说:
容器
│
└── 程序
│
├── 100 MB
├── 500 MB
├── 1 GB
├── 2 GB
└── ...
如果我们希望规定:
这个容器最多只能使用 100 MB 内存。
就需要使用 Cgroup v2 的:
memory.max
memory.max 用于设置一个 Cgroup 的内存使用上限。
例如:
echo 100M > /sys/fs/cgroup/memory-test/memory.max
或者直接使用字节:
echo 104857600 > /sys/fs/cgroup/memory-test/memory.max
表示:
这个 Cgroup 的 memory usage
不能超过 100 MB
读取:
cat /sys/fs/cgroup/memory-test/memory.max
应该得到:
104857600
如果:
memory.max = max
则表示没有设置硬性内存上限。
和 CPU 实验一样,我们先不修改 mini-runc,直接创建一个测试 Cgroup。
mkdir /sys/fs/cgroup/memory-test
确认 Memory controller 可用:
cat /sys/fs/cgroup/cgroup.controllers
如果里面包含:
memory
就可以继续。
然后设置:
echo 100M > /sys/fs/cgroup/memory-test/memory.max
写一个简单的 Go 程序:
package main
import (
"fmt"
"time"
)
func main() {
var data [][]byte
for {
buf := make([]byte, 10*1024*1024) // 10 MB
data = append(data, buf)
fmt.Printf("allocated: %d MB\n", len(data)*10)
time.Sleep(time.Second)
}
}
这个程序每秒申请:
10 MB
而且把申请到的内存保存到 data 中,所以这些内存不会立刻被 GC 回收。
编译:
go build -o mem-hog mem-hog.go
先直接运行:
./mem-hog
你会看到类似:
allocated: 10 MB
allocated: 20 MB
allocated: 30 MB
allocated: 40 MB
...
程序会持续申请内存。
用:
ps -p <PID> -o pid,rss,comm
假设 mem-hog 的 PID 是:
12345
执行:
echo 12345 > /sys/fs/cgroup/memory-test/cgroup.procs
现在:
memory-test
│
└── PID 12345
这个进程已经进入了:
memory-test
接下来再设置:
echo 100M > /sys/fs/cgroup/memory-test/memory.max
程序会继续:
10 MB
20 MB
30 MB
...
90 MB
100 MB
当它继续申请内存并超过:
100 MB
Cgroup 的 Memory controller 就会介入。
默认情况下,如果无法满足新的内存分配,可能触发该 Cgroup 内的 OOM 处理,进程最终可能被杀掉。
因此实验结果可能类似:
allocated: 80 MB
allocated: 90 MB
allocated: 100 MB
Killed
这里的 Killed 就非常有意义:
不是宿主机整体内存耗尽,而是这个进程所在的 Cgroup 已经碰到了
memory.max设置的边界。
Linux 内核文档也说明了,当达到 memory.max 且无法回收内存时,会在该 Cgroup 内触发 OOM killer。
还可以通过 memory.events 观察,Cgroup v2 提供了:
cat /sys/fs/cgroup/memory-test/memory.events
可能看到:
low 0
high 0
max 15
oom 1
oom_kill 1
oom_group_kill 0
这里:
max
表示内存使用曾经触碰到 memory.max。
而:
oom_kill
表示 Cgroup 内发生过因为内存压力导致的 OOM kill。
所以相比单纯观察:
Killed
memory.events 能让我们从 Cgroup 自己的角度确认到底发生了什么。
然后我们把 Memory 限制加入 mini-runc:
const cgroupPath = "/sys/fs/cgroup/mini-runc"
创建:
if err := os.MkdirAll(cgroupPath, 0755); err != nil {
fmt.Println("create cgroup error:", err)
return
}
设置内存限制:
if err := os.WriteFile(
filepath.Join(cgroupPath, "memory.max"),
[]byte("104857600"),
0644,
); err != nil {
fmt.Println("set memory limit error:", err)
return
}
这里:
104857600 bytes
就是:
100 MB
然后和 CPU 一样:
if err := os.WriteFile(
filepath.Join(cgroupPath, "cgroup.procs"),
[]byte(strconv.Itoa(cmd.Process.Pid)),
0644,
); err != nil {
fmt.Println("add process to cgroup error:", err)
return
}
到这里可以把两个 Controller 放在一起:
Cgroup
│
├── cpu.max
│ ↓
│ 限制 CPU bandwidth
│
└── memory.max
↓
限制内存上限
CPU 的思路是:
控制这个 Cgroup 在一定时间内能够获得多少 CPU 时间。
Memory 的思路则是:
控制这个 Cgroup 能够使用的内存上限。
两者的控制方式不同,但使用 Cgroup 的基本流程完全一致:
创建 Cgroup
↓
设置 controller
↓
把进程加入 cgroup.procs
↓
Linux Kernel 执行限制
这也正是为什么我们前面说:
Cgroup 本身并不关心“容器”这个概念,它只是对属于同一个控制组的 Linux 进程施加资源限制。
PIDs:限制容器能够创建的进程数量
前面我们已经通过 PID Namespace 让容器拥有了自己的进程世界:
宿主机:
PID 1000
PID 1001
PID 1002
...
容器:
PID 1
PID 2
PID 3
...
但这里存在一个容易混淆的问题:
PID Namespace 只改变进程的视图,并不限制进程的数量。
例如,我们现在的容器中只有:
PID 1
└── /bin/sh
如果这个 Shell 不断创建子进程:
PID 1
├── PID 2
├── PID 3
├── PID 4
├── PID 5
├── ...
└── PID N
只要宿主机还有能力创建进程,它就可以继续增长。
因此,我们还需要一个 Cgroup controller:
pids
Cgroup v2 通过:
/sys/fs/cgroup/.../pids.max
限制一个 Cgroup 中最多可以拥有多少个进程。
pids.max 表示一个 Cgroup 中允许存在的最大进程数。
例如:
echo 10 > /sys/fs/cgroup/pids-test/pids.max
表示:
这个 Cgroup 最多允许 10 个进程。
也可以:
cat /sys/fs/cgroup/pids-test/pids.max
查看当前限制:
10
如果设置为:
max
则表示不限制进程数量。
先创建测试 Cgroup:
mkdir /sys/fs/cgroup/pids-test
确认 PIDs controller 可用:
cat /sys/fs/cgroup/cgroup.controllers
如果输出中包含:
pids
说明可以使用。
然后设置一个比较小的限制:
echo 10 > /sys/fs/cgroup/pids-test/pids.max
为了实验更加直观,我们直接写一个 Go 程序:
package main
import (
"fmt"
"os/exec"
"time"
)
func main() {
var children []*exec.Cmd
for i := 0; i < 20; i++ {
cmd := exec.Command("sleep", "1000")
if err := cmd.Start(); err != nil {
fmt.Printf("create process %d failed: %v\n", i+1, err)
break
}
children = append(children, cmd)
fmt.Printf("created process: %d, pid=%d\n", i+1, cmd.Process.Pid)
time.Sleep(100 * time.Millisecond)
}
select {}
}
编译:
go build -o pid-hog pid-hog.go
这个程序会不断创建:
sleep
sleep
sleep
sleep
...
假设它的 PID 是:
12345
把它加入:
echo 12345 > /sys/fs/cgroup/pids-test/cgroup.procs
然后观察:
cat /sys/fs/cgroup/pids-test/pids.current
你会看到类似:
1
这里的 1 就是当前 Cgroup 中已经存在的进程数量。
此时 pid-hog 会不断执行:
fork/clone
↓
创建新的 sleep
而这些新进程会继承父进程所属的 Cgroup,因此会受到:
pids.max = 10
的限制。
当达到上限后,继续创建进程会失败。
你会看到类似:
created process: 1, pid=...
created process: 2, pid=...
created process: 3, pid=...
...
created process: 9, pid=...
create process 10 failed: fork/exec ...: resource temporarily unavailable
具体错误信息可能会有所不同,但核心现象就是:
Cgroup 阻止了继续创建进程。
和 Memory controller 类似,PIDs controller 也提供了状态信息。
查看:
cat /sys/fs/cgroup/pids-test/pids.events
可能看到:
max 1
其中:
max
表示:
曾经有进程创建操作因为
pids.max而被拒绝。
所以我们不仅可以观察程序报错,还可以从 Cgroup 本身确认限制确实被触发了。
pids.current 和 pids.max
这里有两个文件非常值得记住:
pids.current
↓
当前 Cgroup 中有多少个进程
pids.max
↓
最多允许多少个进程
例如:
pids.current = 8
pids.max = 10
表示:
当前:8 个进程
上限:10 个进程
如果继续创建两个:
当前:10
上限:10
再继续创建:
创建失败
继续改造我们的 mini-runc:
if err := os.WriteFile(
filepath.Join(cgroupPath, "pids.max"),
[]byte("100"),
0644,
); err != nil {
fmt.Println("set pids limit error:", err)
return
}
例如:
Cgroup
│
├── cpu.max
│ └── 50%
│
├── memory.max
│ └── 100 MB
│
└── pids.max
└── 100 processes
然后还是:
os.WriteFile(
filepath.Join(cgroupPath, "cgroup.procs"),
[]byte(strconv.Itoa(cmd.Process.Pid)),
0644,
)
把容器进程加入 Cgroup。
由于子进程默认会继续处于相同的 Cgroup 中,因此容器内部创建的进程也会受到 pids.max 的约束。
从 Cgroup 到 mini-runc:给容器加上资源边界
结合前面所有内容,我们现在可以得到一个完整版本:
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"golang.org/x/sys/unix"
)
const (
rootfs = "/workspace/myrunc/mini-runc/rootfs"
cgroupPath = "/sys/fs/cgroup/mini-runc"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "child" {
child()
return
}
parent()
}
func parent() {
cmd := exec.Command("/proc/self/exe", "child")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS,
}
// 1. 创建容器进程
if err := cmd.Start(); err != nil {
fmt.Println("start container error:", err)
return
}
// 2. 设置 Cgroup
if err := setupCgroup(cmd.Process.Pid); err != nil {
fmt.Println("setup cgroup error:", err)
_ = cmd.Process.Kill()
_ = cmd.Wait()
return
}
// 3. 等待容器退出
if err := cmd.Wait(); err != nil {
fmt.Println("container exited:", err)
}
}
func setupCgroup(pid int) error {
// 创建 Cgroup
if err := os.MkdirAll(cgroupPath, 0755); err != nil {
return fmt.Errorf("create cgroup: %w", err)
}
// CPU:约 50%
if err := os.WriteFile(
filepath.Join(cgroupPath, "cpu.max"),
[]byte("50000 100000"),
0644,
); err != nil {
return fmt.Errorf("set cpu.max: %w", err)
}
// Memory:100 MB
if err := os.WriteFile(
filepath.Join(cgroupPath, "memory.max"),
[]byte("104857600"),
0644,
); err != nil {
return fmt.Errorf("set memory.max: %w", err)
}
// PIDs:最多 100 个进程
if err := os.WriteFile(
filepath.Join(cgroupPath, "pids.max"),
[]byte("100"),
0644,
); err != nil {
return fmt.Errorf("set pids.max: %w", err)
}
// 将容器进程加入 Cgroup
if err := os.WriteFile(
filepath.Join(cgroupPath, "cgroup.procs"),
[]byte(strconv.Itoa(pid)),
0644,
); err != nil {
return fmt.Errorf("add process to cgroup: %w", err)
}
return nil
}
func child() {
// 1. 设置 hostname
if err := syscall.Sethostname([]byte("mycontainer")); err != nil {
fmt.Println("set hostname error:", err)
return
}
// 2. 设置 Mount Namespace 为 private
if err := unix.Mount(
"",
"/",
"",
unix.MS_PRIVATE|unix.MS_REC,
"",
); err != nil {
fmt.Println("make mount private error:", err)
return
}
// 3. 准备 rootfs
oldRoot := filepath.Join(rootfs, "old_root")
// 4. 将 rootfs bind mount 到自己
if err := unix.Mount(
rootfs,
rootfs,
"",
unix.MS_BIND|unix.MS_REC,
"",
); err != nil {
fmt.Println("bind mount rootfs error:", err)
return
}
// 5. 创建 old_root
if err := os.MkdirAll(oldRoot, 0700); err != nil {
fmt.Println("mkdir old_root error:", err)
return
}
// 6. 切换根文件系统
if err := unix.PivotRoot(rootfs, oldRoot); err != nil {
fmt.Println("pivot_root error:", err)
return
}
// 7. 切换工作目录
if err := os.Chdir("/"); err != nil {
fmt.Println("chdir error:", err)
return
}
// 8. 卸载旧 rootfs
if err := unix.Unmount("/old_root", unix.MNT_DETACH); err != nil {
fmt.Println("unmount old root error:", err)
return
}
// 9. 删除 old_root
if err := os.RemoveAll("/old_root"); err != nil {
fmt.Println("remove old root error:", err)
return
}
// 10. 挂载 /proc
if err := unix.Mount(
"proc",
"/proc",
"proc",
0,
"",
); err != nil {
fmt.Println("mount proc error:", err)
return
}
// 11. 设置容器环境
env := []string{
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"HOME=/root",
"TERM=xterm",
}
// 12. 将当前 PID 1 替换为 /bin/sh
if err := syscall.Exec(
"/bin/sh",
[]string{"/bin/sh"},
env,
); err != nil {
fmt.Println("exec error:", err)
return
}
}
编译:
go build -o mini-runc .
运行:
sudo ./mini-runc
进入容器之后:
echo $$
应该:
1
查看 Cgroup:
cat /sys/fs/cgroup/mini-runc/cgroup.procs
应该能看到容器进程对应的 PID。
然后:
cat /sys/fs/cgroup/mini-runc/cpu.max
应该:
50000 100000
再:
cat /sys/fs/cgroup/mini-runc/memory.max
应该:
104857600
以及:
cat /sys/fs/cgroup/mini-runc/pids.max
应该:
100
这说明我们的容器进程已经进入了这个 Cgroup,并拥有:
CPU ≈ 50%
Memory ≤ 100 MB
PIDs ≤ 100
现在我们终于可以把前面所有章节连起来:
mini-runc
│
创建容器进程
│
┌──────────────┴──────────────┐
│ │
Namespace Cgroup
│ │
┌──────┼──────┐ ┌───────┼───────┐
│ │ │ │ │ │
PID UTS Mount CPU Memory PIDs
│ │ │ │ │ │
│ │ rootfs 50% 100MB 100
│ │ │
└──────┴──────┘
│
pivot_root
│
/proc
│
environment
│
execve()
│
▼
/bin/sh
这里我们提出两个疑问:
- Docker 也有一个类似
rootfs的“模板”吗? - cgroupPath 是所有容器公用一个吗?
有,但不能简单理解成 Docker 永远维护一个固定的 rootfs/ 目录。
我们现在的 mini-runc 是:
mini-runc/
├── rootfs/
│ ├── bin/
│ ├── etc/
│ ├── lib/
│ └── usr/
└── ...
然后:
rootfs/
↓
pivot_root()
↓
容器 /
Docker 的思路也是一样的:最终一定要给 runc/container runtime 提供一个可作为容器根文件系统的 filesystem tree。
但 Docker 的镜像是分层的,例如:
nginx image
Layer 1
├── bin
├── etc
└── ...
Layer 2
├── usr
└── ...
Layer 3
└── nginx
containerd / Docker 的 snapshotter 会把这些 layer 组织成一个容器自己的 root filesystem 视图,然后再交给 runtime。
比如我们之前已经在机器上看到这个东西了:
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/
以及之前 mount 输出里的:
overlay on /var/lib/docker/rootfs/overlayfs/e233... type overlay
这其实就是非常直接的证据:
Docker Image Layers
↓
containerd snapshotter
↓
OverlayFS
↓
容器 root filesystem
↓
runc
所以 Docker 并不是:
/var/lib/docker/rootfs-template/
然后每个容器复制一份,而更接近:
镜像 Layers
↓
Snapshotter
↓
为某个容器准备一个 filesystem snapshot
↓
作为容器的 rootfs
这也是为什么 Docker 创建多个 nginx 容器,不需要每次都完整复制几百 MB 的 rootfs。
我们的 cgroupPath 是所有容器共用的吗?
const (
cgroupPath = "/sys/fs/cgroup/mini-runc"
)
如果真的这样写,那么是所有容器共用一个 Cgroup。
例如:
/sys/fs/cgroup/mini-runc/
├── cgroup.procs
├── cpu.max
├── memory.max
└── pids.max
你运行:
./mini-runc
第一个容器:
PID 1001
写进去:
cgroup.procs
└── 1001
再运行第二个:
./mini-runc
第二个容器:
PID 2001
也会进去:
cgroup.procs
├── 1001
└── 2001
那么结果就是:
两个容器共享同一套 CPU、Memory、PIDs 限制。
例如:
mini-runc Cgroup
│
├── container A
└── container B
cpu.max = 50%
这意味着 A+B 合计受到 50% CPU bandwidth 的限制,而不是:
A = 50%
B = 50%
真正的容器运行时应该怎么做?
当然是:
每个容器一个独立的 Cgroup。
例如:
/sys/fs/cgroup/
└── mini-runc/
├── container-a/
│ ├── cpu.max
│ ├── memory.max
│ ├── pids.max
│ └── cgroup.procs
│
└── container-b/
├── cpu.max
├── memory.max
├── pids.max
└── cgroup.procs
这样:
container-a
└── CPU 50%
container-b
└── CPU 20%
两个容器互不影响。
所以我们的代码应该从:
const cgroupPath = "/sys/fs/cgroup/mini-runc"
变成类似:
containerID := "mycontainer"
cgroupPath := filepath.Join(
"/sys/fs/cgroup/mini-runc",
containerID,
)
得到:
/sys/fs/cgroup/mini-runc/mycontainer
如果第二个容器:
mycontainer2
就是:
/sys/fs/cgroup/mini-runc/mycontainer2
容器已经隔离和限流了,但 root 还是太强
到这里,我们的 mini-runc 已经具备了容器最核心的两层能力。
第一层是 Namespace:
id="1k7z5r"
Namespace
↓
隔离
它让容器拥有自己的 PID、hostname 和文件系统视图。
第二层是 Cgroup:
id="v9c2wa"
Cgroup
↓
限制资源
它让容器的 CPU、Memory、PIDs 等资源受到控制。
所以现在我们的容器已经可以理解成:
id="m4n8j2"
容器进程
│
├── Namespace
│ └── 隔离世界
│
└── Cgroup
└── 资源边界
但是,还有一个问题。
假设我们进入容器:
id="z7y4pm"
whoami
得到:
root
这意味着容器里的进程仍然可能以 UID 0 的身份运行。
于是新的问题出现了:
既然容器里的进程是 root,那么它到底有多大的权限?
如果这个 root 和宿主机上的 root 完全等价,那么前面的 Namespace 和 Cgroup 就算做得再漂亮,容器的安全边界依然存在很大问题。
因此,我们需要进一步回答:
Linux 中的 root 到底拥有些什么权限?
Linux Capabilities:为什么容器里的 root 不是宿主机的 root
首先需要纠正一个很容易产生的误解:
UID 0 并不等于“拥有一个无法拆分的超级权限”。
Linux 很早就将传统 root 权限进一步拆分成了许多独立的 Capability。
例如:
CAP_NET_ADMIN
↓
管理网络相关配置
CAP_SYS_ADMIN
↓
大量系统管理能力
CAP_SYS_CHROOT
↓
执行 chroot
CAP_KILL
↓
向进程发送某些信号
CAP_SETUID
↓
修改 UID
这些 Capability 可以独立控制。
所以一个进程即使:
UID = 0
也不意味着它一定拥有所有 Capability。
root 和 Capability 的关系可以简单理解成:
UID = 0
↓
传统上的 root 身份
Capabilities
↓
这个 root 到底具体拥有哪些内核权限
因此:
root + 全部 capabilities
和:
root + 一部分 capabilities
其实是完全不同的权限边界。
这也是现代容器为什么可以安全地让容器内部继续使用:
UID = 0
却不一定赋予它宿主机 root 所拥有的全部能力。
在宿主机执行:
id
可能看到:
uid=0(root) gid=0(root) groups=0(root)
然后:
cat /proc/self/status | grep Cap
可能看到类似:
CapInh: 0000000000000000
CapPrm: 000001ffffffffff
CapEff: 000001ffffffffff
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000
这些十六进制数字其实就是一组 Capability 的位图。
也就是说,Linux Kernel 并不是简单地保存一个:
is_root = true
然后所有操作都放行。
而是会进一步检查:
这个进程有没有执行当前操作所需要的 Capability?
为什么容器里的 root 不应该拥有宿主机全部 Capability?
假设容器中的进程拥有某些非常强大的能力,那么它理论上可以进行一些本来不应该允许普通容器进程执行的操作。
例如:
修改网络配置
加载某些内核相关资源
操作其他进程
修改系统管理参数
执行某些特权操作
尤其是某些 Capability 非常强大。
所以容器运行时通常会做这样一件事情:
容器中的 root
│
├── 保留必要 Capability
│
└── 删除不必要 Capability
从内核角度来看,Capability 可以理解成:
把超级用户权限拆成一组可以独立检查的能力位。
例如:
Capability
├── CAP_CHOWN
├── CAP_DAC_OVERRIDE
├── CAP_FOWNER
├── CAP_KILL
├── CAP_NET_ADMIN
├── CAP_NET_RAW
├── CAP_SETUID
├── CAP_SYS_ADMIN
└── ...
进程拥有其中某个 Capability,就代表它可以执行对应的一类特权操作。
这与我们之前学习的 Namespace、Cgroup 很像:
Namespace
→ 隔离“看到什么”
Cgroup
→ 限制“使用多少”
Capability
→ 限制“能做什么”
这样一来,容器的三个核心边界就逐渐清晰了:
容器
│
├── Namespace
│ └── 我能看到什么?
│
├── Cgroup
│ └── 我能使用多少?
│
└── Capability
└── 我能做什么?
目前我们的 mini-runc 大致是:
id="6c7f3h"
mini-runc
│
├── PID Namespace
├── UTS Namespace
├── Mount Namespace
├── rootfs
├── pivot_root
├── /proc
├── environment
├── execve()
│
└── Cgroup
├── CPU
├── Memory
└── PIDs
但是此时容器进程如果还是:
UID = 0
那么我们还没有真正处理它的权限边界。
因此,下一步我们要做的是:
mini-runc
│
├── Namespace
├── Cgroup
│
└── Capability
├── 保留必要权限
└── 删除不必要权限
最终,让容器中的:
UID = 0
变成一种受限制的 root。
而这也是现代容器安全模型中非常关键的一点:
容器并不是靠“把 root 禁掉”来实现安全,而是让 root 只拥有完成当前任务所必需的能力。
给容器删除危险的 Capability
Linux 对一个进程的 Capability 并不是只保存一份,而是存在多个集合中。
可以查看当前进程:
cat /proc/self/status | grep Cap
得到类似:
CapInh: 0000000000000000
CapPrm: 000001ffffffffff
CapEff: 000001ffffffffff
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000
其中我们当前最关心三个:
CapPrm
CapEff
CapBnd
可以简单理解为:
Permitted
↓
这个进程被允许拥有的 Capability
Effective
↓
当前实际用于权限检查的 Capability
Bounding
↓
这个进程及其后续程序能够拥有的 Capability 上限
Capability 是一个 bitset,每一位对应一个具体的 Capability。
例如:
CAP_NET_RAW
CAP_SYS_ADMIN
CAP_SYS_CHROOT
...
都会对应其中的某一个 bit。
Linux 提供了:
prctl(PR_CAPBSET_DROP, cap)
可以从 Capability Bounding Set 中删除某个 Capability。
Go 中可以写成:
unix.Prctl(
unix.PR_CAPBSET_DROP,
uintptr(cap),
0,
0,
0,
)
例如:
if err := unix.Prctl(
unix.PR_CAPBSET_DROP,
uintptr(unix.CAP_SYS_ADMIN),
0,
0,
0,
); err != nil {
return err
}
但是:
仅仅删除 Bounding Set,并不会立即把当前进程 Effective Set 中的 Capability 删除掉。
也就是说:
CapEff
↓
当前进程现在能不能使用这个能力?
CapBnd
↓
以后还能不能重新获得这个能力?
所以如果我们的目标是:
让容器进程现在就失去某个 Capability。
只处理 Bounding Set 是不够的。
我们先选择一个非常直观的 Capability:
CAP_NET_RAW
它允许进程使用一些原始网络能力,例如创建 raw socket。
在很多 Linux 系统中,普通容器会保留一部分网络相关 Capability,但我们自己的 mini-runc 可以先故意把它删掉。
在 child() 初始化过程中,我们可以在 execve() 之前执行:
if err := unix.Prctl(
unix.PR_CAPBSET_DROP,
uintptr(unix.CAP_NET_RAW),
0,
0,
0,
); err != nil {
fmt.Println("drop bounding capability error:", err)
return
}
它的意思是:
从当前进程的 Capability Bounding Set 中删除
CAP_NET_RAW。
但是前面说过,这还不够。
因为我们还需要处理当前进程的:
Permitted
Effective
Linux 提供了 capset() 系统调用,可以修改当前进程的 Capability 集合。
Go 的 golang.org/x/sys/unix 提供了相关接口。
我们可以定义一个辅助函数:
func dropCapability(capability int) error {
var hdr unix.CapUserHeader
var data [2]unix.CapUserData
hdr.Version = unix.LINUX_CAPABILITY_VERSION_3
if err := unix.Capget(&hdr, &data[0]); err != nil {
return err
}
index := capability / 32
bit := uint(capability % 32)
data[index].Permitted &^= 1 << bit
data[index].Effective &^= 1 << bit
if err := unix.Capset(&hdr, &data[0]); err != nil {
return err
}
return nil
}
这里:
Permitted
和:
Effective
都被清除了对应的 bit。
于是CAP_NET_RAW就不再属于当前进程的有效 Capability。
所以完整的操作应该是:
func dropCapability(capability int) error {
// 1. 从 Bounding Set 删除
if err := unix.Prctl(
unix.PR_CAPBSET_DROP,
uintptr(capability),
0,
0,
0,
); err != nil {
return fmt.Errorf("drop capability from bounding set: %w", err)
}
// 2. 从 Permitted / Effective Set 删除
var hdr unix.CapUserHeader
var data [2]unix.CapUserData
hdr.Version = unix.LINUX_CAPABILITY_VERSION_3
if err := unix.Capget(&hdr, &data[0]); err != nil {
return fmt.Errorf("get capabilities: %w", err)
}
index := capability / 32
bit := uint(capability % 32)
data[index].Permitted &^= 1 << bit
data[index].Effective &^= 1 << bit
if err := unix.Capset(&hdr, &data[0]); err != nil {
return fmt.Errorf("set capabilities: %w", err)
}
return nil
}
然后在 child() 中:
if err := dropCapability(unix.CAP_NET_RAW); err != nil {
fmt.Println("drop CAP_NET_RAW error:", err)
return
}
最好在:
rootfs
pivot_root
/proc
等初始化完成后,在最终 execve() 前完成 Capability 裁剪。
于是最终流程变成:
child
│
├── Namespace
├── rootfs
├── pivot_root
├── /proc
│
├── 删除 CAP_NET_RAW
│
└── execve("/bin/sh")
│
▼
容器进程
为什么我们不一次把所有 Capability 全删掉?
因为 Capability 并不是越少越好,而是:
只保留程序真正需要的能力。
例如一个容器需要配置网络,那么可能需要:
CAP_NET_ADMIN
需要创建某些特定类型的网络 socket,可能需要:
CAP_NET_RAW
而一个普通的 Web 服务容器可能完全不需要这些能力。
到这里,容器的安全边界又多了一层
Namespace
↓
我能看到什么?
Cgroup
↓
我能使用多少?
Capability
↓
我能做什么?
因此:
Container Process
│
┌───────────────┼───────────────┐
│ │ │
Namespace Cgroup Capability
│ │ │
隔离 限制 权限
│ │ │
看见什么? 用多少? 做什么?
这时候,我们的 mini-runc 已经开始具备完整的隔离、资源和权限边界。
不过 Capability 仍然不是最后一道安全边界。
一个进程即使没有某些 Capability,仍然可以调用大量 Linux 系统调用。
例如:
socket()
mount()
ptrace()
reboot()
bpf()
...
那么问题就来了:
能不能直接限制容器进程“哪些系统调用可以调用”?
Seccomp:限制容器可以调用哪些系统调用
即使我们删除了一些危险的 Capability,容器进程依然可以继续调用大量 Linux 系统调用,例如:
read()
write()
openat()
socket()
clone()
mount()
ptrace()
bpf()
...
那么问题来了:
能不能直接规定:这个容器进程只允许调用哪些系统调用?
答案就是:
Seccomp。
Seccomp 全称是 Secure Computing Mode,Linux 通过它限制进程能够使用的系统调用。现代 Linux 中通常使用 seccomp-BPF,也就是通过 BPF 程序根据系统调用号以及相关参数决定是否允许、拒绝或以其他方式处理一次系统调用。
Seccomp 到底限制什么?先回顾一下我们熟悉的系统调用模型。
用户程序:
/bin/sh
│
│ 系统调用
▼
Linux Kernel
例如:
openat()
read()
write()
socket()
clone()
最终都会进入 Linux Kernel。
Seccomp 就位于这里:
用户程序
│
│ syscall
▼
Seccomp
│
├── 允许
│ ↓
│ Kernel
│
└── 拒绝
所以它和 Capability 的区别非常直观:
Capability
↓
限制某些特权操作
Seccomp
↓
限制系统调用本身
例如,一个进程可能没有:
CAP_SYS_ADMIN
但它仍然可以调用很多普通系统调用。
如果我们希望进一步规定:
这个容器不允许调用 bpf()
那么 Seccomp 就非常适合做这件事情。
第一个实验:禁止一个系统调用,我们先不去构造复杂的 seccomp 策略。
选择一个非常简单的系统调用:
getpid()
正常情况下:
getpid();
会返回当前进程 PID。
例如:
PID = 1
现在我们通过 Seccomp 禁止:
getpid()
那么程序再次调用它时,Kernel 就会按照我们的 Seccomp 策略拒绝这次调用。
创建 Seccomp filter 大致需要经历:
创建 BPF Filter
↓
指定规则
↓
安装到当前进程
↓
之后的 syscall 都经过 Filter
例如:
syscall
│
▼
Seccomp BPF
│
├── getpid → DENY
├── read → ALLOW
├── write → ALLOW
└── exit → ALLOW
Seccomp filter 安装以后,会作用于当前进程;当进程通过 fork()、clone() 创建新的线程或子进程时,Seccomp filter 也会按照 Linux 的规则继承。
这里和前面的 Namespace、Cgroup 有一个明显区别:
Go 标准库本身没有提供一套完整方便的 Seccomp 策略构建 API。
实际项目通常使用专门的库,例如:
github.com/seccomp/libseccomp-golang
它是对 Linux libseccomp 的 Go binding。
安装:
go get github.com/seccomp/libseccomp-golang
然后就可以创建一个 Seccomp filter。
例如:
import scmp "github.com/seccomp/libseccomp-golang"
创建:
filter, err := scmp.NewFilter(scmp.ActErrno.SetReturnCode(int16(unix.EPERM)))
if err != nil {
return err
}
这里的意思是:
默认情况下,系统调用被拒绝时返回
EPERM。
然后我们显式允许一些最基本的系统调用:
allowed := []scmp.ScmpSyscall{
scmp.ScmpSyscall(scmp.ScmpSyscallName("read")),
scmp.ScmpSyscall(scmp.ScmpSyscallName("write")),
scmp.ScmpSyscall(scmp.ScmpSyscallName("exit")),
scmp.ScmpSyscall(scmp.ScmpSyscallName("exit_group")),
}
再:
for _, syscallID := range allowed {
if err := filter.AddRule(scmp.ActAllow, syscallID); err != nil {
return err
}
}
最后:
if err := filter.Load(); err != nil {
return err
}
这样 Seccomp filter 就被安装到了当前进程。
这里有一个巨大的坑:
不能像上面的例子一样,随便只允许几个系统调用然后启动 /bin/sh。
因为 /bin/sh 以及它启动的程序需要大量系统调用。
例如:
execve()
brk()
mmap()
mprotect()
openat()
close()
read()
write()
fstat()
arch_prctl()
set_tid_address()
set_robust_list()
rt_sigaction()
...
如果你只允许:
read
write
exit
exit_group
那么:
execve("/bin/sh")
很可能马上就运行不起来。
所以 Seccomp 的实际使用通常不是:
“只允许我想到的几个 syscall”
而更常见的是:
允许默认行为
↓
单独禁止少数危险 syscall
或者使用一套经过验证的 syscall allowlist。
为了避免把 Shell 的所有系统调用都列出来,我们可以反过来:
默认允许
↓
只禁止 getpid()
概念上:
Seccomp
│
├── 默认 ALLOW
│
└── getpid()
↓
EPERM
代码:
package main
import (
"fmt"
scmp "github.com/seccomp/libseccomp-golang"
)
func setupSeccomp() error {
filter, err := scmp.NewFilter(scmp.ActAllow)
if err != nil {
return err
}
getpid, err := scmp.GetSyscallFromName("getpid")
if err != nil {
return err
}
if err := filter.AddRule(
scmp.ActErrno.SetReturnCode(1),
getpid,
); err != nil {
return err
}
return filter.Load()
}
这里:
scmp.ActAllow
表示:
默认允许系统调用。
而:
filter.AddRule(
scmp.ActErrno.SetReturnCode(1),
getpid,
)
表示:
如果调用
getpid(),拒绝它,并返回EPERM。
和我们前面的 Capability 一样:
必须在
execve("/bin/sh")之前安装Seccomp。
我们的 child() 现在大概是:
child
│
├── hostname
├── Mount Namespace
├── rootfs
├── pivot_root
├── /proc
├── Capability
│
├── Seccomp
│
└── execve("/bin/sh")
例如:
if err := setupSeccomp(); err != nil {
fmt.Println("setup seccomp error:", err)
return
}
// 最后再 execve
if err := syscall.Exec(
"/bin/sh",
[]string{"/bin/sh"},
env,
); err != nil {
fmt.Println("exec error:", err)
}
原因很简单:
安装 Seccomp
↓
当前进程受到限制
↓
execve("/bin/sh")
↓
/bin/sh 继承 Seccomp filter
这样真正运行在容器里的程序也会受到这个过滤器限制。
Seccomp 并不只有“允许”和“拒绝”两种结果。
常见的处理动作包括:
ALLOW
↓
允许系统调用
ERRNO
↓
拒绝,并返回指定错误码
KILL
↓
直接终止进程
TRAP
↓
发送 SIGSYS
LOG
↓
记录并允许继续执行
所以它可以实现非常细粒度的系统调用策略。
例如:
socket()
↓
允许
read()
↓
允许
write()
↓
允许
ptrace()
↓
EPERM
bpf()
↓
EPERM
这就是 Seccomp 的核心价值:
即使进程已经进入容器、拥有自己的 rootfs、加入了 Cgroup,并且仍然以 UID 0 运行,我们还可以进一步限制它能够使用哪些 Linux 系统调用。
这里其实已经形成了一个非常完整的容器安全模型:
Namespace 决定进程看到的世界,Cgroup 决定进程能够消耗多少资源,Capability 决定进程拥有多少特权,而 Seccomp 决定进程能够向 Kernel 提出哪些请求。
从 Capability/Seccomp 到 mini-runc:限制容器权限
Docker 当前文档列出的默认保留 Capability 是这 14 个:
CAP_AUDIT_WRITE
→ 向内核审计日志写入审计记录
CAP_CHOWN
→ 修改文件的 UID/GID
CAP_DAC_OVERRIDE
→ 绕过普通文件读、写、执行权限检查
CAP_FOWNER
→ 绕过部分“必须是文件所有者”的限制
CAP_FSETID
→ 修改文件时保留 set-user-ID / set-group-ID 等特殊位
CAP_KILL
→ 向其他进程发送某些信号,而不受普通 UID 检查限制
CAP_MKNOD
→ 创建特殊文件,例如设备节点
CAP_NET_BIND_SERVICE
→ 绑定小于 1024 的特权端口
CAP_NET_RAW
→ 创建 RAW/PACKET 等原始网络套接字,例如某些 ping、抓包能力
CAP_SETFCAP
→ 设置文件的 Capability
CAP_SETGID
→ 修改 GID、补充组等身份信息
CAP_SETPCAP
→ 修改 Capability 集合中的部分权限
CAP_SETUID
→ 修改 UID 等身份信息
CAP_SYS_CHROOT
→ 执行 chroot 等根目录切换操作
也就是说,Docker 并不是“默认给 root 全部 Capability”,而是采用一个默认保留列表,其余 Capability 默认不授予。Docker 也提供 --cap-add 和 --cap-drop 做增删。(Docker Documentation)
比如下面这些 Capability 默认就不给:
CAP_SYS_ADMIN
→ 大量系统管理能力;权限范围非常广,经常被称为“万能大户”
CAP_SYS_MODULE
→ 加载、卸载内核模块
CAP_SYS_PTRACE
→ 执行某些高权限的进程跟踪/调试操作
CAP_SYS_RAWIO
→ 执行某些底层原始 I/O 操作
CAP_SYS_BOOT
→ 执行影响系统启动/重启相关的特权操作
CAP_NET_ADMIN
→ 修改网络配置,例如路由、接口、部分防火墙配置等
CAP_BPF
→ 执行受 Capability 约束的 BPF 相关特权操作
...
尤其 CAP_SYS_ADMIN 非常强,Docker 默认不会给它。(Docker Documentation)
Docker 的 Seccomp 默认禁哪些?
这个和 Capability 不太一样。
Docker 当前默认 Seccomp profile 是一个allowlist 思路:默认动作是拒绝,然后显式允许需要的 syscall;官方文档说当前默认 profile 大约阻止 300+ syscall 中的 44 个。(Docker Documentation)
比较典型的被默认禁止的包括:
acct
→ 开启/关闭进程记账
add_key
→ 向 Kernel Keyring 添加密钥
bpf
→ 执行受权限控制的 BPF 操作
clock_adjtime
→ 调整系统时钟
clock_settime
→ 设置系统时间
clone
→ 创建进程/线程
(Docker 默认规则会结合参数进一步判断)
delete_module
→ 删除内核模块
finit_module
→ 从文件加载内核模块
init_module
→ 加载内核模块
ioperm
→ 修改进程对 I/O 端口的访问权限
iopl
→ 修改 x86 I/O privilege level
kexec_load
→ 加载用于 kexec 的内核
keyctl
→ 操作 Linux Kernel Keyring
mount
→ 挂载文件系统
open_by_handle_at
→ 根据文件句柄访问文件
perf_event_open
→ 创建性能监控事件
ptrace
→ 进程跟踪和调试
reboot
→ 执行重启相关操作
setns
→ 加入其他 Namespace
swapon
→ 启用 swap
swapoff
→ 禁用 swap
umount / umount2
→ 卸载文件系统
unshare
→ 创建/分离 Namespace
userfaultfd
→ 创建 userfaultfd
...
而且它不是简单“这个 syscall 危险所以全部禁止”。有些规则会进一步根据 syscall 参数判断。例如当前 Docker profile 对 socket 就有针对特定 address family 的限制。(Docker Documentation)
Capability 和 Seccomp 是文件吗?
不是。这个一定要分清。
Capability 是 Linux Kernel 中进程的一组状态。
你可以通过:
cat /proc/self/status | grep '^Cap'
看到:
CapInh:
CapPrm:
CapEff:
CapBnd:
CapAmb:
但这些 /proc 文件是内核给你展示状态的接口,Capability 本身不是存放在某个普通文件里。
大致是:
Linux Kernel
│
└── Process
└── Capability sets
│
▼
/proc/self/status
CapEff 就是当前实际用于权限检查的 Effective set;CapBnd 是 Bounding set,用来限制后续 execve() 可以获得的能力。(man7.org)
Seccomp 也不是一个普通文件。
它实际上是附加到进程上的内核过滤器状态:
Linux Kernel
│
└── Process
│
└── Seccomp filter
│
└── BPF program
可以通过:
grep '^Seccomp' /proc/self/status
查看当前进程是否启用了 Seccomp。
例如:
Seccomp: 0
Seccomp_filters: 0
表示当前没有 Seccomp filter。
不过这里有一个容易混淆的地方:
Seccomp profile 可以是文件。
例如 Docker 支持:
docker run \
--security-opt seccomp=/path/to/seccomp/profile.json \
...
这个 JSON 是用户空间描述配置,Docker 读取它之后,再把规则转换成实际的 seccomp filter 安装到容器进程上。(Docker Documentation)
所以:
Capability
❌ 不是文件
Seccomp Filter
❌ 不是文件
Docker Seccomp Profile
✅ 可以是 JSON 文件
我们安装对应lib库:
# 如果有docker 默认会安装
sudo dnf install libseccomp libseccomp-devel
go get github.com/seccomp/libseccomp-golang
然后给出全部代码:
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
scmp "github.com/seccomp/libseccomp-golang"
"golang.org/x/sys/unix"
)
const (
rootfs = "/workspace/myrunc/mini-runc/rootfs"
cgroupPath = "/sys/fs/cgroup/mini-runc"
)
// Linux Capability 编号。
// 这里显式写出名字,避免直接使用 12、21 之类的魔法数字。
const (
CAP_NET_ADMIN = 12
CAP_NET_RAW = 13
CAP_SYS_MODULE = 16
CAP_SYS_RAWIO = 17
CAP_SYS_PTRACE = 19
CAP_SYS_ADMIN = 21
CAP_SYS_BOOT = 22
CAP_BPF = 39
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "child" {
child()
return
}
parent()
}
func parent() {
cmd := exec.Command("/proc/self/exe", "child")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWNS,
}
// 1. 创建容器进程
if err := cmd.Start(); err != nil {
fmt.Println("start container error:", err)
return
}
// 2. 设置 Cgroup
if err := setupCgroup(cmd.Process.Pid); err != nil {
fmt.Println("setup cgroup error:", err)
_ = cmd.Process.Kill()
_ = cmd.Wait()
return
}
// 3. 等待容器进程退出
if err := cmd.Wait(); err != nil {
fmt.Println("container exited:", err)
}
}
func setupCgroup(pid int) error {
// 创建 Cgroup。
if err := os.MkdirAll(cgroupPath, 0755); err != nil {
return fmt.Errorf("create cgroup: %w", err)
}
// CPU:限制为约 50% 的一个 CPU 时间份额。
if err := os.WriteFile(
filepath.Join(cgroupPath, "cpu.max"),
[]byte("50000 100000"),
0644,
); err != nil {
return fmt.Errorf("set cpu.max: %w", err)
}
// Memory:限制为 100 MB。
if err := os.WriteFile(
filepath.Join(cgroupPath, "memory.max"),
[]byte("104857600"),
0644,
); err != nil {
return fmt.Errorf("set memory.max: %w", err)
}
// PIDs:最多允许 100 个进程。
if err := os.WriteFile(
filepath.Join(cgroupPath, "pids.max"),
[]byte("100"),
0644,
); err != nil {
return fmt.Errorf("set pids.max: %w", err)
}
// 把容器进程加入 Cgroup。
if err := os.WriteFile(
filepath.Join(cgroupPath, "cgroup.procs"),
[]byte(strconv.Itoa(pid)),
0644,
); err != nil {
return fmt.Errorf("add process to cgroup: %w", err)
}
return nil
}
func child() {
// 1. 设置 hostname。
if err := syscall.Sethostname([]byte("mycontainer")); err != nil {
fmt.Println("set hostname error:", err)
return
}
// 2. 设置 Mount Namespace 为 private。
if err := unix.Mount(
"",
"/",
"",
unix.MS_PRIVATE|unix.MS_REC,
"",
); err != nil {
fmt.Println("make mount private error:", err)
return
}
// 3. 准备 rootfs。
oldRoot := filepath.Join(rootfs, "old_root")
// 4. 将 rootfs bind mount 到自己。
if err := unix.Mount(
rootfs,
rootfs,
"",
unix.MS_BIND|unix.MS_REC,
"",
); err != nil {
fmt.Println("bind mount rootfs error:", err)
return
}
// 5. 创建 old_root。
if err := os.MkdirAll(oldRoot, 0700); err != nil {
fmt.Println("mkdir old_root error:", err)
return
}
// 6. 切换根文件系统。
if err := unix.PivotRoot(rootfs, oldRoot); err != nil {
fmt.Println("pivot_root error:", err)
return
}
// 7. 切换当前工作目录。
if err := os.Chdir("/"); err != nil {
fmt.Println("chdir error:", err)
return
}
// 8. 卸载旧 rootfs。
if err := unix.Unmount("/old_root", unix.MNT_DETACH); err != nil {
fmt.Println("unmount old root error:", err)
return
}
// 9. 删除 old_root。
if err := os.RemoveAll("/old_root"); err != nil {
fmt.Println("remove old root error:", err)
return
}
// 10. 挂载 /proc。
if err := unix.Mount(
"proc",
"/proc",
"proc",
0,
"",
); err != nil {
fmt.Println("mount proc error:", err)
return
}
// 11. 防止后续进程通过 execve 等方式获得新的特权。
if err := unix.Prctl(
unix.PR_SET_NO_NEW_PRIVS,
1,
0,
0,
0,
); err != nil {
fmt.Println("set no_new_privs error:", err)
return
}
// 12. 删除不需要的 Capability。
if err := dropCapabilities(); err != nil {
fmt.Println("drop capabilities error:", err)
return
}
// 13. 安装 Seccomp Filter。
if err := setupSeccomp(); err != nil {
fmt.Println("setup seccomp error:", err)
return
}
// 14. 设置容器环境变量。
env := []string{
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"HOME=/root",
"TERM=xterm",
}
// 15. 使用 execve 将当前 PID 1 替换为 /bin/sh。
if err := syscall.Exec(
"/bin/sh",
[]string{"/bin/sh"},
env,
); err != nil {
fmt.Println("exec error:", err)
return
}
}
func dropCapabilities() error {
// 这里选择主动删除一些高权限 Capability。
//
// 这不是 Docker 完整的默认 Capability 配置,
// 而是为了演示“删除 Capability”这一过程。
caps := []int{
CAP_NET_ADMIN,
CAP_NET_RAW,
CAP_SYS_MODULE,
CAP_SYS_RAWIO,
CAP_SYS_PTRACE,
CAP_SYS_ADMIN,
CAP_SYS_BOOT,
CAP_BPF,
}
// --------------------------------------------------
// 第一步:从 Capability Bounding Set 中删除。
// --------------------------------------------------
//
// Bounding Set 决定后续 execve() 最多还能重新获得哪些 Capability。
for _, capability := range caps {
if err := unix.Prctl(
unix.PR_CAPBSET_DROP,
uintptr(capability),
0,
0,
0,
); err != nil {
return fmt.Errorf(
"drop capability %d from bounding set: %w",
capability,
err,
)
}
}
// --------------------------------------------------
// 第二步:获取当前 Capability 集合。
// --------------------------------------------------
var hdr unix.CapUserHeader
var data [2]unix.CapUserData
hdr.Version = unix.LINUX_CAPABILITY_VERSION_3
if err := unix.Capget(&hdr, &data[0]); err != nil {
return fmt.Errorf("capget: %w", err)
}
// --------------------------------------------------
// 第三步:从 Permitted / Effective Set 中删除。
// --------------------------------------------------
for _, capability := range caps {
index := capability / 32
bit := uint(capability % 32)
mask := uint32(1 << bit)
data[index].Permitted &^= mask
data[index].Effective &^= mask
}
if err := unix.Capset(&hdr, &data[0]); err != nil {
return fmt.Errorf("capset: %w", err)
}
// --------------------------------------------------
// 第四步:清空 Ambient Set。
// --------------------------------------------------
//
// Ambient Capability 会参与后续 execve() 的 Capability 计算,
// 因此这里一起清空。
if err := unix.Prctl(
unix.PR_CAP_AMBIENT,
unix.PR_CAP_AMBIENT_CLEAR_ALL,
0,
0,
0,
); err != nil {
return fmt.Errorf("clear ambient capabilities: %w", err)
}
return nil
}
func setupSeccomp() error {
// --------------------------------------------------
// 创建 Seccomp Filter。
//
// ActAllow:
// 默认允许 syscall。
//
// 我们这里只主动禁止少量高风险 syscall,
// 方便理解和验证 Seccomp。
// --------------------------------------------------
filter, err := scmp.NewFilter(scmp.ActAllow)
if err != nil {
return fmt.Errorf("create seccomp filter: %w", err)
}
// 这些 syscall 在容器正常运行 Shell 时通常不需要。
//
// 注意:
// 这里不是 Docker 官方完整 Seccomp profile,
// 只是 mini-runc 的教学版本。
blockedSyscalls := []string{
"acct",
"add_key",
"bpf",
"clock_adjtime",
"clock_settime",
"delete_module",
"finit_module",
"init_module",
"ioperm",
"iopl",
"kexec_load",
"keyctl",
"mount",
"open_by_handle_at",
"perf_event_open",
"ptrace",
"reboot",
"setns",
"swapon",
"swapoff",
"umount",
"umount2",
"unshare",
"userfaultfd",
}
for _, name := range blockedSyscalls {
syscallID, err := scmp.GetSyscallFromName(name)
if err != nil {
// 某些 syscall 在不同架构上可能不存在。
//
// 当前实验环境如果没有这个 syscall,
// 跳过即可,不影响其他规则。
continue
}
// 返回 EPERM,而不是直接杀死进程。
if err := filter.AddRule(
scmp.ActErrno.SetReturnCode(int16(unix.EPERM)),
syscallID,
); err != nil {
return fmt.Errorf(
"add seccomp rule for %s: %w",
name,
err,
)
}
}
// 把 Filter 加载到当前进程。
if err := filter.Load(); err != nil {
return fmt.Errorf("load seccomp filter: %w", err)
}
return nil
}
更多推荐
所有评论(0)