Alert Architecture#
This page describes how an alert moves through materialize-monitoring: evaluated by one of two rulers, sent to every
replica of the bundled Alertmanager, routed by a configuration the chart renders from alerting, and delivered to a receiver.
It covers why the notifier is shaped the way it is, what state it holds, and what happens to that state when things fail.
The other pages in this section cover each part in depth:
| Page | Covers |
|---|---|
| Configuring | The two rule evaluators, what each reads, and how each is wired |
| Alert Channels | Receivers, receiver classes, severity presets, credentials, and extra routes |
| Maintenance Windows | Silences, recurring mute windows, and inhibition |
The production checklist for this component is Production Best Practices > Alertmanager.
End-to-end flow#
flowchart TB
subgraph eval["Evaluation"]
truler["Thanos Ruler<br/>PromQL"]
lruler["Loki Ruler<br/>LogQL"]
end
hs["alertmanager-headless<br/>one address per ready replica"]
subgraph am["Alertmanager — 2 replicas"]
am0["replica 0<br/>zone A · own volume"]
am1["replica 1<br/>zone B · own volume"]
am0 <-->|"gossip :9094 TCP + UDP<br/>silences · notification log"| am1
end
cfg["alertmanager-config Secret<br/>rendered from alerting.*"]
creds["alertmanager-receivers Secret<br/>receiver credentials"]
recv["Receivers<br/>Slack · PagerDuty · webhook · email · …"]
svc["alertmanager<br/>ClusterIP Service"]
graf["Grafana<br/>Alertmanager datasource"]
gw["alloy-gateway<br/>ServiceMonitor scrape"]
truler -->|"dns+ lookup"| hs
lruler -->|"SRV lookup"| hs
hs -->|"every alert"| am0
hs -->|"every alert"| am1
cfg -.->|"mounted · reloaded in place"| am0
cfg -.-> am1
creds -.->|"*_file"| am0
creds -.-> am1
am0 --> recv
am1 --> recv
graf -->|"alerts · silences"| svc
gw -->|"/metrics"| svc
svc --- am0
svc --- am1Five properties of that shape are the design.
Alertmanager is the single notification surface. Both rulers notify it, so an operator configuring where alerts go configures one thing, whether the alert came from a metric or a log line.
Every replica receives every alert. The rulers resolve the headless Service to one address per replica and send each alert to all of them. See Why the rulers address every replica.
The replicas gossip, and deduplicate. Silences and the notification log replicate between them, so a notification goes out once and either replica can send it.
The configuration is rendered, not written.
The chart builds Alertmanager’s configuration from alerting, checks it at render time, and reloads it in place.
See Configuration.
Nothing about alerting lives in Grafana.
Grafana reads and writes Alertmanager’s alerts and silences through a datasource, and keeps none of that state itself.
It also lists the Loki ruler’s rules and their state, read-only, through the Loki datasource.
That datasource points at the Loki Gateway, which routes Grafana’s rule-list requests to the ruler and refuses rule edits, because the alloy-gateway owns the rule definitions.
Why the rulers address every replica#
Alertmanager’s gossip replicates two things between replicas: silences and the notification log, which records what was sent to whom and when. It does not replicate alerts. A replica knows about an alert only if a ruler sent that alert to it.
That makes the address a ruler notifies part of the HA design rather than a detail.
| Ruler notifies | What happens when one replica is lost |
|---|---|
| The load-balanced ClusterIP Service | Each send reaches one replica, and a kept-alive connection can pin every send to the same one. The survivor may hold none of the firing alerts, and notifies nothing until the ruler’s next resend reaches it. |
| Every replica, through the headless Service | Both replicas hold every firing alert. The survivor carries on notifying with no gap. |
So both rulers address the headless Service through DNS discovery, and re-resolve it as replicas come and go.
| Ruler | Setting | Discovery |
|---|---|---|
| Thanos Ruler | thanos.ruler.alertmanagers.config | dns+alertmanager-headless.<namespace>.svc.<domain>:9093, an A-record lookup re-resolved every 30s |
| Loki Ruler | loki.loki.rulerConfig.alertmanager_url with enable_alertmanager_discovery: true | http://_http._tcp.alertmanager-headless.<namespace>.svc.<domain>, an SRV lookup re-resolved every alertmanager_refresh_interval (30s) |
The headless Service publishes only ready replicas, so a replica that is starting or terminating drops out of both rulers’ target lists on its own.
The Loki ruler creates a tenant’s notifier only once that tenant has rule groups.
Until then it has discovered nothing, and loki_prometheus_notifications_alertmanagers_discovered is absent rather than zero.
How two replicas send one notification#
Each replica evaluates the same routing tree against the same alerts and would notify independently. Gossip is what stops that.
A replica sends a group’s notification only after waiting its position in the cluster multiplied by
--cluster.peer-timeout (15s by default), and only if the notification log does not already record that notification as sent.
The first replica sends at once and gossips the log entry; the second finds it and stays quiet.
If the first replica is down or cannot deliver, the second sends 15 seconds later.
This depends on the mesh port being open on both TCP and UDP.
Memberlist joins peers and pushes full state over TCP, and gossips updates over UDP.
With either one blocked the replicas do not converge, and every notification goes out once per replica.
The failure presents as duplicate pages, which reads like a routing problem rather than a network one.
The chart’s NetworkPolicy opens 9094 on both transports, and a validator warns when a narrowed policy does not.
High availability#
Two replicas is the default shape rather than a hardening step. A single Alertmanager is lost to an ordinary node drain, holds the only copy of every silence, and cannot report its own absence. A second replica survives the first and can report it gone.
Three is not the default. Gossip tolerates a partition by notifying from both sides rather than by electing a leader, so a third replica adds little availability. It adds a third copy of every notification whenever gossip is broken.
| Setting | Default | Role |
|---|---|---|
alertmanager.replicaCount | 2 | Above one, the subchart passes --cluster.peer for every ordinal and opens the mesh ports on the headless Service |
alertmanager.podDisruptionBudget | maxUnavailable: 1 | A drain evicts one replica at a time |
alertmanager.topologySpreadConstraints | hard across zones, soft across hosts | Keeps the two replicas out of one failure domain |
alertmanager.extraArgs.cluster.label | materialize-monitoring | Stamped on every gossip message, so a peer from another Alertmanager cluster that inherits a recycled pod IP is rejected |
alertmanager.readinessProbe | /-/ready | A StatefulSet rollout replaces one replica at a time and waits on it |
What each failure costs#
| Failure | Outcome |
|---|---|
| One replica restarts, or its node is drained | The other keeps notifying. The replacement joins the mesh and pulls its peer’s silences and notification log before it serves. |
| One zone is lost | The replica in that zone stays Pending until the zone returns, because its volume is zonal. The other replica, in another zone, keeps notifying. |
| Both replicas are lost at once | Each comes back from its own volume with the silences and notification log it last saved. Nothing is lost that was written before the failure. |
| The mesh port is blocked | Both replicas notify independently, so every notification arrives twice. Nothing is dropped. |
| A configuration Alertmanager rejects | The previous configuration keeps running, and alertmanager_config_last_reload_successful drops to 0. On a fresh start, the pods crash-loop instead. |
Zones and volumes#
The zone rule is whenUnsatisfiable: DoNotSchedule.
A replica that cannot satisfy it goes Pending, which is the signal the cluster autoscaler reads to add a node in the deficient zone.
Each replica’s volume is zonal, which is compatible with a hard rule only under volumeBindingMode: WaitForFirstConsumer.
That binding mode creates each volume in whichever zone the scheduler placed its pod, so each replica lands in its own zone and stays there.
It is the default for every managed-cloud CSI class.
Under Immediate binding both volumes can be provisioned in one zone before scheduling, and the second replica then
stays Pending while the first keeps notifying.
Two differences from the zone rule on Thanos Receive are deliberate.
| Difference | Why |
|---|---|
No minDomains | A single-zone cluster schedules both replicas. Only a cluster whose nodes carry no zone label at all needs no-zone-spread, or min_zones = 0 on Terraform. |
No matchLabelKeys: [controller-revision-hash] | It would let a new replica ignore an old-revision peer when first scheduled, which is exactly what an upgrade from one replica to two does. The new replica could land in the peer’s zone, and the peer — pinned there by its volume — would then never reschedule. |
State and storage#
Alertmanager holds three kinds of state.
| State | Created by | Replicated by gossip | Survives losing both replicas |
|---|---|---|---|
| Alerts | The rulers, resent every evaluation | No — each replica receives them directly | Rebuilt from the rulers’ next resend |
| Silences | Operators, through Grafana or amtool | Yes | Yes, from each replica’s volume |
| Notification log | Alertmanager, on each notification | Yes | Yes, from each replica’s volume |
Gossip is what makes one replica’s loss invisible. The volume is what makes losing both survivable: silences exist nowhere else, and without the notification log the first evaluation after a total loss re-sends every notification that had already gone out.
Each replica keeps a 4Gi volume.
Alertmanager needs kilobytes; 4Gi is the smallest disk GCP Hyperdisk and Azure managed disks provision.
Both the volume’s existence and its size render into the StatefulSet’s volumeClaimTemplates, which Kubernetes refuses to change after install.
Changing either requires deleting the StatefulSet with --cascade=orphan before the upgrade.
--data.retention (120h) is how long expired silences and notification-log entries are kept.
The cluster label#
Every alert carries a cluster label naming the Kubernetes cluster it came from.
The value is clusterName, which Terraform’s cluster_name sets, and which is also stamped on every log
line and every metric this stack collects.
| Ruler | How it stamps cluster |
|---|---|
| Thanos Ruler | --label=cluster="$(CLUSTER_NAME)", an external label, added to every alert it sends and every sample it writes |
| Loki Ruler | An alert_relabel_configs entry, because the Loki ruler has no external labels |
Both fill the label only where an alert lacks it.
An alert whose expression kept cluster from its series keeps that value, which is the same one wherever the series came from this cluster.
Neither subchart can read pipeline.env, so the chart renders the name into a ruler-env ConfigMap in each ruler’s
namespace, and each ruler reads it as an environment variable.
Within one cluster the label is constant, and it matters as soon as two clusters share anything downstream.
| Shared downstream | Without cluster |
|---|---|
| A chat channel | A notification does not say which cluster it is about |
| An incident tool | An Alertmanager group key is built from the route and the group labels, and PagerDuty’s dedup_key and Opsgenie’s alias are hashes of it. Two clusters sending one condition open one incident, and either cluster’s resolution closes it |
| One Alertmanager | Identical label sets from two clusters are the same alert, so each overwrites the other’s state |
The default alerting.routes.root.group_by includes cluster for the second row.
All three depend on clusterName being unique per cluster.
Left at its default, default, on several clusters, the label is present and distinguishes nothing.
Configuration#
The chart renders Alertmanager’s configuration itself, from alerting, rather than passing it to the subchart.
The subchart renders its config block into a ConfigMap with toYaml, so nothing in it can be computed.
The severity presets, the receiver classes and the render-time checks all have to be.
So alertmanager.config.enabled is off, and the chart renders the alertmanager-config Secret and mounts it at /etc/alertmanager/config.
flowchart LR
values["alerting.*<br/>(values)"] --> tpl["templates/alertmanager-config.yaml"]
tpl --> val{"render-time checks"}
val -->|"error"| fail["helm install fails"]
val --> secret["alertmanager-config Secret"]
secret -->|"kubelet refresh"| mount["/etc/alertmanager/config"]
mount --> reloader["config-reloader sidecar"]
reloader -->|"POST /-/reload"| am["Alertmanager"]A change to alerting reaches the running replicas without a restart.
The kubelet refreshes the mounted Secret, the config-reloader sidecar sees the change and asks Alertmanager to reload,
and the whole path takes seconds to about a minute.
The sidecar is needed because the subchart rolls its pods on a checksum of the ConfigMap it no longer renders.
The Secret also carries the chart’s notification templates, mzmon.gotmpl, ahead of the files under alerting.templates.
They replace Alertmanager’s built-in subjects, Slack body and links, and the links point at Grafana’s alerting pages.
The chart writes Grafana’s address into them at render time, from the same setting Grafana builds its own links from.
Alert Channels describes them.
alertmanager.baseURL is left empty by default and is not pointed at Grafana: Alertmanager builds links into its own UI
from it, and Grafana serves none of those paths.
extraArgs.web.route-prefix: / keeps every endpoint at the root whatever baseURL is set to.
The routing tree#
The rendered tree has the same shape in every install.
| Order | Route | Source |
|---|---|---|
| 1 | Each route in alerting.routes.extra, verbatim | The operator |
| 2 | One route per severity in the selected preset, delivering that severity’s class | alerting.preset, alerting.presets and the receivers’ class |
| 3 | A catch-all delivering the class of alerting.unknownSeverity | So an alert with no recognised severity still reaches somebody |
The root route’s receiver is mzmon-null, which notifies nobody.
While alerting.receivers is empty, rows 2 and 3 are left out and every alert lands there.
Alerts on mzmon-null still fire and still show in Alertmanager and Grafana.
A class served by one receiver is a single route.
A class served by several is a parent route with one child per receiver, each set to continue, because an Alertmanager
route names exactly one receiver.
Alert Channels covers configuring each part.
What is checked, and when#
| Check | Where | Catches |
|---|---|---|
| Structural validators | Every helm template and helm install | An unroutable severity, an undefined receiver or time interval, an inline credential, a credential file no volume mounts, a receiver key that is not an integration, a template Alertmanager would not load, a Grafana URL that is not absolute |
amtool check-config | CI, over representative scenarios (make alertmanager-config-check) | The chart generating a tree Alertmanager rejects |
amtool template render | The same CI run, over the same scenarios | A notification template that parses but fails when a notification is sent |
| Alertmanager itself | Each reload | Anything else in a receiver body, which the chart passes through without modelling |
The chart does not model receiver types.
A receiver’s config passes through verbatim, which is why every integration Alertmanager supports works, and why the last row exists.
Credentials#
Receiver credentials are referenced by path, never inlined.
Nearly every Alertmanager credential field has a _file variant, and the chart mounts Secrets for those fields to read.
One Secret is mounted by default: alertmanager-receivers, at /etc/alertmanager/secrets/alertmanager-receivers/.
It is optional, so the pods start before it exists.
Alertmanager reads a *_file credential each time it sends, so creating or rotating the Secret takes effect without a restart.
The render fails when a receiver carries an inline credential, unless alerting.assertNoInlineCredentials is turned off.
It also fails when a *_file path falls under no mounted volume, since that notification would fail at send time.
Amazon SNS is the one integration that uses the pod’s cloud identity instead, through IRSA or EKS Pod Identity on the alertmanager ServiceAccount.
Cloud provider services lists the permissions for it and for the cloud email services.
TLS#
Alertmanager serves plaintext on its API port, 9093, by default.
alerting.server.tls moves that port to TLS, and profiles/mtls.values.yaml turns it on along with the settings that have to move with it.
The chart renders the listener settings into web.yml in the alertmanager-config Secret, which Alertmanager reads through --web.config.file.
Eight clients dial the port, and each one needs a matching change:
| Client | How it follows |
|---|---|
| Grafana’s datasource | The chart’s default URL follows the switch; the profile supplies the CA |
amtool inside the pod | The chart renders /etc/amtool/config.yml with the URL and the CA that issued Alertmanager’s certificate |
| Liveness and readiness probes | scheme: HTTPS, set by the profile |
| The config reloader | reload-url, set by the profile |
| The ServiceMonitor | scheme and a CA, set by the profile |
| Both rulers | Their addresses and TLS settings, set by the profile |
The render fails on each client left on the other scheme. Alertmanager reads its certificate, client CA and client-auth policy on every connection, so a renewal takes effect without a restart. Whether TLS is on at all is decided at startup, so turning it on or off rolls the replicas. It also rolls every Loki component, because the Loki ruler’s settings live in the configuration all of Loki shares.
Both rulers address the headless Service and reach each replica by pod IP (Thanos) or per-pod name (Loki).
The certificate carries neither, so each ruler verifies Alertmanager as alertmanager, one of the Service names the certificate does carry.
Each ruler presents its own component’s certificate from phase 1.
| Phase | Profile | Alertmanager’s API port |
|---|---|---|
| 1 | mtls.values.yaml | TLS, no client certificate requested |
| 2 | + mtls-phase2.values.yaml | VerifyClientCertIfGiven: a presented certificate is verified, and a client presenting none is served |
| 3 | + mtls-phase3.values.yaml | Unchanged from phase 2 |
Phase 2 is as far as this port goes.
The kubelet’s probes and the config reloader dial it and cannot present a client certificate.
The render refuses RequireAndVerifyClientCert while either is in place.
Gossip between replicas, on 9094, stays plaintext.
Securing covers the phases for the whole stack.
Observing Alertmanager#
Alertmanager is scraped through a ServiceMonitor, once per replica. Both Services the subchart renders carry the same labels, so the monitor drops the headless Service’s targets to avoid scraping each replica twice.
| Series | Healthy value | Means when it is not |
|---|---|---|
alertmanager_cluster_members | 2 on each replica | A replica is down, or the mesh has partitioned |
alertmanager_config_last_reload_successful | 1 | The last configuration change was rejected, and the previous one is still running |
rate(alertmanager_notifications_failed_total[5m]) | 0 | A receiver is refusing or unreachable, per integration |
alertmanager_notifications_total | rising while alerts fire | Nothing is being delivered |
Grafana’s Alertmanager datasource (connections.datasources.alertmanager) gives the alert list and the silence editor.
It points at the ClusterIP Service, which is correct for a reader: every replica holds the same alerts and silences.
Its scheme follows alerting.server.tls.
None of these series is alerted on yet. The deadman’s switch and the meta-alerts that read them arrive with the rule set.
Resource map#
| Resource | Name | From |
|---|---|---|
| StatefulSet | alertmanager | Subchart |
| Service (ClusterIP) | alertmanager | Subchart |
| Service (headless) | alertmanager-headless | Subchart |
| PodDisruptionBudget | alertmanager | Subchart |
| ServiceMonitor | alertmanager | Subchart |
| PersistentVolumeClaim | storage-alertmanager-<ordinal> | StatefulSet, retained after uninstall |
| Secret | alertmanager-config | Chart, from alerting |
| NetworkPolicy | mzmon-alertmanager, mzmon-alertmanager-egress-dns | Chart |
| GrafanaDatasource | mzmon-alertmanager-datasource | Chart |
| ConfigMap | ruler-env, in each ruler’s namespace | Chart, from clusterName |
| Secret | alertmanager-receivers | The operator |
| Secret | mzmon-alertmanager-tls | cert-manager, when certificates.enabled is on |
The subchart’s names are pinned by alertmanager.fullnameOverride, like Loki’s, Thanos’s and Grafana’s, so they do not depend on the release name.
That is what lets both rulers address alertmanager-headless literally, from inside subcharts where this chart’s helpers cannot run.
The render warns when the rulers’ addresses and the pinned name disagree.
What is not built yet#
| Missing | Consequence |
|---|---|
| The shipped rule set | Only rules an operator applies, and the Thanos subchart’s own mixin rules, reach Alertmanager |
| A deadman’s switch, and meta-alerts on the series above | Alertmanager being unable to deliver is visible on a dashboard, and pages nobody |
| Rollout-signal inhibition | Upgrade noise is suppressed with a silence or a mute window, by hand. See Maintenance Windows |
alerting.alertmanager.mode: external | A deployment with its own Alertmanager overrides both rulers’ addresses by hand. See Configuring |
| Client certificates required on the API port | Alertmanager stops at VerifyClientCertIfGiven, because its probes and config reloader cannot present one. See TLS |
| TLS on gossip | Replicas gossip over plaintext on 9094 |
| Terraform inputs for receivers and the preset | The Terraform path configures alerting through additional_values |