首页 / Docker 入门教程 / 镜像构建最佳实践

Docker 入门教程

镜像构建最佳实践

本教程共 25 篇 · 第 24 篇 · 更新于 2026-07-26 · 约 8 分钟阅读

DockerDocker 入门教程Dockerfile镜像构建最佳实践构建缓存多阶段构建镜像安全

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 层

每个 RUNCOPYADD 都会产生一层。层多了镜像就大,因为中间产物(如 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 里最后清理缓存、下载文件、临时目录。
  • \ 换行,保持可读性。
Note

v29 默认启用 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 类似。它的作用有两个:

  1. 减小构建上下文:不发无关文件,构建更快。
  2. 避免缓存失效:源码改动如果波及被忽略文件,不会触发 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 时对照检查:

实践怎么做收益
最小基础镜像slimalpine,不用完整版体积减小 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 + 扫描)。这三条做到,镜像质量就超过大多数项目了。

下一章是命令速查和资源汇总,把整本书用到的命令整理成表格,再给一些继续学习的资源。