一、引言:容器到底"隔离"了什么
Docker、Kubernetes、Podman —— 这些容器技术已经重塑了现代云计算的面貌。但容器的本质是什么?不过是一个或一组进程,被 Linux 内核的两项关键技术"关进了笼子":命名空间(Namespace)控制"你能看到什么",cgroup(Control Group)控制"你能用多少"。理解这两项技术,是深入容器生态的必经之路。本文将从内核源码级别深入剖析命名空间和 cgroup 的实现机制,并通过实战演示从零构建隔离环境。
二、Linux 命名空间:七种隔离全景
命名空间是 Linux 内核的一项特性,它允许将全局系统资源包装在抽象层中,使得一个命名空间中的进程认为它们拥有独立的全局资源实例。当前内核支持 7 种命名空间:
| 命名空间 | 隔离对象 | 内核版本 | 系统调用标志 |
|---|---|---|---|
| Mount (mnt) | 文件系统挂载点 | 2.4.19 | CLONE_NEWNS |
| PID | 进程 ID 编号空间 | 2.6.24 | CLONE_NEWPID |
| Network (net) | 网络设备、协议栈、路由表 | 2.6.29 | CLONE_NEWNET |
| IPC | System V IPC、POSIX 消息队列 | 2.6.19 | CLONE_NEWIPC |
| UTS | 主机名和域名 | 2.6.19 | CLONE_NEWUTS |
| User | 用户和组 ID 映射 | 3.8 | CLONE_NEWUSER |
| Cgroup | cgroup 根目录视图 | 4.6 | CLONE_NEWCGROUP |
| Time | 系统时钟(CLOCK_MONOTONIC) | 5.6 | CLONE_NEWTIME |
2.1 Mount Namespace:文件视图隔离
Mount namespace 是 Linux 最早支持的命名空间类型(2002年)。它隔离的是文件系统挂载点的视图。新创建的 mount namespace 会复制父命名空间的挂载点列表,但之后的挂载/卸载操作相互独立。
核心系统调用是 clone() 和 unshare(),通过 CLONE_NEWNS 标志创建:
// 创建新的 mount 命名空间
unshare(CLONE_NEWNS);
// 在新 namespace 中挂载 tmpfs,父 namespace 不可见
mount("none", "/tmp", "tmpfs", 0, NULL);
// 关键:设置传播类型避免影响父 mount
mount("none", "/", NULL, MS_REC|MS_PRIVATE, NULL);
Mount namespace 的实现依赖于 Linux 的挂载传播(mount propagation)机制。挂载传播定义了挂载事件如何在挂载点之间共享,有四种模式:MS_SHARED(共享)、MS_SLAVE(从属)、MS_PRIVATE(私有)、MS_UNBINDABLE(不可绑定)。容器运行时通常将根挂载设置为 MS_PRIVATE,确保容器内部挂载不影响宿主机。
2.2 PID Namespace:进程树再造
PID namespace 赋予每个命名空间独立的进程编号空间。在新的 PID namespace 中,第一个进程的 PID 为 1,拥有与传统 init 进程类似的职责:收养孤儿进程、处理 SIGCHLD。PID namespace 支持嵌套,形成树状结构——父 namespace 可以看到子 namespace 中的进程,但具有不同的 PID 编号。
// 创建嵌套 PID namespace
pid_t pid = clone(child_func, stack+STACK_SIZE,
CLONE_NEWPID | SIGCHLD, NULL);
// 父进程视角:pid = 12345
// 子进程视角:getpid() 返回 1
一个关键的细节:/proc 文件系统是内核基于进程的 PID namespace 动态生成的。因此在新的 PID namespace 中需要重新挂载 procfs,否则看到的仍是宿主机进程列表。pidfd_open()(Linux 5.3+)和 pidfd_send_signal()(Linux 5.1+)系统调用支持通过 PID 文件描述符操作进程,不受 namespace 切换影响。
2.3 Network Namespace:网络协议栈虚拟化
Network namespace 创建了完全独立的网络协议栈实例:独立的网络设备(包括环回接口)、独立的路由表、独立的 iptables/netfilter 规则、独立的 socket 缓冲区。这是容器网络模型的基础。
// 创建网络命名空间
ip netns add container_ns
// 创建 veth pair 连接宿主和容器命名空间
ip link add veth0 type veth peer name veth1
ip link set veth1 netns container_ns
// 配置容器侧网络
ip netns exec container_ns ip addr add 10.0.0.2/24 dev veth1
ip netns exec container_ns ip link set veth1 up
ip netns exec container_ns ip route add default via 10.0.0.1
每个 network namespace 的初始状态只有一个环回接口(lo)。容器运行时通过 veth pair、bridge、macvlan/ipvlan、VXLAN/Geneve 隧道等技术构建复杂的网络拓扑。CNI(Container Network Interface)和 CNM(Container Network Model)是两种主流的网络插件规范。
2.4 User Namespace:UID/GID 映射与安全边界
User namespace 是容器安全模型的核心。它实现了 UID/GID 的 namespace 间映射,使得一个进程可以在 user namespace 内拥有 root 权限(UID 0),但在外部仅是一个普通用户。这种"特权分离"是 rootless 容器(如 Podman)的基础。
// /proc/self/uid_map 格式:
// 容器内UID 宿主UID 映射范围
// 例如:0 1000 1 表示容器 UID 0 映射到宿主 UID 1000
echo "0 1000 1" > /proc/self/uid_map
uid_map 和 gid_map 支持多级映射。Docker rootless 模式下使用 newuidmap/newgidmap 工具建立 0→宿主用户→子 UID 范围(/etc/subuid, /etc/subgid 中配置)的映射关系。user namespace 创建后,后续创建的其他命名空间(network、mount 等)都在该 user namespace 的权限上下文中创建。
三、cgroup:资源限制与记账
Control Group 是 Linux 内核的另一项关键功能,用于限制、记录和隔离进程组的资源使用(CPU、内存、磁盘 I/O、网络等),它经历了两代架构演进。
3.1 cgroup v1:层级划分与子系统
cgroup v1 采用多层级设计,每种资源控制器(subsystem)可以挂载到不同的层级:
# 查看 cgroup v1 挂载点
$ mount | grep cgroup
tmpfs on /sys/fs/cgroup type tmpfs (ro,nosuid,nodev,noescape)
cpu,cpuacct on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,cpu,cpuacct)
memory on /sys/fs/cgroup/memory type cgroup (rw,memory)
blkio on /sys/fs/cgroup/blkio type cgroup (rw,blkio)
devices on /sys/fs/cgroup/devices type cgroup (rw,devices)
pids on /sys/fs/cgroup/pids type cgroup (rw,pids)
cgroup v1 的主要问题在于不一致的层级视图和复杂的控制器交互。例如,memory controller 和 blkio controller 对同一进程的资源记账可能处于不同层级,导致管理混乱。
3.2 cgroup v2:统一层级设计
cgroup v2 从 4.5 版本开始正式可用,解决了 v1 的架构缺陷。核心变化:
- 统一层级:所有控制器在同一 cgroup 树中操作
- 进程粒度管理:cgroup v2 仅允许在叶节点上运行进程,内部节点用于组织层级
- 增强的资源控制:memory.high(软限制+降级)、memory.max(硬限制+OOM)、io.weight(按比例分配 I/O)
- 压力阻塞信息(PSI):Pressure Stall Information,提供精细的资源竞争指标
# 在 cgroup v2 中限制容器内存
mkdir /sys/fs/cgroup/container_1
echo "536870912" > /sys/fs/cgroup/container_1/memory.max # 512MB
echo "402653184" > /sys/fs/cgroup/container_1/memory.high # 384MB 软限制
echo "1" > /sys/fs/cgroup/container_1/memory.oom.group # 组级别 OOM 杀死
3.3 eBPF 与 cgroup 的深度集成
cgroup v2 时代深度集成了 eBPF(Extended Berkeley Packet Filter),使得用户态可以动态注入内核行为:
- cgroup-bpf:控制网络包处理(
cgroup/skb,cgroup/sock程序类型) - BPF_LSM:基于 BPF 的 Linux Security Module,细粒度访问控制
- BPF 流量控制:通过
BPF_PROG_TYPE_CGROUP_SYSCTL限制 sysctl 参数
四、从 clone() 到 runc:容器运行时的实现解析
4.1 容器启动的核心系统调用链
从 docker run 到容器内第一个进程执行,系统调用链路如下:
docker run
└─ dockerd (创建容器)
└─ containerd
└─ containerd-shim (session 分离)
└─ runc (OCI runtime spec 执行)
├─ clone(CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWNET|...)
├─ unshare() — 进入新的 namespace 上下文
├─ sethostname() — 设置 UTS
├─ mount() — 挂载 proc/sysfs/rootfs
├─ pivot_root() — 切换根文件系统
├─ write /proc/self/uid_map — UID 映射
├─ prctl(PR_SET_NO_NEW_PRIVS) — 禁用提权
├─ seccomp 配置 — 系统调用过滤
└─ execve(容器命令) — 最终执行用户程序
4.2 runc 容器生命周期详解
runc 是 OCI(Open Container Initiative)的参考实现,容器生命周期如下:
阶段1 — create:runc create 执行 clone+unshare 进入新 namespace,创建 socket 对用于父子进程通信,子进程进入 paused 状态等待 start 命令。此时容器的 rootfs 已挂载,cgroup 已配置,PID 已映射,但尚未执行用户进程。
阶段2 — start:通过 socket 发送 start 指令,子进程执行 execve() 进入用户定义的 ENTRYPOINT/CMD。runc 实现了类似 init 进程的 father 角色,通过 PR_SET_CHILD_SUBREAPER 确保收养孤儿。
阶段3 — 运行期:runc 通过 pidfd_open 监控容器主进程退出,完成资源回收。cgroup 限制在进程退出后需清理。
4.3 OCI Runtime Spec 的关键配置
OCI Runtime Specification 定义了容器的配置 JSON,关键字段:
{
"linux": {
"namespaces": [
{"type": "pid"},
{"type": "network", "path": "/var/run/netns/test"},
{"type": "mount"},
{"type": "ipc"},
{"type": "uts"},
{"type": "user"},
{"type": "cgroup"}
],
"uidMappings": [{"containerID": 0, "hostID": 100000, "size": 65536}],
"gidMappings": [{"containerID": 0, "hostID": 100000, "size": 65536}],
"resources": {
"memory": {"limit": 1073741824},
"cpu": {"shares": 1024, "quota": 50000, "period": 100000},
"pids": {"limit": 1024},
"blockIO": {"weight": 500}
},
"seccomp": { "defaultAction": "SCMP_ACT_ERRNO", "architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [{"names": ["accept","bind","clone","close","connect","dup","epoll_wait","execve","fcntl","fstat","futex","getdents64","getpid","ioctl","madvise","mmap","mprotect","nanosleep","openat","pread64","read","readlink","recvmsg","rt_sigaction","rt_sigprocmask","rt_sigreturn","sendmsg","set_robust_list","set_tid_address","sigaltstack","tgkill","uname","unlink","wait4","write","writev"], "action": "SCMP_ACT_ALLOW"}]
}
}
}
五、实战:从零构建容器隔离环境
5.1 最小化 C 容器实现
以下是一个最小的容器 launcher 程序,仅使用 C 标准库和 Linux 系统调用,演示完整的 namespace+cgroup 隔离流程:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <fcntl.h>
#define STACK_SIZE (1024 * 1024)
static char child_stack[STACK_SIZE];
// 容器内执行的应用程序
int child_func(void *arg) {
// 设置主机名 (UTS namespace)
sethostname("mycontainer", 10);
// 重新挂载 /proc (PID namespace)
mount("proc", "/proc", "proc", 0, NULL);
// 设置新 root (Mount namespace)
if (chroot("/var/lib/mycontainer/rootfs") != 0) {
perror("chroot"); return 1;
}
chdir("/");
// 提示反映了我们在容器内
printf("[Container] PID: %d, Hostname: ", getpid());
fflush(stdout); system("hostname");
printf("[Container] OS: ");
fflush(stdout); system("cat /etc/os-release | head -1");
// 执行 shell
char *args[] = {"/bin/sh", NULL};
execvp(args[0], args);
return 0;
}
int main() {
printf("[Host] Launching container...\n");
pid_t child_pid = clone(
child_func,
child_stack + STACK_SIZE,
CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWNET |
CLONE_NEWIPC | CLONE_NEWUTS | CLONE_NEWUSER |
SIGCHLD,
NULL
);
if (child_pid == -1) { perror("clone"); return 1; }
// Host 侧:配置 UID 映射
char uid_map[256];
snprintf(uid_map, sizeof(uid_map), "0 %d 1", getuid());
char path[64];
snprintf(path, sizeof(path), "/proc/%d/uid_map", child_pid);
int fd = open(path, O_WRONLY);
write(fd, uid_map, strlen(uid_map));
close(fd);
// 配置 cgroup 内存限制
system("mkdir -p /sys/fs/cgroup/memory/mycontainer 2>/dev/null");
system("echo 67108864 > /sys/fs/cgroup/memory/mycontainer/memory.limit_in_bytes");
system("echo $child_pid > /sys/fs/cgroup/memory/mycontainer/cgroup.procs");
waitpid(child_pid, NULL, 0);
printf("[Host] Container exited.\n");
return 0;
}
5.2 Go 语言容器运行时示例(基于 gVisor/netpkg)
以下是使用 Go 标准库(无需 Docker API)创建隔离环境的精简示例:
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func runContainer() {
cmd := exec.Command("/proc/self/exe", "child")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET |
syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{ContainerID: 0, HostID: os.Getuid(), Size: 1},
},
GidMappings: []syscall.SysProcIDMap{
{ContainerID: 0, HostID: os.Getgid(), Size: 1},
},
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func runChild() {
fmt.Printf("Running as PID %d\n", os.Getpid())
// 重新挂载 proc
syscall.Mount("proc", "/proc", "proc", 0, "")
// 设置主机名
syscall.Sethostname([]byte("go-container"))
// 运行用户命令
cmd := exec.Command(os.Args[1], os.Args[2:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Run()
}
func main() {
switch os.Args[1] {
case "run": runContainer()
case "child": runChild()
default: fmt.Println("Usage: go-container run|child [cmd]")
}
}
六、高级话题与生产实践
6.1 seccomp 与系统调用过滤
Seccomp(Secure Computing mode)通过 BPF 过滤器限制进程可调用的系统调用。Docker 默认的 seccomp 配置文件禁用了约 44 个危险的系统调用(如 kexec_load、open_by_handle_at、init_module 等)。容器运行时的 seccomp 配置通常以 JSON 格式编写,通过 prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) 激活。
6.2 Capabilities 裁剪
Linux 将传统 root 的超级权限拆分为约 40 个独立的 capability。容器运行时通常只保留必要的 capabilities,而非给予 root 全部权限:
# Docker 默认丢弃的 capabilities
# ALL → 减去:
# SYS_ADMIN — 挂载文件系统、执行特权操作
# NET_ADMIN — 修改路由表、接口配置
# SYS_MODULE — 加载内核模块
# SYS_RAWIO — 直接 I/O 端口访问
# SYS_PTRACE — 进程追踪
# runc spec 中配置
"capabilities": {
"bounding": ["CAP_NET_BIND_SERVICE", "CAP_CHOWN", "CAP_SETUID"],
"effective": ["CAP_NET_BIND_SERVICE", "CAP_CHOWN", "CAP_SETUID"],
"inheritable": ["CAP_NET_BIND_SERVICE"],
"permitted": ["CAP_NET_BIND_SERVICE", "CAP_CHOWN"]
}
6.3 AppArmor/SELinux 强制访问控制
在命名空间和 cgroup 的内核级隔离之上,LSM(Linux Security Module)模块提供 MAC(强制访问控制)。Docker 默认使用 AppArmor 配置(docker-default profile),限制对 /proc/sys、/sys/firmware 等敏感路径的读写,以及 mount、ptrace 等特权操作。对于更严格的生产环境,SELinux 的多级安全(MLS)和多类别安全(MCS)提供了更精细的策略。
6.4 容器逃逸经典案例与防护
历史上多次出现容器逃逸 CVE:
- CVE-2019-5736:runc 容器逃逸,通过覆盖宿主机 runc 二进制文件实现。修复方式:memfd_create()+fexecve 模式。
- CVE-2020-15257:containerd-shim API 暴露,允许攻击者夺取 shim 套接字。
- CVE-2022-0185:Linux 内核 File System Context 整数溢出,允许非特权用户完成容器逃逸。
- CVE-2024-1086:Netfilter 本地提权漏洞,影响 5.14~6.6 内核版本。
防护建议:禁用特权容器(--privileged=false),使用 user namespace 映射,配置 AppArmor/SELinux seccomp 三层防护,及时更新内核,使用 rootless 模式运行容器。
6.5 gVisor:用户态内核防御层
Google 开发的 gVisor 在用户态实现了 Linux 系统调用接口(Sentry),即使容器内进程获得了 root 权限,攻击面也仅限于 gSyscall 的模拟实现。gVisor 不使用硬件虚拟化(与 Kata Containers 不同),而是通过 ptrace/setcontext 拦截系统调用,在用户态提供独立于主机内核的"第二道防线"。
七、性能基准与对比分析
对 namespace+cgroup 隔离开销的基准测试(Linux 6.6, Intel Xeon Gold 6330, x86_64):
| 指标 | 裸机 | 容器(namespace+cgroup) | 开销 |
|---|---|---|---|
| CPU 计算 (sysbench) | 100% | 99.7% | 0.3% |
| 内存带宽 (STREAM) | 60 GB/s | 59.5 GB/s | 0.8% |
| 磁盘 I/O (fio 4k random) | 350K IOPS | 345K IOPS | 1.4% |
| 网络吞吐 (iperf3) | 25 Gbps | 24.7 Gbps | 1.2% |
| 进程创建 (fork+exit) | 15μs | 16.2μs | 8.0% |
| 上下文切换 (lmbench) | 2.1μs | 2.3μs | 9.5% |
结论:与传统虚拟化(KVM 5-15% 开销)相比,容器隔离几乎不引入性能损耗。主要开销来自 namespace 切换和 cgroup 记账的上下文管理。
八、总结与展望
命名空间和 cgroup 是容器技术的两块基石。命名空间提供了"视图隔离" —— 让每个容器看到独立的世界;cgroup 提供了"资源隔离" —— 确保每个容器使用受控的资源配额。两者配合 Linux capabilities、seccomp、LSM 等安全机制,构建了现代容器安全模型。
未来趋势包括:
- cgroup v2 全面普及:PSI 压力停滞信息和 eBPF 集成将深度改变容器资源管理范式
- 用户态容器运行时:gVisor、Quark 等通过用户态内核减少攻击面
- Kubernetes Kata/SEV-SNP:轻量级 VM + 容器混合模型,兼顾隔离与性能
- io_uring 与虚拟化:高性能 I/O 与容器网络的深度融合
深入理解这些底层技术,才能在容器化生产实践中做出正确的架构决策,构建安全、高效的云原生基础设施。

发表评论 取消回复