<?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>Alertmanager on Tarragon</title><link>https://tarrragon.github.io/blog/tags/alertmanager/</link><description>Recent content in Alertmanager on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 14 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/alertmanager/index.xml" rel="self" type="application/rss+xml"/><item><title>Alertmanager 的通知管道：路由樹與分組、webhook 與 ntfy、email 與 Slack、私密網址的檔案、心跳與通知送不出去的偵測</title><link>https://tarrragon.github.io/blog/backend/04-observability/vendors/prometheus/alertmanager-notification-channels/</link><pubDate>Tue, 13 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/04-observability/vendors/prometheus/alertmanager-notification-channels/</guid><description>&lt;blockquote>
&lt;p>這一篇屬於 &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;a href="https://tarrragon.github.io/blog/backend/04-observability/vendors/prometheus/single-host-compose-stack/" data-link-title="單機 Compose 的最小監控堆疊：各 exporter 的掛載、讀取設定與 Grafana 佈建" data-link-desc="以 Docker Compose 在一台主機上接起 Prometheus、Alertmanager、Grafana 與主機、容器、資料庫、快取、入口各層的 exporter：每個掛載與旗標為什麼需要、讀取設定與規則的放法、Grafana 以檔案佈建、連接埠與帳號的權限，以及讀取對象全部 UP 時仍可能漏掉的監控">單機 Compose 的最小監控堆疊&lt;/a> 之後：那一篇的 Alertmanager 只有一個不送任何通知的 receiver，告警只出現在它的網頁上。&lt;/p>&lt;/blockquote>
&lt;p>這一篇整理讓 Alertmanager 把告警送出去的設定：路由、receiver、私密值的放法，以及告警鏈本身停掉或送不出去時怎麼發現。哪些條件該告警、依原因抑制的規則見 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/host-overload-alerting/" data-link-title="4.27 主機過載時的監控告警：可用與變慢分開判斷、依原因抑制、監控自身的等待" data-link-desc="多個服務共用一台主機而主機過載時，各服務的探測與告警會一起失敗：可用性與延遲分成兩個判斷的寫法、間歇的條件怎麼持續成立、以主機飽和抑制各服務變慢的規則與它的取捨、以合成告警驗證抑制、抑制藏起各服務受影響程度的差別、探測與 Prometheus 自己也在等 CPU 時的訊號，以及讓監控不比服務先失效的資源隔離；以一次開發環境的過載為例">主機過載時的監控告警&lt;/a>，這裡從「告警已經進到 Alertmanager」開始。文中的設定取自一個短網址服務的監控（Alertmanager 0.34.1、Prometheus 3.15.0），通知服務在本機以 ntfy 2.30 與 Mailpit（攔截郵件的測試用 SMTP 伺服器）的容器代替，文中的時間與行為都是在這套本機環境裡量到的。文中的「合成告警」指以 Alertmanager 的 API 直接送進去、只帶幾個標籤的測試告警，用來在不停掉任何服務的情況下觀察通知。&lt;/p>
&lt;h2 id="路由樹一則告警送到哪個-receiver">路由樹：一則告警送到哪個 receiver&lt;/h2>
&lt;p>receiver 是一組通知設定的名字，裡面寫要送到哪裡（ntfy、信箱、Slack 頻道）；route 決定哪些告警送到哪個 receiver。route 是一棵樹：最上層的 route 接住全部告警，底下的子路由各自以 &lt;code>matchers&lt;/code> 比對告警的標籤，Alertmanager 由上往下比，告警落在第一個符合的子路由；一條都不符合的留在最上層，送到最上層的 receiver。子路由加上 &lt;code>continue: true&lt;/code> 時，符合之後還會繼續往下比，同一則告警可以同時送到兩條符合的子路由；後面的子路由都不符合時，它不會再回到最上層的 receiver。&lt;/p>
&lt;p>下面的設定依嚴重度分兩條路，另外替心跳開一條。心跳是一條永遠成立的 &lt;code>Watchdog&lt;/code> 告警，持續送到監控主機以外的心跳監看服務（接收心跳、在心跳中斷時發出通知的外部服務，例如 healthchecks.io），用來證明告警鏈還在運作，規則與心跳監看服務的設定見〈Watchdog：告警鏈本身的心跳〉：&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">route&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">receiver&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ntfy-warning &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 沒有被子路由接走的（warning）送到這裡&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">group_by&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">alertname, host] &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">group_wait&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">group_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5m&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">repeat_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">12h&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routes&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"> 8&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">alertname=&amp;#34;Watchdog&amp;#34;]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">receiver&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">heartbeat&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">group_by&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">alertname]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">group_wait&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">0s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">group_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">1m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">repeat_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">matchers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">severity=&amp;#34;critical&amp;#34;]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">receiver&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ntfy-critical&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">repeat_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">2h &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">17&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">receivers&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">19&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">ntfy-critical&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">webhook_configs&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">21&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">url_file&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/etc/alertmanager/secrets/ntfy-critical.url&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&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">ntfy-warning&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">webhook_configs&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">24&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">url_file&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/etc/alertmanager/secrets/ntfy-warning.url&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&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">heartbeat&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">webhook_configs&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">27&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">url_file&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/etc/alertmanager/secrets/heartbeat.url&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">send_resolved&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Watchdog&lt;/code> 那一條排在 &lt;code>severity=&amp;quot;critical&amp;quot;&lt;/code> 之前，因為比對按順序進行；心跳的 &lt;code>severity&lt;/code> 是 &lt;code>none&lt;/code>，在這份設定裡不會被 critical 那條子路由接走，但把最特定的條件排在前面，之後改標籤時不必回頭檢查順序。&lt;/p>
&lt;p>路由寫完，用 &lt;code>amtool&lt;/code> 確認一組標籤會落到哪個 receiver，不必等告警實際發生：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 在放 alertmanager.yml 的目錄底下執行；標籤是要測的告警帶的標籤，每個標籤一個參數&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">docker run --rm -v &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PWD&lt;/span>&lt;span class="s2">:/a&amp;#34;&lt;/span> --entrypoint amtool prom/alertmanager:v0.34.1 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> config routes &lt;span class="nb">test&lt;/span> --config.file&lt;span class="o">=&lt;/span>/a/alertmanager.yml &lt;span class="nv">severity&lt;/span>&lt;span class="o">=&lt;/span>critical &lt;span class="nv">alertname&lt;/span>&lt;span class="o">=&lt;/span>HostDown&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>上面的設定測三組，&lt;code>severity=critical alertname=HostDown&lt;/code> 回 &lt;code>ntfy-critical&lt;/code>，&lt;code>severity=warning alertname=HostDiskLow&lt;/code> 回 &lt;code>ntfy-warning&lt;/code>，&lt;code>alertname=Watchdog severity=none&lt;/code> 回 &lt;code>heartbeat&lt;/code>。amtool 把每一個參數當成一個標籤比對，所以標籤要拆成各自的參數；整串標籤被當成一個參數時（例如放進 zsh 的變數再展開，zsh 不切字），路由一律落到最上層，同時 stderr 印出 matchers 解析器的警告。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>這一篇屬於 <a href="/blog/backend/04-observability/vendors/prometheus/" data-link-title="Prometheus" data-link-desc="Pull-based metrics 主流 OSS、PromQL 與 alerting">Prometheus</a> 的實作系列，接在 <a href="/blog/backend/04-observability/vendors/prometheus/single-host-compose-stack/" data-link-title="單機 Compose 的最小監控堆疊：各 exporter 的掛載、讀取設定與 Grafana 佈建" data-link-desc="以 Docker Compose 在一台主機上接起 Prometheus、Alertmanager、Grafana 與主機、容器、資料庫、快取、入口各層的 exporter：每個掛載與旗標為什麼需要、讀取設定與規則的放法、Grafana 以檔案佈建、連接埠與帳號的權限，以及讀取對象全部 UP 時仍可能漏掉的監控">單機 Compose 的最小監控堆疊</a> 之後：那一篇的 Alertmanager 只有一個不送任何通知的 receiver，告警只出現在它的網頁上。</p></blockquote>
<p>這一篇整理讓 Alertmanager 把告警送出去的設定：路由、receiver、私密值的放法，以及告警鏈本身停掉或送不出去時怎麼發現。哪些條件該告警、依原因抑制的規則見 <a href="/blog/backend/04-observability/host-overload-alerting/" data-link-title="4.27 主機過載時的監控告警：可用與變慢分開判斷、依原因抑制、監控自身的等待" data-link-desc="多個服務共用一台主機而主機過載時，各服務的探測與告警會一起失敗：可用性與延遲分成兩個判斷的寫法、間歇的條件怎麼持續成立、以主機飽和抑制各服務變慢的規則與它的取捨、以合成告警驗證抑制、抑制藏起各服務受影響程度的差別、探測與 Prometheus 自己也在等 CPU 時的訊號，以及讓監控不比服務先失效的資源隔離；以一次開發環境的過載為例">主機過載時的監控告警</a>，這裡從「告警已經進到 Alertmanager」開始。文中的設定取自一個短網址服務的監控（Alertmanager 0.34.1、Prometheus 3.15.0），通知服務在本機以 ntfy 2.30 與 Mailpit（攔截郵件的測試用 SMTP 伺服器）的容器代替，文中的時間與行為都是在這套本機環境裡量到的。文中的「合成告警」指以 Alertmanager 的 API 直接送進去、只帶幾個標籤的測試告警，用來在不停掉任何服務的情況下觀察通知。</p>
<h2 id="路由樹一則告警送到哪個-receiver">路由樹：一則告警送到哪個 receiver</h2>
<p>receiver 是一組通知設定的名字，裡面寫要送到哪裡（ntfy、信箱、Slack 頻道）；route 決定哪些告警送到哪個 receiver。route 是一棵樹：最上層的 route 接住全部告警，底下的子路由各自以 <code>matchers</code> 比對告警的標籤，Alertmanager 由上往下比，告警落在第一個符合的子路由；一條都不符合的留在最上層，送到最上層的 receiver。子路由加上 <code>continue: true</code> 時，符合之後還會繼續往下比，同一則告警可以同時送到兩條符合的子路由；後面的子路由都不符合時，它不會再回到最上層的 receiver。</p>
<p>下面的設定依嚴重度分兩條路，另外替心跳開一條。心跳是一條永遠成立的 <code>Watchdog</code> 告警，持續送到監控主機以外的心跳監看服務（接收心跳、在心跳中斷時發出通知的外部服務，例如 healthchecks.io），用來證明告警鏈還在運作，規則與心跳監看服務的設定見〈Watchdog：告警鏈本身的心跳〉：</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">route</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">receiver</span><span class="p">:</span><span class="w"> </span><span class="l">ntfy-warning         </span><span class="w"> </span><span class="c"># 沒有被子路由接走的（warning）送到這裡</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">group_by</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">alertname, host]    </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">group_wait</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">group_interval</span><span class="p">:</span><span class="w"> </span><span class="l">5m</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">repeat_interval</span><span class="p">:</span><span class="w"> </span><span class="l">12h</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="nt">routes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span>- <span class="nt">matchers</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">alertname=&#34;Watchdog&#34;]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span><span class="nt">receiver</span><span class="p">:</span><span class="w"> </span><span class="l">heartbeat</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span><span class="nt">group_by</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">alertname]</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span><span class="nt">group_wait</span><span class="p">:</span><span class="w"> </span><span class="l">0s</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">group_interval</span><span class="p">:</span><span class="w"> </span><span class="l">1m</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span><span class="nt">repeat_interval</span><span class="p">:</span><span class="w"> </span><span class="l">5m</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span>- <span class="nt">matchers</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">severity=&#34;critical&#34;]</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span><span class="nt">receiver</span><span class="p">:</span><span class="w"> </span><span class="l">ntfy-critical</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">      </span><span class="nt">repeat_interval</span><span class="p">:</span><span class="w"> </span><span class="l">2h         </span><span class="w"> </span><span class="c"># 沒寫的參數沿用上層的值</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w"></span><span class="nt">receivers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">ntfy-critical</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">    </span><span class="nt">webhook_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">      </span>- <span class="nt">url_file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/alertmanager/secrets/ntfy-critical.url</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">ntfy-warning</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">    </span><span class="nt">webhook_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">      </span>- <span class="nt">url_file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/alertmanager/secrets/ntfy-warning.url</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">heartbeat</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">    </span><span class="nt">webhook_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w">      </span>- <span class="nt">url_file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/alertmanager/secrets/heartbeat.url</span><span class="w">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="w">        </span><span class="nt">send_resolved</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span></span></span></code></pre></div><p><code>Watchdog</code> 那一條排在 <code>severity=&quot;critical&quot;</code> 之前，因為比對按順序進行；心跳的 <code>severity</code> 是 <code>none</code>，在這份設定裡不會被 critical 那條子路由接走，但把最特定的條件排在前面，之後改標籤時不必回頭檢查順序。</p>
<p>路由寫完，用 <code>amtool</code> 確認一組標籤會落到哪個 receiver，不必等告警實際發生：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在放 alertmanager.yml 的目錄底下執行；標籤是要測的告警帶的標籤，每個標籤一個參數</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run --rm -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">:/a&#34;</span> --entrypoint amtool prom/alertmanager:v0.34.1 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  config routes <span class="nb">test</span> --config.file<span class="o">=</span>/a/alertmanager.yml <span class="nv">severity</span><span class="o">=</span>critical <span class="nv">alertname</span><span class="o">=</span>HostDown</span></span></code></pre></div><p>上面的設定測三組，<code>severity=critical alertname=HostDown</code> 回 <code>ntfy-critical</code>，<code>severity=warning alertname=HostDiskLow</code> 回 <code>ntfy-warning</code>，<code>alertname=Watchdog severity=none</code> 回 <code>heartbeat</code>。amtool 把每一個參數當成一個標籤比對，所以標籤要拆成各自的參數；整串標籤被當成一個參數時（例如放進 zsh 的變數再展開，zsh 不切字），路由一律落到最上層，同時 stderr 印出 matchers 解析器的警告。</p>
<h2 id="分組與-group_waitgroup_intervalrepeat_interval">分組與 group_wait、group_interval、repeat_interval</h2>
<p><code>group_by</code> 列出的標籤值相同的告警屬於同一組，一組送一則通知。下文的數字取自〈路由樹：一則告警送到哪個 receiver〉那份設定。三個時間參數都以組為單位：</p>
<ul>
<li><code>group_wait</code>：一組的第一則告警進來之後，等多久才送第一次通知。等待期間陸續成立的同組告警合進同一則，一台主機停掉時同時成立的好幾條規則不會變成好幾則推播。實測合成告警送進去之後 30 秒送出。</li>
<li><code>group_interval</code>：一組已經通知過之後，組裡有變化（新的告警加入、有告警解除）時，距離上次通知至少隔多久才再送。解除的通知因此最晚在恢復之後一個 <code>group_interval</code>（這份設定是 5 分鐘）才到：實測停掉前台容器再重新啟動之後 5 分鐘收到解除。</li>
<li><code>repeat_interval</code>：組裡沒有變化、告警一直成立時，隔多久再提醒一次。critical 設 2 小時、warning 設 12 小時，一直沒處理的問題不會被遺忘，也不會每 5 分鐘響一次。</li>
</ul>
<p><code>group_wait</code> 30 秒與 <code>group_interval</code> 5 分鐘是 Alertmanager 的預設值（<code>repeat_interval</code> 的預設是 4 小時）；2 小時與 12 小時是這個服務依值班的回應時間選的。重送的計時與靜音存在 Alertmanager 的 <code>--storage.path</code> 目錄裡，這個目錄沒有掛 volume 時，容器重建之後所有成立中的組會立刻重送一次，靜音也會消失。</p>
<p>critical 與 warning 分成兩個 topic，是讓只想收 critical 的人只訂一個，也讓手機上可以單獨把 warning 的 topic 設成不響（這是訂閱端的設定，不是 Alertmanager 的 <a href="/blog/backend/knowledge-cards/alert-silence/" data-link-title="Alert Silence（告警靜音）" data-link-desc="維護期間要暫停某些告警的通知、或懷疑告警該響而沒響時，查 Alertmanager 的 silence 做什麼、它與抑制及週期性靜音時段的差別、它過期之前會藏住什麼，以及怎麼列出與監看還沒過期的靜音">silence</a>）。這份設定把 warning 全天推送、以較低的優先度降低打擾；<a href="/blog/backend/04-observability/dashboard-alert/" data-link-title="4.4 dashboard 與 alert 設計" data-link-desc="讓 dashboard 與 alert 對應 runbook 與容量趨勢">4.4 dashboard 與 alert 設計</a> 把 warning 定義成工作時間處理，要照那個定義，以 Alertmanager 的 <code>time_intervals</code> 定義工作時段，再替 warning 開一條帶 <code>active_time_intervals</code> 的子路由（warning 原本由最上層的 receiver 接走，時段要掛在子路由上）：</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">time_intervals</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">office-hours</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">time_intervals</span><span class="p">:</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">weekdays</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;monday:friday&#39;</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">times</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>{<span class="nt">start_time: &#39;09:00&#39;, end_time</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;18:00&#39;</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="nt">location</span><span class="p">:</span><span class="w"> </span><span class="l">Asia/Taipei       </span><span class="w"> </span><span class="c"># 時段依這個時區解讀；不寫時是 UTC</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="nt">route</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">routes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="c"># ……Watchdog 與 critical 兩條在前，順序同〈路由樹：一則告警送到哪個 receiver〉</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span>- <span class="nt">matchers</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">severity=&#34;warning&#34;]</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span><span class="nt">receiver</span><span class="p">:</span><span class="w"> </span><span class="l">ntfy-warning</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">active_time_intervals</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">office-hours]</span></span></span></code></pre></div><p>實測時段以外的 warning 不送出，告警仍留在 Alertmanager 的告警清單裡（<code>amtool alert query</code> 列得出來），進入時段之後才送。<code>weekdays</code> 的範圍依星期日到星期六的順序寫，要寫週末時寫成兩個值 <code>['saturday', 'sunday']</code>；寫成 <code>'saturday:sunday'</code> 時 Alertmanager 以 <code>start day cannot be before end day</code> 拒絕載入設定，容器直接退出。<code>severity</code> 是 <code>info</code> 或 <code>none</code> 的告警（Watchdog 以外）沒有子路由接走時會落到最上層的 warning，要另開一條送到不通知的 receiver。</p>
<p>把 receiver 從不送通知的換成實際送出通知的那一個時，已經在 Alertmanager 裡的告警會馬上補送：實測重新載入設定之後 3 秒，兩則原本就成立的 warning（磁碟剩餘空間、探測變慢）送到了 ntfy。第一次接上通知時，手機會先收到一批既有的告警。</p>
<h2 id="webhookalertmanager-原生支援以外的通知服務">webhook：Alertmanager 原生支援以外的通知服務</h2>
<p>Alertmanager 0.34 原生支援的通知服務有 email、Slack、Microsoft Teams、Discord、Telegram、PagerDuty、Opsgenie、Pushover、Amazon SNS、Webex、Mattermost、Rocket.Chat、Jira、incident.io、VictorOps 與企業微信，各自是一種 <code>*_configs</code>。清單以外的服務走 <code>webhook_configs</code>（<a href="/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook</a>）：Alertmanager 預設對一個網址 POST 一份固定格式的 JSON，由對方決定怎麼呈現（0.34 另有自訂內容的 <code>payload</code> 欄位，官方標註自行承擔風險，這裡不用）。所以對方要讀得懂這份 JSON：ntfy 以訊息範本讀它（見下一節），自己寫的轉接程式也可以。只接受自己格式的 API（例如 LINE 的 Messaging API）不能直接填成 webhook 的網址，中間要放一個轉接服務，把這份 JSON 轉成對方的格式。</p>
<p>這份 JSON 以組為單位，一次通知一份。頂層的 <code>status</code> 是整組的狀態，組裡還有任何一則在成立就是 <code>firing</code>，全部解除才是 <code>resolved</code>；<code>groupLabels</code> 是分組用的標籤，<code>commonLabels</code> 與 <code>commonAnnotations</code> 是組裡每一則都相同的標籤與註解（annotations）；<code>alerts</code> 是組裡每一則告警，各自帶 <code>status</code>、<code>labels</code>、<code>annotations</code> 與開始、結束的時間。組裡有的告警已經解除、有的還在成立時，整組的 <code>status</code> 仍是 <code>firing</code>，而 <code>alerts</code> 裡混著兩種，要只列出還在成立的就得逐則檢查各自的 <code>status</code>。</p>
<h2 id="以-ntfy-推播到手機">以 ntfy 推播到手機</h2>
<p>ntfy 是一個以 HTTP 發佈、以 App 訂閱的推播服務：對 <code>https://ntfy.sh/&lt;topic&gt;</code> POST 一段文字，訂閱了這個 topic 的手機就收到一則通知。可以用公開的 ntfy.sh，也可以自己架。告警的標題與內容帶著主機名與錯誤描述送到 ntfy 的伺服器，資料不能離開組織環境時用自架的 ntfy，心跳監看服務同理。運作方式與兩者的差別見 <a href="/blog/linux/debug/ntfy-push-notification-service/" data-link-title="ntfy：推送通知服務怎麼運作、公共站 vs 自架" data-link-desc="用 ntfy 把機器的告警推到手機、想搞懂它是誰維護的開源服務、topic 的安全模型、以及該用公共站還是自架時回來讀">ntfy：推送通知服務怎麼運作、公共站 vs 自架</a>。Alertmanager 沒有原生的 ntfy 支援，所以以 webhook 送過去；webhook 送的是 JSON，Alertmanager 直接把它 POST 到 topic 時，手機上看到的是一整段 JSON。</p>
<p>ntfy 的訊息範本把 JSON 轉成可讀的通知：網址加上 <code>tpl=yes</code>，ntfy 把收到的 JSON 當成資料，以 Go 的範本語法產生標題（<code>t</code>）、內容（<code>m</code>）與優先度（<code>p</code>）。critical 用的範本如下，為了閱讀分行：</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">https://ntfy.sh/&lt;topic&gt;?tpl=yes
</span></span><span class="line"><span class="ln">2</span><span class="cl">  &amp;t=[{{.status}}] {{.commonLabels.alertname}} {{.commonLabels.host}}
</span></span><span class="line"><span class="ln">3</span><span class="cl">  &amp;m={{if eq .status &#34;resolved&#34;}}已解除{{else}}{{range .alerts}}{{if eq .status &#34;firing&#34;}}{{.annotations.summary}}\n{{end}}{{end}}{{end}}
</span></span><span class="line"><span class="ln">4</span><span class="cl">  &amp;p={{if eq .status &#34;resolved&#34;}}default{{else}}urgent{{end}}</span></span></code></pre></div><p>這三個範本的作用：</p>
<ul>
<li><strong>標題</strong>寫狀態、規則名稱與主機，鎖定畫面上只看標題就知道是哪台主機的哪一種問題，例如 <code>[firing] HostDown app-2</code>。</li>
<li><strong>內容</strong>在成立時逐一列出組裡還在成立的告警的 <code>summary</code>；解除時寫「已解除」。解除時不沿用 <code>summary</code>，因為 <code>summary</code> 是告警最後一次成立時算出的文字，實測解除通知裡寫著「portal 已有 2m 55s 沒被看到」，讀起來像問題還在。</li>
<li><strong>優先度</strong>依狀態切換。ntfy 的優先度決定手機的提示強度：<code>urgent</code>（5）長時間震動並跳出橫幅，<code>default</code>（3）短震動加提示音，<code>low</code>（2）不震動也不出聲。只用固定的 <code>priority=urgent</code> 時，解除的通知也以最高強度跳出；實測換成範本之後，成立時是 5、解除時是 3。warning 的網址把 <code>p</code> 的範本改成成立時 <code>default</code>、解除時 <code>low</code>，所以 warning 成立時手機仍會短震動並出聲。</li>
</ul>
<p>實際寫進檔案的網址是一行，每個參數都經過 URL 編碼。編碼有兩個作用：網址裡不能直接放空白、引號、中文這些字元；而 Alertmanager 0.34 會先以自己的範本語法處理 <code>url</code> 與 <code>url_file</code> 的內容，沒編碼的 <code>{{.status}}</code> 會被它先解析而送出失敗或變成 <code>&lt;no value&gt;</code>，編碼成 <code>%7B%7B</code> 之後才原樣送到 ntfy。手寫編碼容易漏字元，以腳本產生比較可靠：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># ntfy_url.py：印出 ntfy 的網址</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"># 用法：python3 ntfy_url.py &lt;伺服器網址與 topic&gt; &lt;成立時的優先度&gt; &lt;解除時的優先度&gt;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">from</span> <span class="nn">urllib.parse</span> <span class="kn">import</span> <span class="n">quote_plus</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">base</span><span class="p">,</span> <span class="n">firing_p</span><span class="p">,</span> <span class="n">resolved_p</span> <span class="o">=</span> <span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:</span><span class="mi">4</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">t</span> <span class="o">=</span> <span class="s2">&#34;[{{.status}}] {{.commonLabels.alertname}} {{.commonLabels.host}}&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">m</span> <span class="o">=</span> <span class="p">(</span><span class="s1">&#39;{{if eq .status &#34;resolved&#34;}}已解除{{else}}{{range .alerts}}&#39;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">     <span class="s1">&#39;{{if eq .status &#34;firing&#34;}}{{.annotations.summary}}</span><span class="se">\n</span><span class="s1">{{end}}{{end}}{{end}}&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">p</span> <span class="o">=</span> <span class="s1">&#39;{{if eq .status &#34;resolved&#34;}}&#39;</span> <span class="o">+</span> <span class="n">resolved_p</span> <span class="o">+</span> <span class="s1">&#39;{{else}}&#39;</span> <span class="o">+</span> <span class="n">firing_p</span> <span class="o">+</span> <span class="s1">&#39;{{end}}&#39;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">base</span><span class="si">}</span><span class="s2">?tpl=yes&amp;t=</span><span class="si">{</span><span class="n">quote_plus</span><span class="p">(</span><span class="n">t</span><span class="p">)</span><span class="si">}</span><span class="s2">&amp;m=</span><span class="si">{</span><span class="n">quote_plus</span><span class="p">(</span><span class="n">m</span><span class="p">)</span><span class="si">}</span><span class="s2">&amp;p=</span><span class="si">{</span><span class="n">quote_plus</span><span class="p">(</span><span class="n">p</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">end</span><span class="o">=</span><span class="s2">&#34;&#34;</span><span class="p">)</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 第一個引數換成自己的 ntfy 伺服器與 topic；輸出的路徑換成自己放私密檔的目錄</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">python3 ntfy_url.py https://ntfy.sh/&lt;topic&gt; urgent default &gt; secrets/ntfy-critical.url
</span></span><span class="line"><span class="ln">3</span><span class="cl">python3 ntfy_url.py https://ntfy.sh/&lt;topic&gt; default low &gt; secrets/ntfy-warning.url</span></span></code></pre></div><p>範本裡的欄位名稱是 JSON 的鍵，小寫開頭（<code>.status</code>、<code>.commonLabels</code>）；Alertmanager 自己的通知範本（例如 Slack 的 <code>title</code>）用的是大寫開頭的 <code>.Status</code>、<code>.CommonLabels</code>，兩者在同一份設定的不同位置出現時不能互抄。<code>p</code> 的範本在 ntfy 2.30 上實測可用。ntfy 也內建一個 <code>template=alertmanager</code>，開箱即用，而它的標題帶 emoji、內容讀 <code>instance</code> 標籤；以 <code>host</code> 區分主機的設定裡，那一欄顯示 <code>&lt;no value&gt;</code>。</p>
<p>ntfy 的 topic 名稱本身就是讀取權限：公開的 ntfy.sh 上，知道 topic 名稱的人都能訂閱，收到這些告警。所以 topic 取一個猜不到的長名稱，而且不進版本控制，放法見〈私密網址的存放：設定檔不展開環境變數，改用讀檔案的 <code>*_file</code> 欄位〉。</p>
<h2 id="私密網址的存放設定檔不展開環境變數改用讀檔案的-_file-欄位">私密網址的存放：設定檔不展開環境變數，改用讀檔案的 <code>*_file</code> 欄位</h2>
<p>Alertmanager 的設定檔不展開環境變數，<code>${NTFY_TOPIC}</code> 會原封不動地被當成網址的一部分。含有私密值的欄位因此各有一個讀檔案的版本：webhook 的 <code>url_file</code>、email 的 <code>smtp_auth_password_file</code>、Slack 的 <code>api_url_file</code>。設定檔進版本控制，私密的檔案放在一個不進版本控制的目錄。<a href="/blog/backend/04-observability/vendors/prometheus/single-host-compose-stack/" data-link-title="單機 Compose 的最小監控堆疊：各 exporter 的掛載、讀取設定與 Grafana 佈建" data-link-desc="以 Docker Compose 在一台主機上接起 Prometheus、Alertmanager、Grafana 與主機、容器、資料庫、快取、入口各層的 exporter：每個掛載與旗標為什麼需要、讀取設定與規則的放法、Grafana 以檔案佈建、連接埠與帳號的權限，以及讀取對象全部 UP 時仍可能漏掉的監控">單機 Compose 的最小監控堆疊</a> 把整個 <code>infra/observability/alertmanager</code> 目錄掛到容器的 <code>/etc/alertmanager</code>，所以私密的檔案放在它底下的 <code>secrets/</code>，容器裡就出現在 <code>/etc/alertmanager/secrets/</code>；只掛單一設定檔的寫法要另加一行掛這個目錄。</p>
<p><code>secrets/</code> 列進 <code>.gitignore</code>，旁邊提交一份 <code>secrets.example/</code>，內容是把 topic 換成 <code>CHANGE-ME</code> 的網址。範例裡的範本已經編碼好，部署時複製一份、只換 topic，不必重新產生網址。</p>
<p><code>url_file</code> 在每次送通知時才讀：實測改了檔案內容之後，下一則通知就用新的網址，不必重新載入 Alertmanager。檔案不存在時，<code>amtool check-config</code> 照樣通過、Alertmanager 照樣啟動，失敗發生在送通知的那一刻，要靠下方〈通知送不出去的偵測〉發現。</p>
<h2 id="email-與-slack">email 與 Slack</h2>
<p>下面兩段的 receiver 併進主設定裡已有的 <code>receivers</code> 清單，不能整段附加在檔案後面：同一份設定出現兩個 <code>receivers</code> 時，<code>amtool check-config</code> 回 <code>field receivers already set</code>。receiver 也要有一條子路由指到它才會被用到，例子見〈通知送不出去的偵測〉裡送到 <code>mail</code> 的子路由。</p>
<p>email 的 SMTP 伺服器寫在 <code>global</code>，所有 <code>email_configs</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">global</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">smtp_smarthost</span><span class="p">:</span><span class="w"> </span><span class="l">smtp.example.com:587      </span><span class="w"> </span><span class="c"># SMTP 伺服器與連接埠</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">smtp_from</span><span class="p">:</span><span class="w"> </span><span class="l">alertmanager@example.com</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">smtp_auth_username</span><span class="p">:</span><span class="w"> </span><span class="l">alertmanager</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">smtp_auth_password_file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/alertmanager/secrets/smtp-password</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">receivers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">mail</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">email_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">to</span><span class="p">:</span><span class="w"> </span><span class="l">oncall@example.com</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">        </span><span class="nt">send_resolved</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">                 </span><span class="c"># email 預設不送解除的通知</span></span></span></code></pre></div><p><code>smtp_require_tls</code> 預設是 <code>true</code>，伺服器不支援 STARTTLS 時 Alertmanager 拒絕送出；本機以 Mailpit 測試時設成 <code>false</code>，一則只帶 <code>alertname</code>、<code>host</code>、<code>severity</code> 的合成告警以 <code>[FIRING:1] HostDiskLow app-1 (warning)</code> 為主旨送達：主旨依序是狀態與告警數、分組標籤的值，括號裡是組內共同而不屬於分組的標籤值，所以帶著更多標籤的真實告警，括號裡會多出那些值。解除的通知是否送出，各通知類型（integration，例如 webhook、email）的預設不同：webhook 預設送，email 與 Slack 預設不送，要寫 <code>send_resolved: true</code>。真正的 SMTP 伺服器這裡沒有測過。</p>
<p>Slack 用 incoming webhook 的網址，標題與內容用 Alertmanager 自己的範本語法：</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">receivers</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">slack</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">slack_configs</span><span class="p">:</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">api_url_file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/alertmanager/secrets/slack-webhook.url</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">channel</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;#alerts&#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="nt">send_resolved</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w">        </span><span class="nt">title</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;[{{ .Status }}] {{ .CommonLabels.alertname }} {{ .CommonLabels.host }}&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w">        </span><span class="nt">text</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;{{ if eq .Status &#34;resolved&#34; }}已解除{{ else }}{{ range .Alerts.Firing }}{{ .Annotations.summary }}{{ &#34;\n&#34; }}{{ end }}{{ end }}&#39;</span></span></span></code></pre></div><p><code>.Alerts.Firing</code> 只取組裡還在成立的告警，作用等同 ntfy 那一段範本裡逐則檢查 <code>status</code> 的寫法；整組解除時它是空的，所以和 ntfy 一樣另外寫「已解除」。這一段只以 <code>amtool check-config</code> 檢查過語法，沒有實際送到 Slack。</p>
<h2 id="本機測試自架的-ntfymailpit-與合成告警">本機測試：自架的 ntfy、Mailpit 與合成告警</h2>
<p>真實的 topic 與 SMTP 伺服器還沒準備好時，路由、ntfy 範本與 email 主旨都可以在本機確認：ntfy 與 Mailpit（一個收下所有郵件、以網頁與 API 顯示的測試用 SMTP 伺服器）各跑一個容器，和 Alertmanager 放在同一個 compose 網路裡。告警的內容留在這台電腦上，收到的推播以 ntfy 的 API 讀出，手機不必訂閱 topic。這兩個服務以一個只在測試時加入的 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="c"># compose.notify-test.yml；測試時把它加進 .env 的 COMPOSE_FILE，測完拿掉</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">services</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">ntfy</span><span class="p">:</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">image</span><span class="p">:</span><span class="w"> </span><span class="l">binwiederhier/ntfy:v2.30.0</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">command</span><span class="p">:</span><span class="w"> </span><span class="l">serve</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">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;127.0.0.1:8090:80&#34;</span><span class="w">      </span><span class="c"># 本機讀 ntfy 收到的訊息用</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">  </span><span class="nt">mailpit</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">axllent/mailpit:v1.31.5</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;127.0.0.1:8025:8025&#34;</span><span class="w">    </span><span class="c"># Mailpit 的網頁與 API</span></span></span></code></pre></div><p>Alertmanager 以服務名稱連到兩者：ntfy 的網址以 <code>ntfy_url.py</code> 產生、伺服器寫 <code>http://ntfy/&lt;topic&gt;</code>（例如 <code>python3 ntfy_url.py http://ntfy/alerts-critical urgent default &gt; secrets/ntfy-critical.url</code>），email 那一段的 <code>global</code> 寫 <code>smtp_smarthost: mailpit:1025</code> 與 <code>smtp_require_tls: false</code>。這兩處是測試用的值，換回真實的 topic 與 SMTP 伺服器時一起改掉。</p>
<p>合成告警直接送進 Alertmanager，不經過 Prometheus 的規則：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 標籤換成要測的路由會比對的標籤；summary 有空白或中文時，值要以雙引號包起來</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker compose <span class="nb">exec</span> alertmanager amtool alert add --alertmanager.url<span class="o">=</span>http://localhost:9093 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  <span class="nv">alertname</span><span class="o">=</span>HostDiskLow <span class="nv">host</span><span class="o">=</span>app-1 <span class="nv">severity</span><span class="o">=</span>critical --annotation<span class="o">=</span><span class="s1">&#39;summary=&#34;app-1 的磁碟剩 5%&#34;&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 不經過容器時，對 Alertmanager 的 API 送同樣的內容（本機發佈的連接埠換成自己的）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">curl -XPOST -H <span class="s1">&#39;content-type: application/json&#39;</span> http://127.0.0.1:9093/api/v2/alerts <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  -d <span class="s1">&#39;[{&#34;labels&#34;:{&#34;alertname&#34;:&#34;HostDiskLow&#34;,&#34;host&#34;:&#34;app-1&#34;,&#34;severity&#34;:&#34;critical&#34;},&#34;annotations&#34;:{&#34;summary&#34;:&#34;app-1 的磁碟剩 5%&#34;}}]&#39;</span></span></span></code></pre></div><p><code>summary</code> 的值沒有雙引號時告警照樣建立，而 amtool 印出一段 matchers 解析器改版的警告，說它退回舊的解析方式。送出之後過一個 <code>group_wait</code>，以 <code>curl -s 'http://127.0.0.1:8090/alerts-critical/json?poll=1'</code> 讀 ntfy 收到的訊息（<code>alerts-critical</code> 換成 topic 名稱），每一則是一行 JSON，<code>title</code>、<code>message</code>、<code>priority</code> 三個欄位就是手機上會看到的標題、內容與提示強度；email 在 Mailpit 的網頁 <code>http://127.0.0.1:8025</code> 看。實測以 Alertmanager 0.34.1 送出上面那一則，30 秒後 ntfy 收到標題 <code>[firing] HostDiskLow app-1</code>、內容 <code>app-1 的磁碟剩 5%</code>、優先度 5 的訊息；一則帶著〈通知送不出去的偵測〉那條規則標籤的 <code>AlertmanagerNotificationsFailing</code>（<code>host=mon</code>、<code>integration=webhook</code>）經 <code>mail</code> receiver 送到 Mailpit，主旨是 <code>[FIRING:1] AlertmanagerNotificationsFailing mon (webhook monitoring warning)</code>。</p>
<h2 id="watchdog告警鏈本身的心跳">Watchdog：告警鏈本身的心跳</h2>
<p>ntfy、email 與 Slack 的通知全部要 Prometheus 在評估規則、Alertmanager 在送通知才發得出來。Prometheus 停掉、Alertmanager 停掉、整台監控主機停電時，沒有任何告警成立，手機上什麼都沒有，和一切正常時看起來一樣。這種故障要由監控主機以外的地方發現：讓告警鏈持續送出一個「我還活著」的訊號，由外部的服務在訊號停止時通知（這類從監控主機以外發出的訊號叫 <a href="/blog/backend/knowledge-cards/out-of-band-signal/" data-link-title="Out-of-Band Signal（頻外訊號）" data-link-desc="觀測系統本身也可能在事故中失能時、判斷該從哪個獨立通道取得系統存活狀態">頻外訊號</a>，判斷方式見 <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>）。這個做法叫 dead man&rsquo;s switch，背後的推導見 <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>。</p>
<p>訊號是一條永遠成立的規則：</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">alert</span><span class="p">:</span><span class="w"> </span><span class="l">Watchdog</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">expr</span><span class="p">:</span><span class="w"> </span><span class="l">vector(1)                </span><span class="w"> </span><span class="c"># 沒有任何輸入也成立</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">labels</span><span class="p">:</span><span class="w"> </span>{<span class="nt">severity: none, scope</span><span class="p">:</span><span class="w"> </span><span class="l">monitoring}</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">annotations</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">summary</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;告警鏈的心跳：Prometheus 在評估規則、Alertmanager 在送通知&#34;</span></span></span></code></pre></div><p>心跳監看服務（例如 healthchecks.io）替每一個檢查發一個 ping 網址，收到一個請求就算一次心跳。這個網址寫進 <code>heartbeat.url</code>，Alertmanager 以 webhook 對它 POST，就是一次心跳。心跳那條子路由把 <code>group_wait</code> 設成 <code>0s</code>，Alertmanager 在 Watchdog 一成立就送出；<code>repeat_interval: 5m</code> 讓 Alertmanager 每 5 分鐘重送一次；它永遠不會解除，所以 <code>send_resolved: false</code>。實測兩次心跳相隔 5 分 01 秒。在心跳監看服務上把這個檢查的預期週期設 5 分鐘、寬限 5 分鐘，超過 10 分鐘沒收到，心跳監看服務就經由它自己的通知管道（例如 email）通知。這條路徑上的判斷全部發生在監控主機以外，監控主機整台停掉時它照樣運作。以自架的 healthchecks 演練停掉 Alertmanager，通知在最後一次心跳之後經過週期加寬限寄出，時間線與服務的挑選條件見 <a href="/blog/backend/04-observability/vendors/prometheus/external-heartbeat-and-probe-services/" data-link-title="第三方心跳監看與外部探測服務：挑選條件、healthchecks 的 ping 與 /fail、Alertmanager 的接法與演練" data-link-desc="以第三方服務當作監控主機失效域以外的觀察者：心跳監看服務的挑選條件與送出的告警資料留在哪裡，healthchecks 的 ping 網址、週期與寬限、/fail 與自架和託管版的回應差異，Alertmanager 送心跳與送失敗通知的 receiver 與路由順序、驗收與暫停，停掉 Alertmanager 的演練，以及外部探測服務的挑選條件">第三方心跳監看與外部探測服務</a>；託管版 healthchecks.io 沒有實際接過。</p>
<p>時間參數的條件是：兩次心跳之間最長的間隔，要小於心跳監看服務的週期加寬限。Alertmanager 只在每個 <code>group_interval</code> 的時間點檢查要不要重送，所以最長的間隔約是 <code>repeat_interval</code> 加一個 <code>group_interval</code>。這份設定是 5 分加 1 分，小於 10 分鐘；<code>group_interval</code> 沿用上層的 5 分鐘時，重送落在 5 分鐘的倍數上，任何一次稍晚錯過檢查點就變成 10 分鐘，正好碰到心跳監看服務的上限，所以心跳的 <code>group_interval</code> 要比 <code>repeat_interval</code> 短。</p>
<h2 id="通知送不出去的偵測">通知送不出去的偵測</h2>
<p>心跳涵蓋的是告警鏈停掉；告警鏈在運作而通知送不出去是另一種情形：網址打錯、私密檔沒放、通知服務拒收或連不上。這時 Alertmanager 在日誌寫下 <code>Notify for alerts failed</code>，並遞增 <code>alertmanager_notifications_failed_total</code>，以 <code>integration</code>（webhook、email、slack）與 <code>reason</code> 分開計數。實測拿掉 critical 的網址檔之後送一則 critical 告警，日誌是 <code>notify retry canceled due to unrecoverable error</code>，計數在 <code>reason=&quot;other&quot;</code> 加 1，這一次通知不重試。</p>
<p>Prometheus 讀得到 Alertmanager 自己的指標，所以這件事可以寫成一條規則：</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">alert</span><span class="p">:</span><span class="w"> </span><span class="l">AlertmanagerNotificationsFailing</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">expr</span><span class="p">:</span><span class="w"> </span><span class="l">sum by (host, integration) (increase(alertmanager_notifications_failed_total[10m])) &gt; 0</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">for</span><span class="p">:</span><span class="w"> </span><span class="l">5m</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">labels</span><span class="p">:</span><span class="w"> </span>{<span class="nt">severity: warning, scope</span><span class="p">:</span><span class="w"> </span><span class="l">monitoring}</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">annotations</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="nt">summary</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Alertmanager 經由 {{ $labels.integration }} 送通知失敗，10 分鐘內 {{ $value | printf \&#34;%.0f\&#34; }} 次&#34;</span></span></span></code></pre></div><p><code>summary</code> 不能省：ntfy 範本的內容欄讀的是它，沒有的話推播內容只剩 <code>&lt;no value&gt;</code>。這條規則與 Watchdog 以 promtool 的單元測試檢查：測試檔餵進一條失敗計數的序列，promtool 確認 Watchdog 沒有任何輸入也成立、失敗的告警帶著 <code>integration</code> 標籤、10 分鐘內沒有新的失敗就不再成立。測試本身也要驗：把規則的門檻從 <code>&gt; 0</code> 改成 <code>&gt; 1</code>，測試應該失敗，實測確實失敗；改壞規則而測試照樣通過，代表測試沒有在檢查那個門檻。</p>
<p>這條規則送出的告警，同樣要經過 Alertmanager 的某一個 receiver，所以它能不能送到，取決於哪一條管道壞了。它是 warning，走 warning 的 topic：critical 的管道壞掉時它送得出來，實測送失敗之後 5 分 41 秒，它出現在 warning 的 topic 上（<code>for: 5m</code> 加上 <code>group_wait</code> 的 30 秒）；warning 的管道本身壞掉時它送不出來，只留在 Alertmanager 的頁面與日誌。兩個 topic 都在同一個 ntfy 服務上，ntfy 整個連不上時兩者一起斷，而心跳走的是另一個服務，照常送達，這種故障不會產生任何通知。只有一條通知管道的設定裡更是如此，這條規則永遠走那條壞掉的管道。</p>
<p>要補上這個缺口，這條規則要另走一條不經過 ntfy 的路。一種是在路由裡替它開子路由，送一份到〈email 與 Slack〉裡的 <code>mail</code> receiver，同時保留 ntfy 那一份，排在 critical 那一條之前：</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">matchers</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">alertname=&#34;AlertmanagerNotificationsFailing&#34;]</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">receiver</span><span class="p">:</span><span class="w"> </span><span class="l">mail</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">continue</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">                 </span><span class="c"># 送 mail 之後繼續往下比</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">matchers</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">alertname=&#34;AlertmanagerNotificationsFailing&#34;]</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">receiver</span><span class="p">:</span><span class="w"> </span><span class="l">ntfy-warning        </span><span class="w"> </span><span class="c"># 後面的子路由都不符合時不會回到最上層，所以明寫</span></span></span></code></pre></div><p>email 本身送不出去時，送 email 的那一份照樣失敗，這一類由 ntfy 那一份送到手機；ntfy 與 email 同時壞掉時兩份都送不出去，要涵蓋這種情形，改用下面送到心跳監看服務的做法。</p>
<p>另一種是送到心跳監看服務：在 healthchecks.io 上另開一個檢查，它的 ping 網址加上 <code>/fail</code> 就是回報失敗的網址，以一個 <code>send_resolved: false</code> 的 webhook receiver 送到那裡，由心跳監看服務自己的管道通知。不另開檢查、用心跳的那一個時，下一次 Watchdog 心跳最晚約 6 分鐘後（<code>repeat_interval</code> 加一個 <code>group_interval</code>）就把它翻回正常。以自架的 healthchecks 實測，<code>/fail</code> 送出之後約 2 秒寄出通知，同一個檢查在下一次一般的 ping 之後翻回正常；receiver 的設定見 <a href="/blog/backend/04-observability/vendors/prometheus/external-heartbeat-and-probe-services/" data-link-title="第三方心跳監看與外部探測服務：挑選條件、healthchecks 的 ping 與 /fail、Alertmanager 的接法與演練" data-link-desc="以第三方服務當作監控主機失效域以外的觀察者：心跳監看服務的挑選條件與送出的告警資料留在哪裡，healthchecks 的 ping 網址、週期與寬限、/fail 與自架和託管版的回應差異，Alertmanager 送心跳與送失敗通知的 receiver 與路由順序、驗收與暫停，停掉 Alertmanager 的演練，以及外部探測服務的挑選條件">第三方心跳監看與外部探測服務</a> 的〈Alertmanager 的 receiver 與路由：心跳與送失敗的通知〉。</p>
<h2 id="延伸閱讀告警規則抑制與觀測的共命運">延伸閱讀：告警規則、抑制與觀測的共命運</h2>
<ul>
<li>哪些條件值得告警、<code>severity</code> 怎麼分、主機飽和時怎麼抑制它造成的症狀，見 <a href="/blog/backend/04-observability/host-overload-alerting/" data-link-title="4.27 主機過載時的監控告警：可用與變慢分開判斷、依原因抑制、監控自身的等待" data-link-desc="多個服務共用一台主機而主機過載時，各服務的探測與告警會一起失敗：可用性與延遲分成兩個判斷的寫法、間歇的條件怎麼持續成立、以主機飽和抑制各服務變慢的規則與它的取捨、以合成告警驗證抑制、抑制藏起各服務受影響程度的差別、探測與 Prometheus 自己也在等 CPU 時的訊號，以及讓監控不比服務先失效的資源隔離；以一次開發環境的過載為例">主機過載時的監控告警</a>；抑制本身的概念見 <a href="/blog/backend/knowledge-cards/alert-inhibition/" data-link-title="Alert Inhibition（告警抑制）" data-link-desc="同一個原因觸發一串症狀告警、值班人員被淹沒，或要在 Alertmanager 寫 inhibit_rules 時，查抑制做什麼、它和分組與靜音的差別、哪些症狀適合被抑制，以及它安靜失效的幾種條件">Alert Inhibition</a>。</li>
<li>告警太多、推播響到沒人看時，<code>repeat_interval</code> 與分組之外的處理見 <a href="/blog/backend/knowledge-cards/alert-fatigue/" data-link-title="Alert Fatigue" data-link-desc="說明過多低品質告警如何降低 on-call 反應品質">Alert Fatigue</a>。</li>
<li>監控和被監控的對象一起壞掉的各種形態，以及心跳以外的頻外訊號，見 <a href="/blog/backend/04-observability/observability-shared-fate/" data-link-title="4.25 觀測共命運失效：工具退化時的訊號與人層設計" data-link-desc="觀測系統跟被觀測系統共享失效域、在最需要時一起退化；out-of-band 訊號、優雅降級、以及工具盲飛時客服與工程師怎麼還能應對">4.25 觀測共命運失效</a>。</li>
<li>多台主機時 <code>group_by</code> 與抑制規則為什麼要帶 <code>host</code>，見 <a href="/blog/backend/04-observability/vendors/prometheus/multi-host-scraping/" data-link-title="多台主機的指標收集：讀取架構、主機標籤、目標清單、監控主機的位置與主機消失的偵測" data-link-desc="服務分到多台主機之後，Prometheus 怎麼讀到每一台：exporter 的部署位置與連接埠、中央直接讀取與各主機先收再推送的架構、代表主機的 host 標籤加在哪裡、目標清單由什麼產生，主機停掉與主機從清單上消失各自怎麼偵測，以及監控元件放在哪一台、compose 檔怎麼依主機角色拆開；附在單機上模擬第二台主機的驗證">多台主機的指標收集</a> 的〈代表主機的 host 標籤〉。</li>
</ul>
]]></content:encoded></item><item><title>第三方心跳監看與外部探測服務：挑選條件、healthchecks 的 ping 與 /fail、Alertmanager 的接法與演練</title><link>https://tarrragon.github.io/blog/backend/04-observability/vendors/prometheus/external-heartbeat-and-probe-services/</link><pubDate>Wed, 14 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/04-observability/vendors/prometheus/external-heartbeat-and-probe-services/</guid><description>&lt;blockquote>
&lt;p>這一篇屬於 &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;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;a href="https://tarrragon.github.io/blog/devops/operations/06-high-availability/small-service-host-layout/" data-link-title="小型服務的兩到三台主機配置：第二台與第三台主機的用法、監控主機的位置與連線、兩台主機互相監看，以及雲端業者負責的範圍" data-link-desc="一台主機的服務擴充到兩到三台時，每一台放什麼：第二台給監控、給應用程式的副本或給資料庫各自換到什麼與前提，與第三方心跳監看服務的比較，監控主機放在另一個業者或地端時經 VPN 讀取、連線中斷的誤判，兩台主機互相監看的條件，第三台給資料庫或兩台應用主機搭配託管資料庫，備份的位置，以及加主機也換不到、由雲端業者或入口設定處理的故障與攻擊">小型服務的兩到三台主機配置&lt;/a> 的〈兩台主機互相監看〉，本篇的 ping 網址、&lt;code>/fail&lt;/code> 與路由在自架時同樣適用。&lt;/p>&lt;/blockquote>
&lt;p>以第三方服務做心跳監看（dead man&amp;rsquo;s switch）與外部探測時，挑選服務要看的條件，以及把 Alertmanager 接到心跳監看服務的設定，整理在這一篇。第三方服務提供的是失效域以外的位置，以及它自己的存活由業者維運。設定接在 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/vendors/prometheus/alertmanager-notification-channels/" data-link-title="Alertmanager 的通知管道：路由樹與分組、webhook 與 ntfy、email 與 Slack、私密網址的檔案、心跳與通知送不出去的偵測" data-link-desc="Alertmanager 從只在網頁上看告警，到依嚴重度送到手機與信箱：路由樹怎麼決定一則告警送到哪裡、分組與 group_wait、group_interval、repeat_interval 各控制什麼，以 webhook 接 ntfy 推播與它的訊息範本，email 與 Slack 的設定，私密網址為什麼要放在檔案裡（設定檔不展開環境變數），以及 Watchdog 心跳與通知送不出去時的偵測">Alertmanager 的通知管道&lt;/a> 那份路由與 &lt;code>Watchdog&lt;/code> 規則之上，還沒有 Alertmanager 的從 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/vendors/prometheus/single-host-compose-stack/" data-link-title="單機 Compose 的最小監控堆疊：各 exporter 的掛載、讀取設定與 Grafana 佈建" data-link-desc="以 Docker Compose 在一台主機上接起 Prometheus、Alertmanager、Grafana 與主機、容器、資料庫、快取、入口各層的 exporter：每個掛載與旗標為什麼需要、讀取設定與規則的放法、Grafana 以檔案佈建、連接埠與帳號的權限，以及讀取對象全部 UP 時仍可能漏掉的監控">單機 Compose 的最小監控堆疊&lt;/a> 起步。心跳監看以 healthchecks 為例：它是開源專案，有託管版 healthchecks.io，也可以自架。文中的行為以自架的 healthchecks 4.4 與 Alertmanager 0.34.1 在 macOS 的 OrbStack 上實測，通知信寄到本機的 Mailpit；本篇沒有實際接過託管版 healthchecks.io，兩者的網址結構與判斷方式相同，回應碼的差異寫在下文，通知管道與方案限制以託管版的文件為準。外部探測服務本篇只寫挑選條件，沒有實際接過任何一家。&lt;/p>
&lt;h2 id="心跳監看服務的挑選條件">心跳監看服務的挑選條件&lt;/h2>
&lt;p>&lt;strong>服務本身跑在哪裡。&lt;/strong> 心跳監看服務與自己的主機在同一個雲端業者、同一個區域時，那家業者的區域故障會讓兩者一起停：主機停了，而本來要通知的監看服務也停了。查服務的文件或「關於」頁寫的業者與地區（healthchecks.io 寫在它的 about 頁），與自己主機的業者和區域比對；只寫了業者、沒寫地區時，至少要業者不同。&lt;/p>
&lt;p>&lt;strong>通知管道。&lt;/strong> 心跳中斷的通知由服務自己的管道送出（email、簡訊、電話、手機 App、Slack 這類整合）。這條管道不能經過自己的監控主機：心跳監看服務以 webhook 把通知送回監控主機上的 ntfy 時，監控主機停掉的那一刻，這條通知也送不到。收通知的人與 Alertmanager 通知的人相同，是會處理的人。&lt;/p>
&lt;p>&lt;strong>判斷方式。&lt;/strong> 服務要能讓每個檢查項目各自設定週期（period，預期的心跳間隔）與寬限（grace time，容許的延遲），最後一次心跳之後超過兩者相加就判定中斷。healthchecks 另外支援以 cron 表示式描述預期時間，給排程工作用；Alertmanager 的心跳是固定間隔，用週期加寬限就夠。&lt;/p>
&lt;p>&lt;strong>回報失敗的端點。&lt;/strong> 除了「我還在」，服務要能接受「出事了」。healthchecks 的 ping 網址加上 &lt;code>/fail&lt;/code> 就是回報失敗，收到就立刻判定失敗並通知，不必等週期。Alertmanager 偵測到通知送不出去時，透過這個端點把「通知送不出去」這件事交給心跳監看服務，由它自己的管道通知。&lt;/p>
&lt;p>&lt;strong>送出去的資料留在哪裡。&lt;/strong> Alertmanager 的 webhook 送出的是整份告警 JSON：標籤、註解（annotations），以及每則告警的 &lt;code>generatorURL&lt;/code>。&lt;code>generatorURL&lt;/code> 是 Prometheus 的網址，Prometheus 沒有設 &lt;code>--web.external-url&lt;/code> 時取主機名，在容器裡就是容器 ID。healthchecks 會把每次 ping 的請求本文存下來（上限由自架版的 &lt;code>PING_BODY_LIMIT&lt;/code> 設定，託管版寫在它的 API 文件），所以這些內容會存在服務那一邊。Watchdog 那一則的內容固定且少；送失敗通知的那一則帶著失敗的通知類型與主機標籤。告警內容不能離開組織時，改用自架的監看服務，或只送不帶本文的心跳請求（兩種做法見 4.28 的〈觀察者的位置：第三方服務、另一個失效域的主機（含互相監看）與另一個業者的排程平台〉）。&lt;/p>
&lt;p>&lt;strong>方案的限制。&lt;/strong> 免費方案通常限制檢查項目的數量、通知管道的種類與保留的歷史，各家的數字常變，選用時看服務當下公布的方案。心跳監看至少要兩個檢查項目，理由見下方〈Alertmanager 的 receiver 與路由：心跳與送失敗的通知〉。&lt;/p>
&lt;h2 id="healthchecks-的-ping-網址週期寬限與-fail">healthchecks 的 ping 網址、週期、寬限與 /fail&lt;/h2>
&lt;p>在 healthchecks 建立一個檢查項目（check），它會產生一個專屬的 ping 網址，路徑是一個 UUID（託管版的網址形如 &lt;code>https://hc-ping.com/&amp;lt;UUID&amp;gt;&lt;/code>，自架版是 &lt;code>SITE_ROOT&lt;/code> 環境變數設定的網站網址底下的 &lt;code>/ping/&amp;lt;UUID&amp;gt;&lt;/code>）。對這個網址送一個 GET 或 POST 就算一次心跳，回應是 &lt;code>OK&lt;/code>；接上 Alertmanager 之後，送心跳的是它的 webhook。網址本身就是寫入權限：知道它的人都能替這個檢查送心跳，或者回報失敗，所以它是私密值，與 ntfy 的網址一樣放在 Alertmanager 以 &lt;code>url_file&lt;/code> 讀取的檔案裡（放法見〈Alertmanager 的通知管道〉的〈私密網址的存放：設定檔不展開環境變數，改用讀檔案的 &lt;code>*_file&lt;/code> 欄位〉）。&lt;/p>
&lt;p>檢查項目建立之後、還沒收過任何 ping 時是 &lt;code>new&lt;/code> 狀態，這個狀態不會因為沒有心跳而通知。UUID 不存在時，自架版與託管版的回應不同。自架的 healthchecks 4.4 實測回 404 與 &lt;code>not found&lt;/code>，Alertmanager 會把它記成送出失敗。託管版的 API 文件寫的是回 200 與 &lt;code>OK (not found)&lt;/code>，Alertmanager 會把它當成送出成功：網址檔裡的 UUID 寫錯時，通知送不出去的偵測不會留下任何失敗計數，而那個檢查項目從來沒收到心跳、停在 &lt;code>new&lt;/code>，也不會通知。所以接好之後，到服務的頁面確認檢查項目收到了第一次心跳。檢查項目之後被刪除時，託管版同樣回 200，心跳從此不再被看著而沒有任何訊號，這一種只有定期演練（見〈心跳中斷的演練〉）會發現。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>這一篇屬於 <a href="/blog/backend/04-observability/vendors/prometheus/" data-link-title="Prometheus" data-link-desc="Pull-based metrics 主流 OSS、PromQL 與 alerting">Prometheus</a> 的實作系列。只有一台主機時為什麼需要失效域以外的觀察者、心跳監看與外部探測各回答什麼問題，見 <a href="/blog/backend/04-observability/single-host-monitoring-outside-view/" data-link-title="4.28 單台主機的監控盲區與失效域以外的觀察者：主機停機、使用者路徑中斷與告警鏈中斷，心跳監看與外部探測各自回答的問題與位置，以及與內部探測並排的判讀" data-link-desc="服務與監控放在同一台主機時監控結構上看不到的故障：主機整台停掉、使用者到主機的路徑中斷、告警鏈停掉與通知送不出去；從失效域以外送出訊號的心跳監看與外部探測各自回答的問題與成立條件，觀察者由第三方服務、另一個失效域的主機（含兩台主機互相監看）或另一個業者的排程平台提供時的差別，以及心跳監看、外部探測與主機內部探測並排時的判讀">4.28 單台主機的監控盲區與失效域以外的觀察者</a>；手上有另一個失效域的主機、要自己架觀察者時的條件，見 <a href="/blog/devops/operations/06-high-availability/small-service-host-layout/" data-link-title="小型服務的兩到三台主機配置：第二台與第三台主機的用法、監控主機的位置與連線、兩台主機互相監看，以及雲端業者負責的範圍" data-link-desc="一台主機的服務擴充到兩到三台時，每一台放什麼：第二台給監控、給應用程式的副本或給資料庫各自換到什麼與前提，與第三方心跳監看服務的比較，監控主機放在另一個業者或地端時經 VPN 讀取、連線中斷的誤判，兩台主機互相監看的條件，第三台給資料庫或兩台應用主機搭配託管資料庫，備份的位置，以及加主機也換不到、由雲端業者或入口設定處理的故障與攻擊">小型服務的兩到三台主機配置</a> 的〈兩台主機互相監看〉，本篇的 ping 網址、<code>/fail</code> 與路由在自架時同樣適用。</p></blockquote>
<p>以第三方服務做心跳監看（dead man&rsquo;s switch）與外部探測時，挑選服務要看的條件，以及把 Alertmanager 接到心跳監看服務的設定，整理在這一篇。第三方服務提供的是失效域以外的位置，以及它自己的存活由業者維運。設定接在 <a href="/blog/backend/04-observability/vendors/prometheus/alertmanager-notification-channels/" data-link-title="Alertmanager 的通知管道：路由樹與分組、webhook 與 ntfy、email 與 Slack、私密網址的檔案、心跳與通知送不出去的偵測" data-link-desc="Alertmanager 從只在網頁上看告警，到依嚴重度送到手機與信箱：路由樹怎麼決定一則告警送到哪裡、分組與 group_wait、group_interval、repeat_interval 各控制什麼，以 webhook 接 ntfy 推播與它的訊息範本，email 與 Slack 的設定，私密網址為什麼要放在檔案裡（設定檔不展開環境變數），以及 Watchdog 心跳與通知送不出去時的偵測">Alertmanager 的通知管道</a> 那份路由與 <code>Watchdog</code> 規則之上，還沒有 Alertmanager 的從 <a href="/blog/backend/04-observability/vendors/prometheus/single-host-compose-stack/" data-link-title="單機 Compose 的最小監控堆疊：各 exporter 的掛載、讀取設定與 Grafana 佈建" data-link-desc="以 Docker Compose 在一台主機上接起 Prometheus、Alertmanager、Grafana 與主機、容器、資料庫、快取、入口各層的 exporter：每個掛載與旗標為什麼需要、讀取設定與規則的放法、Grafana 以檔案佈建、連接埠與帳號的權限，以及讀取對象全部 UP 時仍可能漏掉的監控">單機 Compose 的最小監控堆疊</a> 起步。心跳監看以 healthchecks 為例：它是開源專案，有託管版 healthchecks.io，也可以自架。文中的行為以自架的 healthchecks 4.4 與 Alertmanager 0.34.1 在 macOS 的 OrbStack 上實測，通知信寄到本機的 Mailpit；本篇沒有實際接過託管版 healthchecks.io，兩者的網址結構與判斷方式相同，回應碼的差異寫在下文，通知管道與方案限制以託管版的文件為準。外部探測服務本篇只寫挑選條件，沒有實際接過任何一家。</p>
<h2 id="心跳監看服務的挑選條件">心跳監看服務的挑選條件</h2>
<p><strong>服務本身跑在哪裡。</strong> 心跳監看服務與自己的主機在同一個雲端業者、同一個區域時，那家業者的區域故障會讓兩者一起停：主機停了，而本來要通知的監看服務也停了。查服務的文件或「關於」頁寫的業者與地區（healthchecks.io 寫在它的 about 頁），與自己主機的業者和區域比對；只寫了業者、沒寫地區時，至少要業者不同。</p>
<p><strong>通知管道。</strong> 心跳中斷的通知由服務自己的管道送出（email、簡訊、電話、手機 App、Slack 這類整合）。這條管道不能經過自己的監控主機：心跳監看服務以 webhook 把通知送回監控主機上的 ntfy 時，監控主機停掉的那一刻，這條通知也送不到。收通知的人與 Alertmanager 通知的人相同，是會處理的人。</p>
<p><strong>判斷方式。</strong> 服務要能讓每個檢查項目各自設定週期（period，預期的心跳間隔）與寬限（grace time，容許的延遲），最後一次心跳之後超過兩者相加就判定中斷。healthchecks 另外支援以 cron 表示式描述預期時間，給排程工作用；Alertmanager 的心跳是固定間隔，用週期加寬限就夠。</p>
<p><strong>回報失敗的端點。</strong> 除了「我還在」，服務要能接受「出事了」。healthchecks 的 ping 網址加上 <code>/fail</code> 就是回報失敗，收到就立刻判定失敗並通知，不必等週期。Alertmanager 偵測到通知送不出去時，透過這個端點把「通知送不出去」這件事交給心跳監看服務，由它自己的管道通知。</p>
<p><strong>送出去的資料留在哪裡。</strong> Alertmanager 的 webhook 送出的是整份告警 JSON：標籤、註解（annotations），以及每則告警的 <code>generatorURL</code>。<code>generatorURL</code> 是 Prometheus 的網址，Prometheus 沒有設 <code>--web.external-url</code> 時取主機名，在容器裡就是容器 ID。healthchecks 會把每次 ping 的請求本文存下來（上限由自架版的 <code>PING_BODY_LIMIT</code> 設定，託管版寫在它的 API 文件），所以這些內容會存在服務那一邊。Watchdog 那一則的內容固定且少；送失敗通知的那一則帶著失敗的通知類型與主機標籤。告警內容不能離開組織時，改用自架的監看服務，或只送不帶本文的心跳請求（兩種做法見 4.28 的〈觀察者的位置：第三方服務、另一個失效域的主機（含互相監看）與另一個業者的排程平台〉）。</p>
<p><strong>方案的限制。</strong> 免費方案通常限制檢查項目的數量、通知管道的種類與保留的歷史，各家的數字常變，選用時看服務當下公布的方案。心跳監看至少要兩個檢查項目，理由見下方〈Alertmanager 的 receiver 與路由：心跳與送失敗的通知〉。</p>
<h2 id="healthchecks-的-ping-網址週期寬限與-fail">healthchecks 的 ping 網址、週期、寬限與 /fail</h2>
<p>在 healthchecks 建立一個檢查項目（check），它會產生一個專屬的 ping 網址，路徑是一個 UUID（託管版的網址形如 <code>https://hc-ping.com/&lt;UUID&gt;</code>，自架版是 <code>SITE_ROOT</code> 環境變數設定的網站網址底下的 <code>/ping/&lt;UUID&gt;</code>）。對這個網址送一個 GET 或 POST 就算一次心跳，回應是 <code>OK</code>；接上 Alertmanager 之後，送心跳的是它的 webhook。網址本身就是寫入權限：知道它的人都能替這個檢查送心跳，或者回報失敗，所以它是私密值，與 ntfy 的網址一樣放在 Alertmanager 以 <code>url_file</code> 讀取的檔案裡（放法見〈Alertmanager 的通知管道〉的〈私密網址的存放：設定檔不展開環境變數，改用讀檔案的 <code>*_file</code> 欄位〉）。</p>
<p>檢查項目建立之後、還沒收過任何 ping 時是 <code>new</code> 狀態，這個狀態不會因為沒有心跳而通知。UUID 不存在時，自架版與託管版的回應不同。自架的 healthchecks 4.4 實測回 404 與 <code>not found</code>，Alertmanager 會把它記成送出失敗。託管版的 API 文件寫的是回 200 與 <code>OK (not found)</code>，Alertmanager 會把它當成送出成功：網址檔裡的 UUID 寫錯時，通知送不出去的偵測不會留下任何失敗計數，而那個檢查項目從來沒收到心跳、停在 <code>new</code>，也不會通知。所以接好之後，到服務的頁面確認檢查項目收到了第一次心跳。檢查項目之後被刪除時，託管版同樣回 200，心跳從此不再被看著而沒有任何訊號，這一種只有定期演練（見〈心跳中斷的演練〉）會發現。</p>
<p>週期與寬限在檢查項目上設定，選值的條件是 Alertmanager 實際送出心跳的最長間隔（<code>repeat_interval</code> 加一個 <code>group_interval</code>）要小於週期加寬限。沿用〈Alertmanager 的通知管道〉的 <code>repeat_interval: 5m</code> 與 <code>group_interval: 1m</code>，最長間隔是 6 分鐘，服務端設週期 5 分鐘、寬限 5 分鐘，總和 10 分鐘，留 4 分鐘的餘裕給一次稍晚的送出。週期設成 1 分鐘、寬限 1 分鐘而路由維持 5 分鐘的重送，服務會在每兩次心跳之間判定中斷，一直誤報。實測為了縮短等待，兩者各設 1 分鐘、路由也一起設短，停掉 Alertmanager 之後，<code>DOWN</code> 的通知信在最後一次心跳之後 2 分鐘寄出（時間線見〈心跳中斷的演練〉）：判斷的起點是最後一次收到心跳的時間，不是 Alertmanager 停掉的時間。所以從 Alertmanager 停掉到收到通知，最長是週期加寬限（剛送完一次心跳就停掉的情形）。停掉的是 Prometheus 時，Alertmanager 在已收到的 Watchdog 告警結束時間之前照常重送心跳，這段重送期另外加在延遲上，依 Prometheus 的原始碼約 4 個評估週期（評估週期短於 1 分鐘時以 1 分鐘計），本篇沒有實測。心跳恢復時，服務寄出 <code>UP</code> 的通知。</p>
<p>對 ping 網址加上 <code>/fail</code> 送出，檢查項目立刻變成失敗並寄出通知：實測 Alertmanager 送出之後約 2 秒，Mailpit 收到 <code>DOWN | &lt;檢查項目名稱&gt;</code>。收到 <code>/fail</code> 的檢查項目，之後只要再收到一次一般的 ping，就翻回正常。</p>
<h2 id="alertmanager-的-receiver-與路由心跳與送失敗的通知">Alertmanager 的 receiver 與路由：心跳與送失敗的通知</h2>
<p>心跳的子路由寫在〈Alertmanager 的通知管道〉的〈路由樹：一則告警送到哪個 receiver〉那份設定裡：<code>Watchdog</code> 送到 <code>heartbeat</code> receiver，<code>url_file</code> 指向放著 ping 網址的檔案，時間參數的理由見同篇〈Watchdog：告警鏈本身的心跳〉。送失敗的通知另外在 healthchecks 建一個檢查項目，下文稱 <strong>notify-fail 檢查項目</strong>（與送到它的 receiver 同名），網址是它的 ping 網址加上 <code>/fail</code>。下面的子路由與 receiver 併進那份設定已有的 <code>routes</code> 與 <code>receivers</code> 清單，不能整段附加在檔案後面（同一份設定出現兩個 <code>receivers</code> 時，<code>amtool check-config</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">route</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">routes</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="c"># Watchdog 的子路由在最前面（〈Alertmanager 的通知管道〉的路由樹）</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">matchers</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">alertname=&#34;AlertmanagerNotificationsFailing&#34;]</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">receiver</span><span class="p">:</span><span class="w"> </span><span class="l">notify-fail</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">continue</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">                 </span><span class="c"># 送到 /fail 之後繼續往下比</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="c"># 已經有〈通知送不出去的偵測〉裡送到 mail 的那一條（continue: true）時，它排在這裡</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span>- <span class="nt">matchers</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">alertname=&#34;AlertmanagerNotificationsFailing&#34;]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span><span class="nt">receiver</span><span class="p">:</span><span class="w"> </span><span class="l">ntfy-warning        </span><span class="w"> </span><span class="c"># 原本那一份；後面的子路由都不符合時不會回到最上層，所以明寫</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">    </span><span class="c"># severity=&#34;critical&#34; 的子路由排在這些之後</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="nt">receivers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">  </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">notify-fail</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">    </span><span class="nt">webhook_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">      </span><span class="c"># 檔案內容是 notify-fail 檢查項目的 ping 網址加上 /fail</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span>- <span class="nt">url_file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/alertmanager/secrets/notify-fail.url</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">        </span><span class="nt">send_resolved</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">         </span><span class="c"># 解除時不再送：送出的會是同一個 /fail 網址</span></span></span></code></pre></div><p><code>continue: true</code> 讓這則告警送到 notify-fail 之後繼續往下比對；而一則告警只要比中了任何一條子路由，就不會回到最上層的 receiver，原本由最上層送到 ntfy 的那一份因此要再寫一條子路由接住。比對到沒有 <code>continue</code> 的子路由就停止，所以順序是：notify-fail、〈通知送不出去的偵測〉裡送到 <code>mail</code> 的那一條（兩者都帶 <code>continue: true</code>），最後才是送到 <code>ntfy-warning</code> 的那一條。notify-fail 若接在送到 <code>ntfy-warning</code> 那一條之後，永遠比不到。<code>send_resolved: false</code> 是因為 webhook 解除時送出的網址與成立時相同，設成 <code>true</code> 時，問題解除的那一刻會再對 <code>/fail</code> 送一次，心跳監看服務又記下一次失敗。</p>
<p>路由寫好之後，以〈路由樹〉那一節的 <code>amtool config routes test</code> 確認比對結果，標籤帶 <code>alertname=AlertmanagerNotificationsFailing severity=warning</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在放 alertmanager.yml 的目錄底下執行；版本換成自己用的 Alertmanager</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run --rm -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">:/a&#34;</span> --entrypoint amtool prom/alertmanager:v0.34.1 <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  config routes <span class="nb">test</span> --config.file<span class="o">=</span>/a/alertmanager.yml <span class="nv">alertname</span><span class="o">=</span>AlertmanagerNotificationsFailing <span class="nv">severity</span><span class="o">=</span>warning</span></span></code></pre></div><p>實測三條都寫時回 <code>notify-fail,mail,ntfy-warning</code>；只有 notify-fail 與 ntfy-warning 兩條時回 <code>notify-fail,ntfy-warning</code>，拿掉送到 <code>ntfy-warning</code> 的那一條時只剩 <code>notify-fail</code>；notify-fail 排在送到 <code>ntfy-warning</code> 那一條之後時回 <code>mail,ntfy-warning</code>，送不到 <code>/fail</code>。</p>
<p><code>AlertmanagerNotificationsFailing</code> 是〈Alertmanager 的通知管道〉的〈通知送不出去的偵測〉裡那條規則的名稱；在 Kubernetes 上用 chart 附的規則時，名稱與 matcher 的寫法見 <a href="/blog/backend/04-observability/vendors/prometheus/kubernetes-monitoring-entry/" data-link-title="在 Kubernetes 上接上監控：kube-prometheus-stack 的元件、ServiceMonitor 與 PrometheusRule 的選取、節點標籤、儀表板 sidecar、Alertmanager 的預設路由、控制平面的讀取與單節點叢集的資源用量" data-link-desc="在 Kubernetes 上以 kube-prometheus-stack 接上監控：chart 裝了哪些元件與 CRD，讀取對象與告警規則改以 ServiceMonitor、PrometheusRule 宣告之後由誰轉成 Prometheus 的設定，release 標籤決定哪些宣告會被選中，主機標籤改成節點標籤，儀表板經由 ConfigMap 與 sidecar 載入，Alertmanager 預設不送任何通知，以及控制平面元件只綁本機時讀不到的原因、單節點叢集上的資源用量與記憶體不足時的表現">在 Kubernetes 上接上監控</a> 的〈chart 預設的 Alertmanager 路由：不送任何通知〉；那裡的私密檔以 Secret 掛載，<code>url_file</code> 的路徑多一層 Secret 名稱的目錄，而 Alertmanager 由 Operator 管理，刪掉它的 Pod 會被重建，〈心跳中斷的演練〉的停機演練在叢集上要另外安排，本篇沒有在叢集上演練。</p>
<p><code>/fail</code> 要送到 notify-fail 檢查項目，不能送到心跳用的那一個。心跳用的檢查項目收到 <code>/fail</code> 之後，下一次一般的 ping 會把它翻回正常；以〈Alertmanager 的通知管道〉的 <code>repeat_interval: 5m</code> 與 <code>group_interval: 1m</code>，兩者相加，下一次心跳最晚約 6 分鐘後就到：通知雖然寄出了，服務上看到的狀態很快又變回正常，之後查歷史的人會以為只是一次短暫的波動。notify-fail 檢查項目只收 <code>/fail</code>，失敗的狀態會一直留到有人處理。</p>
<p>notify-fail 檢查項目平時不收 ping，所以「到服務的頁面確認收到第一次心跳」對它不適用；網址寫錯時它永遠沉默，在託管版上連失敗計數都沒有。驗收的方式是送一則合成的 <code>AlertmanagerNotificationsFailing</code>（送法見〈Alertmanager 的通知管道〉的〈本機測試：自架的 ntfy、Mailpit 與合成告警〉），確認 notify-fail 檢查項目變成失敗並寄出通知。實測以 Alertmanager 的 API 送出，<code>group_wait</code> 預設的 30 秒之後送到 <code>/fail</code>，notify-fail 檢查項目變成失敗並寄出通知。</p>
<p>驗收或處理完通知送不出去的問題之後，把 notify-fail 檢查項目恢復成等待的狀態要用暫停（paused），不要對它送一次一般的 ping。一次 ping 會讓它開始按週期等待下一次心跳，而 notify-fail 平時不會收到心跳，所以週期加寬限之後必定誤報一次：healthchecks 的預設週期是 1 天、寬限 1 小時，誤報會在約 25 小時後出現，很難連回當初那一次 ping。檢查項目的 <code>manual_resume</code>（暫停之後要手動恢復）維持關閉：開啟時暫停中的檢查項目會忽略 ping，它會不會也忽略 <code>/fail</code>，healthchecks 的文件沒有寫。實測在 healthchecks 建立之後從來沒收過 ping 的檢查項目，過了週期加寬限仍是 <code>new</code>，沒有寄出任何通知；送一次 ping 之後它寄出 <code>UP</code>，週期的計時從那一次 ping 開始，2 分鐘後因為沒有下一次心跳又寄出 <code>DOWN</code>，是一次誤報；設成暫停之後的 3 分鐘內沒有寄出任何通知，下一次 <code>/fail</code> 照常寄出 <code>DOWN</code>。實測以 healthchecks 的 Django 管理指令（<code>manage.py shell</code>）直接把狀態改成暫停，代替網頁上的暫停按鈕；託管版的暫停以它的文件為準。</p>
<h2 id="心跳中斷的演練">心跳中斷的演練</h2>
<p>心跳監看服務的中斷通知平時不會發出，演練確認的是它會發出，而且送到了人手上。實測的時間線（healthchecks 的週期與寬限各 1 分鐘，Alertmanager 的 <code>repeat_interval</code> 1 分鐘、<code>group_interval</code> 30 秒；這組值是為了縮短演練的等待而設短的，正式的值見〈healthchecks 的 ping 網址、週期、寬限與 /fail〉）：</p>
<ul>
<li>03:47:47 與 03:49:10 各收到一次心跳，間隔 1 分 23 秒，落在 <code>repeat_interval</code> 加上一個 <code>group_interval</code> 之內。</li>
<li>03:50:26 停掉 Alertmanager。</li>
<li>03:51:10 寄出 <code>DOWN | heartbeat</code>，是最後一次心跳之後 2 分鐘、Alertmanager 停掉之後 44 秒。</li>
<li>03:51:40 重新啟動 Alertmanager。它不保存收到的告警，重新啟動之後告警清單是空的，要等 Prometheus 下一次送出告警；實測在合成告警重新送進去之後，03:51:49 寄出 <code>UP | heartbeat</code>。</li>
</ul>
<p>正式的值與選值條件見〈healthchecks 的 ping 網址、週期、寬限與 /fail〉。照正式的值演練時，停掉 Alertmanager 之後最晚 10 分鐘收到 <code>DOWN</code>。服務寄出了 <code>DOWN</code> 只證明它判定了中斷，沒有證明有人收到：通知寄到的信箱有沒有人看、手機 App 有沒有跳出通知、通知裡寫不寫得出是哪一個檢查項目中斷，要在演練時一併確認。</p>
<h2 id="外部探測服務的挑選條件">外部探測服務的挑選條件</h2>
<p>外部探測服務從業者的多個地點，照使用者的路徑對服務發請求。心跳監看回答監控還在不在，外部探測回答使用者連不連得到，兩者都要。挑選時看幾件事：</p>
<ul>
<li><strong>探測的路徑。</strong> 請求要打使用者用的公開網址，經過 DNS、HTTPS 與公開的入口。打主機的 IP 時，探測量不到 DNS 與 TLS 憑證的故障。</li>
<li><strong>判斷成功的方式。</strong> 只看狀態碼時，入口回一個錯誤頁也可能是 200；能比對回應內容裡的一段固定字串，或打一個只有服務正常時才回得出內容的端點，判斷才會準。以短網址服務為例，探測一條固定的測試短網址，檢查回應是 <code>302</code> 而且 <code>Location</code> 指向預期的網址，比打首頁更貼近使用者要的功能。</li>
<li><strong>探測的地點與次數。</strong> 探測地點要包含使用者所在的地區：以台灣使用者為主的服務，探測點全在其他洲時，在地路由的故障量不到。服務要能設定成多個地點同時失敗才通知，排除單一地點自己網路的問題；代價是只有某個地區或某家網路業者的使用者連不進來時，這個設定不會通知。</li>
<li><strong>服務本身跑在哪裡。</strong> 與心跳監看的條件相同。外部探測與心跳監看向同一家業者購買時，那家停掉會同時拿掉兩種訊號，分屬不同業者時一家停掉還剩另一種。</li>
<li><strong>通知管道。</strong> 與心跳監看的條件相同，不經過自己的監控主機。</li>
<li><strong>送出去的資料。</strong> 送到探測業者的是探測的設定（網址、預期的回應內容）與服務的回應，不是告警 JSON；告警內容不能離開組織的服務，要查業者存了哪些回應內容，回應裡有內部資訊時改打只回固定內容的端點。</li>
<li><strong>對服務的影響。</strong> 探測請求會出現在服務的存取紀錄與指標裡；探測打的是會記錄點擊的轉址路徑時，點擊數會被探測灌高，要在服務的統計裡以來源或 User-Agent 排除，或改打不記錄的端點。</li>
</ul>
<p>外部探測服務的候選（UptimeRobot、Better Stack、Pingdom 這類）與各自的免費方案對照，見 <a href="/blog/devops/infra/takeover/legacy-external-monitoring/" data-link-title="無 SSH 環境的監控與告警" data-link-desc="無 SSH 環境沒辦法裝 agent、沒辦法串 log pipeline，用外部 HTTP check、錯誤追蹤服務與效能基線建立最低成本的監控能力">無 SSH 環境的監控與告警</a> 的〈工具選型〉。自己有另一個失效域的主機時，外部探測也可以自己跑（例如在那台主機上跑 Prometheus 的 blackbox_exporter 或 Gatus），條件是那台主機不與服務主機共用失效域，而且它停掉時有人知道；兩台主機互相監看時，用來分辨兩台之間連線中斷的那一個探測要同時避開服務主機與監控主機的失效域，理由見 <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="延伸閱讀觀察者的位置alertmanager-的路由與自架的觀察者">延伸閱讀：觀察者的位置、Alertmanager 的路由與自架的觀察者</h2>
<ul>
<li>心跳監看與外部探測放在第三方、另一個失效域的主機或兩台互相監看時各自的差別，以及心跳監看、外部探測與主機內部探測並排時怎麼判讀，見 <a href="/blog/backend/04-observability/single-host-monitoring-outside-view/" data-link-title="4.28 單台主機的監控盲區與失效域以外的觀察者：主機停機、使用者路徑中斷與告警鏈中斷，心跳監看與外部探測各自回答的問題與位置，以及與內部探測並排的判讀" data-link-desc="服務與監控放在同一台主機時監控結構上看不到的故障：主機整台停掉、使用者到主機的路徑中斷、告警鏈停掉與通知送不出去；從失效域以外送出訊號的心跳監看與外部探測各自回答的問題與成立條件，觀察者由第三方服務、另一個失效域的主機（含兩台主機互相監看）或另一個業者的排程平台提供時的差別，以及心跳監看、外部探測與主機內部探測並排時的判讀">4.28 單台主機的監控盲區與失效域以外的觀察者</a>。</li>
<li>心跳之外的通知管道（ntfy、email、Slack）、Watchdog 的時間參數與通知送不出去的偵測規則，見 <a href="/blog/backend/04-observability/vendors/prometheus/alertmanager-notification-channels/" data-link-title="Alertmanager 的通知管道：路由樹與分組、webhook 與 ntfy、email 與 Slack、私密網址的檔案、心跳與通知送不出去的偵測" data-link-desc="Alertmanager 從只在網頁上看告警，到依嚴重度送到手機與信箱：路由樹怎麼決定一則告警送到哪裡、分組與 group_wait、group_interval、repeat_interval 各控制什麼，以 webhook 接 ntfy 推播與它的訊息範本，email 與 Slack 的設定，私密網址為什麼要放在檔案裡（設定檔不展開環境變數），以及 Watchdog 心跳與通知送不出去時的偵測">Alertmanager 的通知管道</a>。</li>
<li>觀察者改成自架、跑在另一個失效域的主機上時，VPN、互相監看的條件與兩台同時停掉的風險，見 <a href="/blog/devops/operations/06-high-availability/small-service-host-layout/" data-link-title="小型服務的兩到三台主機配置：第二台與第三台主機的用法、監控主機的位置與連線、兩台主機互相監看，以及雲端業者負責的範圍" data-link-desc="一台主機的服務擴充到兩到三台時，每一台放什麼：第二台給監控、給應用程式的副本或給資料庫各自換到什麼與前提，與第三方心跳監看服務的比較，監控主機放在另一個業者或地端時經 VPN 讀取、連線中斷的誤判，兩台主機互相監看的條件，第三台給資料庫或兩台應用主機搭配託管資料庫，備份的位置，以及加主機也換不到、由雲端業者或入口設定處理的故障與攻擊">小型服務的兩到三台主機配置</a>。</li>
<li>心跳監看的判斷方式見 <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>。</li>
</ul>
]]></content:encoded></item></channel></rss>