ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

容器不是轻量虚拟机:从内核隔离到镜像分层的原理剖析

容器不是轻量虚拟机:从内核隔离到镜像分层的原理剖析 我第一次接触容器是在2015年前后那时候Docker刚火起来社区里最流行的说法是虚拟机太重容器是更轻的虚拟机。当时我也这么理解直到有一次在生产环境排查一个诡异的问题——容器里明明只跑了一个进程宿主机上用 ps 却能列出一大堆相关进程才发现自己根本没搞懂容器的原理。容器从来不是轻量的虚拟机它走的是另一条完全不同的路不是模拟一台完整的机器而是在一个共享内核之上把进程装进一个个隔离的房间里。这个思路上的分叉就是虚拟化的革新最核心的地方。这篇文章打算把这个概念彻底讲透虚拟化到底虚拟了什么容器的隔离是怎么实现的为什么镜像要做成分层结构分层架构又带来了哪些连锁反应。适合刚入门想建立正确心智模型的人也适合已经用了一段时间Docker、但遇到问题说不清根因的人。读完你不会只会敲命令而是能说出每一步背后的原理。1. 先拆虚拟化整机模拟与内核共享是两条不同的路1.1 虚拟化的本质把物理资源变成可调度的逻辑资源虚拟化这个词被用得太多以至于很多人忘了它最初要解决的问题。在没有虚拟化的年代一台服务器上跑一个应用物理资源是绑死的CPU是这块CPU内存是这根内存条应用只能用这台机器看到的那一份资源。问题是大部分服务器的负载其实很低CPU跑不满内存用不满但为了应对业务高峰又必须买大机器。虚拟化的本质就是用一层软件把物理资源重新抽象成一份一份的逻辑资源然后按需分配给不同的工作负载。云厂商能按小时卖云主机靠的就是这一手抽离。但重新抽象这件事在工程上有两条完全不同的实现路径。搞不清这两条路径后面所有关于容器的讨论都会飘在空中。1.2 路径一硬件虚拟化虚拟机是整机沙箱第一条路径是在硬件层面做文章常见的载体就是Hypervisor也就是虚拟机监视器。KVM、VMware、Hyper-V都属于这一类。它的做法是在宿主机上插入一层Hypervisor由它来模拟CPU、内存、磁盘、网卡这些硬件设备然后在模拟出来的硬件上安装完整的客户机操作系统。每个虚拟机都有自己的内核、自己的系统库、自己的一套 /etc、自己的init进程。用生活里的例子来类比虚拟机等于你租了一套带独立厨房、独立洗手间、独立大门的公寓。公寓里水电网都是自己一套物业管不到里面。你想把厨房改成书房把房门换一扇物业都不管你因为这套房子物理上就是你的。隔离性很好但代价是——每一套公寓都要配齐一套完整的设施装修费、家具费、公摊面积全都要自己背。换算成资源开销就是每个虚拟机要跑一个完整的操作系统内核至少占几百MB内存还要经历一轮完整的启动流程从BIOS/UEFI到grub再到内核初始化快则几十秒慢则几分钟。如果你只是想跑一个几百MB的Java进程却要搭一台2GB内存的虚拟机浪费相当明显。但换来的是极强的隔离虚拟机里发生任何事都很穿透Hypervisor影响宿主机因为客户机内核是跑在受限的CPU特权级下的硬件隔离边界非常清晰。1.3 路径二操作系统级虚拟化容器是进程沙箱第二条路径不在硬件层做文章而是直接在操作系统内核里切分资源。思路很直白既然所有进程本来就要共享同一个内核那我们就把内核提供的各种能力——进程树、网络栈、文件系统、用户身份——分别做隔离让一组进程只看到自己那份互相看不见、也抢不过。这就是容器。继续用房子类比容器不是公寓而是合租。所有房间共享同一套房子的厨房、洗手间和下水管道。每个人的房间是锁起来的你只能看到自己房间里的东西公区的使用额度大家分摊。房东宿主机内核统一管理水电但你并不知道隔壁住了谁、隔壁在干什么。这条路的好处显而易见不需要为每个容器启动一个完整内核容器共享宿主内核所以几毫秒就能完成启动内存开销可能就是几十MB甚至更小一台机器上可以轻松跑几十上百个容器。坏处也同样明显合租嘛下水道是通的厨房是公用的——如果某个房客把总水管搞爆了所有人都遭殃。对应到技术上所有容器共享宿主内核一旦内核本身被攻破或者某个容器利用内核漏洞逃逸出namespace整个宿主就沦陷了。1.4 两条路不是对抗是配合这是很多人会搞错的一点虚拟化和容器不是你死我活的关系。现代云平台最常见的架构恰恰是双层嵌套底层用KVM这类硬件虚拟化把物理机切成虚拟机虚拟机上再跑Docker容器。虚拟机负责提供租户级的安全边界容器负责提供高效的部署密度。很多云厂商对外提供的容器实例底层实际都还有一层虚机兜底这层虚机才是真正的多租户隔离墙。理解了这两条路的分叉你看容器的一堆概念就会顺很多。比如容器为什么启动快因为不需要引导内核、容器为什么资源占用少因为没有完整OS、容器为什么被诟病隔离不如虚拟机因为共享内核。这些问题的答案全都能从路线选择上推出来不需要死记。2. 容器凭什么轻又能隔离namespace 与 cgroup 的配合逻辑2.1 namespace让容器进程看不见外面的世界要理解容器的隔离先要理解一个基本事实从内核的角度看容器里的进程就是宿主上的普通进程本质上没有任何特殊的隐藏属性。真正的魔法在于——进程启动的时候内核会给它套上一个又一个新的namespace让这个进程以及它的子进程看待系统的视野范围被裁剪了。Linux的namespace有很多种下面这几个是最关键的namespace隔离内容容器里的直观效果PID进程号容器内进程以为自己是PID 1看不到宿主其它进程Mount挂载点容器有独立的文件系统挂载视图mount操作不污染宿主Network网络栈容器拥有自己的网卡、IP、路由表和iptables规则UTS主机名容器内hostname独立不影响宿主和兄弟容器IPC进程间通信消息队列、信号量等IPC资源隔离User用户ID容器里的root可映射为宿主上的普通UID以PID namespace为例容器里的主进程以为自己是PID 1因为它是这个namespace里第一个进程但切到宿主视角它其实是PID 3012。你在容器里执行 ps 看到的进程列表只是当前namespace裁剪后的视图。这就是容器像一个独立小系统的观感来源。Network namespace 是另一个很值得琢磨的点。每个容器创建时都会获得自己的network namespace容器里的进程以为自己在独享一张网卡实际上宿主上面挂着一堆veth网卡对一端塞进容器一端挂在宿主网桥或CNI插件创建的虚拟网络上。Docker宿主的 docker0 网桥干的就是把所有容器网卡串在一起的活。2.2 cgroup让容器进程抢不过邻居namespace解决的是看得见/看不见的问题但光看不见不够。你想如果一个容器里的进程死循环吃满所有CPU隔壁邻居怎么办看不见对方不等于对方不受影响。所以还需要第二套机制控制能用多少这就是cgroup控制组。cgroup的核心思想是把进程按组管理然后在每个组上施加资源配额。常见的控制器和对应到Docker参数是这样的cpu控制器限制容器的CPU使用量。docker run --cpus0.5 限制容器最多用半个核。memory控制器限制容器的内存上限超过之后触发OOM内核按策略杀掉组内进程。docker run -m 512m 起作用的地方就在这。pids控制器限制容器内最多创建多少进程防止进程炸弹一瞬间打爆宿主。blkio控制器限制容器读写磁盘的带宽避免某个容器把磁盘IO占满。cpuset控制器把容器进程钉在特定CPU核心上常用于需要稳定性能的场景。有意思的是你平时用的 docker stats 命令显示的CPU和内存数据其实不是Docker自己算的它只是去读了cgroup目录下的一堆metric文件而已。如果你在宿主上进去 /sys/fs/cgroup 对应子目录能看到容器里每个进程的资源统计数据排查性能问题时这个目录经常救人一命。2.3 隔离不等于安全边界到底在哪这里必须说清楚一个关键认知namespace和cgroup提供的隔离属于资源隔离和名字空间隔离不是安全隔离。它们隔离了进程的可见范围和资源配额但没有改变一个根本事实——所有容器进程都跑在同一个宿主内核上。虚拟机为什么隔离更硬因为在KVM体系里客户机内核运行在物理CPU的non-root模式它访问内存、中断、设备时全部经过虚拟化层截获即使客户机内核被攻破攻击者也很难逃出虚拟机的物理资源边界。而容器里的进程发起系统调用时走的就是宿主内核的完整调用链一旦某个系统调用存在内核漏洞容器里的恶意代码就可能以宿主内核的权限行事。每隔一段时间就会出现一次容器运行时逃逸漏洞本质上都是这条路径被打穿的案例。所以我的建议非常直接跑不可信代码、承载多租户生产负载的地方老老实实套虚拟机容器适合跑可信的应用进程密度优先但边界风险必须可控。很多厂商的实际做法就是在虚拟机里跑容器把虚拟机当作安全边界把容器当作效率工具。这个组合在很长一段时间里都是最稳妥的姿势也再一次呼应了1.4里说的两条路配合。3. 一层套一层的镜像设计分层架构为什么是场革命3.1 镜像不等于磁盘快照从整块搬迁到逐层拼装在容器之前云计算的世界里也有镜像这个概念。虚拟机镜像是长什么样的一个巨大的磁盘文件里面包含完整操作系统、应用、配置和状态几个GB到几十个GB。你要分发一个虚拟机镜像通常只能整个复制传输慢、存储重复、版本管理笨重。两台虚拟机如果只差一个配置文件你也得各存一整块磁盘存储浪费非常严重。Docker带来的第一个革命是把镜像从一个黑盒磁盘拆成一组显式声明的分层结构。分层就是镜像由多个只读层叠加而成每一层对应Dockerfile里的一条构建指令。比如FROM ubuntu:22.04 # 第1层基础镜像 RUN apt-get update apt-get install -y nginx # 第2层安装软件 COPY index.html /usr/share/nginx/html/ # 第3层拷贝文件 ENTRYPOINT [nginx, -g, daemon off;] # 启动配置不产生层每一层在构建成功之后就是一个不可变的只读块被存储在宿主机上并通过内容摘要唯一标识。为什么不可变很重要因为层是缓存和复用的单位只要你的第三层内容没变构建时就能直接复用前两层的缓存不用重新执行RUN指令。这就好比搭乐高标准化砖块是可以反复拆装的你不需要每次盖新楼都重新烧一遍砖。3.2 联合文件系统把层次叠加成完整视图层是分开存的但容器进程需要的是一整套看起来完整的文件系统。谁把层次重新拼起来答案是联合文件系统Union File System。Linux生态里最常见的实现是OverlayFSDocker默认的overlay2存储驱动就是基于它实现的。OverlayFS的思想很朴素把若干个目录叠在一起对外提供一个统一视图。叠放规则很简单——上层的文件覆盖下层同名文件。对Docker来说镜像的所有只读层组合挂载成lowerdir容器启动后自己的可写层挂载成upperdir两者合并成一个完整的rootfs容器里的进程看到的就是这个合并结果。这套机制带来一个重要性质去重共享。想象你在宿主机上跑五十个基于ubuntu:22.04镜像的容器每个镜像用的都是同一份Ubuntu基础层。底层实现里这五十个容器共享同一份只读基础层文件而不是复制五十份。你的磁盘上只需要存一份基础层再加上每个容器独有的少量业务层。这直接解决了虚拟机时代最头疼的存储重复问题也是分层架构这个题目里最落地的一条红利。3.3 写时复制容器启动飞快和写文件不爆炸的真相分层设计还有一个隐藏的杀手锏写时复制Copy-on-WriteCoW。这是什么意思容器启动的时候Docker并不会把整个镜像复制一遍到容器的私有空间而是直接建立叠加视图。容器进程读文件时先查可写层可写层没有再到下面的只读层找当进程要修改某个文件时无论这个文件原本在哪一层都先把该文件从只读层复制一份到可写层然后在可写层上修改。这个过程被称为copy-up。它的优势是容器刚启动那几毫秒什么都不需要复制所以启动极快容器运行中如果它不怎么改写文件磁盘开销也极小。这个概念在操作系统中其实很常见虚拟机的内存快照、数据库的MVCC都用类似思路容器把它延续到了文件系统层面。但需要提醒一句CoW不是免费的午餐。首次写入某个大文件时copy-up会触发一次完整的读加写所以你会看到容器工作负载的IO瞬间波动。如果一个容器高频写小文件可写层会持续膨胀最终反映在磁盘占用上——这个坑后面我会专门展开。4. 从镜像到容器的运行链路一次 docker run 背后发生了什么4.1 构建阶段Dockerfile 指令如何变成层很多人把 docker build 当黑盒用其实它背后的逻辑很清晰。构建器逐条执行Dockerfile里的指令每条会产生一个新的只读层并挂到一个临时容器上执行执行完成把改动提交成新层然后销毁临时容器进入下一条指令。构建的缓存规则是基础镜像没变、指令字符串没变、这条指令的输入没变这层就能复用旧缓存。所以调整Dockerfile时把变化频率低的指令放在前面把经常变的代码拷贝放后面能最大化缓存命中率。反过来如果你把COPY写在前面后面所有的RUN就全部失效整个重建。我在团队里反复强调这一点Dockerfile的指令顺序直接决定构建速度这不是小事几十个人的团队每天都在跑构建每快一分钟都是实打实的效率。另外还有一个值得养成的习惯加 .dockerignore。很多人只写.gitignore不带.dockerignore结果把 node_modules、.git、日志目录全塞进构建上下文镜像又大又脏。构建上下文是发给Docker daemon的每发一次网络和磁盘都在替你买单。4.2 启动阶段containerd、runc 与 rootfs 的组装一条 docker run 命令会被Docker Engine传递给containerdcontainerd再调用runc创建并启动容器进程。runc做的是最底层的工作创建容器的rootfs把镜像层作为lowerdir新可写层作为upperdir挂载出OverlayFS合并视图。创建前面说的那批namespacePID、Mount、Network、UTS、IPC等挨个设置好。把容器进程注册进cgroup施加内存、CPU等资源限制。通过pivot_root或chroot切换根目录让容器进程以为/就是自己的rootfs。执行容器入口命令把主进程拉起来。这里面有一个值得记住的事实从宿主角度看容器里的主进程就是一个挂着某容器rootfs、带着一堆namespace标记的普通进程。它创建新线程、打开文件、发起网络请求走的全是宿主内核的正常路径。所以排查容器故障时在宿主上用 ps、ls /proc 是能看到容器进程的前提是你知道它的PID。4.3 存储驱动与可写层为什么默认是 overlay2以及何时该用 volumeDocker支持多种存储驱动历史上有vfs、aufs、devicemapper、overlay、overlay2。现在绝大多数主线发行版默认采用overlay2原因很实际性能好、稳定性高、和现代内核版本兼容性好。devicemapper当年用稀疏文件模拟块设备性能和可靠性问题一堆aufs没进Linux主线内核很多发行版要额外打补丁overlay2基于主线OverlayFS天然优势明显。还有一个大家容易混淆的概念容器可写层 vs 数据卷volume。容器可写层是临时的容器删除后该层随之销毁volume是持久化存储生命周期由用户管理。写数据库数据、写日志文件这类必须持久化的东西用volume挂载别写在容器可写层。原因不只是持久性还有性能——overlay2的可写层绕不开copy-up而volume本质上是直接路径访问少了这层间接成本。一个容易被忽略的细节容器的可写层在宿主上对应一个目录用 docker inspect 能找到路径。排查容器里文件为什么和镜像里不一样这类问题时这个目录就是第一现场。你甚至可以直接在宿主上进去看容器可写层里到底多了哪些文件。5. 分层带来的连锁反应镜像仓库、构建策略与编排演进5.1 镜像仓库让分发变成只拉缺的层镜像分层设计最直接的红利体现在分发上。虚拟机镜像是整体传输的容器镜像却可以按层增量拉取。假设你本地的Ubuntu基础层已经有了再拉一个新的业务镜像时只要基础层内容一致Docker只会拉取业务镜像多出来的那几层省掉大量网络流量和时间。这在多环境部署、灾备切换、自动扩容的场景里是实打实的收益。镜像仓库Registry是这套分发体系的核心枢纽。镜像通过 tag 定位版本但真正标识内容的是 digest也就是基于层内容计算出的摘要。tag可以被覆盖digest不可变所以生产环境锁定版本时用digest比用tag可靠得多。一个常见的生产事故是某个人把latest标签推到仓库覆盖了之前的镜像一堆机器因为拉取策略不一致而部署了不同版本。用digest或者用带提交ID的不可变tag能从源头杜绝这类问题。5.2 分层也是把双刃剑基础镜像升级与安全补丁的博弈分层的复用性带来一个隐忧基础层一旦有问题下面依赖它的所有镜像都要受影响。比如基础镜像里的glibc爆出安全漏洞你不能只改基础镜像而是要让所有业务镜像重新构建、重新发布。这就是业界后来强调不可变基础设施和供应链安全的原因基础镜像的构建必须可控、可复现补丁节奏必须固定。另一个连锁反应是构建策略的演进。经典的多阶段构建就是靠分层实现的先用带SDK的大镜像编译产物最后阶段只把编译产物拷贝进精简运行镜像最终镜像只包含运行所需体积能缩小一个数量级。没有分层架构这种构建方式很难优雅落地。每次我演示一个5GB的Golang编译镜像如何变成20MB运行镜像时读者都直呼神奇其实本质就是层在起作用——该抛的层抛掉该留的层留着。5.3 从单机 Docker 到编排虚拟化革新的下半场单机Docker很快会碰到天花板一台宿主机挂了上面的容器跟着没了容器多了你没法手动决定它跑在哪台机器上网络互通、负载均衡、服务发现全都要自己搭。这时候就要聊编排层了。Kubernetes这类编排器做的事本质上就是把容器当成一种可调度的资源单元像操作系统调度进程一样在集群里调度容器。从概念上理解Pod是Kubernetes的调度单元里面可以放一个或多个容器共享网络和存储Deployment负责声明我要几个副本、用哪个镜像、什么更新策略控制器就负责保证当前状态收敛到期望状态。这套模型让应用部署从手工登录机器敲命令变成声明式变更加自动化收敛——你只需要告诉编排器你想达到什么状态剩下的是它的事。虚拟化革新的后半段正是这个容器解决了单机密度和启动速度的问题编排解决了集群规模和管理复杂度的问题。一个解决微观效率一个解决宏观调度二者是衔接关系不是替代关系。6. 用容器这些年我踩过的认知坑与排查思路6.1 认知坑一把容器重启当成系统重启容器里没有systemd没有开机自启逻辑。docker restart 实际上就是把进程杀掉再按入口命令拉起来如果你在业务代码里依赖了系统重启时会做某种初始化容器重启不会替你执行。很多重启就好的经验在容器世界里会失效。正确的做法是把初始化逻辑写进应用的启动脚本或者用init容器做前置初始化让每次容器启动都天然经过完整的初始化流程。我在一个项目里见过真实事故应用依赖本机的一个临时文件做锁进程被kill后文件还在重启后直接报锁已存在拒绝启动。排查了半天根源就是有人把docker restart当reboot用了。容器不是一个持久化系统它是无状态进程的载体所有需要跨重启保留的东西都应该外置。6.2 认知坑二在容器里跑sshd让调试路径变成攻击面我见过不少团队把Docker当虚拟机用进容器先装sshd然后ssh进去调试。这在交付和运维上都是坏味道——一方面镜像多出一个攻击面另一方面调试状态无法复现问题就成了这台容器能连那台不能连的玄学现场。容器的调试正路是 docker exec 或 kubectl exec配合只读挂载排查现场需要远程调试时建议用专门的调试sidecar容器而不是把所有容器都变成可ssh的机器。这个原则在安全合规要求高的环境里尤其重要镜像里少一个监听端口就少一条被入侵的路径。6.3 认知坑三忽视可写层的膨胀与inode问题前面反复讲过CoW和可写层这里说一个我实战中踩得最深的坑。某个长期运行的容器里应用疯狂写临时日志到容器可写层结果宿主的磁盘被慢慢撑满更关键的是目录inode耗尽连删除文件都报No space left on device。那一次排查费了很大劲因为df看磁盘还有余量但df -i一看inode已经100%。这类问题的排查套路是固定的# 第一步拿到容器可写层在宿主上的路径 docker inspect --format{{.GraphDriver.Data.UpperDir}} 容器名 # 第二步到宿主上检查目录体积和inode用量 du -sh UpperDir路径 df -i UpperDir路径 # 第三步确认是日志写入造成的膨胀后把日志路径切到volume或stdout及时把日志路径改成由采集器统一消费而不是堆在容器可写层这类问题就能从根上避开。记住一个原则容器可写层只放进程运行过程中必须临时产生的文件一切需要持久化或者量大到可能膨胀的数据全部用volume。6.4 给新手的概念落地清单最后给刚接触容器的人一份可以直接用的检查清单基础层共享与容量估算一个2GB的基础镜像10个容器大概占2GB加业务层不是20GB。看到容器多就按倍数算磁盘的人多半没理解分层。存储驱动确认docker info 里看Storage Driver生产环境优先overlay2遇到老机器缺内核模块时再考虑兼容方案。镜像瘦身三件套多阶段构建、合并RUN命令、构建后清理apt和yum缓存。版本锁定生产环境镜像用digest不用latest浮动标签。镜像构建上下文控制.dockerignore 和 .gitignore 一样重要别把仓库历史塞给Docker daemon。踩过几次坑之后我对容器技术最大的体会是它真正的价值不在于让人换一种方式跑进程而在于逼着所有人重新思考交付一个可运行系统到底意味着什么。分层架构让软件打包变得像搭积木一样可复用namespace和cgroup让隔离变得轻量而灵活。这两条主线你真正吃透了再看云原生那一片眼花缭乱的名词会发现它们大多只是在给这两条主线做延伸。别被更轻量的虚拟机这种说法带偏从内核和文件系统的视角去理解很多概念都是一通百通的。
返回列表