Bash & Shell Scripting · Part 10 — Real-World Automation & Best Practices
Capstone: getopts, usage(), script structure, logging, env config, and a production-grade backup script with traps and validation — plus best practices and when NOT to use bash. Bilingual exercises.
Đây là Phần 10 của series 10 bài — bài kết gom mọi thứ thành script bạn thực sự đưa vào production. Phần 1–9 đã cho bạn ngôn ngữ: biến, điều kiện, vòng lặp, function, I/O, công cụ text, và xử lý lỗi. Giờ ta lắp các mảnh thành script automation thật có parse tham số, logging, cấu hình, kiểm tra, và dọn dẹp.
Hết phần này bạn sẽ có một template trong đầu — và một ví dụ chạy được — cho mọi script bạn viết ở công việc.
Pipeline script production
Một script automation dễ bảo trì luôn theo bốn nhịp giống nhau:
- Parse tham số — hiểu người dùng muốn gì.
- Validate — kiểm tra input, dependency, và quyền trước khi đụng dữ liệu.
- Làm việc — tác vụ thật (backup, deploy, sync, v.v.).
- Log + exit — báo cáo chuyện gì xảy ra và trả status code có ý nghĩa.
Mọi thứ khác — function, hằng số, default từ env — tồn tại để bốn bước đó dễ đọc và an toàn.
Parse tùy chọn với getopts
getopts là parser tích hợp của bash cho flag ngắn như -v, -h, -o file.txt. Nó portable trong bash và ít lỗi hơn nhiều so với tự viết vòng while/case.
Chuỗi tùy chọn
Tham số thứ hai của getopts là chuỗi tùy chọn:
- Chữ cái đơn (
v) nghĩa là flag không nhận tham số:-v. - Chữ cái theo sau bởi
:(o:) nghĩa là flag bắt buộc có tham số:-o file.txt. :ở đầu tắt thông báo lỗi mặc định củagetoptsđể bạn inusageriêng.
#!/usr/bin/env bash
set -euo pipefail
VERBOSE=0
OUTPUT=""
usage() {
cat >&2 <<'EOF'
Usage: mytool [-h] [-v] [-o FILE] [ARGS...]
-h show this help
-v verbose output
-o write output to FILE (required for some modes)
EOF
}
while getopts ":hvo:" opt; do
case "$opt" in
h) usage; exit 0 ;;
v) VERBOSE=1 ;;
o) OUTPUT="$OPTARG" ;;
\?) echo "Unknown option: -$OPTARG" >&2; usage; exit 2 ;;
:) echo "Option -$OPTARG requires an argument." >&2; usage; exit 2 ;;
esac
done
shift $((OPTIND - 1)) # remaining positional args: "$@"
echo "verbose=$VERBOSE output=$OUTPUT remaining=$*"
Biến getopts đặt sẵn cho bạn:
| Biến | Ý nghĩa |
|---|---|
$opt | Chữ cái vừa parse |
$OPTARG | Tham số cho flag có đối số (-o) |
$OPTIND | Chỉ số arg vị trí tiếp theo; dùng shift $((OPTIND - 1)) sau vòng lặp |
Giới hạn:
getoptschỉ xử lý flag ngắn (-abclà ba flag;-o filehoạt động). Với option dài (--verbose,--output=file) dùnggetoptcủa GNU coreutils, parser tự viết — hoặc chuyển sang ngôn ngữ có thư viện CLI thật.
Xây usage() / help
Mọi script nhận flag nên có function usage in ra stderr. Như vậy output thường trên stdout vẫn sạch để pipe.
Pattern hiệu quả:
- Dùng heredoc (
<<'EOF') để không vô tình expand$variablestrong help. - Gọi
usagetừ-h, từ handler flag lạ, và từ lỗi validate. - Thoát
0khi help cố ý (-h), thoát2(hoặc quy ước dự án) khi dùng sai.
usage() {
cat >&2 <<EOF
$(basename "$0") — backup a directory to a tarball
Usage: $(basename "$0") [-hvn] [-o DIR] SOURCE
-h show help
-v verbose logging
-n dry-run (show commands, do not execute)
-o output directory (default: \$BACKUP_DIR or ./backups)
Environment:
BACKUP_DIR default output directory
LOG_LEVEL info | warn | error (default: info)
Exit codes:
0 success
1 runtime failure (copy, tar, etc.)
2 usage / validation error
EOF
}
Chú ý \$BACKUP_DIR trong heredoc — escape để help hiện tên literal, không phải giá trị biến.
Cấu trúc script thật
Lệnh rải rác ở top-level sẽ khó bảo trì sau ~50 dòng. Script production theo một khung xương:
#!/usr/bin/env bash
# strict mode — Part 9 covered each flag
set -euo pipefail
# ── constants ──────────────────────────────────────
readonly SCRIPT_NAME="$(basename "$0")"
readonly DEFAULT_BACKUP_DIR="${BACKUP_DIR:-./backups}"
# ── globals set by getopts / env ───────────────────
VERBOSE=0
DRY_RUN=0
# ── logging ────────────────────────────────────────
log() { ... }
info() { ... }
warn() { ... }
error() { ... }
# ── helpers ────────────────────────────────────────
usage() { ... }
require_cmd() { ... }
cleanup() { ... }
# ── core logic ─────────────────────────────────────
validate_inputs() { ... }
run_backup() { ... }
# ── entry point ────────────────────────────────────
main() {
parse_args "$@"
validate_inputs
run_backup
}
main "$@"
Vì sao main "$@" ở cuối?
- Toàn bộ script chạy trong
main, nênreturnsớm không vô tình bỏ qua setup cleanup. - Bạn có thể source file trong test và gọi từng function mà không chạy
main. "$@"giữ quoting tham số — không bao giờ dùng$*để chuyển input người dùng.
Helper logging — stderr kèm timestamp
Script nên log chẩn đoán ra stderr và để stdout cho dữ liệu người dùng có thể pipe. Một lớp logging nhỏ giữ thông điệp nhất quán:
_log_level="${LOG_LEVEL:-info}" # info | warn | error
log() {
# usage: log LEVEL "message"
local level="$1"
shift
printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$level" "$*" >&2
}
info() { log INFO "$@"; }
warn() { log WARN "$@"; }
error() { log ERROR "$@"; }
# Optional: suppress info when quiet
info() {
[[ "$VERBOSE" -eq 1 || "$_log_level" == "info" ]] && log INFO "$@" || true
}
Redirect >&2 là bắt buộc với tool xuất stdout dạng máy đọc được. Timestamp quan trọng khi bạn grep log vài giờ sau trong CI.
Cấu hình qua biến môi trường
Hard-code path và secret trong script là bẫy bảo trì và bảo mật. Dùng biến môi trường kèm default:
# ${VAR:-default} — use default if unset or empty
# ${VAR:-} — empty default (explicit "no value")
readonly BACKUP_DIR="${BACKUP_DIR:-./backups}"
readonly RETENTION_DAYS="${RETENTION_DAYS:-7}"
readonly COMPRESS="${COMPRESS:-gzip}" # gzip | none
Ghi mọi biến env trong usage() và trong block comment đầu file. Với secret (token API, mật khẩu), không bao giờ để trong script — đọc từ env hoặc secrets manager và fail nhanh nếu thiếu.
if [[ -z "${DEPLOY_TOKEN:-}" ]]; then
error "DEPLOY_TOKEN is not set"
exit 2
fi
Ví dụ đầy đủ: backup-dir.sh
Đây là script backup mức production dùng mọi kỹ thuật trong series. Đọc từ trên xuống trước khi copy từng phần vào dự án của bạn.
#!/usr/bin/env bash
# backup-dir.sh — tarball a source directory with validation, logging, and cleanup
#
# Environment:
# BACKUP_DIR output directory (default: ./backups)
# RETENTION_DAYS delete archives older than N days (default: 7, 0 = skip)
# LOG_LEVEL info | warn | error
#
# Exit codes:
# 0 success
# 1 runtime error (tar, disk, etc.)
# 2 usage / validation error
set -euo pipefail
# ── constants ──────────────────────────────────────
readonly SCRIPT_NAME="$(basename "$0")"
readonly DEFAULT_BACKUP_DIR="${BACKUP_DIR:-./backups}"
readonly RETENTION_DAYS="${RETENTION_DAYS:-7}"
# ── runtime state ──────────────────────────────────
VERBOSE=0
DRY_RUN=0
OUTPUT_DIR="$DEFAULT_BACKUP_DIR"
SOURCE=""
TEMP_LIST=""
# ── logging ────────────────────────────────────────
log() {
local level="$1"
shift
printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$level" "$*" >&2
}
info() { [[ "$VERBOSE" -eq 1 ]] && log INFO "$@" || log INFO "$@"; }
warn() { log WARN "$@"; }
error() { log ERROR "$@"; }
# ── usage ──────────────────────────────────────────
usage() {
cat >&2 <<EOF
$SCRIPT_NAME — create a timestamped tarball backup of a directory
Usage: $SCRIPT_NAME [-hvn] [-o DIR] SOURCE
-h show this help
-v verbose (log every step)
-n dry-run (print commands, do not execute)
-o output directory (default: \$BACKUP_DIR or ./backups)
Environment:
BACKUP_DIR default -o value
RETENTION_DAYS prune archives older than N days (0 = disable)
Examples:
$SCRIPT_NAME -v ./myapp
BACKUP_DIR=/mnt/backups $SCRIPT_NAME -o /mnt/backups ./myapp
EOF
}
# ── helpers ────────────────────────────────────────
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
error "required command not found: $cmd"
exit 2
fi
}
run() {
# run [cmd...] — respects DRY_RUN
if [[ "$DRY_RUN" -eq 1 ]]; then
info "[dry-run] $*"
else
info "exec: $*"
"$@"
fi
}
cleanup() {
local code=$?
if [[ -n "$TEMP_LIST" && -f "$TEMP_LIST" ]]; then
rm -f "$TEMP_LIST"
fi
if [[ "$code" -ne 0 ]]; then
error "exiting with status $code"
fi
exit "$code"
}
# ── argument parsing ───────────────────────────────
parse_args() {
local opt
while getopts ":hvno:" opt; do
case "$opt" in
h) usage; exit 0 ;;
v) VERBOSE=1 ;;
n) DRY_RUN=1 ;;
o) OUTPUT_DIR="$OPTARG" ;;
\?) error "unknown option: -$OPTARG"; usage; exit 2 ;;
:) error "option -$OPTARG requires an argument"; usage; exit 2 ;;
esac
done
shift $((OPTIND - 1))
if [[ $# -lt 1 ]]; then
error "missing SOURCE directory argument"
usage
exit 2
fi
if [[ $# -gt 1 ]]; then
error "unexpected extra arguments: $*"
usage
exit 2
fi
SOURCE="$1"
}
# ── validation ─────────────────────────────────────
validate_inputs() {
require_cmd tar
require_cmd find
require_cmd date
if [[ ! -d "$SOURCE" ]]; then
error "SOURCE is not a directory: $SOURCE"
exit 2
fi
if [[ ! -d "$OUTPUT_DIR" ]]; then
info "creating output directory: $OUTPUT_DIR"
run mkdir -p "$OUTPUT_DIR"
fi
if [[ ! -w "$OUTPUT_DIR" ]]; then
error "output directory is not writable: $OUTPUT_DIR"
exit 2
fi
}
# ── core work ──────────────────────────────────────
prune_old_backups() {
[[ "$RETENTION_DAYS" -eq 0 ]] && return 0
info "pruning backups older than $RETENTION_DAYS days in $OUTPUT_DIR"
run find "$OUTPUT_DIR" -maxdepth 1 -name '*.tar.gz' -mtime "+$RETENTION_DAYS" -print -delete
}
run_backup() {
local base dest ts
base="$(basename "$SOURCE")"
ts="$(date '+%Y%m%d-%H%M%S')"
dest="$OUTPUT_DIR/${base}-${ts}.tar.gz"
TEMP_LIST="$(mktemp)"
trap cleanup EXIT
info "backing up $SOURCE → $dest"
run tar -czf "$dest" -C "$(dirname "$SOURCE")" "$base"
if [[ "$DRY_RUN" -eq 0 ]]; then
local size
size="$(du -h "$dest" | cut -f1)"
info "backup complete: $dest ($size)"
fi
prune_old_backups
info "done"
}
# ── entry point ────────────────────────────────────
main() {
parse_args "$@"
validate_inputs
run_backup
}
main "$@"
Đi qua từng lớp làm gì:
- mọi lỗi dừng script; biến chưa set báo lỗi; lỗi trong pipe lan truyền
- CLI dự đoán được; flag lạ thoát
2. - fail trước khi làm việc nếu thiếu
tar/find. - file tạm xóa khi thành công, lỗi, hoặc Ctrl-C.
- chế độ dry-run cho preview CI và tự tin vận hành.
- exit code có nghĩa —
2dùng sai,1runtime (ngầm quaset -e).
Checklist best practice
Trước khi merge hoặc deploy shell script, chạy qua checklist này:
| Kiểm tra | Vì sao |
|---|---|
#!/usr/bin/env bash shebang | Đường dẫn bash portable |
set -euo pipefail at top (after any needed -e exceptions) | Fail nhanh, không biến unset im lặng |
usage() + -h + exit 2 on bad args | Vận hành biết cách chạy |
| Logs to stderr, data to stdout | Pipe an toàn |
command -v for external deps | Lỗi rõ thay vì “command not found” giữa chừng |
trap cleanup EXIT for temp files / locks | Không rác tạm khi lỗi |
Quote variables: "$var", "$@" | Word-splitting và glob bất ngờ (Phần 2) |
readonly for constants | Ghi đè nhầm bị bắt sớm |
Env vars documented + defaults via ${VAR:-default} | Cấu hình không cần sửa source |
Run ShellCheck (shellcheck script.sh) | Bắt bug phổ biến trước runtime |
LF line endings, chmod +x | Không bất ngờ \r (Phần 1) |
Khi KHÔNG nên dùng bash
Bash giỏi ghép công cụ Unix và tự động hóa tác vụ chủ yếu spawn process và di chuyển file. Chuyển sang Python, Go, hoặc ngôn ngữ khác khi:
- Logic phức tạp — điều kiện lồng nhau, state machine, hoặc rule nghiệp vụ cần unit test.
- Cần cấu trúc dữ liệu thật — dict, parse JSON, CSV có cột kiểu, lớp ORM/database.
- Cần cross-platform — Windows không WSL, hoặc macOS + Linux hành vi giống hệt ngoài
#!/usr/bin/env bash. - Xử lý string/JSON nặng —
jqgiúp, nhưng bảo trì 200 dòngjq+ bash thường khó hơn 50 dòng Python. - Hiệu năng quy mô lớn — xử lý gigabyte trong process, vòng lặp chặt, hoặc concurrency vượt background job.
- Bảo trì theo team — nếu chỉ một người đọc bash còn cả team đọc TypeScript, CLI Node/Python nhỏ có thể thắng.
Pattern phổ biến: bash là lớp bọc mỏng validate env, rồi exec python -m mytool "$@".
Lỗi cả dev có kinh nghiệm vẫn mắc
- Parse
"$@"bằngfor arg in $*— vỡ khi tên file có khoảng trắng. Dùng"$@"hoặcgetopts. - Quên
shift $((OPTIND - 1))saugetopts— arg vị trí vẫn còn flag. - Log ra stdout trong script để pipe. Vận hành nhận luồng dữ liệu hỏng.
trapđặt sau code tạo file tạm — thoát sớm bỏ qua cleanup. Đặt trap ngay khimktempthành công.set -etrong function không suy nghĩ — lệnh fail trong điều kiện vẫn có thể gây bất ngờ; test vớiset -etrong đầu (Phần 9).- Để script bash 400 dòng gọi JSON API thay vì thừa nhận đó giờ là ứng dụng.
Bài tập
Thử từng bài trước khi mở lời giải.
- Mở rộng
backup-dir.shvới flag-k Nghi đèRETENTION_DAYScho một lần chạy (không export biến env). - Viết
deploy.shtối thiểu nhận-h,-v, và-t TARGET(bắt buộc), kiểm tragitvàsshtồn tại, và ở dry-run (-n) chỉ inrsync -av ./dist/ "$TARGET". - Chạy
shellchecktrên script bài 2 và sửa mọi cảnh báo.
Lời giải
# Exercise 1 — add to parse_args() in backup-dir.sh:
# In the option string, add k::
while getopts ":hvnk:o:" opt; do
case "$opt" in
# ... existing cases ...
k) RETENTION_DAYS="$OPTARG" ;;
esac
done
# And in usage(), document:
# -k N keep backups for N days (overrides RETENTION_DAYS for this run)#!/usr/bin/env bash
# deploy.sh — exercise 2 + 3 (ShellCheck-clean)
set -euo pipefail
readonly SCRIPT_NAME="$(basename "$0")"
VERBOSE=0
DRY_RUN=0
TARGET=""
usage() {
cat >&2 <<EOF
Usage: $SCRIPT_NAME [-hvn] -t TARGET
-h help
-v verbose
-n dry-run
-t rsync target (required), e.g. user@host:/var/www/app
EOF
}
log() { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }
require_cmd() {
command -v "$1" >/dev/null 2>&1 || { log "missing: $1"; exit 2; }
}
main() {
local opt
while getopts ":hvnt:" opt; do
case "$opt" in
h) usage; exit 0 ;;
v) VERBOSE=1 ;;
n) DRY_RUN=1 ;;
t) TARGET="$OPTARG" ;;
\?) log "unknown option: -$OPTARG"; usage; exit 2 ;;
:) log "-$OPTARG needs an argument"; usage; exit 2 ;;
esac
done
if [[ -z "$TARGET" ]]; then
log "missing -t TARGET"
usage
exit 2
fi
require_cmd git
require_cmd ssh
[[ "$VERBOSE" -eq 1 ]] && log "target=$TARGET dry_run=$DRY_RUN"
if [[ "$DRY_RUN" -eq 1 ]]; then
log "[dry-run] rsync -av ./dist/ $TARGET"
else
rsync -av ./dist/ "$TARGET"
fi
}
main "$@"shellcheck deploy.sh # exercise 3 — should report no issuesTóm tắt series
Bạn đã hoàn thành cả mười phần. Đây là toàn bộ lộ trình:
- Part 1 — The Shell, the Shebang & Your First Script
- Part 2 — Variables, Quoting & Expansion
- Part 3 — Conditionals,
test& Exit Codes - Part 4 — Loops
- Part 5 — Functions, Arguments & Sourcing
- Part 6 — Arrays & String Manipulation
- Part 7 — I/O Redirection & Pipes
- Part 8 — Text Processing: grep, sed & awk
- Part 9 — Robust Scripting & Error Handling
- Phần 10 — Automation thực tế & Best practice (bạn đang ở đây)
Điều cốt lõi
Script bash production không phải túi lệnh — mà là chương trình nhỏ có header strict, tham số đã parse, input đã validate, function có cấu trúc, log stderr, config từ env, cleanup có trap, và exit code có nghĩa. Dùng bash nơi nó mạnh (glue, deploy hook, backup); chuyển sang ngôn ngữ mạnh hơn khi script bắt đầu giống ứng dụng. Giờ bạn có đủ công cụ — hãy tự động hóa thứ gì đó thật.