cgroups v2 与命名空间底层实现与资源隔离

cgroups 与 namespace 是现代容器技术的两大基石。本文深入对比 cgroup v1 与 v2 的层级差异,逐一剖析 cpu/memory/io/pids 等控制器的工作原理,详解 PID/IPC/Net/Mount/UTS/User/cgroup 七种命名空间的隔离机制,覆盖 unshare/nsenter/clone3 工具、挂载传播与用户命名空间安全边界,最后结合 systemd 的 cgroup 集成给出生产实践建议。

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 的核心问题:

  1. 多层级管理复杂:同一进程需要在多个目录中重复加入
  2. 竞争条件:不同控制器之间的资源竞争缺乏协调
  3. delegate 安全差:向非特权用户委托 cgroup 管理权限困难
  4. 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 种命名空间:

NamespaceFlag隔离资源
PIDCLONE_NEWPID进程 ID 号空间
IPCCLONE_NEWIPCSystem V IPC / POSIX 消息队列
NetworkCLONE_NEWNET网络设备、协议栈、端口
MountCLONE_NEWNS挂载点(文件系统视图)
UTSCLONE_NEWUTS主机名与域名
UserCLONE_NEWUSERUID / GID 映射
CgroupCLONE_NEWCGROUPcgroup 根目录视图

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 的内核态路径解析

延伸阅读

  1. Linux Kernel Documentation: Documentation/admin-guide/cgroup-v2.rst
  2. Michael Kerrisk, “Namespaces in Operation” 系列文章(LWN.net)
  3. systemd.resource-control(5) man page
  4. 《Container Security》(Liz Rice 著)— 命名空间与 capabilities 安全边界
  5. 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

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. ARM64 体系结构与内核实现
  2. 内核网络栈:sk_buff、NAPI 与 XDP
  3. eBPF 开发实战:CO-RE 与 libbpf