在微服务架构中,**优雅停机(Graceful Shutdown)**是保障服务发布、滚动更新(Rolling Update)以及自动缩容(Scale-in)期间系统高可用、用户请求零报错的核心基石。
当使用 Spring Boot 3.x 部署在 AWS ECS (Elastic Container Service) 并启用了 AWS Service Connect (基于 Envoy 的服务网格) 时,仅在 Spring Boot 中开启 server.shutdown=graceful 往往无法完全避免偶发的 503 Service Unavailable 或 Connection reset by peer。
本文将从原理深度解构底层竞态条件,附带完整的 Spring Boot 与 AWS 官方文档索引、源码级参数说明,并提供生产环境已验证的协同解决方案。
核心术语表 (Core Terminology & Glossary)
在微服务生命周期与平滑下线领域,涉及多个网络与系统级专业概念。以下为核心关键术语对照表:
| 中文术语 | 英文对照 | 核心含义与技术解释 |
|---|---|---|
| 在途请求 / 处理中的请求 | In-flight Request | 指已经被应用服务器(如 Tomcat)接收,且当前正在执行线程池中计算或等待 I/O 返回、尚未向客户端发出完整响应的 HTTP/RPC 请求。优雅停机的首要目标就是确保所有在途请求被完整处理完毕,不被强制斩断。 |
| 流量排空 / 连接排空 | Traffic Draining / Connection Draining | 指在实例下线或升级前,主动阻断新请求的流入,同时维持现有连接通道,给予一定的时间窗口让所有在途流量、活跃 TCP 连接自然结束处理的过程。在 Envoy、ALB、Nginx 及 WebServer 中均有对应的 Draining 实现。 |
| 优雅停机 / 平滑下线 | Graceful Shutdown | 一种系统关机策略。进程收到停止信号后,不是立刻执行退出,而是按序完成:停止接收新请求 $\to$ 排空在途请求 $\to$ 关闭内部定时/异步任务 $\to$ 安全关闭数据库连接池/MQ 连接 $\to$ 最终销毁 Spring 上下文并干净退出。 |
| 预停机缓冲 / 预停机钩子 | Pre-stop Buffer / Pre-stop Hook | 在进程真正开始关闭网络端口(Acceptor)之前人为引入的一段安全等待时间(通常为 10~15 秒)。用于抵消服务发现(Cloud Map/DNS)与全网客户端代理(Envoy)更新路由表的传播延迟,确保没有新请求路由过来后才真正停机。 |
| 网络接收器 / 连接接收线程 | Acceptor / Acceptor Thread | 嵌入式 Web 容器(如 Tomcat/Jetty)专门负责监听客户端 TCP SYN 握手并创建 Socket 连接的底层组件。开启优雅停机后,WebServer 的第一动作就是关闭 Acceptor 停止监听。 |
| 服务发现服务 | SDS (Service Discovery Service) | Envoy 服务网格体系中用于动态发现后端集群端点(Endpoints)的 API 协议。在 ECS Service Connect 中,Cloud Map 维护端点变化,Envoy 通过类似 SDS 的机制从控制面拉取最新的上游实例 IP:Port 列表。 |
| 任务终止超时 | Stop Timeout | 容器编排层(如 AWS ECS Task Definition)设定的宽限期。ECS 向容器下发 SIGTERM 信号后开始倒计时,若容器在达到 stopTimeout 时仍未退出,系统将强制发送不可捕获的 SIGKILL 信号将其强行终止。 |
| 就绪探针 | Readiness Probe | 检查应用**“是否具备接收外部业务流量的能力”**的探针。探针失败仅会导致负载均衡器将其移出路由转发池,不会导致容器被杀死或重启。停机时会主动翻转为不就绪。 |
| 存活探针 | Liveness Probe | 检查应用**“进程内部是否活着、是否陷入死锁或严重损坏”的探针。若探针连续失败,编排系统会直接判定容器坏死并强制将其杀死并重启**。停机排空期必须保持为健康。 |
| 注销延迟 | Deregistration Delay | 负载均衡器(如 AWS ALB Target Group)将实例置为 draining 状态后,等待已建立的客户端连接完成请求的最大超时(默认 300s,建议调至 15~30s)。期间 ALB 停止往该实例分发新请求。 |
| 级联雪崩 / 故障扩散 | Cascading Failure / Domino Effect | 由于局部组件(如数据库短时卡顿)导致所有实例的全局健康检查全部报错,进而引发网格/ALB 将所有节点同时摘除,导致服务 100% 瘫痪的故障扩散现象。 |
| 优雅信号与强制杀死 | SIGTERM (15) vs SIGKILL (9) | SIGTERM(Termination Signal)是可被进程捕获的软中断信号,用于通知进程启动优雅退出;SIGKILL(Kill Signal)由操作系统内核直接强杀进程,不可被捕获、不能被忽略,不给进程任何保存现场和清理资源的机会。 |
核心痛点:为什么仅配置 Spring Boot 优雅停机仍会出现 503?
架构与请求链路
在启用了 ECS Service Connect 的架构下:
- 每个服务 Task 内部会自动注入一个
ecs-service-connect容器(内部运行 Envoy Proxy 作为 Sidecar)。 - 服务实例的 IP 与端口由 AWS Cloud Map 进行注册与发现。
- 客户端 Task 发起 RPC/HTTP 请求时,请求先到达客户端本地的 Envoy,客户端 Envoy 根据从 Cloud Map 同步的 SDS(Service Discovery Service)路由表,将流量负载均衡转发至目标服务端 Task。
下线时序的竞态条件 (Race Condition)
当 ECS 决定停止某个 Task(例如发布新版本或者集群缩容)时,会同时触发两条独立的链路:
关闭 Acceptor,拒绝新连接! ClientEnvoy ->> SpringBoot: 3. 在 SDS 尚未更新前,依然转发新请求 SpringBoot -->> ClientEnvoy: 4. TCP RST / Connection Refused! ClientEnvoy -->> ClientEnvoy: 客户端收到 HTTP 503 / 502 / ECONNRESET
- 竞态根因:
- ECS 向 Cloud Map 发出注销指令后,端点从全网所有客户端 Envoy 的路由表中移除存在传播延迟(通常在 2 ~ 10 秒之间)。
- ECS 在发出注销的同时(或极短时间内),就会直接向应用容器进程发送
SIGTERM信号。 - Spring Boot 收到
SIGTERM后,其内置 Web 容器(如 Tomcat)会立刻关闭网络 Acceptor 线程以拒绝新请求。 - 此时,尚未收到路由更新的客户端 Envoy 依然会将新的请求分发过来,导致请求直接遭到拒绝,产生错误。
因此,必须建立一个缓冲窗口(Pre-stop Buffer),让 Service Connect 的端点下线先完全生效,再允许 Spring Boot 关闭端口。
Spring Boot 3.x 优雅停机核心参数与机制
官方文档索引
- Spring Boot Reference: Graceful Shutdown
- Common Application Properties: Server & Lifecycle
- Spring Boot Actuator: Kubernetes Probes & Availability
- Spring Framework Core: Lifecycle Processing (
SmartLifecycle)
核心配置清单 (application.yml)
server:
# 启用优雅停机。可选值:graceful(优雅停机),immediate(立即强杀,默认值)
shutdown: graceful
spring:
lifecycle:
# 每个 SmartLifecycle 关闭阶段的最大等待宽限时间,默认 30s
# 超过此时间仍有未处理完的请求,将被强制打断
timeout-per-shutdown-phase: 30s
task:
execution:
shutdown:
# 等待 @Async 异步线程池任务完成
await-termination: true
await-termination-period: 30s
scheduling:
shutdown:
# 等待 @Scheduled 定时任务执行完毕
await-termination: true
await-termination-period: 30s
management:
endpoint:
health:
# 开启存活态与就绪态探针 (/actuator/health/liveness 和 /actuator/health/readiness)
probes:
enabled: true
endpoints:
web:
exposure:
include: health,info,metrics
源码级参数行为剖析
| 配置属性 | 默认值 | 作用阶段与行为解释 |
|---|---|---|
server.shutdown | immediate | 设为 graceful 时,Spring Boot 的 GracefulShutdown 接口会对内嵌的 Tomcat / Jetty / Undertow / Reactor Netty 进行控制:先停止接受新的 TCP 连接,但保持现有连接通道继续处理 In-flight 请求。 |
spring.lifecycle.timeout-per-shutdown-phase | 30s | Spring 将所有实现 SmartLifecycle 的 Bean 划分为不同阶段(Phase)。Web 服务器关闭位于 WebServerApplicationContext.GRACEFUL_SHUTDOWN_PHASE(Integer.MAX_VALUE - 2048)。此参数指定每一个 Phase 允许等待的最大超时时长。 |
spring.task.execution.shutdown.await-termination | false | 控制 ThreadPoolTaskExecutor 容器销毁时是否调用 ExecutorService.awaitTermination(),配合后续排空异步计算。 |
spring.task.execution.shutdown.await-termination-period | 0s | 线程池等待任务完成的最大容忍时间。 |
/actuator/health、Readiness 与 Liveness 探针深度对比
Spring Boot 2.3+ 引入了对云原生探针(Probes)的一等支持。理解三者的定位、HTTP 状态码映射及在停机信号下的跃迁至关重要。
三者的核心区别与设计职责
| 维度 | /actuator/health (全局健康检查) | /actuator/health/liveness (存活探针) | /actuator/health/readiness (就绪探针) |
|---|---|---|---|
| 核心职责 | 评估应用及其所有依赖项的整体健康度。 | 评估应用内部自身是否处于正常运行状态。 | 评估应用是否已经准备好接受外部请求流量。 |
| 底层依据 | 聚合所有 HealthIndicator(包括 DB、Redis、RabbitMQ、DiskSpace 等)。 | 关联 LivenessState。仅关心内部死锁、不可逆致命异常。 | 关联 ReadinessState。关注初始化是否完成、缓存预热及是否处于下线排空期。 |
| 外部依赖影响 | 强相关。若 Redis/MySQL 挂掉,此端点立刻变为 DOWN。 | 无关联。哪怕 DB 断开,Liveness 仍为 CORRECT (UP)。 | 有条件关联。应用可自定义逻辑在核心依赖不可用时标记为未就绪。 |
| 探针失败后果 | 通常仅用于报警巡检,切忌用作编排器重启探针。 | 编排器(K8s / ECS)判定容器损毁,直接重启(Kill & Restart)容器。 | 负载均衡器 / 网格(Envoy)停止分发新请求到该节点,但不重启容器。 |
[!WARNING] 切勿把
/actuator/health直接配成 ECS 或 K8s 的存活健康检查(Liveness Check)!
一旦下游数据库或缓存发生瞬间网络抖动,全局 health 会变成 503,编排器会误杀所有健康的应用容器,引发大面积雪崩。存活检查必须使用/actuator/health/liveness。
HTTP 状态码与响应体映射规则
Spring Boot Actuator 默认使用 HttpCodeStatusMapper 将状态映射为 HTTP 响应码:
| 探测端点 | 业务状态 (Status) | 内部枚举 (AvailabilityState) | HTTP 状态码 | JSON 响应体示例 | 编排层动作 |
|---|---|---|---|---|---|
| Liveness | UP | LivenessState.CORRECT | 200 OK | {"status":"UP"} | 容器健康,保持运行 |
| Liveness | DOWN | LivenessState.BROKEN | 503 Service Unavailable | {"status":"DOWN"} | 判定死锁崩溃,立即重启容器 |
| Readiness | UP | ReadinessState.ACCEPTING_TRAFFIC | 200 OK | {"status":"UP"} | 正常接流,允许流量路由 |
| Readiness | OUT_OF_SERVICE | ReadinessState.REFUSING_TRAFFIC | 503 Service Unavailable | {"status":"OUT_OF_SERVICE"} | 摘除路由流量,保留容器排空 |
| Health (全局) | UP | - | 200 OK | {"status":"UP", "components": {...}} | 正常 |
| Health (全局) | DOWN / OUT_OF_SERVICE | - | 503 Service Unavailable | {"status":"DOWN", ...} | 组件异常 |
收到 SIGTERM 信号时的状态机跃迁
当容器收到操作系统的 SIGTERM 信号并开启了优雅停机后,Spring Boot 内部状态跃迁时序如下:
- 关键跃迁行为细节:
- Readiness 立即变 503:Spring Boot 发布
AvailabilityChangeEvent,将ReadinessState置为REFUSING_TRAFFIC。就绪探针接口/actuator/health/readiness立即返回503 OUT_OF_SERVICE。如果配置了上游就绪探测,负载均衡器会感知到并停止分发新流量。 - Liveness 必须且始终保持 200 OK:在停机阶段,
LivenessState绝不会变为BROKEN,它依然保持CORRECT (200 OK)。- 核心设计原因:如果停机时 Liveness 变为 503,ECS/K8s 的存活检查会立即判定容器异常,并直接下发
SIGKILL强杀容器,这会导致正在处理中的在途请求被粗暴切断,优雅停机前功尽弃。
- 核心设计原因:如果停机时 Liveness 变为 503,ECS/K8s 的存活检查会立即判定容器异常,并直接下发
- 全局
/actuator/health变为 503:因为全局 health 端点默认包含了 Readiness 状态(当开启 probes 时),所以也会随之变为503 OUT_OF_SERVICE。
- Readiness 立即变 503:Spring Boot 发布
能否直接用 /actuator/health 替代 /actuator/health/readiness?(生产避坑分析)
结论:技术上“能跑通”,但在生产环境中“极度不推荐直接替代”,存在严重的级联雪崩隐患。
为什么表面上“能跑通”?
- 启动阶段:Spring Boot 上下文完全启动成功前,
/actuator/health无法访问或返回 503,流量不会进。 - 停机阶段:在开启了探针时,全局 health 会聚合就绪态;收到
SIGTERM后,Readiness 变为REFUSING_TRAFFIC,全局/actuator/health确实也会同步翻转为503 OUT_OF_SERVICE。
从生命周期的表面特征看,它似乎也能起到“启动前阻断流量、停机时切断流量”的作用。
为什么生产环境极度不推荐?(三大致命陷阱)
陷阱 1:下游依赖抖动引发“全集群被负载均衡器全量摘除”(雪崩效应)
/actuator/health是全量聚合检查:它不仅检查本应用,还会去PING数据库(SELECT 1)、Redis、RabbitMQ、检查磁盘空间等。- 灾难场景:假设深夜 MySQL 连接池瞬间被打满,或者 Redis 发生 3 秒的主从切换抖动:
- 应用自身完全健康,只是暂时部分外部请求较慢;
- 但因为连不上外部依赖,集群里所有实例的
/actuator/health会在同一秒全部变成 503; - ALB 或网格检测到所有实例均不健康,直接把 100% 的机器从路由池全部剔除,导致原本局部的短暂依赖抖动瞬间演变成全站彻底瘫痪。
- Readiness 的优势:
/actuator/health/readiness默认只关注本应用的内部生命周期(是否初始化完、是否在优雅停机),外部数据库抖动绝不会导致它报 503,保证了故障隔离与爆炸半径最小化。
陷阱 2:ECS 容器定义中的
healthCheck会误杀容器- ALB Target Group 健康检查:失败时仅执行**“摘除流量(停止分发请求)”**,不会杀死容器。
- ECS Task Definition 容器内
healthCheck:- 一旦健康检查连续失败达到
retries次数,ECS Agent 会判定容器损毁,直接发送SIGKILL强杀并重启容器! - 如果把全局
/actuator/health写在 ECS Task Definition 的healthCheck里,一旦数据库网络闪断,ECS 会把集群里的所有任务逐个杀光重启,直接引发服务雪崩。
- 一旦健康检查连续失败达到
陷阱 3:高频探测带来的额外 I/O 与连接池开销
- 负载均衡或网格的探测频率通常很高(如 5 秒一次)。
- 假设集群有 20 个实例,每 5 秒探测一次
/actuator/health,意味着每秒有多次高频查询在打数据库和 Redis,浪费宝贵的数据库连接池资源。 - 而
/actuator/health/readiness仅仅是读取 JVM 内存里的一个原子状态变量,无任何网络 I/O 与数据库连接消耗,响应耗时通常低于 1ms。
端点职责与最佳实践分工矩阵
| 场景 / 配置位置 | 推荐端点 | 选型原因 |
|---|---|---|
ECS Task Definition (containerDefinitions[].healthCheck) | /actuator/health/liveness | 仅在进程死锁、彻底无法恢复时才触发 ECS 容器重启。 |
| ALB Target Group / Service Connect 路由探测 | /actuator/health/readiness | 仅控制流量的接入与摘除,下游组件短暂抖动绝不影响路由拓扑。 |
| Prometheus / 运维监控大盘 / 告警巡检 | /actuator/health | 运维监控需要感知全量依赖状态,一旦数据库异常能第一时间触发报警。 |
是否需要手动将健康状态设置为 Unhealthy?
核心结论:在标准的 AWS ECS + Service Connect 自动化停机(发布更新、自动缩容)流程中,通常“完全不需要”手动改动健康状态。
为什么不需要手动设置?
- Service Connect 的摘流基于“控制面事件”,而非“HTTP 轮询”:
- 在传统自建体系中,往往需要先把节点打成 503 等网关轮询发现。但 ECS Service Connect 是事件驱动的:当触发任务停止时,ECS 控制面会主动向 AWS Cloud Map 调用
DeregisterInstanceAPI,各节点的 Envoy 随后通过 SDS 推送端点删除事件。这个过程根本不依赖对应用 HTTP 接口的探测。
- 在传统自建体系中,往往需要先把节点打成 503 等网关轮询发现。但 ECS Service Connect 是事件驱动的:当触发任务停止时,ECS 控制面会主动向 AWS Cloud Map 调用
- Spring Boot 已经自动完成状态翻转:
- 只要开启了
server.shutdown: graceful,在收到SIGTERM的第一毫秒,Spring Boot 内部的ApplicationAvailabilityBean就会自动发布事件,将ReadinessState切换为REFUSING_TRAFFIC(端点自动返回 503),框架已经替你做了,无需业务代码额外干预。
- 只要开启了
- 主要矛盾不在于“状态改不改”,而在于“网络传播延迟”:
- 即使在收到信号时立即改为 503,全网 Client Envoy 仍需要 2~10 秒才能收到并应用路由更新。因此最关键的手段是延时缓冲(Sleep Buffer),给网络收敛留出安全时间,而不是手动调用 API 改状态。
什么时候才“真的需要”手动干预?
- 场景 A:业务主动自我隔离与软熔断:应用未死但由于核心长连接断开或内部严重降级,希望临时脱离服务发现接流,可通过代码调用
AvailabilityChangeEvent.publish(..., ReadinessState.REFUSING_TRAFFIC)主动避开流量。 - 场景 B:自建外部两阶段平滑下线 Webhook:部分高要求发布流水线采用两阶段调度:发布脚本先向应用发送自定义 Webhook(如
POST /actuator/drain),应用主动切为 503 并排空,确认流量归零后发布系统才调用 ECS API 停止容器。
AWS ECS Service Connect 核心参数与架构
官方文档索引
- AWS ECS Developer Guide: Service Connect Overview
- AWS ECS Developer Guide: Service Connect Concepts & Architecture
- AWS ECS Task Definition Parameters: Container
stopTimeout - AWS ECS API Reference:
TimeoutConfiguration - AWS ECS Developer Guide: Graceful Shutdown of Tasks
- AWS Cloud Map Developer Guide: Service Discovery
关键核心参数剖析
容器级 stopTimeout (ECS Task Definition)
- 默认值:30 秒。
- 行为:当 ECS 决定停止某个 Task 时,向容器内 PID 1 发送
SIGTERM并启动倒计时。如果倒计时耗尽容器尚未正常退出,ECS Agent 将无条件发送SIGKILL强制销毁容器。 - 协同规则:
$$\text{stopTimeout} > \text{Pre-stop Buffer 时间} + \text{Spring Boot timeout-per-shutdown-phase} + \text{安全余量 (约 10s)}$$
若 Pre-stop 休眠 15s,Spring Boot 宽限期 30s,则
stopTimeout必须设置在 55s~60s 以上,否则进程必被SIGKILL强杀!
Service Connect TimeoutConfiguration
在服务配置的 ServiceConnectConfiguration 中可以配置客户端代理的超时规则:
idleTimeoutSeconds:空闲连接超时,建议不要设得过大,防止长连接一直占着下线节点的通道。perRequestTimeoutSeconds:单个请求的最大处理超时,需根据业务长耗时 API 评估。
生产级协同解决方案
为了平滑吸收 Cloud Map 与 Envoy 的路由下发延迟,业界推荐以下两种实践方式:
方案 A:容器 Entrypoint Shell 拦截信号并休眠(推荐,无代码侵入)
Kubernetes 原生支持 lifecycle.preStop.exec,但 ECS Task Definition 原生没有 preStop 配置。通过自定义 entrypoint.sh,在收到 SIGTERM 时先休眠 10~15 秒,期间 Spring Boot 仍处于活跃状态正常处理残留请求。
entrypoint.sh
#!/bin/sh
set -e
# 1. 后台启动 Spring Boot 应用,记录其进程 PID
java -XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-jar /app/application.jar &
JAVA_PID=$!
# 2. 定义 SIGTERM 信号拦截处理函数
term_handler() {
echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')] [INFO] Received SIGTERM from ECS."
# 缓冲时间:给 AWS Cloud Map 注销与客户端 Envoy 更新 SDS 留出足够时间
BUFFER_SECONDS=15
echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')] [INFO] Sleeping ${BUFFER_SECONDS}s for Service Connect draining..."
sleep $BUFFER_SECONDS
echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')] [INFO] Forwarding SIGTERM to Spring Boot (PID: ${JAVA_PID})..."
kill -TERM "$JAVA_PID"
# 等待 Java 进程优雅退出
wait "$JAVA_PID"
EXIT_STATUS=$?
echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')] [INFO] Application exited cleanly with status ${EXIT_STATUS}."
exit $EXIT_STATUS
}
# 注册信号陷阱 (Trap)
trap 'term_handler' TERM INT
# 3. 前台阻塞等待 Java 进程,直到收到信号或自然退出
wait "$JAVA_PID"
Dockerfile
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# 安装 curl 用于健康检查
RUN apk add --no-cache curl
COPY target/*.jar application.jar
COPY entrypoint.sh entrypoint.sh
RUN chmod +x entrypoint.sh
# 务必以 entrypoint 脚本执行
ENTRYPOINT ["./entrypoint.sh"]
方案 B:Spring Boot 内部扩展(通过 SmartLifecycle 自定义停机阶段)
如果不希望使用 Shell 包装脚本,可以通过 Spring 的 SmartLifecycle 在 Web 容器关闭前插入一个自定义的优先 Phase。
package com.example.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.ReadinessState;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* Service Connect 优雅停机缓冲生命周期组件
*/
@Component
public class ServiceConnectGracefulShutdownHook implements SmartLifecycle {
private static final Logger log = LoggerFactory.getLogger(ServiceConnectGracefulShutdownHook.class);
// 缓冲时间 (秒)
private static final int DRAIN_BUFFER_SECONDS = 15;
private final ApplicationEventPublisher eventPublisher;
private volatile boolean running = false;
public ServiceConnectGracefulShutdownHook(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
@Override
public void start() {
this.running = true;
}
@Override
public void stop() {
// 同步停机逻辑
}
@Override
public void stop(Runnable callback) {
new Thread(() -> {
try {
log.info("Shutdown initiated. Marking readiness probe as REFUSING_TRAFFIC...");
// 1. 将 Spring Boot Actuator 就绪状态置为拒绝流量 (HTTP 503)
AvailabilityChangeEvent.publish(this.eventPublisher, this, ReadinessState.REFUSING_TRAFFIC);
// 2. 阻塞休眠,等待 Service Connect/Cloud Map 完成端点注销广播
log.info("Waiting {} seconds for Service Connect Envoy SDS propagation...", DRAIN_BUFFER_SECONDS);
TimeUnit.SECONDS.sleep(DRAIN_BUFFER_SECONDS);
log.info("Drain buffer completed. Proceeding to stop WebServer...");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("Drain sleep interrupted", e);
} finally {
this.running = false;
callback.run(); // 触发下一个 Phase (即 WebServer 停机)
}
}, "service-connect-drain-thread").start();
}
@Override
public boolean isRunning() {
return this.running;
}
/**
* WebServer 的 phase 是 Integer.MAX_VALUE - 2048
* 设置一个比 WebServer 更大的 Phase 值,确保它在 WebServer 关闭前优先执行 stop()
*/
@Override
public int getPhase() {
return Integer.MAX_VALUE;
}
}
方案 C:ECS Task Definition & Terraform 配置
在基础设施定义中,关键是配置合理的 stopTimeout 和容器健康检查。
ECS Task Definition JSON 片段
{
"family": "order-service",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "1024",
"memory": "2048",
"containerDefinitions": [
{
"name": "order-service-app",
"image": "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/order-service:v1.0.0",
"essential": true,
"stopTimeout": 60,
"portMappings": [
{
"name": "http-api",
"containerPort": 8080,
"hostPort": 8080,
"protocol": "tcp"
}
],
"healthCheck": {
"command": [
"CMD-SHELL",
"curl -f http://localhost:8080/actuator/health/readiness || exit 1"
],
"interval": 10,
"timeout": 5,
"retries": 2,
"startPeriod": 30
}
}
]
}
Terraform (AWS Provider) 配置示例
resource "aws_ecs_service" "order_service" {
name = "order-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.order_service.arn
desired_count = 3
launch_type = "FARGATE"
# 滚动部署策略:保证发布时始终有额外健康实例
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
service_connect_configuration {
enabled = true
namespace = aws_service_discovery_private_dns_namespace.corp.arn
service {
port_name = "http-api"
discovery_name = "order-service"
client_alias {
port = 8080
}
timeout {
idle_timeout_seconds = 60
per_request_timeout_seconds = 30
}
}
}
network_configuration {
subnets = ["subnet-abc12345", "subnet-def67890"]
security_groups = [aws_security_group.app_sg.id]
}
}
从 99.9% 到 100%:为什么必须客户端韧性兜底?(Consumer-Side Resilience)
核心拷问:正确配置后,流量能保证 100% 平稳吗?
工程结论:服务端做好前述所有配置(Pre-stop Sleep 15s + Spring Boot Graceful + stopTimeout 60s),可以解决 99.9% 以上的常规发布平稳问题;但要在分布式系统中达到面向用户的“绝对 100% 零故障”,必须依赖客户端重试作为最后一块兜底拼图!
换言之:真正的 100% 平稳是“服务端优雅排空(99.9%)+ 客户端瞬时重试(0.1%)”双端协同的闭环结果。
为什么单靠服务端极难做到数学上的“绝对 100%”?
在分布式网络中,即便服务端做到了极致,仍然存在 3 个物理层面的微观边角竞态(Edge Cases):
TCP Keep-Alive 长连接的“飞行碰撞”(In-flight Collision / TCP Race Condition)
- 客户端 Envoy 与服务端之间往往维持着 HTTP Keep-Alive 空闲长连接池以复用连接。
- 微观竞态场景:
- 在第 14.999 秒(15 秒 Sleep 缓冲即将结束、Spring Boot 准备关闭端口),客户端 Envoy 恰好在这条长连接上发出了一条新的 HTTP 请求报文;
- 恰在同一时刻,服务端 Sleep 结束触发 WebServer 关闭,操作系统向客户端发出了 TCP
FIN挥手报文; - 两个数据包在网络光纤物理链路中并发相遇擦肩而过;
- 客户端网卡收到
FIN包时发现该连接还有发出去没收到响应的请求,底层就会抛出经典的Connection reset by peer或Broken pipe。
P99.9 极端长耗时请求超出宽限期
- 假设系统配置了
spring.lifecycle.timeout-per-shutdown-phase: 30s,但恰好有一个极低频的超长慢查询或批量计算请求执行了 32 秒; - 30 秒倒计时结束时,Spring Boot 会强制中断该请求线程,导致客户端收到非预期的截断异常。
非计划内的“不可抗力强杀”(Non-graceful Termination)
- 如果应用容器因堆外内存泄露等原因触发了宿主机 Linux 内核的 OOM Killer,内核会直接发送不可捕获的
SIGKILL (Signal 9),直接瞬间秒杀进程,根本无法执行任何 Pre-stop 脚本或 Java 优雅停机钩子。 - 底层 EC2 宿主机硬件坏道、网络物理割接等突发硬件故障。
双端协同闭环架构
┌────────────────────────────────────────────────────────┐
│ 100% 业务零中断 (Zero-Downtime) │
└───────────────────────────┬────────────────────────────┘
│
┌───────────────────────┴────────────────────────┐
▼ ▼
【服务端:吸收 99.9% 流量】 【客户端:兜底剩余 0.1%】
1. Pre-stop Buffer 15s (路由收敛) 1. 捕获 503 / ECONNRESET 瞬断
2. Spring Boot Graceful (在途排空) 2. 幂等 GET/查询请求无感重试 1~2 次
3. stopTimeout 60s (防强杀) 3. 指数退避 100ms 重试到集群其他存活节点
为什么加上重试对终端用户就是 100%?
当碰上那 0.1% 的“TCP 飞行碰撞”或在途连接瞬断时,客户端拦截到网络异常并在 50~100ms 内自动重试到集群中另一台健康的实例。对于调用端或前端最终用户而言,仅仅感受到了几十毫秒的轻微网络抖动,业务请求成功率依然是 100%。
客户端代码实战:配置幂等重试拦截器
以 Spring 6 / Spring Boot 3 的 RestClient 为例,配置针对 503 状态码与底层连接重置(Connection reset)的智能重试拦截器:
package com.example.client;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.RestClient;
import java.io.IOException;
@Configuration
public class RestClientConfig {
@Bean
public RestClient orderServiceRestClient() {
return RestClient.builder()
.baseUrl("http://order-service:8080")
.requestInterceptor(retryInterceptor())
.build();
}
private ClientHttpRequestInterceptor retryInterceptor() {
return (request, body, execution) -> {
int maxAttempts = 3;
int attempt = 0;
while (true) {
attempt++;
try {
ClientHttpResponse response = execution.execute(request, body);
// 如果遇到 503 (下游服务处于停机排空状态) 且属于幂等 GET 请求,进行重试
if (response.getStatusCode().value() == 503 && "GET".equalsIgnoreCase(request.getMethod().name())) {
if (attempt >= maxAttempts) {
return response;
}
Thread.sleep(100L * attempt); // 简易指数退避
continue;
}
return response;
} catch (IOException ex) {
// 遇到底层连接被断开 (Connection reset) 时尝试重试
if (attempt >= maxAttempts) {
throw ex;
}
try {
Thread.sleep(100L * attempt);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw ex;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Request retry interrupted", e);
}
}
};
}
}
生产验证与压测验证清单
生产环境:滚动更新零中断压测
在上线到生产环境之前,建议通过如下方式进行零中断停机验证:
- 持续压测下滚动更新:
使用
hey或k6向被测服务发送持续请求(例如hey -z 60s -c 20 http://order-service:8080/api/health)。 - 触发 Deployment:
在压测过程中执行
aws ecs update-service --cluster ... --service ... --force-new-deployment。 - 检查指标:
- 压测工具的
Status code 200比例必须达到 100%,没有503或connection reset发生。 - 查看 ECS CloudWatch 日志,确认
entrypoint.sh的 15 秒休眠日志正常打印。 - 确认 Spring Boot 正常打印
Commencing graceful shutdown. Waiting for active requests to complete并在stopTimeout耗尽前正常退出,没有出现SIGKILL强制终结标记(Exit Code 137)。
- 压测工具的
本地开发与调试测试:Ctrl + C (SIGINT) vs 生产 SIGTERM
在本地开发或者 Docker 容器测试时,按 Ctrl + C 同样可以完整验证优雅停机行为。
底层信号原理对比
Ctrl + C发送的是SIGINT(Signal 2,中断信号)。- AWS ECS / Docker 默认发送的是
SIGTERM(Signal 15,终止信号)。 - JVM 的处理行为:JVM 注册的
ShutdownHook(Spring Boot 优雅停机的总触发入口)对SIGINT和SIGTERM的响应完全等价。无论收到哪个信号,Spring Boot 内部都会启动相同的优雅停机与资源清理流程。
entrypoint.sh 对本地 Docker 调试的天然支持
在本文方案 A 提供的 entrypoint.sh 脚本中,已特别注册了双重信号捕获:
trap 'term_handler' TERM INT # 同时捕获 SIGTERM 和 SIGINT
当你本地运行 Docker 容器进行交互式调试时:
docker run -it --rm -p 8080:8080 my-spring-app:latest
此时按 Ctrl + C,脚本同样会进入 term_handler,打印出 15 秒排空等待日志,随后将 SIGTERM 转交给 Java 进程,完美模拟生产停机流程。
本地裸跑测试避坑提示
- 推荐:直接运行编译好的可执行 Jar:按
java -jar target/application.jarCtrl + C后,控制台会清晰打印出Commencing graceful shutdown. Waiting for active requests to complete。 - 避坑:避免依赖
mvn spring-boot:run或./gradlew bootRun进行停机测试。- 原因:部分构建工具(如 Maven)在捕获到终端的
Ctrl + C后,会立刻强行终止子进程,导致 Spring Boot 还没来得及执行 Shutdown Hook 就被直接杀掉,无法观测到优雅停机日志。
- 原因:部分构建工具(如 Maven)在捕获到终端的