Bash · File Descriptors · Pipes · Redirects · Linux
Pipes, Redirects und File Descriptors wirklich verstehen
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.

17 Min. Lesezeit File Descriptors · Pipes · Redirects · exec · tee · here-docs Bash 4.x · 5.x · Linux · macOS

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.

11. FAQ: Pipes, Redirects und File Descriptors

1Was bedeutet 2>&1 genau?
Dupliziere FD1 nach FD2 — mache stderr zum Alias für dasselbe Ziel wie stdout. Reihenfolge entscheidend: cmd > file 2>&1 leitet beide in Datei; cmd 2>&1 > file leitet stderr ans Terminal.
2Variablen nach Pipe-while verloren?
Pipe-while-Body läuft in Subshell — Variablen gehen verloren. Lösung: < <(befehl) statt befehl | while — while-Body in Parent-Shell.
3<< vs. <<< — Unterschied?
<< ist Here-Doc: mehrzeiliger Inline-Text bis zum Delimiter. <<< ist Here-String: ein String als stdin, kein Fork, kein Subshell-Overhead.
4Ausgaben gleichzeitig ins Terminal und Log?
exec > >(tee -a log.txt) 2>&1 am Skriptanfang. Alle folgenden Ausgaben gehen gleichzeitig ins Terminal und in die Log-Datei.
5&> vs. > file 2>&1?
&> ist Bash-spezifisch. > file 2>&1 ist POSIX-kompatibel. Für portable Skripte (sh, dash, CI) die POSIX-Form bevorzugen.
6Eigenen File Descriptor öffnen?
exec 3>datei öffnet FD3 zum Schreiben. echo 'text' >&3 schreibt dorthin. exec 3>&- schließt ihn. FDs 0-2 reserviert; ab 3 frei verfügbar.
7tee für gleichzeitiges Komprimieren und Hashen?
cat file | tee >(gzip > compressed.gz) >(sha256sum > hash.txt) > /dev/null. Einmal lesen, gleichzeitig beide Pfade verarbeiten.
8exec ohne Befehl vs. exec mit Befehl?
exec befehl ersetzt die Shell durch befehl — keine Rückkehr. exec ohne Befehl aber mit Redirect verändert nur die FDs der aktuellen Shell, keine Prozessersetzung.
9Wann named Pipes (mkfifo) verwenden?
Für bidirektionale Kommunikation, langlebige Producer-Consumer-Muster oder wenn unabhängige Prozesse ohne gemeinsame Eltern-Shell kommunizieren sollen.
10Skript im Terminal vs. Pipe erkennen?
[[ -t 1 ]] prüft, ob FD1 ein tty ist. Interaktiv: farbige Ausgabe und Fortschrittsbalken. In Pipe: saubere maschinenlesbare Ausgabe.