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 映射为宿主普通用户 |
| cgroup | cgroup 根视图 | 只见自己的 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 内存限制与回收的关系
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。