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} | FD | Default destination {Đích mặc định} | Typical use {Dùng để} |
|---|---|---|---|
| stdin (standard input) | 0 | your keyboard / terminal | data into the command {dữ liệu vào lệnh} |
| stdout (standard output) | 1 | your terminal screen | normal results {kết quả bình thường} |
| stderr (standard error) | 2 | your terminal screen | errors 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 {stdout và stderr đề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
Redirecting stdout: > and >> {Redirect stdout: > và >>}
> 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}. Usemkdir -pfirst when writing to nested paths {Dùngmkdir -ptrướ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>&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 — 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 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
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 > fileand wondering why errors still print to the screen —2>&1must come after> file{Viếtcmd 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}. - ❌ Expecting
cmd1 | cmd2to carry stderr through the pipe — pipes move stdout only; redirect stderr separately with2>or2>&1{Tưởngcmd1 | cmd2truyền stderr qua pipe — pipe chỉ chuyển stdout; redirect stderr riêng bằng2>hoặc2>&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 trongwhile 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$variablesnguyê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}.
- Write
quiet-ok.shthat runscurl -sS -o /dev/null -w "%{http_code}" https://example.com, prints onlyOKif the exit code is0and the HTTP code is200, otherwise printsFAILto stderr and exits1{Viếtquiet-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}. - Write a script that uses a here-document to create
nginx-snippet.confwith aserver_nametaken from$HOST(expanded), then verify the file withgrep{Viết script dùng here-document tạonginx-snippet.confvớiserver_namelấy từ$HOST(có expand), rồi kiểm tra file bằnggrep}. - Given
access.loglines like192.168.1.5 GET /api 200, use a pipeline (awkorcut+sort+uniq -c) to print the top 3 IP addresses by request count {Với dòngaccess.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}.
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.2Exercise 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 và 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}.