第 3 篇:Dockerfile 后端最佳实践
0. 本篇定位
前两篇分别讲清了容器化的工程动机,以及镜像、容器、仓库、层、tag、digest 的制品模型。本篇进入第一个真正落地的文件:Dockerfile。对后端工程师来说,Dockerfile 不是一份“能把 jar 放进镜像”的脚本,而是服务运行环境的最小声明。它决定了基础镜像、构建上下文、层缓存、运行用户、启动入口、JVM 参数、文件权限、镜像体积和安全边界。
这篇文章聚焦 Spring Boot / Java 后端,但原则也适用于其他后端语言:把构建期和运行期分开,把低频变化放在前面,把高频业务制品放在后面,把敏感信息挡在镜像外,把应用作为非 root 进程运行,把主进程和信号处理设计清楚,把 JVM 和容器资源限制放在同一张图里理解。
读完本篇后,你应该能看懂一个 Dockerfile 为什么这样写,而不是只会复制模板。更具体地说,你应该能判断:为什么不能直接 COPY . .;为什么多阶段构建能减少镜像体积和攻击面;为什么 ENTRYPOINT 写法会影响优雅停机;为什么 USER app 之后还要处理目录权限;为什么 Java 容器化不能随便写 -Xmx;为什么 .dockerignore、label、健康检查和日志策略都和后端发布质量有关。
1. 问题背景
很多团队的第一个 Dockerfile 都长得很像:
FROM openjdk:17COPY . /appWORKDIR /appRUN mvn package -DskipTestsCMD java -jar target/app.jar它可能能跑,但问题非常多。第一,整个仓库都进入构建上下文,.git、IDE 文件、日志、临时文件、本地配置都可能影响缓存或泄露信息。第二,构建工具、源码、Maven 缓存和测试产物留在最终镜像里,镜像体积大,扫描面也大。第三,基础镜像没有固定策略,JDK 运行镜像可能比 JRE 或精简运行镜像大得多。第四,默认 root 用户运行,挂载目录、文件写入和容器逃逸风险都被放大。第五,CMD 或 shell 写法可能导致信号无法传递给 Java 进程,滚动发布时请求被硬切断。第六,JVM 参数没有考虑容器资源限制,容易 OOMKilled 或浪费内存。
Dockerfile 的难点不是语法,而是取舍。为了构建快,我们希望缓存命中高;为了镜像小,我们希望最终镜像只包含运行所需内容;为了安全,我们希望非 root、最少依赖、无敏感信息;为了稳定,我们希望启动命令、信号、时区、证书、字体、临时目录和 JVM 参数可控;为了排障,我们希望镜像带有版本元数据,应用日志能和镜像版本关联。
如果 Dockerfile 写得粗糙,问题会在不同阶段暴露。本地阶段表现为构建慢、缓存乱、运行路径不一致;CI 阶段表现为构建时间长、镜像大、扫描结果噪音多;测试阶段表现为容器里缺字体、缺证书、缺时区、缺权限;生产阶段表现为 OOMKilled、无法优雅停机、日志不可采集、漏洞修复困难、回滚制品不确定。一个好 Dockerfile 的价值,就是提前把这些问题变成可审查的声明。
还有一类问题更隐蔽:Dockerfile 把团队的工程边界写歪了。比如为了构建方便,把生产配置复制进镜像;为了启动方便,把数据库密码写成 ENV;为了排障方便,在最终镜像里保留 curl、bash、编译器和包管理器;为了临时修复,把运行中容器里的改动 docker commit 成新镜像。这些做法短期看能解决问题,长期会绕开 CI、测试、扫描、审计和回滚。Dockerfile 的最佳实践不是形式主义,它本质上是在保护制品链路不被临时操作污染。
对于后端团队,Dockerfile 还承担知识传递作用。新人读 Dockerfile,应该能知道服务需要什么 Java 版本、如何构建、运行时依赖什么系统能力、以什么用户启动、默认监听哪个端口、如何注入 JVM 参数、哪些文件进入镜像、哪些内容被排除。如果 Dockerfile 只是几行模糊命令,团队就会把这些知识继续散落在 README、CI 脚本、运维文档和个人经验里。
2. 核心概念
Dockerfile 最佳实践可以归纳成五个边界:构建上下文边界、构建期和运行期边界、层缓存边界、进程生命周期边界、安全权限边界。
| 概念 | 负责什么 | 常见错误 | 后端判断标准 |
|---|---|---|---|
| build context | 决定哪些文件发送给构建器 | 整个仓库无差别进入上下文 | .dockerignore 清晰,构建输入最小化 |
| multi-stage build | 分离编译工具和运行环境 | 运行镜像保留 Maven、源码、缓存 | 最终镜像只保留运行所需内容 |
| layer order | 决定缓存命中和分发复用 | 高频变化文件放太早 | 依赖层稳定,业务层靠后 |
| ENTRYPOINT | 定义容器主进程和启动方式 | shell 吞信号,进程不是 PID 1 | Java 能接收 SIGTERM 并优雅停机 |
| USER / permission | 限制运行权限 | 切到非 root 后目录不可写 | 最小权限和必要写路径同时满足 |
| JVM options | 让 Java 适配容器资源 | 固定 -Xmx 或完全不设边界 | 和 memory limit、GC、诊断策略一致 |
2.1 Dockerfile 不是命令集合,而是运行环境声明
Dockerfile 的每一行都在声明“最终镜像应该是什么样子”。FROM 不是随便选一个能运行 Java 的系统,而是在选择基础系统、JRE 发行版、证书、字体、包管理、安全更新和体积基线。COPY 不是把文件塞进去,而是在决定哪些输入进入制品、哪些变化会破坏缓存。RUN 不是临时执行命令,而是在固化文件系统差异。USER 不是安全装饰,而是在约束进程权限。ENTRYPOINT 不是启动命令速记,而是在定义容器主进程如何被平台管理。
把 Dockerfile 当运行环境声明后,审查方式也会变化。你会问基础镜像为什么选这个版本,依赖下载是否可缓存,源码是否进入最终镜像,镜像里有没有密钥,运行用户能否写必要目录,Java 进程能否接收终止信号,JVM 参数是否和容器内存一致,镜像元数据是否能指向 commit。这些问题比“命令能不能跑”更接近生产质量。
这种审查方式还能帮助团队区分“模板默认值”和“业务特殊性”。基础镜像、用户创建、工作目录、label、日志输出、JVM 默认参数可以由团队统一模板提供;字体、证书、系统包、时区、临时目录、应用端口、健康检查路径则可能因服务而异。好的 Dockerfile 不应该把所有差异藏在复制粘贴里,而应该让特殊依赖显式出现,并能解释为什么需要它。
2.2 后端 Dockerfile 的五个质量目标
第一个目标是构建快。快不是单纯靠强机器,而是靠稳定缓存、合理上下文和依赖复用。第二个目标是镜像小。小不是盲目追求极限,而是最终镜像不包含构建工具、源码、缓存和无关文件。第三个目标是运行稳。稳意味着入口命令、工作目录、文件权限、时区、证书、字体、临时目录、JVM 参数都可预期。第四个目标是权限低。低权限不是只写 USER app,还要保证应用需要写的目录提前授权。第五个目标是可排障。镜像要带元数据,应用要输出标准日志,启动失败时能从日志和镜像信息还原原因。
这五个目标之间有取舍。最小镜像可能缺少排障工具,排障方便的镜像可能体积更大;固定 -Xmx 看起来可控,但在不同环境资源规格下可能不灵活;极致缓存可能让 Dockerfile 变复杂,不适合所有团队。最佳实践不是唯一答案,而是在当前团队能力和生产风险之间做出清晰选择。
以基础镜像为例,eclipse-temurin:21-jre 比完整 JDK 运行镜像更适合大多数 Spring Boot 服务,因为运行阶段不需要编译器;更极致的 distroless 或 jlink 自定义运行时可以进一步减小体积和攻击面,但会提高排障门槛,也要求团队对证书、字体、时区和诊断工具有更成熟的处理方式。初学团队不必第一天追求最小镜像,但必须避免把构建工具和源码留在生产镜像里。
再看 JVM 参数。完全不设置参数,JVM 会根据容器可见资源自行推断,但不一定符合服务特征;固定 -Xmx2g 看似明确,但同一镜像在 1Gi、2Gi、4Gi 容器规格中会产生不同风险;使用 MaxRAMPercentage 更适合多环境复用,但仍然要考虑直接内存、线程栈、Metaspace、GC 和 native memory。Dockerfile 可以提供保守默认值,真正的生产调优应该结合部署资源和压测结果。
2.3 最佳实践的本质是取舍
所以 Dockerfile 最佳实践不是把所有技巧堆进去,而是在构建速度、镜像体积、安全边界、排障便利和团队维护成本之间做平衡。对大多数后端团队来说,先把构建期和运行期分开、避免敏感信息入镜像、使用非 root、保证信号传递和 JVM 参数可调,比一开始追求极限精简镜像更重要。
3. 运行机制
3.1 构建过程:上下文、指令、层与配置
Dockerfile 构建过程大致分为:准备构建上下文、解析 Dockerfile、拉取基础镜像、按指令生成层、写入镜像 config、打 tag、推送仓库。每一步都可能影响后续发布质量。
.dockerignore 先决定哪些文件不会进入构建上下文。随后 FROM 解析基础镜像,构建器根据后续指令计算缓存键。COPY 和 ADD 会把上下文中的文件放入层,文件内容变化会影响缓存。RUN 会在当前层之上执行命令并生成新层。ENV、WORKDIR、USER、ENTRYPOINT 等会写入镜像 config 或影响后续指令。最终镜像由层和 config 组合而成。
构建上下文是很多性能问题的起点。docker build . 里的点不是“当前目录随便读”,而是把当前目录中未被 .dockerignore 排除的内容发送给构建器。上下文越大,构建准备越慢,缓存计算越复杂,误复制风险越高。把 .git、测试报告、日志、临时导出文件、本地证书、IDE 配置、旧 target 目录排除掉,是 Dockerfile 最基础的质量门槛。
ADD 和 COPY 也要区分。大多数后端项目使用 COPY 更清晰,因为它只复制文件;ADD 还带有自动解压和远程 URL 能力,容易制造隐式行为。除非确实需要 ADD 的额外语义,否则不要为了少打几个字符使用它。Dockerfile 的可维护性来自显式,而不是“刚好能工作”。
3.2 多阶段构建的对象流转
多阶段构建的核心是:构建阶段可以很重,运行阶段必须尽量干净。构建阶段使用 Maven 或 Gradle、JDK、源码和测试工具,产出 jar;运行阶段只从构建阶段复制 jar,再加上 JRE、运行用户、必要证书和启动命令。这样最终镜像不包含源码和构建缓存。
function buildBackendImage(source): builder = image("maven:3.9-eclipse-temurin-21") builder.copy("pom.xml") builder.run("mvn dependency:go-offline") builder.copy("src") builder.run("mvn package")
runtime = image("eclipse-temurin:21-jre") runtime.createUser("app") runtime.copyFrom(builder, "target/backend-api.jar", "/app/app.jar") runtime.setUser("app") runtime.setEntrypoint(["java", "-jar", "/app/app.jar"]) return runtime.image()这个过程有三个关键点。第一,pom.xml 和依赖下载先于源码复制,是为了让依赖缓存稳定。第二,构建阶段和运行阶段是不同镜像,最终只复制产物。第三,运行阶段才设置最终用户和启动入口,确保生产运行模型清晰。
多模块 Maven 项目还要更谨慎。只复制根 pom.xml 通常不够,因为子模块 POM 变化也会影响依赖解析。一个常见做法是先复制根 POM 和所有模块 POM,执行依赖预下载,再复制源码。这样业务代码变化不会破坏依赖层,但模块结构变化仍然能正确触发缓存失效。缓存的目标不是永远命中,而是在输入没有变化时命中、输入变化时正确失效。
如果使用 Spring Boot layered jar,可以进一步把依赖和应用代码拆成不同层。Spring Boot 的 layertools 能把 fat jar 拆成 dependencies、spring-boot-loader、snapshot-dependencies、application 等部分。业务代码变化时,依赖层可以复用,节点拉取也更快。不过 layered jar 会让 Dockerfile 更复杂,团队需要确认收益是否值得。对于发布频繁、镜像分发慢的大型服务,它很有价值;对于小型服务,普通多阶段构建可能已经足够。
3.3 Dockerfile 失败时先看构建期还是运行期
Dockerfile 问题要先分构建期和运行期。构建期失败通常表现为依赖下载失败、编译失败、缓存失效、基础镜像拉取失败、构建上下文缺文件、构建时间异常。证据入口是 CI 日志、Docker build 输出、.dockerignore、Dockerfile 指令顺序、基础镜像引用和网络代理配置。
运行期失败通常表现为容器启动失败、文件不存在、权限不足、证书不可用、字体缺失、时区错误、JVM OOM、停止不优雅、健康检查失败。证据入口是容器日志、退出码、docker inspect、镜像 config、运行参数、文件权限、资源限制和应用启动日志。
不要把运行期问题全部归因到 Dockerfile,也不要把 Dockerfile 问题全部推给应用。比如 Permission denied 可能是 Dockerfile 中切换了 USER 但没有授权目录,也可能是 Kubernetes 挂载卷权限导致;OOMKilled 可能是 JVM 参数不合理,也可能是容器 memory limit 太小;证书错误可能是基础镜像缺少 CA,也可能是应用配置了错误证书路径。先定位阶段,再决定修复位置。
还要注意“构建成功但镜像不可运行”的状态。CI 里 docker build 成功,只证明镜像构建出来了,不证明应用能启动。比较稳妥的做法是构建后立即执行 smoke test:启动容器,注入最小配置,等待端口监听或健康检查返回,失败时保存容器日志。这样很多 ENTRYPOINT、文件路径、权限、缺证书、缺字体问题会在 CI 阶段暴露,而不是推到测试或生产。
如果团队使用远程构建器或 BuildKit,构建环境和开发机还会存在差异。比如本地有代理,CI 没有;本地 Maven 缓存存在,CI 不存在;本地使用 x86,CI 构建多架构;本地 Docker 版本支持某个语法,CI 构建器不支持。Dockerfile 最好不要依赖开发机偶然状态,构建依赖要么来自仓库,要么来自受控缓存,要么由 CI 明确注入。
4. 最小可运行示例
4.1 示例目标与覆盖范围
下面是一份适合 Spring Boot fat jar 的 Dockerfile。它不是所有项目唯一答案,但覆盖了后端生产化最常见的关键点:多阶段构建、缓存、非 root、元数据、JVM 参数、标准入口。
4.2 Dockerfile 与 .dockerignore
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .COPY .mvn .mvnCOPY mvnw .
RUN ./mvnw -B -DskipTests dependency:go-offline
COPY src src
RUN ./mvnw -B clean package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
LABEL org.opencontainers.image.title="backend-api"LABEL org.opencontainers.image.description="Spring Boot backend API"
RUN groupadd --system app && useradd --system --gid app --home-dir /app app
COPY --from=build /workspace/target/*.jar /app/backend-api.jar
RUN chown -R app:app /app
USER app
ENV TZ=Asia/ShanghaiENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/backend-api.jar"]配套 .dockerignore:
.git.idea.vscodetarget*.logDockerfiledocker-compose*.yamlREADME.mddocs4.3 构建、启动与示例取舍
构建:
docker build -t backend-api:1.0.0 .启动:
docker run --rm -p 8080:8080 \ -e SPRING_PROFILES_ACTIVE=local \ backend-api:1.0.0这份示例有一个需要解释的点:ENTRYPOINT 使用了 sh -c "exec java ..."。这里的 sh -c 是为了展开 JAVA_OPTS,exec 是为了让 shell 被 Java 进程替换,使 Java 成为主进程并接收信号。如果团队不需要通过环境变量展开 JVM 参数,也可以使用纯 exec form:ENTRYPOINT ["java", "-jar", "/app/backend-api.jar"]。关键不是迷信某种写法,而是理解信号和参数展开的取舍。
示例里没有把健康检查写进 Dockerfile,是刻意保留讨论空间。Dockerfile 的 HEALTHCHECK 适合 Docker 单机或 Compose 场景,但在 Kubernetes 中,通常更推荐在 Pod spec 中配置 startupProbe、livenessProbe、readinessProbe。原因是探针和部署环境强相关,不同环境的启动时间、依赖可达性和流量策略可能不同。Dockerfile 可以提供默认暴露端口和健康接口约定,真正的生产探针通常放到部署层管理。
示例里也没有安装 curl、vim、netstat 等排障工具。生产运行镜像是否保留工具,需要团队取舍。保留工具方便临时进入容器排障,但增加体积和攻击面;不保留工具更安全,但要求团队依赖日志、指标、事件和临时 debug 容器。更推荐的方向是运行镜像保持精简,排障能力通过平台侧 debug 工具补齐,而不是让每个业务镜像都内置一套运维工具。
5. 配置逐行拆解
5.1 构建阶段字段:让缓存稳定
# syntax=docker/dockerfile:1 声明 Dockerfile 语法版本,方便使用 BuildKit 能力。团队如果使用缓存挂载、secret 挂载等高级能力,这一行能减少构建环境差异。
FROM maven:3.9-eclipse-temurin-21 AS build 是构建阶段。它可以包含 Maven、JDK 和构建工具,因为这些内容不会进入最终运行镜像。构建阶段的目标是产出 jar,不是承接生产流量。
WORKDIR /workspace 固定构建目录。固定目录能减少相对路径问题,也方便 CI 日志和缓存定位。
COPY pom.xml .、COPY .mvn .mvn、COPY mvnw . 先复制依赖描述和 Maven Wrapper,再执行 dependency:go-offline。这样业务源码变化时,依赖下载层更容易复用。实际项目如果有多模块,还需要复制父 POM 和各模块 POM,否则依赖缓存仍然会失效。
RUN ./mvnw -B -DskipTests dependency:go-offline 让 Maven 尽量提前下载依赖。它不能保证所有插件和测试期依赖都完整缓存,但能显著减少后续构建重复下载。CI 中还可以配合 BuildKit cache mount 或外部 Maven 缓存提升速度。
如果启用 BuildKit,可以把 Maven 本地仓库作为缓存挂载,而不是把它固化进镜像层。例如使用 RUN --mount=type=cache,target=/root/.m2 ./mvnw ...。这样依赖缓存能跨构建复用,但不会进入最终镜像。这个能力很好用,但要注意构建器是否支持、缓存是否隔离、不同分支是否会互相污染、私有依赖是否需要凭证。缓存是加速手段,不应该成为构建正确性的前提。
COPY src src 放在依赖下载之后,是因为源码变化频率高。把高频变化内容放后面,可以保护前面的依赖缓存。
RUN ./mvnw -B clean package -DskipTests 产出 jar。是否跳过测试取决于 CI 设计。如果 CI 已经在镜像构建前跑过测试,这里可以跳过;如果镜像构建是唯一质量门禁,就不应该跳过。关键是测试责任要明确,不能因为模板里写了 -DskipTests 就默认所有项目都跳过。
5.2 运行阶段字段:让镜像更小更安全
FROM eclipse-temurin:21-jre 是运行阶段。它只保留运行 Java 服务所需的 JRE。生产团队应该固定基础镜像版本,并建立升级和扫描流程。不要长期使用过宽泛的 tag。
RUN groupadd ... && useradd ... 创建非 root 用户。非 root 运行能降低权限风险,但它不是魔法。应用需要写入的目录必须提前授权,挂载卷也要考虑 UID/GID。否则服务会在启动时因为权限不足失败。
COPY --from=build /workspace/target/*.jar /app/backend-api.jar 从构建阶段复制产物。最终镜像没有 Maven、源码和构建缓存。这里建议尽量避免通配符匹配多个 jar 的歧义,复杂项目可以在构建阶段把目标 jar 重命名成固定文件。
更稳妥的方式是在 Maven 构建阶段明确最终产物名称,例如通过 finalName 或构建脚本把 jar 复制为 /workspace/app.jar。否则项目中出现 original-xxx.jar、sources jar、测试 jar、多模块 jar 时,通配符可能复制到错误文件。Dockerfile 应该减少隐式匹配,尤其是生产制品路径。
RUN chown -R app:app /app 给应用目录授权。注意这个指令放在复制 jar 之后,否则后续复制文件可能仍然属于 root。对于大目录递归 chown 会增加层体积和构建时间,生产模板可以用 COPY --chown=app:app 优化。
USER app 切换运行用户。切换后后续命令和最终进程都以该用户运行。排障时如果看到权限问题,要同时检查镜像内文件所有者、运行用户、挂载卷权限和安全上下文。
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError" 给 JVM 默认参数。MaxRAMPercentage 让堆大小按容器可见内存比例计算,避免固定 -Xmx 在不同规格环境中失配。ExitOnOutOfMemoryError 让严重 OOM 后进程退出,由平台按策略重启。生产环境还要结合 GC、直接内存、线程栈、Metaspace 和容器 limit 调整。
Java 内存不是只有堆。容器 memory limit 包含堆、Metaspace、代码缓存、线程栈、直接内存、JNI/native 内存、GC 结构和进程自身开销。把 MaxRAMPercentage 设置得太高,可能让堆占满绝大部分内存,导致 native memory 不足;设置得太低,又可能频繁 GC。一般服务要结合压测观察堆使用、GC pause、线程数、连接池、直接内存和容器 RSS,而不是只看 -Xmx。
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/backend-api.jar"] 前面已经解释过,核心是同时支持参数展开和信号传递。不要写成 ENTRYPOINT java $JAVA_OPTS -jar ... 这种 shell form 后不理解进程模型的写法。
5.3 JVM、信号与权限要一起看
后端 Dockerfile 的难点在组合。USER app 让权限更安全,但如果应用需要写 /tmp、日志目录或上传目录,权限必须提前设计。JAVA_OPTS 让 JVM 可调,但如果部署平台也注入 JVM 参数,要避免重复或冲突。ENTRYPOINT 支持启动,但如果信号传递错误,Kubernetes 滚动更新时旧实例可能不能优雅停机。
Spring Boot 服务建议启用优雅停机,并让 readiness probe 在停机期间及时摘流。Dockerfile 只负责让 Java 进程能接收 SIGTERM,应用还要处理连接池关闭、线程池停止、请求完成和资源释放。不要指望 Dockerfile 单独解决发布下线问题,它只是生命周期链路的一环。
JVM 参数也不要全部写死在镜像里。镜像可以提供安全默认值,环境可以通过部署配置覆盖。生产环境不同服务的内存模型差异很大:网关类服务、批处理服务、普通 CRUD API、大报表服务、长连接服务,对堆、直接内存、线程数和 GC 的要求都不同。Dockerfile 里写死一套全局参数,会让后续调优困难。
还要避免“镜像默认值”和“部署覆盖值”互相打架。比如 Dockerfile 里设置 JAVA_OPTS,Helm values 里也设置 JAVA_TOOL_OPTIONS,平台注入器又追加一组诊断参数。最终 Java 进程到底用了哪些参数,必须能在启动日志里看到。建议应用启动时打印关键 JVM 参数、可见 CPU、可见内存、profile、时区和版本信息,帮助排障确认运行事实。
6. 后端工程场景
6.1 本地、CI、测试和生产各看什么
本地开发阶段,Dockerfile 应该让新人快速构建和启动服务。.dockerignore 要避免把本地临时文件带入镜像,构建缓存要能复用依赖层,启动日志要能清晰显示 profile、端口、版本和关键配置摘要。
CI 阶段,Dockerfile 是制品生产入口。CI 应该先跑测试,再构建镜像,写入 commit label,扫描镜像,推送仓库并输出 digest。构建慢时先看 Dockerfile 顺序和缓存,不要只加机器。
测试环境阶段,Dockerfile 的问题会表现为环境差异。比如测试容器缺少字体导致导出 PDF 乱码,缺少 CA 证书导致调用 HTTPS 失败,时区不一致导致定时任务偏移,非 root 用户无法写临时目录导致上传失败。很多问题不是业务逻辑,而是运行镜像缺少真实依赖。
生产阶段,Dockerfile 影响发布安全和运行稳定。非 root、最小镜像、固定基础镜像、JVM 容器感知、信号处理、标准输出、版本 label 都是生产化边界。它们不一定每个项目第一天都做到完美,但必须进入模板和评审。
6.2 订单 API 的 Dockerfile 改造路径
订单 API 最初使用单阶段 Dockerfile,把源码、Maven、缓存和 jar 都放在同一个镜像里。CI 构建 15 分钟,镜像 900MB,扫描出大量构建工具漏洞,生产运行时仍然以 root 用户启动。第一次优化先加 .dockerignore,排除 .git、target、日志和文档,构建上下文明显变小。
第二次优化引入多阶段构建。构建阶段保留 Maven 和源码,运行阶段只复制 jar 到 JRE 镜像,镜像体积下降,扫描噪音减少。第三次优化调整层顺序,先复制 POM 下载依赖,再复制源码构建,CI 缓存命中后构建时间稳定。第四次优化加入非 root 用户和目录权限,解决安全基线问题。第五次优化调整 ENTRYPOINT 和 Spring Boot 优雅停机,滚动发布时请求中断减少。第六次优化把 commit、版本和构建时间写入 label 和应用启动日志,排障时能快速确认运行内容。
这个路径说明 Dockerfile 优化不需要一次到位,但每一步都应该解决明确问题。不要为了“最佳实践”堆砌指令,也不要因为第一版能跑就停止改进。Dockerfile 是后端服务运行环境的入口,它应该随着团队发布要求一起演进。
如果订单 API 后续需要生成 PDF,它可能需要字体;如果需要调用企业内网 HTTPS,它可能需要内部 CA;如果需要使用时区相关定时任务,它需要明确时区策略;如果需要写临时文件,它需要可写目录;如果需要较长时间处理请求,它需要优雅停机和足够终止宽限期。这些需求都应该回到 Dockerfile、应用配置和部署模板中显式表达,而不是靠某台机器历史状态。
6.3 把改造路径变成准入标准
团队也可以把这条路径变成准入标准。第一阶段要求服务能使用标准 Dockerfile 模板构建;第二阶段要求镜像体积低于阈值;第三阶段要求非 root 运行和正确信号处理;第四阶段要求构建输出 digest 和版本 label;第五阶段要求通过镜像扫描和 smoke test。每个阶段都有明确验收,比一句“按最佳实践改一下”更容易落地。
7. 常见错误与排障
Dockerfile 相关故障通常有清晰证据,只要先分构建期和运行期,就不会乱。
7.1 构建、体积、权限、停机和 OOM
第一类是构建非常慢。现象是 CI 每次重新下载依赖,构建耗时不稳定。可能原因是 .dockerignore 缺失、COPY . . 太早、依赖下载放在源码复制之后、Maven 缓存没有复用。排查时看 build 输出的缓存命中情况和 Dockerfile 顺序。修复方向是缩小上下文、拆分依赖层和源码层、使用缓存挂载或外部依赖缓存。
第二类是镜像体积过大。现象是 push/pull 慢、扫描慢、节点磁盘压力大。可能原因是单阶段构建、运行镜像包含 JDK/Maven/源码/缓存/测试产物、清理发生在后续层。排查时用 docker history 看大层来源。修复方向是多阶段构建、只复制运行产物、同层清理包缓存、选择合适基础镜像。
第三类是容器无法写临时文件。现象是启动时报 Permission denied,上传、导出、临时解压失败。可能原因是切换到非 root 后目录未授权,或者挂载卷 UID/GID 不匹配。排查时看运行用户、文件所有者、挂载路径和应用写入路径。修复方向是提前创建并授权必要目录,或者调整挂载卷权限。不要为了省事改回 root。
第四类是停止发布时请求中断。现象是滚动更新期间出现连接中断、请求被硬杀、日志里没有优雅停机过程。可能原因是 ENTRYPOINT 吞掉 SIGTERM,Java 进程不是主进程,应用未开启 graceful shutdown,readiness 没有及时摘流。排查时看进程树、容器停止日志、Kubernetes 事件和应用停机配置。修复方向是使用正确 exec 写法、启用优雅停机、设置 terminationGracePeriodSeconds 和 readiness 策略。
第五类是 OOMKilled。现象是容器被平台杀掉,应用日志不一定有 Java OOM。可能原因是容器 memory limit 太低、JVM 堆设置过大、直接内存或线程栈被忽略、流量峰值超出容量。排查时看容器退出原因、内存指标、JVM 参数、GC 日志、线程数和连接池。修复方向是按压测调整 resources 和 JVM 参数,而不是只增大 -Xmx。
这些故障都说明 Dockerfile 和运行配置是一组组合拳。Dockerfile 提供默认运行模型,部署平台提供环境资源和覆盖参数,应用提供健康检查和优雅停机逻辑。只改其中一层,未必能解决根因。
7.2 基础镜像升级要有回归流程
还有一个常见故障是基础镜像升级后行为变化。现象可能是 HTTPS 调用失败、字体渲染变化、时区数据变化、glibc/musl 差异导致 native 依赖异常,或者默认 CA 证书集合不同。排查时要比较基础镜像 digest、系统发行版、JRE 版本、证书包、字体包和 native 依赖。修复方向不是永远锁死旧基础镜像,而是建立基础镜像升级回归流程。
7.3 敏感文件进入镜像要按泄露处理
另一个常见故障是镜像里意外包含敏感文件。现象可能来自安全扫描、仓库审计或事故复盘。可能原因是 .dockerignore 缺失、构建上下文包含本地配置、COPY . . 无差别复制、构建时把私有 token 写进文件。排查时看镜像层、构建上下文、Dockerfile 复制路径和 CI secret 注入方式。修复时要重新生成镜像、轮换泄露密钥,并检查仓库中旧层是否需要清理和阻断访问。
8. 生产化边界
8.1 生产 Dockerfile 的硬边界
生产 Dockerfile 至少要满足几条边界。构建输入最小化,避免无关文件和敏感信息进入上下文。构建期和运行期分离,最终镜像不保留构建工具和源码。基础镜像版本有治理,不长期使用模糊 tag。应用非 root 运行,必要目录权限明确。启动入口能正确传递信号。JVM 参数和容器资源限制一致。镜像带有版本元数据。日志走标准输出。最终镜像经过扫描和发布门禁。
生产化还要求团队能持续维护 Dockerfile。基础镜像需要升级,JDK 需要升级,漏洞需要修复,JVM 参数需要随流量调整,启动方式需要随 Spring Boot 版本和平台策略调整。不要把 Dockerfile 当成写完就不动的文件。它和应用代码一样,需要 review、测试和演进。
生产 Dockerfile 的评审最好和发布模板一起看。只看 Dockerfile,看不到资源限制、探针、Secret、volume、securityContext 和 terminationGracePeriodSeconds;只看部署模板,看不到镜像里是否真的有非 root 用户、入口命令是否正确、运行目录是否可写。后端服务的生产质量来自两者组合。比如 Dockerfile 里使用 USER app,Kubernetes 里也应该避免 privileged、避免不必要 capability,并设置合适的 runAsUser/runAsGroup/fsGroup。
供应链安全也会逐步进入 Dockerfile 评审。镜像 label、SBOM、签名、构建来源证明、基础镜像来源、依赖缓存来源、secret 挂载方式,都和 Dockerfile 写法有关。第一阶段不必把所有能力都上齐,但不要写出会阻碍后续治理的 Dockerfile,例如依赖个人电脑状态、使用可变基础 tag、把密钥写进层、手工 commit 容器。
8.2 从“能运行”到“可维护模板”
团队最好把 Dockerfile 经验沉淀成语言栈模板,而不是每个服务自己复制一份来改。模板可以约定基础镜像、用户创建、工作目录、label、JVM 默认参数、日志策略、健康检查端口、构建缓存和安全要求。应用项目只填服务名、jar 名、端口和少量特殊依赖。
模板化不是为了消灭差异,而是为了让差异显式。某个服务需要字体、需要系统包、需要额外证书、需要特殊 JVM 参数,都应该写在模板扩展点里,并说明原因。这样平台可以统一治理基础能力,应用团队也能保留必要灵活性。
8.3 模板必须带验证
模板还应该包含验证。比如构建后输出镜像大小、层数量、基础镜像、运行用户、entrypoint、重要 label;启动 smoke test 验证健康接口;扫描阶段输出漏洞结果;发布前确认 digest。没有验证的模板只是复制粘贴,有验证的模板才是工程能力。
9. 什么时候用,什么时候不用
9.1 应认真写 Dockerfile 的场景
所有需要进入容器化交付链路的后端服务,都应该认真写 Dockerfile。即使暂时不用 Kubernetes,只要服务要在本地、测试、预发、生产之间移动,Dockerfile 就是制品入口。对于一次性脚本或短期实验,可以使用更简单写法,但也要避免把密钥和无关文件打进镜像。
9.2 不要一开始追求过度技巧
不建议一开始就追求极端精简镜像、复杂缓存和大量构建技巧。如果团队刚开始容器化,先做到多阶段构建、.dockerignore、非 root、正确 ENTRYPOINT、版本 label 和合理 JVM 参数,就已经能避免大部分问题。等 CI 构建时间、镜像体积、安全扫描和发布速度成为瓶颈,再引入更高级优化。
9.3 Dockerfile 优化的优先级
优先级最高的是安全和确定性:不要把敏感信息进镜像,不要让生产使用不可追踪基础镜像,不要 root 运行,不要让启动命令吞信号。第二优先级是构建和分发效率:优化上下文、层顺序、多阶段构建和镜像体积。第三优先级才是高级技巧:BuildKit cache、Spring Boot layered jar、distroless、SBOM、签名和供应链证明。
判断一个 Dockerfile 是否值得继续优化,可以看三个指标:CI 构建是否稳定,镜像体积是否影响发布,运行故障是否能快速定位。如果这三项都还没达标,就不要急着追求最潮的基础镜像;如果这三项已经稳定,再逐步收紧安全和供应链能力。
10. 下一篇衔接
本篇把 Dockerfile 从“能构建”推进到“能支撑后端工程交付”。你现在应该能解释多阶段构建、层缓存、非 root、ENTRYPOINT、JVM 参数和构建上下文之间的关系,也能判断常见 Dockerfile 问题应该从构建期还是运行期排查。
下一篇会从 Dockerfile 进入容器运行本身:容器启动以后,生命周期如何变化,退出码怎么看,日志怎么看,docker inspect 能提供什么证据,资源限制如何影响进程,如何在本地快速定位容器运行问题。也就是从“如何构建一份好镜像”进入“如何运行和排障一个容器”。