前面的测试、Docker 和优雅停机章节分别解决了行为验证、运行环境和实例生命周期。真正发布时,还需要把它们连接起来:测试失败就停止,测试通过的产物才能进入镜像,发布后检查就绪状态,失败时恢复已知版本。
本章给 Java 心跳案例仓库增加 GitHub Actions 流水线,以及单台 Linux 主机上的 Compose 发布脚本。示例文件属于包含 pom.xml 的 Spring Boot 仓库,不是专栏网站的 Next.js 仓库。这里提供可复制的部署范例,不代表读者已经拥有远程主机、镜像仓库权限或生产数据库。
1. 明确发布单元
一次发布需要保留以下关联:
1Git commit2-> Maven verify:编译、测试、Flyway 集成验证3-> 保存通过验证的 JAR4-> 用这个 JAR 制作镜像5-> 推送镜像并记录 sha256 摘要6-> 部署同一摘要7-> Readiness 与业务冒烟检查
Commit SHA 标签方便追踪,镜像摘要用于精确标识内容。不能在部署主机再次 git pull、重新解析依赖或编译源码,否则上线的产物可能与 CI 验证的产物不同。
本文约定:
| 项目 | 前提 |
|---|---|
| Java | 21,继续使用 Maven Wrapper |
| 数据库测试 | 已完成 Testcontainers 章节,测试使用临时 PostgreSQL |
| 生产数据库 | 已创建应用账号,凭据只保存在部署环境 |
| 镜像仓库 | GitHub Container Registry,发布作业具有包写权限 |
| 部署主机 | Linux、Docker Engine、Compose 插件、curl |
| 部署执行器 | 受控 self-hosted Runner,标签为 spring-health-prod |
| 应用文件 | /opt/spring-health/compose.production.yaml 与 runtime.env |
专用 Runner 只能运行受信任主分支的发布任务,不执行外部 Pull Request 的代码。GitHub 的 production Environment 应限制可发布分支,并按团队规则配置审核与秘密访问权限。访问 Docker Socket 通常等价于较高的主机权限,因此 Runner 不应与任意用户任务混用。
2. 让测试进入正确的 Maven 阶段
现有 *Tests 测试类由 Surefire 发现;执行 ./mvnw verify 会经过测试和打包阶段。若把集成测试改名为 *IT,需要在 pom.xml 的 build/plugins 中显式启用 Failsafe,不能只改文件名:
01<plugin>02<groupId>org.apache.maven.plugins</groupId>03<artifactId>maven-failsafe-plugin</artifactId>04<executions>05<execution>06<goals>07<goal>integration-test</goal>08<goal>verify</goal>09</goals>10</execution>11</executions>12</plugin>
插件版本继续由 Spring Boot Parent 管理。保留原 *Tests 命名时无需为了 CI 再移动测试。使用 verify,不要只运行 integration-test 后忽略 Failsafe 的结果检查。
Testcontainers 在 CI 中也必须能访问 Docker。GitHub 托管的 Linux Runner 可用于这个范例;自建执行器应先配置好 Docker 访问。不要用生产数据库地址填补测试容器启动失败。
数据库至少验证两条路径:
- 空数据库从 V1 迁移到当前版本,应用能正常启动并执行核心读写。
- 从上一发布版本的结构和代表性数据升级,已有数据仍满足新约束,旧版本兼容期内仍能运行。
第一条可由现有 @SpringBootTest 加 @ServiceConnection 覆盖。第二条需要专门的升级测试,不能用空库迁移成功替代。测试应在临时数据库中先用 Flyway target 迁移到上一版本,再用完整配置迁移到最新版本;迁移版本必须按实际发布记录选择。
3. 直接封装已经通过测试的 JAR
Docker 部署中的 Dockerfile 会自行编译源码。本篇新增一个只负责封装产物的 Dockerfile,用于 CI:
01FROM eclipse-temurin:21-jre-jammy0203RUN groupadd --system spring \04&& useradd --system --gid spring --home-dir /application spring \05&& install -d -o spring -g spring /application/var/target-files06WORKDIR /application0708COPY --chown=spring:spring target/spring-0.0.1-SNAPSHOT.jar application.jar09USER spring:spring1011EXPOSE 808012ENTRYPOINT ["java", "-jar", "application.jar"]
如果已修改 Maven 的 finalName 或项目版本,需要同时修改这里和流水线中的 JAR 路径。生产环境还应将基础镜像固定为经审核的摘要,并通过正常发布更新它。
前文的 .dockerignore 排除了 target。因此为这个 Dockerfile 增加专用忽略文件,只放行所需 JAR:
1**2!target/3!target/spring-0.0.1-SNAPSHOT.jar
Docker 会优先使用与 Dockerfile 同名的专用 ignore 文件。源码、测试报告、编辑器目录和环境文件都不进入这次构建上下文。
4. 编译、测试和发布镜像
在 Java 案例仓库新增下面的工作流。Pull Request 只执行验证;主分支推送验证成功后,才发布镜像并进入部署作业。
01name: Spring health release0203on:04pull_request:05push:06branches: [main]0708permissions:09contents: read1011jobs:12verify:13runs-on: ubuntu-24.0414timeout-minutes: 2015steps:16- uses: actions/checkout@v617- uses: actions/setup-java@v418with:19distribution: temurin20java-version: '21'21cache: maven22- name: Verify application23run: |24chmod +x mvnw25./mvnw --batch-mode --no-transfer-progress clean verify26- name: Save verified JAR27uses: actions/upload-artifact@v428with:29name: verified-jar30path: target/spring-0.0.1-SNAPSHOT.jar31if-no-files-found: error32retention-days: 733- name: Save test reports34if: always()35uses: actions/upload-artifact@v436with:37name: test-reports38path: |39target/surefire-reports/40target/failsafe-reports/41if-no-files-found: ignore4243publish:44needs: verify45if: github.event_name == 'push' && github.ref == 'refs/heads/main'46runs-on: ubuntu-24.0447timeout-minutes: 1548permissions:49contents: read50packages: write51outputs:52image: ${{ steps.image.outputs.ref }}53steps:54- uses: actions/checkout@v655- uses: actions/download-artifact@v556with:57name: verified-jar58path: target59- name: Publish verified image60id: image61env:62REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}63run: |64image="ghcr.io/${GITHUB_REPOSITORY,,}"65printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io \66--username "$GITHUB_ACTOR" --password-stdin67docker build --file Dockerfile.release --tag "$image:$GITHUB_SHA" .68docker push "$image:$GITHUB_SHA"69image_ref="$(docker image inspect \70--format '{{index .RepoDigests 0}}' "$image:$GITHUB_SHA")"71[[ "$image_ref" == "$image@sha256:"* ]]72printf 'ref=%s\n' "$image_ref" >> "$GITHUB_OUTPUT"7374deploy:75needs: publish76if: github.event_name == 'push' && github.ref == 'refs/heads/main'77runs-on: [self-hosted, linux, spring-health-prod]78timeout-minutes: 1079environment: production80concurrency:81group: spring-health-production82cancel-in-progress: false83steps:84- uses: actions/checkout@v685- name: Deploy by digest86env:87IMAGE_REF: ${{ needs.publish.outputs.image }}88run: bash scripts/deploy-production.sh
needs 建立真正的依赖:测试失败就不会上传可发布 JAR,镜像失败就不会部署。测试报告使用 always() 留下失败证据,但镜像发布没有设置忽略失败。
示例使用清晰的 Action 主版本标签。正式维护时可以将第三方 Action 固定到审核过的完整 commit SHA,并使用自动更新工具管理升级。生产 Runner 的镜像拉取权限应提前配置为只读,不要把 CI 的包写入 Token 长期保存到服务器。
concurrency 防止两个发布脚本同时替换同一个 Compose 服务,但它不是严格先进先出的发布队列。多个候选版本密集到达时,应在 production Environment 中选择需要上线的版本,撤销已过时的待发布任务。
5. 主机配置与有界回滚
在部署主机准备 Compose 文件,镜像引用来自运行脚本的环境变量:
01name: spring-health0203services:04app:05image: ${IMAGE_REF:?Set IMAGE_REF to an image digest}06env_file:07- /opt/spring-health/runtime.env08environment:09SERVER_PORT: "8080"10MANAGEMENT_SERVER_PORT: "8080"11TARGET_FILES_DIRECTORY: /application/var/target-files12ports:13- "127.0.0.1:8080:8080"14volumes:15- target-files:/application/var/target-files16restart: unless-stopped17stop_grace_period: 30s1819volumes:20target-files:
runtime.env 由运维在主机上配置,权限限制为运行账号可读,不能提交到 Git。它至少包含已有生产配置需要的 SPRING_PROFILES_ACTIVE=prod、数据库连接信息、JWT issuer/JWK 地址及监控目标。生产数据库和身份提供方必须从该主机可达。
文件上传篇的 TargetFileStorage 会在启动时创建存储目录。镜像提前为非 root 用户准备可写目录,并用命名卷保留配置清单,避免权限错误和替换容器后文件丢失。升级时保持应用 UID/GID 稳定;已有卷的权限不会随着新镜像自动重置。卷中清单也需要纳入备份。
端口只绑定本机回环地址,由本机反向代理提供 HTTPS。这里显式把业务与 Actuator 端口统一为 8080,覆盖前文可选的 application-prod.yaml 管理端口 8081,使容器映射与下面的就绪探针保持一致。Compose 的 environment 也优先于 runtime.env 中的同名变量。
Actuator 就绪检查继续将数据库纳入 Readiness;反向代理不要向公网转发 /actuator/**,探针仅由部署主机或其他可信调用方访问。
创建发布脚本:
01#!/usr/bin/env bash02set -euo pipefail0304: "${IMAGE_REF:?IMAGE_REF is required}"05[[ "$IMAGE_REF" == *@sha256:* ]] || {06echo "Deploy an immutable image digest"07exit 108}09export IMAGE_REF1011compose_file=/opt/spring-health/compose.production.yaml12compose() {13docker compose --file "$compose_file" "$@"14}15wait_ready() {16for ((attempt = 1; attempt <= 30; attempt++)); do17if curl --fail --silent --max-time 2 \18http://127.0.0.1:8080/actuator/health/readiness >/dev/null; then19return 020fi21sleep 222done23return 124}2526previous_image=""27previous_container="$(compose ps --quiet app)"28if [[ -n "$previous_container" ]]; then29previous_image="$(docker inspect --format '{{.Config.Image}}' "$previous_container")"30fi3132# Pull failure leaves the current container running.33compose pull app34if compose up --detach --no-deps app && wait_ready; then35echo "Release ready: $IMAGE_REF"36exit 037fi3839echo "Release failed; collect application logs in the restricted deployment log"40compose logs --tail 100 app || true41if [[ -n "$previous_image" ]]; then42export IMAGE_REF="$previous_image"43if compose up --detach --no-deps app && wait_ready; then44echo "Previous application version restored: $previous_image"45else46echo "Rollback failed; manual recovery is required"47fi48else49# On the first release there is no known-good version to restore.50compose stop app51echo "No previous version; stopped the failed first release"52fi53exit 1
取日志失败不能阻断回滚,所以只有这条辅助命令允许失败后继续。回滚成功仍返回非零,让 CI 明确记录“本次发布失败”,而不是因为旧版本恢复就把新版本标成成功。不要在失败分支执行 docker compose down --volumes;应用回滚不应删除持久化数据库。
这是单实例 Compose 发布,替换时存在短暂服务中断。需要滚动更新或零停机时,应使用前文介绍的多实例、就绪探针和负载均衡机制,并先解决任务重复执行和跨版本数据库兼容,不能仅把实例数改成二就声称无中断。
6. 数据库迁移与应用回滚分开处理
新应用启动时 Flyway 可能已经完成迁移。恢复旧镜像不会自动撤销 SQL,因此迁移必须考虑至少一个发布窗口内的兼容性:
| 变化 | 建议顺序 |
|---|---|
| 增加字段 | 先增加允许旧代码继续写入的字段,再发布使用新字段的代码 |
| 增加非空约束 | 先兼容写入并分批回填,再检查数据,最后添加约束 |
| 重命名或删除字段 | 先让新旧版本同时兼容,待旧实例退出和回滚窗口结束后再移除 |
| 历史数据清理 | 独立运行有上限的任务,不和应用镜像回滚绑在一起 |
不要修改已经在生产执行过的 Flyway 文件,也不要在 CI 自动调用 clean 或通过 repair 掩盖校验失败。迁移失败时应保留执行记录和数据库现场;是否需要前滚修复或恢复备份,应由具体状态决定。
在升级集成测试中,可以先迁移到上一版本、插入旧格式数据,再调用最新迁移并断言。例如前面资源权限、告警和多实例任务分别增加 V6、V7、V8;从 V5 升级到 V8 时,需要验证原有监控配置仍可读,新增成员关系默认不会错误授予任何用户访问权限。
7. 验证真实 JWT 的失败路径
JWT 篇的 jwt() 和端到端测试中的 JwtDecoder 替身便于验证授权,但不能证明签名检查正确。下面的测试使用临时 RSA 密钥与真实 NimbusJwtDecoder,验证 issuer、audience、过期及错误签名。
依赖继续使用已有 OAuth2 Resource Server Starter 与测试 Starter,Nimbus JOSE 类型由 JWT 支持依赖提供。测试密钥只在进程内生成,不写入仓库或生产环境。
01package cn.usehook.spring.security;0203import com.nimbusds.jose.JWSAlgorithm;04import com.nimbusds.jose.JWSHeader;05import com.nimbusds.jose.crypto.RSASSASigner;06import com.nimbusds.jwt.JWTClaimsSet;07import com.nimbusds.jwt.SignedJWT;08import java.security.KeyPair;09import java.security.KeyPairGenerator;10import java.security.interfaces.RSAPublicKey;11import java.time.Instant;12import java.util.Date;13import java.util.List;14import org.junit.jupiter.api.Test;15import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;16import org.springframework.security.oauth2.jwt.JwtClaimValidator;17import org.springframework.security.oauth2.jwt.JwtException;18import org.springframework.security.oauth2.jwt.JwtValidators;19import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;2021import static org.assertj.core.api.Assertions.assertThat;22import static org.assertj.core.api.Assertions.assertThatThrownBy;2324class JwtValidationTests {25@Test26void checksSignatureIssuerAudienceAndExpiration() throws Exception {27KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");28generator.initialize(2048);29KeyPair trusted = generator.generateKeyPair();30KeyPair untrusted = generator.generateKeyPair();31NimbusJwtDecoder decoder = NimbusJwtDecoder32.withPublicKey((RSAPublicKey) trusted.getPublic()).build();33decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(34JwtValidators.createDefaultWithIssuer("https://issuer.example"),35new JwtClaimValidator<List<String>>(36"aud", audiences -> audiences != null && audiences.contains("monitor-api")37)38));39Instant now = Instant.now();40String valid = token(trusted, "https://issuer.example", "monitor-api", now.plusSeconds(300));41assertThat(decoder.decode(valid).getSubject()).isEqualTo("alice");4243for (String invalid : List.of(44token(untrusted, "https://issuer.example", "monitor-api", now.plusSeconds(300)),45token(trusted, "https://wrong.example", "monitor-api", now.plusSeconds(300)),46token(trusted, "https://issuer.example", "another-api", now.plusSeconds(300)),47token(trusted, "https://issuer.example", "monitor-api", now.minusSeconds(300))48)) {49assertThatThrownBy(() -> decoder.decode(invalid)).isInstanceOf(JwtException.class);50}51}5253private static String token(54KeyPair key, String issuer, String audience, Instant expiresAt55) throws Exception {56JWTClaimsSet claims = new JWTClaimsSet.Builder()57.issuer(issuer).subject("alice").audience(audience)58.issueTime(Date.from(expiresAt.minusSeconds(600)))59.expirationTime(Date.from(expiresAt))60.claim("scope", "monitor").build();61SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claims);62jwt.sign(new RSASSASigner(key.getPrivate()));63return jwt.serialize();64}65}
过期时间故意比当前时间早五分钟,避开常见的允许时钟偏差窗口。这个测试验证真实密码学和声明校验,但没有经过应用的 SecurityFilterChain,也没有请求生产 JWK 地址。
因此还要保留 MVC/端到端授权测试,并在受控预发布环境用测试身份完成一次真实令牌请求:合法 Token 读到允许的资源;错误 issuer、audience、过期或篡改签名得到 401;合法身份读取他人资源得到约定的 403 或 404。生产 issuer 和 audience 仍应使用 JWT 篇的配置,不能把测试中的示例地址复制到生产。
8. 发布结果需要哪些证据
| 阶段 | 应保留的证据 |
|---|---|
| 构建 | 源码提交、Wrapper/JDK 版本、测试报告与 JAR |
| 镜像 | 镜像摘要、基础镜像版本、依赖与镜像扫描结果 |
| 数据库 | Flyway 当前版本、升级测试结果、备份与恢复方案 |
| 部署 | 目标环境、前一摘要、新摘要、就绪检查结果 |
| 业务 | 受控身份访问、检查记录写入、告警任务执行的结果 |
| 回滚 | 回滚目标、数据库兼容性,以及恢复后的就绪和业务验证 |
Readiness 成功只证明应用满足已配置的就绪条件,不代表全部业务通过。发布完成后仍应执行少量不会影响真实客户数据的冒烟检查,并观察错误率、延迟、连接池与任务积压。失败时停止继续扩大流量,而不是无上限自动重试发布。