容器的实现原理

本文最后更新于 2025年9月10日 下午

容器实现的原理

在Linux系统中,容器的实现本身就是一种资源隔离的方式。它通过控制进程的资源调度,访问权限来确定一项进程可以操作的资源范围,来达到隔离的目的

这其中主要使用到3个技术 : namespace,unionfs 和cgroups

namespace

namespace 是一种控制进程访问范围的结构体。在进程结构体(task_struct)中,通过nsproxy 结构 指向一个包含多种资源结构指针的结构体。

struct nsproxy :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct nsproxy {
atomic_t count; // 引用计数
struct uts_namespace *uts_ns; // UTS 命名空间(主机名、域名)
struct ipc_namespace *ipc_ns; // IPC 命名空间(信号量、共享内存、消息队列)
struct mnt_namespace *mnt_ns; // 挂载命名空间(文件系统挂载点)
struct pid_namespace *pid_ns_for_children; // PID 命名空间(用于子进程)
struct net *net_ns; // 网络命名空间
#ifdef CONFIG_CGROUPS
struct cgroup_namespace *cgroup_ns; // 控制组命名空间
#endif
#ifdef CONFIG_TIME_NS
struct time_namespace *time_ns; // 时间命名空间
#endif
};

各个字段的含义:

字段名 描述
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 --fork --pid --mount --uts --ipc --net --user --map-root-user /bin/bash

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
2
3
4
5
6
unshare --mount --net --user --map-root-user bash

mount -t tmpfs tmpfs /mnt/tmp #文件系统挂载
ip link set lo up #网络挂载
mount -t proc proc /proc #虚拟文件系统挂载
# --user 要么用 --map-root-user,要么自己写 uid_map

我们得到了一个自定义资源预设命名空间的进程

QA

unshare —uts —pid /bin/bash 需要通过—fork创建新的命名空间

使用 unshare --pid 创建新的 PID 命名空间后,当前进程依然处于旧命名空间,它的 PID 无法更改。因此必须通过 --fork 创建一个新进程,它才能真正运行在新的 PID 命名空间中,并成为该命名空间内的 PID 1。

如何进入一个命名空间

Linux 把每个进程当前所在的命名空间暴露在了 /proc/[pid]/ns/ 目录下,你可以通过访问它来“进入”命名空间。

1
2
bash
sudo nsenter --target <PID> --net --uts --pid --mount --ipc /bin/bash

可以通过 bind mount 的方式把 namespace 文件挂到一个路径上,实现“命名空间持久化”:

1
2
3
4
mkdir -p /var/run/my_ns
touch /var/run/my_ns/net
mount --bind /proc/<pid>/ns/net /var/run/my_ns/net

进程在不同命名空间具有不同的PID信息

关键结构:

1
2
3
4
5
6
struct pid {
atomic_t count;
unsigned int level; // 命名空间层级(0 = root)
struct hlist_head tasks[PIDTYPE_MAX];
struct upid numbers[1]; // 实际的 PID 数组(多个命名空间的 PID)
};
1
2
3
4
struct upid {
int nr; // 当前 namespace 下的 PID
struct pid_namespace *ns; // 属于哪个 namespace
};

由此 我们可以看到 unshare 是一个对进程的资源权限进行创建绑定的过程

overlayfs

在实现容器文件系统时,常使用的是 overlayfs(属于 unionfs 类型的聚合文件系统)。用来将容器所需要的文件资源“聚合”在同一个资源目录下。

通过mount 创建文件系统

1
2
3
4
sudo mount -t overlay overlay \
-o lowerdir=/mnt/lower,upperdir=/mnt/upper,workdir=/mnt/work \
/mnt/merged

参数 含义
-t overlay 指定文件系统类型是 overlay
overlay 设备名(这里可以随便写,比如也可以写成 none
-o lowerdir=... 指定只读的底层目录(可多个)
upperdir=... 指定可写的上层目录
workdir=... overlayfs 需要的工作目录(中间缓存用)
最后的 rootfs_chat/merged 是挂载点,即最终组合出来的目录视图

挂载创建好后,因为该挂载存在于父进程之中,所以使用—mount创建后,新的子进程可以访问到该挂载路径

但是此时的根路径依然继承自父进程,所以需要将新进程的根进程修改为“/”

1
chroot rootfs_chat/merged/ 

chroot 本身并不安全,不能完全隔离文件系统,需要结合 mount namespace 才能保证隔离性。否则进程可以 escape chroot。

QA

chroot 通过修改进程结构体中的“root” 变量,来帮助进程识别对应的根目录的

{

struct fs_struct {
struct path root; // 当前的根目录(对应 /
struct path pwd; // 当前工作目录(对应 .
};

cgroups

cgroup 是一个内核中的“资源控制树结构”,进程“加入”到这个树结构的某个节点下,进而接受控制器的约束。

结构体定义:

1
2
3
4
5
6
struct cgroup {
struct kernfs_node *kn; // 在 cgroupfs 中对应的目录节点
struct cgroup_subsys_state __rcu *subsys[CGROUP_SUBSYS_COUNT]; // 控制器状态
struct cgroup_parent *parent; // 父 cgroup
...
};

控制器结构 :

1
2
3
4
5
struct cgroup_subsys_state {
struct cgroup *cgroup; // 属于哪个 cgroup 节点
struct cgroup_subsys *ss; // 属于哪个控制器(memory, cpu等)
...
};

进程结构体中对cgroup的引用 :

1
2
3
4
5
struct task_struct {
...
struct css_set *cgroups; // 进程所属的 cgroup 集合(css_set)
...
};

cgroups限制写入

1
2
3
4
mkdir /sys/fs/cgroup/mygroup
echo 1234 > /sys/fs/cgroup/mygroup/cgroup.procs #将某个进程加入资源组
echo 50M > /sys/fs/cgroup/mygroup/memory.max #在对应的虚拟文件中设置限制值的大小

限制方式

1. 内存限制(memory controller)

控制文件(cgroups v2):

1
2
3
4
memory.max        # 最大内存
memory.current # 当前内存使用量
memory.swap.max # 最大 swap 使用

限制机制:

  • 每次进程分配内存(malloc / brk / mmap),内核会通过 cgroup 追踪它的页数
  • 内核维护一个“内存账户”,记录每个 cgroup 当前使用了多少内存
  • 当使用超过 memory.max
    • 如果是软限制(memory.high):内核优先回收缓存页
    • 如果是硬限制(memory.max):内核会拒绝分配,或者直接 OOM 杀死进程

2. CPU 限制(cpu controller)

控制文件:

1
2
3
cpu.max           # 格式为 "quota period",如 "50000 100000" 表示 50%
cpu.weight # 比例调度(类似 nice 值)

限制机制:

  • Linux 用 CFS(完全公平调度器) 管理 CPU 时间
  • cgroups 中每个组都维护一个“虚拟时间”和“实际用量”
  • 当某个 cgroup 超过分配的 quota,调度器会“跳过”它的进程,让出 CPU
  • 实现类似“限速”:比如每 100ms 最多用 50ms,就等于 50% CPU

3. IO 限制(io controller)

控制文件:

1
2
3
o.max            # 限制读写速率
io.weight # 相对优先级

限制机制:

  • 当进程访问磁盘(读/写)时,内核通过 block 层检查它所在的 cgroup
  • 如果当前组已达到速率上限,则排队
  • 用的是节流算法(token bucket + deadline),按比例分发 IO 机会

4. 进程数限制(pids controller)

控制文件:

1
2
3
pids.max         # 最大可创建进程数
pids.current # 当前已创建的进程数

处理流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
进程调用系统资源(如内存分配、CPU执行、磁盘读写)  

内核触发资源控制器的钩子函数(如 memory, cpu, io, pids)

内核查找该进程所属的 cgroup 节点

读取当前使用统计(如 memory.current、cpu.stat)

与配置的限制(如 memory.max、cpu.max)做比较

根据控制器的逻辑执行:
├── ✅ 允许(资源未超限)
├── 🚫 拒绝(如 fork 被拒绝)
├── 🧹 触发回收(如内存回收缓存页)
└── 💣 杀死进程(如 OOM killer)

不同资源的控制方式示意:

资源类型 钩子点(内核行为) 控制行为
Memory 分配页(alloc_pages 拒绝分配 / 回收 / OOM
CPU 调度器(schedule 跳过调度 / 限速
IO submit_bio() / IO调度 节流 / 排队
PIDs 创建进程(fork() 直接失败
NetCls 发包、Qos 分类 加标签限流
Devices 打开文件(open() 拒绝访问设备

总结

容器的创建过程

  1. 创建聚合文件系统,将容器所需要的文件统一到一个文件路径下
  2. 创建命名空间,初始化命名空间中的资源路径
  3. 将父进程中创建的文件系统挂载到子进程中,将子进程根目录指向挂载目录
  4. 查询进程的PID号,创建cgroup文件,添加对该进程的资源限制。

容器的实现原理
http://gadoid.io/2025/04/01/容器的实现原理/
作者
Codfish
发布于
2025年4月1日
许可协议