on-call 生存指南:如何优雅地处理半夜告警 原创
凌晨 3:17,手机震了。
你眯着眼解锁屏幕:
[FIRING] payment-service error rate > 5%。脑子里闪过三个问题:这个问题严重吗?该怎么处理?我能不能 10 分钟内解决然后继续睡?
这篇文章不讲"某团队某天遇到某故障"的故事。讲的是你今晚就能落地的具体措施——从告警路由配置到应急脚本到事后复盘模板,每一步都有可以直接抄的配置和命令。
一、告警到达之前:该做的准备
半夜被叫醒不可怕,可怕的是被叫醒后一脸懵——不知道这个告警什么意思、不知道影响多大、不知道从哪开始查。on-call 的胜负,在告警到达之前就决定了。
1.1 每条告警必须带 Runbook URL
一条合格的告警,应该让你在 30 秒内知道三件事:发生了什么、影响多大、第一步该干什么。
Alertmanager 告警模板配置:
# Prometheus 告警规则
groups:
- name: service-alerts
rules:
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{status=~"5.."}[5m])) by (service)
/ sum(rate(http_requests_total[5m])) by (service)
> 0.05
for: 3m
labels:
severity: page # 路由到电话/短信
team: payment
annotations:
summary: "{{ $labels.service }} 错误率超 5%"
description: "当前错误率 {{ $value | humanizePercentage }},持续 3 分钟"
impact: "支付服务受影响,可能导致用户下单失败"
runbook: "https://wiki.internal/sre/runbooks/payment-high-error-rate"
dashboard: "https://grafana.internal/d/payment-overview"
escalation: "如果 15 分钟内未恢复,升级到 @DBA 团队"关键字段说明:
| 字段 | 作用 | 示例 |
|---|---|---|
summary | 一句话说清楚是什么 | 支付服务错误率超 5% |
impact | 业务影响 | 支付服务受影响,可能导致用户下单失败 |
runbook | 处置手册 URL | 直接点开看操作步骤 |
dashboard | 监控面板 URL | 直接看数据 |
escalation | 升级条件 | 15 分钟未恢复升级到 DBA |
没有 runbook 的告警 = 噪音。 如果你发了一条告警但没写 runbook,on-call 的人收到后只能靠猜。在告警治理阶段(见系列第 4 篇),CI 门禁应该强制校验:没有 runbook annotation 的告警规则不允许合入。
1.2 Runbook 模板
每条告警的 Runbook 应该包含以下结构:
## [告警名称] 处置手册
### 1. 确认影响范围
- 查看面板:[Grafana 链接]
- 影响指标:错误率 / 延迟 / 可用性
- 影响用户:全量 / 部分 / 灰度
### 2. 快速判断原因
- [ ] 最近 30 分钟有没有发版?→ 查看 CI/CD 发布记录
- [ ] 依赖的下游服务是否正常?→ 查看依赖大盘
- [ ] 数据库是否有慢查询?→ 查看 DB 监控
- [ ] 是否有基础设施变更?→ 查看 K8s 事件
### 3. 应急操作(按顺序执行)
1. 如果是发版导致 → 回滚:`kubectl rollout undo deployment/xxx -n production`
2. 如果是下游超时 → 切降级开关:`./toggle-circuit-breaker.sh on`
3. 如果是 DB 慢查询 → kill 慢 SQL:`pt-kill --busy-time 30`
4. 如果无法定位 → 扩容买时间:`kubectl scale deployment/xxx --replicas=20`
### 4. 升级条件
- 15 分钟未恢复 → 通知 @DBA / @网络组
- 30 分钟未恢复 → 通知值班主管
- 影响交易核心链路 → 立即通知 CTO1.3 告警分级与路由
不是所有告警都值得半夜叫醒人。用 Alertmanager 的路由配置,把告警分成三个级别:
# alertmanager.yml
route:
receiver: default
group_by: ['alertname', 'service']
group_wait: 10s # 首次告警等待 10s(聚合同组告警)
group_interval: 30s # 同组告警间隔
repeat_interval: 4h # 重复通知间隔
routes:
# P0:立即电话 + 短信
- matchers:
- severity = page
receiver: phone-sms
group_wait: 0s # 零延迟
repeat_interval: 30m # 30 分钟重发一次
# P1:企业微信/钉钉通知
- matchers:
- severity = ticket
receiver: im-notify
group_wait: 30s
repeat_interval: 2h
# P2:只进看板,不通知
- matchers:
- severity = info
receiver: null
receivers:
- name: phone-sms
webhook_configs:
- url: 'https://api.oncall.internal/phone'
pagerduty_configs:
- routing_key: 'your-key'
severity: critical
- name: im-notify
webhook_configs:
- url: 'https://oapi.dingtalk.com/robot/send?access_token=xxx'
- name: null分级标准:
| 级别 | severity | 触发方式 | 响应时效 | 典型场景 |
|---|---|---|---|---|
| P0 | page | 电话 + 短信 | 立即 | 核心服务不可用、数据丢失 |
| P1 | ticket | IM 通知 | 工作时间 | 非核心服务异常、指标劣化 |
| P2 | info | 仅看板 | 不要求 | 容量预警、趋势变化 |
半夜只应该收到 P0。 如果你在半夜收到 P1,说明告警分级有问题。
二、告警到达之后:5 分钟止血法
告警来了,不要慌,按以下流程走。目标是 5 分钟内判断严重程度并止血,而不是 5 分钟内彻底修复。
2.1 第一步:确认是不是真的(30 秒)
告警可能是误报。在看告警的同时,用以下方式交叉验证:
# 1. 直接查 Prometheus 确认指标
curl -s 'http://prometheus:9090/api/v1/query?query=up{job="payment-service"}' | jq .
# 2. 查 Pod 状态
kubectl get pods -n production -l app=payment-service -o wide
# 3. 看最近的事件
kubectl get events -n production --sort-by='.lastTimestamp' | tail -20
# 4. 快速看一眼日志
kubectl logs -n production -l app=payment-service --tail=50 --since=5m如果指标正常、Pod 正常、日志正常 → 大概率是误报,标记 resolved 后回去睡觉。
2.2 第二步:判断影响面(1 分钟)
确认告警是真的之后,立刻判断影响面。这是决定后续动作力度的关键:
| 影响面 | 判断依据 | 动作 |
|---|---|---|
| 全量用户 | 核心链路不可用 | 立即启动应急流程,拉群通知 |
| 部分用户 | 某个功能/接口异常 | 评估是否需要降级 |
| 内部服务 | 无用户感知 | 观察趋势,按 Runbook 处理 |
快速查影响面的 PromQL:
# 当前活跃错误数
sum(rate(http_requests_total{status=~"5..", service="payment-service"}[5m]))
# 影响的接口
sum by (handler) (
rate(http_requests_total{status=~"5..", service="payment-service"}[5m])
)
# SLO 错误预算消耗
1 - (
sum(rate(http_requests_total{status!~"5..", service="payment-service"}[1h]))
/ sum(rate(http_requests_total{service="payment-service"}[1h]))
)2.3 第三步:止血(3 分钟)
止血不等于修复。 止血的目的是用最快的手段恢复用户可用性,哪怕是用"笨办法"。
止血工具箱(按速度排序):
| 手段 | 命令 | 适用场景 | 耗时 |
|---|---|---|---|
| 降级开关 | curl -X POST config-service/degrade/payment | 依赖服务超时 | 10 秒 |
| 回滚 | kubectl rollout undo deployment/payment -n prod | 发版导致 | 30 秒 |
| 扩容 | kubectl scale deployment/payment --replicas=30 | 流量突增 | 2 分钟 |
| 重启 | kubectl rollout restart deployment/payment -n prod | 进程状态异常 | 1 分钟 |
| 切流 | 修改 Ingress/DNS 权重 | 单机房故障 | 30 秒 |
一个实用的止血脚本模板:
#!/bin/bash
# /opt/sre/emergency/止血.sh
# 使用方式: ./止血.sh <service-name> <action>
SERVICE=$1
ACTION=$2
NAMESPACE=production
case $ACTION in
rollback)
echo "[$(date)] 回滚 $SERVICE..."
kubectl rollout undo deployment/$SERVICE -n $NAMESPACE
kubectl rollout status deployment/$SERVICE -n $NAMESPACE --timeout=120s
echo "[$(date)] 回滚完成,验证: kubectl get pods -n $NAMESPACE -l app=$SERVICE"
;;
scale-up)
CURRENT=$(kubectl get deployment $SERVICE -n $NAMESPACE -o jsonpath='{.spec.replicas}')
NEW=$((CURRENT * 2))
echo "[$(date)] 扩容 $SERVICE: $CURRENT -> $NEW"
kubectl scale deployment/$SERVICE -n $NAMESPACE --replicas=$NEW
;;
restart)
echo "[$(date)] 重启 $SERVICE..."
kubectl rollout restart deployment/$SERVICE -n $NAMESPACE
;;
degrade)
echo "[$(date)] 开启降级 $SERVICE..."
curl -s -X POST "http://config-service.internal/api/degrade" \
-H "Content-Type: application/json" \
-d "{\"service\": \"$SERVICE\", \"enabled\": true}"
echo ""
echo "[$(date)] 降级已开启"
;;
status)
echo "=== Pod 状态 ==="
kubectl get pods -n $NAMESPACE -l app=$SERVICE -o wide
echo ""
echo "=== 最近事件 ==="
kubectl get events -n $NAMESPACE --field-selector involvedObject.name=$SERVICE --sort-by='.lastTimestamp' | tail -10
echo ""
echo "=== 最近日志 ==="
kubectl logs -n $NAMESPACE -l app=$SERVICE --tail=20 --since=2m
;;
*)
echo "用法: $0 <service> <rollback|scale-up|restart|degrade|status>"
exit 1
;;
esac把这个脚本放在所有节点或一台 jumpserver 上,半夜告警来了直接执行 ./止血.sh payment-service rollback,10 秒搞定回滚。
2.4 第四步:通知和协作
止血之后,不要默默修复。及时通知相关人员:
通知模板(可以直接粘贴到群里):
🚨 [故障通知]
服务:payment-service
现象:错误率从 0.2% 升至 8.3%
影响:支付接口部分失败,影响约 15% 用户
开始时间:03:17
当前状态:已回滚至 v2.4.1,错误率恢复中
负责人:@oncall
下次更新:03:40(或状态变化时)
排障面板:https://grafana.internal/d/payment?from=now-1h&to=now三、告警根因排查:从现象到代码
止血只是恢复了服务,根因还没找到。根因排查的效率,取决于你的可观测性基础设施有多完善(见系列第 6 篇 K8s 可观测性全景图)。
3.1 排查决策树
按以下顺序排查,每一步都有明确的判断和跳转:
告警触发
│
├─ 最近 30 分钟有发版吗?
│ ├─ 是 → 大概率是发版问题
│ │ → 查看 CI/CD 日志,对比新旧版本 diff
│ │ → 执行回滚止血
│ │
│ └─ 否 → 继续
│
├─ 依赖的下游服务正常吗?
│ ├─ 否 → 下游问题传导
│ │ → 查看下游服务状态
│ │ → 开启降级开关或熔断
│ │
│ └─ 正常 → 继续
│
├─ 数据库有异常吗?
│ ├─ 是(慢查询/连接数飙升/锁等待)
│ │ → 查看慢查询日志
│ │ → kill 慢 SQL 或重启连接池
│ │
│ └─ 否 → 继续
│
├─ 基础设施有变更吗?
│ ├─ K8s 节点异常/Pod 被驱逐
│ │ → kubectl describe node/pod
│ │ → 查看资源水位
│ │
│ └─ 网络/DNS 问题
│ → 检查 CoreDNS / CNI / NAT
│
└─ 以上都正常 → 深入排查
→ 看 Trace 链路定位耗时在哪个环节
→ 看日志提取 error 堆栈
→ 看 Profile(如有)定位资源消耗3.2 链路追踪定位法
当 Metrics 和 Logs 都指向"慢"但说不清"哪一步慢"时,用 Trace 定位:
# 在 Tempo/Jaeger 里搜索慢请求
# 按服务名 + 延迟排序,找到最慢的 Trace
# 用 TraceID 在 Loki 里查关联日志
{namespace="production", app="payment-service"}
|= "trace_id=abc123def456"从 Trace 的 Span 树里,你能一眼看到:
- 请求在哪个服务耗时最长
- 哪个下游调用超时了
- 哪个 DB 查询慢了
3.3 日志快速过滤
# 提取 ERROR 级别日志并统计
kubectl logs -n production -l app=payment-service --since=30m | \
grep -E '"level":"(ERROR|FATAL)"' | \
jq -r '.msg' | \
sort | uniq -c | sort -rn | head -20
# 按 TraceID 关联查日志
kubectl logs -n production -l app=payment-service --since=30m | \
jq -r 'select(.trace_id != null) | "\(.trace_id) \(.msg)"' | \
grep "abc123def456"3.4 限定排查时间
一个重要原则:根因排查不要超过 30 分钟。
如果 30 分钟还没找到根因,说明你的可观测性数据不够。这时候应该:
- 先确保止血措施有效,用户不受影响
- 收集现场证据(dump 内存、保存日志快照、截图 Grafana)
- 第二天工作时间用完整的精力排查
凌晨 4 点的排查效率,远不如上午 10 点。
四、自动止血:让机器替你处理简单故障
最好的 on-call 体验,是被叫醒后发现告警已经被自动处理了。
4.1 Kubernetes 自愈:Pod 级别
K8s 本身已经内置了 Pod 级别的自愈能力,确保以下配置都正确开启:
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-service
spec:
replicas: 6
template:
spec:
containers:
- name: app
image: payment-service:v2.4.1
# 存活探针:失败就重启 Pod
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3 # 连续失败 3 次 → 重启
# 就绪探针:失败就摘除流量
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 2 # 连续失败 2 次 → 从 Endpoints 摘除
# 启动探针:慢启动应用保护
startupProbe:
httpGet:
path: /health/live
port: 8080
failureThreshold: 30
periodSeconds: 10 # 最多等 5 分钟启动
# 资源限制:防止单 Pod 吃光节点资源
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 1000m
memory: 2Gi4.2 HPA + VPA:自动伸缩
# HPA:按负载自动扩缩容
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: payment-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: payment-service
minReplicas: 6
maxReplicas: 40
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "500"4.3 Alertmanager + Webhook:告警自愈
对于可以自动处理的告警,用 Webhook 触发自动修复脚本:
# alertmanager.yml
receivers:
- name: auto-remediation
webhook_configs:
- url: 'http://remediation-bot.internal:8080/webhook'
send_resolved: true
# remediation-bot 服务逻辑(伪代码)
# from flask import Flask, request
# app = Flask(__name__)
#
# @app.route('/webhook', methods=['POST'])
# def handle():
# alerts = request.json.get('alerts', [])
# for alert in alerts:
# if alert['status'] != 'firing':
# continue
# labels = alert['labels']
# alertname = labels.get('alertname')
# service = labels.get('service')
#
# if alertname == 'PodCrashLooping':
# # Pod 反复崩溃 → 回滚上一个版本
# subprocess.run(['kubectl', 'rollout', 'undo',
# f'deployment/{service}', '-n', 'production'])
#
# elif alertname == 'HighMemoryUsage':
# # 内存超限 → 重启 Pod 释放内存
# subprocess.run(['kubectl', 'rollout', 'restart',
# f'deployment/{service}', '-n', 'production'])
#
# elif alertname == 'NodeNotReady':
# # 节点失联 → 打污点驱逐 Pod
# node = labels.get('node')
# subprocess.run(['kubectl', 'taint', 'node', node,
# 'node.kubernetes.io/unschedulable=:NoSchedule'])
# return '', 2004.4 哪些场景适合自动止血
| 场景 | 自动止血动作 | 风险 |
|---|---|---|
| Pod CrashLoop | 回滚上一版本 | 低,有版本可回滚 |
| 内存超限 OOM | 重启 Pod | 低,无状态服务安全 |
| 节点 NotReady | 打 taint 驱逐 Pod | 低,K8s 原生能力 |
| 流量突增 | HPA 自动扩容 | 低,有上限保护 |
| 磁盘写满 | 清理旧日志 | 中,需排除正在写入的文件 |
| 慢查询堆积 | kill 慢 SQL | 高,可能误杀正常长查询 |
原则:自动止血只用于低风险、可逆的操作。高风险操作(如删数据、kill 查询)必须人工确认。
五、事后复盘:把痛苦变成资产
故障修复后,复盘是把"痛苦经历"转化为"团队资产"的关键环节。
5.1 复盘文档模板
# 故障复盘:<故障标题>
## 基本信息
- 故障时间:2026-08-10 03:17 ~ 03:52(共 35 分钟)
- 影响范围:支付服务 15% 用户受影响,约 3200 笔交易失败
- 严重级别:P0
- 故障责任人:<姓名>
## 时间线
| 时间 | 事件 |
|------|------|
| 03:17 | Alertmanager 触发 HighErrorRate 告警,电话通知 oncall |
| 03:19 | oncall 确认告警真实性 |
| 03:22 | 判断为 v2.4.2 发版导致,执行回滚 |
| 03:28 | 回滚完成,错误率开始下降 |
| 03:35 | 错误率恢复至正常水平 |
| 03:52 | 确认全部恢复,关闭故障 |
## 根因分析
<技术层面的根因,不带情绪,不追责>
v2.4.2 版本引入了新的 DB 连接池配置(max-active: 50 → 200),
但遗漏了 max-wait 参数配置。高并发下连接获取超时,
导致大量请求排队等待连接,最终触发超时和 5xx 错误。
## 暴露的问题
1. DB 连接池配置变更没有在预发环境做压测
2. CI/CD 流水线没有配置连接池参数校验
3. 告警从触发到通知有 3 分钟延迟(Alertmanager group_wait 配置过大)
## 改进措施
| 序号 | 改进项 | 负责人 | 截止日期 | 状态 |
|------|--------|--------|---------|------|
| 1 | 预发环境增加连接池压测用例 | @后端 | 08-17 | 待开始 |
| 2 | CI 流水线增加连接池配置校验 | @DevOps | 08-15 | 待开始 |
| 3 | 优化 P0 告警 group_wait 为 0s | @SRE | 08-12 | 已完成 |
| 4 | Runbook 补充连接池配置排查步骤 | @SRE | 08-14 | 待开始 |
## 经验教训
- 配置变更和代码变更同等重要,需要同等的测试覆盖
- on-call 止血脚本(回滚)效果验证有效,35 分钟内完成恢复5.2 复盘原则
复盘的目的不是追责,是改进。 坚持以下原则:
- 对事不对人:不追究"谁的错",追究"流程哪里有漏洞"
- 每个问题必须有改进措施:有截止日期和负责人
- 改进措施要可验证:是"增加 CI 校验",不是"加强意识"
- 跟踪到完成:复盘不是开完会就结束,每项改进必须 close
六、on-call 健康管理
6.1 合理的排班机制
| 机制 | 推荐做法 | 避免的做法 |
|---|---|---|
| 轮值周期 | 每人 1 周,不超过 2 周 | 一个人长期 on-call |
| 交接时间 | 周一上午交接 | 周五下午交接(周末出事找谁?) |
| 备岗机制 | 每个时段有 primary + secondary | 只有一个 on-call |
| 休息补偿 | on-call 周结束后调休 1 天 | 无补偿,消耗积极性 |
| 告警上限 | 单日 P0 告警超过 5 次自动转交 | 不设上限 |
6.2 Prometheus 配置 on-call 轮班
用 Alertmanager 的 time_intervals 实现按人按时段路由:
# alertmanager.yml
route:
receiver: oncall-current
routes:
- matchers:
- severity = page
receiver: oncall-current
mute_time_intervals:
- off-hours # 非工作时间静默 P1/P2
mute_time_intervals:
- name: off-hours
time_intervals:
- weekdays:
start: 22:00
end: 08:00
location: Asia/Shanghai
# 用 webhook 服务动态切换 oncall 人
receivers:
- name: oncall-current
webhook_configs:
- url: 'http://oncall-scheduler.internal:9090/route'oncall-scheduler 服务根据排班表,动态决定把告警发给谁(电话、短信、IM)。
6.3 on-call 告警健康度
定期统计以下指标,评估 on-call 体验是否健康:
# 每周 P0 告警数量
sum(rate(alertmanager_notifications_total{severity="page"}[7d]))
# 平均告警处理时间(MTTA / MTTR)
avg_over_time(alert_resolve_time_seconds[7d])
# 半夜告警占比(22:00-08:00)
sum(rate(alertmanager_notifications_total{severity="page"}[7d] offset 14h))
/ sum(rate(alertmanager_notifications_total{severity="page"}[7d]))
# 误报率
sum(rate(alertmanager_notifications_total{severity="page", resolved_by="manual_false_positive"}[7d]))
/ sum(rate(alertmanager_notifications_total{severity="page"}[7d]))健康标准:
| 指标 | 健康 | 需关注 | 不健康 |
|---|---|---|---|
| 每周 P0 告警 | < 3 次 | 3-7 次 | > 7 次 |
| 平均处理时间 | < 15 分钟 | 15-30 分钟 | > 30 分钟 |
| 半夜告警占比 | < 10% | 10-25% | > 25% |
| 误报率 | < 5% | 5-15% | > 15% |
如果某项指标进入"不健康"区间,说明告警治理需要再次介入(见系列第 4 篇)。
七、终极武器:告警静默与维护窗口
有时候你提前知道会触发告警——计划内维护、压测、迁移。这时候用静默规则避免噪音:
# 用 amtool 创建静默规则(维护窗口 2 小时)
amtool silence add \
--comment="数据库迁移维护窗口" \
--duration=2h \
--author="zhang-san" \
alertname=~"Database.*" \
team="db"
# 查看活跃的静默规则
amtool silence query
# 提前结束静默
amtool silence expire <silence-id>维护窗口的标准操作流程:
- 提前创建静默规则:覆盖维护期间可能触发的告警
- 通知相关方:在群里发维护公告,写明时间段和影响
- 执行维护:按操作手册执行
- 验证恢复:维护结束后确认服务正常
- 移除静默:确认稳定后及时取消静默
写在最后
on-call 的终极目标不是"处理告警更快",而是"半夜告警越来越少"。
每处理一次告警,都应该产出以下资产之一:
- 一条 Runbook(下次有人遇到同样问题,直接照做)
- 一个自动止血规则(下次遇到同样问题,机器自动处理)
- 一项改进措施(从源头避免同样问题再发生)
今天多写一条 Runbook,明天少接一个电话。
从完善告警的 Runbook annotation 开始,然后逐步把止血脚本准备好,最后推动自动止血。三步走完,你的 on-call 体验会有质的飞跃。
本文是 SRE 实战系列第 8 篇。系列前 7 篇覆盖了 SLO 落地、Sloth/Pyrra 工具实战、告警治理、监控存储选型、K8s 可观测性全景图、云成本优化。本文聚焦 on-call 日常操作,是把前面所有理论和工具落地的"最后一公里"。