37. 虚拟化与容器原理

梳理虚拟化层次划分、全虚拟化与半虚拟化、VT-x 与 AMD-V 硬件辅助,讲清 Hypervisor 两类架构与 KVM、QEMU 的分工,内存虚拟化的影子页表与 EPT、virtio 的 I/O 路径,再拆解容器本质即 namespace 加 cgroup 加联合文件系统,配合 unshare、runc 实操与 seccomp、capabilities 隔离。

1. 虚拟化的层次与分类

1.1 什么是虚拟化

虚拟化的本质是用软件抽象出与物理资源等价、但可被切分与隔离的逻辑资源。CPU、内存、存储、网络都可被虚拟化。它要解决三个问题:隔离(互不干扰)、封装(可迁移快照)、多租户(超卖提利用率)。

1.2 按抽象层次分类

层次代表技术隔离粒度
硬件级KVM、Xen、VMware ESXi完整机器
操作系统级Docker、LXC、gVisor进程组
语言级JVM、WASM 运行时函数/模块
库级Wine、用户态驱动系统调用

层次越高,隔离越弱但开销越小。虚拟机隔离内核,容器共享内核,这是两者最根本的分界。

1.3 全虚拟化与半虚拟化

  • 全虚拟化:Guest OS 不知道自己被虚拟化,敏感指令由 Hypervisor 动态二进制翻译或硬件辅助截获。无需改 Guest 内核,但翻译有开销。
  • 半虚拟化(paravirtualization):Guest 知道自己跑在虚拟机上,主动用 hypercall 替代敏感指令。Xen 早期典型做法,性能好但需修改 Guest 内核。
  • 硬件辅助虚拟化:Intel VT-x 与 AMD-V 引入根模式/非根模式,让敏感指令在非根模式下自动陷入,无需二进制翻译。

2. Hypervisor 类型与 KVM 与 QEMU 分工

2.1 Type 1 与 Type 2

  • Type 1(裸金属):Hypervisor 直接跑在硬件上,如 Xen、ESXi、Hyper-V。性能高、常用于数据中心。
  • Type 2(宿主型):跑在宿主 OS 之上,如 VirtualBox、VMware Workstation。便于桌面使用。

KVM 是特例:它把 Linux 内核本身变成 Type 1 Hypervisor,靠 kvm.ko 模块加载,因此归类为「内核内嵌式」。

2.2 KVM 与 QEMU 的分工

这是最易混淆的一点:

  • KVM:内核模块,只负责 CPU 与内存的虚拟化(利用 VT-x/AMD-V),提供 /dev/kvm 接口。
  • QEMU:用户态程序,负责设备模拟(磁盘、网卡、显卡)与虚拟机生命周期管理。它调用 KVM 加速 CPU。
QEMU(设备模型 + 控制面)
   └── ioctl(/dev/kvm) ──▶ KVM 模块 ──▶ VT-x/AMD-V 硬件

单独用 QEMU 是纯软件模拟(很慢,但能跨架构);加上 -enable-kvm 后 CPU 走硬件,性能接近原生。

# 检查硬件虚拟化支持
grep -Eoc 'vmx|svm' /proc/cpuinfo
ls -l /dev/kvm
qemu-system-x86_64 -enable-kvm -m 2048 -smp 2 -hda disk.img

3. CPU 虚拟化与硬件辅助

3.1 特权级与敏感指令

x86 原本有 Ring 0~3。Guest 内核想跑在 Ring 0,但真正 Ring 0 已被 Hypervisor 占据。早期 x86 有 17 条「敏感但非特权」指令(如 popf、sgdt),在非 Ring 0 下不陷入,直接导致虚拟化漏洞。这曾是 x86 虚拟化被认为「不可能」的原因。

3.2 VT-x 的两种模式

  • 根模式(root):Hypervisor 运行。
  • 非根模式(non-root):Guest 运行。
  • VMCS:虚拟机控制结构,保存 Guest 与 Host 的寄存器状态、陷入条件。
  • VM Entry / VM Exit:Guest 与 Host 之间的切换,代价约几百到上千周期。

AMD-V 的对应概念是 SVM 与 VMCB。ARM 则是 EL0~EL3 异常级别与 HCR。

3.3 VM Exit 的成本

每次 Guest 访问敏感资源(如读写特权寄存器、MMIO)都会触发 VM Exit。频繁 VM Exit 是虚拟化性能杀手,因此产生了「批处理 hypercall」「posted interrupt」「APICv」等优化。

4. 内存虚拟化

4.1 三层地址空间

Guest 虚拟地址 GVA
   │ Guest 页表(Guest 内核维护)
Guest 物理地址 GPA
   │ Hypervisor 页表
Host 物理地址 HPA

传统 x86 页表只有两级翻译,无法一次完成 GVA→HPA,于是有两种方案。

4.2 影子页表

Hypervisor 为每个 Guest 页表维护一份影子页表,直接完成 GVA→HPA 映射。Guest 改页表时 Hypervisor 需截获并同步影子表,实现复杂、缺页开销大。

4.3 EPT 与 NPT

  • Intel EPT(Extended Page Tables)与 AMD NPT:硬件提供第二级页表,CPU 自动完成 GPA→HPA 翻译,无需软件影子表。
  • 代价是 TLP 缺失时需两次页表遍历(GVA→GPA→HPA),最坏 24 次内存访问。为此引入 VPID 与 大页(2MB/1GB) 降低 TLB 压力。

5. I/O 虚拟化与 virtio

5.1 三种 I/O 虚拟化方式

方式原理性能
全模拟QEMU 模拟真实设备寄存器最差
半虚拟化virtio 前后端协作好
设备直通VT-d/IOMMU 把物理设备给 Guest接近原生

5.2 virtio 架构

virtio 定义了一套标准化虚拟设备接口,Guest 装前端驱动(virtio-net),Host 装后端(vhost-net)。核心是 virtqueue(环形缓冲区),Guest 把请求描述符放环里,Host 处理后回写。

Guest 前端驱动 ──virtqueue(共享内存)── Host 后端/vhost

vhost 把后端下沉到内核态,甚至 vhost-user 下沉到 DPDK/SPDK 用户态,进一步减少上下文切换与拷贝。vring + 批处理 + 中断抑制是 virtio 高性能的三板斧。

5.3 SR-IOV

单根 I/O 虚拟化让一块物理网卡呈现为多个虚拟功能(VF),每个 VF 可直通给一个 VM,绕过 Hypervisor 数据面,接近线速。

5.4 容器网络虚拟化

容器网络同样靠内核设施拼装:

  • veth pair:一对虚拟网卡,一端在容器 net namespace,一端在宿主。
  • bridge:宿主上的虚拟交换机(如 docker0),把多个 veth 连成二层网络。
  • NAT:出网靠 iptables MASQUERADE 做源地址转换。
  • overlay:跨主机用 VXLAN 封装,把二层帧塞进 UDP 报文。
ip link add veth0 type veth peer name veth1   # 造一对 veth
ip link set veth1 netns <container-pid>       # 一端塞进容器
ip link set docker0 up && ip addr add 172.17.0.1/16 dev docker0

6. 容器的本质

6.1 一句话定义

容器 = namespace(隔离视图)+ cgroup(限制资源)+ 联合文件系统(分层镜像)+ 一组安全约束。它没有虚拟硬件,只是被内核特殊对待的一组进程。

6.2 与虚拟机的边界

维度虚拟机容器
隔离对象硬件 + 内核进程视图
内核各自独立共享宿主
启动秒级毫秒级
密度低高
隔离强度强弱(内核漏洞可逃逸)
镜像GB 级MB 级

结论:强隔离与多租户用虚拟机,快速交付与高密度用容器,二者常混用(如 Kata Containers 用轻量 VM 包裹容器)。

7. namespace 详解

7.1 六种主要 namespace

namespace隔离内容隔离后可见
pid进程号容器内 PID 从 1 开始
net网络栈独立网卡、IP、路由、端口
mnt挂载点独立根文件系统
uts主机名与域名独立 hostname
ipc信号量、共享内存独立 System V IPC
user用户与权限映射容器内 root 映射为宿主普通用户
cgroupcgroup 根视图只见自己的 cgroup 树

7.2 手工造一个容器

不用 Docker,用 unshare 就能手搓隔离:

# 新建 pid/net/mnt/uts/ipc 命名空间,并映射为 PID 1
unshare --pid --net --mount --uts --ipc --fork --mount-proc \
  /bin/bash -c 'echo "my pid: $$"; hostname mini; hostname'

# 查看当前进程所属 namespace
ls -l /proc/self/ns/
readlink /proc/self/ns/pid

7.3 user namespace 与 root 映射

user namespace 允许容器内的 root 在宿主上只是一个普通 UID,大幅降低逃逸危害:

unshare --user --map-root-user id
# uid=0(root) gid=0(root)  ← 容器内是 root,宿主上仍是普通用户

8. cgroup 资源限制

8.1 cgroup v1 与 v2

cgroup 把进程分组,对各组施加资源限制。v1 每种子系统一棵独立树,挂载点杂乱;v2 统一为单棵树,用 cpu.weight、memory.max、io.max 等文件控制。

# v2:限制某 cgroup 内存上限 256MB、CPU 权重 100
mkdir /sys/fs/cgroup/demo
echo 268435456 > /sys/fs/cgroup/demo/memory.max
echo 100       > /sys/fs/cgroup/demo/cpu.weight
echo $$        > /sys/fs/cgroup/demo/cgroup.procs

8.2 常用控制器

  • cpu:cpu.max 限带宽(如 50000 100000 表示 0.5 核),cpu.weight 定相对份额。
  • memory:memory.max 硬限,memory.high 软限,超出触发 OOM 或回收。
  • io:io.max 限制设备读写 IOPS 与带宽。
  • pids:pids.max 限制进程数,防 fork 炸弹。

8.3 与 namespace 的差异

namespace 管看得见什么,cgroup 管用得了多少。二者正交,必须配合才能既隔离又限额。

9. 联合文件系统与 OverlayFS

9.1 分层镜像

Docker 镜像由只读层堆叠而成,每层是一次构建指令的产物。容器启动时在最上面加一个可写层。这就是写时复制(CoW):读时穿透到下层,写时把文件复制到可写层再改。

9.2 OverlayFS 的四个目录

lowerdir  ← 只读下层(可多个,镜像层)
upperdir  ← 可写上层(容器层)
merged    ← 合并后呈现给用户的视图
workdir   ← 内部工作目录
mount -t overlay overlay \
  -o lowerdir=/lower:/lower2,upperdir=/upper,workdir=/work /merged

9.3 关键特性

  • 不复制整个文件,只在首次写时复制该文件,节省空间与时间。
  • 删除用 whiteout 文件标记,不真正删下层。
  • 同一文件跨层修改会整文件复制,大文件小改动开销大(这是 CoW 的固有代价)。

10. 容器运行时与实操

10.1 OCI 与运行时分層

docker/nerdctl(CLI)
   └── containerd / CRI-O(高层运行时,管镜像与生命周期)
          └── runc(低层运行时,真正调 clone/unshare)
                 └── Linux 内核(namespace + cgroup)

OCI 标准规定了镜像格式与运行时接口,让 containerd、CRI-O、runc、Kata 可以互换。

10.2 runc 实操

# 导出镜像为 OCI bundle,然后手工跑一个容器
mkdir rootfs && docker export $(docker create alpine) | tar -C rootfs -xf -
runc spec            # 生成 config.json
runc run mycontainer # 按 config.json 创建并运行

config.json 里就是 namespace 列表、cgroup 路径、capabilities、seccomp 规则——容器的一切秘密都在这份文件里。

10.3 容器内看什么

docker run --rm -it --pid=host alpine sh   # 共享宿主 PID namespace
docker inspect --format '{{.State.Pid}}' c1
nsenter -t <pid> -n ip addr                # 进入容器的 net namespace 排查网络

11. 容器安全隔离

11.1 capabilities

Linux 把 root 特权拆成几十种 capability(CAP_NET_ADMIN、CAP_SYS_ADMIN)。容器默认只保留一小部分,可显式丢弃:

docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx

11.2 seccomp

seccomp 过滤系统调用。Docker 默认 profile 禁掉约 44 个危险调用(如 kexec_load、reboot):

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "syscalls": [
    { "names": ["read", "write", "openat"], "action": "SCMP_ACT_ALLOW" }
  ]
}

11.3 其他加固

  • SELinux/AppArmor:强制访问控制,约束容器能碰哪些文件。
  • 只读根文件系统:--read-only,阻断落盘型攻击。
  • user namespace:--userns-remap,让容器 root 无宿主特权。
  • 不挂载 docker.sock:挂载等于把宿主 root 交出去。

12. 常见陷阱

  • 以为容器是轻量虚拟机:容器共享宿主内核,内核漏洞即逃逸,强隔离场景必须上 VM 或 gVisor/Kata。
  • 在容器里跑 systemd 或改内核参数:容器无独立内核,sysctl -w 多半失败或影响宿主。
  • 容器内写数据不挂卷:可写层随容器删除而消失,数据必须落 volume 或绑定挂载。
  • PID 1 不转发信号:容器内 PID 1 若不当,docker stop 的 SIGTERM 收不到,只能等超时被杀。
  • cgroup v1 与 v2 混用:不同发行版默认不同,限制参数写法不通用,排查资源问题先看挂载点。
  • 给容器过多 capabilities:--privileged 等于放弃隔离,能用 --cap-add 就别开特权。
  • 镜像层数过多:每层都有元数据开销,且跨层复制大文件代价高,应合并 RUN 并善用 .dockerignore。
  • 误判 CoW 性能:首次写大文件会整文件复制,数据库类负载必须用卷而非容器可写层。

参考文章

  • 操作系统体系结构 — 内核态与用户态、系统调用的底层机制
  • 进程与线程 — 容器的调度实体与 clone 系统调用
  • 虚拟内存 — 页表、TLB 与 EPT 的地址翻译基础
  • 文件系统与 IO — OverlayFS 与存储栈的衔接
  • 内存管理 — cgroup 内存限制与回收的关系

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机基础」更多文章

  1. 46. 排队论与容量估算:利特尔法则与尾延迟
  2. 45. 编译器优化与中间表示:SSA、内联与循环优化
  3. 44. 并发模型对比:Actor、CSP 与数据并行