cgroups 与 namespace 是现代 Linux 容器技术的两大基石。cgroups 负责资源限制与统计(CPU、内存、I/O、进程数等),namespace 负责视图隔离(进程树、网络栈、文件系统等)。没有它们,Docker、Kubernetes、systemd-nspawn 等容器运行时都将失去存在的根基。
本文从 cgroup v1 与 v2 的架构差异出发,逐一剖析各控制器的工作原理,深入讲解七种命名空间背后的内核机制,覆盖 unshare/nsenter/clone3 等操控工具,挂载传播(mount propagation)与用户命名空间安全边界,最后结合 systemd 的 cgroup 集成为生产实践提供参考。
一、cgroup v1 vs v2 的架构差异
1.1 v1 的设计问题
cgroup v1 中每个控制器(cpu、memory、blkio 等)各自维护一个独立的层级树,这意味着同一个进程要同时属于多个层级:
v1 hierarchy:
/sys/fs/cgroup/cpu/myapp/tasks # cpu 限制
/sys/fs/cgroup/memory/myapp/tasks # 内存限制
/sys/fs/cgroup/blkio/myapp/tasks # IO 限制
v1 的核心问题:
- 多层级管理复杂:同一进程需要在多个目录中重复加入
- 竞争条件:不同控制器之间的资源竞争缺乏协调
- delegate 安全差:向非特权用户委托 cgroup 管理权限困难
- root 污染:关键进程(如 systemd)无法干净地移出 root cgroup
1.2 v2 的单一层级设计
cgroup v2 采用单一统一层级(Single Unified Hierarchy)——所有控制器挂载在同一个树中,每个进程只属于一个 cgroup。
v2 hierarchy:
/sys/fs/cgroup/
├── cgroup.controllers # 当前层级可用的控制器列表
├── cgroup.subtree_control # 子树启用的控制器
├── myapp/
│ ├── cgroup.procs # 归属该 cgroup 的 PID 列表
│ ├── cpu.max # CPU 限制
│ ├── memory.max # 内存硬限制
│ └── io.max # IO 限制
v2 引入的关键改进:
- 线程级 vs 进程级:默认按进程(process)分组,通过
cgroup.threads支持线程级分组 - 保护机制:
memory.min/memory.low/memory.high/memory.max四层水位 - 资源竞争内置协调:所有控制器在同一颗决策树内协作
- 真正的非特权 delegate:通过文件所有权安全地将 cgroup 管理权交给用户
二、cgroup v2 控制器详解
2.1 CPU 控制器(cpu)
cgroup v2 的 CPU 控制器引入了两个核心文件:
cpu.max:"quota period"格式。例如"100000 100000"表示每 100ms 周期内最多使用 100ms CPU 时间,即 1 核;"50000 100000"表示 0.5 核cpu.weight:CPU 相对权重(1-10000,默认 100),用于组间竞争时的比例分配
# 限制 cgroup 最多使用 0.5 CPU 核心
echo "50000 100000" > /sys/fs/cgroup/myapp/cpu.max
# 设置较高权重以在竞争中获得更多 CPU
echo 200 > /sys/fs/cgroup/myapp/cpu.weight
2.2 Memory 控制器(memory)
cgroup v2 的 memory 控制器提供四层保护:
| 文件 | 语义 |
|---|---|
memory.min | 内存保护线,内核尽量不回收该 cgroup 的内存至该线以下 |
memory.low | 软下限, memory pressure 时优先回收低于 low 的 cgroup |
memory.high | 软上限,超过时会积极回收内存,但不 OOM kill |
memory.max | 硬上限,内存分配超过此值会触发 OOM(cgroup-aware OOM) |
# 硬限制 512MB
echo 536870912 > /sys/fs/cgroup/myapp/memory.max
# 软限制 256MB
echo 268435456 > /sys/fs/cgroup/myapp/memory.high
cgroup-aware OOM 是 v2 的重要特性:当 cgroup 达到 memory.max 时,内核只在该 cgroup 内部选择受害者进程,而不是系统全局范围 kill,这对容器化部署至关重要。
2.3 IO 控制器(io)
IO 控制器支持按设备限制带宽与 IOPS:
# 限制 /dev/sda 的读取带宽为 10MB/s
echo "8:0 rbps=10485760" > /sys/fs/cgroup/myapp/io.max
# 限制写入 IOPS 为 100
echo "8:0 wiops=100" >> /sys/fs/cgroup/myapp/io.max
2.4 PID 控制器(pids)
# 限制该 cgroup 内最多 100 个进程
echo 100 > /sys/fs/cgroup/myapp/pids.max
三、创建与使用 cgroup v2
3.1 手动创建与限制
# 确保 cgroup v2 已挂载
mount | grep cgroup2
# cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime)
# 创建新 cgroup
sudo mkdir /sys/fs/cgroup/myapp
# 启用需要的控制器
echo "+cpu +memory +io +pids" > /sys/fs/cgroup/cgroup.subtree_control
# 将当前 shell 移入 myapp cgroup
echo $$ | sudo tee /sys/fs/cgroup/myapp/cgroup.procs
# 设置限制
echo "50000 100000" | sudo tee /sys/fs/cgroup/myapp/cpu.max
echo 268435456 | sudo tee /sys/fs/cgroup/myapp/memory.max
3.2 用 systemd-run 快速限制
# 启动一个受 CPU 和 Memory 限制的 shell
systemd-run --user --shell --property=CPUQuota=50% --property=MemoryMax=256M
# 启动受限制的后台服务
systemd-run --user --unit=myjob \
--property=CPUWeight=200 \
--property=MemoryMax=512M \
--property=TasksMax=50 \
-- ./myprogram
四、Namespace 类型与内核实现
namespace 是 Linux 内核提供的资源隔离技术,每种 namespace 隔离一类系统资源。当前 Linux 共支持 7 种命名空间:
| Namespace | Flag | 隔离资源 |
|---|---|---|
| PID | CLONE_NEWPID | 进程 ID 号空间 |
| IPC | CLONE_NEWIPC | System V IPC / POSIX 消息队列 |
| Network | CLONE_NEWNET | 网络设备、协议栈、端口 |
| Mount | CLONE_NEWNS | 挂载点(文件系统视图) |
| UTS | CLONE_NEWUTS | 主机名与域名 |
| User | CLONE_NEWUSER | UID / GID 映射 |
| Cgroup | CLONE_NEWCGROUP | cgroup 根目录视图 |
4.1 PID Namespace
PID namespace 隔离进程 ID 空间——每个 PID namespace 有自己独立的进程编号体系。PID 1 在新的 PID namespace 中可以是任意用户指定的进程(如容器的 entrypoint)。
// 使用 clone() 创建新的 PID namespace
#define _GNU_SOURCE
#include <sched.h>
#include <unistd.h>
#include <sys/wait.h>
static int child_func(void *arg) {
printf("In child PID namespace, PID=%d, PPID=%d\n",
getpid(), getppid());
// 在这里 PID=1(如果是该 namespace 的第一个进程)
execlp("bash", "bash", NULL);
return 0;
}
#define STACK_SIZE (1024 * 1024)
int main() {
char *stack = malloc(STACK_SIZE);
char *stack_top = stack + STACK_SIZE;
pid_t pid = clone(child_func, stack_top,
CLONE_NEWPID | CLONE_NEWNS | SIGCHLD, NULL);
if (pid == -1) {
perror("clone");
return 1;
}
printf("In parent, child PID=%d\n", pid);
waitpid(pid, NULL, 0);
free(stack);
return 0;
}
4.2 Network Namespace
Network namespace 隔离完整的网络协议栈——每个 namespace 可以有独立的路由表、iptables、网络接口、端口空间。
# 创建并进入新的 network namespace
sudo ip netns add mynet
sudo ip netns exec mynet bash
# 在新 namespace 中
$ ip link set lo up
$ ip addr add 10.0.0.1/24 dev lo
$ ip addr
1: lo: <LOOPBACK,UP> ... inet 10.0.0.1/24 scope host lo
# 退出后,主命名空间不受影响
$ exit
Docker 正是用 veth pair 连接容器 network namespace 与主机网桥(docker0),实现容器网络。
4.3 Mount Namespace
Mount namespace 隔离文件系统的挂载点。每个 mount namespace 有自己独立的挂载视图。chroot 只能改变根目录,而 mount namespace 是更彻底的重映射。
# unshare mount namespace 并重新挂载 /tmp 为 tmpfs
sudo unshare --mount bash
mount -t tmpfs tmpfs /tmp
# /tmp 现在是一个全新的 tmpfs,不影响宿主机
4.4 User Namespace
User namespace 是最强大的安全隔离机制。它允许非 root 用户创建容器,并通过 UID/GID 映射让容器内的 root 实际上映射到宿主机上的普通用户。
# 以普通用户创建 user namespace
unshare --user --map-root-user bash
# 在容器内显示为 root
$ id
uid=0(root) gid=0(root) groups=0(root)
# 但在宿主机上,这个进程的实际 UID 是普通用户
# 宿主机: ps -eo pid,uid,comm | grep bash
// 通过 write /proc/[pid]/uid_map 设置 UID 映射
// 格式: inside_uid outside_uid count
// 容器内 UID 0-65535 映射到宿主机 UID 1000-165535
uid_t uid = 1000; // 宿主机上的实际用户
char map[256];
snprintf(map, sizeof(map), "0 %d 65536\n", uid);
int fd = open("/proc/self/uid_map", O_WRONLY);
write(fd, map, strlen(map));
close(fd);
五、Namespace 操控工具
5.1 unshare:创建新的 namespace
unshare 命令从当前进程脱离某些命名空间,创建新的独立环境:
# 同时创建 PID、Net、UTS、IPC、Mount、User namespace
sudo unshare --pid --net --uts --ipc --mount --user --fork \
--mount-proc /proc bash
# --fork: PID namespace 要求用 fork 创建新 init 进程
# --mount-proc: 在新的 PID namespace 下挂载 proc
5.2 nsenter:进入已有 namespace
# 进入指定 PID 进程所在的所有 namespace
sudo nsenter --target <PID> --all bash
# 只进入网络 namespace
sudo nsenter --target <PID> --net bash
5.3 clone3:新一代进程创建
Linux 5.3 引入的 clone3 系统调用提供类型安全的参数传递,替代易出错的 clone:
#include <linux/sched.h>
#include <sys/syscall.h>
struct clone_args args = {
.flags = CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWCGROUP,
.pidfd = 0,
.child_tid = 0,
.parent_tid = 0,
.exit_signal = SIGCHLD,
.stack = (unsigned long)stack,
.stack_size = STACK_SIZE,
.set_tid = 0,
.set_tid_size = 0,
.cgroup = 0,
};
pid_t pid = syscall(__NR_clone3, &args, sizeof(args));
六、挂载传播与根目录传播
6.1 Mount Propagation 类型
当一个 mount namespace 中的挂载操作需要影响其他 namespace 时,涉及挂载传播(mount propagation):
- private:挂载变化不传播到其他 namespace,也不接收外部传播
- shared:挂载变化双向传播
- slave:接收来自共享挂载点的传播,但不反向传播
- unbindable:不允许被 bind mount
# 将 /mnt 设置为 private(容器常用)
mount --make-private /mnt
# 查看挂载传播设置
findmnt -o TARGET,PROPAGATION /mnt
Docker 启动容器时,默认将容器的根挂载设置为 private,防止容器内的挂载操作污染宿主机。
七、User Namespace 安全边界
User namespace 虽然允许非特权用户创建容器,但也引入了新的攻击面。写入型 syscall(如挂载文件系统、创建设备节点)在 user namespace 中受到严格限制:
- 不能挂载大多数文件系统类型(只允许 tmpfs、proc、sysfs、cgroup 等)
- 不能创建设备节点(mknod 受限)
- 不能加载内核模块
- 不能修改 sysctl 参数
这些限制由内核的 capable() 检查与 namespace 级别的 capability 集合控制。
八、systemd 与 cgroup v2 集成
systemd 从版本 238 开始原生支持 cgroup v2(混合模式或纯净模式),每个 systemd unit 对应一个 cgroup:
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
[Service]
ExecStart=/usr/bin/myapp
CPUQuota=50%
MemoryMax=512M
TasksMax=100
IOReadBandwidthMax=/dev/sda 10M
[Install]
WantedBy=multi-user.target
systemd 创建的 cgroup 层级:
/sys/fs/cgroup/system.slice/myapp.service/
├── cgroup.procs
├── cpu.max
├── memory.max
├── io.max
└── pids.max
通过 systemctl set-property 可以运行时动态修改资源限制:
sudo systemctl set-property myapp.service CPUQuota=75%
sudo systemctl set-property myapp.service MemoryMax=1G
相关阅读
- https://plumephp.com/os-virtualization-containers/ —— KVM/QEMU 硬件虚拟化与容器技术的完整对比
- https://plumephp.com/os-linux-memory/ —— cgroup v2 内存限制与 OOM 机制的深入分析
- https://plumephp.com/os-system-call-internals/ —— 系统调用底层实现,clone3/unshare 的内核态路径解析
延伸阅读
- Linux Kernel Documentation:
Documentation/admin-guide/cgroup-v2.rst - Michael Kerrisk, “Namespaces in Operation” 系列文章(LWN.net)
- systemd.resource-control(5) man page
- 《Container Security》(Liz Rice 著)— 命名空间与 capabilities 安全边界
- Linux 源码:
kernel/cgroup/cgroup.c、kernel/nsproxy.c
# ============================================================
# 完整可运行示例:cgroup v2 + namespace 隔离实验
# 需要 root 权限,建议在一个干净的 cgroup v2 环境中运行
# ============================================================
CGROUP=/sys/fs/cgroup/demo-ns
echo "=== 步骤 1: 创建 cgroup ==="
sudo mkdir -p $CGROUP 2>/dev/null || true
# 启用控制器(如果尚未启用)
for ctrl in cpu memory io pids; do
if [ -f /sys/fs/cgroup/cgroup.subtree_control ]; then
echo "+$ctrl" | sudo tee /sys/fs/cgroup/cgroup.subtree_control >/dev/null 2>/dev/null || true
fi
done
echo "=== 步骤 2: 设置资源限制 ==="
echo "50000 100000" | sudo tee $CGROUP/cpu.max >/dev/null # 0.5 CPU
echo 134217728 | sudo tee $CGROUP/memory.max >/dev/null # 128MB
echo 50 | sudo tee $CGROUP/pids.max >/dev/null # 50 进程
echo "=== 步骤 3: unshare 创建新 namespace 并移入 cgroup ==="
sudo unshare --pid --net --uts --ipc --mount --user --fork \
--mount-proc /proc bash -c '
echo $$ > '"$CGROUP"'/cgroup.procs
echo "Inside new namespaces:"
echo " Hostname: $(hostname)"
echo " PID: $(cat /proc/self/status | grep ^Pid:)"
echo " UID: $(id)"
echo " Network interfaces:"
ip -o link show | awk '"'"'{print " " $2}'"'"'
echo " Cgroup limits:"
echo " CPU max: $(cat '"$CGROUP"'/cpu.max)"
echo " Memory max: $(cat '"$CGROUP"'/memory.max) bytes"
echo " PID max: $(cat '"$CGROUP"'/pids.max)"
exec bash
'
echo "=== 清理 ==="
sudo rmdir $CGROUP 2>/dev/null || true
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。