一、引言:容器到底"隔离"了什么

Docker、Kubernetes、Podman —— 这些容器技术已经重塑了现代云计算的面貌。但容器的本质是什么?不过是一个或一组进程,被 Linux 内核的两项关键技术"关进了笼子":命名空间(Namespace)控制"你能看到什么",cgroup(Control Group)控制"你能用多少"。理解这两项技术,是深入容器生态的必经之路。本文将从内核源码级别深入剖析命名空间和 cgroup 的实现机制,并通过实战演示从零构建隔离环境。

二、Linux 命名空间:七种隔离全景

命名空间是 Linux 内核的一项特性,它允许将全局系统资源包装在抽象层中,使得一个命名空间中的进程认为它们拥有独立的全局资源实例。当前内核支持 7 种命名空间:

命名空间隔离对象内核版本系统调用标志
Mount (mnt)文件系统挂载点2.4.19CLONE_NEWNS
PID进程 ID 编号空间2.6.24CLONE_NEWPID
Network (net)网络设备、协议栈、路由表2.6.29CLONE_NEWNET
IPCSystem V IPC、POSIX 消息队列2.6.19CLONE_NEWIPC
UTS主机名和域名2.6.19CLONE_NEWUTS
User用户和组 ID 映射3.8CLONE_NEWUSER
Cgroupcgroup 根目录视图4.6CLONE_NEWCGROUP
Time系统时钟(CLOCK_MONOTONIC)5.6CLONE_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/s59.5 GB/s0.8%
磁盘 I/O (fio 4k random)350K IOPS345K IOPS1.4%
网络吞吐 (iperf3)25 Gbps24.7 Gbps1.2%
进程创建 (fork+exit)15μs16.2μs8.0%
上下文切换 (lmbench)2.1μs2.3μs9.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 与容器网络的深度融合

深入理解这些底层技术,才能在容器化生产实践中做出正确的架构决策,构建安全、高效的云原生基础设施。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部