<?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>Drain on Tarragon</title><link>https://tarrragon.github.io/blog/tags/drain/</link><description>Recent content in Drain on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 03 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/drain/index.xml" rel="self" type="application/rss+xml"/><item><title>擴展的觸發與縮回</title><link>https://tarrragon.github.io/blog/devops/02-horizontal-scaling/scaling-triggers/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/devops/02-horizontal-scaling/scaling-triggers/</guid><description>&lt;p>擴展的觸發與縮回，一個決定何時加實例、一個決定何時減實例，兩者的難度不對稱。擴容相對簡單——加一台機器、等它就緒、開始分流。縮回難得多，因為減一台實例前，要先把它身上的流量與在途工作安全移走，硬砍會中斷正在處理的請求。這一章講擴縮這個閉環，重點放在縮回這個常被忽略、也更容易出事的方向。什麼訊號代表系統飽和、該不該擴，是 &lt;a href="https://tarrragon.github.io/blog/devops/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷&lt;/a> 的主題，這裡承接的是「確認要擴之後，這個擴縮怎麼運作」。&lt;/p>
&lt;h2 id="擴展前先窮盡低成本手段">擴展前先窮盡低成本手段&lt;/h2>
&lt;p>加實例是有成本的手段，不該是遇到壓力的第一反應。一個健康的擴展決策，會先窮盡零成本或低成本的手段，確認真的不夠了才擴機。以讀取變慢為例，加副本或加機器之前，先做過補索引、讓儀表板改讀預聚合的摘要表、把降採樣 job 調到避開高峰時段——這些預聚合類的手段常常是「加機器前」最有效的降載，做完可能就不需要擴了。擴展的觸發原則是按觀察到的真實瓶頸行動、不按預測搶跑，且在擴機之前先把便宜的優化用盡。&lt;/p>
&lt;h2 id="觸發訊號分層各層對應不同動作">觸發訊號分層，各層對應不同動作&lt;/h2>
&lt;p>擴展的觸發不是單一訊號、單一動作，成熟的系統分層應對。本站 collector 的 ingestion 就分四層防線，每層有各自的觸發條件與動作：源頭的 SDK 在超過粒度時自動降取樣、單機的背壓與限流在寫入接近滿載時擋、水平擴展在單機 CPU 或連線飽和時加實例、佇列解耦在突發流量超過整個 collector 群的即時處理能力時插入緩衝。訊號從源頭到基礎設施逐層升級，先用便宜的層擋，擋不住才動到貴的層。&lt;/p>
&lt;p>共享儲存的擴展也有量化的觸發訊號。collector 的 SQLite 後端撞到「&lt;code>database is locked&lt;/code> 每分鐘出現一次以上」或「聚合查詢超過 3 秒」，就是該換 PostgreSQL 的訊號；PostgreSQL 撐到每秒數萬筆持續寫入或需要自動降採樣，就是該換時間序列資料庫的訊號。這些訊號的共通原則還是那條：按觀察到的瓶頸切換，不按預測提前重構。&lt;/p>
&lt;h2 id="縮回要先-drain不能硬砍">縮回要先 drain，不能硬砍&lt;/h2>
&lt;p>縮回的核心難點是實例身上還有活。減一台實例前，要走跟關閉服務同一套收束流程：先把它從負載平衡的目標裡摘掉（停止送新流量）、等它手上的在途請求處理完、再真正終止。這跟 &lt;a href="https://tarrragon.github.io/blog/devops/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">模組四 graceful shutdown&lt;/a> 是同一套機制——縮容其實就是一次有計畫的實例退場，退場的固定順序（摘流量、drain、終止）在那裡展開。硬砍一台正在處理請求的實例，那些請求全部中斷，用戶端看到的就是一批莫名其妙的失敗。&lt;/p>
&lt;p>縮回還有兩個容易出事的地方。一是縮太快造成容量不足——流量剛回落就急著縮，結果下一波又上來、新實例還沒起好。二是縮縮擴擴的抖動，訊號在門檻上下跳、實例反覆增減，這靠冷卻時間（cool-down）壓住：擴或縮之後強制等一段時間再判斷，不讓它對每個瞬間波動都反應。擴縮該選哪個訊號、冷卻時間怎麼配，在 &lt;a href="https://tarrragon.github.io/blog/devops/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷&lt;/a> 的擴縮訊號段有完整對照。&lt;/p>
&lt;p>有一種源頭的縮回不靠減實例，而靠降載。collector 的背壓 buffer 滿了就回 429 加一個 &lt;code>Retry-After&lt;/code>，SDK 收到 429 自動把取樣率從 1.0 降到 0.5、再降到 0.1；等連續成功幾十次，再逐步回升到 1.0。這是一個閉環的自動降載與恢復——不加機器、直接在源頭把進來的量壓下去，撐過尖峰再放回來。對短暫的高峰，這種源頭降載比擴實例划算得多。&lt;/p>
&lt;h2 id="該擴還是該解耦">該擴、還是該解耦&lt;/h2>
&lt;p>擴展到某個點會遇到一個判斷：繼續加實例、還是改變架構。當 collector 群已經水平擴展、仍無法即時消化突發流量時，繼續加實例的邊際效益在下降，這時該考慮插入一個佇列解耦——collector 簡化成「接收、驗證、寫進佇列、回 202」，後面的 worker 按自己的速度消化積壓，把「即時處理」換成「保證不丟、慢慢處理」。但這個決策有反向的一面：如果只是短暫的高峰，佇列的維護成本可能高於它的收益，這時回到源頭用動態取樣降量更划算。佇列解耦的完整設計在 &lt;a href="https://tarrragon.github.io/blog/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">模組七 突發流量&lt;/a> 展開，這裡的判斷點是：加實例、源頭降載、佇列解耦是三個不同成本的選項，按尖峰是短暫還是持續來選。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>什麼訊號代表系統進入飽和、該擴容 → &lt;a href="https://tarrragon.github.io/blog/devops/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷&lt;/a>&lt;/li>
&lt;li>縮回的實例退場順序、drain 怎麼做 → &lt;a href="https://tarrragon.github.io/blog/devops/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;li>佇列解耦怎麼接、突發流量的完整應對 → &lt;a href="https://tarrragon.github.io/blog/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">模組七 突發流量&lt;/a>&lt;/li>
&lt;li>垂直還是水平——擴的方向怎麼選 → &lt;a href="https://tarrragon.github.io/blog/devops/02-horizontal-scaling/vertical-vs-horizontal/" data-link-title="垂直與水平擴展的判斷" data-link-desc="決定該加 CPU 還是加實例時，用「這個元件能不能做成無狀態」當判斷樞紐，並知道有狀態的節點該用垂直撐、讀路徑用副本擴、撐不住才分片">垂直與水平擴展的判斷&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>擴展的觸發與縮回，一個決定何時加實例、一個決定何時減實例，兩者的難度不對稱。擴容相對簡單——加一台機器、等它就緒、開始分流。縮回難得多，因為減一台實例前，要先把它身上的流量與在途工作安全移走，硬砍會中斷正在處理的請求。這一章講擴縮這個閉環，重點放在縮回這個常被忽略、也更容易出事的方向。什麼訊號代表系統飽和、該不該擴，是 <a href="/blog/devops/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷</a> 的主題，這裡承接的是「確認要擴之後，這個擴縮怎麼運作」。</p>
<h2 id="擴展前先窮盡低成本手段">擴展前先窮盡低成本手段</h2>
<p>加實例是有成本的手段，不該是遇到壓力的第一反應。一個健康的擴展決策，會先窮盡零成本或低成本的手段，確認真的不夠了才擴機。以讀取變慢為例，加副本或加機器之前，先做過補索引、讓儀表板改讀預聚合的摘要表、把降採樣 job 調到避開高峰時段——這些預聚合類的手段常常是「加機器前」最有效的降載，做完可能就不需要擴了。擴展的觸發原則是按觀察到的真實瓶頸行動、不按預測搶跑，且在擴機之前先把便宜的優化用盡。</p>
<h2 id="觸發訊號分層各層對應不同動作">觸發訊號分層，各層對應不同動作</h2>
<p>擴展的觸發不是單一訊號、單一動作，成熟的系統分層應對。本站 collector 的 ingestion 就分四層防線，每層有各自的觸發條件與動作：源頭的 SDK 在超過粒度時自動降取樣、單機的背壓與限流在寫入接近滿載時擋、水平擴展在單機 CPU 或連線飽和時加實例、佇列解耦在突發流量超過整個 collector 群的即時處理能力時插入緩衝。訊號從源頭到基礎設施逐層升級，先用便宜的層擋，擋不住才動到貴的層。</p>
<p>共享儲存的擴展也有量化的觸發訊號。collector 的 SQLite 後端撞到「<code>database is locked</code> 每分鐘出現一次以上」或「聚合查詢超過 3 秒」，就是該換 PostgreSQL 的訊號；PostgreSQL 撐到每秒數萬筆持續寫入或需要自動降採樣，就是該換時間序列資料庫的訊號。這些訊號的共通原則還是那條：按觀察到的瓶頸切換，不按預測提前重構。</p>
<h2 id="縮回要先-drain不能硬砍">縮回要先 drain，不能硬砍</h2>
<p>縮回的核心難點是實例身上還有活。減一台實例前，要走跟關閉服務同一套收束流程：先把它從負載平衡的目標裡摘掉（停止送新流量）、等它手上的在途請求處理完、再真正終止。這跟 <a href="/blog/devops/04-service-health/graceful-shutdown/" data-link-title="Graceful shutdown" data-link-desc="設計服務收到停止信號後的收束流程時，釐清 SIGTERM 到 SIGKILL 的 grace period、退場的固定順序、以及不同 workload 的 drain 窗口要留多長">模組四 graceful shutdown</a> 是同一套機制——縮容其實就是一次有計畫的實例退場，退場的固定順序（摘流量、drain、終止）在那裡展開。硬砍一台正在處理請求的實例，那些請求全部中斷，用戶端看到的就是一批莫名其妙的失敗。</p>
<p>縮回還有兩個容易出事的地方。一是縮太快造成容量不足——流量剛回落就急著縮，結果下一波又上來、新實例還沒起好。二是縮縮擴擴的抖動，訊號在門檻上下跳、實例反覆增減，這靠冷卻時間（cool-down）壓住：擴或縮之後強制等一段時間再判斷，不讓它對每個瞬間波動都反應。擴縮該選哪個訊號、冷卻時間怎麼配，在 <a href="/blog/devops/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷</a> 的擴縮訊號段有完整對照。</p>
<p>有一種源頭的縮回不靠減實例，而靠降載。collector 的背壓 buffer 滿了就回 429 加一個 <code>Retry-After</code>，SDK 收到 429 自動把取樣率從 1.0 降到 0.5、再降到 0.1；等連續成功幾十次，再逐步回升到 1.0。這是一個閉環的自動降載與恢復——不加機器、直接在源頭把進來的量壓下去，撐過尖峰再放回來。對短暫的高峰，這種源頭降載比擴實例划算得多。</p>
<h2 id="該擴還是該解耦">該擴、還是該解耦</h2>
<p>擴展到某個點會遇到一個判斷：繼續加實例、還是改變架構。當 collector 群已經水平擴展、仍無法即時消化突發流量時，繼續加實例的邊際效益在下降，這時該考慮插入一個佇列解耦——collector 簡化成「接收、驗證、寫進佇列、回 202」，後面的 worker 按自己的速度消化積壓，把「即時處理」換成「保證不丟、慢慢處理」。但這個決策有反向的一面：如果只是短暫的高峰，佇列的維護成本可能高於它的收益，這時回到源頭用動態取樣降量更划算。佇列解耦的完整設計在 <a href="/blog/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">模組七 突發流量</a> 展開，這裡的判斷點是：加實例、源頭降載、佇列解耦是三個不同成本的選項，按尖峰是短暫還是持續來選。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>什麼訊號代表系統進入飽和、該擴容 → <a href="/blog/devops/05-capacity-planning/scaling-inflection-point/" data-link-title="規模拐點判斷" data-link-desc="判斷什麼訊號代表該擴容、什麼代表可縮容、以及該往垂直還水平擴時，用飽和曲線的三段區間、膝點的早期訊號與 ramp-up 方法回來讀">規模拐點判斷</a></li>
<li>縮回的實例退場順序、drain 怎麼做 → <a href="/blog/devops/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/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">模組七 突發流量</a></li>
<li>垂直還是水平——擴的方向怎麼選 → <a href="/blog/devops/02-horizontal-scaling/vertical-vs-horizontal/" data-link-title="垂直與水平擴展的判斷" data-link-desc="決定該加 CPU 還是加實例時，用「這個元件能不能做成無狀態」當判斷樞紐，並知道有狀態的節點該用垂直撐、讀路徑用副本擴、撐不住才分片">垂直與水平擴展的判斷</a></li>
</ul>
]]></content:encoded></item><item><title>Graceful shutdown</title><link>https://tarrragon.github.io/blog/devops/04-service-health/graceful-shutdown/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/devops/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/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/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/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/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/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/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/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/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/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
</ul>
]]></content:encoded></item></channel></rss>