当事件发生时,服务向你的 URL 发出的回调请求。

Webhooks 推送事件而非轮询

由于回调来自外部,投递本质是至少一次,因此接收端既要容忍重试,也要验证来源,而不是默认它就安全。 事件一到,回调即发 Webhook 把请求的方向整个倒了过来:不再是客户端四处轮询新数据,而是当事件发生时,由提供方往你预先注册的那个地址发一条 HTTP 请求。请求体是一小段自描述的变更说明,直接送去一个经常完全不同的服务器上的接口。因为呼叫由发布方主动发起,你作为接收方就不必一遍遍地重新连接,只需安静等新东西主动送上门,递送因此几乎瞬间可达。 重试与幂等是接收方的事 「重试与幂等」是接收方自己的本分。提供方在没收到你的及时回执时会再发一次,而一旦递达,同一条事件就可能重复出现,于是端点必须具备幂等性。接收方要把已经处理过的事件 id 记下来、把重复当空操作处理,而较重的动作最好丢进队列或后台去做,别活活堵在这条回执里同步傻等。把确认做得快、把去重与清理做得明确,再吵的网络也能变得无害,而不是一转成故障。 验证调用方而不只验 TLS 验证真伪不能只靠 TLS 这一层。由于请求来自公网回调,你必须在收到时取出签名、用共享密钥自己重新算一遍,并借助时间戳把「多旧的请求还接受」收进一个窗口。同时把回调固定在一个明确已知的地址、密钥按计划轮换,因为一个伪造的 webhook 可以做出与真回调完全一样的动作。于是最稳妥的形状是:快速回执、幂等处理、每次都验签名——默认不信任任何请求。 坏掉的样子 投递失败往往是安静发生的。如果发布方只重试两次且没有退避等待,一次短暂发布或冷启动就可能让一条订单支付通知永远丢失,而接收方根本不知道自己错过了什么。更糟的是接收端太慢:当验签、解析与写库全部堵在请求处理函数里同步执行,每次投递都会超时,发送方把每次尝试都记为失败,重试风暴又把同一批积压反复重放。正确做法是先快速回执、再把载荷推进队列、由后台工作线程慢慢消化,这样流量高峰只会表现为队列变长,而不会变成悄无声息的丢失。重试没有指数退避、也没有死信队列时,偶发抖动会被放大成系统性漏单,事后只能靠对账才能发现。 信任断裂的方式更加隐蔽。密钥轮转时如果只更新了发送方、却漏掉某个验签节点,那么所有真回调都会被拒收,在监控上看起来和伪造洪水一模一样。如果没有时间戳窗口和已处理事件编号记录,攻击者可以把截获的支付回调在几天后重放一次,让发货流程重复执行。调试接口若记录完整请求体,还可能泄露个人信息;而先返回成功、后做校验的处理器,则会让投毒事件看起来一切正常。应当把重试不足、时钟偏差、处理太慢和重复到达都当成常态,而不是边角情况。固定回调地址、按计划轮换密钥、拒绝过期与重放请求,都是同一道防线。 快速验证 把下面程序保存到临时目录的 webhook_verify_demo.py,运行 python3 webhook_verify_demo.py。它只用标准库,用 HMAC-SHA256 对带时间戳的消息做签名,并用断言检查五种行为。不需要网络,也不依赖第三方包,不会读写真实文件,适合在本地快速理解验签、容差与去重的配合。 import hashlib import hmac import time SECRET = b"whsec_test_secret_1" OLD_SECRET = b"whsec_old_secret_0" TOLERANCE_SECONDS = 300 seen_ids: set[str] = set() def sign(event_id: str, timestamp: int, payload: bytes, key: bytes = SECRET) -> str: msg = f"{event_id}.{timestamp}.".encode() + payload return hmac.new(key, msg, hashlib.sha256).hexdigest() def verify(event_id: str, timestamp: int, payload: bytes, signature: str, now: int | None = None) -> bool: if now is None: now = int(time.time()) if abs(now - timestamp) > TOLERANCE_SECONDS: return False if event_id in seen_ids: return False expected = sign(event_id, timestamp, payload) if not hmac.compare_digest(expected, signature): return False seen_ids.add(event_id) return True def main() -> None: now = int(time.time()) payload = b'{"type":"order.paid","order":"42"}' fresh = sign("evt_1", now, payload) assert verify("evt_1", now, payload, fresh, now) is True tampered = b'{"type":"order.paid","order":"43"}' assert verify("evt_2", now, tampered, fresh, now) is False stale_sig = sign("evt_3", now - 3600, payload) assert verify("evt_3", now - 3600, payload, stale_sig, now) is False assert verify("evt_1", now, payload, fresh, now) is False rotated_sig = sign("evt_5", now, payload, key=OLD_SECRET) assert verify("evt_5", now, payload, rotated_sig, now) is False print("PASS: fresh accepted; tampered, stale, replay, rotated key rejected") if __name__ == "__main__": main() 程序只接受一次新鲜投递,其余四种坏情况一律拒绝:正文被篡改导致签名对不上、时间戳早了一小时而超出五分钟容差、事件编号重复使用之前已接受的签名、用已轮转掉的老密钥签发。每种拒绝都返回否且不记入已见集合,因此只有第一次投递会进入记录。同样,超出容差的未来时间戳也会被拒绝,这样截获的回调就不会长期有效,重放攻击也就失去立足之地。 ...

十一月 18, 2027 · 2 分钟 · XB Field Notes
一次失败被转成一列要尝试的行动。

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

事件结束后,价值在于你决定改变什么。一场追责的复盘会得到一个防御性的团队;一场问「我们能改变什么,让这种事不再发生」的复盘,会得到持久的改进。 追责扼杀诚实的报告 一个人害怕被追责时,记录就会缩水,真正的原因会被藏起来。安全的选择是只讲对自己有利的部分,于是时间线就断了。复盘里的时间线应当是固定的事实,人只是系统里的一个受约束部件,而不是失败的原因。 两个机制最容易造成失真。一是后见之明:结果已知之后,每一步看起来都像明显错误,但当事人在当时并没有这个结果。二是把某个人当成最终原因就停下来。单个人的动作几乎总是叠加在没写检查、轮班过载、交接含糊、预算把工作挤到一起之上。有用的复盘会一路追问,直到答案是能被改变的某个设计决定或流程缺口,而不是一个名字。 复盘的结构 一份合格的复盘文档有四个部分。时间线是按时间顺序的中立记录,写清楚谁在什么时候做了什么、看到了什么,和解释分开。原因部分把触发事件与促成条件分开,深层修复通常藏在这些条件里。行动计划是最重要的一部分:每条有一名负责人、一个期限和一个可验证的完成条件,是一条不完成就会失败的明确任务,而不是一条散会后就消失的看了再说。 两道评审关口保证它不糊弄。第一道重读时间线并逐一核对,看每条行动是否真的对应一个促成原因,而不只是最表面的症状。第二道淘汰无法验证的行动,比如多小心、以后注意,把它们变成具体的改动:补上检查、收紧告警、加测试、更新手册。没有负责人也没有验证步骤的条目,只是愿望,不是变更。 后续跟进才是重点 碰头会是中间点,不是终点。只开会不跟进的复盘会重复上演:几周后同一个故障换一层皮回来,同一张图、同一个告警、同一个意外。真正闭环的做法是约定一周后复查,每条行动由负责人报告真正落地的变更。有些变更上线后不成立,文档也应该写进去,因为只要这条跟进在不断修正并换成更好的方案,它依然是一个闭环。 能长期坚持的团队,会在日历上放一个短的、固定周期的复查,并只用变更是否落地、是否验证来评价一次复盘,而不会用开会时的感觉。真正有用的信号是同类原因的复发率在下降,而不是总故障数在下降——因为诚实上报的团队可能前期显得更糟,恰恰要保持这种诚实,才是这套做法存在的意义。 坏掉的样子 追责式复盘有四种典型坏法。第一,时间线缩水:防御性删改去掉不确定的观察,只剩告警到修复两点,中间的判断、重试和交接全消失。如果文档里看不到任何人的困惑、误判和交接,说明时间线是被过滤过的,不是记录下来的。第二,行动项没有负责人和验证:写着加强监控,却没有名字、期限和检查方法。根因是把文档当成结果,而不是把变更当成结果;识别方法是看每条行动是否不用动生产就能标完成,能,就是愿望。第三,只修触发事件,不修促成条件:回滚了发布,却留下缺失的门禁、误报的告警和含糊的手册。逐条问每个促成条件对应哪条改动,如果行动只对得上触发事件,就是不完整。第四,后续跟进悄悄流产:复查会从不排期,负责人被别的事带走,几周后同因换皮再来。带着未关闭行动清单的同形故障,就是它的签名。 快速验证 把下面代码存为 postmortem_check.py,在空目录运行 python3 postmortem_check.py。它只用标准库,在内存里构造一好一坏两份复盘,不写任何文件。 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 ("owner", "due", "proof"): if not str(action.get(field, "")).strip(): errors.append(f"action {i} missing {field}") return errors GOOD = { "timeline": ["00:01 alert fired", "00:07 rollback done"], "causes": {"trigger": "bad deploy", "conditions": ["no canary check"]}, "actions": [ {"task": "add canary check", "owner": "ana", "due": "2027-03-12", "proof": "CI blocks deploy without canary"}, ], } BAD = { "timeline": ["00:01 alert fired"], "causes": {"trigger": "bad deploy", "conditions": []}, "actions": [ {"task": "be more careful", "owner": "", "due": "soon", "proof": ""}, ], } def main(): good_errors = check_review(GOOD) bad_errors = check_review(BAD) assert good_errors == [], f"good sample should pass, got: {good_errors}" assert any("missing owner" in e for e in bad_errors), f"bad sample should flag owner, got: {bad_errors}" assert any("missing proof" in e for e in bad_errors), f"bad sample should flag proof, got: {bad_errors}" print("PASS: good review has timeline, causes, and fully owned actions") print(f"FAIL as expected: bad review -> {'; '.join(bad_errors)}") print("summary: 1 passed check, 1 failed check (expected 1 and 1)") if __name__ == "__main__": main() 好样本因含时间线、原因与负责人齐全的行动而通过;坏样本因缺负责人且验证为空而失败。断言要求好样本零错误,坏样本必报缺负责人与缺验证。预期看到一行通过、一行按名指出缺失字段的失败,以及一过一败的汇总。 ...

三月 5, 2027 · 1 分钟 · 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 str(ev.get(field, "")).strip(): errors.append(f"event {i} missing {field}") 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: sort by t before appending") 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": "", "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() 好样本通过的原因是每条事件都有完整日期与时区的标准时间戳、明确记录者与可观察动作,并且严格按时间顺序追加。坏样本把四种失真集中在一起:只有钟点没有日期时区、补记缺记录者、关键动作缺描述、时间戳还出现倒流。预期看到一行通过、一行按字段名指出全部缺陷的失败,以及一过一败的汇总。该脚本已在苹果电脑用解释器第三版第十一代验证通过。它只校验响应中的记录纪律,不涉及事后复盘的责任划分,那部分请看复盘配套文章。 ...

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