概述
凌晨一点四十七分,手机震了。告警群炸了。
某出行项目新上线的推荐服务,上线不到 2 小时,3 个 Pod 全部 OOMKilled。内存从 800MB 飙到 2GB 限制值,然后被内核杀掉,Kubernetes 拉起新 Pod,又被杀掉。循环往复,用户请求全部打到降级链路上,P99 延迟从 80ms 飙到 2000ms。
事后复盘,问题出在哪?开发团队写了完整的单元测试,通过了代码审查,跑了压测——但没有人问过一个最基本的问题:这个服务在生产环境到底"准备好了"没有?
“准备好了"这三个字,在 SRE 圈子里有个正式名字:生产就绪审查(Production Readiness Review,PRR)。Google 在 SRE Book 第 32 章把它定义为 SRE 接手服务运维的第一步——只有通过 PRR 评审,SRE 团队才会同意承担该服务的运维责任。
但问题来了。传统 PRR 是一份文档,由 SRE 工程师逐条检查,填表签字。这种做法在服务数量少的时候还行,当一个企业有 50+ 微服务同时迭代时,人工 PRR 就成了发布瓶颈。开发团队抱怨"审批太慢”,SRE 团队抱怨"服务太多审不过来",最后 PRR 变成走形式——签字盖章,该炸还是炸。
这篇文章讲的是:怎么把 PRR 从人工文档变成自动化门禁,用 7 个维度的可机器检查项替代人工判断,让"准备好"这件事变成 CI/CD 流水线上的一道闸门,而不是会议室里的一场审批会。
生产就绪审查到底是什么
先说人话。
生产就绪审查就是:一个服务在正式承接真实用户流量之前,必须通过的一组检查。检查的核心问题是——“如果这个服务在生产环境出事了,我们能不能发现、能不能定位、能不能恢复”。
这不是代码审查。代码审查关心的是"代码写得对不对",PRR 关心的是"这个服务放到生产环境能不能活下来、出了事能不能救回来"。一个代码写得很漂亮但没有任何监控告警的服务,代码审查能通过,PRR 不能通过。
Google 的 PRR 模型覆盖几个核心维度:架构韧性、容量规划、监控告警、故障恢复、部署流程、安全合规。我在实际落地时把它扩展到 7 个维度,因为多出来的两个——运维归属和成本可见性——是我踩了坑之后发现不能省的。
| 维度 | 核心问题 | 传统人工检查 | 自动化门禁 |
|---|---|---|---|
| 架构韧性 | 挂一个副本会不会全挂 | 看部署 YAML 确认副本数 | 检查 PDB + 拓扑分布约束 |
| 资源配额 | 会不会 OOM 或 CPU 限流 | 看资源配置 | 检查 requests/limits + 历史利用率 |
| 部署安全 | 发坏了能不能回滚 | 问开发"能不能回滚" | 检查滚动策略 + 回滚超时 |
| 可观测性 | 出了事能不能发现和定位 | 看有没有 Prometheus 指标 | 检查四金信号指标 + 告警规则 |
| 故障恢复 | 数据丢了能不能恢复 | 看有没有备份 | 检查备份频率 + 恢复测试记录 |
| 安全合规 | 权限管没管住 | 看 RBAC 配置 | 检查 NetworkPolicy + 镜像扫描 |
| 运维归属 | 出了事找谁 | 看 Wiki 文档 | 检查服务目录 + Runbook 链接 |
为什么人工 PRR 会失效
我在某出行项目推进 PRR 的时候,最初用的就是人工审查。SRE 团队写了一份 40 页的检查清单,每次新服务上线前,开发团队填表,SRE 逐条审核。结果三个星期后发现:
第一,检查标准漂移。 不同 SRE 对同一检查项理解不同。比如"是否配置了合理的资源限制"这条,有人觉得 2CPU/4GB 就算合理,有人觉得必须根据压测数据来定。标准不统一,审查结果就不可重复。
第二,检查时机滞后。 PRR 往往在开发完成后、上线前进行。这时候架构已经定型,如果 PRR 发现架构层面的问题(比如所有副本都在同一个可用区),修改成本极高。Google SRE Book 也强调这一点——SRE 参与越早,服务质量提升越快,最终质量也越好(相关文章:SRE 与开发团队协作模式)。
第三,检查结果过期。 服务上线后迭代频繁,第一次 PRR 通过不代表第十次发布还满足条件。一个服务第一次上线时配了 PDB(PodDisruptionBudget),后面某次发布把 PDB 删了,没有重新走 PRR,直到节点维护时 Pod 被驱逐导致服务不可用才发现。
根本问题是:人工 PRR 是一次性的快照,而生产环境是持续变化的。你需要的是持续检查,不是一次审查。
7 维门禁设计:从文档到代码
把 PRR 从文档变成代码,核心思路是:能机器检查的全部自动化,需要人工判断的保留为显式审查点。
下面逐维度讲设计思路和踩过的坑。
维度一:架构韧性
架构韧性检查的是"单点故障会不会导致服务不可用"。最常见的反例是:Deployment 配了 3 个副本,但 3 个副本全调度到同一个节点上,节点一挂服务全挂。
自动化检查项:
# PRR 架构韧性检查规则
architecture_resilience:
- id: ARC-001
name: "副本数 >= 2"
check: "kubectl get deploy {svc} -o jsonpath='{.spec.replicas}'"
assert: ">= 2"
severity: critical
description: "生产环境服务至少 2 副本,单副本无冗余"
- id: ARC-002
name: "配置了 PDB"
check: "kubectl get pdb {svc} -o name"
assert: "not_empty"
severity: critical
description: "必须配置 PodDisruptionBudget,防止节点维护时 Pod 被全部驱逐"
- id: ARC-003
name: "拓扑分布约束"
check: "kubectl get deploy {svc} -o jsonpath='{.spec.template.spec.topologySpreadConstraints}'"
assert: "not_empty"
severity: warning
description: "建议配置 topologySpreadConstraints,避免副本集中在一个可用区"
- id: ARC-004
name: "反亲和性配置"
check: "kubectl get deploy {svc} -o jsonpath='{.spec.template.spec.affinity.podAntiAffinity}'"
assert: "not_empty"
severity: warning
description: "建议配置 podAntiAffinity,避免副本调度到同一节点"
这里有个踩坑细节。最初我把 ARC-003(拓扑分布约束)设为 critical,结果一堆存量服务不满足,门禁全部卡住,开发团队集体抗议。后来我改成 warning——存量服务可以暂时不满足,但新建服务必须配置。这个策略上的妥协是必要的:门禁不是为了阻断发布,而是让不满足标准的服务可见、可追踪。
我在落地时给 warning 级别的检查项加了一个机制:不阻断发布,但自动在 Issue Tracker 里创建一张整改工单,绑定到服务的 owner,限期修复。这样既不阻塞业务,又不会让问题石沉大海。
实际效果:推行 3 个月后,120+ 微服务中 PDB 覆盖率从 31% 提升到 96%,拓扑分布约束覆盖率从 0 提升到 78%。不是靠人工催,是靠门禁自动暴露问题。
维度二:资源配额
资源配额检查的是"CPU 和内存配置合不合理"。开头痛点里那个 OOM 故障,根因就是开发团队给推荐服务配了 800MB memory request、2GB memory limit,但实际运行时内存峰值能到 2.3GB——limit 卡在 2GB,超了就被 OOMKill。
自动化检查项:
import subprocess
import json
def check_resource_quota(service_name, namespace="prod"):
"""PRR 资源配额检查:requests/limits 合理性 + 历史利用率"""
issues = []
# 1. 检查是否配置了 requests 和 limits
deploy_json = json.loads(
subprocess.check_output(
["kubectl", "get", "deploy", service_name,
"-n", namespace, "-o", "json"]
)
)
containers = deploy_json["spec"]["template"]["spec"]["containers"]
for c in containers:
resources = c.get("resources", {})
requests = resources.get("requests", {})
limits = resources.get("limits", {})
# 必须同时配置 requests 和 limits
if not requests.get("cpu") or not requests.get("memory"):
issues.append({
"id": "RES-001",
"severity": "critical",
"msg": f"容器 {c['name']} 缺少 cpu/memory requests"
})
if not limits.get("cpu") or not limits.get("memory"):
issues.append({
"id": "RES-002",
"severity": "critical",
"msg": f"容器 {c['name']} 缺少 cpu/memory limits"
})
# 2. 检查 request/limit 比值是否合理
# request 应在 limit 的 50%-80% 之间
# request 过低导致调度到资源不足的节点
# request 过高导致资源浪费
if requests.get("memory") and limits.get("memory"):
req_mem = parse_quantity(requests["memory"])
lim_mem = parse_quantity(limits["memory"])
ratio = req_mem / lim_mem
if ratio < 0.5:
issues.append({
"id": "RES-003",
"severity": "warning",
"msg": f"容器 {c['name']} memory request/limit 比值 "
f"{ratio:.0%},低于 50% 建议值,"
f"可能导致节点超卖"
})
# 3. 查询 Prometheus 历史利用率
# 如果实际使用量接近 limit,说明 limit 设低了
utilization = query_prometheus_utilization(service_name)
for container, stats in utilization.items():
if stats["memory_p95"] > stats["memory_limit"] * 0.85:
issues.append({
"id": "RES-004",
"severity": "critical",
"msg": f"容器 {container} 内存 P95 使用量 "
f"{stats['memory_p95']:.0f}MB 超过 limit 的 85%,"
f"存在 OOM 风险(当前 limit: "
f"{stats['memory_limit']:.0f}MB)"
})
return issues
这段代码里最关键的是 RES-004 检查项——基于历史利用率预测 OOM 风险。传统 PRR 只看配置文件里写了什么,不看实际跑起来用了多少。我在落地时把 Prometheus 查询集成进来,取过去 7 天的内存 P95 使用量,如果超过 limit 的 85%,直接判 critical。
回到开头的 OOM 案例。如果当时有这个检查项,门禁会在 CI 阶段就拦住:历史压测数据显示内存峰值 2.3GB,limit 设的 2GB,P95/limit 比值 115%,远超 85% 阈值。开发团队在 CI 阶段就能看到"这个服务有 OOM 风险,请调整 limit 或优化内存使用",而不是上线 2 小时后被告警吵醒。
| 检查项 | 阈值 | 严重级别 | 触发动作 |
|---|---|---|---|
| 缺少 requests | — | critical | 阻断发布 |
| 缺少 limits | — | critical | 阻断发布 |
| request/limit 比值 < 50% | < 0.5 | warning | 创建整改工单 |
| 内存 P95 > limit × 85% | > 85% | critical | 阻断发布 |
| CPU throttling 率 > 10% | > 10% | warning | 创建整改工单 |
维度三:部署安全
部署安全检查的是"发坏了能不能快速回滚"。我在某新能源物流平台做 K8s 迁移时,最常见的问题是开发团队配了滚动更新但没配 maxUnavailable,默认值 25% 意味着每次更新时最多 25% 的副本不可用。3 副本的服务更新时 1 个不可用,如果此时恰好有流量高峰,用户就可能感知到延迟。
自动化检查项:
deployment_safety:
- id: DEP-001
name: "配置了滚动更新策略"
check: "kubectl get deploy {svc} -o jsonpath='{.spec.strategy}'"
assert: "type == 'RollingUpdate'"
severity: critical
- id: DEP-002
name: "maxUnavailable <= 25%"
check: |
kubectl get deploy {svc} -o \
jsonpath='{.spec.strategy.rollingUpdate.maxUnavailable}'
assert: "<= 25%"
severity: warning
description: "建议 maxUnavailable 设为 0 或低值,确保更新时服务可用"
- id: DEP-003
name: "配置了健康探针"
check: |
kubectl get deploy {svc} -o \
jsonpath='{.spec.template.spec.containers[0].readinessProbe}'
assert: "not_empty"
severity: critical
description: "必须配置 readinessProbe,否则流量会被路由到未就绪的 Pod"
- id: DEP-004
name: "readinessProbe 和 livenessProbe 分离"
check: |
检查 readinessProbe.httpGet.path
与 livenessProbe.httpGet.path 是否不同
assert: "different_paths"
severity: warning
description: "就绪探针和存活探针应检查不同端点,避免存活探针误判"
DEP-004 这条是我踩坑后加的。有一次某出行项目的订单服务频繁重启,排查发现开发把 readinessProbe 和 livenessProbe 配了同一个 /health 端点。当数据库连接池满了时,/health 返回 500,readinessProbe 把 Pod 从 Endpoints 摘掉(流量不再进来),但 livenessProbe 也失败了,于是 kubelet 把 Pod 重启了。重启后数据库连接还是满的,又重启。形成死循环。
正确做法是 readinessProbe 和 livenessProbe 检查不同维度:就绪探针检查"能不能接流量"(比如数据库连通性),存活探针检查"进程是否活着"(比如一个轻量的 /live 端点只返回 200,不做深度检查)。这两个探针的区别在 K8s 日志采集方案选型时也需要考虑(相关文章:K8s 日志采集方案选型)。
维度四:可观测性
可观测性检查的是"出了事能不能发现和定位"。一个没有监控的服务等于蒙眼开车——你不知道它健不健康,直到用户投诉。
Google SRE 的四金信号——延迟(Latency)、流量(Traffic)、错误(Errors)、饱和度(Saturation)——是可观测性的最低标准。但我在落地时发现,很多团队"有监控"但不"可观测":Prometheus 采了一堆指标,Grafana 画了一堆面板,但告警规则配得不合理,要么不告警,要么告警风暴。
自动化检查项:
def check_observability(service_name):
"""PRR 可观测性检查:四金信号 + 告警规则 + 链路追踪"""
issues = []
# 1. 检查四金信号指标是否存在
golden_signals = {
"latency": f'histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{{service="{service_name}"}}[5m]))',
"traffic": f'rate(http_requests_total{{service="{service_name}"}}[5m])',
"errors": f'rate(http_requests_total{{service="{service_name}",status=~"5.."}}[5m])',
"saturation": f'container_cpu_usage_seconds_total{{pod=~"{service_name}-.*"}}'
}
for signal, query in golden_signals.items():
result = query_prometheus(query)
if not result:
issues.append({
"id": "OBS-001",
"severity": "critical",
"msg": f"缺少 {signal} 指标,四金信号不完整"
})
# 2. 检查告警规则是否存在
alerts = query_alertmanager_rules(service_name)
if not alerts:
issues.append({
"id": "OBS-002",
"severity": "critical",
"msg": "该服务没有任何告警规则,故障无法被自动发现"
})
# 3. 检查是否有 Runbook 链接
# 告警必须关联 Runbook,否则值班人员不知道怎么处理
for alert in alerts:
if not alert.get("annotations", {}).get("runbook_url"):
issues.append({
"id": "OBS-003",
"severity": "warning",
"msg": f"告警 {alert['name']} 缺少 runbook_url 注解,"
f"值班人员无法快速找到处理指南"
})
# 4. 检查链路追踪是否启用
traces = check_jaeger_service(service_name)
if not traces:
issues.append({
"id": "OBS-004",
"severity": "warning",
"msg": "未接入链路追踪,跨服务调用链不可见"
})
return issues
OBS-003 这条——告警必须关联 Runbook——是我在告警体系优化时加的。之前告警触达 5 秒,故障定位从 30 分钟降到 8 分钟(相关文章:MTTR 优化实践),靠的就是每条告警都关联了 Runbook。值班人员收到告警后,一眼就能看到"这个告警是什么意思、应该检查什么、怎么处理"。没有 Runbook 的告警等于噪音——你知道出事了,但不知道怎么办。
维度五:故障恢复
故障恢复检查的是"出事后能不能快速恢复"。这里有个关键认知:备份不等于恢复能力。你有备份不代表你能恢复——备份没测过恢复,就是一个未经检验的假设。
自动化检查项:
recovery:
- id: REC-001
name: "数据库备份频率"
check: "查询备份系统 API,确认最近一次成功备份时间"
assert: "last_backup_age < 24h"
severity: critical
description: "数据库至少每天备份一次"
- id: REC-002
name: "备份恢复测试"
check: "查询恢复测试记录,确认最近一次成功恢复测试"
assert: "last_restore_test_age < 30d"
severity: warning
description: "建议每月至少做一次恢复演练,验证备份可用性"
- id: REC-003
name: "配置了优雅终止"
check: |
kubectl get deploy {svc} -o \
jsonpath='{.spec.template.spec.terminationGracePeriodSeconds}'
assert: ">= 30"
severity: warning
description: "建议 terminationGracePeriodSeconds >= 30,给服务足够时间优雅退出"
- id: REC-004
name: "preStop hook 配置"
check: |
kubectl get deploy {svc} -o \
jsonpath='{.spec.template.spec.containers[0].lifecycle.preStop}'
assert: "not_empty"
severity: warning
description: "建议配置 preStop hook,确保 Pod 退出前从注册中心摘除"
REC-002 是我认为最该有但最少有人做的检查项。我在跨机房容灾方案设计时,把 RPO 定在 5 分钟以内、RTO 定在 30 分钟以内(相关文章:跨机房容灾架构)。但这个 RTO 能不能真做到,取决于你有没有实际演练过恢复流程。没有演练过的 RTO 是一个许愿,不是承诺。
维度六:安全合规
安全合规检查的是"权限管没管住、镜像干不干净"。这里要避免一个误区:安全审查不等于安全扫描。扫描是发现漏洞,审查是确认安全策略是否到位。
自动化检查项:
def check_security_compliance(service_name, namespace="prod"):
"""PRR 安全合规检查"""
issues = []
# 1. NetworkPolicy 是否配置
np = kubectl_get_networkpolicy(service_name, namespace)
if not np:
issues.append({
"id": "SEC-001",
"severity": "warning",
"msg": "未配置 NetworkPolicy,Pod 间网络未隔离"
})
# 2. 镜像是否扫描
image = get_deploy_image(service_name, namespace)
scan_result = query_harbor_scan(image)
if not scan_result or scan_result.get("scan_status") != "success":
issues.append({
"id": "SEC-002",
"severity": "critical",
"msg": f"镜像 {image} 未通过安全扫描"
})
elif scan_result.get("vulnerabilities", {}).get("critical", 0) > 0:
issues.append({
"id": "SEC-003",
"severity": "critical",
"msg": f"镜像 {image} 有 "
f"{scan_result['vulnerabilities']['critical']} 个严重漏洞"
})
# 3. ServiceAccount 权限检查
sa = get_service_account(service_name, namespace)
if sa == "default":
issues.append({
"id": "SEC-004",
"severity": "critical",
"msg": "使用 default ServiceAccount,应创建专用 SA 并绑定最小权限"
})
# 4. Pod Security Standards
pod_security = check_pod_security(service_name, namespace)
if pod_security.get("privileged"):
issues.append({
"id": "SEC-005",
"severity": "critical",
"msg": "容器以 privileged 模式运行,安全隔离被绕过"
})
return issues
SEC-004(使用 default SA)这条检查项的来源:我在某电商项目做等保 2.0 合规时发现,80% 的服务用的是 default ServiceAccount,这意味着所有服务共享同一套权限。一旦一个服务被攻破,攻击者可以用这个 SA 的权限访问集群内所有资源。等保 2.0 要求最小权限原则,每个服务必须有独立的 SA(相关文章:K8s RBAC 与安全上下文)。
维度七:运维归属
运维归属检查的是"出了事找谁"。这一维度看起来最简单,但实际是最容易被忽略的。我在推行 PRR 时遇到过这样的场景:告警响了,值班人员一看告警——“order-service CPU 100%"——然后呢?找谁?这个服务谁负责的?有没有 Runbook?
自动化检查项:
ownership:
- id: OWN-001
name: "服务目录注册"
check: "查询服务目录 API,确认服务已注册"
assert: "registered"
severity: critical
description: "服务必须在服务目录中注册,包含 owner 信息"
- id: OWN-002
name: "Owner 明确"
check: "查询服务目录,确认 owner 字段非空"
assert: "owner_not_empty"
severity: critical
- id: OWN-003
name: "Runbook 链接"
check: "查询服务目录,确认 runbook_url 非空"
assert: "runbook_not_empty"
severity: critical
description: "每条告警必须关联 Runbook(参考告警策略设计)"
- id: OWN-004
name: "On-Call 排班"
check: "查询排班系统,确认服务已纳入排班"
assert: "in_rotation"
severity: critical
description: "服务必须有明确的 On-Call 负责人"
OWN-004 这条直接关联到 On-Call 排班设计。我在做 On-Call 排班时发现,最大的问题不是排班规则不合理,而是有些服务根本没纳入排班(相关文章:On-Call 排班设计)。告警响了半天没人接,因为排班系统里压根没有这个服务。自动检查"服务是否在排班系统中注册”,能从根上解决这个问题。
门禁引擎实现:Go 写一个 PRR Gate
上面 7 个维度的检查项,需要一个引擎来执行。我用 Go 写了一个 PRR Gate,嵌入到 CI/CD 流水线中,每次发布前自动执行所有检查项。
package main
import (
"encoding/json"
"fmt"
"os"
)
// Severity 检查项严重级别
type Severity string
const (
Critical Severity = "critical"
Warning Severity = "warning"
Info Severity = "info"
)
// CheckResult 检查结果
type CheckResult struct {
ID string `json:"id"`
Name string `json:"name"`
Severity Severity `json:"severity"`
Passed bool `json:"passed"`
Message string `json:"message"`
Evidence string `json:"evidence"`
}
// PRRGate 生产就绪审查门禁
type PRRGate struct {
ServiceName string
Namespace string
Checks []Check
}
// Check 检查接口
type Check interface {
ID() string
Name() string
Severity() Severity
Execute(svc, ns string) (CheckResult, error)
}
// Run 执行所有检查项
func (g *PRRGate) Run() (*GateReport, error) {
report := &GateReport{
Service: g.ServiceName,
Total: len(g.Checks),
}
for _, check := range g.Checks {
result, err := check.Execute(g.ServiceName, g.Namespace)
if err != nil {
result = CheckResult{
ID: check.ID(),
Name: check.Name(),
Severity: check.Severity(),
Passed: false,
Message: fmt.Sprintf("检查执行失败: %v", err),
}
}
report.Results = append(report.Results, result)
if !result.Passed {
if result.Severity == Critical {
report.CriticalFail++
} else {
report.WarningFail++
}
}
}
// 门禁策略:critical 不通过则阻断发布
report.GatePassed = report.CriticalFail == 0
return report, nil
}
// GateReport 门禁报告
type GateReport struct {
Service string `json:"service"`
Total int `json:"total"`
CriticalFail int `json:"critical_fail"`
WarningFail int `json:"warning_fail"`
GatePassed bool `json:"gate_passed"`
Results []CheckResult `json:"results"`
}
func main() {
gate := &PRRGate{
ServiceName: os.Getenv("SERVICE_NAME"),
Namespace: os.Getenv("NAMESPACE"),
Checks: registerChecks(),
}
report, err := gate.Run()
if err != nil {
fmt.Printf("PRR 门禁执行失败: %v\n", err)
os.Exit(2)
}
output, _ := json.MarshalIndent(report, "", " ")
fmt.Println(string(output))
if !report.GatePassed {
fmt.Printf("\n❌ PRR 门禁未通过: %d 项 critical 检查失败\n",
report.CriticalFail)
os.Exit(1)
}
if report.WarningFail > 0 {
fmt.Printf("\n⚠️ PRR 门禁通过,但有 %d 项 warning 需关注\n",
report.WarningFail)
} else {
fmt.Println("\n✅ PRR 门禁全部通过")
}
}
这个引擎的核心设计有几点:
第一,检查项可插拔。 每个 Check 实现一个接口,注册时通过 registerChecks() 挂载。新增检查维度不需要改引擎代码,只需要写一个新的 Check 实现。我在某出行项目落地时,最初只有 4 个维度的检查项,后来逐步扩展到 7 个维度,每次扩展都是加 Check 实现类,不改核心逻辑。
第二,门禁策略分级。 Critical 不通过就阻断发布(exit 1),Warning 不通过只报告不阻断(exit 0)。这个策略很重要——如果所有检查项都阻断发布,门禁会变成发布瓶颈,开发团队会想办法绕过它。分级策略让"必须满足"和"建议满足"分开,保证门禁不被绕过。
第三,输出 JSON 报告。 这不是为了好看,是为了让 CI/CD 平台能解析结果、自动创建工单、自动通知 owner。我在落地时把报告推送到一个内部 Dashboard,所有服务的 PRR 状态一目了然。
门禁在 CI/CD 中的位置
# GitLab CI / Jenkins Pipeline 中的 PRR Gate 位置
stages:
- build
- test
- scan # 安全扫描
- prr-gate # ← 生产就绪审查门禁
- deploy-staging
- integration-test
- deploy-prod # 只有 PRR 通过才到这步
- verify
prr-gate:
stage: prr-gate
image: prr-gate:latest
variables:
SERVICE_NAME: $CI_PROJECT_NAME
NAMESPACE: prod
script:
- /app/prr-gate
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: on_success
artifacts:
reports:
json: prr-report.json
关键点是 PRR Gate 放在 deploy-staging 之前。这意味着:服务还没部署到任何环境,PRR 先检查一遍。如果 critical 检查项不通过,发布流程在此中断,开发团队在 CI 阶段就能看到哪些项不满足、怎么修。比上线后发现 OOM 要早得多。
7 维门禁 vs 传统人工 PRR:实测对比
我在某出行项目推行 PRR Gate 半年后做过一次数据对比,结果如下:
| 指标 | 传统人工 PRR(前 6 个月) | 自动化 7 维门禁(后 6 个月) |
|---|---|---|
| 平均审查耗时 | 3.5 天/服务 | 47 秒/服务 |
| 上线后 24h 内故障率 | 12%(约 1/8 服务上线后出问题) | 3%(下降 75%) |
| PRR 通过率 | 89%(人工签字居多) | 67%(机器检查更严格) |
| 首次不通过后平均修复时间 | 4.2 天 | 1.8 天 |
| SRE 团队人均审查服务数 | 8 个/月 | 不限(自动化) |
几个关键发现:
通过率下降是好事。 人工 PRR 通过率 89%,看起来很健康,实际上是因为人工审查标准不统一、执行不严格。自动化后通过率降到 67%,说明确实有 22% 的服务在"人工签字通过"的情况下其实不满足生产就绪标准。这些服务在自动化门禁中被拦住了,避免了上线后出问题。
修复速度提升 2.3 倍。 原因是自动化门禁给出的是具体的、可操作的检查项——“缺少 PDB"“内存 P95 超过 limit 85%"——开发团队知道该修什么。人工 PRR 给出的是"建议完善容灾方案"这种模糊表述,开发团队不知道从哪下手。
审查耗时从天级降到秒级。 这是最大的效率提升。原来 SRE 团队 3 个人,每人每月审 8 个服务,已经满负荷。自动化后不限量,100 个服务同时跑 PRR Gate 也没问题。SRE 团队的时间被释放出来去做更有价值的事——比如混沌工程演练、SLO 体系搭建。
落地路径:从 0 到 1 推行 PRR Gate
如果要从零推行 PRR Gate,我的建议是分 3 个阶段,每个阶段 1 个月:
第一个月:存量盘点。 先跑一遍全量检查,不阻断任何发布,只出报告。目的是摸清家底——多少服务没有 PDB、多少服务没有监控、多少服务没有 Runbook。这个数据本身就是推动改进的最有力武器。
第二个月:新建服务强制。 所有新上线的服务必须通过 PRR Gate。存量服务不强制,但在 Dashboard 上标红。这个阶段开发团队会开始适应门禁的存在,也会开始在新服务中主动满足检查项。
第三个月:全量强制。 所有发布(含存量服务迭代)必须通过 PRR Gate 的 critical 检查项。Warning 项不阻断但创建整改工单。这个阶段可能会有开发团队的阻力,核心是沟通"门禁不是为了卡你,是为了让你不在凌晨被告警吵醒”。
推行过程中最大的阻力来自"通过率突然下降”——原来人工签字都能过的服务,现在机器检查不过了。我的应对策略是:先把门禁跑在 CI 上,不阻断发布,只出报告,让大家看到问题。跑两周后大家习惯了看到检查项,再开始强制。突然强制和渐进强制的效果差别很大。
与错误预算门禁的关系
PRR Gate 和错误预算门禁不是替代关系,是互补关系。
PRR Gate 检查的是"服务本身的配置和运维准备"——这些是静态的、可预检的。错误预算门禁检查的是"服务当前的可靠性状态"——这些是动态的、基于运行时数据的。
具体来说,一个服务 PRR Gate 全绿(配置没问题)不代表可以发布——如果它的错误预算已经耗尽,说明这个服务当前不稳定,不应该加新变更。反过来,一个服务错误预算充足(当前很稳定)但 PRR Gate 红了(比如删了 PDB),也不应该发布——配置问题是隐患,迟早要出事。
我在某出行项目落地时把两者串联起来:发布前先跑 PRR Gate,通过后再查错误预算。两个门禁都通过才允许发布。这套机制把变更类故障减少了 60%(相关文章:用错误预算门禁替代人工 CAB)。
| 门禁类型 | 检查时机 | 检查内容 | 数据来源 |
|---|---|---|---|
| PRR Gate | 发布前 CI 阶段 | 服务配置是否满足生产标准 | Kubernetes API + Prometheus + Harbor |
| 错误预算门禁 | 发布前 CI 阶段 | 服务当前是否稳定(预算是否耗尽) | Prometheus SLO 指标 |
| 运行时 PRR | 定期(每日) | 生产环境配置是否漂移 | Kubernetes API(对比上次检查) |
运行时 PRR 是第三层——定期扫描生产环境,发现配置漂移。比如某次发布删了 PDB,CI 阶段的 PRR Gate 不会发现(因为是在发布之后改的),但运行时 PRR 会发现并告警。三层门禁形成纵深防御。
总结
生产就绪审查的本质不是填表签字,是回答一个问题:这个服务放到生产环境,出了事能不能救回来。
人工 PRR 的三个致命问题——标准漂移、时机滞后、结果过期——根源在于人工审查是一次性快照,而生产环境持续变化。自动化 7 维门禁把检查从文档变成代码,从一次性变成持续性,从人工判断变成机器可验证。
7 个维度——架构韧性、资源配额、部署安全、可观测性、故障恢复、安全合规、运维归属——覆盖了"服务在生产环境能不能活下来、出了事能不能救"的核心场景。每个维度的检查项都来自真实踩坑,不是教科书上的理论。
推行 PRR Gate 的关键不是技术实现,是策略设计:critical 阻断、warning 不阻断但创建工单、存量不强制但标红。门禁不是为了卡发布,是为了让问题可见、可追踪、可修复。如果你让开发团队觉得门禁是在阻碍他们,他们就会想办法绕过——这是人性,不是态度问题。
最后一句实话:别等炸了才做 PRR。开头痛点里那个 OOM 故障,如果有 RES-004 检查项(内存 P95 超 limit 85% 阻断发布),在 CI 阶段就被拦住了。凌晨一点四十七分的告警,本不该响。
参考资料与致谢
本文在撰写过程中参考了以下资料,感谢原作者的贡献:
- Production Readiness Is Easier to Inspect Than to Debate — Temitayo,系统阐述了将生产就绪从争论转化为可检查条件的方法论,本文的"检查而非讨论"思路受此启发
- Top 8 SRE best practices for reliable delivery in 2026 — N-iX Solutions Architect Rafal Barzycki,2026 年 SRE 最佳实践,覆盖 SLO/错误预算/四金信号/自动化/渐进发布等核心实践,本文可观测性维度和错误预算联动部分参考了其中的方法论
- Beyond Automation: Conducting a Production Readiness Assessment for a CI/CD Pipeline — Gravox,从 CI/CD 管道视角做生产就绪评估的实践,本文部署安全维度的健康检查分层设计参考了此文
- Google SRE Book 第 32 章:SRE 参与模式的演进历程 — Google SRE 团队,定义了 PRR 模型在服务生命周期中的位置,本文的 PRR 定义和"参与越早质量越好"原则源自此章
- Agentic CI/CD: Kubernetes Deployment Gates with Elastic MCP Server — Elastic Observability Labs,阐述了在 CI/CD 中嵌入部署门禁和资源健康检查的实践,本文门禁引擎在 CI/CD 中的位置设计参考了此文