Linux 命名空间与 Cgroups 深度实战:容器技术的两大基石
容器技术的兴起源于对资源隔离与限制的两大需求。Linux 内核提供的 Namespace(命名空间)和 Cgroups(控制组)正是支撑现代容器运行时(Docker、containerd、Kubernetes等)的核心技术。本文将从源码级别和实战角度,深入解析这两大机制的工作原理与工程应用。
一、Namespace 命名空间:构建隔离的视图
1.1 Namespace 概述
Namespace 是 Linux 内核提供的一种资源隔离机制,它允许不同进程组看到不同的系统视图。你可以将其理解为"进程的视觉分隔"——每个 Namespace 中的进程只能看到属于该 Namespace 的资源,而无法感知其他 Namespace 的存在。
Linux 内核一共提供了 8 种 Namespace:
| Namespace | 隔离资源 | 系统调用标志 | 内核版本 |
|---|---|---|---|
| Mount (mnt) | 文件系统挂载点 | CLONE_NEWNS | 2.4.19 |
| PID | 进程 ID 编号空间 | CLONE_NEWPID | 2.6.24 |
| Network (net) | 网络设备、协议栈、路由表 | CLONE_NEWNET | 2.6.29 |
| UTS | 主机名和域名 | CLONE_NEWUTS | 2.6.19 |
| IPC | System V IPC、POSIX 消息队列 | CLONE_NEWIPC | 2.6.19 |
| User | 用户和组 ID | CLONE_NEWUSER | 3.8 |
| Cgroup | Cgroup 根目录 | CLONE_NEWCGROUP | 4.6 |
| Time | 系统时钟(BOOT/MONOTONIC) | CLONE_NEWTIME | 5.6 |
1.2 核心系统调用
Namespace 的创建和管理依赖三个核心系统调用:
// clone: 创建新进程同时创建新 Namespace
int clone(int (*fn)(void *), void *stack, int flags, void *arg);
// unshare: 将当前进程从共享的 Namespace 中分离
int unshare(int flags);
// setns: 将当前进程加入到已存在的 Namespace
int setns(int fd, int nstype);
其中 flags 参数使用位掩码组合多个 Namespace 类型,例如 CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWNET。
1.3 Mount Namespace:文件系统的隔离
Mount Namespace 是 Linux 最早实现的 Namespace 类型(始于 2002 年)。它实现了文件系统挂载点的隔离,使得不同 Namespace 中的进程可以看到完全不同的文件系统树。
关键特性:
- 传播类型控制:通过
MS_SHARED/MS_PRIVATE/MS_SLAVE/MS_UNBINDABLE控制挂载事件的跨 Namespace 传播 - 容器镜像基础:Docker/OCI 容器通过 Mount Namespace 实现根文件系统的独立挂载,实现不同容器运行不同发行版
- OverlayFS 配合:现代容器运行时利用 Mount Namespace + OverlayFS 构建写时复制(CoW)的分层镜像,充分利用缓存减少磁盘占用
1.4 PID Namespace:进程编号的虚拟空间
PID Namespace 实现了进程 ID 的隔离,使得每个 Namespace 内部都有独立的 PID 编号空间。最典型的新 Namespace 中第一个进程会被分配 PID 1,它扮演着类似宿主机上 init 进程的角色。
# 在新的 PID Namespace 中执行 shell
# 看不到宿主机上的其他进程,自己成为 PID 1
$ sudo unshare --pid --fork --mount-proc /bin/bash
$ ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 4532 2268 pts/0 S 10:00 0:00 /bin/bash
PID Namespace 支持嵌套,层数上限为 32 层。这在容器运行容器(Docker-in-Docker)场景中极为关键。
1.5 Network Namespace:网络世界的平行宇宙
Network Namespace 实现了网络协议栈的完全隔离。当创建新的 Network Namespace 时,其中仅包含环回接口(lo),所有物理/虚拟网络接口都属于初始的根 Namespace。
# 创建 Network Namespace
sudo ip netns add blue
sudo ip netns add green
# 查看当前 Network Namespace 列表
$ sudo ip netns list
green
blue (id: 0)
# 在 blue namespace 中执行命令
$ sudo ip netns exec blue ip link show
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
一个完整的 Network Namespace 包含:
- 独立的网络设备(eth0、veth pair 端点)/li> li>独立的路由表和路由规则
- 独立的 iptables/netfilter 规则链
- 独立的 socket、协议栈状态(TCP/UDP 缓冲区等)/li> li>独立的 /proc/net 和 /sys/class/net 视图
跨 Namespace 通信则依赖 veth pair(虚拟以太网对),一端的网络包会直接从另一端转发,类似一根虚拟网线连接两个平行的网络世界。
1.6 其他 Namespace 类型
UTS Namespace:隔离主机名(hostname)和域名(NIS domain name),使不同容器可以设置不同的 hostname,这在容器编排系统中是基本需求。
IPC Namespace:隔离 System V IPC 对象(信号量、消息队列、共享内存段)和 POSIX 消息队列。不同容器中的进程无法通过 IPC 通信。
User Namespace:实现 UID/GID 的映射与隔离,普通用户在容器内以 root(UID 0)运行时会映射到宿主机非特权用户,极大提升了安全性。这也是 Rootless Docker 的技术基础。
Cgroup Namespace:隔离进程看到的 cgroup 路径视图,让容器内的进程认为自己从 / 开始就是 cgroup 根。
Time Namespace:Linux 5.6 引入,允许不同 Namespace 看到不同的系统时间,适用于需要模拟特定时间点的测试场景。
二、Cgroups 控制组:资源的精细调度
2.1 Cgroups 历史沿革
Cgroups(Control Groups)最早由 Google 工程师 Paul Menage 和 Rohit Seth 在 2006 年提出,最初名为"Process Containers"。2007 年正式合并入 Linux 2.6.24 内核,后经过 Tejun Heo 彻底重写,推出 cgroup v2(自 4.5 起可在生产环境使用)。
cgroup v1 存在几个显著缺陷:
- 控制器之间缺乏统一的一致性视图
- 进程只能属于一个层级树,限制了配置灵活性
- 与线程模式的冲突,导致行为不一致
- 在容器热迁移、资源审计时存在不可靠性
cgroup v2 从根本上解决了这些问题。
2.2 cgroup v2 设计哲学
v1(碎片化): v2(统一):
├─ cpu,cpuacct ├─ /sys/fs/cgroup/
├─ memory │ ├─ system.slice/
├─ blkio → │ │ ├─ cpu.max
├─ devices 统一到 │ │ ├─ memory.max
├─ pids │ │ └── io.max
└─ hugetlb │ └─ user.slice/
│ ├─ cpu.max
│ └─ memory.high
cgroup v2 的核心设计:
- 统一层级树:所有控制器共享单一的 cgroup 树
- 内部节点无进程:进程只能存在于叶子节点(叶子节点约束),v1 中同一组织单元进程分散在多棵树的问题得到解决
- "no internal processes" rule
- 渐进式资源控制:提供 threshold 通知机制,如 memory.high 在超额时触发召回而非直接 OOM
- 线程模式完善:对进程和线程级别的资源记账更加可控
2.3 CPU 控制器实战
v2 中 CPU 资源分配主要通过 cpu.max 实现,格式为 $MAX $PERIOD,表示在 $PERIOD 微秒内可以使用 $MAX 微秒的 CPU 时间。
# 创建 cgroup
sudo mkdir -p /sys/fs/cgroup/mycontainer
# 限制为 1 核心的 50%(0.5 CPU)
echo "50000 100000" | sudo tee /sys/fs/cgroup/mycontainer/cpu.max
# 启用 CPU 控制器
echo "+cpu" | sudo tee /sys/fs/cgroup/cgroup.subtree_control
# 将进程加入 cgroup
echo $$ | sudo tee /sys/fs/cgroup/mycontainer/cgroup.procs
# 压力测试
$ stress --cpu 4 --timeout 10s
cpu.weight:在竞争 CPU 时分配相对权重(默认 100),范围 1-10000。权重越高,在竞争中获得的时间片越多。
# 竞争时分配 3:1 的权重比
echo "300" | sudo tee /sys/fs/cgroup/container-a/cpu.weight
echo "100" | sudo tee /sys/fs/cgroup/container-b/cpu.weight
RT(实时)进程限制:通过 cpu.max 和 cpu.rstat 可限制实时进程的调度预算。
2.4 Memory 控制器实战
v2 内存控制器提供完整的内存用量管控能力:
| 文件 | 作用 | 类型 |
|---|---|---|
| memory.max | 硬限制,超出触发 OOM | 绝对值 |
| memory.high | 软限制,超额触发回收压力 | 绝对值 |
| memory.low | 内存保护优先级 | 绝对值 |
| memory.min | 保证的最低可用内存 | 绝对值 |
| memory.swap.max | Swap 使用限制 | 绝对值 |
| memory.peak | 观测历史峰值 | 只读 |
# 创建内存限制组,硬限制 512MB,高水位 400MB
echo "536870912" | sudo tee /sys/fs/cgroup/mycontainer/memory.max
echo "419430400" | sudo tee /sys/fs/cgroup/mycontainer/memory.high
echo "+memory" | sudo tee /sys/fs/cgroup/cgroup.subtree_control
# 将进程加入并检查
echo $$ | sudo tee /sys/fs/cgroup/mycontainer/cgroup.procs
cat /sys/fs/cgroup/mycontainer/memory.current # 查看当前用量
cat /sys/fs/cgroup/mycontainer/memory.stat # 查看统计详情
memory.max vs memory.high 的区别:
memory.high:达到此值时内核会对该 cgroup 施加页面回收压力,试图将内存压低到 high 以下。如果成功,进程可能观测到短暂的分配延迟或页面错误(page fault),但不会导致进程死亡。memory.max:硬限制。当 cgroup 内存使用达到 max 且回收失败时,OOM killer 会终止该 cgroup 内的一个或多个进程。对于容器化进程,这是最后的安全阀。
2.5 I/O 控制器(io.max/io.low)实战
v2 的 I/O 控制器名为 io(不同于 v1 的 blkio),比 v1 更加精细:
# 限制 /dev/sda 读取带宽 10MB/s、写入带宽 5MB/s
echo "10737418240 5368709120" | sudo tee /sys/fs/cgroup/mycontainer/io.max
# 查看 I/O 统计
$ cat /sys/fs/cgroup/mycontainer/io.stat
8:0 rbytes=1234567 wbytes=8910112 rios=234 wios=456 dbytes=0 dios=0
io.weight:类似 CPU 权重,对 I/O 带宽进行相对分配。io.low 则提供最小带宽保障(区别于 max 的硬性上限)。
2.6 Pids 控制器
pids.max 限制 cgroup 内可以创建的进程/线程总数,防止 fork 炸弹攻击耗尽系统 PID:
echo "100" | sudo tee /sys/fs/cgroup/mycontainer/pids.max
echo "+pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control
# 检查当前使用量
cat /sys/fs/cgroup/mycontainer/pids.current
三、从零理解容器:Namespace + Cgroups 的协同
容器的本质可以概括为:Namespace(隔离视图)+ Cgroups(资源限制)+ Rootfs(文件系统)+ Security(Capabilities/Seccomp/SELinux)。
以下是使用 system-call 手动构建一个极简容器的伪代码逻辑,帮助理解两者如何协同工作:
// clone 出新的 PID/Mount/Network/UTS/IPC Namespace
cmd := exec.Command("/proc/self/exe", "child")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC,
UidMappings: []syscall.SysProcIDMap{{ContainerID: 0, HostID: 65534, Size: 1}},
GidMappings: []syscall.SysProcIDMap{{ContainerID: 0, HostID: 65534, Size: 1}},
}
cmd.Run()
// 在子进程(容器 namespace 内部)中:
// 1. 设置 hostname(UTS)
syscall.Sethostname([]byte("my-container"))
// 2. 设置 rootfs(Mount)
syscall.PivotRoot(newRoot, oldRoot)
// 3. Mount proc(PID)
syscall.Mount("proc", "/proc", "proc", 0, "")
// 4. 配置网络(Network)—— 创建 veth pair 的一端放入 namespace
然后在宿主机侧:
// 使用 cgroup 限制容器内所有进程的资源使用
containerCgroup := "/sys/fs/cgroup/containers/my-container"
os.MkdirAll(containerCgroup, 0755)
ioutil.WriteFile(containerCgroup+"/cpu.max", []byte("200000 100000"), 0644)
ioutil.WriteFile(containerCgroup+"/memory.max", []byte("134217728"), 0644)
ioutil.WriteFile(containerCgroup+"/pids.max", []byte("64"), 0644)
// 将容器 PID 1 进程加入 cgroup
ioutil.WriteFile(containerCgroup+"/cgroup.procs", []byte(strconv.Itoa(containerPid)), 0644)
这就是 docker run --cpus=2 --memory=128m --pids-limit=64 在系统调用层面实际做的事情。
四、cgroup 文件系统与 systemd 集成
在基于 systemd 的现代 Linux 发行版中,cgroup v2 深度集成。systemd 将 cgroup 层级单元化为 .service、.slice。你可以通过 systemd 工具直接查看每个服务的资源使用:
$ systemd-cgtop
Control Group Tasks %CPU Memory Input/s Output/s
/user.slice/user-1000.slice 386 12.5 4.1G - -
/system.slice/docker.service 12 2.3 512M - -
/system.slice/kubelet.service 8 0.8 256M - -
为 systemd 服务设置资源限制变得简单:
[Service]
# 0.5 CPU
CPUQuota=50%
# 硬限制 512MB
MemoryMax=512M
# 软限制/高水位 400MB
MemoryHigh=400M
# 进程数限制
TasksMax=256
# I/O 权重
IOWeight=200
这实际上会在 /sys/fs/cgroup/system.slice/<service>.service/ 下创建对应的 cgroup 控制文件。
五、生产环境最佳实践与陷阱
5.1 内存限制与 OOM 处理
设置 memory.max 时务必考虑以下因素:
- 应用 JVM/Node.js 等运行时需要预留堆外内存:JVM 的 MaxHeapSize 只是堆内存,还需考虑 MetaSpace、线程栈、JIT Code Cache、GC 开销等。建议
memory.max = MaxHeapSize * 1.3。 - 利用 memory.high 做软限制:设置
memory.high为memory.max的 80-90%,在内存达到 OOM 之前提前触发页回收,给应用一个缓冲期。(Kubernetes 的requests.memory和limits.memory正是对应 memory.high 和 memory.max 的语义) - Swap 使用策略:
memory.swap.max = memory.max表示不启用 Swap;设为 0 表示 cgroup 总可用内存为 memory.max 的匿名内存 + cache,但不允许匿名内存换出。在生产环境中,重要的数据库/缓存服务应考虑 Swap 禁用。
5.2 CPU 限制与 throttling 监控
Kubernetes 的 Pod 处于 Burstable(即设了 requests 和 limits)时,可能频繁触发 CPU throttling。监控 container_cpu_cfs_throttled_periods_total 指标至关重要:
alert: ContainerCpuThrottled
expr: rate(container_cpu_cfs_throttled_periods_total[5m]) / rate(container_cpu_cfs_periods_total[5m]) > 0.5
for: 10m
labels:
severity: warning
解决 throttling 的常见方法是增加 CPU limit 或改为 Guaranteed 类型(limits = requests)。
5.3 PID Namespace 与 init 进程设计
在 PID Namespace 内,PID 1 进程必须承担特殊责任:
- 收割僵尸进程:init 进程必须 wait() 回收孤儿僵尸进程,否则 PID 资源会耗尽
- 信号转发:容器 stop 等场景依赖 PID 1 正确转发信号给子进程
- tini/dumb-init 等 init 工具:像 ENTRYPOINT 中使用
["/usr/bin/dumb-init", "--"]可以让普通 init 工具正确处理信号和进程回收
5.4 cgroup v1 到 v2 迁移注意事项
许多老旧工具/监控系统仍假定 cgroup v1 路径,切换到 v2 时常见问题包括:
- cAdvisor 在 v1 中通过读取
cpu.stat、memory.usage_in_bytes获取指标,v2 中文件名和格式均不同 - Kubernetes 的 cadvisor + node-exporter 组合在 v2 支持上需要较新版本
- 一些 Java 应用的 cgroup 自动检测逻辑(如
-XX:MaxRAMPercentage)需要版本 >= JDK 10u191 / JDK 11u13
六、Namespace 与 Cgroups 内核实现亮点
6.1 Namespace 的 nsproxy
Linux 通过 nsproxy 结构体(kernel/nsproxy.c)管理进程所属的 Namespace 集合:
struct nsproxy {
atomic_t count;
struct uts_namespace *uts_ns; // UTS
struct ipc_namespace *ipc_ns; // IPC
struct mnt_namespace *mnt_ns; // Mount
struct pid_namespace *pid_ns; // PID
struct net *net_ns; // Network
struct cgroup_namespace *cgroup_ns; // Cgroup
struct time_namespace *time_ns; // Time
};
// task_struct 中引用 nsproxy
struct task_struct {
...
struct nsproxy *nsproxy;
};
每个进程的 nsproxy 在 fork/>clone 时根据新创建的 Namespace 进行替换,实现进程在多个 Namespace 间的"视觉切换"。
6.2 cgroup 的 v2 统一层级
cgroup v2 的内核实现中,通过 cgroup 结构体统一管理所有控制器状态。每个 cgroup 内嵌一个 cgroup_subsys_state 数组:
struct cgroup {
struct cgroup_subsys_state self; // cgroup 自身的 CSS
unsigned long flags;
// ...各种控制器特定状态
};
struct cgroup_subsys_state {
struct cgroup *cgroup;
struct cgroup_subsys *ss; // 指向具体子系统
};
// 控制器通过 css 获取自身状态
struct mem_cgroup {
struct cgroup_subsys_state css;
struct page_counter memory;
// ...
};
v2 中控制器必须声明支持的统一统一层级语义,内核保证"no internal processes",因此同一个层级中各控制器的叶子节点进程集合是一致的。
七、总结与展望
Namespace 和 Cgroups 这两大机制共同构成了 Linux 容器化的技术底座。理解它们有助于:
- 排查容器逃逸问题:熟悉各类 Namespace 的边界,当出现信息泄露时能快速定位是哪个隔离失效
- 精准诊断性能瓶颈:区分是 Namespace 切换的开销还是 Cgroup throttling
- 合理配置资源限制:避免 memory.max 设置不合理导致频繁 OOM,或不合理 CPU limit 导致 throughput 下降
- 设计云原生基础设施:在构建 PaaS/K8s 运行时、监控方案时进行更深层次的选型判断
随着 eBPF、Landlock 等新技术融入内核,Namespace 与 Cgroups 的技术栈正在进一步扩展。例如 Cilium 通过 eBPF 在 Network Namespace 之上构建了零信任网络策略,而 runc 也在探索基于 cgroup v2 的 rootless 容器安全加固方案。理解这些基石,是掌握云原生运维的必经之路。
参考资源
- Linux 内核源码:
kernel/nsproxy.c、kernel/cgroup/ - man pages:clone(2)、unshare(2)、cgroups(7)
- Kernel documentation:
Documentation/admin-guide/cgroup-v2.rst - The Linux Programming Interface(Michael Kerrisk)
- systemd.service(5) 中的资源控制文档

发表评论 取消回复