6.1 容器底层原理——Linux namespace 与 cgroups
预计阅读时间:15 分钟
📖 目录
学习目标
- 理解容器与虚拟机的本质区别:共享内核 vs 独立内核
- 掌握 7 种 Linux namespace 各自隔离的资源及内核版本
- 能用
unshare/ip netns手动创建 namespace 隔离环境 - 会用 cgroups v2 限制进程的 CPU、内存和 PID 上限
- 理解 OverlayFS 分层存储原理与 CoW 机制
- 了解容器安全边界和经典逃逸场景
核心知识
| 主题 | 关键命令 / 接口 | 关联章节 |
|---|---|---|
| 7 种 namespace | unshare, nsenter, ip netns | 3.1:Docker 容器入门 Docker 容器入门 |
| cgroups v2 | /sys/fs/cgroup/, systemd-run | 6.2:Kubernetes 入门 Kubernetes 入门(资源 QoS) |
| OverlayFS | /var/lib/docker/overlay2/ | 3.1:Docker 容器入门 Docker 镜像分层 |
| 容器安全 | seccomp, AppArmor, --cap-drop | 5.6:SELinux 与 AppArmor SELinux 与 AppArmor |
知识关联
- 前置知识:3.1:Docker 容器入门 Docker 容器入门(容器使用经验)、2.4:进程管理 进程管理(PID namespace 基础)、1.5:文件系统结构 Linux 文件系统结构(OverlayFS 依赖文件系统知识)
- 后续影响:6.2:Kubernetes 入门 Kubernetes 入门(K8s 基于容器运行时编排 Pod)、5.6:SELinux 与 AppArmor SELinux/AppArmor(容器安全加固)
- 配套技术:unshare/nsenter 手动操作 namespace,cgroups v2 通过 systemd 统一管理资源,runc/containerd 是容器运行时的业界标准
原理讲解
为什么容器选择共享内核而非独立内核
容器共享宿主机内核的设计选择源于对"轻量级"的极致追求。虚拟机为每个实例运行完整的 Guest OS 内核,启动需要引导 BIOS、加载内核、初始化系统服务,耗时分钟级,内存开销数百 MB。容器直接复用宿主机内核,通过 namespace 和 cgroups 实现隔离和资源限制,启动只是 fork 一个进程,耗时毫秒级,内存开销几乎为零。这种设计的代价是安全边界较弱——容器与宿主机共享内核,内核漏洞可能导致容器逃逸。但对于大多数场景(微服务、CI/CD、开发环境),容器的轻量级优势远大于安全风险,且可通过 seccomp、AppArmor、rootless 等机制缓解。
为什么要设计 7 种 namespace
7 种 namespace 各自隔离一类全局资源,这种"按资源类型分离"的设计源于 Unix 哲学——每个机制只做一件事并做好。PID namespace 隔离进程编号(容器内 PID 从 1 开始),NET namespace 隔离网络栈(每个容器有独立的 IP、路由表、iptables),MNT namespace 隔离文件系统挂载点(容器有自己的根文件系统),UTS namespace 隔离主机名(容器可以有自己的 hostname),IPC namespace 隔离进程间通信(共享内存、信号量、消息队列),USER namespace 隔离用户和组 ID(容器内 root ≠ 宿主机 root),CGROUP namespace 隔离 cgroups 视图(容器只能看到自己的资源限制)。如果把所有资源混在一个 namespace 里,管理会变得混乱且难以组合——例如你只想隔离网络但不想隔离 PID,按资源类型分离的设计让你可以自由组合。
OverlayFS 为什么能成为容器存储的标准
OverlayFS 的核心优势是"分层合并 + Copy-on-Write"。Docker 镜像由多个只读层组成(每层是上一层的增量),所有层通过 overlay 合并为统一视图。当容器写入文件时,触发 CoW——将文件从 lower 层复制到容器层再修改,lower 层保持不变。这种设计带来两个关键好处:多个容器共享相同的镜像层,节省磁盘空间;镜像拉取时只需下载缺失的层,减少网络传输。相比 AUFS(已废弃)和 Device Mapper(配置复杂),OverlayFS 实现简单、性能优秀、内核原生支持,成为 Docker 和 containerd 的默认存储驱动。
3.1 容器 vs 虚拟机:隔离模型
容器不是"轻量级虚拟机"。虚拟机通过 Hypervisor 硬件虚拟化提供独立 Guest OS 内核,容器则共享宿主机内核,仅通过内核特性实现隔离。
| 维度 | 虚拟机 | 容器 |
|---|---|---|
| 内核 | 每个 VM 有独立 Guest OS 内核 | 所有容器共享 Host 内核 |
| 隔离边界 | 硬件虚拟化(Hypervisor) | 内核特性(namespace + cgroups) |
| 启动时间 | 分钟级(需引导 OS) | 毫秒级(直接启动进程) |
| 镜像大小 | GB 级(含完整 OS) | MB 级(仅应用+依赖) |
| 资源开销 | 高(每个 VM 有独立内存/CPU 预留) | 极低(直接使用宿主机内核) |
| 安全边界 | 强(硬件隔离,Guest 内核独立) | 弱(共享内核,逃逸漏洞风险) |
容器本质上是宿主机上的一组受限进程:namespace 提供隔离视图,cgroups 提供资源约束。
3.2 7 种 Namespace 隔离模型
每种 namespace 将一类全局资源包装成独立视图,使进程"以为"自己独占该资源。
| Namespace | 隔离内容 | 内核版本 | 容器中的效果 |
|---|---|---|---|
| PID | 进程编号 | 2.6.24 | 容器内只能看到自己的进程 |
| NET | 网络设备、IP 地址、路由表 | 2.6.29 | 每个容器有独立的网络栈 |
| MNT | 文件系统挂载点 | 2.4.19 | 容器有自己的根文件系统 |
| UTS | 主机名和域名 | 2.6.19 | 容器可以有自己的 hostname |
| IPC | System V IPC / POSIX 消息队列 | 2.6.19 | 进程间通信隔离 |
| USER | 用户和组 ID | 3.8 | 容器内 root ≠ 宿主机 root |
| CGROUP | cgroups 层次结构视图 | 4.6 | 容器只能看到自己的 cgroup 节点 |
3.3 cgroups 层次结构
cgroups(control groups)将进程组织成树形层次,在每个节点上施加资源限制。Linux 内核自 5.x 起主推 cgroups v2,优势:统一层次结构、更干净的接口、原生支持 pressure stall information(PSI)。
| 资源 | cgroups v2 接口文件 | 含义 |
|---|---|---|
| CPU | cpu.max | 配额上限:$MAX $PERIOD |
| CPU | cpu.weight | 相对权重(默认 100) |
| 内存 | memory.max | 内存硬限制 |
| 内存 | memory.high | 内存软限制(触发回收) |
| 内存 | memory.current | 当前内存用量 |
| I/O | io.max | I/O 带宽限制 |
| I/O | io.weight | I/O 权重 |
| PIDs | pids.max | 最大进程数 |
3.4 联合文件系统(Union FS)
Docker 镜像采用分层结构,得益于 OverlayFS(overlay2 驱动)。每一层是上一层的增量,所有只读层通过 overlay 合并为统一视图,容器写入时触发 Copy-on-Write(将文件从 lower 复制到容器层再修改)。
# Docker 镜像层示意
# [容器层] ← 可写(运行时的修改)
# [镜像层 n] ← 只读(RUN apt-get install)
# [镜像层 ...] ← 只读
# [镜像层 1] ← 只读(FROM ubuntu:24.04)
# ──────────────
# overlay2 merge
3.5 OCI 运行时——容器如何被真正启动
Docker 只是容器生态的"上层建筑"。真正创建 namespace、应用 cgroups、运行进程的是底层的 OCI(Open Container Initiative)运行时。主流实现是 runc(Docker 和 containerd 的默认运行时)。一次 docker run 的完整调用链:
docker run nginx:alpine
└─ docker CLI → Docker daemon (dockerd)
└─ containerd(管理镜像与容器生命周期)
└─ containerd-shim(容器进程的父进程,负责转发信号)
└─ runc create(真正执行 fork + 创建 namespace/cgroups)
└─ /bin/sh -c nginx(容器内的 PID 1)
runc 启动容器的关键操作:读取 OCI bundle(config.json 声明 namespace、capabilities、挂载点),然后调用内核的 clone() 带 CLONE_NEW* 标志创建进程,再应用 cgroups 限制。可以用 runc run 直接体验(需要 root 和一个 OCI bundle):
# 用 docker 导出最小 rootfs,再用 runc 手动启动(理解底层的最佳方式)
mkdir -p /tmp/runc-test/rootfs
docker export $(docker create busybox) | tar -x -C /tmp/runc-test/rootfs
cd /tmp/runc-test
runc spec # 生成默认 config.json
runc run mybox
# 进入容器内 shell(/bin/sh),ps 只有自己
/ # ps
# 输出: PID USER TIME COMMAND
# 1 root 0:00 /bin/sh
3.6 容器安全边界——隔离不是安全
namespace 提供的是"视图隔离"而非"安全隔离"。容器与宿主机共享内核,攻击面远大于虚拟机。经典逃逸面与防御:
| 逃逸面 | 攻击原理 | 防御手段 |
|---|---|---|
特权容器(--privileged) | 获得全部 capabilities,可直接操作宿主机设备(如 mount 宿主机磁盘) | 生产环境禁用 --privileged;用 --cap-drop=ALL --cap-add=... 精确授权 |
| 挂载 docker.sock | 容器内控制 Docker daemon,间接获得宿主机 root | 绝不挂载 /var/run/docker.sock;用 Docker API 的 TLS + 授权代理 |
| 内核漏洞(CVE-2022-0185 等) | 通过有缺陷的内核接口(如 fs_context)提权 | 及时升级内核;启用 seccomp 白名单 + AppArmor/SELinux 配置 |
| 挂载宿主机敏感目录 | 只读挂载也可能被利用(如 /proc/sys 写 sysctl) | 审查所有 volume 挂载;read-only: true + tmpfs |
| 用户命名空间未启用 | 容器内 root 即宿主机 root(uid 0 直接映射) | 在 daemon.json 配置 userns-remap,或改用 rootless 模式(均需显式启用,不是默认行为) |
# 一个"看起来正常"的危险容器——千万不要在生产这么做
docker run -d --privileged --cap-add=ALL \
-v /:/host -v /var/run/docker.sock:/var/run/docker.sock \
ubuntu sleep 3600
# 攻击者只需一行就能读写宿主机文件系统:
# chroot /host bash
--cap-drop=ALL + 按需 --cap-add(容器默认仍有 14 个 cap);② 非 root 用户运行(USER 10001);③ 只读根文件系统(--read-only);④ 启用 seccomp 默认策略与 AppArmor;⑤ 镜像扫描(Trivy/Clair);⑥ 运行时用 gVisor/Kata 隔离内核敏感工作负载。示例代码
示例 1:unshare 创建 PID + UTS namespace
# 创建新的 PID namespace 并挂载隔离的 /proc
sudo unshare --pid --fork --mount-proc /bin/bash
# 在新的 namespace 中
echo $$
# 输出: 1
ps aux
# 输出: 只显示当前 namespace 的进程
示例 2:ip netns 创建网络 namespace + veth pair
# 创建两个网络 namespace
sudo ip netns add ns1
sudo ip netns add ns2
# 创建 veth pair 并连接到 bridge
sudo ip link add veth1 type veth peer name veth1-br
sudo ip link set veth1 netns ns1
sudo ip netns exec ns1 ip addr add 10.0.0.1/24 dev veth1
sudo ip netns exec ns1 ip link set veth1 up
sudo ip link add veth2 type veth peer name veth2-br
sudo ip link set veth2 netns ns2
sudo ip netns exec ns2 ip addr add 10.0.0.2/24 dev veth2
sudo ip netns exec ns2 ip link set veth2 up
# 测试连通
sudo ip netns exec ns1 ping -c 2 10.0.0.2
# 输出: 2 packets transmitted, 2 received
示例 3:nsenter 进入已有 namespace
# 查看目标进程的 namespace inode
ls -la /proc/$PID/ns/
# 输出: lrwxrwxrwx ... net -> net:[4026531992]
# 进入目标进程的网络 namespace
sudo nsenter -t $PID -n ip addr
# 同时进入多个 namespace
sudo nsenter -t $PID --pid --mount --net /bin/bash
示例 4:cgroups v2 手动限制内存
# 创建子 cgroup
sudo mkdir /sys/fs/cgroup/myapp
# 限制内存 100MB
echo "100M" | sudo tee /sys/fs/cgroup/myapp/memory.max
# 限制 CPU 50%(50000/100000)
echo "50000 100000" | sudo tee /sys/fs/cgroup/myapp/cpu.max
# 将当前进程加入 cgroup
echo $$ | sudo tee /sys/fs/cgroup/myapp/cgroup.procs
# 验证当前内存用量
cat /sys/fs/cgroup/myapp/memory.current
# 输出: 4194304 (约 4MB)
# 清理
sudo rmdir /sys/fs/cgroup/myapp
示例 5:手动搭建 overlay2 文件系统
# 准备目录结构
mkdir -p /tmp/overlay-test/{lower,upper,work,merged}
echo "lower file" > /tmp/overlay-test/lower/hello.txt
# 挂载 overlay
sudo mount -t overlay overlay \
-o lowerdir=/tmp/overlay-test/lower,\
upperdir=/tmp/overlay-test/upper,\
workdir=/tmp/overlay-test/work \
/tmp/overlay-test/merged
# 读取合并视图
cat /tmp/overlay-test/merged/hello.txt
# 输出: lower file
# 在合并层写入(CoW 触发,写入 upper)
echo "overwrite" > /tmp/overlay-test/merged/hello.txt
cat /tmp/overlay-test/upper/hello.txt
# 输出: overwrite
# lower 文件保持不变
# 卸载
sudo umount /tmp/overlay-test/merged
示例 6:capabilities 最小授权实验
# 容器默认拥有的 capabilities(对比 root 的全部能力)
docker run --rm alpine sh -c 'capsh --print | grep Current'
# 输出: Current: = cap_chown,cap_dac_override,cap_fowner,cap_fsetid,
# cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,
# cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap
# 实验:默认容器无法 mount(cap_sys_admin 被移除)
docker run --rm alpine sh -c 'mount -t tmpfs none /mnt'
# 输出: mount: permission denied
# 实验:只给 NET_BIND_SERVICE 时仍能绑定 80 端口
docker run --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
alpine sh -c 'python3 -m http.server 80 & sleep 1; echo started'
# 输出: Serving HTTP on 0.0.0.0 port 80 ...
# 查看进程实际 capabilities(宿主视角)
docker run -d --name cap-test alpine sleep 300
grep CapEff /proc/$(pgrep -f 'sleep 300' | head -1)/status
# 输出: CapEff: 000001ffffffffff
# 用 capsh 解码:capsh --decode=000001ffffffffff
# 输出: 0x000001ffffffffff=cap_chown,cap_dac_override,...(14 个默认 cap)
常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
unshare --pid 后 ps 仍显示所有进程 | 未隔离 /proc 挂载点 | 加 --mount-proc 或在 namespace 内 mount -t proc none /proc |
nsenter 报 Invalid argument | 目标进程已退出或权限不足 | 检查 PID 存活且用 sudo;PID namespace 需特权 |
cgroups v2 mkdir 后 write error: Operation not supported | 控制器未在当前层级启用 | 先 cat cgroup.controllers 并 echo "+cpu +memory" > cgroup.subtree_control |
overlay 挂载报 too many levels of symbolic links | upper/work 不在同一文件系统或路径包含链接 | 确保 upper/work/lower 在同一文件系统,用绝对路径 |
容器逃逸:挂载了 docker.sock | 容器内的进程可控制宿主机 Docker daemon | 永远不要将 /var/run/docker.sock 挂载到不受信任的容器 |
systemd-run --user 报 Failed to connect bus | 用户实例 systemd 未启动 | 检查 loginctl user-status,或改用 sudo systemd-run |
最佳实践
| 实践 | 说明 |
|---|---|
优先用 systemd-run 而非手动 mkdir cgroup | 手动 cgroup 不可逆,systemd unit 管理更安全可追踪 |
容器内用 tini / dumb-init 作为 entrypoint | 容器 PID 1 需 reaping 僵尸进程,Java/Node 的 init 进程不具备该能力 |
| 理解 capabilities 的最小权限原则 | --cap-drop=ALL --cap-add=NET_BIND_SERVICE 而非 --privileged |
| 用 USER namespace 映射非 root | 容器内 root 映射到宿主机非特权 uid,降低逃逸风险 |
| Dockerfile 将常变层放后面 | 利用层缓存减少构建时间;合并 RUN 命令减少层数 |
| 启用 seccomp + AppArmor | 限制容器可用的系统调用,默认策略能阻止大部分已知逃逸 |
用 --read-only --tmpfs /tmp 运行容器 | 不可变根文件系统+可写临时目录,兼顾安全与功能 |
ip netns exec 需要对应 netns 名称存在 | 用 ip netns list 确认;删除用 ip netns delete <name> |
练习题
- 手动隔离实验:用
unshare --pid --mount --uts --fork --mount-proc启动一个 shell,修改 hostname,验证ps aux只显示两个进程。退出后检查宿主机 hostname 是否被影响。 - 跨 namespace 通信:创建两个网络 namespace(ns-red 和 ns-blue),分别配置 IP 10.0.1.1/24 和 10.0.1.2/24,用 veth pair 连接到同一个 Linux bridge,验证 ping 互通。
- cgroups 内存压测:创建一个 memory.max=50M 的 cgroup,将
stress --vm-bytes 80M --vm-keep -m 1加入该 cgroup,观察 OOM 行为并用dmesg查看内核日志。 - nsenter 调试:启动一个
sleep 300作为后台容器进程,用nsenter -t $PID -n进入其网络 namespace 执行ip addr,再用lsns列出当前系统的所有 namespace。 - OverlayFS 实验:手动准备 lower/upper/work/merged 目录,在 lower 中放 3 个文件,通过 merged 修改其中 1 个并删除另 1 个,观察 upper 目录的变化,验证 CoW 行为。
点击查看答案
sudo unshare --pid --mount --uts --fork --mount-proc /bin/bash→hostname isolated→ps aux只显示 2 个进程(bash + ps)。退出后宿主机 hostname 不变,说明 UTS namespace 隔离生效。ip netns add ns-red && ip netns add ns-blue→ 创建 veth pair 分别接入两个 namespace 并配 IP → 创建 Linux bridge 将两端的 veth 接入 →ip netns exec ns-red ping 10.0.1.2应成功。mkdir /sys/fs/cgroup/test && echo 50M > /sys/fs/cgroup/test/memory.max && echo $$ > /sys/fs/cgroup/test/cgroup.procs→ 运行 stress → dmesg 中出现 OOM killer 信息。用rmdir /sys/fs/cgroup/test清理。- 后台进程
sleep 300 &获取 PID →sudo nsenter -t $PID -n ip addr看到隔离的网络栈 →lsns列出所有 namespace 及其所属进程。 - 在 lower 放 3 个文件 → 通过 merged 修改 1 个(upper 出现新文件,lower 不变)→ 删除 1 个(upper 出现 character device whiteout)→ 验证 CoW:只将被修改/删除的文件复制到 upper,其他仍读 lower。
学习检查点
学完本章后,请检验自己是否掌握以下内容:
| 检查项 | 自测问题 | 验证方法 |
|---|---|---|
| 概念理解 | 能用自己的话解释 Linux Namespace 和 Cgroups 的作用 | 尝试向他人讲解 |
| 命令操作 | 能不查文档完成 unshare 创建命名空间、cgroup 限制资源 | 在终端实际执行 |
| 原理掌握 | 能说出容器与虚拟机在隔离层级和性能上的本质区别 | 画出流程图 |
| 故障排查 | 能独立排查容器内网络不通或资源限制不生效的问题 | 模拟故障并修复 |
| 最佳实践 | 能说明为什么容器需要最小化基础镜像和非 root 运行 | 对比不同方案 |
本章总结
速查表
| 概念 | 关键命令 / 文件 | 一句话总结 |
|---|---|---|
| Container vs VM | 共享内核 vs 独立内核 | 容器是受限进程,VM 是虚拟硬件 |
| PID namespace | unshare --pid --mount-proc | 进程树隔离,容器内 PID 从 1 开始 |
| NET namespace | ip netns add / unshare --net | 独立网络栈:接口、IP、路由、iptables |
| MNT namespace | unshare --mount | 独立挂载点视图,CoW 存储的基础 |
| UTS namespace | unshare --uts | 独立 hostname |
| IPC namespace | unshare --ipc | 隔离共享内存/信号量/消息队列 |
| USER namespace | unshare --user | uid/gid 映射,容器 root ≠ 宿主机 root |
| CGROUP namespace | cgroups v2 自动 | 隐藏宿主机 cgroup 层级 |
| cgroups v2 | /sys/fs/cgroup/{cpu,memory,pids}.max | 树形资源限制:CPU 配额、内存上限、进程数 |
| OverlayFS | mount -t overlay | 分层合并 + CoW,镜像层共享 |
| nsenter | nsenter -t $PID -n -m | 进入已有 namespace 调试 |
学习路径:6.1:容器底层原理 容器底层原理(本篇)→ 3.1:Docker 容器入门 Docker 入门 → 5.2:Docker 生产实践 Docker 生产实践 → 6.2:Kubernetes 入门 Kubernetes 入门 → 6.7:GitOps 入门 GitOps。
cgroups v2 运维实践
在生产环境中,cgroups v2 不仅是容器的底层机制,也是系统管理员排查资源问题的核心工具:
# 查看当前进程的 cgroup 状态
cat /proc/self/cgroup # 输出 cgroup v2 路径
# 查看容器(Docker)的资源限制
cat /sys/fs/cgroup/docker/{容器ID}/cpu.max
cat /sys/fs/cgroup/docker/{容器ID}/memory.max
# 查看系统级 PSI(Pressure Stall Information)
cat /proc/pressure/cpu # CPU 压力指标
cat /proc/pressure/memory # 内存压力指标
cat /proc/pressure/io # I/O 压力指标
# PSI 阈值告警(当 some avg10 > 10 时告警)
# systemd 可配置:
# MemoryPressureThresholdSec=30s
# MemoryPressureWatch=on
| 指标 | 文件 | 含义 |
|---|---|---|
| memory.current | /sys/fs/cgroup/{unit}/memory.current | 当前内存使用量 |
| memory.max | /sys/fs/cgroup/{unit}/memory.max | 内存上限(max = 无限制) |
| cpu.stat | /sys/fs/cgroup/{unit}/cpu.stat | CPU 使用统计(usage_usec/system_usec) |
| pids.current | /sys/fs/cgroup/{unit}/pids.current | 当前进程数 |
| io.stat | /sys/fs/cgroup/{unit}/io.stat | I/O 统计(rbytes/wbytes) |
docker info | grep "Cgroup" 确认。若显示 cgroups v1,需在内核启动参数中添加 systemd.unified_cgroup_hierarchy=1。