本节目标:能写出一个在 SIGTERM 后「先停止新调用、再等在途完成、最后强制关闭」的停机流程,并知道每一步的语义、顺序和时间预算。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded)+grpc-services1.76.0。
承接的问题
6.2 的池解决了「用几条 channel」,但没解决「进程退出时这些 channel 怎么办」。发布、缩容、滚动重启都会给进程发 SIGTERM;如果直接 System.exit(),在途请求会被切断,客户端看到 UNAVAILABLE,正在写库的操作可能只完成一半。如果只调 shutdown() 不等待,进程可能在请求还没结束时退出,效果一样。
gRPC 提供了两层关闭语义:客户端的 ManagedChannel 和服务端的 Server。两者都有 shutdown()、shutdownNow()、awaitTermination(),但作用对象不同:前者关的是「我到下游的连接」,后者关的是「上游到我的连接」。一个完整的停机流程要同时处理入口(9094 HTTP、9093 gRPC)和出口(到 libraryd 的 channel),顺序错了就会丢请求。
这一节先讲两类关闭的语义,再讲进程级的 drain 顺序、在途计数和时间预算,最后给出 linkd-v3 的停机代码。健康检查在 5.3,连接池在 6.2,本节把它们串成一个可执行的流程。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,gRPC-Java 1.76.0。本节的时间数字是预算示例,不是实测的停机耗时。
核心机制一:ManagedChannel 的关闭语义
ManagedChannel 的三个方法分工明确:
| 方法 | 新调用 | 在途调用 | 是否阻塞 |
|---|---|---|---|
shutdown() | 拒绝,UNAVAILABLE | 允许完成 | 否 |
shutdownNow() | 拒绝 | 取消,CANCELLED | 否 |
awaitTermination(timeout) | 不改变 | 等待结束 | 是,最多 timeout |
正确的关闭顺序是 shutdown() → awaitTermination() → 必要时 shutdownNow()。shutdown() 只是「不再接受新调用」,它不会等;awaitTermination() 只等,不会触发关闭;shutdownNow() 是最后手段,会取消还在跑的流。
package com.plume.linkd.v3.core.lifecycle;
import io.grpc.ManagedChannel;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
public final class ChannelDrain {
public static boolean drain(ManagedChannel channel, Duration timeout) {
channel.shutdown();
boolean terminated;
try {
terminated = channel.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
channel.shutdownNow();
return false;
}
if (!terminated) {
channel.shutdownNow();
try {
channel.awaitTermination(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return terminated;
}
}
三个细节:
shutdown()是幂等的。 重复调用不会抛异常;但不要在每次请求结束时调,它会让 channel 进入终态,后续调用全部失败。awaitTermination可以被中断。 捕获InterruptedException后要恢复中断标记;如果停机线程被中断,说明进程可能正在被强杀,直接shutdownNow()是合理的。shutdownNow()之后还要再等一次。 取消是异步的,流收到CANCELLED需要一点时间;再等 1 秒能让资源释放干净,避免进程退出时日志里出现「channel 还在关闭」。
isShutdown() 表示「已经调用过 shutdown/shutdownNow」,isTerminated() 表示「所有资源已释放」。awaitTermination 返回 true 等价于 isTerminated()。不要用 isShutdown() 判断能否继续调用;它只说明关闭动作已经开始。
核心机制二:Server 的 drain 与 GOAWAY
服务端的 Server 接口有同样的三个方法,但语义是「停止接受新调用」:
shutdown():停止接受新调用,向客户端发 GOAWAY,已在途的调用继续完成。awaitTermination():等待所有调用和连接结束。shutdownNow():强制关闭,取消在途调用。
GOAWAY 是 HTTP/2 的帧,告诉客户端「这条连接不再接受新流,但已经开始的流可以继续」。gRPC 的客户端收到 GOAWAY 后,会把新调用转到其他连接或重新解析;在途流不受影响。服务端不需要手工发 GOAWAY,Server.shutdown() 会处理。
服务端停机前要先把健康状态改成 NOT_SERVING,让负载均衡器停止把新流量送过来。顺序是「健康降级 → shutdown() → 等待 → 强制关闭」:
package com.plume.linkd.v3.core.lifecycle;
import io.grpc.Server;
import io.grpc.health.v1.HealthCheckResponse;
import io.grpc.protobuf.services.HealthStatusManager;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
public final class GrpcServerDrain {
public static final String LIBRARY_SERVICE = "plume.linkd.v3.Library";
public static void drain(Server server, HealthStatusManager health, Duration timeout) {
health.setStatus("", HealthCheckResponse.ServingStatus.NOT_SERVING);
health.setStatus(LIBRARY_SERVICE, HealthCheckResponse.ServingStatus.NOT_SERVING);
health.enterTerminalState();
server.shutdown();
try {
if (!server.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
server.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
server.shutdownNow();
}
}
}
enterTerminalState() 把所有服务永久标记为 NOT_SERVING,之后不能再改回;它适合停机,不适合临时摘流。临时摘流只调用 setStatus(NOT_SERVING),恢复时再设回 SERVING。
shutdown() 之后,9093 的监听 socket 关闭,新的 TCP 连接被拒绝;已有的 HTTP/2 连接收到 GOAWAY,新流被拒绝,老流继续。客户端如果还有未发送的调用,会收到 UNAVAILABLE,应该由上游重试或降级。
核心机制三:进程级 drain 的顺序
一个 linkd-v3 进程同时有四个入口/出口:
| 组件 | 端口 | 停机动作 |
|---|---|---|
| HTTP 网关 | 9094 | 停止接受新请求,返回 503 |
| 管理/指标 | 9095 | 健康检查返回 NOT_SERVING |
| gRPC 服务 | 9093 | Server.shutdown(),等在途流 |
| 下游 channel/池 | 到 9093 | shutdown(),等在途调用 |
正确顺序:
- 停止入口。 9094 不再接受新请求;9095 的健康检查改为
NOT_SERVING,让 LB 把实例摘掉。 - 等待在途请求。 用计数器等所有已进入的请求结束,最多等到 drain 超时。
- 关闭 gRPC 服务。
Server.shutdown(),等在途流;超时shutdownNow()。 - 关闭下游 channel/池。
ChannelDrain.drain()或LibraryChannelPool.close()。 - 退出进程。 释放线程池、关闭日志。
第 1 步和第 2 步之间有一个传播窗口:LB 收到 NOT_SERVING 后,可能还要几秒才停止转发。Kubernetes 的 preStop 睡眠或 readiness 探针的失败阈值就是用来覆盖这个窗口的。不要在第 1 步之后立刻 shutdown() 9093,否则 LB 还没摘掉,新请求已经被拒绝。
在途计数要覆盖「从请求进入到响应写完」的整个过程,不能只包住 gRPC 调用。下面是一个基于监视器锁的计数器,enter() 和 leave() 成对,beginDrain() 之后拒绝新请求:
package com.plume.linkd.v3.core.lifecycle;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
public final class DrainCoordinator {
private final Object lock = new Object();
private boolean draining;
private int inFlight;
public boolean enter() {
synchronized (lock) {
if (draining) {
return false;
}
inFlight++;
return true;
}
}
public void leave() {
synchronized (lock) {
if (inFlight > 0) {
inFlight--;
}
if (inFlight == 0) {
lock.notifyAll();
}
}
}
public void beginDrain() {
synchronized (lock) {
draining = true;
lock.notifyAll();
}
}
public int inFlight() {
synchronized (lock) {
return inFlight;
}
}
public boolean awaitQuiescence(Duration timeout) throws InterruptedException {
long deadline = System.nanoTime() + timeout.toNanos();
synchronized (lock) {
while (inFlight > 0) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0L) {
return false;
}
long millis = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remaining));
lock.wait(millis);
}
return true;
}
}
}
beginDrain() 和 enter() 在同一把锁下,避免「检查时没在 drain、进入后已经开始 drain」的竞态:enter() 返回 true 的请求一定会计入 inFlight,awaitQuiescence 会等它。leave() 在 inFlight 已经是 0 时不做减法,防止计数器被误用成负数。
awaitQuiescence 返回 false 表示超时,还有请求没结束。这时不要无限等;记录剩余请求数和它们的请求 ID,进入第 3 步强制关闭,并在日志里标记这些请求可能未完成。
核心机制四:时间预算与竞态
停机时间不是越长越好。Kubernetes 的 terminationGracePeriodSeconds 是硬上限,超过就 SIGKILL;所有步骤的预算必须小于它,并留出余量。一个可用的预算:
| 阶段 | 预算 | 说明 |
|---|---|---|
| 停止入口 + 健康降级 | 立即 | 设置标志、返回 503 |
| LB 传播等待 | 2 s | preStop 或 readiness 失败窗口 |
| 等待在途请求 | 3 s | 覆盖 800 ms 总 deadline 加余量 |
9093 shutdown + 等待 | 5 s | 覆盖 streaming 流的收尾 |
| 下游 channel 关闭 | 2 s | shutdown + 等待 |
| 强制兜底 | 1 s | shutdownNow 后再等 |
| 合计 | 约 13 s | terminationGracePeriodSeconds: 20 |
预算要和客户端 deadline 对齐。客户端总 deadline 是 800 ms,单跳 300 ms;服务端 drain 等 3 秒,足够覆盖大多数请求。如果某个 streaming 流(WatchStock)持续几分钟,drain 不可能等它自然结束,超时后必须 shutdownNow(),客户端会收到 CANCELLED 或 UNAVAILABLE,由客户端重新订阅。
几个竞态:
- 请求在
beginDrain()之后、shutdown()之前到达 9093。 入口 9094 已经拒绝,但直连 9093 的客户端可能还能进来;enter()返回 false,网关返回 503,不会计入在途。 - 请求已经进入、但还没登记到计数器。 计数要放在请求处理的最外层,在解析参数之前
enter();不要放在 gRPC 调用之前,否则参数解析和鉴权的时间不在计数内。 - 异步任务在响应写完之后还在跑。 如果请求派生了一个后台任务,计数器要覆盖到后台任务结束,或者在 drain 时取消它;否则进程退出会切断后台任务。
- 健康检查自己也被 drain。 9095 的健康端点在 drain 期间要返回
NOT_SERVING,但不能返回 500;LB 把 500 当成故障,可能触发不必要的告警。
在 linkd-v3 里落地:SIGTERM 停机流程
linkd-v3 的启动类持有 Server、HealthStatusManager、DrainCoordinator 和下游 channel。SIGTERM 的 hook 按下面的顺序执行:
package com.plume.linkd.v3.server;
import com.plume.linkd.v3.core.lifecycle.ChannelDrain;
import com.plume.linkd.v3.core.lifecycle.DrainCoordinator;
import com.plume.linkd.v3.core.lifecycle.GrpcServerDrain;
import io.grpc.ManagedChannel;
import io.grpc.Server;
import io.grpc.protobuf.services.HealthStatusManager;
import java.time.Duration;
public final class LinkdShutdown implements Runnable {
private final DrainCoordinator coordinator;
private final Server grpcServer;
private final HealthStatusManager health;
private final ManagedChannel libraryChannel;
private final HttpGateway gateway;
public LinkdShutdown(DrainCoordinator coordinator, Server grpcServer,
HealthStatusManager health, ManagedChannel libraryChannel,
HttpGateway gateway) {
this.coordinator = coordinator;
this.grpcServer = grpcServer;
this.health = health;
this.libraryChannel = libraryChannel;
this.gateway = gateway;
}
@Override
public void run() {
gateway.stopAccepting();
coordinator.beginDrain();
try {
if (!coordinator.awaitQuiescence(Duration.ofSeconds(3))) {
System.err.println("drain timeout, in-flight=" + coordinator.inFlight());
}
GrpcServerDrain.drain(grpcServer, health, Duration.ofSeconds(5));
ChannelDrain.drain(libraryChannel, Duration.ofSeconds(2));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
grpcServer.shutdownNow();
libraryChannel.shutdownNow();
}
}
}
注册 hook:
DrainCoordinator coordinator = new DrainCoordinator();
Runtime.getRuntime().addShutdownHook(
new Thread(new LinkdShutdown(coordinator, server, health, channel, gateway),
"linkd-shutdown"));
gateway.stopAccepting() 让 9094 对新请求返回 503 并带 Retry-After;coordinator.beginDrain() 让已经进入的请求继续计数,新请求被拒绝。GrpcServerDrain 先把健康状态改为 NOT_SERVING,再 shutdown() 9093;最后关下游 channel。
如果使用 6.2 的池,把 ChannelDrain.drain(libraryChannel, ...) 换成 pool.close();池内部会对每条 channel 做同样的 shutdown、等待、shutdownNow。不要在 hook 里直接 System.exit(),那会跳过其余 hook,也会让日志缓冲区来不及刷。
常见错误与边界
错误一:先 shutdownNow() 再 shutdown()。 顺序反了,在途请求会先被取消,shutdown() 已经没有意义。正常停机先 shutdown(),shutdownNow() 只做超时兜底。
错误二:不等待就退出。 调了 shutdown() 但没 awaitTermination,JVM 退出时线程被强杀,效果和 shutdownNow() 一样,只是没有明确的 CANCELLED。
错误三:在 EventLoop 或 gRPC 回调线程里 awaitTermination。 会阻塞该线程负责的其他调用,甚至造成死锁。停机逻辑放在独立的 shutdown hook 线程里。
错误四:drain 预算大于 terminationGracePeriodSeconds。 Kubernetes 会在预算用完之前 SIGKILL,日志里看不到「drain timeout」,只会看到进程被杀。所有预算之和要小于 grace period,并留 20% 余量。
错误五:健康状态还显示 SERVING 就关闭 9093。 LB 还没摘流,新请求会打到正在关闭的实例,客户端看到 UNAVAILABLE。先改健康状态,等传播窗口,再 shutdown()。
错误六:只关 9093,不关下游 channel。 进程退出时下游 channel 的 EventLoop 线程还在,JVM 可能延迟退出;连接也没有发送正常的 GOAWAY。下游 channel 也要 drain。
边界:drain 不能保证所有请求成功。 超过预算的请求会被强制取消;streaming 流可能永远不结束,必须靠 shutdownNow()。drain 的目标是「减少突然切断」,不是「保证零失败」。
边界:客户端也可能 drain。 网关自己也是上游的客户端;它的 9094 入口要停止接受新请求,同时它的下游 channel 要等在途调用。两个方向都要处理,不能只关一边。
小结
ManagedChannel.shutdown()停止新调用、保留在途;shutdownNow()取消在途;awaitTermination()只等待。Server.shutdown()发 GOAWAY、停止新流、保留在途;健康状态要先改为NOT_SERVING,再关闭。- 进程级顺序是「停入口 → 健康降级 → 等在途 → 关 9093 → 关下游 channel → 退出」。
DrainCoordinator用同一把锁保护draining和inFlight,避免新请求在检查后被漏计。- 时间预算要和客户端 deadline、Kubernetes grace period 对齐;超时后
shutdownNow()是必要的兜底。 - drain 的目标是减少突然切断,不是保证零失败;streaming 流和超预算请求会被取消。
- 本节的时间数字是预算示例,不是本机实测的停机耗时。
下一节进入第 7 章:重试预算、退避和抖动。linkd-v3 的 800 ms 总预算里,重试最多 2 次、基础退避 20 ms,怎么分配才不会把 deadline 吃光,是 7.1 的内容。
阅读导航:上一节 6.2 客户端连接池设计 | 下一节 7.1 重试预算与退避
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。