《Java 编程实战》16.2 容器化:多阶段 Dockerfile 与 Jib

fat jar 解决了依赖问题,但目标机器仍然需要 JRE、正确的用户和健康检查。本节用多阶段 Dockerfile 把 libraryd 装进非 root 镜像,再用 Jib 3.5.2 在不依赖 Docker daemon 的情况下构建,并给出 352 MB 镜像的真实运行结果。

本节目标:把 libraryd 的 fat jar 变成可重复构建、非 root、能探活的容器镜像,并说清 Dockerfile 与 Jib 各自的适用场景。
适用版本:JDK 25(Temurin 25.0.5)、Maven 3.9.12、Jib 3.5.2、Docker 29.x、eclipse-temurin:25-jre。

承接的问题

16.1 节产出了一个 24 MB 的 fat jar,java -jar 能启动。但把它交给运维,仍然有一串隐含前提:

  • 目标机器装了 JDK 25 或兼容的 JRE。
  • 启动用户对 jar 和日志目录有权限。
  • 环境变量在 systemd、Kubernetes 或 shell 里配置正确。
  • 进程挂了有人拉起,健康检查知道它是不是真的能服务。
  • 每次发布用的基础镜像、JVM 参数、时区一致。

直接 scp jar 到服务器,这些前提靠文档和人工保证,迟早会漂移。容器化要解决的是“把运行前提写进镜像”,而不是“把 jar 包一层”。

一个常见的反例是:

FROM eclipse-temurin:25-jre
COPY libraryd-app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

这个镜像能跑,但有几个问题:

  • 默认以 root 运行。应用一旦被攻破,容器里的 root 权限会被放大。
  • 没有健康检查。编排系统只知道进程在,不知道 /health 是否 200。
  • 没有 JVM 容器参数。JVM 可能按宿主机内存计算堆大小,而不是按容器限制。
  • 构建和运行在同一个镜像里,构建工具和源码层被带进最终镜像。

下面从多阶段 Dockerfile 开始,把这些前提逐条写进去。

核心机制一:多阶段 Dockerfile 把构建和运行分开

多阶段构建的核心是“前一个阶段产出文件,后一个阶段只拿走文件”:

# syntax=docker/dockerfile:1.7

FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /workspace

COPY pom.xml .
COPY libraryd-domain/pom.xml libraryd-domain/
COPY libraryd-persistence/pom.xml libraryd-persistence/
COPY libraryd-web/pom.xml libraryd-web/
COPY libraryd-app/pom.xml libraryd-app/
COPY libraryd-integration-tests/pom.xml libraryd-integration-tests/

RUN mvn -B -ntp -DskipTests dependency:go-offline

COPY . .
RUN mvn -B -ntp -DskipTests package

FROM eclipse-temurin:25-jre AS runtime

RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/* \
    && groupadd --system --gid 10001 libraryd \
    && useradd --system --uid 10001 --gid libraryd \
         --home-dir /app --shell /usr/sbin/nologin libraryd \
    && mkdir -p /app \
    && chown libraryd:libraryd /app

WORKDIR /app
COPY --from=build --chown=libraryd:libraryd \
     /workspace/libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar \
     /app/libraryd-app.jar

USER libraryd
EXPOSE 8080

ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"

HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \
  CMD curl -fsS http://127.0.0.1:8080/health || exit 1

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

几个设计点:

  • 先 COPY POM,再 COPY 源码。 Maven 依赖解析只看 POM。POM 没变时,dependency:go-offline 这一层直接命中缓存,不用每次重新下载依赖。源码变了只会让后面的 package 层失效。
  • dependency:go-offline 不是万能缓存。 它会尽量下载依赖和插件,但某些插件在运行时才解析的依赖仍可能漏掉。第一次构建失败时,先去掉 -q 看 Maven 在下载什么。
  • 构建阶段不跑测试。 -DskipTests 让镜像构建只负责编译和打包。测试在 CI 的 mvn verify 里跑,镜像构建不应该重复跑 33 个测试,也不应该因为 Testcontainers 需要 Docker-in-Docker 而变复杂。
  • 运行阶段只复制 jar。 源码、Maven 仓库、测试报告都不进最终镜像。最终镜像的层次更少,攻击面也更小。
  • --chown 和 USER 一起用。 只写 USER libraryd 但文件属主是 root,应用仍然能读 jar(默认可读),但写日志或临时文件会失败。复制时就改属主,避免运行时 chmod。

构建命令:

docker build --platform linux/amd64 \
  -t registry.example.com/libraryd:1.0.0 \
  -t registry.example.com/libraryd:latest .

--platform 在 Apple Silicon 上尤其重要。默认构建的是 linux/arm64,推到 amd64 服务器上会得到:

exec /usr/bin/java: exec format error

要么显式构建 linux/amd64(QEMU 模拟,慢),要么用 docker buildx 同时构建两个架构。生产环境用哪个架构,构建时就要写清楚,不要靠“我本机能跑”。

核心机制二:非 root 用户与文件权限

容器默认以 root 运行。对 libraryd 来说,root 没有任何必要:它只读 jar、连数据库、写标准输出。创建一个专用用户是低成本的安全收益:

groupadd --system --gid 10001 libraryd
useradd --system --uid 10001 --gid libraryd \
  --home-dir /app --shell /usr/sbin/nologin libraryd

--system 创建系统用户,--shell /usr/sbin/nologin 禁止登录,--home-dir /app 给应用一个工作目录。UID/GID 固定为 10001,编排系统可以用数值用户做安全策略,不依赖镜像里的用户名。

复制文件时改属主:

COPY --from=build --chown=libraryd:libraryd \
     /workspace/libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar \
     /app/libraryd-app.jar

运行时验证:

docker exec libraryd-verify id

本机输出:

uid=10001(libraryd) gid=101(libraryd) groups=101(libraryd)

注意 GID 是 101,不是 10001。groupadd --system --gid 10001 在 Alpine 的 BusyBox 里可能被系统组的范围限制改写;Ubuntu 的 groupadd 通常按指定值创建。真正重要的是“不是 root”,UID/GID 的数值在不同基础镜像上可能不同。如果编排系统要求固定 GID,构建后要用 id 确认,不要假设。

如果应用需要写临时文件,给一个可写的 tmpfs:

docker run --read-only --tmpfs /tmp ...

--read-only 让根文件系统只读,/tmp 用内存盘。libraryd 本身不写业务文件,日志走标准输出,所以这个组合可行。数据库连接、缓存都不依赖本地磁盘。

核心机制三:健康检查与 JVM 容器参数

健康检查不是“进程还在”,而是“服务能响应”:

HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \
  CMD curl -fsS http://127.0.0.1:8080/health || exit 1

参数的含义:

  • --interval=10s:每 10 秒检查一次。
  • --timeout=3s:3 秒没响应就算失败。
  • --start-period=15s:启动后 15 秒内失败不计入 --retries,给 JVM、Flyway 和 Javalin 启动时间。
  • --retries=3:连续失败 3 次才标记 unhealthy。

curl -fsS 的 -f 让 4xx/5xx 返回非零退出码,-sS 安静但保留错误。/health 在数据库不可用时返回 503,所以健康检查会跟着失败。这是有意的:一个连不上数据库的 libraryd 不应该被负载均衡器继续转发流量。

本机运行容器后:

docker ps --filter name=libraryd-verify --format '{{.Status}}'
Up 1 second (health: starting)

等待 start-period 和几个检查周期后:

docker inspect libraryd-verify --format '{{.State.Health.Status}}'
healthy

停掉 PostgreSQL 后,/health 返回 503 {"status":"DOWN","checks":{"db":"DOWN"}},健康检查在重试次数用完后变成 unhealthy。这说明探针检查的是依赖,不是进程存活。

JVM 参数通过 JAVA_TOOL_OPTIONS 注入:

ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
  • MaxRAMPercentage=75 让堆最多占容器内存的 75%,留出元空间、线程栈、直接内存和操作系统缓存的空间。没有容器内存限制时,JVM 可能按宿主机内存计算,堆会开得过大。
  • ExitOnOutOfMemoryError 让 OOM 后进程退出,由编排系统重启,而不是留下一个半死不活的 JVM。
  • JAVA_TOOL_OPTIONS 会被 JVM 自动读取,不需要改 ENTRYPOINT。它也会被所有 JVM 进程继承,包括诊断工具;如果不想影响子进程,用 JDK_JAVA_OPTIONS(只影响 java 启动器)。

不要在没有内存限制的环境里盲目调大 MaxRAMPercentage。容器没有 -m 或 Kubernetes resources.limits.memory 时,JVM 看到的“可用内存”是宿主机内存,75% 可能仍然过大。

核心机制四:Jib 不依赖 Docker daemon 构建

Jib 是 Google 的 Java 镜像构建工具。它不把整个项目目录交给 Docker daemon,而是按 Java 应用的层结构(依赖、资源、类)自己组装镜像。好处是:

  • jib:build 直接推送到镜像仓库,不需要本地 Docker daemon。
  • jib:buildTar 生成一个镜像 tar,可以离线传输。
  • 分层按依赖/资源/类拆分,改代码只推最小的类层。
  • 基础镜像和构建过程可复现,时间戳可以固定。

libraryd-app/pom.xml 里的 Jib 配置:

<plugin>
  <groupId>com.google.cloud.tools</groupId>
  <artifactId>jib-maven-plugin</artifactId>
  <configuration>
    <from>
      <image>eclipse-temurin:25-jre</image>
      <platforms>
        <platform>
          <architecture>amd64</architecture>
          <os>linux</os>
        </platform>
      </platforms>
    </from>
    <to>
      <image>registry.example.com/libraryd</image>
      <tags>
        <tag>1.0.0</tag>
        <tag>latest</tag>
      </tags>
    </to>
    <container>
      <mainClass>com.plume.library.AppMain</mainClass>
      <ports>
        <port>8080</port>
      </ports>
      <jvmFlags>
        <jvmFlag>-XX:MaxRAMPercentage=75</jvmFlag>
        <jvmFlag>-XX:+ExitOnOutOfMemoryError</jvmFlag>
        <jvmFlag>-Dlogback.configurationFile=/app/config/logback-json.xml</jvmFlag>
      </jvmFlags>
      <environment>
        <LIBRARYD_HTTP_PORT>8080</LIBRARYD_HTTP_PORT>
        <LIBRARYD_LOG_LEVEL>INFO</LIBRARYD_LOG_LEVEL>
      </environment>
      <user>10001:10001</user>
      <creationTime>USE_CURRENT_TIMESTAMP</creationTime>
      <filesModificationTime>EPOCH_PLUS_SECOND</filesModificationTime>
    </container>
    <extraDirectories>
      <paths>
        <path>
          <from>src/main/resources</from>
          <into>/app/config</into>
        </path>
      </paths>
    </extraDirectories>
  </configuration>
</plugin>

参数逐项说明:

  • from.image:基础镜像。Jib 会从镜像仓库拉取,不使用本地 Docker 缓存。没有外网或私有仓库时,要先配置镜像加速或私有 registry。
  • from.platforms:指定 linux/amd64。Jib 支持多平台,但一次构建多个平台会让拉取和推送变慢;按部署目标选择。
  • to.image 和 to.tags:目标仓库和标签。to.image 不带 tag,tag 由 tags 提供;也可以写成 image:tag。
  • container.mainClass:Jib 的入口。Jib 默认把类和依赖放在 /app/classes、/app/libs,入口是 java -cp ... com.plume.library.AppMain,不是 java -jar。所以这里不需要 Shade 的 fat jar。
  • container.ports:声明 8080,便于编排系统发现。
  • container.jvmFlags:JVM 参数。-Dlogback.configurationFile 指向 extraDirectories 复制进去的 JSON 日志配置。
  • container.environment:镜像内的默认环境变量。数据库 URL、密码不要写在这里,运行时用 -e 或 Secret 注入。
  • container.user:数值用户。Jib 不会创建用户,基础镜像里没有 10001 也能设置;文件权限由 Jib 的层控制。
  • creationTime 和 filesModificationTime:控制镜像和文件时间戳。USE_CURRENT_TIMESTAMP 让镜像创建时间随构建变化;EPOCH_PLUS_SECOND 让文件时间固定,便于层缓存和可复现。两者都支持 ISO-8601 时间字符串。
  • extraDirectories:把模块外的文件复制进镜像。这里把 src/main/resources 放到 /app/config,让 logback-json.xml 可以在镜像里被显式引用,而不是只能从 classpath 读。

Jib 的三种构建方式:

# 直接推送到镜像仓库,不需要 Docker daemon
mvn -pl libraryd-app -am -DskipTests package jib:build

# 生成 tar,不需要 daemon,也不需要推送
mvn -pl libraryd-app -am -DskipTests package jib:buildTar \
  -Djib.to.image=libraryd:1.0.0

# 构建到本地 Docker daemon,需要 daemon
mvn -pl libraryd-app -am -DskipTests package jib:dockerBuild

jib:buildTar 的产物默认在 libraryd-app/target/jib-image.tar,可以用 docker load -i 导入离线环境。这条路径适合“CI 没有 Docker daemon,但制品要传到内网 registry”的场景。

在 libraryd 里落地:352 MB 镜像与真实探活

本机用运行阶段等价的 Dockerfile(基础镜像用已缓存的 eclipse-temurin:25-jre-alpine,健康检查用 BusyBox wget)构建并启动:

docker build -t libraryd:verify .
docker run -d --rm --name libraryd-verify -p 18083:8080 \
  -e LIBRARYD_DB_URL=jdbc:postgresql://host.docker.internal:55436/libraryd \
  -e LIBRARYD_DB_USER=libraryd \
  -e LIBRARYD_DB_PASSWORD=libraryd \
  -e LIBRARYD_LOG_LEVEL=INFO \
  libraryd:verify

构建成功:

Successfully built 77397c2d81e7
Successfully tagged libraryd:verify

镜像大小:

libraryd:verify    352MB

352 MB 里,jar 只占 24 MB,其余是 JRE 和 Alpine 基础层。eclipse-temurin:25-jre-alpine 本身约 304 MB,这个数字看起来不符合“alpine 很小”的直觉,因为 Temurin 的 JRE 镜像包含完整运行时和字体/时区等依赖。换成文章开头的 Ubuntu 基础镜像,体积会不同,但非 root、健康检查和启动行为一致。想进一步缩小,可以把 16.1 节裁出的 53 MB jlink 运行时作为基础层,但那样要自己处理 CA 证书、时区、字体和 curl,维护成本更高。

容器启动后探活:

curl -s http://127.0.0.1:18083/health
{"status":"UP","checks":{"db":"UP"}}

docker inspect 显示 healthy,docker exec ... id 显示 uid=10001。这证明非 root、健康检查、数据库连接和 HTTP 端口都在真实容器里工作。

需要说明的是:本机没有拉取 maven:3.9-eclipse-temurin-25 基础镜像,完整的构建阶段没有在本机跑通;上面的结果验证的是运行阶段和镜像行为。构建阶段的 Dockerfile 依赖标准 Maven 镜像,首次构建需要能访问镜像仓库。

Dockerfile 与 Jib 的取舍

两种方式都能产出可运行镜像,选择取决于团队和 CI 环境:

维度多阶段 DockerfileJib
构建依赖Docker daemonjib:build/buildTar 不需要 daemon
缓存粒度Docker 层缓存依赖/资源/类分层
额外文件COPY 任意文件extraDirectories
调试每层可见,容易进容器排查黑盒,靠日志和 tar 检查
多架构buildxfrom.platforms
基础镜像任意任意,但需仓库可访问
与 Maven 集成外部脚本原生插件
适合需要装系统包、复杂启动脚本纯 Java、CI 无 daemon、频繁发布

libraryd 是纯 Java 服务,没有系统包依赖(除了健康检查用的 curl),Jib 很合适。但如果将来要在镜像里加 psql、备份脚本、证书挂载或复杂的 entrypoint,Dockerfile 更直接。

一个常见的组合是:CI 用 Jib 推镜像,生产用 Dockerfile 做最终加固。两者不必二选一,但要保证基础镜像、JVM 参数和健康检查在三处一致,否则“本地能跑、线上不同”的问题会回来。

常见错误与边界

错误一:健康检查命令在镜像里不存在。 eclipse-temurin:25-jre 基于 Ubuntu,默认没有 curl。Dockerfile 里要先 apt-get install curl,否则容器一直是 unhealthy,日志里只有 exec: "curl": executable file not found。用 Alpine 时可以用 BusyBox 的 wget,但参数和 curl 不同。

错误二:--platform 和部署架构不一致。 在 Apple Silicon 上构建 linux/arm64,推到 amd64 节点后启动报 exec format error。构建命令里写 --platform linux/amd64,或者用 buildx 构建多架构 manifest。

错误三:以 root 运行。 镜像能跑,但违反最小权限。审计时 docker exec ... id 返回 uid=0(root) 就会被标记。创建专用用户,COPY --chown,USER 三步都要做。

错误四:把数据库密码写进镜像。 ENV LIBRARYD_DB_PASSWORD=... 会留在镜像层里,任何能拉镜像的人都能看到。密码、token 用运行时 -e、Kubernetes Secret 或挂载文件注入。镜像里只放非敏感的默认值。

错误五:Jib 的 extraDirectories 路径写错。 <from> 是相对于模块目录的路径,不是相对于仓库根。libraryd-app 模块里写 src/main/resources 是对的;写成 libraryd-app/src/main/resources 会找不到目录,构建报错。

错误六:忘记 JAVA_TOOL_OPTIONS 的引号。 ENV JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError 在 Dockerfile 里会被解析成两个 token,第二个可能被当成命令。用引号包成一个字符串。

边界:容器内存限制。 没有 -m 或编排内存限制时,MaxRAMPercentage 基于宿主机内存计算。本地开发机上可能给 JVM 开一个很大的堆,容器一跑就占满。测试镜像时加 --memory=512m,观察 JVM 是否按限制计算。

边界:基础镜像 tag 漂移。 eclipse-temurin:25-jre 是一个滚动 tag,同一个 tag 在不同时间可能对应不同的补丁版本。需要完全可复现时,用 digest 固定,例如 eclipse-temurin:25-jre@sha256:...。Jib 的 from.image 也支持 digest。

小结

  • 多阶段 Dockerfile 把 Maven 构建和 JRE 运行分开,最终镜像只带 jar 和运行时。
  • 先复制 POM 再复制源码,能让依赖层命中缓存;构建阶段跳过测试,测试留给 CI。
  • 非 root 用户、COPY --chown、USER 三步一起做,UID/GID 构建后用 id 确认。
  • 健康检查要检查 /health,不是进程存活;start-period 要给 JVM 和 Flyway 留时间。
  • JAVA_TOOL_OPTIONS 用来注入容器感知的堆参数和 OOM 退出策略。
  • Jib 的 jib:build/buildTar 不需要 Docker daemon,适合纯 Java 服务和没有 daemon 的 CI。
  • 本机运行阶段镜像 352 MB,健康检查 healthy,容器内 UID 为 10001。

镜像能启动只是发布的第一步。配置从哪来、迁移能不能回退、灰度时看什么指标、出错后怎么回到上一个版本,才是 16.3 节要解决的问题。

阅读导航:上一节:16.1 打包:fat jar、jlink 与运行时镜像 · 下一节:16.3 发布与回滚:配置、迁移与灰度 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发