容器的实现原理
本文最后更新于 2025年9月10日 下午
容器实现的原理
在Linux系统中,容器的实现本身就是一种资源隔离的方式。它通过控制进程的资源调度,访问权限来确定一项进程可以操作的资源范围,来达到隔离的目的
这其中主要使用到3个技术 : namespace,unionfs 和cgroups
namespace
namespace 是一种控制进程访问范围的结构体。在进程结构体(task_struct)中,通过nsproxy 结构 指向一个包含多种资源结构指针的结构体。
struct nsproxy :
1 | |
各个字段的含义:
| 字段名 | 描述 |
|---|---|
count |
引用计数,避免结构体被过早释放 |
uts_ns |
UTS 命名空间(uname -n、主机名等) |
ipc_ns |
IPC 命名空间(SysV IPC,如信号量、共享内存) |
mnt_ns |
挂载命名空间(隔离的挂载视图) |
pid_ns_for_children |
当前进程的子进程将进入的 PID 命名空间 |
net_ns |
网络命名空间(如网络设备、IP 地址、路由等) |
cgroup_ns |
控制组命名空间(控制组的名字空间隔离) |
time_ns |
时间命名空间(每个容器/命名空间自己的时间视图) |
当进程被创建时,未定义的命名空间 会默认使用父进程的结构体信息(可以通过clone()来指定子进程需要绑定的命名空间)。所以当新的子进程被创建时,默认直接可以访问父进程所能使用的资源。
创建新的命名空间
通过使用 unshare 命令来创建新的命名空间。操作系统会对每种资源进行相应的初始化
1 | |
unshare 可以让你为进程单独创建以下命名空间:
| 命名空间 | 参数 | 作用 |
|---|---|---|
| UTS | --uts |
主机名/域名隔离(可以改 hostname) |
| PID | --pid |
进程号隔离(新的进程树,当前进程是 PID 1) |
| Mount | --mount |
文件系统挂载点隔离 |
| Net | --net |
网络栈隔离(独立的网络接口) |
| IPC | --ipc |
进程间通信隔离 |
| User | --user |
用户/权限隔离(uid/gid 映射) |
| Cgroup | --cgroup |
控制组隔离 |
| Time | --time |
系统时间命名空间(较新内核) |
使用 - -mount 会将当前进程的挂载系统视图加载到新的进程中去
使用 - -mount-proc 会创建一个独立的/proc 文件系统
进入新的命名空间后,进行资源挂载
1 | |
我们得到了一个自定义资源预设命名空间的进程
QA
unshare —uts —pid /bin/bash 需要通过—fork创建新的命名空间
使用 unshare --pid 创建新的 PID 命名空间后,当前进程依然处于旧命名空间,它的 PID 无法更改。因此必须通过 --fork 创建一个新进程,它才能真正运行在新的 PID 命名空间中,并成为该命名空间内的 PID 1。
如何进入一个命名空间
Linux 把每个进程当前所在的命名空间暴露在了 /proc/[pid]/ns/ 目录下,你可以通过访问它来“进入”命名空间。
1 | |
可以通过 bind mount 的方式把 namespace 文件挂到一个路径上,实现“命名空间持久化”:
1 | |
进程在不同命名空间具有不同的PID信息
关键结构:
1 | |
1 | |
由此 我们可以看到 unshare 是一个对进程的资源权限进行创建绑定的过程
overlayfs
在实现容器文件系统时,常使用的是 overlayfs(属于 unionfs 类型的聚合文件系统)。用来将容器所需要的文件资源“聚合”在同一个资源目录下。
通过mount 创建文件系统
1 | |
| 参数 | 含义 |
|---|---|
-t overlay |
指定文件系统类型是 overlay |
overlay |
设备名(这里可以随便写,比如也可以写成 none) |
-o lowerdir=... |
指定只读的底层目录(可多个) |
upperdir=... |
指定可写的上层目录 |
workdir=... |
overlayfs 需要的工作目录(中间缓存用) |
最后的 rootfs_chat/merged |
是挂载点,即最终组合出来的目录视图 |
挂载创建好后,因为该挂载存在于父进程之中,所以使用—mount创建后,新的子进程可以访问到该挂载路径
但是此时的根路径依然继承自父进程,所以需要将新进程的根进程修改为“/”
1 | |
chroot 本身并不安全,不能完全隔离文件系统,需要结合 mount namespace 才能保证隔离性。否则进程可以 escape chroot。
QA
chroot 通过修改进程结构体中的“root” 变量,来帮助进程识别对应的根目录的
{
struct fs_struct {
struct path root; // 当前的根目录(对应 /)
struct path pwd; // 当前工作目录(对应 .)
};
cgroups
cgroup 是一个内核中的“资源控制树结构”,进程“加入”到这个树结构的某个节点下,进而接受控制器的约束。
结构体定义:
1 | |
控制器结构 :
1 | |
进程结构体中对cgroup的引用 :
1 | |
cgroups限制写入
1 | |
限制方式
1. 内存限制(memory controller)
控制文件(cgroups v2):
1 | |
限制机制:
- 每次进程分配内存(
malloc/brk/mmap),内核会通过 cgroup 追踪它的页数 - 内核维护一个“内存账户”,记录每个 cgroup 当前使用了多少内存
- 当使用超过
memory.max:- 如果是软限制(memory.high):内核优先回收缓存页
- 如果是硬限制(memory.max):内核会拒绝分配,或者直接 OOM 杀死进程
2. CPU 限制(cpu controller)
控制文件:
1 | |
限制机制:
- Linux 用 CFS(完全公平调度器) 管理 CPU 时间
- cgroups 中每个组都维护一个“虚拟时间”和“实际用量”
- 当某个 cgroup 超过分配的 quota,调度器会“跳过”它的进程,让出 CPU
- 实现类似“限速”:比如每 100ms 最多用 50ms,就等于 50% CPU
3. IO 限制(io controller)
控制文件:
1 | |
限制机制:
- 当进程访问磁盘(读/写)时,内核通过 block 层检查它所在的 cgroup
- 如果当前组已达到速率上限,则排队
- 用的是节流算法(token bucket + deadline),按比例分发 IO 机会
4. 进程数限制(pids controller)
控制文件:
1 | |
处理流程
1 | |
不同资源的控制方式示意:
| 资源类型 | 钩子点(内核行为) | 控制行为 |
|---|---|---|
| Memory | 分配页(alloc_pages) |
拒绝分配 / 回收 / OOM |
| CPU | 调度器(schedule) |
跳过调度 / 限速 |
| IO | submit_bio() / IO调度 |
节流 / 排队 |
| PIDs | 创建进程(fork()) |
直接失败 |
| NetCls | 发包、Qos 分类 | 加标签限流 |
| Devices | 打开文件(open()) |
拒绝访问设备 |
总结
容器的创建过程
- 创建聚合文件系统,将容器所需要的文件统一到一个文件路径下
- 创建命名空间,初始化命名空间中的资源路径
- 将父进程中创建的文件系统挂载到子进程中,将子进程根目录指向挂载目录
- 查询进程的PID号,创建cgroup文件,添加对该进程的资源限制。