Docker 镜像分层与构建缓存机制:如何写出更小更快的镜像
一、为什么镜像会越做越大?
很多团队在项目初期只有一个几百 MB 的镜像,随着需求迭代,镜像逐渐膨胀到 2GB 以上,构建时间从几十秒变成十几分钟,推送和拉取也变得缓慢。问题的根源往往不是代码本身,而是对 Docker 镜像分层机制和构建缓存规则理解不足。
二、镜像分层:UnionFS 的只读叠加
Docker 镜像由多个只读层叠加而成,底层通过 UnionFS(如 overlay2)把多层目录合并成统一视图。容器启动时在镜像顶部再加一个可写层,所有运行时修改都落在可写层,镜像层本身保持不变。
关键结论:每一条构建指令都会产生新的层,层一旦生成就被缓存复用,但它也带着该指令产生的全部文件变更。删除文件并不会减小下层体积,只会在新层里记录“删除标记”。
三、指令与层的对应关系
在 Dockerfile 中,FROM、RUN、COPY、ADD 等指令都会生成层;ENV、LABEL、EXPOSE、CMD 等元数据指令不产生文件层。理解这一点,就能理解为什么“把 apt 缓存清理掉”有时并不能减小镜像——如果清理写在了另一条 RUN 里,旧层中的缓存文件依然存在。
例如下面这种写法就是典型的反模式:
RUN apt-get update && apt-get install -y python3
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
第二条指令只会新增一个“空”层,第一条层里的包列表仍然占用空间。正确写法是把安装和清理合并到同一条 RUN 中,让它们在同一层内完成。
四、构建缓存如何命中
Docker 构建时会为每条指令计算缓存键:指令内容 + 上下文文件内容(对 COPY/ADD 而言)+ 父层缓存。缓存键一致则直接复用。因此只要某一层失效,其后所有层都会重新构建。
这解释了最常见的痛点:在 Dockerfile 里先 COPY . . 再 RUN pip install,任何代码改动都会让依赖安装层失效,导致每次都重新下载依赖。正确顺序是先拷贝依赖清单(requirements.txt、package.json),安装依赖,再拷贝源码。
五、四个立竿见影的优化手段
1. 编写 .dockerignore:排除 .git、node_modules、日志、构建产物,避免无意义的上下文传输和缓存失效。
2. 依赖清单先行:把不变的部分放前面,让缓存命中率最大化。
3. 合并 RUN 指令:安装、清理、配置放在同一条指令里,减少层数并避免残留文件。
4. 多阶段构建:编译环境与运行环境分离,最终镜像只保留运行所需产物。
六、一个多阶段构建示例
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 /app .
FROM alpine:3.20
COPY --from=builder /app /app
ENTRYPOINT ["/app"]
依赖下载与源码编译分离,缓存可以稳定复用;最终运行镜像只包含静态二进制,体积和攻击面同时降低。
七、小结
镜像体积和构建速度并不是玄学,而是分层机制与缓存规则共同作用的结果。把“变化频率低的内容放在前面、同类操作合并到一层、运行时只保留必要产物”这三条原则落实到 Dockerfile 中,通常就能把镜像缩小一半以上,构建时间缩短数倍。
- 点赞
- 收藏
- 关注作者
评论(0)