容器资源隔离的Linux内核机制:namespace与cgroup

引言

假设你是一个 SaaS 平台的运维负责人,某天凌晨三点,一个“租户刺客”突然爆发——他的疯狂循环像一台永不停歇的碎纸机,将宿主机 32 核 CPU 全部打满。更糟的是,这个进程在 top 命令里看起来完全正常,因为它只是某个容器内的孤儿进程,而你的监控系统却因为 namespace 的“信息屏蔽”而瞎了。于是,整个平台上的所有租户响应时间集体飙红,你被电话轰炸到怀疑人生。

这个场景的核心矛盾在于:容器本质上是共享宿主机内核的进程,但业务上又需要彼此“假装”独占一台机器。而实现这种“假装”的底层魔法,正是 Linux 内核提供的两大机制——namespace(命名空间)和 cgroup(控制组)。

如果说虚拟机是通过硬件虚拟化(Hypervisor)给每个租户盖了一栋独立别墅,那么容器就是在同一栋大楼里用“隔音墙 + 中央空调分户计费”来隔离。namespace 是那堵隔音墙(隔离可见性),cgroup 是那个分户电表(限制资源用量)。本文将从源码和实战两个维度,把这堵墙和这块电表彻底拆开揉碎。

核心概念

生活类比:酒店客房 vs 集体宿舍

想象你住进一家高端酒店(宿主机)。每个房间(容器)都有独立的房卡(PID namespace),你看不见隔壁房间住了谁(进程隔离);房间里的电视遥控器(Network namespace)只能控制自己房间的电视,不会串台到隔壁;酒店前台(UTS namespace)告诉你“本酒店叫希尔顿”,但每个房间里的闹钟(Mount namespace)显示的时间却可以各不相同。

而这家酒店的中央空调系统(cgroup)则负责“分户计费”:每个房间的制冷功率上限是多少(cpu.cfs_quota_us)、温度最低能调到几度(memory.limit_in_bytes)、卫生间水压最大能到多少(blkio.throttle.read_bps_device)。空调压缩机(宿主机 CPU)只有一个,但每个房间能用的冷气量被精确量化。

技术定义

  • namespace:内核为进程提供的一种“视图隔离”机制。它让一组进程只能看到全局资源的一个子集。Linux 目前支持 8 种 namespace(Mount、PID、Net、IPC、UTS、User、Cgroup、Time)。
  • cgroup:内核提供的资源限制、优先级控制、审计和隔离机制。它通过层级化的任务组(task group)将进程分组,并对组内进程进行统一的资源配额管理。

两者是“一体两面”:namespace 解决“看得到什么”的问题,cgroup 解决“能用多少”的问题。Docker 的 docker run --cpus=2 --memory=1g -it ubuntu bash 命令,本质上就是同时调用了 clone()cgroupfs 的写操作。

源码/原理深度分析

namespace 的创建与生命周期

namespace 的创建并非独立系统调用,而是通过 clone() 系统调用的 flags 参数实现。当 Docker 启动一个容器时,runc 会调用 runc/libcontainer 中的 factory.Create() 方法,最终触发 clone()

// kernel/fork.c (简化版)
static __latent_entropy struct task_struct *copy_process(...)
{
    ...
    retval = copy_namespaces(clone_flags, p); // 根据 flags 创建新的 namespace
    ...
}

// kernel/nsproxy.c
int copy_namespaces(unsigned long flags, struct task_struct *tsk)
{
    struct nsproxy *old_ns = tsk->nsproxy;
    struct nsproxy *new_ns;
    ...
    new_ns = create_new_namespaces(flags, tsk, user_ns, tsk->fs);
    ...
}

clone() 传入 CLONE_NEWPID 时,内核会创建新的 PID namespace。注意:新命名空间中的第一个进程 PID 永远是 1,它的作用是充当该命名空间内的“僵尸进程收割者”。如果 PID 1 进程退出,内核会向该 namespace 内的所有进程发送 SIGKILL,这解释了为什么 Docker 容器如果没有 --init 进程,孤儿进程会变成僵尸。

cgroup 的层级结构与控制器

cgroup v2 是当前主流(Kubernetes 1.25+ 默认使用)。它的核心设计是 统一层级(Unified Hierarchy),所有控制器(CPU、Memory、IO)挂在同一个树形结构下,解决了 v1 中多层级混乱导致的死锁问题。

/sys/fs/cgroup/
├── cgroup.controllers          # 可用控制器列表
├── cgroup.subtree_control      # 可下放给子组的控制器
├── cpu.max                     # "quota period" 格式
├── memory.max                  # 字节数
└── my-container/
    ├── cgroup.procs            # 属于该组的进程 PID
    ├── cpu.max                 # "200000 100000" -> 2 核
    └── memory.max              # "1073741824" -> 1GB

当你执行 echo 200000 > /sys/fs/cgroup/my-container/cpu.max 时,内核的 cpu_cfs_quota 机制会确保该 cgroup 内的所有进程在每个调度周期(默认 100ms)内,最多只能获得 200ms 的 CPU 时间。这个计算是由 kernel/sched/fair.c 中的 __refill_cfs_bandwidth_runtime() 函数完成的。

实战代码

示例一:用 Go 手写一个“迷你 Docker”(namespace 隔离)

package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func main() {
    switch os.Args[1] {
    case "run":
        run()
    case "child":
        child()
    default:
        panic("unknown command")
    }
}

func run() {
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    // 关键:clone 时指定 namespace 隔离标志
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS | // 独立主机名
            syscall.CLONE_NEWPID | // 独立进程树
            syscall.CLONE_NEWNS |  // 独立挂载点
            syscall.CLONE_NEWNET, // 独立网络栈
    }

    must(cmd.Run())
}

func child() {
    // 设置新主机名
    must(syscall.Sethostname([]byte("tiny-container")))
    // 挂载 proc 文件系统,否则 ps 命令看到的是宿主机的进程
    must(syscall.Mount("proc", "/proc", "proc", 0, ""))

    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        fmt.Println("Error:", err)
        os.Exit(1)
    }
}

运行效果go run minidocker.go run /bin/bash 后,执行 ps -ef,只能看到 bashps 两个进程,仿佛自己独占了一台机器。

示例二:cgroup v2 手动限流(CPU + 内存)

#!/bin/bash
# 演示如何通过 cgroupfs 直接控制资源

# 1. 创建控制组
mkdir -p /sys/fs/cgroup/demo-container

# 2. 限制 CPU:在 100ms 周期内最多使用 20ms(即 0.2 核)
echo "20000 100000" > /sys/fs/cgroup/demo-container/cpu.max

# 3. 限制内存:最多 256MB
echo "268435456" > /sys/fs/cgroup/demo-container/memory.max

# 4. 将当前 shell 及其子进程加入该组
echo $$ > /sys/fs/cgroup/demo-container/cgroup.procs

# 5. 测试:启动一个死循环,观察 CPU 使用率
while true; do
    :
done

验证方法:另开终端执行 top -p ,你会看到 CPU 使用率稳定在 20% 左右,而不是 100%。内存超限时,进程会收到 OOM Killer 的 SIGKILL

示例三:通过 Go 调用 cgroup 接口实现动态扩容

package main

import (
    "fmt"
    "os"
    "path/filepath"
    "strconv"
    "time"
)

const cgroupRoot = "/sys/fs/cgroup"

func writeFile(path, content string) error {
    return os.WriteFile(filepath.Join(cgroupRoot, path), []byte(content), 0644)
}

func main() {
    group := "app-tier"
    os.MkdirAll(filepath.Join(cgroupRoot, group), 0755)

    // 将当前进程加入控制组
    pid := os.Getpid()
    writeFile(group+"/cgroup.procs", strconv.Itoa(pid))

    // 初始限制:1 核
    writeFile(group+"/cpu.max", "100000 100000")
    fmt.Println("CPU limit: 1 core")

    time.Sleep(5 * time.Second)

    // 动态扩容到 4 核(模拟业务流量高峰)
    writeFile(group+"/cpu.max", "400000 100000")
    fmt.Println("CPU limit: 4 cores (expanded)")

    // 阻塞,让进程保持运行
    select {}
}

方案对比

维度 namespace + cgroup(容器) KVM(虚拟机) 裸机进程
隔离级别 内核级视图隔离 硬件级虚拟化 无隔离
启动速度 毫秒级 秒级(需引导内核) 微秒级
资源密度 高(可超卖) 低(内存页表开销大)
安全边界 弱(共享内核,有逃逸风险) 强(Guest OS 独立)
适用场景 微服务、无状态应用 多租户强隔离、Windows 应用 高性能计算

关键结论:容器不是“轻量级虚拟机”,而是“加了约束的进程”。Kubernetes 选择容器而非 KVM,核心原因是密度和弹性——在同等硬件上可以运行 10 倍的实例数,且扩容时间从秒级降到毫秒级。

最佳实践与避坑指南

最佳实践

  1. 永远使用 --inittini:否则 PID 1 不是 init 进程,无法正确收割僵尸进程,容器退出时可能残留孤儿进程。
  2. Kubernetes 中显式声明 resources.limits:只设置 requests 而不设 limits 会导致 cgroup 不生效,宿主机 OOM 时可能误杀其他 Pod。
  3. 监控 cgroup 而不是 /proc:在容器内看 /proc/meminfo 看到的是宿主机数据,必须通过 cgroupmemory.current 获取真实用量。

常见坑

  • /proc 信息泄露:默认情况下,容器内的 cat /proc/cpuinfo 会看到宿主机全部 CPU 型号。需要挂载 lxcfs 或使用 hidepid 选项来“伪造”数据。
  • cgroup v1 vs v2 混用:Docker 19.03 之前默认 v1,K8s 1.25 之后要求 v2。混用会导致 /sys/fs/cgroup 路径不兼容。
  • PID namespace 与 docker top 的偏差docker top 显示的 PID 是宿主机视角的,而容器内 ps 显示的是 namespace 视角的,两者需要映射。
graph TD A[用户执行 docker run] --> B[Docker CLI] B --> C[Docker Daemon] C --> D[containerd] D --> E[runc] E --> F[clone with CLONE_NEW*] F --> G[新进程 PID=1] G --> H[写入 cgroupfs] H --> I[进程受限运行] I --> J{资源超限?} J -->|是| K[OOM Killer 或 CPU Throttle] J -->|否| L[正常服务]

总结

namespacecgroup 是容器的“任督二脉”:前者打通了“看”的边界,后者打通了“用”的边界。理解它们不仅能帮你解决“容器里看不到磁盘”这类玄学问题,更能帮你设计出高密度的多租户架构。

延伸思考:当你把视角从单机容器移到 Kubernetes 集群时,namespacecgroup 的隔离粒度是否足够? Service Mesh 的 sidecar 注入、eBPF 的可观测性增强,都在试图弥补内核隔离机制在“网络安全”和“细粒度审计”上的不足。下一次,当你的微服务被一个失控的 goroutine 打爆时,希望你能想起今天拆解的这堵隔音墙和那块分户电表。