Bash & Shell Scripting · Part 9 — Robust Scripting & Error Handling
Harden bash scripts with set -euo pipefail, traps, mktemp cleanup, die(), IFS, set -x debugging, ShellCheck, and idempotency — bilingual, with a comparison table and exercises.
This is Part 9 of a 10-part series on writing robust, production-grade shell scripts {Đây là Phần 9 của series 10 bài về viết shell script chắc chắn, mức production}. You already know variables, loops, functions, I/O, and text processing {Bạn đã biết biến, vòng lặp, hàm, I/O, và xử lý text}. Now we turn a working script into one that fails loudly, cleans up after itself, and survives re-runs {Giờ ta biến script chạy được thành script báo lỗi rõ ràng, tự dọn dẹp, và chịu được chạy lại}.
Most bash bugs are silent: a command fails, the script keeps going, and you ship broken output {Phần lớn bug bash là im lặng: một lệnh thất bại, script vẫn chạy tiếp, và bạn ship output hỏng}. This part teaches the unofficial strict mode, traps, temp-file hygiene, and the tooling habits that catch problems before they reach production {Phần này dạy strict mode không chính thức, trap, vệ sinh file tạm, và thói quen dùng tool để bắt lỗi trước khi lên production}.
The unofficial strict mode: set -euo pipefail {Strict mode không chính thức: set -euo pipefail}
Drop these three lines near the top of every serious script — right after the shebang and before real work {Đặt ba dòng này gần đầu mọi script nghiêm túc — ngay sau shebang và trước phần logic chính}:
#!/usr/bin/env bash
set -euo pipefail
Together they form what the community calls the unofficial strict mode {Gộp lại thành cái cộng đồng gọi là strict mode không chính thức}. Each flag fixes a class of foot-gun {Mỗi flag sửa một loại bẫy khác nhau}:
| Flag | What it does {Nó làm gì} | Without it {Không có nó} |
|---|---|---|
-e (errexit) | Exit immediately when any command returns non-zero, unless the failure is in a tested context {Thoát ngay khi lệnh trả non-zero, trừ khi thất bại nằm trong ngữ cảnh được kiểm tra} | Script continues after rm, curl, or grep fails — corrupting later steps {Script chạy tiếp sau khi rm, curl, hoặc grep thất bại — làm hỏng các bước sau} |
-u (nounset) | Treat unset variables as an error {Coi biến chưa set là lỗi} | $TYPO expands to empty string — logic silently wrong {$TYPO expand thành chuỗi rỗng — logic sai mà không ai biết} |
-o pipefail | Pipeline exit status is the last non-zero stage, not only the last command {Exit status của pipeline là bước non-zero cuối, không chỉ lệnh cuối} | `grep foo missing.txt |
-e in detail {-e chi tiết}
With set -e, bash stops the script the moment a simple command fails {Với set -e, bash dừng script ngay khi một lệnh đơn thất bại}:
#!/usr/bin/env bash
set -euo pipefail
mkdir /tmp/demo-dir
cd /tmp/demo-dir
touch hello.txt
echo "all good"
If mkdir fails (permissions, disk full), the script never reaches cd or touch {Nếu mkdir thất bại (quyền, đĩa đầy), script không bao giờ tới cd hay touch}.
Sharp edges {Cạnh sắc}: -e does not always trigger where beginners expect {-e không luôn kích hoạt ở chỗ người mới nghĩ}:
set -euo pipefail
# ✅ Safe — failure is explicitly tested
if ! grep -q "ERROR" app.log; then
echo "no errors"
fi
# ✅ Safe — || handles the failure
grep -q "ERROR" app.log || echo "no errors"
# ✅ Safe — command in if/test condition
if grep -q "ERROR" app.log; then
echo "found errors"
fi
# ⚠️ Surprising — a failing command in && chain may NOT trigger -e
false && echo "never runs" # script continues (exit status of && is 0)
# ⚠️ Surprising — failure inside $(...) or backticks may NOT trigger -e
out=$(grep "missing" /no/file) # grep fails but assignment may not exit
Rule {Quy tắc}: when you intend to handle failure, use
if,||, or check$?explicitly — do not rely on-eto catch everything {khi bạn cố ý xử lý thất bại, dùngif,||, hoặc kiểm tra$?rõ ràng — đừng trông-ebắt hết mọi thứ}.
-u in detail {-u chi tiết}
-u turns typos into hard failures {-u biến lỗi đánh máy thành lỗi cứng}:
set -euo pipefail
name="Alice"
echo "Hello, $name" # OK
echo "Hello, $nmae" # ERROR: nmae: unbound variable
Provide defaults for optional values before you use them {Đặt giá trị mặc định cho biến tùy chọn trước khi dùng}:
PORT="${PORT:-3000}" # use 3000 if PORT is unset or empty
DEBUG="${DEBUG:-0}"
-o pipefail in detail {-o pipefail chi tiết}
Without pipefail, only the last command’s exit code matters {Không có pipefail, chỉ exit code của lệnh cuối mới quan trọng}:
# Without pipefail — exits 0 (wc succeeded)
grep "ERROR" /no/such/file.log | wc -l
echo "exit: $?" # 0 — misleading!
# With pipefail — exits 2 (grep failed)
set -o pipefail
grep "ERROR" /no/such/file.log | wc -l
echo "exit: $?" # 2 — correct
Combine all three at the start of pipelines you care about {Gộp cả ba ở đầu pipeline bạn quan tâm}:
#!/usr/bin/env bash
set -euo pipefail
error_count=$(
grep -h "ERROR" /var/log/app/*.log 2>/dev/null \
| sort \
| uniq -c \
| wc -l
)
echo "distinct error lines: $error_count"
Hardening IFS: IFS=$'\n\t' {Cứng hóa IFS: IFS=$'\n\t'}
IFS (Internal Field Separator) controls how bash splits unquoted expansions in for, read, and word splitting {IFS (Internal Field Separator) điều khiển cách bash tách expansion không quote trong for, read, và word splitting}. The default includes space, which breaks filenames with spaces {Mặc định có space, làm hỏng tên file có dấu cách}.
A common hardening pattern {Một mẫu cứng hóa phổ biến}:
#!/usr/bin/env bash
set -euo pipefail
# Keep tab and newline as separators; drop space from IFS
IFS=$'\n\t'
# Safer iteration over lines (still not perfect for all filenames)
while IFS= read -r -d '' file; do
echo "processing: $file"
done < <(find . -maxdepth 1 -type f -print0)
For most scripts, pairing IFS=$'\n\t' with always quoting "$var" is enough {Với hầu hết script, kết hợp IFS=$'\n\t' với luôn quote "$var" là đủ}. Reset IFS only when you deliberately need the default behavior in a small block {Chỉ reset IFS khi bạn cố ý cần hành vi mặc định trong một block nhỏ}:
# Temporarily restore default IFS for one command
old_ifs=$IFS
IFS=$' \t\n'
# ... something that needs default splitting ...
IFS=$old_ifs
Traps: cleanup on every exit path {Trap: dọn dẹp trên mọi đường thoát}
A trap registers a shell command to run when a signal or shell event fires {Trap đăng ký lệnh shell chạy khi signal hoặc sự kiện shell xảy ra}. The three you use most in production scripts {Ba cái bạn dùng nhiều nhất trong script production}:
| Trap target {Đích trap} | When it runs {Khi nào chạy} | Typical use {Dùng để} |
|---|---|---|
EXIT | Every script exit — success, die, set -e failure, or exit N {Mọi lần script thoát — thành công, die, lỗi set -e, hoặc exit N} | Remove temp files, release locks, print timing {Xóa file tạm, nhả lock, in thời gian} |
ERR | When a command fails (often paired with set -e) {Khi lệnh thất bại (thường kết hợp set -e)} | Log line number and command before exit {Ghi log số dòng và lệnh trước khi thoát} |
INT | User presses Ctrl-C {Người dùng bấm Ctrl-C} | Graceful shutdown message, then cleanup via EXIT {Thông báo tắt êm, rồi dọn qua EXIT} |
trap + mktemp pattern {Mẫu trap + mktemp}
Never invent temp paths with $$ alone — use mktemp {Đừng tự bịa đường dẫn tạm chỉ bằng $$ — dùng mktemp}:
#!/usr/bin/env bash
set -euo pipefail
tmpdir=""
lockfile=""
cleanup() {
local status=$?
if [[ -n "$tmpdir" && -d "$tmpdir" ]]; then
rm -rf "$tmpdir"
fi
if [[ -n "$lockfile" && -f "$lockfile" ]]; then
rm -f "$lockfile"
fi
# Re-raise the original exit code after cleanup
exit "$status"
}
trap cleanup EXIT
trap 'echo "Interrupted — cleaning up" >&2' INT
tmpdir=$(mktemp -d)
lockfile=$(mktemp)
echo "working in $tmpdir"
# Simulate work
echo "data" > "$tmpdir/output.txt"
cp "$tmpdir/output.txt" "$lockfile"
echo "done"
trap cleanup EXIT runs even when set -e aborts the script mid-way {trap cleanup EXIT chạy kể cả khi set -e dừng script giữa chừng}. That is why temp files do not litter /tmp after a failure {Đó là lý do file tạm không vứt bừa trong /tmp sau khi thất bại}.
ERR trap for better diagnostics {Trap ERR để chẩn đoán tốt hơn}
Bash 4.1+ can report which command failed {Bash 4.1+ có thể báo lệnh nào thất bại}:
#!/usr/bin/env bash
set -euo pipefail
on_err() {
local line=$1
echo "ERROR: command failed at line $line" >&2
}
trap 'on_err $LINENO' ERR
false # triggers ERR trap, then -e exits
Gotcha {Bẫy}: quote variables inside trap strings — unquoted
$tmpdirintrap rm -rf $tmpdir EXITbreaks on paths with spaces {quote biến bên trong chuỗi trap —trap rm -rf $tmpdir EXITkhông quote sẽ vỡ với đường dẫn có dấu cách}. Prefer a function (trap cleanup EXIT) over inline commands {Ưu tiên function (trap cleanup EXIT) hơn lệnh inline}.
Defensive quoting recap {Ôn quote phòng thủ}
Strict mode does not replace quoting — it depends on it {Strict mode không thay quote — nó phụ thuộc quote}. The rules from earlier parts still apply {Quy tắc từ các phần trước vẫn đúng}:
# Always double-quote expansions
cp "$src" "$dest"
rm -f "$lockfile"
# Quote command substitutions you pass to commands
count=$(wc -l < "$logfile")
echo "lines: $count"
# Quote here-doc delimiters when literals must stay literal
cat > "$config" <<'EOF'
DEBUG=$PATH_SHOULD_NOT_EXPAND
EOF
# Arrays need special care
files=(report.csv summary.csv)
tar -czf "$archive" "${files[@]}"
Inside trap handlers and die, quote "$*" and "$@" the same way {Trong handler trap và die, quote "$*" và "$@" giống hệt}.
Explicit errors with die() {Lỗi rõ ràng với die()}
set -e handles unexpected failures; die handles expected failures you want to explain {set -e xử lý thất bại không mong đợi; die xử lý thất bại mong đợi mà bạn muốn giải thích}:
#!/usr/bin/env bash
set -euo pipefail
die() {
echo "$*" >&2
exit 1
}
require_file() {
local path=$1
if [[ ! -f "$path" ]]; then
die "required file not found: $path"
fi
}
require_cmd() {
local cmd=$1
if ! command -v "$cmd" >/dev/null 2>&1; then
die "required command not in PATH: $cmd"
fi
}
require_cmd jq
require_file "config.json"
echo "config OK"
Print errors to stderr (>&2), exit with a non-zero code, and keep messages actionable {In lỗi ra stderr (>&2), thoát với code non-zero, và giữ thông báo có thể hành động được}. Call die instead of bare exit 1 when a human will read the log {Gọi die thay vì exit 1 trần khi con người sẽ đọc log}.
Debugging: set -x, bash -x, and PS4 {Debug: set -x, bash -x, và PS4}
When a script misbehaves, trace execution line by line {Khi script chạy sai, trace từng dòng thực thi}:
# Trace everything from this point
set -x
deploy_to_staging
set +x # turn tracing off
# Or run the whole script traced from outside
bash -x ./deploy.sh
# Trace only one function
trace_section() {
set -x
"$@"
set +x
}
trace_section rsync -av ./dist/ server:/var/www/
Customize the trace prefix with PS4 {Tùy biến prefix trace bằng PS4}:
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}: '
set -x
# Output looks like: + deploy.sh:42: kubectl apply -f manifest.yaml
Use tracing in CI logs sparingly — it is noisy but invaluable for “why did this branch not run?” bugs {Dùng trace trong log CI có chừng mực — ồn nhưng vô giá cho bug “vì sao nhánh này không chạy?”}.
ShellCheck: catch real bugs before runtime {ShellCheck: bắt bug thật trước khi chạy}
ShellCheck is a static analyzer for shell scripts {ShellCheck là trình phân tích tĩnh cho shell script}. It catches problems strict mode alone misses {Nó bắt lỗi mà strict mode một mình bỏ sót}:
# Install (macOS)
brew install shellcheck
# Scan a script
shellcheck deploy.sh
# Common severities:
# SC2086 — Double quote to prevent globbing and word splitting
# SC2155 — Declare and assign separately to avoid masking return values
# SC2046 — Quote command substitutions
Example ShellCheck catches {Ví dụ ShellCheck bắt được}:
# SC2086 — unquoted variable in rm
file=$1
rm $file # ShellCheck: quote "$file"
# SC2155 — masks exit code of mktemp
tmpdir=$(mktemp -d) # better: tmpdir=$(mktemp -d) with set -e, or separate lines
# SC2012 — use find instead of ls | grep
ls | grep "\.txt$" # fragile; ShellCheck suggests find or glob
Run ShellCheck in CI or a pre-commit hook — it finds real production bugs (unquoted vars, unreachable code, missing fi) not style nitpicks {Chạy ShellCheck trong CI hoặc pre-commit hook — nó tìm bug production thật (biến không quote, code không tới được, thiếu fi) chứ không phải góp ý style vô hại}.
Idempotency: safe to run twice {Idempotency: chạy hai lần vẫn an toàn}
An idempotent script produces the same end state whether you run it once or ten times {Script idempotent cho cùng trạng thái cuối dù bạn chạy một hay mười lần}. Pipelines and deploy scripts should be idempotent by default {Pipeline và script deploy nên idempotent mặc định}.
#!/usr/bin/env bash
set -euo pipefail
TARGET_DIR="/var/www/myapp"
# Idempotent directory creation
mkdir -p "$TARGET_DIR"
# Idempotent copy — same result on re-run
rsync -a --delete ./build/ "$TARGET_DIR/"
# Idempotent package install (apt)
if ! dpkg -s nginx >/dev/null 2>&1; then
sudo apt-get install -y nginx
fi
# Idempotent line in config
grep -q 'server_name example.com' /etc/nginx/sites-available/app \
|| echo 'server_name example.com;' >> /etc/nginx/sites-available/app
Patterns that help {Mẫu hữu ích}:
mkdir -pinstead of baremkdir{mkdir -pthay vìmkdirtrần}cp -f/rsyncinstead of failing when the destination exists {cp -f/rsyncthay vì fail khi đích đã có}- Test-then-act:
grep -q … || appendinstead of blind append {Kiểm tra-rồi-làm:grep -q … || appendthay vì append mù} - Lock files for scripts that must not overlap:
flockor amktemplock with trap cleanup {File lock cho script không được chạy chồng:flockhoặc lockmktempvới trap cleanup}
#!/usr/bin/env bash
set -euo pipefail
LOCK=/tmp/myjob.lock
exec 9>"$LOCK"
flock -n 9 || { echo "another instance is running" >&2; exit 1; }
echo "running job..."
A production-ready skeleton {Khung script sẵn sàng production}
Putting it all together {Gộp tất cả lại}:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
readonly SCRIPT_NAME=${0##*/}
die() { echo "$SCRIPT_NAME: $*" >&2; exit 1; }
tmpdir=""
cleanup() {
local status=$?
[[ -n "$tmpdir" ]] && rm -rf "$tmpdir"
exit "$status"
}
trap cleanup EXIT
trap 'echo "Interrupted" >&2' INT
trap 'die "failed at line $LINENO"' ERR
tmpdir=$(mktemp -d)
workfile="$tmpdir/data.txt"
require_cmd curl
curl -fsS "https://example.com/api/status" -o "$workfile"
[[ -s "$workfile" ]] || die "empty response from API"
echo "OK: $(cat "$workfile")"
This skeleton: strict mode, safe IFS, quoted paths, mktemp + EXIT trap, die for readable failures, and curl -f so HTTP errors fail the script {Khung này: strict mode, IFS an toàn, đường dẫn được quote, mktemp + trap EXIT, die cho lỗi dễ đọc, và curl -f để lỗi HTTP làm script thất bại}.
Mistakes beginners make {Lỗi người mới hay mắc}
- ❌ Relying on
set -eto catch every failure — commands inif,||,&&, and some subshells behave differently; check explicitly when it matters {Trôngset -ebắt mọi thất bại — lệnh trongif,||,&&, và một số subshell hành xử khác; kiểm tra rõ khi quan trọng}. - ❌ Not quoting variables inside
trap 'rm -rf $tmpdir' EXIT— spaces or globs in paths cause data loss {Không quote biến trongtrap 'rm -rf $tmpdir' EXIT— dấu cách hoặc glob trong đường dẫn gây mất dữ liệu}. - ❌ Leaving temp files when the script dies — always
mktemp+trap cleanup EXIT{Để file tạm khi script chết — luônmktemp+trap cleanup EXIT}. - ❌ Ignoring ShellCheck warnings — SC2086 (unquoted expansion) and SC2155 (masked return) map directly to production incidents {Bỏ qua cảnh báo ShellCheck — SC2086 (expansion không quote) và SC2155 (che return) map thẳng tới sự cố production}.
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
strict-demo.shwithset -euo pipefailthat takes one argument, copies it to a temp file viamktemp, prints the copy path, and removes the temp file on exit (success or failure) {Viếtstrict-demo.shvớiset -euo pipefailnhận một argument, copy vào file tạm quamktemp, in đường dẫn bản copy, và xóa file tạm khi thoát (thành công hay thất bại)}. - Add a
die()helper and use it when the input file argument is missing or not readable {Thêm helperdie()và dùng khi file argument thiếu hoặc không đọc được}. - Run
shellcheckon your script, fix any SC2086 warnings, and addset -xbehind aDEBUG=1guard so tracing only happens when requested {Chạyshellchecktrên script, sửa mọi cảnh báo SC2086, và thêmset -xsau guardDEBUG=1để chỉ trace khi được yêu cầu}.
Solution {Lời giải}
#!/usr/bin/env bash
# strict-demo.sh
set -euo pipefail
IFS=$'\n\t'
die() {
echo "strict-demo.sh: $*" >&2
exit 1
}
[[ "${DEBUG:-0}" == "1" ]] && set -x
tmpdir=""
tmpcopy=""
cleanup() {
local status=$?
if [[ -n "$tmpcopy" && -f "$tmpcopy" ]]; then
rm -f "$tmpcopy"
fi
if [[ -n "$tmpdir" && -d "$tmpdir" ]]; then
rm -rf "$tmpdir"
fi
exit "$status"
}
trap cleanup EXIT
if [[ $# -lt 1 ]]; then
die "usage: $0 <file>"
fi
src=$1
[[ -r "$src" ]] || die "cannot read file: $src"
tmpdir=$(mktemp -d)
tmpcopy=$(mktemp "$tmpdir/copy.XXXXXX")
cp -- "$src" "$tmpcopy"
echo "temp copy: $tmpcopy"
cat "$tmpcopy"chmod +x strict-demo.sh
echo "hello" > sample.txt
./strict-demo.sh sample.txt
DEBUG=1 ./strict-demo.sh sample.txt
./strict-demo.sh missing.txt # die: cannot read file
shellcheck strict-demo.shThe EXIT trap removes $tmpcopy and $tmpdir even when die fires or cp fails {Trap EXIT xóa $tmpcopy và $tmpdir kể cả khi die chạy hoặc cp thất bại}. cp -- "$src" prevents $src from being parsed as flags if it starts with - {cp -- "$src" ngăn $src bị parse như flag nếu bắt đầu bằng -}. DEBUG=1 enables tracing without polluting normal runs {DEBUG=1 bật trace mà không làm bẩn lần chạy bình thường}.
Takeaway {Điều cốt lõi}
Start serious scripts with set -euo pipefail and know its sharp edges in if, ||, and pipelines {Bắt đầu script nghiêm túc bằng set -euo pipefail và biết cạnh sắc trong if, ||, và pipeline}. Harden word splitting with IFS=$'\n\t' and defensive quoting everywhere {Cứng hóa word splitting bằng IFS=$'\n\t' và quote phòng thủ mọi nơi}. Register trap cleanup EXIT with mktemp so temp files never leak {Đăng ký trap cleanup EXIT với mktemp để file tạm không bao giờ rò rỉ}. Use die() for human-readable failures, set -x when debugging, ShellCheck before merge, and design scripts to be idempotent {Dùng die() cho lỗi dễ đọc, set -x khi debug, ShellCheck trước khi merge, và thiết kế script idempotent}. Next up: automation patterns and long-term maintainability {Tiếp theo: mẫu automation và khả năng bảo trì lâu dài}.