FR-068 系統 Log 轉發(Log Forwarding to Log Server)

2026-08-25 需求討論拍板(user+協調者,對話中定案,未走 HTML 討論稿——範圍單純、決策點僅三個)。 Notion 母卡:見 FR 登記表。agent log 轉發本案不做(user 裁示,未來另案)。

給 PM 的三分鐘版

這節不需要任何技術背景就讀得懂。工程細節從下一節起。

這案在做什麼:讓管理者在系統設定頁上填幾個欄位,就把我們系統產生的 log(系統運作 紀錄)自動送到客戶自己的 log 平台去。

為什麼客戶要這個:客戶(特別是把系統裝在自己機房的落地版客戶)機房裡通常已經有一套 集中收 log 的平台——ELK、Graylog、rsyslog 這類,所有系統的 log 都往那裡送,統一在一個 地方查。合規客戶更進一步:他們的資安監控平台(SIEM)需要收到「誰登入了、誰改了什麼」 這種稽核事件,這是稽核與事故調查的依據。我們的系統如果不送過去,就成了客戶監控網裡 的一個黑洞。

送的內容分兩條,各自獨立勾選

內容 誰要看
應用 log 系統運作紀錄、錯誤訊息 工程師排錯用
稽核事件 誰登入、誰改了什麼設定 合規客戶接 SIEM 真正要的就是這條

影響哪些畫面/操作:系統管理下新增一頁「Log 轉發設定」——填對方主機位址、埠號、選 用哪種格式送、勾要送哪兩條流,存檔就開始送。不必改設定檔、不必重啟服務。旁邊有一顆 「發送測試 log」按鈕,可以當場確認連不連得通。

拍板了什麼(決策 D1–D6 的白話版,完整理由見 §2):

題目 定案
用什麼格式送 Syslog 與 GELF 兩種——這兩種是 rsyslog/Graylog/ELK 三家都吃的最大公約數。不做各家專屬的對接(每家的認證方式和版本相容性都是坑,維護成本不成比例)
送哪些內容 應用 log 與稽核事件兩條都做,畫面上分開勾。只做應用 log 會做完才發現不是合規客戶要的東西
設定放哪一層 系統全域一份(目標客群是一個客戶一套系統)。資料結構預留了「每個租戶各自設定」的空間,未來要開只動畫面和一個小函式
送不出去怎麼辦 絕不能拖垮主系統——網路斷、對方主機掛了,本機 log 照常寫、系統照常跑,不重試轟炸
敏感內容要遮嗎 第一版不遮(log 是送到客戶自己機房內、信任邊界在客戶內網,手冊會註明)。架構有預留遮罩的掛點
設定改了要重啟嗎 不用,改完數十秒內自動生效(實作查證後的精確說法見 §5.2,畫面上會如實顯示這個延遲,不假裝是瞬時)

現在到哪了:母卡 CM-1389 已收案,隨 v1.17.0 出貨,188 STG 與 189 POC 都已升級 到位。出貨後四筆修正(含一筆「Graylog 收得到卻讀不懂」的封包格式問題)見 README.md 的「實作與設計的差異」節。

本案不含:檢測 Agent 那側的 log 轉發(決策者裁示未來另案處理)。


變更紀錄

日期 變更 對應
2026-08-25 初版設計定案(D1–D6) 母案

1. 需求背景與目標

客戶(尤其落地版)有集中 log 管理需求:系統 log 要能轉發到客戶自有的 log server(ELK/Graylog/rsyslog 等),在系統 UI 上設定即可生效。合規客群接 SIEM 的核心訴求是稽核事件(誰登入、誰改了什麼)進他們的集中平台;應用 log 轉發則服務工程排錯集中化。

2. 決策定案

# 決策 定案內容 理由/被排除方案
D1 協定 Syslog(RFC 5424,UDP/TCP 可選)+ GELF(UDP/TCP)兩種 Syslog 是三家(rsyslog/Graylog/ELK via Logstash syslog input)最大公約數,Python stdlib SysLogHandler 現成;GELF 給 Graylog 原生結構化欄位保真。排除各家專屬 API 對接(Elastic API 認證/版本相容坑多;syslog 通吃零依賴)。ELK 對接以手冊附 Logstash 配置範例達成
D2 轉發範圍 應用 log + 稽核事件兩者都轉,UI 分開勾選 GRC 客戶接 SIEM 十之八九要的是稽核事件;只做 app log 會做完發現不是客戶要的。兩流各自獨立開關
D3 設定層級 系統層(全域一份),資料結構預留租戶層擴充 目標客群偏落地版(一客戶一套系統),全域一份最簡。預留:設定表帶 tenant_id 欄可空(NULL=全域),讀取邏輯「租戶層有值優先、fallback 全域」的形狀先做成 helper 單點,未來開租戶層只動 UI 與該 helper
D4 失敗語意 轉發失敗絕不拖垮主服務:UDP fire-and-forget;TCP 非阻塞+斷線靜默降級(本地 log 照寫+本地記一條轉發失敗告警,不重試風暴)。佇列緩衝:handler 掛在 QueueHandler/QueueListener 後面(stdlib),主執行緒零阻塞 log 轉發是旁路,任何網路抖動都不可影響 API 延遲
D5 敏感內容 第一版不過濾(客戶自有 log server,信任邊界在客戶內網,手冊註明)。架構預留 pipeline 掛點:轉發 handler 前的 filter 鏈天然支援;UI 預留「敏感資料遮罩」開關位置(disabled 標示即將推出)。實作時內建預設規則組(IPv4/email/token 形狀 regex 遮罩),不做自訂規則編輯器(過度設計)
D6 生效方式 設定變更熱生效(重讀設定、卸舊掛新 handler,不重啟服務)+ UI「發送測試 log」按鈕驗連通 改個 log 位址要重啟服務不可接受

3. 詳細設計

3.1 設定資料

新表 config.log_forwarding_settings(系統層單列起步):

欄位 說明
tenant_id BIGINT NULL NULL=全域(D3 預留;第一版只有 NULL 一列)
enabled BOOLEAN 總開關
protocol VARCHAR syslog / gelf
transport VARCHAR udp / tcp
host / port log server 座標
forward_app_log BOOLEAN D2 流一:應用 log
forward_audit_events BOOLEAN D2 流二:稽核事件
masking_enabled BOOLEAN DEFAULT FALSE D5 預留(第一版 UI disabled)
審計四欄 慣例

3.2 BE 掛載

  • 啟動時讀設定 → enabled 則組 handler 鏈:QueueHandlerQueueListenerSysLogHandler(或 GELF handler,GELF UDP 百行內自寫或極輕依賴,實作棒查證後定)。
  • 兩條流的源頭:app log=掛在既有 root/app logger;稽核事件=掛在稽核事件寫入點(reference_audit_event_logging_pattern 既有埋點鏈),以 structured 欄位出(GELF 時 event_type/actor/target 成獨立欄位,syslog 時 JSON in message)。
  • 熱生效:settings 更新 API 成功後觸發 reload(單進程直接重掛;gunicorn 多 worker 走既有的跨 worker 訊號機制——實作棒查證現況後定,沒有就 fallback「下次 worker 重生生效+UI 註明」,不為此蓋新輪子)。
  • 測試按鈕:POST .../log-forwarding/test 發一條固定測試訊息,回傳送出成功與否(UDP 只能驗送出不驗到達,UI 文案如實說)。

3.3 FE

系統管理群新設定頁(或併入既有整合設定頁——實作棒看現有選單結構定):表單(開關/協定/傳輸/host/port/兩流勾選)+測試按鈕+遮罩開關(disabled)。權限比照同群其他系統管理頁。

3.4 手冊

三家對接範例各一段:rsyslog(收 UDP 514 範例 conf)、Graylog(GELF input 建法)、ELK(Logstash syslog input pipeline 範例)。

4. 拆分

# 子任務 Repo 規模
T-1 BE:設定表 migration+CRUD API+handler 鏈掛載(syslog+gelf、queue、熱生效、測試端點)+稽核事件流接點 BE M
T-2 FE:設定頁+測試按鈕+i18n FE S~M
T-3 手冊三家對接範例+SPEC 頁 docs S

依賴:T-1 → T-2;T-3 收尾做。agent log 轉發不在本案(未來另案,屆時 agent 端沿同一設定下發形狀)。

5. 實作回寫

2026-08-29(CM-1429)補記。§3 有三處寫「實作棒查證後定」,實作當時都查證並做了 決定,但結論只留在 common/log_forwarding/ 的檔頭註解裡,沒回到設計稿。本節把它們 收進來,內容取自那兩支檔頭(實作當下寫的一手判斷,非事後追述)。

§1–§4 維持 2026-08-25 拍板當下的原樣不動——定案稿是快照,差異另記,與 README.md 的「實作與設計的差異」節同一原則。出貨後的四筆行為修正記在 README 那節,本節只管「設計時懸而未決、實作查證後定案」的三處。

5.1 GELF handler:自寫,不引套件(§3.2 第一處)

候選 pygelfgraypy 都不在本專案依賴內pyproject.toml 無、venv 內 find_spec 皆 None),引入等於新增一個第三方依賴。而 GELF 1.1 的線上格式極小——固定 8 個欄位的 JSON,UDP 走 gzip、TCP 走 NUL 結尾,規格全文一頁,自寫約百行。

換掉的成本是:

  • 落地版打包:新依賴要進 Nuitka 收集清單、進離線安裝包、進授權盤點(graypy BSD、 pygelf MIT 都合規,但每多一個就多一列要維護)。
  • 行為可控:兩個套件的失敗語意都不是我們要的——預設會讓 socket 例外往上冒或自行 重試,而 D4 要求「斷線靜默降級、不重試風暴」。用套件反而要再包一層去壓制它。

順帶定案:不做 GELF UDP chunking。規格允許把大訊息切成多個 chunk,但本專案的 log 是單行文字加少量欄位,gzip 後遠小於單一 datagram 上限;真超長時寧可截斷(8 KiB,尾端 標 …[truncated])也不引入 chunk 重組——收不齊的 chunk 在對端是碎片垃圾,比截斷更難查。

落點:common/log_forwarding/gelf_handler.py

5.2 多 worker 熱生效:推翻 D6 的前提,改 per-worker watcher(§3.2 第二處)

§3.2 原本假設「走既有的跨 worker 訊號機制」。查證結果是本專案根本沒有這種機制:全 codebase grep 不到 SIGHUP handler、不到 Redis pub/sub 的訂閱端(redis 只被當 KV 用)、 不到 worker 登記表(register_gunicorn_master 只記 master pid 供 tamper 終止,是單向 的)。gunicorn 有 --reload,但那是重啟整個 worker,代價遠大於重掛一個 handler。

照 §3.2「沒有就 fallback,不為此蓋新輪子」——但 fallback 不必差到「下次 worker 重生才 生效」。定案:每個 worker 各起一條 daemon watcher 執行緒,週期(預設 30s, LOG_FORWARDING_WATCH_INTERVAL)重讀設定,指紋有變才重掛。

  • 效果上仍滿足 D6「不必重啟服務」,代價是最長 30s 的生效延遲。
  • 不需要任何跨進程協調——每個 worker 各自從 DB 這個共同真相來源讀,天然一致。
  • API response 帶 applies_within_seconds 明示這個延遲,不假裝瞬時(D6 說「熱生 效」,實際是「數十秒內生效」,UI 必須如實說)。
  • 輪詢成本:一次 SELECT × worker 數 ÷ 30s,以預設 4 workers 計約每秒 0.13 次查詢。

落點:common/log_forwarding/forwarder.py

5.3 FE:新開頁,不併入既有設定頁(§3.3)

定案 新開頁 /system/log-forwardingsrc/views/log-forwarding/LogForwardingForm.vue), 選單掛在系統管理群下(ui_routes sort 35,migration scripts/sql/2026-08-27-fr068-2-log-forwarding-menu-route.sql),權限走 log-forwarding.{read,update} 兩個能力點,比照同群其他系統管理頁。