自定义 HMAC 格式按顺序连接 event_id、点号、timestamp、点号及不变的原始正文字节。

Webhooks 推送事件而非轮询

Webhook 让提供方在状态变化时通知已注册的接收端。它改变了谁主动发起 HTTP 请求,但单凭这个方向,不能保证必达、即时、有序或只处理一次。真正的运维契约来自提供方的投递规则,以及接收方如何确认和保存工作。理解这层区别,才能知道故障后应该重投、补查还是人工处理。 投递策略由提供方定义 不同服务并不相同。GitHub 不会自动重新投递失败的 webhook,需要合适的重投或对账流程。Stripe 在线上模式下会按指数退避重试最长三天,沙箱的规则则不同。两者都不意味着接收端可以无限期离线而不丢工作。应记录真实重试窗口,以及投递耗尽后由谁发现、如何补回,不能仅凭“用了 webhook”就宣称至少一次保证已经成立。 事件也未必包含完整的当前对象。载荷可能是历史快照,或只提供需要另行查询的标识。事件可能乱序,不同事件编号也可能涉及同一个业务变化。执行不可逆操作之前,应决定是直接应用事件、获取当前状态,还是核对版本后再处理。投递去重与业务操作幂等解决相关但不同的问题,保存一个事件编号不自动等于发货只能发生一次。 先认证、可靠保存,再确认 验签必须遵循提供方的准确格式;要求原始正文时,就使用收到的原始字节。把 JSON 解析后重新编码,可能改变空白或字段顺序,让真实签名失效。TLS 保护到端点的传输,普通服务端认证的 HTTPS 并不证明请求一定来自指定提供方。签名密钥不能写入日志,比较应采用适当方式,轮换也必须遵守提供方流程。 时间戳保护同样是特定协议能力。Stripe 会签入投递时间戳,其库可以检查容差;GitHub 文档定义的正文 HMAC 没有同样的已签名时间戳,因此不能要求所有服务都提供对应头。一次重试可能生成新的投递签名,却仍然代表同一个旧事件。新鲜度按协议检查,重复性按稳定事件身份判断,两者不能相互替代。 新工作返回成功之前,应先可靠地保存到队列或事务性收件表。如果先确认再入队,中间崩溃就会造成发送方停止重试、接收方却丢掉事件的空窗。请求路径要短,但不能为追求短而跳过认证或可靠交接。对于已经保存的合法重复事件,可以返回提供方认可的成功响应,并避免重复副作用;它不应该仅因重复就被当作恶意请求。 坏掉的样子 内存里的已见集合会在重启后消失,也不能协调多个 worker。先记录编号再执行副作用,如果没有可恢复的待处理状态,可能丢工作;先执行副作用再记编号,则可能在崩溃后重复。能使用数据库唯一约束和事务的地方应使用它们,下游操作也需要稳定操作标识支持安全重试。一笔数据库事务本身,不能原子覆盖无关外部系统的付款或发货请求。 密钥轮换只更新部分验签实例,会让真请求间歇性失败。只在计划中的过渡期允许新旧密钥重叠,监控拒绝原因,并按政策移除退役密钥。还要限制正文大小和队列增长。事件虽然都快速收到二百响应,却一直积压不处理,同样是业务故障;投递健康与业务完成必须分别观察,不能让好看的 HTTP 成功率掩盖停滞。 Webhook 签名投递:机制与证据边界。 快速验证 保存为 webhook_verify_demo.py,运行 python3 webhook_verify_demo.py。程序使用明确自定义的 HMAC 格式、公开测试密钥和固定时钟,只依赖标准库与内存状态。它不会连接支付服务,也不是可以直接上线的接收器。这里故意把签名、时效与重复判定的分支隔离,方便检查每一步实际证明了什么。 import hashlib import hmac import re SECRET = b"public-demo-key-not-a-real-credential" OLD_SECRET = b"public-retired-demo-key" TOLERANCE_SECONDS = 300 seen_ids = set() def sign(event_id, timestamp, payload, key=SECRET): message = f"{event_id}.{timestamp}.".encode("ascii") + payload return hmac.new(key, message, hashlib.sha256).hexdigest() def accept(event_id, timestamp, payload, signature, now): # This is an invented wire format, not a Stripe or GitHub verifier. if not re.fullmatch(r"evt_[0-9]+", event_id): return "rejected" if abs(now - timestamp) > TOLERANCE_SECONDS: return "rejected" if not re.fullmatch(r"[0-9a-f]{64}", signature): return "rejected" expected = sign(event_id, timestamp, payload) if not hmac.compare_digest(expected, signature): return "rejected" if event_id in seen_ids: return "duplicate" seen_ids.add(event_id) # Process-local demonstration, NOT durable acceptance. return "accepted" now = 1_800_000_000 payload = b'{"type":"order.paid","order":"42"}' fresh = sign("evt_1", now, payload) assert accept("evt_1", now, payload, fresh, now) == "accepted" assert accept("evt_1", now, payload, fresh, now) == "duplicate" # Authenticate even a claimed duplicate before calling it harmless. assert accept("evt_1", now, payload, "0" * 64, now) == "rejected" # Change only the body: id and timestamp still match the signed message. second = sign("evt_2", now, payload) assert accept("evt_2", now, payload + b" ", second, now) == "rejected" # Change only the id, leaving body and timestamp intact. assert accept("evt_3", now, payload, second, now) == "rejected" for timestamp in (now - 301, now + 301): assert accept("evt_4", timestamp, payload, sign("evt_4", timestamp, payload), now) == "rejected" assert accept("evt_5", now, payload, sign("evt_5", now, payload, OLD_SECRET), now) == "rejected" assert accept("evt_6", now, payload, "not-a-digest", now) == "rejected" assert seen_ids == {"evt_1"} assert accept("evt_2", now, payload, second, now) == "accepted" assert seen_ids == {"evt_1", "evt_2"} print("PASS: authenticated duplicate, independent body/id tampering, stale/future, retired key, malformed digest") 2026-10-19,两份完整中英文围栏均在 Python 3.12.14 下原样运行,断言启用,退出码为零,打印上述 PASS 行且标准错误为空。不要使用 -O 或 PYTHONOPTIMIZE;否则 accept 调用被省略,却仍打印 PASS。原样围栏覆盖真实重复、伪造重复、分别篡改正文与编号、正负三百零一秒、退役密钥及格式错误摘要。另行补充的测试接受包含边界的正负三百秒,并拒绝七种非法事件编号;这些补充用例不在上面的围栏里。每种语言另有五份故意破坏签名、正文、编号、新鲜度或去重保护的变体,均在打印 PASS 前触发断言失败。这些负对照说明测试会发现指定破坏,并不是完整安全证明。 ...

十一月 18, 2027 · 2 分钟 · XB Field Notes
同一个元数据通过框内并列具体金丝雀门禁行动与空泛的更加小心行动;两者均通过,但字段齐全不能证明因果学习成立。

复盘是为了学习,不是追责

事件结束后,复盘应解释发生了什么、当事人在当时知道什么,以及哪些改变可能减少下一次伤害。不追责让坦诚报告更安全,但不保证故障绝不复发,也不取消行动责任,更不会把每一种解释自动变成事实。学习同时需要可信的经过记录,以及有用的后续工作真正发生的证据,不能只用会议气氛或文档长度评价成果。 重建当时的决策上下文 结果已知之后,早先的选择很容易显得显然错误。应询问当时有哪些信号、文档、时间压力和可用替代方案。相关操作应如实进入时间线,但如果分析在某个人的名字上停止,就没有继续解释系统中的哪些条件让这个动作显得合理,或者让它更容易发生。理解条件不是删掉人的行动,而是把行动放回其真实背景。 时间线是依据证据重建的经过,不是永远不可修改的记录。保留日志与沟通来源,区分直接观察和推断,对不确定时间戳、互相冲突的说法明确标注;获得更强证据时,也应修正原有解释。时间线很短,可以提示上下文不足,却不能证明有人故意隐瞒。未经证实便指责作者过滤记录,会重新制造复盘本来试图消除的责备。 把影响、条件和行动连接起来 先说明客户受到什么影响,以及影响如何测量。检测、缓解和恢复应分别记录:回滚命令执行完毕,不等于同一时刻客户流量已经恢复。描述触发变更的同时,列出让错误扩大或持续的促成条件,例如缺少容量限制,或某条依赖让局部问题传播到其他系统。仍然没有证据的问题可以保留为待调查项,不必强行宣布唯一根因。 从这些条件出发,选择少量且有优先级的行动。行动可以预防重现、改善检测、限制影响,或让恢复过程更安全。并非每个促成因素都必须对应一次生产变更;应调查不确定性,并记录明确接受的取舍。如果运行手册修订或培训演练确实针对具体失效方式,它们同样可能有价值,不能因为没有直接修改生产就把它们一律判成无效愿望。 每条已经接受的行动应有一名协调完成的负责人、目标日期、跟踪引用和可验证结果。明确负责人不等于把事故归咎于他。验证条件应描述可观察的结果,例如受控测试确实阻止不安全发布,而不是仅写已经创建文档。若验证需要另一团队协作,也应把依赖和验收方式写清楚,避免名义上有负责人,实际却无人能够推动完成。 跟进不等于制造确定性 按风险与交付时间安排复查。一周后检查可能有用,但不存在适用于所有事故的统一七天期限。应核对变更是否落地、验证是否通过,以及是否引入新的问题。如果方案失败,任务应继续保持开放并更新计划,不能因为又开了一次会,就宣布问题已经闭环。明确延期和重新排序,也比让行动在清单中悄悄消失更诚实。 即使前次行动全部完成,同类事故再次出现时仍值得调查。原来的解释可能不完整,措施可能只减少影响而不能消除发生机会,运行条件也可能已经改变。因此应同时比较影响和促成因素,不只比较事故数量。报告变少本身不能证明可靠性提高,尤其当上报激励发生变化时;诚实报告增加也不应被直接当作团队表现变差。 坏掉的样子 模糊任务可以在每次会议后继续存在,因为没有人能够判断它到底算不算完成。过大的任务则可能长期开放,因为优先级和人员投入始终没有匹配承诺。接受工作时就应审阅可行性,并记录主动推迟的理由,不能让日期不断后移却没有任何决策依据。 检查表也可能取代真正学习。字段填写齐全,不证明因果解释可靠、提议的变更安全,或者验证方式可信。广泛分享的复盘不应包含客户敏感信息和凭据;必要证据应保留在权限合适的位置,而不是为了显得完整,把所有原始材料复制进叙述。共享经验与共享每一个细节是不同决定,应根据读者需要控制范围。 字段齐全,行动仍可能空泛。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 保存以下内容为 postmortem_check.py,运行 python3 postmortem_check.py。程序使用 Python 3.7 或更新版本,只用标准库检查三份合成记录及额外的日期反例,不接触外部服务。运行时不要使用 -O、-OO 或非零的 PYTHONOPTIMIZE,以免断言被跳过。接受的输入结构是包含行动字典列表的字典;这是本地元数据练习,不是面向任意不可信文档的通用验证器,也没有判断事故的真实原因。 from copy import deepcopy from datetime import date import re def check_review(doc): errors = [] for key in ("timeline", "causes", "actions"): if not doc.get(key): errors.append(f"missing {key}") for i, action in enumerate(doc.get("actions", [])): for field in ("task", "owner", "proof"): value = action.get(field) if not isinstance(value, str) or not value.strip(): errors.append(f"action {i} missing {field}") due = action.get("due") try: if not isinstance(due, str) or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", due): raise ValueError("date format") date.fromisoformat(due) except ValueError: errors.append(f"action {i} invalid due date") return errors GOOD = { "timeline": ["00:01 alert fired", "00:07 rollback completed"], "causes": {"trigger": "deployment", "conditions": ["no canary gate"]}, "actions": [{"task": "add a canary gate", "owner": "ana", "due": "2026-10-22", "proof": "staging test blocks a bad release"}], } BAD = deepcopy(GOOD) BAD["actions"][0].update(owner=None, due="soon", proof="") SHALLOW = deepcopy(GOOD) SHALLOW["actions"][0].update(task="be more careful", proof="manager agrees") assert check_review(GOOD) == [] assert check_review(BAD) == ["action 0 missing owner", "action 0 missing proof", "action 0 invalid due date"] assert check_review(SHALLOW) == [] # Metadata completeness cannot judge quality. for invalid in ("2026-02-30", "20261022", None): candidate = deepcopy(GOOD) candidate["actions"][0]["due"] = invalid assert check_review(candidate) == ["action 0 invalid due date"] print("PASS: complete metadata accepted; missing owner/proof and invalid dates rejected") print("LIMIT: a vague action still passes; causal review requires human judgment") 完整记录通过。空负责人、空验证条件、含糊期限、不存在的日期、紧凑日期和空日期都被拒绝。关键是“更加小心”这条模糊行动依然通过,因为其字段已经填满。最后一行 LIMIT 明确暴露这个刻意保留的边界:元数据校验可以辅助审阅,却不能证明学习已经发生,更不能保证下一次事故不会重来。人仍需结合证据判断行动是否真正改变了导致伤害的条件。 ...

三月 5, 2027 · 2 分钟 · XB Field Notes
实时时间线记录告警、时间线记录员、错误率仍为百分之八的回滚,以及切换决定。预计十点二十分见效的节点为空心并用虚线连接,因为它不是已观察到的恢复。

事件响应需要一条时间线

事件进行时,记忆不可靠,机器时钟也可能存在偏差。一条时间线——谁被叫了、观察到了什么、尝试了什么、变更何时落地——是让一次响应可以被记录、并稍后可审阅的东西。 时间与角色来自仅作阅读的原文样例。共用日期与协调世界时偏移是图示缩写,底层记录仍需完整时间戳。纵向间距不代表实际耗时。预期见效并未画成已证实修复;本次没有运行校验器或事件系统。 边发生边记录 事件现场里,记忆是最不可靠的记录者,所以要在报警刚响起时就开始记。写下首次告警的时间、最初的症状、每个尝试过的动作以及它对应的可观察结果,还有修复真正生效的那一刻。这份如实记录,既让团队在事件中间不会重复试一个已经失败的修复,也让事后能写出一份诚实复盘的素材。把时间线当做从告警响起的实时文档这习惯很重要:记下触发告警的指标、怀疑的原因与后来的准确实因,并写下「现象依旧,未缓解」,而不是默默再试一遍。 把沟通与执行分开 事件扩大后,排错与同步信息会争夺注意力。明确运维与沟通职责:一个人执行操作,另一个人维护时间线,持续同步状态、影响范围和未解决的问题。这第二个角色,正是为了防止「所有人都在抢修、没人留痕」的困局。共享时间线还能缩短接手成本:后来加入的人扫一眼记录就知道进行到哪里,而不是打断每个人、听一段带着情绪的重述。事件发生后及时更新,并附上相关日志与决策的证据链接,降低交接时反复查找材料的成本。 学习需要这份记录 靠记忆写的复盘,只是一个看似合理的说法;靠时间线写的,则是一份能逐条复核的事实重建。正因为时间线把每个动作钉在具体的分钟上,复盘才更容易聚焦系统缺口而非个人责任:哪个控制项当时不存在、哪本 runbook 写得不全、哪件事迟迟没有负责人。这才是从第一秒就开始记录的意义所在。尽可能从首次告警开始记录;如果开始较晚,应把回填项明确标记出来。只要来源可靠,不完整的记录仍有价值,不能为了叙述顺畅,就把未知空白补成未经证实的确定结论。 坏掉的样子 实时时间线是在事件还在进行时坏掉的,而不是在事后复盘时写坏的,这正是它要与复盘文章区分开的地方。第一种坏法是时间戳没有时区:一个人用笔记本本地时间写下十点零二分,告警流水线却用协调世界时记录同样时刻,相同钟点可能代表不同瞬间。应保留日期、偏移、时钟来源和已知偏差;即使时钟已经同步,时间戳本身也不能证明因果关系。 第二种坏法是第二天早晨凭记忆补记。团队靠回忆重建记录,只保留最后生效的修复,删掉两次没有缓解的重试,还把顺序整理成通顺的故事。这个版本恰恰丢掉接手者最需要的东西:已经试过什么,每次尝试带来什么可观察变化。补记越圆滑,失真越严重,重复试错几乎不可避免。 第三种坏法是并行操作缺乏协调。即使编辑器完整保留每行,两名响应者也可能同时声称负责回滚。应明确变更负责人,为事件分配稳定标识,保留原始记录顺序,并标记迟到的补记。按事件时间生成的展示视图,不应悄悄改写底层证据;相同时间戳也可能来自合法的并行动作。 第四种坏法是关键决策被聊天记录淹没。切换机房的决定藏在两百条表情与插话深处,有人说了同意,却没人把决策内容、负责人与预期效果抄进时间线。一小时后谁决定了什么、谁来执行、何时见效,全都说不清楚。与复盘篇关注事后如何学习不同,这里讲的都是响应进行中的记录失效。 快速验证 把下面代码存为 timeline_check.py,在空目录运行 python3 timeline_check.py。它只用标准库,在内存里构造一好一坏两条实时时间线,不写任何文件,检查每条记录必备的三个字段:t、actor 与 action,并校验时间戳是否可解析、是否带时区、是否按时间递增。 """Live incident timeline validator: each event needs t/actor/action, ordered by time.""" from datetime import datetime def parse_time(raw): text = str(raw or "").strip().replace("Z", "+00:00") # Require a full timestamp with date and timezone, not a bare wall-clock time. if "T" not in text or not text: raise ValueError(f"bad timestamp {raw!r}: use ISO-8601 with date and timezone") try: stamp = datetime.fromisoformat(text) except ValueError: raise ValueError(f"bad timestamp {raw!r}: use ISO-8601 with date and timezone") from None if stamp.tzinfo is None: raise ValueError(f"bad timestamp {raw!r}: missing timezone") return stamp def check_timeline(events): errors = [] seen = [] for i, ev in enumerate(events): for field in ("t", "actor", "action"): if not isinstance(ev.get(field), str) or not ev[field].strip(): errors.append(f"event {i} missing {field} or not a nonempty string") try: seen.append(parse_time(ev.get("t", ""))) except ValueError as exc: errors.append(f"event {i} {exc}") seen.append(None) stamps = [s for s in seen if s is not None] # Only judge ordering over entries that parsed, so one bad clock # does not hide a second sequencing mistake. ordered = sorted(stamps) if stamps != ordered: errors.append("events out of order: review clock sources and late entries") return errors GOOD = [ {"t": "2027-03-01T10:01:00+00:00", "actor": "pager", "action": "alert fired: error-rate burn"}, {"t": "2027-03-01T10:04:00+00:00", "actor": "ana", "action": "took timeline keeper seat, opened shared doc"}, {"t": "2027-03-01T10:09:00+00:00", "actor": "lee", "action": "rolled back deploy 1a2b3c, error-rate still 8%"}, {"t": "2027-03-01T10:14:00+00:00", "actor": "ana", "action": "decision: fail over to region-b, owner lee, effect visible by 10:20"}, ] BAD = [ {"t": "10:09", "actor": "lee", "action": "rollback done?"}, {"t": "2027-03-01T10:04:00+00:00", "actor": None, "action": "someone joined late, backfilled from memory"}, {"t": "2027-03-01T10:02:00+00:00", "actor": "ana", "action": []}, ] def main(): good_errors = check_timeline(GOOD) bad_errors = check_timeline(BAD) assert good_errors == [], f"good sample should pass, got: {good_errors}" assert any("missing actor" in e for e in bad_errors), f"bad sample should flag actor, got: {bad_errors}" assert any("missing action" in e for e in bad_errors), f"bad sample should flag action, got: {bad_errors}" assert any("bad timestamp" in e or "timezone" in e for e in bad_errors), f"bad sample should flag clock, got: {bad_errors}" assert any("out of order" in e for e in bad_errors), f"bad sample should flag order, got: {bad_errors}" print("PASS: good timeline has t/actor/action on every event in time order") print(f"FAIL as expected: bad timeline -> {'; '.join(bad_errors)}") print("summary: 1 passed check, 1 failed check (expected 1 and 1)") if __name__ == "__main__": main() 好样本通过的原因是每条事件都有完整日期与时区的标准时间戳、明确记录者与可观察动作,并且按非递减时间排列;允许同一时间戳对应多条事件。坏样本把四种失真集中在一起:只有钟点没有日期时区、补记缺记录者、关键动作缺描述、时间戳还出现倒流。预期看到一行通过、一行按字段名指出全部缺陷的失败,以及一过一败的汇总。修订脚本已用 Linux 上的 Python 3.12.14 运行。它拒绝空值与非字符串字段,却不能验证观察是否真实,也不保证事件序号唯一。这里接受 Python 支持的若干 ISO-8601 形式,并非严格的 RFC 3339 校验器。它只校验响应中的记录纪律,不涉及事后复盘的责任划分,那部分请看复盘配套文章。 ...

三月 1, 2027 · 3 分钟 · XB Field Notes