Bash · stdin · stdout · Pipes · Linux
stdin, stdout und interaktive
Tools richtig zusammensetzen

Bash-Skripte, die im Terminal korrekt funktionieren, versagen oft in Pipes, als Cronjob oder in CI-Umgebungen. Die Ursache liegt im unterschiedlichen Verhalten von stdin, stdout und interaktiven Tools je nach Kontext. /dev/null, /dev/tty, isatty-Erkennung und Non-Interactive-Mode sind die Werkzeuge für Skripte, die in allen Kontexten zuverlässig funktionieren.

14 Min. Lesezeit /dev/null · /dev/tty · tee · Process Substitution · isatty Bash 4.x · 5.x · POSIX · Linux · macOS

1. Dateideskriptoren: stdin, stdout und stderr

Jeder Prozess in Unix erbt drei offene Dateideskriptoren: stdin (Dateideskriptor 0) für Eingaben, stdout (Dateideskriptor 1) für normale Ausgaben und stderr (Dateideskriptor 2) für Fehlermeldungen. Diese drei Ströme sind austauschbar mit Dateien, Geräten oder anderen Prozessen — das ist das Fundament der Unix-Philosophie "kleine Tools, die über Pipes kommunizieren". In einer interaktiven Shell sind alle drei mit dem Terminal verbunden. In einem Skript, das in einer Pipe läuft, ist stdin an den vorherigen Prozess und stdout an den nächsten gebunden. In einer CI-Pipeline sind alle drei oft mit Log-Aggregatoren verbunden.

Das unterschiedliche Verhalten von Tools je nach stdin/stdout-Kontext ist eine häufige Quelle von Fehlern. curl zeigt in einem Terminal einen Fortschrittsbalken auf stderr — in einer Pipe nicht. grep färbt Treffer im Terminal ein — ohne Terminal nicht. less funktioniert nur mit einem echten Terminal als stdout. read ohne Optionen liest von stdin — wenn stdin keine Pipe ist, wartet es auf Tastatureingabe und blockiert ein nicht-interaktives Skript. Diese Kontextabhängigkeit zu verstehen und korrekt zu handhaben ist entscheidend für Skripte, die in mehr als einem Kontext laufen sollen.

Die Bashsyntax für Redirection ist reich: > datei leitet stdout in eine Datei um (überschreiben), >> datei hängt an, 2> datei leitet stderr um, &> datei leitet sowohl stdout als auch stderr um. 2>&1 verbindet stderr mit dem aktuellen Ziel von stdout. Die Reihenfolge der Umleitungen ist wichtig: cmd > datei 2>&1 ist korrekt (stderr folgt stdout in die Datei), cmd 2>&1 > datei ist falsch (stderr geht ans Terminal, stdout in die Datei). Diese Reihenfolgeregel verwirrt regelmäßig auch erfahrene Shell-Nutzer.

2. /dev/null: Ausgaben richtig verwerfen

/dev/null ist das "schwarze Loch" des Unix-Systems: Alles, was dorthin geschrieben wird, wird sofort verworfen. Für stdin/stdout-Handling in Skripten hat /dev/null zwei Hauptanwendungsfälle: Ausgaben verwerfen, die im aktuellen Kontext irrelevant sind, und als leere Eingabequelle dienen. Das Muster befehl > /dev/null 2>&1 verwirft sowohl stdout als auch stderr — nützlich für Befehle, die in Skripten nur wegen ihres Exit-Codes aufgerufen werden. Die Kurzform befehl &> /dev/null ist äquivalent und kürzer.

Als Eingabequelle öffnet /dev/null als stdin einen sofort geschlossenen Eingabestrom: befehl < /dev/null. Das ist besonders nützlich für Befehle, die von stdin lesen und dabei blockieren würden, wenn stdin mit einem Terminal verbunden ist. SSH mit -n oder explizitem < /dev/null verhindert, dass SSH den stdin-Stream des Skripts konsumiert — ein klassischer Bug in Skripten, die SSH in einer Schleife aufrufen: Das erste SSH-Kommando konsumiert den Rest der Schleifeneingabe aus stdin, alle nachfolgenden Iterationen bekommen leeren Input.


#!/usr/bin/env bash
# stdin-stdout-patterns.sh — correct /dev/null and redirection usage
set -euo pipefail
IFS=$'\n\t'

# Discard stdout and stderr — only care about exit code
check_connectivity() {
  local host="$1"
  ping -c1 -W2 "$host" &>/dev/null
}

# /dev/null as stdin: prevent SSH from consuming script's stdin in loops
deploy_to_hosts() {
  local -a hosts=("$@")
  local host
  for host in "${hosts[@]}"; do
    # Without </dev/null, SSH would consume remaining stdin iterations
    ssh -n -o BatchMode=yes "$host" 'bash /opt/deploy.sh' < /dev/null
  done
}

# Separate stdout (data) from stderr (status messages)
# Callers can capture stdout cleanly without status noise
process_and_report() {
  local input_file="$1"
  echo "Processing $input_file..." >&2   # Status -> stderr
  grep -c '^ERROR' "$input_file"         # Result count -> stdout
}

# Capture only stdout, let stderr pass through to terminal
error_count="$(process_and_report /var/log/app.log)"
echo "Found $error_count errors"

# Redirect stdout to log while preserving exit status
log_output() {
  local logfile="$1"
  shift
  exec > >(tee -a "$logfile") 2>&1
  "$@"
}

3. /dev/tty: Terminal direkt ansprechen

/dev/tty ist das steuernde Terminal des aktuellen Prozesses — unabhängig davon, wohin stdin und stdout umgeleitet wurden. Wenn ein Skript in einer Pipe läuft und trotzdem etwas aus dem Terminal lesen muss (z.B. ein Passwort oder eine interaktive Bestätigung), ist /dev/tty die Lösung. read -p "Bestätigen? " -r answer < /dev/tty liest direkt vom Terminal, auch wenn stdin von einer Datei oder einer Pipe kommt. Das ist das Muster, das Tools wie git commit, sudo und ssh intern verwenden, um Passwörter sicher zu lesen, ohne den normalen Datenfluss zu stören.

Für Ausgaben nach /dev/tty schreiben: echo "Warnung" > /dev/tty gibt die Meldung immer am Terminal aus, auch wenn stdout in eine Datei umgeleitet ist. Das ist nützlich für Fortschrittsanzeigen oder Warnungen, die der Benutzer sehen soll, aber nicht in der Pipe-Ausgabe erscheinen dürfen. Wichtig: /dev/tty ist nur zugänglich, wenn der Prozess ein steuerndes Terminal hat. In Cron-Jobs, systemd-Services und CI-Pipelines gibt es kein steuerndes Terminal — der Versuch, /dev/tty zu öffnen, schlägt mit "No such device or address" fehl. Daher immer erst prüfen, ob ein Terminal vorhanden ist, bevor /dev/tty verwendet wird.

4. isatty: interaktiven Kontext erkennen

Die Fähigkeit, zwischen interaktivem und nicht-interaktivem Kontext zu unterscheiden, ist entscheidend für Skripte und Tools, die in beiden Umgebungen korrekt funktionieren sollen. In Bash erkennt der Test [[ -t 0 ]], ob stdin (FD 0) mit einem Terminal verbunden ist — das entspricht dem C-Standard-Aufruf isatty(0). Analog prüft [[ -t 1 ]] für stdout und [[ -t 2 ]] für stderr. Diese Tests sind der korrekte Weg, um Verhalten an den Kontext anzupassen: Farbige Ausgaben nur im Terminal, keine Fortschrittsanzeigen in Pipes, keine interaktiven Prompts in CI-Umgebungen.

Das Muster für kontextabhängiges Verhalten in Shell-Tools: Am Anfang des Skripts den Kontext erkennen und in einer Variablen speichern. Alle nachfolgenden Ausgabefunktionen nutzen diese Variable, um Terminal-spezifische Features zu aktivieren oder zu deaktivieren. Das Ergebnis ist ein Tool, das im Terminal bunt und informativ ist, in einer Pipe sauber und maschinell lesbar und in CI ohne überflüssige Ausgaben — alles aus demselben Code, ohne manuell umkonfiguriert werden zu müssen. Diese Technik wird von professionellen CLI-Tools wie git, docker und modernen Bash-Frameworks konsequent eingesetzt.


#!/usr/bin/env bash
# isatty-context.sh — context-aware output based on terminal detection
set -euo pipefail
IFS=$'\n\t'

# Detect interactive context once at startup
readonly IS_INTERACTIVE_STDIN=$( [[ -t 0 ]] && echo 1 || echo 0 )
readonly IS_INTERACTIVE_STDOUT=$( [[ -t 1 ]] && echo 1 || echo 0 )
readonly HAS_COLORS=$( [[ -t 1 ]] && tput colors &>/dev/null && echo 1 || echo 0 )

# ANSI colors only when stdout is a terminal
if (( HAS_COLORS )); then
  RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RESET='\033[0m'
else
  RED=''; GREEN=''; YELLOW=''; RESET=''
fi

log_ok()   { printf "${GREEN}[OK]${RESET}   %s\n" "$*" >&2; }
log_warn() { printf "${YELLOW}[WARN]${RESET} %s\n" "$*" >&2; }
log_err()  { printf "${RED}[ERR]${RESET}  %s\n" "$*" >&2; }

# Interactive prompt only when stdin is a terminal
confirm() {
  local prompt="$1"
  if (( IS_INTERACTIVE_STDIN )); then
    printf '%s [y/N] ' "$prompt" > /dev/tty
    read -r -n1 answer < /dev/tty
    printf '\n' > /dev/tty
    [[ "$answer" =~ ^[Yy]$ ]]
  else
    # Non-interactive: default to 'no' unless CI_CONFIRM=1 is set
    [[ "${CI_CONFIRM:-0}" == "1" ]]
  fi
}

# Progress indicator only in terminal
show_progress() {
  local label="$1"
  if (( IS_INTERACTIVE_STDOUT )); then
    printf '\r%s...' "$label" > /dev/tty
  fi
}

confirm "Deploy to production?" && log_ok "Proceeding" || { log_warn "Aborted"; exit 0; }

5. Pipe-Kompositionen und Process Substitution

Pipes (|) und Process Substitution (<(), >()) sind die mächtigsten stdin/stdout-Werkzeuge in Bash. Eine Pipe verbindet stdout des linken Befehls mit stdin des rechten. Der entscheidende Unterschied zu sequenziellen Befehlen: Pipe-Befehle laufen gleichzeitig (nicht nacheinander) — der linke Befehl produziert Daten, der rechte konsumiert sie sofort, ohne dass die gesamte Ausgabe des linken Befehls erst zwischengespeichert wird. Das ist für große Datenmengen entscheidend: grep 'ERROR' large.log | wc -l braucht nie mehr als einen Puffer-Chunk an Speicher, egal wie groß die Logdatei ist.

Process Substitution erweitert Pipes um eine wichtige Fähigkeit: Ein Befehlsausdruck wird wie eine Datei verwendbar. <(befehl) erzeugt einen virtuellen Dateipfad (typisch /dev/fd/63), den man als Dateiargument übergeben kann. Das ermöglicht, zwei Befehlsausgaben direkt zu vergleichen (diff <(sort a.txt) <(sort b.txt)), ohne temporäre Dateien anlegen zu müssen. >(befehl) funktioniert als Ausgabe-Ziel: tee >(gzip -9 > backup.gz) >(sha256sum > backup.sha256) leitet stdout gleichzeitig an zwei Prozesse weiter. Diese Kompositionen sind übersichtlicher und effizienter als explizite Temporärdatei-Verwaltung.

6. tee: gleichzeitig in Datei und Pipe schreiben

Das Werkzeug tee ist das Verbindungsstück zwischen Pipeline-Überwachung und weiterer Verarbeitung: Es liest stdin, schreibt die Daten gleichzeitig in eine Datei und weiterhin in stdout. In Skripten, die Deployment-Ausgaben protokollieren sollen, ohne sie aus der Pipeline zu entfernen, ist tee unverzichtbar. Das Muster exec > >(tee -a "$LOGFILE") 2>&1 am Skriptanfang leitet alle Ausgaben gleichzeitig ins Terminal (oder die CI-Konsole) und in die Logdatei — ohne jeden echo-Aufruf anzupassen.

Ein häufiger Fehler beim Einsatz von tee: Der Exit-Code des primären Befehls geht verloren. befehl | tee logfile gibt den Exit-Code von tee zurück, nicht den von befehl. Mit set -o pipefail ist der Exit-Code des fehlgeschlagenen linken Befehls wieder verfügbar — aber nur der "schlechteste" Exit-Code in der Pipe, nicht der des spezifischen fehlgeschlagenen Befehls. Das Bash-Array ${PIPESTATUS[@]} enthält die Exit-Codes aller Pipe-Segmente nach dem letzten Befehl einer Pipeline — damit kann man gezielt auf den Fehler des ersten Befehls in cmd | tee prüfen, ohne pipefail für die gesamte Pipeline zu aktivieren.


#!/usr/bin/env bash
# tee-process-sub.sh — tee with PIPESTATUS and process substitution
set -euo pipefail
IFS=$'\n\t'

readonly LOG_FILE="/var/log/deploy/$(date +%Y%m%d-%H%M%S).log"
mkdir -p "$(dirname "$LOG_FILE")"

# Redirect all output to log AND terminal simultaneously
exec > >(tee -a "$LOG_FILE") 2>&1

echo "=== Deployment started at $(date) ==="

# Process substitution: split stream to two consumers simultaneously
# tee with process substitution: compress and checksum in one pass
backup_database() {
  local output_base="$1"
  mysqldump --single-transaction --quick mydb \
    | tee \
      >(gzip -9 > "${output_base}.sql.gz") \
      >(sha256sum > "${output_base}.sha256") \
    > /dev/null
  # Verify that both outputs exist
  [[ -f "${output_base}.sql.gz" ]] || { echo "Backup failed" >&2; return 1; }
}

# PIPESTATUS: capture exit codes from each pipe segment
check_pipe_status() {
  set +o pipefail  # Manage ourselves
  long_running_cmd | grep 'SUCCESS'
  local pipe_codes=("${PIPESTATUS[@]}")
  set -o pipefail
  if (( pipe_codes[0] != 0 )); then
    echo "[ERROR] long_running_cmd failed with ${pipe_codes[0]}" >&2
    return "${pipe_codes[0]}"
  fi
}

echo "=== Deployment finished at $(date) ==="

7. Non-Interactive-Mode: Tools richtig konfigurieren

Viele Tools verhalten sich anders, wenn sie erkennen, dass sie nicht in einem interaktiven Terminal laufen. Das ist gewünscht — aber nur, wenn die Tools den Kontext korrekt erkennen. Problematisch werden Tools, die hardcodiert interaktive Features aktivieren (--progress, Pager, bestätigungs-Dialoge) ohne den stdin/stdout-Kontext zu prüfen. Für die stdin/stdout-Komposition in Skripten ist es wichtig, für jedes eingesetzte Tool die richtigen Non-Interactive-Flags zu kennen und konsequent zu verwenden.

Die wichtigsten Non-Interactive-Flags für häufig verwendete Tools: curl mit -s (silent) oder --no-progress-meter unterdrückt den Fortschrittsbalken auf stderr. git mit -q (quiet) oder GIT_TERMINAL_PROMPT=0 als Umgebungsvariable verhindert interaktive Passwort-Prompts. apt-get mit -y und DEBIAN_FRONTEND=noninteractive beantwortet alle Dialoge automatisch. rsync mit --no-progress. docker mit --no-ansi. Das konsequente Setzen dieser Flags ist ein wesentlicher Teil sauber komponierbarer stdin/stdout-Pipelines in Skripten.

8. Here-Documents und Here-Strings für stdin

Here-Documents (<<EOF ... EOF) und Here-Strings (<<< "string") sind Inline-stdin-Quellen in Bash. Sie ermöglichen, mehrzeilige Inhalte oder Strings direkt als stdin an Befehle zu übergeben, ohne temporäre Dateien oder Pipes. Here-Documents sind besonders nützlich für Konfigurationsdateien, SQL-Queries und Remote-Kommandos in SSH-Verbindungen: ssh server 'bash -s' << 'EOF' ... EOF sendet das gesamte Skript als stdin an das Remote-bash-Prozess, ohne das Skript vorher kopieren zu müssen.

Die Variante <<'EOF' (mit Anführungszeichen um den Delimiter) unterdrückt die Variablenexpansion im Here-Document — wichtig, wenn der Inhalt des Here-Documents Shell-Variablen enthält, die auf dem Remote-System ausgewertet werden sollen, nicht lokal. <<-EOF ignoriert führende Tabs (nicht Leerzeichen) in der Einrückung des Here-Documents, was die Code-Lesbarkeit in tief eingerückten Kontexten verbessert. Here-Strings mit <<< sind kompakter für einzeilige stdin-Inputs: wc -w <<< "text" zählt die Wörter ohne Subshell oder Pipe. Ein subtiles Detail: Here-Strings fügen am Ende einen Newline hinzu — printf '%s' "$var" | cmd tut das nicht.

9. Redirection-Varianten im Vergleich

Für das stdin/stdout-Handling in Bash gibt es für jede Anforderung mehrere Lösungen mit unterschiedlichen Eigenschaften. Die Wahl der richtigen Variante beeinflusst Lesbarkeit, Performance und Korrektheit.

Anforderung Variante Eigenschaft Wann verwenden
stdin leer lassen < /dev/null Kein Blocking, kein stdin-Konsum SSH in Schleifen, interaktive Tools nicht-interaktiv aufrufen
stdout + stderr verwerfen &> /dev/null Kompakt, beide Streams weg Exit-Code-Tests, setup-Befehle ohne Ausgabe
stdout loggen + anzeigen tee -a logfile Beide Ziele gleichzeitig Deployment-Logs, CI-Ausgaben archivieren
Zwei Outputs aus einer Quelle tee >(cmd1) >(cmd2) Gleichzeitig, kein Tmpfile Backup + Checksum, Komprimierung + Logging
Terminal lesen in Pipe read < /dev/tty Direkter Terminal-Zugriff Passwörter, Bestätigungen bei umgeleitetem stdin

Die Beherrschung der stdin/stdout-Redirection in Bash ist keine Fingerübung, sondern die Voraussetzung für Skripte, die in verschiedenen Kontexten zuverlässig funktionieren. Die Kombination von isatty-Erkennung, Non-Interactive-Flags, /dev/null für leere Inputs und tee mit Process Substitution für Multi-Output-Pipelines deckt die meisten praktischen Anforderungen ab.

Mironsoft

Shell-Automatisierung, DevOps-Tooling und Deployment-Infrastruktur

Shell-Skripte, die in Terminal, Pipe und CI gleich funktionieren?

Wir analysieren bestehende Shell-Skripte auf stdin/stdout-Probleme, implementieren isatty-basierte kontextadaptive Ausgaben und machen Deployment-Skripte fit für interaktive und nicht-interaktive Umgebungen.

Kontextanalyse

Analyse von stdin/stdout-Problemen in Skripten die in CI anders laufen als lokal

Refactoring

isatty-Erkennung, Non-Interactive-Flags und korrekte Redirection einbauen

Pipeline-Design

Robuste Pipe-Kompositionen mit tee, Process Substitution und PIPESTATUS

10. Zusammenfassung

Das korrekte Zusammensetzen von stdin, stdout und interaktiven Tools in Bash erfordert das Verstehen des Ausführungskontexts. [[ -t 0 ]] und [[ -t 1 ]] erkennen zuverlässig, ob stdin/stdout mit einem Terminal verbunden sind. /dev/null als stdin verhindert, dass SSH und interaktive Tools den stdin-Stream des Skripts konsumieren. /dev/tty ermöglicht direkten Terminal-Zugriff auch wenn stdin/stdout umgeleitet sind. Non-Interactive-Flags für externe Tools eliminieren blockierende Prompts und Fortschrittsanzeigen in Pipes.

Pipe-Kompositionen mit tee und Process Substitution ermöglichen elegante Multi-Output-Pipelines ohne temporäre Dateien. ${PIPESTATUS[@]} gibt Zugriff auf die Exit-Codes aller Pipe-Segmente. Here-Documents mit <<'EOF' senden Skripte als stdin an Remote-Shells ohne vorheriges Kopieren. Die Kombination dieser Techniken ergibt Skripte, die im interaktiven Terminal, in Pipes, als Cron-Jobs und in CI-Pipelines konsistent und korrekt funktionieren — ohne manuell umkonfiguriert werden zu müssen.

stdin, stdout und interaktive Tools — Das Wichtigste auf einen Blick

isatty-Erkennung

[[ -t 0 ]] für stdin, [[ -t 1 ]] für stdout. Farben, Prompts und Fortschrittsanzeigen nur im Terminal aktivieren.

/dev/null und /dev/tty

< /dev/null verhindert stdin-Konsum in Schleifen. /dev/tty für direkten Terminal-Zugriff auch bei umgeleitetem stdin/stdout.

tee + Process Substitution

tee >(cmd1) >(cmd2) für simultane Multi-Output-Pipelines. PIPESTATUS[@] für Exit-Codes aller Pipe-Segmente.

Non-Interactive-Flags

curl -s, git -q, apt-get -y mit DEBIAN_FRONTEND=noninteractive, rsync --no-progress — für jedes Tool den richtigen Flag kennen.

11. FAQ: stdin, stdout und interaktive Tools richtig zusammensetzen

1Wie erkenne ich ob stdin ein Terminal ist?
[[ -t 0 ]] für stdin, [[ -t 1 ]] für stdout. isatty(0) in Shell. Für kontextadaptives Verhalten: am Anfang erkennen, in Variable speichern, überall nutzen.
2SSH konsumiert stdin meiner Schleife?
ssh -n oder ssh ... < /dev/null für jeden SSH-Aufruf in Schleifen. Verhindert stdin-Konsum und nachfolgende leere Iterationen.
3/dev/null als stdin vs. stdout?
stdin < /dev/null: sofort EOF, kein Blocking. stdout > /dev/null: Ausgaben verwerfen. &> /dev/null: beides gleichzeitig.
4Wann funktioniert /dev/tty nicht?
Ohne steuerndes Terminal: Cron, systemd, Docker ohne TTY, CI-Runner. Immer zuerst [[ -t 0 ]] prüfen bevor /dev/tty geöffnet wird.
5Exit-Code des ersten Pipe-Segments?
set -o pipefail für schlechtesten Exit-Code. ${PIPESTATUS[@]} für Exit-Codes aller Segmente — ${PIPESTATUS[0]} ist der erste Befehl.
6Was ist Process Substitution?
Befehl als Datei: <(cmd) als Eingabe, >(cmd) als Ausgabe. diff <(sort a) <(sort b). tee >(gz) >(sha). Kein Tmpfile.
7<< EOF vs. <<'EOF'?
<< EOF: Variablen lokal expandiert. <<'EOF': keine Expansion — Inhalt literal. Für Remote-SSH: <<'EOF' damit Variablen auf Remote-System ausgewertet werden.
8stdout + stderr gleichzeitig in Datei und Terminal?
exec > >(tee -a logfile) 2>&1 am Skriptanfang. Alle Ausgaben gehen ins Terminal UND in die Logdatei ohne jeden echo anzupassen.
9Warum zeigt curl Fortschrittsbalken im Skript?
curl schreibt Fortschrittsbalken auf stderr wenn stderr ein Terminal ist. Lösung: curl -s (silent) oder --no-progress-meter für Skripte und Pipes.
10Fügt <<< einen Newline hinzu?
Ja — wc -c <<< 'abc' = 4. printf '%s' 'abc' | wc -c = 3. Wichtig bei Checksummen und exakten Byte-Zählungen.