容器和虚拟机区别详解

结论: 容器虚拟化的是操作系统层——同一台宿主机上的所有容器共享同一个 Linux 内核,仅通过 Namespace 做视图隔离、cgroup 做资源限额;虚拟机虚拟化的是硬件层——每台虚机都有独立的 Guest OS 与内核。因此容器启动快、体积小、密度高,而虚拟机的隔离强度远高于容器。一句话选择原则:需要运行不可信代码或不同内核版本,用虚拟机;跑无状态服务并追求迭代速度,用容器。

很多人第一次接触 Docker 时,会把它理解成"轻量版的虚拟机"。这个理解在日常使用中够用,但当你开始思考"能不能把数据库放进容器""多租户业务能不能共用一个宿主""容器被攻破会不会连累宿主机"这类问题时,这个理解就会给出错误答案。

容器不是"小虚拟机",它是"被隔离和限制了的一组进程"。 这句话后面会反复印证。

隔离层级:容器共享宿主内核,虚拟机拥有独立内核

结论前置:两者的根本差异在于虚拟化发生在哪一层——容器在操作系统层做隔离,虚拟机在硬件层做隔离。

  • 虚拟机(Virtual Machine,VM):由 Hypervisor(如 KVM、VMware ESXi)虚拟出 CPU、内存、磁盘、网卡等硬件,虚拟机里跑的是完整的客户机操作系统(Guest OS),拥有自己的内核。宿主机内核与客户机内核互不知情,客户机内核崩溃不会直接影响宿主机。
  • 容器(Container):宿主机只有一个内核,容器里的进程是宿主机内核上的普通进程,只是被 Namespace 限制了"能看到什么",被 cgroup 限制了"能用多少"。容器内没有内核,也不需要启动内核。

由此推出三条重要推论:

  1. 容器不能运行与宿主机内核不兼容的东西。 在 Linux 宿主机上跑 Windows 容器是做不到的(Windows 容器需要 Windows 宿主或 Hyper-V 隔离),容器内也不能加载宿主机没有的内核模块。
  2. 容器的内核版本就是宿主机的内核版本。 uname -r 在容器内外看到的是同一个值,这是判断自己是否在容器里的常用方法之一。
  3. 容器的攻击面最终指向同一个内核。 所有容器共享这一个内核,一旦出现内核漏洞被利用,隔离就被击穿——这就是后文要讲的容器逃逸。

Linux 容器靠什么实现隔离?Namespace 与 cgroup

结论前置:Namespace 决定"能看到什么",cgroup 决定"能用多少",两者叠加再加上 capabilities、seccomp 等机制,构成了容器的隔离模型。

Linux 内核常见 Namespace 类型及其作用:

  • Mount(mnt):隔离挂载点,容器看到自己的根文件系统。
  • PID:隔离进程号,容器内 PID 1 是自己的 init 进程,看不到宿主机进程树。
  • Network(net):隔离网络栈,容器拥有独立的网卡、路由表、iptables 规则与端口空间。
  • UTS:隔离主机名与域名,容器可以拥有自己的 hostname。
  • IPC:隔离 System V IPC 与 POSIX 消息队列。
  • User(user):隔离用户与 UID 映射,是最重要的"根权限隔离"手段。
  • Cgroup(cgroup ns):隔离 cgroup 层级视图,让容器看不到宿主机的控制组结构。

cgroup 的作用则是给容器套上资源上限,常见的有 CPU(cpu.max)、内存(memory.max)、进程数(pids.max)、块设备 I/O 限速等。注意:容器默认并不会自动限资源,如果不显式设置,一个容器可以把宿主机内存跑满,进而触发系统 OOM Killer 把宿主机上的其他进程杀掉——包括同机的另一个核心业务容器。

核心差异对照表:容器 vs 虚拟机

结论前置:这张表覆盖了选型时真正需要比较的九个维度,从启动到持久化一目了然。

对比项容器虚拟机
虚拟化层级操作系统层(共享宿主内核)硬件层(Hypervisor 虚拟 CPU/内存/设备)
是否有独立内核否,与宿主机共享同一个 Linux 内核是,每台虚机有独立 Guest OS 与内核
启动速度亚秒~数秒(本质是启动进程)数十秒~数分钟(要引导完整操作系统)
镜像体积常见 20 MB ~ 500 MB( distroless/alpine 可到几十 MB)常见 500 MB ~ 十数 GB(含操作系统与更新)
资源开销接近裸进程,主要是进程自身内存每台虚机自带一份 Guest OS 内存,叠加 Hypervisor 开销
部署密度单台宿主常见几十到上百个容器单台宿主常见几台到几十台虚机
隔离强度较弱,共享内核是其上限强,内核级别天然隔离
可移植性强(镜像一次构建处处运行),但要求宿主内核 ABI 兼容中等,需在不同虚拟化平台间做格式/驱动适配
持久化容器层天然临时,数据必须挂在卷(Volume)或外部存储上虚机磁盘天然持久,重启后数据仍在
安全边界依赖 Namespace/cgroup/capabilities/seccomp 等多层机制,存在逃逸风险依赖 Hypervisor,逃逸难度显著更高

一句话记忆:虚拟机是"房子里再盖一栋楼",容器是"同一栋楼里分隔出的房间"。 房间的墙没有楼那么厚,但造价低、拆改快。

上手验证:亲眼看到隔离边界

结论前置:三条命令就能验证"容器是进程"这件事——查 PID、看 Namespace、读 cgroup 限额。 下面以 Ubuntu 24.04 LTS + Docker Engine 为例。

bash
# 起一个常驻容器
docker run -d --name ngx --restart unless-stopped -p 8080:80 nginx:1.27-alpine

# 关键一步:查出容器主进程在宿主机上的真实 PID
docker inspect --format '{{.State.Pid}}' ngx
# 输出比如 24578

# 在宿主机进程树里能直接看到它(容器并不"消失",只是被隔离了视图)
ps -p "$(docker inspect --format '{{.State.Pid}}' ngx)" -o pid,ppid,comm,etime

# 查看它所属的 Namespace;与宿主机 init 进程对比即可看出隔离
sudo ls -l /proc/24578/ns/
sudo ls -l /proc/1/ns/

# 用 nsenter 进入容器的网络命名空间,看到的是容器内部的网卡
sudo nsenter -t 24578 -n ip addr

资源限额可以这样查看和验证(路径依赖 cgroup 版本与驱动,以下为 cgroup v2 + systemd 驱动的常见路径):

bash
# 给正在运行的容器加上内存与 CPU 上限
docker update --memory=512m --memory-swap=512m --cpus=1.5 ngx

CID=$(docker inspect --format '{{.Id}}' ngx)
cat /sys/fs/cgroup/system.slice/docker-${CID}.scope/memory.max   # 单位字节
cat /sys/fs/cgroup/system.slice/docker-${CID}.scope/cpu.max      # 格式如 "150000 100000"

# 实时观察实际占用
docker stats --no-stream ngx

如果 /sys/fs/cgroup/system.slice/docker-*.scope/ 不存在,说明宿主机使用了 cgroupfs 驱动或 cgroup v1,此时路径一般为 /sys/fs/cgroup/memory/docker//memory.limit_in_bytes。这类路径差异,是"同一份容器在不同宿主机上行为不一致"的高频原因。

容器逃逸风险与安全边界:什么时候不该用容器

结论前置:容器的安全边界不是"墙",而是一组可以逐个被削弱的内核机制。默认配置下 Docker 容器的隔离强度不足以承载不可信代码,必须额外加固。

所谓容器逃逸(Container Escape),指的是容器内进程突破 Namespace/cgroup 限制,获得宿主机上的访问权限。常见成因有四类:

  1. 挂载了危险目录:-v /:/host 让容器可以读写整个宿主机文件系统,-v /var/run/docker.sock:/var/run/docker.sock 更是等于把 Docker 控制权限交出去——拿到 socket 的进程可以直接创建特权容器接管宿主机。
  2. 使用 --privileged:特权容器几乎拥有宿主机的全部 capabilities,且能访问设备节点,逃逸难度大幅降低。
  3. 以 root 身份运行容器进程:容器内是 root 就意味着它是宿主机内核视角下的 root 进程(在未做 User Namespace 映射时),配合内核漏洞即可提权到宿主。
  4. 内核漏洞:因为共享内核,宿主机内核的提权漏洞(如历史上多个 cgroup、overlayfs 相关 CVE)会直接成为逃逸通道。

加固的标准动作如下,建议直接写进部署规范:

bash
docker run -d --name app \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --cap-drop ALL --cap-add NET_BIND_SERVICE \
  --security-opt no-new-privileges \
  --security-opt seccomp=/etc/docker/seccomp/default.json \
  --pids-limit 200 \
  --memory=1g --cpus=2 \
  --user 10001:10001 \
  -v app-data:/data:rw \
  -p 8000:8000 \
  app:1.0

镜像层面同样要做最小化与非 root:

dockerfile
# 多阶段构建:构建依赖不进运行镜像
FROM python:3.12-slim AS build
WORKDIR /src
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/deps -r requirements.txt

# 运行阶段使用 distroless 非 root 镜像
FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
COPY --from=build /deps /app/deps
COPY app.py /app/app.py
ENV PYTHONPATH=/app/deps
USER nonroot
EXPOSE 8000
ENTRYPOINT ["python", "/app/app.py"]

如果业务确实需要运行不可信代码(在线评测、SaaS 多租户执行用户脚本、第三方插件环境),容器的隔离强度是不够的,应升级为下列之一:虚拟机沙箱(Kata Containers、Firecracker)、用户态内核(gVisor)、Rootless Docker/Podman 配合 User Namespace 映射,或者最稳妥的——直接用虚拟机。

各自适用场景:一张表定位该选谁

结论前置:不要让技术偏好决定选型,让"你要保护什么"和"你要迭代多快"决定。

场景更适合原因
微服务、无状态 Web、API 服务容器秒级扩缩、镜像一致、CI/CD 天然适配
CI/CD 构建与测试环境容器用完即弃,干净可复现
需要不同内核版本或自定义内核模块虚拟机容器无法自带内核
Windows/.NET Framework、Oracle 等传统闭源软件虚拟机依赖完整操作系统、注册表与特定内核行为
多租户 SaaS,租户代码互不可信虚拟机或 Kata/gVisor共享内核的多租户风险过高
大型数据库、中间件(MySQL/Redis/Kafka)视情况,生产多用虚拟机或裸金属追求稳定 IO 与可预期的内核参数,且避免重启丢状态
边缘/ARM、工控机资源受限环境容器体积小、常驻内存少
桌面虚拟化/VDI虚拟机需要完整桌面操作系统体验

一个实用判据:如果你的服务重启后必须保留本机状态、或需要调优内核参数(sysctl)、加载模块、挂载特殊文件系统,那就用虚拟机;如果是无状态服务并且已经引入了 Kubernetes,容器是默认答案。

生产中最常见的形态:虚拟机上跑容器

结论前置:现实中的绝大多数容器,其实运行在虚拟机里——公有云的 ECS 本质就是虚拟机或轻量虚拟化实例,Node 上再跑容器运行时。 这不是妥协,而是取长补短。

典型分层:

  • 第一层:物理机 + Hypervisor,切出虚拟机(通常是 KVM)。 提供强隔离,让不同业务、不同租户、不同环境各有边界。
  • 第二层:虚拟机里跑容器运行时(containerd / Docker Engine)。 提供高效率与快速发布。
  • 第三层:容器之上是编排(Kubernetes),负责调度、自愈、滚动发布、服务发现。

这样组合的好处很直接:虚拟机负责"安全边界",容器负责"交付效率"。 一个 Pod 出问题最多影响这个 Node,一个有问题的 Node 也不会影响同宿主机上其他业务的虚拟机。

代价也是真实的:多了一层资源损耗与管理复杂度,需要规划 Node 上的预留资源(--kube-reserved、--system-reserved)、合理设置 requests/limits、以及处理网络栈多一跳带来的排障复杂度(Node 上的 iptables/IPVS、CNI 插件、宿主机安全组三层要分别看)。