本节目标:把前两节的架构与验收沉淀成值班人员能直接执行的步骤,减少「知道原理但不知道先敲哪条命令」的时间。
口径:命令是执行模板;带「本机」字样的输出才来自真实执行,其余为示意。
承接的问题
验收通过只说明系统在受控条件下能工作。上线之后,值班人员面对的是不完整的信息:告警只说「错误率上升」,没说哪一层;日志里有关键字,但没有上下文;仪表盘有十几个面板,不知道先看哪一个。
运行手册的作用不是重复架构文档,而是把「症状 → 第一条命令 → 判断 → 下一步」写成固定路径。好的手册有三个特征:
- 每条命令都能直接复制,不需要先猜主机名或端口。
- 每个判断都有明确的阈值或分支,不是「进一步排查」。
- 每个高危动作都有回滚方式和影响说明。
本节按生命周期组织:启动、健康检查、故障处置、升级回滚、交接。复盘的「验收缺口」放在最后,让每次故障都能回到 15.2 的清单里。
启动与停止
启动顺序:先确认 libraryd 可用,再启动 comm-server 9093,最后启动 gateway 9094。顺序反了,网关会在启动自检时把下游标记为不可用,虽然后续会恢复,但告警会被误触发。
#!/usr/bin/env bash
set -euo pipefail
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
# 1. 依赖先就绪
curl -fsS -o /dev/null http://127.0.0.1:8080/healthz
# 2. comm-server
nohup "$JAVA_HOME/bin/java" \
-Xms512m -Xmx512m \
-XX:+HeapDumpOnOutOfMemoryError \
-jar comm-server/target/comm-server-3.0.0-SNAPSHOT.jar \
> /var/log/linkd/comm-server.log 2>&1 &
echo $! > /var/run/linkd-comm-server.pid
# 3. 等就绪,而不是 sleep 固定秒数
for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:9095/readyz > /dev/null 2>&1; then
break
fi
sleep 1
done
curl -fsS http://127.0.0.1:9095/readyz
# 4. gateway
nohup "$JAVA_HOME/bin/java" \
-Xms512m -Xmx512m \
-jar comm-gateway/target/comm-gateway-3.0.0-SNAPSHOT.jar \
> /var/log/linkd/gateway.log 2>&1 &
echo $! > /var/run/linkd-gateway.pid
readyz 和 healthz 的语义要分开:healthz 回答「进程活着」,readyz 回答「能接流量」。启动脚本等 readyz,负载均衡也只把流量发给 readyz 通过的实例。只有 healthz 的实例可能在依赖未就绪时接流量,返回一片 503。
停止要用 SIGTERM,让进程执行优雅关闭:
kill -TERM "$(cat /var/run/linkd-gateway.pid)"
for i in $(seq 1 20); do
if ! kill -0 "$(cat /var/run/linkd-gateway.pid)" 2>/dev/null; then
echo "stopped in ${i}s"
exit 0
fi
sleep 1
done
echo "still running after 20s" >&2
exit 1
不要用 kill -9 收尾。它会跳过关闭钩子,在途请求被重置,连接池不释放,日志也来不及 flush。如果 20 秒还没退出,先抓线程栈再决定,而不是直接强杀。
优雅关闭的实现要覆盖三件事:停止接收新请求、排空在途请求、限时退出。
package com.plume.linkd.v3.server;
import io.grpc.Server;
import java.util.concurrent.TimeUnit;
public final class GracefulShutdown {
private final Server server;
private final long graceSeconds;
public GracefulShutdown(Server server, long graceSeconds) {
this.server = server;
this.graceSeconds = graceSeconds;
Runtime.getRuntime().addShutdownHook(new Thread(this::stop, "grpc-shutdown"));
}
public void stop() {
server.shutdown();
try {
if (!server.awaitTermination(graceSeconds, TimeUnit.SECONDS)) {
server.shutdownNow();
server.awaitTermination(5, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
server.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
server.shutdown() 停止接收新调用,并让在途调用继续完成;awaitTermination 等待排空;超时后 shutdownNow() 强制取消。顺序不能反:先 shutdownNow() 会让在途请求直接失败,客户端看到的是 UNAVAILABLE,而不是完整的响应。
关闭钩子只适合单进程。容器环境要确认 SIGTERM 真的传到了 JVM,而不是被 entrypoint 脚本吞掉;Kubernetes 的 terminationGracePeriodSeconds 要大于这里的 graceSeconds。
健康检查
三个端点,职责不同:
| 端点 | 端口 | 检查内容 | 失败时 |
|---|---|---|---|
/healthz | 9095 | 进程存活、事件循环可调度 | 重启实例 |
/readyz | 9095 | 依赖可达、连接池已建立、限流器已加载 | 摘除流量,不重启 |
/metrics | 9095 | Prometheus 文本 | 告警,不影响业务 |
curl -fsS http://127.0.0.1:9095/healthz
curl -fsS http://127.0.0.1:9095/readyz
curl -fsS http://127.0.0.1:9095/metrics | head -40
lsof -nP -iTCP:9093 -sTCP:LISTEN
lsof -nP -iTCP:9094 -sTCP:LISTEN
readyz 不能检查太多东西。如果它依赖 libraryd,那么 libraryd 一抖动,所有实例同时被摘除,故障从「部分功能不可用」升级为「全站不可用」。正确的做法是:readyz 只检查「本实例能否接流量」的本地条件,下游健康由熔断和降级处理。
探针也可以用 Java 写,便于在测试和运维工具里复用:
package com.plume.linkd.v3.server;
import io.grpc.ConnectivityState;
import io.grpc.ManagedChannel;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public final class HealthProbe {
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(200))
.build();
public boolean httpOk(String baseUrl) {
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/readyz"))
.timeout(Duration.ofMillis(500))
.GET()
.build();
try {
return http.send(request, HttpResponse.BodyHandlers.ofString()).statusCode() == 200;
} catch (Exception e) {
return false;
}
}
public String channelState(ManagedChannel channel) {
ConnectivityState state = channel.getState(true);
return state == null ? "UNKNOWN" : state.name();
}
}
channel.getState(true) 会尝试建立连接,适合启动自检,不适合每秒调用。运行期监控用 getState(false),只读当前状态,不触发连接。READY 表示至少有一条可用连接;TRANSIENT_FAILURE 通常意味着正在重连或下游不可达。
五类常见故障的处置
UNAVAILABLE
症状:网关返回 503,原因码 UPSTREAM_UNAVAILABLE,日志里出现 Connection refused 或 NameResolver 失败。
处置顺序:
lsof -nP -iTCP:8080 -sTCP:LISTEN # libraryd 是否在听
pgrep -fl 'libraryd.*jar' # 进程是否存在
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/healthz
lsof -nP -iTCP:9093 -sTCP:ESTABLISHED | wc -l
判断:没有 LISTEN 是 libraryd 没起来;有 LISTEN 但 curl 超时,是应用卡住;ESTABLISHED 数为 0 但进程在,可能是连接池未建立或 DNS 解析失败。先恢复依赖,再观察熔断是否自动半开;不要急着重启网关,重启会丢掉熔断状态和连接池。
DEADLINE_EXCEEDED
症状:网关返回 504,原因码 DEADLINE_EXCEEDED,p99 上升,但错误率可能不高。
处置顺序:
curl -fsS http://127.0.0.1:9095/metrics | grep -E 'rpc_duration|queue|inflight'
jcmd "$(cat /var/run/linkd-comm-server.pid)" Thread.print > /tmp/threads-1.txt
sleep 5
jcmd "$(cat /var/run/linkd-comm-server.pid)" Thread.print > /tmp/threads-2.txt
netstat -anv -p tcp | awk '$4 ~ /\.8080$/ || $5 ~ /\.8080$/'
判断:线程栈两次都停在同一个业务方法或锁上,是代码阻塞;停在 socket read,是 libraryd 慢;Recv-Q 持续大于 0,是应用没来得及读。先确认是「下游慢」还是「自己卡」,再决定扩容、回滚还是限流。
RESOURCE_EXHAUSTED
症状:网关返回 429,原因码 RATE_LIMITED,或服务端返回 RESOURCE_EXHAUSTED。
处置顺序:
curl -fsS http://127.0.0.1:9095/metrics | grep -E 'ratelimit|concurrency|queue_rejected'
curl -sS -D - -o /dev/null http://127.0.0.1:9094/books/978-7-111-00001-1
判断:看 Retry-After 和限流维度。如果是单租户打满配额,通知租户或临时提额;如果是全局打满,先确认是真实流量还是重试风暴。重试风暴的特征是「入口 QPS 没涨,下游 QPS 涨了 3 倍」,此时应该降低重试次数,而不是扩容。
证书过期
症状:TLS 握手失败,客户端报 certificate expired,服务端日志出现 SSLHandshakeException。
openssl x509 -in /etc/linkd/server.crt -noout -subject -enddate
openssl s_client -connect 127.0.0.1:9093 -alpn h2 -servername localhost </dev/null
keytool -list -v -keystore /etc/linkd/truststore.p12 -storetype PKCS12 | grep -A2 'Valid'
判断:notAfter 已过就是过期;证书未过期但握手失败,检查 SAN 是否包含客户端使用的主机名、CA 链是否完整、客户端 truststore 是否包含该 CA。轮换证书要走灰度:先更新客户端信任,再更新服务端证书,否则会出现「服务端换了、客户端不认」的空窗。
连接泄漏
症状:lsof 的连接数持续上涨,TIME_WAIT 增多,最终出现 Too many open files。
lsof -nP -iTCP:9093 -sTCP:ESTABLISHED | wc -l
lsof -nP -p "$(cat /var/run/linkd-comm-server.pid)" | wc -l
netstat -anv -p tcp | awk '$6 == "TIME_WAIT"' | wc -l
jcmd "$(cat /var/run/linkd-comm-server.pid)" Thread.print | grep -c 'grpc-default-executor'
判断:连接数随请求数单调上涨、请求停止后不回落,是泄漏;TIME_WAIT 多但总数稳定,是正常的连接复用模式。看连接池的借出数与归还数是否相等,差值是泄漏的下界。修复前先重启实例止血,但要把现场(线程栈、连接列表、指标快照)保存下来。
macOS 没有 ss,连接状态用 netstat 和 lsof;抓包用 sudo tcpdump -i lo0 -nn port 9093。生产如果是 Linux,命令要换成对应的 ss -tanp,不要把 macOS 的命令直接抄过去。
升级与回滚
升级按灰度推进,每一步都有明确的观察窗口和回滚条件:
| 阶段 | 流量 | 观察时长 | 回滚条件 |
|---|---|---|---|
| 部署 | 0% | 就绪检查 | readyz 失败 |
| 金丝雀 | 5% | 10 分钟 | 错误率 > 1% 或 p99 > 500 ms |
| 扩大 | 25% | 15 分钟 | 错误率 > 0.5% 或 p99 > 400 ms |
| 全量 | 100% | 30 分钟 | 任一 SLO 跌破 |
回滚不是「重新部署旧版本」这么简单,要确认:
- 数据库 schema 是否兼容旧版本(只加字段、不删不改)。
- 新版本写入的数据,旧版本能否读取。
- 连接是否会被强制断开,还是走 drain。
- 配置项是否有新增必填项。
# 回滚到上一个版本
./deploy.sh --version "$(cat /var/run/linkd/previous-version)"
# 确认流量已切回
for i in $(seq 1 20); do
curl -sS -H 'x-canary: v2' -o /dev/null -w '%{http_code} ' \
http://127.0.0.1:9094/books/978-7-111-00001-1
done
echo
curl -fsS http://127.0.0.1:9095/readyz
回滚后至少观察 30 分钟,确认延迟、错误率、连接数都回到基线。只看「没有 5xx」不够:熔断可能还开着,流量其实没有真正恢复。
值班交接
交接不是「我下班了」。至少包含六项:
| 项 | 内容 | 证据 |
|---|---|---|
| 当前状态 | 各服务版本、副本数、健康状态 | 仪表盘截图 |
| 未闭环事件 | 告警、工单、临时变更 | 工单链接 |
| 容量余量 | 当前 QPS / 上限、预计峰值 | 容量表 |
| 证书与配额 | 最近到期时间、剩余配额 | 日期与数字 |
| 近期变更 | 发布、配置、演练 | 变更记录 |
| 待办与风险 | 明日计划、已知隐患 | 文字说明 |
口头交接容易漏项,用固定模板填一遍,双方确认。临时变更尤其要写:为了止血改的限流阈值、临时打开的注入开关、手工加的 DNS 记录,如果不写进交接,下一个人会在几小时后再次踩坑。
复盘:从现象回到验收缺口
复盘固定七段,最后一段是本书强调的「验收缺口」:
# <一句话标题>
## 影响
受影响接口、时间段、错误率、用户可见现象。
## 时间线
UTC+8 时间 | 事件 | 证据来源(日志/指标/命令输出)
## 根因
直接原因、触发条件、为什么现有测试没发现。
## 处置
止血动作、恢复时间、是否回滚。
## 改进项
动作 | 负责人 | 截止日期 | 对应章节
## 验收缺口
本应覆盖的检查项 | 为什么没覆盖 | 补进 15.2 的哪一组
## 回归证据
修复后重跑的命令 | 结果 | 证据存放位置
「验收缺口」要指到具体一行,例如「半开连接只测了 10 秒,没测 100 次循环后的 fd 增长,补进 15.2 故障注入第 4 条」。写「测试不足」无法验收,也无法分配负责人。
常见错误与边界
错误一:用 kill -9 停服务。 跳过关闭钩子,在途请求被重置,连接池不释放。除非进程完全无响应,否则先用 SIGTERM。
错误二:启动脚本用固定 sleep。 机器快时浪费时间,机器慢时还没就绪就接流量。用 readyz 轮询,带超时。
错误三:readyz 检查下游。 libraryd 抖动会让所有实例同时被摘除,故障扩大。readyz 只检查本地接流量的条件。
错误四:回滚只换镜像。 schema、配置、连接 drain 都可能不兼容。回滚前逐项确认。
错误五:交接只交「没事」。 临时变更、即将到期的证书、未闭环告警,都是下一个人最需要的信息。
错误六:复盘只写改进项,不写验收缺口。 改进项修的是这一次,验收缺口修的是下一次。
边界:运行手册会过时。 端口、命令、依赖版本变化后要更新;每次故障处置发现手册与实际不符,当场记下来,事后修订。
边界:手册不能替代判断。 它给的是默认路径,不是唯一路径。遇到手册没覆盖的情况,先止损、再记录、后补充。
小结
- 启动等
readyz而不是固定sleep,停止用SIGTERM并验证 20 秒内退出。 - 优雅关闭顺序:
shutdown()停新请求、awaitTermination排空、超时才shutdownNow()。 healthz管存活、readyz管接流量、metrics管观测;readyz不检查下游。- 五类故障各有固定第一条命令:UNAVAILABLE 查端口、DEADLINE 查线程栈、RESOURCE 查限流维度、证书查
notAfter、泄漏查连接数趋势。 - 升级按 5%→25%→100% 灰度,回滚要确认 schema、配置、drain 和流量真正切回。
- 交接写六项,复盘写七段;「验收缺口」指到 15.2 的具体检查项。
阅读导航:上一节 15.2 联调、验收清单与故障注入 | 下一节 目录与学习路径
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。