第 27 章

虚拟化和容器

虚拟化和容器

你有没有想过,一台物理服务器怎么同时运行几十个"独立的计算机"?或者为什么你在 Docker 里运行一个容器,它和外面的系统之间像有一道隐形的墙,但本质上用的又是同一个操作系统内核?

虚拟化和容器是现代云计算的基础。AWS、Google Cloud、阿里云——它们本质上都是把一台台物理服务器切成很多份,按需出租。理解虚拟化,就理解了云计算的底层基础设施。

Level 1:建立直觉

三种隔离级别

物理机:
  最强的隔离(完全独立的硬件)
  最低的利用率(一台机器只跑一个应用)
  成本最高

虚拟机(VM):
  很强的隔离(完整的操作系统,硬件级别的隔离)
  较高的利用率(一台物理机跑十几个 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 的内存总和可以超过物理内存,通过以下技术实现:

容器的核心: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。

本章评分
4.8  / 5  (3 评分)

💬 留言讨论