上一部分,我们已经给 TaskHub 补上了认证、授权和测试。此时它在开发机上能启动,接口也能通过检查,很容易让人产生一种错觉:接下来不过是把文件传到服务器,再运行一次 java -jar。
真正的部署恰恰从这里开始。开发机上的进程只需要服务一位开发者,生产环境里的进程却要面对网络流量、数据库抖动、配置差异、版本切换和机器资源上限。应用启动成功,只能说明 Java 进程活着;它能不能接流量、出现问题能不能定位、更新失败能不能退回去,才决定这次发布是否完整。
这一部分继续使用贯穿课程的 TaskHub,项目基于 Spring Boot 4.1.0 和 Java 25。我们先把同一份代码打成可执行 JAR,在本机验收这个制品,再把它放进一个非 root、资源受限的容器。随后给它接上外部配置、数据库迁移、健康检查、标准输出日志和优雅停机。你会看到,所谓“部署”不是一条神奇命令,而是一条每一步都能验证、也都能撤回的交付链。

一个很常见的混乱是:测试的是工作区代码,打包时又临时跳过测试;服务器上保留一份源码,然后在那里重新编译;容器里跑的版本叫 latest,却没人知道它对应哪个提交。等线上出错,大家手里有三份“看起来差不多”的代码,谁也说不清真正运行的是哪一份。
TaskHub 采用更简单的规则:一次发布只产生一份不可变候选制品,后续环境只改变配置,不重新编译代码。 如果采用进程部署,这份候选制品就是 JAR;如果采用本章的容器主线,最终候选制品就是带 digest 的镜像。JAR 验收帮助我们先排除应用打包问题,镜像一旦生成,测试、预发布和生产就推广同一个镜像内容,差别只来自数据库地址、密钥、日志级别和资源配额等外部配置。
先在项目根目录执行完整验证:
./mvnw clean verifyverify 会经过编译、单元测试、集成测试和打包等 Maven 生命周期阶段。这次构建的末尾是:
[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 把红灯遮住。跳过测试只适合在前置验证已经完成后避免同一构建阶段重复执行,不能成为生产发布的默认门禁。
构建完成后,先确认产物,而不是凭印象手写文件名:
ls -lh target/taskhub-*.jar这次得到的可执行 JAR 大小是 61 MB:
-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 的工具模式列出它的镜像层:
java -Djarmode=tools \
-jar target/taskhub-0.0.1-SNAPSHOT.jar list-layersdependencies
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:
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 自己能运行,可以把“应用打包有问题”和“容器配置有问题”分开。
开发配置使用内存数据库,适合做一次最短验收:
java -jar target/taskhub-0.0.1-SNAPSHOT.jar \
--server.port=8080日志中应该能找到启动完成信息:
Started TaskHubApplication in 2.892 seconds另开一个终端检查健康端点:
curl --fail --silent http://127.0.0.1:8080/actuator/health{"groups":["liveness","readiness"],"status":"UP"}这里使用 --fail 很有用:当服务返回 400 或 500 段状态码时,curl 会以非零状态退出,脚本不至于把一个错误页面当成成功。--silent 则让自动化输出保持简洁。
如果需要传 JVM 参数,参数必须出现在 -jar 之前;Spring Boot 的应用参数则写在 JAR 文件之后:
java -Xms256m -Xmx512m \
-Duser.timezone=Asia/Shanghai \
-jar target/taskhub-0.0.1-SNAPSHOT.jar \
--server.port=8080下面这种顺序经常出现在旧文章里,却不会按预期设置 JVM 系统属性:
# 错误示范:-D 参数放到了 -jar 和文件名之后
java -jar target/taskhub-0.0.1-SNAPSHOT.jar -Dspring.profiles.active=prod激活生产配置更推荐环境变量。这样命令本身不需要因平台而变化:
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 历史、进程信息或录屏。进入容器编排平台后,应由密钥系统把密码作为受控变量或只读文件注入。
看到启动横幅不算通过。最小验收至少包含四件事:进程启动、健康端点返回成功、受保护的业务接口仍要求认证、日志里没有数据库迁移或配置绑定错误。如果版本包含写操作,还要做一次创建与查询的冒烟测试,并清理测试数据。
例如,未携带凭据访问任务接口应保持上一部分配置的安全边界:
curl --silent --output /dev/null --write-out '%{http_code}\n' \
http://127.0.0.1:8080/api/tasks401使用部署验收专用账号后再检查正常响应。不要把用户名和密码写进镜像或流水线日志;下面的密码由临时变量或密钥注入:
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 的基础配置保留适合本地学习的默认值:
# 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
生产 Profile 只写生产规则,不把真实凭据放进去:
# 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
这里故意不给 DB_PASSWORD 准备一个“方便启动”的默认值。生产配置缺少密码时,应用应该启动失败,让问题在接流量之前暴露。change-me 之类的默认密码看似照顾了开发体验,实际很容易原封不动地进入生产。
环境变量名与属性名之间遵循宽松绑定。一般做法是把点替换为下划线、删除连字符并转为大写。例如:
我们在 YAML 中使用 ${DB_URL} 是为了让团队采用更短、语义明确的部署变量名;直接设置 SPRING_DATASOURCE_URL 也可以。两种方式选一种并写进部署约定,不要在同一套环境里混着设,否则排查覆盖顺序会很痛苦。

镜像层是可复用、可推送的内容。一旦在 Dockerfile 中写过 ENV DB_PASSWORD=...,即使后面再删除,它仍可能留在构建历史或旧层中。构建参数也不是密钥保险箱。镜像应该能公开给拥有仓库读取权限的人,而不携带任何环境的通行证。
本地联调可以使用不提交到 Git 的 .env.prod:
SPRING_PROFILES_ACTIVE=prod
DB_URL=jdbc:postgresql://postgres:5432/taskhub
DB_USERNAME=taskhub_app
DB_PASSWORD=仅用于本地联调并在 .gitignore 中排除它:
.env*
!.env.example.env.example 只保留变量名和说明,不放可用值。生产平台应改用自己的 Secret 机制。即使变量是运行时注入的,有容器检查权限的人仍可能看到它,所以权限控制、轮换和审计仍然需要做。
外部化配置不是把所有属性都改成可选。数据库密码、可信签发方、外部服务地址这类生产必需项,如果缺失就应该在应用接收流量前失败。一个带默认空字符串的配置也许能让启动日志更好看,却会把错误推迟到第一次真实请求,定位反而更难。
TaskHub 自己的配置使用 @ConfigurationProperties 和 Bean Validation,就是为了把一组相关属性一次绑定并校验。例如 taskhub.default-page-size 必须处于合理范围,display-name 不能为空。Spring 在创建配置 Bean 时发现非法值,会阻止应用上下文完成刷新,并把具体属性路径写进启动错误。这比业务代码运行到一半才得到 NullPointerException 清楚得多。
发布脚本应主动验证“变量存在”,但不要打印变量值。Shell 中可以这样检查:
: "${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/,再注入:
SPRING_CONFIG_IMPORT=optional:configtree:/run/secrets/文件名会成为属性名,文件内容成为属性值。这样密码不需要出现在镜像、Compose 文件或普通环境变量列表中。optional: 表示开发环境没有这个目录时仍可启动;生产环境是否必须存在,则应由部署模板或启动校验负责。
不要为了排错打印完整 Spring Environment、数据源 URL 或请求头。数据库 URL 可能携带账号参数,Authorization、Cookie 和密钥更不应该进入日志。排错信息要回答“读取了哪个配置来源、连接哪个非敏感主机、失败在哪一阶段”,而不是把配置值全部摊开。
代码可以换回旧镜像,数据库结构却不会跟着自动倒退。假设新版本把 tasks 表增加了 version 列,而生产数据库还没有这列,JPA 在 ddl-auto: validate 下会拒绝启动。这不是麻烦,而是保护:生产环境不应该让 Hibernate 根据实体差异随意改表。
更可靠的做法是使用 Flyway 管理版本化迁移。每一次结构变化都变成一个随代码提交的 SQL 文件:
src/main/resources/db/migration/
├── V1__create_tasks.sql
└── V2__add_task_version.sql例如第二次迁移可以先增加一个与旧代码兼容的列:
ALTER TABLE tasks
ADD COLUMN version BIGINT NOT NULL DEFAULT 0;应用启动时,Flyway 会对照 flyway_schema_history,按版本执行尚未应用的脚本,然后 Hibernate 再验证实体与表结构是否一致。迁移执行过后会留下校验和,不要直接改写已经进入共享环境的 V1 或 V2。需要修正时新增 V3,这样每个环境都能沿同一条历史前进。
TaskHub 第一次启动空数据库时,实际出现的关键顺序是:
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 在一个空测试库中只用几十毫秒,并不代表它面对数千万行数据仍然安全。涉及不可逆数据转换时,要提前准备备份、校验查询和恢复演练,而不是把“有备份”当成一句口号。
Flyway 的校验和不一致,常见原因是有人改了已经执行过的脚本。repair 能修改 schema history,但它不是“让红字消失”的通用按钮。先判断数据库实际执行了什么、仓库里的脚本为何变化、其他环境处于哪个版本,再决定是还原原脚本、补一个新迁移,还是在确认数据库状态后修复历史表。
迁移脚本还要尽量做到一次执行目标明确。把建表、全量数据清洗、删除旧列和创建大索引全部放进一个文件,任何一步失败都很难判断恢复点。结构扩展、数据回填和结构收缩拆开后,每一步都可以设置独立的观察窗口,也更容易安排低峰期。
备份只有经过恢复验证才算回退能力。最实用的演练不是看对象存储里有没有备份文件,而是在隔离环境恢复它,运行 Flyway 信息检查,再用当前版与上一版 TaskHub 分别做关键查询。这样能同时验证备份完整性、迁移历史和应用兼容性。
现在 JAR 已经通过验收,我们再给它建立运行边界。最短 Dockerfile 只需基础 JRE、COPY 和 ENTRYPOINT,但它会把几十 MB 的依赖与几 KB 的业务代码放进同一层。每次只改一个 Controller,也要重新传输整个 JAR 层。
Spring Boot 的可执行 JAR 内含 layers.idx,默认把内容分成稳定依赖、启动器、快照依赖和应用代码。我们用 tools 模式提取这些层,再分别复制到运行镜像:
# 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
这份文件有几个容易被一眼略过的细节。
第一,构建、提取和运行是三个阶段。构建阶段使用 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”。它不会修改主机防火墙,也不会把容器端口发布到宿主机。真正建立端口映射的是运行时参数:
docker run -p 127.0.0.1:8080:8080 taskhub:1.4.2这里把主机监听地址限制为 127.0.0.1,适合前面还有反向代理的单机部署。如果写成 -p 8080:8080,Docker 通常会在所有主机地址发布端口。是否对公网开放还取决于防火墙和云网络规则,但不要把这件事交给运气。
容器处于同一个自定义网络时,彼此可以直接用服务名和容器端口通信,不需要把 PostgreSQL 的 5432 端口发布到主机。数据库端口只在内部网络可见,暴露面更小。

先完成测试门禁,再由多阶段 Dockerfile 在同一提交上构建镜像:
./mvnw clean verify
docker build --pull -t taskhub:1.4.2 .--pull 会检查基础镜像是否有更新。为了完全可重复,正式流水线可以把基础镜像固定到 digest,并由自动化工具定期提出升级;固定 digest 后不要忘记更新安全补丁,否则“可重复”会变成“永远停在旧漏洞”。
构建后检查运行用户、入口和端口声明:
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 目录和本地环境文件。项目根目录可以加入:
.git
.idea
.vscode
.env*
secrets/
README.md
target/当前 Dockerfile 在构建阶段复制 src,所以不能把源码排除;本机的 target 则不参与镜像构建,避免旧 JAR 混入上下文。若团队改成在 Docker 外打包、镜像只接收已验证 JAR,就需要反过来排除 src,并只放行目标 JAR。.dockerignore 应与构建主线匹配,而不是从别的项目复制一份看起来很严格的清单。
Spring Boot Maven 插件也能通过 Cloud Native Buildpacks 直接生成 OCI 镜像:
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.imageName=taskhub:1.4.2Buildpacks 会识别 JAR、选择运行时、处理应用层,并让镜像以非 root 用户运行。它适合希望统一镜像基线、减少手写 Dockerfile 的团队。代价是底层构建由 builder 与 buildpack 决定,想安装特定系统包或精细控制每一步时,需要理解它们的扩展方式。
两条路线并不存在“练习版”和“专业版”的高低之分。TaskHub 选择 Dockerfile,是因为这一部分需要把分层、用户、健康检查和信号处理逐项展开;如果组织已经维护成熟的 Buildpacks 平台,直接使用它反而更一致。无论选哪条路,镜像都应该来自已验证制品,且不能携带生产密钥。
传统服务器出问题时,人们习惯 SSH 上去安装工具、改配置、替换一个 class 文件。把这套习惯搬进容器,会让运行状态迅速偏离镜像声明。容器内临时修改在重建后会消失,没重建时又无法从 Git 和 Dockerfile 还原,最终形成只有某个实例拥有的“手工补丁”。
正确的修复路径是修改源码或构建文件,生成新版本镜像,通过相同发布链替换实例。临时进入容器只用于受控诊断,诊断完成后也应该销毁被操作过的实例。生产镜像可以缺少编辑器、编译器和网络诊断大全;需要深度排障时,平台可以启动权限受限的临时调试容器,而不是永久扩大应用镜像的攻击面。
镜像也不会自动更新系统安全补丁。基础镜像 tag 指向新内容时,正在运行的旧容器不会自己变化。团队需要定期重新构建、扫描并发布,即使 TaskHub 业务代码没有改。固定 digest 保证构建可追溯,自动更新流程负责提醒新 digest,两者缺一不可。
构建上下文同样属于供应链边界。Docker 客户端会把上下文中未忽略的文件交给构建器,因此 .dockerignore 不只是提速配置。它能避免本地密钥、测试报告、编辑器缓存和 Git 历史意外进入远端构建环境。每次增加新的敏感文件目录,都应同步检查忽略规则。
单独运行一个容器只能证明镜像会启动。TaskHub 的生产配置依赖 PostgreSQL,我们用 Compose 表达本地或单机部署关系。下面的文件重点展示网络、健康依赖、只读文件系统和资源约束,不把密码写进 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"
生产平台不会把密钥文件放在项目旁边,这里只是演示 Compose 如何挂载 Secret。secrets/ 必须加入 .gitignore,文件权限也应限制。真正的生产值由平台的密钥存储创建,不经过源码仓库。
depends_on 加 service_healthy 只解决“启动 TaskHub 前,先等 PostgreSQL 健康”。它不保证数据库以后永远可用,也不会替代应用里的超时、连接池和错误处理。数据库重启后,TaskHub 能否恢复连接,仍要通过测试和监控验证。
启动并观察状态:
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 权限,可以与日常运行账号分离,缩小应用被利用后的影响范围。
生产平台至少要区分两个问题:这个进程是否已经坏到只能重启,以及这个实例此刻是否适合接收新流量。把它们都塞进 /actuator/health,会产生很危险的自动化行为。
Spring Boot Actuator 提供两组探针:
在非 Kubernetes 环境中也可以显式启用探针,并把它们额外映射到主业务端口:
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。如果管理端点使用独立端口,附加主端口路径尤其重要:管理端口健康,不代表真正承接业务请求的连接器也健康。
安全配置只公开探针状态,不公开其他管理能力:
authorize.requestMatchers(
"/actuator/health",
"/actuator/health/liveness",
"/actuator/health/readiness",
"/livez",
"/readyz")
.permitAll();
authorize.requestMatchers("/actuator/**")
.hasRole("ADMIN");公开响应保持最少信息:
curl --fail --silent http://127.0.0.1:8080/livez
curl --fail --silent http://127.0.0.1:8080/readyz{"status":"UP"}
{"status":"UP"}TaskHub 已经有一个检查任务仓库的 HealthIndicator。是否把它加入 readiness,要根据业务行为决定。如果数据库不可用时任务接口完全没有可提供的能力,把仓库检查加入 readiness 是合理的;如果应用仍能提供缓存读取或降级页面,直接让所有实例退出流量可能比返回受控错误更糟。
可以显式指定 readiness 组成:
management:
endpoint:
health:
group:
readiness:
include: readinessState,taskRepository不要把数据库、缓存或外部 API 放进 liveness。 共享数据库短暂故障时,如果所有实例的 liveness 同时失败,平台会把它们一起重启。重启修不好数据库,只会制造连接风暴和更长的恢复时间。liveness 应关注应用自身无法恢复的状态。

JVM 预热、数据库迁移和缓存加载可能让启动持续几十秒。Dockerfile 的 HEALTHCHECK 使用 start-period,避免启动窗口中的失败过早计数。Kubernetes 中可以为启动很慢的应用配置 startupProbe,让 liveness 在启动探针成功后再接管。
探针参数不能靠复制:
timeout 要大于一次正常探测的高分位耗时,但不能长到探针线程堆积。period 与 failureThreshold 一起决定发现故障需要多久。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 默认就会输出控制台日志。生产环境可开启内置结构化格式:
logging:
structured:
format:
console: logstash
level:
root: INFO
com.welearn.taskhub: INFO一条启动日志会成为单行 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、敏感请求体和未脱敏异常上下文。
查看最近日志:
docker compose logs --since=10m --tail=200 taskhub持续观察:
docker compose logs --follow taskhub不要把 --follow 当成监控系统。它适合部署后的短时观察;长期运行还需要集中式存储、查询、保留策略和告警。真正值得告警的通常是持续的错误率、延迟、拒绝流量状态、连接池耗尽和 OOM,而不是日志里出现一次 WARN 就通知所有人。
一次发布失败时,我们至少希望用日志回答这些问题:启动的是哪个版本;激活了哪个 Profile;迁移执行到哪一版;Web 服务器在哪个端口就绪;收到终止信号后是否完成优雅关闭。版本号和提交可以在构建时写入应用信息,再通过受保护的 /actuator/info 或启动日志显示。
但“可追溯”不等于把所有配置打印出来。记录版本标识、配置来源名称和非敏感开关即可,密钥值永远不需要出现在可观测数据里。
如果不设置限制,容器可以争用主机允许的绝大部分 CPU 和内存。Docker 的隔离不是自动配额。TaskHub 的 Compose 配置给进程 768 MB 内存、1 个 CPU 和 200 个进程/线程 ID 上限,就是为了让异常消耗被限制在一个明确边界内。
内存限制也不能简单等同于 -Xmx768m。Java 进程除了堆,还需要元空间、线程栈、代码缓存、直接内存和本地库。把堆上限顶到容器上限,流量一上来就可能被内核以 OOM 方式终止,Java 连抛出可分析异常的机会都没有。
我们使用百分比给非堆内存留出空间:
JAVA_TOOL_OPTIONS=-XX:InitialRAMPercentage=25 -XX:MaxRAMPercentage=6565% 不是所有应用的标准答案。线程多、使用大量直接缓冲区或图像处理的应用,需要更多非堆空间;堆对象多的应用可能需要更高配额。正确做法是用压测和生产指标观察堆峰值、GC 暂停、RSS 与容器限制,再调整比例。
查看实时资源:
docker stats "$(docker compose ps -q taskhub)"不要只看某一秒的数字。记录空闲、正常流量和压测阶段的 CPU、内存用量与限制,重点观察它们是否持续逼近上限,以及逼近时请求延迟和 GC 是否同时恶化。
容器意外退出后检查是否被 OOM 杀死:
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 默认支持优雅关闭。可以显式保留超时配置,让团队清楚等待边界:
spring:
lifecycle:
timeout-per-shutdown-phase: 20s当进程收到 SIGTERM,应用开始拒绝新请求,同时给正在处理的请求最多 20 秒完成。Compose 的 stop_grace_period: 30s 比它更长,给 Spring 关闭连接池、刷新日志和退出 JVM 留出余量。
手动验证:
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 环境可以明确指定旧版本再重建应用容器:
TASKHUB_IMAGE=taskhub:1.4.1 docker compose up -d --no-deps taskhub上面的 Compose 已经把镜像写成:
image: ${TASKHUB_IMAGE:-taskhub:1.4.2}生产平台更应该使用镜像 digest,防止同名标签被覆盖。回滚完成后仍要重新执行健康检查和冒烟测试,并确认数据库结构允许旧代码工作。
如果新版本已经写入旧版无法理解的数据,或迁移删除了旧版依赖的列,直接换回旧镜像会制造第二次故障。此时可能需要前向修复:快速发布一个修正版本,或先恢复数据兼容层再退应用。
因此发布决策不能只看“容器能不能换回去”。要同时回答:
回滚不是失败的耻辱,而是发布系统的一条正常分支。真正危险的是没有停止条件、没有旧制品、也没有数据兼容计划,只能在线上继续赌下一次修改。
发布流程写得再完整,如果从未走过失败分支,关键时刻仍可能卡在权限、镜像拉取或数据库兼容上。可以在预发布环境安排一场范围明确的演练:先部署 1.4.1,创建几条任务;再发布包含向后兼容迁移的 1.4.2,确认新旧数据都能读取;随后故意让 1.4.2 的非关键配置错误触发 readiness 失败,观察平台是否停止切流;最后恢复到 1.4.1,并验证先前创建的数据仍在。
演练中记录时间点,而不只记录“成功了”。从提交发布到新实例 ready 用了多久,故障出现到告警用了多久,决定回滚到旧实例 ready 又用了多久,这些数字构成真实的恢复时间。下次优化应瞄准最慢环节:可能是镜像太大,也可能是审批、迁移或人工找 digest 花了更多时间。
还要验证权限。发布账号是否只能更新 TaskHub,而不能随意读取数据库 Secret;值班人员是否能查看日志和触发已审批的回滚,却不能改写镜像仓库;迁移账号是否只在迁移步骤短暂可用。最小权限若从未经过演练,常见结果要么是发布时权限不足,要么是为了省事长期授予管理员权限。
演练结束后把临时账号、测试 Secret 和测试数据清理掉,并把发现的问题改进到脚本与文档。不要只在会议记录里写“下次注意”。能自动检查的条件,例如禁止 latest、必须记录 digest、资源限制不能为空,应进入流水线规则;必须由人判断的迁移风险,则进入发布审批清单。
部署事项很多,但可以按“制品—配置—数据—运行—观察—撤回”六个问题检查。下面这份清单适合放进合并请求或发布工单,而不是只留在某个人脑子里。
你也可以用几条命令做部署后的快速核对:
# 容器状态与端口
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 命令更能检验你是否理解了部署边界。
走到这里,TaskHub 已经不再只是“我的电脑上能跑”的项目。它有可追溯制品,有外部化配置和版本化数据库结构;容器不以 root 运行,资源与写入范围有边界;平台能区分存活和就绪,日志也能把版本、迁移与错误串起来。更重要的是,发布并非只有向前一条路,我们提前保留了停止与撤回的条件。
下一部分会继续沿用这套生产视角,回到第 8 篇已经拆出的 reactive-taskhub 响应式链路。Mono 和 Flux 本身并不会自动带来更高吞吐量,我们要具体看背压、线程切换、阻塞边界、超时和可观测性如何改变部署后的真实行为。
稳定窗口结束后再完成发布,并保留上一版镜像与兼容数据库结构,直到回滚窗口关闭。