跳到主内容
创建时间: 2026-10-04最后更新: 2026-10-04

前面的测试、Docker 和优雅停机章节分别解决了行为验证、运行环境和实例生命周期。真正发布时,还需要把它们连接起来:测试失败就停止,测试通过的产物才能进入镜像,发布后检查就绪状态,失败时恢复已知版本。

本章给 Java 心跳案例仓库增加 GitHub Actions 流水线,以及单台 Linux 主机上的 Compose 发布脚本。示例文件属于包含 pom.xml 的 Spring Boot 仓库,不是专栏网站的 Next.js 仓库。这里提供可复制的部署范例,不代表读者已经拥有远程主机、镜像仓库权限或生产数据库。

1. 明确发布单元

一次发布需要保留以下关联:

release-flow.txt
1
Git commit
2
-> Maven verify:编译、测试、Flyway 集成验证
3
-> 保存通过验证的 JAR
4
-> 用这个 JAR 制作镜像
5
-> 推送镜像并记录 sha256 摘要
6
-> 部署同一摘要
7
-> Readiness 与业务冒烟检查

Commit SHA 标签方便追踪,镜像摘要用于精确标识内容。不能在部署主机再次 git pull、重新解析依赖或编译源码,否则上线的产物可能与 CI 验证的产物不同。

本文约定:

项目前提
Java21,继续使用 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,不能只改文件名:

pom.xml
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 访问。不要用生产数据库地址填补测试容器启动失败。

数据库至少验证两条路径:

  1. 空数据库从 V1 迁移到当前版本,应用能正常启动并执行核心读写。
  2. 从上一发布版本的结构和代表性数据升级,已有数据仍满足新约束,旧版本兼容期内仍能运行。

第一条可由现有 @SpringBootTest 加 @ServiceConnection 覆盖。第二条需要专门的升级测试,不能用空库迁移成功替代。测试应在临时数据库中先用 Flyway target 迁移到上一版本,再用完整配置迁移到最新版本;迁移版本必须按实际发布记录选择。

3. 直接封装已经通过测试的 JAR

Docker 部署中的 Dockerfile 会自行编译源码。本篇新增一个只负责封装产物的 Dockerfile,用于 CI:

Dockerfile.release
01
FROM eclipse-temurin:21-jre-jammy
02
03
RUN groupadd --system spring \
04
&& useradd --system --gid spring --home-dir /application spring \
05
&& install -d -o spring -g spring /application/var/target-files
06
WORKDIR /application
07
08
COPY --chown=spring:spring target/spring-0.0.1-SNAPSHOT.jar application.jar
09
USER spring:spring
10
11
EXPOSE 8080
12
ENTRYPOINT ["java", "-jar", "application.jar"]

如果已修改 Maven 的 finalName 或项目版本,需要同时修改这里和流水线中的 JAR 路径。生产环境还应将基础镜像固定为经审核的摘要,并通过正常发布更新它。

前文的 .dockerignore 排除了 target。因此为这个 Dockerfile 增加专用忽略文件,只放行所需 JAR:

Dockerfile.release.dockerignore
1
**
2
!target/
3
!target/spring-0.0.1-SNAPSHOT.jar

Docker 会优先使用与 Dockerfile 同名的专用 ignore 文件。源码、测试报告、编辑器目录和环境文件都不进入这次构建上下文。

4. 编译、测试和发布镜像

在 Java 案例仓库新增下面的工作流。Pull Request 只执行验证;主分支推送验证成功后,才发布镜像并进入部署作业。

.github/workflows/release.yaml
01
name: Spring health release
02
03
on:
04
pull_request:
05
push:
06
branches: [main]
07
08
permissions:
09
contents: read
10
11
jobs:
12
verify:
13
runs-on: ubuntu-24.04
14
timeout-minutes: 20
15
steps:
16
- uses: actions/checkout@v6
17
- uses: actions/setup-java@v4
18
with:
19
distribution: temurin
20
java-version: '21'
21
cache: maven
22
- name: Verify application
23
run: |
24
chmod +x mvnw
25
./mvnw --batch-mode --no-transfer-progress clean verify
26
- name: Save verified JAR
27
uses: actions/upload-artifact@v4
28
with:
29
name: verified-jar
30
path: target/spring-0.0.1-SNAPSHOT.jar
31
if-no-files-found: error
32
retention-days: 7
33
- name: Save test reports
34
if: always()
35
uses: actions/upload-artifact@v4
36
with:
37
name: test-reports
38
path: |
39
target/surefire-reports/
40
target/failsafe-reports/
41
if-no-files-found: ignore
42
43
publish:
44
needs: verify
45
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
46
runs-on: ubuntu-24.04
47
timeout-minutes: 15
48
permissions:
49
contents: read
50
packages: write
51
outputs:
52
image: ${{ steps.image.outputs.ref }}
53
steps:
54
- uses: actions/checkout@v6
55
- uses: actions/download-artifact@v5
56
with:
57
name: verified-jar
58
path: target
59
- name: Publish verified image
60
id: image
61
env:
62
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63
run: |
64
image="ghcr.io/${GITHUB_REPOSITORY,,}"
65
printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io \
66
--username "$GITHUB_ACTOR" --password-stdin
67
docker build --file Dockerfile.release --tag "$image:$GITHUB_SHA" .
68
docker push "$image:$GITHUB_SHA"
69
image_ref="$(docker image inspect \
70
--format '{{index .RepoDigests 0}}' "$image:$GITHUB_SHA")"
71
[[ "$image_ref" == "$image@sha256:"* ]]
72
printf 'ref=%s\n' "$image_ref" >> "$GITHUB_OUTPUT"
73
74
deploy:
75
needs: publish
76
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
77
runs-on: [self-hosted, linux, spring-health-prod]
78
timeout-minutes: 10
79
environment: production
80
concurrency:
81
group: spring-health-production
82
cancel-in-progress: false
83
steps:
84
- uses: actions/checkout@v6
85
- name: Deploy by digest
86
env:
87
IMAGE_REF: ${{ needs.publish.outputs.image }}
88
run: bash scripts/deploy-production.sh

needs 建立真正的依赖:测试失败就不会上传可发布 JAR,镜像失败就不会部署。测试报告使用 always() 留下失败证据,但镜像发布没有设置忽略失败。

示例使用清晰的 Action 主版本标签。正式维护时可以将第三方 Action 固定到审核过的完整 commit SHA,并使用自动更新工具管理升级。生产 Runner 的镜像拉取权限应提前配置为只读,不要把 CI 的包写入 Token 长期保存到服务器。

concurrency 防止两个发布脚本同时替换同一个 Compose 服务,但它不是严格先进先出的发布队列。多个候选版本密集到达时,应在 production Environment 中选择需要上线的版本,撤销已过时的待发布任务。

5. 主机配置与有界回滚

在部署主机准备 Compose 文件,镜像引用来自运行脚本的环境变量:

/opt/spring-health/compose.production.yaml
01
name: spring-health
02
03
services:
04
app:
05
image: ${IMAGE_REF:?Set IMAGE_REF to an image digest}
06
env_file:
07
- /opt/spring-health/runtime.env
08
environment:
09
SERVER_PORT: "8080"
10
MANAGEMENT_SERVER_PORT: "8080"
11
TARGET_FILES_DIRECTORY: /application/var/target-files
12
ports:
13
- "127.0.0.1:8080:8080"
14
volumes:
15
- target-files:/application/var/target-files
16
restart: unless-stopped
17
stop_grace_period: 30s
18
19
volumes:
20
target-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/**,探针仅由部署主机或其他可信调用方访问。

创建发布脚本:

scripts/deploy-production.sh
01
#!/usr/bin/env bash
02
set -euo pipefail
03
04
: "${IMAGE_REF:?IMAGE_REF is required}"
05
[[ "$IMAGE_REF" == *@sha256:* ]] || {
06
echo "Deploy an immutable image digest"
07
exit 1
08
}
09
export IMAGE_REF
10
11
compose_file=/opt/spring-health/compose.production.yaml
12
compose() {
13
docker compose --file "$compose_file" "$@"
14
}
15
wait_ready() {
16
for ((attempt = 1; attempt <= 30; attempt++)); do
17
if curl --fail --silent --max-time 2 \
18
http://127.0.0.1:8080/actuator/health/readiness >/dev/null; then
19
return 0
20
fi
21
sleep 2
22
done
23
return 1
24
}
25
26
previous_image=""
27
previous_container="$(compose ps --quiet app)"
28
if [[ -n "$previous_container" ]]; then
29
previous_image="$(docker inspect --format '{{.Config.Image}}' "$previous_container")"
30
fi
31
32
# Pull failure leaves the current container running.
33
compose pull app
34
if compose up --detach --no-deps app && wait_ready; then
35
echo "Release ready: $IMAGE_REF"
36
exit 0
37
fi
38
39
echo "Release failed; collect application logs in the restricted deployment log"
40
compose logs --tail 100 app || true
41
if [[ -n "$previous_image" ]]; then
42
export IMAGE_REF="$previous_image"
43
if compose up --detach --no-deps app && wait_ready; then
44
echo "Previous application version restored: $previous_image"
45
else
46
echo "Rollback failed; manual recovery is required"
47
fi
48
else
49
# On the first release there is no known-good version to restore.
50
compose stop app
51
echo "No previous version; stopped the failed first release"
52
fi
53
exit 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 支持依赖提供。测试密钥只在进程内生成,不写入仓库或生产环境。

src/test/java/cn/usehook/spring/security/JwtValidationTests.java
01
package cn.usehook.spring.security;
02
03
import com.nimbusds.jose.JWSAlgorithm;
04
import com.nimbusds.jose.JWSHeader;
05
import com.nimbusds.jose.crypto.RSASSASigner;
06
import com.nimbusds.jwt.JWTClaimsSet;
07
import com.nimbusds.jwt.SignedJWT;
08
import java.security.KeyPair;
09
import java.security.KeyPairGenerator;
10
import java.security.interfaces.RSAPublicKey;
11
import java.time.Instant;
12
import java.util.Date;
13
import java.util.List;
14
import org.junit.jupiter.api.Test;
15
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
16
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
17
import org.springframework.security.oauth2.jwt.JwtException;
18
import org.springframework.security.oauth2.jwt.JwtValidators;
19
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
20
21
import static org.assertj.core.api.Assertions.assertThat;
22
import static org.assertj.core.api.Assertions.assertThatThrownBy;
23
24
class JwtValidationTests {
25
@Test
26
void checksSignatureIssuerAudienceAndExpiration() throws Exception {
27
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
28
generator.initialize(2048);
29
KeyPair trusted = generator.generateKeyPair();
30
KeyPair untrusted = generator.generateKeyPair();
31
NimbusJwtDecoder decoder = NimbusJwtDecoder
32
.withPublicKey((RSAPublicKey) trusted.getPublic()).build();
33
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
34
JwtValidators.createDefaultWithIssuer("https://issuer.example"),
35
new JwtClaimValidator<List<String>>(
36
"aud", audiences -> audiences != null && audiences.contains("monitor-api")
37
)
38
));
39
Instant now = Instant.now();
40
String valid = token(trusted, "https://issuer.example", "monitor-api", now.plusSeconds(300));
41
assertThat(decoder.decode(valid).getSubject()).isEqualTo("alice");
42
43
for (String invalid : List.of(
44
token(untrusted, "https://issuer.example", "monitor-api", now.plusSeconds(300)),
45
token(trusted, "https://wrong.example", "monitor-api", now.plusSeconds(300)),
46
token(trusted, "https://issuer.example", "another-api", now.plusSeconds(300)),
47
token(trusted, "https://issuer.example", "monitor-api", now.minusSeconds(300))
48
)) {
49
assertThatThrownBy(() -> decoder.decode(invalid)).isInstanceOf(JwtException.class);
50
}
51
}
52
53
private static String token(
54
KeyPair key, String issuer, String audience, Instant expiresAt
55
) throws Exception {
56
JWTClaimsSet 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();
61
SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claims);
62
jwt.sign(new RSASSASigner(key.getPrivate()));
63
return jwt.serialize();
64
}
65
}

过期时间故意比当前时间早五分钟,避开常见的允许时钟偏差窗口。这个测试验证真实密码学和声明校验,但没有经过应用的 SecurityFilterChain,也没有请求生产 JWK 地址。

因此还要保留 MVC/端到端授权测试,并在受控预发布环境用测试身份完成一次真实令牌请求:合法 Token 读到允许的资源;错误 issuer、audience、过期或篡改签名得到 401;合法身份读取他人资源得到约定的 403 或 404。生产 issuer 和 audience 仍应使用 JWT 篇的配置,不能把测试中的示例地址复制到生产。

8. 发布结果需要哪些证据

阶段应保留的证据
构建源码提交、Wrapper/JDK 版本、测试报告与 JAR
镜像镜像摘要、基础镜像版本、依赖与镜像扫描结果
数据库Flyway 当前版本、升级测试结果、备份与恢复方案
部署目标环境、前一摘要、新摘要、就绪检查结果
业务受控身份访问、检查记录写入、告警任务执行的结果
回滚回滚目标、数据库兼容性,以及恢复后的就绪和业务验证

Readiness 成功只证明应用满足已配置的就绪条件,不代表全部业务通过。发布完成后仍应执行少量不会影响真实客户数据的冒烟检查,并观察错误率、延迟、连接池与任务积压。失败时停止继续扩大流量,而不是无上限自动重试发布。

9. 参考资料