in Shell-Skripten
Shell-Skripte, die mit Netzwerkdiensten, APIs und externen Systemen interagieren, scheitern ohne Retry-Logik beim ersten transienten Fehler. Exponentielles Backoff mit Jitter, der timeout-Befehl und klare Max-Retry-Grenzen machen Shell-Skripte resilient — ohne dass ein Operator nachts manuell neu starten muss.
Inhaltsverzeichnis
- 1. Warum Retry-Logik in Shell-Skripten notwendig ist
- 2. Einfache Retry-Schleife: Grundmuster
- 3. Exponentielles Backoff: Wartezeit verdoppeln
- 4. Jitter: Thundering Herd verhindern
- 5. Der timeout-Befehl: Hängende Befehle abbrechen
- 6. Retry mit Timeout kombinieren
- 7. Idempotenz: Voraussetzung für sicheres Retry
- 8. Retry für HTTP-APIs: Statuscodes berücksichtigen
- 9. Retry-Strategien im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Retry-Logik in Shell-Skripten notwendig ist
Retry-Logik in Shell-Skripten ist keine optionale Verbesserung, sondern eine Grundvoraussetzung für alle Skripte, die mit externen Ressourcen interagieren. Netzwerkverbindungen sind fluktuierend, APIs haben Rate-Limits und transiente Fehler, Dienste starten nach einem Neustart langsam hoch, Datenbanken sind kurzzeitig nicht erreichbar. Ein Skript ohne Retry-Mechanismus schlägt beim ersten derartigen transienten Fehler ab und erfordert manuellen Eingriff — genau das, was durch Automatisierung vermieden werden soll.
Die drei Hauptprobleme ohne Retry in Shell-Skripten: Erstens, ein Skript bricht bei vorübergehenden Netzwerkfehlern sofort ab und muss manuell neu gestartet werden. Zweitens, ein Skript wartet unbegrenzt auf einen hängenden Befehl (fehlende Timeout-Logik). Drittens, mehrere Instanzen desselben Skripts überlasten nach einem Ausfall gleichzeitig den Zieldienst, weil alle zur selben Zeit in festen Intervallen wieder versuchen — das klassische Thundering-Herd-Problem, das ohne Jitter beim Backoff entsteht.
Die Lösung für alle drei Probleme: Eine wiederverwendbare Retry-Funktion mit exponentiellem Backoff, optionalem Jitter und dem GNU-Coreutils-Befehl timeout für Zeitlimits. Diese Komponenten lassen sich zu einer kompakten Bibliothek zusammenfassen, die in beliebigen Shell-Skripten eingebunden werden kann. Der Aufwand für die Implementierung ist gering, der Nutzen in Produktionsumgebungen erheblich.
2. Einfache Retry-Schleife: Grundmuster
Das einfachste Retry-Muster in Shell-Skripten ist eine for- oder while-Schleife, die einen Befehl bis zu N-mal ausführt und nach jedem fehlgeschlagenen Versuch eine feste Wartezeit einlegt. Die Struktur: Befehl ausführen, Exit-Code prüfen, bei 0 die Schleife beenden, bei Fehler warten und den Versuchszähler erhöhen. Nach N Versuchen ohne Erfolg gibt die Funktion den letzten Fehler-Exit-Code zurück. Das ist das Fundament der Retry-Logik, auf dem alle komplexeren Strategien aufbauen.
Der kritische Punkt beim einfachen Retry in Shell-Skripten: Das Skript muss mit set -euo pipefail laufen, aber die Retry-Funktion darf durch den Fehler des Befehls nicht abgebrochen werden. Die Lösung: Den Befehl mit || true oder in einem Kontext aufrufen, in dem set -e nicht greift — zum Beispiel nach || oder in einem if-Ausdruck. Das erhöht die Komplexität leicht, ist aber für korrekte Retry-Logik in Shell-Skripten unvermeidlich.
#!/usr/bin/env bash
# lib/retry.sh — Reusable retry library with exponential backoff and jitter
set -euo pipefail
# retry <max_attempts> <command> [args...]
# Simple retry with fixed wait interval
retry() {
local max_attempts="$1"; shift
local attempt=1
local wait_seconds=1
while (( attempt <= max_attempts )); do
printf '[RETRY] Versuch %d/%d: %s\n' "$attempt" "$max_attempts" "$*" >&2
if "$@"; then
[[ $attempt -gt 1 ]] && printf '[RETRY] Erfolgreich nach %d Versuchen\n' "$attempt" >&2
return 0
fi
local exit_code=$?
printf '[RETRY] Fehlgeschlagen (Exit %d)\n' "$exit_code" >&2
if (( attempt < max_attempts )); then
printf '[RETRY] Warte %ds vor nächstem Versuch…\n' "$wait_seconds" >&2
sleep "$wait_seconds"
fi
(( attempt++ ))
done
printf '[RETRY] Alle %d Versuche fehlgeschlagen\n' "$max_attempts" >&2
return 1
}
# Usage examples
retry 3 curl -sf "https://api.example.com/health"
retry 5 rsync -avz --partial /data/ user@host:/backup/
retry 3 bin/magento cache:flush
3. Exponentielles Backoff: Wartezeit verdoppeln
Exponentielles Backoff in Shell-Skripten ist der Standard-Algorithmus für intelligentes Retry-Verhalten: Nach jedem fehlgeschlagenen Versuch wird die Wartezeit verdoppelt (oder mit einem anderen Faktor multipliziert). Statt nach 1, 1, 1, 1 Sekunden zu warten, wartet das Skript nach 1, 2, 4, 8 Sekunden — mit einer maximalen Wartezeit (Cap), um zu verhindern, dass die Pausen ins Stundenlange wachsen. Die typischen Parameter: Basis-Wartezeit 1 Sekunde, Multiplikator 2, maximale Wartezeit 60 Sekunden, maximale Versuche 5.
Die Implementierung des exponentiellen Backoff in Shell-Skripten nutzt die Bash-Arithmetik mit $(( base * 2 ** (attempt - 1) )). Da Bash nur Integer-Arithmetik beherrscht, sind Basis und Multiplikator ganze Zahlen. Für feinere Kontrolle kann awk oder python3 -c für Float-Arithmetik verwendet werden. In der Praxis ist Integer-Backoff für Shell-Skripte ausreichend — die Grenzen liegen in der Regel bei 1, 2, 4, 8, 16, 32 Sekunden, was die meisten transienten Probleme überbrückt, ohne übermäßig lang zu warten.
#!/usr/bin/env bash
set -euo pipefail
# retry_with_backoff <max_attempts> <base_wait> <max_wait> <command> [args...]
retry_with_backoff() {
local max_attempts="$1"
local base_wait="$2" # seconds for first wait
local max_wait="$3" # cap for wait time
shift 3
local attempt=1
local wait_seconds="$base_wait"
while (( attempt <= max_attempts )); do
printf '[BACKOFF] Versuch %d/%d: %s\n' "$attempt" "$max_attempts" "$*" >&2
if "$@"; then
[[ $attempt -gt 1 ]] && \
printf '[BACKOFF] Erfolgreich nach %d Versuchen\n' "$attempt" >&2
return 0
fi
local exit_code=$?
if (( attempt < max_attempts )); then
printf '[BACKOFF] Fehlgeschlagen (Exit %d) — warte %ds\n' \
"$exit_code" "$wait_seconds" >&2
sleep "$wait_seconds"
# Double the wait, cap at max_wait
wait_seconds=$(( wait_seconds * 2 ))
(( wait_seconds > max_wait )) && wait_seconds="$max_wait"
fi
(( attempt++ ))
done
printf '[BACKOFF] Alle %d Versuche fehlgeschlagen: %s\n' "$max_attempts" "$*" >&2
return 1
}
# Wait sequence: 1s, 2s, 4s, 8s (capped at 30s)
retry_with_backoff 5 1 30 \
curl -sf --max-time 10 "https://api.mironsoft.de/v1/health"
# Longer waits for database availability (2s, 4s, 8s, 16s, 30s)
retry_with_backoff 5 2 30 \
mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" -e "SELECT 1" "$DB_NAME"
4. Jitter: Thundering Herd verhindern
Jitter ist die Ergänzung des exponentiellen Backoff in Shell-Skripten, die das Thundering-Herd-Problem löst. Wenn viele Instanzen desselben Skripts gleichzeitig starten — etwa nach einem Server-Neustart oder in einer horizontalen Skalierungsumgebung — und alle mit demselben Backoff-Schema nach einem Fehler wieder versuchen, treffen alle gleichzeitig auf den Zieldienst. Jitter fügt eine zufällige Komponente zur Wartezeit hinzu, die die Retry-Versuche zeitlich verteilt.
Die Implementierung von Jitter in Bash nutzt die Variable $RANDOM, die Werte von 0 bis 32767 liefert. Der einfachste Jitter: jitter=$(( RANDOM % max_jitter )) und wait=$(( backoff + jitter )). Das "Full Jitter"-Verfahren von AWS verwendet sleep $(( RANDOM % wait_seconds )) — die gesamte Wartezeit ist zufällig zwischen 0 und dem berechneten Backoff-Wert. Das "Equal Jitter"-Verfahren halbiert die Wartezeit und addiert eine zufällige Hälfte: $(( wait_seconds / 2 + RANDOM % (wait_seconds / 2) )). Für Shell-Skripte in Produktionsumgebungen ist Full Jitter oder Equal Jitter mit einem Backoff-Cap die empfohlene Strategie.
5. Der timeout-Befehl: Hängende Befehle abbrechen
Der timeout-Befehl aus GNU Coreutils ist das Standard-Werkzeug für Zeitlimits in Shell-Skripten. Die Syntax timeout DAUER BEFEHL [ARGS] führt den Befehl aus und sendet nach Ablauf der Dauer ein SIGTERM (Standard) oder ein anderes Signal (--signal). Mit --kill-after wird nach dem SIGTERM zusätzlich nach einer weiteren Wartezeit ein SIGKILL gesendet, falls der Prozess auf SIGTERM nicht reagiert. Der Exit-Code von timeout: 0 bei Erfolg, 124 wenn das Zeitlimit überschritten wurde, der originale Exit-Code des Befehls sonst.
Für Retry-Logik in Shell-Skripten ist die Kombination von timeout mit der Retry-Funktion besonders wertvoll: Jeder Versuch hat ein individuelles Zeitlimit, sodass ein einzelner hängender Versuch nicht die gesamte Retry-Sequenz blockiert. timeout 30 curl -sf ... bricht den curl-Aufruf nach 30 Sekunden mit Exit-Code 124 ab. Die Retry-Funktion behandelt Exit-Code 124 als normalen Fehler und macht weiter — inklusive Backoff. Das Zeitlimit pro Versuch sollte deutlich kürzer sein als das maximale Wartefenster der gesamten Retry-Sequenz.
#!/usr/bin/env bash
set -euo pipefail
# retry_with_backoff_jitter <max> <base> <max_wait> <cmd> [args...]
retry_with_backoff_jitter() {
local max_attempts="$1"
local base_wait="$2"
local max_wait="$3"
shift 3
local attempt=1
local wait_seconds="$base_wait"
while (( attempt <= max_attempts )); do
printf '[RETRY] Versuch %d/%d: %s\n' "$attempt" "$max_attempts" "$*" >&2
# Each attempt gets its own timeout (30s)
if timeout 30 "$@"; then
return 0
fi
local exit_code=$?
# Special handling for timeout exit code
if [[ $exit_code -eq 124 ]]; then
printf '[RETRY] Zeitlimit überschritten (30s)\n' >&2
else
printf '[RETRY] Fehlgeschlagen (Exit %d)\n' "$exit_code" >&2
fi
if (( attempt < max_attempts )); then
# Equal jitter: half fixed, half random
local half=$(( wait_seconds / 2 ))
local jitter=$(( half > 0 ? RANDOM % half : 0 ))
local actual_wait=$(( half + jitter ))
printf '[RETRY] Backoff: %ds (base %ds + jitter %ds)\n' \
"$actual_wait" "$half" "$jitter" >&2
sleep "$actual_wait"
# Exponential increase with cap
wait_seconds=$(( wait_seconds * 2 ))
(( wait_seconds > max_wait )) && wait_seconds="$max_wait"
fi
(( attempt++ ))
done
printf '[RETRY] Aufgegeben nach %d Versuchen\n' "$max_attempts" >&2
return 1
}
# Wait for service availability on startup
wait_for_service() {
local host="$1" port="$2"
retry_with_backoff_jitter 10 1 30 \
bash -c "echo > /dev/tcp/$host/$port" 2>/dev/null
printf '[OK] Dienst %s:%s erreichbar\n' "$host" "$port" >&2
}
# Use in deployment context
wait_for_service "${DB_HOST:-localhost}" "${DB_PORT:-3306}"
wait_for_service "${REDIS_HOST:-localhost}" "${REDIS_PORT:-6379}"
6. Retry mit Timeout kombinieren
Die Kombination von Retry und Timeout in Shell-Skripten erfordert zwei separate Zeitgrenzen: das Timeout pro Versuch (wie lange ein einzelner Befehl laufen darf) und das Gesamt-Timeout für die gesamte Retry-Sequenz (wie lange insgesamt auf Erfolg gewartet wird). Ohne Gesamt-Timeout kann eine Retry-Sequenz mit 10 Versuchen und maximalem Backoff von 60 Sekunden theoretisch über 10 Minuten laufen — was in einer CI-Pipeline zu einem Job-Timeout führt, bevor die Retry-Sequenz selbst aufgibt.
Die Implementierung eines Gesamt-Timeouts für Retry in Shell-Skripten nutzt SECONDS — die eingebaute Bash-Variable, die die Sekunden seit dem Start der aktuellen Shell-Session zählt. Zu Beginn der Retry-Sequenz speichert man start_time=$SECONDS. Vor jedem Versuch prüft man (( SECONDS - start_time >= total_timeout )) und bricht ab, wenn das Gesamt-Timeout überschritten ist. Alternativ wird die gesamte Retry-Sequenz selbst von einem äußeren timeout-Befehl umschlossen.
7. Idempotenz: Voraussetzung für sicheres Retry
Die technische Voraussetzung für Retry-Logik in Shell-Skripten ist Idempotenz: Der wiederholte Aufruf eines Befehls muss zu demselben Ergebnis führen wie ein einmaliger Aufruf. Nicht alle Befehle sind von Natur aus idempotent. Ein INSERT INTO-SQL ohne INSERT OR IGNORE oder ON CONFLICT DO NOTHING erzeugt bei Wiederholung einen Duplikat-Fehler. Ein mkdir ohne -p scheitert, wenn das Verzeichnis bereits existiert. Ein API-Aufruf, der eine Transaktion startet, muss bei Wiederholung prüfen, ob die Transaktion bereits abgeschlossen ist.
Für Retry in Shell-Skripten gilt: Idempotente Operationen können unbegrenzt wiederholt werden. Nicht-idempotente Operationen müssen entweder idempotent gemacht werden (durch entsprechende Flags oder Transaktions-IDs) oder dürfen nicht Teil einer Retry-Schleife sein. Die Prüfung auf Idempotenz ist kein technisches Problem der Retry-Logik selbst, sondern eine Anforderung an die Skript-Architektur, die vor der Implementierung der Retry-Schleife beantwortet werden muss.
8. Retry für HTTP-APIs: Statuscodes berücksichtigen
Bei HTTP-API-Aufrufen in Shell-Skripten ist Retry nicht pauschal für alle Fehlerfälle angebracht. HTTP-Statuscodes unterscheiden zwischen Fehlern, bei denen Retry sinnvoll ist, und solchen, bei denen er sinnlos oder schädlich ist. Statuscodes 500, 502, 503 und 504 sind transiente Serverfehler — hier ist Retry mit Backoff die richtige Strategie. Statuscode 429 (Too Many Requests) enthält oft einen Retry-After-Header mit der genauen Wartezeit — diese muss ausgelesen und verwendet werden. Statuscodes 400, 401, 403 und 404 sind permanente Fehler — hier ist Retry sinnlos und verbraucht nur Ressourcen.
Die Implementierung von HTTP-Retry in Shell-Skripten mit curl: curl -w "%{http_code}" gibt den HTTP-Statuscode am Ende der Ausgabe aus. Mit --silent --output /dev/null wird der Body unterdrückt, wenn nur der Statuscode interessiert. Eine Wrapper-Funktion prüft den Statuscode und entscheidet, ob Retry, sofortiger Abbruch oder Erfolg angebracht ist. Für produktive API-Aufrufe empfiehlt sich zusätzlich --max-time für curl (individuelles Timeout pro HTTP-Verbindung) und --retry-connrefused für automatisches Retry bei Verbindungsverweigerung.
#!/usr/bin/env bash
set -euo pipefail
# http_retry: Retry HTTP request with status-code-aware logic
# Usage: http_retry <max_attempts> <url> [curl args...]
http_retry() {
local max_attempts="$1" url="$2"; shift 2
local attempt=1
local wait_seconds=1
local max_wait=60
while (( attempt <= max_attempts )); do
local response http_code body
# Capture both body and status code
response=$(curl -sf --max-time 20 -w "\n%{http_code}" "$@" "$url" 2>&1 || true)
http_code=$(printf '%s' "$response" | tail -1)
body=$(printf '%s' "$response" | head -n -1)
printf '[HTTP] Versuch %d/%d — Status: %s\n' "$attempt" "$max_attempts" "$http_code" >&2
case "$http_code" in
2??)
# Success
printf '%s' "$body"
return 0
;;
429)
# Rate limited — respect Retry-After header if present
local retry_after
retry_after=$(curl -sI --max-time 5 "$url" 2>/dev/null \
| grep -i "Retry-After:" | awk '{print $2}' | tr -d '\r' || echo "$wait_seconds")
printf '[HTTP] Rate-Limit — warte %ss (Retry-After)\n' "$retry_after" >&2
sleep "$retry_after"
;;
5??)
# Transient server error — exponential backoff with jitter
local jitter=$(( RANDOM % wait_seconds ))
local actual_wait=$(( wait_seconds + jitter ))
printf '[HTTP] Serverfehler %s — warte %ds (Backoff + Jitter)\n' \
"$http_code" "$actual_wait" >&2
sleep "$actual_wait"
wait_seconds=$(( wait_seconds * 2 ))
(( wait_seconds > max_wait )) && wait_seconds="$max_wait"
;;
4??)
# Permanent client error — no retry
printf '[HTTP] Permanenter Fehler %s — kein Retry\n' "$http_code" >&2
return 1
;;
"")
# Connection failed / timeout
printf '[HTTP] Verbindungsfehler — warte %ds\n' "$wait_seconds" >&2
sleep "$wait_seconds"
wait_seconds=$(( wait_seconds * 2 ))
(( wait_seconds > max_wait )) && wait_seconds="$max_wait"
;;
esac
(( attempt++ ))
done
printf '[HTTP] Alle %d Versuche fehlgeschlagen: %s\n' "$max_attempts" "$url" >&2
return 1
}
# Usage
response=$(http_retry 5 "https://api.mironsoft.de/v1/deploy/status" \
-H "Authorization: Bearer ${API_TOKEN:?Token fehlt}")
printf 'Response: %s\n' "$response"
9. Retry-Strategien im Vergleich
Verschiedene Retry-Strategien in Shell-Skripten sind für unterschiedliche Szenarien geeignet. Die Wahl hängt davon ab, wie häufig transiente Fehler auftreten, wie kritisch die Latenz ist und ob das Thundering-Herd-Problem relevant ist.
| Strategie | Wartezeit | Thundering Herd | Einsatz |
|---|---|---|---|
| Festes Intervall | Konstant (z.B. 5s) | Hoch | Einfache Warteprüfungen |
| Exponentiell | 1s, 2s, 4s, 8s… | Mittel | API-Calls, einzelne Instanzen |
| Exponentiell + Full Jitter | random(0, 2^n) | Minimal | Viele parallele Instanzen |
| Exponentiell + Equal Jitter | 2^n/2 + random(0, 2^n/2) | Sehr gering | Empfohlen für die meisten Fälle |
| Statuscode-abhängig | Variabel nach HTTP-Code | Gering | HTTP-API-Aufrufe |
Für die meisten Produktionsanwendungen von Retry-Logik in Shell-Skripten ist exponentielles Backoff mit Equal Jitter die beste Wahl: Es verteilt die Last gut, lässt sich einfach implementieren und hat ein vorhersagbares Verhalten. Festes Intervall ist nur für Szenarien geeignet, in denen genau bekannt ist, dass der Dienst nach einer festen Zeit wieder verfügbar sein wird — etwa beim Warten auf einen Service-Start nach einem Neustart. Statuscode-abhängiges Retry ist für alle HTTP-API-Aufrufe Pflicht, weil permanente Fehler nicht durch Wiederholung lösbar sind.
Mironsoft
Shell-Automatisierung, DevOps-Tooling und Deployment-Infrastruktur
Shell-Skripte, die transiente Fehler selbst lösen?
Wir implementieren Retry-Bibliotheken mit exponentiellem Backoff, Jitter und Timeout für eure Shell-Skripte — damit Deployments, Backup-Routinen und API-Aufrufe nicht mehr beim ersten transienten Fehler abbrechen.
Retry-Bibliothek
Wiederverwendbare Retry-Funktionen mit Backoff, Jitter und Timeout für eure Shell-Projekte
API-Integration
HTTP-Retry mit Statuscode-Logik und Retry-After-Header für Rate-Limited APIs
Resilienz-Review
Bestehende Shell-Skripte auf fehlende Retry-Logik und hängende Befehle analysieren
10. Zusammenfassung
Retry, Backoff und Timeout-Strategien in Shell-Skripten sind die Grundlage resilienter Automatisierung. Die drei Kernkomponenten — eine Retry-Schleife mit konfigurierter Maximalanzahl, exponentieller Backoff mit Jitter für verteilte Systeme und der timeout-Befehl für hängende Einzelaufrufe — lassen sich zu einer kompakten, wiederverwendbaren Bibliothek zusammenfassen. Einmal implementiert und in alle Skripte eingebunden, macht diese Bibliothek den Unterschied zwischen Skripten, die beim ersten Netzwerkflattern abbrechen, und solchen, die transiente Probleme selbstständig überwinden.
Für HTTP-API-Aufrufe ist zusätzlich die statuscode-abhängige Retry-Logik notwendig: Nicht jeder HTTP-Fehler sollte zu Retry führen. Permanente Fehler (4xx) niemals wiederholen. Transiente Fehler (5xx, Verbindungsfehler) mit exponentiellem Backoff wiederholen. Rate-Limits (429) mit dem Retry-After-Header respektieren. Idempotenz ist die Voraussetzung für jede Retry-Logik in Shell-Skripten — nicht-idempotente Operationen müssen explizit behandelt oder aus der Retry-Schleife ausgeschlossen werden.
Retry, Backoff und Timeout — Das Wichtigste auf einen Blick
Exponentielles Backoff
Wartezeit nach jedem Versuch verdoppeln. Cap bei max_wait (z.B. 60s). Typische Sequenz: 1, 2, 4, 8, 16, 30, 30… Sekunden.
Jitter
Equal Jitter: wait/2 + random(0, wait/2). Verhindert Thundering Herd bei parallelen Instanzen. $RANDOM für Integer-Zufallswerte in Bash.
timeout-Befehl
timeout 30 befehl — sendet SIGTERM nach 30s. Exit-Code 124 bei Timeout. --kill-after für SIGKILL nach weiterem Warten.
HTTP-Retry
5xx und Verbindungsfehler: Retry mit Backoff. 429: Retry-After-Header lesen. 4xx: kein Retry. curl -w "%{http_code}" für Statuscode-Auswertung.