Dockerfile 深度实践:从构建原理到镜像优化、缓存优化与生产级落地

欢迎你来读这篇博客。

这篇文章专门讲 Dockerfile

前面的 Docker 基础文章更多关注容器、镜像、网络、数据卷、端口映射、常见部署这些内容;而 Dockerfile 是镜像构建的核心。一个 Dockerfile 写得好不好,会直接影响:

  • 镜像体积
  • 构建速度
  • 构建缓存命中率
  • 线上启动速度
  • 安全风险
  • 部署稳定性
  • CI/CD 流水线效率
  • 团队后续维护成本

很多人写 Dockerfile 的第一阶段是:

能跑就行。

第二阶段是:

镜像怎么这么大?

第三阶段是:

为什么每次构建都这么慢?

第四阶段是:

为什么线上容器停不掉、日志爆了、密码泄露了、构建缓存又失效了?

这篇文章的目标就是把这些坑一次性讲清楚。

一、Dockerfile 到底是什么?

Dockerfile 是一个文本文件,用来描述如何构建 Docker 镜像。

它不是 shell 脚本,但里面包含很多类似 shell 的构建步骤。Docker 会按照 Dockerfile 中的指令,一步一步构建出镜像。

一个最简单的 Java 应用 Dockerfile 可能是这样:

1
2
3
4
5
6
7
8
9
FROM eclipse-temurin:17-jre

WORKDIR /app

COPY target/app.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

构建命令:

1
docker build -t demo-app:1.0.0 .

运行命令:

1
docker run -d --name demo-app -p 8080:8080 demo-app:1.0.0

从表面看,Dockerfile 就几行内容。但它背后的东西不少:

flowchart LR
    A[源码与构建上下文] --> B[docker build]
    B --> C[读取 Dockerfile]
    C --> D[执行每条指令]
    D --> E[生成镜像层 Layer]
    E --> F[组合成最终镜像]
    F --> G[docker run]
    G --> H[容器运行实例]

要写好 Dockerfile,先记住一个核心认知:

Dockerfile 不是部署脚本,而是镜像构建说明书。

也就是说,它的目标不是“在服务器上装一遍环境”,而是“构建一个可复现、可分发、可运行的镜像”。


二、Dockerfile 的核心心智模型

很多 Dockerfile 写得烂,不是因为命令不会,而是因为心智模型错了。

写 Dockerfile 必须理解下面几个概念。

1. 构建上下文 Build Context

当你执行:

1
docker build -t demo-app:1.0.0 .

最后那个 . 表示当前目录会作为 build context 发送给 Docker 构建器。

Dockerfile 中的:

1
COPY target/app.jar app.jar

只能复制 build context 里的文件。

例如目录结构如下:

1
2
3
4
5
demo/
├── Dockerfile
├── target/
│ └── app.jar
└── README.md

demo 目录执行:

1
docker build -t demo-app:1.0.0 .

此时 Dockerfile 可以访问 target/app.jar

但如果你写:

1
COPY ../app.jar app.jar

通常是不行的,因为 ../app.jar 不在 build context 里面。

构建上下文越大,构建越慢,缓存越容易失效。所以 .dockerignore 非常重要。

2. 镜像层 Layer

Docker 镜像不是一个单独的大文件,而是由多层组成。

一般来说:

  • FROM 会确定基础镜像层
  • RUN 会产生新的文件系统层
  • COPYADD 会产生新的文件系统层
  • CMDENTRYPOINTENVEXPOSE 等更多是修改镜像配置元数据

示意图:

flowchart TB
    L1[基础镜像层: debian / alpine / ubuntu] --> L2[安装运行时: JRE / Node / Nginx]
    L2 --> L3[复制依赖文件]
    L3 --> L4[复制应用文件]
    L4 --> L5[配置环境变量与启动命令]
    L5 --> IMG[最终镜像]

镜像层有两个重要特性:

  1. 层可以复用
  2. 层一旦创建,后续删除不一定真正减少历史层体积

例如:

1
2
3
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

这看起来最后删除了 apt 缓存,但缓存可能已经存在于前面的 layer 中。

更好的写法是:

1
2
3
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*

因为安装和清理发生在同一层里。

3. 构建缓存 Build Cache

Docker 构建时会尽量复用之前构建过的 layer。

假设 Dockerfile:

1
2
3
4
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY target/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

如果 target/app.jar 没变,那么 COPY target/app.jar app.jar 这一层可能复用缓存。

如果 target/app.jar 变了,这一层和后续层都会重新构建。

缓存规则可以简化理解为:

前面的层没变,后面的层才可能复用;前面某层变了,后面基本都要重新来。

所以 Dockerfile 的指令顺序非常重要。

4. 构建阶段 Build Stage

多阶段构建允许一个 Dockerfile 里出现多个 FROM

例如:

1
2
3
4
5
6
7
8
9
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY . .
RUN mvn clean package -DskipTests

FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

第一个阶段负责构建,第二个阶段负责运行。

最终镜像只包含第二个阶段中的内容,不包含 Maven、不包含源码、不包含构建缓存。


三、Dockerfile 指令总览

常见指令如下:

指令 作用 是否常用 优化重点
FROM 指定基础镜像,开始一个构建阶段 基础镜像体积、安全、版本固定
RUN 构建时执行命令 缓存、层数、清理无用文件
COPY 从构建上下文复制文件 精准复制,避免全量复制
ADD 类似 COPY,额外支持 URL 和自动解压 偶尔 默认优先 COPY
WORKDIR 设置工作目录 替代大量 cd
ENV 设置环境变量 不要存敏感信息
ARG 设置构建参数 用于版本、构建开关
EXPOSE 声明容器监听端口 只声明,不等于发布端口
CMD 默认命令或参数 与 ENTRYPOINT 配合
ENTRYPOINT 容器入口命令 推荐 exec 格式
USER 指定运行用户 强烈建议 避免 root 运行
HEALTHCHECK 容器健康检查 建议 提高可观测性
LABEL 镜像元数据 建议 版本、作者、源码地址
VOLUME 声明挂载点 谨慎 生产镜像不建议乱写
SHELL 修改默认 shell 少量 Windows 或特殊 shell 场景
STOPSIGNAL 指定停止信号 偶尔 优雅停机场景

四、FROM:基础镜像选择

FROM 是 Dockerfile 的第一块地基。

1
FROM eclipse-temurin:17-jre

选基础镜像时要考虑:

  • 体积
  • 安全漏洞数量
  • 运行时依赖是否完整
  • 架构支持,例如 amd64arm64
  • 维护频率
  • 是否官方或可信发布者
  • 与应用技术栈是否匹配

1. 常见基础镜像类型

镜像类型 示例 优点 缺点
完整操作系统镜像 ubuntudebian 兼容性好 体积较大
精简系统镜像 debian:bookworm-slim 兼顾兼容性和体积 工具较少
Alpine 镜像 alpine 很小 musl libc 兼容性问题
语言运行时镜像 nodepythoneclipse-temurin 使用方便 可能偏大
distroless 镜像 gcr.io/distroless/java17 小、安全面低 调试困难
scratch scratch 极小 只适合静态二进制等场景

2. 不要盲目追求 Alpine

很多人会觉得 Alpine 小,所以所有镜像都用 Alpine。

这不总是对的。

Alpine 使用 musl libc,而很多常见 Linux 发行版使用 glibc。部分 Java、Python、Node 原生依赖、字体、图像处理、DNS、时区、本地化相关行为可能会出现差异。

建议:

  • Go 静态二进制:可以考虑 Alpine 或 scratch
  • Java 服务:优先考虑 eclipse-temurin:17-jreeclipse-temurin:17-jre-alpine 需充分测试
  • Python 有 native 依赖:谨慎使用 Alpine
  • Node 有 native module:谨慎使用 Alpine
  • 生产稳定优先:slim 类镜像通常更稳

3. 不要在生产中长期使用 latest

不推荐:

1
FROM node:latest

推荐:

1
FROM node:20-bookworm-slim

或者:

1
FROM eclipse-temurin:17.0.11_9-jre

更严格可以固定 digest:

1
FROM eclipse-temurin:17-jre@sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

优点:

  • 构建可复现
  • 避免上游 latest 变化导致线上构建不一致
  • 便于安全审计和版本回滚

缺点:

  • digest 固定后需要主动升级基础镜像
  • 维护成本更高

所以实际可以分层处理:

  • 开发环境:可以用较宽松 tag
  • 测试/预发:固定主版本或小版本
  • 生产/合规场景:固定 digest

五、WORKDIR:别在 RUN 里到处 cd

不推荐:

1
2
3
RUN mkdir -p /app
RUN cd /app && echo hello > a.txt
COPY target/app.jar /app/app.jar

推荐:

1
2
3
4
WORKDIR /app

RUN echo hello > a.txt
COPY target/app.jar app.jar

WORKDIR 的好处:

  • 可读性更好
  • 后续 RUNCOPYCMDENTRYPOINT 默认都基于该目录
  • 避免一堆 cd /xxx && ...

如果目录不存在,WORKDIR 会创建目录。

推荐始终显式设置:

1
WORKDIR /app

不要依赖基础镜像默认工作目录。


六、COPY 和 ADD:优先 COPY,谨慎 ADD

1. COPY

COPY 用于把构建上下文中的文件复制到镜像中。

1
COPY target/app.jar app.jar

也可以复制目录:

1
COPY config/ /app/config/

2. ADD

ADD 功能更多:

  • 可以从 URL 添加文件
  • 可以自动解压本地 tar 包

例如:

1
ADD app.tar.gz /app/

但也正因为它功能多,语义不够直接。大多数情况下推荐使用 COPY

3. COPY 的优化原则

原则一:不要一上来 COPY 全部

不推荐:

1
2
COPY . .
RUN mvn clean package -DskipTests

这样只要项目里任何文件变化,都会导致后续构建缓存失效。

推荐:

1
2
3
4
5
COPY pom.xml .
RUN mvn dependency:go-offline

COPY src ./src
RUN mvn clean package -DskipTests

这样如果只是源码变化,依赖下载层可以复用。

原则二:只复制运行需要的文件

运行阶段不应该复制源码:

1
2
# 不推荐
COPY . .

运行阶段更应该:

1
COPY --from=builder /build/target/app.jar app.jar

原则三:合理使用 --chown

如果最终使用非 root 用户运行,可以在复制文件时直接设置 owner。

1
COPY --chown=app:app target/app.jar app.jar

这样可以避免额外的:

1
RUN chown -R app:app /app

4. COPY 和缓存

COPY 会参与缓存校验。被复制文件的内容或相关元数据变化,会导致该层缓存失效。

所以:

  • 高频变化的文件尽量放后面复制
  • 低频变化的依赖声明文件放前面复制
  • 大文件和无关文件不要进入 build context

七、RUN:构建时执行命令

RUN 在镜像构建阶段执行。

例如:

1
2
3
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*

1. RUN 的常见问题

问题一:拆太多层

不推荐:

1
2
3
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

推荐:

1
2
3
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*

问题二:安装推荐包导致镜像膨胀

不推荐:

1
RUN apt-get install -y curl

推荐:

1
2
3
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*

问题三:构建依赖进入运行镜像

不推荐:

1
2
3
4
5
6
7
8
FROM node:20

WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

CMD ["npm", "run", "start"]

如果只是前端静态资源,最终不应该包含 Node、npm、源码和 node_modules。

推荐多阶段构建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FROM node:20 AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:stable-alpine

COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

2. RUN 的 shell 格式和 exec 格式

shell 格式:

1
RUN echo hello

等价于:

1
RUN /bin/sh -c "echo hello"

exec 格式:

1
RUN ["echo", "hello"]

多数 Linux 镜像中,RUN 常用 shell 格式,因为方便写管道、变量和多命令。


八、ARG 和 ENV:构建参数与运行环境变量

1. ARG

ARG 是构建时参数。

1
2
3
ARG APP_VERSION=1.0.0

RUN echo "build version: ${APP_VERSION}"

构建时传入:

1
docker build --build-arg APP_VERSION=1.2.0 -t demo-app:1.2.0 .

ARG 适合:

  • 版本号
  • 下载地址
  • 构建开关
  • 是否跳过测试
  • 构建环境标识

2. ENV

ENV 是镜像环境变量,容器运行时也会存在。

1
ENV JAVA_OPTS="-Xms256m -Xmx512m"

运行时可以覆盖:

1
docker run -e JAVA_OPTS="-Xms512m -Xmx1024m" demo-app:1.0.0

3. ARG 和 ENV 的区别

对比项 ARG ENV
生效阶段 构建阶段 构建阶段和运行阶段
容器运行时是否存在 通常不存在 存在
是否适合敏感信息 不适合 不适合
典型用途 构建版本、构建开关 应用运行参数

注意:

不要把密码、Token、私钥写进 ARG 或 ENV。

因为它们可能出现在镜像历史、构建日志或运行环境中。

错误示例:

1
2
ARG GIT_TOKEN=xxxxx
ENV DB_PASSWORD=123456

正确思路:

  • 构建阶段需要密钥:使用 BuildKit secret mount
  • 运行阶段需要密钥:运行时注入、配置中心、密钥管理系统

九、EXPOSE:只是声明端口,不是端口映射

Dockerfile 中:

1
EXPOSE 8080

只是说明这个镜像内的应用会监听 8080 端口。

它不会自动让宿主机访问容器端口。

真正端口映射要在运行时做:

1
docker run -p 8080:8080 demo-app:1.0.0

可以这么理解:

flowchart LR
    A[Dockerfile EXPOSE 8080] --> B[镜像元数据声明]
    C[docker run -p 8080:8080] --> D[真正发布端口]

推荐写 EXPOSE,因为它是镜像文档的一部分。但不要误以为写了它就能访问服务。


十、CMD 和 ENTRYPOINT:容器启动命令

这两个指令很容易混。

1. CMD

CMD 表示容器默认命令。

1
CMD ["java", "-jar", "app.jar"]

运行时可以覆盖:

1
docker run demo-app:1.0.0 echo hello

这时 echo hello 会替代原来的 CMD。

2. ENTRYPOINT

ENTRYPOINT 表示容器入口程序。

1
ENTRYPOINT ["java", "-jar", "app.jar"]

运行时追加的参数会接在 ENTRYPOINT 后面。

3. 推荐使用 exec 格式

推荐:

1
ENTRYPOINT ["java", "-jar", "app.jar"]

不推荐:

1
ENTRYPOINT java -jar app.jar

原因是 shell 格式会启动一个 shell 作为 PID 1,信号转发可能不如 exec 格式直接,影响容器优雅停止。

4. ENTRYPOINT + CMD 组合

可以把 ENTRYPOINT 当固定命令,把 CMD 当默认参数。

1
2
ENTRYPOINT ["java", "-jar", "app.jar"]
CMD ["--spring.profiles.active=prod"]

运行时覆盖参数:

1
docker run demo-app:1.0.0 --spring.profiles.active=test

5. Java 应用支持 JAVA_OPTS 的写法

如果想用环境变量控制 JVM 参数,经常会写:

1
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

这确实方便,但它用了 shell。

更强一点的做法是写一个入口脚本,并在脚本最后使用 exec

1
2
3
4
#!/usr/bin/env sh
set -e

exec java ${JAVA_OPTS} -jar /app/app.jar "$@"

Dockerfile:

1
2
3
4
5
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

ENTRYPOINT ["docker-entrypoint.sh"]
CMD []

这样既支持环境变量,又能让 Java 进程接收信号。


十一、USER:不要用 root 跑应用

默认情况下,很多镜像里容器进程是 root 用户。

这不推荐。

更好的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
FROM eclipse-temurin:17-jre

RUN groupadd -r app && useradd -r -g app app

WORKDIR /app

COPY --chown=app:app target/app.jar app.jar

USER app

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

如果是 Alpine:

1
RUN addgroup -S app && adduser -S app -G app

为什么要用非 root?

  • 降低容器逃逸后的破坏面
  • 降低误删系统文件的风险
  • 更符合最小权限原则
  • 生产安全扫描更容易通过

注意:如果应用需要写目录,必须确保目录权限正确。

1
2
RUN mkdir -p /app/logs /app/tmp \
&& chown -R app:app /app

十二、HEALTHCHECK:让容器状态更可观测

没有健康检查时,容器只要主进程没退出,Docker 就认为它还活着。

但这不代表服务真的可用。

比如:

  • Java 进程在,但数据库连接池挂了
  • Nginx 进程在,但配置错误
  • 应用启动了,但健康接口返回 500
  • 服务死锁了,但进程还没退出

可以加健康检查:

1
2
HEALTHCHECK --interval=30s --timeout=3s --retries=3 --start-period=20s \
CMD curl -f http://localhost:8080/actuator/health || exit 1

查看:

1
2
docker ps
docker inspect demo-app | grep -A 30 Health

问题是:很多精简镜像里没有 curl

可以选择:

  1. 安装 curl/wget
  2. 应用自身提供轻量健康检查命令
  3. 在编排层做健康检查
  4. 使用 shell 的 /dev/tcp,但可移植性一般

如果安装 curl:

1
2
3
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*

但这会增加镜像体积。生产上要权衡。


十三、LABEL:镜像元数据

LABEL 可以给镜像添加元数据。

1
2
3
4
5
LABEL org.opencontainers.image.title="demo-app" \
org.opencontainers.image.description="Spring Boot demo application" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.source="https://git.example.com/backend/demo-app" \
org.opencontainers.image.authors="team@example.com"

常见用途:

  • 标记版本
  • 标记源码仓库
  • 标记构建时间
  • 标记维护者
  • 配合镜像扫描、SBOM、制品治理系统

可以查看:

1
docker inspect demo-app:1.0.0

十四、VOLUME:为什么生产镜像里要谨慎写

Dockerfile 可以写:

1
VOLUME ["/data"]

它表示该路径会作为挂载点。

但生产镜像里不建议随便写 VOLUME

原因:

  1. 它可能创建匿名卷
  2. 匿名卷不容易管理
  3. 后续在 Dockerfile 中对该目录写入可能不符合预期
  4. 数据持久化应该由运行部署层明确声明

比如数据库官方镜像会声明数据目录,这是合理的。
但普通业务应用镜像里,除非非常明确,否则可以不写 VOLUME

更推荐在运行时显式挂载:

1
docker run -v app-data:/app/data demo-app:1.0.0

十五、STOPSIGNAL:优雅停机相关

容器停止时,Docker 默认会给容器主进程发送 SIGTERM,等待一段时间后再发送 SIGKILL

可以通过 Dockerfile 指定停止信号:

1
STOPSIGNAL SIGTERM

大多数 Linux 服务默认 SIGTERM 即可。

更关键的是:

  • 应用进程必须是容器的主进程
  • ENTRYPOINT 推荐 exec 格式
  • Java/Spring Boot 要正确处理优雅停机
  • 不要让 shell 吞掉信号

Spring Boot 可以配置:

1
2
3
4
5
6
server:
shutdown: graceful

spring:
lifecycle:
timeout-per-shutdown-phase: 30s

Dockerfile 层面负责信号可达,应用层面负责优雅退出。


十六、.dockerignore:被严重低估的优化点

.dockerignore 用来排除不需要发送到构建上下文的文件。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.git
.idea
.vscode
*.log
*.tmp
.DS_Store

target/classes
target/test-classes
target/surefire-reports

node_modules
dist
coverage

.env
.env.*
*.pem
*.key

1. 为什么它重要?

因为执行:

1
docker build -t demo-app:1.0.0 .

时,当前目录作为 build context 参与构建。

如果你不写 .dockerignore,可能会把这些东西都发给构建器:

  • .git
  • IDE 配置
  • 本地日志
  • 测试报告
  • node_modules
  • 临时文件
  • 密钥文件
  • 大型数据文件

结果就是:

  • 构建变慢
  • 缓存更容易失效
  • 镜像可能泄露敏感信息
  • CI/CD 流水线浪费时间

2. 常见错误

错误示例:

1
COPY . .

然后项目中没有 .dockerignore

这等于把整个项目目录一股脑塞进去。
如果目录里有 .env、私钥、日志、测试数据,恭喜你,镜像变成了“赛博垃圾桶”。

3. Java 项目 .dockerignore 推荐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.git
.idea
.vscode
*.iml
*.log
.DS_Store

target/classes
target/generated-sources
target/generated-test-sources
target/maven-status
target/surefire-reports
target/test-classes

.env
.env.*
*.pem
*.key

如果是先在宿主机构建 jar,再 Docker build,可以保留:

1
2
target/*
!target/*.jar

4. Node 项目 .dockerignore 推荐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.git
.idea
.vscode
*.log
.DS_Store

node_modules
dist
coverage

.env
.env.*
*.pem
*.key

十七、Docker 构建缓存深度优化

Dockerfile 优化里,缓存是核心。

1. 基本规则

Docker 构建缓存可以简化理解为:

flowchart TB
    A[FROM 是否命中缓存] --> B[第一条指令是否命中缓存]
    B --> C[第二条指令是否命中缓存]
    C --> D[第三条指令是否命中缓存]
    D --> E[后续层继续判断]
    C -->|某层失效| F[该层之后全部重建]

一旦某一层缓存失效,后续层通常都要重新构建。

2. 指令顺序原则

核心原则:

低频变化的内容放前面,高频变化的内容放后面。

例如 Java Maven 项目:

不推荐:

1
2
COPY . .
RUN mvn clean package -DskipTests

推荐:

1
2
3
4
5
COPY pom.xml .
RUN mvn dependency:go-offline

COPY src ./src
RUN mvn clean package -DskipTests

因为:

  • pom.xml 变化频率低
  • src 变化频率高
  • 依赖下载很耗时
  • 源码变化不应该导致依赖重新下载

3. Node 项目缓存优化

不推荐:

1
2
3
COPY . .
RUN npm install
RUN npm run build

推荐:

1
2
3
4
5
COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

只要 package-lock.json 不变,npm ci 层可以复用。

4. Python 项目缓存优化

不推荐:

1
2
COPY . .
RUN pip install -r requirements.txt

推荐:

1
2
3
4
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

5. 缓存失效的常见来源

来源 说明
COPY . . 项目任意文件变化都可能导致缓存失效
未写 .dockerignore 日志、临时文件变化导致缓存失效
依赖和源码一起复制 源码变化导致依赖层重建
RUN apt-get update 单独一层 可能复用旧缓存,导致包索引不符合预期
构建参数变化 ARG 被使用后会影响后续缓存
基础镜像变化 FROM 变化导致整体重建

十八、BuildKit:现代 Dockerfile 优化能力

BuildKit 是 Docker 现代构建后端,提供更强的缓存、并行构建、secret、ssh mount、多平台构建等能力。

建议 Dockerfile 顶部加:

1
# syntax=docker/dockerfile:1

这表示使用 Dockerfile frontend 的语法版本。

1. 启用 BuildKit

较新 Docker 版本一般默认启用。也可以显式:

1
DOCKER_BUILDKIT=1 docker build -t demo-app:1.0.0 .

使用 buildx:

1
docker buildx build -t demo-app:1.0.0 .

2. cache mount:依赖缓存优化神器

传统写法里,依赖下载可能被 layer cache 影响。BuildKit 的 cache mount 可以提供一个跨构建复用的缓存目录。

Maven 缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# syntax=docker/dockerfile:1

FROM maven:3.9-eclipse-temurin-17 AS builder

WORKDIR /build

COPY pom.xml .

RUN --mount=type=cache,target=/root/.m2 \
mvn -B dependency:go-offline

COPY src ./src

RUN --mount=type=cache,target=/root/.m2 \
mvn -B clean package -DskipTests

这样 Maven 本地仓库 /root/.m2 可以在多次构建间复用。

npm 缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# syntax=docker/dockerfile:1

FROM node:20 AS builder

WORKDIR /app

COPY package.json package-lock.json ./

RUN --mount=type=cache,target=/root/.npm \
npm ci

COPY . .

RUN npm run build

apt 缓存

1
2
3
4
5
6
# syntax=docker/dockerfile:1

RUN --mount=type=cache,target=/var/cache/apt \
--mount=type=cache,target=/var/lib/apt \
apt-get update \
&& apt-get install -y --no-install-recommends curl

注意:不同发行版、不同包管理器的缓存目录不一样,使用前要确认。

3. secret mount:不要把密钥写进镜像

错误写法:

1
2
ARG TOKEN
RUN curl -H "Authorization: Bearer ${TOKEN}" https://example.com/private.tar.gz

这个 token 可能进入构建历史或日志。

BuildKit 推荐:

构建:

1
2
3
docker build \
--secret id=api_token,src=./api_token.txt \
-t demo-app:1.0.0 .

Dockerfile:

1
2
3
4
5
# syntax=docker/dockerfile:1

RUN --mount=type=secret,id=api_token \
TOKEN="$(cat /run/secrets/api_token)" \
&& curl -H "Authorization: Bearer ${TOKEN}" https://example.com/private.tar.gz -o private.tar.gz

secret mount 的特点:

  • 只在该 RUN 指令执行期间可见
  • 不会持久化到镜像层
  • 不应该出现在最终镜像里

4. ssh mount:拉取私有 Git 仓库

构建:

1
2
3
docker buildx build \
--ssh default \
-t demo-app:1.0.0 .

Dockerfile:

1
2
3
4
# syntax=docker/dockerfile:1

RUN --mount=type=ssh \
git clone git@github.com:example/private-repo.git

5. bind mount:构建时临时挂载源码

BuildKit 支持构建时 bind mount,适合某些只需要参与构建但不想进入镜像层和缓存的内容。

1
2
3
4
# syntax=docker/dockerfile:1

RUN --mount=type=bind,target=/src \
cp /src/some-file /app/some-file

多数业务场景用 COPY 足够,bind mount 属于高级优化。

6. external cache:CI/CD 加速

CI/CD 机器经常是临时环境,本地缓存没法长期保留。可以使用 registry cache。

1
2
3
4
5
docker buildx build \
--cache-from=type=registry,ref=registry.example.com/demo-app:buildcache \
--cache-to=type=registry,ref=registry.example.com/demo-app:buildcache,mode=max \
-t registry.example.com/demo-app:1.0.0 \
--push .

这样不同 CI 构建任务之间可以复用远程缓存。


十九、多阶段构建深入

多阶段构建不是“高级技巧”,而是生产 Dockerfile 的基本功。

1. 为什么要多阶段?

以 Java 项目为例,构建需要:

  • Maven
  • JDK
  • 源码
  • 测试依赖
  • 编译缓存

运行只需要:

  • JRE
  • app.jar
  • 配置
  • 必要证书/字体/时区

如果把构建环境也放进最终镜像,会导致:

  • 镜像体积大
  • 安全面变大
  • 源码泄露
  • 构建依赖污染运行环境

2. 标准多阶段结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# syntax=docker/dockerfile:1

FROM maven:3.9-eclipse-temurin-17 AS deps
WORKDIR /build
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 \
mvn -B dependency:go-offline

FROM deps AS builder
WORKDIR /build
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
mvn -B clean package -DskipTests

FROM eclipse-temurin:17-jre AS runtime
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

这里分了三个阶段:

阶段 作用
deps 下载依赖
builder 编译打包
runtime 最终运行

3. 命名阶段

推荐:

1
FROM maven:3.9-eclipse-temurin-17 AS builder

然后:

1
COPY --from=builder /build/target/*.jar app.jar

不要用:

1
COPY --from=0 /build/target/*.jar app.jar

因为阶段顺序一改,数字就可能错。

4. 只构建某个阶段

调试时可以:

1
docker build --target builder -t demo-app:builder .

这对排查构建阶段问题非常有用。

5. debug stage 与 production stage

可以在一个 Dockerfile 里提供 debug 和 production 两种目标。

1
2
3
4
5
6
7
8
9
10
11
12
13
FROM eclipse-temurin:17-jre AS base
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar

FROM base AS debug
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl vim procps \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["java", "-jar", "app.jar"]

FROM base AS production
USER app
ENTRYPOINT ["java", "-jar", "app.jar"]

构建 debug 镜像:

1
docker build --target debug -t demo-app:debug .

构建生产镜像:

1
docker build --target production -t demo-app:1.0.0 .

二十、Java / Spring Boot Dockerfile 深度优化

Java 是企业后端里非常常见的 Docker 化对象。这里重点讲 Spring Boot。

1. 最粗糙写法

1
2
3
4
5
6
7
8
9
FROM openjdk:17

WORKDIR /app

COPY target/app.jar app.jar

EXPOSE 8080

CMD java -jar app.jar

问题:

  • 基础镜像可能偏大
  • 没有固定版本
  • 使用 shell 格式 CMD
  • 没有非 root 用户
  • 没有 JVM 参数入口
  • 没有健康检查
  • 没有镜像元数据
  • 没有利用 Spring Boot 分层 jar
  • 没有优雅停机考虑

2. 基础生产写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FROM eclipse-temurin:17-jre

RUN groupadd -r app && useradd -r -g app app

WORKDIR /app

COPY --chown=app:app target/app.jar app.jar

USER app

EXPOSE 8080

ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0"

ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]

这里已经做了几件事:

  • 使用 JRE 而不是完整 JDK
  • 非 root 用户运行
  • 支持 JVM 参数
  • 使用 exec 转发信号
  • 声明端口

3. Maven 多阶段构建版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# syntax=docker/dockerfile:1

FROM maven:3.9-eclipse-temurin-17 AS builder

WORKDIR /build

COPY pom.xml .

RUN --mount=type=cache,target=/root/.m2 \
mvn -B dependency:go-offline

COPY src ./src

RUN --mount=type=cache,target=/root/.m2 \
mvn -B clean package -DskipTests

FROM eclipse-temurin:17-jre

RUN groupadd -r app && useradd -r -g app app

WORKDIR /app

COPY --from=builder --chown=app:app /build/target/*.jar app.jar

USER app

EXPOSE 8080

ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"

ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]

4. Spring Boot Layered Jar 优化

Spring Boot 支持 layered jar,可以把 jar 拆成不同层:

  • dependencies
  • spring-boot-loader
  • snapshot-dependencies
  • application

这样应用代码变化时,不必让依赖层全部失效。

Maven 插件配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
</plugins>
</build>

Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# syntax=docker/dockerfile:1

FROM maven:3.9-eclipse-temurin-17 AS builder

WORKDIR /build

COPY pom.xml .

RUN --mount=type=cache,target=/root/.m2 \
mvn -B dependency:go-offline

COPY src ./src

RUN --mount=type=cache,target=/root/.m2 \
mvn -B clean package -DskipTests

RUN mkdir -p target/extracted \
&& java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted

FROM eclipse-temurin:17-jre

RUN groupadd -r app && useradd -r -g app app

WORKDIR /app

COPY --from=builder --chown=app:app /build/target/extracted/dependencies/ ./
COPY --from=builder --chown=app:app /build/target/extracted/spring-boot-loader/ ./
COPY --from=builder --chown=app:app /build/target/extracted/snapshot-dependencies/ ./
COPY --from=builder --chown=app:app /build/target/extracted/application/ ./

USER app

EXPOSE 8080

ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"

ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

优化点:

flowchart TB
    A[dependencies 第三方依赖] --> B[变化频率低,可长期缓存]
    C[snapshot-dependencies 快照依赖] --> D[变化频率中]
    E[spring-boot-loader] --> F[变化频率低]
    G[application 业务代码] --> H[变化频率高,放最后]

这样当你只改业务代码时,依赖层大概率可以复用。

5. Java 容器内存参数建议

不要简单写死:

1
-Xmx512m

如果容器运行时限制内存 512MB,JVM 堆也设置 512MB,就容易 OOM,因为还有:

  • metaspace
  • direct memory
  • thread stack
  • code cache
  • native memory

更容器友好的方式:

1
-XX:MaxRAMPercentage=75.0

例如:

1
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"

运行时再按环境覆盖:

1
2
3
docker run \
-e JAVA_OPTS="-XX:MaxRAMPercentage=70.0 -XX:+UseG1GC" \
demo-app:1.0.0

6. Spring Boot 优雅停机与 Dockerfile

Dockerfile:

1
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]

Spring 配置:

1
2
3
4
5
6
server:
shutdown: graceful

spring:
lifecycle:
timeout-per-shutdown-phase: 30s

运行时:

1
docker stop -t 35 demo-app

含义:

  • Docker 发送 SIGTERM
  • Java 进程收到信号
  • Spring Boot 执行优雅停机
  • 35 秒后仍未退出才强杀

二十一、Node / 前端项目 Dockerfile 优化

1. 错误写法

1
2
3
4
5
6
7
8
9
10
11
FROM node:20

WORKDIR /app

COPY . .

RUN npm install

RUN npm run build

CMD ["npm", "run", "start"]

问题:

  • COPY . . 导致缓存容易失效
  • npm install 不如 npm ci 可复现
  • 最终镜像包含源码和 node_modules
  • 前端静态资源不需要 Node 运行时
  • 镜像大

2. 优化写法:构建后交给 Nginx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# syntax=docker/dockerfile:1

FROM node:20-bookworm-slim AS builder

WORKDIR /app

COPY package.json package-lock.json ./

RUN --mount=type=cache,target=/root/.npm \
npm ci

COPY . .

RUN npm run build

FROM nginx:stable-alpine AS runtime

COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

3. 自定义 Nginx 配置

1
2
3
4
5
6
7
8
FROM nginx:stable-alpine

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

nginx.conf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server {
listen 80;
server_name _;

root /usr/share/nginx/html;
index index.html;

location / {
try_files $uri $uri/ /index.html;
}

location /assets/ {
expires 7d;
add_header Cache-Control "public";
}
}

适合 Vue、React、Vite 等单页应用。


二十二、Go 项目 Dockerfile 优化

Go 项目非常适合多阶段构建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# syntax=docker/dockerfile:1

FROM golang:1.22 AS builder

WORKDIR /src

COPY go.mod go.sum ./

RUN --mount=type=cache,target=/go/pkg/mod \
go mod download

COPY . .

RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -o /out/app ./cmd/app

FROM scratch

COPY --from=builder /out/app /app

EXPOSE 8080

ENTRYPOINT ["/app"]

如果需要 CA 证书:

1
2
3
4
5
6
7
FROM alpine:3.20 AS certs
RUN apk --no-cache add ca-certificates

FROM scratch
COPY --from=builder /out/app /app
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app"]

二十三、Python 项目 Dockerfile 优化

Python 镜像优化重点:

  • 固定 Python 版本
  • 使用 slim 镜像
  • 单独复制 requirements
  • 使用 venv 或安装到系统路径
  • 避免 pip 缓存进入镜像
  • 多阶段处理编译依赖

基础写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FROM python:3.12-slim

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["python", "app.py"]

带构建依赖的多阶段写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# syntax=docker/dockerfile:1

FROM python:3.12-slim AS builder

WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential gcc \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt .

RUN --mount=type=cache,target=/root/.cache/pip \
pip wheel --wheel-dir /wheels -r requirements.txt

FROM python:3.12-slim

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

COPY --from=builder /wheels /wheels
COPY requirements.txt .

RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
&& rm -rf /wheels

COPY . .

EXPOSE 8000

CMD ["python", "app.py"]

二十四、镜像体积优化:不是越小越好,而是刚好够用

1. 查看镜像大小

1
docker images

查看层信息:

1
docker history demo-app:1.0.0

更好用的工具:

1
dive demo-app:1.0.0

dive 可以分析每层文件变化和浪费空间。

2. 常见瘦身手段

手段 效果
使用更小基础镜像 减少初始体积
多阶段构建 去掉构建工具和源码
清理包管理器缓存 减少无用文件
精准 COPY 避免复制无关文件
.dockerignore 减少上下文和泄露
JRE 替代 JDK Java 运行镜像更小
distroless 进一步减少系统组件
删除测试文件和文档 减少产物体积

3. 瘦身反模式

反模式一:为了小盲目 Alpine

小不等于稳定。兼容性问题排查起来很贵。

反模式二:运行镜像里保留构建工具

例如最终镜像里还有:

  • Maven
  • npm
  • gcc
  • make
  • git
  • 源码目录

这基本说明没做好多阶段构建。

反模式三:先复制再删除

1
2
COPY . .
RUN rm -rf test docs .git

如果 .git 已经在上一层复制进镜像,后面删掉不一定能减少历史层体积。应该用 .dockerignore 从源头排除。


二十五、Dockerfile 安全优化

1. 不要把密钥写进镜像

错误:

1
2
3
ENV DB_PASSWORD=123456
ARG GITHUB_TOKEN=xxx
COPY id_rsa /root/.ssh/id_rsa

风险:

  • docker history 可能看到
  • 镜像被推送后泄露
  • CI 日志可能泄露
  • 镜像扫描可能记录

正确:

  • 构建期使用 BuildKit secret
  • 运行期使用环境变量或密钥管理
  • 私钥不要进入镜像层

2. 非 root 用户

1
2
RUN groupadd -r app && useradd -r -g app app
USER app

3. 最小化安装包

1
2
3
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*

4. 使用可信基础镜像

优先:

  • Docker Official Images
  • Verified Publisher 镜像
  • 企业内部维护的基础镜像
  • 定期扫描和更新的镜像

5. 镜像扫描

常见工具:

1
2
docker scout quickview demo-app:1.0.0
docker scout cves demo-app:1.0.0

也可以使用:

1
trivy image demo-app:1.0.0

6. 生成 SBOM

1
docker sbom demo-app:1.0.0

或使用 Syft:

1
syft demo-app:1.0.0

7. 减少运行时能力

有些安全项不在 Dockerfile 里,而是在运行时配置:

1
2
3
4
5
6
docker run \
--read-only \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--tmpfs /tmp \
demo-app:1.0.0

Dockerfile 可以配合:

1
USER app

但运行时安全策略仍要在部署层配置。


二十六、可复现构建优化

可复现构建的目标:

同一份源码、同一份依赖、同一份 Dockerfile,在不同机器构建出尽可能一致的镜像。

1. 固定基础镜像版本

不推荐:

1
FROM node:latest

推荐:

1
FROM node:20-bookworm-slim

更严格:

1
FROM node:20-bookworm-slim@sha256:...

2. 使用 lock 文件

Node:

1
2
COPY package.json package-lock.json ./
RUN npm ci

不要:

1
RUN npm install

Java:

  • 固定 Maven 插件版本
  • 固定依赖版本
  • 使用内部 Maven 仓库代理
  • 避免动态版本,例如 1.0.+

Python:

1
2
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

更严格可以使用 hash pin。

3. 构建时写入版本信息

1
2
3
4
5
6
7
ARG APP_VERSION
ARG GIT_COMMIT
ARG BUILD_TIME

LABEL org.opencontainers.image.version="${APP_VERSION}" \
org.opencontainers.image.revision="${GIT_COMMIT}" \
org.opencontainers.image.created="${BUILD_TIME}"

构建:

1
2
3
4
5
docker build \
--build-arg APP_VERSION=1.0.0 \
--build-arg GIT_COMMIT=$(git rev-parse --short HEAD) \
--build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
-t demo-app:1.0.0 .

二十七、Dockerfile 可维护性优化

好的 Dockerfile 应该让别人一眼看懂:

  • 用什么基础镜像
  • 哪个阶段负责构建
  • 哪个阶段负责测试
  • 哪个阶段负责运行
  • 复制了什么文件
  • 启动命令是什么
  • 端口是什么
  • 是否非 root
  • 如何传参数

1. 推荐结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# syntax=docker/dockerfile:1

# ========== build arguments ==========
ARG JAVA_VERSION=17

# ========== dependency stage ==========
FROM maven:3.9-eclipse-temurin-${JAVA_VERSION} AS deps
WORKDIR /build
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 \
mvn -B dependency:go-offline

# ========== build stage ==========
FROM deps AS builder
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
mvn -B clean package -DskipTests

# ========== runtime stage ==========
FROM eclipse-temurin:${JAVA_VERSION}-jre AS runtime
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

2. 不要过度炫技

不要为了“高级”而把 Dockerfile 写成谜语。

比如过度使用:

  • 复杂 shell
  • 动态下载
  • 大量 ARG 拼接
  • 多层嵌套脚本
  • 构建时访问不稳定外部资源

Dockerfile 是基础设施代码,稳定和可读优先。


二十八、生产级 Spring Boot Dockerfile 完整模板

下面给一个较完整的 Spring Boot 模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# syntax=docker/dockerfile:1

ARG JAVA_VERSION=17
ARG APP_NAME=demo-app

# ========== deps stage ==========
FROM maven:3.9-eclipse-temurin-${JAVA_VERSION} AS deps

WORKDIR /build

COPY pom.xml .

RUN --mount=type=cache,target=/root/.m2 \
mvn -B dependency:go-offline

# ========== build stage ==========
FROM deps AS builder

WORKDIR /build

COPY src ./src

RUN --mount=type=cache,target=/root/.m2 \
mvn -B clean package -DskipTests

RUN mkdir -p target/extracted \
&& java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted

# ========== runtime stage ==========
FROM eclipse-temurin:${JAVA_VERSION}-jre AS runtime

ARG APP_NAME
ARG APP_VERSION=unknown
ARG GIT_COMMIT=unknown
ARG BUILD_TIME=unknown

LABEL org.opencontainers.image.title="${APP_NAME}" \
org.opencontainers.image.version="${APP_VERSION}" \
org.opencontainers.image.revision="${GIT_COMMIT}" \
org.opencontainers.image.created="${BUILD_TIME}"

RUN groupadd -r app && useradd -r -g app app

WORKDIR /app

COPY --from=builder --chown=app:app /build/target/extracted/dependencies/ ./
COPY --from=builder --chown=app:app /build/target/extracted/spring-boot-loader/ ./
COPY --from=builder --chown=app:app /build/target/extracted/snapshot-dependencies/ ./
COPY --from=builder --chown=app:app /build/target/extracted/application/ ./

USER app

EXPOSE 8080

ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC" \
TZ="Asia/Shanghai"

ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

构建脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env bash
set -e

APP_NAME="demo-app"
APP_VERSION="${1:-1.0.0}"
GIT_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
BUILD_TIME="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"

docker buildx build \
--build-arg APP_NAME="${APP_NAME}" \
--build-arg APP_VERSION="${APP_VERSION}" \
--build-arg GIT_COMMIT="${GIT_COMMIT}" \
--build-arg BUILD_TIME="${BUILD_TIME}" \
-t "${APP_NAME}:${APP_VERSION}" \
.

运行:

1
2
3
4
5
docker run -d \
--name demo-app \
-p 8080:8080 \
-e JAVA_OPTS="-XX:MaxRAMPercentage=70.0 -XX:+UseG1GC" \
demo-app:1.0.0

二十九、常见反模式总结

反模式一:COPY . . 一把梭

1
COPY . .

如果没有 .dockerignore,这是事故源头。

反模式二:生产使用 latest

1
FROM openjdk:latest

构建不可控。

反模式三:把密码写进镜像

1
ENV DB_PASSWORD=123456

这是把密码做成“预制菜”,谁拉镜像谁吃到。

反模式四:构建和运行不分离

1
2
3
4
FROM maven:3.9
COPY . .
RUN mvn package
CMD ["java", "-jar", "target/app.jar"]

最终镜像里带 Maven 和源码。

反模式五:容器里用 root 跑服务

1
2
3
FROM ubuntu
COPY app /app
CMD ["/app"]

没有 USER

反模式六:shell 格式 ENTRYPOINT

1
ENTRYPOINT java -jar app.jar

信号处理可能不理想。

反模式七:安装包不清理缓存

1
RUN apt-get update && apt-get install -y curl

会留下包索引缓存。

反模式八:镜像里装太多调试工具

1
RUN apt-get install -y vim net-tools telnet curl wget git procps

生产镜像不是瑞士军刀,越多越危险。

反模式九:使用 VOLUME 制造匿名卷

1
VOLUME /app/data

业务镜像里随手写可能导致数据位置难追踪。


三十、Dockerfile 优化检查清单

1. 构建速度

  • 是否写了 .dockerignore
  • 是否避免 COPY . . 太早出现
  • 是否先复制依赖声明文件
  • Maven/npm/pip 是否利用缓存
  • 是否使用 BuildKit cache mount
  • CI/CD 是否使用 remote cache

2. 镜像体积

  • 是否使用多阶段构建
  • 运行镜像是否不包含构建工具
  • 是否使用 JRE 而非 JDK
  • 是否清理 apt/apk/yum 缓存
  • 是否避免复制源码和测试文件
  • 是否检查 docker history

3. 安全

  • 是否避免 root 用户运行
  • 是否没有写死密码和 Token
  • 是否使用可信基础镜像
  • 是否固定基础镜像版本
  • 是否定期扫描镜像漏洞
  • 是否生成 SBOM
  • 是否减少不必要系统工具

4. 可维护性

  • 阶段命名是否清晰
  • ENTRYPOINT 是否明确
  • 是否有 LABEL 元数据
  • 是否有必要注释
  • 是否能通过 --target 调试构建阶段
  • 是否支持构建参数注入版本信息

5. 运行可靠性

  • 是否使用 exec 格式或入口脚本中 exec
  • 是否支持优雅停机
  • 是否声明 EXPOSE
  • 是否支持环境变量覆盖
  • 是否有健康检查或编排层健康检查
  • Java 内存参数是否适配容器限制

三十一、排查 Dockerfile 构建问题的命令

1. 构建时显示详细日志

1
docker build --progress=plain -t demo-app:1.0.0 .

2. 不使用缓存构建

1
docker build --no-cache -t demo-app:1.0.0 .

3. 只构建某个阶段

1
docker build --target builder -t demo-app:builder .

4. 进入中间阶段调试

1
docker run -it --rm demo-app:builder sh

5. 查看镜像层

1
docker history demo-app:1.0.0

6. 查看镜像配置

1
docker inspect demo-app:1.0.0

7. 查看最终容器文件

1
docker run --rm -it demo-app:1.0.0 sh

如果运行镜像没有 shell,比如 distroless,就不能这样调试。这也是 distroless 的权衡。


三十二、完整示例:从差 Dockerfile 到好 Dockerfile

1. 初始版本

1
2
3
4
5
6
7
8
9
10
11
FROM maven:3.9-eclipse-temurin-17

WORKDIR /app

COPY . .

RUN mvn clean package -DskipTests

EXPOSE 8080

CMD java -jar target/demo.jar

问题:

  • 运行镜像带 Maven
  • 运行镜像带源码
  • COPY . . 导致缓存差
  • shell 格式 CMD
  • root 用户
  • 没有 JVM 参数入口
  • 没有构建缓存优化
  • 没有元数据
  • 没有 .dockerignore

2. 中级版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FROM maven:3.9-eclipse-temurin-17 AS builder

WORKDIR /build

COPY pom.xml .
RUN mvn dependency:go-offline

COPY src ./src
RUN mvn clean package -DskipTests

FROM eclipse-temurin:17-jre

WORKDIR /app

COPY --from=builder /build/target/*.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

已经解决:

  • 多阶段构建
  • 依赖缓存更好
  • 运行镜像不带 Maven
  • ENTRYPOINT exec 格式

但还可以继续优化:

  • 非 root
  • BuildKit cache mount
  • Spring Boot layered jar
  • JVM 参数
  • LABEL
  • 优雅停机

3. 生产优化版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# syntax=docker/dockerfile:1

ARG JAVA_VERSION=17

FROM maven:3.9-eclipse-temurin-${JAVA_VERSION} AS deps

WORKDIR /build

COPY pom.xml .

RUN --mount=type=cache,target=/root/.m2 \
mvn -B dependency:go-offline

FROM deps AS builder

WORKDIR /build

COPY src ./src

RUN --mount=type=cache,target=/root/.m2 \
mvn -B clean package -DskipTests

RUN mkdir -p target/extracted \
&& java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted

FROM eclipse-temurin:${JAVA_VERSION}-jre AS runtime

ARG APP_VERSION=unknown
ARG GIT_COMMIT=unknown
ARG BUILD_TIME=unknown

LABEL org.opencontainers.image.title="demo-app" \
org.opencontainers.image.version="${APP_VERSION}" \
org.opencontainers.image.revision="${GIT_COMMIT}" \
org.opencontainers.image.created="${BUILD_TIME}"

RUN groupadd -r app && useradd -r -g app app

WORKDIR /app

COPY --from=builder --chown=app:app /build/target/extracted/dependencies/ ./
COPY --from=builder --chown=app:app /build/target/extracted/spring-boot-loader/ ./
COPY --from=builder --chown=app:app /build/target/extracted/snapshot-dependencies/ ./
COPY --from=builder --chown=app:app /build/target/extracted/application/ ./

USER app

EXPOSE 8080

ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"

ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

三十三、Dockerfile 优化的本质

Dockerfile 优化不是简单地“少几行”或“镜像小一点”。

它本质上是在平衡六件事:

mindmap
  root((Dockerfile 优化))
    构建速度
      缓存命中
      BuildKit
      .dockerignore
      remote cache
    镜像体积
      多阶段构建
      精简基础镜像
      清理缓存
      只保留运行产物
    安全性
      非 root
      不写密钥
      漏洞扫描
      最小权限
    可复现
      固定版本
      lock 文件
      构建参数
      元数据
    可维护
      阶段命名
      清晰结构
      少炫技
      可调试
    运行可靠
      exec ENTRYPOINT
      优雅停机
      健康检查
      容器内存参数

工程上真正好的 Dockerfile,通常有这些特征:

  1. 构建阶段和运行阶段分离。
  2. 构建缓存命中率高。
  3. 镜像只包含运行所需文件。
  4. 使用非 root 用户运行。
  5. 不包含密码、私钥、Token。
  6. 基础镜像版本明确。
  7. 支持版本信息追踪。
  8. 支持 CI/CD 快速构建。
  9. 支持容器优雅停止。
  10. 出问题时能够快速定位。

总结

Dockerfile 是容器化工程的核心之一。

刚开始学 Dockerfile,可以先记住指令:

  • FROM
  • WORKDIR
  • COPY
  • RUN
  • EXPOSE
  • CMD
  • ENTRYPOINT

但真正写好 Dockerfile,要进一步理解:

  • 构建上下文
  • 镜像分层
  • 构建缓存
  • 多阶段构建
  • BuildKit
  • .dockerignore
  • 非 root 用户
  • 密钥管理
  • 基础镜像选择
  • Java/Node/Python 等语言的构建特性

一份差的 Dockerfile 也许能跑,但会慢、胖、不安全、不可复现,还会在 CI/CD 和生产环境里给你上强度。

一份好的 Dockerfile 应该是:

构建快、镜像小、安全、稳定、可复现、可维护。

最后给一个最实用的判断标准:

如果你修改一行业务代码,Docker 却重新下载全部依赖,那 Dockerfile 还有优化空间。
如果你最终镜像里还有 Maven、npm、gcc、源码和私钥,那已经不是优化空间了,那是事故现场。

参考资料


Dockerfile 深度实践:从构建原理到镜像优化、缓存优化与生产级落地
https://allendericdalexander.github.io/2026/06/07/devops/cri/dockerfile-deep-optimization-blog/
作者
AtLuoFu
发布于
2026年6月7日
许可协议