6.1 容器底层原理——Linux namespace 与 cgroups

预计阅读时间:15 分钟

📖 目录

学习目标

  • 理解容器与虚拟机的本质区别:共享内核 vs 独立内核
  • 掌握 7 种 Linux namespace 各自隔离的资源及内核版本
  • 能用 unshare / ip netns 手动创建 namespace 隔离环境
  • 会用 cgroups v2 限制进程的 CPU、内存和 PID 上限
  • 理解 OverlayFS 分层存储原理与 CoW 机制
  • 了解容器安全边界和经典逃逸场景

核心知识

主题关键命令 / 接口关联章节
7 种 namespaceunshare, nsenter, ip netns3.1:Docker 容器入门 Docker 容器入门
cgroups v2/sys/fs/cgroup/, systemd-run6.2:Kubernetes 入门 Kubernetes 入门(资源 QoS)
OverlayFS/var/lib/docker/overlay2/3.1:Docker 容器入门 Docker 镜像分层
容器安全seccomp, AppArmor, --cap-drop5.6:SELinux 与 AppArmor SELinux 与 AppArmor

知识关联

原理讲解

为什么容器选择共享内核而非独立内核

容器共享宿主机内核的设计选择源于对"轻量级"的极致追求。虚拟机为每个实例运行完整的 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
IPCSystem V IPC / POSIX 消息队列2.6.19进程间通信隔离
USER用户和组 ID3.8容器内 root ≠ 宿主机 root
CGROUPcgroups 层次结构视图4.6容器只能看到自己的 cgroup 节点

3.3 cgroups 层次结构

cgroups(control groups)将进程组织成树形层次,在每个节点上施加资源限制。Linux 内核自 5.x 起主推 cgroups v2,优势:统一层次结构、更干净的接口、原生支持 pressure stall information(PSI)。

资源cgroups v2 接口文件含义
CPUcpu.max配额上限:$MAX $PERIOD
CPUcpu.weight相对权重(默认 100)
内存memory.max内存硬限制
内存memory.high内存软限制(触发回收)
内存memory.current当前内存用量
I/Oio.maxI/O 带宽限制
I/Oio.weightI/O 权重
PIDspids.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 --pidps 仍显示所有进程未隔离 /proc 挂载点--mount-proc 或在 namespace 内 mount -t proc none /proc
nsenterInvalid argument目标进程已退出或权限不足检查 PID 存活且用 sudo;PID namespace 需特权
cgroups v2 mkdirwrite error: Operation not supported控制器未在当前层级启用cat cgroup.controllersecho "+cpu +memory" > cgroup.subtree_control
overlay 挂载报 too many levels of symbolic linksupper/work 不在同一文件系统或路径包含链接确保 upper/work/lower 在同一文件系统,用绝对路径
容器逃逸:挂载了 docker.sock容器内的进程可控制宿主机 Docker daemon永远不要将 /var/run/docker.sock 挂载到不受信任的容器
systemd-run --userFailed 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>

练习题

  1. 手动隔离实验:用 unshare --pid --mount --uts --fork --mount-proc 启动一个 shell,修改 hostname,验证 ps aux 只显示两个进程。退出后检查宿主机 hostname 是否被影响。
  2. 跨 namespace 通信:创建两个网络 namespace(ns-red 和 ns-blue),分别配置 IP 10.0.1.1/24 和 10.0.1.2/24,用 veth pair 连接到同一个 Linux bridge,验证 ping 互通。
  3. cgroups 内存压测:创建一个 memory.max=50M 的 cgroup,将 stress --vm-bytes 80M --vm-keep -m 1 加入该 cgroup,观察 OOM 行为并用 dmesg 查看内核日志。
  4. nsenter 调试:启动一个 sleep 300 作为后台容器进程,用 nsenter -t $PID -n 进入其网络 namespace 执行 ip addr,再用 lsns 列出当前系统的所有 namespace。
  5. OverlayFS 实验:手动准备 lower/upper/work/merged 目录,在 lower 中放 3 个文件,通过 merged 修改其中 1 个并删除另 1 个,观察 upper 目录的变化,验证 CoW 行为。
点击查看答案
  1. sudo unshare --pid --mount --uts --fork --mount-proc /bin/bashhostname isolatedps aux 只显示 2 个进程(bash + ps)。退出后宿主机 hostname 不变,说明 UTS namespace 隔离生效。
  2. 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 应成功。
  3. 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 清理。
  4. 后台进程 sleep 300 & 获取 PID → sudo nsenter -t $PID -n ip addr 看到隔离的网络栈 → lsns 列出所有 namespace 及其所属进程。
  5. 在 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 namespaceunshare --pid --mount-proc进程树隔离,容器内 PID 从 1 开始
NET namespaceip netns add / unshare --net独立网络栈:接口、IP、路由、iptables
MNT namespaceunshare --mount独立挂载点视图,CoW 存储的基础
UTS namespaceunshare --uts独立 hostname
IPC namespaceunshare --ipc隔离共享内存/信号量/消息队列
USER namespaceunshare --useruid/gid 映射,容器 root ≠ 宿主机 root
CGROUP namespacecgroups v2 自动隐藏宿主机 cgroup 层级
cgroups v2/sys/fs/cgroup/{cpu,memory,pids}.max树形资源限制:CPU 配额、内存上限、进程数
OverlayFSmount -t overlay分层合并 + CoW,镜像层共享
nsenternsenter -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.statCPU 使用统计(usage_usec/system_usec)
pids.current/sys/fs/cgroup/{unit}/pids.current当前进程数
io.stat/sys/fs/cgroup/{unit}/io.statI/O 统计(rbytes/wbytes)
Docker 与 cgroups v2 Docker 20.10+ 默认使用 cgroups v2。可通过 docker info | grep "Cgroup" 确认。若显示 cgroups v1,需在内核启动参数中添加 systemd.unified_cgroup_hierarchy=1

延伸阅读

常见问题

Docker 和虚拟机在隔离级别上有什么本质区别?
虚拟机通过 Hypervisor 虚拟出完整硬件(CPU、内存、磁盘、网卡),每台 VM 运行独立内核。容器共享宿主机内核,通过 namespace 做进程级隔离。VM 隔离更强(hypervisor + 独立内核),容器启动更快(秒级)、资源开销更小(MB 级)。安全隔离要求高的场景用 VM,应用封装和部署用容器。
7 种 Linux namespace 分别隔离什么?
PID(进程号隔离,容器内 PID 1 = 宿主机上的普通进程)、NET(网络栈,独立的网络设备/IP/路由)、MNT(挂载点,独立的文件系统层次)、UTS(主机名和域名)、IPC(System V IPC 和 POSIX 消息队列)、USER(用户和组 ID 映射,容器内 root 可映射为普通用户)、CGROUP(cgroups 视图隔离)。有了这些,进程以为自己运行在独立系统中。
cgroups v2 怎么限制容器内存?
cgroups v2 通过统一文件系统管理:echo "500M" > /sys/fs/cgroup/mygroup/memory.max 设置内存硬限制,超过此限制触发 OOM。echo "100M" > memory.high 设置软限制(超过后被节流但不 kill)。用 systemd-run 最方便:systemd-run --scope -p MemoryMax=500M -p MemoryHigh=400M /path/to/program。
↑ 回到顶部