IT-Admin.tech

Robust Bash scripts: error handling, set flags and idempotent patterns

Ablaufdiagramm für Fehlerhandling, Locking und Cleanup in Bash-Skripten vor unscharfem Terminal
Ablaufdiagramm: Traps, Locking, atomische Writes und Docker ENTRYPOINT als visuelle Grundlage für betriebssichere Bash-Jobs.

Robust Bash scripts are an operational requirement: In Cron, systemd timers or Docker entrypoints scripts must handle errors explicitly, perform clean cleanup and be repeatable without producing side effects. This post provides a compact, practical pattern set of set flags (options that change the shell’s behavior), traps (functions executed on signals or errors) and idempotent patterns (repeatability without modifying side effects).

Why simple scripts fail in production

Errors rarely result from complex logic – they are usually caused by incorrect assumptions about the environment, return values or parallelism. Common causes:

  • Errors are not visible or are masked (pipelines, grep with no matches).
  • Unexpected environment in Cron/containers (minimal PATH, IFS, missing tools).
  • Concurrent jobs write to the same resources.
  • Incomplete cleanup after interruption (tempfiles, mounts, locks).

The goal is practical robustness: unambiguous exit codes, traceable logs, idempotent steps and controlled concurrency.

Robust base framework for Bash scripts

A consistent scaffold reduces pitfalls. Adapt it to your requirements, but keep the central building blocks: set flags, defined environment, logging, traps and cleanup.

Shell
#!/usr/bin/env bash

set -Eeuo pipefail
IFS=$'nt'
PATH="/usr/sbin:/usr/bin:/sbin:/bin"
export LC_ALL=C

log() { printf '%s [%s] pid=%s %sn' "$(date -Is)" "$1" "$$" "$2" >&2; }

die() { log "ERROR" "$2"; exit "$1"; }

on_error() { local rc=$?; log "ERROR" "Fehler rc=${rc} in Zeile ${1:-?}: ${2:-?}"; exit "$rc"; }
cleanup() { :; }

trap 'on_error "$LINENO" "$BASH_COMMAND"' ERR
trap cleanup EXIT

Explanation: set -e exits on uncaught errors, -u warns on unset variables, pipefail ensures that pipelines report failures, and -E allows ERR traps in functions. IFS reduces unintended word splitting, a defined PATH avoids different tool versions in Cron/containers.

set flags: benefits, risks, practice

set -e: useful with explicit exceptions

set -e is useful when an error makes continuing dangerous. Problems arise when typical tools use exit code 1 for „no result“ (e.g. grep). Solve this with explicit checks.

Shell
# Optional removal, tolerate error
rm -f -- "/var/tmp/maybe-there" || true

# Grep with no match handled deliberately
if grep -q "pattern" file; then
  echo "gefunden"
else
  echo "nicht gefunden"
fi

set -u: protects against typos

set -u prevents silent errors from using empty variables, but requires defaults or explicit error messages.

Shell
: "${BACKUP_DIR:?BACKUP_DIR ist nicht gesetzt}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"

pipefail and -E: improve diagnostics

pipefail makes pipelines more reliable; -E and an ERR trap output line and command, which makes Cron logs significantly more informative.

Error handling: exit codes, traps and fallback paths

Standardize exit codes

Exit codes are the simplest interface to monitoring and orchestration. Define team rules (e.g. 2 = Usage/Parameter, 10+ = external dependencies) and document them in the runbook.

Shell
usage() { cat <<'EOF'
Usage: job.sh --source DIR --target DIR
EOF
}

# Beispiel: Parameterprüfung
[[ -n "$SOURCE" ]] || die 2 "--source fehlt"

Cleanup with state markers

The EXIT trap runs on every exit. For complex resources use simple state variables so cleanup is idempotent.

Shell
TMPDIR=""
MOUNTED=0
cleanup() {
  local rc=$?
  if [[ "$MOUNTED" -eq 1 ]]; then
    umount "/mnt/work" || log "WARN" "Unmount fehlgeschlagen"
  fi
  [[ -n "${TMPDIR}" && -d "${TMPDIR}" ]] && rm -rf -- "${TMPDIR}" || true
  log "INFO" "Beende mit rc=${rc}"
}

trap cleanup EXIT
TMPDIR="$(mktemp -d)"
mount /dev/sdb1 /mnt/work && MOUNTED=1

Idempotent patterns: repeatable and safe

Idempotence means: repeated runs result in the same target state. This is essential for deployments, migrations or init scripts in containers.

Check-then-Do with robust checks

Shell
ensure_dir() {
  local dir="$1" mode="$2"
  [[ -d "$dir" ]] || mkdir -p -- "$dir"
  chmod "$mode" -- "$dir"
}
ensure_dir "/var/lib/myjob" "0750"

What matters is what you check: existence alone is rarely sufficient; verify contents, permissions or service responses when necessary.

Marker files and atomic writes

Marker files are practical, but only reliable with atomic writes (tmp + mv).

Shell
mark_done() {
  local marker="$1" tmp="${marker}.tmp.$$"
  printf '%sn' "$(date -Is)" > "$tmp"
  mv -f -- "$tmp" "$marker"
}

if [[ ! -f "/var/lib/myjob/.init_done" ]]; then
  # ...Initialisierung...
  mark_done "/var/lib/myjob/.init_done"
fi

Markers do not replace validation: additionally verify that the success was actually achieved (service responds, DB object exists).

Locking to prevent concurrent starts

flock is robust under Linux: kernel locks are released when the process ends. For distributed filesystems or multi-host coordination you need external coordinators (DB, Redis, Consul).

Shell
LOCKFILE="/var/lock/myjob.lock"
exec 9>"$LOCKFILE"
if ! flock -n 9; then
  log "WARN" "Job läuft bereits, beende"
  exit 0
fi
log "INFO" "Lock erhalten"

Robust Bash scripts: signal handling and PID 1 in containers

In containers the ENTRYPOINT shell often becomes PID 1. PID 1 has special responsibilities: it must forward signals correctly and reap child processes (clean up zombies). If the shell is not replaced via exec, it remains PID 1 and can prevent signal forwarding—shutdowns may be delayed or orchestrators like Kubernetes may receive incorrect exit codes. Alternative: use tini or exec directly.

Signal forwarding and reaping

Traps for SIGTERM/SIGINT forward signals, stop background processes in a controlled way and use wait to wait for child processes to exit.

Shell
term_handler() {
  log "INFO" "SIGTERM empfangen, leite an Kinder weiter"
  # Beispiel: pids enthält PIDs von Background-Prozessen
  for pid in "${pids[@]:-}"; do
    kill -TERM "$pid" 2>/dev/null || true
  done
  # Auf Kinder warten, damit keine Zombies bleiben
  wait
  exit 143
}

trap 'term_handler' SIGTERM SIGINT

# Beispiel: Dienst im Hintergrund starten
/usr/local/bin/myworker &
pids+=("$!")

# Hauptprozess wartet
wait -n || true

Alternatively: use ENTRYPOINT ["/sbin/tini", "--"] in the Dockerfile or start containers with --init so that a small PID 1 reaper takes on the task.

Distributed locks: when flock is not enough

For a single host, flock is often sufficient. In distributed systems (NFS, multiple hosts), central coordination or database locks are the right choice. Examples:

Postgres Advisory Lock

Postgres offers advisory locks (application-scoped) via simple SQL calls. This is useful if you already have a relational DB in your stack.

Shell
# Versucht Lock zu setzen und prüft Rückgabe
if psql -qAt -c "SELECT pg_try_advisory_lock(12345)" | grep -qx "t"; then
  log "INFO" "Advisory lock erhalten"
else
  log "WARN" "Lock konnte nicht gesetzt werden"
  exit 0
fi
# Später: Lock freigeben
psql -c "SELECT pg_advisory_unlock(12345)" || true

Note: Advisory locks are tied to the DB session; they are released on connection loss — which is often desirable.

Redis SETNX mit TTL

Redis can implement simple leader or lock patterns with SET resource value NX PX. Caution: network partitions can lead to stale locks — set a TTL and verify the lock owner.

Secure file operations, permissions and tempdirs

Errors in file operations often lead to security issues. Best practices:

  • Create temporary directories with mktemp -d and set secure permissions (umask/ chmod).
  • Atomic writes: write to a temporary file and replace it with mv.
  • Handling permissions: set umask or explicitly correct target permissions with chmod.
Shell
old_umask=$(umask)
umask 027
TMPDIR=$(mktemp -d -p /var/tmp myjob.XXXXXX)
chmod 0700 "$TMPDIR"
# ... arbeiten ...
umask "$old_umask"

Monitoring, metrics and logs: practical integration

Exit codes are the primary signaling medium; in addition, structured logging and exporting metrics provide tangible benefits for SRE/monitoring (e.g., Prometheus Node Exporter Textfile Collector).

Shell
# Metrik als Textfile für node_exporter
METRIC_DIR="/var/lib/node_exporter/textfile_collector"
mkdir -p "$METRIC_DIR"
cat > "$METRIC_DIR/myjob.prom.tmp" <<EOF
# HELP myjob_last_run_seconds Unix timestamp des letzten Laufs
# TYPE myjob_last_run_seconds gauge
myjob_last_run_seconds $(date +%s)
EOF
mv -f "$METRIC_DIR/myjob.prom.tmp" "$METRIC_DIR/myjob.prom"

Key point: write only into the designated directory, use atomic moves so exporters see consistent files.

Tests, CI integration and static analysis

Don’t test scripts only manually: ShellCheck identifies style and security issues, and unit tests for shell functions (e.g., with bats-core) catch logical errors. Integration tests simulate missing dependencies and concurrent starts.

Yaml
# GitHub Actions: ShellCheck und einfache Linting-Checks
name: Shell CI
on: [push, pull_request]
jobs:
  shellcheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: ludeeus/action-shellcheck@v1
        with:
          files: "scripts/**/*.sh"

Troubleshooting: common failure patterns and diagnostic sequence

When a job fails, work systematically:

  1. Check logs (stderr/stdout) for ERR trap output. Look for the line number and the BASH_COMMAND provided by the ERR trap.
  2. Verify the environment: PATH, IFS, variables with declare -p.
  3. Check lock status: does the lockfile exist, who holds it? (ps aux | grep)
  4. In containers: was PID 1 the shell? Check the process tree and signal handling.

Rollback and recovery strategy

For risky changes (DB migration, file operations) define a clear procedure:

  • Before: complete backup (file backup, DB dump) and checksums to enable recovery.
  • Write migration scripts idempotently: check precondition, perform changes only once, write a marker and validate the result.
  • Provide and test a rollback script – automatic rollback only for clearly defined failures.
Shell
# Example: migration with backup and marker
if [[ -f "/var/lib/myjob/.migration_v2_done" ]]; then
  log "INFO" "Migration v2 already executed"
  exit 0
fi
# Backup
pg_dump -Fc mydb -f /var/backups/mydb-pre-v2.dump || die 20 "DB backup failed"
# Migration
run ./migrate_v2.sh || die 30 "Migration failed"
mark_done "/var/lib/myjob/.migration_v2_done"

Short checklist for production readiness

  • Deliberately set set-flags; exceptions documented.
  • Defined environment: PATH, IFS, Locale.
  • Clear exit codes, logging to stderr, structured logs optional.
  • Locking with flock or central coordination for multi-host scenarios.
  • Idempotence: check-then-do, atomic markers, controlled cleanup.
  • Docker: ENTRYPOINT with exec, healthchecks without side effects.
  • Tests: ShellCheck, negative tests, parallel-start tests, dry-run.
  • Monitoring: export runtimes and errors as text files for exporters.
  • Runbook: exit-code definitions, log locations, RESTore and rollback steps.

Conclusion

Robustness is not a one-liner, but the result of small, consistent decisions: appropriate set-flags, traps for diagnostics and cleanup, idempotent patterns, sensible locking and clear logging. These practices significantly reduce operational risk and provide reproducible failure patterns for monitoring, support and incident handling. Maintain a compact runbook with exit-code definitions, log locations and recovery steps as well – this makes Bash scripts reliable building blocks of your automation.

Appendix: short incident runbook (template)

A concise guide you can copy into your runbook:

  • 1) Log exam: check /var/log/job.stderr and -stdout, note the ERR trap line.
  • 2) Check lock: ls -l /var/lock/, ps -ef | grep <pid>.
  • 3) Environment snapshot: env | sort, declare -p of the relevant variables.
  • 4) Try safe rerun: check DRY_RUN=1, then perform a real run with a backup.
  • 5) If migration affected: RESTore backup, delete marker, run tests locally.

Robust Bash scripts: security, audit and deployment in operations

Besides error handling and idempotence, three aspects are decisive for productive use: secret handling, version control and controlled rollout processes. Scripts often run with extensive system privileges; therefore the principle of least privilege applies: run as a dedicated service user, use targeted capabilities instead of root, and strict file permissions for temporary files and markers.

Never log secrets unmasked or output them via set -x. Use container or orchestrator mechanisms (Docker Secrets, Kubernetes Secrets) or a centralized vault integration. Verify downloaded artifacts with checksums so a compromised mirror does not become part of the supply chain.

Shell
# Debug nur optional und ohne Secrets
if [[ "${DEBUG:-0}" -eq 1 ]]; then
  set -x
fi
mask_log() { sed 's/(password=)[^[:space:]]+/1***REDACTED***/g'; }
# Beispiel: stdout | mask_log | logger

Version control scripts in Git, build CI pipelines that deliver linting, unit tests and signing. Package critical logic — especially for complex error correction or high throughput — into a small, statically typed binary tool (Go/Rust) and use the script as an orchestrator. That improves testability and reduces the surface area for accidental failures.

For deployment and operations: perform staged rollouts (Canary), monitor error rate and runtime metrics, and implement automatic quarantine for repeated failures (e.g. tagging, alerting, temporary backoff). Document every change in the runbook and release notes so audits are traceable and a fast, tested rollback path is available.

For this topic, Bash error handling and set -Euo pipefail are also important. The article places these aspects in context and shows what matters in everyday operations.

Weiterfuehrend

Passende weitere Inhalte