<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>模組四：服務探活與自動恢復 on Tarragon</title><link>https://tarrragon.github.io/blog/devops/operations/04-service-health/</link><description>Recent content in 模組四：服務探活與自動恢復 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Sat, 20 Jun 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/devops/operations/04-service-health/index.xml" rel="self" type="application/rss+xml"/><item><title>Health check endpoint 設計</title><link>https://tarrragon.github.io/blog/devops/operations/04-service-health/health-check-endpoint/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/04-service-health/health-check-endpoint/</guid><description>&lt;p>Health check endpoint 的責任是讓外部用一個標準請求就問到服務的權威健康狀態，取代肉眼登入機器猜死活。它是這個模組所有自動恢復機制的共同輸入：重啟靠它判斷該不該重啟、負載平衡靠它決定要不要送流量、告警靠它知道什麼時候該叫人。端點回什麼、探多深，是每個服務要自己設計的決策，不是框架給的預設值。&lt;/p>
&lt;p>設計的核心問題只有一個：「這個端點回 200 的時候，到底保證了什麼」。答得越含糊，下游依賴這個訊號做的每個決策就越不可靠。&lt;/p>
&lt;h2 id="淺檢查只證明-http-handler-活著">淺檢查只證明 HTTP handler 活著&lt;/h2>
&lt;p>最簡單的 health endpoint 是收到請求就回 200。這種檢查探到的深度只到「HTTP server 還在接受連線、路由還在運作」，不保證服務真的能做事。進程活著不等於內部子系統活著——一個服務可以進程好端端跑著、&lt;code>pgrep&lt;/code> 找得到、CPU 不高，但它內部某個關鍵子系統已經 wedged，這種狀態下淺檢查照樣回 200。這正是 &lt;a href="https://tarrragon.github.io/blog/linux/debug/process-service-state-diagnosis/" data-link-title="程序、服務與狀態怎麼判" data-link-desc="要判斷一個程式活著沒、某個系統服務現在由誰提供、桌面 session 有沒有被鎖、或終端機多工器的 session 還在不在時，用對的權威來源而不是靠畫面或猜的名字">程序、服務與狀態怎麼判&lt;/a> 裡「進程活著 ≠ 在運作」那條，搬到服務自我回報的場景。&lt;/p>
&lt;p>一個資料寫入服務的 handler 執行緒還在監聽、但它背後的資料庫連線池已經全部逾時，淺檢查會回 200，負載平衡繼續把寫入請求送進來，每一筆都在下游卡死。訊號說健康、實際在掉資料——淺檢查的盲點就在這裡：它證明的比它看起來保證的少很多。&lt;/p>
&lt;h2 id="深檢查探到服務真正依賴的子系統">深檢查探到服務真正依賴的子系統&lt;/h2>
&lt;p>深檢查在回答健康之前，先確認服務賴以運作的關鍵子系統確實可用。一個寫入服務的深檢查會實際碰一下儲存層——取一個連線、確認 pool 沒枯竭就夠，不必完整寫一筆；一個查詢服務會確認索引載入完成、快取連得上。深檢查回 200 的保證比淺檢查強：不只 handler 活著，服務真正做事需要的那條路徑也通。&lt;/p>
&lt;p>深檢查的成本是它要花時間、也可能引入自己的失敗。檢查本身若把每個下游依賴都同步 ping 一遍，就製造了依賴放大：一個非關鍵的下游慢一點，health check 跟著逾時，服務被判成不健康、被摘掉流量或被重啟，但服務本身其實還能服務核心請求。所以深檢查的設計要先切開「關鍵依賴」跟「非關鍵依賴」——關鍵依賴掛了服務確實無法運作，值得檢查；非關鍵依賴（一個可以降級的推薦引擎、一個非同步的分析上報）掛了不該讓整個服務被判死。這條切分沒有通則，取決於這個服務缺了哪個子系統就真的做不了事。&lt;/p>
&lt;h2 id="帶診斷資訊的回應把活著跟有沒有在做事分開">帶診斷資訊的回應把「活著」跟「有沒有在做事」分開&lt;/h2>
&lt;p>health endpoint 回的不只有狀態碼，還可以帶回讓外部判讀健康程度的欄位。本站 collector 的 &lt;code>/health&lt;/code> 回 200 的同時附帶「最後一次成功寫入的時間」——這個時間戳把兩件事分開了：狀態碼 200 說進程活著，最後寫入時間說它有沒有在做事。進程活著但最後寫入停在十分鐘前，是「活著但卡住」的定案訊號，比單看狀態碼可靠。&lt;/p>
&lt;p>更細的健康程度來自處理管線的內部計數。collector 在 endpoint 暴露 &lt;code>channel.depth&lt;/code>（待寫入事件的積壓數）跟 &lt;code>storage.errors&lt;/code>（寫入錯誤累計）這類計數器；積壓持續變高、錯誤持續累加，代表服務還活著、但已經降級——處理速度跟不上進來的量。這是二元的「掛了沒」之外的中間態訊號。&lt;/p>
&lt;p>中間態不一定要暴露給最終呈現層。同一套 collector 的 DevOps 儀表板服務狀態卡刻意設計成純二元——綠色正常、紅色異常，不讓看板的人去解讀積壓數字。中間態的量化欄位存在於 endpoint、供機器判讀與告警規則收斂用；呈現層則收斂成二元，讓人一眼就懂。健康程度探得多細，跟最終要呈現多細，是兩個獨立的決策。&lt;/p>
&lt;h2 id="健康端點與應用的路由共用同一個路徑空間">健康端點與應用的路由共用同一個路徑空間&lt;/h2>
&lt;p>健康端點通常跟應用的其他路由由同一個伺服器、同一個 port 處理，所以它的路徑會跟應用的路由搶同一個名字。應用有「整個路徑都是參數」的路由時，這件事會變成一個不報錯的錯誤：短網址服務的轉址是 &lt;code>GET /{code}&lt;/code>，健康端點是 &lt;code>GET /healthz&lt;/code>，而 &lt;code>healthz&lt;/code> 本身就是一個合法的 7 碼 Base62 短碼。以 Go 的 &lt;code>http.ServeMux&lt;/code> 實測，固定路徑與 &lt;code>{code}&lt;/code> 都對得上時由固定路徑接走——&lt;code>/healthz&lt;/code> 進健康檢查的 handler、&lt;code>/aB3xYz9&lt;/code> 進轉址的 handler——所以萬一亂數產生了 &lt;code>healthz&lt;/code> 這個短碼，那條連結永遠轉不了址，也不會有任何錯誤。&lt;/p>
&lt;p>處理方式有三種：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>讓固定路由使用參數不可能含有的字元&lt;/strong>：Base62 短碼只有英數字，固定路由放在 &lt;code>/-/healthz&lt;/code> 這種帶 &lt;code>-&lt;/code> 的路徑下（Prometheus 自己的健康端點就是 &lt;code>/-/healthy&lt;/code>），短碼依產生方式就不可能同名，不需要維護任何清單。前提是參數的字元集是固定的；允許使用者自訂名稱時，自訂名稱的驗證也要排除那些字元。&lt;/li>
&lt;li>&lt;strong>讓產生參數的那一端排除同名的字串&lt;/strong>：短碼產生器與自訂名稱的驗證排除 &lt;code>healthz&lt;/code> 這類固定路由的名字。不必改路徑，代價是之後每加一個固定路由都要記得更新排除清單，漏加不會報錯。&lt;/li>
&lt;li>&lt;strong>把健康端點放到另一個 port&lt;/strong>：不和應用路由共用路徑空間，代價是多開一個 port、平台與探測工具都要多設定一個位址。&lt;/li>
&lt;/ul>
&lt;h2 id="啟動期的健康是另一種狀態">啟動期的健康是另一種狀態&lt;/h2>
&lt;p>服務剛起來、還在載入索引或重建狀態時，它既不是掛了、也還不能服務——這是啟動自檢要覆蓋的一段。collector 啟動時會檢查儲存完整性、重建索引，這段期間服務進程活著，但還沒準備好接流量。淺檢查在這時就會回 200，若外部據此開始送流量，請求會打在還沒就緒的服務上。啟動期的健康狀態要跟「就緒可服務」分開回報——啟動中、就緒、存活是三種不同狀態，對應 &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">liveness、readiness 與 startup 三種 probe&lt;/a> 要分開回答的問題。&lt;/p>
&lt;h2 id="誰來戳這個端點">誰來戳這個端點&lt;/h2>
&lt;p>設計好回什麼之後，還要決定誰來讀它。服務自己不會主動回報，要有外部的探測者定期戳。最輕量的做法是一個定時器對 endpoint 發健康請求並設逾時，戳不動就讓那次檢查失敗、走既有的告警鏈——&lt;a href="https://tarrragon.github.io/blog/linux/debug/service-failure-monitoring/" data-link-title="服務掛了怎麼自動知道：從肉眼盯到主動告警" data-link-desc="不想每次都手動 systemctl 檢查服務死活、想讓機器在 service 掛掉時主動推播通知、或擔心整台機器當掉沒人知道時回來讀">服務掛了怎麼自動知道&lt;/a> 的外部探針段有單機 systemd 上的完整實作（curl &lt;code>/health&lt;/code> 設 5 秒逾時、逾時就 failed）。跑在編排平台上時，這個角色由平台的 probe 機制接手，探測的語意、失敗後的動作，是 &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">liveness 與 readiness&lt;/a> 的主題。&lt;/p>
&lt;p>外部探測比服務自我回報可靠的地方在於：服務內部卡死時，它可能連「我不健康」都報不出來——是外部戳它、發現戳不動，才抓得到這種失效。自我回報只能覆蓋服務還有能力回應的那些失敗。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>活著、準備好接流量、啟動中是三種健康、對應三種 probe → &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">Liveness 與 Readiness&lt;/a>&lt;/li>
&lt;li>端點設計好之後，怎麼在單機用 systemd 定時探測並告警 → &lt;a href="https://tarrragon.github.io/blog/linux/debug/service-failure-monitoring/" data-link-title="服務掛了怎麼自動知道：從肉眼盯到主動告警" data-link-desc="不想每次都手動 systemctl 檢查服務死活、想讓機器在 service 掛掉時主動推播通知、或擔心整台機器當掉沒人知道時回來讀">服務掛了怎麼自動知道&lt;/a>&lt;/li>
&lt;li>探到不健康之後怎麼自動重啟、放棄了才告警 → &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/systemd-watchdog-restart/" data-link-title="systemd watchdog 與自動重啟" data-link-desc="在單機 systemd 上做服務自動恢復時，釐清 watchdog 主動報活與 restart policy 被動拉起是兩套機制、以及為什麼要先重啟幾次才放棄">systemd watchdog 與自動重啟&lt;/a>&lt;/li>
&lt;li>health signal 怎麼收斂成 DevOps 儀表板的服務狀態卡 → &lt;a href="https://tarrragon.github.io/blog/devops/monitoring/04-collector/dashboard-devops/" data-link-title="DevOps Dashboard 設計" data-link-desc="Collector 和 SDK 是否健康 — 日常監控的服務狀態卡、吞吐量曲線、儲存用量，以及告警觸發後的排障視圖">Monitoring DevOps 儀表板&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Health check endpoint 的責任是讓外部用一個標準請求就問到服務的權威健康狀態，取代肉眼登入機器猜死活。它是這個模組所有自動恢復機制的共同輸入：重啟靠它判斷該不該重啟、負載平衡靠它決定要不要送流量、告警靠它知道什麼時候該叫人。端點回什麼、探多深，是每個服務要自己設計的決策，不是框架給的預設值。</p>
<p>設計的核心問題只有一個：「這個端點回 200 的時候，到底保證了什麼」。答得越含糊，下游依賴這個訊號做的每個決策就越不可靠。</p>
<h2 id="淺檢查只證明-http-handler-活著">淺檢查只證明 HTTP handler 活著</h2>
<p>最簡單的 health endpoint 是收到請求就回 200。這種檢查探到的深度只到「HTTP server 還在接受連線、路由還在運作」，不保證服務真的能做事。進程活著不等於內部子系統活著——一個服務可以進程好端端跑著、<code>pgrep</code> 找得到、CPU 不高，但它內部某個關鍵子系統已經 wedged，這種狀態下淺檢查照樣回 200。這正是 <a href="/blog/linux/debug/process-service-state-diagnosis/" data-link-title="程序、服務與狀態怎麼判" data-link-desc="要判斷一個程式活著沒、某個系統服務現在由誰提供、桌面 session 有沒有被鎖、或終端機多工器的 session 還在不在時，用對的權威來源而不是靠畫面或猜的名字">程序、服務與狀態怎麼判</a> 裡「進程活著 ≠ 在運作」那條，搬到服務自我回報的場景。</p>
<p>一個資料寫入服務的 handler 執行緒還在監聽、但它背後的資料庫連線池已經全部逾時，淺檢查會回 200，負載平衡繼續把寫入請求送進來，每一筆都在下游卡死。訊號說健康、實際在掉資料——淺檢查的盲點就在這裡：它證明的比它看起來保證的少很多。</p>
<h2 id="深檢查探到服務真正依賴的子系統">深檢查探到服務真正依賴的子系統</h2>
<p>深檢查在回答健康之前，先確認服務賴以運作的關鍵子系統確實可用。一個寫入服務的深檢查會實際碰一下儲存層——取一個連線、確認 pool 沒枯竭就夠，不必完整寫一筆；一個查詢服務會確認索引載入完成、快取連得上。深檢查回 200 的保證比淺檢查強：不只 handler 活著，服務真正做事需要的那條路徑也通。</p>
<p>深檢查的成本是它要花時間、也可能引入自己的失敗。檢查本身若把每個下游依賴都同步 ping 一遍，就製造了依賴放大：一個非關鍵的下游慢一點，health check 跟著逾時，服務被判成不健康、被摘掉流量或被重啟，但服務本身其實還能服務核心請求。所以深檢查的設計要先切開「關鍵依賴」跟「非關鍵依賴」——關鍵依賴掛了服務確實無法運作，值得檢查；非關鍵依賴（一個可以降級的推薦引擎、一個非同步的分析上報）掛了不該讓整個服務被判死。這條切分沒有通則，取決於這個服務缺了哪個子系統就真的做不了事。</p>
<h2 id="帶診斷資訊的回應把活著跟有沒有在做事分開">帶診斷資訊的回應把「活著」跟「有沒有在做事」分開</h2>
<p>health endpoint 回的不只有狀態碼，還可以帶回讓外部判讀健康程度的欄位。本站 collector 的 <code>/health</code> 回 200 的同時附帶「最後一次成功寫入的時間」——這個時間戳把兩件事分開了：狀態碼 200 說進程活著，最後寫入時間說它有沒有在做事。進程活著但最後寫入停在十分鐘前，是「活著但卡住」的定案訊號，比單看狀態碼可靠。</p>
<p>更細的健康程度來自處理管線的內部計數。collector 在 endpoint 暴露 <code>channel.depth</code>（待寫入事件的積壓數）跟 <code>storage.errors</code>（寫入錯誤累計）這類計數器；積壓持續變高、錯誤持續累加，代表服務還活著、但已經降級——處理速度跟不上進來的量。這是二元的「掛了沒」之外的中間態訊號。</p>
<p>中間態不一定要暴露給最終呈現層。同一套 collector 的 DevOps 儀表板服務狀態卡刻意設計成純二元——綠色正常、紅色異常，不讓看板的人去解讀積壓數字。中間態的量化欄位存在於 endpoint、供機器判讀與告警規則收斂用；呈現層則收斂成二元，讓人一眼就懂。健康程度探得多細，跟最終要呈現多細，是兩個獨立的決策。</p>
<h2 id="健康端點與應用的路由共用同一個路徑空間">健康端點與應用的路由共用同一個路徑空間</h2>
<p>健康端點通常跟應用的其他路由由同一個伺服器、同一個 port 處理，所以它的路徑會跟應用的路由搶同一個名字。應用有「整個路徑都是參數」的路由時，這件事會變成一個不報錯的錯誤：短網址服務的轉址是 <code>GET /{code}</code>，健康端點是 <code>GET /healthz</code>，而 <code>healthz</code> 本身就是一個合法的 7 碼 Base62 短碼。以 Go 的 <code>http.ServeMux</code> 實測，固定路徑與 <code>{code}</code> 都對得上時由固定路徑接走——<code>/healthz</code> 進健康檢查的 handler、<code>/aB3xYz9</code> 進轉址的 handler——所以萬一亂數產生了 <code>healthz</code> 這個短碼，那條連結永遠轉不了址，也不會有任何錯誤。</p>
<p>處理方式有三種：</p>
<ul>
<li><strong>讓固定路由使用參數不可能含有的字元</strong>：Base62 短碼只有英數字，固定路由放在 <code>/-/healthz</code> 這種帶 <code>-</code> 的路徑下（Prometheus 自己的健康端點就是 <code>/-/healthy</code>），短碼依產生方式就不可能同名，不需要維護任何清單。前提是參數的字元集是固定的；允許使用者自訂名稱時，自訂名稱的驗證也要排除那些字元。</li>
<li><strong>讓產生參數的那一端排除同名的字串</strong>：短碼產生器與自訂名稱的驗證排除 <code>healthz</code> 這類固定路由的名字。不必改路徑，代價是之後每加一個固定路由都要記得更新排除清單，漏加不會報錯。</li>
<li><strong>把健康端點放到另一個 port</strong>：不和應用路由共用路徑空間，代價是多開一個 port、平台與探測工具都要多設定一個位址。</li>
</ul>
<h2 id="啟動期的健康是另一種狀態">啟動期的健康是另一種狀態</h2>
<p>服務剛起來、還在載入索引或重建狀態時，它既不是掛了、也還不能服務——這是啟動自檢要覆蓋的一段。collector 啟動時會檢查儲存完整性、重建索引，這段期間服務進程活著，但還沒準備好接流量。淺檢查在這時就會回 200，若外部據此開始送流量，請求會打在還沒就緒的服務上。啟動期的健康狀態要跟「就緒可服務」分開回報——啟動中、就緒、存活是三種不同狀態，對應 <a href="/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">liveness、readiness 與 startup 三種 probe</a> 要分開回答的問題。</p>
<h2 id="誰來戳這個端點">誰來戳這個端點</h2>
<p>設計好回什麼之後，還要決定誰來讀它。服務自己不會主動回報，要有外部的探測者定期戳。最輕量的做法是一個定時器對 endpoint 發健康請求並設逾時，戳不動就讓那次檢查失敗、走既有的告警鏈——<a href="/blog/linux/debug/service-failure-monitoring/" data-link-title="服務掛了怎麼自動知道：從肉眼盯到主動告警" data-link-desc="不想每次都手動 systemctl 檢查服務死活、想讓機器在 service 掛掉時主動推播通知、或擔心整台機器當掉沒人知道時回來讀">服務掛了怎麼自動知道</a> 的外部探針段有單機 systemd 上的完整實作（curl <code>/health</code> 設 5 秒逾時、逾時就 failed）。跑在編排平台上時，這個角色由平台的 probe 機制接手，探測的語意、失敗後的動作，是 <a href="/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">liveness 與 readiness</a> 的主題。</p>
<p>外部探測比服務自我回報可靠的地方在於：服務內部卡死時，它可能連「我不健康」都報不出來——是外部戳它、發現戳不動，才抓得到這種失效。自我回報只能覆蓋服務還有能力回應的那些失敗。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>活著、準備好接流量、啟動中是三種健康、對應三種 probe → <a href="/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">Liveness 與 Readiness</a></li>
<li>端點設計好之後，怎麼在單機用 systemd 定時探測並告警 → <a href="/blog/linux/debug/service-failure-monitoring/" data-link-title="服務掛了怎麼自動知道：從肉眼盯到主動告警" data-link-desc="不想每次都手動 systemctl 檢查服務死活、想讓機器在 service 掛掉時主動推播通知、或擔心整台機器當掉沒人知道時回來讀">服務掛了怎麼自動知道</a></li>
<li>探到不健康之後怎麼自動重啟、放棄了才告警 → <a href="/blog/devops/operations/04-service-health/systemd-watchdog-restart/" data-link-title="systemd watchdog 與自動重啟" data-link-desc="在單機 systemd 上做服務自動恢復時，釐清 watchdog 主動報活與 restart policy 被動拉起是兩套機制、以及為什麼要先重啟幾次才放棄">systemd watchdog 與自動重啟</a></li>
<li>health signal 怎麼收斂成 DevOps 儀表板的服務狀態卡 → <a href="/blog/devops/monitoring/04-collector/dashboard-devops/" data-link-title="DevOps Dashboard 設計" data-link-desc="Collector 和 SDK 是否健康 — 日常監控的服務狀態卡、吞吐量曲線、儲存用量，以及告警觸發後的排障視圖">Monitoring DevOps 儀表板</a></li>
</ul>
]]></content:encoded></item><item><title>Liveness 與 Readiness</title><link>https://tarrragon.github.io/blog/devops/operations/04-service-health/liveness-vs-readiness/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/04-service-health/liveness-vs-readiness/</guid><description>&lt;p>Liveness 跟 readiness 探測失敗時，平台採取的動作完全不同：readiness 失敗，平台把這個實例從流量目標裡摘掉、但讓它繼續跑；liveness 失敗，平台判定這個實例壞到無法恢復、直接重建它。兩者混淆的代價是用錯恢復動作——把一個只是暫時不能接流量的服務給重啟了，或把一個真的死掉的服務一直留在流量池裡。分清這兩種健康，是設計自動恢復的前提。&lt;/p>
&lt;p>差別的根源是它們回答的問題不同。Readiness 問「現在可以安全地把流量送給它嗎」，liveness 問「它還有基本運作能力、還是壞到只能砍掉重來」。同一個服務在同一時刻，readiness 可能為否、liveness 卻為是——它活著、只是還沒準備好。&lt;/p>
&lt;h2 id="readiness宣告可以安全接流量">Readiness：宣告可以安全接流量&lt;/h2>
&lt;p>Readiness 是服務對平台的宣告：我現在可以安全承接流量了。平台只把流量導向 readiness 通過的實例；一旦某個實例 readiness 轉為否，平台停止送新流量給它，但不動它的進程。這個機制讓服務有辦法在「還活著、但暫時不該接流量」的狀態下把自己隔離起來——正在載入大型快取、下游必要依賴短暫斷線、或正在收束準備關閉。&lt;/p>
&lt;p>Readiness 設計的核心決策是「哪些依賴要納入判斷」。這條線劃錯，readiness 不是太緊就是太鬆。依賴大致分三類，各有不同的納入原則：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>必要依賴&lt;/strong>：資料庫、認證服務這種，不可用時服務完全無法處理請求。這類納入 readiness——它們斷了，服務就該停止接流量。&lt;/li>
&lt;li>&lt;strong>可降級依賴&lt;/strong>：推薦引擎、非關鍵快取這種，不可用時服務仍能服務核心請求、只是功能打折。這類不該納入 readiness，改用熔斷或 fallback 在應用層處理；把它們納入，等於讓一個可以降級的功能故障拖垮整個服務的接流量能力。&lt;/li>
&lt;li>&lt;strong>觀測依賴&lt;/strong>：metrics collector、log shipper 這種。把它們納入 readiness 是常見的誤判——觀測基建掛掉本身不影響服務處理請求的能力，卻會讓整個服務被判成不 ready、被摘光流量。監控自己的健康不該是服務能否服務的前提。&lt;/li>
&lt;/ul>
&lt;h2 id="liveness宣告基本運作能力還在">Liveness：宣告基本運作能力還在&lt;/h2>
&lt;p>Liveness 是服務宣告自己還有基本運作能力；它失敗時平台的動作是重建實例，因為判斷已經是「這個實例壞到不可恢復」。正因為後果是重啟，liveness 只該用來偵測那些「重啟真的能修好」的失敗。&lt;/p>
&lt;p>適合 liveness 偵測的是進程回不了頭的內部壞死。一個典型是死結——把 liveness 端點設在跟主要工作獨立的執行緒上，主工作 deadlock 時這個端點回應不了，平台重建實例確實能解。另一個是記憶體洩漏逼近 OOM 的前兆：服務主動回報 unhealthy、讓平台在還來得及的時候有序重建，好過等平台的 OOM kill 硬砍（OOM kill 不走 graceful shutdown、在途請求直接中斷）。還有一類是關鍵背景任務永久停止——像憑證續期、session 清理這種停了服務會慢慢壞掉的任務，值得反映進 liveness。&lt;/p>
&lt;p>不適合 liveness 偵測的是下游的暫時性問題。下游資料庫短暫不可用、外部 API 逾時、快取命中率升高——這些用 liveness 去重建實例毫無幫助，重啟這個服務不會修好它的下游，只會用重啟放大問題：實例重建期間容量更少、下游壓力反而更大。這類屬於 readiness（暫時摘流量）或熔斷（快速失敗）的範疇，不是 liveness。把下游故障接進 liveness，是製造 restart loop 最常見的方式。&lt;/p>
&lt;h2 id="startup啟動期是第三種狀態">Startup：啟動期是第三種狀態&lt;/h2>
&lt;p>服務剛起來、還在初始化的那段時間，既不是死、也還不能服務，這是第三種探針要覆蓋的狀態。Startup 探針在啟動期間持續探測，一旦成功就把健康判斷交棒給 liveness 跟 readiness。它存在的理由是啟動期的容忍窗口跟穩定運行期不同——一個冷啟動要拉 image、連依賴、重建索引的服務，可能要幾分鐘才就緒，但這不代表穩定運行時也該容忍幾分鐘不回應。Startup 探針的總容忍時間由探測失敗門檻乘上探測間隔決定（例如門檻 30 次、間隔 10 秒，就是 300 秒窗口），設計時量測最差情境的啟動時間（冷啟動加 image 拉取加依賴連線）再加兩三成 headroom。&lt;/p>
&lt;p>這帶出一個反模式：把 startup、readiness、liveness 全設成同一個 &lt;code>/health&lt;/code> 端點。三種探針問的是不同問題——初始化完成不等於依賴就緒，依賴暫時不可達不等於服務本身壞了。同一個端點無法同時給出三種語意不同的答案，混用的結果是平台在錯的時機做錯的動作。&lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/health-check-endpoint/" data-link-title="Health check endpoint 設計" data-link-desc="設計服務的健康檢查端點時，判斷什麼回應算健康、check 要探到多深、依賴要不要一起檢查時回來讀">Health check endpoint 設計&lt;/a> 講的檢查深度，在這裡對應到「哪一種探針該探到哪一層」。&lt;/p>
&lt;h2 id="探針靈敏度是雙向的取捨">探針靈敏度是雙向的取捨&lt;/h2>
&lt;p>Liveness 設太敏感，服務會陷入 restart loop——一點抖動就被判死、重建、重建期間更不穩、又被判死；過載時 liveness 讓實例反覆重啟的結果與辨認方法見 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/overload-outcomes/" data-link-title="9.16 負載超過容量時服務的表現：變慢、請求失敗、無回應、程序終止，尖峰過去或應對切換之後仍持續的停在失效、擺盪與第二波湧入，以及應對選出的降級回應，各自的判斷標準、成因、應對與看得到它的監控層" data-link-desc="負載超過容量（過載）時，服務呈現的結果分成變慢、請求失敗（抵達後被拒與根本沒有抵達）、無回應、程序終止，以及尖峰過去、依賴恢復或應對切換之後仍持續的停在失效、擺盪與第二波湧入；每一種的判斷標準、通常的成因與應對，哪一種先出現由先耗盡的資源與等待是否設有上限決定，影響範圍怎麼隨時間擴大，服務端監控的各層各自看得到哪一種，降級回應怎麼計數，過載在資料上造成的重複寫入、接受後遺失與讀到舊資料，以及描述一次過載要寫出的項目">9.16 負載超過容量時服務的表現&lt;/a> 的〈程序終止：被系統結束、當機或反覆重啟〉。設太寬鬆，壞掉的實例會長期留在線上、持續吃流量卻服務不了。Readiness 同理，只是後果換成流量抖動而非重啟迴圈。&lt;/p>
&lt;p>Liveness 觸發的重啟從來不是零成本，設計靈敏度時要把代價算進去：在途請求被中斷、連線要重建（資料庫連線池、快取、佇列消費者都要重新建立）、實例重建期間出現容量缺口。最危險的是 thundering herd——多個實例因為同一個下游問題同時被判失敗、同時重啟、同時回來搶資源，瞬間把下游壓力放大。這也是為什麼 liveness 不該接下游故障：下游一抖，所有實例一起重啟，比原本的問題更糟。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>在單機 systemd 上，liveness 對應 watchdog、重啟對應 restart policy → &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/systemd-watchdog-restart/" data-link-title="systemd watchdog 與自動重啟" data-link-desc="在單機 systemd 上做服務自動恢復時，釐清 watchdog 主動報活與 restart policy 被動拉起是兩套機制、以及為什麼要先重啟幾次才放棄">systemd watchdog 與自動重啟&lt;/a>&lt;/li>
&lt;li>探針該探到哪一層、回什麼才算健康 → &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/health-check-endpoint/" data-link-title="Health check endpoint 設計" data-link-desc="設計服務的健康檢查端點時，判斷什麼回應算健康、check 要探到多深、依賴要不要一起檢查時回來讀">Health check endpoint 設計&lt;/a>&lt;/li>
&lt;li>所有副本共用的外部依賴（第三方金流）納入 readiness 時，全部副本同時下線、連付款回呼都收不到的例子 → &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/monitoring-inventory-k8s-checkout/" data-link-title="4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout：叢集內的監控、託管資料庫與第三方金流各自的失效域，以及佇列消費端與外部依賴的缺口" data-link-desc="把一個跑在託管 Kubernetes 上、有訊息佇列消費端、託管資料庫與第三方金流的電商 checkout 現有的監控放進監控鏈的各層，逐層判讀缺口與補法；另寫付款服務的 readiness 納入金流業者時對付款回呼的影響、失效域從主機換成叢集與雲端帳號之後觀察者要放在哪裡，以及業務結果、對帳、到期事件、消費端、備份、安全、第三方依賴與成本的逐類盤點">4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout&lt;/a> 的〈付款服務的 readiness 納入金流業者時的後果〉&lt;/li>
&lt;li>不同平台能不能分開表達這三種探針，是選型的關鍵 → &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/process-supervisor-selection/" data-link-title="Process supervisor 選型" data-link-desc="在 systemd、supervisord、Docker restart policy、Kubernetes 之間選服務監管方式時，用平台能不能分開表達 startup、readiness、liveness、drain 當判斷標準">Process supervisor 選型&lt;/a>&lt;/li>
&lt;li>Readiness 轉為否、先摘流量再收束，是關閉流程的第一步 → &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">Graceful shutdown&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Liveness 跟 readiness 探測失敗時，平台採取的動作完全不同：readiness 失敗，平台把這個實例從流量目標裡摘掉、但讓它繼續跑；liveness 失敗，平台判定這個實例壞到無法恢復、直接重建它。兩者混淆的代價是用錯恢復動作——把一個只是暫時不能接流量的服務給重啟了，或把一個真的死掉的服務一直留在流量池裡。分清這兩種健康，是設計自動恢復的前提。</p>
<p>差別的根源是它們回答的問題不同。Readiness 問「現在可以安全地把流量送給它嗎」，liveness 問「它還有基本運作能力、還是壞到只能砍掉重來」。同一個服務在同一時刻，readiness 可能為否、liveness 卻為是——它活著、只是還沒準備好。</p>
<h2 id="readiness宣告可以安全接流量">Readiness：宣告可以安全接流量</h2>
<p>Readiness 是服務對平台的宣告：我現在可以安全承接流量了。平台只把流量導向 readiness 通過的實例；一旦某個實例 readiness 轉為否，平台停止送新流量給它，但不動它的進程。這個機制讓服務有辦法在「還活著、但暫時不該接流量」的狀態下把自己隔離起來——正在載入大型快取、下游必要依賴短暫斷線、或正在收束準備關閉。</p>
<p>Readiness 設計的核心決策是「哪些依賴要納入判斷」。這條線劃錯，readiness 不是太緊就是太鬆。依賴大致分三類，各有不同的納入原則：</p>
<ul>
<li><strong>必要依賴</strong>：資料庫、認證服務這種，不可用時服務完全無法處理請求。這類納入 readiness——它們斷了，服務就該停止接流量。</li>
<li><strong>可降級依賴</strong>：推薦引擎、非關鍵快取這種，不可用時服務仍能服務核心請求、只是功能打折。這類不該納入 readiness，改用熔斷或 fallback 在應用層處理；把它們納入，等於讓一個可以降級的功能故障拖垮整個服務的接流量能力。</li>
<li><strong>觀測依賴</strong>：metrics collector、log shipper 這種。把它們納入 readiness 是常見的誤判——觀測基建掛掉本身不影響服務處理請求的能力，卻會讓整個服務被判成不 ready、被摘光流量。監控自己的健康不該是服務能否服務的前提。</li>
</ul>
<h2 id="liveness宣告基本運作能力還在">Liveness：宣告基本運作能力還在</h2>
<p>Liveness 是服務宣告自己還有基本運作能力；它失敗時平台的動作是重建實例，因為判斷已經是「這個實例壞到不可恢復」。正因為後果是重啟，liveness 只該用來偵測那些「重啟真的能修好」的失敗。</p>
<p>適合 liveness 偵測的是進程回不了頭的內部壞死。一個典型是死結——把 liveness 端點設在跟主要工作獨立的執行緒上，主工作 deadlock 時這個端點回應不了，平台重建實例確實能解。另一個是記憶體洩漏逼近 OOM 的前兆：服務主動回報 unhealthy、讓平台在還來得及的時候有序重建，好過等平台的 OOM kill 硬砍（OOM kill 不走 graceful shutdown、在途請求直接中斷）。還有一類是關鍵背景任務永久停止——像憑證續期、session 清理這種停了服務會慢慢壞掉的任務，值得反映進 liveness。</p>
<p>不適合 liveness 偵測的是下游的暫時性問題。下游資料庫短暫不可用、外部 API 逾時、快取命中率升高——這些用 liveness 去重建實例毫無幫助，重啟這個服務不會修好它的下游，只會用重啟放大問題：實例重建期間容量更少、下游壓力反而更大。這類屬於 readiness（暫時摘流量）或熔斷（快速失敗）的範疇，不是 liveness。把下游故障接進 liveness，是製造 restart loop 最常見的方式。</p>
<h2 id="startup啟動期是第三種狀態">Startup：啟動期是第三種狀態</h2>
<p>服務剛起來、還在初始化的那段時間，既不是死、也還不能服務，這是第三種探針要覆蓋的狀態。Startup 探針在啟動期間持續探測，一旦成功就把健康判斷交棒給 liveness 跟 readiness。它存在的理由是啟動期的容忍窗口跟穩定運行期不同——一個冷啟動要拉 image、連依賴、重建索引的服務，可能要幾分鐘才就緒，但這不代表穩定運行時也該容忍幾分鐘不回應。Startup 探針的總容忍時間由探測失敗門檻乘上探測間隔決定（例如門檻 30 次、間隔 10 秒，就是 300 秒窗口），設計時量測最差情境的啟動時間（冷啟動加 image 拉取加依賴連線）再加兩三成 headroom。</p>
<p>這帶出一個反模式：把 startup、readiness、liveness 全設成同一個 <code>/health</code> 端點。三種探針問的是不同問題——初始化完成不等於依賴就緒，依賴暫時不可達不等於服務本身壞了。同一個端點無法同時給出三種語意不同的答案，混用的結果是平台在錯的時機做錯的動作。<a href="/blog/devops/operations/04-service-health/health-check-endpoint/" data-link-title="Health check endpoint 設計" data-link-desc="設計服務的健康檢查端點時，判斷什麼回應算健康、check 要探到多深、依賴要不要一起檢查時回來讀">Health check endpoint 設計</a> 講的檢查深度，在這裡對應到「哪一種探針該探到哪一層」。</p>
<h2 id="探針靈敏度是雙向的取捨">探針靈敏度是雙向的取捨</h2>
<p>Liveness 設太敏感，服務會陷入 restart loop——一點抖動就被判死、重建、重建期間更不穩、又被判死；過載時 liveness 讓實例反覆重啟的結果與辨認方法見 <a href="/blog/backend/09-performance-capacity/overload-outcomes/" data-link-title="9.16 負載超過容量時服務的表現：變慢、請求失敗、無回應、程序終止，尖峰過去或應對切換之後仍持續的停在失效、擺盪與第二波湧入，以及應對選出的降級回應，各自的判斷標準、成因、應對與看得到它的監控層" data-link-desc="負載超過容量（過載）時，服務呈現的結果分成變慢、請求失敗（抵達後被拒與根本沒有抵達）、無回應、程序終止，以及尖峰過去、依賴恢復或應對切換之後仍持續的停在失效、擺盪與第二波湧入；每一種的判斷標準、通常的成因與應對，哪一種先出現由先耗盡的資源與等待是否設有上限決定，影響範圍怎麼隨時間擴大，服務端監控的各層各自看得到哪一種，降級回應怎麼計數，過載在資料上造成的重複寫入、接受後遺失與讀到舊資料，以及描述一次過載要寫出的項目">9.16 負載超過容量時服務的表現</a> 的〈程序終止：被系統結束、當機或反覆重啟〉。設太寬鬆，壞掉的實例會長期留在線上、持續吃流量卻服務不了。Readiness 同理，只是後果換成流量抖動而非重啟迴圈。</p>
<p>Liveness 觸發的重啟從來不是零成本，設計靈敏度時要把代價算進去：在途請求被中斷、連線要重建（資料庫連線池、快取、佇列消費者都要重新建立）、實例重建期間出現容量缺口。最危險的是 thundering herd——多個實例因為同一個下游問題同時被判失敗、同時重啟、同時回來搶資源，瞬間把下游壓力放大。這也是為什麼 liveness 不該接下游故障：下游一抖，所有實例一起重啟，比原本的問題更糟。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>在單機 systemd 上，liveness 對應 watchdog、重啟對應 restart policy → <a href="/blog/devops/operations/04-service-health/systemd-watchdog-restart/" data-link-title="systemd watchdog 與自動重啟" data-link-desc="在單機 systemd 上做服務自動恢復時，釐清 watchdog 主動報活與 restart policy 被動拉起是兩套機制、以及為什麼要先重啟幾次才放棄">systemd watchdog 與自動重啟</a></li>
<li>探針該探到哪一層、回什麼才算健康 → <a href="/blog/devops/operations/04-service-health/health-check-endpoint/" data-link-title="Health check endpoint 設計" data-link-desc="設計服務的健康檢查端點時，判斷什麼回應算健康、check 要探到多深、依賴要不要一起檢查時回來讀">Health check endpoint 設計</a></li>
<li>所有副本共用的外部依賴（第三方金流）納入 readiness 時，全部副本同時下線、連付款回呼都收不到的例子 → <a href="/blog/backend/04-observability/monitoring-inventory-k8s-checkout/" data-link-title="4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout：叢集內的監控、託管資料庫與第三方金流各自的失效域，以及佇列消費端與外部依賴的缺口" data-link-desc="把一個跑在託管 Kubernetes 上、有訊息佇列消費端、託管資料庫與第三方金流的電商 checkout 現有的監控放進監控鏈的各層，逐層判讀缺口與補法；另寫付款服務的 readiness 納入金流業者時對付款回呼的影響、失效域從主機換成叢集與雲端帳號之後觀察者要放在哪裡，以及業務結果、對帳、到期事件、消費端、備份、安全、第三方依賴與成本的逐類盤點">4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout</a> 的〈付款服務的 readiness 納入金流業者時的後果〉</li>
<li>不同平台能不能分開表達這三種探針，是選型的關鍵 → <a href="/blog/devops/operations/04-service-health/process-supervisor-selection/" data-link-title="Process supervisor 選型" data-link-desc="在 systemd、supervisord、Docker restart policy、Kubernetes 之間選服務監管方式時，用平台能不能分開表達 startup、readiness、liveness、drain 當判斷標準">Process supervisor 選型</a></li>
<li>Readiness 轉為否、先摘流量再收束，是關閉流程的第一步 → <a href="/blog/devops/operations/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">Graceful shutdown</a></li>
</ul>
]]></content:encoded></item><item><title>systemd watchdog 與自動重啟</title><link>https://tarrragon.github.io/blog/devops/operations/04-service-health/systemd-watchdog-restart/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/04-service-health/systemd-watchdog-restart/</guid><description>&lt;p>單機 systemd 上的自動恢復由兩套獨立機制組成：watchdog 讓服務定期主動報活、超時沒報就被 systemd 重建；restart policy 在進程退出後把它重新拉起。前者對應 &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">liveness 探針&lt;/a> 的語意——服務宣告自己還在運作；後者對應進程層的崩潰恢復。兩者觸發條件不同、覆蓋的失效模式也不同，一個服務常常兩套都要。&lt;/p>
&lt;p>在編排平台上，這兩件事由 probe 機制表達；在單機部署，systemd 靠 &lt;code>sd_notify&lt;/code> 協議讓服務明確宣告狀態，反而比 probe 更直接——服務主動說「我還活著」「我準備好了」，不必外部反覆猜測。&lt;/p>
&lt;h2 id="watchdog服務主動報活超時就被重建">Watchdog：服務主動報活，超時就被重建&lt;/h2>
&lt;p>Watchdog 是主動式的 liveness：服務端定期呼叫 &lt;code>sd_notify(WATCHDOG=1)&lt;/code> 告訴 systemd 「我還在正常運作」，systemd 設定一個 &lt;code>WatchdogSec=&lt;/code> 逾時；服務在時限內沒報，systemd 判定它卡死、自動 kill 加 restart。本站 collector 用的是 &lt;code>WatchdogSec=30s&lt;/code>——服務要在 30 秒內報一次，逾時就被重啟。&lt;/p>
&lt;p>Watchdog 抓的正是 &lt;a href="https://tarrragon.github.io/blog/linux/debug/process-service-state-diagnosis/" data-link-title="程序、服務與狀態怎麼判" data-link-desc="要判斷一個程式活著沒、某個系統服務現在由誰提供、桌面 session 有沒有被鎖、或終端機多工器的 session 還在不在時，用對的權威來源而不是靠畫面或猜的名字">進程活著但子系統死掉&lt;/a> 那類失效：進程還在、&lt;code>systemctl is-active&lt;/code> 還顯示 active，但內部某個關鍵迴圈已經 hung。這種狀態下崩潰恢復幫不上忙（進程根本沒退出），但 watchdog 抓得到——因為卡死的服務報不出那一次心跳。前提是服務改得動：&lt;code>sd_notify(WATCHDOG=1)&lt;/code> 要寫進服務自己的碼，報活的位置要放在「真的有在做事才會經過」的路徑上，才不會變成一個進程活著就自動報活的假訊號。&lt;/p>
&lt;p>服務改不動的情況（閉源程式、別人的服務），watchdog 這條用不上，改從外部主動戳它——一個定時器對服務發健康請求並設逾時，戳不動就讓那次檢查失敗。這個外部探針的完整單機實作在 &lt;a href="https://tarrragon.github.io/blog/linux/debug/service-failure-monitoring/" data-link-title="服務掛了怎麼自動知道：從肉眼盯到主動告警" data-link-desc="不想每次都手動 systemctl 檢查服務死活、想讓機器在 service 掛掉時主動推播通知、或擔心整台機器當掉沒人知道時回來讀">服務掛了怎麼自動知道&lt;/a> 的外部健康探針段。Watchdog 跟外部探針的分界就是「控不控制得了那個服務」：控制得了用 watchdog（零額外依賴、服務自己報），控制不了用外部探針（從體外戳）。&lt;/p>
&lt;h2 id="restart-policy進程退出後被拉起">Restart policy：進程退出後被拉起&lt;/h2>
&lt;p>Restart policy 覆蓋的是進程真的退出的情況——崩潰、被 kill、非正常結束。&lt;code>Restart=on-failure&lt;/code> 讓 systemd 在服務以失敗狀態退出時自動重啟，&lt;code>RestartSec=5&lt;/code> 設定重啟前等幾秒。這是最基本的崩潰恢復：多數暫時性失敗（一次連線抖動、一個 race）重試一下就好，不值得驚動人。&lt;/p>
&lt;p>但無限重啟會掩蓋真正壞掉的服務——一個因為配置錯誤永遠起不來的服務，會被 restart policy 反覆拉起、反覆失敗。所以要配重試上限：&lt;code>StartLimitBurst=3&lt;/code> 加 &lt;code>StartLimitIntervalSec=60&lt;/code> 表示 60 秒內失敗 3 次才真的進 failed 狀態、停止重試。本站 collector 用的門檻是 10 分鐘內重啟 3 次以上就停止自動重啟、改發告警要求人工介入——這條門檻是「機器自動恢復」跟「人工恢復」的交界：撞上限之前交給機器重試，撞上限之後承認自動恢復無效、升級成人的問題。&lt;/p>
&lt;h2 id="先自動重啟放棄了才告警">先自動重啟，放棄了才告警&lt;/h2>
&lt;p>自動恢復跟告警是兩段，要分開。理由是多數失敗自己重試就會過，每次瞬斷都吵人會把告警洗到沒人看。正確的分段是：restart policy 負責「重試幾次」，告警只在「重試上限撞到、真的放棄」時才發。&lt;/p>
&lt;p>這裡有個實測踩到、跟直覺相反的意外：systemd 的 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/systemd-onfailure/" data-link-title="systemd OnFailure（失敗觸發鉤子）" data-link-desc="想讓某個服務進 failed 時自動觸發告警或修復動作、或發現 OnFailure 每次重啟中途都觸發把告警洗爆時讀">&lt;code>OnFailure&lt;/code>&lt;/a> 鉤子不是「放棄才觸發」，而是每一次失敗都觸發——包含 &lt;code>Restart=on-failure&lt;/code> 的每次 auto-restart 中途。&lt;a href="https://tarrragon.github.io/blog/linux/debug/service-failure-monitoring/" data-link-title="服務掛了怎麼自動知道：從肉眼盯到主動告警" data-link-desc="不想每次都手動 systemctl 檢查服務死活、想讓機器在 service 掛掉時主動推播通知、或擔心整台機器當掉沒人知道時回來讀">服務掛了怎麼自動知道&lt;/a> 實測一個反覆崩潰、重試 3 次後放棄的服務，&lt;code>OnFailure&lt;/code> 觸發了 4 次（3 次 auto-restart 加 1 次最終放棄）。這個觸發次數是特定 systemd 版本的實測，&lt;code>OnFailure&lt;/code> 與 &lt;code>Restart=&lt;/code> 的互動跨版本調整過，換一個版本可能量到不同次數，但「每次失敗都觸發、不是只在放棄時」這個機制本身跨版本成立。所以只靠 restart 加 start-limit 的 config，每次瞬斷都會發告警。真正做到「只在放棄才吵」，要在告警處理器裡加一道狀態閘門——auto-restart 中途服務的 &lt;code>ActiveState&lt;/code> 是 &lt;code>activating&lt;/code>、撞上限進 failed 才是 &lt;code>failed&lt;/code>，處理器只在 &lt;code>failed&lt;/code> 才送，加上這道閘門後同一個崩潰測試從 4 則告警降到 1 則。config 管重試次數、handler 的閘門管只在終局告警，兩段合起來才是完整的「先重啟、放棄才吵」。這道告警鏈的完整設定（&lt;code>OnFailure&lt;/code> 鉤子、送出腳本、遞迴陷阱）在那篇有實測驗證過的完整版，本模組不重述。&lt;/p>
&lt;h2 id="恢復閉環要回饋到狀態呈現">恢復閉環要回饋到狀態呈現&lt;/h2>
&lt;p>自動恢復做完之後，狀態呈現要跟著收斂。collector 在自動恢復成功後送一個 &lt;code>collector.started&lt;/code> 事件，讓 DevOps 儀表板的服務狀態卡從紅轉綠；撞到重啟上限、升級成人工告警時，狀態卡維持紅色等人處理。恢復機制跟呈現層的這個回饋迴路，讓「服務掉了又自己回來」這件事在看板上留下可追溯的痕跡，而不是靜默地掉了又靜默地回來。收斂成儀表板的細節見 &lt;a href="https://tarrragon.github.io/blog/devops/monitoring/04-collector/dashboard-devops/" data-link-title="DevOps Dashboard 設計" data-link-desc="Collector 和 SDK 是否健康 — 日常監控的服務狀態卡、吞吐量曲線、儲存用量，以及告警觸發後的排障視圖">Monitoring DevOps 儀表板&lt;/a>。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Watchdog 對應的概念層 liveness、restart 對應的重啟代價 → &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">Liveness 與 Readiness&lt;/a>&lt;/li>
&lt;li>單機 systemd 之外，supervisord、Docker、Kubernetes 怎麼表達重啟與重建 → &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/process-supervisor-selection/" data-link-title="Process supervisor 選型" data-link-desc="在 systemd、supervisord、Docker restart policy、Kubernetes 之間選服務監管方式時，用平台能不能分開表達 startup、readiness、liveness、drain 當判斷標準">Process supervisor 選型&lt;/a>&lt;/li>
&lt;li>告警鏈的完整單機實作（OnFailure、送出腳本、心跳、canary）→ &lt;a href="https://tarrragon.github.io/blog/linux/debug/service-failure-monitoring/" data-link-title="服務掛了怎麼自動知道：從肉眼盯到主動告警" data-link-desc="不想每次都手動 systemctl 檢查服務死活、想讓機器在 service 掛掉時主動推播通知、或擔心整台機器當掉沒人知道時回來讀">服務掛了怎麼自動知道&lt;/a>&lt;/li>
&lt;li>重啟前先讓服務有序收束在途工作 → &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">Graceful shutdown&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>單機 systemd 上的自動恢復由兩套獨立機制組成：watchdog 讓服務定期主動報活、超時沒報就被 systemd 重建；restart policy 在進程退出後把它重新拉起。前者對應 <a href="/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">liveness 探針</a> 的語意——服務宣告自己還在運作；後者對應進程層的崩潰恢復。兩者觸發條件不同、覆蓋的失效模式也不同，一個服務常常兩套都要。</p>
<p>在編排平台上，這兩件事由 probe 機制表達；在單機部署，systemd 靠 <code>sd_notify</code> 協議讓服務明確宣告狀態，反而比 probe 更直接——服務主動說「我還活著」「我準備好了」，不必外部反覆猜測。</p>
<h2 id="watchdog服務主動報活超時就被重建">Watchdog：服務主動報活，超時就被重建</h2>
<p>Watchdog 是主動式的 liveness：服務端定期呼叫 <code>sd_notify(WATCHDOG=1)</code> 告訴 systemd 「我還在正常運作」，systemd 設定一個 <code>WatchdogSec=</code> 逾時；服務在時限內沒報，systemd 判定它卡死、自動 kill 加 restart。本站 collector 用的是 <code>WatchdogSec=30s</code>——服務要在 30 秒內報一次，逾時就被重啟。</p>
<p>Watchdog 抓的正是 <a href="/blog/linux/debug/process-service-state-diagnosis/" data-link-title="程序、服務與狀態怎麼判" data-link-desc="要判斷一個程式活著沒、某個系統服務現在由誰提供、桌面 session 有沒有被鎖、或終端機多工器的 session 還在不在時，用對的權威來源而不是靠畫面或猜的名字">進程活著但子系統死掉</a> 那類失效：進程還在、<code>systemctl is-active</code> 還顯示 active，但內部某個關鍵迴圈已經 hung。這種狀態下崩潰恢復幫不上忙（進程根本沒退出），但 watchdog 抓得到——因為卡死的服務報不出那一次心跳。前提是服務改得動：<code>sd_notify(WATCHDOG=1)</code> 要寫進服務自己的碼，報活的位置要放在「真的有在做事才會經過」的路徑上，才不會變成一個進程活著就自動報活的假訊號。</p>
<p>服務改不動的情況（閉源程式、別人的服務），watchdog 這條用不上，改從外部主動戳它——一個定時器對服務發健康請求並設逾時，戳不動就讓那次檢查失敗。這個外部探針的完整單機實作在 <a href="/blog/linux/debug/service-failure-monitoring/" data-link-title="服務掛了怎麼自動知道：從肉眼盯到主動告警" data-link-desc="不想每次都手動 systemctl 檢查服務死活、想讓機器在 service 掛掉時主動推播通知、或擔心整台機器當掉沒人知道時回來讀">服務掛了怎麼自動知道</a> 的外部健康探針段。Watchdog 跟外部探針的分界就是「控不控制得了那個服務」：控制得了用 watchdog（零額外依賴、服務自己報），控制不了用外部探針（從體外戳）。</p>
<h2 id="restart-policy進程退出後被拉起">Restart policy：進程退出後被拉起</h2>
<p>Restart policy 覆蓋的是進程真的退出的情況——崩潰、被 kill、非正常結束。<code>Restart=on-failure</code> 讓 systemd 在服務以失敗狀態退出時自動重啟，<code>RestartSec=5</code> 設定重啟前等幾秒。這是最基本的崩潰恢復：多數暫時性失敗（一次連線抖動、一個 race）重試一下就好，不值得驚動人。</p>
<p>但無限重啟會掩蓋真正壞掉的服務——一個因為配置錯誤永遠起不來的服務，會被 restart policy 反覆拉起、反覆失敗。所以要配重試上限：<code>StartLimitBurst=3</code> 加 <code>StartLimitIntervalSec=60</code> 表示 60 秒內失敗 3 次才真的進 failed 狀態、停止重試。本站 collector 用的門檻是 10 分鐘內重啟 3 次以上就停止自動重啟、改發告警要求人工介入——這條門檻是「機器自動恢復」跟「人工恢復」的交界：撞上限之前交給機器重試，撞上限之後承認自動恢復無效、升級成人的問題。</p>
<h2 id="先自動重啟放棄了才告警">先自動重啟，放棄了才告警</h2>
<p>自動恢復跟告警是兩段，要分開。理由是多數失敗自己重試就會過，每次瞬斷都吵人會把告警洗到沒人看。正確的分段是：restart policy 負責「重試幾次」，告警只在「重試上限撞到、真的放棄」時才發。</p>
<p>這裡有個實測踩到、跟直覺相反的意外：systemd 的 <a href="/blog/linux/dotfile/knowledge-cards/systemd-onfailure/" data-link-title="systemd OnFailure（失敗觸發鉤子）" data-link-desc="想讓某個服務進 failed 時自動觸發告警或修復動作、或發現 OnFailure 每次重啟中途都觸發把告警洗爆時讀"><code>OnFailure</code></a> 鉤子不是「放棄才觸發」，而是每一次失敗都觸發——包含 <code>Restart=on-failure</code> 的每次 auto-restart 中途。<a href="/blog/linux/debug/service-failure-monitoring/" data-link-title="服務掛了怎麼自動知道：從肉眼盯到主動告警" data-link-desc="不想每次都手動 systemctl 檢查服務死活、想讓機器在 service 掛掉時主動推播通知、或擔心整台機器當掉沒人知道時回來讀">服務掛了怎麼自動知道</a> 實測一個反覆崩潰、重試 3 次後放棄的服務，<code>OnFailure</code> 觸發了 4 次（3 次 auto-restart 加 1 次最終放棄）。這個觸發次數是特定 systemd 版本的實測，<code>OnFailure</code> 與 <code>Restart=</code> 的互動跨版本調整過，換一個版本可能量到不同次數，但「每次失敗都觸發、不是只在放棄時」這個機制本身跨版本成立。所以只靠 restart 加 start-limit 的 config，每次瞬斷都會發告警。真正做到「只在放棄才吵」，要在告警處理器裡加一道狀態閘門——auto-restart 中途服務的 <code>ActiveState</code> 是 <code>activating</code>、撞上限進 failed 才是 <code>failed</code>，處理器只在 <code>failed</code> 才送，加上這道閘門後同一個崩潰測試從 4 則告警降到 1 則。config 管重試次數、handler 的閘門管只在終局告警，兩段合起來才是完整的「先重啟、放棄才吵」。這道告警鏈的完整設定（<code>OnFailure</code> 鉤子、送出腳本、遞迴陷阱）在那篇有實測驗證過的完整版，本模組不重述。</p>
<h2 id="恢復閉環要回饋到狀態呈現">恢復閉環要回饋到狀態呈現</h2>
<p>自動恢復做完之後，狀態呈現要跟著收斂。collector 在自動恢復成功後送一個 <code>collector.started</code> 事件，讓 DevOps 儀表板的服務狀態卡從紅轉綠；撞到重啟上限、升級成人工告警時，狀態卡維持紅色等人處理。恢復機制跟呈現層的這個回饋迴路，讓「服務掉了又自己回來」這件事在看板上留下可追溯的痕跡，而不是靜默地掉了又靜默地回來。收斂成儀表板的細節見 <a href="/blog/devops/monitoring/04-collector/dashboard-devops/" data-link-title="DevOps Dashboard 設計" data-link-desc="Collector 和 SDK 是否健康 — 日常監控的服務狀態卡、吞吐量曲線、儲存用量，以及告警觸發後的排障視圖">Monitoring DevOps 儀表板</a>。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Watchdog 對應的概念層 liveness、restart 對應的重啟代價 → <a href="/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">Liveness 與 Readiness</a></li>
<li>單機 systemd 之外，supervisord、Docker、Kubernetes 怎麼表達重啟與重建 → <a href="/blog/devops/operations/04-service-health/process-supervisor-selection/" data-link-title="Process supervisor 選型" data-link-desc="在 systemd、supervisord、Docker restart policy、Kubernetes 之間選服務監管方式時，用平台能不能分開表達 startup、readiness、liveness、drain 當判斷標準">Process supervisor 選型</a></li>
<li>告警鏈的完整單機實作（OnFailure、送出腳本、心跳、canary）→ <a href="/blog/linux/debug/service-failure-monitoring/" data-link-title="服務掛了怎麼自動知道：從肉眼盯到主動告警" data-link-desc="不想每次都手動 systemctl 檢查服務死活、想讓機器在 service 掛掉時主動推播通知、或擔心整台機器當掉沒人知道時回來讀">服務掛了怎麼自動知道</a></li>
<li>重啟前先讓服務有序收束在途工作 → <a href="/blog/devops/operations/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">Graceful shutdown</a></li>
</ul>
]]></content:encoded></item><item><title>Process supervisor 選型</title><link>https://tarrragon.github.io/blog/devops/operations/04-service-health/process-supervisor-selection/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/04-service-health/process-supervisor-selection/</guid><description>&lt;p>選 process supervisor 的判斷標準是這個平台能不能分別表達服務生命週期的四個階段：啟動（startup）、就緒（readiness）、存活（liveness）、收束（drain）。表達力越完整，越能讓平台在對的時機做對的動作；表達力有缺，缺的那部分邏輯就要在應用層自己補，複雜度從平台設定轉移到程式碼裡。選型時比對的是這個服務需要的生命週期粒度與平台能表達的粒度是否一致，而不是誰的功能多。&lt;/p>
&lt;p>在動手比較之前，先問服務四個問題：啟動要多久、哪些依賴是就緒條件；失敗時該自己恢復還是交平台重建；停止時有哪些在途請求、連線、背景工作要收束；以及平台能不能把 startup、readiness、liveness、drain 分開表達。這四個問題的答案決定了要往哪個方向選。&lt;/p>
&lt;h2 id="各平台的生命週期表達力">各平台的生命週期表達力&lt;/h2>
&lt;p>各平台對這四階段的支援程度不同，這張對照是選型的骨架：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>平台&lt;/th>
 &lt;th>啟動 gate&lt;/th>
 &lt;th>就緒與存活&lt;/th>
 &lt;th>收束&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>systemd&lt;/td>
 &lt;td>無原生 startup gate&lt;/td>
 &lt;td>&lt;code>sd_notify(READY=1)&lt;/code> 宣告就緒&lt;/td>
 &lt;td>&lt;code>ExecStop&lt;/code> + &lt;code>KillSignal&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>supervisord&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>只有 RUNNING 狀態、不分離就緒與存活&lt;/td>
 &lt;td>&lt;code>stopsignal&lt;/code> + &lt;code>stopwaitsecs&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Docker&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>&lt;code>HEALTHCHECK&lt;/code> 不分離就緒與存活&lt;/td>
 &lt;td>&lt;code>stop_grace_period&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Kubernetes&lt;/td>
 &lt;td>&lt;code>startupProbe&lt;/code>&lt;/td>
 &lt;td>readiness 與 liveness 獨立探針&lt;/td>
 &lt;td>&lt;code>preStop&lt;/code> hook + endpoint 摘除&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ECS&lt;/td>
 &lt;td>startup health check&lt;/td>
 &lt;td>依 health check 設定&lt;/td>
 &lt;td>deregistration delay&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Kubernetes 的表達力最完整——三種探針獨立、收束有 preStop hook 加 endpoint 摘除，能精確表達每個階段。代價是參數最多、也最容易配錯：探針門檻、間隔、grace period 任何一個設歪，行為就跟預期不符。systemd 在單機場景反而直接，&lt;code>sd_notify&lt;/code> 讓服務主動宣告狀態，不必外部反覆探測，但它沒有原生的 startup gate 概念，啟動期的健康要自己用就緒宣告的時機表達。&lt;/p>
&lt;p>supervisord 是單機上的經典應用監管者，比 systemd 更輕、跨發行版一致，適合不想綁 systemd 的環境；限制是它只有一個 RUNNING 狀態，不像 systemd 有 &lt;code>sd_notify&lt;/code> 可以宣告就緒，也就沒辦法區分就緒與存活。這個「不分離就緒與存活」的限制在 Docker 跟 ECS 上一樣存在——&lt;code>HEALTHCHECK&lt;/code> 只有一個健康概念，無法同時回答「可以接流量嗎」跟「還活著嗎」。服務若真的需要把這兩者分開（例如依賴斷線時要摘流量但不要重啟），這段差距就得在應用層補：自己維護就緒狀態、自己在健康端點裡分辨這次探測該回答哪個問題。這一段邏輯搬進程式碼是可行的，代價是本來平台該表達的職責變成應用自己扛。&lt;/p>
&lt;h2 id="restart-policy-是恢復動作的表達">Restart policy 是恢復動作的表達&lt;/h2>
&lt;p>除了生命週期階段，各平台對「進程退出後怎麼辦」也有各自的表達。Docker 的 restart policy 有 &lt;code>no&lt;/code>（不重啟）、&lt;code>on-failure&lt;/code>（非零退出才重啟，可設次數上限）、&lt;code>always&lt;/code>（永遠重啟，含手動停止後 daemon 重啟也拉起）、&lt;code>unless-stopped&lt;/code>（類似 always 但尊重手動停止）。Kubernetes 的 Pod &lt;code>restartPolicy&lt;/code> 有 &lt;code>Always&lt;/code>、&lt;code>OnFailure&lt;/code>、&lt;code>Never&lt;/code>，語意對應到 Pod 層的容器重啟。&lt;/p>
&lt;p>這些選項對應的決策跟 systemd 的 &lt;code>Restart=on-failure&lt;/code> 是同一件事：這個服務退出時，是該無條件拉回、只在異常時拉回、還是不動它交給更上層處理。選 &lt;code>always&lt;/code> 類的策略要搭配重試上限或退避，否則一個永遠起不來的服務會陷入無限重啟迴圈——這條跟 systemd 的 &lt;code>StartLimitBurst&lt;/code> 是同一個問題，&lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/systemd-watchdog-restart/" data-link-title="systemd watchdog 與自動重啟" data-link-desc="在單機 systemd 上做服務自動恢復時，釐清 watchdog 主動報活與 restart policy 被動拉起是兩套機制、以及為什麼要先重啟幾次才放棄">systemd watchdog 與自動重啟&lt;/a> 有單機上的完整設定。&lt;/p>
&lt;h2 id="容器裡的-pid-1-是另一層選型">容器裡的 PID 1 是另一層選型&lt;/h2>
&lt;p>跑在容器裡時，還有一個容易漏掉的選型：誰當 PID 1。容器的 PID 1 是 init process，除了跑服務，還負責接收 &lt;code>SIGTERM&lt;/code>／&lt;code>SIGINT&lt;/code> 並轉發給子進程、以及回收結束的子進程（zombie reaping）。這個責任交給誰，直接影響服務收不收得到關閉信號、以及會不會累積殭屍進程。&lt;/p>
&lt;p>解法看容器裡跑幾個進程，兩種修法對應兩種情況、不是互斥的競爭方案。單一主進程的情況，用 exec form（或啟動腳本裡 &lt;code>exec&lt;/code>）讓服務直接取代 shell 當 PID 1、自己接手信號就夠。多進程容器還多一個問題：若 PID 1 不做 &lt;code>wait()&lt;/code>，結束的子進程會變殭屍累積，這時要用 tini 或 dumb-init 這類輕量 init 當 PID 1，由它負責信號轉發跟殭屍回收，或在 Kubernetes 設 &lt;code>shareProcessNamespace&lt;/code> 讓 kubelet 接手。一句話分工：exec form 解單進程的信號傳遞，tini／dumb-init 解多進程的信號傳遞加殭屍回收。殭屍為什麼會因為 PID 1 不做 &lt;code>wait()&lt;/code> 而累積、以及累積到什麼程度會讓整個 uid 開不了新進程，機制見 &lt;a href="https://tarrragon.github.io/blog/macos/macos_process_limit_zombie_reaping/" data-link-title="殭屍程序與使用者程序上限：程序表格位的配置與回收" data-link-desc="fork 失敗報 Resource temporarily unavailable、或想確認程序結束後格位何時才釋放時回來看。殭屍佔的是程序表格位、不是記憶體，父程序收屍前它一直計入 per-user 額度。">殭屍程序與使用者程序上限&lt;/a>（該篇量的是 macOS 的參數，exit 與 wait 兩階段的機制本身是 POSIX 通用的）。信號傳不到服務造成的關閉失敗，是 &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">graceful shutdown&lt;/a> 章最常見的失效模式，這裡是它的選型根因。&lt;/p></description><content:encoded><![CDATA[<p>選 process supervisor 的判斷標準是這個平台能不能分別表達服務生命週期的四個階段：啟動（startup）、就緒（readiness）、存活（liveness）、收束（drain）。表達力越完整，越能讓平台在對的時機做對的動作；表達力有缺，缺的那部分邏輯就要在應用層自己補，複雜度從平台設定轉移到程式碼裡。選型時比對的是這個服務需要的生命週期粒度與平台能表達的粒度是否一致，而不是誰的功能多。</p>
<p>在動手比較之前，先問服務四個問題：啟動要多久、哪些依賴是就緒條件；失敗時該自己恢復還是交平台重建；停止時有哪些在途請求、連線、背景工作要收束；以及平台能不能把 startup、readiness、liveness、drain 分開表達。這四個問題的答案決定了要往哪個方向選。</p>
<h2 id="各平台的生命週期表達力">各平台的生命週期表達力</h2>
<p>各平台對這四階段的支援程度不同，這張對照是選型的骨架：</p>
<table>
  <thead>
      <tr>
          <th>平台</th>
          <th>啟動 gate</th>
          <th>就緒與存活</th>
          <th>收束</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>systemd</td>
          <td>無原生 startup gate</td>
          <td><code>sd_notify(READY=1)</code> 宣告就緒</td>
          <td><code>ExecStop</code> + <code>KillSignal</code></td>
      </tr>
      <tr>
          <td>supervisord</td>
          <td>無</td>
          <td>只有 RUNNING 狀態、不分離就緒與存活</td>
          <td><code>stopsignal</code> + <code>stopwaitsecs</code></td>
      </tr>
      <tr>
          <td>Docker</td>
          <td>無</td>
          <td><code>HEALTHCHECK</code> 不分離就緒與存活</td>
          <td><code>stop_grace_period</code></td>
      </tr>
      <tr>
          <td>Kubernetes</td>
          <td><code>startupProbe</code></td>
          <td>readiness 與 liveness 獨立探針</td>
          <td><code>preStop</code> hook + endpoint 摘除</td>
      </tr>
      <tr>
          <td>ECS</td>
          <td>startup health check</td>
          <td>依 health check 設定</td>
          <td>deregistration delay</td>
      </tr>
  </tbody>
</table>
<p>Kubernetes 的表達力最完整——三種探針獨立、收束有 preStop hook 加 endpoint 摘除，能精確表達每個階段。代價是參數最多、也最容易配錯：探針門檻、間隔、grace period 任何一個設歪，行為就跟預期不符。systemd 在單機場景反而直接，<code>sd_notify</code> 讓服務主動宣告狀態，不必外部反覆探測，但它沒有原生的 startup gate 概念，啟動期的健康要自己用就緒宣告的時機表達。</p>
<p>supervisord 是單機上的經典應用監管者，比 systemd 更輕、跨發行版一致，適合不想綁 systemd 的環境；限制是它只有一個 RUNNING 狀態，不像 systemd 有 <code>sd_notify</code> 可以宣告就緒，也就沒辦法區分就緒與存活。這個「不分離就緒與存活」的限制在 Docker 跟 ECS 上一樣存在——<code>HEALTHCHECK</code> 只有一個健康概念，無法同時回答「可以接流量嗎」跟「還活著嗎」。服務若真的需要把這兩者分開（例如依賴斷線時要摘流量但不要重啟），這段差距就得在應用層補：自己維護就緒狀態、自己在健康端點裡分辨這次探測該回答哪個問題。這一段邏輯搬進程式碼是可行的，代價是本來平台該表達的職責變成應用自己扛。</p>
<h2 id="restart-policy-是恢復動作的表達">Restart policy 是恢復動作的表達</h2>
<p>除了生命週期階段，各平台對「進程退出後怎麼辦」也有各自的表達。Docker 的 restart policy 有 <code>no</code>（不重啟）、<code>on-failure</code>（非零退出才重啟，可設次數上限）、<code>always</code>（永遠重啟，含手動停止後 daemon 重啟也拉起）、<code>unless-stopped</code>（類似 always 但尊重手動停止）。Kubernetes 的 Pod <code>restartPolicy</code> 有 <code>Always</code>、<code>OnFailure</code>、<code>Never</code>，語意對應到 Pod 層的容器重啟。</p>
<p>這些選項對應的決策跟 systemd 的 <code>Restart=on-failure</code> 是同一件事：這個服務退出時，是該無條件拉回、只在異常時拉回、還是不動它交給更上層處理。選 <code>always</code> 類的策略要搭配重試上限或退避，否則一個永遠起不來的服務會陷入無限重啟迴圈——這條跟 systemd 的 <code>StartLimitBurst</code> 是同一個問題，<a href="/blog/devops/operations/04-service-health/systemd-watchdog-restart/" data-link-title="systemd watchdog 與自動重啟" data-link-desc="在單機 systemd 上做服務自動恢復時，釐清 watchdog 主動報活與 restart policy 被動拉起是兩套機制、以及為什麼要先重啟幾次才放棄">systemd watchdog 與自動重啟</a> 有單機上的完整設定。</p>
<h2 id="容器裡的-pid-1-是另一層選型">容器裡的 PID 1 是另一層選型</h2>
<p>跑在容器裡時，還有一個容易漏掉的選型：誰當 PID 1。容器的 PID 1 是 init process，除了跑服務，還負責接收 <code>SIGTERM</code>／<code>SIGINT</code> 並轉發給子進程、以及回收結束的子進程（zombie reaping）。這個責任交給誰，直接影響服務收不收得到關閉信號、以及會不會累積殭屍進程。</p>
<p>解法看容器裡跑幾個進程，兩種修法對應兩種情況、不是互斥的競爭方案。單一主進程的情況，用 exec form（或啟動腳本裡 <code>exec</code>）讓服務直接取代 shell 當 PID 1、自己接手信號就夠。多進程容器還多一個問題：若 PID 1 不做 <code>wait()</code>，結束的子進程會變殭屍累積，這時要用 tini 或 dumb-init 這類輕量 init 當 PID 1，由它負責信號轉發跟殭屍回收，或在 Kubernetes 設 <code>shareProcessNamespace</code> 讓 kubelet 接手。一句話分工：exec form 解單進程的信號傳遞，tini／dumb-init 解多進程的信號傳遞加殭屍回收。殭屍為什麼會因為 PID 1 不做 <code>wait()</code> 而累積、以及累積到什麼程度會讓整個 uid 開不了新進程，機制見 <a href="/blog/macos/macos_process_limit_zombie_reaping/" data-link-title="殭屍程序與使用者程序上限：程序表格位的配置與回收" data-link-desc="fork 失敗報 Resource temporarily unavailable、或想確認程序結束後格位何時才釋放時回來看。殭屍佔的是程序表格位、不是記憶體，父程序收屍前它一直計入 per-user 額度。">殭屍程序與使用者程序上限</a>（該篇量的是 macOS 的參數，exit 與 wait 兩階段的機制本身是 POSIX 通用的）。信號傳不到服務造成的關閉失敗，是 <a href="/blog/devops/operations/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">graceful shutdown</a> 章最常見的失效模式，這裡是它的選型根因。</p>
<h2 id="選型收斂">選型收斂</h2>
<p>單機、服務自己寫得動、要零額外依賴且需要區分就緒與存活 → systemd，用 <code>sd_notify</code> 宣告就緒與報活。單機但不想綁 systemd、只要基本的拉起與重啟 → supervisord。多機、需要 startup、readiness、liveness、drain 全部分開表達、能吃下配置複雜度 → Kubernetes。容器化但生命週期需求簡單、不需要分離就緒與存活 → Docker restart policy 加 <code>HEALTHCHECK</code>，不足的部分在應用層補；兩者在 Compose 下各管各的，unhealthy 不會觸發 restart policy，程序卡住而沒結束時要另外處理（見 <a href="/blog/devops/operations/04-service-health/container-healthcheck/" data-link-title="容器的健康檢查：Docker HEALTHCHECK 的狀態、unhealthy 之後由誰處理、depends_on 的啟動把關與 distroless 映像檔" data-link-desc="說明 Docker 的 HEALTHCHECK 與 Compose 的 healthcheck 怎麼運作：starting、healthy、unhealthy 三個狀態與時間參數的換算，容器變成 unhealthy 之後 Docker 與 restart policy 各自做什麼、不做什麼，depends_on 的 service_healthy 只在啟動時把關會帶來的連鎖失敗，以及沒有 shell 的 distroless 映像檔怎麼做健康檢查">容器的健康檢查</a>）。判斷標準始終是同一條：服務需要的生命週期粒度，跟平台能表達的粒度對不對得上——需求簡單卻上最複雜的平台，付的是配置成本；需求複雜卻用表達力不足的平台，付的是應用層補洞的成本。</p>
<h2 id="要不要上-kubernetes">要不要上 Kubernetes</h2>
<p>「要不要引入編排層」是這個選型裡最大的一個決策，值得單獨判。上 Kubernetes 的成本是配置複雜度與一整套維運（叢集升級、網路、儲存、權限），這筆成本是固定的、不隨服務數量攤薄到很小。值得付的訊號是三個同時成立：跑在多台機器上、需要 startup/readiness/liveness/drain 全部分開精確表達、而且有多個服務要統一調度與擴縮。這三個都成立時，Kubernetes 把「本來要自己拼的調度、健康、擴縮」收進一個平台，複雜度換到了值得的地方。這筆成本由哪些元件組成、哪些能力要節點超過一台才發揮，見 <a href="/blog/backend/05-deployment-platform/vendors/kubernetes/components-and-request-path/" data-link-title="Kubernetes 的元件與請求路徑：控制平面、節點、Pod、Deployment、Service 與 Ingress" data-link-desc="從 Docker Compose 的使用經驗出發，說明 Kubernetes 用期望狀態與控制迴圈管理容器的方式、控制平面與節點上各元件的責任、Deployment、ReplicaSet 與 Pod 三層工作負載、一個外部 HTTP 請求經過 Ingress 與 Service 抵達 Pod 的路徑和每一段出問題時的訊號，以及節點故障後的重建時間與這套模型在什麼規模下划算">Kubernetes 的元件與請求路徑</a> 的〈Kubernetes 這套模型的成本與適用規模〉。</p>
<p>反過來，單機或少數幾台、生命週期需求簡單、服務數量不多時，上 Kubernetes 是拿一大筆配置與維運複雜度、換一套用不到的能力——systemd 或 Docker 加 restart policy 就足夠，省下的複雜度是實打實的。常見的誤區是把 Kubernetes 當成「正規」的預設起點，結果一個兩台機器的服務背上了整個叢集的維運負擔。判斷標準回到同一條：需求的粒度配不配得上平台的粒度，不是「業界都用所以我也要用」。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>平台要表達的 startup、readiness、liveness 各是什麼語意 → <a href="/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">Liveness 與 Readiness</a></li>
<li>systemd 上 restart policy 與 watchdog 的完整設定 → <a href="/blog/devops/operations/04-service-health/systemd-watchdog-restart/" data-link-title="systemd watchdog 與自動重啟" data-link-desc="在單機 systemd 上做服務自動恢復時，釐清 watchdog 主動報活與 restart policy 被動拉起是兩套機制、以及為什麼要先重啟幾次才放棄">systemd watchdog 與自動重啟</a></li>
<li>收束階段的信號傳遞與 grace period 設計 → <a href="/blog/devops/operations/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">Graceful shutdown</a></li>
<li>部署平台的完整生命週期契約 → <a href="/blog/backend/05-deployment-platform/platform-lifecycle-contract/" data-link-title="5.6 Platform Lifecycle Contract" data-link-desc="說明 runtime、startup、readiness、liveness、shutdown 與 drain 如何組成平台生命週期合約。">Backend 部署平台生命週期契約</a></li>
</ul>
]]></content:encoded></item><item><title>Graceful shutdown</title><link>https://tarrragon.github.io/blog/devops/operations/04-service-health/graceful-shutdown/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/04-service-health/graceful-shutdown/</guid><description>&lt;p>服務收到停止信號時，graceful shutdown 決定它是有序收束、還是被硬砍中斷。有序收束的責任分兩層：shutdown 是服務停止接受新工作、釋放自己持有的資源；drain 是平台在真正移除這個實例之前，讓已經在處理的請求、連線、背景工作有時間收完。這兩層都做對，一次正常的部署替換或縮容才不會掉掉在途的工作；做錯，使用者會在每次部署時撞到中斷的請求。&lt;/p>
&lt;p>收束的相對面是硬砍。平台給的收束時間是有上限的，超過上限服務就被 &lt;code>SIGKILL&lt;/code> 強制結束、不走任何清理。所以 graceful shutdown 的成敗判斷標準是清理邏輯能不能在 grace period 內跑完——跑不完，清理邏輯寫得再完整也等於沒有。&lt;/p>
&lt;h2 id="信號路徑與-grace-period">信號路徑與 grace period&lt;/h2>
&lt;p>關閉從一個信號開始，設計的第一件事是確認這個信號真的到得了服務、以及服務有足夠時間反應。在 Kubernetes 上，平台先執行 preStop hook、再送 &lt;code>SIGTERM&lt;/code>；&lt;code>terminationGracePeriodSeconds&lt;/code> 是平台願意等的最長時間，超過就 &lt;code>SIGKILL&lt;/code>。這個值要覆蓋 preStop、drain、資源釋放的總時間——設太短，收束到一半被硬砍。&lt;/p>
&lt;p>驗證信號到不到得了服務，靠實際觸發一次關閉看紀錄：在 staging 觸發實例刪除，看 log 有沒有出現關閉處理器的紀錄。沒看到，代表信號根本沒傳到服務，要先修傳遞路徑、再談清理邏輯——清理邏輯寫得再完整，信號收不到就一行都不會跑。&lt;/p>
&lt;h2 id="退場的固定順序">退場的固定順序&lt;/h2>
&lt;p>實例退場的四個步驟要固定順序：平台先把這個實例從流量目標摘掉、服務停止接受新請求、服務完成手上的在途請求、實例退出。順序穩定，rollback 這種「反向操作」才能在同一套機制下運作。這條順序的第一步對應 &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">readiness&lt;/a>——關閉的起手式是把 readiness 轉為否，讓平台停止送新流量，而不是直接關進程。先把流量停掉再收束在途工作，跟先關進程再期待流量自己不來，是完全不同的結果。&lt;/p>
&lt;p>這裡有一個單機環境不會遇到、多機才有的細節：readiness 轉為否，到平台真的停止送流量之間，有一段傳播延遲。Kubernetes 把 endpoint 跟 readiness 綁定，readiness 轉否要先傳到 endpoint controller、再傳到每個節點的 kube-proxy 或 envoy，這段期間客戶端仍可能打到已經標記為 not-ready 的實例。穩定的做法是在 preStop hook 加一段短暫等待（5 到 15 秒），讓摘除的狀態傳播到所有轉發層，再開始真正的收束。這段等待是 drain 總窗口的一個子區間，不是浪費——它填的正是「服務說我不 ready」跟「流量真的不再進來」之間的空隙。&lt;/p>
&lt;h2 id="drain-窗口按-workload-決定">drain 窗口按 workload 決定&lt;/h2>
&lt;p>Drain 要留多久，取決於服務跑的是哪種 workload，沒有通用值：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>短請求 API&lt;/strong>（HTTP REST、gRPC unary）：窗口通常 5 到 30 秒，收束條件是在途請求數歸零。主要風險是負載平衡的 deregistration delay 仍會送幾秒流量進來，drain 窗口要覆蓋這段。&lt;/li>
&lt;li>&lt;strong>長連線&lt;/strong>（WebSocket、gRPC streaming、SSE）：窗口從 30 秒到數分鐘，收束條件是現有連線收斂、且重連的波形穩定。主要風險是 reconnect storm——一堆連線同時被斷、同時重連，把接手的實例壓垮。&lt;/li>
&lt;li>&lt;strong>背景 worker&lt;/strong>：窗口取決於單一 job 的最長執行時間，收束條件是不可中斷的 job 跑完。風險是被強制結束的 job 留下不一致狀態。&lt;/li>
&lt;/ul>
&lt;p>服務若混合了多種 workload，drain 窗口取最嚴格（最長）的那個——短請求 5 秒就收完，但同一個服務還有一個要跑兩分鐘的 job，總窗口就得容納兩分鐘。用短請求的窗口去砍一個長 job，等於每次部署都中斷它。&lt;/p>
&lt;h2 id="信號收不到收束就變硬砍">信號收不到，收束就變硬砍&lt;/h2>
&lt;p>清理邏輯的前提是收得到信號，容器環境有三個常見的信號傳不到陷阱，都跟 PID 1 有關。第一個是用 shell 當 PID 1 又不轉發——&lt;code>ENTRYPOINT [&amp;quot;sh&amp;quot;, &amp;quot;-c&amp;quot;, &amp;quot;java -jar app.jar&amp;quot;]&lt;/code> 這種寫法，&lt;code>SIGTERM&lt;/code> 送到 sh，sh 預設不轉發給 java，java 一直收不到、等 grace period 到期被 &lt;code>SIGKILL&lt;/code> 強殺；修法是用 exec form 或在腳本裡 &lt;code>exec&lt;/code>，讓服務直接當 PID 1。第二個是多進程容器的殭屍回收——PID 1 不做 &lt;code>wait()&lt;/code>，結束的子進程累積成殭屍，這屬於 &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/process-supervisor-selection/" data-link-title="Process supervisor 選型" data-link-desc="在 systemd、supervisord、Docker restart policy、Kubernetes 之間選服務監管方式時，用平台能不能分開表達 startup、readiness、liveness、drain 當判斷標準">supervisor 選型&lt;/a> 裡 init process 的職責。第三個是啟動腳本的 trap handler 卡住，把本來 graceful 的關閉拖成 ungraceful 的 hang——trap handler 本身要設逾時，不能無限等。&lt;/p>
&lt;p>這三個陷阱的共同表現是一樣的：log 裡看不到關閉處理器跑過的紀錄、服務每次都撐到 grace period 上限才消失。看到這個表現，先查 PID 1 是誰、信號有沒有轉發，而不是先懷疑清理邏輯。&lt;/p>
&lt;h2 id="收束要保護的是已承諾未完成的工作">收束要保護的是已承諾未完成的工作&lt;/h2>
&lt;p>Graceful shutdown 真正要保護的，是那些「已經對外承諾、但還沒真正完成」的工作。本站 collector 是個具體例子：它收到事件先回 202、事件進 channel buffer、再非同步寫入儲存。從回 202 到真正寫入之間有一個窗口，這段期間若被 &lt;code>SIGKILL&lt;/code> 硬砍，這些已承諾但未持久化的事件就遺失了。graceful shutdown 的收束序列要 flush 這些 pending write——把 buffer 裡還沒寫的先寫完，再退出。&lt;/p>
&lt;p>哪些關閉保護得了、哪些保護不了，看退出走不走 graceful。走 &lt;code>SIGTERM&lt;/code> 加 grace period 的正常關閉，收束序列有機會 flush；但 OOM kill、硬體故障這種非 graceful 的結束，不走任何清理、在途工作直接中斷——這也是 &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">liveness&lt;/a> 要在記憶體逼近上限時主動回報 unhealthy 的理由：主動回報讓平台在還能 graceful 的時候有序重建，好過等 OOM kill 硬砍中斷在途工作。這條「有沒有走 graceful」的分界在監控上也留得下痕跡——collector 正常關閉會送一個 &lt;code>collector.shutdown&lt;/code> 事件，這個事件的有無，就是區分有序退場跟異常中斷的訊號。&lt;/p></description><content:encoded><![CDATA[<p>服務收到停止信號時，graceful shutdown 決定它是有序收束、還是被硬砍中斷。有序收束的責任分兩層：shutdown 是服務停止接受新工作、釋放自己持有的資源；drain 是平台在真正移除這個實例之前，讓已經在處理的請求、連線、背景工作有時間收完。這兩層都做對，一次正常的部署替換或縮容才不會掉掉在途的工作；做錯，使用者會在每次部署時撞到中斷的請求。</p>
<p>收束的相對面是硬砍。平台給的收束時間是有上限的，超過上限服務就被 <code>SIGKILL</code> 強制結束、不走任何清理。所以 graceful shutdown 的成敗判斷標準是清理邏輯能不能在 grace period 內跑完——跑不完，清理邏輯寫得再完整也等於沒有。</p>
<h2 id="信號路徑與-grace-period">信號路徑與 grace period</h2>
<p>關閉從一個信號開始，設計的第一件事是確認這個信號真的到得了服務、以及服務有足夠時間反應。在 Kubernetes 上，平台先執行 preStop hook、再送 <code>SIGTERM</code>；<code>terminationGracePeriodSeconds</code> 是平台願意等的最長時間，超過就 <code>SIGKILL</code>。這個值要覆蓋 preStop、drain、資源釋放的總時間——設太短，收束到一半被硬砍。</p>
<p>驗證信號到不到得了服務，靠實際觸發一次關閉看紀錄：在 staging 觸發實例刪除，看 log 有沒有出現關閉處理器的紀錄。沒看到，代表信號根本沒傳到服務，要先修傳遞路徑、再談清理邏輯——清理邏輯寫得再完整，信號收不到就一行都不會跑。</p>
<h2 id="退場的固定順序">退場的固定順序</h2>
<p>實例退場的四個步驟要固定順序：平台先把這個實例從流量目標摘掉、服務停止接受新請求、服務完成手上的在途請求、實例退出。順序穩定，rollback 這種「反向操作」才能在同一套機制下運作。這條順序的第一步對應 <a href="/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">readiness</a>——關閉的起手式是把 readiness 轉為否，讓平台停止送新流量，而不是直接關進程。先把流量停掉再收束在途工作，跟先關進程再期待流量自己不來，是完全不同的結果。</p>
<p>這裡有一個單機環境不會遇到、多機才有的細節：readiness 轉為否，到平台真的停止送流量之間，有一段傳播延遲。Kubernetes 把 endpoint 跟 readiness 綁定，readiness 轉否要先傳到 endpoint controller、再傳到每個節點的 kube-proxy 或 envoy，這段期間客戶端仍可能打到已經標記為 not-ready 的實例。穩定的做法是在 preStop hook 加一段短暫等待（5 到 15 秒），讓摘除的狀態傳播到所有轉發層，再開始真正的收束。這段等待是 drain 總窗口的一個子區間，不是浪費——它填的正是「服務說我不 ready」跟「流量真的不再進來」之間的空隙。</p>
<h2 id="drain-窗口按-workload-決定">drain 窗口按 workload 決定</h2>
<p>Drain 要留多久，取決於服務跑的是哪種 workload，沒有通用值：</p>
<ul>
<li><strong>短請求 API</strong>（HTTP REST、gRPC unary）：窗口通常 5 到 30 秒，收束條件是在途請求數歸零。主要風險是負載平衡的 deregistration delay 仍會送幾秒流量進來，drain 窗口要覆蓋這段。</li>
<li><strong>長連線</strong>（WebSocket、gRPC streaming、SSE）：窗口從 30 秒到數分鐘，收束條件是現有連線收斂、且重連的波形穩定。主要風險是 reconnect storm——一堆連線同時被斷、同時重連，把接手的實例壓垮。</li>
<li><strong>背景 worker</strong>：窗口取決於單一 job 的最長執行時間，收束條件是不可中斷的 job 跑完。風險是被強制結束的 job 留下不一致狀態。</li>
</ul>
<p>服務若混合了多種 workload，drain 窗口取最嚴格（最長）的那個——短請求 5 秒就收完，但同一個服務還有一個要跑兩分鐘的 job，總窗口就得容納兩分鐘。用短請求的窗口去砍一個長 job，等於每次部署都中斷它。</p>
<h2 id="信號收不到收束就變硬砍">信號收不到，收束就變硬砍</h2>
<p>清理邏輯的前提是收得到信號，容器環境有三個常見的信號傳不到陷阱，都跟 PID 1 有關。第一個是用 shell 當 PID 1 又不轉發——<code>ENTRYPOINT [&quot;sh&quot;, &quot;-c&quot;, &quot;java -jar app.jar&quot;]</code> 這種寫法，<code>SIGTERM</code> 送到 sh，sh 預設不轉發給 java，java 一直收不到、等 grace period 到期被 <code>SIGKILL</code> 強殺；修法是用 exec form 或在腳本裡 <code>exec</code>，讓服務直接當 PID 1。第二個是多進程容器的殭屍回收——PID 1 不做 <code>wait()</code>，結束的子進程累積成殭屍，這屬於 <a href="/blog/devops/operations/04-service-health/process-supervisor-selection/" data-link-title="Process supervisor 選型" data-link-desc="在 systemd、supervisord、Docker restart policy、Kubernetes 之間選服務監管方式時，用平台能不能分開表達 startup、readiness、liveness、drain 當判斷標準">supervisor 選型</a> 裡 init process 的職責。第三個是啟動腳本的 trap handler 卡住，把本來 graceful 的關閉拖成 ungraceful 的 hang——trap handler 本身要設逾時，不能無限等。</p>
<p>這三個陷阱的共同表現是一樣的：log 裡看不到關閉處理器跑過的紀錄、服務每次都撐到 grace period 上限才消失。看到這個表現，先查 PID 1 是誰、信號有沒有轉發，而不是先懷疑清理邏輯。</p>
<h2 id="收束要保護的是已承諾未完成的工作">收束要保護的是已承諾未完成的工作</h2>
<p>Graceful shutdown 真正要保護的，是那些「已經對外承諾、但還沒真正完成」的工作。本站 collector 是個具體例子：它收到事件先回 202、事件進 channel buffer、再非同步寫入儲存。從回 202 到真正寫入之間有一個窗口，這段期間若被 <code>SIGKILL</code> 硬砍，這些已承諾但未持久化的事件就遺失了。graceful shutdown 的收束序列要 flush 這些 pending write——把 buffer 裡還沒寫的先寫完，再退出。</p>
<p>哪些關閉保護得了、哪些保護不了，看退出走不走 graceful。走 <code>SIGTERM</code> 加 grace period 的正常關閉，收束序列有機會 flush；但 OOM kill、硬體故障這種非 graceful 的結束，不走任何清理、在途工作直接中斷——這也是 <a href="/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">liveness</a> 要在記憶體逼近上限時主動回報 unhealthy 的理由：主動回報讓平台在還能 graceful 的時候有序重建，好過等 OOM kill 硬砍中斷在途工作。這條「有沒有走 graceful」的分界在監控上也留得下痕跡——collector 正常關閉會送一個 <code>collector.shutdown</code> 事件，這個事件的有無，就是區分有序退場跟異常中斷的訊號。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>收束的第一步是 readiness 轉否、停止接流量 → <a href="/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">Liveness 與 Readiness</a></li>
<li>信號傳不到的 PID 1 選型問題 → <a href="/blog/devops/operations/04-service-health/process-supervisor-selection/" data-link-title="Process supervisor 選型" data-link-desc="在 systemd、supervisord、Docker restart policy、Kubernetes 之間選服務監管方式時，用平台能不能分開表達 startup、readiness、liveness、drain 當判斷標準">Process supervisor 選型</a></li>
<li>部署替換時 drain 與 rollback 的完整流程 → <a href="/blog/backend/05-deployment-platform/deployment-rollout-drain-rollback/" data-link-title="5.8 Deployment Rollout with Drain and Rollback（實作示範）" data-link-desc="以 checkout service 示範部署切換如何交付 canary evidence、drain signal、release gate 與 incident decision log。">Backend 部署替換、drain 與 rollback</a></li>
<li>端到端資料完整性：已承諾未持久化窗口的更多場景 → <a href="/blog/devops/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
</ul>
]]></content:encoded></item><item><title>容器的健康檢查：Docker HEALTHCHECK 的狀態、unhealthy 之後由誰處理、depends_on 的啟動把關與 distroless 映像檔</title><link>https://tarrragon.github.io/blog/devops/operations/04-service-health/container-healthcheck/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/04-service-health/container-healthcheck/</guid><description>&lt;p>這篇說明 Docker 容器這一層的健康檢查：Dockerfile 的 &lt;code>HEALTHCHECK&lt;/code> 與 Compose 的 &lt;code>healthcheck&lt;/code> 怎麼判定狀態、狀態變了之後誰有反應，以及在多容器的 Compose 專案裡設定它時會遇到的問題。健康端點本身要回什麼、探到多深，在 &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/health-check-endpoint/" data-link-title="Health check endpoint 設計" data-link-desc="設計服務的健康檢查端點時，判斷什麼回應算健康、check 要探到多深、依賴要不要一起檢查時回來讀">Health check endpoint 設計&lt;/a>；文中的行為在 Docker 29（OrbStack）上以 Compose 實測。&lt;/p>
&lt;h2 id="健康狀態的三個值與時間參數">健康狀態的三個值與時間參數&lt;/h2>
&lt;p>容器設了健康檢查之後，除了原本的執行狀態（running、exited），還多一個健康狀態，Docker 官方文件定義它有三個值：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>健康狀態&lt;/th>
 &lt;th>意思&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>starting&lt;/code>&lt;/td>
 &lt;td>容器剛啟動、還沒有一次檢查成功&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>healthy&lt;/code>&lt;/td>
 &lt;td>最近一次檢查成功（不論之前是什麼狀態，一次成功就回到 healthy）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>unhealthy&lt;/code>&lt;/td>
 &lt;td>連續失敗的次數達到 &lt;code>retries&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>檢查的方式是 Docker 在容器裡執行一個指令，用指令的結束碼判定：&lt;code>0&lt;/code> 是這次檢查通過，&lt;code>1&lt;/code> 是不健康（這次檢查失敗），&lt;code>2&lt;/code> 保留、文件明寫不要使用。實測 &lt;code>curl -f&lt;/code> 遇到 HTTP 錯誤時結束碼是 &lt;code>22&lt;/code>，Docker 同樣把它算成失敗，所以多數範例會寫成 &lt;code>curl -f ... || exit 1&lt;/code>，讓結束碼落在定義過的值上。指令寫到 stdout 與 stderr 的內容會被保留下來，&lt;code>docker inspect&lt;/code> 的 &lt;code>.State.Health.Log&lt;/code> 讀得到每一次檢查的結束碼與輸出，排查時從這裡看是哪一個檢查、為什麼失敗。&lt;/p>
&lt;p>決定「多久會被判 unhealthy」的是四個時間參數。官方文件的預設值與在 Compose 裡的寫法：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nt">healthcheck&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">test&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;CMD&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;curl&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;-fsS&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;-o&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;/dev/null&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http://127.0.0.1/&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">10s &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 兩次檢查之間的間隔，預設 30s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">3s &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 單次檢查超過這個時間就算失敗，預設 30s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 連續失敗幾次才判 unhealthy，預設 3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">start_period&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">15s &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 啟動後這段時間內的失敗不計入連續失敗，預設 0s&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>test&lt;/code> 裡的網址是容器自己的位址與服務的健康端點，要換成手上服務實際聽的 port 與路徑；四個時間值是範例，依服務的啟動時間與能容忍多久的誤判來調。服務從正常變成壞掉之後，大約要經過 &lt;code>interval × retries&lt;/code> 才會被判 unhealthy：上面這組設定下，讓一個前端容器的 Nginx 開始回 403，31 秒後健康狀態變成 &lt;code>unhealthy&lt;/code>。預設值（30 秒 × 3 次）要一分半。&lt;/p>
&lt;p>&lt;code>start_period&lt;/code> 期間的失敗不計入連續失敗次數，給服務載入的時間；這段期間 Docker 改用 &lt;code>start_interval&lt;/code>（預設 5 秒，Docker Engine 25.0 起）的頻率檢查，所以服務一就緒就能很快變成 &lt;code>healthy&lt;/code>。期間內只要有一次檢查成功，容器就視為啟動完成；從那次成功之後，即使還在 &lt;code>start_period&lt;/code> 之內，失敗也會照常計入連續失敗次數。&lt;/p>
&lt;h2 id="unhealthy-之後容器重啟由什麼觸發">unhealthy 之後，容器重啟由什麼觸發&lt;/h2>
&lt;p>健康狀態與重啟是兩套各自運作的機制。Compose 裡常見的寫法是同一個服務同時設 &lt;code>restart: unless-stopped&lt;/code> 與 &lt;code>healthcheck&lt;/code>，讀起來像是「不健康就重啟」，而兩者互不相干：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>restart policy 只看容器有沒有結束。&lt;/strong> &lt;code>no&lt;/code>、&lt;code>on-failure&lt;/code>、&lt;code>always&lt;/code>、&lt;code>unless-stopped&lt;/code> 的觸發條件都是容器的主程序結束。&lt;/li>
&lt;li>&lt;strong>健康狀態只是一個狀態。&lt;/strong> 變成 &lt;code>unhealthy&lt;/code> 時，Docker 發出一個 &lt;code>health_status: unhealthy&lt;/code> 事件，之後什麼也不做。&lt;/li>
&lt;/ul>
&lt;p>實測兩種情形的差別。讓一個設了 &lt;code>restart: unless-stopped&lt;/code> 的前端容器回 403、程序照常執行：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">31 秒後 Health=unhealthy FailingStreak=3 RestartCount=0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">再 100 秒 Health=unhealthy FailingStreak=10 RestartCount=0 StartedAt 沒變
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">事件 health_status: unhealthy 只出現一次&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>另一個容器讓它的主程序結束：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">1 秒後 RestartCount 0 → 1，restart policy 把它重新啟動
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">7 秒後 Health=healthy&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>所以「程序活著、但不回應」這種情形，單靠 Docker 不會自動恢復，要有另一個程序或平台讀健康狀態再動手。可以讀它的有三類：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>編排平台&lt;/strong>：Kubernetes 的 liveness probe 失敗會重啟容器（probe 的分工見 &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">Liveness 與 Readiness&lt;/a>）。Docker 自己的 Swarm 模式也會：Docker Engine 原始碼裡負責執行 Swarm 任務的那一段（moby 的 &lt;code>daemon/cluster/executor/container/controller.go&lt;/code>）收到 &lt;code>unhealthy&lt;/code> 事件時，會停掉容器並把這個任務標為失敗，Swarm 再依重啟條件排一個新的任務取代它。實測一個健康檢查在啟動 12 秒後開始失敗的 Swarm 服務，任務以 &lt;code>task: non-zero exit (137): dockerexec: unhealthy container&lt;/code> 結束、隨即被新任務取代，之後每一輪都重複。同一段程式碼也在任務啟動時等到 &lt;code>healthy&lt;/code> 才把它加進服務的負載平衡，所以在 Swarm 模式下，同一個健康檢查同時擔任 readiness 與 liveness。&lt;/li>
&lt;li>&lt;strong>監聽 Docker 事件的工具&lt;/strong>：訂閱 &lt;code>health_status&lt;/code> 事件，看到 unhealthy 就重啟那個容器，現成的有 autoheal 這類專做這件事的容器。這等於自己補上 liveness 的行為，代價是要多跑一個有權限操作 Docker 的程序。&lt;/li>
&lt;li>&lt;strong>讓服務自己結束&lt;/strong>：服務偵測到自己回不了頭時直接結束，交給 restart policy 拉起來。一個容器裡跑多個程序時，讓 entrypoint 在任一個程序結束時結束整個容器，屬於這一類。&lt;/li>
&lt;/ul>
&lt;p>反過來，壞掉的服務修好之後也不需要重啟：健康檢查持續在跑，上面那個前端改回正常後，下一次檢查成功，狀態就回到 &lt;code>healthy&lt;/code>，&lt;code>RestartCount&lt;/code> 仍是 0。&lt;/p></description><content:encoded><![CDATA[<p>這篇說明 Docker 容器這一層的健康檢查：Dockerfile 的 <code>HEALTHCHECK</code> 與 Compose 的 <code>healthcheck</code> 怎麼判定狀態、狀態變了之後誰有反應，以及在多容器的 Compose 專案裡設定它時會遇到的問題。健康端點本身要回什麼、探到多深，在 <a href="/blog/devops/operations/04-service-health/health-check-endpoint/" data-link-title="Health check endpoint 設計" data-link-desc="設計服務的健康檢查端點時，判斷什麼回應算健康、check 要探到多深、依賴要不要一起檢查時回來讀">Health check endpoint 設計</a>；文中的行為在 Docker 29（OrbStack）上以 Compose 實測。</p>
<h2 id="健康狀態的三個值與時間參數">健康狀態的三個值與時間參數</h2>
<p>容器設了健康檢查之後，除了原本的執行狀態（running、exited），還多一個健康狀態，Docker 官方文件定義它有三個值：</p>
<table>
  <thead>
      <tr>
          <th>健康狀態</th>
          <th>意思</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>starting</code></td>
          <td>容器剛啟動、還沒有一次檢查成功</td>
      </tr>
      <tr>
          <td><code>healthy</code></td>
          <td>最近一次檢查成功（不論之前是什麼狀態，一次成功就回到 healthy）</td>
      </tr>
      <tr>
          <td><code>unhealthy</code></td>
          <td>連續失敗的次數達到 <code>retries</code></td>
      </tr>
  </tbody>
</table>
<p>檢查的方式是 Docker 在容器裡執行一個指令，用指令的結束碼判定：<code>0</code> 是這次檢查通過，<code>1</code> 是不健康（這次檢查失敗），<code>2</code> 保留、文件明寫不要使用。實測 <code>curl -f</code> 遇到 HTTP 錯誤時結束碼是 <code>22</code>，Docker 同樣把它算成失敗，所以多數範例會寫成 <code>curl -f ... || exit 1</code>，讓結束碼落在定義過的值上。指令寫到 stdout 與 stderr 的內容會被保留下來，<code>docker inspect</code> 的 <code>.State.Health.Log</code> 讀得到每一次檢查的結束碼與輸出，排查時從這裡看是哪一個檢查、為什麼失敗。</p>
<p>決定「多久會被判 unhealthy」的是四個時間參數。官方文件的預設值與在 Compose 裡的寫法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;curl&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-fsS&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-o&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;/dev/null&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://127.0.0.1/&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s     </span><span class="w"> </span><span class="c"># 兩次檢查之間的間隔，預設 30s</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">3s       </span><span class="w"> </span><span class="c"># 單次檢查超過這個時間就算失敗，預設 30s</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">         </span><span class="c"># 連續失敗幾次才判 unhealthy，預設 3</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">  </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">15s </span><span class="w"> </span><span class="c"># 啟動後這段時間內的失敗不計入連續失敗，預設 0s</span></span></span></code></pre></div><p><code>test</code> 裡的網址是容器自己的位址與服務的健康端點，要換成手上服務實際聽的 port 與路徑；四個時間值是範例，依服務的啟動時間與能容忍多久的誤判來調。服務從正常變成壞掉之後，大約要經過 <code>interval × retries</code> 才會被判 unhealthy：上面這組設定下，讓一個前端容器的 Nginx 開始回 403，31 秒後健康狀態變成 <code>unhealthy</code>。預設值（30 秒 × 3 次）要一分半。</p>
<p><code>start_period</code> 期間的失敗不計入連續失敗次數，給服務載入的時間；這段期間 Docker 改用 <code>start_interval</code>（預設 5 秒，Docker Engine 25.0 起）的頻率檢查，所以服務一就緒就能很快變成 <code>healthy</code>。期間內只要有一次檢查成功，容器就視為啟動完成；從那次成功之後，即使還在 <code>start_period</code> 之內，失敗也會照常計入連續失敗次數。</p>
<h2 id="unhealthy-之後容器重啟由什麼觸發">unhealthy 之後，容器重啟由什麼觸發</h2>
<p>健康狀態與重啟是兩套各自運作的機制。Compose 裡常見的寫法是同一個服務同時設 <code>restart: unless-stopped</code> 與 <code>healthcheck</code>，讀起來像是「不健康就重啟」，而兩者互不相干：</p>
<ul>
<li><strong>restart policy 只看容器有沒有結束。</strong> <code>no</code>、<code>on-failure</code>、<code>always</code>、<code>unless-stopped</code> 的觸發條件都是容器的主程序結束。</li>
<li><strong>健康狀態只是一個狀態。</strong> 變成 <code>unhealthy</code> 時，Docker 發出一個 <code>health_status: unhealthy</code> 事件，之後什麼也不做。</li>
</ul>
<p>實測兩種情形的差別。讓一個設了 <code>restart: unless-stopped</code> 的前端容器回 403、程序照常執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">31 秒後      Health=unhealthy  FailingStreak=3   RestartCount=0
</span></span><span class="line"><span class="ln">2</span><span class="cl">再 100 秒    Health=unhealthy  FailingStreak=10  RestartCount=0   StartedAt 沒變
</span></span><span class="line"><span class="ln">3</span><span class="cl">事件         health_status: unhealthy 只出現一次</span></span></code></pre></div><p>另一個容器讓它的主程序結束：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1 秒後       RestartCount 0 → 1，restart policy 把它重新啟動
</span></span><span class="line"><span class="ln">2</span><span class="cl">7 秒後       Health=healthy</span></span></code></pre></div><p>所以「程序活著、但不回應」這種情形，單靠 Docker 不會自動恢復，要有另一個程序或平台讀健康狀態再動手。可以讀它的有三類：</p>
<ul>
<li><strong>編排平台</strong>：Kubernetes 的 liveness probe 失敗會重啟容器（probe 的分工見 <a href="/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">Liveness 與 Readiness</a>）。Docker 自己的 Swarm 模式也會：Docker Engine 原始碼裡負責執行 Swarm 任務的那一段（moby 的 <code>daemon/cluster/executor/container/controller.go</code>）收到 <code>unhealthy</code> 事件時，會停掉容器並把這個任務標為失敗，Swarm 再依重啟條件排一個新的任務取代它。實測一個健康檢查在啟動 12 秒後開始失敗的 Swarm 服務，任務以 <code>task: non-zero exit (137): dockerexec: unhealthy container</code> 結束、隨即被新任務取代，之後每一輪都重複。同一段程式碼也在任務啟動時等到 <code>healthy</code> 才把它加進服務的負載平衡，所以在 Swarm 模式下，同一個健康檢查同時擔任 readiness 與 liveness。</li>
<li><strong>監聽 Docker 事件的工具</strong>：訂閱 <code>health_status</code> 事件，看到 unhealthy 就重啟那個容器，現成的有 autoheal 這類專做這件事的容器。這等於自己補上 liveness 的行為，代價是要多跑一個有權限操作 Docker 的程序。</li>
<li><strong>讓服務自己結束</strong>：服務偵測到自己回不了頭時直接結束，交給 restart policy 拉起來。一個容器裡跑多個程序時，讓 entrypoint 在任一個程序結束時結束整個容器，屬於這一類。</li>
</ul>
<p>反過來，壞掉的服務修好之後也不需要重啟：健康檢查持續在跑，上面那個前端改回正常後，下一次檢查成功，狀態就回到 <code>healthy</code>，<code>RestartCount</code> 仍是 0。</p>
<p>所以同一個 <code>HEALTHCHECK</code>，效果取決於容器由誰管理。一般的 <code>docker run</code> 與 Compose 下，Docker 不拿它決定流量、也不拿它重啟，它的用途是顯示狀態、讓 Compose 決定啟動順序，以及給上面那幾類外部工具讀；Swarm 模式下它決定任務何時開始接流量、何時被取代。不論是 <code>docker run</code>、Compose 還是 Swarm，<code>HEALTHCHECK</code> 都只有一個健康狀態，沒有 Kubernetes 那樣分開的 readiness 與 liveness。</p>
<h2 id="depends_on-的-service_healthy-把關的時機與連鎖失敗">depends_on 的 service_healthy 把關的時機與連鎖失敗</h2>
<p>Compose 的 <code>depends_on</code> 可以要求依賴的服務達到某個條件才啟動自己：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy  </span><span class="w"> </span><span class="c"># 等 postgres 的健康狀態變成 healthy</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">  </span><span class="nt">cache</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">    </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started  </span><span class="w"> </span><span class="c"># 只等它啟動，不看健康狀態</span></span></span></code></pre></div><p><code>service_healthy</code> 解決的是啟動順序：資料庫還在初始化時，應用程式不會先起來然後連線失敗。它的作用範圍只有「啟動或重建設了這個 <code>depends_on</code> 的服務（上例的應用程式）的那一刻」。依賴的服務在之後變成 unhealthy，已經在跑的服務不受影響，Compose 不會因此停掉或重啟它。</p>
<p>這個時間點上的限制會帶出一個連鎖失敗。把入口的反向代理設成等所有上游 <code>service_healthy</code>，然後讓其中一個前端變成 unhealthy，再重建入口：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Container portal-1 Error dependency portal failed to start
</span></span><span class="line"><span class="ln">2</span><span class="cl">dependency failed to start: container portal-1 is unhealthy</span></span></code></pre></div><p>入口的容器停在 <code>created</code>，沒有啟動。一個前端頁面壞掉，結果整個站的入口在下一次部署或重啟時起不來，其他正常的服務也連帶進不去。改成 <code>service_started</code> 之後，同樣的情形下入口照常啟動，只有壞掉的那個前端所在的路徑出錯。</p>
<p>判斷要用哪一個條件，問的是「依賴的服務不健康時，這個服務啟動起來還有沒有用」：</p>
<ul>
<li><strong>沒有用</strong>：後端沒有資料庫就無法服務任何請求，等資料庫 <code>service_healthy</code> 合理。</li>
<li><strong>有用</strong>：入口後面有多個上游，其中一個壞了，其他路徑仍然要能服務。這時用 <code>service_started</code> 只排順序，上游的故障留給入口在請求當下處理（那條路徑回 502）。</li>
</ul>
<p><code>service_started</code> 有一個前提：入口在啟動時不需要連上上游。Nginx 在載入設定時就要解析 <code>upstream</code> 裡的主機名，解析不到會直接拒絕啟動（實測訊息是 <code>host not found in upstream</code>）；<code>service_started</code> 保證上游的容器已經建立、名字解析得到，所以上游只是 unhealthy 時入口起得來，但上游的容器根本沒建立起來時，入口一樣起不來。要讓入口連這一點都不依賴，要把上游的解析延到請求當下（Nginx 用 <code>resolver</code> 加上變數形式的 <code>proxy_pass</code>）。</p>
<p><code>docker compose up</code> 遇到 <code>dependency failed to start</code> 時以非零結束碼結束（實測結束碼 1），所以部署腳本看得到失敗；從錯誤訊息看不出的是影響範圍——訊息點名的是壞掉的前端，起不來的卻是入口。</p>
<p>部署流程要在當下就確認服務健康，可以用 <code>docker compose up -d --wait --wait-timeout 60</code>：它會等所有啟動的服務變成 healthy 才結束，任一個變成 unhealthy 就以結束碼 1 結束（實測健康檢查一開始就失敗的服務，7 秒後回報 <code>container ... is unhealthy</code>，不必等滿逾時）。<code>60</code> 是範例，換成服務正常啟動需要的最長秒數。</p>
<h2 id="沒有-shell-的映像檔怎麼做健康檢查">沒有 shell 的映像檔怎麼做健康檢查</h2>
<p>健康檢查的指令在容器裡執行，所以容器裡要有能執行它的工具，而映像檔帶了哪些工具隨映像檔與它的變體而不同：實測官方的 Nginx 與 PHP 映像檔帶著 <code>curl</code>，Valkey 的 Alpine 版只有 <code>wget</code>、Debian 版兩者都沒有。資料庫與快取這類不講 HTTP 的服務，健康檢查改用它們自己的指令列工具（<code>pg_isready</code>、<code>valkey-cli ping</code>）。手上的映像檔有什麼，用 <code>docker run --rm --entrypoint sh &lt;映像檔&gt; -c 'command -v curl wget'</code> 查一次就知道（<code>&lt;映像檔&gt;</code> 換成映像檔名稱與標籤）。這條指令要靠映像檔裡的 <code>sh</code> 執行，它本身失敗時，代表那是一個連 shell 都沒有的映像檔：為了縮小攻擊面而選用的 distroless 映像檔就是這樣，<code>CMD-SHELL</code> 與 <code>curl</code> 都用不了。</p>
<p>常見的做法是讓服務的執行檔自己兼任檢查的客戶端：帶一個參數啟動時不開伺服器，改成對本機的健康端點發一次請求，用結束碼回報。以 Go 為例，<code>main</code> 裡先處理這個參數：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nx">healthcheck</span> <span class="o">:=</span> <span class="nx">flag</span><span class="p">.</span><span class="nf">Bool</span><span class="p">(</span><span class="s">&#34;healthcheck&#34;</span><span class="p">,</span> <span class="kc">false</span><span class="p">,</span> <span class="s">&#34;請求本機的 /healthz，成功時結束碼 0，失敗時 1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">flag</span><span class="p">.</span><span class="nf">Parse</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">if</span> <span class="o">*</span><span class="nx">healthcheck</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="c1">// 逾時比 compose 的 timeout 短，Docker 判逾時之前就能留下錯誤訊息</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Timeout</span><span class="p">:</span> <span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">resp</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;http://127.0.0.1:8080/healthz&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="o">||</span> <span class="nx">resp</span><span class="p">.</span><span class="nx">StatusCode</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusOK</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">os</span><span class="p">.</span><span class="nf">Exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">os</span><span class="p">.</span><span class="nf">Exit</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Compose 裡用 exec 形式呼叫同一支執行檔（exec 形式不經過 shell，distroless 裡也能執行）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;/server&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-healthcheck&#34;</span><span class="p">]</span></span></span></code></pre></div><p><code>/server</code> 是執行檔在映像檔裡的路徑，<code>-healthcheck</code> 是上面定義的參數名稱，兩者都要換成手上服務的實際值。這個做法不增加映像檔的內容，檢查的也是服務自己的 HTTP 端點；代價是健康檢查的邏輯寫進了應用程式碼，換語言或換框架時要跟著改。另一種做法是把一個靜態編譯的小型檢查工具複製進映像檔，邏輯與應用程式分開，但映像檔多了一個要維護的檔案。</p>
<h2 id="容器健康檢查看不到的範圍">容器健康檢查看不到的範圍</h2>
<p>容器的健康檢查在容器自己裡面執行，所以它回答的是「這個容器自己的端點能不能回應」。它看不到兩件事：別的容器透過網路連不連得到它，以及請求經過入口的路由之後能不能到達它。另外，狀態變了之後沒有人被通知，除非有工具去讀它。</p>
<p>這兩件事要靠從外部探測的工具補上：另一個容器定期從網路對各服務發請求，並把結果彙整成儀表板或告警。多容器服務怎麼把容器內的檢查、外部探測與指令列查詢分工，見 <a href="/blog/devops/operations/04-service-health/multi-container-monitoring/" data-link-title="多容器服務的存活監看：容器健康檢查、外部探測與指令列查詢的分工" data-link-desc="說明一組用 Compose 跑的多容器服務怎麼回答「每個服務還活著嗎」：容器健康檢查、從另一個容器發請求的外部探測、SSH 進主機時用的查詢腳本各自看得到與看不到的範圍，外部探測工具（Gatus、Uptime Kuma）的選擇、TCP 檢查與經過負載平衡的檢查的限制，以及監看工具自己掛掉時怎麼發現">多容器服務的存活監看</a>。這兩件事（健康檢查沒有檢查的範圍）與「狀態沒有程式讀」，在 <a href="/blog/backend/04-observability/monitoring-layers/" data-link-title="4.0 服務端監控的分層：容器與程序的自檢、服務網路內的探測、元件指標、應用層訊號、告警規則與分送、告警鏈的監看與使用者路徑的合成監控" data-link-desc="以每一層監控檢查的對象、看不到的範圍與接手的那一層，把服務端監控串成一條監控鏈，以及拿它盤點一個服務時怎麼判讀缺口的性質（檢查範圍、位置、讀取與訊號失真），涵蓋自架主機、Kubernetes 與雲端託管的情形；使用者路徑那一層另寫合成監控與真實使用者監測的差別，以及盤點時各種缺口的補法">4.0 服務端監控的分層</a> 分別稱為自檢的檢查範圍盲區與讀取缺口，那一篇寫了由哪一層接手、以及重啟為什麼不算補上讀取缺口。</p>
]]></content:encoded></item><item><title>多容器服務的存活監看：容器健康檢查、外部探測與指令列查詢的分工</title><link>https://tarrragon.github.io/blog/devops/operations/04-service-health/multi-container-monitoring/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/devops/operations/04-service-health/multi-container-monitoring/</guid><description>&lt;p>這篇說明一組用 Docker Compose 跑在同一台主機上的多容器服務（入口的反向代理、前端、後端、資料庫、快取），怎麼回答「每個服務還活著嗎」。範圍是單機的 Compose 環境；容器健康檢查本身的機制在 &lt;a href="https://tarrragon.github.io/blog/devops/operations/04-service-health/container-healthcheck/" data-link-title="容器的健康檢查：Docker HEALTHCHECK 的狀態、unhealthy 之後由誰處理、depends_on 的啟動把關與 distroless 映像檔" data-link-desc="說明 Docker 的 HEALTHCHECK 與 Compose 的 healthcheck 怎麼運作：starting、healthy、unhealthy 三個狀態與時間參數的換算，容器變成 unhealthy 之後 Docker 與 restart policy 各自做什麼、不做什麼，depends_on 的 service_healthy 只在啟動時把關會帶來的連鎖失敗，以及沒有 shell 的 distroless 映像檔怎麼做健康檢查">容器的健康檢查&lt;/a>，跨多台機器的編排平台由 Kubernetes 這類平台的 probe 接手。&lt;/p>
&lt;h2 id="活著由三層回答">「活著」由三層回答&lt;/h2>
&lt;p>多容器服務裡，「某個服務還活著嗎」至少有三種問法，每一種要由不同位置的檢查回答：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>問法&lt;/th>
 &lt;th>由誰檢查&lt;/th>
 &lt;th>檢查的位置&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>這個容器的程序在不在&lt;/td>
 &lt;td>Docker&lt;/td>
 &lt;td>容器的執行狀態（running、exited）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>這個服務自己的端點能不能回應&lt;/td>
 &lt;td>容器的健康檢查，Docker 在容器裡執行&lt;/td>
 &lt;td>容器內部，打 &lt;code>127.0.0.1&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>從別的地方打不打得通&lt;/td>
 &lt;td>外部探測，另一個容器發請求&lt;/td>
 &lt;td>Compose 網路上，用服務名稱&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三層彼此不能互相取代。程序在跑不代表端點能回應：一個容器裡同時跑 Nginx 與 PHP-FPM，PHP-FPM 死了而 Nginx 還在，容器是 running，每個請求都是 502。端點能回應也不代表從外面打得通：服務自己回應正常，入口的路由設定寫錯，使用者照樣進不去。本篇的外部探測從同一台主機上的另一個容器發出，主機停掉時跟著停；從另一台機器、照使用者路徑打對外網址的探測回答的是使用者從外面連不連得到，見 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/single-host-monitoring-outside-view/" data-link-title="4.28 單台主機的監控盲區與失效域以外的觀察者：主機停機、使用者路徑中斷與告警鏈中斷，心跳監看與外部探測各自回答的問題與位置，以及與內部探測並排的判讀" data-link-desc="服務與監控放在同一台主機時監控結構上看不到的故障：主機整台停掉、使用者到主機的路徑中斷、告警鏈停掉與通知送不出去；從失效域以外送出訊號的心跳監看與外部探測各自回答的問題與成立條件，觀察者由第三方服務、另一個失效域的主機（含兩台主機互相監看）或另一個業者的排程平台提供時的差別，以及心跳監看、外部探測與主機內部探測並排時的判讀">4.28 單台主機的監控盲區與失效域以外的觀察者&lt;/a>。&lt;/p>
&lt;h2 id="外部探測從另一個容器發請求">外部探測：從另一個容器發請求&lt;/h2>
&lt;p>容器的健康檢查只看得到容器內部，外部探測補上的是網路與路由這一段。做法是在同一個 Compose 網路裡多跑一個探測工具的容器，它用服務名稱（&lt;code>go:8080&lt;/code>、&lt;code>postgres:5432&lt;/code>）定期對各服務發請求，把結果彙整成一個儀表板。&lt;/p>
&lt;p>已經在跑 Prometheus 的環境，外部探測通常交給 Prometheus 的 blackbox_exporter（一種 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/prometheus-exporter/" data-link-title="Prometheus Exporter（指標轉接程式）" data-link-desc="要監控一個不會自己輸出 Prometheus 指標的系統（主機、容器、資料庫、快取、Nginx）時，查 exporter 做什麼、它和應用程式自己開的 /metrics 差在哪，以及 exporter 讀得到資料卻缺標籤、或者讀不到它所在層以外的機器時怎麼發現">exporter&lt;/a>，替 Prometheus 從外面打端點並輸出結果），結果直接進既有的告警規則（Prometheus 的元件分工見 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/vendors/prometheus/" data-link-title="Prometheus" data-link-desc="Pull-based metrics 主流 OSS、PromQL 與 alerting">Prometheus&lt;/a>）。這一節談的是還沒有監控堆疊的單機環境，要一個獨立的探測工具。選工具時主要的分別在設定放在哪裡：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>設定方式&lt;/th>
 &lt;th>適合&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Gatus&lt;/td>
 &lt;td>一份 YAML 設定檔，可以放進版本控制；每個端點寫一組判定條件&lt;/td>
 &lt;td>環境要能從 repo 重建、設定要經過 review 的專案&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Uptime Kuma&lt;/td>
 &lt;td>在網頁介面上新增監看項目，設定存在掛載進容器的 volume（官方範例的 &lt;code>/app/data&lt;/code>），重建容器不會遺失，但不在 repo 裡&lt;/td>
 &lt;td>想在網頁上直接點選設定、不需要版控的環境&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩者都能做 HTTP、TCP 檢查與告警通知。設定存在工具自己資料目錄裡的，重建環境時監看清單要另外備份與還原；設定是一份檔案的工具，監看清單跟著程式碼一起版控，新增一個服務時，監看項目可以在同一個變更裡加上。&lt;/p>
&lt;p>以 Gatus 為例，一個 HTTP 端點的檢查寫成一組條件，全部成立才算通過：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nt">endpoints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Go 後端&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http://go:8080/healthz&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Compose 網路裡的服務名稱與健康端點&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">30s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">conditions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;[STATUS] == 200&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 狀態碼要是 200；回應時間不放進存活的條件&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>url&lt;/code> 要換成手上服務在 Compose 網路裡的名稱、port 與健康端點；&lt;code>interval&lt;/code> 依服務的特性調整。回應時間不放進存活探測的條件：同一台主機過載時，服務只是變慢也會被判成失敗，「慢」和「掛了」在結果裡長得一樣；延遲改以探測回應時間在一段時間窗內的分位數、或後端輸出的延遲分布判斷，寫法見 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/host-overload-alerting/" data-link-title="4.27 主機過載時的監控告警：可用與變慢分開判斷、依原因抑制、監控自身的等待" data-link-desc="多個服務共用一台主機而主機過載時，各服務的探測與告警會一起失敗：可用性與延遲分成兩個判斷的寫法、間歇的條件怎麼持續成立、以主機飽和抑制各服務變慢的規則與它的取捨、以合成告警驗證抑制、抑制藏起各服務受影響程度的差別、探測與 Prometheus 自己也在等 CPU 時的訊號，以及讓監控不比服務先失效的資源隔離；以一次開發環境的過載為例">4.27 主機過載時的監控告警&lt;/a>〈可用性與延遲分成兩個判斷〉。&lt;/p>
&lt;h3 id="tcp-檢查的保證範圍">TCP 檢查的保證範圍&lt;/h3>
&lt;p>資料庫與快取不講 HTTP，探測工具通常改用 TCP 檢查：連得上那個 port 就算通過。連得上只代表那個位址與 port 上有程序在聽、連線建立成功，Gatus 的文件也提醒這不保證服務本身健康。&lt;/p>
&lt;p>能送一段資料再檢查回應的工具，可以把檢查往前推一步。Valkey（與 Redis）收到 &lt;code>PING&lt;/code> 會回 &lt;code>PONG&lt;/code>，所以檢查改成送 &lt;code>PING&lt;/code>、回應裡要有 &lt;code>PONG&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Valkey&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp://cache:6379&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">body&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;PING\r\n&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 連上之後送出的內容&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">conditions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;[CONNECTED] == true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;[BODY] == pat(*PONG*)&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 回應裡要包含 PONG&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>cache:6379&lt;/code> 同樣要換成手上快取服務的名稱與 port。PostgreSQL 的協定沒有這種一行就能完成的問答，TCP 檢查只能證明它在聽；它能不能執行查詢，由容器自己的健康檢查（&lt;code>pg_isready&lt;/code>）與後端的錯誤率回答。&lt;/p></description><content:encoded><![CDATA[<p>這篇說明一組用 Docker Compose 跑在同一台主機上的多容器服務（入口的反向代理、前端、後端、資料庫、快取），怎麼回答「每個服務還活著嗎」。範圍是單機的 Compose 環境；容器健康檢查本身的機制在 <a href="/blog/devops/operations/04-service-health/container-healthcheck/" data-link-title="容器的健康檢查：Docker HEALTHCHECK 的狀態、unhealthy 之後由誰處理、depends_on 的啟動把關與 distroless 映像檔" data-link-desc="說明 Docker 的 HEALTHCHECK 與 Compose 的 healthcheck 怎麼運作：starting、healthy、unhealthy 三個狀態與時間參數的換算，容器變成 unhealthy 之後 Docker 與 restart policy 各自做什麼、不做什麼，depends_on 的 service_healthy 只在啟動時把關會帶來的連鎖失敗，以及沒有 shell 的 distroless 映像檔怎麼做健康檢查">容器的健康檢查</a>，跨多台機器的編排平台由 Kubernetes 這類平台的 probe 接手。</p>
<h2 id="活著由三層回答">「活著」由三層回答</h2>
<p>多容器服務裡，「某個服務還活著嗎」至少有三種問法，每一種要由不同位置的檢查回答：</p>
<table>
  <thead>
      <tr>
          <th>問法</th>
          <th>由誰檢查</th>
          <th>檢查的位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>這個容器的程序在不在</td>
          <td>Docker</td>
          <td>容器的執行狀態（running、exited）</td>
      </tr>
      <tr>
          <td>這個服務自己的端點能不能回應</td>
          <td>容器的健康檢查，Docker 在容器裡執行</td>
          <td>容器內部，打 <code>127.0.0.1</code></td>
      </tr>
      <tr>
          <td>從別的地方打不打得通</td>
          <td>外部探測，另一個容器發請求</td>
          <td>Compose 網路上，用服務名稱</td>
      </tr>
  </tbody>
</table>
<p>三層彼此不能互相取代。程序在跑不代表端點能回應：一個容器裡同時跑 Nginx 與 PHP-FPM，PHP-FPM 死了而 Nginx 還在，容器是 running，每個請求都是 502。端點能回應也不代表從外面打得通：服務自己回應正常，入口的路由設定寫錯，使用者照樣進不去。本篇的外部探測從同一台主機上的另一個容器發出，主機停掉時跟著停；從另一台機器、照使用者路徑打對外網址的探測回答的是使用者從外面連不連得到，見 <a href="/blog/backend/04-observability/single-host-monitoring-outside-view/" data-link-title="4.28 單台主機的監控盲區與失效域以外的觀察者：主機停機、使用者路徑中斷與告警鏈中斷，心跳監看與外部探測各自回答的問題與位置，以及與內部探測並排的判讀" data-link-desc="服務與監控放在同一台主機時監控結構上看不到的故障：主機整台停掉、使用者到主機的路徑中斷、告警鏈停掉與通知送不出去；從失效域以外送出訊號的心跳監看與外部探測各自回答的問題與成立條件，觀察者由第三方服務、另一個失效域的主機（含兩台主機互相監看）或另一個業者的排程平台提供時的差別，以及心跳監看、外部探測與主機內部探測並排時的判讀">4.28 單台主機的監控盲區與失效域以外的觀察者</a>。</p>
<h2 id="外部探測從另一個容器發請求">外部探測：從另一個容器發請求</h2>
<p>容器的健康檢查只看得到容器內部，外部探測補上的是網路與路由這一段。做法是在同一個 Compose 網路裡多跑一個探測工具的容器，它用服務名稱（<code>go:8080</code>、<code>postgres:5432</code>）定期對各服務發請求，把結果彙整成一個儀表板。</p>
<p>已經在跑 Prometheus 的環境，外部探測通常交給 Prometheus 的 blackbox_exporter（一種 <a href="/blog/backend/knowledge-cards/prometheus-exporter/" data-link-title="Prometheus Exporter（指標轉接程式）" data-link-desc="要監控一個不會自己輸出 Prometheus 指標的系統（主機、容器、資料庫、快取、Nginx）時，查 exporter 做什麼、它和應用程式自己開的 /metrics 差在哪，以及 exporter 讀得到資料卻缺標籤、或者讀不到它所在層以外的機器時怎麼發現">exporter</a>，替 Prometheus 從外面打端點並輸出結果），結果直接進既有的告警規則（Prometheus 的元件分工見 <a href="/blog/backend/04-observability/vendors/prometheus/" data-link-title="Prometheus" data-link-desc="Pull-based metrics 主流 OSS、PromQL 與 alerting">Prometheus</a>）。這一節談的是還沒有監控堆疊的單機環境，要一個獨立的探測工具。選工具時主要的分別在設定放在哪裡：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>設定方式</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Gatus</td>
          <td>一份 YAML 設定檔，可以放進版本控制；每個端點寫一組判定條件</td>
          <td>環境要能從 repo 重建、設定要經過 review 的專案</td>
      </tr>
      <tr>
          <td>Uptime Kuma</td>
          <td>在網頁介面上新增監看項目，設定存在掛載進容器的 volume（官方範例的 <code>/app/data</code>），重建容器不會遺失，但不在 repo 裡</td>
          <td>想在網頁上直接點選設定、不需要版控的環境</td>
      </tr>
  </tbody>
</table>
<p>兩者都能做 HTTP、TCP 檢查與告警通知。設定存在工具自己資料目錄裡的，重建環境時監看清單要另外備份與還原；設定是一份檔案的工具，監看清單跟著程式碼一起版控，新增一個服務時，監看項目可以在同一個變更裡加上。</p>
<p>以 Gatus 為例，一個 HTTP 端點的檢查寫成一組條件，全部成立才算通過：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">endpoints</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Go 後端</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;http://go:8080/healthz&#34;</span><span class="w">   </span><span class="c"># Compose 網路裡的服務名稱與健康端點</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">    </span><span class="nt">conditions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;[STATUS] == 200&#34;</span><span class="w">           </span><span class="c"># 狀態碼要是 200；回應時間不放進存活的條件</span></span></span></code></pre></div><p><code>url</code> 要換成手上服務在 Compose 網路裡的名稱、port 與健康端點；<code>interval</code> 依服務的特性調整。回應時間不放進存活探測的條件：同一台主機過載時，服務只是變慢也會被判成失敗，「慢」和「掛了」在結果裡長得一樣；延遲改以探測回應時間在一段時間窗內的分位數、或後端輸出的延遲分布判斷，寫法見 <a href="/blog/backend/04-observability/host-overload-alerting/" data-link-title="4.27 主機過載時的監控告警：可用與變慢分開判斷、依原因抑制、監控自身的等待" data-link-desc="多個服務共用一台主機而主機過載時，各服務的探測與告警會一起失敗：可用性與延遲分成兩個判斷的寫法、間歇的條件怎麼持續成立、以主機飽和抑制各服務變慢的規則與它的取捨、以合成告警驗證抑制、抑制藏起各服務受影響程度的差別、探測與 Prometheus 自己也在等 CPU 時的訊號，以及讓監控不比服務先失效的資源隔離；以一次開發環境的過載為例">4.27 主機過載時的監控告警</a>〈可用性與延遲分成兩個判斷〉。</p>
<h3 id="tcp-檢查的保證範圍">TCP 檢查的保證範圍</h3>
<p>資料庫與快取不講 HTTP，探測工具通常改用 TCP 檢查：連得上那個 port 就算通過。連得上只代表那個位址與 port 上有程序在聽、連線建立成功，Gatus 的文件也提醒這不保證服務本身健康。</p>
<p>能送一段資料再檢查回應的工具，可以把檢查往前推一步。Valkey（與 Redis）收到 <code>PING</code> 會回 <code>PONG</code>，所以檢查改成送 <code>PING</code>、回應裡要有 <code>PONG</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Valkey</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">    </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;tcp://cache:6379&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span><span class="nt">body</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;PING\r\n&#34;</span><span class="w">                </span><span class="c"># 連上之後送出的內容</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="nt">conditions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;[CONNECTED] == true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;[BODY] == pat(*PONG*)&#34;</span><span class="w">     </span><span class="c"># 回應裡要包含 PONG</span></span></span></code></pre></div><p><code>cache:6379</code> 同樣要換成手上快取服務的名稱與 port。PostgreSQL 的協定沒有這種一行就能完成的問答，TCP 檢查只能證明它在聽；它能不能執行查詢，由容器自己的健康檢查（<code>pg_isready</code>）與後端的錯誤率回答。</p>
<h3 id="負載平衡後面的檢查涵蓋哪一個後端">負載平衡後面的檢查涵蓋哪一個後端</h3>
<p>入口後面有多個同一份 API 的後端、由負載平衡輪流分配時，經過入口的一次檢查只會打到其中一個。某一個後端壞了，經過入口的檢查可能剛好落在好的那一個而通過，也可能落在壞的那一個而失敗，結果時好時壞，看不出是哪一個壞了。</p>
<p>所以兩種檢查都要有、各自回答不同的問題：直接打每一個後端（用各自的服務名稱），回答「哪一個後端壞了」；經過入口打一次，回答「入口本身與路由是不是通的」。</p>
<h2 id="ssh-進主機時的指令列查詢">SSH 進主機時的指令列查詢</h2>
<p>儀表板適合一直開著看；SSH 進主機排查時，要的是一條指令列出每個服務的狀態，而且能被腳本與排程呼叫。<code>docker compose ps</code> 已經有執行狀態與健康狀態兩欄，缺的是兩件事：沒有容器的服務不會被列出來，以及沒有一個結束碼可以讓呼叫的一方判斷結果。</p>
<p>補上這兩件事的腳本，邏輯是拿 <code>docker compose config --services</code> 列出定義過的所有服務，逐一查它的狀態，再依最嚴重的那一個決定結束碼。結束碼的設計值得多分一層：</p>
<table>
  <thead>
      <tr>
          <th>結束碼</th>
          <th>情況</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>0</code></td>
          <td>全部服務在跑，設了健康檢查的都是 healthy</td>
      </tr>
      <tr>
          <td><code>1</code></td>
          <td>有服務沒在跑（沒啟動、已結束），或有服務 unhealthy</td>
      </tr>
      <tr>
          <td><code>2</code></td>
          <td>沒有 <code>1</code> 的情況，但有服務還在 <code>starting</code></td>
      </tr>
  </tbody>
</table>
<p>把 <code>starting</code> 分出來，是因為它在兩種呼叫情境下的意思相反：部署流程剛重建完容器，<code>starting</code> 是預期中的狀態，流程可以重試直到變成 <code>0</code>；定時執行的檢查看到某個服務一直停在 <code>starting</code>，代表它卡在啟動階段，該當成警告。只有 <code>0</code> 與 <code>1</code> 兩種結束碼時，呼叫的一方分不出這兩件事。</p>
<h2 id="監看工具自己掛掉時">監看工具自己掛掉時</h2>
<p>外部探測工具也是一個容器。它掛掉時檢查就停了，告警也不會送出，而沒有任何機制會發出「監看停了」的通知。常見的補法有兩種：</p>
<ul>
<li><strong>讓另一套機制監看它</strong>：探測工具多半有自己的健康端點（Gatus 的 <code>/health</code> 回 <code>{&quot;status&quot;:&quot;UP&quot;}</code>），可以交給主機上的另一套機制（例如 systemd 的計時器、或另一台機器上的探測）去戳。</li>
<li><strong>反過來監看訊號是否消失</strong>：讓探測工具定期送出一個「我還在」的訊號給外部服務，外部服務在訊號停止時告警，這就是 <a href="/blog/backend/knowledge-cards/dead-mans-switch/" data-link-title="Dead Man&#39;s Switch（心跳監看）" data-link-desc="需要知道監控或排程工作自己停掉了、而它停掉時發不出任何告警時，以「時限內沒收到訊號就通知」反過來判斷">dead man&rsquo;s switch</a>。它的好處是外部服務不需要連進服務所在的網路，設計見 <a href="/blog/backend/04-observability/cases/watchdog-dead-mans-switch/" data-link-title="4.C19 Watchdog / dead man&#39;s switch：訊號消失即告警" data-link-desc="永遠觸發的告警交給獨立失效域的外部偵測器、它消失才代表告警管線本身死了；搭配 blackbox 外部探測互補">Watchdog / dead man&rsquo;s switch</a>。</li>
</ul>
<p>本篇的外部探測是從同一台主機上另一個容器發出的，主機整台停掉、使用者到主機的路徑中斷時它跟著停或照樣成功；探測端放在主機的失效域以外的做法見 <a href="/blog/backend/04-observability/single-host-monitoring-outside-view/" data-link-title="4.28 單台主機的監控盲區與失效域以外的觀察者：主機停機、使用者路徑中斷與告警鏈中斷，心跳監看與外部探測各自回答的問題與位置，以及與內部探測並排的判讀" data-link-desc="服務與監控放在同一台主機時監控結構上看不到的故障：主機整台停掉、使用者到主機的路徑中斷、告警鏈停掉與通知送不出去；從失效域以外送出訊號的心跳監看與外部探測各自回答的問題與成立條件，觀察者由第三方服務、另一個失效域的主機（含兩台主機互相監看）或另一個業者的排程平台提供時的差別，以及心跳監看、外部探測與主機內部探測並排時的判讀">4.28 單台主機的監控盲區與失效域以外的觀察者</a>。</p>
<p>練習或開發用的環境可以不補，但要知道這是一個沒被涵蓋的位置；儀表板全綠的意思是「最後一次檢查時全部正常」，前提是探測工具本身還在跑。</p>
<h2 id="三層的分工">三層的分工</h2>
<p>回到開頭的三種問法：容器的健康檢查告訴 Docker 每個容器自己好不好，並讓 Compose 決定啟動順序；外部探測回答從網路與入口打不打得通，並把結果集中到一個地方；指令列查詢讓人在主機上快速確認、讓腳本拿到一個可判斷的結束碼。三者都不會自己修好壞掉的服務——Compose 管理的容器不會因為 unhealthy 被重啟，探測工具只負責看與通知——自動恢復要另外設計，見 <a href="/blog/devops/operations/04-service-health/container-healthcheck/#unhealthy-%e4%b9%8b%e5%be%8c%e5%ae%b9%e5%99%a8%e9%87%8d%e5%95%9f%e7%94%b1%e4%bb%80%e9%ba%bc%e8%a7%b8%e7%99%bc" data-link-title="容器的健康檢查：Docker HEALTHCHECK 的狀態、unhealthy 之後由誰處理、depends_on 的啟動把關與 distroless 映像檔" data-link-desc="說明 Docker 的 HEALTHCHECK 與 Compose 的 healthcheck 怎麼運作：starting、healthy、unhealthy 三個狀態與時間參數的換算，容器變成 unhealthy 之後 Docker 與 restart policy 各自做什麼、不做什麼，depends_on 的 service_healthy 只在啟動時把關會帶來的連鎖失敗，以及沒有 shell 的 distroless 映像檔怎麼做健康檢查">容器的健康檢查</a> 與 <a href="/blog/devops/operations/04-service-health/process-supervisor-selection/" data-link-title="Process supervisor 選型" data-link-desc="在 systemd、supervisord、Docker restart policy、Kubernetes 之間選服務監管方式時，用平台能不能分開表達 startup、readiness、liveness、drain 當判斷標準">Process supervisor 選型</a>。三種做法回答的都是服務活不活著；服務活著而變慢、或某一層的資源快用完，要靠各層的指標與時間窗判斷，各層的訊號從哪裡收見 <a href="/blog/backend/04-observability/host-traffic-resource-monitoring/" data-link-title="4.26 單機容器服務的流量與資源監控：各層的訊號、Prometheus 與 exporter、監控所在層的盲區" data-link-desc="健康檢查之外的流量與資源監控：入口、應用程式、容器、主機與資料庫等服務內部各層的訊號從哪裡收、用哪一個 exporter，Prometheus 加 exporter、單一 agent、開箱即用工具與臨時查看指令的取捨，單次取樣與時間窗的差別，監控裝在虛擬機裡時看不到主機的 CPU 負載、看到的磁碟要分清是哪一顆，以及每接上一個來源的驗收與偵測容器停止的寫法；cAdvisor 與 node_exporter 在 OrbStack 上實測過">4.26 單機容器服務的流量與資源監控</a>。本篇的容器健康檢查與外部探測，在 <a href="/blog/backend/04-observability/monitoring-layers/" data-link-title="4.0 服務端監控的分層：容器與程序的自檢、服務網路內的探測、元件指標、應用層訊號、告警規則與分送、告警鏈的監看與使用者路徑的合成監控" data-link-desc="以每一層監控檢查的對象、看不到的範圍與接手的那一層，把服務端監控串成一條監控鏈，以及拿它盤點一個服務時怎麼判讀缺口的性質（檢查範圍、位置、讀取與訊號失真），涵蓋自架主機、Kubernetes 與雲端託管的情形；使用者路徑那一層另寫合成監控與真實使用者監測的差別，以及盤點時各種缺口的補法">4.0 服務端監控的分層</a> 分別是容器與程序的自檢、服務網路內的探測兩層；它們與元件指標、應用層訊號、告警規則與分送、告警鏈的監看、使用者路徑的合成監控各自回答什麼問題、看不到什麼，見那一篇。指令列查詢是給人臨時確認用的，不在那一篇的分層裡。</p>
]]></content:encoded></item></channel></rss>