更新积压、可观测与故障恢复:定位消失的 PR 与停摆机器人
三周没有更新 PR,安静不等于健康
周会上有人说依赖更新机器人“最近很稳定”,因为仓库里没有新 PR。平台同学随后发现,基础镜像已经发布了多次更新,另一个仓库也出现了同一依赖的安全修复。继续追查才看到六种彼此无关的故障叠在一起:一个目录从未被发现,一个 manifest 解析失败,私有源 token 已失效,公共版本源持续超时,分支创建被权限拒绝,已有 PR 又被 CI 卡住。还有一批仓库根本没有任务运行,只是没人为“机器人应该出现”建立心跳。
单看 PR 数量会把这些状态压成同一个零。零可能表示没有候选,也可能表示机器人没运行;可能是策略主动抑制,也可能是 API 限流;甚至可能已有分支但建 PR 失败。可观测性的任务不是给机器人加一块漂亮仪表盘,而是让每个候选在发现、解析、查源、建分支、建 PR、CI 和合并之间留下可关联、可计龄、可恢复的状态。
先定义什么叫一个活着的更新候选
最小观测对象是 candidate_id,它由仓库、基线提交、manager、文件路径、依赖身份和目标版本的规范化摘要生成。一次任务重试沿用同一个候选身份,目标版本变化才产生新候选。这样分支重建、PR 重开和 CI 重跑不会被错误计算成三次更新,旧目标被新目标取代时也能留下 superseded 关系。
候选沿着七个有因果顺序的阶段前进:
“期望更新面”来自仓库盘点,而不是机器人自己报告的发现结果。否则遗漏的 Dockerfile、Actions workflow、Terraform provider 或 Helm Chart 从一开始就不在分母里,覆盖率会永远是百分之百。盘点记录只保存文件类型、规范化路径摘要、owner 和期望 manager;真实私有包名与内部路径不进入公共指标标签。
每次阶段变化写一条追加事件,至少包含 candidate_id、run_id、stage、outcome、reason_code、observed_at、attempt、config_digest、bot_identity 和 evidence_uri。事件只增不改;当前状态由最后一个合法事件投影得到。这样控制器崩溃后可以从事件重建状态,也能判断候选是在版本源前等待,还是已经进入 CI。reason_code 使用受控枚举,原始 HTTP 状态、请求 ID、check run ID 和脱敏错误摘要放在证据对象里;不要把整段日志塞进指标标签。
启用日志、权限与心跳
托管 Dependabot 不需要安装本地服务,但需要在仓库设置启用相应能力,并把 .github/dependabot.yml 放到默认分支。版本更新任务的 recent jobs 会提供任务类型、任务 ID、关联 PR、错误摘要和完整日志,入口与可见边界见 Dependabot job logs。读取这些证据的身份只需对应仓库的受控访问;集中采集器不要获得组织级代码写权限,也不要把日志原文转存到低权限观测平台。
Dependabot 还可能因长期无人处理更新 PR 而暂停创建或 rebase;平台会在 PR、仓库设置或告警界面给出暂停信号,重新发生维护者交互后恢复,具体状态以 Dependabot updates stopped 为准。不要把平台的动态停摆阈值复制进内部告警规则;直接采集 paused 状态、最近用户交互和最近成功任务即可。
自托管 Renovate 必须额外部署调度器或 CI job。仓库内 schedule 只决定运行中的 Renovate 是否允许处理某类更新,不能启动进程;全局调度与仓库窗口是两层条件,官方解释见 Renovate scheduling。因此心跳至少分成 scheduler_started、repository_started 和 repository_finished:全局 job 没启动、仓库未被枚举、仓库运行中崩溃会留下不同缺口。
排障时自托管 Renovate 可设置 LOG_LEVEL=debug,托管 App 则从关联入口查看仓库 job log;官方建议与日志位置见 Renovate troubleshooting。debug 日志只在短时诊断窗口开启。日志聚合前对 authorization header、registry URL 查询参数、内部包名和仓库路径做脱敏;用于关联的 run_id 和 candidate_id 可以保留,token、Cookie 与包管理器配置原文不能保留。
把每一段转成能解释的指标
发现覆盖率写成 discovered_expected_surfaces / expected_surfaces,分母来自独立盘点。解析成功率是 parsed_surfaces / discovered_surfaces;查源成功率是 lookup_succeeded / lookup_attempted;分支转化率是 branch_created / actionable_candidates;PR 转化率是 pr_created / branch_ready;CI 成功率是 ci_passed / ci_completed;合并吞吐是每个观察窗口内进入 merged 的候选数。这些比例要与各阶段的等待年龄一起看,不能只看成功率。
阶段年龄 stage_age = now - entered_stage_at 比 PR 总年龄更有诊断价值。候选在 lookup 停很久与 PR 在 review 停同样久需要完全不同的 owner。等待年龄按业务域、生态、阶段和原因枚举聚合成 histogram;仓库、依赖与 PR 编号留在事件存储,通过 evidence_uri 下钻。把每个包名作为 Prometheus label 会同时制造高基数、成本失控和私有依赖泄漏。
每个阶段分别制定等待预算,而不是给所有候选一个总 SLA。discover/parse 超龄通常由仓库 owner 处理,lookup 超龄交给平台或版本源 owner,branch/pr 超龄检查写权限与平台配额,ci 超龄再拆成排队、基础设施、测试和检查契约,review 超龄才进入代码 owner 队列。告警同时带当前阶段、进入时间、预算、原因和下一动作;只报“PR 已老化”会把恢复责任重新丢回人工猜测。
积压不是所有 open PR 的数量,而是每个阶段尚未完成且没有被合法抑制的候选集合。ignored、scheduled_later、minimum_age_wait、no_update 和 superseded 都要有独立终态或等待原因;否则主动策略会被误报成故障。被 ignore 的安全候选还必须关联有期限的例外,不能从积压分母中永久消失。
一个适合 Prometheus 文本出口的低基数模型如下;数值只是展示格式,阈值要由仓库基线、更新窗口与 CI 容量决定:
dependency_update_runs_total{bot="renovate",outcome="success"} 1
dependency_update_stage_items{stage="lookup",outcome="blocked",reason="rate_limit"} 2
dependency_update_stage_age_seconds_bucket{stage="ci",le="3600"} 7
dependency_update_expected_surfaces{ecosystem="container"} 12
dependency_update_discovered_surfaces{ecosystem="container"} 11
dependency_update_last_success_unixtime{bot="renovate",scope="business-domain"} <timestamp>
dependency_update_api_remaining{provider="github",resource="core"} <remaining>last_success_unixtime 需要与“本窗口是否应运行”结合。如果仓库窗口关闭,年龄增长是预期;如果全局 scheduler 已运行且窗口开放,仓库仍无 start/finish,才进入停摆。api_remaining 不设置固定通用告警值,而是结合下一批预计请求量、reset 时间和队列长度判断能否完成当前轮次。
用可运行模型区分六类“没有 PR”
下面的 Python 3 标准库实验读取合成事件,不访问 GitHub、Registry 或 CI。正向路径让一个候选走完整链;反向路径分别制造配置遗漏、凭据错误、版本源故障、限流、CI 失败和机器人停摆。把代码保存为临时目录中的 backlog_lab.py。
import argparse
import json
STAGES = ["discover", "parse", "lookup", "branch", "pr", "ci", "merge"]
def classify(item, now):
events = item.get("events", [])
if not events:
if item.get("run_expected") and now > item["expected_by"]:
return "BOT_STOPPED"
return "NOT_DUE"
last = events[-1]
if last["outcome"] == "success":
if last["stage"] == "merge":
return "HEALTHY"
return "WAITING_" + STAGES[STAGES.index(last["stage"]) + 1].upper()
reason = last["reason"]
if last["stage"] == "discover" and reason in {"no_match", "config_invalid"}:
return "CONFIG"
if last["stage"] == "lookup" and reason in {"unauthorized", "token_expired"}:
return "AUTHENTICATION"
if last["stage"] == "lookup" and reason in {"forbidden", "scope_denied"}:
return "AUTHORIZATION"
if last["stage"] == "lookup" and reason in {"primary_rate_limit",
"secondary_rate_limit"}:
return "RATE_LIMIT"
if last["stage"] == "lookup" and reason in {"timeout", "dns", "server_error"}:
return "VERSION_SOURCE"
if last["stage"] == "ci" and reason in {"runner_queue", "runner_offline"}:
return "CI_CAPACITY"
if last["stage"] == "ci" and reason in {"service_error", "network_error"}:
return "CI_INFRASTRUCTURE"
if last["stage"] == "ci" and reason in {"check_missing", "check_renamed"}:
return "CI_CONTRACT"
if last["stage"] == "ci" and reason == "test_failure":
return "CI_TEST"
return "AUTOMATION"
def event(stage, outcome="success", reason="none", at=0):
return {"stage": stage, "outcome": outcome, "reason": reason, "at": at}
def cases(mode):
if mode == "good":
return [{"id": "candidate-ok", "events":
[event(stage, at=index + 1) for index, stage in enumerate(STAGES)]}]
return [
{"id": "surface-missed", "events": [event("discover", "failed", "no_match")]},
{"id": "private-source", "events": [event("discover"), event("parse"),
event("lookup", "failed", "unauthorized")]},
{"id": "source-down", "events": [event("discover"), event("parse"),
event("lookup", "failed", "timeout")]},
{"id": "scope-denied", "events": [event("discover"), event("parse"),
event("lookup", "failed", "forbidden")]},
{"id": "api-budget", "events": [event("discover"), event("parse"),
event("lookup", "failed", "primary_rate_limit")]},
{"id": "tests-red", "events": [event("discover"), event("parse"),
event("lookup"), event("branch"), event("pr"),
event("ci", "failed", "test_failure")]},
{"id": "runner-wait", "events": [event("discover"), event("parse"),
event("lookup"), event("branch"), event("pr"),
event("ci", "failed", "runner_queue")]},
{"id": "ci-down", "events": [event("discover"), event("parse"),
event("lookup"), event("branch"), event("pr"),
event("ci", "failed", "service_error")]},
{"id": "check-gone", "events": [event("discover"), event("parse"),
event("lookup"), event("branch"), event("pr"),
event("ci", "failed", "check_missing")]},
{"id": "no-heartbeat", "events": [], "run_expected": True, "expected_by": 50},
]
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("mode", choices=["good", "bad"])
args = parser.parse_args()
result = [{"id": item["id"], "diagnosis": classify(item, now=100)}
for item in cases(args.mode)]
counts = {}
for row in result:
counts[row["diagnosis"]] = counts.get(row["diagnosis"], 0) + 1
print(json.dumps({"items": result, "counts": counts}, sort_keys=True))先运行完整链:
python backlog_lab.py good预期 candidate-ok 的诊断为 HEALTHY。这不是说真实平台健康,而是证明七个阶段事件能还原一条完成链。
再运行故障链:
python backlog_lab.py bad预期 counts 中分别出现 CONFIG、AUTHENTICATION、AUTHORIZATION、VERSION_SOURCE、RATE_LIMIT、CI_TEST、CI_CAPACITY、CI_INFRASTRUCTURE、CI_CONTRACT 和 BOT_STOPPED,每类数量为一。反向实验的关键不是输出一串名字,而是证明分类使用了不同的第一证据:配置故障停在发现;认证、授权、限流和版本源都停在查源但 reason 不同;CI 已经拥有 PR,却要由测试 owner、Runner owner、CI 平台 owner 或保护规则 owner 接手;停摆则连 run heartbeat 都没有。生产分类器还应为 parse、branch 和 PR 平台写入错误增加独立原因,不要把未知错误强行归入最近似的类别。
从症状回到第一份可靠证据
发现覆盖率下降时,比较独立更新面盘点与机器人 extracted files。路径重命名、monorepo 新目录、禁用 manager、错误 glob 和默认分支配置都可能让文件消失。Renovate 可用 dryRun: "extract" 快速输出抽取结果,lookup 模式再确认候选查询;模式语义见 Renovate dry run。修复后不要只看配置校验通过,要看到同一个 surface 摘要产生 parse 事件。
解析失败通常已经读到文件,但无法建立当前版本或锁文件模型。证据是 manager、文件摘要、包管理器 stderr 和运行时版本。清空缓存可能暂时改变现象,却会破坏原始证据;先在隔离工作区用与机器人相同的运行时和包管理器复现,再修语法、锁文件冲突或工具版本。再验证要同时看到 parse=success 与解析出的当前身份,不能只看到进程退出码为零。
查源失败要先按响应类型分流。401 通常表示身份无法认证,例如 token 缺失、过期或发给了错误主机;403 还要结合平台错误体和授权范围,区分身份已认证但 scope、仓库安装范围、组织策略或 SSO 不允许。前者轮换或重新注入凭据,后者修授权边界;两者都用不输出 secret 的元数据请求验证,不能一律靠换 token。429、Retry-After、remaining 归零或平台明确的 secondary rate limit 指向不同限流证据,应该暂停、排队并等待服务端时钟,不应并发重试。GitHub 建议优先读取 retry-after、x-ratelimit-remaining 和 x-ratelimit-reset,并在持续二级限流时指数退避,见 REST API 使用最佳实践。DNS、TLS、timeout 与 5xx 才归为版本源或网络故障;它们的恢复验证是同一 datasource 查询成功,而不是 GitHub API 恢复。
分支阶段失败常见于平台 token 没有 contents 写权限、基础分支改变、旧分支冲突、提交签名策略或 bot 身份被停用。PR 阶段失败则可能是 pull request 写权限、已有重复 PR、平台校验、分支保护或 API 写请求限流。两者不能只统计成 create_pr_failed:分支已经生成时,diff 是可恢复资产;尚未生成分支时,必须重新运行包管理器并重新证明供应链差异。
CI 失败意味着发现、解析、查源、分支和 PR 大概率已经工作。先按同一候选 SHA 上的 Required Check 区分测试断言失败、Runner 排队或离线、CI 服务/网络故障、检查未上报或改名。测试失败修代码或拆分兼容单元,保留首次失败 run;Runner 排队按等待年龄和容量预算扩容或降更新并发;基础设施失败可在同一 SHA 上有界重试;检查名变化导致永久 pending 时要修分支保护契约。取消失败 run、重跑成绿色和关闭 PR 都不能删除首次证据,关闭也只改变展示状态,候选仍未完成。
机器人停摆没有候选错误可查。证据从外向内取得:调度器心跳是否存在,仓库枚举是否包含目标,仓库运行是否开始,finish 是否带终态,平台 App 是否仍安装,身份是否可换取 token。若只有“最近成功时间”,一次长时间无更新的仓库会误报;要发送无候选也成功的 repository_finished{outcome="no_update"},证明机器人确实看过。
限流、并发与成本是同一个容量问题
更新机器人会同时消耗平台 API、版本源、包管理器下载、Git 分支写入、CI Runner 和人工 review。只限制 PR 数不能保护前四段;大量仓库在同一时刻 lookup,即使最终没有 PR,也可能耗尽 API 和 Registry 配额。自托管调度器应按 host 建立令牌桶与公平队列,读取服务端 reset/retry 信号,把安全候选、常规候选和重试候选分开排队。
容量模型至少估算每轮仓库数、每仓库 surface 与候选数、平均 lookup 请求、缓存命中、分支生成 CPU/磁盘、PR 写请求、每 PR 的 CI 分钟与人工审查时间。队列增长时先找瓶颈阶段:lookup 年龄增长不能靠加 Runner,CI 年龄增长也不能靠提高 API 配额。增加并发前观察错误率、retry-after、Registry 延迟、Runner 排队和本地临时磁盘。
缓存能降低版本源成本,但缓存键必须包含 datasource、registry 主机、认证域和查询参数;不能让一个租户的私有包结果泄漏给另一个租户。负缓存要有短 TTL,并在凭据轮换后失效,否则一次 401 会被缓存成长期“没有版本”。调试缓存与克隆目录应设置容量水位和清理顺序,先删可重建的旧工作区,再删版本元数据缓存,最后才考虑牺牲当前运行证据。
恢复一次故障,要从停点继续而不是全部重放
恢复控制器读取候选最后一个成功阶段和失败 reason。凭据修复后从 lookup 重试,不需要重新发现整个组织;CI 基础设施恢复后重跑检查,不要重新生成同一分支;PR API 限流解除后复用已有 branch SHA 创建 PR。每个副作用使用幂等键,例如 candidate_id + action + target_sha,平台返回“已存在”时读取并关联既有对象,不重复创建。
重试必须有边界。认证错误在凭据版本未变化时不自动重试;配置错误在 config_digest 未变化时不重试;限流按服务端时钟恢复;版本源 5xx 指数退避并进入熔断;CI 基础设施故障按 run attempt 有界重试;确定性测试失败交给代码 owner。无差别每分钟重试会放大供应商故障、消耗配额并淹没有效日志。
修复再验证沿原路径前进一步:配置修复必须出现 discover/parse;凭据修复必须出现 lookup success;版本源恢复必须返回候选或明确 no-update;平台权限修复必须关联 branch/PR ID;CI 修复必须产生同一 SHA 的完整必需检查;停摆恢复必须重新出现 scheduler、repository start 和 finish 三段心跳。只看到错误日志消失不算恢复。
迁移和退出要故意演练“双机器人”故障
从 Dependabot 迁到 Renovate,或从托管 Renovate 迁到自托管实例时,先冻结旧控制器的新候选,导出开放 PR、ignore、分支前缀、仓库覆盖和最近成功任务,再让新控制器以 dry run 重建同一更新面。对比的不是 PR 标题,而是 surface 覆盖、当前解析身份、目标候选、策略结果和 owner。差异得到裁决后,新控制器只在小批仓库写入,观察重复 PR、分支碰撞、CI 放大和 API 消耗。
退出 Renovate 时,可以先提交根级 enabled: false,让它再次运行并执行一次性清理,关闭自己创建的 issue/PR 并删除分支;行为见 Renovate enabled。先撤销 App 或 token 会让清理永远无法执行。清理结果要与导出的开放对象核对,确认人工接管的应急 PR 没有被误删,然后再撤销身份、Webhook、调度任务、缓存和日志出口。
退出 Dependabot 时,删除 .github/dependabot.yml 只停止版本更新;安全更新与 alerts 是独立设置,官方的停用入口见 配置 Dependabot version updates 和 配置 Dependabot security updates。迁移若仍要保留安全发现,可以保留 alerts、关闭旧安全 PR 入口,并证明新链路能消费同一结论,不能为了避免重复 PR 顺手关闭发现能力。
退出演练的反向场景是在同一测试仓库短暂开启两套写控制器:给它们不同分支前缀,故意让它们发现同一候选,验证去重器能按 manifest、依赖、目标版本和 base SHA 识别冲突。仓库级单写者租约记录 controller_id、config_digest、获取时间和到期时间;没有租约的控制器只能 dry run,不能 rebase、关闭或重建另一方 PR。租约切换前冻结新写入,等待旧运行到终态或逐项转交,再由新控制器从已确认 base SHA 接管。若出现双方反复 rebase、关闭或重建,立即撤销新控制器写权限,保留两边事件与平台审计日志,修正接管规则后再演练。
退出验收不以“旧机器人不再开 PR”为终点。逐项撤销旧 GitHub App 安装或仓库选择、PAT/Deploy Key、Registry token、Webhook secret、CI 变量、云端角色和缓存访问凭据,并用旧身份执行无副作用探针,确认已得到预期的认证或授权拒绝;探针只记录凭据版本与响应类别,不记录 secret。随后确认旧 scheduler 无心跳、旧 Webhook 无投递、旧分支前缀无非终态对象、计费 Runner/缓存/日志出口已释放,同时新控制器对同一更新面仍能完成一次 discover、lookup 和受控写入。
清理实验、观测数据与遗留身份
本地实验只产生脚本本身,可直接删除:
rm backlog_lab.py
# PowerShell: Remove-Item backlog_lab.py生产故障结束后,先等待当前 run 写出可恢复终态,再停止调度;导出仍在非终态的 candidate 与 evidence URI;清理旧克隆、临时锁文件和过期缓存;撤销临时 debug、额外 API scope 和诊断网络规则;最后按保留策略压缩事件与指标。不要先删工作目录再调查 branch 生成失败,也不要长期保存含私有依赖图的 debug 日志。
长期保留低敏感的状态事件、配置摘要、候选摘要、阶段时间、原因枚举、分支/PR/CI 外部 ID、恢复动作和退出核对结果。完整日志、manifest 原文、私有 Registry 地址、包名和响应体缩短保留并严格授权。指标平台只接收聚合状态;需要逐候选排障时跳回受控证据存储。
团队治理的核心是让每种停点有 owner:仓库 owner 维护更新面和配置,平台 owner 维护调度、身份与 API 容量,依赖 owner 处理解析与兼容,CI owner 处理 Runner 和检查契约,安全 owner 处理有期限例外。每个观察窗口至少运行一次合成 canary:已知可发现、可查源、可建分支且能通过轻量 CI 的虚拟依赖。如果 canary 没有走完全链,即使真实仓库暂时没有候选,也要按停摆处置。
健康的依赖更新系统不承诺“永远没有积压”。它能证明每个期望 surface 被看见,每个候选停在哪里、为什么停、由谁恢复,恢复后从哪个检查点继续;迁移时不会双写,退出后不遗留 App、token、Webhook、分支和计费资源。做到这些,PR 数量才从一个容易误读的结果,变成完整状态机中可解释的一站。
