🟡 Intermediate

Deploy Wave Thrash

Ako sa štyri paralelné Dokku deploye dokázali zreťaziť do kaskádového zlyhania cez caddy-docker-proxy, prečo sa dvadsaťtri aplikácií "odrazu" objavilo ako down, a aké tri nezávislé vrstvy obrany stačia zlikvidovať celú triedu incidentov.

TL;DR

  • Spúšťač: 4+ deploye na 4-core box naraz → load 70+, dockerd hádže broken pipe na /containers/json.
  • Kaskáda: caddy-docker-proxy vidí meniace sa labely a recreuje (znovu vytvára) caddy-caddy-1 4× za 30 s.
  • Symptóm 1: monitor vidí 23/56 apps "HTTP 000000" počas okna kde Caddy bol dole.
  • Symptóm 2: jeden aplikačný deploy dostal socket hang up na post-deploy audit → označené ako FAILED → auto-rollback na rovnaký commit (no-op) → alert.
  • Fix #1 — retry audit: post-deploy /health retry na HTTP 000/5xx (2/4/8/15 s backoff). Nezabíjaj úspešný deploy kvôli 10-sekundovému blinkovi Caddy.
  • Fix #2 — adaptive concurrency: queue worker číta os.loadavg() pred tým ako vezme slot. Load > cpus×1.5 → cap n-1. Load > cpus×3 → cap 1. Vôbec nespúšťaj pile-up.
  • Fix #3 — retry alerty: health-monitor cron nepáli Telegram na prvý fail. Retry 2× s 5 s backoff — blink sa samo zahojí.

Tri vrstvy lebo každá zachytáva iný bod zlyhania. Sama o sebe ani jedna nie je dosť.

Incident timeline

Čas v UTC. Load average z /proc/loadavg.

05:40:15  pulsecraft v1.0.1 container startup (CSP follow-up)
05:43:01  deployer queue: workflow, pulsecraft, tasks, projects → all running
05:43:53  dockerd: "failed to resolve container image" context canceled
          dockerd: "Handler for GET /containers/json ... broken pipe"
05:43:54  caddy-caddy-1 rejoins bridge + sso-auth networks (= restart #1)
05:43:58  caddy-caddy-1 rejoins (= restart #2)
05:44:15  caddy-caddy-1 rejoins (= restart #3)
05:44:21  caddy-caddy-1 rejoins (= restart #4, last)
05:44:10  cron: health-monitor.sh probe → 23/56 apps "HTTP 000"
05:44:42  deployer: pulsecraft post-deploy audit
          /health → socket hang up
          → marks FAILED, auto-rollback to 52ceabed (same commit, no-op)
05:44:51  load1 = 75.69 on 4-core ARM box
05:46:52  load1 = 12.66 (falling)
05:47:29  tasks retry succeeds (v2.4.7 deployed)

Dva úplne nezávislé systémy (deployer a health-monitor) boli obete tej istej 30-sekundovej caddy-docker-proxy flapping cascade.

Prečo caddy-docker-proxy flapuje

caddy-docker-proxy (image lucaslorentz/caddy-docker-proxy) sleduje Docker eventy a regeneruje Caddy config z labelov na kontajneroch. Pri zmene labelov volá caddy reload. Pod vysokým load-om sa stáva:

  1. Dokku spustí viac súčasných deployov → každý Docker build vytvára ephemerálne <app>.web.1.upcoming-<N> kontajnery s labelmi.
  2. dockerd je saturovaný (CPU užívaný zipom na tarball, I/O na overlayfs). HTTP endpointy na /containers/json buffer-ujú → broken pipe.
  3. caddy-docker-proxy dostane event o novom kontajneri, ale zoznam všetkých kontajnerov fail-uje → interná logika sa zasekáva na full-refresh.
  4. V zlyhávajúcich úsekoch framework recreuje hlavný caddy-caddy-1 kontajner (nie len reload). Počas creation neexistuje žiaden proxy → každý request vracia connection refused na ingress.
  5. kcompactd0 (memory compactor) zaberie 69 % CPU lebo container build zaplaví page cache. To ďalej zvyšuje load.

V našom konkrétnom prípade išlo o 4 nezávislé restarty Caddy za 30 sekúnd, každý ~7 sekúnd nedostupnosti. Kumulatívne ~25 s úplného downtime na ingress.

Ako to detekovať

Docker eventy ako signál

# Ak je caddy-caddy-1 recreated viackrát za 5 min — alarm
docker events --since 5m --until 0s --filter "container=caddy-caddy-1" \
  --format '{{.Time}} {{.Action}}' | awk '$2=="start"' | wc -l

Load average vs CPU count

# Per-cpu load. >1.5 na core = moderate, >3 = high.
python3 -c "
import os
with open('/proc/loadavg') as f: l1 = float(f.read().split()[0])
c = os.cpu_count()
print(f'load1={l1:.2f} cpus={c} per-cpu={l1/c:.2f}')"

Dockerd "broken pipe" patterny

journalctl -u docker --since "10 min ago" | \
  grep -iE "context canceled|broken pipe|failed to resolve"

Keď sa tie tri veci objavia spolu, máš deploy-wave thrash. Vyhni sa triage v panike — nie je to Caddy bug, je to resource saturation.

Fix #1 — Post-deploy audit retry

Problém: jeden failed curl /health z vnútra deployeru = deploy označený ako FAILED, auto-rollback.

Zmeň jedno-výstrelový test na retry-s-backoff pre transient chyby. Pseudo-kód v Node.js:

function classify({ status }) {
  if (status >= 200 && status < 400) return 'ok';
  if (status === 0 || (status >= 500 && status < 600)) return 'retry';
  return 'fail'; // 4xx = app bug, retrying nepomôže
}

const BACKOFF_MS = [2000, 4000, 8000, 15000];
for (let i = 0; i <= MAX_RETRIES; i++) {
  const r = await httpGet(`${baseUrl}/health`);
  const v = classify(r);
  if (v === 'ok') break;
  if (v === 'fail' || i === MAX_RETRIES) return { ok: false };
  await sleep(BACKOFF_MS[Math.min(i, BACKOFF_MS.length - 1)]);
}

Prečo 4xx nie? Lebo 4xx znamená aplikácia odpovedá — len niečím iným. Napr. /health vracia 404 = endpoint neexistuje, retry nepomôže. 5xx a status 0 (connection refused / timeout) sú čisto transient.

Konfigurovateľné: POST_DEPLOY_HEALTH_RETRIES=4 env default. Celkový worst-case delay pred pád: 2+4+8+15 = 29 sekúnd. Dosť aby Caddy restart prekryl.

Fix #2 — Adaptive concurrency

Problém: aj s retry audit-om, ak dávaš 4+ deploye naraz, Docker sa zadusí skôr než audit vôbec prídu na rad.

Riešenie: queue worker sa pozrie na load predtým ako vezme slot:

function computeEffectiveConcurrency(load1, cpus, max) {
  if (!Number.isFinite(load1) || load1 < 0) return max;
  if (load1 > cpus * 3)   return 1;                  // high pressure
  if (load1 > cpus * 1.5) return Math.max(1, max-1); // moderate
  return max;
}

async function processAppQueue(app) {
  const cap = effectiveMaxConcurrent();
  if (activeDeployCount >= cap) {
    // already-running deploys finish normally; we just wait
    return;
  }
  // … take slot …
}

Dôležité:

  • Nikdy nevracaj 0. Aspoň 1 deploy musí bežať alebo queue úplne zastane.
  • Kontrola je "pred slot", nie "počas deploy". Bežiace deploye pokračujú — len sa nepridávajú nové.
  • Prechody logujeme 1× za 30 s (rate-limit), aby load oscilujúci okolo prahu logy neprepĺňal.

Tuning: 1.5× a per-cpu sú konzervatívne. Na box-e s rýchlejšími disky/sieťou môžeš pushnúť na 2×/4×. Neskôr budeš rád.

Fix #3 — Monitoring retry

Problém: ak paging vypáli telefón kvôli 30-sekundovému caddy restart-u, tak po troch incidentoch vypneš notifikácie a zanedbáš skutočné problémy.

Rozdelenie pozorovania na dva fázy:

probe_once() {
  local domain=$1
  curl -s -o /dev/null -w "%{http_code}" --max-time 10 \
    "https://${domain}/health" 2>/dev/null || echo "000"
}

is_transient() {
  [[ "$1" == "000" ]] && return 0            # connection failure
  [[ "$1" =~ ^5[0-9][0-9]$ ]] && return 0    # server error
  return 1
}

# First probe, retry na transient
status=$(probe_once "$domain")
attempts=1
while is_transient "$status" && [[ $attempts -le $RETRY_ATTEMPTS ]]; do
  sleep "$RETRY_BACKOFF_S"
  status=$(probe_once "$domain")
  attempts=$((attempts + 1))
done

# Až teraz rozhodni či je app fakt dole
if [[ "$status" != "200" ]]; then
  echo "DOWN: $domain ($status after $attempts attempts)"
fi

Trade-off: ak fakt padne 23 apps naraz, monitoring teraz trvá 23 × backoff × retries = 23 × 5 × 2 = 230 s. Ale beh každých 10 min, stále sa zmestí. A hlavne sa zbaví falošných alertov.

Ping message upgrade: reportuj počet pokusov. Nie iba "down: X" ale "down: X after 3 attempts". Triager vidí "flapping" vs "hard down" na prvý pohľad.

Impact

  • ~25 s kumulatívneho ingress downtime (4 caddy recreates × ~7 s každý).
  • 23/56 aplikácií falošne hlásených ako "down" cez monitoring.
  • 1 úspešný deploy (pulsecraft) zbytočne rollback-nutý kvôli jednorázovému socket hang up na audit endpointe.
  • Žiaden dátový dopad — všetky aplikácie sa zotavili samé do 90 s, žiadna manuálna intervencia nebola potrebná. Incident objavený až keď Telegram alert zobudil triagera.

Prevencia / čo merať nabudúce

Ak sa caddy-docker-proxy začne preťažovať opäť, tieto tri metriky by mali zachytiť problém skôr než cron health-monitor fire-uje alert:

  1. caddy-caddy-1 restart rate. Normálne: 0 restartov za 24 h. Alarm pri ≥ 2 restartov za 5 min. docker events --filter container=caddy-caddy-1 --filter event=start.
  2. Load-per-core pomer. Pri load1 / cpus > 2 už pravdepodobne beží adaptive throttle; pri > 5 bude kríza. Exportovať ako Prometheus gauge z /proc/loadavg.
  3. Dockerd "broken pipe" count. Týchto chýb by za hodinu malo byť 0. journalctl -u docker --since 1h | grep -c "broken pipe" do log-based alertu.

Zachytenie ktorejkoľvek z nich dá 2-5 minút varovania skôr než naozaj padne ingress.

Bolo by tiež zmysluplné doplniť dokku-level rate limit (napr. max 3 deploye za minútu na box). Zatiaľ nie je naplánované — adaptive concurrency pokrýva worst case, ďalší škrt dáva zmysel len pri konkrétnych dôkazoch že je treba.

Čo nepomôže

  • Nastaviť retry na 10×. Za pár incidentov zistíš, že akékoľvek Caddy blinknutie dlhšie ako 2 min je indikátorom niečoho zásadne zlého a chceš o tom vedieť. 2 retries s backoff pokryjú 99 % prípadov.
  • Pozdržať deploy queue na pevných 30 s. Statický throttle nepočíta s reálnym stavom servera. Buď je to priveľmi agresívne (CPU idle, zbytočne čakáš) alebo primerne mäkké (box pod memory pressure, ale statický delay neznižuje prítok).
  • Znížiť MAX_CONCURRENT_DEPLOYS permanentne na 1. Zabije ti throughput. 90 % času sú boxy nevyužité. Adaptívna kadencia ti dá throughput keď je ticho, bezpečnosť keď je kríza.
  • Vymeniť caddy-docker-proxy za iný ingress. Problém je v podprahovej resource saturation, nie v caddy-docker-proxy. Traefikov watcher dôjde presne tak ďaleko pri rovnakých podmienkach.

Poznámky z Sardonic Repulsion deploy stack-u

  • Box: Ubuntu 24.04 ARM64, Dokku 0.37.7, 4 cores, 8 GB RAM, localhost:5000 registry mirror.
  • Deployer v1.13.0+ má fix #1, v1.14.0+ má fix #2. Verify: curl -sk https://deployer.sardonicrepulsion.com/version.
  • Health-monitor retry pattern žije v /root/scripts/health-monitor.sh (cron: */10 * * * *). Canonical copy vo workspace/scripts/health-monitor.sh.
  • Registry mirror bol kvôli OCI-index chybe upgradnutý registry:2.8.3registry:3.1.0 v ten istý deň — nesúvisiace s deploy thrash, ale vyšlo to najavo pri triage-i lebo jeden subagent potreboval pullnúť node:22-slim.

Ďalšie čítanie