ARTICLE DETAIL

资讯详情

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

Docker 镜像分层、OverlayFS 与 Dockerfile 瘦身实战

Docker 镜像分层、OverlayFS 与 Dockerfile 瘦身实战 Docker 镜像以及镜像分层是每个用容器的人绕不开的基本功。很多人第一次接触 Docker只记住docker run和docker pull直到某天发现镜像 1.2GB、构建一次要十分钟、CI 磁盘天天告急才开始回头看镜像到底由什么组成。我自己也是从“能跑就行”的阶段过来的后来在多个项目里做镜像瘦身、构建提速、私有仓库迁移才慢慢意识到镜像分层不是面试题而是直接影响构建时间、传输成本、存储占用、发布节奏和排障效率的核心机制。你如果正在学 Docker或者已经用了一段时间但说不清RUN、COPY为什么会变成一层这篇内容会从底层写到实操把镜像、容器、联合文件系统、Dockerfile 指令、缓存、瘦身、排查这些点串起来。适合刚入门的人补齐概念也适合已经上手的人优化现有 Dockerfile。1. Docker 镜像不是一块铁板先搞清它和容器的关系1.1 镜像、容器、仓库三个词别混着用Docker 镜像本质上是一个只读模板里面包含运行某个应用所需的文件系统、依赖库、环境变量、启动命令和元数据。它不直接运行真正跑起来的是容器。容器可以理解成在镜像之上加了一个可写层所有运行时产生的文件、日志、临时数据都写在这个可写层里镜像本身不会被改动。仓库则是存放和分发镜像的地方比如 Docker Hub、Harbor、云厂商的容器镜像服务都属于仓库范畴。很多人把“拉镜像”和“起容器”当成一回事结果排查问题时找不到方向docker pull只负责把镜像层下载到本地docker run才会基于镜像创建容器。镜像 ID、容器 ID、仓库标签是三套不同的标识混用会让人在清理磁盘、回滚版本时犯低级错误。我习惯在团队里反复强调一句话镜像是“菜谱”容器是“按菜谱做出来的那盘菜”仓库是“菜谱图书馆”。菜谱可以反复用菜可以有很多盘图书馆只负责保存和借阅。1.2 为什么镜像要分层复用、缓存、分发三笔账镜像分层最直接的好处是复用。比如你本地已经有node:20-bookworm-slim这个基础镜像再构建一个 Node 应用时基础层不需要重新下载只需要拉取你新增的应用层。构建缓存也依赖分层Dockerfile 中某条指令没有变化对应的层就能直接复用构建速度会快很多。分发同样受益假设十个服务都基于同一个基础镜像仓库里只需要存一份基础层十个服务镜像共享这些底层存储和传输成本都会下降。这个机制对 CI/CD 影响很大如果 Dockerfile 写得很随意每次改一行代码都会让依赖安装层缓存失效构建时间从几十秒变成几分钟。分层还影响回滚镜像标签指向某一组层回滚标签本质上就是换一组层来创建容器。理解这三笔账之后再看 Dockerfile 优化就不是为了“看起来专业”而是为了少下载、少构建、少占磁盘、少等发布。1.3 一个生活类比集装箱和乐高底板我常用集装箱来类比镜像。镜像像一只标准集装箱里面装着应用运行需要的东西容器像集装箱被吊到卡车上开始运输运输过程中新增的货物放在最上面一层不会改动箱体本身。分层则像乐高底板底层是操作系统基础往上是运行时、依赖库、应用代码、配置和启动命令。每一块乐高板都可以单独替换但上面的板子必须和下面的接口匹配。这个类比能解释很多现象为什么删掉容器后镜像还在为什么容器里改文件不会影响其他容器为什么基础镜像换版本后上层应用可能要重新构建。实际工作中我最怕看到有人直接在容器里apt-get install然后docker commit这相当于在已经装好的集装箱外面又糊了一层水泥短期能跑长期没人说得清里面有什么。镜像分层不是为了炫技而是为了让每一层职责清楚、可追溯、可复用。2. 联合文件系统与 OverlayFS镜像分层的底层逻辑2.1 UnionFS 到底“联合”了什么联合文件系统简称 UnionFS是 Docker 镜像分层能够成立的关键。它可以把多个目录“叠加”成一个统一的目录视图用户看到的是一个完整文件系统但底层其实是多个只读层加一个可写层。Docker 早期用过 AUFS后来主流是 OverlayFS、Overlay2不同操作系统和存储驱动可能不同。你可以在 Linux 上执行docker info查看Storage Driver常见输出是overlay2。这个信息很重要因为镜像层在宿主机上的存放方式、性能表现、排查手段都和存储驱动有关。UnionFS 并不复制所有文件而是按优先级合并目录上层有同名文件就遮住下层上层没有就透出下层。这样多个镜像可以共享底层文件只有差异部分单独保存。镜像分层之所以能节省空间靠的就是这种“共享底层、叠加差异”的机制。理解 UnionFS 后再看docker history里一层一层的记录就不会觉得神秘了。2.2 OverlayFS 的 lowerdir、upperdir、merged、workdirOverlayFS 挂载时有几个关键目录lowerdir是只读层可以有一个或多个upperdir是可写层容器运行时的修改写在这里workdir是内部工作目录用于准备和合并merged是最终呈现给用户的合并视图。你可以用mount | grep overlay在宿主机上看到类似参数虽然路径被 Docker 做了哈希处理但结构很清晰。镜像的每一层都在lowerdir里按顺序排列越靠上的层优先级越高。容器启动时Docker 会在upperdir创建一个可写层所有新增、修改、删除操作都先落到这里。删除文件时OverlayFS 不会真的去改只读层而是写一个 whiteout 文件把下层文件遮住。这个细节能解释一个常见困惑为什么容器里删了大文件镜像体积没变小因为删除记录写在可写层只读层里的原文件还在。要真正减小镜像得回到构建阶段把不该进镜像的文件提前排除或清理。2.3 写时复制容器里改文件为什么镜像没变写时复制Copy-on-Write是镜像分层能同时做到“共享”和“隔离”的关键。多个容器可以基于同一个镜像启动它们共享只读层只有当某个容器要修改文件时才会把那个文件复制到自己的可写层之后的修改只影响这个容器。这样既节省了磁盘又保证了容器之间互不干扰。实际表现是你在容器 A 里改了/etc/nginx/nginx.conf容器 B 看到的还是原文件你删了容器 A可写层随之消失镜像纹丝不动。很多人第一次遇到“容器里装完软件重新跑又没了”就是没理解可写层的生命周期。需要持久化的数据应该放到 volume 或 bind mount而不是依赖容器可写层。写时复制也影响性能读多写少的场景很划算频繁写大文件的场景可能不如直接挂卷。排查磁盘占用时可以用docker ps -s查看容器可写层大小别只盯着镜像体积。2.4 层 ID、ChainID 与镜像清单的关系Docker 镜像不是简单的一串层而是有元数据和清单。docker image inspect可以看到RootFS.Layers里面列出每一层的 diff IDGraphDriver.Data会显示LowerDir、UpperDir、MergedDir、WorkDir。构建过程中Docker 会根据父层和当前指令计算一个缓存键缓存键一致就复用层。这也是为什么COPY package.json和COPY .对缓存影响完全不同前者只在依赖清单变化时失效后者任何文件变化都会让后续层缓存失效。镜像清单还包含配置信息比如环境变量、启动命令、暴露端口、工作目录。理解这些结构后排查“为什么相同 Dockerfile 构建出的镜像 ID 不同”会容易很多因为构建时间、文件内容、元数据变化都会影响最终镜像 ID。层 ID 不是给人背的但知道去哪里看排障时能省很多时间。3. Dockerfile 指令与层每一行都要算账3.1 FROM、RUN、COPY、ADD 的层行为Dockerfile 里并不是每条指令都会产生新的文件系统层。FROM指定基础镜像基础镜像本身已经有一组层。RUN在构建时执行命令并把执行结果提交成新层所以RUN apt-get install、RUN npm install、RUN go build都会增加层。COPY把构建上下文里的文件复制进镜像也会产生层。ADD除了复制还支持自动解压 tar 包和从 URL 拉取功能多但容易产生意外我通常建议只在确实需要自动解压时用ADD其他情况一律COPY。ENV、ARG、LABEL、CMD、ENTRYPOINT、EXPOSE、WORKDIR、USER这些指令主要修改镜像元数据在docker history里也会显示成记录但它们通常不携带大文件对体积影响很小。真正吃体积的是RUN、COPY、ADD尤其是安装编译工具、下载依赖、复制构建产物这些操作。写 Dockerfile 时心里要有一张账本哪条指令会产生大层哪条指令只是改配置哪条指令会让缓存失效。3.2 ENV、ARG、LABEL、CMD、ENTRYPOINT 是不是层严格说镜像历史里几乎每条指令都会留下记录但“记录”不等于“大文件层”。ENV设置环境变量容器运行时能读到ARG只在构建阶段生效默认不会保留到运行环境但可能影响缓存键LABEL添加元数据适合记录版本、维护人、构建时间CMD和ENTRYPOINT决定容器启动时执行什么。它们对镜像体积的影响通常可以忽略但对镜像行为影响巨大。我见过有人把密钥写进ENV结果镜像一推送到仓库密钥就跟着分发出去也见过CMD和ENTRYPOINT混用不当导致启动命令被覆盖。我的习惯是ENTRYPOINT放固定入口CMD放默认参数需要用户覆盖命令时用CMD或ENTRYPOINT的 exec 形式别用 shell 形式否则信号传递和 PID 1 行为可能出问题。元数据层虽然轻但安全和管理意义很重不能随便写。3.3 合并 RUN 与清理缓存的正确姿势RUN越多层越多镜像体积也可能越大。更关键的是临时文件如果在某一层被写入即使下一层删掉它仍然留在历史层里。比如RUN apt-get update单独一行再RUN apt-get install -y nginx最后RUN rm -rf /var/lib/apt/lists/*镜像里仍然保留着更新后的包索引。正确做法是把更新、安装、清理放在同一个RUN里RUN apt-get update \ apt-get install -y --no-install-recommends \ ca-certificates curl nginx \ rm -rf /var/lib/apt/lists/*这样临时文件在同一层写入并删除最终层里不会留下包索引。类似地Python 可以加--no-cache-dirNode 可以用npm ci并在多阶段构建里只复制生产依赖Go 可以静态编译后把二进制复制到空镜像。合并RUN不是越少越好而是把生命周期相关的操作放在一起让临时产物不落到最终镜像里。这里还要注意和\的写法一条命令失败会中断构建避免半成品层被缓存。缓存一旦形成后面即使修复也可能继续用旧结果必要时用--no-cache重新构建。3.4 多阶段构建把编译工具留在上一站多阶段构建是镜像瘦身最有效的手段之一。思路很简单第一阶段用完整镜像编译第二阶段只复制运行所需产物。比如 Go 应用可以用golang:1.22编译再把二进制复制到scratch或distrolessNode 应用可以用 Node 镜像安装依赖并构建再把dist和生产依赖复制到运行时镜像。这样编译器、包管理器缓存、源码、测试工具都不会进入最终镜像。一个典型 Go 多阶段 Dockerfile# syntaxdocker/dockerfile:1.6 FROM golang:1.22-bookworm AS build WORKDIR /src COPY go.mod go.sum ./ RUN --mounttypecache,target/go/pkg/mod go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -trimpath -ldflags-s -w -o /out/app ./cmd/server FROM gcr.io/distroless/static-debian12 COPY --frombuild /out/app /app USER nonroot:nonroot ENTRYPOINT [/app]这个例子里--mounttypecache让依赖缓存不进入镜像层-ldflags-s -w去掉符号表和调试信息distroless去掉 shell 和包管理器最终体积可能只有几十 MB。多阶段构建不是只为了小它还减少攻击面因为运行时镜像里少了很多无关工具。3.5 .dockerignore 与构建上下文看不见的层外成本很多人优化镜像只看 Dockerfile却忽略构建上下文。docker build .会把当前目录打包发给 Docker 守护进程如果目录里有node_modules、.git、日志、测试数据、大模型文件构建还没开始就慢了几十秒镜像也可能意外变大。.dockerignore应该在项目初始化时就加至少排除.git .gitignore node_modules dist build coverage *.log .env* tmpCOPY . .之前一定要确认哪些文件会进去。我踩过一次坑本地有一个 2GB 的调试数据目录没排除CI 构建每次都在传上下文后来加.dockerignore直接把构建时间从 4 分钟降到 50 秒。构建上下文不仅影响速度还影响缓存因为文件元数据变化可能导致COPY层失效。一个干净的上下文加上精准的COPY比任何“高级优化”都实在。4. 实操构建、查看、瘦身、分发一条龙4.1 从零构建一个 Node 镜像并观察层找一个简单 Node 项目先写一个不太优化的 DockerfileFROM node:20 WORKDIR /app COPY . . RUN npm install RUN npm run build EXPOSE 3000 CMD [node, dist/server.js]构建docker build -t demo-app:bad . docker images demo-app:bad然后看历史docker history demo-app:bad你会看到COPY . .、RUN npm install、RUN npm run build各占一层源码和开发依赖都进了镜像。再改成多阶段# syntaxdocker/dockerfile:1.6 FROM node:20-bookworm-slim AS deps WORKDIR /app COPY package*.json ./ RUN --mounttypecache,target/root/.npm npm ci FROM node:20-bookworm-slim AS build WORKDIR /app COPY --fromdeps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:20-bookworm-slim AS runtime ENV NODE_ENVproduction WORKDIR /app COPY package*.json ./ RUN --mounttypecache,target/root/.npm npm ci --omitdev COPY --frombuild /app/dist ./dist USER node EXPOSE 3000 CMD [node, dist/server.js]再构建demo-app:good对比docker images和docker history。通常体积会从 1GB 级别降到 200MB 左右层也更清晰依赖层、构建层、运行层各司其职。4.2 docker history 与 dive 查看分层docker history是最基础的层查看工具docker history --no-trunc demo-app:good--no-trunc能看到完整命令定位是哪条指令产生了大层。镜像的RootFS信息可以用docker image inspect demo-app:good \ --format {{json .RootFS.Layers}} | jq如果想看每层具体新增了哪些文件可以装divedive demo-app:good它会显示镜像效率、每层文件变化、浪费空间。我一般用它做两件事找被删除但仍占空间的临时文件找意外复制进去的密钥、日志、测试数据。注意dive看的是层内文件不是运行时行为所以还要结合docker run --rm -it demo-app:good sh检查实际文件系统。查看层的时候别只看大小还要看层数和缓存友好度。层数太多不一定是坏事但改动频繁的层放在后面能减少缓存失效。4.3 镜像瘦身案例1.1GB 到 190MB我拿一个真实项目举例原始镜像基于node:20包含完整 Debian、开发依赖、源码、构建缓存、npm 缓存体积 1.1GB。优化步骤第一基础镜像换成node:20-bookworm-slim去掉大量非必要包第二多阶段构建编译阶段和运行阶段分开第三运行阶段只装生产依赖npm ci --omitdev第四.dockerignore排除.git、日志、测试数据第五npm install换成npm ci并利用 BuildKit 缓存挂载第六合并RUN清理临时文件第七非 root 用户运行。优化后体积 190MB构建时间从 3 分 20 秒降到 1 分 05 秒。对比表如下项目优化前优化后基础镜像node:20node:20-bookworm-slim构建阶段单阶段多阶段依赖开发生产仅生产缓存目录进入镜像cache mount源码进入运行镜像仅构建阶段体积1.1GB190MB构建时间3m20s1m05s这组数字因项目而异但思路通用基础镜像要小构建要分阶段依赖要分环境缓存不要进层上下文要干净。4.4 导出导入、私有仓库与标签策略镜像分发常用docker save和docker loaddocker save -o demo-app-1.0.tar demo-app:1.0 docker load -i demo-app-1.0.tarsave/load保留镜像层和历史适合离线传输。export/import针对容器文件系统会丢失层历史和元数据不要拿它当镜像备份。推送到私有仓库docker tag demo-app:1.0 registry.example.com/team/demo-app:1.0 docker push registry.example.com/team/demo-app:1.0标签策略上我坚决反对生产只用latest。latest会漂移今天拉到的和明天拉到的可能不是同一个镜像。建议同时打语义版本和提交哈希例如demo-app:1.0.3、demo-app:sha-abc1234latest只作为开发便利标签。回滚时用版本标签排查时用哈希标签责任清楚。私有仓库还要注意清理策略保留最近 N 个版本和固定发布版本避免仓库无限膨胀。4.5 构建缓存与 BuildKit cache mountBuildKit 的 cache mount 能把包管理器缓存从镜像层里分离出来。比如# syntaxdocker/dockerfile:1.6 FROM golang:1.22 AS build WORKDIR /src COPY go.mod go.sum ./ RUN --mounttypecache,target/go/pkg/mod \ go mod download COPY . . RUN --mounttypecache,target/go/pkg/mod \ --mounttypecache,target/root/.cache/go-build \ CGO_ENABLED0 go build -o /out/app ./cmd/serverNode、Python、Rust、Java 都有类似用法。cache mount 的内容不会进入最终镜像但会在构建器上保留适合 CI 中复用。CI 环境要确保 BuildKit 开启并配置缓存导出导入比如--cache-from、--cache-to。我一般会在 CI 中固定缓存目录配合分支隔离避免不同分支互相污染。注意缓存不是永久可靠的依赖大版本升级时主动清理一次防止旧缓存导致奇怪问题。5. 常见问题与排查速查表5.1 镜像拉取慢或失败先分清镜像名、认证和网络拉取失败先看错误信息。manifest unknown通常是镜像名或标签写错unauthorized是没登录或权限不足no space left on device是磁盘满TLS handshake timeout可能是网络到仓库不通。排查顺序docker info看仓库配置docker login确认认证docker pull加--verbose或以--debug启动守护进程看日志。企业环境常见问题是仓库地址、证书、DNS 解析。配置 registry mirror 可以减少公共仓库拉取压力示例{ registry-mirrors: [https://your-mirror-host] }修改/etc/docker/daemon.json后重启 Docker 服务。注意不要把私有仓库地址配成公共镜像源否则认证会混乱。拉取大镜像时先看本地是否已有基础层docker images和docker system df能帮你判断是网络慢还是磁盘满。5.2 构建缓存不生效的六种情况缓存不生效很常见我整理成速查表现象可能原因排查方式处理每次构建都重新装依赖COPY . .在依赖安装前看docker history先复制package.json等清单改一行代码全部重来复制上下文太早看构建日志调整COPY顺序RUN缓存命中但结果不对命令依赖外部资源加--no-cache验证固定版本、校验哈希CI 缓存总是空缓存导出导入未配置看 CI 日志配置--cache-from、--cache-to基础镜像更新后没变化标签没变、层缓存命中docker pull基础镜像固定 digest 或主动清理多阶段复制缓存异常中间阶段未命中--progressplain拆阶段、固定依赖清单核心原则让不常变的内容先复制常变的内容后复制。依赖清单、锁文件、工具版本先固定源码和配置后放。这样缓存命中率高构建也更容易预测。5.3 容器删了数据没了卷与可写层容器可写层随容器删除而消失这是设计使然。数据库、上传文件、日志这类需要持久化的数据必须用 volume 或 bind mount。排查“数据不见了”时先看docker inspect的Mounts确认数据目录是否挂载。如果用了匿名卷删容器时可能留下孤儿卷占磁盘但没人管可以用docker volume ls和docker volume prune清理。另一个常见误区是把数据写进镜像层运行时再改结果一升级镜像就回到初始状态。正确做法是镜像只放代码和默认配置运行数据通过卷挂载。需要备份时备份卷目录或使用支持快照的存储而不是docker commit容器。可写层适合临时文件不适合当作持久存储。5.4 磁盘被镜像和缓存吃满磁盘告急时先看总览docker system df -v它会列出镜像、容器、卷、构建缓存各自占用。常见清理命令docker image prune -a --filter until336h docker builder prune --filter until168h docker container prune docker volume prune清理前确认没有正在使用的资源。docker image prune -a会删除所有未被容器引用的镜像比较激进生产机器上我一般用标签和保留策略不用一刀切。构建缓存也可能很大尤其是频繁构建的项目docker builder prune能释放不少空间。镜像层共享意味着删一个镜像不一定释放很多空间因为底层可能被其他镜像引用。看docker system df -v里的SHARED SIZE和UNIQUE SIZE能避免误判。5.5 镜像层数过多导致启动慢不一定但影响构建分发层数多不等于启动一定慢但层数过多会增加镜像清单复杂度构建、推送、拉取都可能变慢。更关键的是每条RUN都可能留下临时文件层数越多越难清理。我的经验是不必追求“层数最少”而要追求“每层职责清楚、缓存友好、最终镜像干净”。一般服务镜像控制在 10 到 20 层以内已经很舒服基础镜像层数无法控制但应用层可以控制。把安装依赖、复制代码、构建、运行拆成合理阶段比机械合并所有RUN更有效。如果一个RUN里塞了太多无关操作改一处就全部缓存失效反而更慢。层数、缓存、体积三者要平衡不能只看一个指标。6. 生产环境里我比较在意的几个镜像习惯6.1 标签、基础镜像与版本锁定基础镜像选择直接影响体积、安全和支持周期。alpine体积小但 musl libc 可能和某些二进制不兼容slim基于 Debian兼容性好体积适中distroless更小、更安全但调试困难scratch适合静态编译的 Go/Rust 程序。选择时看应用依赖、调试需求、团队熟悉度。标签上生产环境不要用latest也不要用node:20这种会漂移的标签最好固定到node:20.11.1-bookworm-slim或 digest。基础镜像升级要有节奏先在测试环境构建验证再推生产。我一般会在 Dockerfile 里用ARG声明基础镜像版本方便 CI 统一替换。版本锁定不是死板而是让每次构建可复现出问题时能追溯。6.2 镜像扫描、非 root 与最小权限镜像安全从构建开始。不要把密钥、令牌、私钥写进ENV或COPYBuildKit 的--mounttypesecret可以安全注入构建密钥。运行时尽量用非 root 用户RUN groupadd -r app useradd -r -g app app USER app或者直接用基础镜像自带的非 root 用户比如USER node、USER nonroot:nonroot。镜像扫描工具可以集成到 CI比如trivy image demo-app:good发现高危漏洞就阻断发布。扫描不是一劳永逸基础镜像更新、依赖升级都要重新扫。最小权限还包括只暴露必要端口、只挂载必要目录、只安装必要软件。我见过为了调试在镜像里装curl、netcat、vim上线后忘记删攻击面白白扩大。调试工具可以留在构建阶段或临时容器里不要进生产镜像。6.3 多架构、CI 缓存与清理节奏现在很多团队要同时支持 x86 和 ARMdocker buildx可以一次构建多架构docker buildx build \ --platform linux/amd64,linux/arm64 \ -t registry.example.com/team/demo-app:1.0.3 \ --push .多架构镜像会生成 manifest list拉取时自动匹配平台。CI 中要配置缓存目录和并发控制避免多个流水线同时写同一份缓存导致冲突。清理节奏也要固定构建缓存按时间保留例如保留 7 天镜像按版本保留例如保留最近 10 个发布版本和所有里程碑版本容器和卷定期检查孤儿资源。我一般会在 CI 里加一个低峰期清理任务先docker system df记录基线再按策略清理避免磁盘突然打满影响发布。6.4 给团队定规范Dockerfile 审查清单团队协作中Dockerfile 审查比个人优化更重要。我整理的审查清单包括基础镜像是否固定版本是否使用多阶段构建是否先复制依赖清单再复制源码是否合并安装和清理命令是否使用.dockerignore是否以非 root 运行是否写入了密钥或敏感信息是否使用latest是否暴露多余端口是否包含不必要的调试工具是否有健康检查是否明确ENTRYPOINT和CMD是否配置资源限制。每次代码评审按这个清单过一遍能避免大部分镜像问题。规范不要写得太复杂能落地才有价值。我们团队把这些检查做成 CI 脚本不通过就直接阻断合并时间久了大家都形成习惯。我个人在实际操作中的体会是Docker 镜像和镜像分层这件事入门时觉得是概念做久了才发现是工程习惯。你不需要一开始就背下 OverlayFS 的所有细节但一定要知道镜像层会共享、会缓存、会残留Dockerfile 每一条指令都在影响构建速度和运行安全。先把.dockerignore加上把COPY顺序调对把基础镜像换小把多阶段构建用起来再去看docker history和dive优化方向自然就清楚了。最后再分享一个小技巧每次优化前后都记录镜像体积、构建时间、层数三个数字别凭感觉说“好像快了”。数字不会骗人几次对比之后你会对分层机制有完全不同的理解。
返回列表