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+,
dockerdhádžebroken pipena/containers/json. - Kaskáda:
caddy-docker-proxyvidí meniace sa labely a recreuje (znovu vytvára)caddy-caddy-14× 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 upna post-deploy audit → označené ako FAILED → auto-rollback na rovnaký commit (no-op) → alert. - Fix #1 — retry audit: post-deploy
/healthretry 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:
- Dokku spustí viac súčasných deployov → každý Docker build vytvára ephemerálne
<app>.web.1.upcoming-<N>kontajnery s labelmi. dockerdje saturovaný (CPU užívaný zipom na tarball, I/O na overlayfs). HTTP endpointy na/containers/jsonbuffer-ujú →broken pipe.caddy-docker-proxydostane event o novom kontajneri, ale zoznam všetkých kontajnerov fail-uje → interná logika sa zasekáva na full-refresh.- V zlyhávajúcich úsekoch framework recreuje hlavný
caddy-caddy-1kontajner (nie len reload). Počas creation neexistuje žiaden proxy → každý request vraciaconnection refusedna ingress. 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 3× 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 upna 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:
caddy-caddy-1restart 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.- Load-per-core pomer. Pri
load1 / cpus > 2už pravdepodobne beží adaptive throttle; pri> 5bude kríza. Exportovať ako Prometheus gauge z/proc/loadavg. - 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_DEPLOYSpermanentne 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 voworkspace/scripts/health-monitor.sh. - Registry mirror bol kvôli OCI-index chybe upgradnutý
registry:2.8.3→registry:3.1.0v 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
- Docker documentation: Daemon defaults and throttling
- caddy-docker-proxy: Label refresh intervals
- Deployer repo — commit history pre v1.13.0 a v1.14.0 obsahuje pôvodné PR-y (#14, #15) s implementačnými detailmi.