前置回顾
第 04 篇讲过:Docker 镜像的每一层是只读且不可变的,后面的层只能"遮住"前面(whiteout),不能删除前面的数据.第 05 篇讲过:每条 RUN/COPY/ADD 产生一层.这两个原理是 Dockerfile 优化的全部理论基础--一切技巧都在于"让不想要的数据不要进入任何层".
回顾:每条指令都是一层
第 05 篇说过,Dockerfile 中每条会修改文件系统的指令都产生一个新层.先把这个认知用代码钉死:
|
这就是第 04 篇思考题的答案在 Dockerfile 维度的投射:一旦数据进入一层,该层被提交后就永远留在镜像里.后面删除它只是加 whiteout,空间不会释放. 优化 Dockerfile 的核心思路就是让不必要的数据不进入任何一层.
缓存优化
Docker 怎么判断缓存是否命中
构建时,Docker 对每条指令做缓存检查.规则非常机械:
| 指令类型 | 缓存键(Cache Key) | 命中条件 |
|---|---|---|
FROM |
镜像名:tag 的 digest | digest 完全一致 |
RUN |
指令字符串本身 | 字符串一字不差(包括空格) |
COPY / ADD |
指令字符串 + 源文件的 内容哈希 | 指令相同 + 文件内容没变 |
关键约束:缓存是逐层链式的
一旦某层缓存未命中,该层及之后所有层的缓存全部失效--即使后续指令本身没变.这是 Docker 设计上的保守策略:它假定每一层的结果依赖于前一层的状态.
最重要的优化:COPY 的顺序策略
这是 Dockerfile 优化中最简单,收益最大的一条规则:把最不常变的东西先 COPY,最常变的东西最后 COPY.
|
构建时间对比(在已有缓存的前提下改一行源码):
|
"不常变的东西先 COPY"本质上是在利用第 04 篇的分层共享机制.lnmp install 产生的 node_modules/ 那一层被单独提交为 Layer,只要 package.json 不变,这层的 sha256 就不变.Docker 检测到缓存命中后直接复用这层--就像两个镜像共享同一个基础层一样.
缓存破坏与绕过
有时候你需要 让某条指令的缓存失效.比如 apt-get update 的结果会过时,你希望定期重跑.这时可以主动破坏缓存:
|
多阶段构建(Multi-stage Build)
没有多阶段构建时的痛苦
在第 04 篇我们理解了一个核心约束:层的只读性意味着你不能在 Dockerfile 中"先装一个工具,用完再删"来节省镜像大小--工具本身已经永久留在了它被安装的那一层.
多阶段构建(Docker 17.05+)破解了这个问题.核心思路:在"构建阶段"用完整的编译环境,然后在"运行阶段"只拷贝最终产物,构建阶段的全部工具链和中间文件完全不进入最终镜像.
|
语法
|
常见多阶段模式
模式一:Go/Rust 静态编译程序
|
模式二:前端静态资源
|
模式三:同一个应用的多个二进制
|
镜像瘦身
基础镜像到底差多少
| 基础镜像 | 大小 | 包里有什么 | 适用场景 |
|---|---|---|---|
ubuntu:22.04 |
~77MB | glibc + apt + bash + coreutils + ... | 通用场景,需要 apt 装东西 |
debian:bookworm-slim |
~40MB | glibc + apt(精简版) | 想要 apt 但尽量小 |
alpine:3.19 |
~7MB | musl libc + apk + busybox | 追求体积极限 |
gcr.io/distroless/static |
~2MB | 只有 CA 证书 + /tmp 等空目录 | 静态编译的 Go 程序 |
scratch |
0MB | 完全空白 | 静态编译且不需要 CA 证书或自带证书 |
Alpine 的坑--不能只看体积
Alpine 用 musl libc 而非标准 glibc,这会导致几个棘手问题:
| 问题 | 原因 | 影响 |
|---|---|---|
| DNS 解析行为不同 | musl 的 DNS resolver 比 glibc 更严格,某些边缘场景失败 | K8s DNS 偶发超时 |
| 性能差异 | musl 的 malloc 实现在高并发下不如 glibc 的 ptmalloc |
高并发服务可能有 10-20% 性能损失 |
| 某些预编译二进制不兼容 | 许多 Linux 二进制编译时链接了 glibc 的 .so 文件 |
第三方闭源工具可能直接跑不了 |
| 包管理器不同 | 用 apk 而非 apt,包名不同,某些冷门包缺失 |
需要手动查包名或从源码编译 |
选择建议
如果体积不是极致敏感(比如镜像 < 200MB 就能接受),debian:slim 是更安全的选择--glibc 兼容性最好,apt 生态最全.Go 静态编译程序直接 scratch 或 distroless/static.Alpine 适合你对兼容性做过充分测试后的场景.
.dockerignore - 别把垃圾送进构建上下文
docker build 先把构建上下文(整个目录)发送给 dockerd,然后 dockerd 从中选取 COPY/ADD 需要的文件.如果上下文里有 node_modules/,.git/,构建产物,发送过程本身就又慢又浪费.
|
|
安全实践
不以 root 运行容器
回顾第 02 篇的知识:容器进程在宿主机上就是一个普通进程.如果这个进程以 UID 0(root)运行,一旦 Namespace 隔离有漏洞导致容器逃逸,攻击者就直接拿到了宿主机的 root 权限.
即使不逃逸,也有风险--比如挂载了宿主机目录时,容器内的 root 可以读写宿主机上任何文件.
|
为什么不用 nobody?
很多教程说 USER nobody,但这不安全--如果多个容器都用 nobody,它们文件系统权限会互相看到.创建每个应用独有的非 root 用户 是最佳实践.
敏感信息不要留在层里
回顾第 04 篇的 whiteout 机制:即使你在后面的层删掉了密钥文件,它仍然存在于前面的层中.任何人拿到这个镜像,都可以通过 docker history 和层提取工具拿到历史上任何一层的内容.
|
锁定版本,不要依赖 latest
|
Dockerfile 自查清单
写完一个 Dockerfile 后,逐条检查:
| 类别 | 检查项 | 原理 |
|---|---|---|
| 体积 | 是否用了多阶段构建来排除编译工具链? | 最终镜像不含 builder 阶段的层(05 篇) |
同一个 RUN 内是否合并了安装 + 清理? |
临时文件不进入已提交的层(04 篇 whiteout) | |
| 是否选了合适的基础镜像(scratch/distroless/alpine/debian-slim)? | 基础镜像越大 → CVE 越多,攻击面越大 | |
| 缓存 | COPY package*.json 是否在 COPY src/ 之前? |
不常变的文件先 COPY,常变的靠后(缓存链规则) |
是否有 .dockerignore 排除 node_modules/,.git/? |
减小构建上下文,避免 COPY 包含无用文件导致缓存失效 | |
RUN 指令是否按稳定程度从高到低排列? |
不常变的在前(如 apt install),常变的在后(如代码编译) | |
| 安全 | 是否以非 root 用户运行(USER 指令)? |
限制容器逃逸后的影响范围(02 篇 User NS) |
敏感信息是否通过 --mount=type=secret 而非 COPY 传入? |
密钥不进入任何镜像层 | |
| 基础镜像和系统包的版本是否精确锁定(不用 latest)? | 可复现构建;避免上游变动导致的意外故障 |
小结
所有 Dockerfile 最佳实践的底层逻辑都源自两个基本原理:
-
层是不可变的(04 篇) → 删掉的东西还在下层 → 不想要的数据不要让它产生 → 合并 RUN,多阶段构建
-
层是缓存的最小单位(05 篇) → 一条指令变了,后续缓存全灭 → 按稳定程度排序 → 不常变的放前面
本篇要点回顾
| 要点 | 一句话概括 |
|---|---|
| COPY 顺序 | 依赖清单(package.json)在前,源码在后--让 npm install 的缓存可以被反复命中 |
| 多阶段构建 | builder 阶段放完整工具链,runtime 阶段只 COPY --from=builder 最终产物 |
| 镜像瘦身 | scratch (0MB) → distroless (~2MB) → alpine (~7MB) → debian-slim (~40MB) → ubuntu (~77MB),越往右越通用但越重 |
| Alpine 的坑 | musl libc 替代 glibc--DNS 行为不同,性能差异,二进制不兼容 |
| .dockerignore | 排除 node_modules/.git 等垃圾,构建上下文从几百 MB → 几 MB |
| 安全:非 root | USER 指令切换到专属用户,限制容器逃逸后的影响范围 |
| 安全:密钥 | 用 --mount=type=secret 传密钥,不要 COPY--密钥进入镜像层就永远在里面 |
| 安全:锁定版本 | 基础镜像和包都用精确版本号,不用 latest--保证可复现,可审计 |
动手练习
- 写一个最简单 Go HTTP 服务(或任何你熟悉的语言),先用一个单阶段的 Dockerfile 构建,记录镜像大小;再改为多阶段构建,对比最终镜像体积
- 对一个已有的 Dockerfile,故意把
COPY . /app放在RUN npm install前面,改一行源码后构建两次,记录时间;交换顺序后重复,对比差异 - 用
docker image history查看镜像的每一层及其大小,找出体积最大的层,思考能否优化 - 写一个使用
USER指令的 Dockerfile,用docker run --rm -it whoami验证容器进程确实不是 root - 创建一个
.dockerignore文件,对比加前后的 "Sending build context" 大小 - 思考题:为什么
FROM scratch只能运行静态编译的二进制?如果你尝试在 scratch 镜像里运行一个依赖 glibc 的动态链接二进制,会发生什么?(提示:ldd /path/to/binary)