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宏定义隔离对象内核版本
CgroupCLONE_NEWCGROUPCgroup 根目录4.6
PIDCLONE_NEWPID进程 ID 编号空间2.6.24
NetworkCLONE_NEWNET网络设备、路由表、防火墙规则2.6.29
MountCLONE_NEWNS挂载点2.4.19
IPCCLONE_NEWIPCSystem V IPC、POSIX 消息队列2.6.19
UTSCLONE_NEWUTS主机名和域名2.6.19
UserCLONE_NEWUSER用户和组 ID 映射3.8
TimeCLONE_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:])

这是一个完全自包含的容器运行时的雏形。它展示了:

  1. 通过 unshare() 创建独立的 Namespace
  2. 通过 pivot_root() 切换根文件系统
  3. 通过 Cgroups v2 接口控制资源使用
  4. 通过 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(随机读)145K144K~1%
网络吞吐(loopback)25 Gbps25 Gbps~0%
网络吞吐(overlay)25 Gbps23 Gbps~8%
fork 系统调用耗时1.2μs1.3μs~8%

主要开销来源:

  1. Overlay 文件系统:容器的分层文件系统带来额外 IO 开销,生产中将数据卷挂载到宿主机(hostPath 或 PersistentVolume)可避免
  2. 网络虚拟化:bridge + veth pair + iptables 规则比直连多跳一跳
  3. 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
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ .skip-link { position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } .skip-link:focus { top: 0; outline: 3px solid #0056b3; }