概述

凌晨一点四十七分,手机震了。告警群炸了。

某出行项目新上线的推荐服务,上线不到 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.5warning创建整改工单
内存 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 阶段就被拦住了。凌晨一点四十七分的告警,本不该响。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. Production Readiness Is Easier to Inspect Than to Debate — Temitayo,系统阐述了将生产就绪从争论转化为可检查条件的方法论,本文的"检查而非讨论"思路受此启发
  2. Top 8 SRE best practices for reliable delivery in 2026 — N-iX Solutions Architect Rafal Barzycki,2026 年 SRE 最佳实践,覆盖 SLO/错误预算/四金信号/自动化/渐进发布等核心实践,本文可观测性维度和错误预算联动部分参考了其中的方法论
  3. Beyond Automation: Conducting a Production Readiness Assessment for a CI/CD Pipeline — Gravox,从 CI/CD 管道视角做生产就绪评估的实践,本文部署安全维度的健康检查分层设计参考了此文
  4. Google SRE Book 第 32 章:SRE 参与模式的演进历程 — Google SRE 团队,定义了 PRR 模型在服务生命周期中的位置,本文的 PRR 定义和"参与越早质量越好"原则源自此章
  5. Agentic CI/CD: Kubernetes Deployment Gates with Elastic MCP Server — Elastic Observability Labs,阐述了在 CI/CD 中嵌入部署门禁和资源健康检查的实践,本文门禁引擎在 CI/CD 中的位置设计参考了此文