第一章:Go服务在Kubernetes中IO中断缺失的系统性风险
当Go程序在Kubernetes中运行时,若未显式处理信号或未适配容器生命周期事件,其标准IO流(如os.Stdin、os.Stdout、os.Stderr)可能在Pod终止阶段遭遇静默截断——Kubernetes通过SIGTERM触发优雅终止,但Go默认不监听SIGPIPE,也不自动响应stdin关闭事件,导致阻塞型IO操作(如bufio.Scanner.Scan()或io.Copy)陷入永久等待,进而阻碍preStop钩子执行与terminationGracePeriodSeconds的正常流转。
IO中断语义的错位根源
Kubernetes容器运行时(如containerd)在收到docker stop或kubectl delete指令后,向PID 1进程发送SIGTERM,随后在宽限期结束时强制发送SIGKILL。然而Go运行时默认忽略SIGPIPE,且os.Stdin.Read()等调用在底层read(2)返回EPIPE或EBADF时,仅返回io.ErrUnexpectedEOF或io.EOF,不会自动触发goroutine退出。若主goroutine阻塞于未设超时的IO读取,整个进程无法响应退出信号。
复现静默挂起的关键步骤
以下代码模拟典型风险场景:
package main
import (
"bufio"
"fmt"
"os"
"time"
)
func main() {
scanner := bufio.NewScanner(os.Stdin)
fmt.Println("Waiting for stdin input... (try 'echo hello | kubectl exec -i pod -- go-run)")
for scanner.Scan() { // ⚠️ 此处无超时,stdin关闭后Scan()仍阻塞
fmt.Printf("Received: %s\n", scanner.Text())
}
if err := scanner.Err(); err != nil {
fmt.Printf("Scanner error: %v\n", err) // 实际上此行永不执行
}
fmt.Println("Exiting gracefully...") // 永不打印
}
部署该服务后执行 kubectl exec -i <pod> -- sh -c 'echo test',随后删除Pod——进程将卡在scanner.Scan(),preStop钩子超时失败,Pod状态停滞于Terminating。
缓解策略对比
| 方法 | 是否需修改代码 | 是否兼容SIGTERM | 是否防止goroutine泄漏 |
|---|---|---|---|
time.AfterFunc + os.Stdin.Close() |
是 | 否 | 部分缓解 |
context.WithTimeout + io.CopyContext |
是 | 是 | ✅ 推荐 |
signal.Notify + 显式关闭stdin管道 |
是 | ✅ 完全支持 | ✅ |
生产环境必须为所有阻塞IO操作注入上下文超时,并在SIGTERM捕获后主动关闭相关io.ReadCloser资源。
第二章:Go运行时信号处理与IO阻塞的本质剖析
2.1 Go goroutine调度模型与阻塞IO的耦合机制
Go 运行时通过 M:N 调度器(G-P-M 模型)管理 goroutine,但传统阻塞系统调用(如 read()、accept())会直接阻塞 OS 线程(M),导致其绑定的 P 无法调度其他 G。
阻塞 IO 的运行时接管机制
Go 在 syscalls 层面封装了 runtime.entersyscall() / exitsyscall(),当 G 执行阻塞系统调用时:
- 主动让出 P,使其他 M 可绑定该 P 继续调度;
- M 进入休眠,由内核完成 IO;完成时通过 netpoller 唤醒对应 G。
// 示例:阻塞 accept 触发调度让渡
ln, _ := net.Listen("tcp", ":8080")
conn, _ := ln.Accept() // runtime.entersyscall() → P 被解绑
此处
Accept()底层调用epoll_wait或kqueue,若未就绪则触发entersyscall,P 转交其他 M;IO 就绪后 netpoller 回调唤醒 G 并重新绑定 M-P。
关键状态迁移(mermaid)
graph TD
G[goroutine] -->|发起阻塞IO| S[sysmon检测]
S --> E[entersyscall: P解绑]
E --> M[M线程休眠]
M -->|IO就绪| N[netpoller通知]
N --> X[exitsyscall: P重绑定]
X --> R[继续执行]
调度开销对比(单位:ns)
| 场景 | 平均延迟 | 是否释放 P |
|---|---|---|
| 非阻塞 IO(epoll) | ~50 | 否 |
阻塞 read() |
~300 | 是 |
time.Sleep(1ms) |
~150 | 是 |
2.2 os.Signal监听与syscall.SIGTERM/SIGINT的捕获实践
Go 程序需优雅响应系统终止信号,os.Signal 结合 signal.Notify 是标准实践路径。
信号注册与通道接收
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
sigChan为带缓冲通道,避免信号丢失;syscall.SIGTERM(进程终止请求)和syscall.SIGINT(Ctrl+C)被显式监听;- 若未调用
signal.Reset()或signal.Ignore(),默认行为仍有效。
信号处理流程
graph TD
A[主 goroutine 启动] --> B[注册信号通道]
B --> C[阻塞等待信号]
C --> D{收到 SIGTERM/SIGINT?}
D -->|是| E[执行清理逻辑]
D -->|否| C
常见信号语义对比
| 信号 | 触发方式 | 默认动作 | 典型用途 |
|---|---|---|---|
SIGTERM |
kill <pid> |
终止 | 服务平滑退出 |
SIGINT |
Ctrl+C |
终止 | 交互式中断调试流程 |
SIGQUIT |
Ctrl+\ |
Core dump | 调试时生成堆栈快照 |
2.3 net.Listener.Close() 的非原子性及连接残留问题复现
net.Listener.Close() 仅关闭监听套接字,不主动中断已接受但未处理的连接,导致 Accept() 返回的 net.Conn 可能长期存活。
复现场景关键逻辑
ln, _ := net.Listen("tcp", ":8080")
go func() {
for {
conn, err := ln.Accept() // 即使 ln.Close() 已返回,此处仍可能成功
if err != nil {
return // 仅当 Accept 返回 error(如 syscall.EINVAL)才退出
}
go handle(conn) // 连接被接管,ln.Close() 对其无影响
}
}()
ln.Close() // 非阻塞、不等待活跃连接
ln.Close()仅禁用新accept(2)系统调用,内核中已完成三次握手的连接仍保留在已完成队列,Accept()可继续取走——这是 POSIX socket 语义决定的非原子行为。
典型残留连接状态对比
| 状态 | 是否受 ln.Close() 影响 |
原因 |
|---|---|---|
| 监听套接字(fd) | ✅ 立即关闭 | 内核释放监听资源 |
已 Accept() 的 Conn |
❌ 完全不受影响 | 独立 fd,生命周期自治 |
TIME_WAIT 中连接 |
❌ 不触发提前回收 | 由 TCP 状态机独立控制 |
问题传播路径
graph TD
A[ln.Close()] --> B[监听 socket 关闭]
B --> C[accept queue 停止入队]
C --> D[已入队连接仍可 Accept]
D --> E[Conn.Read/Write 继续工作]
E --> F[应用层无感知残留]
2.4 http.Server.Shutdown() 的超时控制与context取消链路验证
http.Server.Shutdown() 并非立即终止,而是启动优雅关闭流程:先关闭监听器,再等待活跃连接完成处理。其行为高度依赖传入的 context.Context。
超时控制的核心机制
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := server.Shutdown(ctx)
if err != nil {
log.Printf("shutdown error: %v", err) // 可能为 context.DeadlineExceeded
}
context.WithTimeout构建带截止时间的上下文;Shutdown()阻塞直至所有连接关闭或上下文被取消;- 若超时,返回
context.DeadlineExceeded,但已建立的连接仍会尽力完成响应。
context 取消链路验证要点
Shutdown()内部调用srv.closeIdleConns(),并监听ctx.Done();- 所有活跃
http.Conn在读写时均检查关联context(如Request.Context())是否已取消; - 中间件、Handler、下游 HTTP 客户端需统一传播该取消信号,形成端到端链路。
| 链路环节 | 是否受 Shutdown ctx 影响 | 说明 |
|---|---|---|
| Listener 关闭 | 是 | 立即停止接受新连接 |
| 空闲连接回收 | 是 | closeIdleConns() 触发 |
| 活跃请求处理 | 否(除非显式使用) | 需 Handler 主动监听 ctx |
graph TD
A[Shutdown(ctx)] --> B{ctx.Done?}
B -->|否| C[等待连接空闲/完成]
B -->|是| D[强制中断未完成写入]
C --> E[返回 nil]
D --> F[返回 context.Canceled]
2.5 基于pprof和gdb的阻塞goroutine现场快照分析方法
当服务出现高延迟但CPU使用率偏低时,极可能是大量 goroutine 阻塞在系统调用或 channel 操作上。此时需获取实时阻塞现场快照。
pprof 快照捕获
# 获取阻塞型 goroutine 的堆栈快照(需程序启用 net/http/pprof)
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutines_blocked.txt
debug=2 输出完整 goroutine 状态(含 IOWait、ChanReceive、Select 等阻塞原因),而非默认精简视图。
gdb 动态诊断(Go 1.19+)
gdb -p $(pgrep myserver) -ex 'info goroutines' -ex 'goroutine 42 bt' -batch
info goroutines 列出所有 goroutine ID 及状态;goroutine <id> bt 打印指定协程完整调用栈,精准定位阻塞点(如 syscall.Syscall 或 runtime.gopark)。
分析维度对比
| 工具 | 实时性 | 需程序支持 | 可见阻塞原因 | 适用场景 |
|---|---|---|---|---|
| pprof | 高 | 是(HTTP) | ✅ | 快速筛查批量阻塞 |
| gdb | 极高 | 否(仅需符号) | ✅✅(含内核态) | 深度根因定位 |
graph TD
A[服务响应延迟] --> B{是否CPU低?}
B -->|是| C[怀疑goroutine阻塞]
C --> D[pprof/goroutine?debug=2]
C --> E[gdb info goroutines]
D --> F[识别阻塞模式]
E --> F
F --> G[定位具体syscall/channel点]
第三章:就绪探针超时背后的服务生命周期错位
3.1 Kubernetes就绪探针触发时机与Pod phase转换条件实测
就绪探针(readinessProbe)不参与 Pod 生命周期的初始创建,仅影响 Running 阶段中 Ready 状态的置位。
探针触发边界条件
- 首次执行在容器
Started之后、initialDelaySeconds耗尽时开始 - 后续按
periodSeconds周期性执行,失败连续failureThreshold次后将 Pod 的Readycondition 置为False
实测关键状态转换表
| Pod Phase | Ready Condition | 触发条件 |
|---|---|---|
| Pending | False(未设) |
调度未完成 |
| Running | True |
所有容器启动成功 且 就绪探针首次成功 |
| Running | False |
就绪探针连续失败 ≥ failureThreshold |
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5 # 容器启动后延迟5s才开始探测
periodSeconds: 10 # 每10秒探测一次
failureThreshold: 3 # 连续3次失败才标记为NotReady
该配置下:容器启动后第5秒执行首次探测;若第5/15/25秒三次均返回非2xx/3xx,则第25秒末
kubectl get pod中READY列变为0/1。
graph TD
A[Container Started] --> B{Wait initialDelaySeconds?}
B -->|Yes| C[Execute First Probe]
C --> D{HTTP 2xx/3xx?}
D -->|Yes| E[Ready=True]
D -->|No| F[Increment Failure Count]
F --> G{Failure Count ≥ failureThreshold?}
G -->|Yes| H[Ready=False]
3.2 Go HTTP服务未优雅关闭导致TCP连接堆积的抓包验证
当 Go HTTP 服务进程被 kill -9 强制终止时,net.Listener 未调用 Close(),底层 accept socket 未释放,已建立的 TCP 连接(ESTABLISHED)无法触发 FIN 流程,客户端重试会持续堆积在服务端 TIME_WAIT 或 CLOSE_WAIT 状态。
抓包关键现象
- Wireshark 中可见大量重复
SYN→RST循环(客户端重连失败) - 服务端
ss -tn state all | wc -l显示连接数异常增长
Go 服务端典型缺陷代码
func main() {
http.ListenAndServe(":8080", nil) // ❌ 无信号监听,无 Shutdown()
}
该写法忽略 os.Signal 监听,http.Server 无法执行 Shutdown(ctx),导致 listener.Close() 被跳过,accept fd 泄漏。
修复后对比(关键参数说明)
| 参数 | 缺陷实现 | 修复实现 |
|---|---|---|
srv.Shutdown() |
未调用 | ctx, _ := context.WithTimeout(...) |
srv.Close() |
隐式跳过 | Shutdown() 内部保障 graceful 终止 |
graph TD
A[收到 SIGTERM] --> B[调用 srv.Shutdown]
B --> C[停止 accept 新连接]
B --> D[等待活跃请求超时]
D --> E[关闭 listener]
3.3 Readiness Probe失败日志与kubelet probe manager行为对照分析
当 readiness probe 连续失败时,kubelet 的 probe manager 会按退避策略重试,并同步更新 Pod 状态。
日志特征识别
典型失败日志片段:
E0521 10:23:41.228] Probe failed for pod "nginx-7c85d5b5f4-2xq9z_default(abc123)" (failure threshold: 3) - http://10.244.1.5:8080/health: Get "http://10.244.1.5:8080/health": dial tcp 10.244.1.5:8080: connect: connection refused
→ 表明 HTTP 探针在容器网络可达但端口未监听,触发 failureThreshold 计数器累加。
kubelet probe manager 核心逻辑
// pkg/kubelet/prober/prober_manager.go
func (pm *proberManager) runProbe(probeType probeType, pod *v1.Pod, status v1.PodStatus) {
// 1. 检查 probe 是否启用(readinessProbe != nil)
// 2. 根据 periodSeconds 定时触发(默认10s)
// 3. 失败后按 initialDelaySeconds + failureThreshold * periodSeconds 指数退避
}
→ failureThreshold=3 且 periodSeconds=10 时,连续失败需达30秒才标记为 NotReady。
状态同步关键路径
| 组件 | 行为 | 触发条件 |
|---|---|---|
| probe manager | 更新 podStatus.Conditions[Readiness] |
探针连续失败 ≥ failureThreshold |
| status manager | 向 API Server PATCH /status |
条件变更且 pod.Status.Phase != Pending |
graph TD
A[Probe 执行] --> B{成功?}
B -->|是| C[重置 failureCount=0]
B -->|否| D[failureCount++]
D --> E{failureCount ≥ failureThreshold?}
E -->|是| F[设置 Ready=False]
E -->|否| G[等待下一次周期]
第四章:构建可中断IO的Go服务标准化模式
4.1 基于context.WithTimeout的HTTP Server优雅关闭封装模板
HTTP Server 优雅关闭的核心在于:阻塞新连接、完成已处理请求、限时终止长连接。context.WithTimeout 提供了统一的超时信号源,是协调 shutdown 流程的理想基础。
封装设计要点
- 使用
sync.Once确保Shutdown()最多执行一次 - 将
http.Server与context.Context绑定,实现信号驱动 - 预留钩子(如
OnPreShutdown)支持业务清理逻辑
关键代码实现
func NewGracefulServer(addr string, handler http.Handler, timeout time.Duration) *GracefulServer {
srv := &http.Server{Addr: addr, Handler: handler}
return &GracefulServer{
server: srv,
cancel: func() {}, // 占位,后续由 WithTimeout 初始化
}
}
func (g *GracefulServer) Run() error {
listener, err := net.Listen("tcp", g.server.Addr)
if err != nil { return err }
// 创建带超时的上下文,用于 shutdown 控制
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
g.cancel = cancel
g.server.BaseContext = func(_ net.Listener) context.Context { return ctx }
go func() {
if err := g.server.Serve(listener); err != http.ErrServerClosed {
log.Printf("server serve error: %v", err)
}
}()
return nil
}
func (g *GracefulServer) Shutdown() error {
return g.server.Shutdown(context.Background()) // 使用独立上下文避免 timeout 干扰
}
逻辑分析:
BaseContext将每个请求绑定到ctx,确保Shutdown()触发后新请求被拒绝;Shutdown(context.Background())不受自身 timeout 限制,保障强制终止能力。30s是最大等待活跃请求完成的宽容窗口。
超时策略对比
| 场景 | 推荐 timeout | 说明 |
|---|---|---|
| API 微服务 | 10–15s | 多数请求短平快 |
| 文件上传/流式响应 | 60–120s | 需预留大文件传输时间 |
| 内部管理接口 | 5s | 低延迟要求,快速失败 |
4.2 自定义net.Listener实现ConnectionGuard与graceful accept拦截
为实现连接准入控制与优雅停机,需封装底层 net.Listener,注入守护逻辑。
ConnectionGuard 设计原理
在 Accept() 调用前执行策略检查:
- 拒绝黑名单 IP
- 限流(令牌桶)
- TLS 协议协商前置校验
核心实现代码
type GuardedListener struct {
net.Listener
guard func(net.Conn) error
}
func (g *GuardedListener) Accept() (net.Conn, error) {
conn, err := g.Listener.Accept()
if err != nil {
return nil, err
}
if err = g.guard(conn); err != nil {
conn.Close() // 拒绝后立即关闭
return nil, err
}
return conn, nil
}
guard 函数接收原始连接,可读取 RemoteAddr()、LocalAddr() 及 TLS 状态;返回非 nil 错误将触发连接丢弃并关闭。Accept() 语义保持不变,兼容所有 http.Server 等标准库组件。
graceful accept 拦截机制
| 阶段 | 行为 |
|---|---|
| 正常运行 | 允许 Accept 并调用 guard |
| 关闭信号触发 | 原子切换 acceptEnabled 标志,后续 Accept 返回 ErrServerClosed |
graph TD
A[Accept()] --> B{acceptEnabled?}
B -->|true| C[执行 guard]
B -->|false| D[return ErrServerClosed]
C --> E{guard passed?}
E -->|yes| F[返回 conn]
E -->|no| G[conn.Close(); return error]
4.3 长连接(WebSocket/gRPC)场景下的Conn.CloseRead/Write精准控制
在长连接中,CloseRead() 与 CloseWrite() 提供了半关闭能力,避免粗暴 Close() 导致未处理数据丢失。
半关闭语义差异
CloseRead():停止接收新数据,但可继续发送(对端仍可写)CloseWrite():停止发送,但可继续读取对端残留数据
gRPC 流式调用中的典型应用
// 客户端单向流:发送完毕后关闭写,等待服务端响应
stream, _ := client.Upload(context.Background())
for _, chunk := range chunks {
stream.Send(chunk)
}
stream.CloseSend() // 等价于底层 Conn.CloseWrite()
// 后续仍可接收服务端返回的最终状态
resp, _ := stream.Recv() // 有效
CloseSend()触发 HTTP/2 RST_STREAM(WRITE_CLOSED),服务端Recv()将返回io.EOF;参数无超时控制,需配合 context 超时管理。
WebSocket 连接状态对照表
| 操作 | 对端 ReadMessage() 行为 |
对端 WriteMessage() 是否允许 |
|---|---|---|
conn.CloseRead() |
立即返回 io.EOF |
✅ 仍可写 |
conn.CloseWrite() |
无影响 | ❌ 返回 websocket: close sent |
graph TD
A[客户端调用 CloseWrite] --> B[发送 FIN 或 WebSocket Close Frame]
B --> C[服务端 Read 接收到 EOF]
C --> D[服务端 Write 仍可成功]
4.4 结合k8s lifecycle hooks与preStop脚本的双保险终止流程设计
在高可用服务中,优雅终止需兼顾容器内进程可控性与外部依赖(如注册中心、消息队列)的解注册时序。单靠 preStop hook 存在超时即强制 kill 的风险,引入 lifecycle 钩子可实现更细粒度的生命周期干预。
双阶段终止协同机制
- 第一阶段:
lifecycle.preStop触发轻量级健康探针降级与连接拒绝 - 第二阶段:
preStop容器命令执行完整清理(如反注册、刷盘、等待活跃请求完成)
典型 YAML 片段
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "curl -X POST http://localhost:8080/health/ready?status=down && sleep 2"]
逻辑分析:该钩子向应用暴露的健康端点发送降级指令,使负载均衡器在 2 秒内停止转发新流量;
sleep 2确保下游 LB 感知到状态变更,避免请求被路由至即将终止的 Pod。
终止流程时序保障
| 阶段 | 动作 | 超时控制 | 依赖 |
|---|---|---|---|
| Hook 触发 | 健康状态降级 | terminationGracePeriodSeconds 内生效 |
/health/ready 接口 |
| preStop 执行 | 执行清理脚本 | 默认 30s,可配置 timeoutSeconds |
cleanup.sh 可靠性 |
graph TD
A[Pod 收到 TERM 信号] --> B[lifecycle.preStop 启动]
B --> C[健康端点置为 not ready]
C --> D[preStop 命令执行 cleanup.sh]
D --> E[等待活跃连接关闭]
E --> F[容器终止]
第五章:从滚动更新故障到云原生可观测性体系升级
某电商中台在双十一大促前夜执行订单服务 v2.3 的滚动更新,Kubernetes 集群按默认策略(maxSurge=25%, maxUnavailable=0)逐批替换 Pod。第 3 批更新启动后,Prometheus 报警显示 order-service_http_request_duration_seconds_bucket{le="0.5"} 指标突增 17 倍,同时 Jaeger 追踪链路中 62% 的 /v1/orders/submit 请求出现 context deadline exceeded 错误。运维团队紧急回滚,但故障窗口已造成 8 分钟下单成功率跌至 41%。
故障根因定位过程
通过 kubectl describe pod order-service-7f9b4c5d8-xvq2k 发现新 Pod 处于 Running 状态但 Ready=False,进一步检查容器日志发现大量 failed to connect to redis:6379: dial tcp 10.244.3.12:6379: i/o timeout。而该 Redis 实例 IP(10.244.3.12)实为旧版 ConfigMap 中硬编码的地址——新版本应使用 Service DNS 名 redis-prod.default.svc.cluster.local,但 Helm Chart 的 values.yaml 中未同步更新 redis.host 字段。
可观测性能力断层分析
| 维度 | 故障前能力状态 | 暴露问题 |
|---|---|---|
| 日志 | ELK 收集全量日志 | 缺少结构化字段 service_version 和 trace_id 关联 |
| 指标 | Prometheus 监控基础 QPS/延迟 | 无服务间依赖拓扑自动发现,无法快速识别 Redis 连接失败传播路径 |
| 分布式追踪 | Jaeger 接入 HTTP 中间件 | 未注入 redis.client.address 标签,无法区分连接目标实例 |
OpenTelemetry 实施改造清单
- 在 Spring Boot 应用中引入
opentelemetry-spring-boot-starter1.22.0,启用otel.instrumentation.redis.enabled=true - 使用 OpenTelemetry Collector 的
k8sattributes接收器自动注入 Pod 标签,并通过transform处理器添加service.version属性 - 配置 Prometheus Remote Write 将指标、Jaeger gRPC 将 trace、Loki Push API 将日志统一接入统一后端
# otel-collector-config.yaml 片段
receivers:
otlp:
protocols:
grpc:
exporters:
prometheusremotewrite:
endpoint: "https://prometheus-remote-write.example.com/api/v1/write"
headers:
Authorization: "Bearer ${PROM_RW_TOKEN}"
jaeger:
endpoint: "jaeger-collector:14250"
tls:
insecure: true
云原生可观测性架构演进图谱
graph LR
A[应用代码] -->|OTel SDK 自动注入| B(OpenTelemetry Collector)
B --> C[Metrics: Prometheus Remote Write]
B --> D[Traces: Jaeger gRPC]
B --> E[Logs: Loki Push API]
C --> F[(统一时序存储)]
D --> G[(分布式追踪存储)]
E --> H[(日志存储)]
F --> I[Grafana 统一仪表盘]
G --> I
H --> I
I --> J[基于 SLO 的告警引擎]
J --> K[自动化故障自愈脚本]
关键治理机制落地
建立 observability-governance GitOps 仓库,所有监控规则、SLO 定义、告警路由均通过 Argo CD 同步。例如订单服务 SLO 定义明确要求 error_rate < 0.5% 且 p95_latency < 800ms,当连续 5 分钟违反任一指标时,触发 curl -X POST https://webhook.internal/rollback?service=order-service 调用 Helm rollback API。
效果验证数据对比
故障复盘演练显示:新体系下同类 Redis 连接异常场景,MTTD(平均故障检测时间)从 4.2 分钟降至 37 秒,MTTR(平均修复时间)从 18 分钟压缩至 210 秒;2023 年 Q4 共拦截 17 次配置类滚动更新风险,其中 12 次在预发布环境即被 SLO 偏离告警捕获。
