PlusMagi's Blog By Pitt Phunsanit Shell Script รวมเทคนิคเขียน Shell Script แบบ Defensive Programming

รวมเทคนิคเขียน Shell Script แบบ Defensive Programming

การเขียน Shell Script สไตล์ Defensive Programming คือการตั้งสมมติฐานไว้ล่วงหน้าว่า ทุกอินพุตสามารถผิดพลาดได้ ทุกคำสั่งมีโอกาสล้มเหลว และสภาพแวดล้อมภายนอกไม่เสถียร เป้าหมายหลักคือการหยุดการทำงานทันทีก่อนที่ข้อผิดพลาดจะลุกลามจนสร้างความเสียหายต่อระบบ (Fail-Fast and Safe)


1. ล็อกโหมดความปลอดภัยที่หัวไฟล์เสมอ (Strict Flags)

บรรทัดแรกหลัง Shebang ควรเปิดการทำงานของแฟล็กความปลอดภัย

#!/bin/sh
set -eu

  • set -e (Exit on Error)
    หยุดสคริปต์ทันทีเมื่อคำสั่งใดคำสั่งหนึ่งคืนค่า Exit code ที่ไม่ใช่ 0
  • set -u (No Unset)
    แจ้ง Error และหยุดทันทีหากมีการเรียกใช้ตัวแปรที่ยังไม่ได้กำหนดค่า ป้องกันอุบัติเหตุ rm -rf "$TARGET/" เมื่อ $TARGET ว่างเปล่า

หากใช้ Bash: แนะนำให้ใช้ set -euo pipefail เพิ่มเติม

  • -o pipefail
    ถ้าคำสั่งใดในไปป์ (cmd1 | cmd2 | cmd3) พัง ตัวสคริปต์จะมองว่าทั้งไปป์นั้นล้มเหลวทันที (ปกติจะดูแค่ exit code ของคำสั่งสุดท้าย)

2. กำหนดค่าตัวแปรอย่างรัดกุม (Safe Parameter Expansion)

อย่าอ้างถึงตัวแปรลอย ๆ ให้ใช้กลไก Parameter Expansion ควบคุมค่าเริ่มต้นและตรวจความถูกต้อง

# 1. บังคับว่าต้องมีค่า (Mandatory) หากไม่มีจะหยุดการทำงานพร้อมพิมพ์ error
target_dir="${1:?Error: Target directory parameter is required.}"

# 2. กำหนดค่าเริ่มต้น (Default value) หากไม่มีค่า ให้ใช้ค่าสำรอง
backup_retention="${BACKUP_DAYS:-7}"

# 3. กำหนดค่าและเซ็ตกลับลงตัวแปรหากยังไม่มีค่า (Assign default)
: "${CONFIG_FILE:=/etc/app/config.json}"


3. เทคนิคป้องกันหายนะคำสั่ง rm -rf

คำสั่งลบไฟล์เป็นจุดที่อันตรายที่สุด ให้ตรวจสอบอย่างน้อย 2 ชั้นก่อนลบเสมอ

# อันตรายมาก (ห้ามเขียนเด็ดขาด):
# rm -rf "$cache_dir/*"   <-- หาก cache_dir ว่างเปล่า จะกลายเป็น rm -rf /*

# วิธีปลอดภัยระดับ Defensive:
target_cache="${CACHE_DIR:?Error: CACHE_DIR is unset}"

# ตรวจสอบว่าเป็น Directory และไม่ใช่ Root path ก่อนลบ
if [ -d "$target_cache" ] && [ "$target_cache" != "/" ]; then
    rm -rf -- "${target_cache:?}"/*
else
    printf "Error: Target directory '%s' is invalid or not found.\n" "$target_cache" >&2
    exit 1
fi

  • เครื่องหมาย -- ป้องกันไม่ให้ชื่อไฟล์ที่ขึ้นต้นด้วยขีด (-rf, -i) ถูกมองเป็นแฟล็กของคำสั่ง

4. จัดการ Clean up ทรัพยากรด้วย trap

ไม่ว่าสคริปต์จะทำงานจบตามปกติ เจอ Error (set -e) หรือถูกผู้ใช้กดยกเลิก (Ctrl+C), ทรัพยากรชั่วคราว (Temp files, Locks) ต้องถูกเคลียร์ทิ้งเสมอ

# สร้าง Temp directory แบบปลอดภัย
tmp_dir="$(mktemp -d)"

cleanup() {
    exit_code=$?
    # ลบไดเรกทอรีชั่วคราว
    rm -rf -- "$tmp_dir"
    printf "Cleaned up temporary files.\n" >&2
    exit "$exit_code"
}

# ดักจับสัญญาณ EXIT (จบงาน), INT (SIGINT / Ctrl+C), TERM (SIGTERM / Kill)
trap cleanup EXIT INT TERM


5. ควบคุมสภาพแวดล้อม (Environment Isolation)

1. กำหนด PATH ให้ชัดเจน ป้องกัน Path Hijacking

# ล็อก PATH ให้ค้นหาในไดเรกทอรีมาตรฐานก่อนเสมอ
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export PATH

2. ฟิกซ์ Locale ป้องกันพฤติกรรมการ Sort หรือ Regex เพี้ยน

# บังคับใช้ ASCII/C Standard เพื่อให้ผลลัพธ์ของ sort, grep, sed เหมือนกันทุกเซิร์ฟเวอร์
export LC_ALL=C

3. รีเซ็ต IFS ป้องกันการแตกคำเพี้ยน

# คืนค่า Internal Field Separator เป็น Space, Tab, Newline มาตรฐาน
IFS=' 	
'


6. ตรวจสอบการพึ่งพา (Dependency Check) ก่อนเริ่มงาน

ก่อนเรียกใช้เครื่องมือภายนอก ให้ตรวจล่วงหน้าตั้งแต่บรรทัดแรกๆ ของสคริปต์

require_cmd() {
    for cmd in "$@"; do
        if ! command -v "$cmd" >/dev/null 2>&1; then
            printf "Fatal: Required command '%s' not found on system.\n" "$cmd" >&2
            exit 127
        fi
    done
}

# ตรวจสอบว่ามีคำสั่งเหล่านี้ครบถ้วน
require_cmd jq curl rsync


7. ป้องกันการรันซ้อน (Concurrency & Atomic Lock)

หากเป็นสคริปต์ Backup หรือ Cron job ที่ห้ามรันซ้อนกัน ให้ใช้ไดเรกทอรีเป็นตัวล็อก (เนื่องจาก mkdir ในระดับ POSIX เป็น Atomic Operation)

LOCK_DIR="/tmp/my_app_backup.lock"

acquire_lock() {
    if ! mkdir "$LOCK_DIR" 2>/dev/null; then
        printf "Another instance is already running. Exiting.\n" >&2
        exit 1
    fi
}

release_lock() {
    rmdir "$LOCK_DIR" 2>/dev/null || true
}

acquire_lock
trap release_lock EXIT INT TERM


8. ส่ง Error เข้า Stderr และใช้ Return Codes ให้สื่อความหมาย

  • ข้อความแจ้งเตือนหรือข้อผิดพลาดทั้งหมดต้องส่งออกทาง Standard Error (>&2) เสมอ เพื่อไม่ให้ไปปนเปื้อนข้อมูล Output ของคำสั่งที่ผู้ใช้นำไป Pipe ต่อ
  • กำหนดรหัส Exit Status ให้ชัดเจน
log_err() {
    printf "[ERROR] %s\n" "$*" >&2
}

log_info() {
    printf "[INFO] %s\n" "$*" >&2
}

if [ ! -r "$config_file" ]; then
    log_err "Config file '$config_file' is missing or unreadable."
    exit 2 # 2 = Configuration / Parameter error
fi


สรุป Checklist สำหรับ Defensive Script

รายการตรวจสอบแนวทางปฏิบัติ
Safety Flagsใช้ set -eu (และ -o pipefail บน Bash)
Variable Referencesครอบ Double Quotes "$var" ทุกครั้ง และใช้ "${VAR:?error}"
Command Substitutionใช้ $(cmd) เสมอ เลิกใช้ Backticks `cmd`
Resource Cleanupผูก trap เข้ากับ EXIT INT TERM เพื่อล้าง Temp/Lock
Safety Double Dashใส่ -- หน้าชื่อไฟล์ทุกครั้ง เช่น rm -f -- "$file" หรือ cat -- "$file"
Dependenciesเช็ค command -v ล่วงหน้าก่อนเริ่มกระบวนการหลัก
Outputsแยก Logs ไปยัง >&2 และส่งเฉพาะ Data ออก stdout