java 技术随笔

Dockerfile 最佳实践与 Java 应用镜像瘦身:多阶段构建、分层缓存与 JVM 容器内存

同一个 Spring Boot 应用,有人打的镜像 800MB 启动还慢,有人打到 150MB 且分层缓存拉满构建飞快。差别基本都在 Dockerfile 写得好不好。本文从镜像分层原理讲起,给出生产级 Java 应用的 Dockerfile 规范,重点讲多阶段构建与镜像瘦身,顺便把 JVM 在容器里的内存坑一起说清。

镜像为什么分层:Dockerfile 的最佳实践都源于此

Docker 镜像由一层层只读文件系统叠加而成,每个 Dockerfile 指令(FROM/RUN/COPY/ADD)都会生成一层。层的意义有两个:

复用与缓存:构建时若某层指令和它的上下文没变,Docker 直接复用缓存层,跳过执行。这就是"把依赖安装放前面、把经常变的源码 COPY 放最后"能大幅提速的原因。

共享存储:同一宿主机上多个镜像共享相同的基础层,只占一份磁盘。

记住这个模型,下面所有最佳实践都是它的推论:不变的指令放前面,变化的放后面;单条 RUN 内做清理,避免产生临时层。

生产级 Java 应用 Dockerfile 模板

以 Spring Boot 项目为例,用 多阶段构建:第一个阶段用带 Maven 的大镜像完成编译,第二阶段只把编译产物拷贝进精简的运行镜像,最终交付的镜像里没有编译器、没有源码、没有依赖缓存:

# ===== 第一阶段:构建 =====
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
# 先只拷贝 pom,利用缓存:依赖没变就不重新下载
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 再拷贝源码并打包(跳过测试)
COPY src ./src
RUN mvn package -DskipTests -B

# ===== 第二阶段:运行 =====
FROM eclipse-temurin:17-jre
LABEL maintainer="dev@example.com"
WORKDIR /app
# 从构建阶段拷贝产物(多阶段的关键:COPY --from)
COPY --from=builder /build/target/app.jar app.jar
# 非 root 运行,提升安全性
RUN useradd -r appuser
USER appuser
EXPOSE 8080
# 优雅停机 + JVM 容器感知
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XshowSettings:vm", "-jar", "app.jar"]

构建与验证命令:

# 构建
docker build -t myapp:1.0 .
# 查看镜像与历史层
docker images
docker history myapp:1.0
# 启动并验证
docker run -d -p 8080:8080 --name myapp myapp:1.0

镜像瘦身:从 800MB 到 150MB 的四个动作

1. 用多阶段构建,丢掉编译期依赖。上面模板的最终镜像只有 JRE + jar,这是瘦身最大的一步(省掉 Maven 和整个 .m2 仓库)。

2. 换更小的基础镜像。eclipse-temurin:17-jre(约 200MB+)比 jdk 全量版小;Alpine 版(temurin:17-jre-alpine)能再小到 100MB 内,但要注意 Alpine 用 musl libc,个别依赖 Native 库(如某些 sdk)可能不兼容,先用 jre 版稳妥。追求极致可以用 distroless(无 shell 无包管理器,几乎最小,但排障难受)。

3. 在构建阶段就排除无用内容。jar 内的重复依赖用 spring-boot 的 layout 精简;不要 COPY 整个 target 目录,只 COPY 最终 jar。

4. 每层用完即清。编译阶段产生的临时文件(如解压出来的文件、中间安装包)要在同一 RUN 里删除,避免它们成为镜像层永久留在镜像里。

Dockerfile 最佳实践清单

.dockerignore 必须写。把 .git、target、*.log、node_modules、.idea 排除在构建上下文外,否则 COPY . . 会把几百 MB 无关文件全打进上下文(还慢):

.git
target
*.log
.idea
*.iml
node_modules

一个容器一个进程。Java 应用镜像里不要同时跑 shell 脚本循环和 java,日志交给 docker logs 收集,进程交给 docker 管理。

ADD 能不用就不用。ADD 会自动解压 tar、能拉远程 URL,行为隐晦,COPY 语义清晰,本地文件一律用 COPY。

CMD 与 ENTRYPOINT 配合。固定命令放 ENTRYPOINT,可覆盖的默认参数放 CMD,别全塞 CMD 导致别人 docker run 覆盖时把主命令盖没了。

配 HEALTHCHECK。镜像里声明健康检查,K8s/编排系统才能感知应用死活:

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD curl -fs http://localhost:8080/actuator/health || exit 1

标签与元数据。LABEL 记录版本、维护者、git 提交号,出问题能快速知道是谁的哪个版本。

JVM 在容器里的两个经典坑

坑一:内存。老 JVM 默认按宿主机物理内存算堆,容器限制 512MB 时 JVM 却可能以为有 16GB 内存,直接 OOM 被内核杀掉。JDK 8u191+ / JDK 10+ 默认开启 UseContainerSupport,能识别 cgroup 限制;老版本要手动加 -XX:+UseContainerSupport 并显式设 -Xmx 或 -XX:MaxRAMPercentage=75,留出堆外内存(Metaspace、线程栈、Direct Buffer)的余量。

坑二:CPU 与 GC。容器限了 2 核但 JVM 按宿主机核数算 GC 线程和并行度,可能白白创建几十个 GC 线程。同样用 -XX:ActiveProcessorCount=2 或依赖容器感知,让 JVM 认知的核数和 cgroup 一致。

生产参数建议:

ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-XX:ActiveProcessorCount=2", "-XshowSettings:vm", "-jar", "app.jar"]

FAQ 速答

Q:为什么改了代码重新构建还是很快?因为源码 COPY 层之前的所有层都命中了缓存,只有最后一两层重建。想更稳就严格保持"依赖层在前"。

Q:RUN mvn package 时想用缓存为什么有时不生效?因为 COPY pom.xml 和 COPY src 之间任何一层变化都会使后面全部失效。先把 pom 拷进来装依赖,再拷源码,才能最大化缓存命中。

Q:镜像 800MB 一定是写错了吗?不一定,但大概率是没做多阶段构建或把完整 JDK 打进去了。先 docker history 看每层体积,最大的层就是罪魁。

一句话总结:多阶段构建是瘦身的骨架,分层顺序是缓存提速的关键,非 root + 健康检查 + 容器感知参数是生产可用的底线。

标签
Docker容器Spring BootDevOps