jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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àibà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:

  1. Parse tham số — hiểu người dùng muốn gì.
  2. Validate — kiểm tra input, dependency, và quyền trước khi đụng dữ liệu.
  3. Làm việc — tác vụ thật (backup, deploy, sync, v.v.).
  4. Log + exit — báo cáo chuyện gì xảy ra và trả status code có ý nghĩa.
parse args getopts validate inputs + deps do work main logic log + exit status code trap cleanup EXIT wraps the whole run · ShellCheck guards quality
parse args → validate → do work → log+exit — wrap the whole run with strict mode and trap cleanup EXIT

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 getoptschuỗ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ủa getopts để bạn in usage riê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
$optChữ cái vừa parse
$OPTARGTham số cho flag có đối số (-o)
$OPTINDChỉ số arg vị trí tiếp theo; dùng shift $((OPTIND - 1)) sau vòng lặp

Giới hạn: getopts chỉ xử lý flag ngắn (-abc là ba flag; -o file hoạt động). Với option dài (--verbose, --output=file) dùng getopt củ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 $variables trong help.
  • Gọi usage từ -h, từ handler flag lạ, và từ lỗi validate.
  • Thoát 0 khi help cố ý (-h), thoát 2 (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ên return sớ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 — 2 dùng sai, 1 runtime (ngầm qua set -e).

Checklist best practice

Trước khi merge hoặc deploy shell script, chạy qua checklist này:

Kiểm traVì 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 argsVận hành biết cách chạy
Logs to stderr, data to stdoutPipe an toàn
command -v for external depsLỗi rõ thay vì “command not found” giữa chừng
trap cleanup EXIT for temp files / locksKhông rác tạm khi lỗi
Quote variables: "$var", "$@"Word-splitting và glob bất ngờ (Phần 2)
readonly for constantsGhi đè 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 +xKhô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ặngjq giúp, nhưng bảo trì 200 dòng jq + 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ằng for arg in $* — vỡ khi tên file có khoảng trắng. Dùng "$@" hoặc getopts.
  • Quên shift $((OPTIND - 1)) sau getopts — 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 khi mktemp thành công.
  • set -e trong function không suy nghĩ — lệnh fail trong điều kiện vẫn có thể gây bất ngờ; test với set -e trong đầ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.

  1. Mở rộng backup-dir.sh với flag -k N ghi đè RETENTION_DAYS cho một lần chạy (không export biến env).
  2. Viết deploy.sh tối thiểu nhận -h, -v, và -t TARGET (bắt buộc), kiểm tra gitssh tồn tại, và ở dry-run (-n) chỉ in rsync -av ./dist/ "$TARGET".
  3. Chạy shellcheck trê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 issues

Tóm tắt series

Bạn đã hoàn thành cả mười phần. Đây là toàn bộ lộ trình:


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