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 / 一个容器 |
启动时间:差的不是「虚拟化开销」,是「没有内核要启动」
容器启动做的事:
- runtime(containerd)准备 rootfs(overlay 挂载)
- 创建 namespace、设置 cgroup、capabilities/seccomp
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。