本节目标:把
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 环境:
| 维度 | 多阶段 Dockerfile | Jib |
|---|---|---|
| 构建依赖 | Docker daemon | jib:build/buildTar 不需要 daemon |
| 缓存粒度 | Docker 层缓存 | 依赖/资源/类分层 |
| 额外文件 | COPY 任意文件 | extraDirectories |
| 调试 | 每层可见,容易进容器排查 | 黑盒,靠日志和 tar 检查 |
| 多架构 | buildx | from.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 发布与回滚:配置、迁移与灰度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。