<?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>Sigterm on Tarragon</title><link>https://tarrragon.github.io/blog/tags/sigterm/</link><description>Recent content in Sigterm 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/sigterm/index.xml" rel="self" type="application/rss+xml"/><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>