虚拟化和容器
虚拟化和容器
你有没有想过,一台物理服务器怎么同时运行几十个"独立的计算机"?或者为什么你在 Docker 里运行一个容器,它和外面的系统之间像有一道隐形的墙,但本质上用的又是同一个操作系统内核?
虚拟化和容器是现代云计算的基础。AWS、Google Cloud、阿里云——它们本质上都是把一台台物理服务器切成很多份,按需出租。理解虚拟化,就理解了云计算的底层基础设施。
Level 1:建立直觉
三种隔离级别
物理机:
最强的隔离(完全独立的硬件)
最低的利用率(一台机器只跑一个应用)
成本最高
虚拟机(VM):
很强的隔离(完整的操作系统,硬件级别的隔离)
较高的利用率(一台物理机跑十几个 VM)
开销:每个 VM 有独立内核,启动慢(分钟级),内存占用大
容器:
较强的隔离(进程级别,共享内核)
最高的利用率(一台主机跑上百个容器)
开销极低(秒级启动,内存开销小)
这三者不是相互替代的关系,而是不同场景的工具:
- 云服务器(EC2/ECS):物理机 → VM → 给用户
- Kubernetes Pod:VM → 容器 → 运行应用
- 高安全场景(多租户):物理机 → VM → 容器(双重隔离)
虚拟机是什么
虚拟机(Virtual Machine):在一台物理机上模拟出来的完整计算机,有自己的 CPU、内存、硬盘、网卡——尽管这些都是软件模拟或硬件辅助虚拟化的。
虚拟化架构:
物理硬件(CPU, RAM, SSD, NIC)
↓
Hypervisor(虚拟机监控器)
├── VM 1(Ubuntu 22.04,2核,4GB)
│ ├── 内核 1
│ └── 应用 1
├── VM 2(Windows Server 2022,4核,8GB)
│ ├── 内核 2
│ └── 应用 2
└── VM 3(CentOS 8,8核,16GB)
├── 内核 3
└── 应用 3
Hypervisor 管理每个 VM 的 CPU 时间、内存、I/O,保证它们互不干扰。
容器是什么
容器(Container):利用操作系统的命名空间(Namespace)和控制组(Cgroups)实现进程级隔离,但共享宿主机的操作系统内核。
容器架构:
物理硬件
↓
宿主机 Linux 内核
↓
容器运行时(containerd / Docker)
├── 容器 1(Nginx,资源限制:1核,256MB)
│ └── nginx 进程(PID 1 in its own PID namespace)
├── 容器 2(PostgreSQL,资源限制:2核,2GB)
│ └── postgres 进程
└── 容器 3(Node.js,资源限制:0.5核,512MB)
└── node 进程
容器没有自己的内核——只是被隔离的进程。这是它比 VM 轻量的根本原因。
一个直观的对比实验
# 创建一个容器(毫秒级)
time docker run --rm ubuntu echo "hello"
# real 0m0.312s ← 312 毫秒,包含镜像检查、Namespace 创建、进程启动
# 创建一个虚拟机(分钟级)
# 传统 KVM/QEMU VM:30-120 秒启动
# Firecracker microVM:< 125 毫秒(专为 serverless 优化)
# 内存开销对比
# Docker 容器(Ubuntu 镜像):~5MB 额外内存
# KVM 虚拟机(Ubuntu):~200-500MB 额外内存(内核+系统进程)
Level 2:原理剖析
硬件辅助虚拟化(Intel VT-x / AMD-V)
早期虚拟化(VMware 1999 年)要用软件模拟所有 x86 指令,性能开销巨大(30-50%)。
2005/2006 年,Intel(VT-x)和 AMD(AMD-V)引入了硬件虚拟化支持,为 CPU 增加了新的执行模式:
CPU 特权级(有了 VT-x 之后):
Host 模式(VMX Root):
Ring -1:Hypervisor(最高权限)
Ring 0:Host OS 内核
Ring 3:Host OS 用户态
Guest 模式(VMX Non-Root):
Ring 0:VM 内核(实际上是受限的,VT-x 捕获特权指令)
Ring 3:VM 用户态
当 VM 内核执行特权指令(如 IN/OUT I/O 指令、修改 CR3 页表寄存器),CPU 自动触发 VM Exit,控制权转给 Hypervisor 处理,然后 VM Entry 回到 VM 继续执行。
VM Exit 的成本:约 1000-5000 个 CPU 周期
现代服务器每秒发生 VM Exit 次数:数万到数百万
→ I/O 密集型应用受影响最大(每次 I/O 都要 VM Exit)
IOMMU(Intel VT-d / AMD-Vi):让 VM 直接安全地访问物理 I/O 设备(PCIe 直通),减少 VM Exit 次数,大幅提升 I/O 性能。
KVM + QEMU:Linux 的虚拟化方案
**KVM(Kernel-based Virtual Machine)**是 Linux 内核的虚拟化模块(自 Linux 2.6.20,2007年):
KVM 架构:
/dev/kvm:KVM 内核模块
QEMU:用户态进程,模拟设备(磁盘、网卡等)
┌──────────────────────┐
│ QEMU(用户态) │ 模拟磁盘、网卡
│ ┌──────────────────┐ │
│ │ VM 代码执行 │ │ ← 通过 KVM 在 VMX Non-Root 模式直接跑
│ └──────────────────┘ │
└──────────────────────┘
↕ VM Exit/Entry(ioctl /dev/kvm)
┌──────────────────────┐
│ KVM 内核模块 │ 处理 VM Exit
└──────────────────────┘
┌──────────────────────┐
│ Linux 内核 │
└──────────────────────┘
AWS EC2、Google GCE、阿里云 ECS——大多数公有云的底层都是某种形式的 KVM。
内存虚拟化:嵌套页表(EPT/NPT)
虚拟机里的内核有自己的虚拟地址空间,但它的"物理地址"也是虚假的(因为物理机上可能有十几个 VM 各自认为自己有一片连续的物理内存)。
**嵌套页表(Intel EPT / AMD NPT)**硬件支持两级地址翻译:
VM 里:虚拟地址 → (Guest Page Table) → Guest 物理地址
Host:Guest 物理地址 → (EPT) → Host 物理地址(真实物理地址)
地址翻译链:
虚拟地址 → PT1 → ... → PT4 (4级) → 到 EPT 再翻译 → 最终物理地址
最坏情况:4级 Guest PT × 4级 EPT = 24 次内存访问(!)
TLB 命中时:0 次额外访问(EPT TLB)
内存超配(Memory Overcommit):Hypervisor 分配给所有 VM 的内存总和可以超过物理内存,通过以下技术实现:
- 气球驱动(Balloon Driver):空闲 VM 把内存"还"给 Hypervisor
- 透明大页共享(KSM):内容相同的内存页在物理上只保留一份
- 内存交换(Swap):不活跃的 VM 内存换到磁盘
容器的核心:Namespace + Cgroups(再深入)
Linux Namespace(7种):
┌───────────────┬──────────────────────────────────────┐
│ Namespace │ 隔离内容 │
├───────────────┼──────────────────────────────────────┤
│ PID │ 进程 ID 空间(容器内 PID 1 ≠ 宿主 PID)│
│ NET │ 网络接口、IP、路由、socket │
│ MNT │ 文件系统挂载点 │
│ UTS │ hostname、domainname │
│ IPC │ 信号量、消息队列、共享内存 │
│ USER │ UID/GID 映射(容器内 root ≠ 宿主 root)│
│ CGROUP │ Cgroup 视图隔离 │
└───────────────┴──────────────────────────────────────┘
Linux Cgroups v2(资源限制):
cpu.max → CPU 配额
memory.max → 内存上限
memory.swap.max → Swap 上限
blkio.weight → 磁盘 I/O 权重
pids.max → 进程数限制
# 手动用 namespace + cgroup 创建"容器"(Docker 底层就是这个)
# 创建新的 PID、NET、MNT、UTS namespace,运行 bash
unshare --pid --net --mount --uts --fork /bin/bash
# 进入容器的 namespace(nsenter)
nsenter -t 1234 --pid --net --mount /bin/bash
# 现在你"在容器里"了
容器镜像:OverlayFS
Docker 镜像是分层的:
应用镜像(myapp):
Layer 4: ADD ./app /app (应用代码,5MB)
Layer 3: RUN pip install -r requirements.txt (依赖,200MB)
Layer 2: FROM python:3.11
Layer 1: FROM ubuntu:22.04 (操作系统,60MB)
OverlayFS 实现:
lowerdir: 只读层(layer1 + layer2 + layer3 + layer4)
upperdir: 可写层(容器运行时的修改)
merged: 两者的合并视图(容器看到的文件系统)
启动 10 个同一镜像的容器:
只读层共享(只存一份 = 265MB)
每个容器有自己的 upperdir(通常很小)
节省大量磁盘空间!
验证 OverlayFS:
# 查看容器的文件系统层
docker inspect mycontainer | python -m json.tool | grep -A 10 GraphDriver
# 直接在宿主机上查看 overlay 挂载
mount | grep overlay
# overlay on /var/lib/docker/overlay2/xxx/merged type overlay
# (lowerdir=...,upperdir=...,workdir=...)
# 查看镜像层
docker history myapp:v1
# IMAGE CREATED CREATED BY SIZE
# abc123 1 hour ago COPY . . 2.3MB
# def456 2 days ago RUN pip install ... 180MB
# ...
Level 3 · 规范怎么定义的(资深)
虚拟化技术的硬件与软件标准
硬件辅助虚拟化的规范由 CPU 厂商定义。Intel 的 VT-x(Virtualization Technology for x86)在 Intel SDM Volume 3C 中有数百页的精确定义,核心概念包括:VMCS(Virtual Machine Control Structure)——一个 4KB 的数据结构,定义了虚拟机的执行环境(guest 的寄存器状态、中断处理方式、哪些指令/事件会触发 VM Exit);VMX 操作模式——CPU 在 root 模式(VMM/Hypervisor 运行)和 non-root 模式(Guest 运行)之间切换。AMD 的对应技术称为 AMD-V/SVM,使用 VMCB(Virtual Machine Control Block)而非 VMCS。
内存虚拟化由硬件嵌套页表实现:Intel 的 EPT(Extended Page Table)和 AMD 的 NPT(Nested Page Table)。Guest 的虚拟地址→Guest 物理地址→Host 物理地址需要两级翻译,最坏情况下一次 TLB Miss 需要 24 次内存访问(4 级 Guest 页表 × 4 级 Host 页表 + 额外的 EPT 违例处理)。这就是虚拟化场景下 TLB 压力更大、大页更重要的原因。
容器的隔离机制由 Linux 内核的 Namespace(7 种:mnt、pid、net、ipc、uts、user、cgroup)和 Cgroups v2(Control Groups,内核文档 Documentation/admin-guide/cgroup-v2.rst)定义。OCI(Open Container Initiative)规范(runtime-spec 和 image-spec)定义了容器运行时的标准接口,Docker、containerd、CRI-O 都遵循 OCI 规范。
Level 4 · 边界与陷阱(所有人)
陷阱 1:容器不是安全沙箱——逃逸攻击真实存在
容器共享宿主机内核,任何内核漏洞都可能被容器内的攻击者利用来"逃逸"到宿主机。2019 年的 CVE-2019-5736(runc 漏洞)允许容器内的恶意进程覆盖宿主机上的 runc 二进制文件,获得 root 权限。2022 年的 CVE-2022-0185(Linux 内核文件系统 bug)也可从容器内触发提权。相比之下,虚拟机通过硬件隔离(VT-x 的 non-root 模式),攻击者需要同时突破 Guest 内核和 Hypervisor 才能逃逸——安全性高一个量级。这就是为什么 Google Cloud 使用 gVisor(用户态内核,拦截系统调用)、AWS 使用 Firecracker(轻量级 VM)来增强容器安全。
陷阱 2:虚拟机的 VM Exit 开销可能导致 I/O 性能暴跌
每次 Guest 执行被 Hypervisor 截获的操作(如访问硬件设备、执行特权指令)都会触发 VM Exit,CPU 需要保存完整的 Guest 状态、切换到 Host 模式、执行 Hypervisor 代码、再 VM Entry 返回——整个过程约 1-5μs。对于 I/O 密集型虚拟机(如高频网络包收发),每个网络包可能触发一次 VM Exit,性能下降可达 50% 以上。SR-IOV(Single Root I/O Virtualization)和 VFIO(Virtual Function I/O)通过让虚拟机直接访问硬件设备(绕过 Hypervisor 的 I/O 路径)来解决这一问题,但代价是牺牲了实时迁移能力。
陷阱 3:Cgroup 的内存限制可能导致 OOM Kill 在"内存充足"时触发
容器的内存限制通过 Cgroup 的 memory.max 设置。但 Linux 内核在计算容器内存使用时,会把 Page Cache(文件系统缓存)算入容器的内存使用量。这意味着容器可能因为读取了大量文件(产生 Page Cache)而触发 OOM Kill,即使应用实际使用的匿名内存远低于限制值。Java 应用特别容易受此影响——JVM 通常根据 Cgroup 限制设置最大堆大小,但忽略了 Page Cache 的占用。解决方案是留出足够的余量(通常建议容器限制 = JVM 堆 + 非堆 + 300-500MB 用于 Page Cache 和其他开销),或使用 memory.low 设置"软限制"让内核优先回收 Page Cache。