<?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>Compose on Tarragon</title><link>https://tarrragon.github.io/blog/tags/compose/</link><description>Recent content in Compose on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 05 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/compose/index.xml" rel="self" type="application/rss+xml"/><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>