一次部署至少要回答三个问题:运行的字节是否来自审核过的构建,实例当前能否接收业务,以及旧实例退出时已经接受的工作到了什么状态。开发服务器返回 200,只回答了某个请求在开发环境中能够完成。

Play 的 production 启动脚本把构建与服务运行分开,但这种分离不会自动产生正确的就绪检查,也不会把强制结束变成有序停止。实验用同一个 dist 产物启动独立进程,经本地代理读取健康、就绪和流响应;随后分别发送 SIGTERM 和 SIGKILL,以客户端结果、新数据库连接和资源日志交叉核对。

产物身份先于启动成功

本篇增量已接入第00篇的累计源码包。共享工程显式启用数据库后55项JUnit全部执行,stage与dist通过;生产重放的16个场景再次通过,见 evidence/batch30-35/shared-build.json 和 shared-production-http。下文隔离构建与测量记录仍保留原始时间,不替换成共享重跑数字。

实验固定 Play 3.0.6、Scala 2.13.15、sbt 1.10.7、JDK 21.0.11、Pekko 1.0.3、Pekko HTTP 1.0.1、HikariCP 5.0.1、PostgreSQL JDBC 42.7.5。Play 源码固定在 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。默认后端由运行时 play.server.provider 读回为 play.core.server.PekkoHttpServerProvider,不能从依赖列表中存在某个 JAR 推断实例实际使用了它。

stage 生成可直接运行的目录,dist 将分发内容打包为 ZIP。两者都包含启动脚本、配置和运行时依赖。本文将 ZIP 解压到独立目录,逐文件比对它与 stage 的字节,随后从解压目录启动;运行时读取 ProductionProbe 的代码来源,确认类从该目录的 JAR 加载。进程工作目录中没有 app、test、project 源码目录。官方部署说明描述了两种产物形式。

flowchart LR
  A[冻结源码与依赖] --> B[clean / test]
  B --> C[stage 目录]
  B --> D[dist ZIP]
  D --> E[独立解压目录]
  C -.逐文件 SHA 与字节对照.-> E
  E --> F[启动脚本 exec JVM]
  F --> G[运行时类来源 / 后端 / 配置读回]
  G --> H[真实 HTTP 与数据库终态]

这里的产物同一性是本次构建内部的检查。它没有证明两个不同机器的 ZIP 必然逐字节相等;ZIP 元数据、工具链和生成输入都可能影响重现性。发布记录应同时保留源码提交、构建命令、依赖摘要、最终 ZIP 摘要和实际运行产物标识,回滚时直接选择旧产物,避免现场重新解析依赖。

生产进程也仍然依赖外部条件。JDK、可写日志与 PID 目录、数据库权限、端口、配置与密钥均不因为 ZIP 完整而自动具备。启动失败应定位到具体层:JVM 参数错误发生在应用加载前;配置缺失可能发生在依赖注入时;端口绑定失败则发生在服务器创建时。只有看到监听成功后再请求接口,才能把启动和业务可用连接起来。

配置覆盖是可以读取的事实

conf/production.conf 显式包含 application.conf,为本章增加三个实验配置项。启用实验资源的开关默认关闭,避免其他章节的常规测试意外打开数据库。

1
2
3
4
include "application.conf"
prod.enabled = false
prod.label = "packaged"
prod.label = ${?PROD_LABEL}

外部文件通过 -Dconfig.file 选择,它再包含 production.conf。系统属性可以覆盖应用配置;环境变量必须由配置中的替换表达式读取,任意新环境变量名不会自然成为 Play 配置键。本实验分别读取三种结果:stage 使用 -Dprod.label=system 时得到 system;外部文件在 include 后设置 prod.label=external 时得到 external;没有这两项覆盖的 dist 进程得到 PROD_LABEL 的值 environment。官方生产配置说明给出了文件、系统属性与环境替换的入口。

配置检查接口只输出 label、后端类名、当前接受状态和代码来源,不输出密钥或完整配置树。真实系统中连这些诊断信息也应限制访问。实验每次启动都在环境中生成独立合成 secret,原始命令和证据不保存 secret 值;不能把仓库中的演示密钥用于公开服务。

本章接口只供回环实验:/production/drain 使用 GET 便于有限驱动,业务系统的管理操作应使用受保护的控制面。数据库账号与密码也是本地 fixture 的合成值。将实验直接绑定公网并不能得到生产部署方案。

存活与就绪依赖不同条件

health 返回进程能够处理该 HTTP 请求的事实。ready 则要求应用仍接受工作、专用执行器可接收任务,并成功取得数据库连接完成有效性检查。执行器采用两个线程、两个排队位置;就绪检查也会受到资源容量影响,不能无限排队后仍被称为及时的就绪信号。

关闭准入后,实验要求 health=200、ready=503、新工作 503。这组状态允许代理先停止分发新业务,实例继续完成已接受的请求。把存活和就绪都写成恒定 200,会使数据库初始化失败或已进入排空阶段的实例继续获得新请求。反过来,把任意短暂数据库故障都映射成存活失败,可能触发不必要的进程重启。

Controller 只承接这些状态,资源与接受策略集中在 ProductionProbe。下面是工程中完整、实际编译的 Controller,ProductionProbe 的完整实现随累计工程交付。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package controllers;
import jakarta.inject.Inject;
import java.util.concurrent.CompletionStage;
import production.ProductionProbe;
import play.mvc.Controller;
import play.mvc.Result;
public final class ProductionController extends Controller {
private final ProductionProbe probe;
@Inject public ProductionController(ProductionProbe probe) { this.probe = probe; }
public Result health() { return ok("process-alive"); }
public Result state() { return ok(probe.state()); }
public CompletionStage<Result> ready() { return probe.ready().thenApply(s -> status(s.path("ready").asBoolean() ? 200 : 503, s)); }
public Result drain() { probe.drain(); return ok(probe.state()); }
public CompletionStage<Result> work(String id, int millis) {
return probe.work(id, millis).handle((body, error) -> error == null ? ok(body) : status(503, "work-rejected-or-failed"));
}
public Result stream(int chunks) { return ok().chunked(probe.stream(chunks)).as("application/octet-stream"); }
}

方法中的 CompletionStage 保持异步返回,JDBC 查询由有界专用执行器执行。业务错误在这个有限实验中统一变为 503;实际业务应区分输入错误、重复请求和依赖不可用,不能把这种简化映射直接扩展成完整错误协议。

就绪检查成功也存在有效期。数据库可能在探测结束后立即断开,准入状态也可能随后改变。就绪用于影响流量分发,业务路径本身仍须处理获取连接失败、请求截止期和事务回滚。

代理的首字节与完整响应

本地代理是 Python 标准库实现的有限 HTTP 转发器,每读取一段上游数据就向客户端写出并 flush。它重建连接边界,使用连接关闭表示下游响应结束;因此不能把它观察到的传输形式写成 Nginx 或云负载均衡器的默认行为。

流接口生成 8 个 32 KiB 块,相邻 tick 间隔 40 ms。客户端经代理先读一块,记录首块时间,再读完剩余字节。验收同时要求总长 262144 字节、完整摘要一致,以及首块出现后响应仍持续一段时间。若只记录最终 200,就无法分辨代理是渐进转发还是聚合后一次返回。

本次首块在约 36.7 ms 出现,完整响应在约 452.7 ms 接收完毕,SHA-256 为 8a39d2abd3999ab73c34db2476849cddf303ce389b35826850f9a700589b4a90。原始记录在 observations.json 的 proxy-stream。它证明该本地代理及本次有限响应发生了渐进交付。它没有测试公网 TLS、压缩、缓存、代理缓冲上限、慢消费者或多层代理;这些功能可能再次改变首字节和取消传播。

健康和就绪也经代理验证。直接请求后端正常而经代理失败时,Host 校验、路径重写、代理超时和连接协议都可能成为差异入口。可信代理头属于第 27 篇的独立安全协议,本章代理只设置固定 Host: localhost,没有伪造用户来源身份。

SIGTERM:停止接收、排空与资源释放

Play 3.0.6 的 ProdServerStart在生产启动路径注册 JVM shutdown hook。收到可处理的停止信号后,它调用服务器停止流程。Pekko HTTP 后端在不同 Coordinated Shutdown 阶段执行 unbind 和 terminate;unbind 停止接收新连接,terminate 在期限内等待现有请求。

本章额外在 before-service-unbind 阶段关闭业务准入,防止应用继续接受新工作。应用停止钩子随后等待专用执行器结束、关闭数据库池和文件通道,日志分别记录 cleanup-start 与 cleanup-done。只有前者出现时,不能宣称资源已释放。

sequenceDiagram
  participant C as 代理客户端
  participant P as Play 进程
  participant D as PostgreSQL
  participant O as 停止驱动
  C->>P: 工作请求
  P->>D: BEGIN / INSERT / pg_sleep
  D-->>O: 新连接查询行数 0
  O->>P: SIGTERM
  P->>P: 关闭业务准入 / unbind
  D-->>P: 查询完成 / COMMIT
  P-->>C: 200 committed=true
  P->>P: 等待执行器 / 关闭池和文件
  P-->>O: 进程退出
  O->>D: 新连接查询行数 1

驱动等待 inserted-uncommitted 日志后才发送信号;信号前还通过新连接确认该行尚不可见。这样避免进程收到信号时工作尚未开始,或者其实已经提交的两种假通过。实际信号后约 1.729 秒进程退出,退出码 143;代理客户端拿到完整 200,独立连接看到一行,监听端口关闭。资源日志顺序如下:

1
2
3
4
5
inserted-uncommitted graceful
before-unbind inFlight=1
committed graceful
cleanup-start inFlight=0
cleanup-done workerTerminated=true,poolClosed=true,fileClosed=true

服务器配置使用 play.server.terminationTimeout=10s,service-requests-done.timeout=12s,驱动给进程 18 秒等待。它们约束不同范围:10 秒是服务器请求终止预算,12 秒是协调停止阶段预算,18 秒是实验驱动对整个进程的等待上限。应用停止钩子、其他阶段和 JVM 剩余停止工作都占用时间,不能把 10 秒当成进程必然在 10 秒内退出的保证。

Server.determineServerTerminateTimeout会检查服务器预算与阶段预算的关系;PekkoHttpServer展示了真正执行 unbind、延迟和 terminate 的位置。部署平台的宽限时间还要覆盖摘流传播与必要的应用清理,具体数值须用完整停止路径测量。

SIGKILL:事务恢复与清理缺口

第二个进程使用相同的 dist 目录。请求在事务内插入 killed 行并执行有限 pg_sleep;新连接确认不可见后,驱动直接发送 SIGKILL。客户端连接断开,应用没有机会记录 cleanup-done。驱动等待 PostgreSQL 中该应用名的连接归零,再查订单表,未提交行数仍为零。

这次回滚证明了这笔尚未提交的数据库事务在连接终止后的结果。它没有证明任意请求在强制结束时都不会成功:若 COMMIT 已到达数据库而响应尚未交给客户端,客户端失败与数据库成功可以同时成立。调用方重试仍需要第 23 篇的幂等键与业务状态查询。

文件通道会随被杀进程消失,但其磁盘路径仍然存在;没有应用清理事件,不能把操作系统回收句柄写成应用停止钩子完成。PID 文件也可能残留。生产启动源码明确把 PID 删除放在协调停止阶段,非正常退出不经过该任务。恢复应先验证旧进程确实不存在,不能看到 PID 文件就直接删除或向复用后的 PID 发送信号。

恢复进程继续使用同一个产物、同一个实验 schema 和新的独立 PID 文件。启动建表采用 IF NOT EXISTS,保留此前成功提交的 graceful 行;新请求写入 recovered,最终只有这两行。这里没有清空数据库伪造“干净恢复”,也没有声称完成了崩溃后自动清理所有外部文件。

复现实验与证据入口

从累计工程目录运行,先按第 00 篇准备 JAVA_HOME 与固定 launcher。PostgreSQL fixture 必须是受控的本地实验实例,脚本只创建并最终删除自己随机命名的 prod_* schema,保留容器给其他章节使用。用环境变量传入实际容器名和 JDBC URL。

1
2
python3 lab/production_build.py --log evidence/batch30/build.log
python3 lab/production_checks.py --evidence evidence/batch30/run --container "$PLAY_LAB_CONTAINER" --jdbc-url "$PLAY_LAB_JDBC_URL"

网络证据目录必须尚不存在。脚本从 target/universal 读取 ZIP,在 target/production-unpacked 解压;已有解压目录时拒绝覆盖。重新构建前先归档实验结果,再执行 clean 构建。启动参数、JVM 版本、JUnit XML、产物摘要、SQL 独立查询和各进程日志分别保存,不能用一份 PASS 摘要替代原始观察。

原始材料位于 examples/play-lab/evidence/batch30/isolated/。build-final-02.log 是最终构建记录,run-final-02/observations.json 记录请求与退出,run-final-02/sql.json 保留独立连接查询,run-final-02/artifact.json 记录 ZIP 与 247 个文件的 SHA-256。正文中的完整 Controller 由该工程实际编译,不是仅通过代码块语法检查。共享累计工程的后续集成测试和页面验收是另外的验证层。

隔离构建的 47 个不同 JUnit 方法中,40 个实际执行、7 个数据库基线方法因默认关闭数据库而跳过;本章新增的就绪与存活测试实际执行。sbt 的汇总包含重复通知,不能把其中的 Total 54 写成 54 个不同测试。真实数据库场景由随后独立运行的生产驱动完成,16 条场景观察全部通过;这不等于补跑了被跳过的数据库 JUnit。

场景 本章要求的可观测结果 覆盖边界
stage 与 dist 分发文件逐字节相同,解压 JAR 被实际加载 单次构建
配置与就绪 system/external/environment 读回;drain 后 200/503/503 回环合成管理接口
本地代理流 262144 字节与摘要正确,首块先于结束 标准库代理 HTTP/1.1
SIGTERM 在途事务 200、行数 1、清理事件、进程退出同时成立 有限单事务
SIGKILL 与恢复 客户端断连、未提交行 0、无清理事件;原产物可再启动 强杀前尚未提交
云平台摘流、TLS、OOM、磁盘满、网络分区 NOT_RUN 不能据此宣称生产可用性

两个改动练习

  1. 将在途请求改为超过服务器终止预算的有限工作,同时分别记录 HTTP 终态、SQL 终态和进程退出。先设计新的 JDBC 超时与外层等待上限,避免用驱动强杀掩盖应用未结束;判断失败响应之后是否仍可能发生提交。
  2. 给本地代理增加明确的响应聚合模式,保持字节总数和 SHA 不变,比较首块与完整响应的时间。再让客户端读一块后主动断开,分别验证代理连接、上游流和资源终态,不能只看客户端异常。

上一篇:29 分层测试与证据 · 下一篇:31 性能与容量 · 系列起点:最小应用