在微服务架构中,**优雅停机(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 的架构下:

下线时序的竞态条件 (Race Condition)

当 ECS 决定停止某个 Task(例如发布新版本或者集群缩容)时,会同时触发两条独立的链路:

sequenceDiagram autonumber participant ECS as ECS 控制面 participant CloudMap as AWS Cloud Map / SDS participant ClientEnvoy as 客户端 Task (Envoy) participant ServerEnvoy as 服务端 Task (Envoy) participant SpringBoot as Spring Boot 3.x 应用 Note over ECS: 触发任务停止 (Deployment / Scale-in) par 链路 1: 控制面注销 ECS ->> CloudMap: 1. 发起实例注销 (DeregisterInstance) CloudMap -->> ClientEnvoy: 2. SDS 端点更新传播 (延迟约 2~10 秒) and 链路 2: 数据面信号 ECS ->> SpringBoot: 1. 发送 SIGTERM 信号 ECS ->> ServerEnvoy: 1. 发送 SIGTERM 信号 end Note over SpringBoot: Spring Boot 立即执行优雅停机
关闭 Acceptor,拒绝新连接! ClientEnvoy ->> SpringBoot: 3. 在 SDS 尚未更新前,依然转发新请求 SpringBoot -->> ClientEnvoy: 4. TCP RST / Connection Refused! ClientEnvoy -->> ClientEnvoy: 客户端收到 HTTP 503 / 502 / ECONNRESET

因此,必须建立一个缓冲窗口(Pre-stop Buffer),让 Service Connect 的端点下线先完全生效,再允许 Spring Boot 关闭端口。


Spring Boot 3.x 优雅停机核心参数与机制

官方文档索引

核心配置清单 (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.shutdownimmediate设为 graceful 时,Spring Boot 的 GracefulShutdown 接口会对内嵌的 Tomcat / Jetty / Undertow / Reactor Netty 进行控制:先停止接受新的 TCP 连接,但保持现有连接通道继续处理 In-flight 请求。
spring.lifecycle.timeout-per-shutdown-phase30sSpring 将所有实现 SmartLifecycle 的 Bean 划分为不同阶段(Phase)。Web 服务器关闭位于 WebServerApplicationContext.GRACEFUL_SHUTDOWN_PHASE(Integer.MAX_VALUE - 2048)。此参数指定每一个 Phase 允许等待的最大超时时长。
spring.task.execution.shutdown.await-terminationfalse控制 ThreadPoolTaskExecutor 容器销毁时是否调用 ExecutorService.awaitTermination(),配合后续排空异步计算。
spring.task.execution.shutdown.await-termination-period0s线程池等待任务完成的最大容忍时间。

/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 响应体示例编排层动作
LivenessUPLivenessState.CORRECT200 OK{"status":"UP"}容器健康,保持运行
LivenessDOWNLivenessState.BROKEN503 Service Unavailable{"status":"DOWN"}判定死锁崩溃,立即重启容器
ReadinessUPReadinessState.ACCEPTING_TRAFFIC200 OK{"status":"UP"}正常接流,允许流量路由
ReadinessOUT_OF_SERVICEReadinessState.REFUSING_TRAFFIC503 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 内部状态跃迁时序如下:

stateDiagram-v2 [*] --> 正常运行态: 启动完成 (ApplicationReadyEvent) state 正常运行态 { Liveness_UP: Liveness = CORRECT (200 OK) Readiness_UP: Readiness = ACCEPTING_TRAFFIC (200 OK) } 正常运行态 --> 优雅停机排空期: 收到 SIGTERM (JVM Shutdown Hook 触发) state 优雅停机排空期 { Liveness_Still_UP: Liveness 保持 CORRECT (200 OK) Readiness_REFUSING: Readiness 立即翻转为 REFUSING_TRAFFIC (503 OUT_OF_SERVICE) Web_Drain: WebServer 停止 Acceptor,排空 In-flight 请求 } 优雅停机排空期 --> 容器关闭: 所有请求处理完毕 或 超出 shutdown-phase 超时 容器关闭 --> [*]: JVM 正常退出 (Exit Code 0)

能否直接用 /actuator/health 替代 /actuator/health/readiness?(生产避坑分析)

结论:技术上“能跑通”,但在生产环境中“极度不推荐直接替代”,存在严重的级联雪崩隐患。

为什么表面上“能跑通”?

从生命周期的表面特征看,它似乎也能起到“启动前阻断流量、停机时切断流量”的作用。

为什么生产环境极度不推荐?(三大致命陷阱)
  1. 陷阱 1:下游依赖抖动引发“全集群被负载均衡器全量摘除”(雪崩效应)

    • /actuator/health 是全量聚合检查:它不仅检查本应用,还会去 PING 数据库(SELECT 1)、Redis、RabbitMQ、检查磁盘空间等。
    • 灾难场景:假设深夜 MySQL 连接池瞬间被打满,或者 Redis 发生 3 秒的主从切换抖动:
      • 应用自身完全健康,只是暂时部分外部请求较慢;
      • 但因为连不上外部依赖,集群里所有实例的 /actuator/health 会在同一秒全部变成 503;
      • ALB 或网格检测到所有实例均不健康,直接把 100% 的机器从路由池全部剔除,导致原本局部的短暂依赖抖动瞬间演变成全站彻底瘫痪。
    • Readiness 的优势:/actuator/health/readiness 默认只关注本应用的内部生命周期(是否初始化完、是否在优雅停机),外部数据库抖动绝不会导致它报 503,保证了故障隔离与爆炸半径最小化。
  2. 陷阱 2:ECS 容器定义中的 healthCheck 会误杀容器

    • ALB Target Group 健康检查:失败时仅执行**“摘除流量(停止分发请求)”**,不会杀死容器。
    • ECS Task Definition 容器内 healthCheck:
      • 一旦健康检查连续失败达到 retries 次数,ECS Agent 会判定容器损毁,直接发送 SIGKILL 强杀并重启容器!
      • 如果把全局 /actuator/health 写在 ECS Task Definition 的 healthCheck 里,一旦数据库网络闪断,ECS 会把集群里的所有任务逐个杀光重启,直接引发服务雪崩。
  3. 陷阱 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 自动化停机(发布更新、自动缩容)流程中,通常“完全不需要”手动改动健康状态。

为什么不需要手动设置?
  1. Service Connect 的摘流基于“控制面事件”,而非“HTTP 轮询”:
    • 在传统自建体系中,往往需要先把节点打成 503 等网关轮询发现。但 ECS Service Connect 是事件驱动的:当触发任务停止时,ECS 控制面会主动向 AWS Cloud Map 调用 DeregisterInstance API,各节点的 Envoy 随后通过 SDS 推送端点删除事件。这个过程根本不依赖对应用 HTTP 接口的探测。
  2. Spring Boot 已经自动完成状态翻转:
    • 只要开启了 server.shutdown: graceful,在收到 SIGTERM 的第一毫秒,Spring Boot 内部的 ApplicationAvailabilityBean 就会自动发布事件,将 ReadinessState 切换为 REFUSING_TRAFFIC(端点自动返回 503),框架已经替你做了,无需业务代码额外干预。
  3. 主要矛盾不在于“状态改不改”,而在于“网络传播延迟”:
    • 即使在收到信号时立即改为 503,全网 Client Envoy 仍需要 2~10 秒才能收到并应用路由更新。因此最关键的手段是延时缓冲(Sleep Buffer),给网络收敛留出安全时间,而不是手动调用 API 改状态。
什么时候才“真的需要”手动干预?

AWS ECS Service Connect 核心参数与架构

官方文档索引

关键核心参数剖析

容器级 stopTimeout (ECS Task Definition)

Service Connect TimeoutConfiguration

在服务配置的 ServiceConnectConfiguration 中可以配置客户端代理的超时规则:


生产级协同解决方案

为了平滑吸收 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)

P99.9 极端长耗时请求超出宽限期

非计划内的“不可抗力强杀”(Non-graceful Termination)


双端协同闭环架构

                ┌────────────────────────────────────────────────────────┐
                │             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);
                }
            }
        };
    }
}

生产验证与压测验证清单

生产环境:滚动更新零中断压测

在上线到生产环境之前,建议通过如下方式进行零中断停机验证:

  1. 持续压测下滚动更新: 使用 hey 或 k6 向被测服务发送持续请求(例如 hey -z 60s -c 20 http://order-service:8080/api/health)。
  2. 触发 Deployment: 在压测过程中执行 aws ecs update-service --cluster ... --service ... --force-new-deployment。
  3. 检查指标:
    • 压测工具的 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 同样可以完整验证优雅停机行为。

底层信号原理对比

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 进程,完美模拟生产停机流程。

本地裸跑测试避坑提示