Skip to content

Architecture

alertkube is a single Go binary and an event-to-alert pipeline: observe Kubernetes resources, detect bad conditions, dedupe/suppress, route, and deliver. It uses client-go informers directly; see ADR-0001.

The pipeline

flowchart TB
  subgraph Sources
    W[9 Watchers<br/>Pod · Node · Deployment · StatefulSet<br/>DaemonSet · Job · CronJob · PVC · HPA]
    RC[Alertmanager receiver<br/>POST /api/v1/receiver/alerts]
  end
  W -- emit --> EM[makeEmitter]
  RC -- toAlert --> EM
  EM --> SO[Severity overrides]
  SO --> ST[Store<br/>mute window · dedupe · resolve TTL]
  ST --> RT[Router<br/>silence · inhibition · route match]
  RT --> GR[Grouper<br/>storm folding]
  GR --> DI[Registry.Dispatch<br/>concurrent fan-out · per-sink timeout]
  DI --> SK[Sinks]
  ST <-->|snapshot| PE[(ConfigMap<br/>persistence)]
  SW[Sweeper · 30s] -->|resolve TTL · escalations| ST
Stage Package Role
Watch internal/watchers observe a resource, detect a failure condition, emit *alert.Alert
Identify internal/alert ComputeFingerprint (sha256) - stable identity / join key
Dedup internal/alert (Store) mute window, last-sent tracking, resolve TTL
Route internal/router silences, inhibitions, route → sink matching
Group internal/group storm folding (first passes, rest absorbed into a summary)
Dispatch internal/sinks (Registry) concurrent fan-out, per-sink rate limit + 15s timeout
Persist internal/persist ConfigMap snapshot, survives restarts
Sweep sweeper.go synthetic resolves, escalations, history cleanup

Main wiring: main() -> runController() -> buildWatchers() / buildSinks() -> makeEmitter().

The fingerprint is the spine

Every downstream stage depends on the alert fingerprint:

// sha256(kind|ns|name|reason), truncated to 12 hex chars
func ComputeFingerprint(kind Kind, ns, name, reason string) string

It keys dedupe, grouping, persistence, and PagerDuty/Opsgenie incident correlation. Changing it invalidates persisted state.

The suppression triple

alertkube has four suppression mechanisms:

Mechanism Where Keyed on Purpose
Mute window Store fingerprint + time don't resend the same alert within N seconds
Silence Router label matchers + until suppress matching alerts until a timestamp
Inhibition Router source alert active alert A suppresses dependent alert B
Annotation silence Router alert-silence-until a workload silences itself (can be disabled)

See silence vs inhibition vs mute window.

Two sink families

All sinks implement Name, Send, and Supports. They split into two families:

  • HTTP-push sinks: Slack, Teams, Discord, Telegram, generic webhook.
  • Stateful incident sinks: PagerDuty and Opsgenie, keyed by fingerprint.

Stateful sinks must receive every resolve and must never receive grouped summaries. internal/app/pipeline.go enforces that split (statefulSinks + dropStateful/keepStateful).

Durability

internal/persist snapshots active alerts and mute history to a ConfigMap. Restarts still send pending resolves and do not re-page standing conditions. Snapshots strip Details and enforce a size guard; see ADR-0003.

High availability

With leaderElection.enabled=true, a coordination.k8s.io Lease ensures only the leader dispatches. Followers serve metrics and health while /readyz stays 503 until they acquire leadership. See run alertkube in HA.

Diagrams

Components

Producers on the left, delivery on the right. shardGate is the ownership boundary: with sharding off it is a no-op, so a single replica sees the straight-line pipeline.

flowchart TB
    subgraph Producers
        W["watchers/<br/>K8s informers<br/>Pod · Node · Deploy · PVC · Job<br/>DaemonSet · STS · CronJob · HPA"]
        S["sources/<br/>cloud polling<br/>aws · azure · gcp"]
        R["receiver/<br/>Alertmanager webhook<br/>POST /api/v1/receiver/alerts"]
        RU["rules/<br/>derived alerts<br/>count · all · absent"]
    end

    C["collectors/<br/>enrichment: events · logs · describe"]
    SG{{"shardGate<br/>fnv32a(kind/ns/name) mod N"}}

    subgraph Core
        ST["alert.Store<br/>dedupe · mute · TTL · escalation"]
        RT["router/<br/>first-match-wins"]
        SUP["suppression<br/>silence · crd · filter · inhibitions"]
        GR["group/<br/>storm folding"]
    end

    subgraph Delivery
        DQ["dispatcher<br/>per-worker queues + durable outbox"]
        DL["deadLetterLog"]
        REG["sinks.Registry"]
        BR["breaker<br/>failures + slow sends"]
        SK["10 sinks"]
    end

    P["persist.Store<br/>ConfigMap + gzip"]
    M["metrics/<br/>Prometheus + HandlerSlot API"]

    W --> C --> SG
    W --> SG
    S --> SG
    SG --> ST
    R --> ST
    RU --> ST
    ST --> RT --> SUP --> GR --> DQ
    DQ --> REG --> BR --> SK
    DQ -.abandoned.-> DL
    DQ <-.outbox replay.-> P
    ST <-.snapshot.-> P
    DL --> M
    ST --> M

    style SG fill:#fff3cd,stroke:#d39e00

Alert lifecycle

sequenceDiagram
    participant I as informer
    participant WA as watcher
    participant SG as shardGate
    participant ST as alert.Store
    participant RT as router
    participant GR as grouper
    participant D as dispatcher
    participant SK as sink
    participant P as persist

    I->>WA: Add/Update (panic-recovered)
    WA->>WA: nsFilter.allows(ns)?
    WA->>WA: classify → Reason + Severity
    WA->>SG: emit(alert)
    SG->>SG: owns(kind/ns/name)?
    Note over SG: foreign → AlertsSuppressed{foreign_shard}, drop
    SG->>ST: ShouldSend(fp = sha256(kind|ns|name|reason))
    Note over ST: muted → AlertsSuppressed{muted}, drop
    ST->>RT: Route(alert)
    RT->>RT: silences → inhibitions → first-match route
    RT->>GR: Offer(alert)
    Note over GR: absorbed into an open window → return
    GR->>D: enqueue(alert, route, onFail)
    D->>D: pendingAdd(id) → outbox
    D->>D: queueFor(fp) → the one worker that owns this fingerprint
    D->>SK: Dispatch fan-out (breaker-gated)
    alt delivered
        SK-->>D: 200
        D->>D: pendingDone(id)
    else all sinks failed, firing
        D->>ST: onFail() → MarkFailed(fp), rollback dedupe
    else all sinks failed, resolve
        D->>D: retry ≤3, then deadLetter
    end
    P-->>ST: sweeper (30s): generation changed → Save(gzip)

Restart and outbox replay

sequenceDiagram
    participant K as kubelet
    participant A as app.Run
    participant P as persist.Store
    participant ST as alert.Store
    participant D as dispatcher
    participant SK as sink

    K->>A: start container
    A->>A: shard.FromEnv → Lease + state names
    A->>A: metrics.Serve → /readyz 503, API slots 503
    A->>D: newDispatcher + Start (workers live before replay)
    A->>P: Load(ctx, 10s)
    P-->>A: Snapshot{Active, LastSent, RuntimeSilences, Pending}
    A->>ST: Restore(snap)
    A->>D: ReplayPending(snap.Pending, owns)
    Note over D: records owned by another shard are dropped,<br/>counted on alertkube_outbox_replay_foreign_total
    D->>SK: re-deliver owned records (at-least-once by design)
    A->>A: startInformers → WaitForCacheSync (fatal on failure)
    A->>A: MarkReady → /readyz 200

HA failover

sequenceDiagram
    participant L as pod-A (leader)
    participant LE as Lease
    participant F as pod-B (follower)
    participant CM as state ConfigMap

    Note over F: MarkReady() at startup — a follower is Ready by design,<br/>else RollingUpdate maxUnavailable:0 deadlocks
    L->>LE: renew every 5s (30s duration / 20s deadline)
    L->>CM: sweeper Save() on generation change
    L--xLE: pod dies / renew deadline blown
    Note over L: OnStoppedLeading → MarkNotReady + ClearLeaderHandlers<br/>data-plane routes → 503, not stale data
    F->>LE: acquire (≤30s leaderless window)
    F->>CM: Load snapshot
    F->>F: Restore + ReplayPending + install handlers
    F->>F: SetLeading(true) — heartbeat window starts now

Sharded deployment

Each shard is independent: its own Lease, its own state object. That is what makes "a shard can itself be a leader-elected pair" true rather than aspirational.

sequenceDiagram
    participant S0 as shard 0
    participant S1 as shard 1
    participant S2 as shard 2
    participant K as apiserver
    participant PD as PagerDuty

    Note over S0,S2: all shards watch everything; each acts only on its own bucket
    S0->>K: Lease alertkube-shard-0
    S1->>K: Lease alertkube-shard-1
    S2->>K: Lease alertkube-shard-2
    Note over K: three independent leases — every shard leads its own slice

    S0->>S0: owns(pod-x)? ✅ → emit
    S1->>S1: owns(pod-x)? ❌ → foreign_shard, drop
    S2->>S2: owns(pod-x)? ❌ → drop
    S0->>PD: deliver

    S0->>K: Save → ConfigMap alertkube-state-0
    S1->>K: Save → ConfigMap alertkube-state-1
    S2->>K: Save → ConfigMap alertkube-state-2
    Note over K: disjoint objects — no shard overwrites another's mute history

    Note over S1: after a SHARD_TOTAL rollout, pod-x moves to shard 1
    S1->>S1: replay: record for pod-x not yet owned → dropped, counted
    S1->>PD: re-evaluated on next watch event instead (no double-page)