jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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}:

FlagWhat 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 pipefailPipeline 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
set -euo pipefail # -e exit on error · -u unset = error · -o pipefail -e any command fails → stop now -u using $UNSET → error, not "" -o pipefail a | b fails if a fails too trap cleanup EXIT runs on any exit — success, error, or Ctrl-C → remove temp files, unlock
set -euo pipefail catches failures early; trap cleanup EXIT removes temp files on any exit path

-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 -e to catch everything {khi bạn cố ý xử lý thất bại, dùng if, ||, hoặc kiểm tra $? rõ ràng — đừng trông -e bắ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 để}
EXITEvery 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}
ERRWhen 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}
INTUser 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 $tmpdir in trap rm -rf $tmpdir EXIT breaks on paths with spaces {quote biến bên trong chuỗi trap — trap rm -rf $tmpdir EXIT khô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 trapdie, quote "$*""$@" 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 -p instead of bare mkdir {mkdir -p thay vì mkdir trần}
  • cp -f / rsync instead of failing when the destination exists {cp -f / rsync thay vì fail khi đích đã có}
  • Test-then-act: grep -q … || append instead of blind append {Kiểm tra-rồi-làm: grep -q … || append thay vì append mù}
  • Lock files for scripts that must not overlap: flock or a mktemp lock with trap cleanup {File lock cho script không được chạy chồng: flock hoặc lock mktemp vớ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 -e to catch every failure — commands in if, ||, &&, and some subshells behave differently; check explicitly when it matters {Trông set -e bắt mọi thất bại — lệnh trong if, ||, &&, 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 trong trap '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ôn mktemp + 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}.

  1. Write strict-demo.sh with set -euo pipefail that takes one argument, copies it to a temp file via mktemp, prints the copy path, and removes the temp file on exit (success or failure) {Viết strict-demo.sh với set -euo pipefail nhận một argument, copy vào file tạm qua mktemp, 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)}.
  2. Add a die() helper and use it when the input file argument is missing or not readable {Thêm helper die() và dùng khi file argument thiếu hoặc không đọc được}.
  3. Run shellcheck on your script, fix any SC2086 warnings, and add set -x behind a DEBUG=1 guard so tracing only happens when requested {Chạy shellcheck trên script, sửa mọi cảnh báo SC2086, và thêm set -x sau guard DEBUG=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.sh

The EXIT trap removes $tmpcopy and $tmpdir even when die fires or cp fails {Trap EXIT xóa $tmpcopy$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'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}.