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.
Đây là Phần 9 của series 10 bài về viết shell script chắc chắn, mức production. Bạn đã biết biến, vòng lặp, hàm, I/O, và xử lý text. 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.
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. 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.
Strict mode không chính thức: set -euo pipefail
Đặ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
Gộp lại thành cái cộng đồng gọi là strict mode không chính thức. Mỗi flag sửa một loại bẫy khác nhau:
| Flag | Nó làm gì | Không có nó |
|---|---|---|
-e (errexit) | 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 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) | Coi biến chưa set là lỗi | $TYPO expand thành chuỗi rỗng — logic sai mà không ai biết |
-o pipefail | Exit status của pipeline là bước non-zero cuối, không chỉ lệnh cuối | `grep foo missing.txt |
-e chi tiết
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"
Nếu mkdir thất bại (quyền, đĩa đầy), script không bao giờ tới cd hay touch.
Cạnh sắc: -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
Quy tắc: khi bạn cố ý xử lý thất bại, dùng
if,||, hoặc kiểm tra$?rõ ràng — đừng trông-ebắt hết mọi thứ.
-u chi tiết
-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
Đặ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 chi tiết
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
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"
Cứng hóa IFS: IFS=$'\n\t'
IFS (Internal Field Separator) điều khiển cách bash tách expansion không quote trong for, read, và word splitting. Mặc định có space, làm hỏng tên file có dấu cách.
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)
Với hầu hết script, kết hợp IFS=$'\n\t' với luôn quote "$var" là đủ. 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
Trap: dọn dẹp trên mọi đường thoát
Trap đăng ký lệnh shell chạy khi signal hoặc sự kiện shell xảy ra. Ba cái bạn dùng nhiều nhất trong script production:
| Đích trap | Khi nào chạy | Dùng để |
|---|---|---|
EXIT | Mọi lần script thoát — thành công, die, lỗi set -e, hoặc exit N | Xóa file tạm, nhả lock, in thời gian |
ERR | Khi lệnh thất bại (thường kết hợp set -e) | Ghi log số dòng và lệnh trước khi thoát |
INT | Người dùng bấm Ctrl-C | Thông báo tắt êm, rồi dọn qua EXIT |
Mẫu trap + 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 chạy kể cả khi set -e dừng script giữa chừng. Đó là lý do file tạm không vứt bừa trong /tmp sau khi thất bại.
Trap ERR để chẩn đoán tốt hơn
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
Bẫy: 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. Ưu tiên function (trap cleanup EXIT) hơn lệnh inline.
Ôn quote phòng thủ
Strict mode không thay quote — nó phụ thuộc quote. 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[@]}"
Trong handler trap và die, quote "$*" và "$@" giống hệt.
Lỗi rõ ràng với die()
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"
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. Gọi die thay vì exit 1 trần khi con người sẽ đọc log.
Debug: set -x, bash -x, và PS4
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/
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
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: bắt bug thật trước khi chạy
ShellCheck là trình phân tích tĩnh cho shell script. 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
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
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: chạy hai lần vẫn an toàn
Script idempotent cho cùng trạng thái cuối dù bạn chạy một hay mười lần. 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
Mẫu hữu ích:
mkdir -pthay vìmkdirtrầncp -f/rsyncthay vì fail khi đích đã có- Kiểm tra-rồi-làm:
grep -q … || appendthay vì append mù - 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..."
Khung script sẵn sàng production
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")"
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.
Lỗi người mới hay mắc
- Trông
set -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. - Không quote biến trong
trap 'rm -rf $tmpdir' EXIT— dấu cách hoặc glob trong đường dẫn gây mất dữ liệu. - Để file tạm khi script chết — luôn
mktemp+trap cleanup EXIT. - Bỏ qua cảnh báo ShellCheck — SC2086 (expansion không quote) và SC2155 (che return) map thẳng tới sự cố production.
Bài tập
Thử từng bài trước khi mở lời giải.
- Viết
strict-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). - Thêm helper
die()và dùng khi file argument thiếu hoặc không đọc được. - Chạy
shellchecktrê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.
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.shTrap EXIT xóa $tmpcopy và $tmpdir kể cả khi die chạy hoặc cp thất bại. cp -- "$src" ngăn $src bị parse như flag nếu bắt đầu bằng -. DEBUG=1 bật trace mà không làm bẩn lần chạy bình thường.
Điều cốt lõi
Bắt đầu script nghiêm túc bằng set -euo pipefail và biết cạnh sắc trong if, ||, và pipeline. Cứng hóa word splitting bằng IFS=$'\n\t' và quote phòng thủ mọi nơi. Đăng ký trap cleanup EXIT với mktemp để file tạm không bao giờ rò rỉ. Dùng die() cho lỗi dễ đọc, set -x khi debug, ShellCheck trước khi merge, và thiết kế script idempotent. Tiếp theo: mẫu automation và khả năng bảo trì lâu dài.