stdin/stdout/stderr, exec, tee, here-docs und here-strings
Die meisten Shell-Entwickler kennen | grep und 2>&1 — aber wenige verstehen das vollständige File-Descriptor-Modell, das darunter liegt. Wer es wirklich versteht, schreibt I/O-Umleitungen ohne Fehler, nutzt exec für dauerhafte Umleitung und kombiniert tee, here-docs und here-strings elegant für komplexe Datenflüsse.
Inhaltsverzeichnis
- 1. Das File-Descriptor-Modell verstehen
- 2. stdin, stdout und stderr: die drei Standard-Deskriptoren
- 3. Redirects im Detail: >, >>, <, <<, 2>, &>
- 4. Eigene File Descriptors öffnen und schließen
- 5. exec für dauerhafte Umleitungen im Skript
- 6. Pipes: wie sie funktionieren und was sie nicht können
- 7. tee: gleichzeitig in mehrere Streams schreiben
- 8. Here-Docs und Here-Strings: Inline-Eingaben meistern
- 9. Redirect-Formen im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Das File-Descriptor-Modell verstehen
Bevor man Pipes, Redirects und File Descriptors in Bash wirklich verstehen kann, muss man das zugrundeliegende Unix-Modell begreifen. Jeder Prozess in Unix/Linux hat eine Tabelle von File Descriptors — positive Integer, die Verweise auf geöffnete Dateien, Pipes, Sockets oder andere I/O-Ressourcen sind. Wenn ein Prozess read() auf File Descriptor 0 aufruft, liest er aus dem Standard-Input. write() auf FD 1 schreibt auf Standard-Output. Das ist das einzige, was der Prozess weiß — ob dahinter ein Terminal, eine Datei oder eine Pipe liegt, ist ihm transparent. Die Shell nutzt dieses Modell, um I/O beliebig umzuleiten.
Wenn die Shell einen neuen Prozess via fork() erzeugt, erbt das Kind die gesamte File-Descriptor-Tabelle des Elternprozesses. Die Shell kann vor dem exec()-Aufruf (der das Kind-Programm lädt) die FD-Tabelle des Kindes modifizieren — Dateien öffnen, FDs duplizieren, FDs schließen. Das ist genau das, was Redirects tun: Sie manipulieren die FD-Tabelle des Kindprozesses, bevor das eigentliche Programm startet. Das Programm selbst liest von FD 0 und schreibt auf FD 1, wie immer — und merkt nicht, dass diese FDs jetzt auf Dateien oder Pipes zeigen statt auf ein Terminal.
Dieses Verständnis macht viele verwirrende Redirect-Konstruktionen verständlich. 2>&1 bedeutet: "Dupliziere File Descriptor 1 nach File Descriptor 2" — mache FD 2 zu einem Alias für dasselbe Ziel wie FD 1. Die Reihenfolge der Redirects ist dabei entscheidend: command > file 2>&1 bedeutet "FD 1 auf Datei umleiten, dann FD 2 auf dasselbe Ziel wie FD 1 (die Datei)". command 2>&1 >file bedeutet "FD 2 auf dasselbe Ziel wie FD 1 (noch das Terminal), dann FD 1 auf die Datei" — ein häufiger Fehler.
2. stdin, stdout und stderr: die drei Standard-Deskriptoren
Die drei Standard-File Descriptors sind in Unix/Linux fest definiert: FD 0 ist stdin (Standard-Input), FD 1 ist stdout (Standard-Output) und FD 2 ist stderr (Standard-Error). Jeder neue Prozess erbt alle drei vom Elternprozess, der typischerweise die Shell ist. Beim Start eines interaktiven Terminals zeigen alle drei auf das Terminal-Pseudogerät. Die semantische Trennung von stdout und stderr ist eine Konvention: Statusmeldungen, Fehler und Logs gehören auf stderr, damit stdout sauber für maschinell verarbeitbare Ausgaben bleibt.
In Shell-Skripten ist die konsequente Nutzung dieser Konvention wichtig für Composability — die Fähigkeit, Skripte in Pipes zu verketten. Ein Skript, das Statusmeldungen auf stdout schreibt, macht seine Ausgabe in einer Pipe unbrauchbar: Der nächste Befehl erhält sowohl die Nutzdaten als auch die Log-Zeilen. Das korrekte Muster: Echte Ausgabe (Dateinamen, berechnete Werte, JSON) auf stdout, alles andere (echo "[INFO] ...", echo "[ERROR] ...") mit >&2 auf stderr. So kann ein Skript sowohl als Filter in einer Pipe als auch standalone mit aussagekräftiger Konsolenausgabe verwendet werden.
#!/usr/bin/env bash
# fd-basics.sh — File Descriptor fundamentals and redirect ordering
set -euo pipefail
# FD 1 = stdout, FD 2 = stderr — always write status to stderr
log_info() { echo "[INFO] $*" >&2; }
log_error() { echo "[ERROR] $*" >&2; }
# Correct: stdout for data, stderr for diagnostics
process_files() {
local dir="$1"
log_info "Processing directory: $dir"
while IFS= read -r -d '' f; do
if [[ -r "$f" ]]; then
echo "$f" # stdout: the actual result list
else
log_error "Cannot read: $f" # stderr: diagnostic
fi
done < <(find "$dir" -name "*.txt" -print0)
}
# Correct redirect order: FD1 → file, then FD2 → same target as FD1
# command > file 2>&1 ← both stdout and stderr go to file
# command 2>&1 > file ← stderr goes to TERMINAL (old FD1), stdout to file
process_files /var/data > /tmp/results.txt 2>&1 # both to file
process_files /var/data > /tmp/results.txt 2>/dev/null # only stdout
# FD duplication: save and restore stdout
exec 3>&1 # Save FD1 in FD3
exec > /tmp/log.txt # Redirect all stdout to file
echo "This goes to file"
exec 1>&3 # Restore FD1 from FD3
exec 3>&- # Close FD3
echo "This goes to terminal again"
3. Redirects im Detail: >, >>, <, 2>, &>
Die grundlegenden Redirect-Operatoren in Bash sind > (stdout in Datei, überschreibend), >> (stdout in Datei, anhängend), < (stdin aus Datei), 2> (stderr in Datei) und &> (stdout und stderr in dieselbe Datei — Bash-spezifisch, nicht POSIX). Das Präfixieren mit einer FD-Nummer — 3>datei — öffnet einen eigenen File Descriptor. N>&M dupliziert FD M nach FD N. N>&- schließt FD N.
Besonderes Augenmerk verdient der Unterschied zwischen &>datei (Bash) und >datei 2>&1 (POSIX-kompatibel). Beide leiten stdout und stderr in dieselbe Datei um, aber nur die zweite Form funktioniert in POSIX-Shell (sh). Für Skripte, die nur unter Bash laufen, ist &> kürzer und klarer. Für portable Skripte sollte man >datei 2>&1 bevorzugen. Der Operator >/dev/null 2>&1 ist das klassische Muster, um alle Ausgaben eines Befehls zu unterdrücken — &/dev/null ist die kürzere Bash-Entsprechung.
4. Eigene File Descriptors öffnen und schließen
Bash erlaubt das Öffnen eigener File Descriptors mit exec N>datei, exec N<datei oder exec N<>datei (lesen und schreiben). Die FD-Nummern 0–2 sind Standard-reserviert; FDs ab 3 sind frei verwendbar. Ein wichtiger Anwendungsfall: Logging-FD öffnen, der parallel zu den normalen Ausgaben in eine Log-Datei schreibt. exec 9>/var/log/skript.log öffnet FD 9 für das gesamte Skript; echo "message" >&9 schreibt in die Log-Datei ohne stdout oder stderr zu beeinflussen.
Eigene File Descriptors sind auch das Fundament von flock für Prozess-Locking. exec 9>/var/lock/skript.lock; flock -n 9 öffnet FD 9 als Lockfile und versucht, einen exklusiven Lock zu setzen. Das Betriebssystem gibt den Lock beim Schließen des FDs automatisch frei — beim normalen Beenden des Prozesses und bei Crashes gleichermaßen. Das ist deutlich zuverlässiger als PID-File-basierte Locking-Mechanismen, die manuell aufgeräumt werden müssen. Beim Öffnen eigener FDs sollte man immer exec N>&- am Ende des Nutzungsbereichs ausführen, um den FD zu schließen und Ressourcen freizugeben.
5. exec für dauerhafte Umleitungen im Skript
Das exec-Builtin hat in Bash zwei Verwendungen: Mit einem Befehl als Argument ersetzt exec befehl den aktuellen Shell-Prozess durch den Befehl. Ohne Befehl, aber mit Redirects, verändert exec die File Descriptors der aktuellen Shell selbst — dauerhaft für alle folgenden Befehle im Skript. Das ist der Unterschied zu einem einmaligen Redirect bei einem einzelnen Befehl. exec > /var/log/skript.log 2>&1 am Skriptanfang leitet alle stdout- und stderr-Ausgaben des gesamten Skripts in die Log-Datei um — alle folgenden echo-Aufrufe, Befehlsausgaben und Fehlermeldungen landen automatisch in der Datei.
Das klassische Muster für Skripte, die gleichzeitig ins Terminal und in eine Log-Datei schreiben sollen, kombiniert exec mit tee: exec > >(tee -a /var/log/skript.log) 2>&1. Die Process Substitution >(tee -a ...) erzeugt eine Pipe, die alle Ausgaben an tee weiterleitet. tee schreibt sie gleichzeitig auf seinen stdout (das Terminal) und in die angegebene Datei. Das Ergebnis: Vollständiges Log in der Datei, gleichzeitig interaktive Ausgabe auf dem Terminal — ohne dass ein einziger Befehl im Skript geändert werden muss. Das ist ein mächtiges Produktions-Muster für Deployment-Skripte, die sowohl beobachtbar als auch auditierbar sein müssen.
#!/usr/bin/env bash
# exec-redirect.sh — Persistent redirects with exec + tee logging
set -euo pipefail
LOG_FILE="/var/log/deploy/$(date +%Y%m%d-%H%M%S).log"
mkdir -p "$(dirname "$LOG_FILE")"
# Redirect all stdout and stderr to file AND terminal simultaneously
exec > >(tee -a "$LOG_FILE") 2>&1
echo "[$(date)] Deployment started"
# Custom file descriptors for structured logging
exec 3>>"${LOG_FILE%.log}.debug.log" # FD3 for debug log
debug() { echo "[DEBUG] $*" >&3; } # Write to debug FD only
info() { echo "[INFO] $*"; } # Write to stdout (→ tee → terminal+log)
error() { echo "[ERROR] $*" >&2; } # Write to stderr (→ also tee'd)
info "Checking dependencies..."
debug "PATH=$PATH"
debug "User=$(id -un)"
# Read from custom FD (useful for reading configuration)
exec 4< /etc/deploy/config.env
while IFS='=' read -r -u 4 key value; do
[[ "$key" =~ ^#.*$ ]] && continue # Skip comments
[[ -z "$key" ]] && continue
export "${key}=${value}"
debug "Config loaded: ${key}=***"
done
exec 4<&- # Close FD4
info "Deployment complete"
# Close debug log FD
exec 3>&-
6. Pipes: wie sie funktionieren und was sie nicht können
Eine Pipe in Bash ist ein unidirektionaler Puffer im Kernel: Daten, die der linke Prozess auf stdout schreibt, können vom rechten Prozess über stdin gelesen werden. Beide Prozesse laufen gleichzeitig — die Pipe-Mechanik ist ein Synchronisationsmechanismus, kein sequentieller Aufruf. Wenn der Puffer voll ist (typisch 64 KB auf Linux), blockiert der schreibende Prozess, bis der lesende Prozess Daten konsumiert. Das ermöglicht die Verarbeitung von Datenmengen, die den RAM überschreiten, solange der Consumer schnell genug liest.
Was Pipes nicht können: Daten rückwärts fließen lassen. Jede Pipe ist unidirektional. Wer bidirektionale Kommunikation zwischen zwei Prozessen braucht, benötigt zwei Pipes (eine pro Richtung) oder eine named Pipe (mkfifo). Ein weiteres wichtiges Limit: Variablen, die in einem Pipe-Element gesetzt werden, sind nach der Pipe nicht sichtbar — weil jedes Pipe-Element in einer Subshell läuft. Das erklärt, warum command | while read line; do count=$((count+1)); done; echo $count immer 0 ausgibt: der while-Body läuft in einer Subshell, die count-Variable im Elternprozess bleibt unverändert. Die Lösung ist Process Substitution oder ein anderes Akkumulations-Muster.
7. tee: gleichzeitig in mehrere Streams schreiben
Das tee-Kommando ist ein T-Stück für Datenströme: Es liest von stdin und schreibt dieselben Daten gleichzeitig auf stdout und in eine oder mehrere Dateien. In Kombination mit Process Substitution — command | tee >(weitere-verarbeitung) — wird tee zu einem leistungsfähigen Fan-out-Operator, der einen Datenstrom gleichzeitig in mehrere Pipelines aufteilt. Das ermöglicht Muster wie: Eine Datei lesen, gleichzeitig SHA256 berechnen und komprimieren, ohne die Datei zweimal zu lesen oder eine Zwischendatei anzulegen.
Wichtige Flags: tee -a hängt an eine bestehende Datei an statt sie zu überschreiben — das ist das richtige Verhalten für Log-Dateien, die zwischen mehreren Skriptläufen akkumuliert werden sollen. tee /dev/stderr dupliziert stdout auf stderr — nützlich zum Debugging in Pipes. tee /dev/fd/3 schreibt in einen eigenen File Descriptor. Das Kombinieren von exec > >(tee) mit mehreren >()-Process-Substitutions ermöglicht einen einzelnen Befehl, der parallel in Terminal, Logdatei und einen weiteren Verarbeitungsprozess schreibt — eine Fähigkeit, die in den meisten Skriptsprachen deutlich mehr Code erfordern würde.
#!/usr/bin/env bash
# tee-fanout.sh — Fan-out patterns with tee and process substitution
set -euo pipefail
SOURCE="/var/backup/database.sql.gz"
DEST_DIR="/var/backup/encrypted"
HASH_FILE="${DEST_DIR}/database.sha256"
COMPRESSED_FILE="${DEST_DIR}/database.sql.gz.enc"
LOG_FILE="/var/log/backup-verify.log"
# Fan-out: read source once, simultaneously:
# 1. Decrypt and decompress for verification
# 2. Calculate SHA256 of encrypted file
# 3. Log transfer stats
openssl enc -aes-256-gcm -d \
-in "$SOURCE" \
-pass file:/etc/backup/.passphrase \
-pbkdf2 | \
tee \
>(sha256sum | awk '{print $1}' > "$HASH_FILE") \
>(gzip -9 | openssl enc -aes-256-gcm \
-out "$COMPRESSED_FILE" \
-pass file:/etc/backup/.passphrase \
-pbkdf2) | \
wc -c | \
tee -a "$LOG_FILE" | \
awk '{printf "[VERIFY] Processed %d bytes\n", $1}'
# Here-doc as stdin for a command (no temporary file needed)
mysql --defaults-file=/etc/mysql/backup.cnf <<'SQL'
SELECT COUNT(*) AS total_rows FROM information_schema.tables
WHERE table_schema = 'myapp';
SQL
# Here-string: single-line stdin without echo subprocess
read -r hostname port <<< "db.example.com:5432"
echo "Host: $hostname, Port: $port"
# Named pipe for bidirectional shell coordination
FIFO="$(mktemp -u)"
mkfifo "$FIFO"
trap 'rm -f "$FIFO"' EXIT
producer() { for i in {1..5}; do echo "item-$i"; done; } > "$FIFO" &
consumer() { while read -r item; do echo "Processing: $item"; done; } < "$FIFO"
wait
8. Here-Docs und Here-Strings: Inline-Eingaben meistern
Here-Docs (<<DELIMITER) bieten eine Möglichkeit, mehrzeilige Texte direkt im Skript als stdin-Eingabe für einen Befehl zu definieren — ohne eine temporäre Datei. Das ist besonders nützlich für SQL-Queries, Konfigurationsdateien und API-Payloads, die direkt an Befehle übergeben werden sollen. Der wichtige Unterschied: Mit <<DELIMITER (ohne Anführungszeichen) werden Variablen und Backticks expandiert. Mit <<'DELIMITER' (Delimiter in einfachen Anführungszeichen) bleibt der gesamte Text literal — Variablenexpansion findet nicht statt. Das zweite Format ist für SQL-Queries und Shell-Skripte richtig, die selbst $-Variablen enthalten.
Here-Strings (<<< "wert") sind die einzeilige Variante: Sie übergeben einen String direkt als stdin, ohne eine Subshell zu starten. read -r host port <<< "${address//:/ }" splittet einen "host:port"-String in zwei Variablen über read — ohne Fork, ohne echo-Subshell. grep "muster" <<< "$variable" sucht in einem String ohne echo "$variable" | grep. Das Einrücken von Here-Doc-Inhalten mit Tabs (nicht Leerzeichen) ist mit <<-DELIMITER möglich, was den Quellcode ordentlicher macht, ohne den Inhalt zu beeinflussen.
9. Redirect-Formen im Vergleich
Die Vielzahl der Redirect-Formen in Bash ist auf den ersten Blick verwirrend. Die folgende Tabelle ordnet die wichtigsten Konstrukte und erklärt, wann welche Form die richtige ist.
| Konstrukt | Bedeutung | Subshell? | Typischer Einsatz |
|---|---|---|---|
| cmd > file | stdout in Datei (überschreiben) | Nein | Ergebnis in Datei speichern |
| cmd 2>&1 | stderr auf stdout (duplizieren) | Nein | stdout+stderr zusammen leiten |
| cmd < <(cmd2) | Process Substitution als stdin | Nur cmd2 | while-Loop ohne Var-Verlust |
| cmd <<< "str" | Here-String: String als stdin | Nein | Var an Befehl übergeben |
| exec N>file | Eigenen FD öffnen | Nein | Logging, flock, Config lesen |
| tee >(cmd) | Fan-out zu Prozess-Substitution | Für cmd | Hash + Compress gleichzeitig |
Die richtige Wahl hängt oft vom Kontext ab: Für einfaches Logging in eine Datei reicht >. Für paralleles Schreiben ins Terminal und in eine Datei ist exec > >(tee -a log) das eleganteste Muster. Für while-Schleifen, die Variablen im Body setzen, ist < <(befehl) die richtige Wahl über eine Pipe. Here-Strings vermeiden unnötige echo-Subshells und machen den Datenfluss explizit.
Mironsoft
Shell-Engineering, I/O-Architektur und Deployment-Automatisierung
Shell-Skripte mit sauberem I/O-Design für die Produktion?
Wir entwickeln Shell-Skripte, die stdout für Daten und stderr für Logs nutzen, File Descriptors gezielt einsetzen und mit tee, exec und Process Substitution zuverlässige I/O-Architekturen implementieren — für Deployment-Pipelines, Logging-Infrastruktur und Batch-Verarbeitung.
Shell-Review
Bestehende Skripte auf fehlerhafte Redirect-Reihenfolgen und FD-Leaks prüfen
Logging-Architektur
exec + tee für vollständiges Audit-Logging ohne Code-Änderungen
Pipeline-Design
Composable Shell-Pipelines mit sauberem stdin/stdout-Protokoll entwickeln
10. Zusammenfassung
Das vollständige Verständnis von Pipes, Redirects und File Descriptors beginnt mit dem Unix-FD-Modell: Jeder Prozess hat eine FD-Tabelle, die Shell modifiziert sie vor dem exec()-Aufruf des Kindprozesses. 2>&1 dupliziert FD 1 nach FD 2 — die Reihenfolge bestimmt das Ergebnis. exec ohne Befehl verändert die FDs der aktuellen Shell dauerhaft. tee mit Process Substitution ermöglicht Fan-out auf mehrere Verarbeitungspfade. Here-Docs und Here-Strings ersetzen temporäre Dateien und echo-Subshells für Inline-Eingaben.
Die praktische Konsequenz dieses Wissens: Skripte, die stdout konsequent für Daten und stderr für Diagnosen nutzen, sind composable und können in Pipes verketten werden. Eigene File Descriptors für Logging und Locking sind robuster als temporäre Dateien. Process Substitution < <() löst den Variablenverlust in Pipe-while-Schleifen. Mit diesen Werkzeugen lassen sich Shell-Skripte schreiben, die sich wie gut entworfene Unix-Tools verhalten — komponierbar, observierbar und vorhersehbar.
Pipes, Redirects und File Descriptors — Das Wichtigste auf einen Blick
Redirect-Reihenfolge
cmd > file 2>&1: stderr → Datei. cmd 2>&1 > file: stderr → Terminal, stdout → Datei. Reihenfolge ist entscheidend.
exec für globale Umleitung
exec > >(tee -a log.txt) 2>&1 am Skriptanfang: alle folgenden Ausgaben ins Terminal und Log. Kein Code mehr ändern nötig.
Pipe vs. Process Substitution
Pipe-while: Variablen im Body verloren. < <(cmd): while läuft in Parent-Shell, Variablen bleiben. Für Akkumulationsschleifen immer < <().
Here-String
cmd <<< "$var" übergibt Variable als stdin ohne Fork. Ersetzt echo "$var" | cmd — kein Subshell-Overhead, kein Pipe-Variablenverlust.