Docker镜像与容器核心机制:分层存储原理与工程实践
搞 Docker 的人一定听过这句话镜像和容器是 Docker 的两大基石。但真正被问住的时候很多人才发现连基石都没踩实——镜像到底是不是安装包容器到底是不是虚拟机分层存储到底解决什么问题这三个问题我面试过不下二十个人能一次说清的不超过三分之一。大多数人的答案是镜像就是下载好的软件包容器就是跑起来的软件这个回答放到生产环境一碰就碎镜像怎么比想象中大这么多改了容器里的文件怎么重启就没了为什么同一台机器跑一百个容器磁盘没爆这背后全都指向同一个机制——分层存储。如果你现在对这些概念还处于命令会敲、原理模糊的状态这篇文章正好是为你写的。我会把镜像、容器、分层存储三者之间的关系拆开揉碎再结合构建镜像、拉取镜像、容器安全、日常运维中那些没人明说但天天踩的坑一次性讲透。1. 镜像与容器两个天天被误解的基础概念1.1 镜像安装包、容器轻量虚拟机错在哪儿先说结论镜像不是安装包容器不是虚拟机。安装包是什么是一个可执行文件双击解压安装到系统里它是程序层面的概念。Docker 镜像是什么是一个完整的文件系统快照里面包含了一个应用运行所需的操作系统文件、依赖库、运行配置。换句话说镜像是把一个小型的根文件系统打包起来里面甚至有 /etc、/usr、/var 这些目录结构。你不能运行一个镜像镜像只能被实例化成容器。至于容器很多教程喜欢说容器是轻量虚拟机。这个类比害人不浅。虚拟机是用 Hypervisor 模拟出完整的硬件再跑一个完整内核guest OS 和宿主机操作系统完全隔离。容器根本不存在硬件模拟容器里的所有进程直接跑在宿主机内核上只是通过 namespace 实现了资源视角的隔离——容器里的进程看见自己的 PID 是 1看见一个独立的文件系统但它和宿主机上其他进程共用同一个内核。我见过太多人把容器当虚拟机用进入容器装 systemd、装 SSH、把配置直接改在容器里。这套操作短期能跑时间一长必出问题因为容器是临时性的容器一删里面改的东西全没了。想要改了还在要么重新构建镜像要么用数据卷挂载容器层本身永远不承担持久化职责。这里我把两个概念的核心差异列出来一目了然对比项虚拟机容器硬件模拟有完整虚拟硬件无硬件模拟直接使用宿主机内核隔离层级内核级隔离操作系统级隔离启动速度通常要几十秒到几分钟毫秒到秒级体积GB 级起步镜像层共享磁盘占用相对小单机密度低高1.2 用一次 docker run 看透两者的协作关系光说概念还是抽象我们走一遍docker run的全过程。假设我要跑一个 Nginxdocker run -d -p 8080:80 --name my-nginx nginx这条命令背后发生的事按顺序来dockerd 先在本地镜像仓库里找nginx:latest找不到就去配置的镜像仓库拉取找到或拉到镜像后dockerd 会为这个容器创建一个可写层也就是容器层创建各种 namespace为容器准备独立的 PID、网络、挂载等视角把镜像的只读层和容器可写层联合挂载成一个完整的根文件系统让容器内的进程看到一个完整的系统在容器中启动 nginx 进程并把它作为容器的主进程也就是容器内的 PID 1把宿主机 8080 端口的流量转发到容器内的 80。此时你到宿主机上执行ps aux | grep nginx能看到 nginx 进程但你在容器内执行ps却只能看到以 nginx 为主的几个进程——因为它跑在独立的 PID namespace 里。这就解释了为什么容器里没有 systemd 也能跑也解释了为什么容器内看 PID 1 是 nginx。所以正确的理解方式是镜像是一套完整的只读文件系统模板容器是这套模板在隔离环境中的运行实例。实例可以有无数个而且每个实例都共享模板本身不各自复制。之所以能做到共享模板 仅新增薄薄一层正是因为分层存储。2. 分层存储拆开看读、改、删操作背后发生了什么2.1 OverlayFS镜像层只读容器层可写分层存储的底层实现最常用的是 OverlayFS 这种联合文件系统。你可以把它理解成三块积木lowerdir镜像的各只读层从下往上叠加upperdir容器层可写层merged叠加后的最终视图容器内看到的根文件系统就是这个视图。一个 Docker 镜像默认由很多层构成。你docker history nginx能看到每一层执行的指令和体积。以 Debian 为基础的镜像为例最底下往往是一层完整的 Debian 根文件系统往上可能是设置环境变量“安装软件包”“拷贝应用代码”每一层都是只读的。这里有个非常反直觉的点镜像层是只读的任何容器都没法修改镜像层里的内容。比如你在容器里改了/etc/nginx/nginx.confOverlayFS 会先从 lowerdir 读出原文件在 upperdir 创建一份副本再在这个副本上改。镜像本身一点都不受影响新写的部分全部落在 upperdir。这就是写时复制Copy-on-Write, COW的基本动作。2.2 读、改、删三种操作在分层后的真实路径读文件时OverlayFS 从 merged 视图开始找实际上是从 upperdir 往下找upperdir 没有再去最上层的镜像层找逐层往下。所以容器里看到的是所有镜像层和容器层的合并结果。改文件时正如上面所说upperdir 有就直接改没有就把 lowerdir 的文件拷贝到 upperdir再改这份拷贝。这个先复制再写的动作就是 COW。删文件时又一个容易忽略的设计容器并不会真的去 lowerdir 删除而是在 upperdir 生成一个 whiteout 标记文件把下层某个文件遮挡掉。所以容器里ls看不到了但镜像层里文件还在体积一点没少。这就是为什么在容器里删掉一个大文件再 commit 成新镜像镜像体积不减反增——增删发生在同一层下层文件永远不会消失。顺带说一句docker commit是把容器当前的可写层打包成新镜像层不代表删过的文件会真的消失。生产环境我几乎不用 commit 做镜像交付它能救命的地方只有临时排查后的快速保存。正经做法还是写 Dockerfile保证镜像内容可追溯、可重建。2.3 为什么 1GB 镜像跑 100 个容器不会吃掉 100GB这可能是分层存储最直接的价值。没有分层存储的时代一个应用模板要跑 100 个实例要么复制 100 份文件系统要么挂载网络存储代价惊人。有了分层存储之后100 个容器共享同一个 1GB 镜像的所有只读层每个容器只需要自己的薄薄的可写层。就算容器里有写入也主要是用了多少才占多少初始磁盘开销可能只有几十 MB 每容器。拿虚拟机对比更明显虚拟机跑 100 个 Ubuntu通常要预分配 100 份完整磁盘文件哪怕模板一样。而 Docker 跑 100 个 Nginx 容器底层镜像就一份100 个容器只是多出 100 个可写层传输和存储都省到了极致。另外docker pull镜像的时候也能看到分层下载你会看到 Pull complete 一个接一个每一行就是一个层。推送同理仓库里有相同的层就不会重复传输。这也是为什么 Docker Hub 上很多镜像体积看着不小但应用之间如果有公共基础层实际占用远没有想象高。3. Dockerfile 一层一层写镜像体积就是这么失控的3.1 每条 RUN 自成一层的代价很多人的镜像越来越大不是因为代码多而是 Dockerfile 写法有问题。Dockerfile 里每一条指令RUN、COPY、ADD、ENV 等都会产生一个新的只读层。如果一条 RUN 执行了包管理器的安装命令却没有清理包管理器自身留下的缓存那这些缓存就会被永久写进这一层。我举个例子很多入门教程是这么写的FROM ubuntu:22.04 RUN apt-get update RUN apt-get install -y curl RUN apt-get install -y nginx这个写法至少有三个问题第一apt 的/var/lib/apt/lists缓存没清体积白白多出几十 MB第二三条 RUN 拆成了三层每层都会留下中间产物第三每多一层构建时间、拉取时间、解压时间都在增加。正确写法是合并成一条并且同一层里清缓存FROM ubuntu:22.04 RUN apt-get update \ apt-get install -y --no-install-recommends curl nginx \ rm -rf /var/lib/apt/lists/*这里的rm -rf必须和安装命令放在同一条 RUN。如果你单独写一条RUN rm -rf /var/lib/apt/lists/*那删除动作发生在下一层上一层的缓存文件依然在镜像层里最后出来的镜像体积该多大还是多大。这个坑我当年踩过一次后面凡是看到单独一层删除文件的 Dockerfile我基本都能猜到作者没理解分层。3.2 构建缓存命中的游戏规则Docker 构建时会尽量复用之前构建的缓存层。规则说简单也简单每条指令执行前Docker 会检查当前基础镜像是否相同、指令内容是否相同以及 COPY/ADD 的文件内容是否有变化。只要有一项变了从这一层开始后面全部重新构建。但这里有个反直觉的细节COPY 的文件缓存比较的是文件内容 checksum不是文件修改时间所以内容没变通常能命中缓存。而 RUN 指令比较的是命令文本本身命令没变就可能命中缓存——哪怕你换了 apt 源或者上游软件包已经更新只要命令文本不变它就用旧缓存。这就是很多缓存命中了但跑起来版本不对问题的根源。所以我的习惯是Dockerfile 里把不常变的指令尽量放前面把经常变的代码 COPY 放后面。比如先拷贝package.json/requirements.txt并安装依赖再 COPY 应用代码。这样改一行代码依赖层缓存依旧命中构建速度可以快一个数量级。反之如果一上来就COPY .整个项目任何一个文件变动都会让后面所有层全部重建非常痛苦。3.3 多阶段构建把 1.2GB 的镜像瘦到 312MB如果只讲缓存不讲构建体积总觉得差点意思。多阶段构建是 Docker 17.05 之后引入的特性也是瘦身最重要的武器。以 Node.js 项目为例传统 Dockerfile 可能长这样FROM node:20npm installnpm run build然后整个node:20镜像连带依赖一起带入产物。node:20基础镜像体积就不小加上 node_modules 动不动 1GB 以上。多阶段构建的做法是拆成两个甚至多个 FROM# 第一阶段构建 FROM node:20 AS build WORKDIR /app COPY package.json ./ RUN npm install COPY . . RUN npm run build # 第二阶段运行 FROM node:20-alpine WORKDIR /app COPY --frombuild /app/dist ./dist COPY --frombuild /app/package.json ./ RUN npm install --onlyproduction EXPOSE 3000 CMD [node, dist/index.js]这里的关键点第一阶段里node:20可能 1.2GB 左右但它只是用完就丢的构建环境不会进入最终镜像。最终镜像基于node:20-alpine只拷贝构建产物和运行依赖体积通常能压到 300MB 左右甚至更小。上面这个场景正是从 1.2GB 到 312MB 的典型路径。多阶段构建还能顺手规避密钥泄漏问题很多人的旧 Dockerfile 会把 Git 地址、SSH key 写进 COPY多阶段构建里这些工具和文件只在第一阶段存在最终镜像根本不含这些内容。4. 镜像搬运实战加速下载、私有仓库与多架构4.1 改完 registry-mirrors 还慢问题出在哪Docker 默认从 Docker Hub 拉镜像网络状况不理想时就非常痛苦。最常见的解法是给 Docker daemon 配置 registry-mirrors也就是镜像加速源。修改/etc/docker/daemon.json{ registry-mirrors: [ https://docker.m.daocloud.io, https://dockerhub.icu ] }然后重启 Dockersystemctl restart docker。用docker info看一下 Registry Mirrors 一栏是否已经生效。这里有几个非常容易踩的坑镜像加速只对 Docker Hub 官方仓库生效如果你从 ghcr.io、quay.io 或其他第三方仓库拉镜像是不会走这个配置的很多公共加速源不定期变更或停止服务不要死守一个地址配两个以上兜底而且建议定期确认有效性设置完加速源后老版本 Docker 需要重启才能生效别只改了文件不重启查了半天以为配置没生效如果拉镜像过程老在解压那一步卡住优先怀疑磁盘空间或 IO不一定是网速问题。docker pull是分层下载的你会看到每层 Pull complete但解压时需要空间写盘。4.2 私有仓库该选 Docker Registry 还是 Harbor镜像加速解决的是拉官方镜像慢企业内网通常还需要私有仓库。自用小规模场景官方 registry 镜像就够了一行命令docker run -d -p 5000:5000 --name registry registry:2然后把需要推送的镜像打个私有仓库的 tagdocker tag myapp:latest localhost:5000/myapp:latest docker push localhost:5000/myapp:latest拉取的时候docker pull localhost:5000/myapp:latest。如果你的 registry 没有启用 TLS还要在/etc/docker/daemon.json里配置insecure-registries把这个仓库地址加进去。但企业级场景我一般推荐 Harbor。原因很简单它自带镜像漏洞扫描、项目级的 RBAC 权限控制、镜像复制同步、Web UI 审计这些能力直接对接生产运维。很多团队也会用云厂商的容器镜像服务本质是同样的思路把镜像推送到托管的镜像仓库再在集群节点上拉取走内网加速速度和稳定性都远好于自建。自建还是托管我个人的判断标准是如果团队有专门运维内网环境安全可控Harbor 很顺手如果团队规模小、不想管复杂度托管镜像服务更省心。4.3 多架构镜像一条命令让 x86 和 ARM 都能拉现在 ARM 设备越来越多了x86_64 与 arm64 混部是常态。很多人以为一个镜像只能对应一个架构其实 Docker Hub 上的官方镜像大多是多架构索引。当你docker pull nginx:latestDocker 会先拉取一个 manifest list多架构清单然后按你当前平台的 os/arch 去拉对应的实际镜像层。所以你在 x86 机器上拉和 ARM 机器上拉都是同一个 tag但底层拿到的层完全不同。要自己构建并推送多架构镜像可以用 buildxdocker buildx build --platform linux/amd64,linux/arm64 \ -t youruser/myapp:v1.0 --push .这条命令会通过模拟器分别构建两个平台的镜像然后合成一个多架构清单推送到仓库。注意--push需要同时指定镜像仓库地址如果只是本地构建双架构可以加--load。第一次跑可能非常慢因为要在模拟器里执行完整的系统安装和编译这是正常的不要以为是卡死了。5. 隔离不等于安全容器安全与资源限制的日常习惯5.1 Namespace 与 Cgroups 到底隔离了什么聊完镜像是时候聊聊运行时了。容器技术有两个内核能力经常被混为一谈Namespace 负责隔离可见性Cgroups 负责限制资源。Namespace 有六种主要类型PID 隔离进程号视角、NET 隔离网络栈、IPC 隔离进程间通信、MNT 隔离挂载点、UTS 隔离主机名、USER 隔离用户 ID。容器内看到自己是 PID 1看到独立网卡、独立主机名都是 namespace 的功劳。Cgroups 则是对资源做限定CPU 时间片、内存上限、磁盘 IO 权重等。比如docker run --memory512m --cpus1.5就能限制这个容器最多用 512MB 内存和 1.5 个 CPU 核心。没有 Cgroups 限制的情况下一个容器里跑一段死循环可能直接吃满宿主机所有 CPU这就是很多线上事故的根源。关键认知是容器共享宿主机内核。所以内核级漏洞一旦被利用隔离层是有可能被穿透的。这也是为什么我不建议轻易使用--privileged这种特权模式。跟虚拟机相比容器的隔离是操作系统的隔离不是硬件的隔离安全边界弱得多。5.2 镜像安全基础镜像、漏洞扫描、非 root 补上镜像安全是容器安全的前置条件。很多团队直接把基础镜像写成FROM xxx:latest这等于把所有不确定性留给了上游。生产环境应该使用明确的版本 tag最好是经过漏洞扫描的固定版本而不是 latest 这种随时变化的标签。常见的做法是引入镜像扫描工具比如 trivytrivy image your-registry.com/your-app:1.0.0它会按 CVE 漏洞库逐层扫描输出漏洞等级、受影响软件包和修复建议。我自己在 CI 里会把高危漏洞数量作为发布门禁比如--fail-on-severityHIGH。另外一个几乎零成本的安全习惯不用 root 跑应用。写 Dockerfile 时加两行RUN useradd -r appuser USER appuser这样容器内进程以非 root 身份运行即使被攻破权限也被限制在应用用户范围内。别小看这两行很多真实攻击的第一步就是拿到 root shell然后读宿主机挂载进来的敏感路径。5.3 内存、CPU、日志都要管别让容器裸奔资源限制不只是防止别人拖垮你更是防止你自己的应用搞垮宿主机。我习惯所有生产容器都显式声明资源限制docker run -d \ --name my-app \ --memory512m \ --memory-swap1g \ --cpus1.5 \ --restartunless-stopped \ your-registry.com/your-app:1.0.0--restartunless-stopped的意思是容器异常退出时自动重启但如果你手动 stop它不会自动拉起来。比--always语义更可控。日志这块最容易出事故。默认情况下Docker 的 json-file 日志驱动会让日志文件无限增长磁盘被日志打爆的例子我见过太多。在/etc/docker/daemon.json里加{ log-driver: json-file, log-opts: { max-size: 10m, max-file: 3 } }这样单个容器最多保留 3 个 10MB 的日志文件总量不会超过 30MB。日志再多也应该走日志采集系统捞出来而不是留在本机。顺带提一下docker stats它是查看容器实时资源消耗最直接的命令top 看进程docker stats 看容器级指标配合着排查性能问题非常高效。6. 少走三年弯路的三个操作习惯6.1 磁盘被占满先 docker system df 再动手遇到 No space left on device第一反应不是删容器而是跑docker system df它一次性告诉你镜像、容器、卷、构建缓存分别占了多少空间。docker system df TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 12 5 3.2GB 1.4GB Containers 9 3 50MB 30MB Local Volumes 4 2 120MB 60MB Build Cache 18 0 1.8GB 1.8GB这个输出教给我们的第一个习惯是最占空间的往往不是容器和镜像而是 Build Cache。一套构建流程跑下来缓存十几个 GB 不稀奇。清理命令是docker builder prune -f docker image prune -a -f docker container prune -f docker volume prune -f但注意docker image prune -a会删除所有未被容器使用的镜像包括你不想删的备份镜像执行前用docker images自己过一遍。docker volume prune则会把未被容器使用的匿名卷删掉如果卷里有数据这个动作不可逆务必看清楚再执行。6.2 容器是进程不是虚拟机别往里塞 systemd这是我带过很多新人的第一百零一条军规不要在容器里装 systemd、init、SSH更不要用 systemctl 管理服务。容器的主进程就是这个容器要提供的服务本身。Nginx 容器里主进程就是 nginxNode 应用容器里主进程就是 node。日志直接打到 stdout用docker logs查看需要多进程协作时用多个容器配合或者 sidecar 模式而不是在一个容器内用 systemd 拉一堆进程。为什么因为重启策略、健康检查、日志采集、服务发现都是基于一个容器一个主进程这个模型的。一旦你把 systemd 塞进去容器的主进程变成 systemd它什么时候算健康崩溃后如何重启子进程日志是进 systemd journal 还是 stdout这些全都变得暧昧不清排查问题的时候极其痛苦。容器的哲学是单目标进程不是迷你操作系统。6.3 从 docker run 到 compose环境变量的统一治理第三个小习惯可能最不起眼但对协作和排障影响巨大能写 compose 就不要裸写 docker run。很多初学者学完docker run就觉得完事了然后所有服务都是一大串 docker run 参数。真正的坑在于环境变量、端口映射、卷挂载全都散在历史命令里某天机器重启后你根本想不起某几个服务的完整启动参数。而docker-compose.yml把这些固化成文件还顺手解决了环境变量治理问题services: web: image: your-registry.com/web:1.0.0 restart: unless-stopped ports: - 8080:80 env_file: - .env depends_on: - db用env_file或environment字段统一管理环境变量配合.env文件做不同环境的复用。Docker 官方已经将 docker-compose 演进为docker compose插件新环境直接docker compose up -d即可这是一套成熟且文档完善的编排方式适合绝大多数单机部署场景。我个人从能 docker run 就不写 yaml到默认写 compose只花了一个月就被彻底扭转了。原因很简单人的记忆不可靠文件才是。最后说点题外话搞 Docker 这些年我觉得最重要的不是背命令而是把镜像是只读模板、容器是可写实例、层是传递和共享的最小单元这三句话刻进脑子里。理解了分层存储很多看起来玄学的问题——为什么镜像大了、为什么删了文件还占空间、为什么缓存不生效、为什么 100 个容器不占 100GB——都会变成常识。前面这些经验也是从这些概念倒推出来的。如果你刚开始接触 Docker别急着docker run一把梭先把这三句话想明白后面的事基本都能自己推出来。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →