Phase 08 — OS/Deployment + Auth + NATS(Day 71–80)

100 Day Engineer Challenge

Phase 08 — OS/Deployment + Auth + NATS(Day 71–80)

這份逐日教材依循內部規劃文件的範圍與排序展開1

本檔案由三個任務接力完成:本段(T-059)涵蓋 Day 71–75:Process 生命週期 / Signal 處理與 Graceful Shutdown / Service Manager(systemd user unit / launchd)/ cgroup 資源限制 / /proc 觀測 / 自我更新陷阱;Day 76–78(Auth,壓縮版)由 T-033 接續寫在本檔案中段;Day 79–80(NATS,壓縮版)由 T-034 接續寫在本檔案後半段,三段都不另開新檔。

Day 71–75 的順序與 back-reference:依 prerequisite chain2:Process 生命週期(fork/exec/orphan/zombie)與 Signal/graceful shutdown 排第一(Day 71),因為後面每一層都建立在「先搞懂一個 Process 怎麼活、怎麼死」之上;Service Manager(Day 72)存在的目的就是「process 死掉之後自動處理」,必須先懂 Process 怎麼算活著、怎麼算死掉,Restart 策略才有意義;cgroup 資源限制(Day 73)排在 Service Manager 之後,因為 Service Manager 保證的是「process 掛了會被拉回來」,但沒有資源限制,一個吃滿記憶體的 process 會拖垮同機器上其他 process,cgroup 是「在系統層級 OOM killer 出手之前,先在自己的邊界內解決」的下一層防線;/proc 觀測與自我更新陷阱(Day 74–75)排最後,因為前面三層都是「怎麼讓系統自動處理 failure」,這一層是「系統看起來正常時,怎麼判斷它其實不正常」——需要先懂 Process/cgroup 的邊界,才知道要去 /proc 底下的哪個數字找答案。

三個跨 Phase back-reference(依 §2.8 明訂,不重新從零定義):

  • Process 生命週期 back-reference Phase 05 Process(Day 41–50)——Phase 05 教的是「Process 之上還有 Thread/Goroutine 這些更輕量的執行單位怎麼被排程」,本節的 Process 生命週期回到 OS 本身「一個 Process 從 fork 到真正結束經歷哪些狀態」,是同一個節點在不同關注面向的重新出現,Day 71 不重新從零定義 Process 是什麼,只補「一個 Process 從生到死的完整狀態機」這塊還沒教過的部分。
  • Signal 處理與 graceful shutdown back-reference Phase 01 Graceful degradation(Day 9)——Day 9 教的 Graceful degradation 是「系統部分失效時仍維持基本可用」,本節的 graceful shutdown 是同一個「別讓失效變成硬斷」的原則套用到「process 即將終止」這個具體時刻,兩者是同一種思路的不同應用場景,不是兩個獨立主題。
  • /proc 觀測 back-reference Phase 01 Observability 的 Saturation 指標(Day 10)——Saturation 講的是「資源被用滿的程度」,/procrchar/read_bytes 是把這個抽象指標落到一個具體、可以馬上驗證的數字上:這是 Day 10 教過的 Saturation,在 disk I/O 這個資源上的具體量測方式。

Day 76–78 的順序與 back-reference:依上方已引用的 prerequisite chain,Day 76 先處理 Authentication/Authorization 的區分與 Session/JWT/Access Token/Refresh Token 的基礎模型(後面兩天的機制都建立在這組基礎模型之上);Day 77 接著處理 Expiration/Rotation/Revocation/Reuse Detection——這一串安全機制是「萬一 Day 76 的 Token 被偷了怎麼辦」的具體對策,必須先懂 Access/Refresh Token 各自的用途,這些對策才有意義;Day 78 才是 OAuth 2.0/OIDC——這是把 Token-based auth 擴展到「第三方服務代替使用者授權」的協定,建立在已經理解 Access/Refresh Token 與 JWT 驗證語意之上。Session 這個概念 back-reference Phase 01 Day 4 教過的 Cookie/Session ID 機制——Day 4 已經教過 Cookie 屬性(HttpOnly/Secure/SameSite)與 Session-based 認證的基本輪廓,Day 76 不重新從零解釋 Cookie,只補「Session-based 與 Token-based 兩種模型的差異」這塊還沒教過的部分。

Auth(Day 76–78)不屬於 OS/Deployment 這類直接跟真實環境互動的主題(見上方 Failure mode 額外要求段落),不套用 Proof 5 — Break 的 Setup/Break/Observe/Reason 格式;每天的 DoD 改回一般的「具體實作+驗證紀錄」格式(寫出可執行的程式碼或判斷邏輯、實際跑過並附上輸出結果),延續 Day 1–70 大多數 Phase 採用的做法。

Day 79–80 的順序與 back-reference:NATS 段落依內部規劃文件定位5壓縮成兩天——Day 79 先講 Core NATS(Subject-based Pub/Sub、Queue Groups、Request-Reply),這是「沒有持久化保證」的基礎收發模型;Day 80 才進到 JetStream(Persistence、Consumer/Delivery semantics、Ordering、Retry/Failure),這是在 Core NATS 之上疊加持久化與可靠性保證的進階層,必須先懂 Day 79 的基礎收發語意,才看得出 JetStream 每一項機制各自補上了哪個缺口。NATS 段落的 Queue/Retry/DLQ/Ordering/Delivery semantics back-reference Phase 06 Messaging(Day 58)5——Phase 06 已經定義過這些概念的抽象語意,Day 79–80 的任務是「這是 Day 58 教過的哪個概念,換成 NATS 這個真實系統時長什麼具體 API」,不重新從零定義 Ordering/Delivery semantics 是什麼。NATS 跟 Auth 一樣不屬於 OS/Deployment 直接操作實體環境的主題,不套用 Proof 5 —Break 格式,但因為本機用 Docker 就能實際跑起一個 NATS server,Day 79–80 的 DoD 沿用「具體實作+驗證紀錄」格式時,優先選擇真的跑一次 pub/sub/JetStream 指令並附上實際輸出,而不是只寫追蹤總結。

每個主題依分類標準要求的 5 段式內容撰寫3(一句話理解 / Why / Mechanism / Trade-off / Failure mode / Backend 連結),Core Fundamentals 額外附一則陌生題示範。OS/Deployment 這類直接跟真實環境互動的主題,分類標準對 Failure mode 段落有額外要求4:不能只用文字描述「這樣可能會失敗」,必須有一次實際操作、觀察到失敗發生時具體長相的紀錄(log 內容、指令輸出、被選中的數字),這正是文件第 21 節新增的「Proof 5 — Break」——Day 71 完整定義這個框架的操作方式(見 Day 71 第 0 節),之後 Day 72–75 每天至少套用一次,並在該天的 DoD 留下實際觀察紀錄。每天的 DoD 採 Setup / Break / Observe / Reason 四段格式(下方 Day 71 有完整說明),不沿用 Phase 04 的 Query/EXPLAIN 格式,也不沿用 Phase 05/06 的七問或情境分析格式——OS/Deployment 需要的證據是「真的在機器上操作過、看到失敗發生」,Setup/Break/Observe/Reason 直接對應這個需求。

Day 71 — Process 生命週期 + Signal 處理與 Graceful Shutdown:Proof 5 — Break 框架起點

學習目標

看完今天內容後,能夠:

  1. 完整畫出一個 Process 從 fork 到真正被系統回收的狀態機,並解釋 orphan 與 zombie 這兩種中間狀態各自是怎麼發生的。
  2. 針對一個長running 服務,設計收到 SIGTERM 後的 graceful shutdown 流程,並說明它跟 Day 9 Graceful degradation 是同一種原則的不同應用。
  3. 完整定義並能實際操作 Proof 5 — Break 框架(Setup/Break/Observe/Reason),之後 Day 72–75 每天至少套用一次。

教材大綱

0. Proof 5 — Break:操作框架(本日起,之後每天至少套用一次)

一句話理解

Proof 5 — Break 是「不滿足於『理論上可能會失敗』這句話,而是真的動手讓它失敗一次,記下失敗當下的具體長相」的操作框架,用 Setup → Break → Observe → Reason 四個固定步驟執行。

Why

文件第 21 節五種證明裡,Recall/Explain/Apply/Reason 都可以只靠閱讀與思考完成,唯獨 Break 一定要有一次真的動手才算數——這對 OS/Deployment 這類主題特別關鍵,因為這類機制(Restart 策略、OOM killer、/proc 數字)平常「看起來正常」,唯一能確認自己真的理解它的方法,就是刻意觸發失敗、看它是不是真的照理論運作;只靠閱讀,很容易停在「應該是這樣沒錯」的自我感覺良好,卻從沒驗證過細節(例如 TimeoutStopSec 到底是幾秒、OOM killer 選中的到底是不是你以為的那個 process)。

Mechanism

四個步驟——Setup:先描述正常狀態下的設定與預期行為(例如:一個帶 Restart=on-failure 的 systemd unit,正常執行中)。Break:具體描述怎麼「刻意」讓它失敗(例如:對這個 process 送 SIGKILL,模擬非預期崩潰)。Observe:記下失敗發生當下的具體證據——指令輸出、log 的原文片段、被選中的數字(例如:journalctl --user -u <unit> 顯示的重啟時間戳與次數)。Reason:對照今天教材的機制,解釋「為什麼觀察到的結果長這樣」,並回答「如果改變某個設定(例如把 Restart=on-failure 改成Restart=no),Observe 這一步會有什麼不同」。

Trade-off

真的動手 Break 比純閱讀花更多時間(要真的起一個 service、真的觸發一次失敗、真的去讀 log),代價是換來「這個機制的邊界條件我真的驗證過」的信心;純閱讀省時間,但遇到陌生情境時(例如面試被問「你怎麼知道 systemd 真的會在崩潰後重啟你的 service」)答案會停留在轉述文件,而不是「我做過,這是我看到的 log」。

Failure mode

跳過 Break、只憑理解寫 DoD 的 Conclusion,最常見的破綻是「數字對不上」——例如以為 TimeoutStartSec 預設是 90 秒,沒實測過就寫進 DoD,被追問「你的環境實測是幾秒」答不出來;或是以為 OOM killer 一定會選記憶體用最多的 process,實際跑過才發現oom_score_adj 會讓某些 process 被排除在候選之外,跟直覺不符。

Backend 連結

正式環境的 chaos engineering(例如 Netflix Chaos Monkey 隨機砍掉 production instance)本質上就是把 Proof 5— Break 套用在整個系統層級——如果一個團隊只在紙上推演「我們的 service 掛了會自動恢復」,從沒真的砍過一次看結果,這個假設在真正出事之前都只是猜測。

1

Process 生命週期(fork / exec / orphan / zombie)

Core FundamentalsLv.4
一句話理解

一個 Process 從誕生到真正消失,會經過fork(複製出一個新 Process)、exec(把複製出來的 Process 換成另一份可執行程式)、執行、終止、直到 parent 呼叫 wait/waitpid收下它的離開狀態碼(exit status)才真正從系統的 process table 消失——中間任何一段沒接上,就會停在 orphan 或 zombie 這兩種中間狀態。

Why

Day 41(Phase 05)教過 Process 的記憶體隔離,但沒有教「一個 Process 具體是怎麼被建立、怎麼真正結束」——這件事之所以重要,是因為 Service Manager(Day 72)的 Restart 策略,本質上就是在監控這個狀態機的「終止」事件,如果不懂終止到底是什麼(正常 exit、被 signal 殺死、還是變成 zombie 卡住),就無法理解 Service Manager 判斷「要不要重啟」的依據從哪裡來。

Mechanism

Unix/Linux 建立新 Process 的標準流程是fork()(複製呼叫者的記憶體空間與檔案描述符表,產生一個幾乎一模一樣的子 Process,唯一差異是回傳值——parent 拿到子 Process 的 PID、子 Process 拿到 0)接著 exec()(把這個子 Process 的記憶體內容整個換成另一份可執行檔的程式碼與資料,PID 不變,這就是fork+exec 能「建立一個執行不同程式的新 Process」的原理)。Process 結束時分兩種情況:如果 parent 正常存活且呼叫了wait()/waitpid(),子 Process 的 exit status 被收下,這個 Process 才真正從 process table 移除;如果子 Process 已經結束、但 parent 還沒呼叫 wait(),這個子 Process 會變成 zombie(狀態顯示為 Zps 看得到但已經不占用記憶體,只留一筆 exit status 等著被收);如果 parent 自己先死了(不管是正常或異常),還存活中的子 Process 會被 重新掛到 init(PID 1)或 subreaper 底下,變成 orphan——這個 reparent 動作保證系統裡永遠有人負責最終呼叫這些 Process 的 wait(),orphan 不會永久卡住,但 zombie 會(因為它已經結束了,只是沒人收)。

Trade-off

fork+exec 分兩步(而不是一步「直接啟動新程式」)讓子 Process 有機會在 exec 之前先做設定(例如重新導向 stdin/stdout、關閉不該繼承的檔案描述符),這是 shell 能實作管線(pipe)與重導向的基礎;代價是 fork 要複製 parent 的記憶體頁表(雖然實務上用 copy-on-write 延後真正複製,但仍有一定開銷),比 Day 42(Phase 05)教過的 Thread/Goroutine 建立成本高得多。

Failure mode

長running 的服務如果會產生子 Process(例如起 worker 處理任務)卻沒有正確呼叫 wait() 回收,會累積大量 zombie——zombie 本身不占記憶體,但每個都占一個 process table entry(有上限,kernel.pid_max),累積夠多會讓系統無法再建立新 Process;另一種常見誤解是以為 orphan process 會變成沒人管的孤兒永遠飄著——實際上 orphan 會被 reparent 到 init/subreaper,仍然會被正常 wait(),不是永久孤兒。

Backend 連結

容器化部署裡常見的「PID 1 問題」正是這個機制的直接應用——容器裡的 PID 1(通常是應用程式本身)如果沒有實作wait() 收割子 Process 的邏輯,容器內產生的 zombie 沒有 subreaper 可以幫忙 reparent 收割(容器的 PID namespace 裡沒有真正的 init),這就是 tini/dumb-init 這類輕量 init 工具存在的原因——專門負責在容器內扮演 PID 1,正確 wait() 回收 zombie。

陌生題

一個 Go 服務用 os/exec 啟動子 Process 處理背景任務,程式碼裡呼叫了 cmd.Start() 但從沒呼叫 cmd.Wait()。這個服務長時間運行後,ps aux 會觀察到什麼?如果這個服務本身是容器裡的 PID 1,情況會有什麼不同?(提示:cmd.Start() 只負責fork+exec,沒有 cmd.Wait() 等於 parent 一直沒收下子 Process 的 exit status,子 Process 結束後會變成 zombie 持續累積、顯示為<defunct>;如果這個服務是容器 PID 1,容器沒有真正的 init/subreaper,這些 zombie 會一直留在容器的 process table 裡,直到容器本身重啟才會被清空。)

練習

用 Go 的 os/exec 啟動一個子 Process(例如 sleep 1),呼叫 cmd.Start()不要呼叫 cmd.Wait(),在子 Process 結束後立刻用 ps aux | grep defunct(Linux)觀察 zombie 是否出現;接著改成正確呼叫 cmd.Wait(),確認 zombie 不再出現。把兩次ps 的實際輸出片段附進 Day 71 的 DoD。

2

Signal 處理與 Graceful Shutdown

Core FundamentalsLv.4
一句話理解

Signal 是作業系統用來通知一個 Process「有事情發生了」的非同步機制(例如使用者按 Ctrl+C 送出 SIGINT、Service Manager 要求終止送出 SIGTERM);Graceful shutdown 是 Process 收到終止類 signal 後,在真正結束前的一段時間內,主動完成「停止接新請求、把已經在處理中的請求跑完、釋放資源」這幾件事,而不是被送 signal 的瞬間就硬生生斷掉。

Why

這正是 Day 9 Graceful degradation 的同一個原則,套用到「process 即將終止」這個具體時刻——Day 9 講的是「系統部分失效時仍維持基本可用」,這裡講的是「process 即將死亡時,讓死亡本身也是可控、乾淨的」,兩者都是「別讓失效變成毫無準備的硬斷」的同一種思路,只是發生的時機與範圍不同(Day 9 是某個下游依賴失效,這裡是 process 自己即將終止)。如果沒有 graceful shutdown,一個正在處理使用者請求的 Process 被直接砍掉,會造成請求中斷、連線莫名斷開、甚至資料寫到一半就停手(例如一筆訂單只寫了一半的資料庫異動)。

Mechanism

Unix signal 分成可攔截(catchable)與不可攔截兩種——SIGTERM(15)是「請你自己決定怎麼結束」的請求,程式可以註冊 handler 攔截它、執行清理邏輯後才真正退出;SIGKILL(9)不能被攔截、阻擋或忽略,作業系統直接強制終止該 Process,沒有機會執行任何清理程式碼。Graceful shutdown 的標準流程:收到SIGTERM → 立刻停止接受新的請求(例如讓 HTTP server 的Accept() 停止、或讓 load balancer 健康檢查回報「即將下線」)→讓已經在處理中的請求正常跑完(有一個上限時間,通常對應 Service Manager 設定的逾時,例如 systemd 的 TimeoutStopSec 或 Kubernetes 的 terminationGracePeriodSeconds,預設 30 秒)→關閉資料庫連線池等資源 → 正常 exit(0)。如果在這段逾時內沒有自行結束,Service Manager 會接著送出 SIGKILL 強制終止,這時不管清理邏輯跑到哪裡都會被硬斷。

Trade-off

Graceful shutdown 讓正在處理中的請求有機會正常完成,避免使用者看到連線中斷或資料寫壞,換來更好的可靠性;代價是部署/重啟這個動作本身變慢(需要等在途請求跑完,而不是立刻砍掉),如果某個請求異常地久(例如卡住的長連線),還可能把整個逾時時間都耗盡,最後仍然被 SIGKILL 強制中止。

Failure mode

只註冊了 SIGTERM handler 卻沒有實際停止接受新請求(只清理資源就退出),會造成「已經決定要關閉,卻還在收新請求」的競態;逾時時間設定過短(例如 5 秒),在途的長請求根本來不及跑完就被 SIGKILL,效果等同沒做 graceful shutdown;反過來逾時設定過長,會讓部署/擴縮容的等待時間不必要地拉長。

Backend 連結

任何會被 Service Manager/Kubernetes/Load Balancer 管理的服務,都應該實作 SIGTERM handler;資料庫連線池(Day 2)、背景任務佇列(Phase 06 Messaging)在收到終止訊號時都需要各自的收尾邏輯(連線池要正常關閉連線而不是直接斷線、任務佇列要確保正在處理的任務不會憑空消失),這些收尾邏輯合起來就是一個服務完整的 graceful shutdown 實作。

陌生題

一個 HTTP 服務只寫了 os.Exit(0) 在收到 SIGTERM時直接呼叫,沒有先停止接受新連線、也沒有等在途請求跑完。在滾動部署(rolling deployment,舊 instance 收到 SIGTERM 準備下線,同時新 instance 正在啟動)的情境下,使用者會觀察到什麼?如果改成正確的 graceful shutdown(先停止接受新連線、等在途請求跑完、且逾時設定合理),情況會有什麼不同?(提示:os.Exit(0) 是立即終止,不會等待任何在途 HTTP 請求完成,使用者會看到連線被直接斷開、可能收到不完整回應或連線錯誤;正確實作的 graceful shutdown 會讓在途請求正常收到回應,只有新請求會被導向新 instance,使用者端幾乎無感知。)

Day 71 過關標準(DoD)—— OS/Deployment 特殊驗收格式(Proof 5 — Break:Setup / Break / Observe / Reason)

``text Setup: 寫一個會持續執行的小程式(Go 或 shell script 皆可),只在收到 SIGTERM 時印一行 log「received SIGTERM, shutting down」再正常結束(不註冊任何 handler 的版本、以及有註冊 handler 的版本各準備一份)。Break: 對沒有註冊 SIGTERM handler 的版本,用 kill -TERM <pid>送出終止信號;對有註冊 handler 的版本,也用同樣的指令送出。Observe: 記下兩個版本各自的實際行為——沒有 handler 的版本,程式的預設行為是什麼(多數情況下直接終止,沒有機會印出任何 log);有 handler 的版本,是否成功印出「received SIGTERM」這行 log 才結束。附上兩次執行的實際終端機輸出。Reason: 對照今天 Topic 2 的教材,解釋「為什麼有沒有註冊 handler 會造成這個行為差異」,並回答:如果對有 handler 的版本改送kill -KILL <pid>SIGKILL),Observe 這一步會有什麼不同、為什麼(提示:SIGKILL 不可攔截,handler 完全不會被執行)。另外完成 Topic 1 的練習(zombie process 觀察),把 Process 生命週期與 Signal 處理兩個 Topic 的觀察結果一起附在同一份 DoD 紀錄裡。``

  1. 依 00-master-curriculum.md 第 12 章與 00-knowledge-dependency-graph.md 第 2.8 節
  2. 依 00-knowledge-dependency-graph.md §2.8 的 prerequisite chain 定義
  3. 依 curriculum/content-standard.md 要求的 5 段式內容
  4. 依 curriculum/content-standard.md 對 Failure mode 段落的額外要求
  5. 依 00-knowledge-dependency-graph.md §3.5

Day 72 — Service Manager:systemd user unit / launchd

學習目標

看完今天內容後,能夠:

  1. 針對一個會偶爾崩潰的服務,設計 systemd user unit 的 Restart 策略,並解釋 Type=notify + watchdog 解決了 Type=simple解決不了的哪個問題。
  2. 解釋 launchd 與 systemd 在「服務啟動完成」判斷邏輯上的平台差異,並說明這個差異會在什麼情境下造成實際問題。
  3. 對今天教的至少一個 Restart 機制套用 Proof 5 — Break,記錄實際觀察到的重啟行為。

教材大綱

1

systemd user unit:Restart 策略 / Type=notify+watchdog / TimeoutStartSec

Core FundamentalsLv.4
一句話理解

systemd 是 Linux 上最常見的 Service Manager,用一份宣告式的 unit 檔案描述「這個服務怎麼啟動、崩潰後要不要自動重啟、怎麼判斷它真的啟動成功」,取代人工手動盯著 process 存不存在。

Why

Day 71 教過一個 Process 可能因為各種原因終止(正常 exit、被 signal 殺死、crash),但沒有人天天盯著終端機看某個 service 是不是還活著——Service Manager 存在的目的就是把「process 死掉之後要自動處理」這件事系統化:多久內重啟幾次、重啟前要不要延遲、以及最關鍵的——怎麼判斷「process 存在」與「process 真的能開始處理流量」是兩件不同的事。

Mechanism

systemd unit 檔案(~/.config/systemd/user/myapp.service)的 [Service] 區塊常用設定:Restart=on-failure(只有非正常結束,例如非 0 exit code 或被 signal 殺死,才重啟;正常 exit(0) 不會觸發)、Restart=always(不管怎麼結束都重啟)、RestartSec=(重啟前等待的秒數,避免瘋狂重啟迴圈打爆系統)。Type= 決定 systemd 怎麼判斷「啟動完成」:Type=simple(預設)只要 fork+exec 成功、process 存在,systemd 就認為啟動完成,不管這個 process 內部是不是還在做初始化(例如還在載入設定、還沒開始監聽 port);Type=notify 則要求應用程式自己在真正準備好之後,透過 sd_notify(READY=1) 主動告訴 systemd「我現在才是真的啟動完成」,systemd 在收到這個訊號之前,會讓依賴這個服務的其他 unit 繼續等待。TimeoutStartSec 是 systemd 願意等多久收到READY=1,超過這個時間還沒收到就判定啟動失敗;WatchdogSec搭配 Type=notify 使用,要求應用程式啟動後要定期呼叫sd_notify(WATCHDOG=1) 當作心跳,如果超過 WatchdogSec 沒有心跳,systemd 會認定這個 process 已經卡死(即使它還「activity 存在」),主動送出 SIGKILL 並依 Restart 策略重啟它。

Trade-off

Type=notify + watchdog 讓 systemd 能偵測「process 還在,但已經卡死沒在做事」這種比單純 crash 更隱蔽的失效模式,換來更精確的健康監控;代價是需要應用程式本身配合實作sd_notify 呼叫(Type=simple 完全不用改程式碼,Type=notify需要引入額外的 library/syscall 呼叫,且如果應用程式忘記定期送 watchdog 心跳,會被誤判為卡死而被強制重啟,造成不必要的中斷)。

Failure mode

Type=simple 搭配一個啟動很慢的應用程式(例如需要先跑完資料庫 migration 才能開始服務),systemd 在 process 一 fork 出來就認為啟動完成,如果有其他 unit 用 After=/Requires= 依賴這個服務,可能在它實際還沒準備好處理請求時就已經開始對它發送流量,造成一段時間的請求失敗;RestartSec 設得太短,一個持續 crash 的服務會不斷重啟又立刻再次 crash,短時間內瘋狂消耗 CPU 與 log 空間(systemd 有 StartLimitBurst/StartLimitIntervalSec 這組設定防止這種重啟風暴,超過門檻後 systemd 會直接放棄、不再重啟,需要人工介入)。

Backend 連結

Type=notify 是解決「Kubernetes readiness probe 之前的問題」在單機/VM 部署情境下的對應機制——概念上完全一樣:都是「process 存在」不等於「process 準備好接流量」,readiness probe 是應用層的健康檢查端點,sd_notify(READY=1) 是 process 層的啟動完成信號,兩者解決的是同一類問題在不同部署平台的具體實作。

陌生題

一個服務需要先連上資料庫、跑完一段初始化查詢(平均耗時 8 秒)才能開始處理請求,目前 unit 檔案用Type=simple,沒有設定任何 After= 依賴。部署時,如果有另一個服務會在這個服務啟動後立刻呼叫它,會觀察到什麼問題?改成Type=notify 並在初始化完成後才呼叫 sd_notify(READY=1) 之後,行為會有什麼不同?(提示:Type=simple 下,systemd 在 process 一啟動(fork+exec 成功)就認為服務就緒,呼叫方如果依賴這個時間點判斷「可以開始打請求了」,會在初始化的 8 秒內收到連線失敗或未就緒的錯誤;改成 Type=notify 後,systemd 會等到應用程式自己呼叫 sd_notify(READY=1)(也就是初始化真正完成後)才認定啟動成功,依賴它的呼叫方如果透過 systemd 的依賴機制等待,就不會在還沒準備好時被導入流量。)

2

launchd(macOS)與『註冊成功 ≠ 正在執行』的平台差異

Core FundamentalsLv.4
一句話理解

launchd 是 macOS 上對應 systemd 的 Service Manager,用 plist(XML 設定檔)描述服務怎麼啟動與重啟,但它判斷「啟動完成」的方式跟 systemd 的 Type=notify 不同——launchd 本身沒有一個內建、標準化的「應用程式主動回報『我真的準備好了』」協定。

Why

如果只學過 systemd 就以為所有 Service Manager 都有sd_notify(READY=1) 這種機制,換到 macOS 開發環境(或任何用 launchd 管理背景服務的場景)會誤判——這正是 Day 72 學習目標明講的平台差異,理解它才不會在跨平台部署時對「啟動完成」的判斷標準有錯誤假設。

Mechanism

launchd 的 plist 用 RunAtLoad(開機/載入時啟動)與 KeepAlive(決定要不要在結束後重啟,可以是簡單的 true/false,也可以是更細緻的字典,例如SuccessfulExit: false 表示「只有非正常結束才重啟」,對應 systemd 的 Restart=on-failure)描述重啟行為。但 launchd 判斷「這個服務啟動成功」的依據,本質上就是「fork+exec 有沒有成功、process 有沒有存在」——跟 systemd 的 Type=simple 是同一個層級的判斷,launchd 沒有內建對應 Type=notify 的機制讓應用程式回報「我內部初始化真的完成了」。這代表 launchd 環境下,「這個服務 launchd 認為啟動成功」與「這個服務真的能開始處理請求」這兩件事之間的落差,必須由應用程式自己額外提供訊號(例如自己實作一個健康檢查 HTTP endpoint,讓呼叫方主動輪詢確認,而不是依賴 launchd 本身告知)。

Trade-off

launchd 的設定相對簡單(plist 格式直觀,KeepAlive的字典寫法能表達不少條件),但換來的是「啟動完成」判斷粒度比 systemd 粗——依賴 launchd 本身完全無法得知一個服務內部初始化是否真的做完;要拿到跟 Type=notify 對等的保證,必須在應用層額外自己實作(例如一個獨立的健康檢查腳本,在服務啟動後輪詢一個 readiness endpoint,而不是相信 launchctl list 顯示的 process 存在就代表服務就緒)。

Failure mode

把「launchctl list 顯示這個 label 的 PID 存在且 exit code 是 0」誤認為「服務已經可以正常處理請求」,在啟動較慢的服務上會重演 Day 72 第 1 節同樣的問題——process 存在,但初始化還沒做完;如果部署腳本或依賴這個服務的其他流程,只用 process 是否存在當作就緒判斷,會在這段時間內對還沒準備好的服務發送流量。

Backend 連結

本機開發環境(macOS)用 launchd 管理背景服務(例如本機跑一個 mock server、本機 Postgres)時,寫啟動腳本或 CI 腳本如果需要「等服務真的就緒才繼續下一步」,不能只等launchctl list 顯示 process 存在,要額外加一段輪詢健康檢查 endpoint 的邏輯,這跟正式環境 Kubernetes 用 readiness probe 而不是只看 pod 是否 Running 是同一個道理。

陌生題

本機開發用的一份 CI 腳本,先用 launchctl load 啟動一個本機 mock API server,緊接著下一行就開始對它送測試請求。這個 mock server 啟動後需要先讀取一份 3MB 的 fixture 資料到記憶體(平均耗時 2 秒)才能正確回應請求。這份 CI 腳本會遇到什麼問題?最小的修正方式是什麼?(提示:launchctl load 只保證fork+exec 成功並回傳,不會等待 mock server 內部完成 fixture 載入,緊接著送出的測試請求很可能在還沒載入完成前就打到它,收到錯誤或不完整的回應;最小修正是在送測試請求前,加一段輪詢 mock server 健康檢查 endpoint(或直接重試前幾次請求)直到確認它真的就緒,而不是假設 launchctl load 回傳就代表可以開始送請求。)

Day 72 過關標準(DoD)—— OS/Deployment 特殊驗收格式(Proof 5 — Break:Setup / Break / Observe / Reason)

``text Setup: 用你的作業系統上可用的 Service Manager(Linux 用 systemd user unit,macOS 用 launchd plist)設定一個會執行、可能崩潰的小程式,Restart 策略設為「非正常結束才重啟」(systemd:Restart=on-failure;launchd: KeepAlive 的 SuccessfulExit:false)。Break: 讓這個程式以非 0 exit code 結束(例如程式碼裡故意 os.Exit(1)),對照組再測一次以 exit(0) 正常結束的情況。Observe: 記錄兩種結束方式各自有沒有觸發重啟(systemd 用systemctl --user status <unit>journalctl --user -u<unit> 查看重啟次數與時間戳;launchd 用 launchctl list<label> 查看 PID 是否變化、log show 查對應訊息),附上實際指令輸出。Reason: 對照 Topic 1「Restart=on-failure 只在非正常結束才重啟」的機制,解釋兩種結束方式觀察結果不同的原因;並回答:如果把 Restart 策略改成 systemd 的 Restart=always(或 launchdKeepAlive: true),正常結束(exit 0)那組的 Observe 結果會有什麼不同。``

Day 73 — cgroup 資源限制:MemoryHigh / MemoryMax / OOM Killer

學習目標

看完今天內容後,能夠:

  1. 解釋 MemoryHighMemoryMax 分別對應軟限制與硬限制,觸發後系統的行為各自是什麼。
  2. 說明 OOM killer 選擇要殺死哪個 process 的判斷依據,以及oom_score_adj 如何介入這個判斷。
  3. 解釋 RSS、anonymous memory、page cache 三者在 cgroup 記憶體計費時的差異,並用這個差異解釋「看起來吃很多記憶體」不一定代表真的會觸發 OOM。
  4. MemoryMax 套用 Proof 5 — Break:實際誘發一次 OOM,記錄被 OOM killer 選中的 process 與判斷依據。

教材大綱

1

cgroup 記憶體限制:MemoryHigh / MemoryMax / OOM Killer / RSS vs Anonymous Memory vs Page Cache

Core FundamentalsLv.4
一句話理解

cgroup(control group)是 Linux kernel 提供的資源邊界機制,能對一組 process 設定記憶體使用上限;當這組 process 的記憶體使用觸及設定的硬限制,kernel 會在這個 cgroup 的範圍內啟動 OOM killer,挑一個 process 殺掉來釋放記憶體,而不是讓整台機器上所有 process 一起陪葬。

Why

Day 72 的 Service Manager 保證「process 掛了會被拉回來」,但如果沒有資源限制,一個吃滿記憶體的 process(不管是正常的高負載,還是記憶體洩漏)會把整台機器的實體記憶體與 swap 都耗盡,這時觸發的是系統層級的 OOM killer,它挑選候選 process 的範圍是整台機器上所有 process,可能誤殺跟問題完全無關的其他 service;cgroup 的資源限制讓每個服務都被關在自己的記憶體邊界內,一個服務吃滿記憶體只會影響它自己(以及同一個 cgroup 裡的其他 process),不會拖垮同機器上完全無關的其他服務,這是多租戶或多服務共存於同一台機器時的基本防線。

Mechanism

cgroup v2 的記憶體控制器提供多層限制,其中最常用的兩個:memory.high軟限制——使用量超過這個門檻後,kernel 會對這個 cgroup 內的 process 施加記憶體回收壓力(優先回收可丟棄的 page cache)與節流,讓它變慢,但不會直接殺死任何 process;memory.max硬限制——使用量真的觸及這個門檻,且 kernel 已經無法透過回收(reclaim)騰出足夠空間時,就會觸發這個 cgroup 範圍內的 OOM killer。OOM killer 選擇要殺哪個 process 的依據是一個「badness」分數,主要由該 process 目前佔用的記憶體量(越多分數越高,越容易被選中)與 oom_score_adj 這個可調整值(範圍 -1000 到 1000)共同決定——oom_score_adj 設成 -1000 的 process 永遠不會被 OOM killer 選中(用於絕對不能被殺的關鍵 process),設得越高則越容易被優先選中,即使它佔用的記憶體不是最多的那個。判斷「目前佔用多少記憶體」時,RSS(resident set size,實際常駐在實體記憶體裡的頁面)、anonymous memory(heap、stack 這類沒有對應到任何檔案的記憶體頁)、page cache(快取檔案內容用的記憶體頁)三者在 cgroup 計費時是分開記帳的:page cache 是可回收的(kernel 可以直接丟棄乾淨的 page cache 頁面來騰出空間,不需要寫回磁碟,除非是 dirty page),anonymous memory 通常不可回收(沒有 swap 的情況下,anonymous memory 一旦被寫入就必須留在實體記憶體,直到 process 自己釋放或被殺死)——這代表一個 cgroup 的 memory.current 看起來很高,如果大部分是 page cache,觸及 memory.high 時 kernel 可以先靠回收 page cache 撐過去,不一定真的會走到 OOM;但如果大部分是 anonymous memory(例如記憶體洩漏累積出來的 heap),kernel 沒有太多可回收的空間,會更快真正觸發 memory.max 的 OOM killer。

Trade-off

memory.high(軟限制)讓系統有機會用「變慢」代替「直接殺掉」來爭取緩衝時間,適合用來提前預警與節流;但如果真正的使用量持續成長不停止,最終還是會撞上 memory.max。設定memory.max 太低,會讓正常運作、只是短暫尖峰用量較高的服務被誤殺;設得太高,等於失去資源邊界的保護意義,跟不設限制沒有太大差別。

Failure mode

只看 memory.current(或 ps/top 顯示的 RSS)就判斷「這個服務快爆記憶體了」,沒有拆解裡面 page cache 與 anonymous memory 各佔多少,容易誤判——例如一個服務大量讀取檔案把內容快取進 page cache,RSS 數字看起來很高,但這些是可以隨時被回收的,不代表真的接近 OOM;反過來,如果只監控「總記憶體用量有沒有超過門檻」而不細看趨勢,一個持續緩慢增長的 anonymous memory 洩漏,在真正觸發 OOM 之前可能已經悄悄運行了很久。

Backend 連結

容器化部署(Docker/Kubernetes)的記憶體limits 底層就是靠 cgroup 實作——Kubernetes 的 resources.limits.memory 對應 cgroup 的 memory.maxrequests.memory概念上更接近 memory.high 的預期用量;理解 cgroup 這層機制,才知道 Kubernetes pod 被 OOMKilled 時,看到的其實就是這裡教的「cgroup 範圍內的 OOM killer 選中了這個 process」,而不是一個黑盒子行為。

陌生題

一個服務的 cgroup memory.max 設為 512MB,監控顯示memory.current 已經達到 480MB 且持續好幾天沒有下降過,但服務從沒被 OOM killer 殺過。深入拆解發現這 480MB 裡有 420MB 是 page cache(這個服務會頻繁讀取一批固定的設定檔與範本檔案)。這代表這個服務真的快要 OOM 了嗎?如果同樣的監控數字,480MB 裡有 420MB 是 anonymous memory,結論會不會不同?(提示:如果 480MB 主要是 page cache,代表大部分是可回收的快取,kernel 在真正需要空間時可以直接丟棄這些乾淨的 page cache 頁面來騰出空間,實際上還沒有立即 OOM 的風險,即使 memory.current 數字看起來很逼近 memory.max;如果 480MB 主要是 anonymous memory,代表這些記憶體多半無法被 kernel 主動回收,memory.current 逼近memory.max 就代表真的快要撞上硬限制,是需要立刻調查(是否記憶體洩漏)的警訊。)

Day 73 過關標準(DoD)—— OS/Deployment 特殊驗收格式(Proof 5 — Break:Setup / Break / Observe / Reason)

``text Setup: 在 Linux 環境(本機若非 Linux,改用可取得的 Linux VM/容器)用 systemd-run --user --scope -p MemoryMax=64M <command>(或直接在 cgroup v2 的 cgroup 目錄下手動寫入 memory.max=64M)啟動一個會持續配置記憶體的小程式(例如 Go 程式持續 append 到一個 slice 且不釋放,或任何語言寫的「無限吃記憶體」的小腳本)。Break: 讓程式持續執行,直到它配置的記憶體超過 64MB 的限制。Observe: 用 dmesg | tail -30journalctl -k | tail -30找出 OOM killer 的紀錄,記下被選中的 process 名稱/PID,以及 log 裡出現的 badness/oom_score 相關數字;附上這段 log 的實際原文片段。Reason: 對照今天 Topic 1 的機制,解釋 log 裡顯示的判斷依據(例如被選中的 process 是不是這次 Setup 啟動的那一個、它的記憶體用量數字跟 64MB 限制的關係);並回答:如果同一個 cgroup 裡還有另一個記憶體用量很小、但 oom_score_adj 被手動設成 1000 的 process,Observe 這一步的結果會有什麼不同(提示:即使它記憶體用量遠小於超標的那個程式,過高的 oom_score_adj 仍可能讓它被優先選中)。``

Day 74 — /proc 觀測:rchar vs read_bytes 診斷「監控正常但系統很慢」

學習目標

看完今天內容後,能夠:

  1. 解釋 /proc/[pid]/iorcharread_bytes 的差異,並用這個差異判斷一次 I/O 是命中 page cache 還是真的打到磁碟。
  2. /proc 的數字診斷「監控指標(例如 CPU、記憶體)都正常,但系統實際上變慢」這類落差,並解釋這跟 Day 10 Saturation 指標是同一件事在 disk I/O 資源上的具體量測方式。
  3. 對這個診斷手法套用 Proof 5 — Break:實際比較兩種讀取模式下rchar/read_bytes 的真實數字差異。

教材大綱

1

/proc 觀測:rchar vs read_bytes

Core FundamentalsLv.4
一句話理解

/proc/[pid]/io 是 Linux kernel 針對每個 process 曝露的即時 I/O 統計檔案,其中 rchar(characters read)記錄這個 process 透過 read() 系統呼叫總共讀到多少 bytes(不管資料來源是磁碟還是 page cache),read_bytes 記錄的是實際從儲存裝置層讀取的 bytes 數(真正打到磁碟的量)——同一次read() 呼叫如果命中 page cache,rchar 會增加,但read_bytes 不會,因為根本沒有真的碰到磁碟。

Why

這正是 Day 10 教過的 Saturation(系統資源逼近上限的程度)在 disk I/O 這個資源上的具體量測方式——CPU 使用率、記憶體用量這些「常見監控指標」都正常時,系統實際變慢的原因常常藏在「大量 read() 呼叫,但監控儀表板沒有拆解出這些讀取到底是命中 cache 還是真的打到磁碟」這個盲區;只看「這個 process 呼叫了很多次 read()」(rchar 很高)容易誤判成 I/O 密集,但如果幾乎都是 page cache 命中,磁碟其實一點都不忙,真正的瓶頸在別的地方。

Mechanism

/proc/[pid]/io 每個 process 都有一份,內容包含rchar/wchar(讀/寫的 characters,涵蓋 page cache 命中)、read_bytes/write_bytes(讀/寫實際打到 block device 的 bytes,涵蓋 direct I/O 與 page cache miss 之後真正發起的磁碟讀寫)。判讀方式:rchar 遠大於 read_bytes,代表這個 process 的讀取請求絕大多數被 page cache 擋下、根本沒有真的碰到磁碟(例如重複讀取同一批已經被快取過的設定檔);如果兩者數字接近,代表這些讀取幾乎都是 cache miss,真的在打磁碟——這種情況下如果同時觀察到延遲升高,磁碟 I/O 才是真正的嫌疑對象。診斷「監控正常但系統變慢」的具體流程:先確認 CPU/記憶體等常規指標是否正常(Day 10 Four Golden Signals 裡的 Saturation 通常只監控到這個粒度)→ 找出可疑的 process,讀它的 /proc/[pid]/io→ 比較 rcharread_bytes 的比例與成長速度 → 如果read_bytes 持續快速成長且接近磁碟頻寬上限,代表真正的 Saturation 發生在 disk I/O 這個資源上,只是常規監控儀表板沒有把它畫出來。

Trade-off

/proc 提供的是即時、per-process 的細粒度數字,排查單一 process 的 I/O 行為非常直接;代價是它不是一個「持續記錄的時間序列」——/proc/[pid]/io 只能看到「當下這一刻」的累積值,要看出趨勢(例如過去 10 分鐘 read_bytes 是不是持續升高),需要自己寫腳本定期採樣、計算差值,或依賴額外的監控系統(例如 node_exporter)把這些數字轉換成真正的時間序列 Metrics(Day 10)。

Failure mode

只看 rchar 就判定「這個 process I/O 很重」,沒有對照 read_bytes,會把「大量命中 cache、對磁碟毫無壓力」的 process 誤判為 I/O 瓶頸,浪費時間去優化一個根本不是問題的地方;反過來,如果監控系統完全沒有採集這兩個數字(很多預設的監控 dashboard 只有 CPU/記憶體/網路,沒有拆解到這一層),會在真正發生磁碟 I/O saturation 時完全看不到警訊,只看到「延遲升高但 CPU/記憶體都正常」這種令人困惑的落差。

Backend 連結

資料庫伺服器(例如 PostgreSQL)自己的 buffer cache(Day 32)與作業系統的 page cache 是兩層獨立的快取,排查「資料庫查詢變慢,但 DB 自己的 buffer cache hit rate 看起來正常」這類問題時,/proc/[pid]/io 能幫忙判斷 PostgreSQL process 本身對底層磁碟的實際 I/O 壓力,是資料庫效能排查工具箱裡,比只看應用層監控更底層的一層證據。

陌生題

一個服務的應用層監控顯示 CPU 使用率 20%、記憶體用量穩定、但使用者回報頁面回應時間從平常的 50ms 變成 800ms。排查發現這個服務會在每次請求時重新讀取一份 2GB 的資料檔案的一小部分內容。第一步該去 /proc/[pid]/io 確認什麼?如果發現rchar 快速增加但 read_bytes 幾乎不動,這代表問題出在哪裡;如果兩者一起快速增加,結論又會不同——分別說明。(提示:先確認rcharread_bytes 的比例與成長速度;如果 rchar 快速增加但 read_bytes 幾乎不動,代表讀取幾乎都命中 page cache、沒有真的打到磁碟,延遲升高的原因不是磁碟 I/O,該往其他方向排查(例如 CPU cache miss、鎖競爭、下游依賴變慢);如果兩者一起快速增加,代表這些讀取大量 cache miss、真的在打磁碟,可能是可用於 page cache 的記憶體不足(被其他 process 或這個服務自己的其他用量擠掉)導致重複讀取同一份資料卻無法穩定留在 cache 裡,這時該去查記憶體壓力或考慮加大快取。)

Day 74 過關標準(DoD)—— OS/Deployment 特殊驗收格式(Proof 5 — Break:Setup / Break / Observe / Reason)

``text Setup: 寫一個小程式,重複讀取同一個至少 50MB 大小的檔案的內容(例如迴圈 read 同一個檔案 20 次),並記下開始前先用echo 3 | sudo tee /proc/sys/vm/drop_caches(Linux;若無 root 權限或非 Linux 環境,改為換一個檔案系統快取未命中過的全新檔案)清空 page cache,確保第一次讀取一定是 cache miss。Break: 執行這個程式,在執行「期間」(不是結束後)持續採樣同一個 process 的 /proc/[pid]/io(例如每秒一次 cat/proc/<pid>/io),記錄 rchar 與 read_bytes 隨時間的變化。Observe: 附上第一次讀取(cache miss)與後續重複讀取(cache hit)各自對應的 rchar/read_bytes 數字快照,應該能看到 read_bytes 在第一次讀取後幾乎不再增加,但 rchar 隨每次讀取持續累加。Reason: 對照今天教材,解釋這個數字差異背後的機制(page cache 命中不需要再打磁碟);並回答:如果這個程式每次讀取的都是不同的新檔案(而不是重複讀同一份),Observe 這一步預期會看到什麼不同的模式(提示:每次都是 cache miss,rchar 與 read_bytes 會同步持續增加,不會出現「read_bytes 停滯但 rchar 持續增加」的落差)。``

Day 75 — Phase 08 OS/Deployment 收尾 Capstone:部署生命週期端到端 + 自我更新陷阱

學習目標

看完今天內容後,能夠:

  1. 解釋自我更新陷阱的成因:為什麼替換掉磁碟上的執行檔,不代表正在執行中的 process 會自動變成新版本,以及 /proc/self/exe帶上 (deleted) 後綴的具體原因。
  2. 區分「替換執行檔」與「讓新版本真正生效(reload/restart)」是兩個不同的動作,並針對一次部署設計正確的先後順序。
  3. 完整串起 Day 71–74 全部機制,對一次模擬部署套用 Proof 5 —Break,交付端到端的觀察紀錄,作為 Phase 08 OS/Deployment 這一段的收尾驗收。

教材大綱

1

自我更新陷阱:/proc/self/exe(deleted) 後綴

Core FundamentalsLv.4
一句話理解

一個正在執行中的 process,如果磁碟上原本對應的執行檔被替換(例如部署腳本直接 mv new-binary old-binarycp 覆蓋),這個 process 讀取 /proc/self/exe(指向自己執行檔路徑的符號連結)會看到路徑後面多了 (deleted) 後綴——即使檔案系統上那個路徑現在其實有一個新檔案存在。

Why

這是 Day 71–74 教過的 Process 生命週期、Signal、Service Manager、cgroup 這幾層機制全部正常運作之後,部署時仍然可能踩到的一個具體陷阱——它直接挑戰一個常見的直覺假設:「我把新版本的執行檔複製過去,蓋掉舊的,服務就自動變成新版本了」,理解這個陷阱背後的機制,才知道正確的部署動作應該是什麼。

Mechanism

Linux 檔案系統裡,一個檔案的「路徑」與它實際的資料內容(由 inode 代表)是分開的兩件事。一個 process 執行exec() 啟動時,kernel 會開啟對應路徑的檔案、拿到它的 inode,並把這個 inode 的內容映射進這個 process 的記憶體位址空間——之後即使檔案系統上那個路徑被 unlink(刪除路徑對這個 inode 的參照,mv/cp 覆蓋常見的實作方式本質上就是「建立新檔案 +unlink 舊路徑」),只要還有 process 持有這個 inode 的開啟參照(open file descriptor 或記憶體映射),這個 inode 本身不會被真正回收,資料依然存在磁碟上,只是路徑名稱已經不再指向它。/proc/self/exe 這個符號連結永遠指向「這個 process 當初 exec 時映射進來的那個 inode」——如果對應的路徑已經被 unlink(不管是被刪除還是被覆蓋),readlink /proc/self/exe 讀到的結果會是原本的路徑字串加上 (deleted) 後綴,用來明確標示「這個路徑現在其實已經不指向任何檔案了,你看到的是一個已經從目錄裡消失、但仍然被某個 process 開著的舊 inode」。這個機制同時解釋了另一個常見現象:部署時「刪除舊版本執行檔」卻發現磁碟使用量沒有立刻下降——因為只要正在執行的舊 process 還沒結束,這個已經 unlink 的 inode 就還占著磁碟空間,直到那個 process 真正結束、最後一個參照消失,磁碟空間才會被真正釋放;用 lsof | grep deleted 可以找出這類「已刪除但仍被某個 process 開著」的檔案。

Trade-off

這個「inode 與路徑分離」的設計讓 Linux 能安全地支援「一邊執行舊版本,一邊用新版本覆蓋同一個路徑」而不會讓正在執行的 process 突然讀到一半新一半舊的殘缺內容(因為舊 process 繼續看著自己當初映射的那個完整 inode,完全不受路徑被覆蓋影響);代價正是這裡教的陷阱——「路徑已經是新版本」不代表「正在跑的 process 是新版本」,這個落差如果沒有被正確處理,會造成「明明已經部署了新版本,服務卻還在用舊邏輯回應」的困惑,且舊 inode 占用的磁碟空間也不會如預期釋放。

Failure mode

部署腳本只做了「複製新執行檔覆蓋舊路徑」就認為部署完成,沒有接著讓服務真正重啟(或觸發應用程式自己實作的 reload 邏輯),會造成「部署了但沒生效」——服務其實還是舊版本的邏輯在跑,只是磁碟上的檔案內容已經是新的;另一種常見的困惑是「執行檔明明刪掉了,df 看到的磁碟用量卻沒有變化」,原因正是還有 process 開著那個已經 unlink 的 inode。

Backend 連結

正確的部署動作要把「替換執行檔」與「讓新版本生效」明確拆成兩步:先把新版本放到磁碟(或新路徑),接著用 Day 72 教過的 Service Manager 機制重啟這個服務(讓 Service Manager 對新的執行檔路徑重新 fork+exec,這個新 process 會映射到新的 inode),或者應用程式自己實作優雅的熱重啟(例如 nginx/HAProxy 常見的做法:新 process 先綁定好 socket,把 listening file descriptor 從舊 process 交接給新 process,舊 process 處理完在途請求後才真正退出,這樣切換過程使用者完全無感),單純「覆蓋檔案」永遠不等於「新版本已經在處理請求」。

陌生題

一份自動化部署腳本執行以下步驟:(1) 下載新版本執行檔到 /opt/app/server.new (2) mv /opt/app/server.new/opt/app/server(覆蓋掉正在被目前 process 執行的舊檔案)(3)腳本結束,沒有呼叫任何重啟指令。10 分鐘後有人回報「明明部署了新版本,但 bug 還在」。用今天教的機制解釋這個現象,並說明最小的修正方式。(提示:mv 覆蓋掉的是路徑對應的 inode 參照,正在執行中的舊 process 早在 exec() 時就已經把舊 inode 的內容映射進自己的記憶體,mv 完全不影響這個已經在跑的 process,它會繼續用舊版本的程式碼邏輯處理請求,直到它真正結束;最小修正是在步驟 (2) 之後,接著呼叫 Service Manager 的重啟指令(例如systemctl --user restart <unit>),讓一個全新的 process 針對新的 /opt/app/server 路徑重新 exec,這個新 process 才會真正映射到新版本的 inode。)

Day 75 過關標準(DoD)—— Phase 08 OS/Deployment 收尾 Capstone(綜合 Setup / Break / Observe / Reason)

```text Capstone 任務規格:對應第 12 章 OS/Deployment 驗收1「讓一個 process crash,觀察 Service Manager 是否依 Restart 策略拉回來;設定 MemoryMax,誘發一次 OOM,貼出被選中的 process 與判斷依據」,把 Day 71–75 全部機制串成一次端到端的模擬部署練習:

Setup: 準備一個小型長running 服務(可沿用 Day 71–74 練習用過的程式基礎),用 Day 72 的 Service Manager 設定管理(Restart 策略、依你的平台選 Type=notify 或 launchd 對應設定),並用 Day 73 的 cgroup 設定一個合理的 MemoryMax。

Break(依序完成,每一項都要留下 Day 71–74 那樣的具體觀察證據,不能只寫結論):1. 模擬崩潰:讓服務以非 0 exit code 結束,確認 Service Manager 依 Restart 策略拉回來(對應 Day 72 DoD)。2. 模擬資源超限:讓服務的記憶體用量超過 MemoryMax,確認 OOM killer 被觸發、記下被選中的 process 與 dmesg/journalctl 裡的判斷依據(對應 Day 73 DoD)。3. 模擬部署:先用今天 Topic 1 的方式,只替換服務的執行檔、不重啟,用 /proc/[pid]/exe(Linux;lsof -p <pid> | grep server 亦可作為輔助佐證)確認正在執行的 process 仍指向帶 (deleted) 後綴的舊 inode;接著正確執行 Service Manager 重啟,確認新 process 映射到新版本。

Observe: 把三項 Break 各自的實際指令輸出/log 片段整理成一份完整紀錄(可以是三段落,對應上面三項)。

Reason: 用自己的話寫一段,串連 Day 71–75 全部機制回答:一次完整的部署/故障恢復,從「process 死掉」到「系統自動偵測、重啟、且不超過資源邊界、且真正跑的是正確版本」,中間依序經過了哪些你今天實測過的層級?如果拿掉其中任何一層(例如沒有 cgroup 限制、或部署時忘記重啟),會分別在哪個環節出問題?```

  1. 依 00-master-curriculum.md 第 12 章 OS/Deployment 驗收

Day 76 — Authentication vs Authorization / Session 與 Token 兩種身份驗證模型 / Access Token 與 Refresh Token 的分工

學習目標

看完今天內容後,能夠:

  1. 清楚區分 Authentication(你是誰)與 Authorization(你能做什麼)是兩個獨立問題,並舉一個「Authentication 通過但 Authorization 失敗」的具體例子。
  2. 解釋 Session-based 與 Token-based(JWT)兩種身份驗證模型的核心差異,並具體回答「Token-based authentication 到底解決什麼問題?」
  3. 說明 Access Token 與 Refresh Token 為什麼要拆成兩個不同生命週期的憑證,而不是用一個 Token 打天下。

教材大綱

1

Authentication vs Authorization

Core FundamentalsLv.4
一句話理解

Authentication 回答「你是誰」(驗證身份是否為聲稱的那個人),Authorization 回答「你能做什麼」(已知身份後,判斷這個身份對特定資源/操作有沒有權限)——兩者是先後兩個獨立問題,Authentication 一定先於 Authorization,但 Authentication 通過不代表 Authorization 一定通過。

Why

這組區分聽起來直覺,但混淆兩者在系統設計上引發的錯誤依然常見:把「這個 request 帶的 token 有效」直接當成「這個使用者可以操作這筆資料」,跳過了 Authorization 檢查——這是多租戶系統最常見的資料外洩根因之一(tenant A 的合法 token 被拿去存取 tenant B 的資料,因為系統只驗證了 token 有效性,沒有驗證 token 對應的 tenant/資源邊界)。

Mechanism

Authentication 的產出是一個「已驗證身份」的宣稱(claim),通常是 user_id 或更豐富的身份資訊;Authorization 拿這個身份宣稱去對照一個授權模型(例如 RBAC 角色比對、ABAC 屬性比對、或最基本的 owner-only 檢查)判斷是否放行。一個請求要通過兩個獨立的檢查點(authentication middleware 驗證 token/session 有效 →authorization 邏輯驗證這個身份對這個資源的操作是否被允許),中間任何一個環節混在一起(例如 authorization 邏輯只檢查「有沒有 token」而沒有檢查「這個 token 的身份是否等於這筆資料的擁有者/所屬 tenant」)都會產生越權漏洞(IDOR,Insecure Direct Object Reference 的一種常見成因)。

Trade-off

把 Authentication 與 Authorization 明確拆成兩層,換來清楚的責任邊界(Authentication 只管「這是誰」,Authorization 只管「這個誰能做什麼」,各自可以獨立測試、獨立換實作),代價是需要在每一個資源存取路徑上都記得呼叫 Authorization 檢查——如果系統設計成「只要 Authentication 通過,預設放行」而後面才個別補 Authorization 檢查,很容易漏掉某些路徑。

Failure mode

多租戶系統最典型的錯誤是「Authentication 驗證了 token 屬於某個真實使用者」就直接信任 request 裡帶的 tenant_id/resource_id 參數,沒有另外驗證「這個身份是否真的屬於這個 tenant_id」——攻擊者只要換掉 URL 或 body 裡的 ID 參數,就能存取其他 tenant 的資料,即使他的 token 完全合法。

Backend 連結

這是 Day 77 Rotation/Revocation 要處理的「身份憑證本身安全性」之外,另一層「憑證有效不代表操作合法」的獨立防線;REST API 設計中常見的 middleware chain(先 authentication middleware,再 authorization middleware/policy check)就是把這兩層檢查明確拆開實作的具體體現。

陌生題

一個多租戶 SaaS 系統的 APIGET /api/tenants/{tenant_id}/invoices/{invoice_id},Authentication middleware 驗證了 request 帶的 JWT 有效且解出 user_id=42。這支 API 的 handler 直接用 URL 裡的 tenant_id 去資料庫查 invoice 並回傳,完全沒有另外檢查 user_id=42 是否真的屬於這個 tenant_id。請說明這個設計會造成什麼具體攻擊,以及最小修正方式是什麼。(提示:攻擊者只要是任何一個合法登入的使用者(拿得到自己的有效 JWT),就能把 URL 裡的 tenant_id 換成別人的 tenant_id 讀到別人的 invoice——這是 Authentication 通過但 Authorization 完全沒做的典型 IDOR。最小修正是在 handler 裡另外查 user_id=42 所屬的 tenant_id 清單,確認 URL 裡的 tenant_id 在清單內才放行,而不是信任 URL 參數。)

2

Session-based 與 Token-based(JWT)身份驗證模型

Core FundamentalsLv.4
一句話理解

Session-based 驗證把身份狀態存在 server 端(一份 session store,key 是 session id),瀏覽器只帶著這個 session id;Token-based(以 JWT 為代表)驗證把身份狀態整個簽章後放進一個自我描述(self-contained)的字串裡直接交給 client 保管,server 驗證時只需要驗證簽章,不需要查任何 server 端狀態存放區。

Why

Token-based authentication1 到底解決什麼問題?——它解決的是 Session-based 模型在「多台 server/水平擴展」情境下的一個具體痛點:Session store 需要是一個所有 server instance 都能存取的共用資料存放區(例如 Redis),每次驗證身份都要多一次網路呼叫查 session store;如果 session store 掛了或延遲高,整個系統的身份驗證都被拖累。JWT 把身份資訊本身簽章後直接交給 client,server 收到 request 時只需要用自己持有的密鑰驗證簽章是否有效(純本地運算,不需要查任何外部狀態),這讓身份驗證這一層變成無狀態(stateless),天然適合水平擴展與跨服務(microservice 之間互相信任同一份簽章)的場景。

Mechanism

JWT(JSON Web Token)由三段用 . 串接的 Base64URL 編碼字串組成:Header(宣告簽章演算法,例如 HS256/RS256)、Payload(實際的身份宣稱 claims,例如 sub(使用者 id)、exp(過期時間)、自訂欄位如 tenant_id)、Signature(用 Header 宣告的演算法,對 Header.Payload 這段字串計算出的簽章,用來證明這份內容沒有被竄改)。驗證方在收到 JWT 時,重新計算一次「Header.Payload」的簽章,跟 Token 裡帶的 Signature 比對,只要密鑰沒外流、演算法沒被繞過,任何篡改 Payload 的行為都會讓重算出的簽章對不上;驗證通過後,Payload 裡的內容(包含 exp)可以直接被信任使用,不需要再查任何資料庫。對稱簽章(HS256,簽發方與驗證方共用同一把密鑰)適合單一系統自己簽發自己驗證;非對稱簽章(RS256,簽發方用 private key 簽、驗證方用對應的 public key 驗)適合多個獨立服務都需要驗證同一份 token,卻不能讓每個服務都持有能簽發 token 的 private key 的場景。

Trade-off

JWT 換來的是驗證不需要查詢任何共用狀態(無狀態、易水平擴展),代價是「Revocation(提前讓一個尚未過期的 token 失效)」變得困難——因為 server 端根本沒有「這個 token 還有效」的狀態紀錄,一個已經簽發出去、還沒到期的 JWT,理論上在到期前都會驗證通過,即使使用者已經登出或帳號已被停用(這正是 Day 77 要處理的問題);Session-based 模型反過來,Revocation 只要刪掉 session store 裡對應的那筆紀錄就立即生效,但代價是每次驗證都要多一次查詢,且水平擴展需要額外的共用 session store。

Failure mode

最常見的錯誤是把敏感資訊(密碼、信用卡號)直接放進 JWT 的 Payload——Payload 只是 Base64URL 編碼,不是加密,任何人拿到這串字串都能直接解碼讀出 Payload 內容(只是不能竄改,因為竄改會讓簽章驗證失敗),只做簽章不做加密的 JWT 完全不適合放真正需要保密的資料;另一個常見錯誤是驗證方沒有嚴格檢查 Header 裡宣告的演算法,如果驗證邏輯允許 alg: none(明確宣告不簽章)或允許把RS256 換成 HS256 讓攻擊者用公開的 public key 當作 HMAC 密鑰偽造簽章,會讓整個簽章驗證形同虛設。

Backend 連結

這是 Phase 01 Day 4 教過的 Cookie/Session 概念在「無狀態」這個維度上的延伸——Day 4 提到 Session ID 靠 Cookie 帶回 server 查表,JWT 則是把「查表」這一步換成「驗證簽章」,讓身份驗證這一層不再需要一個所有 server 都能存取的共用狀態;microservice 架構下,一個 API Gateway 驗證使用者登入後簽發 JWT,後面每個獨立微服務只要持有同一把驗證用的 public key(或共用密鑰),就能各自獨立驗證這個 JWT,不需要每次都回頭問發 token 的那個服務。

陌生題

一個系統把使用者的完整個人資料(含身份證字號)直接放進 JWT 的 Payload 簽發給前端,理由是「反正有簽章,資料不會被竄改」。這個設計有什麼安全問題?如果同一個系統的驗證邏輯又允許前端在 request header 自行宣告 alg: HS256(即使原本是用RS256 簽發的),會造成什麼更嚴重的後果?(提示:JWT 的簽章只保證「內容沒被竄改」,不保證「內容保密」——Payload 只是 Base64URL 編碼,任何攔截到這個 token 的人(例如透過瀏覽器開發者工具、或中間人攻擊)都能直接解碼讀出身份證字號,造成個資外洩;如果驗證邏輯允許前端指定 alg,攻擊者可以把 Header 的 alg 改成 HS256,並用系統對外公開的 RS256 public key(通常是公開的)當作 HMAC 的共用密鑰重新計算簽章——因為驗證方此時是用「HS256 + 這把 public key 當密鑰」去驗證,而這把 public key 攻擊者本來就拿得到,等於攻擊者可以偽造任意合法簽章的 token,這是真實發生過的 JWT 函式庫漏洞類型,稱為 algorithm confusion attack。)

3

Access Token 與 Refresh Token 的分工

Core FundamentalsLv.4
一句話理解

Access Token 是短生命週期(通常 5–30 分鐘)、每次 API 請求都會用到的憑證,直接證明「這個 request 現在可以做這件事」;Refresh Token 是長生命週期(通常數天到數週)、只在 Access Token 過期時用來換發新 Access Token 的憑證,平常不會被拿去直接存取一般資源 API。

Why

如果只用一個長生命週期的 Token 打天下,會遇到一個直接的矛盾:Token 生命週期長,才能讓使用者不用一直重新登入(好的使用體驗);但 Token 生命週期長,也代表如果這個 Token 外洩(被竊、被記錄在不安全的 log),攻擊者能濫用的時間窗也長,且因為 JWT 本身無狀態、Revocation 困難(見 Topic 2),這個風險視窗幾乎沒有補救手段。拆成兩種 Token 是解決這個矛盾的具體做法:Access Token 生命週期短,即使外洩,能被濫用的時間窗也短;Refresh Token 生命週期長但使用頻率低(只在換發時用到),可以搭配更嚴格的保護措施(例如只在換發 API 這一條路徑上使用、搭配 Day 77 的 Rotation/Reuse Detection)。

Mechanism

標準流程是——使用者登入成功後,同時取得一組 Access Token 與 Refresh Token;後續每次呼叫一般資源 API 都帶 Access Token;Access Token 過期後(伺服器驗證 exp 發現已過期,回 401),前端改呼叫一支專門的 /token/refresh API,帶上 Refresh Token 換一組新的 Access Token(通常連同新的 Refresh Token 一起換發,見 Day 77 Rotation);只有在 Refresh Token 本身也過期或被判定失效時,才真正要求使用者重新登入。這個設計把「驗證身份是否還有效」(低頻率、可以做更嚴格檢查)跟「證明這個 request 現在可以做這件事」(高頻率、需要低延遲)這兩件事的頻率拆開,讓高頻率路徑(一般 API 呼叫)可以維持 JWT 無狀態驗證的效能優勢,同時把真正需要謹慎處理的長期憑證侷限在低頻率、單一用途的換發路徑上。

Trade-off

拆成兩個 Token 換來「短生命週期 Token 外洩風險視窗小」與「使用者不用頻繁重新登入」兩者兼顧,代價是前端需要額外實作換發邏輯(偵測 401、自動用 Refresh Token 換新 Access Token、重試原本失敗的請求),並且多了一個需要妥善保護的長期憑證(Refresh Token 通常需要比 Access Token 更嚴格的儲存方式,例如 HttpOnlyCookie 而非可被 JavaScript 讀取的儲存位置,降低被 XSS 竊取的風險)。

Failure mode

常見誤用是把 Refresh Token 跟 Access Token 用同樣寬鬆的方式儲存(例如都放進 localStorage,能被任何跑在同一個網頁上的 JavaScript 讀取),一旦網站有 XSS 漏洞,攻擊者不只偷到短命的 Access Token,連長生命週期的 Refresh Token 都一併偷走,兩個 Token 分層防禦的設計意義就被破壞了;另一個常見錯誤是 Refresh Token 換發 API 沒有做任何額外的身份驗證強化(例如沒有搭配裝置指紋或 IP 變化偵測),把它當成一般 API 隨意串接。

Backend 連結

這組分工是 Day 77 Rotation/Revocation/Reuse Detection 這幾個安全機制存在的前提——如果沒有先把「長期憑證」與「日常使用憑證」拆開,就無從談「偵測 Refresh Token 是否被重放」這件事(因為沒有一個明確、低頻率、單一用途的換發路徑可以監控);多租戶 fintech 系統常見的「登入後一段時間沒操作要求重新驗證敏感操作」這類機制,也是在 Access Token 短生命週期之上再疊加的一層額外防線。

陌生題

一個系統的 Access Token 與 Refresh Token 生命週期都設定為 7 天(理由是「這樣使用者比較不會一直被要求重新登入」)。這個設計的風險是什麼?如果 Access Token 改成 15 分鐘、Refresh Token 維持 7 天,前端需要額外處理什麼情境,才不會讓使用者的操作被打斷?(提示:Access Token 生命週期跟 Refresh Token 一樣長,等於失去了「短生命週期降低外洩風險視窗」的意義——Access Token 本身通常沒有 Rotation/Reuse Detection 這類額外防線(那是 Refresh Token 換發路徑的設計),一旦外洩,攻擊者有整整 7 天的濫用時間窗;改成 Access Token 15 分鐘後,前端需要實作「偵測 API 回應 401(Access Token 過期)→ 自動呼叫換發 API 拿新 Access Token → 用新 Access Token 重試原本失敗的那個請求」這一整套邏輯,否則使用者會在每次 Access Token 過期時看到操作失敗,而不是無感地被自動換發延續。)

Day 76 過關標準(DoD)

``text 1. 用任一語言/框架的 JWT 函式庫(或手刻 HMAC-SHA256 簽章邏輯),寫一支小程式完成:(a) 簽發一個帶 sub/exp/tenant_id 欄位的 JWT,(b) 驗證這個 JWT 的簽章與 exp 是否有效,(c) 故意竄改 Payload 裡的 tenant_id 一個字元後重新驗證,確認驗證會失敗並印出實際的錯誤訊息。附三段實際輸出(原始 token 字串、驗證成功的結果、竄改後驗證失敗的錯誤訊息)。2. 針對 Topic 1 的陌生題情境(多租戶 IDOR),寫出你會怎麼修正那支 API handler 的虛擬碼或實際程式碼片段,並說明修正後為什麼能擋下原本的攻擊路徑。3. 針對 Topic 3,畫出(或用文字描述)Access Token 過期後,前端從偵測 401 到成功用 Refresh Token 換發、重試原請求的完整流程圖,並標明哪一步如果失敗(例如 Refresh Token 也過期)應該導向「要求重新登入」。``

  1. 依 00-master-curriculum.md 第 12 章 Auth 段落核心問題

Day 77 — Token 生命週期安全機制:Expiration / Rotation / Revocation / Reuse Detection

學習目標

看完今天內容後,能夠:

  1. 說明 Access Token 與 Refresh Token 各自的 Expiration 時間該怎麼決定,並解釋這個決定背後在權衡什麼。
  2. 完整實作 Refresh Token Rotation:每次換發都同時淘汰舊的、發出新的,並具體解釋這如何限縮 Token 外洩後的可利用視窗。
  3. 具體寫出 Reuse Detection 的判斷邏輯:偵測到一個已經被淘汰的 Refresh Token 又被拿來用時,系統該怎麼反應。
  4. 解釋 Revocation(主動失效)在 JWT 無狀態模型下要額外付出什麼代價才能實作。

教材大綱

1

Token 生命週期安全機制:Expiration / Rotation / Revocation / Reuse Detection

Core FundamentalsLv.4
一句話理解

這四個機制合起來回答同一個問題的不同面向——「一組 Token 從簽發到失效的過程中,如果中途被偷了,系統要怎麼把損害限制在最小範圍」:Expiration 限制「即使沒被偵測到,最晚什麼時候會自動失效」,Rotation 讓「每個 Refresh Token 只能用一次」,Revocation 提供「不用等到過期,主動讓它立即失效」的手段,Reuse Detection 則是「偵測到有人拿一個已經作廢的 Token 硬要用」時的主動示警與應對機制。

Why

Day 76 講過 JWT 最大的代價是「Revocation 困難」——一個已簽發、還沒到期的 JWT,光靠驗證簽章這件事本身無法判斷它是否「應該」已經失效。這四個機制合起來就是在補這個結構性缺口:不是靠改變 JWT 本身的驗證方式(那樣會失去無狀態的優勢),而是在 Refresh Token 換發這條低頻率路徑上,額外維護一份輕量的伺服器端狀態(記錄「哪些 Refresh Token 已經被淘汰」),把「檢查是否作廢」這件成本較高的操作侷限在低頻率的換發路徑,日常高頻率的 Access Token 驗證仍然維持純本地運算的無狀態優勢。

Mechanism

完整流程串起來看——Expiration:Access Token 設定短 exp(例如 15 分鐘)、Refresh Token 設定長 exp(例如 7–14 天,通常搭配「Refresh Token 本身也有絕對上限,即使一直被正常使用換發,超過這個絕對上限還是強制要求重新登入」這個 absolute expiration 設計,避免一個 Refresh Token 靠不斷換發永遠不用重新登入)。Rotation:每次前端拿舊 Refresh Token 換新 Access Token 時,伺服器同時簽發一組全新的 Refresh Token 一起回傳,並把剛剛用掉的那個舊 Refresh Token 標記為已作廢(存進一份伺服器端的狀態,例如資料庫的一個 token_family 表,記錄每個 Refresh Token 的 jti(JWT ID,簽發時給的唯一識別碼)與狀態)——這代表任何一個 Refresh Token 從邏輯上只能成功換發一次。Revocation:維護一份「已撤銷」的狀態(可以是資料庫欄位、也可以是短 TTL 的 Redis 黑名單,TTL 設成等於 Token 剩餘的有效期,過了 Token 本來就會過期,黑名單紀錄也就不需要再留),使用者登出、更改密碼、或管理員強制登出時,把對應的 jti(或整個 token family)標記為已撤銷;驗證換發請求時多查一次這份狀態(Access Token 本身的高頻率驗證仍然不查,只有 Refresh Token 換發這條低頻率路徑查)。Reuse Detection:因為 Rotation 保證「每個 Refresh Token 只能成功換發一次」,如果換發請求帶來的 Refresh Token 在資料庫裡的狀態已經是「已作廢」(代表這個 Refresh Token 之前已經被用掉換發過一次了),這只有兩種可能——使用者自己的舊分頁重複送出了已經過期的換發請求(正常但少見的競態),或是這個 Refresh Token 已經外洩、被攻擊者搶先用掉,現在合法使用者手上還拿著同一個已經失效的舊 Refresh Token 也在嘗試使用。因為系統無法區分這兩種情況,Reuse Detection 的標準做法是「保守假設是攻擊」:一旦偵測到已作廢的 Refresh Token 被重用,立刻撤銷這整個 token family(這一串從最初登入開始、透過 Rotation 一路換發下來的所有 Refresh Token),強制這條裝置/session 鏈上的使用者必須重新登入。

Trade-off

這整套機制換來「Refresh Token 外洩後,攻擊者最多只能換發一次(拿到一組新 token),且這個動作會讓合法使用者原本手上的 Refresh Token 失效,下次合法使用者嘗試用自己的(現在已過期)Refresh Token 換發時,系統會偵測到 Reuse 並整條撤銷」——把原本「Refresh Token 外洩=任何時候都能被拿來換發,直到它自然過期」的無限風險視窗,壓縮到「最多被利用一次,且會被偵測到並觸發全面撤銷」;代價是需要維護一份伺服器端狀態(不再是純無狀態),且每次換發都多一次資料庫讀寫,換發路徑的複雜度提高(需要正確處理「同時有兩個請求用同一個 Refresh Token 換發」這種競態,通常要在資料庫層用交易或唯一約束保證只有一個請求成功標記舊 token 作廢)。

Failure mode

最常見的錯誤是「只做 Rotation、沒做 Reuse Detection」——每次換發都發新的、舊的作廢,但偵測到舊 token 被重用時只是回一個 401 錯誤,沒有進一步撤銷整個 token family,這樣攻擊者偷到 Refresh Token 後,只要搶在合法使用者之前用掉,拿到一組新 token 繼續使用,合法使用者的下一次換發請求失敗,頂多重新登入,攻擊者反而暢行無阻(因為系統沒有意識到剛剛那次「舊 token 被重用」代表的是攻擊訊號);另一個常見錯誤是 Revocation 黑名單沒有設定 TTL 或設定成永久保留,隨著時間推移黑名單無限增長,拖慢每次查詢速度。

Backend 連結

token_family 這種設計本質上是用一個小型的伺服器端狀態機,換取「無狀態 JWT 帶來的效能優勢」與「可以主動撤銷/偵測異常」這兩個原本互斥的目標同時成立;這跟 Day 73(cgroup)教過的「軟限制先變慢預警、硬限制才真正動手」是類似的分層防禦思路——Rotation 是持續運作的一般機制(類比軟限制的持續回收),Reuse Detection 觸發整族撤銷則是偵測到明確異常訊號後的強制動作(類比硬限制觸發 OOM killer)。多租戶 fintech 系統常見的「同一個帳號在不尋常地點登入後,強制所有裝置重新登入」功能,底層往往就是靠撤銷整個 token family(或該使用者名下所有 token family)實作。

陌生題

一個系統實作了 Rotation(每次換發都發新 Refresh Token、作廢舊的),但沒有實作 Reuse Detection(偵測到舊 token 被重用時只回 401,沒有撤銷整個 family)。攻擊者透過某種方式偷到使用者的 Refresh Token(例如 XSS),搶先用掉换到一組新 token。請推演接下來合法使用者與攻擊者各自會發生什麼事,並說明加上 Reuse Detection 後,這個情境會在哪一步被攔截。(提示:攻擊者用掉舊 Refresh Token 後拿到一組全新的 Access/Refresh Token,可以持續正常存取;合法使用者手上的 Refresh Token(現在已經是「已作廢」狀態)在它自己的 Access Token 過期、需要換發時送出換發請求,系統發現這個 Refresh Token 已經是作廢狀態,只回 401,合法使用者被迫重新登入,但攻擊者手上那組新 token 完全不受影響、繼續有效;加上 Reuse Detection 後,系統在合法使用者送出那個「已作廢」的 Refresh Token 時,會判定這是重用訊號,撤銷整個 token family——這代表攻擊者剛剛換到的那組新 token 也會一併失效,攻擊者跟合法使用者都被踢回重新登入,雖然合法使用者一樣被迫重新登入,但攻擊者的存取視窗也同時被關閉,而不是像沒有 Reuse Detection 時那樣可以持續存取下去。)

Day 77 過關標準(DoD)

``text 1. 設計並寫出(可用虛擬碼或實際程式碼,需包含資料表/資料結構定義)一個 Refresh Token 的 token_family 追蹤機制:至少要有 jti、family_id、狀態(active/rotated/revoked)三個欄位,並寫出「換發」這支函式的完整邏輯(含判斷 Reuse 並撤銷整個 family 的分支)。2. 對你在第 1 題寫的換發邏輯,實際跑三種情境並附執行結果(可用測試程式或手動呼叫):(a) 正常換發一次成功,(b) 拿同一個已經換發過的舊 Refresh Token 再換發一次,確認系統偵測到 Reuse 並回應「整個 family 已撤銷」,(c) 撤銷後,原本這個 family 底下最新的那組 Refresh Token 也應該無法再換發成功,附上這三種情境各自的實際輸出/回應內容。3. 針對「使用者主動登出」這個情境,寫出你會怎麼設計 Revocation(是撤銷單一 token 還是整個 family,並說明理由),以及黑名單/撤銷狀態要設定多長的保留時間,並具體推導這個數字怎麼來的(提示:跟 Token 本身的 exp 有什麼關係)。``

Day 78 — OAuth 2.0 與 OIDC:第三方委託授權與身份層

學習目標

看完今天內容後,能夠:

  1. 解釋 OAuth 2.0 要解決的問題(第三方應用程式需要存取使用者在另一個服務上的資源,但不應該拿到使用者的密碼),並具體畫出 Authorization Code Grant 的完整流程。
  2. 解釋為什麼純 OAuth 2.0 不能直接拿來做「登入」,以及 OIDC 在 OAuth 2.0 之上加了什麼東西解決這個問題。
  3. 解釋 PKCE 存在的原因,以及它防禦的是哪一種具體攻擊。

教材大綱

1

OAuth 2.0:Authorization Code Grant 與 PKCE

Core FundamentalsLv.4
一句話理解

OAuth 2.0 是一個「委託授權」協定,讓使用者可以授權第三方應用程式(Client)代表自己去存取另一個服務(Resource Server,例如某雲端硬碟服務的 API)上的特定資源,整個過程使用者的帳號密碼只會輸入在資源所屬服務自己的登入頁面上,第三方應用程式從頭到尾拿不到使用者的密碼,只拿到一個範圍受限、可被撤銷的 Access Token。

Why

在 OAuth 出現之前,「A 網站要使用 B 服務上使用者的資料」最原始的做法是要求使用者把 B 服務的帳號密碼直接輸入在 A 網站上(A 網站再拿這組密碼去登入 B 服務),這代表 A 網站完全拿到使用者在 B 服務的完整帳密,範圍是「使用者在 B 服務能做的所有事」,且密碼一旦外洩或 A 網站心懷不軌,使用者在 B 服務的帳號就整個失守,也沒有任何細緻的權限範圍或簡單的撤銷手段(唯一的補救是使用者自己去 B 服務改密碼)。OAuth 2.0 用「Token 取代密碼、Scope 限制範圍、可獨立撤銷」解決這三個問題:A 網站拿到的是一個範圍受限(例如只能讀某個資料夾)、可以被使用者隨時在 B 服務後台撤銷、且從頭到尾沒接觸過密碼本身的 Access Token。

Mechanism

最常見、也最安全的流程是 Authorization Code Grant:(1) 使用者在第三方應用程式(Client)點擊「用 B 服務登入/授權」,Client 把使用者導向 B 服務(Authorization Server)的授權頁面,並帶上自己的 client_id、想要的 scope(權限範圍)、以及一個 redirect_uri(授權完成後要導回的網址)。(2) 使用者在 B 服務自己的網域下輸入帳密登入(Client 完全看不到這個密碼),並確認要不要授權 Client 要求的 scope。(3) 使用者同意後,B 服務把瀏覽器導回 Client 指定的 redirect_uri,網址上帶著一個一次性、短命的 Authorization Code。(4) Client 的後端(不是瀏覽器前端)拿著這個 Authorization Code,連同自己的 client_id/client_secret,直接對 B 服務的 token endpoint 發出後端對後端的請求,換回真正的 Access Token(以及選擇性的 Refresh Token)。這個流程刻意把「拿到 Authorization Code」(在瀏覽器可見的網址列上發生,相對不安全)與「用 Authorization Code 換 Access Token」(後端對後端,附上只有 Client 自己知道的 client_secret)分成兩步,確保就算有人在瀏覽器層攔截到那個一次性 Authorization Code,沒有 client_secret 也換不到真正的 Access Token。

Trade-off

Authorization Code Grant 透過「後端對後端換 token」這一步換來更高的安全性,但代價是要求 Client 必須有一個能安全保管 client_secret 的後端(不能是純前端的 SPA 或行動 App,因為 client_secret 一旦打包進前端程式碼或 App 裡就形同公開);對於沒有安全後端的場景(純前端 SPA、行動 App),需要額外的機制(PKCE)補上這個缺口。

Failure mode

redirect_uri 沒有在 Authorization Server 端做嚴格的白名單比對(例如只檢查前綴、允許帶任意 query string),會讓攻擊者能把 redirect_uri 換成自己控制的網址,誘導使用者授權後把 Authorization Code 導向攻擊者的伺服器,攻擊者再自己拿這個 Code 去換 Access Token(前提是攻擊者也要能拿到/猜到client_secret,或這是一個不需要 client_secret 的 public client 情境,這正是下面 PKCE 要處理的問題)。

Backend 連結

PKCE(Proof Key for Code Exchange)是為了讓「沒有安全後端可以保管 client_secret」的 Client(SPA、行動 App)也能安全使用 Authorization Code Grant 而設計的擴充機制——Client 在發起授權請求前,自己產生一組隨機字串 code_verifier,計算它的雜湊值 code_challenge 一起帶在授權請求裡;等到最後用 Authorization Code 換 Access Token 時,Client 必須附上原始的code_verifier,Authorization Server 驗證這個 code_verifier雜湊後是否等於一開始收到的 code_challenge。因為 code_verifier從頭到尾只存在 Client 自己的記憶體裡、沒有出現在任何可能被攔截的網址列或跳轉過程中,即使攻擊者攔截到 Authorization Code,沒有對應的 code_verifier 依然換不到 Access Token——這讓不需要client_secret(因為根本沒有安全的地方存)的 public client,也能拿到接近 confidential client 的安全性。

陌生題

一個行動 App(沒有自己的後端伺服器,直接用 App 本身當 OAuth Client)實作 Authorization Code Grant,但因為「行動 App 沒有安全的地方存 client_secret」,開發者決定乾脆不驗證client_secret(Authorization Server 允許這個 Client 的 token 換發請求不用帶 secret),也沒有實作 PKCE。這個設計有什麼具體風險?加上 PKCE 之後,同樣的攻擊情境會在哪一步被擋下?(提示:沒有 client_secret 也沒有 PKCE,代表「誰只要能攔截到 Authorization Code(例如透過惡意 App 註冊了相同的 URL scheme,攔截系統要導回這個 App 的跳轉),就能直接拿這個 Code 去 token endpoint 換到 Access Token」,因為 token 換發請求不需要驗證任何只有合法 Client 才知道的秘密;加上 PKCE 後,即使攻擊者攔截到 Authorization Code,token 換發請求還需要附上一開始沒有出現在任何跳轉網址上、只存在合法 App 記憶體裡的 code_verifier,攻擊者攔截不到這個值,即使拿到 Authorization Code 也換不到 Access Token。)

2

OIDC(OpenID Connect):在 OAuth 2.0 之上加一層身份

Core FundamentalsLv.4
一句話理解

OIDC 是建立在 OAuth 2.0 之上的一層身份驗證(Authentication)協定,在標準 OAuth 流程換到的 Access Token 之外,額外簽發一個 ID Token(固定格式是一個 JWT,Payload 裡包含 sub(使用者唯一識別碼)、iss(簽發者)、aud(這個 token 是為哪個 Client 簽發的)等標準化欄位),讓 Client 能明確、標準化地知道「這個使用者是誰」,而不只是「這個 Client 現在有一個能存取某些資源的 Access Token」。

Why

OAuth 2.0 原始設計的目的是「授權存取資源」,不是「驗證身份」——一個 Client 拿到 Access Token,只能證明「這個 Access Token 有權存取某些資源」,並不直接、標準化地告訴 Client「使用者是誰」(早期不少系統濫用 OAuth 做登入,土法煉鋼地拿 Access Token 去呼叫資源伺服器的某支 API 換回使用者的 email/id,但這個做法沒有標準化,每個服務要自己實作、格式也不一致,且存在安全隱憂——例如混淆了「這個 Access Token 是給哪個 Client 用的」,可能讓 Client A 拿到本來要給 Client B 的 Access Token 冒充登入,稱為 Token Confusion)。OIDC 標準化這件事:明確定義 ID Token 這個專門用來表達身份的格式,並且 ID Token 裡的 aud 欄位明確標示「這是為哪個 Client 簽發的」,Client 驗證 ID Token 時必須確認 aud 是自己的client_id,直接杜絕上述的 Token Confusion 問題。

Mechanism

OIDC 流程跟 OAuth 2.0 Authorization Code Grant 幾乎一樣,差異在於請求時的 scope 多帶一個保留字 openid(宣告這是一次 OIDC 請求),最後從 token endpoint 換回的回應除了原本的 Access Token,會多一個 ID Token;ID Token 是一個標準 JWT,Client 收到後除了驗證簽章(跟 Day 76 教過的 JWT 驗證機制完全相同),還需要額外驗證幾個 OIDC 規定的欄位:iss 是不是自己信任的那個 Authorization Server、aud 是不是自己的client_idexp 是否還在有效期內。驗證通過後,Client 就能直接信任 ID Token Payload 裡的 sub 就是「這次登入的使用者是誰」的標準化答案,不需要再額外呼叫任何 API 去查身份。

Trade-off

OIDC 標準化了身份驗證這一層,讓不同服務提供的「用 XX 登入」都遵循同一套可預期的驗證邏輯,大幅降低每個 Client 自己土法煉鋼拼湊身份驗證邏輯的風險;代價是 Client 端的驗證邏輯必須確實做完整(尤其是 aud/iss 檢查),少驗證任何一項都可能重新引入 OIDC 原本要解決的問題(例如不驗證 aud,等於又退回可能被 Token Confusion 攻擊的狀態)。

Failure mode

最常見的錯誤是把 OIDC 換回的 Access Token(而不是 ID Token)拿來當作身份判斷依據——Access Token 通常是不透明字串或格式未標準化,不保證能安全地解讀出使用者身份,且它的用途是「存取資源」不是「表達身份」;另一個常見錯誤是驗證 ID Token 時只驗證簽章、忘記驗證 aud,會讓一個原本簽發給 Client A 使用的合法 ID Token(例如被 Client A 自己不小心外洩,或使用者同時對多個 Client 授權),被 Client B 拿來冒充登入。

Backend 連結

這正是 Day 76 教過的 JWT 驗證機制與 Day 77 教過的 Token 生命週期管理,在「第三方委託身份」這個更大架構下的具體應用——ID Token 本身就是一個 JWT,套用完全相同的簽章驗證邏輯;多數正式系統的「第三方登入」(例如用 Google/GitHub 帳號登入)功能,底層就是 OIDC 的 Authorization Code Grant + ID Token 驗證這整套機制。

陌生題

一個系統同時支援兩個第三方應用程式 Client A 與 Client B 用同一個 OIDC Authorization Server 登入。Client A 的後端驗證使用者登入時的 ID Token,只驗證了簽章是否有效與 exp是否過期,沒有驗證 aud 欄位。使用者同時對 Client A 與 Client B 都完成過 OIDC 登入流程,各自拿到一個 ID Token(aud 分別是 Client A 與 Client B 的 client_id)。請說明 Client A 這個疏漏會造成什麼具體攻擊情境。(提示:如果使用者(或攻擊者,只要能拿到那個原本簽發給 Client B 的 ID Token,例如透過某種方式攔截或 Client B 自己記錄不當外洩)把「原本簽發給 Client B」的 ID Token 拿去呼叫 Client A 的登入驗證端點,因為 Client A 沒有檢查aud,只要簽章有效、沒過期就會信任這個 token 對應的sub,等於用一個「本來不是要給 Client A 用」的身份憑證成功冒充登入 Client A——正確做法是 Client A 的驗證邏輯必須明確拒絕 aud不等於自己 client_id 的 ID Token。)

Day 78 過關標準(DoD)—— Phase 08 Auth 收尾

``text 1. 用文字或流程圖,完整畫出 Authorization Code Grant 的每一步(含 PKCE 的 code_verifier/code_challenge 何時產生、何時傳遞),標明每一步是「瀏覽器可見」還是「後端對後端」,並標出如果拿掉 PKCE,哪一步會出現安全缺口。2. 寫一段驗證邏輯(虛擬碼或實際程式碼),針對收到的 ID Token 完整檢查:簽章、exp、iss、aud 四項,並針對「aud 不符」這個情況實際構造一個測試 case(可以是手動組一個 aud 錯誤的 JWT)驗證你的邏輯真的會擋下它,附上實際執行結果。3. 串連 Day 76–78:寫一段總結,回答「一個使用者從第一次登入、到 Access Token 過期自動換發、到透過『用第三方服務登入』完成 OIDC 身份驗證,中間各自用到了 Day 76/77/78 教過的哪些具體機制」,並指出如果拿掉 Day 77 的 Reuse Detection,這整個流程會在哪個環節多出一個沒被涵蓋的風險。``

Day 79 — NATS 核心:Subject-based Pub/Sub / Queue Groups / Request-Reply

學習目標

看完今天內容後,能夠:

  1. 解釋 NATS 的 Subject-based pub/sub 模型如何運作,並說出這是 Day 58 教過的 Producer/Consumer/Queue 抽象模型的哪一個具體 API。
  2. 解釋 Queue Group 如何在多個 subscriber 之間做負載平衡,並說出 Core NATS 的 Queue Group 跟 Day 58 教過的「At-least-once + 沒收到 ack 就重新投遞給其他 Consumer」保證,差在哪裡。
  3. 解釋 Request-Reply 如何在一個原生非同步的系統上疊出同步呼叫語意,以及這個方便背後重新引入了 Day 58 提過的哪個代價。

教材大綱

1

Subject-based Pub/Sub

Core FundamentalsLv.4
一句話理解

NATS 用 Subject(用 . 分層的字串,例如orders.created)取代傳統訊息系統裡的 queue 名稱,Publisher 把訊息送到某個 Subject,所有訂閱這個 Subject(或涵蓋它的萬用字元模式)的 Subscriber 都會各自收到一份完整拷貝——這是 Day 58 教過的 Producer → Queue → Consumer 模型裡「Queue」這一層,換成一個用字串路由的訂閱樹之後的具體實作。

Why

Day 58 解釋過為什麼需要 Queue 把 Producer 跟 Consumer 解耦;NATS 進一步要解決的是「怎麼讓多個彼此無關的 Consumer,各自只訂閱自己關心的那部分訊息,而不用共用同一個死板的 queue 名稱」。Subject 的階層字串加上萬用字元訂閱,讓路由規則可以任意細分或合併(例如一個 Consumer 訂閱 orders.created,另一個訂閱涵蓋更廣的orders.>),不需要為每一種訂閱組合各自開一條實體 queue。

Mechanism

Subject 語法有兩種萬用字元——* 比對剛好一層(例如orders.*.created 比對 orders.eu.created,但不比對orders.eu.west.created),> 比對這之後所有層(例如 orders.>比對 orders.createdorders.eu.created 等所有以 orders. 開頭的 Subject)。Core NATS(還沒接上 Day 80 要教的 JetStream 之前)預設完全不做持久化:Publisher 呼叫 Publish 後,NATS server 立刻把這則訊息轉發給當下所有已訂閱的 Subscriber,沒有訂閱者在線就直接遺失,Publisher 也不會收到任何失敗通知(fire-and-forget);每個 Subscriber 收到的是各自獨立的一份拷貝(one-to-many 廣播),不是像 Queue Group(下一小節)那樣互相搶同一份。

Trade-off

Core NATS 換來極低延遲(同機房內通常次毫秒等級)與極簡單的實作,代價是完全沒有 Day 58 教過的任何 Delivery 保證——連「訊息至少被送到一次」這種最基本的保證都沒有,純粹依賴「訂閱者剛好在線」這個運氣。

Failure mode

如果 Subscriber 在訊息送出的當下還沒建立好訂閱(例如服務正在重啟、還沒完成啟動流程),這則訊息就永久遺失,且 Publisher 完全不會知道——這跟 Day 58 Failure Modes 段落「Queue 沒有持久化、Queue 所在進程當機時所有還沒被取走的訊息全部遺失」是同一類問題,只是這裡連 Queue 本身的暫存都不存在(Core NATS 只做即時轉發,不做任何暫存)。

Backend 連結

這正是 Day 58 Backend Applications 段落預告的「Phase 08 會把 Kafka/RabbitMQ/NATS 這裡的抽象概念,對應到 NATS 這個真實系統的具體 API 操作」——Subject 就是 Day 58 的 Queue 概念在 NATS 裡的具體形式,只是換成一個支援階層與萬用字元訂閱的路由字串,而不是一個固定命名的佇列物件。

陌生題

一個系統用 Core NATS 的 Subject orders.created 發布訂單建立事件,Consumer 服務啟動時才建立訂閱。上線後某次 Consumer 服務重啟(大約 20 秒的空窗),發現這 20 秒間建立的 3 筆訂單,Consumer 完全沒有收到對應事件,即使重啟後訂閱又恢復正常運作。這是 bug 嗎?如果不是,根本原因是什麼,該用什麼機制解決?(提示:不是 bug,是 Core NATS 本身「即時轉發、不暫存」的特性所致——這與 Day 58「Queue 沒有持久化時進程當機訊息全部遺失」的 Failure mode 是同一類問題的更極端版本:這裡連正常運作、只是暫時沒有訂閱者在線,訊息都會遺失。解法是 Day 80 要教的 JetStream,它會把訊息持久化進 Stream,即使發布當下沒有訂閱者,之後上線的 Consumer 依然能讀到。)

2

Queue Groups

Core FundamentalsLv.4
一句話理解

Queue Group 是讓同一個 Subject 底下的一組 Subscriber 共用同一個 Group 名稱,NATS 保證同一則訊息只會被送給該 Group 裡的其中一個成員(而不是像一般訂閱那樣每個成員各自收到一份拷貝)——這正是 Day 58 Producer/Consumer/Queue 模型裡「多個 Consumer 平行消化同一份工作、彼此不重複處理」這個概念,在 NATS 裡的具體 API:把多個 Subscriber 用同一個 Group 名稱訂閱同一個 Subject。

Why

如果沒有 Queue Group,每個訂閱同一個 Subject 的 Subscriber 都會各自收到一份完整拷貝(廣播語意),這對「我要平行處理、每個任務只做一次」的 worker 場景不適用——會導致同一個任務被 N 個 worker 各自重複執行一次。Queue Group 補上「多個 worker 分攤同一份工作」這個負載平衡需求。

Mechanism

以 NATS Go client 為例,呼叫nc.QueueSubscribe(subject, queueName, handler) 取代nc.Subscribe(subject, handler);NATS server 收到 Publish 後,若目標 Subject 有一個或多個 Queue Group 在訂閱,會在每個 Group 內部「隨機挑一個成員」送出這則訊息(不是 round-robin,是每則訊息各自獨立隨機挑選,長期趨近平均分配)。同一個 Subject 可以同時被多個不同名稱的 Queue Group,以及沒加入任何 Group 的一般 Subscriber 訂閱——它們彼此獨立:Group A 收到一份、Group B 各自收到一份、每個一般 Subscriber 各自收到一份,但同一個 Group 內部只有一個成員收到。

Trade-off

得到負載平衡(多個 worker 實例平行分攤流量),但 Core NATS 的 Queue Group 不提供 Day 58 教過的「沒收到 ack 就重新投遞給其他 Consumer」這種可靠性保證——一旦被隨機挑中的那個 worker 在處理過程中當機,這則訊息不會自動轉給 Group 裡的其他成員(因為 Core NATS 沒有 ack 機制,一旦送出就視為傳遞完成),Core NATS 的 Queue Group 只做負載分攤,不做失敗補救(這是 Day 80 JetStream 要補上的能力)。

Failure mode

誤以為 Core NATS 的 Queue Group 跟 Day 58 教的「沒收到 ack 就重試」一樣可靠,沒有另外實作補償機制,一旦被選中的 worker 在處理過程中當機,這則訊息就直接遺失,不會有任何其他 worker 接手處理。

Backend 連結

Kafka 的 Consumer Group 與這裡的 Queue Group 是同一個抽象概念(同一份工作只由 Group 裡一個成員處理)的不同實作,差異在於 Kafka 靠 partition assignment 加 offset 持久化保證「沒處理完不算過」,Core NATS 的 Queue Group 則單純是無狀態的隨機負載平衡,沒有這層保證。

陌生題

一個訂單通知系統用 3 個 worker 實例、都用 Queue Groupnotifier 訂閱 orders.created。上線後監控發現,某次流量高峰時,有幾筆訂單事件送出後,剛好收到訊息的那個 worker 在還沒處理完的當下被自動擴縮容機制縮掉(實例被終止),事後檢查那幾筆訂單完全沒有收到通知信。用今天教的機制解釋為什麼會這樣;如果不改用 Day 80 的 JetStream,有什麼權宜做法可以降低風險?(提示:Core NATS 的 Queue Group 一旦決定送給某個 worker,就視為傳遞完成,不會因為那個 worker 之後當機而重新分派給其他 worker——這是 Core NATS at-most-once 語意的具體體現。權宜做法例如讓 worker 收到訊息、開始處理時先寫一筆「處理中」記錄,並讓健康檢查/擴縮容機制避免縮掉正在處理訊息的實例,但這終究是治標;真正的解法是 Day 80 JetStream 的 at-least-once + ack 機制。)

3

Request-Reply

Core FundamentalsLv.4
一句話理解

Request-Reply 是在 NATS 原生非同步的 pub/sub 之上疊出的同步語意——Requester 發送請求時額外帶一個獨一無二的「回覆用」Inbox Subject,Replier 處理完後把回應發布到這個 Inbox Subject,Requester 原地阻塞等待這個 Inbox Subject 收到訊息,形成看起來像「呼叫函式等待回傳值」的同步互動,但底層仍然是兩次獨立的 pub/sub。

Why

有些場景需要真正的同步問答(例如查詢某個 worker 目前的健康狀態、要求某服務立刻回傳一筆資料),純粹的 fire-and-forget pub/sub 無法表達「我要等這次呼叫的回應」這種語意,Request-Reply 補上這個缺口,讓 NATS 也能用於 RPC(remote procedure call)式的呼叫模式,不用另外架設一套 HTTP API。

Mechanism

Requester 呼叫 nc.Request(subject, data, timeout);NATS client library 在背後自動產生一個唯一的臨時 Inbox Subject(格式通常是 _INBOX.<隨機字串>)、訂閱這個 Inbox Subject、把它放進訊息的 reply-to 欄位、再把訊息發布出去,然後阻塞等待這個 Inbox Subject 收到訊息或 timeout。Replier 端用 nc.Subscribe(subject,handler) 收到請求後,從訊息的 reply-to 欄位讀出 Inbox Subject,把回應用 nc.Publish(replyTo, responseData) 發布到那個 Inbox Subject;Requester 端收到後解除阻塞、回傳結果;若超過 timeout 都沒收到回應,Requester 端的呼叫失敗回傳 timeout error。

Trade-off

換來「像呼叫函式一樣」的簡單同步語意,但代價是重新引入了同步呼叫的老問題——Requester 會被 Replier 的處理時間直接拖累,這正是 Day 58 Topic 1 Why 段落提到「如果不用 Queue 解耦,Producer 會被 Consumer 處理時間拖累」的同一種代價:Request-Reply 是刻意選擇放棄這個解耦好處,換取同步語意的方便。若 Replier 此時剛好離線,或被 Queue Group 裡接走請求的那個成員當機,Requester 只能等到 timeout 才知道失敗,沒有更即時的錯誤回饋。

Failure mode

把 Request-Reply 當成穩定的服務間同步呼叫機制大量使用,忽略了它底層仍是 pub/sub + timeout,沒有內建的 retry/circuit breaker,一旦 Replier 端暫時過載導致回應變慢,Requester 端會大量卡在等待 timeout;若 Requester 本身是處理 HTTP 請求的服務,容易連帶讓自己的 HTTP 回應也變慢,形成延遲串聯(cascading latency)。

Backend 連結

這是「同步 vs 非同步呼叫」取捨的具體 API 層級體現——NATS Request-Reply 提供了「看起來方便的同步呼叫」,但工程上仍要意識到它骨子裡是一次分散式呼叫,一樣要處理 timeout/failure,不能因為 API 寫起來像函式呼叫就忽略這件事。

陌生題

一個系統用 NATS Request-Reply 讓 API 服務同步查詢庫存服務的即時庫存數量(nc.Request("inventory.check", data,2*time.Second))。上線後某次庫存服務因為資料庫連線池耗盡,處理每個請求的時間從平常的 20ms 暴增到 3 秒。這對呼叫端(API 服務)會造成什麼具體影響?如果庫存服務同時有 3 個 worker 實例用 Queue Group 訂閱,這個問題會被緩解嗎?(提示:每個 Request 呼叫都會卡到 2 秒 timeout 才失敗,API 服務處理使用者請求的執行緒/goroutine 會被大量卡住,可能連帶拖垮 API 服務自己的回應時間,甚至耗盡自己的並發資源;Queue Group 只是把請求分攤給 3 個 worker,如果資料庫連線池耗盡是全域瓶頸(3 個 worker 共用同一個連線池),分攤請求並不會讓每個請求變快,只是把同樣慢的處理時間套用在更多個 worker 上,並不能真正緩解 root cause。)

Day 79 過關標準(DoD)—— 本機跑起來一次 Core NATS

``text 1. 用 Docker 啟動一個 Core NATS server:docker run -p 4222:4222 --name nats-core nats:latest 2. 用官方 nats CLI(brew install nats-io/nats-tools/nats,或用docker run --network host natsio/nats-box 進容器操作)開三個終端機驗證 Pub/Sub:終端機 A:nats sub "orders.>"終端機 B:nats pub orders.created '{"order_id": 123}'確認終端機 A 即時收到這則訊息,附上兩個終端機的實際輸出內容。3. 驗證 Queue Group 負載平衡:另開兩個終端機,都用 nats sub "orders.created" --queue notifier 加入同一個 Queue Group;再用第三個終端機依序 publish 5 則訊息(例如 order_id 分別是 201–205),觀察並記錄每一則訊息實際被哪一個 subscriber 收到——應該近似隨機分散到兩個 subscriber,不是每個都收到全部 5 則。附上兩個 subscriber 終端機各自收到的訊息清單。4. 驗證 Core NATS 沒有持久化:關掉所有 subscriber(Ctrl+C 結束訂閱),發布一則新訊息(nats pub orders.created '{"order_id": 999}'),之後才重新開一個 subscriber,確認這則訊息不會被補送。用自己的話寫一段解釋,為什麼會這樣(對照 Topic 1 的 Failure mode),並說明如果這是一個不能接受訊息遺失的業務場景,今天教的哪三個機制(Subject/Queue Group/Request-Reply)都無法解決這個問題,需要留到 Day 80 才有答案。``

Day 80 — JetStream:Persistence / Consumer / Ordering / Retry,Phase 08 NATS 收尾 Capstone

學習目標

看完今天內容後,能夠:

  1. 解釋 JetStream 的 Stream 如何把 Day 79 驗證過的「Core NATS 沒有持久化」這個缺口補上,以及 Retention Policy 的取捨。
  2. 解釋 JetStream Consumer 的 Ack/Redelivery 機制如何提供 Day 58 教過的 At-least-once 保證,以及 Msg-Id 去重如何在此之上疊出業務上等同 Exactly-once 的效果。
  3. 解釋 JetStream 在哪個層級保證 Ordering、哪個層級不保證,並用 Day 58 DoD 分析過的訂單狀態機案例驗證這個邊界。
  4. 完成 Phase 08 NATS 收尾驗收:畫出 API → NATS → Worker → DB 的完整流程,並具體回答「Worker crash 後怎麼辦」。

教材大綱

1

JetStream Persistence:Stream

Core FundamentalsLv.4
一句話理解

JetStream 是 NATS 內建的持久化層,把 Core NATS 原本「純轉發、不暫存」的行為,升級成「訊息先寫入一個叫 Stream 的持久化紀錄,再依照設定分派給 Consumer」——這正是 Day 58 Topic 1 Mechanism 段落提到「Queue 把訊息持久化(寫進磁碟或至少多副本記憶體)」這個抽象步驟,在 NATS 裡的具體實作。

Why

Day 79 驗證過 Core NATS 完全不做持久化——沒有訂閱者在線,訊息就直接遺失,連短暫的重啟空窗都無法容忍。訂單事件這類需要事後補送、或需要稽核回放的場景,需要「訊息先穩定存下來,不管 Consumer 何時上線都能拿到」的保證,JetStream 補上這一塊。

Mechanism

建立一個 Stream 時指定它要接收哪些 Subject(可以用萬用字元,例如 orders.> 涵蓋所有 orders 開頭的 Subject)以及儲存策略(File——寫入磁碟,重啟後資料仍在;Memory——僅存在記憶體,重啟即遺失,通常只用於非關鍵場景)。Publish 到被 Stream 涵蓋的 Subject 時,NATS server 先把這則訊息連同一個遞增的 sequence number 寫入 Stream 的儲存(File 模式下會 fsync 到磁碟確認持久化),才回應 Publisher「已收下」——這是 Publisher 端能確認訊息真的存下來的 ack,跟 Core NATS 的 fire-and-forget 完全不同。Stream 有 Retention Policy 決定訊息保留多久,常見三種:Limits(依訊息數量/總大小/存留時間三者其中一個先達到上限,就開始丟最舊的訊息)、WorkQueue(每則訊息被至少一個 Consumer 消費完並 ack 後就從 Stream 刪除)、Interest(只要沒有任何 Consumer 對這則訊息還有興趣,就可以刪除)。

Trade-off

換來「訊息不會因為沒有訂閱者在線就遺失」的持久化保證,代價是每則訊息多了一次寫入磁碟(File 模式)的延遲與 IO 成本,且 Stream 本身需要規劃儲存空間——Retention 設定不當,可能讓 Stream 無限成長吃滿磁碟,或設太緊而不小心丟掉還沒被處理的重要訊息。

Failure mode

把 Stream 設成 Memory Retention,卻沒意識到「重啟 NATS server,這個 Stream 裡所有還沒消費的訊息會全部消失」,等於在關鍵業務流程上重新引入了 Day 79 Core NATS 的 no-persistence 問題,只是換了一層「Stream」的持久化外殼,實際上並沒有真正持久化。

Backend 連結

這與 Day 58 Backend Applications 段落「Kafka/NATS JetStream 都是 At-least-once,需要 Consumer 自行實作 Idempotency」是同一件事的鋪陳——Stream 只解決「訊息會不會遺失」,不解決「訊息會不會被重複處理」,後者要靠下一小節的 Consumer 與 Delivery semantics 處理。

陌生題

一個團隊把訂單事件的 JetStream Stream 設成 Memory Retention(理由是「這樣寫入比較快」),上線一段時間後一次例行的 NATS server 升級重啟,發現重啟前還沒被 Consumer 處理完的 12 筆訂單事件全部消失,沒有任何補救機制能找回。這個問題出在哪一步的設計決策?如果要保留「寫入快」這個好處,同時避免這個問題,可以怎麼調整?(提示:Memory Retention 下 Stream 的資料只存在 NATS server 行程的記憶體裡,行程重啟(不管是升級、崩潰或任何原因)都會讓還沒消費的訊息一起消失,這跟 Core NATS 的 no-persistence 本質上是同一類問題,只是換了一層 Stream 的外殼;調整方向是改用 File Retention 換取真正的持久化,如果真的在意寫入延遲,應該評估 NATS server 的儲存 IO 效能,而不是放棄持久化本身。)

2

JetStream Consumer 與 Delivery Semantics

Core FundamentalsLv.4
一句話理解

JetStream 的 Consumer 是「從一個 Stream 裡讀取訊息的游標與規則設定」的物件,分成 Push(NATS server 主動把訊息推給訂閱者)與 Pull(訂閱者主動來要訊息,適合 worker 自己控制節奏、批次拉取的場景)兩種模式,並要求 Consumer 明確 ack 才會把讀取進度往前推進,這讓 JetStream 能提供 Day 58 教過的 At-least-once 保證(而不是 Core NATS 的 At-most-once)。

Why

光有 Stream 把訊息存下來還不夠——需要一個機制讓 Consumer 知道「我上次讀到哪裡」「這則訊息我到底處理完了沒」,否則要嘛每次都從頭重讀整個 Stream,要嘛沒有任何失敗重試的依據,JetStream Consumer 物件把這個游標與確認機制標準化。

Mechanism

建立 Consumer 時指定 AckPolicy(常見 None——不需要 ack,等同 At-most-once;All——ack 某個 sequence 等於連同它之前全部一起確認;Explicit——每則訊息各自獨立 ack,最常用)與 DeliverPolicy(All——從 Stream 最早的訊息開始;New——只給 Consumer 建立之後才進來的新訊息)。Pull Consumer 的典型流程:Worker 呼叫Fetch(batch_size) 向 server 要一批訊息,處理完每一則後呼叫msg.Ack();若在 AckWait(逾時時間)內沒收到 ack,server 視為這次投遞失敗,重新讓這則訊息可以被再次 Fetch(對應 Day 58 的 Retry 機制,下下小節展開)。Delivery semantics 在 JetStream 的具體體現:Core NATS 是 At-most-once(Day 79 驗證過);JetStream 的 AckPolicy=Explicit + AckWait + Redeliver 是 At-least-once(訊息保證會被投遞到,但 Worker 若在「處理完」與「送出 ack」之間當機,會造成重複投遞,跟 Day 58 Topic 3 Mechanism 描述的 At-least-once 機制完全一致);要做到業務上等同 Exactly-once,JetStream 額外提供 Msg-Id 去重——Publisher 在發布時帶一個唯一的Nats-Msg-Id header,Stream 在設定的 Duplicate Window 時間內(預設 2 分鐘)如果收到同一個 Msg-Id,會直接忽略、不會真的寫入第二筆——這正是 Day 58 Topic 4 Mechanism 教過的「idempotency key+ 去重紀錄」這個抽象機制,在 JetStream 裡的具體 API 實作,只是去重紀錄的維護時間受限於 Duplicate Window,不是永久保存。

Trade-off

Explicit Ack 給予最精細的控制(每則訊息各自確認),但要求 Worker 端邏輯正確處理「處理完才 ack、沒處理完別 ack」,一旦邏輯寫錯(例如收到訊息就馬上 ack,才開始處理),Worker 在處理過程中當機,這則訊息會被誤判為「已完成」,造成漏處理且不會有任何 redelivery 補救(下一小節展開)。Msg-Id 去重換來業務上等同 Exactly-once 的效果,但只在 Duplicate Window 時間內有效,超過這個時間窗的重複發布(例如 Publisher 端自己的重試邏輯拖了很久才重試)不會被擋下。

Failure mode

最常見的錯誤是「收到訊息就立刻 ack,再開始真正的處理邏輯」(方便省事,但等於放棄了 At-least-once 保證)——一旦真正的處理邏輯(例如寫入 DB)在 ack 之後才失敗或當機,Stream 已經認為這則訊息處理完成,不會 redeliver,造成資料實際上遺漏但系統毫無察覺;正確順序應該是先完整執行完業務邏輯、確認真的成功之後才 ack。

Backend 連結

這整套 push/pull + ack + redeliver + Msg-Id 去重,是 Day 58 Topic 2(Retry/DLQ)+ Topic 3(Delivery semantics)+Topic 4(Idempotency)三個抽象概念在一個真實系統裡的具體組合實作,證明了 Day 58 結尾「NATS JetStream 都是 At-least-once + 需要 Consumer 自行實作 Idempotency」這句話在 API 層級具體長什麼樣子。

陌生題

一個 Worker 的處理邏輯寫成「收到訊息 → 立刻msg.Ack() → 開始執行寫入 DB 的邏輯」。某次 DB 短暫不可用,這次的寫入邏輯執行到一半拋出例外、整個處理函式提前結束。用今天教的機制解釋,這則訊息之後的命運會是什麼?如果把 ack 的位置移到寫入 DB 邏輯成功完成之後,同樣的 DB 短暫不可用情境,結果會有什麼不同?(提示:因為 ack 已經在寫入邏輯之前送出,Stream 的角度認為這則訊息已經處理完成,不會 redeliver,即使實際上 DB 寫入沒有真的成功,這筆資料就這樣永久遺漏且沒有任何錯誤訊號;如果把 ack 移到寫入成功之後,DB 暫時不可用導致的例外會讓函式在 ack 之前就結束,Stream 在 AckWait 逾時後判定投遞失敗,重新 redeliver 這則訊息,等 DB 恢復後下一次投遞就能真正處理成功,這才是 At-least-once 保證真正發揮作用的正確用法。)

3

Ordering in JetStream

Core FundamentalsLv.4
一句話理解

單一 Stream 內部依照收到的順序寫入、有一個永遠遞增的 sequence number,單一 Consumer(不管是單一 Worker 用 Pull Consumer 依序 Fetch,或單一 Push Consumer)依照 sequence 順序拿到訊息——但如果多個 Worker 平行共享同一個 Consumer 拉取(在 JetStream 裡實現類似 Queue Group 負載平衡的方式),實際完成處理的順序仍然可能跟 sequence 順序不同,這與 Day 58 Topic 3 Mechanism「單一 Queue 內部 FIFO,但多個 Consumer 平行消化會打亂完成順序」的結論完全一致,JetStream 只是把 Day 58 那套抽象規則換成 Stream/Consumer 的具體物件。

Why

Day 58 DoD 分析過的訂單狀態變更案例(pending → paid →shipped)對順序有硬性要求,錯亂的處理順序會導致業務狀態出錯,需要明確理解 JetStream 在哪個層級保證順序、哪個層級不保證。

Mechanism

Stream 層級的順序保證只到「寫入順序」——每則訊息有唯一遞增的 sequence,同一個 Stream 內部訊息的儲存順序就是收到的順序;單一 Consumer 依照 sequence 遞增順序拿到訊息,這種情況下順序有保證。但如果多個 Worker 實例平行從同一個 Pull Consumer 各自 Fetch(這是 JetStream 裡實現「Queue Group 式」負載平衡的方式——多個 Worker 共享同一個 Consumer,各自 Fetch 到不同批次的訊息平行處理),不同批次之間完成處理的先後順序就不再保證跟 sequence 順序一致,機制上跟 Day 58 DoD 案例裡「3 個 worker 平行消化同一個 Queue,shipped 事件搶先在 paid 事件之前處理完」完全同構。

Trade-off

要順序保證就得放棄平行度(同一個 Subject 的相關訊息只能單一 Worker 依序處理,無法多實例分攤);要平行度就要接受完成順序可能跟送出順序不同,需要額外機制(狀態機前置條件檢查、或用 partition key 把相關訊息路由到同一個 Consumer)補上順序保證,跟 Day 58 DoD 結論的兩個修復方向完全一致,只是換成 JetStream 的具體做法:把 Subject 設計成包含識別碼(例如orders.<order_id>.events),讓同一個實體的所有事件天生走同一條路徑。

Failure mode

誤以為「用了 JetStream = 有嚴格順序保證」,沒意識到只有單一 Consumer 消費時才成立,一旦為了效能加開多個 Worker 平行 Fetch 同一個 Consumer,順序保證就默默消失,如果業務邏輯依賴嚴格順序(例如狀態機),會像 Day 58 DoD 案例一樣出現不合法的狀態組合。

Backend 連結

這是 Day 58 DoD 分析案例(訂單 pending → paid →shipped 錯亂)在 JetStream API 層級的具體重現——理解 JetStream Ordering 的邊界,就是把 Day 58 那套分析方法套用在一個真實系統的 Consumer 平行度設定上。

陌生題

沿用 Day 58 DoD 的訂單狀態機情境,團隊決定改用 JetStream 重建這個系統,為了提高吞吐量,把原本「訂單狀態變更」這個 Consumer 從 1 個 Worker 增加到 5 個 Worker 平行 Fetch 處理。上線後果然重現了 Day 58 DoD 分析過的「shipped 搶先於 paid 被處理」問題。請用今天教的機制解釋根本原因,並提出一個不犧牲太多平行度的修復方案。(提示:根本原因是 5 個 Worker 平行 Fetch 打破了單一 Consumer 依序處理的順序保證,跟 Worker 數量本身無關,是「多個 Worker 共享同一個 Consumer 平行拉取」這個設定選擇造成的;修復方案可以把 Subject 設計成包含 order_id(例如orders.<order_id>.status),讓不同訂單的事件天生分散到不同的處理路徑,但同一張訂單的所有事件因為 Subject 相同,被同一個 Consumer 依 sequence 依序處理,這樣不同訂單之間仍可以透過「多個 Consumer 各自負責一部分訂單」維持平行度,只有同一張訂單內部被迫序列化,跟 Day 58 DoD 結論的 partition key 解法是同一個原理換了個實作。)

4

Retry / Redelivery / Failure Handling

Core FundamentalsLv.4
一句話理解

JetStream 的 Redelivery 機制(AckWait 逾時未收到 ack 就重新投遞、MaxDeliver 設定最大重試次數)與「訊息重試多次後不再自動重試、轉交人工」的處理方式,是 Day 58 Topic 2 Retry/DLQ 在 NATS 裡的具體實作,只是 NATS 原生沒有像 AWS SQS 那樣一鍵設定的 DLQ 物件,而是要組合 msg.Term()(明確放棄這則訊息,不再 redeliver)加上自訂的「失敗訊息另外 publish 到一個 dead-letter subject」這個模式,手動搭出等價效果。

Why

Day 58 已經解釋過為什麼需要重試上限與 DLQ——永久性失敗的訊息如果無限重試,會持續佔用資源、洗版錯誤日誌,卻永遠不會成功,JetStream 需要提供對應的機制才能在真實系統裡落地這個設計。

Mechanism

Consumer 設定 AckWait(例如 30 秒——Worker 拿到訊息後有多久時間要送出 ack,超過視為這次投遞失敗)與MaxDeliver(例如 5 次——同一則訊息最多被重新投遞幾次,超過後 NATS server 不會再主動 redeliver 這則訊息,但訊息仍留在 Stream 裡)。Worker 端在明確判斷「這次失敗是永久性、重試也沒用」時,可以呼叫 msg.Term() 立刻放棄這則訊息,不用等 AckWait 逾時,比消極等待 MaxDeliver 用完更快讓資源釋放;訊息的 metadata 裡帶有NumDelivered 欄位,可以讀到目前是第幾次投遞。要實作 Day 58 意義下真正的 DLQ(把失敗訊息集中到一個地方讓人工檢查),常見做法是 Worker 端自己在 redelivery 次數達到門檻時,主動把這則訊息的內容連同失敗原因 Publish 到另一個專門的 dead-letter subject(例如 orders.created.dlq),該 subject 可以另外建一個 Stream 持久化保存,交由監控告警與人工事後處理,這與 AWS SQS 原生 DLQ 達到的效果一致,只是 JetStream 要求自己組合出來,不是內建現成物件。

Trade-off

AckWait 與 MaxDeliver 設定太短/太少,可能讓本來稍微久一點就能處理完的正常訊息被誤判成失敗而不斷 redeliver(甚至提早用完 MaxDeliver 被放棄);設定太長/太多,則讓永久性失敗的訊息占用資源的時間拖得更久才被放棄,這跟 Day 58 Topic 2 Trade-off 段落講的取捨完全一致,只是換成 AckWait/MaxDeliver 這兩個具體參數名稱。手動組出 DLQ 模式,換來「不受特定雲端廠商限制、完全掌握邏輯」的彈性,代價是要自己維護這段邏輯的正確性——忘記在 Worker 端加上這段判斷,等同完全沒有 DLQ,失敗訊息用完 MaxDeliver 後就悄悄消失在正常監控視野外。

Failure mode

沒有設定 MaxDeliver(或設成極大值)、也沒有實作 DLQ 判斷邏輯,一則永久失敗的訊息會被無限 redeliver、Worker 的錯誤日誌被同一個問題洗版——這與 Day 58 Failure Modes 描述的情境完全相同;另一種常見疏漏是設了 MaxDeliver 但沒有實作 DLQ publish 邏輯,達到 MaxDeliver 後訊息就此在系統裡「沉默消失」(不再被投遞、但也沒有任何人被通知),比沒有 MaxDeliver 更難察覺,因為連錯誤日誌洗版這個至少看得到的警訊都沒有了。

Backend 連結

這是 Day 58 Retry/DLQ 抽象概念在 NATS JetStream 裡完整的具體 API 實作(AckWait/MaxDeliver/Term/自訂 dead-letter subject),也是回答下面收尾 Capstone「Worker crash 後怎麼辦」的核心機制。

陌生題

一個 Worker 設定 AckWait=10 秒、MaxDeliver=3。某則訊息因為對應的收件人資料已經在資料庫被刪除,導致每次處理都必定失敗(丟出明確的「找不到收件人」例外,不是逾時)。Worker 目前的程式邏輯是「例外發生時什麼都不做,讓函式直接結束(既不 Ack 也不 Term)」。這則訊息最終的命運會是什麼?如果 Worker 程式邏輯改成「捕捉到『找不到收件人』這類明確的永久性錯誤時呼叫 msg.Term()並 publish 到 dead-letter subject」,結果會有什麼不同?(提示:目前邏輯下,函式結束但沒有 Ack,AckWait 10 秒後 NATS 判定投遞失敗、redeliver,最多重試到 MaxDeliver=3 次後 NATS 不再主動 redeliver,但因為 Worker 從未呼叫 Term 也沒有 dead-letter 機制,這則訊息就此「沉默消失」,沒有任何人知道它失敗過,且中間 3 次重試花了至少 30 秒才走完;改成明確 Term() + publish dead-letter 後,第一次遇到這種明確的永久性錯誤就能立刻放棄,不需要空等 AckWait 與白白浪費 2 次一定會失敗的 redelivery,且失敗訊息會被記錄在 dead-letter subject 供人工事後檢查,不會悄悄消失。)

Day 80 過關標準(DoD)—— Phase 08 NATS 收尾 Capstone:API → NATS → Worker → DB + Worker crash 後怎麼辦

``text 1. 動手驗證 Redelivery(延續 Day 79 的 Docker NATS server,這次要啟用 JetStream):a. docker run -p 4222:4222 --name nats-js nats:latest -js b. 用 nats CLI 建立一個 File Retention 的 Stream:nats stream add ORDERS --subjects "orders.>" --storage file --retention limits --max-msgs=-1 --max-bytes=-1 --max-age=0 --discard old c. 建立一個 Explicit Ack、AckWait=5s、MaxDeliver=3 的 Pull Consumer:nats consumer add ORDERS worker-consumer --filter orders.created --ack explicit --wait 5s --max-deliver 3 --pull d. 發布一則測試訊息:nats pub orders.created '{"order_id": 999}'e. 用 nats consumer next ORDERS worker-consumer --no-ack 刻意不 ack 地拉取這則訊息;等待超過 5 秒後再拉一次同樣的指令,確認同一則訊息被重新投遞(nats consumer info ORDERS worker-consumer 顯示的 Num Delivered 應該遞增);重複到第 3 次,確認之後這則訊息不再被投遞(已達 MaxDeliver)。附上每一步的實際指令輸出。f. 用自己的話寫一段總結:這個實驗如何對應到 Topic 4 教的 AckWait/MaxDeliver 機制,以及如果 Worker 邏輯忘記呼叫 Term() 或建立 dead-letter subject,這則訊息在第 3 次投遞失敗後會發生什麼事。2. 畫出並書面說明完整的 API → NATS → Worker → DB 流程:- API 服務收到使用者請求後,先完成自己該做的同步工作(例如驗證輸入、寫入自己的主要業務資料表),再把一則事件 Publish 到 JetStream 的某個 Subject(帶 Nats-Msg-Id 用於去重,對應 Topic 2)。- NATS JetStream 把這則訊息寫入 Stream(File Retention,對應 Topic 1),回應 Publish 成功——此時 API 服務才能真正確定這個事件已經穩定存下來,不會因為當下沒有 Worker 在線而遺失(對應 Day 79 驗證過的 Core NATS 缺陷已被解決)。- Worker 用 Pull Consumer 批次 Fetch 訊息,完整執行完業務邏輯(寫入 DB)之後才 Ack(對應 Topic 2 陌生題的正確順序);若同一個 Subject 由多個 Worker 平行 Fetch,需說明相關訊息的 Ordering 如何被保護(對應 Topic 3,例如把 order_id 放進 Subject)。3. 具體回答「Worker crash 後怎麼辦」,分兩種 crash 時機各自說明:a. Worker 在完整執行完 DB 寫入、但送出 Ack 之前 crash:說明 JetStream 如何偵測(AckWait 逾時)、如何補救(redeliver 給另一個 Worker 實例),以及這時候「重複投遞」為什麼不會導致 DB 出現兩筆重複資料——需要具體點出 DB 端要有什麼保護(例如以 order_id 或事件的 Msg-Id 做唯一鍵限制、或用 upsert 而非 insert),呼應 Day 58 Topic 4 Idempotency 的機制在 DB 這一層的具體實作。b. Worker 在真正執行 DB 寫入之前(例如剛 Fetch 到訊息、還在做前置檢查)就 crash:說明這種情況的處理路徑跟 (a) 是否相同、有沒有更簡單的地方。最後回答:如果這則訊息是那種「不管重試幾次都會失敗」的永久性錯誤(例如訊息格式本身有問題),Topic 4 教的哪個機制能避免它被無限 redeliver 卡住正常流程,具體要怎麼接上(Term() +dead-letter subject)。``