<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Nightingale Blog</title>
        <link>https://n9e.github.io/blog</link>
        <description>Alerting, Prometheus and Kubernetes monitoring notes from the Nightingale maintainers.</description>
        <lastBuildDate>Sun, 01 Nov 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>© 2026 Nightingale Contributors · Apache-2.0</copyright>
        <item>
            <title><![CDATA[How long should a Prometheus alert wait? Data from 692 rules]]></title>
            <link>https://n9e.github.io/blog/prometheus-alert-for-duration</link>
            <guid>https://n9e.github.io/blog/prometheus-alert-for-duration</guid>
            <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[We counted the for duration and severity of 692 alert rule templates. 44% use the 60s default. Here is when for: 0, 60s, 15m and 1h are the right choice.]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" alt="A histogram of for durations across 692 alert rule templates, with 304 rules at 60 seconds" src="https://n9e.github.io/assets/images/alert-for-duration-8a11aeeb3b53ac4cd26ec55c82f56d39.webp" width="2400" height="1260" class="img_ev3q"></p>
<p>Every alert rule answers two questions: what counts as bad, and how long it has to stay bad. Most review effort goes into the first one, the expression and the threshold. The second is the <code>for</code> duration, and it often gets whatever value the tool filled in.</p>
<p>We wanted to know how often that happens, so we counted. Nightingale ships alert rule templates for 49 integrations. <strong>692 rules, and 304 of them (44%) have a <code>for</code> duration of exactly 60 seconds</strong>, which is the default in Nightingale's rule editor. This post walks through what the numbers show and what <code>for</code> value suits which kind of alert.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-for-does">What <code>for</code> does<a href="https://n9e.github.io/blog/prometheus-alert-for-duration#what-for-does" class="hash-link" aria-label="Direct link to what-for-does" title="Direct link to what-for-does" translate="no">​</a></h2>
<p>An alert rule is evaluated on a schedule. When the expression first returns a series, the alert is <em>pending</em>. It becomes <em>firing</em> only once the same series has kept matching on every evaluation for at least the <code>for</code> duration. If the series disappears for even one evaluation, the clock starts over.</p>
<p>Three consequences follow:</p>
<ul>
<li class=""><strong>Detection time is about <code>for</code> plus one evaluation interval.</strong> A rule with <code>for: 5m</code> evaluated every minute fires five to six minutes after the problem starts.</li>
<li class=""><strong>A series that flaps never fires.</strong> If the condition comes and goes faster than <code>for</code>, no alert is created, and no recovery either. That is the point of <code>for</code>, and also a risk.</li>
<li class=""><strong>The defaults differ by tool.</strong> In Prometheus, leaving out <code>for</code> means 0, so the alert fires on the first evaluation that matches. In Nightingale's rule editor, a new rule starts at a 60-second <code>for</code> and a 60-second evaluation interval. The bundled templates set their own interval, almost always 15 or 30 seconds.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="how-we-counted">How we counted<a href="https://n9e.github.io/blog/prometheus-alert-for-duration#how-we-counted" class="hash-link" aria-label="Direct link to How we counted" title="Direct link to How we counted" translate="no">​</a></h2>
<p>The data is the alert rule templates bundled with Nightingale's integrations: Kubernetes, Linux, MySQL, Redis, Elasticsearch and 44 more. They are published in the <a class="" href="https://n9e.github.io/alert-rules">alert rule library</a> and on each integration page.</p>
<ul>
<li class="">705 rules in total, <strong>692 after removing exact duplicates</strong> (same integration, same name, same expression).</li>
<li class="">Some checks exist twice on purpose, once for Categraf metrics and once for a Prometheus exporter, because the metric names differ. We count those separately.</li>
<li class="">These are <strong>templates</strong>, not production configs. They show what template authors chose. They say nothing about what users changed after importing them.</li>
<li class="">Counted on 2026-11-01, after the two changes described in Finding 4.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-distribution">The distribution<a href="https://n9e.github.io/blog/prometheus-alert-for-duration#the-distribution" class="hash-link" aria-label="Direct link to The distribution" title="Direct link to The distribution" translate="no">​</a></h2>
<table><thead><tr><th><code>for</code></th><th>Rules</th><th>Share</th></tr></thead><tbody><tr><td>0</td><td>51</td><td>7.4%</td></tr><tr><td>60s</td><td>304</td><td>43.9%</td></tr><tr><td>2m</td><td>59</td><td>8.5%</td></tr><tr><td>3m</td><td>67</td><td>9.7%</td></tr><tr><td>5m</td><td>86</td><td>12.4%</td></tr><tr><td>10m</td><td>38</td><td>5.5%</td></tr><tr><td>15m</td><td>58</td><td>8.4%</td></tr><tr><td>Over 15m</td><td>26</td><td>3.8%</td></tr></tbody></table>
<p>Three rules use 15s or 90s and are left out of the table.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="finding-1-the-default-wins-unless-someone-chose-deliberately">Finding 1: the default wins, unless someone chose deliberately<a href="https://n9e.github.io/blog/prometheus-alert-for-duration#finding-1-the-default-wins-unless-someone-chose-deliberately" class="hash-link" aria-label="Direct link to Finding 1: the default wins, unless someone chose deliberately" title="Direct link to Finding 1: the default wins, unless someone chose deliberately" translate="no">​</a></h2>
<p>The 60-second spike is the obvious feature. We can't tell from the data whether 304 authors thought about it and picked 60s, or simply kept what the form offered. What we can see is a clear difference between where the rules came from:</p>
<ul>
<li class=""><strong>86 rules</strong> in the Kubernetes template keep the standard alert names from <a href="https://github.com/kubernetes-monitoring/kubernetes-mixin" target="_blank" rel="noopener noreferrer" class="">kubernetes-mixin</a> and the Prometheus monitoring mixins, the community rule sets behind most Kubernetes monitoring stacks. <strong>46 of them use 15 minutes</strong>, and only 2 use 60 seconds. The mixin authors set <code>for</code> alert by alert.</li>
<li class="">Rules written for Categraf and for Prometheus exporters have a <strong>median of 60 seconds</strong>. Only 4 of the 352 use 15 minutes.</li>
</ul>
<p>Neither is wrong as a whole. The point is that a default is a choice someone else made for you, before they knew what the rule would watch. With the templates' 15- or 30-second evaluation intervals, a 60-second <code>for</code> means "matched on two to four evaluations in a row". That is reasonable for CPU, memory and latency. It is too short for things that recover on their own, and too long for events that are over in seconds.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="finding-2-for-0-is-right-for-events-if-the-window-is-wide-enough">Finding 2: <code>for: 0</code> is right for events, if the window is wide enough<a href="https://n9e.github.io/blog/prometheus-alert-for-duration#finding-2-for-0-is-right-for-events-if-the-window-is-wide-enough" class="hash-link" aria-label="Direct link to finding-2-for-0-is-right-for-events-if-the-window-is-wide-enough" title="Direct link to finding-2-for-0-is-right-for-events-if-the-window-is-wide-enough" translate="no">​</a></h2>
<p>51 rules fire on the first match (four of them are host heartbeat checks with no PromQL). Most of the rest watch something that has <em>already happened</em>, so waiting adds nothing:</p>
<ul>
<li class="">A counter went up: <code>increase(node_vmstat_oom_kill[1m]) &gt; 0</code>. The OOM kill happened; it will not un-happen in five minutes.</li>
<li class="">A deadline is close: <code>kubelet_certificate_manager_client_ttl_seconds &lt; 604800</code>. Under seven days is under seven days.</li>
<li class="">A process restarted: <code>mysql_global_status_uptime &lt; 60</code>.</li>
</ul>
<p>The restart check hides a trap. <code>uptime &lt; 60</code> is only true for the first 60 seconds after a restart. Any <code>for</code> of 60 seconds or more means the alert can never fire, because uptime passes 60 before the duration is met. And the window has to fit several evaluations: copy <code>uptime &lt; 60</code> into a rule evaluated every 60 seconds, and it gets one chance per restart, which scrape delay can easily push out of the window.</p>
<p>The templates get this right in different ways. The MySQL and MongoDB rules use <code>uptime &lt; 60</code> with <code>for: 0</code>, and they evaluate every 15 seconds, so each restart gets about four chances. The Redis rules widen the window to <code>uptime_in_seconds &lt; 600</code>. The Apache, EMQX and OpenLDAP rules use <code>uptime &lt; 300</code> with <code>for: 60s</code>. <strong>Make the window several evaluation intervals wide, keep <code>for</code> well under it, and keep both in mind if you change the evaluation interval.</strong></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="finding-3-some-rules-move-the-waiting-into-the-expression">Finding 3: some rules move the waiting into the expression<a href="https://n9e.github.io/blog/prometheus-alert-for-duration#finding-3-some-rules-move-the-waiting-into-the-expression" class="hash-link" aria-label="Direct link to Finding 3: some rules move the waiting into the expression" title="Direct link to Finding 3: some rules move the waiting into the expression" translate="no">​</a></h2>
<p>Of the 47 <code>for: 0</code> rules that have a PromQL expression, 22 wrap their metric in a range function, so the persistence check happens inside PromQL:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain"># GitLab's ServiceDown template, for: 0</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">avg_over_time(up[5m]) * 100 &lt; 50</span><br></div></code></pre></div></div>
<p>This is not the same as <code>up == 0</code> with <code>for: 5m</code>:</p>
<table><thead><tr><th></th><th><code>up == 0</code> with <code>for: 5m</code></th><th><code>avg_over_time(up[5m]) &lt; 0.5</code> with <code>for: 0</code></th></tr></thead><tbody><tr><td>Fires when</td><td>Every evaluation in the last 5 minutes saw the target down</td><td>The target was down for more than half of the last 5 minutes</td></tr><tr><td>One good scrape in the middle</td><td>Resets the clock</td><td>Barely moves the average</td></tr><tr><td>Good for</td><td>"Continuously broken"</td><td>"Mostly broken", for flapping targets</td></tr></tbody></table>
<p>Pick the one that matches what you mean. A target that answers one scrape in four is broken, and only the second form will say so.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="finding-4-a-few-instant-checks-deserved-a-longer-wait">Finding 4: a few instant checks deserved a longer wait<a href="https://n9e.github.io/blog/prometheus-alert-for-duration#finding-4-a-few-instant-checks-deserved-a-longer-wait" class="hash-link" aria-label="Direct link to Finding 4: a few instant checks deserved a longer wait" title="Direct link to Finding 4: a few instant checks deserved a longer wait" translate="no">​</a></h2>
<p>Counting also turned up <code>for: 0</code> rules that compare a sampled value against a threshold with no window at all. Two of them we changed while writing this post:</p>
<ul>
<li class=""><code>elasticsearch_cluster_health_status{color="yellow"} == 1</code>. A cluster goes yellow on every rolling restart while replicas are reallocated. It now waits 10 minutes.</li>
<li class=""><code>(node_nf_conntrack_entries / node_nf_conntrack_entries_limit) &gt; 0.75</code>. A single busy scrape was enough to trigger it. It now waits 5 minutes, the same as the sibling rule at 80%.</li>
</ul>
<p>The tables in this post already include both changes. The general rule: give sampled gauges a few evaluation intervals, or a range function as in Finding 3. Keep <code>for: 0</code> for the checks that really are binary: <code>mysql_up == 0</code>, a failed systemd unit, a replication thread that stopped.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="finding-5-long-waits-are-for-slow-problems">Finding 5: long waits are for slow problems<a href="https://n9e.github.io/blog/prometheus-alert-for-duration#finding-5-long-waits-are-for-slow-problems" class="hash-link" aria-label="Direct link to Finding 5: long waits are for slow problems" title="Direct link to Finding 5: long waits are for slow problems" translate="no">​</a></h2>
<p>26 rules wait longer than 15 minutes. Most of them watch something that develops slowly:</p>
<ul>
<li class="">The node filesystem predictions from kubernetes-mixin use one hour. A <code>predict_linear</code> forecast swings a lot on short windows; an hour of consistent prediction means the trend is real.</li>
<li class="">Prometheus TSDB compaction failures wait four hours. One failed compaction is noise; a run of them is a problem.</li>
<li class="">A kernel version mismatch across nodes waits six hours. It is a hygiene check, not an incident.</li>
<li class="">The slow windows of the Kubernetes API error budget burn alerts wait one and three hours. They are designed to catch slow, steady burns.</li>
</ul>
<p>Long <code>for</code> values pair naturally with a lower severity. Nobody should be paged at 3 a.m. for something that took six hours to become true.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-severity-side">The severity side<a href="https://n9e.github.io/blog/prometheus-alert-for-duration#the-severity-side" class="hash-link" aria-label="Direct link to The severity side" title="Direct link to The severity side" translate="no">​</a></h2>
<p>The same templates carry a severity. Nightingale has three levels: S1 (critical), S2 (warning) and S3 (info).</p>
<table><thead><tr><th>Severity</th><th>Rules</th><th>Share</th></tr></thead><tbody><tr><td>S2 / warning</td><td>490</td><td>71%</td></tr><tr><td>S1 / critical</td><td>137</td><td>20%</td></tr><tr><td>S3 / info</td><td>60</td><td>9%</td></tr></tbody></table>
<p>Five rules have no rule-level severity. When seven rules in ten share a level, the level stops helping anyone route or prioritize. A workable split:</p>
<ul>
<li class=""><strong>S1</strong>: someone must act now. Users are affected or data is about to be lost.</li>
<li class=""><strong>S2</strong>: someone should look today. Capacity trends, single replicas down with redundancy left.</li>
<li class=""><strong>S3</strong>: record it, don't notify. Useful context during an incident.</li>
</ul>
<p>24 templates put several severities on one rule. The Linux CPU rule, for example, uses <code>cpu_usage_active &gt; 75</code> as S3, <code>&gt; 85</code> as S2 and <code>&gt; 95</code> as S1. One rule with three tiers keeps the thresholds side by side, which is easier to review than three separate rules that drift apart.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="rules-of-thumb">Rules of thumb<a href="https://n9e.github.io/blog/prometheus-alert-for-duration#rules-of-thumb" class="hash-link" aria-label="Direct link to Rules of thumb" title="Direct link to Rules of thumb" translate="no">​</a></h2>
<table><thead><tr><th>What the rule watches</th><th><code>for</code></th><th>Example</th></tr></thead><tbody><tr><td>Something that already happened</td><td>0</td><td>OOM kill counter increased, certificate under 7 days</td></tr><tr><td>A binary up/down state</td><td>0 to 1 evaluation interval</td><td><code>mysql_up == 0</code></td></tr><tr><td>A sampled gauge: CPU, memory, latency, error rate</td><td>2–3 evaluation intervals</td><td>CPU over 90%</td></tr><tr><td>Something that often recovers by itself</td><td>Longer than its normal recovery time</td><td>A queue that usually drains in 5 minutes: 10m</td></tr><tr><td>A slow trend</td><td>30m to several hours, lower severity</td><td>Disk full within 24 hours</td></tr></tbody></table>
<p><code>for</code> only controls when an alert starts. If an alert fires and recovers over and over, <code>for</code> is not the fix. Use a recovery delay instead: <code>keep_firing_for</code> in Prometheus, or the recover duration in Nightingale. See <a class="" href="https://n9e.github.io/docs/alerting/evaluation">Evaluation interval and recovery</a>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-to-look-next">Where to look next<a href="https://n9e.github.io/blog/prometheus-alert-for-duration#where-to-look-next" class="hash-link" aria-label="Direct link to Where to look next" title="Direct link to Where to look next" translate="no">​</a></h2>
<p>Every rule in the <a class="" href="https://n9e.github.io/alert-rules">alert rule library</a> shows its severity and its <code>for</code> value ("Fires after"), and links to a runbook. You can download a whole rule set as a Prometheus rules file, or import it into Nightingale from the matching <a class="" href="https://n9e.github.io/integrations">integration</a>.</p>]]></content:encoded>
            <category>Alerting</category>
            <category>Prometheus</category>
            <category>Data</category>
        </item>
        <item>
            <title><![CDATA[kube-state-metrics vs metrics-server vs cAdvisor vs node_exporter]]></title>
            <link>https://n9e.github.io/blog/kube-state-metrics-vs-metrics-server-vs-cadvisor-vs-node-exporter</link>
            <guid>https://n9e.github.io/blog/kube-state-metrics-vs-metrics-server-vs-cadvisor-vs-node-exporter</guid>
            <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[What kube-state-metrics, metrics-server, cAdvisor and node_exporter each measure, which one to alert on, and the metric renames that silently break copied queries.]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" alt="Four Kubernetes metric sources: kube-state-metrics, cAdvisor, node_exporter and metrics-server" src="https://n9e.github.io/assets/images/kubernetes-metrics-sources-29962af59a23c3f84206f81323ffa542.webp" width="2400" height="1260" class="img_ev3q"></p>
<p>Kubernetes metrics come from four places, and their names look alike. <code>kube_pod_container_resource_limits</code>, <code>container_memory_working_set_bytes</code>, <code>node_memory_MemAvailable_bytes</code> and the numbers in <code>kubectl top</code> all sound like they describe the same thing. They are made by different components and they answer different questions. Only three of the four are meant for monitoring.</p>
<p>This post explains what each component measures and how to tell which one a metric came from. It also covers the renames that make a query copied from a 2019 blog post return nothing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-short-answer">The short answer<a href="https://n9e.github.io/blog/kube-state-metrics-vs-metrics-server-vs-cadvisor-vs-node-exporter#the-short-answer" class="hash-link" aria-label="Direct link to The short answer" title="Direct link to The short answer" translate="no">​</a></h2>
<table><thead><tr><th>Component</th><th>Measures</th><th>Where it gets the data</th><th>Metric prefix</th><th>Use it for</th></tr></thead><tbody><tr><td><strong>kube-state-metrics</strong></td><td>The state of Kubernetes objects: desired vs actual replicas, pod phase, container restarts, resource requests and limits</td><td>Watches the API server</td><td><code>kube_</code></td><td>Alerting and dashboards</td></tr><tr><td><strong>cAdvisor</strong></td><td>What each container actually uses: CPU, memory, throttling, filesystem, network</td><td>Built into the kubelet, served at <code>/metrics/cadvisor</code></td><td><code>container_</code></td><td>Alerting and dashboards</td></tr><tr><td><strong>node_exporter</strong></td><td>The machine underneath: CPU modes, memory, disks, filesystems, network interfaces</td><td>A DaemonSet reading <code>/proc</code> and <code>/sys</code> on each node</td><td><code>node_</code></td><td>Alerting and dashboards</td></tr><tr><td><strong>metrics-server</strong></td><td>A CPU and memory snapshot for each pod and node</td><td>Scrapes the kubelet's <code>/metrics/resource</code></td><td>none; it serves the Metrics API (<code>metrics.k8s.io</code>)</td><td><code>kubectl top</code>, the Horizontal and Vertical Pod Autoscalers</td></tr></tbody></table>
<p>You will also meet two more sources. The kubelet's own <code>/metrics</code> endpoint provides things like <code>kubelet_volume_stats_available_bytes</code>, which tells you how full a PersistentVolume is. The API server exposes <code>apiserver_*</code> metrics about itself. Neither belongs to the four above, and alert rules use both.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="kube-state-metrics-what-the-api-server-believes">kube-state-metrics: what the API server believes<a href="https://n9e.github.io/blog/kube-state-metrics-vs-metrics-server-vs-cadvisor-vs-node-exporter#kube-state-metrics-what-the-api-server-believes" class="hash-link" aria-label="Direct link to kube-state-metrics: what the API server believes" title="Direct link to kube-state-metrics: what the API server believes" translate="no">​</a></h2>
<p>kube-state-metrics listens to the API server and turns objects into metrics. Its README calls this "the health of the various objects inside" the cluster, as opposed to the health of the cluster components themselves. It exposes the values as the API returns them. It does not apply the heuristics <code>kubectl</code> uses, which is why its numbers sometimes differ from what <code>kubectl get</code> prints.</p>
<p>That makes it the right source for questions about intent:</p>
<ul>
<li class="">Does the Deployment have the replicas it asked for? <code>kube_deployment_spec_replicas</code> vs <code>kube_deployment_status_replicas_available</code>.</li>
<li class="">Is a container stuck in <code>CrashLoopBackOff</code>? <code>kube_pod_container_status_waiting_reason</code>.</li>
<li class="">Why did the last run end? <code>kube_pod_container_status_last_terminated_reason</code>, where <code>reason="OOMKilled"</code> is the one you usually want.</li>
<li class="">Is a node <code>NotReady</code>? <code>kube_node_status_condition</code>.</li>
</ul>
<p>It tells you nothing about usage. A pod at 100% of its CPU limit and an idle pod look the same to kube-state-metrics. For usage you need cAdvisor.</p>
<p><strong>The rename that breaks old queries.</strong> kube-state-metrics 2.0 removed the per-resource metrics such as <code>kube_pod_container_resource_limits_cpu_cores</code> and <code>kube_pod_container_resource_requests_memory_bytes</code>. Their replacements are <code>kube_pod_container_resource_limits</code> and <code>kube_pod_container_resource_requests</code>, with <code>resource</code> and <code>unit</code> labels:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain"># kube-state-metrics 1.x (removed in 2.0)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">kube_pod_container_resource_limits_cpu_cores</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"># kube-state-metrics 2.x</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">kube_pod_container_resource_limits{resource="cpu", unit="core"}</span><br></div></code></pre></div></div>
<p>A query that uses the old name does not fail. It returns an empty result, and an alert rule built on it never fires. We found exactly this in our own Kubernetes template while writing this post: the <a class="" href="https://n9e.github.io/alert-rules/kubernetes#container-cpu-usage-exceeds-80">container CPU rule</a> still used the 1.x metric name and the old <code>pod_name</code> label. It now uses the current names.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="cadvisor-what-containers-actually-use">cAdvisor: what containers actually use<a href="https://n9e.github.io/blog/kube-state-metrics-vs-metrics-server-vs-cadvisor-vs-node-exporter#cadvisor-what-containers-actually-use" class="hash-link" aria-label="Direct link to cAdvisor: what containers actually use" title="Direct link to cAdvisor: what containers actually use" translate="no">​</a></h2>
<p>cAdvisor is compiled into the kubelet, so there is nothing extra to deploy. Prometheus scrapes it from each node at <code>/metrics/cadvisor</code>. These are the metrics you reach for when a pod is slow or keeps getting killed:</p>
<ul>
<li class=""><code>container_cpu_usage_seconds_total</code>: CPU time consumed. Take a <code>rate()</code> to get cores in use.</li>
<li class=""><code>container_memory_working_set_bytes</code>: memory the kernel cannot easily reclaim. The kubelet uses the working set for eviction decisions, and <code>kubectl top</code> shows the same number.</li>
<li class=""><code>container_cpu_cfs_throttled_periods_total</code> and <code>container_cpu_cfs_periods_total</code>: how often the container hit its CPU limit and was paused. The <a class="" href="https://n9e.github.io/alert-rules/kubernetes#cputhrottlinghigh">CPUThrottlingHigh</a> rule is the ratio of the two.</li>
</ul>
<p>Two things trip people up:</p>
<ol>
<li class=""><strong>Extra series.</strong> cAdvisor also reports cgroups that are not your application container: a pod-level series with <code>container=""</code> and, on older Docker-based nodes, the pause container as <code>container="POD"</code>. Summing without a filter counts the same memory twice. Add <code>container!=""</code> (and <code>container!="POD"</code> on old nodes) when you aggregate.</li>
<li class=""><strong>Label names changed.</strong> Before Kubernetes 1.16, cAdvisor labelled series <code>pod_name</code> and <code>container_name</code>. They are now <code>pod</code> and <code>container</code>. A rule that groups <code>by (pod_name)</code> returns nothing on a current cluster.</li>
</ol>
<p>Usage becomes useful when you compare it with a limit, and the limit lives in kube-state-metrics. Both sides share the <code>namespace</code>, <code>pod</code> and <code>container</code> labels, so the join is direct:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">sum by (namespace, pod, container) (container_memory_working_set_bytes{container!=""})</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">/</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">sum by (namespace, pod, container) (kube_pod_container_resource_limits{resource="memory"})</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">&gt; 0.9</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="node_exporter-the-machine-underneath">node_exporter: the machine underneath<a href="https://n9e.github.io/blog/kube-state-metrics-vs-metrics-server-vs-cadvisor-vs-node-exporter#node_exporter-the-machine-underneath" class="hash-link" aria-label="Direct link to node_exporter: the machine underneath" title="Direct link to node_exporter: the machine underneath" translate="no">​</a></h2>
<p>node_exporter runs as a DaemonSet with access to the host's <code>/proc</code> and <code>/sys</code>. It knows nothing about pods. It reports the operating system: <code>node_cpu_seconds_total</code> by mode, <code>node_memory_MemAvailable_bytes</code>, <code>node_filesystem_avail_bytes</code>, disk I/O and network errors.</p>
<p>Use it for the questions Kubernetes does not answer. Is the node's root filesystem filling up? Is the clock drifting? Is a NIC dropping packets? Is the kernel OOM killer firing (<code>node_vmstat_oom_kill</code>)? Node-level memory includes page cache and every process on the host, so it will not add up to the sum of your containers. That is expected.</p>
<p>The <a class="" href="https://n9e.github.io/alert-rules/linux">Linux rule set</a> covers the common host checks, including disk-fill prediction with <code>predict_linear</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="metrics-server-for-autoscaling-not-alerting">metrics-server: for autoscaling, not alerting<a href="https://n9e.github.io/blog/kube-state-metrics-vs-metrics-server-vs-cadvisor-vs-node-exporter#metrics-server-for-autoscaling-not-alerting" class="hash-link" aria-label="Direct link to metrics-server: for autoscaling, not alerting" title="Direct link to metrics-server: for autoscaling, not alerting" translate="no">​</a></h2>
<p>metrics-server collects CPU and memory every 15 seconds from each kubelet's <code>/metrics/resource</code> endpoint and serves them through the Metrics API. The Horizontal Pod Autoscaler reads it, the Vertical Pod Autoscaler reads it, and <code>kubectl top</code> reads it.</p>
<p>It keeps only the latest value, in memory. There is no history to query and no Prometheus endpoint to scrape. The project's README says it plainly: metrics-server is meant for autoscaling, and you should not use it to forward metrics to monitoring solutions. If you want those numbers in your monitoring system, scrape the kubelet's <code>/metrics/resource</code> endpoint directly.</p>
<p>So when <code>kubectl top</code> and your dashboard disagree by a few percent, neither is wrong. They read different endpoints at different moments.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="which-component-answers-your-question">Which component answers your question<a href="https://n9e.github.io/blog/kube-state-metrics-vs-metrics-server-vs-cadvisor-vs-node-exporter#which-component-answers-your-question" class="hash-link" aria-label="Direct link to Which component answers your question" title="Direct link to Which component answers your question" translate="no">​</a></h2>
<table><thead><tr><th>Question</th><th>Metric</th><th>Source</th></tr></thead><tbody><tr><td>Is the pod restarting?</td><td><code>kube_pod_container_status_restarts_total</code></td><td>kube-state-metrics</td></tr><tr><td>Was it OOM-killed?</td><td><code>kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}</code></td><td>kube-state-metrics</td></tr><tr><td>Is a container close to its memory limit?</td><td><code>container_memory_working_set_bytes</code> ÷ <code>kube_pod_container_resource_limits{resource="memory"}</code></td><td>cAdvisor + kube-state-metrics</td></tr><tr><td>Is it being CPU-throttled?</td><td><code>container_cpu_cfs_throttled_periods_total</code> ÷ <code>container_cpu_cfs_periods_total</code></td><td>cAdvisor</td></tr><tr><td>Does the Deployment have the replicas it wants?</td><td><code>kube_deployment_status_replicas_available</code> vs <code>kube_deployment_spec_replicas</code></td><td>kube-state-metrics</td></tr><tr><td>Is a node <code>NotReady</code>?</td><td><code>kube_node_status_condition{condition="Ready"}</code></td><td>kube-state-metrics</td></tr><tr><td>Is a node's disk filling up?</td><td><code>node_filesystem_avail_bytes</code></td><td>node_exporter</td></tr><tr><td>Is a PersistentVolume filling up?</td><td><code>kubelet_volume_stats_available_bytes</code></td><td>kubelet</td></tr><tr><td>How much CPU is the pod using right now?</td><td><code>kubectl top pod</code></td><td>metrics-server</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="ready-made-rules-sorted-by-source">Ready-made rules, sorted by source<a href="https://n9e.github.io/blog/kube-state-metrics-vs-metrics-server-vs-cadvisor-vs-node-exporter#ready-made-rules-sorted-by-source" class="hash-link" aria-label="Direct link to Ready-made rules, sorted by source" title="Direct link to Ready-made rules, sorted by source" translate="no">​</a></h2>
<p>Most of the standard Kubernetes alerts read kube-state-metrics. Each of these pages explains what the alert means, its common causes and the steps to fix it, and lists the metrics it uses:</p>
<ul>
<li class="">kube-state-metrics: <a class="" href="https://n9e.github.io/alert-rules/kubernetes/kubepodcrashlooping">KubePodCrashLooping</a>, <a class="" href="https://n9e.github.io/alert-rules/kubernetes/kubepodnotready">KubePodNotReady</a>, <a class="" href="https://n9e.github.io/alert-rules/kubernetes/kubedeploymentreplicasmismatch">KubeDeploymentReplicasMismatch</a>, <a class="" href="https://n9e.github.io/alert-rules/kubernetes/kubenodenotready">KubeNodeNotReady</a>, <a class="" href="https://n9e.github.io/alert-rules/kubernetes/kubejobfailed">KubeJobFailed</a>, <a class="" href="https://n9e.github.io/alert-rules/kubernetes/kubehpamaxedout">KubeHpaMaxedOut</a></li>
<li class="">kubelet: <a class="" href="https://n9e.github.io/alert-rules/kubernetes/kubepersistentvolumefillingup">KubePersistentVolumeFillingUp</a></li>
<li class="">cAdvisor: <a class="" href="https://n9e.github.io/alert-rules/kubernetes#cputhrottlinghigh">CPUThrottlingHigh</a></li>
<li class="">API server: <a class="" href="https://n9e.github.io/alert-rules/kubernetes/kubeapierrorbudgetburn">KubeAPIErrorBudgetBurn</a>, <a class="" href="https://n9e.github.io/alert-rules/kubernetes/kubeclientcertificateexpiration">KubeClientCertificateExpiration</a></li>
</ul>
<p>The full <a class="" href="https://n9e.github.io/alert-rules/kubernetes">Kubernetes rule set</a> is available as a Prometheus rules file you can download and load with <code>rule_files</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="with-nightingale">With Nightingale<a href="https://n9e.github.io/blog/kube-state-metrics-vs-metrics-server-vs-cadvisor-vs-node-exporter#with-nightingale" class="hash-link" aria-label="Direct link to With Nightingale" title="Direct link to With Nightingale" translate="no">​</a></h2>
<p>Nightingale does not replace any of these four components. It queries the store where their metrics already land, such as Prometheus or VictoriaMetrics, and runs alert rules against it. The <a class="" href="https://n9e.github.io/integrations/kubernetes">Kubernetes integration</a> ships the rules above, each with its runbook attached, and you can <a class="" href="https://n9e.github.io/docs/data/integrations/import">import them into a business group</a> in one step.</p>]]></content:encoded>
            <category>Kubernetes</category>
            <category>Prometheus</category>
            <category>Alerting</category>
        </item>
        <item>
            <title><![CDATA[MCP server for alerting: six design decisions]]></title>
            <link>https://n9e.github.io/blog/mcp-server-for-alerting</link>
            <guid>https://n9e.github.io/blog/mcp-server-for-alerting</guid>
            <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[How Nightingale's built-in MCP server handles permissions, write access and audit: RBAC inherited from the token, write tools unregistered by default, no delete tools, and one caveat.]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" alt="An AI client asking which alerts are firing, connected with a user&amp;#39;s token to Nightingale&amp;#39;s MCP endpoint" src="https://n9e.github.io/assets/images/mcp-server-design-c8e30bb02b115a796a5092ccdd548d47.webp" width="2400" height="1260" class="img_ev3q"></p>
<p>Since v9, the Nightingale process is also an MCP server. Claude Code, Cursor or any other MCP client can connect to <code>/mcp</code> and ask which alerts are firing, which rules are noisiest, or what a metric did over the last hour.</p>
<p>The tool list was the easy part. The hard questions were about trust. Whose permissions does the AI client have? What can it change? How do you find out afterwards what it did? This post walks through the six decisions we made, and one caveat we haven't solved.</p>
<p>What this is not: an autonomous agent that resolves incidents on its own. It is a way for a model to read, and with explicit opt-in to change, an alerting system with the same permissions as the person who connected it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-no-separate-server-the-endpoint-lives-in-the-process">1. No separate server: the endpoint lives in the process<a href="https://n9e.github.io/blog/mcp-server-for-alerting#1-no-separate-server-the-endpoint-lives-in-the-process" class="hash-link" aria-label="Direct link to 1. No separate server: the endpoint lives in the process" title="Direct link to 1. No separate server: the endpoint lives in the process" translate="no">​</a></h2>
<p>There is nothing extra to deploy. <code>POST /mcp</code> is served by the same <code>n9e</code> process that serves the UI and the API, and it is on by default.</p>
<p>It uses MCP's Streamable HTTP transport in <strong>stateless</strong> mode. Every POST is self-contained. The server returns an <code>Mcp-Session-Id</code> on initialize but never validates it, and <code>GET /mcp</code> returns 405 because there is no standalone event stream. The practical benefit: several Nightingale instances behind a load balancer need no sticky sessions. Any instance can answer any request.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-a-tool-call-is-an-internal-api-call">2. A tool call is an internal API call<a href="https://n9e.github.io/blog/mcp-server-for-alerting#2-a-tool-call-is-an-internal-api-call" class="hash-link" aria-label="Direct link to 2. A tool call is an internal API call" title="Direct link to 2. A tool call is an internal API call" translate="no">​</a></h2>
<p>When a client calls <code>list_alert_rules</code>, the MCP server does not read the database. It dispatches the call, in-process, onto Nightingale's own HTTP route (<code>/api/n9e/busi-group/{gid}/alert-rules</code>), carrying the caller's token through the full middleware chain: authenticate, resolve the user, check the permission point, check the business group.</p>
<p>So the answer to "can the AI get around RBAC?" is structural: no. It walks the same path as a click in the UI. There is no separate permission model for AI to configure, audit and keep in sync.</p>
<p>The consequence is that <strong>a client has exactly the permissions of its token's owner</strong>. To limit what the AI can see, you give it a limited account: a dedicated user with the <code>Guest</code> or <code>Standard</code> role, in a team that can read only the business groups you choose. <code>list_busi_groups</code> returns only groups that user can reach, so the model can only ever pick from that set. Do not give the AI account the <code>Admin</code> role. Admin bypasses the business-group layer.</p>
<p>OAuth tokens get one more restriction. A token issued through OAuth works on <code>/mcp</code> and <code>/a2a</code> only; the rest of the API rejects it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-write-tools-are-not-registered-not-just-refused">3. Write tools are not registered, not just refused<a href="https://n9e.github.io/blog/mcp-server-for-alerting#3-write-tools-are-not-registered-not-just-refused" class="hash-link" aria-label="Direct link to 3. Write tools are not registered, not just refused" title="Direct link to 3. Write tools are not registered, not just refused" translate="no">​</a></h2>
<p>There are 74 tools. 42 read and 32 write. By default only the 42 read tools exist. The write tools sit in a separate registration list that is skipped unless you set:</p>
<div class="language-toml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-toml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token table class-name">HTTP.A2A</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key property">MCPEnableWriteTools</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">=</span><span class="token plain"> </span><span class="token boolean">true</span><br></div></code></pre></div></div>
<p>We chose "not registered" over "registered and refused" on purpose. A model plans with the tools it can see. If write tools appear in <code>tools/list</code> and fail at call time, the model keeps trying them, or tries to work around the error. If they are absent, the model never considers them.</p>
<p>This gate sits outside RBAC. Even if a client is handed root's token by mistake, under the default config it cannot change anything. Every tool also carries MCP's <code>readOnlyHint</code> annotation for clients that use it, and it always agrees with the registration list.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-no-delete-tools-at-all">4. No delete tools at all<a href="https://n9e.github.io/blog/mcp-server-for-alerting#4-no-delete-tools-at-all" class="hash-link" aria-label="Direct link to 4. No delete tools at all" title="Direct link to 4. No delete tools at all" translate="no">​</a></h2>
<p>None of the 74 tools deletes anything. "Write" means create and update. Deleting a rule, a user or a business group is not reachable through MCP.</p>
<p>This changes the worst case from "gone" to "changed wrongly", which is easier to notice and to fix. It does not make write tools harmless. Some of them are among the most powerful operations in the product:</p>
<table><thead><tr><th>Tool</th><th>Why it is dangerous</th></tr></thead><tbody><tr><td><code>reset_user_password</code></td><td>Resets any user's password</td></tr><tr><td><code>create_user</code>, <code>update_user_profile</code></td><td>Assign roles as a side effect, which is privilege escalation</td></tr><tr><td><code>bind_role_operations</code></td><td>Replaces a role's whole permission set; a short payload silently removes permissions</td></tr><tr><td><code>create_notify_channel</code>, <code>update_notify_channel</code></td><td>A webhook channel sends alert content to any address it is given</td></tr><tr><td><code>upsert_datasource</code></td><td>Stores data source credentials and probes the address it is given</td></tr><tr><td><code>set_dashboard_public</code></td><td>Makes a dashboard readable without login</td></tr><tr><td><code>toggle_alert_rules</code>, <code>set_datasource_status</code>, <code>create_mute</code></td><td>Create blind spots: rules disabled, a data source off, alerts silenced</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="5-toolsets-are-an-allowlist-and-typos-dont-fall-back">5. Toolsets are an allowlist, and typos don't fall back<a href="https://n9e.github.io/blog/mcp-server-for-alerting#5-toolsets-are-an-allowlist-and-typos-dont-fall-back" class="hash-link" aria-label="Direct link to 5. Toolsets are an allowlist, and typos don't fall back" title="Direct link to 5. Toolsets are an allowlist, and typos don't fall back" translate="no">​</a></h2>
<p>The 74 tools are grouped into 13 toolsets: <code>alerts</code>, <code>targets</code>, <code>datasource</code>, <code>mutes</code>, <code>busi_groups</code>, <code>notify_rules</code>, <code>alert_subscribes</code>, <code>event_pipelines</code>, <code>users</code>, <code>metrics</code>, <code>logs</code>, <code>dashboards</code> and <code>roles</code>. An empty list exposes all of them; a list exposes only those named:</p>
<div class="language-toml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-toml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token table class-name">HTTP.A2A</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key property">MCPToolsets</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token string" style="color:rgb(255, 121, 198)">"alerts"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"busi_groups"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"targets"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"metrics"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><br></div></code></pre></div></div>
<p>A misspelled name is dropped with a warning in the log. It does <strong>not</strong> fall back to "everything". A list made entirely of typos exposes zero tools, not 74. Failing closed is less convenient and much safer.</p>
<p>Work backwards from what you want the AI to do:</p>
<table><thead><tr><th>Use</th><th>Toolsets</th></tr></thead><tbody><tr><td>On-call triage: what is firing, which rule, who owns it</td><td><code>alerts</code>, <code>busi_groups</code>, <code>targets</code></td></tr><tr><td>Plus "explain this spike"</td><td>add <code>metrics</code></td></tr><tr><td>Noise review: noisiest rules, mutes, subscriptions</td><td>add <code>mutes</code>, <code>alert_subscribes</code></td></tr><tr><td>Letting the AI change configuration</td><td>turn on writes, and remove <code>users</code> and <code>roles</code> first</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="6-every-call-is-attributable">6. Every call is attributable<a href="https://n9e.github.io/blog/mcp-server-for-alerting#6-every-call-is-attributable" class="hash-link" aria-label="Direct link to 6. Every call is attributable" title="Direct link to 6. Every call is attributable" translate="no">​</a></h2>
<p>Two trails answer "who changed this, and was it the AI?":</p>
<ul>
<li class="">
<p><strong>The object itself.</strong> A rule, mute or dashboard changed over MCP records the token owner's username in <code>update_by</code>, exactly as if they had edited it in the UI.</p>
</li>
<li class="">
<p><strong>The request log.</strong> Every <code>/mcp</code> request writes a start line with the full request body (tool name and arguments) and a done line with the user and status code, joined by a <code>trace_id</code>:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token function" style="color:rgb(80, 250, 123)">grep</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'"method":"tools/call"'</span><span class="token plain"> n9e.log </span><span class="token operator">|</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-oE</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'"name":"[a-z_]+"'</span><span class="token plain"> </span><span class="token operator">|</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">sort</span><span class="token plain"> </span><span class="token operator">|</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">uniq</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-c</span><span class="token plain"> </span><span class="token operator">|</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">sort</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-rn</span><br></div></code></pre></div></div>
<p>Oversized bodies are truncated, and the log line says so.</p>
</li>
</ul>
<p>A dedicated account per client makes both trails useful. If every client shares one token, <code>update_by</code> tells you nothing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-caveat-query_logs-and-sql-data-sources">The caveat: <code>query_logs</code> and SQL data sources<a href="https://n9e.github.io/blog/mcp-server-for-alerting#the-caveat-query_logs-and-sql-data-sources" class="hash-link" aria-label="Direct link to the-caveat-query_logs-and-sql-data-sources" title="Direct link to the-caveat-query_logs-and-sql-data-sources" translate="no">​</a></h2>
<p>One read-only tool deserves care. <code>query_logs</code> in the <code>logs</code> toolset passes the query body to Nightingale's log query endpoint, which dispatches on the data source type. If you have SQL data sources registered (MySQL, PostgreSQL, ClickHouse, Doris or TDengine), the SQL runs as written.</p>
<p>There is a read-only check on that path. It is a keyword blocklist that rejects statements containing <code>INSERT</code>, <code>UPDATE</code>, <code>DELETE</code>, <code>DROP</code>, <code>ALTER</code>, <code>CREATE</code>, <code>TRUNCATE</code>, <code>REPLACE</code>, <code>GRANT</code> or <code>REVOKE</code>. That stops the obvious cases. It is not a SQL parser, so treat it as a second line of defence rather than a guarantee. If you have SQL data sources, leave <code>logs</code> out of <code>MCPToolsets</code>, or give the AI account a role that cannot reach those data sources. Registering them with read-only database credentials is a sensible extra layer.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="mcp-or-the-built-in-assistant">MCP or the built-in assistant?<a href="https://n9e.github.io/blog/mcp-server-for-alerting#mcp-or-the-built-in-assistant" class="hash-link" aria-label="Direct link to MCP or the built-in assistant?" title="Direct link to MCP or the built-in assistant?" translate="no">​</a></h2>
<p>Nightingale also has a built-in assistant in the UI. For configuration changes it is the safer of the two: its update operations are two-phase. The first call computes the change and shows a confirmation card, and nothing is written until a person confirms. MCP has no such step. A write tool writes on its first call.</p>
<p>So a reasonable split is: use an MCP client for reading and triage, where its own model and tools shine, and use the built-in assistant when something needs to change.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="try-it">Try it<a href="https://n9e.github.io/blog/mcp-server-for-alerting#try-it" class="hash-link" aria-label="Direct link to Try it" title="Direct link to Try it" translate="no">​</a></h2>
<p>Create a token for a dedicated, least-privileged user under <strong>Profile → Token management</strong>, then add Nightingale to your MCP client:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockTitle_OeMC">~/.claude.json</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"mcpServers"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">"nightingale"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"type"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"http"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"url"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"http://127.0.0.1:17000/mcp"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"headers"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"X-User-Token"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"&lt;token of the AI account&gt;"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>Then ask something you can check: <em>"Which alerts are firing right now? Group them by severity."</em> or <em>"List every business group I can see."</em> The second one is also a quick test that the account is scoped the way you intended.</p>
<p>More detail is in the docs: <a class="" href="https://n9e.github.io/docs/ai/mcp-endpoint">Enable the MCP endpoint</a>, <a class="" href="https://n9e.github.io/docs/ai/permissions">Permission inheritance and RBAC</a>, <a class="" href="https://n9e.github.io/docs/ai/toolsets">Read and write toolsets</a> and <a class="" href="https://n9e.github.io/docs/ai/write-tools">Enable write tools safely</a>.</p>]]></content:encoded>
            <category>AI and MCP</category>
            <category>Alerting</category>
        </item>
        <item>
            <title><![CDATA[Prometheus high availability: storage vs alerting]]></title>
            <link>https://n9e.github.io/blog/prometheus-high-availability</link>
            <guid>https://n9e.github.io/blog/prometheus-high-availability</guid>
            <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Running two Prometheus replicas solves half of high availability. How Alertmanager clustering, replica labels and Thanos, Mimir or VictoriaMetrics deduplication fit together.]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" alt="Two Prometheus replicas writing to deduplicated storage and sending alerts to a clustered Alertmanager" src="https://n9e.github.io/assets/images/prometheus-high-availability-116818454807068016c0d755e596c981.webp" width="2400" height="1260" class="img_ev3q"></p>
<p>Ask how to make Prometheus highly available and the usual answer is "run two of them". That is correct, and it is only half the job. Two replicas give you two copies of every alert and two slightly different copies of every time series. Each needs its own fix, and the fixes don't share a configuration.</p>
<p>This post separates the two problems: keeping alerts flowing exactly once, and keeping the data queryable without gaps. It ends with a checklist.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="how-prometheus-does-ha">How Prometheus does HA<a href="https://n9e.github.io/blog/prometheus-high-availability#how-prometheus-does-ha" class="hash-link" aria-label="Direct link to How Prometheus does HA" title="Direct link to How Prometheus does HA" translate="no">​</a></h2>
<p>Prometheus has no clustering. High availability means running two (or more) identical servers. Each one scrapes the same targets, evaluates the same rules and keeps its own local copy of the data. They don't talk to each other.</p>
<p>The copies are never identical. Each replica scrapes at its own moment, so the timestamps differ. When one replica restarts, it has a gap that the other doesn't. That is fine for alerting, and a problem for dashboards.</p>
<p>The usual convention is to give each replica an external label that says which one it is:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token comment" style="color:rgb(98, 114, 164)"># prometheus-a.yml (prometheus-b.yml says replica: b)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">global</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">external_labels</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">cluster</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> prod</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">eu</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">replica</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> a</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="problem-1-alerts-should-arrive-once-even-when-a-replica-dies">Problem 1: alerts should arrive once, even when a replica dies<a href="https://n9e.github.io/blog/prometheus-high-availability#problem-1-alerts-should-arrive-once-even-when-a-replica-dies" class="hash-link" aria-label="Direct link to Problem 1: alerts should arrive once, even when a replica dies" title="Direct link to Problem 1: alerts should arrive once, even when a replica dies" translate="no">​</a></h2>
<p>Both replicas evaluate every rule, so both send every alert. Deduplication happens in Alertmanager.</p>
<p><strong>Cluster the Alertmanagers.</strong> Alertmanager instances gossip with each other. You start each one with <code>--cluster.peer</code> pointing at the others, and they share notification state and silences. When two replicas send the same alert, the cluster sends one notification.</p>
<p><strong>Point every Prometheus at every Alertmanager.</strong> The Alertmanager README is explicit: do not load-balance traffic between Prometheus and its Alertmanagers. Give each Prometheus the full list, because every alert has to reach every instance:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">alerting</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">alertmanagers</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">static_configs</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">targets</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token string" style="color:rgb(255, 121, 198)">'am-0:9093'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'am-1:9093'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'am-2:9093'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><br></div></code></pre></div></div>
<p><strong>Drop the replica label from alerts.</strong> Alerts carry the external labels. If replica A sends <code>{alertname="X", replica="a"}</code> and replica B sends <code>{alertname="X", replica="b"}</code>, Alertmanager sees two different alerts and notifies twice. Strip the label on the way out:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">alerting</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">alert_relabel_configs</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">action</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> labeldrop</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">regex</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> replica</span><br></div></code></pre></div></div>
<p>The Prometheus Operator does this for you with its own replica label. Hand-built setups often miss it, and the symptom is that every page arrives twice.</p>
<p>With these three in place, alerting HA works well. If replica A dies, replica B was evaluating the same rules all along, so there is no gap in detection. If an Alertmanager dies, Prometheus is still sending to the others.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="problem-2-dashboards-and-queries-should-not-show-gaps">Problem 2: dashboards and queries should not show gaps<a href="https://n9e.github.io/blog/prometheus-high-availability#problem-2-dashboards-and-queries-should-not-show-gaps" class="hash-link" aria-label="Direct link to Problem 2: dashboards and queries should not show gaps" title="Direct link to Problem 2: dashboards and queries should not show gaps" translate="no">​</a></h2>
<p>This is the half that "run two" does not solve. Point Grafana at replica A, and you see replica A's gaps. Put a load balancer in front of both, and consecutive refreshes can hit different replicas and draw slightly different lines.</p>
<p>The fix is a layer that knows the two replicas hold the same data and merges them. The three common options handle the replica label in different ways, and that difference matters:</p>
<table><thead><tr><th>Store</th><th>How it deduplicates</th><th>What the replicas' labels must look like</th></tr></thead><tbody><tr><td><strong>Thanos</strong></td><td>Thanos Querier merges series that differ only in the label named by <code>--query.replica-label</code></td><td>Different: each replica has its own <code>replica</code> value</td></tr><tr><td><strong>Grafana Mimir</strong></td><td>The HA tracker elects a leader replica per cluster, keeps its samples and drops the rest. It switches leader if the current one sends nothing for 30 seconds by default</td><td>Different: <code>cluster</code> plus a <code>__replica__</code> label by default</td></tr><tr><td><strong>VictoriaMetrics</strong></td><td><code>-dedup.minScrapeInterval</code> keeps one sample per series per interval. Set it equal to your <code>scrape_interval</code></td><td><strong>Identical</strong>: the docs require the HA pair to write the same series, so no per-replica label</td></tr></tbody></table>
<p>So a setup moved from Thanos to VictoriaMetrics needs its replica label removed, and a setup moved the other way needs one added. Mixing the conventions gives you either doubled series or a store that silently keeps one replica.</p>
<p>Long-term retention is a separate decision from HA. Thanos, Mimir and VictoriaMetrics also keep data longer than a local Prometheus. But you can have deduplicated HA with short retention, or long retention with a single replica. Decide each one on its own.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="scaling-is-not-ha">Scaling is not HA<a href="https://n9e.github.io/blog/prometheus-high-availability#scaling-is-not-ha" class="hash-link" aria-label="Direct link to Scaling is not HA" title="Direct link to Scaling is not HA" translate="no">​</a></h2>
<p>Federation, functional sharding (one Prometheus per team or per cluster) and <code>hashmod</code> sharding spread load. They don't add redundancy: each shard is still a single point of failure until you run it as a pair. If you shard, every shard needs the replica treatment above.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-a-separate-alerting-engine-changes-the-picture">Where a separate alerting engine changes the picture<a href="https://n9e.github.io/blog/prometheus-high-availability#where-a-separate-alerting-engine-changes-the-picture" class="hash-link" aria-label="Direct link to Where a separate alerting engine changes the picture" title="Direct link to Where a separate alerting engine changes the picture" translate="no">​</a></h2>
<p>Everything above assumes Prometheus evaluates the rules. Some setups move evaluation out of Prometheus: Grafana Alerting, vmalert, Thanos Ruler, or <a class="" href="https://n9e.github.io/docs/install/high-availability">Nightingale</a>. Then storage HA and alerting HA become two different systems.</p>
<p>Nightingale takes a different approach from a Prometheus pair, with a different trade-off:</p>
<ul>
<li class=""><strong>It queries one deduplicated data source.</strong> Prometheus behind Thanos Querier, VictoriaMetrics or Mimir. The storage-side HA from Problem 2 is still needed, and Nightingale doesn't replace it.</li>
<li class=""><strong>Rules are shared out, not duplicated.</strong> Several <code>n9e</code> instances share one MySQL and one Redis. Each live instance sends a heartbeat every second. The live instances form a consistent hash ring per data source, and each rule runs on exactly one instance. One evaluation means one event, so no Alertmanager-style deduplication is needed.</li>
<li class=""><strong>The trade-off is a failover gap.</strong> An instance counts as dead after 30 seconds without a heartbeat, and only then does its share of rules move to another instance. A freshly started instance also waits 30 seconds before evaluating. A Prometheus pair has no such gap, because the other replica was evaluating all along.</li>
<li class=""><strong>Shared dependencies need their own HA.</strong> If MySQL is down, everything is down. If Redis is unreachable at startup, the process exits. Running several <code>n9e</code> instances protects against losing an <code>n9e</code> process, and nothing else.</li>
</ul>
<p>Neither design is better in general. Duplicate evaluation with downstream deduplication gives zero-gap failover and costs you a deduplication layer that must be configured exactly right. Single evaluation with a hash ring avoids duplicates by construction and costs you up to 30 seconds of blindness for the rules on a failed instance. The details are in <a class="" href="https://n9e.github.io/docs/operations/high-availability">High availability and failure domains</a>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="checklist">Checklist<a href="https://n9e.github.io/blog/prometheus-high-availability#checklist" class="hash-link" aria-label="Direct link to Checklist" title="Direct link to Checklist" translate="no">​</a></h2>
<p><strong>Alerting</strong></p>
<ul class="contains-task-list containsTaskList_mC6p">
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->Two or more Prometheus replicas with identical rule files.</li>
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->Alertmanager clustered with <code>--cluster.peer</code>; the cluster port reachable between instances.</li>
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->Every Prometheus lists every Alertmanager. No load balancer in between.</li>
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->The replica label dropped in <code>alert_relabel_configs</code>.</li>
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->Tested: stop one replica and one Alertmanager, fire a test alert, and get exactly one notification.</li>
</ul>
<p><strong>Storage and queries</strong></p>
<ul class="contains-task-list containsTaskList_mC6p">
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->A deduplicating layer: Thanos Querier, Mimir's HA tracker or VictoriaMetrics deduplication.</li>
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->Replica labels set the way that layer expects. Thanos and Mimir need them to differ; VictoriaMetrics needs them identical.</li>
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->Dashboards and alert rules query the deduplicated endpoint, not a single replica.</li>
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->Tested: restart one replica and check that a dashboard over that window shows no gap.</li>
</ul>
<p><strong>Everything</strong></p>
<ul class="contains-task-list containsTaskList_mC6p">
<li class="task-list-item"><input type="checkbox" disabled=""> <!-- -->The monitoring system is monitored from outside itself. A dead-man's-switch alert that always fires, and pages when it <em>stops</em> arriving, catches the case where the whole alerting path is down.</li>
</ul>
<p>The last item is the one most often skipped. Every check above runs inside the system it is checking.</p>]]></content:encoded>
            <category>Prometheus</category>
            <category>High availability</category>
            <category>Alerting</category>
        </item>
        <item>
            <title><![CDATA[Symptom-based alerting with Prometheus]]></title>
            <link>https://n9e.github.io/blog/symptom-based-alerting</link>
            <guid>https://n9e.github.io/blog/symptom-based-alerting</guid>
            <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Symptom-based vs cause-based alerting with real Prometheus rules: error ratio, latency and probe alerts that page, and where CPU, disk and certificate alerts still belong.]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" alt="A greyed-out CPU alert next to a highlighted checkout error-rate alert" src="https://n9e.github.io/assets/images/symptom-based-alerting-f69a35ab01ce77da6511c872a7877225.webp" width="2400" height="1260" class="img_ev3q"></p>
<p>At 3 a.m. an alert says CPU is at 92% on <code>db-3</code>. The on-call engineer opens the dashboards. Checkout is fast, errors are flat, and nobody is affected. The alert was true and useless.</p>
<p>A week later, checkout fails for 20 minutes because a dependency returns errors. CPU is fine, memory is fine, every host looks healthy. No alert fires.</p>
<p>Both failures have the same root cause: the rules watch <em>causes</em>, not <em>symptoms</em>. This post explains the difference, shows what symptom rules look like in PromQL, and explains where cause alerts still earn their place.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="symptoms-and-causes">Symptoms and causes<a href="https://n9e.github.io/blog/symptom-based-alerting#symptoms-and-causes" class="hash-link" aria-label="Direct link to Symptoms and causes" title="Direct link to Symptoms and causes" translate="no">​</a></h2>
<p>A <strong>symptom</strong> is what users experience: requests failing, pages loading slowly, a service unreachable, data arriving late. A <strong>cause</strong> is why: a saturated CPU, a full disk, a crashed pod, a slow query.</p>
<p>The idea comes from Google's SRE book. Chapter 6, "Monitoring Distributed Systems", argues that paging should be driven by symptoms, and proposes four golden signals to watch on every user-facing system: <strong>latency, traffic, errors and saturation</strong>. Rob Ewaschuk's essay "My Philosophy on Alerting", written while he was an SRE at Google, makes the same case in more detail.</p>
<p>Cause-based paging fails in two directions:</p>
<ul>
<li class=""><strong>Causes without symptoms create noise.</strong> High CPU, a pod restart, one replica out of three down. Systems absorb these all the time. Each page that turns out to be harmless teaches people to ignore the next one.</li>
<li class=""><strong>Symptoms without a known cause create silence.</strong> You can only write cause rules for failures you have imagined. A new failure mode, a dependency outage or a bad deploy that leaves every resource metric normal goes unnoticed.</li>
</ul>
<p>A symptom rule catches both the failures you predicted and the ones you didn't, because it watches the outcome instead of the mechanism.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-symptom-rules-look-like">What symptom rules look like<a href="https://n9e.github.io/blog/symptom-based-alerting#what-symptom-rules-look-like" class="hash-link" aria-label="Direct link to What symptom rules look like" title="Direct link to What symptom rules look like" translate="no">​</a></h2>
<p>The metric names below follow common conventions. Yours will depend on your instrumentation library, ingress controller or service mesh.</p>
<p><strong>Errors: the share of requests that fail.</strong></p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">sum(rate(http_requests_total{job="checkout", code=~"5.."}[5m]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  /</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">sum(rate(http_requests_total{job="checkout"}[5m]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">&gt; 0.02</span><br></div></code></pre></div></div>
<p>Use a ratio, not a count. 50 errors a minute is a disaster at 100 requests and noise at 100,000.</p>
<p><strong>Latency: how slow the slow requests are.</strong></p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">histogram_quantile(0.99,</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  sum by (le) (rate(http_request_duration_seconds_bucket{job="checkout"}[5m]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">) &gt; 1</span><br></div></code></pre></div></div>
<p>Alert on a high percentile, not the average. An average of 200 ms can hide one request in fifty taking ten seconds.</p>
<p><strong>Availability from outside.</strong> A probe that calls the service the way a user would catches DNS, TLS, load balancer and network failures that internal metrics never see. With the Prometheus blackbox exporter that is <code>probe_success == 0</code>. Our <a class="" href="https://n9e.github.io/alert-rules/linux">Linux rule set</a> includes an "HTTP probe failed" rule for Categraf's HTTP checks.</p>
<p><strong>Freshness and throughput</strong> for things that are not request-driven. Did the nightly job run? Is the queue draining?</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">time() - batch_job_last_success_timestamp_seconds{job="nightly-export"} &gt; 26 * 3600</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="three-details-that-make-symptom-rules-work">Three details that make symptom rules work<a href="https://n9e.github.io/blog/symptom-based-alerting#three-details-that-make-symptom-rules-work" class="hash-link" aria-label="Direct link to Three details that make symptom rules work" title="Direct link to Three details that make symptom rules work" translate="no">​</a></h3>
<ol>
<li class="">
<p><strong>Measure where users are.</strong> The ingress or load balancer sees every request, including the ones that never reached a healthy pod. Per-pod error rates miss requests that were dropped before reaching any pod.</p>
</li>
<li class="">
<p><strong>Aggregate across instances.</strong> <code>sum(...)</code> over the whole service, not one alert per pod. Users don't care which replica failed them.</p>
</li>
<li class="">
<p><strong>Guard against low traffic.</strong> At three requests a minute, one failure is a 33% error ratio. Add a minimum volume:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">(</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  sum(rate(http_requests_total{job="checkout", code=~"5.."}[5m]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    / sum(rate(http_requests_total{job="checkout"}[5m]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">) &gt; 0.02</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">and</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">sum(rate(http_requests_total{job="checkout"}[5m])) &gt; 1</span><br></div></code></pre></div></div>
</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-cause-alerts-still-belong">Where cause alerts still belong<a href="https://n9e.github.io/blog/symptom-based-alerting#where-cause-alerts-still-belong" class="hash-link" aria-label="Direct link to Where cause alerts still belong" title="Direct link to Where cause alerts still belong" translate="no">​</a></h2>
<p>"Alert on symptoms" doesn't mean "delete every cause alert". Some causes deserve a notification. They just shouldn't wake anyone up.</p>
<p><strong>Deadlines.</strong> A disk that will be full in 24 hours, or a certificate that expires in 7 days, is a symptom waiting to happen, with a known date. Alert on these during working hours, at warning severity. The Linux library has a <code>predict_linear</code> rule for disks; the Kubernetes library has <a class="" href="https://n9e.github.io/alert-rules/kubernetes/kubeclientcertificateexpiration">certificate expiry</a> rules.</p>
<p><strong>Lost redundancy.</strong> One replica of three is down. Users are fine, but the next failure will hurt them. That is worth a ticket today, not a page tonight.</p>
<p><strong>The monitoring itself.</strong> If a scrape target or collector stops reporting, every rule that depends on it looks healthy. <code>up == 0</code>, or a dead-man's-switch alert that always fires, is a cause alert you need from day one.</p>
<p><strong>Explanation.</strong> Everything else, such as CPU, memory, GC pauses and connection counts, belongs on a dashboard or as a low-severity alert that someone looks at while investigating a symptom. The Kubernetes rules in our library follow this: "Node CPU usage exceeds 90%" ships at <strong>info</strong> severity, while the symptom-shaped rules such as <a class="" href="https://n9e.github.io/alert-rules/kubernetes/kubeapierrorbudgetburn">KubeAPIErrorBudgetBurn</a> go up to critical.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="connecting-symptoms-to-causes">Connecting symptoms to causes<a href="https://n9e.github.io/blog/symptom-based-alerting#connecting-symptoms-to-causes" class="hash-link" aria-label="Direct link to Connecting symptoms to causes" title="Direct link to Connecting symptoms to causes" translate="no">​</a></h2>
<p>A symptom alert tells you that something is wrong. The responder then needs to know where to look. Two ways to close that gap:</p>
<ul>
<li class=""><strong>Put the causes in the runbook.</strong> Every symptom alert should link to a list of likely causes and the commands that confirm each one. Each page in our <a class="" href="https://n9e.github.io/alert-rules">alert rule library</a> follows that shape: what the alert means, common causes, then the steps to fix it. <a class="" href="https://n9e.github.io/alert-rules/kubernetes/kubepodcrashlooping">KubePodCrashLooping</a> is a typical example.</li>
<li class=""><strong>Mute symptoms while a known cause is active.</strong> If a whole site loses its uplink, every service there reports errors. Alertmanager's <code>inhibit_rules</code>, or an equivalent, can mute the dependent alerts while the upstream alert is firing. That needs a shared label such as <code>site</code> on both.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-worked-example">A worked example<a href="https://n9e.github.io/blog/symptom-based-alerting#a-worked-example" class="hash-link" aria-label="Direct link to A worked example" title="Direct link to A worked example" translate="no">​</a></h2>
<p>Here is a rule set for a checkout service built on these ideas:</p>
<table><thead><tr><th>Rule</th><th>Kind</th><th>Severity</th><th>Who hears about it</th></tr></thead><tbody><tr><td>Error ratio above 2% for 5 minutes</td><td>Symptom</td><td>Critical</td><td>Page</td></tr><tr><td>p99 latency above 1 s for 10 minutes</td><td>Symptom</td><td>Critical</td><td>Page</td></tr><tr><td>External probe failing from 2 or more locations</td><td>Symptom</td><td>Critical</td><td>Page</td></tr><tr><td>Error ratio above 0.5% for 30 minutes</td><td>Symptom, slow burn</td><td>Warning</td><td>Team channel</td></tr><tr><td>Disk full within 24 hours</td><td>Cause with a deadline</td><td>Warning</td><td>Team channel</td></tr><tr><td>One of three replicas down for 15 minutes</td><td>Lost redundancy</td><td>Warning</td><td>Team channel</td></tr><tr><td>Scrape target down</td><td>Monitoring</td><td>Warning</td><td>Team channel</td></tr><tr><td>CPU, memory, GC, DB connections</td><td>Cause</td><td>Info or dashboard</td><td>Nobody, until someone investigates</td></tr></tbody></table>
<p>Three rules can page, and all three describe something a customer would notice.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="auditing-a-rule-set-you-already-have">Auditing a rule set you already have<a href="https://n9e.github.io/blog/symptom-based-alerting#auditing-a-rule-set-you-already-have" class="hash-link" aria-label="Direct link to Auditing a rule set you already have" title="Direct link to Auditing a rule set you already have" translate="no">​</a></h2>
<p>For each rule that can page, ask: <em>if this fires and users are fine, would anyone do anything?</em> If the answer is no, lower its severity or move it to a dashboard.</p>
<p>Then look at the data. List the rules that fired most often in the last month and how many of those firings led to an action. Also list the rules that never fired in a year. They are usually broken rather than lucky: a renamed metric, a label that changed, a threshold that cannot be reached. Nightingale's built-in assistant can answer both questions directly ("which rules fired most often in the last 7 days, and which have never fired?"); see <a class="" href="https://n9e.github.io/docs/ai/workflows#find-the-noise">Common investigation workflows</a>.</p>
<p>For the rules that stay, the <a class="" href="https://n9e.github.io/docs/alerting/best-practices">rule design best practices</a> page covers naming, labels and how much one rule should cover.</p>]]></content:encoded>
            <category>Alerting</category>
            <category>Prometheus</category>
        </item>
        <item>
            <title><![CDATA[Migrating from Zabbix to Prometheus]]></title>
            <link>https://n9e.github.io/blog/migrate-zabbix-to-prometheus</link>
            <guid>https://n9e.github.io/blog/migrate-zabbix-to-prometheus</guid>
            <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A concept-by-concept map from Zabbix to Prometheus: hosts, items, triggers, LLD, dependencies, maintenance. Plus the gaps with no equivalent and a migration order that never turns Zabbix off.]]></description>
            <content:encoded><![CDATA[<p><img decoding="async" loading="lazy" alt="From Zabbix hosts, items and triggers to Prometheus targets, metrics and one alert rule covering every host" src="https://n9e.github.io/assets/images/zabbix-to-prometheus-ab76e9ac8ebf08826765513529b49986.webp" width="2400" height="1260" class="img_ev3q"></p>
<p>Moving from Zabbix to Prometheus is not a data migration. There is no export file that turns into rule files, and no tool that converts triggers into PromQL. The two systems model monitoring differently. Most of the work is translating concepts, then rebuilding a smaller set of rules on top of them.</p>
<p>This post maps each Zabbix concept to its Prometheus counterpart, lists the parts that have no equivalent, and suggests an order of work that keeps Zabbix running until the end.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-difference-that-explains-everything-else">The difference that explains everything else<a href="https://n9e.github.io/blog/migrate-zabbix-to-prometheus#the-difference-that-explains-everything-else" class="hash-link" aria-label="Direct link to The difference that explains everything else" title="Direct link to The difference that explains everything else" translate="no">​</a></h2>
<p><strong>Zabbix is host-centric.</strong> You create a host, attach templates, and the templates define items (what to collect) and triggers (when to alert) for that host. 40 database servers with one template give you 40 copies of each trigger.</p>
<p><strong>Prometheus is query-centric.</strong> Exporters expose metrics, Prometheus scrapes them, and an alert rule is a query. A rule matches every series the query returns. One rule covers all 40 servers, and the 41st server is covered as soon as it is scraped.</p>
<p>Most of the translation follows from this. You rarely port triggers one by one; you merge them.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="concept-map">Concept map<a href="https://n9e.github.io/blog/migrate-zabbix-to-prometheus#concept-map" class="hash-link" aria-label="Direct link to Concept map" title="Direct link to Concept map" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="collection-and-storage">Collection and storage<a href="https://n9e.github.io/blog/migrate-zabbix-to-prometheus#collection-and-storage" class="hash-link" aria-label="Direct link to Collection and storage" title="Direct link to Collection and storage" translate="no">​</a></h3>
<table><thead><tr><th>Zabbix</th><th>Prometheus</th><th>Notes</th></tr></thead><tbody><tr><td>Zabbix server and database</td><td>Prometheus server and its local TSDB</td><td>Local retention is short by default (15 days). Long-term storage is a separate component</td></tr><tr><td>Zabbix agent</td><td>Exporters: node_exporter for hosts, mysqld_exporter for MySQL and so on</td><td>Prometheus <em>pulls</em> over HTTP; the agent's active checks <em>push</em>. Targets must be reachable from Prometheus</td></tr><tr><td>Zabbix proxy</td><td>A Prometheus server per site, or Prometheus in agent mode with <code>remote_write</code></td><td>Agent mode scrapes and forwards without evaluating rules locally</td></tr><tr><td>Item</td><td>A metric name plus labels</td><td>Which metrics exist is decided by the exporter, not by the monitoring server</td></tr><tr><td>Item key <code>vfs.fs.size[/,pfree]</code></td><td><code>node_filesystem_avail_bytes{mountpoint="/"}</code> ÷ <code>node_filesystem_size_bytes{mountpoint="/"}</code></td><td>Percentages are usually computed in the query</td></tr><tr><td>Calculated item</td><td>Recording rule</td><td></td></tr><tr><td>History and trends</td><td>Raw samples; downsampling only in long-term stores such as Thanos</td><td>There is no built-in hourly min/avg/max like Zabbix trends</td></tr><tr><td>Text and log items</td><td>None</td><td>Prometheus stores numbers only. Logs go to Loki, Elasticsearch or similar</td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="hosts-templates-and-discovery">Hosts, templates and discovery<a href="https://n9e.github.io/blog/migrate-zabbix-to-prometheus#hosts-templates-and-discovery" class="hash-link" aria-label="Direct link to Hosts, templates and discovery" title="Direct link to Hosts, templates and discovery" translate="no">​</a></h3>
<table><thead><tr><th>Zabbix</th><th>Prometheus</th><th>Notes</th></tr></thead><tbody><tr><td>Host</td><td>A scrape target, identified by the <code>instance</code> label</td><td></td></tr><tr><td>Host group</td><td>Labels such as <code>team</code> or <code>env</code>, added through relabeling</td><td>Groups become labels you can filter on</td></tr><tr><td>Template</td><td>An exporter, a rule file and a dashboard</td><td>The community equivalent of a template collection is a monitoring mixin</td></tr><tr><td>Low-level discovery (LLD)</td><td>Service discovery for targets; labels for things on a target</td><td>New filesystems or interfaces appear as new series automatically. No discovery rule needed</td></tr><tr><td>User macros <code>{$THRESHOLD}</code></td><td>Hard-coded thresholds, or a threshold metric joined in the query</td><td>Per-host overrides need a pattern, see below</td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="triggers-and-alerting">Triggers and alerting<a href="https://n9e.github.io/blog/migrate-zabbix-to-prometheus#triggers-and-alerting" class="hash-link" aria-label="Direct link to Triggers and alerting" title="Direct link to Triggers and alerting" translate="no">​</a></h3>
<table><thead><tr><th>Zabbix</th><th>Prometheus</th><th>Notes</th></tr></thead><tbody><tr><td>Trigger</td><td>Alerting rule: a PromQL <code>expr</code> plus <code>for</code></td><td>One rule, many series</td></tr><tr><td>Trigger severity (six levels)</td><td>A <code>severity</code> label</td><td>A convention, not a built-in. Most teams use three: critical, warning, info</td></tr><tr><td>Recovery expression, hysteresis</td><td>No direct equivalent</td><td><code>keep_firing_for</code> delays recovery; true hysteresis needs a query trick</td></tr><tr><td><code>nodata()</code></td><td><code>absent()</code>, <code>absent_over_time()</code>, or <code>up == 0</code></td><td><code>absent()</code> only works for series you name explicitly</td></tr><tr><td>Trigger dependencies</td><td>Alertmanager <code>inhibit_rules</code></td><td>Inhibition mutes target alerts while a source alert fires, matched on labels</td></tr><tr><td>Event correlation</td><td>None in Prometheus or Alertmanager</td><td></td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="notification">Notification<a href="https://n9e.github.io/blog/migrate-zabbix-to-prometheus#notification" class="hash-link" aria-label="Direct link to Notification" title="Direct link to Notification" translate="no">​</a></h3>
<table><thead><tr><th>Zabbix</th><th>Prometheus</th><th>Notes</th></tr></thead><tbody><tr><td>Action and operations</td><td>Alertmanager routes and receivers</td><td>Routing matches on labels</td></tr><tr><td>Media type</td><td>Receiver integration: email, Slack, webhook and others</td><td></td></tr><tr><td>Escalation steps</td><td>Not built in</td><td>Alertmanager repeats notifications (<code>repeat_interval</code>) but has no escalation chain. That is an on-call tool's job</td></tr><tr><td>Maintenance period</td><td>Silences, plus <code>mute_time_intervals</code> for recurring windows</td><td>Silences are ad hoc; time intervals cover the weekly window</td></tr><tr><td>Acknowledgement</td><td>None in Alertmanager</td><td>Usually handled in the on-call or chat tool</td></tr></tbody></table>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-parts-with-no-equivalent">The parts with no equivalent<a href="https://n9e.github.io/blog/migrate-zabbix-to-prometheus#the-parts-with-no-equivalent" class="hash-link" aria-label="Direct link to The parts with no equivalent" title="Direct link to The parts with no equivalent" translate="no">​</a></h2>
<p>Plan for these before you start. They are where migrations stall.</p>
<ol>
<li class="">
<p><strong>Escalations.</strong> Zabbix can notify the on-call engineer, then the team lead after 30 minutes, then the manager. Alertmanager cannot. If you rely on escalation, you need an on-call tool behind Alertmanager.</p>
</li>
<li class="">
<p><strong>Per-host thresholds.</strong> In Zabbix you override <code>{$DISK_PFREE_MIN}</code> on one host. In Prometheus a threshold is part of the query. The common workaround is a small metric that carries thresholds, joined in the rule:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">(node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  &lt; on(instance) group_left()</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">disk_free_threshold_percent</span><br></div></code></pre></div></div>
<p>Or keep a default rule and split the exceptions out by label: <code>disk_profile="large"</code> gets its own rule.</p>
</li>
<li class="">
<p><strong>SNMP traps.</strong> The SNMP exporter polls; it does not receive traps. Traps need a separate receiver that turns them into metrics or logs.</p>
</li>
<li class="">
<p><strong>Text and log items.</strong> If Zabbix watches log files or string values, those move to a log system, not Prometheus.</p>
</li>
<li class="">
<p><strong>Trigger dependencies, partly.</strong> <code>inhibit_rules</code> cover the common case of "switch down mutes everything behind it". They need a label that links the two alerts, such as a shared <code>site</code> or <code>rack</code>. Zabbix's dependency tree has no labels to rely on, so you add them first.</p>
</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="an-order-that-keeps-zabbix-running">An order that keeps Zabbix running<a href="https://n9e.github.io/blog/migrate-zabbix-to-prometheus#an-order-that-keeps-zabbix-running" class="hash-link" aria-label="Direct link to An order that keeps Zabbix running" title="Direct link to An order that keeps Zabbix running" translate="no">​</a></h2>
<p>Zabbix stays up and keeps paging people until the very last step. Each step can be checked on its own.</p>
<ol>
<li class=""><strong>Deploy exporters next to the Zabbix agents.</strong> Start with one group of hosts. Leave the agents installed. <em>Check</em>: the targets are <code>up</code> in Prometheus.</li>
<li class=""><strong>Pick the storage you will keep.</strong> A single Prometheus for a start; a pair and a long-term store if you need HA or longer retention. Decide now, because changing stores later is a project of its own.</li>
<li class=""><strong>Import existing rule sets instead of porting templates.</strong> The community rules for node_exporter, MySQL, Redis, Kubernetes and others cover most of what stock Zabbix templates do. Our <a class="" href="https://n9e.github.io/alert-rules">alert rule library</a> has them as downloadable rule files, each with a runbook.</li>
<li class=""><strong>Port the custom triggers, merged.</strong> List your custom triggers, group them by item and threshold, and write one rule per group. Use labels for the exceptions.</li>
<li class=""><strong>Send Prometheus alerts somewhere nobody is paged.</strong> A dedicated channel or mailbox. Compare it with Zabbix every day: fired only in Zabbix (missed rule or metric), fired only in Prometheus (threshold typo, or a Zabbix dependency or maintenance window you forgot), fired in both at different severity.</li>
<li class=""><strong>Move notification one channel at a time.</strong> Chat first, then email, then phone. Disable the matching Zabbix action as each channel moves, but keep Zabbix evaluating so the daily comparison continues.</li>
<li class=""><strong>Stop Zabbix last.</strong> After a week with no differences, stop the agents. Keep the database read-only for as long as you need the history. History does not migrate.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="zabbix-or-prometheus-at-all">Zabbix or Prometheus at all?<a href="https://n9e.github.io/blog/migrate-zabbix-to-prometheus#zabbix-or-prometheus-at-all" class="hash-link" aria-label="Direct link to Zabbix or Prometheus at all?" title="Direct link to Zabbix or Prometheus at all?" translate="no">​</a></h2>
<p>Not every Zabbix setup should move. Zabbix suits fleets of long-lived hosts and network devices, teams that want everything configured in one UI, and environments that need agents to push through firewalls. Prometheus suits environments where targets come and go (containers, autoscaling), where configuration lives in Git, and where software already exposes <code>/metrics</code>. Many organisations run both for years: Zabbix for network gear and legacy hosts, Prometheus for Kubernetes and new services.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="with-nightingale">With Nightingale<a href="https://n9e.github.io/blog/migrate-zabbix-to-prometheus#with-nightingale" class="hash-link" aria-label="Direct link to With Nightingale" title="Direct link to With Nightingale" translate="no">​</a></h2>
<p><a class="" href="https://n9e.github.io/docs/migration/from-zabbix">Nightingale</a> sits on top of Prometheus or VictoriaMetrics as the alerting layer. It manages rules in a UI with business groups and permissions, and handles notification, muting and subscriptions, so step 6 does not require learning Alertmanager's routing tree. It does <strong>not</strong> import Zabbix templates or triggers; the rebuild described above still applies.</p>
<p>Two Nightingale pages go deeper:</p>
<ul>
<li class=""><a class="" href="https://n9e.github.io/docs/migration/from-zabbix">From Zabbix</a>: every Zabbix object mapped to a Nightingale one, including the ones with no equivalent.</li>
<li class=""><a class="" href="https://n9e.github.io/docs/migration/coexistence">Coexistence and rollback plan</a>: the daily comparison and channel-by-channel cutover in detail, with a rollback for each stage.</li>
</ul>
<p>If you are still choosing, see <a class="" href="https://n9e.github.io/comparisons/zabbix">Nightingale vs Zabbix</a>.</p>]]></content:encoded>
            <category>Migration</category>
            <category>Prometheus</category>
            <category>Alerting</category>
        </item>
    </channel>
</rss>