☰
Linux虚拟化与容器化底层原理及资源隔离实践
2026/10/26 2:42:05 网站建设 项目流程

前段时间帮一个团队排查线上问题,起因是他们把一套依赖特定系统库的服务直接塞进容器里跑,结果性能监控数据忽高忽低,查了两天才定位到是 CPU 配额被 cgroup 卡住了。这件事挺典型:很多人用容器是照着教程敲几条命令,能跑起来就算完事,对底下的 Linux 虚拟化和容器化机制基本没概念,等出了问题就两眼一抹黑。这篇就从内核层面把这两套技术拆开讲,说清楚虚拟机和容器各自切的是什么资源、靠什么机制实现隔离、什么时候该用哪个,再给几条能直接照着敲的命令和配置。不管你是刚接触 Linux 的新手,还是已经在生产环境里跑了几十个容器想补补底层原理的老手,应该都能从里面捞到点东西。

1. 先搞清楚虚拟化和容器化到底在切什么

1.1 一台物理机的资源账本

任何一台机器,能拿出来的东西就那么几样:CPU 的时间片、内存的地址空间、磁盘的容量和 IO 带宽、网卡的收发队列。虚拟化和容器化干的事情本质上是同一件——把这一本账拆成很多小份,分给不同的负载用,同时保证它们互相看不见、踩不到对方。区别只在于拆的层次和方式。

虚拟机走的是"假装自己是硬件"的路线。Hypervisor 在物理硬件之上造出一整套假的 CPU、假的内存、假的主板芯片组、假的磁盘控制器,然后把一个完整的操作系统装进去。对这个操作系统来说,它根本不知道自己住在一间假房子里,它看到的中断、看到的设备、看到的内存布局,跟在一台真机器上一模一样。代价是每一台虚拟机都要自带一套完整的内核、一套完整的系统服务、一份独立的页缓存,内存开销基本都是 GB 起步。

容器走的是另一条路:大家共用同一个内核,只把"看到的视野"和"能用的额度"切开。一个进程跑在容器里,用的还是宿主那套内核调度器、那套文件系统驱动、那套网络协议栈,只不过它只能看见自己那棵进程树、自己那块网络命名空间、自己那份挂载表。所以容器的启动时间在毫秒到百毫秒这个量级,镜像可以做到几十 MB,一台机器上跑上百个实例都不稀奇。

1.2 一个容易混淆的判断:容器不是轻量虚拟机

这是我最常听到的错误类比。容器经常被描述成"轻量级虚拟机",这个说法在营销语境里没问题,但落到排障上会害人。因为一旦你把它当虚拟机看,就会默认它有一套独立的内核在帮你兜底——而实际上没有。

举个具体的:你在虚拟机里执行sysctl -w net.ipv4.tcp_tw_reuse=1,改的是那台虚拟机自己的内核参数,跟宿主机和其他虚拟机毫无关系。同样的命令在一个普通容器里执行,要么被拒绝(因为/proc/sys是只读挂载的),要么改的是宿主机共享的那一份内核参数,直接影响到同机器上所有其他容器。我见过有团队在容器启动脚本里批量改内核参数,结果把宿主机上跑着的其他服务连接池全部搞乱的情况。

真正的结论是:虚拟机隔离的是内核,容器隔离的是进程的视图。前者安全边界更硬,后者更依赖配置得当。这个认知差异会直接决定你在生产环境里怎么划边界。

1.3 两者其实是叠加关系而非替代关系

实际生产环境里,这两套技术往往是套着用的。比较常见的架构是:物理机上跑一层虚拟化,切出若干台虚拟机做租户隔离或者故障域隔离;每台虚拟机里再跑容器运行时,用来做应用层的快速交付和弹性伸缩。这么做的好处是,容器共享内核带来的那点隔离风险,被外面那层虚拟机兜住了;同时应用侧又能享受容器镜像和编排带来的便利。

所以正确的姿势不是二选一,而是搞清楚每一层的边界在哪:虚拟机负责"硬隔离"和"故障不扩散",容器负责"交付快"和"密度高"。你把这层关系理顺了,后面所有的选型和配置决策都会顺很多。

2. 虚拟化的地基:特权级、Hypervisor 和 KVM 的实现路径

2.1 为什么 x86 上搞虚拟化一开始那么别扭

要理解 KVM 为什么长成现在这个样子,得先回到 x86 的权限模型。x86 有 Ring 0 到 Ring 3 四个特权级,操作系统内核跑在 Ring 0,用户程序跑在 Ring 3。内核之所以是内核,是因为它能执行那些特权指令——改页表基址、关中断、操作 IO 端口,这些指令在 Ring 3 执行会直接触发异常。

问题来了:如果我要在 Ring 0 上再套一层 Hypervisor,那虚拟机里的那个"客户机内核"该放哪?它也想执行特权指令,但它不是真正的 Ring 0。早年的解法是二进制翻译——Hypervisor 把客户机的特权指令扫出来,动态改写成对 Hypervisor 的调用。这套方案能跑,但性能损耗大,实现也极其复杂。

后来 Intel 和 AMD 分别在 CPU 里加了硬件辅助虚拟化扩展(就是常说的 VT-x 和 AMD-V)。思路很直接:在原有的 Ring 0 之下再插一个"根模式",Hypervisor 跑在根模式,客户机内核跑在非根模式。客户机执行特权指令的时候,CPU 自动陷出到根模式交给 Hypervisor 处理,处理完再切回去。这一下就把二进制翻译那层开销干掉了,虚拟化才真正具备了大规模商用的性能基础。

提示:判断一台机器支不支持硬件虚拟化,直接看/proc/cpuinfo里有没有vmx(Intel)或svm(AMD)标志位。如果是在虚拟机里再开虚拟机,还得确认外层有没有把这个标志透传进来,很多云主机默认是不透传的。

2.2 KVM 的设计取舍:把内核本身变成 Hypervisor

KVM 最妙的地方在于它的定位——它不是一个独立的 Hypervisor 程序,而是一组内核模块。加载之后,Linux 内核就多了一个能力:可以通过/dev/kvm这个字符设备,把一个普通的用户态进程变成一台虚拟机的执行环境。

这个设计带来两个直接后果。

第一,调度、内存管理这些脏活全都不用重写。虚拟机的一个虚拟 CPU,在内核看来就是一个普通的线程,直接交给 CFS 调度器去排队;虚拟机的内存,就是宿主进程地址空间里的一块,该用页缓存就用页缓存,该做内存回收就做内存回收。这也是 KVM 能在 Linux 上迅速铺开的原因——它复用了整个内核生态,而不是另起炉灶。

第二,设备模拟被拆出去了。KVM 只负责 CPU 和内存的虚拟化,至于磁盘、网卡、显卡这些设备怎么办?交给用户态的 QEMU 去做。所以完整的链路是:QEMU 负责创建虚拟机进程、模拟各种设备、处理 IO;KVM 负责在关键时刻接管 CPU 的执行。这也是为什么你看进程列表的时候,一台运行中的虚拟机表现为一个叫qemu-system-x86_64的普通进程。

2.3 从零起一台虚拟机:命令链路走一遍

理清楚了原理,实操就很好懂了。下面这套流程我用了很多年,基本上任何一台装了 KVM 的 Linux 机器上都能跑通。

先确认环境:

# 检查 CPU 是否支持硬件虚拟化 grep -E 'vmx|svm' /proc/cpuinfo | head -1 # 检查 kvm 模块是否加载 lsmod | grep kvm # 检查 /dev/kvm 是否存在并可读写 ls -l /dev/kvm

三样都齐了,再装用户态工具。这里要说一下为什么虚拟化要用 libvirt 这一层:QEMU 的命令行参数极长,一台带两块网卡、三个磁盘的虚拟机,参数能写满半屏,手敲基本不现实。libvirt 提供了一套统一的 XML 描述和一层抽象,底层可以对接 QEMU/KVM,也可以对接别的虚拟化后端,管理起来省心得多。

# 创建一块 qcow2 格式的磁盘镜像 qemu-img create -f qcow2 /var/lib/libvirt/images/demo.qcow2 20G # 查看镜像信息,确认虚拟大小和实际占用 qemu-img info /var/lib/libvirt/images/demo.qcow2

qcow2 这个格式值得单独说一句。它支持稀疏分配和写时复制——你创建一个 20G 的镜像,实际在磁盘上只占几百 KB,随着写入才逐步增长;同时它可以直接以某个镜像为后端做快照,多个虚机共享一份只读基础镜像。这在批量开虚机的场景下能省下大量磁盘空间。

# 用 virt-install 创建并启动虚拟机 virt-install \ --name demo-vm \ --memory 2048 \ --vcpus 2 \ --disk path=/var/lib/libvirt/images/demo.qcow2,format=qcow2 \ --network network=default,model=virtio \ --cdrom /var/lib/libvirt/images/install.iso \ --graphics vnc,listen=127.0.0.1 \ --noautoconsole

这里面有个参数值得展开:model=virtio。前面说过,QEMU 模拟出来的网卡是"假"的,客户机每发一个包都要陷出到 Hypervisor 去处理,开销很大。virtio 是一套半虚拟化设备规范,客户机里装了对应的驱动之后,就能和宿主之间通过共享内存环形队列直接通信,绕开大部分模拟开销。实测下来,virtio 网卡的吞吐能比纯模拟的 e1000 高好几倍,CPU 占用还更低。所以只要客户机是 Linux,网卡和磁盘一律上 virtio,这是没有争议的选择。

启动之后用virsh管理:

virsh list --all # 列出所有虚拟机 virsh dominfo demo-vm # 查看资源配置 virsh console demo-vm # 连到串口控制台 virsh shutdown demo-vm # 优雅关机 virsh destroy demo-vm # 强制断电(慎用)

2.4 内存超分与 balloon:一个容易被忽视的调优点

虚拟机最吃资源的地方是内存。假设你在一台 64G 的物理机上开了 10 台各配 8G 的虚拟机,看起来刚好占满,但只要有一台虚机真的把 8G 用满了,物理内存的压力立刻就上来了。这里就涉及到内存超分和 balloon 机制。

balloon 驱动的原理是:在客户机里装一个驱动,Hypervisor 可以通过它"借走"客户机的一部分内存页。借走之后,客户机内部看起来内存还是那么多,但实际物理页被宿主回收了。这样宿主可以在整体内存紧张的时候,从那些闲置的虚拟机里回收内存,分给真正需要的虚拟机。

配置上主要是这几个参数:<memory>是启动时分配的内存,<currentMemory>是可以动态调整到的值,两者之间的差值就是 balloon 能回收的空间。开启<memballoon model='virtio'/>之后,可以用virsh setmem demo-vm 4G --live在运行中动态调整。

注意:内存超分不是免费的午餐。如果超分比例过高,若干台虚机同时进入内存紧张状态,宿主会开始频繁换页,整个机器的 IO 会被拖垮。我的经验是生产环境超分比例控制在 1.2 倍以内,测试环境可以放到 1.5 倍,再高就要看具体负载特征了。

3. 容器的本质:namespace 划视野,cgroup 限手脚

3.1 手搓一个容器,把神秘感彻底打破

容器这个词听起来很玄,实际上把它的技术底座拆开,就是三样东西的组合:namespace 负责让人看不见、cgroup 负责让人用不多、rootfs 负责让人跑得起来。就这三样,没有第四个。

不信我们直接手工造一个。先准备一棵最小的根文件系统,用 busybox 就够了:

mkdir -p /tmp/miniroot/{bin,proc,sys,dev,tmp} cp /bin/busybox /tmp/miniroot/bin/ cd /tmp/miniroot/bin # 给 busybox 建一堆软链接,让它表现得像个完整的命令集 for cmd in sh ls cat echo mount ps; do ln -sf busybox $cmd; done

然后一条命令进入"容器":

unshare --mount --uts --ipc --pid --net --fork --mount-proc \ --root=/tmp/miniroot /bin/sh

进去之后你会发现:ps只能看到自己这一个进程,ifconfig只能看到一块 lo 网卡,主机名是独立的一份,这就是容器。没有 Docker,没有 containerd,没有任何守护进程,就是内核原生的能力。

unshare这条命令做的事情,本质上就是对每个指定的 namespace 调用clone()或unshare()系统调用,让新进程进入一套新的隔离域。--mount-proc的作用是在新的 mount namespace 里重新挂载/proc,这样ps读到的才是新 PID namespace 的进程列表,否则它看到的还是宿主的。

3.2 七种 namespace 各守一段边界

Linux 目前有七种 namespace,每一种负责切一块东西,理解它们的分工,排障的时候就能快速定位问题出在哪一层。

namespace隔离的内容典型用途常见问题
mnt挂载点视图让容器看到自己的根目录容器里删了挂载点,宿主不受影响但容器内可能残留
pid进程号空间容器内进程 PID 从 1 开始容器内 1 号进程挂了整个容器就退出
net网络设备、协议栈、端口每个容器独立 IP 和端口空间端口冲突、跨容器通信需要额外配置
ipc共享内存、信号量、消息队列防止不同容器互相干扰System V IPC 在老应用中容易踩
uts主机名和域名每个容器有独立 hostname依赖 hostname 做服务发现的会出错
user用户和组 ID 映射容器内 root 映射成宿主普通用户映射配置不当会导致权限问题
cgroupcgroup 视图隐藏宿主的 cgroup 层级与 cgroup v2 的兼容性需要留意

这里面pid和user两个值得多说两句。

pidnamespace 带来的一个直接后果是:容器里的 1 号进程有特殊语义。它如果退出了,内核会把该 namespace 里所有其他进程一起杀掉。所以容器里跑的应用必须能正确处理信号——尤其是 SIGTERM。我见过不少容器在docker stop之后硬等 10 秒才被 SIGKILL 干掉,原因就是应用把 1 号进程的位置让给了某个不转发的 shell 脚本,SIGTERM 发给 shell 之后没往子进程传。

usernamespace 则是容器安全里最关键的一环。它允许把容器内的 UID 0(root)映射到宿主上的一个非特权 UID,比如 100000。这样即使容器被攻破、攻击者在容器里拿到了 root,映射到宿主上也就是个普通用户,破坏范围被严格限制住。开启方式是在配置里指定映射区间,然后把容器以 rootless 模式跑起来。这块配置一次麻烦,但收益很大。

3.3 cgroup:从"能用多少"到"用得怎么样"

namespace 解决的是"看不见",但它完全不限制"用多少"。一个没加限制的容器,理论上可以把整台机器的 CPU 吃满、内存吃光,最后触发宿主 OOM Killer 把别的进程杀掉。这时候就轮到 cgroup 出场了。

cgroup 的模型是一棵层级树,每个节点上挂一组控制器(controller)。常用的控制器有这么几个:

  • cpu:通过cpu.cfs_quota_us和cpu.cfs_period_us控制 CPU 时间片配额
  • memory:通过memory.limit_in_bytes限制内存上限,超了会触发 OOM 或者被限流
  • blkio:限制块设备的读写带宽和 IOPS
  • pids:限制进程数量,防止 fork 炸弹
  • cpuset:把进程绑定到指定 CPU 核

用命令行验证一下最直观:

# 创建一个 cgroup mkdir /sys/fs/cgroup/cpu/demo # 限制为 50% 的单核(period 100ms,quota 50ms) echo 100000 > /sys/fs/cgroup/cpu/demo/cpu.cfs_period_us echo 50000 > /sys/fs/cgroup/cpu/demo/cpu.cfs_quota_us # 把当前 shell 加进去 echo $$ > /sys/fs/cgroup/cpu/demo/tasks

加完之后,这个 shell 及其子进程的总 CPU 使用率就被卡在半个核以内。这个机制对理解容器的 CPU 限制至关重要——docker run --cpus=0.5翻译到底层,就是把上面这三行做了。

cgroup 现在有 v1 和 v2 两个版本,差异不小。v1 里每种资源是一棵独立的树,一个进程可以同时出现在 cpu 树的某个节点和 memory 树的另一个节点上,配置分散在多个目录;v2 把所有控制器统一到一棵树上,一个进程只有一个位置,配置集中在cgroup.controllers和cgroup.subtree_control里。

v2 里有个概念叫资源压力(PSI),会通过/sys/fs/cgroup/<path>/cpu.pressure、memory.pressure这些文件暴露出来,给出 "some" 和 "full" 两个维度的阻塞时间占比。这个数据比单纯的利用率有用得多——利用率 60% 不代表没瓶颈,PSI 才能告诉你进程到底有没有在等。

提示:现在主流发行版基本都默认启用 cgroup v2 了。如果你发现某些老容器运行时的内存限制行为不对劲,先查一下/sys/fs/cgroup/cgroup.controllers这个文件存不存在,用它的有无来判断当前是 v1 还是 v2。

3.4 容器里看到的 CPU 核数是个坑

这一条是纯经验,文档里一般不会写。nproc命令和/proc/cpuinfo读的是宿主物理核数,不会因为 cgroup 限制了 CPU 配额而变化。后果是:JVM 默认的 GC 线程数、Node.js 的 worker 池大小、golang 的 GOMAXPROCS,全都是按物理核数来的。一个只分了 0.5 个核的容器,可能开出来 32 个 GC 线程在那抢那半个核,性能惨不忍睹。

处理办法有两个方向:一是运行时层面把限制传递进去,比如 JVM 的-XX:ActiveProcessorCount=N,Node 的--max-old-space-size配合 worker 数量显式指定;二是改用 CPU 集合绑定(--cpuset-cpus)而不是配额限制,因为 cpuset 是从调度器层面就把核拿走了,容器内读到的核数会是真的少。这两种方式各有取舍,前者灵活但需要改配置,后者直观但会降低调度弹性。

内存方面也有类似问题。JVM 的-Xmx如果不显式指定,默认取物理内存的 1/4,容器限制到 512M 的时候,JVM 可能按 16G 的 1/4 去设堆,直接 OOM。现代版本的 JVM 已经支持读 cgroup 限制了,但如果你的基础镜像里跑的是老版本,这一条必须手动配。

4. 镜像与分层:容器生态真正好用的地方

4.1 overlay2 让镜像共享成为可能

容器的镜像系统之所以能做到"几十 MB 传得快、多实例共享省空间",核心在于联合文件系统。目前主流的是 overlay2,它的结构可以这么理解:

  • lowerdir:只读层,可以有多层,从下往上叠
  • upperdir:可写层,容器运行时所有修改都写在这
  • workdir:overlay 内部使用的临时目录
  • merged:最终呈现给用户看的合并视图

挂载之后,读文件的时候从上层往下找,找到第一个就返回;写文件的时候如果文件在 lowerdir 里,会先把它复制到 upperdir(copy-up),再修改;删除文件则在 upperdir 里生成一个 whiteout 标记,把下层同名的文件遮住。

# 手动挂载一个 overlay 看看效果 mkdir -p /tmp/ovl/{lower,upper,work,merged} echo "base config" > /tmp/ovl/lower/app.conf mount -t overlay overlay \ -o lowerdir=/tmp/ovl/lower,upperdir=/tmp/ovl/upper,workdir=/tmp/ovl/work \ /tmp/ovl/merged cat /tmp/ovl/merged/app.conf # 输出 base config echo "override" > /tmp/ovl/merged/app.conf cat /tmp/ovl/upper/app.conf # 输出 override,说明发生了 copy-up cat /tmp/ovl/lower/app.conf # 还是 base config,下层没变

这个机制直接决定了镜像构建的最佳实践。每一条 Dockerfile 指令都会生成一层,层数越多,元数据越多,容器启动时要做合并的目录项也越多。所以我一直坚持两条规矩:一是把多个RUN合并成一条,二是每层结束前把临时文件清掉。

4.2 Dockerfile 的写法是有讲究的

先看一个反面教材:

FROM ubuntu:22.04 RUN apt-get update RUN apt-get install -y nginx RUN apt-get install -y curl RUN rm -rf /var/lib/apt/lists/*

这份 Dockerfile 有几个问题。第一,四条 RUN 就是四层,每层都要走一遍 overlay 的复制和元数据记录。第二,rm -rf只是在这一层上面打了个 whiteout 标记,底下那层apt-get install装进去的包缓存和列表文件依然实实在在地占着镜像空间,删了个寂寞。第三,apt-get update和apt-get install分在不同的层,如果缓存失效策略没做好,可能出现 update 用的是旧索引、install 找不到包的情况。

正确的写法:

FROM ubuntu:22.04 RUN apt-get update \ && apt-get install -y --no-install-recommends nginx curl \ && rm -rf /var/lib/apt/lists/* \ && apt-get clean

一条 RUN 里串完,临时文件清理发生在同一层,镜像体积能小一大截。--no-install-recommends也是一个容易忽略的点,不加的话会顺带装上一堆推荐包,体积能翻好几倍。

再进阶一步是多阶段构建,这个在编译型语言里几乎是必用的:

# 阶段一:构建环境,体积再大也无所谓 FROM golang:1.22 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server # 阶段二:运行环境,只带二进制 FROM alpine:3.19 COPY --from=builder /out/server /usr/local/bin/server EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/server"]

这里把go mod download单独放一层是有意为之。代码改动不会影响依赖层,构建时这一层能命中缓存,整体构建时间能省下一大半。这种"把变化频率低的东西放前面"的思路,是 Dockerfile 编排的核心原则。

注意:ENTRYPOINT用 exec 形式(方括号数组)而不是 shell 形式,直接决定了 PID 1 是谁。shell 形式会包一层/bin/sh -c,导致 SIGTERM 传给 sh 而不是你的应用,优雅停机直接失效,最终只能等超时被强杀。

5. 网络和存储:容器落地最容易翻车的两块

5.1 bridge 网络的连通路径要说清楚

默认情况下,一个容器跑起来之后会接在一张叫docker0的虚拟网桥上,分到一个172.17.0.0/16网段的地址。这套东西的原理是:宿主机上创建一对 veth 设备,一端插在网桥上,另一端放进容器的 net namespace 里,看起来就像容器里插了块网卡。

# 看网桥和挂在上面的接口 ip link show docker0 brctl show docker0 # 或者用 bridge link show # 看容器里的网卡对 docker exec -it demo ip addr

容器访问外网靠的是宿主机上的 NAT——内核的 iptablesMASQUERADE规则会把容器源地址改写成宿主机出口地址。外部访问容器则靠端口映射,-p 8080:80翻译过来是一条DNAT规则,把发往宿主 8080 的包目标地址改成容器 IP 的 80 端口。

这套默认网络有三个坑,我按踩到的频率排序。

第一个坑是容器间互相访问必须走容器 IP 或者自定义网络。很多人习惯在应用里写localhost:3306去连另一个容器里的数据库,这在默认 bridge 下是连不上的,因为每个容器有独立的 net namespace,localhost指的就是它自己。解决办法是显式创建一个自定义 bridge 网络,同一个网络里的容器可以通过容器名做 DNS 解析:

docker network create app-net docker run -d --name db --network app-net mysql:8 docker run -d --name web --network app-net -p 8080:80 myapp # web 容器里直接用 db:3306 就能连上

第二个坑是 iptables 规则会被绕过。docker 默认会往DOCKER链里插规则,这些规则的优先级高于你自己在INPUT链里写的策略。所以你会遇到"明明INPUT拒了某端口,但容器映射之后照样能访问"的情况。这不是 bug,是链的处理顺序问题。真要做端口管控,得在DOCKER-USER链里加规则,那条链是专门留给用户自定义的。

第三个坑是 MTU 不一致导致的大包丢失。如果宿主机所在的网络有隧道封装,实际可用 MTU 可能只有 1400 出头,而 docker0 默认是 1500。表现是小请求正常,大一点的 POST 或者文件上传直接卡死。排查的时候用ping -M do -s 1472逐步试出真实 MTU,然后在 docker 配置里统一改成 1400 左右。

5.2 数据持久化:三种方式别用混

容器里的文件系统是临时的,容器一删,写在 upperdir 里的东西全没了。要留数据,官方给了三条路,各有各的适用场景,混用会出问题。

方式存储位置适用场景注意事项
volume/var/lib/docker/volumes/数据库数据、需要备份的持久数据由 docker 管理,跨容器共享方便,备份要单独处理
bind mount宿主任意路径配置文件、开发时代码挂载、日志收集宿主路径必须有权限,SELinux 环境要加:z标签
tmpfs内存临时缓存、敏感数据不落盘容器停止即丢失,占用内存配额

数据库这类场景我一律推荐用 volume。原因很实际:bind mount 会把宿主的目录权限、SELinux 上下文、文件系统特性全部暴露给容器,一旦宿主路径的属主变了,容器里就可能读写不了。而 volume 是 docker 自己管理的目录,权限和上下文都由它统一处理,少了一整类问题。

# 创建并使用命名 volume docker volume create mysql-data docker run -d --name db \ -v mysql-data:/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD=*** \ mysql:8 # 查看 volume 实际落在哪 docker volume inspect mysql-data

有个细节很多人不知道:volume 首次挂载到容器时,如果目标目录在镜像里已有内容,这些内容会被复制进空 volume。这本来是为了方便(比如把镜像里的默认配置文件带出来),但如果镜像里那个目录特别大,第一次启动会卡很久。知道这一点,排查"容器启动莫名慢了十几秒"就有方向了。

bind mount 也不是完全不能用。配置文件挂载、开发时代码热更新、日志目录暴露给宿主采集,这些场景用 bind mount 更直接。唯一要注意的是 SELinux 环境下必须加:z或者:Z标签,否则容器读不到宿主文件,报错信息还特别隐晦。

5.3 资源限制不写,等于把命交给运气

前面讲 cgroup 的时候说过原理,这里落到实践。一条不带任何限制的docker run,容器可以吃满整台机器的资源。生产环境必须显式限制:

docker run -d --name api \ --cpus=2 \ --memory=2g \ --memory-swap=2g \ --pids-limit=200 \ --blkio-weight=500 \ --restart=unless-stopped \ myapp:latest

这几个参数里,--memory-swap设成和--memory相等,意思是禁用 swap,只用物理内存。很多人会把它留空,结果容器内存超限之后开始疯狂换页,表现是响应时间突然拉长几十倍,监控上却看不到内存超限的告警。禁用 swap 之后,超限直接 OOM 被杀,问题暴露得反而更快。

--pids-limit这一条容易被忽略,但它防的是 fork 炸弹。一个没限制的容器,如果应用有 bug 导致无限 fork,宿主的进程号资源会被瞬间耗尽,整台机器都起不了新进程。设个 200、500 之类的上限,代价很小,收益很大。

6. 选型判断和几个踩过的真实坑

6.1 什么时候别用容器

容器不是万能的,有些场景硬上容器就是给自己找麻烦。

需要跑不同内核版本或者定制内核的场景,容器直接出局。容器共享宿主内核,你没法在容器里换个内核、加载个自定义模块、改个编译选项。这类需求只能上虚拟机。

需要强隔离边界的场景,比如多租户平台跑的是互相不信任的第三方代码,容器那层隔离在安全等级上是不够的。虽然现在有 gVisor、Kata Containers 这类方案在做增强,但它们本质上是把容器又包回了一个轻量虚拟机里,那还不如一开始就用虚拟机。

有状态的重负载服务,比如大型数据库,容器化带来的灵活调度优势不明显,反而增加了存储和网络的复杂度。我见过把核心数据库塞进容器的,运维复杂度翻了好几倍,收益极其有限。

反过来,无状态的应用服务、CI/CD 的构建环境、需要快速横向扩缩的计算任务,这些是容器的主场,收益非常明显。

判断维度倾向虚拟机倾向容器
隔离要求多租户、不信任代码内部服务、可信负载
内核需求需要定制内核或模块用宿主内核即可
启动速度要求分钟级可接受需要秒级以内
单机密度要求10 台以内几十到上百
交付形态整机镜像、配置复杂镜像分层、快速迭代
状态有状态、数据重无状态、可随时重建

6.2 坑一:容器内 1 号进程不接信号

这个坑我踩过不止一次。现象是docker stop之后容器总是要等满 10 秒才退出,日志里看不到任何优雅停机的记录。

根因前面提过:容器里的 1 号进程,内核不会给它套用默认的信号处理行为。普通进程收到没注册处理函数的 SIGTERM 会直接退出,但 1 号进程不会——它必须显式注册处理函数。如果你的应用没有处理,信号就石沉大海。

更隐蔽的情况是启动脚本吃掉了信号。很多镜像的ENTRYPOINT写的是sh -c "./start.sh",PID 1 是 sh,SIGTERM 发给 sh 之后它不会转发给子进程,子进程继续跑,最后超时被 KILL。

排查链路是这样的:先docker exec进去跑ps -ef,看 PID 1 到底是谁;如果不是你的应用,改ENTRYPOINT为 exec 形式;如果确实是应用本身,检查它有没有注册 SIGTERM 处理。还有一个通用解法是用tini或者dumb-init作为 init 进程,它们专门负责转发信号和回收僵尸进程。

FROM alpine:3.19 RUN apk add --no-cache tini ENTRYPOINT ["/sbin/tini", "--"] CMD ["/usr/local/bin/app"]

6.3 坑二:时间不同步导致的诡异问题

容器默认继承宿主的时间,这本来没问题。但有一种情况会出岔子:容器里跑的东西依赖/etc/localtime或者时区数据库,而基础镜像里压根没装 tzdata。表现是日志时间差 8 小时,或者某些定时任务在错误的时刻触发。

排查方法很直接,进去跑date和宿主的date对一下:

date # 宿主机时间 docker exec demo date # 容器时间 docker exec demo ls /usr/share/zoneinfo # 时区数据库在不在

修法有两种,简单的是挂载宿主时区文件:

docker run -v /etc/localtime:/etc/localtime:ro -e TZ=Asia/Shanghai myapp

但更推荐在构建时就把时区数据装进去,因为挂载依赖宿主环境,镜像换个环境跑就可能行为不一致。Dockerfile 里加一句RUN apk add --no-cache tzdata(Debian 系是apt-get install tzdata),启动时用-e TZ=Asia/Shanghai指定,这样镜像是自包含的。

6.4 坑三:磁盘 inode 被悄悄吃满

这个坑出现过一次之后我记了很久。现象是容器里的应用突然写不了文件,报 "No space left on device",但df -h看磁盘还有一大半空间。

真正的原因是inode 用完了。df -h看的是块使用率,df -i看的才是 inode 使用率。容器镜像分层、小文件密集写入、日志没做轮转,这些都会快速消耗 inode。overlay2 对每个文件都要建元数据,inode 消耗比普通文件系统更凶。

排查就是两步:

df -i # 看 inode 使用率 find /var/lib/docker -type f | wc -l # 数一下 docker 目录下的文件数

处理办法是清理无用镜像和停止的容器,同时给日志加上轮转限制:

# 全局配置日志轮转,写在 /etc/docker/daemon.json { "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "3" } }

不加这个配置,一个疯狂打日志的容器能在几天内把宿主磁盘写满。这个限制是按容器生效的,50m × 3 份,最多占 150MB,够用了。

6.5 坑四:镜像构建缓存导致的"幽灵 bug"

有一种情况特别迷惑人:本地构建、本地跑一切正常,推到别的机器上跑就报错。排查半天发现是构建缓存惹的祸——某条RUN指令依赖它上面那层的内容,但上层内容变了、缓存却命中了,导致构建出来的镜像跟预期不一致。

典型场景是COPY . .之后跟一条RUN装依赖。源代码改了一个文件,COPY层失效了,但下面那条RUN如果命令字符串完全一样,缓存可能还是命中的,装出来的依赖版本跟源码要求对不上。

解法是把依赖声明和依赖安装单独抽出来放到COPY . .之前:

COPY package.json package-lock.json ./ RUN npm ci COPY . .

这样只要package-lock.json没变,依赖层就正常命中缓存;源码变了只会影响后面的层。这个模式在各种语言里都通用,go.mod、requirements.txt、pom.xml都是同样的处理思路。

6.6 最后一点实操建议

虚拟化和容器化这两套东西,原理层面的东西其实不难,就那么几个概念。真正难的是把它们和具体的运维场景对上号——知道什么情况下该限制 CPU 配额、知道网络不通该从哪一层查起、知道镜像为什么突然变大。这些判断力只能靠一次次踩坑积累。

我的习惯是每接一个新环境,先花十分钟把这几样东西摸清楚:内核版本、cgroup 是 v1 还是 v2、docker 的存储驱动是什么、宿主 MTU 是多少、有没有启用 rootless 模式。这几项决定了后面所有的配置方式和排障路径,提前摸清楚能省下大量时间。尤其是 cgroup 版本这一项,v1 和 v2 的配置路径、参数名、可观测指标完全不一样,混淆了会走很多弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询