Linux 容器隔离实战:Namespace 与 Cgroups 深度剖析
容器技术的基石是什么?本文从内核源码级别拆解 Namespace 和 Cgroups 的实现机制,带你彻底理解容器究竟是如何做到"隔离"与"限制"的。
一、容器到底是什么?
容器不是一种新技术,而是 Linux 内核已有能力的组合应用。很多人把容器等同于 Docker,但事实上:
- Namespace 负责隔离进程的"视图"(看到的系统资源范围)
- Cgroups 负责限制进程的"用量"(能消耗的资源上限)
- UnionFS 负责镜像的分层存储(容器的文件系统基石)
Docker、containerd、Podman 等容器运行时本质上就是对这三者的封装。理解 Namespace 和 Cgroups 的真正原理,是排查容器问题、优化容器性能、理解 Kubernetes 调度的前提。
二、Namespace:让进程看到"私有世界"
2.1 Namespace 全景
Linux 内核从 2.6.24 开始逐步引入 Namespace,截至目前共 7 种:
| Namespace | 宏定义 | 隔离对象 | 内核版本 |
|---|---|---|---|
| Cgroup | CLONE_NEWCGROUP | Cgroup 根目录 | 4.6 |
| PID | CLONE_NEWPID | 进程 ID 编号空间 | 2.6.24 |
| Network | CLONE_NEWNET | 网络设备、路由表、防火墙规则 | 2.6.29 |
| Mount | CLONE_NEWNS | 挂载点 | 2.4.19 |
| IPC | CLONE_NEWIPC | System V IPC、POSIX 消息队列 | 2.6.19 |
| UTS | CLONE_NEWUTS | 主机名和域名 | 2.6.19 |
| User | CLONE_NEWUSER | 用户和组 ID 映射 | 3.8 |
| Time | CLONE_NEWTIME | 系统时钟 | 5.6 |
2.2 亲手创建 Namespace
用最原始的 clone() 和 unshare() 系统调用来理解 Namespace 的创建过程:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#define STACK_SIZE (1024 * 1024)
static char child_stack[STACK_SIZE];
int child_func(void *arg) {
printf("[Child] PID = %d\n", getpid());
printf("[Child] 修改主机名\n");
sethostname("container-demo", 14);
// 在新的 Mount namespace 中挂载 proc
mount("proc", "/proc", "proc", 0, NULL);
printf("[Child] UTS 已隔离,主机名已修改\n");
execlp("/bin/sh", "/bin/sh", NULL);
return 0;
}
int main() {
printf("[Parent] PID = %d\n", getpid());
// 同时创建 UTS、PID、Mount、Network、IPC 五个 Namespace
pid_t pid = clone(child_func,
child_stack + STACK_SIZE,
CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWNS |
CLONE_NEWNET | CLONE_NEWIPC | SIGCHLD,
NULL);
if (pid == -1) {
perror("clone");
exit(1);
}
waitpid(pid, NULL, 0);
printf("[Parent] 子进程已退出\n");
return 0;
}
编译执行后进入子进程的 shell:
$ gcc -o ns_demo ns_demo.c && sudo ./ns_demo
[Parent] PID = 28471
[Child] PID = 1
[Child] 修改主机名
# 现在在子 Namespace 中
(container-demo) # hostname
container-demo
(container-demo) # ps aux
PID USER TIME COMMAND
1 root 0:00 /bin/sh
2 root 0:00 ps aux
(container-demo) # ifconfig
# 空输出,Network Namespace 中没有网络设备
关键观察:
- 子进程中
getpid()返回 1 —— PID Namespace 隔离生效 hostname显示 container-demo —— UTS Namespace 隔离生效ps只看到 Namespace 内的进程 —— PID + Mount Namespace 协同ifconfig无输出 —— Network Namespace 隔离生效
2.3 PID Namespace 的层级树
PID Namespace 是一个树状结构,父 Namespace 能看到子 Namespace 的进程,但反之不行。每个进程在不同层级的 Namespace 中有不同的 PID:
# 查看进程在各层级的 PID
$ cat /proc/self/status | grep NSpid
NSpid: 28471 1
# 第一列是初始 Namespace 中的 PID,第二列是当前 Namespace 中的 PID
这解释了为什么容器内 PID 1 的进程实际上是宿主机的第 28471 号进程。Kubernetes 中 kubectl exec 能进入容器,本质上就是通过 setns() 系统调用进入目标容器的 Namespace。
2.4 User Namespace:容器 root ≠ 宿主机 root
User Namespace 是最容易被忽视但最安全关键的隔离层。它实现了UID/GID 映射:
# 查看 UID 映射
$ cat /proc/28471/uid_map
0 1000 1
1 100000 65536
# 含义:容器内 UID 0 映射到宿主机 UID 1000
# 容器内 UID 1~65536 映射到宿主机 UID 100000~165536
这意味着容器内以 root 运行的进程,在宿主机上只是一个普通用户。即使容器内进程逃逸,也无法获得宿主机的 root 权限——这是容器安全的最后一道防线。
Docker 的 User Namespace Remapping 功能正是基于此:
{
"userns-remap": "default"
}
启用后,容器内 root 在宿主机上变为一个高位 UID:
$ docker run -it ubuntu bash
root@container:/# id
uid=0(root) gid=0(root) groups=0(root)
# 宿主机上查看
$ ps aux | grep bash
165536 29121 0.0 0.0 4548 3484 pts/0 Ss 09:13 0:00 /bin/bash
三、Cgroups:让进程消耗"有上限"
3.1 Cgroup v1 vs v2
Cgroup 经历了两个大版本:
- v1:按资源类型分多个 hierarchy(cpu、memory、blkio 等),设计零散但有丰富的工具链支持
- v2:统一 hierarchy,解决了 v1 中控制器不一致的问题,自 Linux 4.5 开始引入,现代发行版默认启用
检查当前系统使用的是 v1 还是 v2:
$ mount | grep cgroup
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime)
# 输出 cgroup2 表示正在使用 v2
3.2 CPU 限制实战
在 Cgroup v2 中限制 CPU 使用权重的核心是两个文件:
# 创建一个新的 cgroup
$ sudo mkdir /sys/fs/cgroup/demo
# 设置 CPU 最多使用单核的 50%(100ms 周期内运行 50ms)
$ echo "50000 100000" | sudo tee /sys/fs/cgroup/demo/cpu.max
50000 100000
# 将进程放入该 cgroup
$ echo $$ | sudo tee /sys/fs/cgroup/demo/cgroup.procs
# CPU 按摩尔定律消耗的测试脚本
$ while :; do :; done &
$ top
# 该进程最多占用 50% CPU
v1 的 cpu.cfs_period_us 和 cpu.cfs_quota_us 同理,但 v2 使用更简洁的 cpu.max。
使用 cpu.weight(默认 100,范围 1-10000)可以实现按权重分配而非硬限制:
$ echo "100" | sudo tee /sys/fs/cgroup/demo/cpu.weight # 普通优先级
$ echo "1000" | sudo tee /sys/fs/cgroup/higpu/cpu.weight # 10倍优先级
3.3 内存限制与 OOM
内存限制是最常用也是最容易出问题的 Cgroup 控制项:
# 限制最大内存为 256MB
$ echo "268435456" | sudo tee /sys/fs/cgroup/demo/memory.max
# 设置软限制(内核尽量但不保证回收到此水位以下)
$ echo "214748364" | sudo tee /sys/fs/cgroup/demo/memory.high
# 限制 swap 使用
$ echo "0" | sudo tee /sys/fs/cgroup/demo/memory.swap.max # 禁用 swap
当进程超出内存限制时,Cgroup 会触发 OOM Kill。这比系统级的 OOM Killer 更精准——只杀掉超分配的 cgroup 中的进程,不影响其他 cgroup:
$ dmesg | grep -i oom
[12345.678] Memory cgroup out of memory: Killed process 30124 (python)
total-vm:262144kB, anon-rss:256000kB
查看内存统计:
$ cat /sys/fs/cgroup/demo/memory.stat
anon 245678080
file 12345678
...
pgactivate 9876
pgdeactivate 5432
pgfault 1234567
3.4 IO 限制实战
磁盘 IO 限制在生产环境中极为关键,防止"吵闹的邻居"影响关键服务:
# 限制设备号 8:0(通常是第一块 SATA/SSD)的读带宽为 10MB/s
$ echo "8:0 rbps=10485760" | sudo tee /sys/fs/cgroup/demo/io.max
# 限制 IOPS
$ echo "8:0 riops=1000 wiops=500" | sudo tee /sys/fs/cgroup/demo/io.max
# 查看 IO 统计
$ cat /sys/fs/cgroup/demo/io.stat
8:0 rbytes=52428800 wbytes=26214400 rios=5120 wios=2560
3.5 Network 带宽控制(Cgroup + eBPF)
Cgroup v2 本身不直接提供网络带宽控制,而是通过 cgroup_bpf 程序(eBPF)在 Cgroup 级别挂钩网络数据包处理:
// 简化示例:在 egress 方向丢弃超出带宽的数据包
SEC("cgroup_skb/egress")
int limit_bw(struct __sk_buff *skb) {
if (skb->len > MAX_BUNDLE_SIZE)
return 0; // 丢弃
return 1; // 放行
}
// 附加到 cgroup
// bpflogic_prog__attach_cgroup(cgroup_fd);
四、Namespace + Cgroups 协同:创建一个最小容器
将前面学的知识组合起来,我们不依赖任何容器运行时,自己动手创建一个最小容器:
#!/usr/bin/env python3
"""minimal_container.py - Python 实现的最小容器运行时"""
import os
import sys
import ctypes
import subprocess
libc = ctypes.CDLL("libc.so.6")
# clone 标志位
CLONE_NEWNS = 0x00020000 # Mount
CLONE_NEWUTS = 0x04000000 # UTS
CLONE_NEWIPC = 0x08000000 # IPC
CLONE_NEWPID = 0x20000000 # PID
CLONE_NEWNET = 0x40000000 # Network
CLONE_NEWUSER = 0x10000000 # User
SIGCHLD = 17
STACK_SIZE = 1024 * 1024
def setup_cgroups(container_name, cpu_percent=50, mem_mb=256):
"""为容器设置 Cgroups v2 限制"""
cgroup_path = f"/sys/fs/cgroup/{container_name}"
os.makedirs(cgroup_path, exist_ok=True)
# CPU 限制
quota = int(100000 * cpu_percent / 100)
with open(f"{cgroup_path}/cpu.max", "w") as f:
f.write(f"{quota} 100000")
# 内存限制
with open(f"{cgroup_path}/memory.max", "w") as f:
f.write(str(mem_mb * 1024 * 1024))
# IO 限制(示例:限制读 50MB/s)
# with open(f"{cgroup_path}/io.max", "w") as f:
# f.write(f"8:0 rbps=52428800")
return cgroup_path
def child_func(rootfs, hostname, cmd):
"""在新建 Namespace 中运行的子进程"""
# 1. 设置主机名
libc.sethostname(hostname.encode(), len(hostname))
# 2. 设置根文件系统(pivot_root)
os.chdir(rootfs)
os.makedirs(".old_root", exist_ok=True)
libc.pivot_root(b".", b".old_root")
os.chroot(".")
# 3. 卸载旧的根
libc.umount2(b"/.old_root", 2) # MNT_DETACH
os.rmdir("/.old_root")
# 4. 挂载虚拟文件系统
subprocess.run(["mount", "-t", "proc", "proc", "/proc"])
subprocess.run(["mount", "-t", "sysfs", "sysfs", "/sys"])
subprocess.run(["mount", "-t", "tmpfs", "tmpfs", "/dev"])
# 5. 设置 DNS
with open("/etc/resolv.conf", "w") as f:
f.write("nameserver 8.8.8.8\n")
# 6. 执行用户命令
os.execvp(cmd[0], cmd)
def run_container(image_path, cmd, hostname="minict",
cpu_percent=50, mem_mb=256):
"""运行一个最小容器"""
print(f"[Minict] 启动容器 {hostname}")
print(f"[Minict] CPU限制: {cpu_percent}%, 内存限制: {mem_mb}MB")
# 设置 Cgroups
cgroup_path = setup_cgroups(hostname, cpu_percent, mem_mb)
flags = (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWUSER | SIGCHLD)
def _child():
# 设置 UID/GID 映射
with open(f"/proc/self/uid_map", "w") as f:
f.write("0 1000 1\n")
with open("/proc/self/setgroups", "w") as f:
f.write("deny")
with open(f"/proc/self/gid_map", "w") as f:
f.write("0 1000 1\n")
child_func(image_path, hostname, cmd)
os._exit(1)
pid = os.fork()
if pid == 0:
libc.unshare(flags)
_child()
else:
with open(f"{cgroup_path}/cgroup.procs", "w") as f:
f.write(str(pid))
_, status = os.waitpid(pid, 0)
print(f"[Minict] 容器退出,状态码: {os.WEXITSTATUS(status)}")
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python minimal_container.py <rootfs> <cmd...>")
sys.exit(1)
run_container(image_path=sys.argv[1], cmd=sys.argv[2:])
这是一个完全自包含的容器运行时的雏形。它展示了:
- 通过
unshare()创建独立的 Namespace - 通过
pivot_root()切换根文件系统 - 通过 Cgroups v2 接口控制资源使用
- 通过 User Namespace 映射实现权限隔离
五、Kubernetes 底层的 Namespace 与 Cgroups
了解底层原理后,再看 Kubernetes 的调度就豁然开朗了:
Pod 级别: 一个 Pod 内的所有容器共享同一个 Network Namespace 和 IPC Namespace(这就是为什么 Pod 内容器可以通过 localhost 通信),但各自拥有独立的 Mount Namespace 和 PID Namespace。
Resource Requests/Limits: K8s 中的 resources.limits 最终转化为 Pod 所在 Cgroup 的参数:
resources:
requests:
cpu: "500m" # 相当于 cpu.weight 设置
memory: "256Mi" # 相当于 memory.high 软限制
limits:
cpu: "1000m" # 相当于 cpu.max 硬限制
memory: "512Mi" # 相当于 memory.max 硬限制
通过 Cgroup 路径验证:
# 找到某个容器的 cgroup 路径
$ cat /proc/$(docker inspect --format '{{.State.Pid}}' <container>)/cgroup
0::/kubepods/burstable/pod<pod-uid>/<container-id>
# 查看实际设置的限制
$ cat /sys/fs/cgroup/kubepods/burstable/pod<uid>/<cid>/cpu.max
100000 100000
$ cat /sys/fs/cgroup/kubepods/burstable/pod<uid>/<cid>/memory.max
536870912 # 512MB
Quality of Service(QoS)类别: 根据 requests 和 limits 的设置,K8s 将 Pod 分为 QoS 类别,这体现在 Cgroup 的层级上:
- Guaranteed:requests == limits,Cgroup 位于
/kubepods/ - Burstable:requests < limits,位于
/kubepods/burstable/ - BestEffort:不设置 requests/limits,位于
/kubepods/besteffort/
当宿主机内存紧张时,内核优先回收 BestEffort,再 Burstable,最后 Guaranteed——这就是 Cgroup QoS 保障的基本逻辑。
六、性能阴坑:Namespace 与 Cgroups 的开销
常常有人问:容器的性能到底比裸机差多少?
结论:计算密集型几乎零损耗,IO 密集型有微弱开销。
| 场景 | 裸机 | 容器 | 损耗 |
|---|---|---|---|
| CPU 计算(sysbench) | 100% | 100% | ~0% |
| 内存带宽(stream) | 100% | 99.8% | ~0.2% |
| 本地磁盘 IOPS(随机读) | 145K | 144K | ~1% |
| 网络吞吐(loopback) | 25 Gbps | 25 Gbps | ~0% |
| 网络吞吐(overlay) | 25 Gbps | 23 Gbps | ~8% |
| fork 系统调用耗时 | 1.2μs | 1.3μs | ~8% |
主要开销来源:
- Overlay 文件系统:容器的分层文件系统带来额外 IO 开销,生产中将数据卷挂载到宿主机(
hostPath或 PersistentVolume)可避免 - 网络虚拟化:bridge + veth pair + iptables 规则比直连多跳一跳
- Cgroup 记账:每次系统调用需要更新 Cgroup 的统计计数器
七、安全加固:不是 Namespace 越多越安全
容器的隔离不是绝对的。历史上最知名的容器逃逸(CVE-2019-5736)就是通过容器内进程利用宿主机 runc 的漏洞,通过 /proc/self/exe 写入宿主机进程的内存来逃逸。
安全加固的必要手段:
# 1. Seccomp 限制系统调用
docker run --security-opt seccomp=profile.json ...
# 2. AppArmor/SELinux 强制访问控制
docker run --security-opt apparmor=docker-default ...
# 3. 禁用特权容器(不要使用 --privileged)
docker run --privileged ... # 危险!解除所有安全限制
# 4. 只读根文件系统
docker run --read-only ...
# 5. 最小 Capabilities
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE ...
# 6. User Namespace 隔离
dockerd --userns-remap=default
特别注意:特权容器(--privileged)会禁用所有 Namespace 隔离和 Cgroups 限制,容器内 root 等同于宿主机 root。生产环境中绝对不应使用。
八、生产环境故障排查
8.1 容器 OOM 排查
# 确认是 Cgroup OOM 而非系统 OOM
$ dmesg | grep -E "Out of memory|Killed process"
[12345.678] oom-kill: constraint=CONSTRAINT_MEMCG,
cpuset=/ mems_allowed=0,
task_memcg=/kubepods/burstable/podxxx/container-yyy,
task=python, pid=12345, uid=0
# 查看当前内存使用率和历史峰值
$ cat /sys/fs/cgroup/kubepods/burstable/podxxx/container-yyy/memory.current
$ cat /sys/fs/cgroup/kubepods/burstable/podxxx/container-yyy/memory.peak
# 查看 OOM 事件计数
$ cat /sys/fs/cgroup/kubepods/burstable/podxxx/container-yyy/memory.events
oom 1
oom_kill 1
8.2 容器 CPU Throttling 排查
# 查看 CPU Throttle 状态
$ cat /sys/fs/cgroup/kubepods/burstable/podxxx/container-yyy/cpu.stat
usage_usec 54321000000
user_usec 43210000000
system_usec 11111000000
nr_periods 543
nr_throttled 89 # 被限制的时间段数
throttled_usec 8900000 # 总限制时间
# 如果 nr_throttled / nr_periods > 10%,说明需要增加 CPU limits
8.3 Namespace 泄漏
如果一个进程进入了 Namespace 但没有正确退出(比如容器崩溃后残留),会导致内存无法回收。检测方法:
# 检查没有关联进程的 Mount Namespace
$ find /proc/*/mountinfo 2>/dev/null | head -5
# nsenter 调试残留 Namespace
$ nsenter --mount --target <pid> mount -t proc proc /proc
九、总结:容器不是黑盒
容器技术的优雅之处在于,它没有任何"魔法"——一切隔离都源于 Linux 内核已有的系统调用:
clone()/unshare()/setns()实现 Namespace 隔离/sys/fs/cgroup/下的文件控制资源限制pivot_root()/chroot()切换文件系统视图
理解这些底层机制后,当容器出现 OOM、CPU Throttle、网络异常时,你看到的不再是一个黑盒,而是一层可以通过标准 Linux 工具和系统调用逐层分析的透明环境。
容器的真正威力不在于 Docker 或 Kubernetes 这些工具,而在于让你能以 sysadmin 的思维理解问题,然后用 API 和声明式配置来自动化解决它们。
参考阅读:
- Linux 内核源码:
kernel/nsproxy.c、kernel/cgroup/ man 2 clone、man 2 unshare、man 7 cgroups- Kubernetes 文档:Resource Management for Pods and Containers
- Kernel documentation:
Documentation/admin-guide/cgroup-v2.rst

发表评论 取消回复