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.

Đâ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:

FlagNó 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 traScript 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 pipefailExit 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 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 -e bắ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 trapKhi nào chạyDùng để
EXITMọi lần script thoát — thành công, die, lỗi set -e, hoặc exit NXóa file tạm, nhả lock, in thời gian
ERRKhi 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
INTNgười dùng bấm Ctrl-CThô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 EXIT khô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 trapdie, quote "$*""$@" 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 -p thay vì mkdir trần
  • cp -f / rsync thay vì fail khi đích đã có
  • Kiểm tra-rồi-làm: grep -q … || append thay vì append mù
  • 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..."

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 -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.
  • 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.

  1. 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. Thêm helper die() và dùng khi file argument thiếu hoặc không đọc được.
  3. 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.
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

Trap EXIT xóa $tmpcopy$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'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.