跳到正文
Spring Boot 容器化:从能启动到可以回滚
Spring Boot 容器化:从能启动到可以回滚

Spring Boot 容器化:从能启动到可以回滚

用多阶段构建、Maven 缓存、非 root 运行、回环端口和不可变镜像串起 Spring Boot 部署,并补上健康检查、配置密钥、数据库迁移与回滚边界。

速读#

Spring Boot 打成 JAR、放进镜像并成功监听 8080,只能证明容器可以启动。真正可交付还要回答:构建是否对应已经测试的提交,最终镜像里是否只有运行所需内容,进程是否以非 root 身份运行,配置和密钥是否在镜像外,端口由谁访问,健康检查怎样判定成功,数据库变更是否兼容回滚,以及线上究竟运行哪个不可变版本。

我第一次写多阶段 Dockerfile 时让 AI 直接生成,镜像能跑就采用了;第二次想优化缓存,才发现自己说不清 COPY --from 和 Docker 层失效规则。后来留下的纪律不是“每个字符都背下来”,而是构建、网络、数据和回滚这些关键行为必须能够用日志和实验解释,否则容器只是把未知状态封装得更整齐。

一个单模块项目的最小 Dockerfile#

下面的示例假设项目使用 Java 21、Maven Wrapper,并在 pom.xml 中把最终产物固定为 target/app.jar。多模块项目、需要私有 Maven 仓库或原生库时,复制顺序和运行镜像要按实际项目调整,不能直接照抄:

1.7
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN --mount=type=cache,target=/root/.m2 \
./mvnw -B -ntp dependency:go-offline
COPY src/ src/
RUN --mount=type=cache,target=/root/.m2 \
./mvnw -B -ntp package -DskipTests
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
COPY --from=build --chown=10001:10001 \
/workspace/target/app.jar /app/app.jar
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

第一阶段包含 JDK、Maven 和源码,第二阶段只复制运行 JAR。这样通常能减少镜像体积和不必要的软件包,也降低构建工具留在生产镜像中的攻击面;具体能减少多少取决于基础镜像和依赖,不能用“必然从 GB 降到几百 MB”代替实际测量。运行镜像仍要定期更新和扫描,少装工具不等于自动没有漏洞。

ENTRYPOINT 使用 exec 形式,Java 进程能直接收到 Docker 发送的 SIGTERM;USER 避免应用默认以 root 运行。任意数字 UID 不一定适合所有项目:如果应用要写上传目录、生成临时文件或读取挂载证书,必须显式准备目录权限,不能为了通过启动又把整个挂载改成 777。Spring Boot 通常可写 /tmp,只读根文件系统等进一步收紧应在确认实际写入路径后再开启。

测试为什么没有直接塞进镜像构建#

示例中的 -DskipTests 跳过测试执行,前提是同一提交已经在 CI 或构建前通过完整测试。它不是性能开关,更不能让“JAR 成功生成”冒充“可以发布”。如果镜像由本地临时构建且没有其他质量门禁,就应去掉这个参数;如果 CI 先测试、再按精确 commit 构建镜像,则可以避免在两个阶段重复运行一套耗时测试。

还要知道 -DskipTests 通常仍会编译测试源码,-Dmaven.test.skip=true 连测试编译也会跳过,两者语义不同。无论选哪一种,都应在流水线日志里能指出测试在哪一步运行、对应哪个 SHA,而不是只在 Dockerfile 里留一句“已经测过”的假设。

构建上下文也需要控制。至少准备 .dockerignore,避免把 Git 历史、本地 target/、IDE 文件、日志和 .env 送给 Docker daemon 或进入缓存层:

.git
.idea
target
*.log
.env

如果构建需要私有仓库凭据,应使用 BuildKit secret 或 CI 临时凭据,不用 ARG MAVEN_PASSWORD 再幻想删除一层就能从镜像历史中消失。

pom.xml 先复制为什么能缓存,又为什么不总够用#

Docker 会按指令和输入内容判断构建缓存。先复制 Maven Wrapper 与 pom.xml,再执行依赖解析,源码每天变化时就不会让依赖步骤必然失效;修改依赖或插件版本时,这一层重新执行则是正确行为。BuildKit 的 /root/.m2 cache mount 还能让重新执行 Maven 步骤时复用本地仓库,而不必把整个仓库打进最终镜像。

dependency:go-offline 并不保证所有插件、profile 和运行路径所需制品都已经下载,第一次真正 package 时仍可能访问网络。父 POM、多模块、.mvn 配置、settings.xml 和私服镜像也会改变缓存输入;如果项目结构复杂,把所有 POM 按模块复制一遍可能比简单复制源码更难维护。缓存优化要看构建日志与耗时,不值得为了省几十秒制造一份没人敢改的 Dockerfile。

基础镜像标签同样会变化。开发阶段使用 maven:3.9-eclipse-temurin-21 方便接收修复,正式交付则应记录构建时的镜像 digest,并由依赖更新工具有计划地升级;永远固定旧 digest 会错过安全更新,只用可移动标签又让同一 Dockerfile 在不同时间产生不同基础层。可复现与更新需要同时管理,不是任选一个极端。

端口是否暴露取决于代理在哪里#

如果 Nginx 运行在宿主机,应用容器可以只发布到回环:

services:
api:
image: registry.example.com/my-api:${APP_VERSION}
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped

8080:8080 默认发布到所有宿主机地址,可能绕过预期的 Nginx、TLS 和认证入口;绑定 127.0.0.1 后,外部必须先经过宿主机代理。Docker 会管理自己的防火墙规则,单看 UFW 的默认拒绝不能证明 0.0.0.0 发布端口安全,因此从源头限制监听地址更可靠。

如果 Nginx 也在容器里,就不需要发布 8080,两个服务加入专用 Compose 网络,Nginx 使用 http://api:8080。容器内的 127.0.0.1 只指向当前容器,不能拿宿主机示例直接套用。EXPOSE 8080 只是镜像元数据,既不会自动发布端口,也不是访问控制。

部署后同时检查容器与宿主机:

Terminal window
docker compose up -d --build
docker compose ps
docker compose logs --tail=200 api
sudo ss -lntup
curl -fsS http://127.0.0.1:8080/actuator/health

最后一条假设项目启用了最小 Actuator 健康端点;若没有,应提供不会修改数据、能反映关键依赖状态的等价接口。只检查 TCP 端口打开,无法证明 Spring 上下文、数据库连接和必要配置已经就绪。

配置、密钥和日志都应在镜像之外#

数据库地址、环境名称和可调参数可以通过环境变量或外部配置文件注入,密码与 Token 则放在仓库外的受限文件、编排平台 secret 或短期凭据中。环境变量不是天然秘密:docker inspect、崩溃转储和调试日志都可能暴露它们,宿主机上能管理 Docker 的用户本来就接近 root 权限。真正的边界仍是最小权限、文件权限、日志脱敏和可执行的轮换流程。

应用日志优先输出到 stdout/stderr,由 Docker 与宿主机日志系统统一收集,并设置轮转上限;直接把无限增长日志写进容器可写层,会同时制造磁盘与排障问题。需要持久保存的业务文件使用明确卷或外部对象存储,镜像本身保持不可变;删除容器不应删除业务数据,但卷也不等于备份。

发布版本、数据库迁移和回滚是一组问题#

生产镜像不应只叫 latest。至少使用 Git commit、构建编号或语义版本标记,并记录最终 digest,部署记录才能回答线上究竟运行哪个内容。发布前拉取或构建目标镜像,启动后等待健康检查,再让 Nginx 切流;旧镜像与旧配置保留到验证完成,失败时能明确恢复上一版本。

数据库迁移决定回滚是否真实可行。新应用若已经把表结构改成旧版本无法读取,简单 docker compose up 换回旧镜像也不会恢复服务。更稳的发布采用向后兼容的 expand/contract:先增加新字段或表,让新旧版本都能工作;代码稳定后再迁移数据;确认没有回滚需求后,最后删除旧结构。不可逆迁移必须先备份并在副本上演练恢复时间。

Spring Boot 的优雅退出也要纳入代理与编排超时。Docker stop 先发送 SIGTERM,应用应停止接收新请求并在 stop_grace_period 内完成在途工作;Nginx 或负载均衡器先摘除实例,再终止容器,才能减少发布期间的 502。单实例项目做不到真正无中断,也应诚实定义允许的短暂停机和回滚步骤。

我现在怎样判断“容器化完成”#

我会检查同一 commit 的测试是否通过,Dockerfile 的关键层为什么这样排列,最终镜像是否以非 root 运行且不包含秘密,宿主机只监听预期地址,健康端点能区分启动与可用,日志不会无限占盘,数据有独立备份,镜像能按 digest 追踪,数据库变更允许回滚或有恢复方案。任何一项只停在“理论上应该”,都仍是部署缺口。

多阶段构建和回环端口是很好的起点,却不是完整交付。容器真正减少的是环境漂移,不会替我做权限、数据和发布判断;把这些边界补齐后,镜像才从一个能运行的压缩包,变成可以验证、升级和退回的生产制品。

版权许可

CC BY-NC-SA 4.0 本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

相关文章

s1oopX

登录 s1oopX