Linux 基础体系 · 第 24/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux cgroups 与 namespaces:资源控制、隔离和容器底层机制

在 Linux 中,容器并不是一种独立的内核虚拟机。容器中的进程仍然由宿主机内核直接调度、分配内存、管理文件和处理系统调用。

容器运行时通常把两类机制组合起来:

  • namespaces:改变进程能够“看见”和使用哪些系统对象,提供隔离视图;
  • cgroups(control groups):限制、统计和组织进程使用的 CPU、内存、进程数等资源。

可以先用一句话区分:

namespace 主要回答“这个进程能看见什么”;cgroup 主要回答“这个进程最多能使用多少”。

两者都不是完整的安全边界。容器还依赖 Linux capabilities、seccomp、SELinux/AppArmor、设备访问控制、挂载配置和内核漏洞治理。


一、从进程模型看容器:同一个内核上的不同视图

Linux 内核中的进程并不是一个只包含代码和寄存器的对象。一个进程还关联着:

  • PID 和父子关系;
  • mount namespace;
  • network namespace;
  • user namespace;
  • IPC namespace;
  • cgroup;
  • 打开的文件描述符;
  • capabilities;
  • SELinux/AppArmor 安全上下文;
  • 内存、文件页缓存和调度资源。

容器运行时创建进程时,会为这些属性选择新的上下文。例如:

宿主机内核
├── 宿主机 PID namespace
│   ├── systemd
│   └── containerd
└── 容器 PID namespace
    ├── PID 1: 应用进程
    └── PID 2: worker

容器内的应用看到的 PID 1,与宿主机看到的 PID 例如 24831,实际上可以是同一个内核进程。PID namespace 为它提供了不同的 PID 映射。

类似地,一个进程在容器内看到的根目录、网卡、主机名和进程列表,都可能与宿主机不同,但底层仍由同一个 Linux 内核管理。


二、cgroups:把进程组织成资源控制层级

2.1 cgroup 的基本对象

cgroup 是一组进程的层级组织。每个 cgroup 目录通常对应一个控制组,目录中的伪文件由内核提供,用于:

  • 设置资源限制;
  • 读取资源使用统计;
  • 查看组内进程;
  • 创建子 cgroup;
  • 启用或关闭资源控制器。

现代主流发行版通常使用 cgroup v2,其统一挂载点一般是:

/sys/fs/cgroup

检查当前系统:

stat -fc %T /sys/fs/cgroup
findmnt -t cgroup2

常见输出类似:

cgroup2
TARGET         SOURCE FSTYPE  OPTIONS
/sys/fs/cgroup cgroup cgroup2 rw,nosuid,nodev,noexec,relatime

cgroup2 表示使用 cgroup v2。若看到多个 cgroup 挂载点,并且控制器分别位于不同层级,通常是 cgroup v1。

cgroup 本身不复制进程,也不创建新的调度实体。它只是把进程放入内核维护的资源控制结构中:

/sys/fs/cgroup/
├── cgroup.controllers
├── cgroup.subtree_control
├── cgroup.procs
└── service-a/
    ├── cgroup.procs
    ├── cpu.max
    ├── memory.max
    └── pids.max

进程通过写入 cgroup.procs 加入控制组:

echo 24831 > /sys/fs/cgroup/service-a/cgroup.procs

迁移进程时,内核会检查权限以及目标 cgroup 的层级规则。进程的线程也有特殊处理,不能简单地把“一个进程”理解为所有线程永远不可分离;cgroup v2 提供了针对线程模式的额外规则,但普通服务通常按进程组管理即可。


2.2 cgroup v2 的控制器启用方式

cgroup v2 将控制器统一放在一个层级中。父 cgroup 通过 cgroup.subtree_control 向子 cgroup 开放控制器:

cat /sys/fs/cgroup/cgroup.controllers

可能输出:

cpuset cpu io memory hugetlb pids rdma misc

但“系统支持某控制器”不等于“当前子树已经启用”。查看当前已启用的控制器:

cat /sys/fs/cgroup/cgroup.subtree_control

启用控制器通常需要写入:

echo "+cpu +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control

随后新建子 cgroup:

mkdir /sys/fs/cgroup/demo
cat /sys/fs/cgroup/demo/cgroup.controllers

这里有一个重要的层级条件:

父 cgroup 只有在 subtree_control 中启用了某个控制器,子 cgroup 才能使用该控制器的限制文件。

如果直接执行:

echo 100M > /sys/fs/cgroup/demo/memory.max

却得到 No such file or directory,常见原因不是内核不支持内存控制,而是父层没有启用 memory 控制器,或者当前系统由 systemd 管理 cgroup,手工目录不在可委派的子树中。


2.3 “无内部进程”规则与 systemd 委派

cgroup v2 对资源控制采用了更严格的层级语义。对于常规的 domain cgroup,父 cgroup 一旦把控制器用于子树,通常应把实际工作进程放到叶子 cgroup,而不是同时放在有子 cgroup 的中间节点。

例如:

service.slice/          # 管理层
└── service-a.scope/    # 实际服务进程

这样可以避免父 cgroup 既直接拥有进程,又把控制器应用到子 cgroup,造成资源归属含义不清。

systemd 会替管理员维护这类层级。生产环境更适合使用:

systemd-run --unit=demo.service \
  -p MemoryMax=100M \
  -p CPUQuota=50% \
  -p TasksMax=64 \
  sleep 300

检查:

systemctl status demo.service
systemctl show demo.service \
  -p ControlGroup \
  -p MemoryCurrent \
  -p MemoryMax \
  -p CPUUsageNSec \
  -p TasksCurrent \
  -p TasksMax

这里:

  • MemoryMax=100M 最终对应 cgroup v2 的 memory.max
  • CPUQuota=50% 是 systemd 对 CPU 配额的抽象;
  • TasksMax=64 对应进程/线程数量控制;
  • ControlGroup 显示服务实际位于哪个 cgroup。

手工写 /sys/fs/cgroup 时,如果该目录由 systemd 管理,可能出现权限错误、控制器不可用或重启后配置消失。生产系统应优先使用 systemd 的资源管理接口,或者明确配置 cgroup delegation,再由容器运行时管理被委派的子树。


三、CPU 控制:配额、周期与实际调度

3.1 cpu.max 的形式

cgroup v2 的 cpu.max 通常包含两个值:

quota period

例如:

cat /sys/fs/cgroup/demo/cpu.max

输出:

100000 100000

含义是:

  • period = 100000 微秒,即 100 ms;
  • quota = 100000 微秒,即每个周期最多运行 100 ms;
  • 因此理论 CPU 上限为:

CPU fraction=quotaperiod=1\text{CPU fraction}=\frac{\text{quota}}{\text{period}}=1

也就是一个逻辑 CPU 的 100%。

设置为半个逻辑 CPU:

echo "50000 100000" > /sys/fs/cgroup/demo/cpu.max

此时:

50000100000=0.5\frac{50000}{100000}=0.5

在足够长的时间尺度上,组内所有任务合计最多获得约半个逻辑 CPU 的运行时间。

这个限制不是“每个进程各占 50%”,而是整个 cgroup 共享 50%:

demo cgroup
├── worker-1
├── worker-2
└── worker-3

三个 worker 竞争同一个配额。

当一个周期内配额耗尽,任务会被 throttled,直到下一个周期开始。查看统计:

cat /sys/fs/cgroup/demo/cpu.stat

可能看到:

usage_usec 1234567
user_usec 1100000
system_usec 134567
nr_periods 200
nr_throttled 120
throttled_usec 4500000

其中:

  • nr_periods:经历了多少个调度周期;
  • nr_throttled:发生限流的周期数;
  • throttled_usec:累计被限流的时间。

3.2 配额不是优先级

CPU 配额和 CPU 权重解决的是不同问题:

  • cpu.max:硬上限;
  • cpu.weight:在竞争 CPU 时的相对权重。

例如两个同处于繁忙机器上的 cgroup:

A: cpu.max = max,cpu.weight = 100
B: cpu.max = max,cpu.weight = 200

当两者都需要 CPU 时,B 通常获得更高的相对份额;但这不是固定的 2 倍保证,因为实际调度还受 CPU 亲和性、实时任务、核数、空闲程度和其他 cgroup 影响。

如果设置:

A: cpu.max = 50000 100000

即使机器有 32 个空闲逻辑 CPU,A 在长时间尺度上仍受一个逻辑 CPU的 50% 配额约束,而不是获得 16 个 CPU 的 50%。

3.3 CPU 限制的边界

CPU 配额会导致:

  • 延迟敏感服务出现周期性抖动;
  • 线程池扩大后竞争同一配额;
  • GC、事件循环和请求处理互相争抢;
  • nr_throttled 增长,但宿主机 CPU 可能仍然空闲。

因此,CPU 限制不能只看平均利用率。应同时观察:

cat cpu.stat
cat cpu.max
cat cpu.weight

应用侧还应观察请求延迟、运行队列和超时。一个服务平均 CPU 使用率只有 30%,并不意味着它不会因为短时配额耗尽而出现尾延迟。


四、内存控制:限制回收、分配和 OOM 路径

4.1 memory.max 不是简单的 RSS 上限

cgroup v2 的 memory.max 限制的是 cgroup 记账的内存使用,通常包括:

  • 匿名内存,例如堆、栈;
  • 文件页缓存;
  • tmpfs 使用的内存;
  • 部分内核内存;
  • socket 相关内存等。

它不应被等同于某一个进程的 RSS。一个进程的 RSS 主要描述当前映射在物理内存中的用户页,而 cgroup 的内存记账还涉及共享页、页缓存和其他内核记账对象。

查看当前使用和限制:

cat /sys/fs/cgroup/demo/memory.current
cat /sys/fs/cgroup/demo/memory.max
cat /sys/fs/cgroup/demo/memory.stat

memory.max 可以是具体字节数,也可以是:

max

表示不设置该项的硬上限。

4.2 超过限制时发生什么

设置:

echo $((100 * 1024 * 1024)) > /sys/fs/cgroup/demo/memory.max

如果 cgroup 当前使用量接近 100 MiB,新的内存分配可能触发以下过程:

  1. 内核尝试回收该 cgroup 可回收的页;
  2. 回收文件页缓存、匿名页等可回收内容;
  3. 如果配置允许,涉及 swap 的页可能被换出;
  4. 如果仍无法满足分配,触发 cgroup 内存 OOM;
  5. 内核选择并杀死 cgroup 内的任务,或者让分配失败;
  6. 更新 memory.events 等统计。

查看事件:

cat /sys/fs/cgroup/demo/memory.events

可能输出:

low 0
high 12
max 3
oom 2
oom_kill 1
oom_group_kill 0

这些字段不是同义词:

  • high:超过 memory.high 后受到回收和限速压力;
  • max:触碰 memory.max 的次数;
  • oom:发生内存 OOM 条件;
  • oom_kill:实际杀死任务的次数。

4.3 memory.highmemory.max

两者的行为不同:

echo $((80 * 1024 * 1024))  > /sys/fs/cgroup/demo/memory.high
echo $((100 * 1024 * 1024)) > /sys/fs/cgroup/demo/memory.max
  • memory.high 是压力阈值,不是立即杀死任务的硬上限;
  • 超过 memory.high 后,内核会增加回收和节流压力;
  • memory.max 是硬限制,无法通过回收满足时会进入 cgroup OOM 路径。

因此可以把它们理解为:

正常运行 ──超过 high──> 受到回收/节流压力
                         │
                         └──仍无法下降,超过 max──> cgroup OOM

memory.high 适合平滑施加压力,memory.max 适合定义最终边界。但具体效果仍受内存访问模式、共享页、swap 和内核版本影响。

4.4 memory.swap.max 与 Swap

在支持 swap 控制的 cgroup v2 系统中:

echo $((200 * 1024 * 1024)) > /sys/fs/cgroup/demo/memory.swap.max

表示该 cgroup 可使用的 swap 上限。它不是“额外允许 200 MiB 的总内存”,而是对 swap 使用量的限制。

例如:

memory.max      = 100 MiB
memory.swap.max = 200 MiB

大致表示:

  • 物理内存部分受 100 MiB 限制;
  • 该 cgroup 最多还可使用 200 MiB swap;
  • 实际可用容量并不等于 300 MiB 的稳定工作集;
  • swap 是否可用还取决于主机是否配置 swap,以及匿名页的回收行为。

现代系统中,容器运行时经常显式配置内存和 swap 关系。如果只配置一个数值而忽略 swap,可能出现“容器没有超过内存限制,但宿主机 swap 抖动严重”的现象。

4.5 cgroup OOM 与全局 OOM

这两种 OOM 不同:

  • cgroup OOM:某个 cgroup 无法满足自身限制,内核通常优先在该 cgroup 内处理;
  • 全局 OOM:宿主机整体无法满足内存分配,内核在系统范围选择进程。

检查内核日志:

journalctl -k -g 'oom|Out of memory|Memory cgroup'

也可以检查:

cat /proc/pressure/memory
cat /sys/fs/cgroup/demo/memory.events

memory.events 说明某个 cgroup 发生了什么;宿主机日志说明内核是否记录了更广泛的 OOM 事件。不能仅根据应用收到 SIGKILL 就断定一定是宿主机全局 OOM。

这与 Linux 虚拟内存中的页表、缺页、页缓存和 swap 密切相关:cgroup 不是在进程地址空间层面设置一个新的页表,而是在物理页和相关内存对象被记账、回收和分配时施加资源边界。


五、其他常见 cgroup 控制器

5.1 pids.max:限制进程和线程数量

echo 64 > /sys/fs/cgroup/demo/pids.max

该限制防止进程通过 fork() 或线程创建耗尽宿主机的任务表资源。

查看:

cat /sys/fs/cgroup/demo/pids.current
cat /sys/fs/cgroup/demo/pids.events

超过限制时,创建任务通常失败,并向调用者返回类似 EAGAIN 的错误。应用可能表现为:

fork: Resource temporarily unavailable
pthread_create: Resource temporarily unavailable

这不是内存不足的同义词。一个程序即使内存仍充足,也可能因为 pids.max 达到上限而无法创建线程。

还要注意容器内 PID 1 的问题:如果 PID 1 不回收已退出的孤儿子进程,进程数量会持续增长,最终触发 pids.max

5.2 io.max:块设备 I/O 限制

cgroup v2 的 io.max 按设备设置读写限制,形式类似:

cat /sys/fs/cgroup/demo/io.max

管理员需要先确定设备的主次设备号:

lsblk -o NAME,MAJ:MIN

配置示例的结构可能是:

8:0 rbps=10485760 wbps=10485760

表示对设备 8:0 设置读写字节速率上限。实际行为取决于 I/O 路径、文件系统、页缓存、设备驱动和内核实现。对数据库进行 I/O 限制时,必须同时观察延迟,而不能只看吞吐量。

5.3 cpuset:绑定 CPU 或 NUMA 节点

cpuset.cpuscpuset.mems 用于限定任务可使用的 CPU 和内存节点。它与 cpu.max 不同:

  • cpuset 限定“可以在哪些 CPU 上运行”;
  • cpu.max 限定“累计可以运行多久”。

在 NUMA 机器上,错误配置 cpuset.mems 可能导致远端内存访问、性能下降,甚至任务无法启动。修改前应检查父 cgroup 的可用 CPU 和内存节点:

cat /sys/fs/cgroup/cpuset.cpus.effective
cat /sys/fs/cgroup/cpuset.mems.effective

六、namespaces:给进程提供隔离后的内核视图

namespace 是 Linux 内核用于隔离特定全局资源的机制。创建 namespace 后,不同 namespace 中的进程对同一类对象可能看到不同内容。

查看当前 shell 的 namespace:

ls -l /proc/$$/ns

可能看到:

ipc  -> ipc:[4026531839]
mnt  -> mnt:[4026531841]
net  -> net:[4026531840]
pid  -> pid:[4026531836]
user -> user:[4026531837]
uts  -> uts:[4026531838]

方括号中的 inode 标识 namespace 实例。两个进程的链接指向相同 inode,通常表示它们处于同一个对应 namespace。

Linux 常见 namespace 类型如下:

类型 隔离内容
Mount 挂载点和文件系统视图
PID 进程 ID 层级和进程可见性
Network 网卡、路由表、端口空间、iptables 相关网络状态
UTS 主机名和域名
IPC System V IPC、POSIX 消息队列等
User UID/GID 映射和 capabilities
Cgroup cgroup 路径视图
Time 部分进程时间视图

不同 namespace 可以组合使用。容器通常至少使用 mount、PID、network、UTS、IPC,有时还使用 user 和 cgroup namespace。


七、PID namespace:进程树隔离和 PID 1 语义

7.1 PID 映射

一个进程可以同时拥有多个 PID:

宿主机 PID namespace: 24831
容器 PID namespace:  1

在容器内:

ps -ef

可能只看到:

UID   PID  PPID CMD
root    1     0 /app/server
root    2     1 worker

在宿主机上,同一进程可能显示为:

root 24831  ... /app/server
root 24832  ... worker

子 PID namespace 的进程可以被父 PID namespace 看到,但反过来不成立。也就是说,宿主机通常能观察容器内进程,而容器内不能通过普通 ps 看到宿主机全部进程。

7.2 PID 1 的特殊行为

PID namespace 中的第一个进程获得 PID 1。它承担两个特殊职责:

  1. 作为该 namespace 中其他进程的父进程;
  2. 回收因父进程退出而成为孤儿的子进程。

如果 PID 1 没有正确处理 SIGCHLD 和子进程回收,可能积累僵尸进程。容器运行时有时会使用一个轻量 init 进程,或者要求应用自身具备正确的进程管理能力。

PID 1 还具有特殊的信号处理行为:某些未显式安装处理器的信号,对普通进程会采取默认动作,但对 namespace 内的 PID 1,内核会更谨慎地处理。这是为什么容器内直接运行一个不适合作为 init 的程序时,停止、转发信号和优雅退出可能出现问题。

7.3 /proc 必须匹配 PID namespace

仅创建 PID namespace 不一定会自动得到正确的 ps 输出。ps 通常读取 /proc,而 /proc 是一个挂载实例,必须在相应 mount namespace 中按正确的 PID namespace 挂载。

使用 unshare 实验:

sudo unshare \
  --mount \
  --pid \
  --fork \
  --mount-proc \
  bash

进入后执行:

echo "PID in namespace: $$"
ps -ef
readlink /proc/1/ns/pid

预期可以看到当前 shell 成为 PID 1,ps 只显示该 PID namespace 中可见的进程。

如果只执行:

sudo unshare --pid --fork bash

却继续使用宿主机已经挂载的 /procps 的显示可能令人困惑:进程已经处于新 PID namespace,但 /proc 视图没有正确反映它。--mount-proc 的作用就是在新的 mount namespace 中重新挂载适配当前 PID namespace 的 procfs。


八、Mount namespace:根文件系统和挂载传播

Mount namespace 隔离的是挂载点视图,而不是自动复制磁盘内容。两个 namespace 可以看到不同的挂载树,但底层文件系统数据仍然可能相同。

创建独立 mount namespace:

sudo unshare --mount --fork bash

查看:

findmnt

在新 shell 中执行挂载操作,是否影响宿主机,还取决于挂载传播属性。

查看传播属性:

findmnt -o TARGET,PROPAGATION

常见传播类型:

  • private:挂载变化不向其他 namespace 传播;
  • shared:变化可以传播给共享同一 peer group 的 namespace;
  • slave:接收上游变化,但不反向传播;
  • unbindable:不能再次绑定挂载。

容器运行时通常会谨慎设置根挂载和 bind mount 的传播属性。如果宿主机目录以不恰当的 shared 方式挂入容器,容器内产生的挂载变化可能传播回宿主机,形成严重风险。

一个简化的临时文件系统实验:

sudo unshare --mount --fork bash
mount --make-rprivate /
mkdir /tmp/ns-test
mount -t tmpfs tmpfs /tmp/ns-test
findmnt /tmp/ns-test

此时 /tmp/ns-test 的挂载只存在于该 mount namespace,退出 shell 后 namespace 消失,挂载也随之释放。mount --make-rprivate / 的目的,是降低挂载事件传播到其他 namespace 的风险;但生产系统不能把这种实验命令直接当作完整容器安全配置。

Mount namespace 还经常配合:

  • pivot_rootchroot
  • bind mount;
  • read-only mount;
  • /proc/sys/dev 的重新挂载;
  • masked path 和临时文件系统。

chroot 只改变路径解析的根目录,不是完整的安全隔离。真正的容器文件系统通常依赖 mount namespace、权限降级、只读挂载和系统调用限制的组合。


九、Network namespace:独立的网络栈

Network namespace 拥有独立的网络资源,例如:

  • 网络设备;
  • IP 地址;
  • 路由表;
  • socket 端口空间;
  • netfilter 相关状态;
  • /proc/net 对应的网络视图。

创建空的 network namespace:

sudo unshare --net --fork bash

进入后:

ip link
ip addr
ip route

通常只能看到回环设备 lo,而且需要显式启用:

ip link set lo up

容器要访问外部网络,运行时一般会执行以下流程:

容器 network namespace
        │
      veth pair
        │
宿主机 bridge 或 CNI bridge
        │
宿主机路由、NAT、防火墙
        │
      外部网络

其中 veth pair 是一对虚拟以太网设备,一端放入容器 network namespace,另一端留在宿主机或网桥中。容器的独立网络并不意味着它拥有独立的物理网卡或独立的内核网络;它仍然使用宿主机内核实现协议栈。

网络隔离的边界取决于配置。例如:

--network=host

会让容器直接使用宿主机 network namespace,端口隔离随之消失。此时 cgroup 仍然可以限制资源,但 network namespace 隔离已经不存在。


十、User namespace:UID 映射与能力边界

User namespace 可以把 namespace 内的 UID/GID 映射为宿主机上的另一个 UID/GID。例如:

容器内 UID 0  ──映射──> 宿主机 UID 100000
容器内 UID 1  ──映射──> 宿主机 UID 100001

这样,进程在容器内可能显示为 root,但在宿主机上并不是 UID 0。

一个普通用户可以尝试创建 user namespace:

unshare --user --map-root-user --mount --pid --fork --mount-proc bash

进入后检查:

id
cat /proc/self/uid_map
cat /proc/self/gid_map

可能看到:

uid=0(root) gid=0(root) groups=0(root)

         0       1000          1

这表示 namespace 内的 UID 0 映射到宿主机 UID 1000。

User namespace 的权限是 namespace 相对的。进程在 user namespace 内取得的 capability,通常只对该 namespace 管理的对象有效;它不等于拥有宿主机全局 root 权限。

但是 user namespace 不是无风险开关:

  • 内核仍然共享;
  • 某些系统调用或资源可以通过跨 namespace 关系影响宿主机;
  • 内核漏洞仍可能导致权限提升;
  • 文件系统、设备节点、挂载和网络配置必须额外限制;
  • 发行版可能通过 sysctl 或安全策略限制 unprivileged user namespace。

设置 UID/GID 映射时存在顺序和权限要求。手工创建流程通常涉及:

  1. 创建 user namespace;
  2. 写入 /proc/<pid>/uid_map
  3. 必要时先向 /proc/<pid>/setgroups 写入 deny
  4. 写入 /proc/<pid>/gid_map
  5. 再执行需要映射身份的操作。

映射文件不能随意修改,也不能把多个不连续的宿主机范围错误地映射成一个重叠范围。


十一、UTS、IPC、Time 与 Cgroup namespace

11.1 UTS namespace

UTS namespace 隔离主机名和域名:

sudo unshare --uts --fork bash
hostname container-demo
hostname

在该 shell 中修改主机名,不应改变其他 UTS namespace 中看到的主机名。

主机名隔离只是名称隔离,不代表网络、DNS 或身份隔离。

11.2 IPC namespace

IPC namespace 隔离 System V IPC 对象和 POSIX 消息队列等资源。它防止不同容器通过同一组 IPC 对象直接互相通信,但共享 IPC namespace 会取消这层隔离:

--ipc=host

数据库、浏览器沙箱和多进程服务对共享内存的使用,可能受到 IPC namespace、权限和 /dev/shm 挂载配置共同影响。

11.3 Time namespace

Time namespace 可以为进程提供不同的 monotonic 和 boot time 偏移,主要用于特定隔离场景。它不是把真实硬件时钟复制成多个独立时钟,也不会自动改变所有时间源。应用若依赖不同的时间语义,仍应明确使用 CLOCK_MONOTONICCLOCK_BOOTTIME 等相应时钟。

该能力受内核版本、工具版本和权限影响,不能假设所有发行版的 unshare 都支持相同参数。

11.4 Cgroup namespace

Cgroup namespace 隔离进程看到的 cgroup 路径:

readlink /proc/self/ns/cgroup
cat /proc/self/cgroup

容器内可能看到:

0::/

而宿主机实际路径可能是:

0::/system.slice/container-runtime.service/container-id

这主要是视图隐藏和路径虚拟化,不会自动授予容器修改 cgroup 的能力。是否能够写入 cgroup 文件,仍然取决于:

  • 文件系统挂载方式;
  • user namespace;
  • 文件权限;
  • cgroup delegation;
  • controller 的层级规则;
  • 运行时是否把 cgroup 文件系统以只读方式提供。

因此:

cgroup namespace 改变“看到的路径”,cgroup controller 才决定“能否控制资源”。


十二、namespace 的创建、加入和生命周期

Linux 提供了几组底层接口:

  • clone():创建子进程时指定 namespace;
  • unshare():让当前进程脱离并进入新的 namespace;
  • setns():加入一个已经存在的 namespace;
  • ioctl():对 namespace 文件描述符执行特定操作,例如创建持久 namespace。

命令行工具的对应关系通常是:

unshare   # 创建或脱离到新的 namespace
nsenter   # 进入已有进程的 namespace

进入某个进程的多个 namespace:

sudo nsenter \
  -t 24831 \
  -m -u -i -n -p \
  -- bash

参数含义:

  • -t 24831:目标进程;
  • -m:mount namespace;
  • -u:UTS namespace;
  • -i:IPC namespace;
  • -n:network namespace;
  • -p:PID namespace。

nsenter 本身不是调试魔法。进入 PID namespace 后,shell 进程本身的 PID 视图和 /proc 挂载必须匹配,否则看到的结果可能混杂。进入容器排障时经常需要先进入 mount namespace,再根据需要处理 /proc

namespace 的生命周期通常由引用计数决定:

  • 只要其中仍有进程,namespace 就存在;
  • 即使没有普通进程,如果某个 namespace 文件被绑定挂载或持有文件描述符,它也可以继续存在;
  • 当最后一个引用释放后,namespace 才被销毁。

这也是网络命名空间可以由运行时创建后暂时保存、再让容器进程加入的原因。


十三、一个最小容器启动过程

一个简化的容器创建流程可以表示为:

sequenceDiagram
    participant R as 容器运行时
    participant K as Linux 内核
    participant C as cgroup
    participant N as namespaces
    participant A as 应用进程

    R->>K: 创建 user/PID/mount/net 等 namespace
    K-->>R: 返回 namespace 句柄和子进程
    R->>C: 创建 cgroup 并设置 CPU、内存、pids 限制
    R->>C: 将容器进程写入 cgroup.procs
    R->>N: 配置挂载、hostname、网络设备和路由
    R->>A: execve() 启动应用
    A->>K: 发起系统调用、申请内存、创建线程
    K->>C: 进行资源记账和控制
    K->>N: 按当前 namespace 解析进程、文件和网络对象

更严格的实际顺序会因运行时和 rootless/rootful 模式不同而变化,但关键关系是:

  1. namespace 决定进程使用哪一套视图;
  2. cgroup 决定进程归属于哪一套资源控制;
  3. execve() 只替换进程映像,不会自动清除它已经加入的 namespace 和 cgroup;
  4. 容器停止时,运行时需要发送信号、回收 PID 1、清理网络和 cgroup;
  5. 若 cgroup 中仍有进程,删除 cgroup 可能失败,或运行时会继续等待清理。

容器镜像本身不是 namespace,也不是 cgroup。镜像主要提供文件系统内容;真正的隔离和资源控制发生在进程启动及其生命周期管理阶段。


十四、一个可观察的 cgroup 实验

下面的例子需要 root 权限,并假设系统使用 cgroup v2。为了避免直接修改 systemd 根层级,示例使用一个临时子树;生产环境不要在不了解主机 cgroup 管理方式时直接执行。

#!/usr/bin/env bash
set -euo pipefail

CG=/sys/fs/cgroup/wr-demo

[[ "$(stat -fc %T /sys/fs/cgroup)" == "cgroup2" ]] || {
    echo "需要 cgroup v2" >&2
    exit 1
}

mkdir "$CG"
cleanup() {
    echo "清理中..."
    rmdir "$CG" 2>/dev/null || true
}
trap cleanup EXIT

# 仅用于实验;实际生产系统应通过 systemd 或明确的 delegation 管理。
echo "+cpu +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control

echo "50000 100000" > "$CG/cpu.max"
echo $((100 * 1024 * 1024)) > "$CG/memory.max"
echo 64 > "$CG/pids.max"

(
    while :; do
        :
    done
) &
PID=$!

echo "$PID" > "$CG/cgroup.procs"

echo "PID=$PID"
echo "cgroup.procs:"
cat "$CG/cgroup.procs"

sleep 2

echo
echo "cpu.stat:"
cat "$CG/cpu.stat"

echo
echo "memory.current:"
cat "$CG/memory.current"

echo
echo "pids.current:"
cat "$CG/pids.current"

kill "$PID"
wait "$PID" 2>/dev/null || true

运行:

sudo bash ./cgroup-demo.sh

预期观察:

  • cpu.statusage_usec 会增长;
  • 因为 cpu.max 是 50%,繁忙循环通常会看到 nr_throttled 增长;
  • memory.current 会显示 shell 和循环进程的记账内存;
  • pids.current 至少包含该 shell 和循环进程。

这个脚本存在几个生产风险:

  1. 向根 cgroup 的 cgroup.subtree_control 写入控制器可能影响该层级的管理方式;
  2. systemd 管理的发行版可能拒绝此操作;
  3. cgroup 目录未必允许当前用户创建;
  4. shell 退出时,清理函数只能删除空 cgroup;
  5. 如果子进程没有正确退出,rmdir 会失败;
  6. 内存控制结果会受 shell、动态链接器和当前页缓存影响,不是精确的“只限制循环进程”。

更稳妥的 systemd 版本:

systemd-run --wait --collect \
  --unit=wr-cgroup-demo.service \
  -p CPUQuota=50% \
  -p MemoryMax=100M \
  -p TasksMax=64 \
  bash -c 'while :; do :; done'

另一个终端中观察:

systemctl show wr-cgroup-demo.service \
  -p ControlGroup \
  -p CPUUsageNSec \
  -p MemoryCurrent \
  -p TasksCurrent

--wait 让命令等待服务结束,--collect 让 systemd 在服务完成后回收 transient unit。若服务被 CPU 限流,CPUUsageNSec 仍然增长,但实际墙上时间和调度行为会受到 quota 影响。


十五、资源控制和隔离如何组合

以下配置并不等价:

组合 A:只有 cgroup

限制 CPU 和内存
但共享宿主机 PID、网络、挂载和主机名

这更像资源受限的普通服务,而不是完整容器。

组合 B:只有 namespaces

看到独立的 PID、网络和挂载视图
但没有 memory.max、cpu.max、pids.max

这可能实现了视图隔离,却仍然允许一个容器消耗大量 CPU、内存或创建大量线程。

组合 C:namespaces + cgroups

namespaces:隔离对象可见性
cgroups:限制资源使用

这是容器的基本内核组合,但依然不自动构成安全沙箱。

例如,容器拥有一个独立 mount namespace,并不意味着它不能访问宿主机文件。若运行时把宿主机 //dev 或敏感目录以可写方式暴露给容器,挂载隔离可能被配置本身破坏。


十六、常见误解和对应边界

16.1 “容器内的 root 等于宿主机 root”

不一定。使用 user namespace 时,容器内 UID 0 可以映射为宿主机普通 UID。

但在没有 user namespace 的 rootful 容器中,容器内 root 可能就是宿主机 UID 0,只是 capabilities、挂载和其他安全策略限制了它的操作范围。因此不能把“容器 root”默认视为安全的非特权身份。

16.2 “namespace 能限制资源”

namespace 通常不负责资源配额。一个独立 PID namespace 内的进程仍可能使用宿主机 CPU、内存和进程表,除非它同时被放入相应 cgroup。

16.3 “memory.max 是应用堆大小”

不是。文件页缓存、共享页、tmpfs 和部分内核记账都可能影响 cgroup 内存使用。应用堆、RSS、PSS、cgroup memory.current 是不同层次的指标。

16.4 “CPU 50% 就是双核机器上的一个核”

cpu.max 语义中,50% 通常表示半个逻辑 CPU 的长期配额,而不是“机器总 CPU 容量的 50%”。多线程任务仍共享这一总配额。

16.5 “删除容器目录就能停止容器”

删除镜像或 rootfs 不会自动终止仍在运行的进程。运行时必须停止进程、清理网络、移除 cgroup,并处理 namespace 的剩余引用。

16.6 “namespace 就是安全边界”

namespace 共享宿主机内核。攻击者若利用内核漏洞,或者通过错误暴露的设备、特权 capability、危险系统调用和可写宿主机挂载,仍可能影响宿主机。


十七、故障诊断:先判断是视图问题还是资源问题

17.1 进程“看不见”

检查目标进程的 namespace:

readlink /proc/$PID/ns/pid
readlink /proc/$PID/ns/mnt
readlink /proc/$PID/ns/net
readlink /proc/$PID/ns/user

再检查:

ls -l /proc/$PID/root
cat /proc/$PID/cgroup

如果宿主机能看到进程,而容器内看不到,通常是 PID namespace 的预期行为,不一定是进程已退出。

17.2 内存不断被杀

依次检查:

cat /sys/fs/cgroup/<path>/memory.current
cat /sys/fs/cgroup/<path>/memory.max
cat /sys/fs/cgroup/<path>/memory.events
cat /sys/fs/cgroup/<path>/memory.stat
journalctl -k -g 'oom|Memory cgroup'

诊断重点:

  1. memory.current 是否接近 memory.max
  2. memory.eventshighmaxoom_kill 是否增长;
  3. 是否是 page cache 或 tmpfs 占用;
  4. 是否存在 swap 压力;
  5. 是否是多个进程合计超过限制,而不是单个进程异常。

17.3 CPU 使用率低但请求超时

检查:

cat /sys/fs/cgroup/<path>/cpu.max
cat /sys/fs/cgroup/<path>/cpu.stat
cat /proc/pressure/cpu

如果 nr_throttledthrottled_usec 很高,说明可能是 cgroup 配额造成的限流,而不是宿主机 CPU 总体不足。

17.4 无法创建线程或子进程

检查:

cat /sys/fs/cgroup/<path>/pids.current
cat /sys/fs/cgroup/<path>/pids.max
cat /sys/fs/cgroup/<path>/pids.events

同时检查应用是否存在:

  • 线程泄漏;
  • 僵尸进程积累;
  • PID 1 未回收孤儿;
  • 突发 fork;
  • systemd 或运行时设置了更低的 TasksMax。

17.5 容器网络异常

在宿主机和容器两侧分别检查:

ip link
ip addr
ip route
ss -lntup

还要确认:

  • 容器是否使用了 host network;
  • veth 是否存在并接入正确网桥;
  • 默认路由是否存在;
  • DNS 配置是否正确;
  • 宿主机转发和 NAT 是否启用;
  • 防火墙规则是否允许转发。

十八、生产环境中的安全与取舍

cgroup 和 namespace 的配置不能脱离主机安全体系。至少需要同时考虑:

  • 删除不必要的 capabilities;
  • 使用 seccomp 限制危险系统调用;
  • 使用 SELinux 或 AppArmor;
  • 限制设备访问;
  • 对宿主机目录采用最小化、只读挂载;
  • 避免不必要的 host PID、host network、host IPC;
  • 控制 user namespace 和 rootless 模式的权限边界;
  • 记录容器运行时、cgroup 事件和内核安全日志;
  • 及时修复内核和运行时漏洞。

例如,以下选项会显著扩大隔离边界:

--privileged
--pid=host
--network=host
--ipc=host

它们不是普通性能开关,而是对隔离模型的改变。--privileged 往往会增加 capabilities、设备访问和其他权限,不能用来“解决容器里权限错误”而不评估后果。

资源限制也有取舍:

  • 过低的 memory.max 会制造频繁 OOM;
  • 过低的 cpu.max 会造成尾延迟和周期性限流;
  • 过低的 pids.max 会阻止正常线程扩张;
  • 过高的限制无法防止单个租户拖垮主机;
  • 只设置硬限制而不监控 memory.eventscpu.stat 和 PSI,往往只能在故障后发现问题。

有效配置必须同时具备:

限制值
+ 资源使用观测
+ 超限事件告警
+ 服务停止和恢复策略

十九、cgroup v1 与 cgroup v2 的差异

现代发行版优先使用 cgroup v2,但仍可能遇到 cgroup v1:

cgroup v1

  • 不同控制器可以挂载在不同层级;
  • 常见文件包括 memory.limit_in_bytescpu.cfs_quota_us
  • 多层级组合可能产生复杂的归属语义;
  • 旧版容器运行时和旧发行版仍可能使用。

cgroup v2

  • 统一层级;
  • 常见文件包括 memory.maxcpu.maxpids.max
  • 使用 cgroup.controllerscgroup.subtree_control 管理控制器;
  • 资源事件和统计接口更加统一;
  • 对层级和 delegation 的约束更严格。

不能把两套接口混用。例如:

memory.limit_in_bytes   # 常见于 v1
memory.max              # v2
cpu.cfs_quota_us        # 常见于 v1
cpu.max                 # v2

排障时首先确认版本:

stat -fc %T /sys/fs/cgroup
mount | grep cgroup

然后再根据对应版本读取文件。容器运行时通常会自动适配,但手工运维脚本若硬编码 v1 文件名,在迁移到现代发行版后可能直接失效。


二十、核心关系总结

一个 Linux 容器进程可以抽象为:

进程地址空间和线程
        │
        ├── namespaces:决定对象视图
        │      ├── PID:进程编号和可见性
        │      ├── mount:挂载树和根文件系统
        │      ├── net:网卡、路由、端口空间
        │      ├── user:UID/GID 映射和能力
        │      ├── IPC/UTS/time:其他内核视图
        │      └── cgroup:cgroup 路径视图
        │
        ├── cgroup:决定资源记账和限制
        │      ├── cpu.max / cpu.weight
        │      ├── memory.high / memory.max
        │      ├── memory.swap.max
        │      ├── pids.max
        │      └── io.max / cpuset
        │
        └── 安全机制:决定可执行的系统操作
               ├── capabilities
               ├── seccomp
               ├── SELinux/AppArmor
               └── 设备和挂载权限

因此,容器底层机制不是单个 API,也不是单个目录:

  • namespace 提供隔离视图;
  • cgroup 提供资源控制和统计;
  • user namespace 与 capabilities 影响身份和权限;
  • mount、网络和 PID 配置决定运行时环境;
  • seccomp、LSM 和设备策略补充安全边界;
  • Linux 虚拟内存、调度器、文件系统和网络栈负责真正执行这些限制。

理解这些关系后,容器中的“进程看不见”“内存被杀”“CPU 利用率不高但被限流”“无法创建线程”“容器 root 仍不能挂载”等现象,都可以回到具体的 namespace、cgroup 文件和内核故障路径,而不必把容器当成一个不可解释的黑盒。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。