EN 中文
← 回文章列表
Observability · Self-hosting

Webhook bot 沒辦法看住自己:存活、日報、交付稽核

沉默死亡是 webhook bot 最糟的失敗模式——而 LINE 會讓它自我延長。三道巡檢 loop 回答三個不同的問題:還活著嗎、在悄悄變爛嗎、承諾兌現了嗎。

2026-07-26 line-bot 系列 · 7
3
道 loop,三個不同問題
0
則健康時的 Telegram 噪音
2 分鐘
立下鐵律的那次 I/O 卡死

背景

LINE 家庭助手系列第七篇。Webhook bot 是被動的:沒人 @ 它,它什麼都不做——這代表死掉的 bot 和閒著的 bot 看起來一模一樣。Docker 重啟出狀況、tunnel 掉線、LLM 供應商抽風時,bot 不會轟然倒下。它只是安靜下來,直到某位家人 @ 它、沒有回應、轉頭來問我。

LINE 還把這件事變得更糟,糟得值得特別強調:連續失敗的 webhook 會被暫停推送。「壞掉」不只是持續——它會複利。平台不再敲一扇沒人應門的門。

所以 bot 外面包著巡檢 loop,這篇講前三道。它們回答三個不同的問題——還活著嗎?(L1)、在悄悄變爛嗎?(L2)、承諾兌現了嗎?(L3)——而且任何兩道都無法互相替代。

L1:哨兵必須住在爆炸半徑外面

存活監控的原始計畫是最順手的那個:homelab 本來就跑著 Prometheus 和 Grafana、告警都接好了——加一條規則,收工。這個計畫在一個觀察面前當場死亡:Grafana 跑在同一台機器的 Docker 裡。 L1 最需要抓的失敗是「Docker 整個死掉」——而在那個情境裡,監控 stack 跟它該回報的 bot 死得一樣徹底。一個住在它要偵測的失敗的爆炸半徑內的監控,是裝飾品。

所以哨兵跑在 host 上、Docker 外面,由跑我其他自動化的同一套 agent runtime cron 排程——一棵能在 Docker 死亡中倖存的程序樹。它的告警走 Telegram、不走 LINE,理由在維運文件裡寫成了原則:不能用這隻 bot 回報這隻 bot 死了。通知路徑不能跟被監控的東西共享任何依賴。

每五分鐘,它從兩個方向檢查同一份 /health 契約:

  • localhost:8083/health——容器活著、服務正常嗎?
  • https://<公開網域>/health——整條鏈:Cloudflare 邊緣 → tunnel 容器 → API。這條通,LINE 的 webhook 就到得了我們。

為什麼探 /health 不直接探 /webhook?因為 /webhook 只收 POST,GET 在 Cloudflare 邊緣就被擋掉——邊緣回的 403 對「origin 到不到得了」什麼都沒說。/health 的 200 是 origin 親口說的,端到端、零歧義。(順便免費涵蓋 tunnel 容器:cloudflared 掛了,公開探針就不通。)

自我閘門:空 stdout 就是告警閘

排程器有一個性質,後來變成了設計模式:stdout 是空的,就什麼都不投遞。所以哨兵健康時什麼都不印——只在狀態轉變時開口:

OK
健康,之前也健康
什麼都不印。不投遞、零噪音。99% 的執行是這種。
🔴
掛了,之前健康
印一則告警——連續失敗兩次才算,單次閃斷不呼叫任何人。
掛了,之前就掛
什麼都不印。停機是已知的;每五分鐘複誦一次,是一顆沒人關得掉的警鈴。
🟢
恢復,之前掛著
印一則恢復通知,附估計停機時長。把迴圈收好。

狀態存在一個小 JSON 檔裡跨執行傳遞。還有一個反直覺的細節:腳本永遠 exit 0——bot 掛掉時也是。偵測到停機是檢查成功。非零的退出碼會告訴排程器「job 失敗了」、招來重試;重試的告警就是重複的告警,這件事本人親身經歷過、親身後悔過。

L2:每日日報,刻意不上閘門

L1 抓的是死亡。它對衰退是瞎的:工具標記搶救率悄悄爬升、工具錯誤變頻繁、搜尋額度靜靜燒完。每一項當下都看不見,放進趨勢線都很明顯。

L2 是每日日報——掃過去 24 小時的容器 log 加 bot 的 SQLite,彙總成一則 Telegram:訊息量、活躍群數、LLM/工具/回覆錯誤計數、搶救解析器出手的頻率、搜尋 API 的本月累計用量。最後那項值得一提:API 的額度是額度,但容器 log 幾天就輪掉了——所以日報把每日計數累加進自己的 state 檔。當原始資料活不過它的來源,監控就得自己當帳本。

跟 L1 相反,日報每天都推,健康與否都推。這是反方向的閘門決策,而且是故意的:只有出事才看得到的趨勢報告不是趨勢報告;而且每天準時抵達本身就是監控系統的心跳——哪天早上日報沒出現,就是訊號。

立下永久鐵律的那兩分鐘

L2 的第一版直接從 host 讀 bot 的 SQLite——那個檔案在一顆容器和 host 都看得到的外接硬碟上。job 卡了兩分鐘以上,逾時。

診斷出來是兩個因素疊加:macOS 給背景服務的外接磁碟存取權是受限的,而 SQLite 的 timeout 參數只防——對一個卡在 I/O 層的 open() 毫無作用。連線沒有失敗;它只是永遠不回來。

修法:背景服務永遠不碰外接碟。資料庫穿過容器讀——docker exec 進去查,/data 在裡面是原生路徑——因為做那次 I/O 的是 Docker daemon,不是受限的服務 session。這條規則現在蓋章在每一道碰 DB 的 loop 上,包括下面的稽核和下一篇的備份 job。環境限定、代價換來、而且正是那種「文件裡之所以有這一條,是因為曾經每一輪執行都花兩分鐘去不知道它」的知識。

L3:稽核承諾,不稽核機制

L1 和 L2 看不到的失敗長這樣:bot 活著(L1 綠燈)、log 乾淨(L2 安靜)——而某人被承諾的提醒從未送達。push 可以失敗而不拋例外;背景 loop 可以卡住而不留 log。使用者發現的方式,是錯過了他特地拜託不要錯過的事——對一隻招牌功能是提醒的 bot,這是砸招牌的那種失敗。

L3 不試圖枚舉送達失敗的所有方式。它稽核終態:每 15 分鐘查提醒表,找已到期、超過寬限期、卻還沒標記完成的列。健康的系統裡這個集合是空的——送達 loop 邊送邊標記。一列逾期超過寬限期,就是「某個環節失敗了」的終態證據,不管機制為何:push 錯誤、loop 卡死、任務崩潰。證據在狀態裡,不在 log 裡。

它跟 L1 一樣自我閘門(乾淨時沉默),多一個細緻處:state 記著已經報過的逾期提醒,同一列卡住只告警一次——不是每 15 分鐘轟一次直到修好。送達或取消的列自然從 state 掉出去。

這個形狀跟資料工程的對帳檢查是同一個:不要監控過程,對帳結果。過程會用沉默說謊;終態不會。

收穫

關於爆炸半徑

跟被監控物共享基礎設施的監控,回報得了每一種失敗,除了把兩者一起帶走的那種。「哨兵住哪裡」是第一個設計決策,在頻率之前、在告警格式之前——而答案必須是:外面。

關於轉變 vs 狀態

對狀態轉變告警(配抖動過濾和恢復通知),是告警頻道值得信任的原因。唯一每天都推的那道 loop,推的是刻意的心跳——有閘門的 loop 沉默代表「健康」,所以沒閘門的那道沉默必須代表「去檢查監控本身」。

關於終態稽核

任何做出承諾的系統——提醒、投遞、排程任務——要稽核的是承諾,不是機器。「到期、過寬限、未完成」抓得住每一種失敗機制,包括你還沒想像到的那些,因為它檢查的是使用者唯一真正體驗到的東西。