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.
Đây là Phần 7 của series 10 bài về viết shell script chắc chắn. Bạn đã biết biến, vòng lặp, hàm, và mảng. Giờ ta xử lý cách bash chuyển byte giữa process, file, và terminal — redirection và pipe.
Mỗi lệnh bạn chạy là một process với ba luồng chuẩn. Redirect chúng vào file, bỏ nhiễu, đưa input từ here-document, hoặc nối lệnh bằng |. 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.
Ba luồng chuẩn
Mọi process Unix mở ba file descriptor mặc định:
| Luồng | FD | Đích mặc định | Dùng để |
|---|---|---|---|
| stdin (standard input) | 0 | your keyboard / terminal | dữ liệu vào lệnh |
| stdout (standard output) | 1 | your terminal screen | kết quả bình thường |
| stderr (standard error) | 2 | your terminal screen | lỗi và thông báo chẩn đoán |
stdout và stderr đều in ra terminal mặc định, nhưng là hai kênh riêng. 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
Redirect stdout: > và >>
> gửi stdout vào file và cắt ngắn (làm rỗng) file trước. >> 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
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"
Bẫy:
>tạo file nếu chưa có, nhưng thư mục cha phải tồn tại. Dùngmkdir -ptrước khi ghi vào đường dẫn lồng nhau.
Redirect stderr: 2>
Thêm số file descriptor trước toán tử redirect. 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
Để 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
Gộp luồng: > file 2>&1, thứ tự quan trọng
2>&1 nghĩa là “cho stderr (2) đi cùng chỗ stdout (1) đang trỏ tới”. Shell áp dụng redirect từ trái sang phải.
Đúng — cả stdout và stderr vào all.log:
./deploy.sh > all.log 2>&1
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 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)
| Cú pháp | Ý nghĩa | Bẫy thứ tự? |
|---|---|---|
cmd > file 2>&1 | both streams → file | ✅ safe if 2>&1 comes after > file |
cmd 2>&1 > file | only stdout → file; stderr → terminal | ❌ classic mistake |
cmd &> file | both → file (truncate) | ✅ shorthand, no ordering puzzle |
cmd 2> err.txt | stderr only → err.txt | ✅ when you want to split streams |
cmd > /dev/null 2>&1 | silence everything | ✅ common in cron/scripts |
/dev/null — thùng rác bit
/dev/null là thiết bị đặc biệt nuốt mọi ghi. Đọ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
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.
Redirect input: < file
< đư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
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-document: <<EOF
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)
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
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-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-string: <<<"text"
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-string expand biến và command substitution trừ khi bạn quote cẩn thận:
name="Alice"
tr '[:lower:]' '[:upper:]' <<< "$name" # ALICE
Pipe: cmd1 | cmd2
Pipe | nối stdout lệnh trái với stdin lệnh phải. Đó 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]'
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
Mỗi đoạn trong pipeline chạy trong subshell — process riêng. 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 — ghi và vẫn cho đi tiếp
tee đọc stdin, ghi ra stdout và một hoặc nhiều file cùng lúc. Cần thiết khi bạn muốn file log và 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
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 — đọc input vào biến
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
Dùng read -r để giữ backslash nguyên văn. Để đọ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)
Đọ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
Gộp lại: one-liner và script phân tích log
One-liner:
grep -h "ERROR" /var/log/app/*.log 2>/dev/null \
| sort \
| uniq -c \
| sort -rn \
| head -20 \
| tee top-errors.txt
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"
Lỗi người mới hay mắc
- Viết
cmd 2>&1 > filerồi thắc mắc vì sao lỗi vẫn in ra màn hình —2>&1phải đứng sau> file. - Tưởng
cmd1 | cmd2truyền stderr qua pipe — pipe chỉ chuyển stdout; redirect stderr riêng bằng2>hoặc2>&1. - 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. - Dùng
>khi ý là>>và vô tình xóa sạch file log. - Delimiter here-doc không quote khi cần
$variablesnguyên văn — dùng<<'EOF'.
Bài tập
Thử từng bài trước khi mở lời giải.
- Viết
quiet-ok.shchạycurl -sS -o /dev/null -w "%{http_code}" https://example.com, chỉ inOKnếu exit code là0và HTTP code là200, không thì inFAILra stderr và exit1. - Viết script dùng here-document tạo
nginx-snippet.confvớiserver_namelấy từ$HOST(có expand), rồi kiểm tra file bằnggrep. - Với dòng
access.logdạng192.168.1.5 GET /api 200, dùng pipeline (awkhoặccut+sort+uniq -c) in 3 IP có số request nhiều nhất.
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.2Bà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. Bài 2 dùng EOF không quote để $HOST expand trong here-doc. 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.
Điều cốt lõi
Mỗi lệnh nói chuyện qua stdin (0), stdout (1), và stderr (2). Redirect bằng >, >>, 2>, và 2>&1 — luôn nhớ > file trước 2>&1. Đưa input bằng <, here-doc, và here-string; nối xử lý bằng | (chỉ stdout); dùng tee khi cần file và luồng; đọc dòng bằng read. Nắm những thứ này là bạn ghép được mọi CLI tool vào script.