部署与安全:Nginx 反代、健康门禁、优雅停机与恢复
发布不是"把 jar 拷到服务器":反代会不会改写 Host、readiness 503 时流量是否真的被摘掉、SIGTERM 后 200ms 的在途请求会不会被截断、回滚是否只换了文件却没换行为——每一层都需要独立的观察。本页用一条真实链路(Nginx 1.31.8 → Boot 4.0.9 服务 → SIGTERM 停机 → 两版本回滚)把部署面拆开验证,并如实标注 Docker 部分因用户未授权启动 daemon 而受阻:配置与官方依据讲清,但不高称实跑。
容器与信号的机制权威在 Linux信号权限与优雅停机(128+N、TERM hook、容器 stop 宽限期推导);健康/就绪的探针语义在 Actuator;可重复构建、制品回滚与凭据门禁在 交付门禁;迁移与回滚的方法论在 迁移模式与双向恢复。本页只补"部署层组合"的新证据。
固定版本:JDK 21(Liberica 21.0.11+11)、Boot 4.0.9 / Framework 7.0.10、Tomcat 11.0.27;Nginx 1.31.8(Homebrew,独立 prefix 运行)。全部观察来自 deploy-gate 实验 的一次完整公开运行(24 项检查:22 通过 + 2 项 Docker 受阻、exit 0),证据在 labs/deploy-gate/evidence/u65-2026-10-28.json。
一、部署面:五个各答不同问题的边界
| 层 | 回答的问题 | 失败的形态 |
|---|---|---|
| 反向代理 | 请求以什么身份(Host/头)到达应用;超时谁裁决 | 400/502/504 |
| 健康门禁 | 这个实例现在该不该接流量 | readiness 503 但仍被路由 |
| 停机 | 在途请求与进程退出谁先完成 | 连接被重置、半截响应 |
| 权限与密钥 | 进程以谁的身份跑、秘密存在哪 | 越权文件、秘密进了环境/日志 |
| 制品与回滚 | 回到旧版意味着行为复原吗 | 换了文件但行为没变(或反了) |
这五层没有"更高级的层能替代"的关系:Nginx 配置对了,readiness 不看照样把流量打进没准备好的实例;停机参数对了,反代不摘流照样把新请求送进正在退出的进程。本页逐层给真实观察。
二、Nginx 反代:Host、超时与连接复用
一个真实 400:upstream 名被当成 Host
首版配置用 upstream deploy_app { server 127.0.0.1:PORT; } + proxy_pass http://deploy_app;,通过 Nginx 请求 /api/v1/about 得到 400 Bad Request(Tomcat 错误页),而直连应用是 200。用上游捕获服务复现,Nginx 发给上游的请求头是(开发期诊断,机制由随后修复与 200 响应确认):
GET /api/v1/about HTTP/1.1
Host: deploy_appNginx 在未显式设置 Host 时,会把 upstream 块的名字当作 Host 头发给上游;本例 upstream 名含下划线,Tomcat 11 按非法主机名拒绝(400)。修复是一行:
proxy_set_header Host $host;修复后同一请求 200。这条实测值得记住的原因:错误表现(400 + Tomcat 页面)与直觉里的"反代配置错误"(502/504)完全不同,不看上游收到的报文就会误判成应用 bug。命名规范(upstream 名不用下划线)与显式 Host 头是两道独立防线,后者更可靠。
超时由谁裁决:proxy_read_timeout
proxy_read_timeout 2s; 下请求 /api/slow?ms=3000(应用真实 sleep 3s):
U65 nginx-timeout-504=PASS slow status=504504 是 Nginx 的裁决,不是应用的:上游还在 sleep,Nginx 已放弃等待并返回 504;应用侧的请求会继续跑完(可能产生副作用)。这决定了重试语义——504 之后的 GET 可以重试,POST 重试必须有幂等键(见 远程客户端 的重试边界)。proxy_connect_timeout、proxy_send_timeout、proxy_read_timeout 分别约束建连、发送、两次读之间的间隔,不是"总耗时预算"——前端还需要自己的 deadline。
keepalive 与连接复用
upstream { keepalive 8; } + proxy_http_version 1.1; + proxy_set_header Connection ""; 三件套让 Nginx 复用与上游的连接。Connection "" 必须显式清空——HTTP/1.1 默认持久连接(并不显式发送 Connection: keep-alive),但 Nginx 向上游转发时如果原样带上客户端的 Connection: close,复用即失效。复用的收益是省下每条请求的 TCP 建连(本机 loopback 差异小,跨机房明显),代价是上游连接池里的陈旧连接需要 keepalive_timeout 治理——与 HTTP/TLS 连接池 的"池容量与陈旧连接"是同一问题的两层。
三、健康门禁:readiness 503 之后,谁真的摘流
探针必须显式入组
实验的自定义探针 deployReadiness 第一版没有进 readiness 组,/actuator/health/readiness 恒为 200——这与 U24/U79 踩过的坑相同:自定义 HealthIndicator 的 bean 名不会自动出现在组里,必须:
management.endpoint.health.group.readiness.include=readinessState,deployReadiness修正后受控开关 /lab/ready?on=false 让 readiness 立刻 503(OUT_OF_SERVICE),而 liveness 保持 200:
U65 readiness-down-liveness-up=PASS readiness=503 liveness=200liveness 不接负载均衡:进程活着但依赖/初始化没准备好时,重启它只会让问题更糟;该被"移出流量"而不是"杀掉重启"。
开源 Nginx 没有主动健康检查
把 readiness 置 503 后,经 Nginx 的业务请求仍然是 200:
U65 readiness-probe-via-nginx-503=PASS via nginx=503 # 探针本身透传
U65 oss-nginx-no-active-check=PASS business still=200 # 业务流量没有自动摘除这是本页最重要的边界:开源版 Nginx 的 max_fails 是被动检查(请求失败后计数、临时标记),它不会定期去敲 /actuator/health/readiness。readiness 语义要真正生效,需要一个"消费者":Kubernetes 的 readinessProbe、Consul/Nacos 的健康检查、或发布脚本读探针后 upstream ... down + reload。实验用后者演示:
U65 orchestrator-drain-502=PASS drained status=502 # upstream 标记 down + reload
U65 orchestrator-restore-200=PASS restored status=200 # 探针恢复后重新纳管"配置了 readiness 端点"与"readiness 真的影响流量"之间隔着一个编排器——把这个责任写清楚,比在 Nginx 里堆 max_fails 参数更接近实际系统。
四、优雅停机与信号:在途请求的归宿
Boot 侧配置:
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=10s实测 SIGTERM 一个正在处理 1.5s 慢请求的实例:
U65 graceful-shutdown-drains=PASS inflight=200 exit=143 elapsed=1.09s
U65 upstream-down-502=PASS after stop status=502三件事同时成立:在途请求正常完成(200)、进程以 143(128+SIGTERM,U14 已验的编码)退出、新请求在实例退出后得到 502(Nginx 连不上上游)。对照 kill -9 负例(对临时副本进程执行):
U65 kill9-resets-connection=PASS kill -9 in-flight=502SIGKILL 不可捕获,在途连接被内核直接重置——Nginx 看到的是上游断开,客户端拿到 502/连接错误。"停机 = kill -9 再拉起"等于每次发布都丢弃当时的在途请求;graceful shutdown 的代价是退出变慢(本例 1.09s),收益是不丢请求。
三个必须对齐的时钟:应用的 timeout-per-shutdown-phase(等待在途请求的上限)、编排器的终止宽限期(K8s terminationGracePeriodSeconds / 容器 stop_grace_period)、上游的摘流时刻。宽限期 < 应用等待时长 = 宽限期到点被 SIGKILL(U14 的推导在本实验的非容器部分得到印证:1.09s 的停机在 10s 等待窗口内完成)。顺序同样重要:先摘流(不再派新请求)再 SIGTERM,否则摘流前的那几百毫秒仍有新请求进入正在退出的进程。
五、Docker 与容器:配置推演(受阻,未验证)
本节全部为配置与官方依据的推演,Docker daemon 未运行且用户未授权启动——按票面口径"受阻仍算待验证",不进入已运行清单。
镜像最小化的常见形态(官方 Dockerfile reference 的多阶段构建):构建阶段用 maven:3.9-eclipse-temurin-21 执行 mvn -B package,运行阶段用 eclipse-temurin:21-jre 只拷贝 jar;ENTRYPOINT ["java","-jar","/app/app.jar"] 用 exec 形式,让 JVM 直接成为容器 PID 1(或紧邻 exec 的进程)并直接接收 SIGTERM。容器内 JVM 内存参数建议 -XX:MaxRAMPercentage=75(尊重 cgroup limit),而不是写死 -Xmx——这与 U14 cgroup 页的"JVM 读取限制,不替内核分配"一致。
容器运行时:网络、卷、资源与 PID 1(受阻)
需要容器才能验证、本轮未验证的项:镜像分层与缓存命中、docker run 的端口映射与网络别名、volume 的持久化语义、--memory 触发容器 OOM(U14 的两条失败路径)、docker stop 的 TERM→KILL 宽限期与 PID 1 对信号的处理(若 ENTRYPOINT 用 shell 形式,SIGTERM 常打不到 JVM)。这些在 evidence 的 checks 中以 blocked: true 记录,正文不转写为通过。
六、最小权限与密钥
实测:应用进程以当前登录用户运行(ps -o user 断言);合成秘密确实注入了被测进程的环境变量,检查断言它没有出现在应用日志、响应体与 /actuator/env(该端点未暴露,404):
U65 app-runs-as-current-user=PASS user=gaochao
U65 secret-not-in-logs=PASS logBytes=134 accessLines=1 tokenPresent=False
U65 secret-not-in-response=PASS response clean
U65 env-endpoint-not-exposed=PASS /actuator/env status=404密钥的生命周期不止"不回显"
工程纪律三条:秘密走环境变量或外部注入,不写进仓库(git grep 扫描 + 评审);管理端点最小暴露(只开 health,info,env 这类能回显配置的端点默认关闭);进程以非特权用户运行(本机以登录用户验证;容器内对应 USER 指令与文件属主——后者属受阻项)。"没在响应里看到秘密"是展示层保证,不是密钥管理方案;轮换、托管与审计(Vault/KMS)未运行。
七、依赖安全:扫描要在门禁里,不在口号里
实验对 6 个关键构件用完整 Maven 坐标(groupId:artifactId,OSV 的 Maven 生态要求)向 OSV 批量查询:
U65 dependency-scan-executed=PASS scanned=[('org.apache.tomcat.embed:tomcat-embed-core','11.0.27'), ('org.springframework.boot:spring-boot','4.0.9'), ('org.springframework:spring-core','7.0.10'), ('org.springframework:spring-webmvc','7.0.10'), ('org.yaml:snakeyaml','2.5'), ('tools.jackson.core:jackson-databind','3.1.7')] queryFailures=[]
U65 no-known-vulnerabilities=PASS vulns=[]用裸 artifactId 查询会静默返回空结果——这是独立复核实测的假阴性来源:snakeyaml 查 1.33 返回空,org.yaml:snakeyaml 查 1.33 返回 GHSA-mjmj-j48q-9wg2。扫描器"没报问题"必须能区分三种情况:坐标正确且无漏洞、坐标错误导致空结果、查询失败;本实验用 queryFailures 字段把后两者分开,并在固定坐标上做了一次阳性对照(复核记录在案)。
三条边界:扫描结果是查询时刻的数据库快照,不是"永久安全";OSV 只覆盖已知漏洞,不覆盖配置错误与业务缺陷;"没扫出"要区分"没有"与"查询失败/坐标错误"。扫描应放在 CI 门禁(U63 的流水线)并设定处置 SLA,而不是发版前手动跑一次。
八、发布与回滚:行为差异才是证据
两修订版 jar 经同一 Nginx 切换:
U65 deploy-v2-behavior=PASS {"version":"2.0.0","capabilities":["deploy","canary"]}
U65 rollback-v1-behavior=PASS {"version":"1.0.0","capabilities":["deploy"]}回滚验证的是用户可见行为回到旧版(canary 能力消失),不是"旧 jar 被复制回去"。切流动作 = 改 upstream 端口 + reload。本实验的切换窗口里 v1 先停、v2 再起,中间有短暂不可用窗口;生产上应先起新实例、切流、最后停旧实例(先切后停),这一顺序是编排建议而非本实验证据。回滚代码与回滚数据是两个决定(U63 的迁移页已展开):本实验两版本共享无状态,不涉及数据结构;若新版做过不可逆迁移,回滚代码会撞上"旧代码读不懂新结构"。
九、证明边界:已运行与受阻分开
- 已运行:Nginx 1.31.8 真实反代(Host 修复、504 超时、keepalive 配置)、readiness/liveness 分离与探针透传、开源 Nginx 无主动检查的实测、编排器摘流/恢复、SIGTERM 优雅停机(在途 200、退出 143)、kill -9 负例、两版本发布回滚、非 root 当前用户、秘密的回显与端点检查、OSV 依赖扫描。
- 受阻未验证:Docker 镜像构建与分层、容器网络/卷/资源限制、容器内信号与 PID 1 语义、compose(用户未授权启动 Docker daemon)。
- 未运行:生产 TLS 终止、Kubernetes、服务网格、生产负载均衡、外部密钥托管。
十、架构师变化条件
把单实例换成多实例:Nginx upstream 需要 max_fails/fail_timeout 与真正的主动检查(或交给 K8s Service/Ingress),摘流从"改配置 reload"变成"控制面下发";把 reload 换成滚动发布会引入新旧版本并存的窗口,契约兼容从"可选"变成"必须"(U63 契约测试的角色);开启 TLS 终止后 Host/转发头语义变化(X-Forwarded-Proto),应用侧的安全假设要重验;容器化后停机链路变成"编排器宽限期 → 容器 stop 信号 → JVM hook → 上游摘流"四段,任何一段的时钟不匹配都会退化成 kill -9。每个变化都要重跑本页的四个对照(400/504、readiness 摘流、在途请求、回滚行为),不能沿用旧结论。
闭卷验收
本页 14 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。