6.1 容器与虚拟机的本质差异

一句话

虚拟机虚拟的是硬件,容器隔离的是进程:VM 的边界由 hypervisor 提供(强、贵、启动慢),容器的边界由内核的 namespace + cgroups 提供(弱、便宜、启动快),共用宿主内核是其余全部差异的源头。

一张图看清边界画在哪

虚拟机                                 容器
┌─────────────────────┐               ┌─────────────────────┐
│ App A │ App B       │               │ App A │ App B       │
│ Libs  │ Libs        │               │ Libs  │ Libs        │
│ Guest Kernel (A)    │  ← 每个 VM     └──────────┬──────────┘
│ Guest Kernel (B)    │     一份内核             │ 系统调用
├─────────────────────┤               ┌──────────▼──────────┐
│ Hypervisor (KVM/Xen)│               │  Host Kernel (共享) │
├─────────────────────┤               ├─────────────────────┤
│ Host Kernel         │               │  namespace / cgroup │
├─────────────────────┤               │  capabilities/seccomp│
│ Hardware            │               └──────────┬──────────┘
└─────────────────────┘                          │
                                        Hardware ┘

容器里 uname -r 看到的是宿主内核版本,不是镜像里的。这一条就能区分「真 VM」和「容器」:镜像里没有内核,只有 rootfs。

差异总表

维度 虚拟机 容器
隔离对象 硬件资源(CPU/内存/设备) 进程的视图与资源用量
边界提供者 hypervisor + guest kernel Linux namespace / cgroups / LSM
内核 每个 VM 一份独立内核 共享宿主内核
启动 固件 → 内核引导 → init/systemd → 应用,秒级 runtime 准备 + 命名空间 + exec,毫秒到百毫秒级
镜像 整机镜像(含内核),GB 级 应用 rootfs,MB 级(分层复用后更小)
内存基线 每 VM 一份内核 + 页缓存 + 网络栈,百 MB 量级 进程 RSS,共享页缓存
密度 单机数十台 单机数百到数千个
隔离强度 强(硬件级边界,攻击面是 hypervisor) 弱(内核即攻击面,逃逸 = 提权到宿主)
异构 OS 支持(Linux/Windows/BSD 各跑各的) 不支持(内核必须与宿主一致;Windows 容器需 VM 隔离)
内核参数 每个 VM 独立 全局共享,容器内只能改命名空间化的少数项
迁移 可热迁移(内存状态整体搬) 不能热迁移进程,只能重建 + 流量切换
故障影响 通常限于 guest 容器能把宿主内核/磁盘/网络拖垮
典型计费/交付单位 一台机器 一个 Pod / 一个容器

启动时间:差的不是「虚拟化开销」,是「没有内核要启动」

容器启动做的事:

  1. runtime(containerd)准备 rootfs(overlay 挂载)
  2. 创建 namespace、设置 cgroup、capabilities/seccomp
  3. execve 目标进程,配置网络(CNI)、卷

第 3 步的本质就是 fork + exec。本机实测(macOS,Go 1.27,200 次取平均):

fork+exec /usr/bin/true     平均 1.589542ms/次
fork+exec /bin/sh -c :      平均 2.806617ms/次

这就是容器启动的下界;加上 runtime、CNI、卷挂载,实际冷启动通常在几十到几百毫秒。VM 启动则必须走完固件 → 引导 → guest 内核初始化 → init → 网络配置,秒级起步,带 cloud-init 的镜像更久。

🔑 「容器是轻量级虚拟机」是错的类比。容器没有内核要引导,所以它不是「更快的 VM」,而是另一类东西:一个被限制了视野和配额的普通进程。

隔离靠什么:七个维度,不是一个 namespace

维度 机制 隔离了什么 没有隔离什么
视图 namespace:mnt / pid / net / ipc / uts / user / cgroup / time 挂载点、PID、网络栈、IPC、主机名、UID 映射、cgroup 视图 内核本身
资源 cgroups v2:cpu / memory / io / pids / cpuset / hugetlb CPU 配额、内存上限、IO 权重、进程数 内核内部共享结构(inode 缓存、conntrack)
权限 capabilities + seccomp 可用的特权操作、可用的系统调用 内核漏洞
强制访问控制 AppArmor / SELinux 文件与操作的策略约束 内核漏洞
设备 device cgroup 可见设备节点 已挂载的设备
时钟 time namespace 部分时钟偏移 真实时钟源
内核 —— 无 完全共享

/proc/self/status 的 NSpid 字段能直接看到「同一进程的两个 PID」:

// 在容器内运行:输出 NSpid: <宿主 PID> <容器内 PID>
package main

import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

func main() {
	f, err := os.Open("/proc/self/status")
	if err != nil {
		panic(err) // 非 Linux 或 /proc 未挂载
	}
	defer f.Close()
	sc := bufio.NewScanner(f)
	for sc.Scan() {
		if strings.HasPrefix(sc.Text(), "NSpid:") {
			fmt.Println(sc.Text())
		}
	}
}

同一时刻在宿主上 ps -ef | grep <宿主机上的 PID> 也能看到它——容器里的进程就是宿主上的进程,只是它看到的世界不一样。

手搓一个最小容器(Linux,需要 root):

# 1. 新建 5 类 namespace,PID 1 变成 sh
sudo unshare --pid --net --mount --uts --ipc --fork --mount-proc /bin/sh

# 2. 在这个 shell 里:
hostname container-x     # 改的是 UTS namespace,不影响宿主
ps -ef                   # 只看到 sh 自己(PID namespace)
ip addr                  # 只有 lo(net namespace)

# 3. 回到宿主:
ps -ef | grep 'sh'       # 同一个进程,宿主上 PID 不是 1

共享内核的三个后果

后果 具体表现 工程应对
逃逸面 = 内核攻击面 内核提权/逃逸类漏洞一旦被利用,容器直接威胁宿主;容器内 root ≈ 宿主上的一串 capabilities 及时打内核补丁、非 root 运行、drop capabilities、seccomp、只读 rootfs、user namespace
内核版本绑死 容器能用的特性由宿主内核决定;想升级内核就得换节点;不同内核版本的节点行为不一致 节点镜像统一、用节点池分组、避免依赖特定内核版本
吵闹邻居 conntrack 表满、inode 缓存、PID 上限、磁盘 IO 抖动会跨容器传导 cgroup 限额、每节点容器密度上限、预留系统资源(system-reserved)

还有一个容易忽视的后果:内核参数是全局的。容器里改 net.ipv4.tcp_tw_reuse 可能成功(net namespace 已命名空间化),改 vm.swappiness 一定失败(全局参数)——不是权限问题,是它在内核里只有一份。

弱隔离的三种补救

方案 思路 隔离强度 启动 代价
gVisor 用户态内核拦截系统调用,应用不直接碰宿主内核 接近 VM(系统调用面收窄) 百毫秒级 兼容性缺口(部分 syscall/ioctl 不支持);系统调用密集负载性能下降明显
Kata Containers 每个 Pod 跑在一个轻量 VM 里,一层 VM 边界 + OCI 兼容 接近 VM(独立内核) 数百毫秒级 需要嵌套虚拟化/裸金属;内存开销高于普通容器
Firecracker 极简 VMM,microVM,设备模型最小化 VM 级 公开口径约 125ms 启动 需要专用宿主;设备/热插拔能力有限

选择判据只有一条:被隔离的代码可不可信。内部服务(同一团队、同一供应链)用普通容器足够;执行用户提交的代码(CI runner、函数计算、在线评测)必须上强隔离,否则一个内核 0day 就是整台宿主。

什么时候必须用虚拟机

场景 为什么容器不行
多租户执行不可信代码 共享内核,逃逸即失守
需要自定义内核参数 / 内核模块 全局共享,容器改不了、也加载不了模块
需要不同内核版本或异构 OS 内核必须与宿主一致
Windows 应用 Windows 容器需要 Windows 宿主或 VM 隔离
合规要求物理/强隔离 审计口径通常要求独立内核或独立硬件
需要 GPU 直通与特定驱动行为 容器可做但隔离与配额能力弱于 VM
需要热迁移 进程状态无法整体搬迁

反过来,什么时候不该用 VM:短生命周期任务、高密度部署、需要秒级扩缩容、CI/构建环境——这些场景容器的启动与密度优势是数量级的。

常见误区

误区 现实
容器是轻量级 VM 没有 guest kernel,本质是受限进程;uname -r 看到的是宿主内核
容器比 VM 快是因为「虚拟化开销小」 是因为没有内核引导与 init;运行期系统调用开销两者都在 μs 量级
容器默认更安全(因为「隔离」) 默认弱于 VM:共享内核、默认 capabilities、常以 root 运行
容器不能跑有状态服务 能跑,但状态要外置(卷/对象存储),调度要用 StatefulSet 类语义
一个容器只能有一个进程 准确说是「一个主进程」;多进程要自己处理信号转发与僵尸回收
容器内 PID 1 和普通进程一样 PID 1 不享受默认信号处理,SIGTERM 可能被忽略;不回收僵尸

反模式

反模式 后果
把容器当安全边界跑不可信代码 一次内核逃逸 = 整台宿主失守
容器内以 root 运行且不 drop capabilities 逃逸后直接拿到大量特权
在容器里改全局内核参数或加载模块 做不到;硬改会污染宿主,影响所有容器
把数据库数据写在容器可写层 容器重建即丢数据;必须挂卷
单容器塞多个进程 + supervisor 信号、日志、退出码、健康检查全乱,编排器无法正确判断生死
用容器跑强依赖 systemd 的遗留应用 init 体系冲突,服务管理不可靠
认为「镜像小 = 启动快」 启动瓶颈常在应用初始化(JVM/依赖扫描),不在镜像拉取
节点上不限制容器密度 吵闹邻居把宿主拖垮,故障扩散到所有 Pod

自检

Q1 同一台 64 GiB 的机器,跑 100 个容器和 100 台 VM,内存差在哪几项?

▸ 答案:差在每实例的内核与基础设施开销:每台 VM 有独立的 guest 内核、独立的页缓存、独立的网络栈与 init,基线通常百 MB 量级;容器共享宿主内核与页缓存,开销就是进程 RSS(本机一个空载 Go 服务 RSS 4.6 MiB)+ 少量 cgroup/namespace 元数据。100 台 VM 光基线就可能吃掉几十 GiB,100 个容器可能只占几 GiB。反过来说,容器之间也共享了页缓存,一个容器的脏页会挤占别人的缓存——这是密度的代价。

Q2 容器里 sysctl -w net.ipv4.tcp_tw_reuse=1 常常成功,sysctl -w vm.swappiness=10 却失败。为什么?

▸ 答案:前者属于 net namespace 已经命名空间化的参数,每个容器有自己的一份;后者是全局内核参数,整个宿主只有一份,内核不允许容器改(改了会影响所有容器和宿主)。这不是权限配置问题,而是内核数据结构决定的。要在容器里调全局参数,只能改宿主或换节点配置。

Q3 容器「启动快」的根本原因是什么?为什么 Java 应用放进容器仍然启动慢?

▸ 答案:根本原因是没有内核引导与 init 流程,只是创建命名空间 + 挂载 rootfs + exec 进程(本机 fork+exec 约 1.6ms,加上 runtime/CNI 到几十~几百 ms)。Java 慢在应用层初始化:JVM 启动、类加载、Spring 上下文、连接池预热,这些与「容器还是 VM」无关。所以优化方向是应用启动(CDS/native image/懒加载),不是换运行形态。

Q4 gVisor 和 Kata 都在补容器的隔离短板,思路差在哪?各自的代价是什么?

▸ 答案:gVisor 用用户态内核(Sentry)接管系统调用,应用不直接接触宿主内核,隔离靠「缩小系统调用面」,代价是兼容性(部分 syscall/ioctl 不支持)与系统调用密集负载的性能损失;Kata 用轻量 VM给每个 Pod 一个独立内核,隔离靠硬件虚拟化边界,代价是启动更慢、内存开销更高、需要虚拟化支持。前者是「拦截」,后者是「隔离」;前者兼容性风险大,后者资源开销大。

Q5 什么情况下你会坚持用 VM 而不是容器?至少给三个判据。

▸ 答案:① 代码不可信(多租户、用户提交的任务)——共享内核不可接受;② 需要独立内核(自定义参数、内核模块、不同内核版本、异构 OS);③ 合规或审计要求物理/强隔离。补充判据:需要热迁移、需要 GPU 直通与厂商驱动认证、遗留应用强依赖 systemd。反过来,短生命周期、高密度、秒级扩缩容的场景,VM 的启动与密度代价不可接受。

跨篇相关:镜像分层与写时复制决定了容器启动要拉多少数据,见 6.2 镜像与分层、写时复制;namespace 与 cgroups 的细节见 6.3 namespace (隔离) 与 cgroups (限制);运行时链路见 6.5 容器运行时 (containerd / runc);镜像漏洞与逃逸面见 6.6 镜像安全与扫描;容器网络与存储见 6.7 容器存储与网络;进程与调度的内核基础见 11.1 进程、线程与调度;Pod 的资源限制与 QoS 见 7.9 资源限制与 QoS;服务模型里「容器服务」处在哪一层,见 5.1 服务模型:IaaS / PaaS / FaaS。