为什么:用户下午要部署,但全仓没有任何部署产物;且生产漏配密钥会静默使用代码里的 dev 默认值,
属上线阻塞项(04-部署架构 / 12-上线检查清单)。
- 配置外置(三件):DB_URL、JWT 双密钥(**发现 app-secret 此前没外置,一直用 dev 默认值**)、
MEDIA_SIGNATURE_SECRET、MEDIA_SIGNATURE_REQUIRED、CORS_ALLOWED_ORIGINS、MEDIA_ROOT_DIR
- 新增三份 application-prod.yml:签名强制开启、CORS 收紧、媒资指向挂载点、日志级别
- 新增 ProdSecretGuard(@Profile("prod")):密钥缺失/仍是 dev 默认值/不足 32 位、签名未开、
DB_URL 或 CORS 为空 → 一次列全并终止启动(exit 1)
- 新增 /healthz 轻量健康端点:查明 /actuator/health 的"超时"是旧 jar 未重启所致,项目本就
没有 actuator 依赖,故不引新依赖;同时把 /healthz 加进安全白名单
- Docker 化:多阶段 Dockerfile(非 root、tini、依赖层缓存、ffmpeg 只给 worker)、.dockerignore、
自包含 docker-compose.yml(含 PostgreSQL 18 + Redis + 命名卷 + 按序初始化 sql + healthcheck)
- tools/smoke.sh(失败非 0 退出)+ DEPLOY.md(Compose 主线 / 裸 jar 兜底 / 回滚 / 故障速查)
- 实测:mvn test 83 条全过;两入口 package 通过;prod 缺密钥退出码 1 且报错明确、配齐后正常启动;
冒烟脚本全 PASS;本机无 Docker,镜像与 compose 未经构建验证(已在回执如实标注)
11 KiB
FruitShorts 后端部署与回滚(DEPLOY)
依据:
docs/04-部署架构.md(第一阶段 Docker Compose 单机、镜像即交付物;生产才上 K8s)、docs/11-发布与回滚方案.md、docs/12-上线检查清单.md(播放签名生产必开、密钥走环境变量)。 目标机形态:办公室一台主机(§三 精简方案:app/api/DB/Redis 合并 1 台 8C32G,转码 worker 独立进程)。
一、两条部署路径(先选一条)
| 路径 | 适用 | 命令主线 |
|---|---|---|
| A. Docker Compose(推荐,文档口径) | 目标主机装了 Docker + Compose;一条命令起全栈(含 PG 18 与 Redis) | 见 §三 |
| B. 裸 jar(兜底) | 主机没有 Docker,或只想起单个进程调试 | 见 §四 |
两条路径的配置完全相同(同一批环境变量),只是"谁来跑进程"不同。
二、环境变量清单
复制 .env.example 为 .env 填值(.env 已在 .gitignore,不要提交)。
使用 --spring.profiles.active=prod(Compose 里已默认)时,下表标 🔴 的项缺失/仍是 dev 默认值/长度不足 32 位 → 启动直接失败(fail-fast,见 §五)。
| 变量 | 必填 | 缺失后果 | 说明 |
|---|---|---|---|
DB_URL |
✅ | 用默认 jdbc:postgresql://127.0.0.1:5432/fruitshorts,连不上就启动失败 |
Compose 里默认指向 db 服务 |
DB_USER / DB_PASSWORD |
✅ | 连库失败 | Compose 的 db 容器用同一组值创建库 |
DB_NAME |
Compose 必填 | 默认 fruitshorts |
只在 compose 里用到 |
JWT_ADMIN_SECRET |
🔴 | prod 拒绝启动 | 管理端令牌密钥,≥32 位,生成:openssl rand -base64 48 |
JWT_APP_SECRET |
🔴 | prod 拒绝启动 | 客户端令牌密钥。过去这个键没外置,一直用代码里的 dev 默认值,务必注入 |
MEDIA_SIGNATURE_SECRET |
🔴 | prod 拒绝启动 | 播放地址签名密钥(docs/12-上线检查清单) |
MEDIA_SIGNATURE_REQUIRED |
🔴 | prod 下不是 true 就拒绝启动 |
生产必须开;本地调试保持 false |
CORS_ALLOWED_ORIGINS |
🔴 | prod 下为空则拒绝启动 | 逗号分隔的正式来源,生产不许用 * |
MEDIA_ROOT_DIR |
建议 | 默认 ./data/uploads,容器里必须为 /data/uploads |
媒资根目录(挂载卷) |
SERVER_PORT |
Compose 已设 | 默认 8081/8080 | app-api 8081 / admin-api 8080 |
SWAGGER_ENABLED |
可选 | 默认 false | 生产是否开放 /doc.html 与 /v3/api-docs |
STORE_VERIFIER / GOOGLE_PLAY_* |
可选 | 默认 disabled |
商店票据校验,没凭据就保持 disabled(对外返回"尚未配置") |
密钥怎么生成:openssl rand -base64 48(每个密钥单独生成,别复用)。
三、路径 A:办公室单机 + Docker Compose(主线)
目标主机前置:Docker + Docker Compose(docker compose version 有输出即可),建议 8C32G。
# 0) 取代码(或拿打包好的目录)并进入 FruitServer
cd FruitServer
# 1) 配置环境变量
cp .env.example .env
openssl rand -base64 48 # 生成三个密钥,分别填进 JWT_ADMIN_SECRET / JWT_APP_SECRET / MEDIA_SIGNATURE_SECRET
vi .env # 还要填 DB_PASSWORD、CORS_ALLOWED_ORIGINS
# 2) 构建并启动(首次会自动建表 + 造种子数据)
docker compose up -d --build
# 3) 看状态与日志
docker compose ps # 预期:db/redis healthy,app-api/admin-api healthy,transcode-worker running
docker compose logs -f app-api # 预期:出现 "生产配置校验通过" 与 "Started AppApiApplication"
# 4) 冒烟
tools/smoke.sh # 预期:全部 PASS,退出码 0
端口与访问:app-api 8081(对客户端开放)、admin-api 只绑 127.0.0.1:8080(管理端仅内网,公网通过 SSH 隧道或反代访问)、PG/Redis 不对宿主暴露。
数据库初始化:sql/*.sql 已挂进 docker-entrypoint-initdb.d(schema → schema-phase1 → partition → data → data-phase1),
只在数据卷为空时执行。改 SQL 后要重来:docker compose down -v(会删数据)再 up。
日常操作
docker compose ps # 状态
docker compose logs -f --tail=200 # 全部日志
docker compose restart app-api # 重启单个服务
docker compose up -d --build app-api # 只重建单个镜像
docker compose down # 停(保留数据卷)
四、路径 B:裸 jar(兜底)
目标主机前置:JDK 21;转码进程额外需要 ffmpeg/ffprobe(brew install ffmpeg 或 apt install ffmpeg)。
数据库:自己装 PostgreSQL 18 并导入 sql/*.sql(顺序同上)。
cd FruitServer
mvn -pl fruitshorts-app-api,fruitshorts-admin-api,fruitshorts-transcode-worker -am package -DskipTests
# 导出环境变量(示例;密钥务必换真实值)
export DB_URL=jdbc:postgresql://127.0.0.1:5432/fruitshorts DB_USER=fruitshorts DB_PASSWORD='***'
export JWT_ADMIN_SECRET="$(openssl rand -base64 48)" # 生产请固定保存,不要每次重新生成
export JWT_APP_SECRET="$(openssl rand -base64 48)"
export MEDIA_SIGNATURE_SECRET="$(openssl rand -base64 48)"
export MEDIA_SIGNATURE_REQUIRED=true CORS_ALLOWED_ORIGINS='https://admin.example.com'
export MEDIA_ROOT_DIR="$PWD/data/uploads"
# 起三个进程(生产用 prod profile;本地调试去掉 --spring.profiles.active=prod 即可)
nohup java -jar fruitshorts-admin-api/target/fruitshorts-admin-api-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=prod > /tmp/admin.log 2>&1 &
nohup java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=prod > /tmp/app.log 2>&1 &
nohup java -jar fruitshorts-transcode-worker/target/fruitshorts-transcode-worker-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=prod > /tmp/worker.log 2>&1 &
⚠️ 重新打包必须重启进程:
spring-boot:repackage会原地替换 jar,运行中的进程再去加载"没加载过的类" 会抛NoClassDefFoundError,表现为请求挂住(S1 记录过这个坑)。顺序永远是"改完 → 打包 → 重启 → 验证"。
五、prod fail-fast 是什么、长什么样
prod profile 下 ProdSecretGuard 会校验:JWT 双密钥 + 媒体签名密钥(非空、不是 dev 默认值、≥32 位)、
signature-required=true、DB_URL 非空、CORS 非空。任一不满足 → 打印全部问题并终止启动:
ERROR c.f.framework.config.ProdSecretGuard : 生产配置校验未通过,拒绝启动(prod profile):
- fruitshorts.jwt.admin-secret(环境变量 JWT_ADMIN_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥
- fruitshorts.jwt.app-secret(环境变量 JWT_APP_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥
- fruitshorts.media.signature-secret(环境变量 MEDIA_SIGNATURE_SECRET) 仍是代码里的 dev 默认值,必须换成生产密钥
- fruitshorts.cors.allowed-origins 为空(环境变量 CORS_ALLOWED_ORIGINS)—— 生产不允许用通配 *,必须显式列出来源
请按 FruitServer/DEPLOY.md 的环境变量清单补齐后重启。
exit code = 1(部署脚本可以直接判失败)。没有 prod profile 时这套校验不生效,本地开发照旧开箱即用。
六、发布后验证(每次发版都做)
docker compose ps:三个应用容器Up (healthy)(worker 是Up)。tools/smoke.sh:全 PASS、退出码 0。它检查/healthz、/app/categories、/app/home/feed、 两个/v3/api-docs、/doc.html,以及未授权路径是否按统一信封拒绝。curl -s http://127.0.0.1:8081/healthz:(从容器里或宿主机)应返回{"code":0,...,"status":"UP"}。- 业务抽查:
curl -s http://127.0.0.1:8081/app/home/feed | head -c 200(有内容); 后台http://127.0.0.1:8080/doc.html(若SWAGGER_ENABLED=true)。 - 登录一个后台账号,确认能进后台首页(连通数据库与权限体系)。
七、回滚
镜像/代码回滚
# 用上一个版本的 tag(每次发版都打 tag:fruitshorts/app-api:2026-10-16-1)
docker compose stop app-api admin-api transcode-worker
docker tag fruitshorts/app-api:2026-10-15-2 fruitshorts/app-api:latest
docker compose up -d app-api admin-api transcode-worker
tools/smoke.sh
或用 git 回到上一个 tag 重新 up -d --build:
git fetch --tags && git checkout <上一个 release tag>
docker compose up -d --build
数据库回滚(谨慎):schema 脚本是幂等增量(ADD COLUMN IF NOT EXISTS 等),一般不需要回滚表结构;
真要回滚数据用备份:docker compose exec -T db pg_dump -U fruitshorts fruitshorts > backup.sql。
发布前先备份是硬要求:
docker compose exec -T db pg_dump -U "${DB_USER:-fruitshorts}" "${DB_NAME:-fruitshorts}" > backup-$(date +%F-%H%M).sql
回滚后必须重跑 tools/smoke.sh,并确认转码队列状态正常(后台「数据看板 → 转码看板」)。
八、未在本机验证的部分(如实说明)
本机没有 Docker / colima / podman(docker 命令不存在),所以:
Dockerfile、.dockerignore、docker-compose.yml未经本机构建与运行验证(只做了语法与配置项静态检查)。- 本机真实验证过的是:裸 jar 路径(两个入口 + worker 起停、
/healthz、冒烟脚本)、 prod fail-fast(缺密钥时退出码 1 + 明确报错)、配置外置(DB_URL等改动后仍能起)。 - 目标主机上有 Docker 之后,请先跑 §三 的 1)–4) 步确认;如报错把
docker compose logs贴回来。
九、故障速查
| 现象 | 先看 | 处理 |
|---|---|---|
| 容器起不来,日志有"生产配置校验未通过" | §五 | 补 .env 里对应变量;密钥用 openssl rand -base64 48 |
/healthz 超时(不是快速 404/200) |
是不是刚重新打包没重启? | 重启进程(§四 的注意事项) |
app-api healthy 但 /app/* 报连库失败 |
docker compose logs db |
确认 DB_URL 指向 db:5432,DB_PASSWORD 与 db 容器一致 |
转码任务一直 QUEUED |
worker 日志 | 确认 transcode-worker 在跑、MEDIA_ROOT_DIR 与 API 相同、镜像里装了 ffmpeg |
十、本地开发(不上 Docker)
mvn -pl fruitshorts-app-api,fruitshorts-admin-api -am package -DskipTests
java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar # 不带 prod profile,开箱即用
tools/smoke.sh # 本机一键冒烟