jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Bash & Shell Scripting · Part 7 — I/O, Redirection & Pipes

Master bash I/O: stdin/stdout/stderr, > and >>, 2>&1 order traps, /dev/null, here-docs, pipes, tee, and read — bilingual, with a comparison table and exercises.

This is Part 7 of a 10-part series on writing robust shell scripts {Đây là Phần 7 của series 10 bài về viết shell script chắc chắn}. You already know variables, loops, functions, and arrays {Bạn đã biết biến, vòng lặp, hàm, và mảng}. Now we tackle how bash moves bytes between processes, files, and the terminal — redirection and pipes {Giờ ta xử lý cách bash chuyển byte giữa process, file, và terminal — redirection và pipe}.

Every command you run is a process with three standard streams {Mỗi lệnh bạn chạy là một process với ba luồng chuẩn}. Redirect them to files, discard noise, feed input from here-documents, or chain commands with | {Redirect chúng vào file, bỏ nhiễu, đưa input từ here-document, hoặc nối lệnh bằng |}. Get this right and your scripts become quiet, composable, and debuggable {Làm đúng phần này thì script của bạn im lặng hơn, ghép được, và dễ debug hơn}.


The three standard streams {Ba luồng chuẩn}

Every Unix process opens three file descriptors by default {Mọi process Unix mở ba file descriptor mặc định}:

Stream {Luồng}FDDefault destination {Đích mặc định}Typical use {Dùng để}
stdin (standard input)0your keyboard / terminaldata into the command {dữ liệu vào lệnh}
stdout (standard output)1your terminal screennormal results {kết quả bình thường}
stderr (standard error)2your terminal screenerrors and diagnostics {lỗi và thông báo chẩn đoán}

stdout and stderr both print to the terminal by default, but they are separate channels {stdoutstderr đều in ra terminal mặc định, nhưng là hai kênh riêng}. That separation lets you save results to a file while still seeing errors on screen — or silence errors entirely {Tách như vậy giúp bạn lưu kết quả vào file mà vẫn thấy lỗi trên màn hình — hoặc tắt hẳn lỗi}.

# ls prints filenames to stdout (FD 1)
ls /tmp

# ls on a missing path prints to stderr (FD 2)
ls /no/such/path

# Both may appear on your screen, but bash tracks them separately
ls /tmp /no/such/path
command a process stdin (0) < input.txt stdout (1) > out.txt stderr (2) 2> err.txt
stdin (0), stdout (1), and stderr (2) can each be redirected independently of the terminal

Redirecting stdout: > and >> {Redirect stdout: >>>}

> sends stdout to a file and truncates (empties) it first {> gửi stdout vào file và cắt ngắn (làm rỗng) file trước}. >> appends without wiping existing content {>> nối thêm mà không xóa nội dung cũ}.

# Overwrite — previous contents of out.txt are gone
echo "first run" > out.txt

# Append — out.txt now has two lines
echo "second run" >> out.txt

cat out.txt
# first run
# second run

A practical log pattern {Một mẫu log thực tế}:

#!/usr/bin/env bash
LOG="deploy.log"

echo "=== deploy started $(date -Iseconds) ===" >> "$LOG"
./scripts/migrate.sh >> "$LOG" 2>&1   # capture everything (see below)
echo "=== deploy finished ===" >> "$LOG"

Gotcha {Bẫy}: > creates the file if it does not exist, but the parent directory must exist {> tạo file nếu chưa có, nhưng thư mục cha phải tồn tại}. Use mkdir -p first when writing to nested paths {Dùng mkdir -p trước khi ghi vào đường dẫn lồng nhau}.


Redirecting stderr: 2> {Redirect stderr: 2>}

Prefix the redirection operator with the file descriptor number {Thêm số file descriptor trước toán tử redirect}. 2> sends stderr only to a file {2> gửi chỉ stderr vào file}.

# Errors go to err.log; normal output still prints to the terminal
grep -r "TODO" /etc 2> err.log

# Discard stderr entirely (common in scripts)
ls /nope 2> /dev/null

# Send stderr to a file AND keep stdout on screen
find /var/log -name "*.log" 2> find-errors.txt

To print a message to stderr from your own script, use FD 2 explicitly {Để in thông báo ra stderr từ script của bạn, dùng FD 2 rõ ràng}:

echo "something went wrong" >&2
# same thing, more explicit:
echo "something went wrong" 1>&2

Combining streams: > file 2>&1, order matters {Gộp luồng: > file 2>&1, thứ tự quan trọng}

2>&1 means “make stderr (2) go wherever stdout (1) currently points” {2>&1 nghĩa là “cho stderr (2) đi cùng chỗ stdout (1) đang trỏ tới”}. The shell applies redirections left to right {Shell áp dụng redirect từ trái sang phải}.

Correct — both stdout and stderr land in all.log {Đúng — cả stdout và stderr vào all.log}:

./deploy.sh > all.log 2>&1

Wrong order — stderr still goes to the terminal {Thứ tự sai — stderr vẫn ra terminal}:

./deploy.sh 2>&1 > all.log
# Step 1: 2>&1  → stderr now points to the terminal (same as stdout's old target)
# Step 2: > all.log → only stdout moves to the file; stderr stays on screen

Bash also offers a shorthand (bash 4+) {Bash còn có cú pháp rút gọn (bash 4+)}:

./deploy.sh &> all.log      # stdout + stderr → all.log (truncate)
./deploy.sh &>> all.log     # stdout + stderr → all.log (append)
Form {Cú pháp}Meaning {Ý nghĩa}Order trap? {Bẫy thứ tự?}
cmd > file 2>&1both streams → file✅ safe if 2>&1 comes after > file
cmd 2>&1 > fileonly stdout → file; stderr → terminal❌ classic mistake
cmd &> fileboth → file (truncate)✅ shorthand, no ordering puzzle
cmd 2> err.txtstderr only → err.txt✅ when you want to split streams
cmd > /dev/null 2>&1silence everything✅ common in cron/scripts

/dev/null — the bit bucket {/dev/null — thùng rác bit}

/dev/null is a special device that discards all writes {/dev/null là thiết bị đặc biệt nuốt mọi ghi}. Reading from it returns EOF immediately {Đọc từ nó trả EOF ngay}.

# Hide errors from a probe command
if grep -q "ERROR" huge.log 2>/dev/null; then
  echo "errors found"
fi

# Run a command quietly but still check exit code
curl -sS https://example.com/api > /dev/null
echo "curl exit: $?"    # 0 = HTTP OK (curl still validates)

# Discard stdout, keep stderr visible
some_noisy_tool > /dev/null

Use /dev/null when you expect failure and do not need the message — not to hide real bugs in production scripts {Dùng /dev/null khi bạn dự đoán thất bại và không cần thông báo — không phải để che bug thật trong script production}.


Input redirection: < file {Redirect input: < file}

< feeds a file’s contents to a command’s stdin instead of the keyboard {< đưa nội dung file vào stdin của lệnh thay vì bàn phím}.

# Count lines in a file without using cat (preferred)
wc -l < access.log

# Sort a file in place safely (sort reads stdin, writes stdout)
sort < unsorted.txt > sorted.txt

# diff can read two files directly, but stdin works too
grep "pattern" < input.txt

Many commands accept a filename argument (grep pattern file) and < file is equivalent to piping — pick whichever reads clearer in context {Nhiều lệnh nhận đối số tên file (grep pattern file) và < file tương đương pipe — chọn cái đọc rõ hơn trong ngữ cảnh}.


Here-documents: <<EOF {Here-document: <<EOF}

A here-document feeds multiple lines to stdin until a delimiter line {Here-document đưa nhiều dòng vào stdin đến khi gặp dòng delimiter}.

cat <<EOF
Hello, $USER
Today is $(date +%Y-%m-%d)
EOF
# Variables and $(…) ARE expanded (unquoted delimiter)

Quoted delimiter disables expansion — literal text {Delimiter có quote tắt expansion — text nguyên văn}:

cat <<'EOF'
Hello, $USER          # prints literally: Hello, $USER
Today is $(date)      # prints literally: Today is $(date)
EOF

Indented delimiter <<-EOF strips leading tabs (not spaces) from each line {Delimiter thụt lề <<-EOF bỏ tab đầu dòng (không phải space)}:

if true; then
	cat <<-EOF
		This line had a leading tab — stripped in output
		So does this one
	EOF
fi

Here-documents are ideal for generating config snippets, SQL, or multi-line messages inside scripts {Here-document hợp để tạo đoạn config, SQL, hoặc thông báo nhiều dòng trong script}:

#!/usr/bin/env bash
CONFIG="/tmp/myapp.env"

cat > "$CONFIG" <<EOF
PORT=3000
NODE_ENV=development
LOG_LEVEL=debug
EOF

echo "wrote $CONFIG"

Here-strings: <<<"text" {Here-string: <<<"text"}

A here-string passes a single string to stdin — handy for one-liners {Here-string đưa một chuỗi vào stdin — tiện cho one-liner}.

# Reverse a string
rev <<< "hello"

# Feed a value to a command expecting stdin
grep "error" <<< "$log_line"

# Compare without a temp file
diff <(echo "a") <(echo "b")   # process substitution — related idea

Here-strings expand variables and command substitutions unless you quote carefully {Here-string expand biến và command substitution trừ khi bạn quote cẩn thận}:

name="Alice"
tr '[:lower:]' '[:upper:]' <<< "$name"   # ALICE

Pipes: cmd1 | cmd2 {Pipe: cmd1 | cmd2}

The pipe | connects the stdout of the left command to the stdin of the right command {Pipe | nối stdout lệnh trái với stdin lệnh phải}. It is the backbone of the Unix “small tools” philosophy {Đó là xương sống của triết lý Unix “công cụ nhỏ”}.

# Classic pipeline: filter → sort → count
grep "ERROR" app.log | sort | uniq -c | sort -rn | head -5

# Find large files under /var, show top 10
find /var -type f -size +10M 2>/dev/null | head -10

# JSON pretty-print (if jq is installed)
curl -sS https://api.example.com/users | jq '.[0:3]'

Pipes pass stdout only — stderr from any stage still goes to the terminal unless you redirect it separately {Pipe chỉ truyền stdout — stderr ở bất kỳ bước nào vẫn ra terminal trừ khi bạn redirect riêng}:

# Only matching lines reach wc; errors from find still print
find /etc -name "*.conf" 2>/dev/null | wc -l

Each segment in a pipeline runs in a subshell — a separate process {Mỗi đoạn trong pipeline chạy trong subshell — process riêng}. Variables set in the middle of a pipe do not survive in the parent shell {Biến đặt giữa pipe không sống sót trong shell cha}:

count=$(grep -c "WARN" app.log)   # subshell via $() — fine for capturing output
# But this does NOT update outer variables:
grep "WARN" app.log | while read -r line; do ((n++)); done
echo "$n"   # empty — the while loop ran in a subshell

tee — write and pass through {tee — ghi và vẫn cho đi tiếp}

tee reads stdin, writes to stdout and one or more files at the same time {tee đọc stdin, ghi ra stdout và một hoặc nhiều file cùng lúc}. Essential when you want a log file and live terminal output {Cần thiết khi bạn muốn file log output live trên terminal}.

# See output AND save it
./run-tests.sh 2>&1 | tee test-results.log

# Append to a log without losing the stream
make build 2>&1 | tee -a build.log

# Split to two files
echo "shared data" | tee file-a.txt file-b.txt

Combine tee with process substitution for split logging {Kết hợp tee với process substitution để tách log}:

./deploy.sh 2>&1 | tee >(grep ERROR >&2) deploy.log
# Normal lines → deploy.log; lines containing ERROR also hit stderr

read — capture input into variables {read — đọc input vào biến}

The read builtin reads a line from stdin (keyboard, pipe, or redirected file) into shell variables {Builtin read đọc một dòng từ stdin (bàn phím, pipe, hoặc file redirect) vào biến shell}.

# Interactive prompt
read -r -p "Your name: " name
echo "Hello, $name"

# Read from a file via redirection
while read -r line; do
  echo "line: $line"
done < data.txt

# Read from a pipe
echo "one two three" | read -r a b c
# ⚠ pipe creates subshell — $a $b $c are empty here in the parent shell

Use read -r to keep backslashes literal {Dùng read -r để giữ backslash nguyên văn}. For reading in a pipeline loop where you need side effects, use a here-string or process substitution instead {Để đọc trong vòng lặp pipeline khi cần side effect, dùng here-string hoặc process substitution thay thế}:

# Safe pattern — no subshell for the while body
while read -r line; do
  printf '%s\n' "$line"
done < <(grep "ERROR" app.log)

Reading multiple fields {Đọc nhiều trường}:

echo "192.168.1.1 443 tcp" | {
  read -r ip port proto
  echo "connecting to $ip:$port ($proto)"
}
# Curly-brace group runs in current shell when not piped as the last segment

Putting it together: a log-analysis one-liner and script {Gộp lại: one-liner và script phân tích log}

One-liner {One-liner}:

grep -h "ERROR" /var/log/app/*.log 2>/dev/null \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -20 \
  | tee top-errors.txt

Small script with here-doc, redirection, and read {Script nhỏ với here-doc, redirect, và read}:

#!/usr/bin/env bash
set -euo pipefail

REPORT="report.txt"
: > "$REPORT"   # truncate to empty

{
  echo "=== Error summary $(date -Iseconds) ==="
  grep -h "ERROR" /var/log/app/*.log 2>/dev/null | sort | uniq -c | sort -rn
} >> "$REPORT"

if [[ ! -s "$REPORT" ]]; then
  echo "no errors found" | tee -a "$REPORT"
fi

echo "report written to $REPORT"

Mistakes beginners make {Lỗi người mới hay mắc}

  • ❌ Writing cmd 2>&1 > file and wondering why errors still print to the screen — 2>&1 must come after > file {Viết cmd 2>&1 > file rồi thắc mắc vì sao lỗi vẫn in ra màn hình — 2>&1 phải đứng sau > file}.
  • ❌ Expecting cmd1 | cmd2 to carry stderr through the pipe — pipes move stdout only; redirect stderr separately with 2> or 2>&1 {Tưởng cmd1 | cmd2 truyền stderr qua pipe — pipe chỉ chuyển stdout; redirect stderr riêng bằng 2> hoặc 2>&1}.
  • ❌ Forgetting that each pipe stage is a separate process — variables changed inside while read … | … or mid-pipeline loops do not persist in the parent shell {Quên mỗi bước pipe là process riêng — biến đổi trong while read … | … hoặc vòng lặp giữa pipeline không còn trong shell cha}.
  • ❌ Using > when you meant >> and accidentally wiping a log file {Dùng > khi ý là >> và vô tình xóa sạch file log}.
  • ❌ Unquoted here-doc delimiter when you need literal $variables — use <<'EOF' {Delimiter here-doc không quote khi cần $variables nguyên văn — dùng <<'EOF'}.

Exercises {Bài tập}

Try each before opening the solution {Thử từng bài trước khi mở lời giải}.

  1. Write quiet-ok.sh that runs curl -sS -o /dev/null -w "%{http_code}" https://example.com, prints only OK if the exit code is 0 and the HTTP code is 200, otherwise prints FAIL to stderr and exits 1 {Viết quiet-ok.sh chạy curl -sS -o /dev/null -w "%{http_code}" https://example.com, chỉ in OK nếu exit code là 0 và HTTP code là 200, không thì in FAIL ra stderr và exit 1}.
  2. Write a script that uses a here-document to create nginx-snippet.conf with a server_name taken from $HOST (expanded), then verify the file with grep {Viết script dùng here-document tạo nginx-snippet.conf với server_name lấy từ $HOST (có expand), rồi kiểm tra file bằng grep}.
  3. Given access.log lines like 192.168.1.5 GET /api 200, use a pipeline (awk or cut + sort + uniq -c) to print the top 3 IP addresses by request count {Với dòng access.log dạng 192.168.1.5 GET /api 200, dùng pipeline (awk hoặc cut + sort + uniq -c) in 3 IP có số request nhiều nhất}.
Solution {Lời giải}
#!/usr/bin/env bash
# quiet-ok.sh
set -euo pipefail

http_code=$(curl -sS -o /dev/null -w "%{http_code}" https://example.com)

if [[ "$http_code" == "200" ]]; then
  echo "OK"
  exit 0
else
  echo "FAIL (HTTP $http_code)" >&2
  exit 1
fi
#!/usr/bin/env bash
# write-nginx-snippet.sh
set -euo pipefail

HOST="${HOST:-localhost}"

cat > nginx-snippet.conf <<EOF
server {
    listen 80;
    server_name $HOST;
    root /var/www/html;
}
EOF

grep -q "server_name $HOST" nginx-snippet.conf
echo "nginx-snippet.conf looks good"
# Top 3 IPs from access.log (sample data)
# 192.168.1.5 GET /api 200
# 10.0.0.2 GET /health 200
# 192.168.1.5 GET /api 201

awk '{ print $1 }' access.log \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -3

# Example output:
#   2 192.168.1.5
#   1 10.0.0.2

Exercise 1 uses /dev/null to discard the body and checks both curl’s exit code (set -e) and the captured HTTP status {Bài 1 dùng /dev/null bỏ body và kiểm tra cả exit code của curl (set -e) lẫn HTTP status đã capture}. Exercise 2 shows an unquoted EOF so $HOST expands inside the here-doc {Bài 2 dùng EOF không quote để $HOST expand trong here-doc}. Exercise 3 is a classic awk | sort | uniq -c | sort -rn | head pipeline — stderr from missing files would need 2>/dev/null if logs were globbed {Bài 3 là pipeline awk | sort | uniq -c | sort -rn | head kinh điển — stderr từ file thiếu cần 2>/dev/null nếu log được glob}.


Takeaway {Điều cốt lõi}

Every command speaks on stdin (0), stdout (1), and stderr (2) {Mỗi lệnh nói chuyện qua stdin (0), stdout (1), và stderr (2)}. Redirect with >, >>, 2>, and 2>&1 — always remember > file before 2>&1 {Redirect bằng >, >>, 2>, và 2>&1 — luôn nhớ > file trước 2>&1}. Feed input with <, here-docs, and here-strings; chain processing with | (stdout only); use tee when you need a file and the stream; capture lines with read {Đưa input bằng <, here-doc, và here-string; nối xử lý bằng | (chỉ stdout); dùng tee khi cần file luồng; đọc dòng bằng read}. Master these and you can wire any CLI tool into a script {Nắm những thứ này là bạn ghép được mọi CLI tool vào script}.