Shell Lesson 38 of 42

Shell Self-Healing Scripts: Detect-Decide-Act Loops, Blast-Radius Limits, Circuit Breakers & The Discipline That Stops Auto-Remediation From Becoming The Outage

In a nutshell

A self-healing script watches your systems and fixes common problems on its own — restarts a stuck worker, clears a full disk, reboots a wedged service — without waking a human at 3 a.m. It runs the same three steps over and over: detect a problem, decide what to do, act on it. That loop is the easy 10%.

The hard 90% — what this lesson is really about — is making that automation safe to point at production. An unsupervised script that can restart services is also a script that can restart every service, in a tight loop, until the whole site is down. Almost every “the auto-remediation made the outage worse” post-mortem is exactly that story.

Think of it as a smoke detector wired to a sprinkler. Detect = smell smoke. Decide = is this a real fire? Act = spray water. Wonderful when the kitchen is genuinely on fire. A disaster if it empties the whole building’s water tank every time someone burns toast — or keeps spraying a fire it can’t put out while the correct fix is to phone the brigade. So you bound it: spray only the room that’s actually burning (a blast-radius limit), and if you’ve sprayed three times and it’s still on fire, stop and call a human (a circuit breaker plus escalation). Those guardrails — not the detection — are what separate a healer that has saved sites from one that has nuked them.

The whole lesson rests on one sentence: automation that can act on prod must be bounded by design.

Level: Advanced · Time: ~45 min

Prerequisites: you can write bash functions and set -euo pipefail scripts; you’ve met idempotency and dry-run in Idempotency, State Files & Reconciliation, and heartbeats / textfile exporters in Monitoring Agents, Exporters & Watchdogs. Basic awk, jq, systemd, and redis-cli / pgrep familiarity helps, but every command here is explained.

After this you can:

Detect to decide to act control loop, with idempotency, blast-radius token bucket, and circuit-breaker guardrails feeding an act-verify-audit-escalate stage

Read the diagram left → right, then loop back: the healer senses a signal and emits a fact, decides on an intent (with hysteresis so it won’t flap), and — only if that intent clears every guardrail (idempotency key, blast-radius token bucket, circuit breaker) — it acts, verifies, audit-logs, and escalates to a human when the breaker trips.

The Cardinal Rule of Auto-Remediation

A self-healing script that runs without guardrails is just a faster way to take production down. Every public post-mortem you have read where “the auto-remediation made it worse” follows the same script: the healer detected a symptom, took an action, the action made the symptom look like it cleared, the underlying cause was still there, the symptom returned, the healer fired again — and the resource it was “healing” entered a restart loop, depended-on services started failing, the healer started firing on those, and within 4 minutes a single bad pod became a regional outage.

The discipline that prevents this:

Layer What it stops
Explicit Detect-Decide-Act loop Conflating “broken” with “looks broken”
Idempotency keys The same incident triggering the same fix multiple times in parallel
Blast-radius limits A bug in the healer cascading across the fleet
Circuit breakers The healer continuing to fire when its own actions are failing
Dry-run mode Shipping un-tested healer logic to production
Audit log “Why did the healer do that?” being unanswerable

This lesson teaches each layer with shell scripts, a lib/heal.sh you can source, and two worked examples: a good healer that has saved sites, and a structurally identical bad one that nuked them.

The Detect-Decide-Act Loop

Every healer has three explicit phases:

┌──────────┐    ┌──────────┐    ┌──────────┐
│  DETECT  │───▶│  DECIDE  │───▶│   ACT    │
└──────────┘    └──────────┘    └──────────┘
     ▲                                │
     └────────────────────────────────┘
              loop interval

The phases are separate functions, callable independently. This matters for testing: you can unit-test Decide with synthetic facts without ever Acting, and you can dry-run by replacing Act with a logger.

Why the Loop Must Have Three Phases (Not Two)

The naive form is “if condition then action” which collapses Detect and Decide into a single test. This works for trivial healers but fails as soon as policy gets non-trivial:

The three conditions are Decide logic. Forcing them into the Detect phase means you can’t reuse the detector for monitoring alerts; forcing them into Act means dry-run can’t show what would happen. Keep them separate.

Skeleton

#!/usr/bin/env bash
# heal-worker.sh — example healer skeleton
set -euo pipefail

source /usr/local/lib/heal.sh

readonly NAME=worker-restarter
readonly INTERVAL=60   # seconds

while true; do
  fact=$(detect)
  intent=$(decide "$fact")
  if [[ -n "$intent" ]]; then
    act "$intent"
  fi
  sleep "$INTERVAL"
done

detect() {
  # Returns a fact line. Empty = no signal.
  local depth
  depth=$(redis-cli LLEN myapp:queue 2>/dev/null || echo 0)
  printf 'queue_depth=%s\n' "$depth"
}

decide() {
  local fact="$1"
  local depth
  depth=$(echo "$fact" | awk -F= '/queue_depth=/ {print $2}')
  if (( depth > 10000 )); then
    # Find the oldest stuck worker
    local pid
    pid=$(pgrep -of myapp-worker)
    [[ -n "$pid" ]] && printf 'restart_worker pid=%s\n' "$pid"
  fi
}

act() {
  heal_act_with_guardrails "$1"
}

The heal_act_with_guardrails wraps idempotency + rate limit + circuit breaker + audit log. It’s what lib/heal.sh provides.

Idempotency Keys: One Incident, One Action

Without idempotency keys, two consecutive Detect cycles that observe the same problem trigger the same Act twice. For “restart worker”, that’s two restarts in 60 seconds — which can confuse a process supervisor and leave the worker in an unknown state.

The fix is an idempotency key derived from the incident not the cycle:

# Compute idempotency key from intent + a coarse time bucket
# Two identical intents in the same 5-minute bucket are deduped.
heal_idempotency_key() {
  local intent="$1"
  local bucket
  bucket=$(( $(date +%s) / 300 ))
  printf '%s\n' "$intent.$bucket" | sha256sum | cut -d' ' -f1
}

The key is then used to gate execution:

heal_act_with_guardrails() {
  local intent="$1"
  local key
  key=$(heal_idempotency_key "$intent")
  local marker="/var/lib/heal/$NAME/keys/$key"

  if [[ -f "$marker" ]]; then
    heal_log "SKIP duplicate intent: $intent (key=$key)"
    return 0
  fi

  mkdir -p "$(dirname "$marker")"
  : > "$marker"
  # ...continue to act
}

Two adjacent cycles that observe the same problem hit the same key and the second is silently skipped. After the bucket rolls (5 minutes later), the key changes and the healer can fire again — which is the desired property: “fix once per 5 min, not on every cycle.”

Idempotency Across Restarts

The marker file persists across script restarts. This is on purpose: if your healer is restarted by systemd at 14:32 and saw an intent at 14:31, it shouldn’t re-fire that same intent in the same bucket. Persisted markers give you crash-safety.

GC the markers nightly:

# tmpfiles.d/heal.conf
d /var/lib/heal/*/keys 0750 heal heal -
e /var/lib/heal/*/keys - - - 7d

tmpfiles.d e directive deletes files older than 7 days. Sufficient for any sensible bucket size.

Blast-Radius Limits: The Rate Limit That Saves The Fleet

This is the most important guardrail in the entire lesson. Every healer must declare a blast-radius limit and refuse to exceed it.

Examples:

The limit is enforced via a token bucket:

# heal_rate_limit_check NAME RATE BURST
# Returns 0 (allow) if a token is available, 1 (deny) otherwise.
heal_rate_limit_check() {
  local name="$1" rate="$2" burst="$3"
  local state="/var/lib/heal/$NAME/rate-$name"
  local now last_refill tokens elapsed

  now=$(date +%s)
  if [[ -f "$state" ]]; then
    last_refill=$(awk '{print $1}' "$state")
    tokens=$(awk '{print $2}' "$state")
  else
    last_refill=$now
    tokens=$burst
  fi

  elapsed=$(( now - last_refill ))
  tokens=$(awk -v t="$tokens" -v e="$elapsed" -v r="$rate" -v b="$burst" \
    'BEGIN { v = t + e * r; if (v > b) v = b; print v }')

  awk -v t="$tokens" 'BEGIN { exit !(t >= 1) }' || {
    printf '%d %s\n' "$now" "$tokens" > "$state"
    return 1
  }

  tokens=$(awk -v t="$tokens" 'BEGIN { print t - 1 }')
  printf '%d %s\n' "$now" "$tokens" > "$state"
  return 0
}

Usage:

# Allow 1 restart per minute, with burst of 3 (catches up during quiet periods)
if heal_rate_limit_check "worker-restart" "0.0167" "3"; then
  # Allowed — proceed with restart
  systemctl restart myapp-worker.service
else
  heal_log "Rate-limited: skipping worker restart"
fi

The token bucket auto-recovers: if the healer doesn’t fire for 10 minutes, the bucket refills to burst, ready for a small flurry. But sustained firing can’t exceed rate per second.

Fleet-Wide Rate Limits via a Shared Lock

Per-host limits aren’t enough when the healer runs on every host. If 100 hosts each “rate-limit to 1/min” and the trigger condition is fleet-wide, you get 100 actions per minute fleet-wide. Solution: a central coordinator (Redis, Consul KV, etcd) that issues fleet-wide tokens:

# Use Redis SETNX as a distributed lock. The lock has a TTL so it auto-expires.
heal_fleet_lock_acquire() {
  local key="$1" ttl="$2"
  local token
  token=$(uuidgen)
  if redis-cli SET "heal:lock:$key" "$token" EX "$ttl" NX | grep -q OK; then
    printf '%s\n' "$token"
    return 0
  fi
  return 1
}

heal_fleet_lock_release() {
  local key="$1" token="$2"
  # Lua: only release if we own the lock (avoids releasing someone else's lock if we're slow)
  redis-cli EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 "heal:lock:$key" "$token"
}

# Usage: only one host fleet-wide can act on this incident
if token=$(heal_fleet_lock_acquire "kafka-broker-restart" 300); then
  systemctl restart kafka.service
  heal_fleet_lock_release "kafka-broker-restart" "$token"
fi

The Lua script in release ensures we don’t accidentally delete someone else’s lock if we held ours past TTL. This is the classic correct Redis distributed lock pattern (Redlock without quorum, sufficient for healer coordination).

Circuit Breakers: Stop When Your Actions Aren’t Working

A circuit breaker disables the healer after N consecutive failed actions. This prevents the worst failure mode: the healer firing repeatedly because its actions don’t actually fix the problem (the worker keeps crashing on restart, but the healer keeps trying to restart it).

heal_circuit_breaker_check() {
  local name="$1" max_failures="$2" cooldown="$3"
  local state="/var/lib/heal/$NAME/cb-$name"
  local now failures last_failure

  now=$(date +%s)
  if [[ ! -f "$state" ]]; then
    return 0   # closed, allow
  fi

  failures=$(awk '{print $1}' "$state")
  last_failure=$(awk '{print $2}' "$state")

  if (( failures >= max_failures )); then
    if (( now - last_failure < cooldown )); then
      heal_log "Circuit OPEN for $name (failures=$failures, cooldown=$((cooldown - (now - last_failure)))s remaining)"
      return 1
    else
      # Cooldown expired — half-open: allow one trial
      heal_log "Circuit HALF-OPEN for $name"
      return 0
    fi
  fi
  return 0
}

heal_circuit_breaker_record() {
  local name="$1" outcome="$2"   # success | failure
  local state="/var/lib/heal/$NAME/cb-$name"
  local now=$(date +%s)
  if [[ "$outcome" == "success" ]]; then
    rm -f "$state"   # reset on success (half-open → closed)
  else
    local failures=0
    [[ -f "$state" ]] && failures=$(awk '{print $1}' "$state")
    failures=$((failures + 1))
    printf '%d %d\n' "$failures" "$now" > "$state"
  fi
}

Usage with verification:

heal_act_with_circuit_breaker() {
  local intent="$1"
  if ! heal_circuit_breaker_check "worker-restart" 3 600; then
    return 1   # circuit open, skip
  fi

  systemctl restart myapp-worker
  sleep 10   # give it time to start
  if systemctl is-active --quiet myapp-worker; then
    heal_circuit_breaker_record "worker-restart" success
  else
    heal_circuit_breaker_record "worker-restart" failure
    return 1
  fi
}

After 3 consecutive failures, the circuit opens and stays open for 10 minutes. After cooldown, one trial action is allowed (half-open); success closes the circuit, failure re-opens it for another 10 minutes.

The numbers come from production experience: 3 failures means “this isn’t a transient — we’re in a real failure mode.” 10 minutes cooldown is enough for a human to investigate (and for monitoring alerts to wake someone).

Hysteresis, Escalation & “Do No Harm”

The three guardrails so far bound how often and how much a healer acts. Three more disciplines bound whether it should act at all — and what it does when it decides it shouldn’t. These are the ones beginners skip, and they are exactly the ones that turn a healer from a hair-trigger into a good operator.

Hysteresis: Don’t Let The Healer Flap

A raw threshold (depth > 10000) is a landmine when the metric hovers near the threshold. Picture queue depth oscillating 9,800 → 10,200 → 9,900 → 10,100 every few cycles. A naive healer fires, un-fires, fires, un-fires — flapping — restarting a worker every couple of minutes for a queue that is basically fine. Each restart drops in-flight work and nudges the metric, so the healer is now causing the oscillation it’s reacting to.

Hysteresis breaks the loop by making “start acting” and “stop acting” use different thresholds — a Schmitt trigger. Fire only above a HIGH mark; only consider the incident cleared below a separate, lower LOW mark; in the dead-band between them, hold the previous state:

HYS_STATE="$HEAL_STATE_DIR/hys/queue"; HIGH=10000; LOW=2000
_firing() { [[ -f "$HYS_STATE" ]]; }

decide_with_hysteresis() {   # arg: current depth -> prints intent or nothing
  local depth="$1"
  if (( depth > HIGH )); then
    mkdir -p "$(dirname "$HYS_STATE")"; : > "$HYS_STATE"      # enter/stay firing
    printf 'restart_worker\n'
  elif (( depth < LOW )); then
    rm -f "$HYS_STATE"                                        # incident cleared
  else
    _firing && printf 'restart_worker\n'                     # dead-band: hold
  fi
}

Now the queue has to genuinely blow past 10,000 to trigger, and genuinely drain below 2,000 to be declared healthy — noise in between changes nothing. The alternative (or complement) is a consecutive-breach counter: only act after the condition has held for N detect cycles, so a single spiky sample is ignored. Both approaches share a rule of thumb: the gap between fire and clear should be wider than the metric’s normal jitter.

Escalation: The Healer Is Triage, Not A Cure

A healer’s job is to buy time on known, boring, safe-to-automate incidents — not to be an all-knowing AIOps brain. The moment it hits something it doesn’t understand or can’t fix, the correct action is to hand off to a human, loudly. Escalate when:

The anti-pattern is the silent give-up: the healer quietly stops (breaker open, or it crashed) while operators — who trust it — have retired the manual runbook. That is worse than never having a healer. Escalation is a first-class action, so make it observable:

heal_escalate() {   # reason -> audit + a metric an alert can watch
  local reason="$1"
  heal_log "ESCALATE: $reason"
  heal_audit "escalate" "handoff" "$reason"
  # Bump a textfile-collector metric; a Prometheus alert pages on it > 0.
  printf 'heal_escalation_total{healer="%s"} 1\n' "$NAME" \
    > "/var/lib/node_exporter/textfile/heal_${NAME}_escalation.prom.$$" \
    && mv "/var/lib/node_exporter/textfile/heal_${NAME}_escalation.prom.$$" \
          "/var/lib/node_exporter/textfile/heal_${NAME}_escalation.prom"
}

That .prom file feeds the same exporter you built in Monitoring Agents, Exporters & Watchdogs; a human is paged the moment the healer raises its hand.

“Do No Harm”: Prefer Inaction Under Uncertainty

Borrow the physicians’ rule. When the healer is uncertain, the correct default is do nothing — an unnecessary restart is a real cost, and an action taken on bad data can be catastrophic. Concretely:

The through-line: a healer should be conservative. Under doubt it declines, records why, and pages a human — because the cost of a missed auto-fix is minutes of degraded service, while the cost of a confident wrong action is the outage you were trying to prevent.

Dry-Run Mode: The Discipline That Catches The Bad Healer Before It Lives

Every healer must have a --dry-run flag that runs Detect+Decide but replaces Act with a logger:

DRY_RUN=${DRY_RUN:-false}

heal_act() {
  local intent="$1"
  if $DRY_RUN; then
    heal_log "DRY-RUN: would have acted on: $intent"
    return 0
  fi
  # ...real action
}

Every new healer should run for 7 days in dry-run before live mode. Compare:

If your healer dry-runs at 200 actions/day on a 50-host fleet, that’s almost certainly a bug — real healers fire 0-5 actions/day. Tune detection thresholds before going live.

The Three-Stage Rollout

  1. Dry-run, dev fleet (1 day) — catch logic bugs in your decide phase.
  2. Dry-run, prod fleet (7 days) — see real production patterns.
  3. Canary live, 1 host (3 days) — actually fire on one host, watch for unintended consequences.
  4. Live, full fleet — gradual rollout, 10% → 25% → 50% → 100% over a week.

This is overkill for trivial healers (e.g., “remove core files older than 7 days”) but it’s exactly right for anything that mutates running services.

Audit Log: The Question Is Always “Why?”

Every action must produce an audit record with enough context to answer “why did the healer do that?” three months from now.

heal_audit() {
  local intent="$1" outcome="$2" reason="$3"
  local audit_file="/var/log/heal/audit.jsonl"
  mkdir -p "$(dirname "$audit_file")"
  jq -nc \
    --arg ts "$(date -Iseconds)" \
    --arg host "$(hostname)" \
    --arg name "$NAME" \
    --arg intent "$intent" \
    --arg outcome "$outcome" \
    --arg reason "$reason" \
    --arg fact "$LAST_FACT" \
    '{ts:$ts, host:$host, healer:$name, intent:$intent, outcome:$outcome, reason:$reason, fact:$fact}' \
    >> "$audit_file"
}

Sample output:

{"ts":"2026-06-22T14:32:01+00:00","host":"web-07","healer":"worker-restarter","intent":"restart_worker pid=12345","outcome":"success","reason":"queue_depth=12450 > 10000","fact":"queue_depth=12450,workers_alive=8"}

JSONL (one JSON object per line) is the right format because it streams to log shippers and indexes well in Loki / OpenSearch / CloudWatch. Always include:

When a post-incident review asks “why was worker pid 12345 restarted at 14:32?” you grep the audit log and have a complete answer.

The Drop-In lib/heal.sh

# lib/heal.sh — sourced helpers for self-healing scripts.
#
# Required env:
#   NAME          — short healer identifier (lowercase, no spaces)
#
# Optional env:
#   DRY_RUN       — true|false, default false
#   HEAL_STATE_DIR — default /var/lib/heal/$NAME
#   HEAL_AUDIT_FILE — default /var/log/heal/audit.jsonl

set -o errexit -o nounset -o pipefail

: "${NAME:?NAME must be set}"
: "${DRY_RUN:=false}"
: "${HEAL_STATE_DIR:=/var/lib/heal/$NAME}"
: "${HEAL_AUDIT_FILE:=/var/log/heal/audit.jsonl}"

heal_log() {
  printf '[%s] [%s] %s\n' "$(date -Iseconds)" "$NAME" "$*"
}

heal_init() {
  mkdir -p "$HEAL_STATE_DIR/keys" "$HEAL_STATE_DIR/rate" "$HEAL_STATE_DIR/cb"
  mkdir -p "$(dirname "$HEAL_AUDIT_FILE")"
}

heal_audit() {
  local intent="$1" outcome="$2" reason="${3:-}"
  jq -nc \
    --arg ts "$(date -Iseconds)" \
    --arg host "$(hostname)" \
    --arg name "$NAME" \
    --arg intent "$intent" \
    --arg outcome "$outcome" \
    --arg reason "$reason" \
    '{ts:$ts, host:$host, healer:$name, intent:$intent, outcome:$outcome, reason:$reason}' \
    >> "$HEAL_AUDIT_FILE"
}

# Idempotency: same intent + 5min bucket = same key
heal_idem_key() {
  local intent="$1"
  local bucket=$(( $(date +%s) / 300 ))
  printf '%s.%d' "$intent" "$bucket" | sha256sum | cut -d' ' -f1
}

heal_idem_seen() {
  local key="$1"
  [[ -f "$HEAL_STATE_DIR/keys/$key" ]]
}

heal_idem_mark() {
  local key="$1"
  : > "$HEAL_STATE_DIR/keys/$key"
}

# Token-bucket rate limit. Args: name, rate (per sec), burst
heal_rate_check() {
  local name="$1" rate="$2" burst="$3"
  local state="$HEAL_STATE_DIR/rate/$name"
  local now last_refill tokens elapsed

  now=$(date +%s)
  if [[ -f "$state" ]]; then
    last_refill=$(awk '{print $1}' "$state")
    tokens=$(awk '{print $2}' "$state")
  else
    last_refill=$now
    tokens=$burst
  fi

  elapsed=$(( now - last_refill ))
  tokens=$(awk -v t="$tokens" -v e="$elapsed" -v r="$rate" -v b="$burst" \
    'BEGIN { v = t + e * r; if (v > b) v = b; print v }')

  awk -v t="$tokens" 'BEGIN { exit !(t >= 1) }' || {
    printf '%d %s\n' "$now" "$tokens" > "$state"
    return 1
  }
  tokens=$(awk -v t="$tokens" 'BEGIN { print t - 1 }')
  printf '%d %s\n' "$now" "$tokens" > "$state"
}

# Circuit breaker. Args: name, max_failures, cooldown_sec
heal_cb_check() {
  local name="$1" max="$2" cooldown="$3"
  local state="$HEAL_STATE_DIR/cb/$name"
  local now=$(date +%s) failures last_fail
  [[ -f "$state" ]] || return 0
  failures=$(awk '{print $1}' "$state")
  last_fail=$(awk '{print $2}' "$state")
  if (( failures >= max )); then
    if (( now - last_fail < cooldown )); then
      heal_log "CIRCUIT-OPEN $name (failures=$failures)"
      return 1
    fi
    heal_log "CIRCUIT-HALF-OPEN $name"
  fi
}

heal_cb_record() {
  local name="$1" outcome="$2"
  local state="$HEAL_STATE_DIR/cb/$name"
  if [[ "$outcome" == "success" ]]; then
    rm -f "$state"
  else
    local f=0
    [[ -f "$state" ]] && f=$(awk '{print $1}' "$state")
    printf '%d %d\n' "$((f + 1))" "$(date +%s)" > "$state"
  fi
}

# All-in-one wrapper. Args: intent_string, action_function, success_check_function, reason
heal_act_with_guardrails() {
  local intent="$1" action_fn="$2" check_fn="$3" reason="${4:-}"
  local key

  heal_init
  key=$(heal_idem_key "$intent")
  if heal_idem_seen "$key"; then
    heal_log "SKIP idempotent: $intent"
    return 0
  fi

  if ! heal_rate_check "$intent" "${HEAL_RATE:-0.0167}" "${HEAL_BURST:-3}"; then
    heal_log "SKIP rate-limited: $intent"
    return 0
  fi

  if ! heal_cb_check "$intent" "${HEAL_CB_MAX:-3}" "${HEAL_CB_COOLDOWN:-600}"; then
    return 0
  fi

  if $DRY_RUN; then
    heal_log "DRY-RUN: $intent"
    heal_audit "$intent" dry-run "$reason"
    heal_idem_mark "$key"
    return 0
  fi

  heal_log "ACTING: $intent"
  if "$action_fn"; then
    sleep 5   # let action settle
    if "$check_fn"; then
      heal_audit "$intent" success "$reason"
      heal_cb_record "$intent" success
      heal_idem_mark "$key"
      heal_log "OK: $intent"
    else
      heal_audit "$intent" verify-failed "$reason"
      heal_cb_record "$intent" failure
      heal_log "FAIL verify: $intent"
      return 1
    fi
  else
    heal_audit "$intent" action-failed "$reason"
    heal_cb_record "$intent" failure
    heal_log "FAIL action: $intent"
    return 1
  fi
}

Worked Example: A Worker-Queue Healer

#!/usr/bin/env bash
# heal-worker-queue.sh — restart workers when queue depth exceeds threshold
set -euo pipefail

NAME=worker-queue-healer
HEAL_RATE=0.0167   # 1 per minute
HEAL_BURST=3
HEAL_CB_MAX=3
HEAL_CB_COOLDOWN=600

source /usr/local/lib/heal.sh

readonly INTERVAL=60
readonly THRESHOLD=10000

detect() {
  local depth alive
  depth=$(redis-cli LLEN myapp:queue 2>/dev/null || echo 0)
  alive=$(pgrep -c -f myapp-worker 2>/dev/null || echo 0)
  printf 'queue_depth=%s,workers_alive=%s\n' "$depth" "$alive"
}

decide() {
  local fact="$1"
  local depth alive
  depth=$(echo "$fact" | sed 's/.*queue_depth=\([0-9]*\).*/\1/')
  alive=$(echo "$fact" | sed 's/.*workers_alive=\([0-9]*\).*/\1/')

  if (( depth > THRESHOLD )) && (( alive > 0 )); then
    local pid
    pid=$(pgrep -of myapp-worker)
    [[ -n "$pid" ]] && printf 'restart_worker pid=%s' "$pid"
  fi
}

action_restart_worker() {
  systemctl restart myapp-worker.service
}

check_worker_alive() {
  systemctl is-active --quiet myapp-worker.service
}

heal_init

while true; do
  fact=$(detect)
  intent=$(decide "$fact")
  if [[ -n "$intent" ]]; then
    heal_act_with_guardrails "$intent" action_restart_worker check_worker_alive "$fact"
  fi
  sleep "$INTERVAL"
done

This healer will:

A Tale of Two Healers

The Good Healer (saved a site)

Site has Redis-backed queue. Workers occasionally OOM-kill, leaving queue stuck. Symptoms: queue_depth spikes, no workers consume.

The healer above runs once a minute. When queue exceeds 10k AND alive_workers > 0 (workers exist but stuck), it restarts the worker service. Rate limit ensures at most 1 restart per minute; circuit breaker opens after 3 fails (which would indicate a deploy-broken binary, not a stuck worker — needs human intervention). In 18 months, it has fired ~40 times, every fire was correct, MTTR for the stuck-worker class went from 25 minutes to under 2 minutes.

The Bad Healer (nuked a site)

Same site, earlier version. The healer was: “if queue_depth > 10000, restart worker.” No rate limit, no idempotency, no circuit breaker.

Real incident timeline:

What the good healer would have done differently:

The same code, with guardrails, has a different outcome. This is the entire reason lib/heal.sh exists.

Healer Liveness: The Healer Itself Must Be Monitored

A healer that crashes and stops running is silently worse than no healer because operators may have removed manual procedures. Every healer must:

  1. Emit a heartbeat to the textfile collector (covered in L34).
  2. Have a Prometheus alert if the heartbeat stops.
  3. Run under systemd with Restart=on-failure.
[Unit]
Description=Worker queue healer
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/heal-worker-queue.sh
Restart=on-failure
RestartSec=10
WatchdogSec=120
NotifyAccess=main

[Install]
WantedBy=multi-user.target

WatchdogSec=120 requires the script to call systemd-notify WATCHDOG=1 at least every 2 minutes; if it stops, systemd kills and restarts. Combined with Restart=on-failure, the healer self-recovers from script bugs.

The 8 Footguns

1. No Idempotency Means Multiple Fires Per Incident

Two cycles 60 seconds apart see the same incident, fire twice. Workers get restart-restarted in 60 seconds, often confusing the supervisor. Fix: idempotency keys with coarse buckets.

2. No Rate Limit Means Cascading Damage

Already covered in the bad-healer example. Fix: every healer declares a rate limit; every action goes through heal_rate_check.

3. No Circuit Breaker Means Infinite Loop Of Failure

Action fails, problem persists, healer fires again. Logs fill, the audit log fills, eventually disk fills. Fix: circuit breaker with explicit max-failures + cooldown.

4. Action Verification Skipped

You restart the service but never check it came back up. The healer thinks it succeeded but the service is dead. Fix: every action has an explicit verification function called after a settling sleep.

5. Detect Phase Includes Decide Logic

Detect is redis-cli LLEN; if redis-cli itself is failing because Redis is down, Detect returns “0” (a falsy value), and Decide concludes “no problem.” But there is a problem — Redis is down! Fix: Detect must return errors as facts, not silently return zero. local depth=$(redis-cli LLEN myapp:queue) || depth="ERROR".

6. Forgetting To heal_init

The heal_init function creates the state directories. Forgetting it means file writes fail silently (errexit catches them, but error is “no such directory” which is opaque). Fix: heal_init at the top of every healer’s main, before the loop.

7. Running Two Instances Of The Same Healer

Without a PID file or systemd lock, an admin running a manual instance of heal-worker-queue.sh while the systemd one is also running gives you 2× the actions. Fix: flock-based singleton:

exec 200>/var/run/heal-worker-queue.lock
flock -n 200 || { heal_log "Already running"; exit 1; }

8. Using bash Math On Floats For Token Bucket

bash arithmetic (( ... )) is integer-only. Trying (( tokens >= 0.5 )) is a syntax error or comparison fail. Fix: use awk for float math (as in lib/heal.sh above) or use millisecond-resolution integer tokens.

Going deeper

The shell healer you’ve built is a real one — but it is a point on a spectrum. Knowing where it sits, and where it breaks, is what separates “I copied a lib” from “I own this in production.”

It’s a control loop — recognise the lineage, know when to graduate

Detect → decide → act is not a shell idiom; it’s a closed-loop controller, the same shape as a thermostat, a Kubernetes controller’s reconcile loop, and IBM’s MAPE-K autonomic-computing model (Monitor, Analyze, Plan, Execute, over shared Knowledge). Seeing that lineage buys you two things. First, the failure modes of control theory apply: a controller with too tight a threshold and no damping oscillates — which is exactly the flapping that hysteresis and rate limits damp. Second, it tells you when to stop using shell. A handful of independent, well-understood remediations on plain hosts? Shell is perfect: transparent, no runtime, greppable. But once healers start interacting (one scales up while another scales down), or the desired state is a rich data model, or you need leader election and reconciliation across a cluster, you’ve outgrown cron-and-flock — that’s when you move to a Kubernetes operator, a real controller, or a policy engine. Shell healers are the right tool for the first 80% and an honest liability for the last 20%.

The distributed-lock caveat: TTL locks are not fencing

The Redis SET … NX EX fleet lock in this lesson is the correct simple pattern, but understand its edge: it is not a fencing lock. If host A acquires the lock, then suffers a GC pause / STW / scheduling stall longer than the TTL, the lock expires, host B acquires it, and now both believe they hold it — right as A wakes up and acts. For merely coordinating who runs a restart, that double-act is bounded by your other guardrails (rate limit, idempotency) and is acceptable. For anything genuinely destructive (fencing a node, deleting a volume), a TTL lock is not enough: you need a fencing token — a monotonically increasing number handed out with the lock, which the target resource checks and rejects if it’s lower than one it has already seen (Martin Kleppmann’s canonical critique). In practice that means a real coordinator with leases (etcd, ZooKeeper, Consul sessions), not redis-cli SETNX. Rule: a TTL lock reduces the probability of concurrent action; it does not make it impossible.

Security: a healer is a high-value confused deputy

A healer runs with enough privilege to restart services — often root — and its inputs come from the very systems it might act on. Two concrete risks:

And apply least privilege: a healer that only restarts myapp-worker.service needs a narrow sudoers/polkit rule for exactly that unit, not blanket root. The smaller the grant, the smaller the blast radius when — not if — the healer has a bug.

Testing healers without touching prod

The three-phase split exists so you can test the scary part safely. decide is a pure function — fact in, intent out, no side effects — so you can capture real facts from prod, replay them through decide, and assert the intents with bats/shunit2 (see Testing: bats, shunit2, Mocking & Fixtures). Property-test the guardrails: “N rapid calls yield ≤ burst actions”, “the breaker opens on the Nth failure”, “the same intent in one bucket fires once”. Then chaos-test the whole loop on a canary by injecting synthetic incidents (push 20k fake items into a scratch queue) and watching the audit log. A healer you can’t test on demand is a healer you’re testing in production.

Performance & state hygiene

Each cycle forks date, awk (several times), sha256sum, and jq. At a 60-second interval on a normal host that cost is invisible. It stops being invisible if you drop the interval to 1s, run on thousands of hosts, or the box is already CPU-starved — then consider a single long-lived process, printf-based integer math instead of per-call awk, and putting the hot state dir on tmpfs. Whatever you do, GC the state: the keys/, rate/, and cb/ directories grow one file per incident-bucket forever unless a tmpfiles.d e rule (or a nightly find -mtime +7 -delete) prunes them.

Portability: the course targets Linux + GNU + systemd

Every command here assumes GNU coreutils and systemd — the standard production target. On a BSD/macOS build host (or a BusyBox container) several of them differ, so know the swaps before you copy a healer onto a non-Linux box:

GNU / systemd (course target) BSD / macOS / BusyBox Used for
sha256sum shasum -a 256 · openssl dgst -sha256 idempotency key hash
date -Iseconds date -u +%Y-%m-%dT%H:%M:%SZ audit / log timestamp
pgrep -c -f · pgrep -of pgrep -f (no -c); -o ok on macOS count / oldest match
flock (util-linux) not installed by default singleton lock
systemctl · systemd-notify · WatchdogSec launchd / launchctl supervision + watchdog
tmpfiles.d GC cron + find -mtime state cleanup

The awk float math, jq, uuidgen, and redis-cli calls are portable as written. When a healer must run on mixed platforms, detect the tool once at startup (command -v sha256sum || alias …) rather than sprinkling if [[ $(uname) ]] through the loop.

Practice challenges

Work these in order — each builds on the last. Every solution is runnable on Linux (bash 4+/GNU); notes flag any macOS/BSD swap.

Challenge 1 — Split a one-liner into three phases (beginner)

You inherit this: if (( $(redis-cli LLEN q) > 100 )); then systemctl restart w; fi. Refactor it into detect, decide, and act functions where detect prints a fact, decide prints an intent (or nothing), and the main loop wires them. Why does this matter before you add anything else?

<details> <summary>Solution</summary>

detect() { printf 'depth=%s\n' "$(redis-cli LLEN q 2>/dev/null || echo ERROR)"; }
decide() {
  local depth; depth=$(echo "$1" | awk -F= '/depth=/{print $2}')
  [[ "$depth" == ERROR ]] && { echo "escalate probe_failed"; return; }
  (( depth > 100 )) && echo "restart_worker"
}
act() { echo "would: $1"; }   # replace with the real action later

fact=$(detect); intent=$(decide "$fact"); [[ -n "$intent" ]] && act "$intent"

Why: separating the phases is the prerequisite for everything else — you can now unit-test decide with fake facts, dry-run by swapping act, and reuse detect for alerting. It also forces the honest handling of a failed probe (ERROR, not a silent 0). </details>

Challenge 2 — Add a dry-run gate (beginner)

Make act honour a DRY_RUN env var: when DRY_RUN=true, it logs “would act” and does nothing; otherwise it acts. Prove it with DRY_RUN=true and then unset.

<details> <summary>Solution</summary>

DRY_RUN=${DRY_RUN:-false}
act() {
  local intent="$1"
  if [[ "$DRY_RUN" == true ]]; then echo "DRY-RUN: would $intent"; return 0; fi
  echo "ACTING: $intent"   # real side effect goes here
}
DRY_RUN=true  act restart_worker   # -> DRY-RUN: would restart_worker
DRY_RUN=false act restart_worker   # -> ACTING: restart_worker

Why: a dry-run flag is how you soak a new healer for days and count intents before it can touch prod — the cheapest bug-catcher you have. </details>

Challenge 3 — Prove idempotency-key dedup (intermediate)

Write idem_key intent now_epoch that buckets time into 300-second windows and hashes intent.bucket. Show that two timestamps inside one bucket produce the same key, and one in the next bucket differs.

<details> <summary>Solution</summary>

idem_key() { local intent="$1" now="$2"; printf '%s.%d' "$intent" $(( now / 300 )) | sha256sum | cut -d' ' -f1; }
# bucket 5730226 spans 1719067800..1719068099
a=$(idem_key "restart pid=1" 1719067850)
b=$(idem_key "restart pid=1" 1719067999)   # same bucket
c=$(idem_key "restart pid=1" 1719068200)   # next bucket
[[ "$a" == "$b" ]] && echo "same bucket -> same key ✓"
[[ "$a" != "$c" ]] && echo "next bucket -> new key ✓"

Verified: same-bucket keys match, next-bucket key differs. Why: the key is derived from the incident (intent + coarse time), not the cycle, so adjacent detect cycles dedupe to one action and the healer re-arms only when the bucket rolls. (macOS: swap sha256sum for shasum -a 256.) </details>

Challenge 4 — Cap the blast radius (intermediate)

Using the heal_rate_check-style token bucket, configure rate=0, burst=3 and fire it 5 times in a tight loop. How many are allowed, how many denied, and why? Print the running count.

<details> <summary>Solution</summary>

STATE=$(mktemp)
bucket() {  # rate burst state -> 0 allow / 1 deny
  local rate="$1" burst="$2" s="$3" now last tok el
  now=$(date +%s)
  if [[ -s "$s" ]]; then last=$(awk '{print $1}' "$s"); tok=$(awk '{print $2}' "$s"); else last=$now; tok=$burst; fi
  el=$(( now - last ))
  tok=$(awk -v t="$tok" -v e="$el" -v r="$rate" -v b="$burst" 'BEGIN{v=t+e*r; if(v>b)v=b; print v}')
  awk -v t="$tok" 'BEGIN{exit !(t>=1)}' || { printf '%d %s\n' "$now" "$tok" > "$s"; return 1; }
  printf '%d %s\n' "$now" "$(awk -v t="$tok" 'BEGIN{print t-1}')" > "$s"
}
a=0; d=0; for i in 1 2 3 4 5; do bucket 0 3 "$STATE" && a=$((a+1)) || d=$((d+1)); done
echo "allowed=$a denied=$d"   # -> allowed=3 denied=2

Verified: 3 allowed, 2 denied. Why: with rate=0 the bucket never refills, so the 3 starting tokens permit exactly 3 actions and the rest are denied — that hard cap is what turns a healer bug from a fleet-wide cascade into a couple of harmless no-ops. Raise rate to refill over time. </details>

Challenge 5 — Drive the circuit breaker through its states (advanced)

Implement cb_check (allow/deny) and cb_record success|failure with max=3, cooldown=2s. Record 3 failures, confirm the circuit is OPEN, wait out the cooldown, confirm it goes HALF-OPEN (allows one trial), then record a success and confirm it’s CLOSED again.

<details> <summary>Solution</summary>

S=$(mktemp); MAX=3; COOL=2
cb_check() {
  local now fail last; now=$(date +%s); [[ -s "$S" ]] || return 0
  fail=$(awk '{print $1}' "$S"); last=$(awk '{print $2}' "$S")
  if (( fail >= MAX )); then
    (( now - last < COOL )) && { echo OPEN; return 1; }
    echo HALF-OPEN            # cooldown elapsed: allow one trial
  fi
}
cb_record() { local o="$1" f=0
  [[ "$o" == success ]] && { : > "$S"; rm -f "$S"; return; }
  [[ -s "$S" ]] && f=$(awk '{print $1}' "$S"); printf '%d %d\n' "$((f+1))" "$(date +%s)" > "$S"; }

cb_record failure; cb_record failure; cb_record failure
cb_check && echo allow || echo "denied (open)"   # -> denied (open)
# wait out cooldown in a fresh process, not a foreground sleep in the loop:
sleep 3
cb_check && echo "allow (half-open trial)"        # -> HALF-OPEN / allow
cb_record success
cb_check && echo "allow (closed again)"           # -> allow

Verified transitions: closed → OPEN after 3 fails → HALF-OPEN after cooldown → CLOSED on success. Why: the breaker is the guardrail that makes the healer give up and escalate instead of hammering a fix that isn’t working — the single behaviour that would have stopped the “bad healer” cascade. </details>

Challenge 6 — Stop the flap with hysteresis (advanced)

A queue oscillates around 10,000. Implement decide with a Schmitt trigger: fire above HIGH=10000, clear below LOW=2000, and hold the previous state in between. Feed it the sequence 12000, 9000, 3000, 1500, 9000 and show it does not flap.

<details> <summary>Solution</summary>

ST=$(mktemp -u); HIGH=10000; LOW=2000
firing() { [[ -f "$ST" ]]; }
decide() {
  local d="$1"
  if   (( d > HIGH )); then : > "$ST"; echo FIRE
  elif (( d < LOW  )); then rm -f "$ST"; echo CLEAR
  else firing && echo HOLD-FIRE || echo HOLD-CLEAR; fi
}
for d in 12000 9000 3000 1500 9000; do printf '%6s -> %s\n' "$d" "$(decide "$d")"; done
# 12000 -> FIRE      (crosses HIGH)
#  9000 -> HOLD-FIRE (in dead-band, still firing)
#  3000 -> HOLD-FIRE (still above LOW)
#  1500 -> CLEAR     (crosses LOW)
#  9000 -> HOLD-CLEAR(in dead-band, stays cleared)

Verified: the 9000 samples do not toggle the state — only crossing 10,000 fires and only crossing 2,000 clears. Why: a single threshold flaps on any metric that jitters across it; the dead-band between HIGH and LOW absorbs the noise, so the healer acts on genuine incidents, not on wobble. Make the gap wider than the metric’s normal jitter. </details>

Common beginner mistakes

These are misconceptions about what a healer is for, distinct from the coding footguns above. Each is a mental-model correction.

Glossary

Quick-Reference Card

LOOP STRUCTURE
  Detect → fact (a measurement, not a decision)
  Decide → intent (a directive, not an action)
  Act    → operation (with guardrails)
  Each phase is a separate function.

GUARDRAILS (in order applied)
  1. Idempotency key → skip if same intent in same bucket
  2. Rate limit → token bucket, e.g. 1/min with burst 3
  3. Circuit breaker → open after 3 fails, 10min cooldown
  4. Dry-run → log instead of act if DRY_RUN=true
  5. Action with verification → action then check then record outcome
  6. Audit log → JSONL with ts, host, healer, intent, outcome, reason

ROLLOUT
  1. Dev fleet dry-run, 1 day
  2. Prod fleet dry-run, 7 days
  3. Single-host live (canary), 3 days
  4. Gradual fleet rollout 10% → 100%

LIVENESS
  Heartbeat to textfile collector
  Prometheus alert on heartbeat stale
  systemd Restart=on-failure + WatchdogSec
  flock singleton to prevent double-runs

NUMBERS THAT WORK
  Bucket size 5 min (idempotency)
  Rate 1/min, burst 3 (most healers)
  Circuit max 3 fails, cooldown 10 min
  Verification settle 5-10 sec before checking

What’s Next

You can now build healers that detect symptoms, decide on remediation, act with bounded blast-radius, and audit every action. The next operational frontier is migration scripts: scripts that transform data, move it between systems, and must be safely re-runnable when something fails midway through. Migration is the ultimate test of idempotency — a half-completed migration must complete cleanly when retried, never duplicate, never lose rows.

In the next lesson — Migration Scripts: Data Transformations, ETL From Shell & Idempotent Re-Runs — we’ll build lib/migrate.sh covering checkpoint files for resumable migrations, the watermark pattern for incremental ETL, dry-run with row-count diff, transactional staging tables, and the disciplined back-out plan every migration needs before it goes live.

shellself-healingauto-remediationcircuit-breakerblast-radiussrecontrol-looprate-limitaudit-logidempotency
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments