自在学

我们与你共同进步

  • 分类课程
  • 文章
  • 工作台
  • 订阅

  • 关于我们
  • 隐私政策
  • 使用条款

探索

  • 分类课程
  • 文章
  • 工作台
  • 订阅

网站信息

  • 关于我们
  • 隐私政策
  • 使用条款

加入社区

自在学学习社区微信二维码

微信扫码,交流学习

株洲市自在学教育科技有限公司© 2025 - 2026 版权所有

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

湘公网安备43020302000292号|湘ICP备2025148919号-1
分类课程工作台文章订阅
分类课程工作台文章价格

Spring Boot后端开发入门

  1. 01Spring Boot 概览
  2. 02选择工具与开始
  3. 03创建你的第一个 Spring Boot REST API
  4. 04为你的 Spring Boot 应用添加数据库访问
  5. 05配置并检查你的 Spring Boot 应用
  6. 06深入数据处理
  7. 07使用 Spring MVC 构建应用程序
  8. 08Project Reactor 和 Spring WebFlux
  9. 09Spring Boot 应用的测试能力
  10. 10保护你的 Spring Boot 应用
  11. 11部署你的 Spring Boot 应用
  12. 12更深入的响应式编程
正在加载课程章节内容
课程编程Spring Boot后端开发入门部署你的 Spring Boot 应用

把 TaskHub 稳稳地送进生产环境

上一部分,我们已经给 TaskHub 补上了认证、授权和测试。此时它在开发机上能启动,接口也能通过检查,很容易让人产生一种错觉:接下来不过是把文件传到服务器,再运行一次 java -jar。

真正的部署恰恰从这里开始。开发机上的进程只需要服务一位开发者,生产环境里的进程却要面对网络流量、数据库抖动、配置差异、版本切换和机器资源上限。应用启动成功,只能说明 Java 进程活着;它能不能接流量、出现问题能不能定位、更新失败能不能退回去,才决定这次发布是否完整。

这一部分继续使用贯穿课程的 TaskHub,项目基于 Spring Boot 4.1.0 和 Java 25。我们先把同一份代码打成可执行 JAR,在本机验收这个制品,再把它放进一个非 root、资源受限的容器。随后给它接上外部配置、数据库迁移、健康检查、标准输出日志和优雅停机。你会看到,所谓“部署”不是一条神奇命令,而是一条每一步都能验证、也都能撤回的交付链。

TaskHub 从代码验证到生产观察与回滚的完整部署交付链


先说清楚:我们到底在发布什么

一个很常见的混乱是:测试的是工作区代码,打包时又临时跳过测试;服务器上保留一份源码,然后在那里重新编译;容器里跑的版本叫 latest,却没人知道它对应哪个提交。等线上出错,大家手里有三份“看起来差不多”的代码,谁也说不清真正运行的是哪一份。

TaskHub 采用更简单的规则:一次发布只产生一份不可变候选制品,后续环境只改变配置,不重新编译代码。 如果采用进程部署,这份候选制品就是 JAR;如果采用本章的容器主线,最终候选制品就是带 digest 的镜像。JAR 验收帮助我们先排除应用打包问题,镜像一旦生成,测试、预发布和生产就推广同一个镜像内容,差别只来自数据库地址、密钥、日志级别和资源配额等外部配置。

先在项目根目录执行完整验证:

bash
./mvnw clean verify

verify 会经过编译、单元测试、集成测试和打包等 Maven 生命周期阶段。这次构建的末尾是:

text
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 1
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  8.396 s

这里的一个 Skipped 来自 PostgreSQL Testcontainers 测试:测试代码存在,但执行构建的机器当时没有可用的容器运行时。它和主动添加 -DskipTests 不是一回事,却同样不能在发布记录里悄悄抹掉。进入生产流水线时,应在具备容器运行时的执行器上补跑这项测试,并把跳过数归零。课程这里保留原始结果,是为了让你看到门禁报告应该如实描述“测了什么、没测什么”,而不是只截取一行绿色的 BUILD SUCCESS。

具体耗时和测试数量会随机器与代码变化,我们真正关心的是失败数为零、跳过项有明确原因,以及 BUILD SUCCESS。如果测试失败,发布流程应该在这里停止,不要用 -DskipTests 把红灯遮住。跳过测试只适合在前置验证已经完成后避免同一构建阶段重复执行,不能成为生产发布的默认门禁。

构建完成后,先确认产物,而不是凭印象手写文件名:

bash
ls -lh target/taskhub-*.jar

这次得到的可执行 JAR 大小是 61 MB:

text
-rw-r--r--  ...  61M  ...  target/taskhub-0.0.1-SNAPSHOT.jar

为什么这个文件有几十 MB?因为它不只装了我们写的 TaskController、TaskService 和实体类,还装入了 Spring Boot、Spring MVC、Spring Data JPA、数据库驱动以及嵌入式 Web 服务器。服务器不需要再单独安装 Tomcat,也不需要拼一长串 classpath。Spring Boot 的 Maven 插件会重打包普通 JAR,把应用类放进 BOOT-INF/classes,依赖放进 BOOT-INF/lib,再通过启动器找到真正的 TaskHubApplication。

可以直接让 Spring Boot 的工具模式列出它的镜像层:

bash
java -Djarmode=tools \
  -jar target/taskhub-0.0.1-SNAPSHOT.jar list-layers
text
dependencies
spring-boot-loader
snapshot-dependencies
application

如果再用 jar tf 查看归档,会看到应用类位于 BOOT-INF/classes、依赖位于 BOOT-INF/lib,根目录还有 BOOT-INF/layers.idx。这也是 java -jar 能启动整个应用的原因。并不是 Java 原生就会自动扫描嵌套依赖,而是清单文件里的 Spring Boot 启动器先建立合适的类加载路径,再调用我们写的 main 方法。

JAR 的大小不是首要优化目标。生产部署更关心它是否可重复构建、是否能追溯到版本、依赖是否经过检查,以及更新时能否复用镜像层。为了省几 MB 把运行依赖移到服务器,会重新引入“这台机器到底装了什么”的问题。

不要让 SNAPSHOT 进入版本账本

课程中继续使用 0.0.1-SNAPSHOT 便于迭代,但正式发布应给每次构建一个不会变化的版本,例如 1.4.2,并记录源代码提交。镜像也使用 taskhub:1.4.2 这样的不可变版本标签,而不是只保留 latest。

标签本身仍然可以被覆盖,所以进入镜像仓库后还要记录 digest:

bash
docker image inspect taskhub:1.4.2 \
  --format '{{index .RepoDigests 0}}'

推送到仓库后,这条命令会返回带 sha256: 的完整仓库引用。把命令实际返回的值写入发布记录,不要手工截取或猜测摘要。

发布记录至少要把“应用版本、源代码提交、镜像 digest、数据库迁移版本、发布时间”放在一起。回滚时才能明确说“退回 digest 为某个值的 1.4.1”,而不是含糊地说“把旧镜像拉回来”。

可重复构建不等于字节永远相同

很多人第一次听到“同一份制品穿过所有环境”,会把它理解成“任何时候重新执行构建,JAR 的每一个字节都必须相同”。字节级可重复当然很有价值,但我们眼前更基本的要求是:进入测试的那个 JAR 不在生产前被重新打包,进入预发布的那个镜像也不在生产前临时加文件。构建只发生一次,推广的是已经验证过的对象。

这条规则能消掉一大类环境差异。比如有人在生产构建时使用了另一版 JDK,某个动态依赖恰好更新,或者流水线在复制资源前执行了额外脚本。源码提交看起来没变,运行内容却已经不是测试过的那一份。如果推广 JAR 或镜像 digest,这些变化就没有机会在最后一公里混进来。

流水线可以把过程拆成三个清楚的阶段:第一阶段运行 clean verify,证明这个提交能够生成并运行 JAR;第二阶段在受控容器构建中锁定同一提交与依赖,生成最终镜像并做安全检查;第三阶段只把镜像 digest 写入不同环境的部署声明。环境审批改变“这个 digest 能不能进入生产”,不会触发新的编译。

制品仓库也要有保留策略。只留下当前版本虽然省空间,却会在紧急回滚时逼着团队重新构建旧提交。重新构建会重新下载依赖与基础镜像,得到的内容未必与当时一致。至少在回滚窗口内保留当前版、上一版以及对应的构建记录,成本通常远低于故障时临时复原环境。


先用 java -jar 验收制品

容器会增加网络、文件系统和用户权限等边界。在进入这些变量之前,先证明 JAR 自己能运行,可以把“应用打包有问题”和“容器配置有问题”分开。

开发配置使用内存数据库,适合做一次最短验收:

bash
java -jar target/taskhub-0.0.1-SNAPSHOT.jar \
  --server.port=8080

日志中应该能找到启动完成信息:

text
Started TaskHubApplication in 2.892 seconds

另开一个终端检查健康端点:

bash
curl --fail --silent http://127.0.0.1:8080/actuator/health
json
{"groups":["liveness","readiness"],"status":"UP"}

这里使用 --fail 很有用:当服务返回 400 或 500 段状态码时,curl 会以非零状态退出,脚本不至于把一个错误页面当成成功。--silent 则让自动化输出保持简洁。

如果需要传 JVM 参数,参数必须出现在 -jar 之前;Spring Boot 的应用参数则写在 JAR 文件之后:

bash
java -Xms256m -Xmx512m \
  -Duser.timezone=Asia/Shanghai \
  -jar target/taskhub-0.0.1-SNAPSHOT.jar \
  --server.port=8080

下面这种顺序经常出现在旧文章里,却不会按预期设置 JVM 系统属性:

bash
# 错误示范:-D 参数放到了 -jar 和文件名之后
java -jar target/taskhub-0.0.1-SNAPSHOT.jar -Dspring.profiles.active=prod

激活生产配置更推荐环境变量。这样命令本身不需要因平台而变化:

bash
SPRING_PROFILES_ACTIVE=prod \
DB_URL='jdbc:postgresql://127.0.0.1:5432/taskhub' \
DB_USERNAME='taskhub_app' \
DB_PASSWORD='本地临时值' \
java -jar target/taskhub-0.0.1-SNAPSHOT.jar

这条命令只用于理解变量如何进入 Spring 环境。真实密钥不要直接敲进共享终端,因为它可能进入 shell 历史、进程信息或录屏。进入容器编排平台后,应由密钥系统把密码作为受控变量或只读文件注入。

什么叫“验收通过”

看到启动横幅不算通过。最小验收至少包含四件事:进程启动、健康端点返回成功、受保护的业务接口仍要求认证、日志里没有数据库迁移或配置绑定错误。如果版本包含写操作,还要做一次创建与查询的冒烟测试,并清理测试数据。

例如,未携带凭据访问任务接口应保持上一部分配置的安全边界:

bash
curl --silent --output /dev/null --write-out '%{http_code}\n' \
  http://127.0.0.1:8080/api/tasks
text
401

使用部署验收专用账号后再检查正常响应。不要把用户名和密码写进镜像或流水线日志;下面的密码由临时变量或密钥注入:

bash
curl --fail --silent \
  --user "learner:${TASKHUB_SMOKE_PASSWORD}" \
  http://127.0.0.1:8080/api/tasks?page=0\&size=1

冒烟测试验证的是“这个制品在目标配置下能提供关键能力”,不是重跑整套测试。整套测试应该在打包之前完成,两者解决的问题不同。


让配置留在环境里

同一个 JAR 要穿过开发、测试和生产环境。要做到这一点,代码里就不能写死数据库主机、账号、日志格式和管理端点策略。Spring Boot 会把 YAML、环境变量、系统属性、命令行参数等合并为一个配置环境,优先级更高的来源可以覆盖较低的来源。

TaskHub 的基础配置保留适合本地学习的默认值:

yaml
# src/main/resources/application.yml
spring:
  application:
    name: taskhub
  datasource:
    url: jdbc:h2:mem:taskhub;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    username: sa
    password: ""
  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false
  flyway:
    enabled: true
 
taskhub:
  display-name: TaskHub 任务看板
  default-page-size: 10

生产 Profile 只写生产规则,不把真实凭据放进去:

yaml
# src/main/resources/application-prod.yml
spring:
  datasource:
    url: ${DB_URL}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
  jpa:
    hibernate:
      ddl-auto: validate
  lifecycle:
    timeout-per-shutdown-phase: 20s
 
server:
  shutdown: graceful
 
management:
  endpoint:
    health:
      show-details: never
 
logging:
  structured:
    format:
      console: logstash

这里故意不给 DB_PASSWORD 准备一个“方便启动”的默认值。生产配置缺少密码时,应用应该启动失败,让问题在接流量之前暴露。change-me 之类的默认密码看似照顾了开发体验,实际很容易原封不动地进入生产。

环境变量名与属性名之间遵循宽松绑定。一般做法是把点替换为下划线、删除连字符并转为大写。例如:

Spring 属性环境变量
spring.profiles.activeSPRING_PROFILES_ACTIVE
spring.datasource.urlSPRING_DATASOURCE_URL
taskhub.default-page-sizeTASKHUB_DEFAULTPAGESIZE
logging.level.com.welearn.taskhubLOGGING_LEVEL_COM_WELEARN_TASKHUB

我们在 YAML 中使用 ${DB_URL} 是为了让团队采用更短、语义明确的部署变量名;直接设置 SPRING_DATASOURCE_URL 也可以。两种方式选一种并写进部署约定,不要在同一套环境里混着设,否则排查覆盖顺序会很痛苦。

环境变量与密钥文件在运行时注入同一份 TaskHub 制品

密钥为什么不能烤进镜像

镜像层是可复用、可推送的内容。一旦在 Dockerfile 中写过 ENV DB_PASSWORD=...,即使后面再删除,它仍可能留在构建历史或旧层中。构建参数也不是密钥保险箱。镜像应该能公开给拥有仓库读取权限的人,而不携带任何环境的通行证。

本地联调可以使用不提交到 Git 的 .env.prod:

dotenv
SPRING_PROFILES_ACTIVE=prod
DB_URL=jdbc:postgresql://postgres:5432/taskhub
DB_USERNAME=taskhub_app
DB_PASSWORD=仅用于本地联调

并在 .gitignore 中排除它:

gitignore
.env*
!.env.example

.env.example 只保留变量名和说明,不放可用值。生产平台应改用自己的 Secret 机制。即使变量是运行时注入的,有容器检查权限的人仍可能看到它,所以权限控制、轮换和审计仍然需要做。

配置错误应该尽早让启动失败

外部化配置不是把所有属性都改成可选。数据库密码、可信签发方、外部服务地址这类生产必需项,如果缺失就应该在应用接收流量前失败。一个带默认空字符串的配置也许能让启动日志更好看,却会把错误推迟到第一次真实请求,定位反而更难。

TaskHub 自己的配置使用 @ConfigurationProperties 和 Bean Validation,就是为了把一组相关属性一次绑定并校验。例如 taskhub.default-page-size 必须处于合理范围,display-name 不能为空。Spring 在创建配置 Bean 时发现非法值,会阻止应用上下文完成刷新,并把具体属性路径写进启动错误。这比业务代码运行到一半才得到 NullPointerException 清楚得多。

发布脚本应主动验证“变量存在”,但不要打印变量值。Shell 中可以这样检查:

bash
: "${DB_URL:?缺少 DB_URL}"
: "${DB_USERNAME:?缺少 DB_USERNAME}"
: "${DB_PASSWORD:?缺少 DB_PASSWORD}"

这三行只在变量缺失时终止脚本。不要紧接着执行 env 或打开 set -x,否则检查通过后,密钥反而可能被命令跟踪输出。流水线日志应显示“生产数据库配置已注入”,而不是显示实际连接凭据。

配置变更也要纳入版本记录。镜像不变但 LOGGING_LEVEL_COM_WELEARN_TASKHUB 从 INFO 调到 DEBUG,应用行为和数据暴露风险都可能变化。至少记录由谁、在什么时间、把哪个非敏感配置从什么策略改成了什么策略。密钥轮换只记录版本或引用标识,不记录密钥本身。

如果平台把密钥挂载为文件,可以用配置树让 Spring 读取。例如把一个名为 spring.datasource.password 的只读文件挂在 /run/secrets/,再注入:

text
SPRING_CONFIG_IMPORT=optional:configtree:/run/secrets/

文件名会成为属性名,文件内容成为属性值。这样密码不需要出现在镜像、Compose 文件或普通环境变量列表中。optional: 表示开发环境没有这个目录时仍可启动;生产环境是否必须存在,则应由部署模板或启动校验负责。

不要为了排错打印完整 Spring Environment、数据源 URL 或请求头。数据库 URL 可能携带账号参数,Authorization、Cookie 和密钥更不应该进入日志。排错信息要回答“读取了哪个配置来源、连接哪个非敏感主机、失败在哪一阶段”,而不是把配置值全部摊开。


数据库结构也属于发布内容

代码可以换回旧镜像,数据库结构却不会跟着自动倒退。假设新版本把 tasks 表增加了 version 列,而生产数据库还没有这列,JPA 在 ddl-auto: validate 下会拒绝启动。这不是麻烦,而是保护:生产环境不应该让 Hibernate 根据实体差异随意改表。

更可靠的做法是使用 Flyway 管理版本化迁移。每一次结构变化都变成一个随代码提交的 SQL 文件:

text
src/main/resources/db/migration/
├── V1__create_tasks.sql
└── V2__add_task_version.sql

例如第二次迁移可以先增加一个与旧代码兼容的列:

sql
ALTER TABLE tasks
    ADD COLUMN version BIGINT NOT NULL DEFAULT 0;

应用启动时,Flyway 会对照 flyway_schema_history,按版本执行尚未应用的脚本,然后 Hibernate 再验证实体与表结构是否一致。迁移执行过后会留下校验和,不要直接改写已经进入共享环境的 V1 或 V2。需要修正时新增 V3,这样每个环境都能沿同一条历史前进。

TaskHub 第一次启动空数据库时,实际出现的关键顺序是:

text
Database: jdbc:h2:mem:taskhub (H2 2.4)
Successfully validated 1 migration
Current version of schema "PUBLIC": << Empty Schema >>
Migrating schema "PUBLIC" to version "1 - create tasks"
Successfully applied 1 migration to schema "PUBLIC", now at version v1
Started TaskHubApplication in 2.892 seconds

这里用内存数据库验收了可执行 JAR,所以日志里是 H2 和 PUBLIC;生产 Profile 连接 PostgreSQL 后,会变成 PostgreSQL 的连接信息与 schema 名称,但“校验历史—判断当前版本—执行待迁移脚本—启动应用”的顺序不变。真正需要放进发布记录的是迁移版本、耗时和结果,不是数据库密码或完整连接串。

多副本启动时,谁来执行迁移

小规模应用让 Flyway 随进程启动是可行的,它会通过 schema history 协调迁移。但生产滚动发布还要考虑迁移耗时、锁表范围和多个新副本同时启动。更稳妥的发布链通常把迁移拆成一个只运行一次的步骤:先备份并执行迁移,验证成功后再扩大新版本副本。

无论迁移由谁触发,都应遵循“扩展—切换—收缩”的节奏。假设要把一个字段改名:

先增加新列,暂时保留旧列。新代码同时兼容两种结构,旧版本也仍能运行。

发布能双写或兼容读取的新版本,完成历史数据回填,并观察错误率与数据一致性。

等所有旧版本退出、回滚窗口结束后,再通过后续迁移移除旧列和兼容代码。

这样即使新版本需要退回,旧代码仍认识数据库结构。相反,如果第一步就删除旧列,应用回滚会立刻撞上缺失字段,所谓“一键回滚”只剩一个按钮外观。

数据库迁移发布前要在接近生产数据量的副本上测耗时与锁行为。ALTER TABLE 在一个空测试库中只用几十毫秒,并不代表它面对数千万行数据仍然安全。涉及不可逆数据转换时,要提前准备备份、校验查询和恢复演练,而不是把“有备份”当成一句口号。

迁移失败时不要急着点 repair

Flyway 的校验和不一致,常见原因是有人改了已经执行过的脚本。repair 能修改 schema history,但它不是“让红字消失”的通用按钮。先判断数据库实际执行了什么、仓库里的脚本为何变化、其他环境处于哪个版本,再决定是还原原脚本、补一个新迁移,还是在确认数据库状态后修复历史表。

迁移脚本还要尽量做到一次执行目标明确。把建表、全量数据清洗、删除旧列和创建大索引全部放进一个文件,任何一步失败都很难判断恢复点。结构扩展、数据回填和结构收缩拆开后,每一步都可以设置独立的观察窗口,也更容易安排低峰期。

备份只有经过恢复验证才算回退能力。最实用的演练不是看对象存储里有没有备份文件,而是在隔离环境恢复它,运行 Flyway 信息检查,再用当前版与上一版 TaskHub 分别做关键查询。这样能同时验证备份完整性、迁移历史和应用兼容性。


用分层 Dockerfile 装下同一份 JAR

现在 JAR 已经通过验收,我们再给它建立运行边界。最短 Dockerfile 只需基础 JRE、COPY 和 ENTRYPOINT,但它会把几十 MB 的依赖与几 KB 的业务代码放进同一层。每次只改一个 Controller,也要重新传输整个 JAR 层。

Spring Boot 的可执行 JAR 内含 layers.idx,默认把内容分成稳定依赖、启动器、快照依赖和应用代码。我们用 tools 模式提取这些层,再分别复制到运行镜像:

dockerfile
# syntax=docker/dockerfile:1.7
 
FROM eclipse-temurin:25-jdk AS build
WORKDIR /workspace
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw -q -DskipTests dependency:go-offline
COPY src/ src/
RUN ./mvnw -q -DskipTests package
 
FROM eclipse-temurin:25-jre AS extractor
WORKDIR /builder
COPY --from=build \
    /workspace/target/taskhub-0.0.1-SNAPSHOT.jar application.jar
RUN java -Djarmode=tools -jar application.jar \
    extract --layers --destination extracted
 
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/* \
    && useradd --system --uid 10001 --create-home taskhub
 
WORKDIR /application
COPY --from=extractor /builder/extracted/dependencies/ ./
COPY --from=extractor /builder/extracted/spring-boot-loader/ ./
COPY --from=extractor /builder/extracted/snapshot-dependencies/ ./
COPY --from=extractor /builder/extracted/application/ ./
 
USER 10001
EXPOSE 8080
 
HEALTHCHECK --interval=10s --timeout=3s \
    --start-period=30s --retries=5 \
    CMD curl --fail --silent \
        http://localhost:8080/actuator/health/readiness || exit 1
 
ENTRYPOINT ["java", "-jar", "application.jar"]

这份文件有几个容易被一眼略过的细节。

第一,构建、提取和运行是三个阶段。构建阶段使用 JDK 和 Maven Wrapper 生成 JAR;提取阶段只负责理解 Spring Boot 的层;运行阶段没有 Maven、源码和编译缓存,只保留 JRE、健康检查所需的 curl 与应用。多阶段构建的价值不只是缩小镜像,也减少了不需要出现在生产环境里的工具。

Dockerfile 中两处 -DskipTests 有一个严格前提:进入 docker build 之前,相同提交已经通过 ./mvnw clean verify,这里不重复跑测试,只在受控构建阶段解析依赖和打包。流水线必须把验证与镜像构建绑定在同一次作业上,任何源码变化都让前置验证失效。如果团队选择“先产出 JAR,再把完全相同的 JAR 推进镜像”的制品推广方式,可以删除 build 阶段,改为 COPY target/taskhub-*.jar application.jar;但此时要由制品仓库保证输入 JAR 正是已经验收的那一份,不能让开发者随手从工作区拿文件。

第二,四次 COPY 的顺序与变化频率一致。依赖通常最稳定,应用代码最常变化,因此后续构建更容易命中前面的缓存。分层不会让单个 JAR 突然变小,它优化的是镜像构建、传输和存储。

第三,最终进程以固定 UID 10001 运行。容器里的 root 仍然是高权限身份,不能因为进程被“关在容器里”就忽略它。固定数字 ID 还能让挂载卷和平台安全策略更容易对齐。镜像中应用文件由 root 拥有并不可写没有问题,运行用户只需要读取它们;真正需要写入的临时目录由运行平台明确挂载。

第四,ENTRYPOINT 使用 JSON 数组,也就是 exec 形式。Java 进程会直接成为容器主进程,docker stop 发出的 SIGTERM 能传给它。写成 ENTRYPOINT java -jar application.jar 会多经过一层 shell,信号转发和退出码处理都更容易出问题。

EXPOSE 8080 没有开放端口

这行只是镜像元数据,表达“应用预计监听 8080”。它不会修改主机防火墙,也不会把容器端口发布到宿主机。真正建立端口映射的是运行时参数:

bash
docker run -p 127.0.0.1:8080:8080 taskhub:1.4.2

这里把主机监听地址限制为 127.0.0.1,适合前面还有反向代理的单机部署。如果写成 -p 8080:8080,Docker 通常会在所有主机地址发布端口。是否对公网开放还取决于防火墙和云网络规则,但不要把这件事交给运气。

容器处于同一个自定义网络时,彼此可以直接用服务名和容器端口通信,不需要把 PostgreSQL 的 5432 端口发布到主机。数据库端口只在内部网络可见,暴露面更小。

Spring Boot 可执行 JAR 按变化频率拆成可复用镜像层

构建镜像并做静态检查

先完成测试门禁,再由多阶段 Dockerfile 在同一提交上构建镜像:

bash
./mvnw clean verify
docker build --pull -t taskhub:1.4.2 .

--pull 会检查基础镜像是否有更新。为了完全可重复,正式流水线可以把基础镜像固定到 digest,并由自动化工具定期提出升级;固定 digest 后不要忘记更新安全补丁,否则“可重复”会变成“永远停在旧漏洞”。

构建后检查运行用户、入口和端口声明:

bash
docker image inspect taskhub:1.4.2 \
  --format 'user={{.Config.User}} entrypoint={{json .Config.Entrypoint}} exposed={{json .Config.ExposedPorts}}'

检查实际结果中的 user 是否为 10001,入口是否为 JSON 数组,端口元数据是否只有 8080/tcp。这一步只读取镜像配置,不代表镜像已经成功启动。

再确认镜像没有意外带入源码、Git 目录和本地环境文件。项目根目录可以加入:

dockerignore
.git
.idea
.vscode
.env*
secrets/
README.md
target/

当前 Dockerfile 在构建阶段复制 src,所以不能把源码排除;本机的 target 则不参与镜像构建,避免旧 JAR 混入上下文。若团队改成在 Docker 外打包、镜像只接收已验证 JAR,就需要反过来排除 src,并只放行目标 JAR。.dockerignore 应与构建主线匹配,而不是从别的项目复制一份看起来很严格的清单。

不写 Dockerfile 的另一条路

Spring Boot Maven 插件也能通过 Cloud Native Buildpacks 直接生成 OCI 镜像:

bash
./mvnw spring-boot:build-image \
  -Dspring-boot.build-image.imageName=taskhub:1.4.2

Buildpacks 会识别 JAR、选择运行时、处理应用层,并让镜像以非 root 用户运行。它适合希望统一镜像基线、减少手写 Dockerfile 的团队。代价是底层构建由 builder 与 buildpack 决定,想安装特定系统包或精细控制每一步时,需要理解它们的扩展方式。

两条路线并不存在“练习版”和“专业版”的高低之分。TaskHub 选择 Dockerfile,是因为这一部分需要把分层、用户、健康检查和信号处理逐项展开;如果组织已经维护成熟的 Buildpacks 平台,直接使用它反而更一致。无论选哪条路,镜像都应该来自已验证制品,且不能携带生产密钥。

镜像不是一台需要登录维护的小服务器

传统服务器出问题时,人们习惯 SSH 上去安装工具、改配置、替换一个 class 文件。把这套习惯搬进容器,会让运行状态迅速偏离镜像声明。容器内临时修改在重建后会消失,没重建时又无法从 Git 和 Dockerfile 还原,最终形成只有某个实例拥有的“手工补丁”。

正确的修复路径是修改源码或构建文件,生成新版本镜像,通过相同发布链替换实例。临时进入容器只用于受控诊断,诊断完成后也应该销毁被操作过的实例。生产镜像可以缺少编辑器、编译器和网络诊断大全;需要深度排障时,平台可以启动权限受限的临时调试容器,而不是永久扩大应用镜像的攻击面。

镜像也不会自动更新系统安全补丁。基础镜像 tag 指向新内容时,正在运行的旧容器不会自己变化。团队需要定期重新构建、扫描并发布,即使 TaskHub 业务代码没有改。固定 digest 保证构建可追溯,自动更新流程负责提醒新 digest,两者缺一不可。

构建上下文同样属于供应链边界。Docker 客户端会把上下文中未忽略的文件交给构建器,因此 .dockerignore 不只是提速配置。它能避免本地密钥、测试报告、编辑器缓存和 Git 历史意外进入远端构建环境。每次增加新的敏感文件目录,都应同步检查忽略规则。


把应用和 PostgreSQL 接起来

单独运行一个容器只能证明镜像会启动。TaskHub 的生产配置依赖 PostgreSQL,我们用 Compose 表达本地或单机部署关系。下面的文件重点展示网络、健康依赖、只读文件系统和资源约束,不把密码写进 YAML:

yaml
services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_DB: taskhub
      POSTGRES_USER: taskhub_app
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    secrets:
      - postgres_password
    volumes:
      - taskhub-db:/var/lib/postgresql/data
    networks: [backend]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U taskhub_app -d taskhub"]
      interval: 5s
      timeout: 3s
      retries: 10
 
  taskhub:
    image: ${TASKHUB_IMAGE:-taskhub:1.4.2}
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      SPRING_PROFILES_ACTIVE: prod
      DB_URL: jdbc:postgresql://postgres:5432/taskhub
      DB_USERNAME: taskhub_app
      SPRING_CONFIG_IMPORT: optional:configtree:/run/secrets/
      JAVA_TOOL_OPTIONS: >-
        -XX:InitialRAMPercentage=25
        -XX:MaxRAMPercentage=65
    secrets:
      - source: postgres_password
        target: spring.datasource.password
    ports:
      - "127.0.0.1:8080:8080"
    networks: [backend]
    read_only: true
    tmpfs:
      - /tmp:size=64m,mode=1777
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    pids_limit: 200
    mem_limit: 768m
    cpus: 1.0
    stop_grace_period: 30s
    restart: unless-stopped
 
networks:
  backend:
 
volumes:
  taskhub-db:
 
secrets:
  postgres_password:
    file: ./secrets/postgres_password.txt

生产平台不会把密钥文件放在项目旁边,这里只是演示 Compose 如何挂载 Secret。secrets/ 必须加入 .gitignore,文件权限也应限制。真正的生产值由平台的密钥存储创建,不经过源码仓库。

depends_on 加 service_healthy 只解决“启动 TaskHub 前,先等 PostgreSQL 健康”。它不保证数据库以后永远可用,也不会替代应用里的超时、连接池和错误处理。数据库重启后,TaskHub 能否恢复连接,仍要通过测试和监控验证。

启动并观察状态:

bash
docker compose up -d
docker compose ps

在 docker compose ps 的实际结果里,先等 PostgreSQL 进入 healthy,再确认 TaskHub 进入 healthy。如果数据库只有 5432/tcp 而没有 主机端口->5432/tcp,说明它没有发布到宿主机;TaskHub 则应显示回环地址到容器 8080 的映射。

注意 PostgreSQL 只显示 5432/tcp,没有主机端口映射;TaskHub 才通过回环地址发布。应用连接数据库使用 postgres:5432,其中 postgres 是 Compose 的服务名,不是 localhost。容器里的 localhost 指容器自己,这是第一次容器化最常见的连接错误之一。

不要把 container_name 当作服务发现方案,也不要依赖容器 IP。容器被替换后 IP 可以变化,Compose 网络会维护稳定的服务名。应用应该连接 postgres,而不是某个检查时看到的 172.x.x.x 地址。

持久化责任属于数据库卷,不属于应用容器

taskhub-db 数据卷保存 PostgreSQL 数据,删除 TaskHub 容器不会删除任务记录。应用容器本身则应保持无状态:它被停止、替换或移动后,只要拿到相同配置并连上数据库,就能继续工作。这个边界让扩容与回滚成为“替换进程”,而不是“搬运某台机器里的文件”。

不过,数据卷不是备份。误删数据、错误迁移和损坏会立刻写进同一个卷;宿主机磁盘损坏时,卷也可能一起丢失。备份需要独立介质、保留周期、加密和恢复测试。Compose 的 named volume 适合教学与单机部署,真正的生产数据库通常由专门的数据库平台管理复制、故障转移和备份。

应用也不应把用户上传或导出结果随手写进根文件系统。只读根文件系统会让这种设计在测试阶段直接失败,提醒我们把长期对象放进对象存储,把临时文件放进有大小限制的 /tmp,把数据库状态留给数据库。容器能随时重建,是架构约束,不是运行口号。

Compose 网络把服务放进同一条逻辑网络,但并不自动提供传输加密、细粒度授权或跨主机高可用。数据库仍需要独立账号、最小权限和连接加密策略。TaskHub 使用 taskhub_app 账号,只授予业务 schema 所需权限;执行迁移的账号如果需要 DDL 权限,可以与日常运行账号分离,缩小应用被利用后的影响范围。


健康检查不是一句“返回 UP”

生产平台至少要区分两个问题:这个进程是否已经坏到只能重启,以及这个实例此刻是否适合接收新流量。把它们都塞进 /actuator/health,会产生很危险的自动化行为。

Spring Boot Actuator 提供两组探针:

探针回答的问题失败后的合理动作
liveness应用内部状态还能否自行恢复重启这个实例
readiness实例现在能否接收流量暂时从流量入口移除

在非 Kubernetes 环境中也可以显式启用探针,并把它们额外映射到主业务端口:

yaml
management:
  endpoint:
    health:
      probes:
        enabled: true
        add-additional-paths: true
      show-details: never
  endpoints:
    web:
      exposure:
        include: health,info,metrics

这样除了 /actuator/health/liveness 与 /actuator/health/readiness,主端口上还会有 /livez 与 /readyz。如果管理端点使用独立端口,附加主端口路径尤其重要:管理端口健康,不代表真正承接业务请求的连接器也健康。

安全配置只公开探针状态,不公开其他管理能力:

java
authorize.requestMatchers(
        "/actuator/health",
        "/actuator/health/liveness",
        "/actuator/health/readiness",
        "/livez",
        "/readyz")
    .permitAll();
 
authorize.requestMatchers("/actuator/**")
    .hasRole("ADMIN");

公开响应保持最少信息:

bash
curl --fail --silent http://127.0.0.1:8080/livez
curl --fail --silent http://127.0.0.1:8080/readyz
text
{"status":"UP"}
{"status":"UP"}

TaskHub 已经有一个检查任务仓库的 HealthIndicator。是否把它加入 readiness,要根据业务行为决定。如果数据库不可用时任务接口完全没有可提供的能力,把仓库检查加入 readiness 是合理的;如果应用仍能提供缓存读取或降级页面,直接让所有实例退出流量可能比返回受控错误更糟。

可以显式指定 readiness 组成:

yaml
management:
  endpoint:
    health:
      group:
        readiness:
          include: readinessState,taskRepository

不要把数据库、缓存或外部 API 放进 liveness。 共享数据库短暂故障时,如果所有实例的 liveness 同时失败,平台会把它们一起重启。重启修不好数据库,只会制造连接风暴和更长的恢复时间。liveness 应关注应用自身无法恢复的状态。

存活探针、就绪探针与优雅停机在发布过程中的职责

启动慢与运行坏是两回事

JVM 预热、数据库迁移和缓存加载可能让启动持续几十秒。Dockerfile 的 HEALTHCHECK 使用 start-period,避免启动窗口中的失败过早计数。Kubernetes 中可以为启动很慢的应用配置 startupProbe,让 liveness 在启动探针成功后再接管。

探针参数不能靠复制:

  • timeout 要大于一次正常探测的高分位耗时,但不能长到探针线程堆积。
  • period 与 failureThreshold 一起决定发现故障需要多久。
  • 启动宽限要覆盖真实冷启动,而不是用一个夸张的大值掩盖启动退化。
  • readiness 恢复也要观察,避免实例在临界状态反复进出流量池。

Docker 的 HEALTHCHECK 会给容器增加 healthy 或 unhealthy 状态,但 Docker Engine 不会仅因为 unhealthy 就自动重启普通容器。是否重启、摘流或告警,由 Compose 之外的守护逻辑或编排平台决定。健康检查负责提供事实,恢复策略负责采取动作,这两个角色不要混为一谈。

从失败信号判断问题在哪一层

部署后看到“服务不可用”,先不要把所有动作都归结为重启。不同信号指向不同层次。

如果容器反复退出,先看退出码、OOM 标记和启动日志。配置绑定失败、JAR 与 Java 版本不匹配、迁移失败都发生在 readiness 之前,流量系统还没有机会参与。此时继续扩大副本只会复制同一个错误。

如果 liveness 成功而 readiness 失败,说明进程自身仍可运行,但它主动拒绝流量。可能是启动任务未完成,也可能是被纳入 readiness 的任务仓库不可用。应该检查数据库、连接池和依赖状态,而不是马上杀掉进程。依赖恢复后 readiness 可以自行回到成功,避免不必要的重启。

如果两个探针都成功,业务请求却大量返回 500,说明探针覆盖得太浅或业务路径出现局部错误。探针不能替代冒烟测试、请求指标和业务指标。一个只返回常量 UP 的端点能证明 HTTP 线程还能执行,却不能证明任务查询、认证和事务真的可用。

如果只有某个实例失败,比较该实例的版本 digest、配置引用、节点资源和日志;如果所有实例同时失败,优先检查共享数据库、入口、证书和刚发生的配置变更。这个判断能避免把共享依赖故障误诊为每个实例都“随机坏了”。

恢复动作也要有上限。自动重启每分钟发生几十次时,继续重启只会消耗资源并冲掉最早的错误现场。平台应进入退避、停止扩散并告警,让人根据事实处理。自动化的目标不是永远做动作,而是在明确条件下做正确动作。


容器日志应该流向标准输出

旧式服务器部署常把日志写到 /var/log/taskhub/application.log,再配置应用自己滚动文件。容器更适合把日志写到 stdout 和 stderr,由容器运行时统一收集、轮转和转发。容器是可替换的,日志不应该随可写层一起消失,也不应该要求运维人员进入容器翻文件。

Spring Boot 默认就会输出控制台日志。生产环境可开启内置结构化格式:

yaml
logging:
  structured:
    format:
      console: logstash
  level:
    root: INFO
    com.welearn.taskhub: INFO

一条启动日志会成为单行 JSON,日志平台可以按字段查询:

json
{"@timestamp":"2026-08-17T06:20:11.183Z","message":"Started TaskHubApplication","logger_name":"com.welearn.taskhub.TaskHubApplication","thread_name":"main","level":"INFO"}

结构化不等于把所有对象序列化进日志。任务描述可能包含用户输入,认证头和数据库密码更不能记录。建议为请求保留 traceId 或 requestId、方法、路径模板、状态码、耗时和稳定的错误代码;不要记录完整令牌、Cookie、敏感请求体和未脱敏异常上下文。

查看最近日志:

bash
docker compose logs --since=10m --tail=200 taskhub

持续观察:

bash
docker compose logs --follow taskhub

不要把 --follow 当成监控系统。它适合部署后的短时观察;长期运行还需要集中式存储、查询、保留策略和告警。真正值得告警的通常是持续的错误率、延迟、拒绝流量状态、连接池耗尽和 OOM,而不是日志里出现一次 WARN 就通知所有人。

日志要能回答发布问题

一次发布失败时,我们至少希望用日志回答这些问题:启动的是哪个版本;激活了哪个 Profile;迁移执行到哪一版;Web 服务器在哪个端口就绪;收到终止信号后是否完成优雅关闭。版本号和提交可以在构建时写入应用信息,再通过受保护的 /actuator/info 或启动日志显示。

但“可追溯”不等于把所有配置打印出来。记录版本标识、配置来源名称和非敏感开关即可,密钥值永远不需要出现在可观测数据里。


资源限制要和 JVM 一起看

如果不设置限制,容器可以争用主机允许的绝大部分 CPU 和内存。Docker 的隔离不是自动配额。TaskHub 的 Compose 配置给进程 768 MB 内存、1 个 CPU 和 200 个进程/线程 ID 上限,就是为了让异常消耗被限制在一个明确边界内。

内存限制也不能简单等同于 -Xmx768m。Java 进程除了堆,还需要元空间、线程栈、代码缓存、直接内存和本地库。把堆上限顶到容器上限,流量一上来就可能被内核以 OOM 方式终止,Java 连抛出可分析异常的机会都没有。

我们使用百分比给非堆内存留出空间:

text
JAVA_TOOL_OPTIONS=-XX:InitialRAMPercentage=25 -XX:MaxRAMPercentage=65

65% 不是所有应用的标准答案。线程多、使用大量直接缓冲区或图像处理的应用,需要更多非堆空间;堆对象多的应用可能需要更高配额。正确做法是用压测和生产指标观察堆峰值、GC 暂停、RSS 与容器限制,再调整比例。

查看实时资源:

bash
docker stats "$(docker compose ps -q taskhub)"

不要只看某一秒的数字。记录空闲、正常流量和压测阶段的 CPU、内存用量与限制,重点观察它们是否持续逼近上限,以及逼近时请求延迟和 GC 是否同时恶化。

容器意外退出后检查是否被 OOM 杀死:

bash
docker inspect "$(docker compose ps -q taskhub)" \
  --format 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}} error={{.State.Error}}'

如果实际结果出现 oom=true,不要立刻把堆调小或把内存翻倍。先确认是堆增长、线程数量、直接内存还是瞬时并发造成的,再决定修代码、限流或提高资源。CPU 的 cpus: 1.0 是上限,不是性能保证;CPU 被节流时,延迟会上升,探针也可能超时,因此资源配置和探针阈值要一起验证。

再收紧一层运行权限

Compose 中还做了几件不显眼但很实用的事:

  • read_only: true 让镜像根文件系统只读,阻止应用随意写入自身目录。
  • /tmp 单独使用有限大小的 tmpfs,给嵌入式服务器等确实需要的临时文件留出口。
  • cap_drop: [ALL] 移除 TaskHub 不需要的 Linux capabilities。
  • no-new-privileges:true 阻止进程通过执行文件获得额外权限。
  • pids_limit 避免线程失控拖垮整台主机。

这些设置需要在测试环境按真实功能验证。比如应用后来增加文件上传,就要把持久文件写到对象存储或明确的数据卷,而不是为了让它“先跑起来”关闭只读根文件系统。安全约束应该逼着状态去正确的位置。

非根用户、只读文件系统、外部密钥和资源上限共同约束容器

这些边界不是各自独立的几个开关,而是一套要一起验收的运行契约。只设置非 root,却把宿主机 Docker 套接字挂进容器,进程仍可能间接获得巨大权限;只设置只读根文件系统,却不给 Java 临时目录留空间,应用可能在处理第一个上传请求时才失败;只设置内存上限,却没有观察 GC、RSS 和退出原因,故障只会从“拖垮主机”变成“容器莫名消失”。每个限制都要对应一个明确的正常路径和一个可观察的失败路径。

资源限制也要与服务容量一起计算。假设单个 TaskHub 容器在 1 个 CPU、768 MB 内存下,稳定承载的并发量低于业务峰值,正确做法不是取消上限,而是先确认数据库连接池、线程池与延迟曲线,再增加副本并给入口设置过载保护。限制告诉平台单个实例最多能消耗多少,容量规划回答总流量需要多少实例,两者解决的是不同问题。

生产安全审查还应检查镜像来源与运行参数有没有在部署时被覆盖。Dockerfile 写了 USER 10001,运行命令仍可用 --user 0 改回 root;镜像声明了健康检查,Compose 也可以禁用它。因此不能只审 Dockerfile,还要把最终合并后的部署配置、镜像 digest 和平台策略一起核对。理想状态是由准入规则拒绝 root、特权模式、可写根文件系统和未设置资源上限的工作负载,让约定从文档变成可执行门禁。

容器内 USER 10001 与宿主机以 rootless 模式运行 Docker 是两件事。前者降低应用进程在容器内的权限,后者减少 Docker 守护进程与容器对宿主机的权限。条件允许时两层都做,但不能把其中一层当作另一层的替代品。


停机也要走完整生命周期

发布新版本时,旧容器不是越快杀掉越好。它可能正在写入一条任务、提交事务或返回响应。强制结束会把客户端留在“不知道请求是否成功”的状态,重试又可能制造重复操作。

当前 Spring Boot 的嵌入式 Tomcat、Jetty 和 Reactor Netty 默认支持优雅关闭。可以显式保留超时配置,让团队清楚等待边界:

yaml
spring:
  lifecycle:
    timeout-per-shutdown-phase: 20s

当进程收到 SIGTERM,应用开始拒绝新请求,同时给正在处理的请求最多 20 秒完成。Compose 的 stop_grace_period: 30s 比它更长,给 Spring 关闭连接池、刷新日志和退出 JVM 留出余量。

手动验证:

bash
time docker compose stop -t 30 taskhub

观察自己的实际日志:应该先出现开始关闭,再出现活动请求结束、连接池关闭和进程正常退出,而不是直接得到 SIGKILL。同时发起一个可控的慢请求,能更清楚地验证请求是否在宽限期内完成。

这里之所以强调 exec 形式 ENTRYPOINT,就是为了让 SIGTERM 直接到达 Java。运行平台的终止宽限必须大于 Spring 内部超时;如果平台 10 秒后就强杀,而应用愿意等 20 秒,后半段配置没有任何意义。

“零停机”不是一个配置项

要在用户无感的情况下切换版本,至少需要两个可承接流量的实例、正确的 readiness、负载均衡连接排空以及足够的冗余容量。单机单容器即使开启优雅关闭,在旧进程退出到新进程就绪之间仍会有空档。

滚动发布的正常顺序是:启动一个新实例,等待迁移与初始化完成,readiness 变为成功,流量入口才把请求交给它;随后旧实例先退出流量池,等待连接排空,再收到终止信号。任何一步失败都停止扩大新版本。

蓝绿发布也是同一逻辑,只是同时保留两组完整环境,在入口处切流。它回退快,但需要额外容量,也要处理两版代码同时访问数据库的兼容性。不要只画两组方框就声称“零停机”,真正决定结果的是探针、流量切换和数据兼容。


回滚要在发布前设计

如果新版本错误率突然升高,临时讨论“还能不能退”已经太晚。回滚路径应该和发布路径使用同一份版本账本,并在发布前通过演练。

TaskHub 的一次发布可以按下面的门槛推进:

通过扩展、切换、收缩保持数据库兼容并保留应用回滚通道

构建并验证 JAR,记录源代码提交;在同一提交上生成带版本标签的候选镜像,推送后记录 digest,后续环境只推广这个 digest。

备份关键数据,检查 Flyway 待执行迁移,确认迁移与上一版代码兼容,再运行一次性迁移步骤。

先启动少量新实例,等待 readiness 成功,执行未授权、查询和写入等冒烟检查。

逐步扩大流量,观察请求错误率、延迟、JVM 内存、数据库连接池和业务指标。超过阈值就停止,而不是继续把剩余实例全换掉。

稳定窗口结束后再完成发布,并保留上一版镜像与兼容数据库结构,直到回滚窗口关闭。

单机 Compose 环境可以明确指定旧版本再重建应用容器:

bash
TASKHUB_IMAGE=taskhub:1.4.1 docker compose up -d --no-deps taskhub

上面的 Compose 已经把镜像写成:

yaml
image: ${TASKHUB_IMAGE:-taskhub:1.4.2}

生产平台更应该使用镜像 digest,防止同名标签被覆盖。回滚完成后仍要重新执行健康检查和冒烟测试,并确认数据库结构允许旧代码工作。

哪些情况不该盲目回滚

如果新版本已经写入旧版无法理解的数据,或迁移删除了旧版依赖的列,直接换回旧镜像会制造第二次故障。此时可能需要前向修复:快速发布一个修正版本,或先恢复数据兼容层再退应用。

因此发布决策不能只看“容器能不能换回去”。要同时回答:

  • 旧镜像是否还在,digest 是否记录准确;
  • 当前数据库 schema 是否向后兼容;
  • 消息格式、缓存内容和外部 API 契约是否兼容;
  • 新版本是否已经执行不可逆副作用;
  • 回滚后用什么指标证明系统恢复。

回滚不是失败的耻辱,而是发布系统的一条正常分支。真正危险的是没有停止条件、没有旧制品、也没有数据兼容计划,只能在线上继续赌下一次修改。

用一次故障演练验证整条链

发布流程写得再完整,如果从未走过失败分支,关键时刻仍可能卡在权限、镜像拉取或数据库兼容上。可以在预发布环境安排一场范围明确的演练:先部署 1.4.1,创建几条任务;再发布包含向后兼容迁移的 1.4.2,确认新旧数据都能读取;随后故意让 1.4.2 的非关键配置错误触发 readiness 失败,观察平台是否停止切流;最后恢复到 1.4.1,并验证先前创建的数据仍在。

演练中记录时间点,而不只记录“成功了”。从提交发布到新实例 ready 用了多久,故障出现到告警用了多久,决定回滚到旧实例 ready 又用了多久,这些数字构成真实的恢复时间。下次优化应瞄准最慢环节:可能是镜像太大,也可能是审批、迁移或人工找 digest 花了更多时间。

还要验证权限。发布账号是否只能更新 TaskHub,而不能随意读取数据库 Secret;值班人员是否能查看日志和触发已审批的回滚,却不能改写镜像仓库;迁移账号是否只在迁移步骤短暂可用。最小权限若从未经过演练,常见结果要么是发布时权限不足,要么是为了省事长期授予管理员权限。

演练结束后把临时账号、测试 Secret 和测试数据清理掉,并把发现的问题改进到脚本与文档。不要只在会议记录里写“下次注意”。能自动检查的条件,例如禁止 latest、必须记录 digest、资源限制不能为空,应进入流水线规则;必须由人判断的迁移风险,则进入发布审批清单。


用一张发布清单收住所有细节

部署事项很多,但可以按“制品—配置—数据—运行—观察—撤回”六个问题检查。下面这份清单适合放进合并请求或发布工单,而不是只留在某个人脑子里。

检查面发布前要回答的问题
制品测试是否通过?JAR、镜像标签、digest 和提交是否一一对应?
配置Profile 是否明确?必需变量是否存在?密钥是否由受控渠道注入?
数据待执行迁移是什么?耗时和锁行为是否验证?旧版是否仍兼容?
运行是否非 root?端口是否只发布到需要的地址?资源和写目录是否受限?
观察liveness、readiness、日志、错误率和延迟能否反映真实状态?
撤回上一版制品是否可用?停止阈值是谁决定?回滚后如何验收?

你也可以用几条命令做部署后的快速核对:

bash
# 容器状态与端口
docker compose ps
 
# 公开探针只返回状态
curl --fail --silent http://127.0.0.1:8080/livez
curl --fail --silent http://127.0.0.1:8080/readyz
 
# 最近启动与迁移日志
docker compose logs --since=5m --tail=200 taskhub
 
# 运行用户必须不是 0
docker compose exec taskhub id
 
# 资源是否在预期范围
docker stats --no-stream "$(docker compose ps -q taskhub)"

检查 id 的实际结果,uid 应为 10001 而不是 0;组名和组编号由基础镜像创建用户时的规则决定,不要为了让输出“看起来一致”而手写一个结果。资源命令也通过 Compose 查询真实容器 ID,避免把项目目录派生出的容器名硬编码进脚本。

最后给自己做四个判断题。它们比背 Docker 命令更能检验你是否理解了部署边界。

1
Dockerfile 写了 EXPOSE 8080 后,宿主机的 8080 端口就会自动对外开放。
2
共享 PostgreSQL 短暂不可用时,最不适合把数据库检查加入哪一类探针?
3
下面哪些做法能让应用回滚更可靠?
4
容器内存限制为 768 MB 时,为什么通常不直接设置 -Xmx768m?

走到这里,TaskHub 已经不再只是“我的电脑上能跑”的项目。它有可追溯制品,有外部化配置和版本化数据库结构;容器不以 root 运行,资源与写入范围有边界;平台能区分存活和就绪,日志也能把版本、迁移与错误串起来。更重要的是,发布并非只有向前一条路,我们提前保留了停止与撤回的条件。

下一部分会继续沿用这套生产视角,回到第 8 篇已经拆出的 reactive-taskhub 响应式链路。Mono 和 Flux 本身并不会自动带来更高吞吐量,我们要具体看背压、线程切换、阻塞边界、超时和可观测性如何改变部署后的真实行为。

上一章保护你的 Spring Boot 应用下一章更深入的响应式编程