镜像构建最佳实践
本教程共 25 篇 · 第 24 篇 · 更新于 2026-07-26 · 约 8 分钟阅读
24. 镜像构建最佳实践
本节目标:学会写「小、快、安全」的 Dockerfile。掌握最小基础镜像、合并 RUN 层、.dockerignore、构建缓存优化、非 root 运行、多阶段构建这几招,并了解镜像漏洞扫描。
为什么要讲究最佳实践
随便写个 Dockerfile 能跑起来不难,但跑起来和跑得好是两回事。一个没优化的镜像可能有这些问题:
- 体积几百 MB 甚至上 GB,拉取推送都慢。
- 改一行代码,整个镜像重新构建,缓存全失效。
- 用 root 跑,容器逃逸风险大。
- 带着一堆编译器和调试工具进生产,攻击面大。
这一章挨个解决。先从最影响体积的「基础镜像」说起。
选择最小基础镜像
FROM 是 Dockerfile 的第一行,选什么镜像直接决定最终体积的下限。
来看个对比,同样跑一个 Python 应用:
# 选项 A:完整 Debian 镜像
FROM python:3.12
# 体积约 1GB
# 选项 B:slim 精简版
FROM python:3.12-slim
# 体积约 150MB
# 选项 C:alpine 版
FROM python:3.12-alpine
# 体积约 50MB
三档选择,体积差 20 倍。怎么选?
- 完整版(
python:3.12):含完整 Debian 和编译工具,开发调试方便,生产别用。 - slim:删了文档和调试工具,保留 glibc,兼容性好,推荐生产用。
- alpine:基于 musl libc,最小,但部分依赖(C 扩展)要重新编译,踩坑多。
Tip没特殊需求选 slim 最稳。alpine 看着小,但 Python/Node 这类带原生扩展的项目,装依赖时常常要装编译工具链,最后体积未必小多少,还容易出兼容问题。
更激进的有 distroless,连 shell 都没有,体积最小、攻击面最小,但调试困难,适合成熟项目。
合并 RUN 层
每个 RUN、COPY、ADD 都会产生一层。层多了镜像就大,因为中间产物(如 apt 缓存、下载的包)即使后面删了,前一层里还在。
来看个反面教材:
# 烂:每条命令一层,缓存全留下
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
rm 删的是第三层里的文件,但前两层里 /var/lib/apt/lists/* 还在,镜像体积没小。
正确写法是合并成一条,用 && 串起来:
# 好:一层搞定,清理在同一层
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
几个配套技巧:
--no-install-recommends:apt 不装推荐包,省空间。- 同一
RUN里最后清理缓存、下载文件、临时目录。 - 用
\换行,保持可读性。
Notev29 默认启用 containerd 镜像存储,对层做了更高效的压缩和去重。但「合并 RUN 层」这个原则仍然成立—减少中间产物是根本,存储优化只是锦上添花。
.dockerignore 排除无关文件
docker build 时,上下文目录下的所有文件都会被打包发给守护进程。如果不加过滤,node_modules、.git、日志文件全发过去,构建慢、镜像大。
在 Dockerfile 同级目录建一个 .dockerignore:
# 依赖目录(容器内重新装)
node_modules
# 版本控制
.git
.gitignore
# 日志和临时文件
*.log
tmp
# 构建产物
dist
build
# 本地环境配置
.env
.env.local
# 编辑器配置
.vscode
.idea
.dockerignore 的语法和 .gitignore 类似。它的作用有两个:
- 减小构建上下文:不发无关文件,构建更快。
- 避免缓存失效:源码改动如果波及被忽略文件,不会触发
COPY层重建。
Warning这条坑特别常见:没加
.dockerignore,本地node_modules被复制进镜像,覆盖了容器内npm install装的依赖。Mac/Windows 的二进制进了 Linux 容器,跑起来各种奇怪报错。务必忽略依赖目录。
构建缓存优化
Docker 构建有缓存机制:如果某层的输入没变,直接复用上次的结果。但缓存一旦失效,后面所有层都得重建。
核心原则:把变化频率低的放前面,变化频率高的放后面。
来看个写法对比。坏写法:
# 烂:COPY 整个目录在前,源码一改依赖就得重装
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN yarn install --production
CMD ["node", "src/index.js"]
每次改一行代码,COPY . . 这层就变了,后面的 yarn install 必然重跑,装一遍依赖十几秒就没了。
好写法:先把依赖描述文件拷进去,装依赖,再拷源码:
# 好:依赖文件单独拷,源码改动不影响依赖层
FROM node:22-alpine
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --production
COPY . .
CMD ["node", "src/index.js"]
只有 package.json 变了才会重装依赖。日常改代码只触发最后一个 COPY,构建秒级完成。
Tip这个模式适用所有语言:Python 的
requirements.txt、Go 的go.mod、Java 的pom.xml,都是「先拷依赖描述文件 -> 装依赖 -> 再拷源码」。记住这个套路,缓存命中率能到 90% 以上。
配套加个 .dockerignore 排除 node_modules,效果最好。
非 root 运行
默认情况下,容器内进程以 root 身份跑。如果应用有漏洞被攻破,攻击者拿到了容器内的 root 权限,进一步逃逸到宿主机就麻烦了。
最佳实践:创建普通用户,用 USER 切过去。
FROM node:22-alpine
WORKDIR /app
# 创建一个系统用户和组
RUN addgroup -S app && adduser -S app -G app
COPY --chown=app:app package.json yarn.lock ./
RUN yarn install --production
COPY --chown=app:app . .
USER app
CMD ["node", "src/index.js"]
几个要点:
- 用
adduser -S创建系统用户(无 home 目录、无登录 shell),最小化。 COPY --chown=app:app把文件所有者改成 app 用户,否则 root 拥有的文件 app 用户可能读不了。USER app放在COPY之后、CMD之前,确保后续运行时是 app 身份。
Note很多官方镜像已经内置了普通用户。比如
node镜像有node用户,python镜像较新版本也有。直接USER node即可,不用自己创建。看镜像文档的「Image Variants」部分。
如果挂载了卷,要注意卷的文件权限:宿主机写出来的文件通常是宿主机用户所有,容器内 app 用户可能读写不了。这是非 root 运行最常见的坑。
多阶段构建
前面几招是「优化单阶段」,多阶段构建是「釜底抽薪」—把构建环境和运行环境彻底分开。
编译型语言尤其需要。Java 项目要 JDK 编译,但运行只要 JRE;Go 项目编译完是个二进制,运行时啥依赖都不用。
来看个 Go 的例子:
# 阶段 1:构建
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp .
# 阶段 2:运行
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/myapp .
USER 65532:65532
CMD ["./myapp"]
要点:
- 用
AS builder给阶段起名。 - 第二个
FROM开始新阶段,前面阶段的层全部丢弃,不进最终镜像。 COPY --from=builder从构建阶段拷产物。- 最终镜像只有 alpine + 一个二进制,可能才 20MB。
Tip最终阶段可以选更小的基础镜像。Go 编译出静态二进制,甚至能
FROM scratch(空镜像),最终镜像就一个文件大小。极致精简。
解释型语言也能用。比如前端项目:Node 阶段编译 React,最终镜像只放 nginx 和编译后的静态文件:
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
最终镜像里没有 Node、没有源码,只有 nginx 和编译产物,体积小、攻击面小。
镜像扫描:docker scout
镜像构建完,还要检查有没有已知漏洞。官方工具是 docker scout(注意不是老的 docker scan)。
Warning老教程里常见的
docker scan命令在 2023 年就已废弃,v29 里不要再用。统一用docker scout。
扫一个本地镜像:
docker scout cves my-app:latest
输出类似:
✓ 0C ✓ 0H ⚠ 2M × 4L ✓ 0?
✗ 2M [CVE-2024-12345] libxml2
Inherited from base image python:3.12-slim
Fixed in: 2.12.5-r1
✗ 1L [CVE-2024-67890] openssl
✓ Fixed version available
报告按严重程度分级(Critical / High / Medium / Low),并给出修复版本。
几个常用命令:
# 对比两个镜像版本的漏洞差异
docker scout compare --to my-app:v1 my-app:v2
# 查看某个镜像的完整 SBOM
docker scout sbom my-app:latest
# 推送时自动扫描(Hub 集成)
docker scout push my-app:latest
Tip扫描结果里「Inherited from base image」很关键—很多漏洞来自基础镜像,自己写的代码没问题。这种情况换基础镜像版本(如
python:3.12-slim->python:3.13-slim)就能修掉一大半。
Docker Hub 也能配置成镜像推送后自动扫描,在网页端看报告。
最佳实践速查表
把这章的招数汇总,写 Dockerfile 时对照检查:
| 实践 | 怎么做 | 收益 |
|---|---|---|
| 最小基础镜像 | 用 slim 或 alpine,不用完整版 | 体积减小 5-10 倍 |
| 合并 RUN 层 | && 串命令,同层清理缓存 | 减少中间产物 |
.dockerignore | 排除依赖、.git、日志、构建产物 | 上下文小、缓存稳 |
| 缓存优化 | 先拷依赖描述文件,再拷源码 | 改代码不重装依赖 |
| 非 root 运行 | 创建普通用户,USER 切换 | 安全性提升 |
| 多阶段构建 | AS builder + COPY --from | 最终镜像只含运行时 |
| 镜像扫描 | docker scout cves | 发现已知漏洞 |
一个完整的优秀 Dockerfile
把上面的招数全用上,写一个生产可用的 Dockerfile:
# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile
COPY . .
FROM node:22-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
COPY --chown=app:app package.json ./
COPY --chown=app:app . .
USER app
EXPOSE 3000
CMD ["node", "src/index.js"]
逐行看亮点:
# syntax=...启用 BuildKit 新语法(v29 默认 BuildKit,但加上显式)。- 多阶段:builder 阶段装依赖,最终阶段不带 dev 依赖。
--frozen-lockfile强制用 lockfile,CI 友好。- 最终镜像用 alpine,创建 app 用户,
USER app切换。 COPY --chown保证文件所有者正确。
Note注意只把
node_modules从 builder 拷过来,不重新装。这样最终镜像里没有 devDependencies,体积更小、攻击面更小。
小结
构建好镜像的思路就三条:小(最小基础镜像 + 合并层 + 多阶段)、快(.dockerignore + 缓存优化)、安全(非 root + 扫描)。这三条做到,镜像质量就超过大多数项目了。
下一章是命令速查和资源汇总,把整本书用到的命令整理成表格,再给一些继续学习的资源。