第14章:虚拟化与容器——一台机器怎么"分身"成多台
┌──────────────────────────────────────────────────────────────┐
│ 元数据卡 │
│ ├─ 主题:虚拟化与容器 │
│ ├─ 难度:★★★★☆ │
│ ├─ 前置要求:第8章(进程上下文)、第6章(虚拟内存) │
│ ├─ 核心概念:Hypervisor / VT-x / EPT / Namespace / Cgroup │
│ ├─ 主语言:C (KVM ioctl / Linux) │
│ └─ 预计阅读:45 min │
└──────────────────────────────────────────────────────────────┘你在哪
"你在地心里发现了一座精巧的叠屋——一个操作系统里运行着另一个操作系统。虚拟化技术让你在一台机器上运行多个隔离的环境。容器比虚拟机更轻量——它们共享内核,只隔离文件和进程视图。"
假设地心基地里架设了一台 64 核、512 GB 内存的引擎。你想跑三个任务:一个控制 Windows 设备的探测驱动、一个 Linux 上的测量微服务、一个 PostgreSQL 数据仓库。
但是每个"操作系统"都以为自己是整台引擎的唯一主人。
Windows 以为独占所有内核和内存。Linux 也以为独占所有内核和内存。——它们各自都以为自己掌控一切,而这正是问题的关键。
解决方案呢?要么物理上买三台机器(贵,浪费),要么在同一个物理机上让它们互相骗。
这种"骗术"就叫虚拟化。
你的任务
本章分层
- 必读:虚拟机与容器的根本区别(硬件级 vs 内核级虚拟化);Namespace 与 Cgroup 的分工;容器 ≠ VM
- 选读:为什么 x86 指令集“天生不适合虚拟化”;容器和 VM 的密度差异
- 深水区:VT-x 的 VMX Root/Non-Root 模式;EPT 硬件加速地址翻译;设备直通与 IOMMU;KVM API 本章不会要求你掌握
- VMCS 结构的每一个控制位
- EPT 页表的完整遍历过程
- KVM 的 on-demand page allocation 调优
学完本章后,你应该能:
- 画出 Type 1 和 Type 2 Hypervisor 的结构图并说明区别
- 解释为什么 x86 指令集天生不适合虚拟化——以及 Intel VT-x / AMD-V 怎么解决
- 说清 EPT(扩展页表)和影子页表两种内存虚拟化方案的优劣
- 理解设备直通(Passthrough)、半虚拟化(Paravirtualization)、软件模拟三种 I/O 虚拟化路径
- 解释容器的 Namespace + Cgroup 组合到底隔离了什么
- 在 KVM 的上下文中看懂一段从用户态创建 VM 的 C 代码
- 一句话说清容器 ≠ VM
遭遇战——CPU 虚拟化:一个不可能完成的任务
为什么 x86 天生"不可虚拟化"
1974 年,Popek 和 Goldberg 发表了一篇论文,提出了一个虚拟化系统结构的"充分条件"。大意是:一个指令集要能被虚拟化,需要所有的敏感指令(会暴露 CPU 真实状态或改变其模式的指令)都是特权指令(只能在 ring 0 执行)。
x86 不符合这个条件。
x86 有一些指令,比如 SIDT(Store Interrupt Descriptor Table Register)、SGDT(Store Global Descriptor Table Register)——它们在 ring 3 也能执行,但读出的却是真实物理 CPU 的 IDT 和 GDT 地址。如果 Guest OS 读到这些地址,它就会意识到"操,这不是我自己的寄存器!"
x86 上不可虚拟化的指令示例(1985-2005):
指令 功能 问题(在 ring 3 执行)
────── ────────── ───────────────────────────
SIDT 存储 IDT 寄存器 读出真实物理地址——不是 Guest 自己的
SGDT 存储 GDT 寄存器 同上
SLDT 存储 LDT 寄存器 同上
PUSHF/POPF 标志寄存器操作 IF 位(中断标志)的修改被静默忽略
POPFD 弹出 EFLAGS VIP/VIF 被静默忽略在 VT-x 出现之前,解决方案是二进制翻译(Binary Translation):
Guest OS 指令流:
┌─────┬─────┬─────┬─────┬─────┬─────┐
│ mov │ sidt│ add │ push│ pop │ ret │
└──┬──┴──┬──┴─────┴─────┴─────┴─────┘
│ │
│ └── 虚拟化检测 → 在这条指令前插入模拟代码
│
└── 非特权指令 → 直接执行VMware Workstation 早期靠这个吃饭——它把 Guest OS 内核中所有敏感指令在运行时替换为陷阱指令,然后由 VMM 模拟。这个方案可行——Linux 内核只有一个二进制,而其中有敏感指令的位置是有限的(不到 100 个不同位置)。VMM 可以扫描多次,把这些点全部捕捉到——但带来了约 10-30% 的性能损耗。
深水区:VT-x 的 Root/Non-Root Mode 是 CPU 硬件虚拟化的核心机制,但本卷主线不需要掌握这些细节。以下内容面向对虚拟化实现感兴趣的专业读者。
VT-x / AMD-V:硬件来救场
2005 年,Intel 和 AMD 各自在 CPU 中加入了虚拟化扩展。核心思想:给 CPU 增加两种操作模式:
VT-x 引入的两种操作模式:
┌─────────────────────────────────────┐
│ VMX Root Mode │ ← VMM(Hypervisor)驻留在此
│ ring 0 / ring 3 │ 拥有完整的硬件控制权
│ 可以执行所有特权指令 │
└────────────┬────────────────────────┘
│ VM Entry (VMLAUNCH / VMRESUME)
│ VM Exit(发生敏感事件时自动回到 Root)
▼
┌─────────────────────────────────────┐
│ VMX Non-Root Mode │ ← Guest OS 驻留在此
│ "看起来"有 ring 0-3 │ 但实际是"虚拟"的 ring 0
│ 大多数指令直接执行(没有性能损失) │ 敏感指令触发 VM-Exit
└─────────────────────────────────────┘在 Non-Root Mode 中,Guest OS 自以为自己拥有 ring 0 的所有权力。它尝试 cli(关中断)或者修改页表——这些原来必须真正执行的操作,现在会被 CPU 硬件自动捕获(VM-Exit),然后由 VMM 模拟。对 Guest 来说,cli 执行完成了——只是实际上并没有真的把物理 CPU 的中断关掉,而是 VMM 记了一笔"Guest 认为中断是关的";真正关中断的决定权仍在 VMM。
Guest 执行 cli(关中断)
│
▼
CPU 硬件检测到:
"这是在 VMX Non-Root Mode 里执行的敏感指令"
│
▼
VM-Exit → 保存 Guest 状态(寄存器、CS、RIP 等)
│
▼
VMM 处理 VM-Exit,原因码 = 中断标志修改
│
▼
VMM 模拟 cli 的效果:在虚拟 VMCS 中记录 IF=0
│
▼
VM-Resume → 恢复 Guest 执行
Guest 看到 cli "执行成功"——但实际上物理机的中断并没有关VMCS(Virtual-Machine Control Structure) 是 VT-x 的核心数据结构,每个 vCPU 对应一个 VMCS,记录了虚拟 CPU 的状态和控制信息:
// VMCS 结构是 Intel 定义的不透明格式,通过 VMREAD/VMWRITE 指令访问
// 以下是其逻辑内容的简化表示
struct vmcs_fields {
/* Guest 状态区 —— 虚拟机状态 */
uint64_t guest_rip; // 当前指令指针
uint64_t guest_rsp; // 栈指针
uint64_t guest_cr0; // 控制寄存器 0
uint64_t guest_cr3; // 页表基地址
uint64_t guest_rflags; // 标志寄存器
uint16_t guest_es_selector; // 段选择子
/* Host 状态区 —— 发生 VM-Exit 后恢复 */
uint64_t host_rip;
uint64_t host_rsp;
uint64_t host_cr0;
uint64_t host_cr3;
/* 控制区 —— 决定什么事件触发 VM-Exit */
uint32_t cpu_based_ctls; // 基于 CPU 的 Exit 控制
uint32_t pin_based_ctls; // 基于引脚的中断 Exit 控制
uint32_t exit_ctls; // VM-Exit 行为控制
uint32_t entry_ctls; // VM-Entry 行为控制
/* Exit 信息区 —— 记录刚发生的 VM-Exit 原因 */
uint32_t exit_reason; // 退出原因码
uint64_t exit_qualification; // 退出资格数据
};每次 VM-Entry/VM-Exit 的代价:约 2000-5000 个 CPU 周期——比系统调用(~100 周期)贵得多。这就是为什么高频率 I/O(如网络包处理)在不做特殊优化时,虚拟化性能会显著下降。
💡 洞见: VT-x 的妙处不是"让虚拟化变快"——而是消除了二进制翻译的开销,让大多数 Guest 指令直接执行。VM-Exit 很贵,但只有敏感指令才会触发它,而普通指令根本不经过 VMM。一个典型的计算密集型 VM,VM-Exit 频率不超过几千次/秒——二进制翻译的 10-30% 损耗就这样被硬件消灭了。
获得技能——内存虚拟化:第二层页表
深水区:EPT(扩展页表)是 Intel 2008 年引入的硬件特性。以下内容探讨两种内存虚拟化方案(影子页表 vs EPT)的实现差异,属于虚拟化实现者关注的深水话题。
深水区:EPT(扩展页表)是 Intel 2008 年引入的硬件特性。以下内容探讨两种内存虚拟化方案(影子页表 vs EPT)的实现差异,属于虚拟化实现者关注的深水话题t Physical Address)。物理机的 MMU 只知道 HPA(Host Physical Address)。
传统方案:VMM 为每个 Guest 进程维护一个"影子页表",直接将 GVA 映射到 HPA:
Guest 的视角: VMM(影子页表机制):
GVA → GPA(Guest 页表) GVA → HPA(影子页表)
↑
实时同步:Guest 修改 CR3 或页表时
→ VM-Exit → VMM 更新影子页表
→ Guest RESUME问题:每次 Guest 修改 CR3(进程切换)、修改页表项,都会触发 VM-Exit。如果 Guest 是个频繁 fork 的 Web 服务器——每秒几百次的页表修改——VM-Exit 的次数就能让 CPU 空转 40%。
EPT(Extended Page Tables)——VT-x 的第二代方案
Nehalem 架构引入的(2008 年)。思路:两层页表,由硬件联合遍历。
Guest 运行时,CPU 自动进行两级地址翻译:
GVA(Guest Virtual Address)
│
│ Guest 自己的页表(由 Guest OS 维护)
│ 翻译:GVA → GPA
▼
GPA(Guest Physical Address)
│
│ EPT 页表(由 VMM 维护)
│ 翻译:GPA → HPA
▼
HPA(Host Physical Address)
一次 GVA → HPA 的翻译需要:
4 级 Guest 页表遍历 × 4 级 EPT 遍历 = 最多 24 次内存访问
(但 TLB 能缓存大部分结果)没有 EPT 的年代,Guest 里的每一个 page walk 都需要 VMM 帮忙——也就是 VM-Exit。
有了 EPT,Guest 的页表操作不再触发 VM-Exit。Guest OS 可以自由修改 CR3、刷新 TLB——CPU 硬件自动完成两层翻译。
性能对比(来自 KVM 社区基准测试):
场景 影子页表 EPT
─────────────────────────────────────────────
空循环(仅 CPU) 100% 100%
编译 Linux 内核 93% 98%
数据库 OLTP 85% 96%
Web 服务器 80% 95%
(以上数值百分比相对于裸机性能)EPT 在 I/O 密集和频繁上下文切换的场景下优势明显,因为 VM-Exit 次数大幅减少。
常见陷阱——I/O 虚拟化的三条路
路径 1:软件模拟(Emulation)
// QEMU 模拟一个虚拟的 e1000 网卡
// Guest 写 MMIO 寄存器 → VM-Exit → QEMU 模拟硬件行为
int e1000_io_write(struct e1000_state *s, uint32_t addr, uint32_t val) {
switch (addr) {
case E1000_TDT: /* 发送描述符尾指针 */
s->tx_desc_tail = val;
/* 读取 Guest 内存中的网络包数据 */
process_tx_packets(s);
break;
case E1000_RCTL: /* 接收控制 */
s->receive_control = val;
break;
// ... 几十个寄存器
}
return 0;
}优点:兼容性完美——Guest 不需要特殊驱动,看到的就是"一块真实的网卡"。 缺点:每次 MMIO 都触发 VM-Exit,性能极差(网络吞吐可低至裸机 20%)。
深水区:设备直通(Passthrough)是 IOMMU 与 VFIO 配合实现的高级 I/O 虚拟化方案。以下为专业读者提供。
路径 2:设备直通(Passthrough / VFIO)
直接把物理设备分配给 Guest:
物理机 PCIe 总线
│
├── GPU(给 VM1——直接 IOMMU 映射)
-d / AMD-Vi)**,设备 DMA 操作的物理地址由 IOMMU 翻译——防止设备错误地访问其他 VM 或 Host 的内存。VMM 配置 IOMMU 页表,把设备 DMA 范围限制给指定 Guest。Guest → MMIO 操作 → 跳过 VMM,直接访问硬件 Guest → DMA 传输 → IOMMU 翻译 GPA → HPA 设备读出/写入 → IOMMU 验证 → 只有允许的物理地址才生效
性能:接近裸机(95%+)。代价:一块物理设备只能给一个 VM。
### 路径 3:半虚拟化(Paravirtualization)
折中方案——Guest 使用经过修改的"虚拟化感知"驱动(virtio),通过共享内存和事件通知与 VMM 通信:Guest OS VMM / Host ───────── ───────── virtio-net 驱动 virtio 后端(vhost) │ │ │ 1. 写入 virtqueue │ │ (共享内存环形缓冲) │ └─────────────────────→ │ │ 2. 实际处理(硬件中断) ←─────────────────────── │ │ 3. 通知 Guest │ │ (通过 EventFD) │ │ │
设计关键:**共享内存环形缓冲区(virtqueue)** 避免了数据在 Guest 和 VMM 之间的复制。Guest 直接把数据包写入环形缓冲区,通知 VMM 取走——VMM 用 DMA 传到物理网卡。一个数据包只需要一次内存拷贝,而不是传统的四次。传统软件模拟:Guest 内存 → QEMU 内存 → 内核 → 硬件 (4次拷贝) virtio 半虚拟化:Guest virtqueue → vhost → 硬件 (1-2次拷贝)
性能:约为裸机的 80-90%,远好于软件模拟。
---
## 常见陷阱(续)——容器:轻量级的"假虚拟化"
### Namespace——你看不见我
容器的本质不是虚拟化硬件,而是虚拟化**内核资源视图**。Linux Namespace 让一组进程以为自己是系统里唯一的进程、唯一的网络接口、唯一的挂载点:
```c
// 用 clone() 创建隔离的进程
// 等同于 "docker run" 底层的系统调用
#define STACK_SIZE (1024 * 1024)
static char child_stack[STACK_SIZE];
int child_fn(void *arg) {
/* 现在这个进程拥有自己的 PID namespace */
printf("在这个 namespace 中,我的 PID = %d\n", getpid());
/* ps aux 只会看到 namespace 内的进程 */
execlp("/bin/bash", "bash", NULL);
return 0;
}
int main() {
pid_t child = clone(child_fn, child_stack + STACK_SIZE,
CLONE_NEWPID | /* 独立的 PID 空间 */
CLONE_NEWNS | /* 独立的挂载空间 */
CLONE_NEWNET | /* 独立的网络栈 */
CLONE_NEWIPC | /* 独立的 IPC 资源 */
CLONE_NEWUTS | /* 独立的 hostname */
SIGCHLD, NULL);
if (child < 0) {
perror("clone failed");
return 1;
}
wait(NULL);
return 0;
}在容器里 ps aux:
# 容器内的进程列表——只看到自己和 init
PID TTY TIME CMD
1 pts/0 00:00:00 bash
11 pts/0 00:00:00 ps在外面看:
# 物理机的进程列表——看到完整的真 PID
PID TTY TIME CMD
8841 pts/3 00:00:00 bash_read ← 容器里的 PID 1
8842 pts/3 00:00:00 ps ← 容器里的 PID 11每个 Namespace 的作用:
| Namespace | 隔离什么 | 宏常数 |
|---|---|---|
| PID | 进程编号 | CLONE_NEWPID |
| Network | 网络栈(接口、路由、iptables) | CLONE_NEWNET |
| Mount | 文件系统挂载点 | CLONE_NEWNS |
| UTS | hostname、domainname | CLONE_NEWUTS |
| IPC | System V IPC、POSIX 消息队列 | CLONE_NEWIPC |
| User | UID/GID 映射 | CLONE_NEWUSER |
| Cgroup | cgroup 根目录 | CLONE_NEWCGROUP |
Cgroup——你只能用这么多
Namespace 解决了"你看不到别人"的问题。Cgroup(Control Groups)解决的是"你不能用太多"的问题:
# 限制一个容器最多使用 2 个 CPU 核心和 1 GB 内存
# 创建 cgroup
$ sudo mkdir /sys/fs/cgroup/cpu/container1
$ sudo mkdir /sys/fs/cgroup/memory/container1
# 设置 CPU 配额:最多 2 核(200% 的 1 核)
$ echo 200000 > /sys/fs/cgroup/cpu/container1/cpu.cfs_quota_us
$ echo 100000 > /sys/fs/cgroup/cpu/container1/cpu.cfs_period_us
# 设置内存上限:1 GB
$ echo 1073741824 > /sys/fs/cgroup/memory/container1/memory.limit_in_bytes
# 将 PID 加入 cgroup
$ echo 12345 > /sys/fs/cgroup/cpu/container1/tasks
$ echo 12345 > /sys/fs/cgroup/memory/container1/tasksLinux 支持这些 cgroup 子系统:
cgroup 子系统 限制内容
─────────────── ──────────────────────────
cpu CPU 使用时间配额
cpuacct CPU 使用量统计(自动计费)
memory 内存 + swap 上限
blkio 块设备 I/O 带宽
net_cls 网络流量分类标记
pids 最大进程数
freezer 暂停/恢复所有进程
hugetlb HugeTLB 页面配额💡 洞见: Namespace + Cgroup 的组合是一种"分而治之":Namespace 负责让容器里的程序不知道自己生活在笼子里——它看到的是干净的 PID 1、自己的网络接口、自己的挂载树。Cgroup 负责确保它不会把整个机器搞垮——CPU 超过配额就被节流,内存超过限制就被 OOM-Kill。
容器 vs VM——一张表说清楚
特性 VM 容器
─── ── ──────
虚拟化层级 硬件(CPU/内存/I/O) 操作系统内核接口
Guest OS 独立内核(HVM) 共享宿主机内核
启动时间 30-90 秒 100-500 毫秒
安全隔离 强(硬件边界) 弱(共享内核,依赖 seccomp)
资源开销 每 VM 额外 ~1GB 每容器额外 ~10MB
性能 ~90-95% 裸机 接近裸机(除系统调用)
存储 镜像 ~GB 级 镜像 ~MB~几百 MB
兼容性 可跑任何 OS 只能跑 Linux(大致)
密度 每台 10-50 VM 每台 100-1000 容器
一句话总结:
VM = 人家给你一台完整的电脑,里面装的 Windows/Linux
容器 = 你用同一台电脑上的不同桌面/工作区,各自的窗口互不干扰
典型的 VM 脆弱语录:重启 VM = 冷启动整个操作系统
典型的容器脆弱语录:崩溃的容器 = 崩溃的进程——如果在 Host /proc/sysrq-trigger 上触发敏感操作,
你可以把宿主机内核搞崩溃(所以需要 seccomp 白名单)深水区挑战:用 KVM API 创建 VM 需要理解 Linux 的
/dev/kvm接口。以下挑战是给虚拟化开发者和内核爱好者的,不作为主线通关要求。
通关挑战——用 KVM API 创建一台虚拟机
KVM 暴露给用户态的接口是一个字符设备 /dev/kvm,用户通过 ioctl 跟它交互。以下代码创建了一个 vCPU,加载一小段 x86 代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <linux/kvm.h>
/* 一段简单的 BIOS:让 x86 CPU 进入 64-bit 模式后 hlt */
static const uint8_t guest_code[] = {
0x0f, 0x01, 0x2e, 0x00, 0x00, 0x00, 0x00, // lgdt [gdt_ptr]
// ... 简化起见,直接 hlt
0xf4, // hlt
};
int main() {
int kvm_fd = open("/dev/kvm", O_RDWR);
if (kvm_fd < 0) { perror("open /dev/kvm"); return 1; }
/* 确认 KVM 版本 */
int version = ioctl(kvm_fd, KVM_GET_API_VERSION, 0);
printf("KVM API version: %d\n", version);
/* 创建 VM */
int vm_fd = ioctl(kvm_fd, KVM_CREATE_VM, 0);
if (vm_fd < 0) { perror("KVM_CREATE_VM"); return 1; }
/* 分配 Guest 物理内存(4KB 用于代码 + 栈)*/
uint64_t mem_size = 0x1000;
uint8_t *mem = mmap(NULL, mem_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
/* 写入 Guest 代码到物理地址 0x0 */
memcpy(mem, guest_code, sizeof(guest_code));
/* 将内存槽注册到 VM */
struct kvm_userspace_memory_region region = {
.slot = 0,
.flags = 0,
.guest_phys_addr = 0x0,
.memory_size = mem_size,
.userspace_addr = (unsigned long)mem,
};
ioctl(vm_fd, KVM_SET_USER_MEMORY_REGION, ®ion);
/* 创建 vCPU */
int vcpu_fd = ioctl(vm_fd, KVM_CREATE_VCPU, 0);
if (vcpu_fd < 0) { perror("KVM_CREATE_VCPU"); return 1; }
/* 获取 KVM_RUN 所需的状态区域大小 */
int mmap_size = ioctl(kvm_fd, KVM_GET_VCPU_MMAP_SIZE, 0);
if (mmap_size < 0) { perror("KVM_GET_VCPU_MMAP_SIZE"); return 1; }
/* mmap 共享的 vCPU 状态区域(含 kvm_run 结构)*/
struct kvm_run *run = mmap(NULL, mmap_size,
PROT_READ | PROT_WRITE,
MAP_SHARED, vcpu_fd, 0);
/* 设置 vCPU 初始寄存器 */
struct kvm_sregs sregs;
ioctl(vcpu_fd, KVM_GET_SREGS, &sregs);
/* 关闭分页 + 保护模式(实模式启动)*/
sregs.cs.base = 0;
sregs.cs.selector = 0;
ioctl(vcpu_fd, KVM_SET_SREGS, &sregs);
struct kvm_regs regs = {0};
regs.rip = 0x0; /* 从地址 0 开始执行 */
regs.rflags = 0x2; /* 必须设置 IF 位 */
ioctl(vcpu_fd, KVM_SET_REGS, ®s);
printf("VM 开始执行...\n");
/* 运行 vCPU——直到发生 VM-Exit */
while (1) {
int ret = ioctl(vcpu_fd, KVM_RUN, 0);
if (ret < 0) { perror("KVM_RUN"); break; }
/* 检查 VM-Exit 原因 */
switch (run->exit_reason) {
case KVM_EXIT_HLT:
printf("Guest 执行了 HLT(正常退出)\n");
goto done;
case KVM_EXIT_IO:
printf("Guest I/O: port=0x%x, size=%d, data=0x%x\n",
run->io.port, run->io.size,
*(uint32_t *)((char *)run + run->io.data_offset));
break;
case KVM_EXIT_SHUTDOWN:
printf("Guest shutdown...\n");
goto done;
default:
printf("VM-Exit 原因: %d\n", run->exit_reason);
goto done;
}
}
done:
close(vcpu_fd);
close(vm_fd);
close(kvm_fd);
munmap(mem, mem_size);
return 0;
}编译:
$ gcc -o kvm_hello kvm_hello.c
$ sudo ./kvm_hello
KVM API version: 12
VM 开始执行...
Guest 执行了 HLT(正常退出)这段代码就是 QEMU+KVM 最原始的内核。你看到的 qemu-system-x86_64 本质上就是一个巨大的 ioctl(KVM_RUN) 循环——只不过它处理了更多的 VM-Exit 事件(MMIO、中断注入、EPT 缺页)。
验收标准
读完本章后,请确认你能回答:
- 虚拟机(VM)和容器的根本区别是什么? 哪个复用宿主机内核?哪个有独立内核?
- Namespace 隔离了什么?Cgroup 限制了什么? 两者分别保证什么维度的"隔离"?
- 为什么容器启动比 VM 快得多? CPU 密集任务在容器和 VM 中的性能差距大吗?
- 为什么容器逃逸比 VM 逃逸更危险? 分别会导致什么后果?
常见卡点
| 卡点 | 真相 |
|---|---|
| "容器 = 轻量级 VM" | 容器复用宿主机内核,VM 运行独立内核。容器逃逸=宿主机沦陷;VM 逃逸=0day 级别的严重漏洞 |
| "容器比 VM 快很多" | CPU 计算密集任务差距极小(❤️%)。但容器无额外内核开销,启动速度差距是数量级的 |
| "KVM 是一个进程" | 每个 VM 在宿主机上是一个 QEMU 进程。`ps aux |
| "Docker 用了虚拟化" | Docker 用的是 Linux Namespace + Cgroup,不需要 CPU 虚拟化硬件支持 |
| "VM 里装了 Guest OS 是完整系统" | 但 VMM 会截获所有敏感指令和硬件中断,Guest "以为"自己在控制一切 |
| "虚拟化损耗主要在 CPU" | 实际主要损耗在 I/O——网络和磁盘的 VM-Exit 频率远高于纯计算 |
现在不需要理解
- SR-IOV 的详细规范(Single Root I/O Virtualization 的物理功能/虚拟功能划分)
- KVM 的 on-demand page allocation 和 Transparent Huge Pages 调优
- libvirt / virt-manager 的管理命令
- OCI 容器运行时规范(runC / crun 的细节差异)
- Kata Containers / gVisor 等安全容器实现
- VMware ESXi 的 vSphere 调度和 DRS 算法
- ARM 的虚拟化扩展(Virtualization Extensions / Security Extensions)
旅人笔记
我第一次在笔记本上用 VMware Workstation 装 Linux 的时候,觉得这东西太神奇了——窗口里的 Ubuntu 居然显示"我有 4 GB 内存",而我笔记本电脑真的只有 4 GB。
后来学了操作系统,才知道这是"骗"的——VMware 给 Guest OS 看到的 4 GB 是假的,真正的物理内存按需分配,Guest 用 1 GB,VMware 才给 1 GB。
但真正让我震惊的是 KVM。当我在 2015 年亲手用十几行 C 代码 +
ioctl调出一个虚拟 CPU 时,那种感觉是——"操作系统跟用户态代码的边界",实际上只是几个系统调用的厚度。而容器就更离谱了。Docker 刚火那阵,我扒了半天它的代码,发现本质就是一个
clone()调用带一堆 namespace flags——进程级别的障眼法。同一个内核、同一套系统调用入口,只是把眼罩给你戴上了。你看不见别人的 PID,你看不见别人的网络接口,你看不见 cgroup 目录——你就觉得自己是一台独立的机器。
虚拟化这件事的本质,说到底:就是撒谎。 给每个客人讲一个不同的故事——而且让他们全都深信不疑。
→ 下一站预告
第15章:CPU 流水线——一条指令的一生
你已经了解了虚拟化——如何让多个软件世界共存于同一台物理机。但更深一层的问题是:你写的每条 C 语言语句,在 CPU 内部是怎么变成一条流水线上的微操作的?冒险、转发、分支预测、乱序执行——这些秘密决定了你的程序到底能跑多快。下一章,进入 CPU 腹地。