Skip to content

Bash Scripting — Tự Động Hóa Cho Kỹ Sư 🤖

Mỗi ngày, một DevOps engineer lặp đi lặp lại hàng chục thao tác: deploy code, kiểm tra service, rotate log, backup database. Bash scripting biến những thao tác thủ công đó thành quy trình tự động, nhất quán, có thể lặp lại — loại bỏ hoàn toàn yếu tố "tay run lúc 3 giờ sáng" khỏi production.

1. Bash — Keo Dán Tự Động Hóa 🧠

🎯 Mục tiêu

Sau bài học này, bạn sẽ:

  • Viết được bash script an toàn với set -euo pipefail và quoting đúng cách
  • Xây dựng deploy script có backup, rollback, và health check
  • Sử dụng shellcheck để bắt bug trước khi script chạy trên production
  • Hiểu tại sao một biến không được quote có thể xóa sạch production data

Bash không phải ngôn ngữ lập trình — Bash là keo dán tự động hóa. Nó kết nối các công cụ Unix lại với nhau thành workflow hoàn chỉnh.

Dùng Bash cho:

Use CaseVí dụTại sao Bash?
Deploy scriptsPull code, restart serviceGọi trực tiếp system commands
Health checksCurl endpoint, kiểm tra responseKết hợp curl + grep + exit code
Log rotationNén log cũ, xóa log > 30 ngàyfind + gzip + rm
System monitoringDisk usage, memory, connectionsOne-liner kết hợp nhiều tool
CI/CD pipeline stepsBuild, test, push imageChạy tuần tự các lệnh

Quy tắc vàng: Script dưới 100 dòng, gọi các tool có sẵn, logic đơn giản → Bash là lựa chọn đúng.

⚠️ Khi KHÔNG nên dùng Bash

Tình huốngTại sao không?Dùng gì thay?
Script > 100 dòngKhó đọc, khó debug, khó maintainPython
Xử lý dữ liệu phức tạpBash không có data structurePython / Go
Error handling quan trọngset -e không đủ cho logic phức tạpPython / Go
Cần unit testBash không có testing framework tốtPython
Parse JSON/YAMLsed/awk cho JSON = thảm họaPython + jq

Nguyên tắc: Nếu bạn đang viết if/else lồng nhau 3 cấp trong Bash — dừng lại và chuyển sang Python.

2. Nền Tảng — Variables, Quoting, Exit Codes 🏗️

2.1 Template bắt buộc cho mọi script

bash
#!/bin/bash
set -euo pipefail  # ← LUÔN bắt đầu với dòng này

# Variables
APP_NAME="myapp"
DEPLOY_DIR="/opt/${APP_NAME}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

# Quoting rules (CRITICAL)
echo "Deploy dir: ${DEPLOY_DIR}"    # ✅ Double quotes: biến được expand
echo 'Literal: ${DEPLOY_DIR}'       # ✅ Single quotes: không expand
echo "Files: $(ls ${DEPLOY_DIR})"   # ✅ Command substitution

# Exit codes
# 0 = success, non-zero = failure
if command -v docker &>/dev/null; then
    echo "Docker is installed"
else
    echo "Docker is NOT installed" >&2
    exit 1
fi

2.2 set -euo pipefail — Dòng quan trọng nhất

Đây là dòng code quan trọng nhất trong bất kỳ bash script nào. Không có nó, script của bạn sẽ âm thầm tiếp tục chạy ngay cả khi có lỗi.

FlagTác dụngKhông có nó thì sao?
-eDừng ngay khi bất kỳ lệnh nào failScript tiếp tục chạy với state hỏng
-uBáo lỗi khi dùng biến chưa khai báoBiến rỗng → hành vi không đoán trước
-o pipefailBắt lỗi trong pipe chain`cmd1

Ví dụ không có set -e:

bash
#!/bin/bash
# THIẾU set -euo pipefail

cd /opt/myapp           # ← cd fail vì thư mục không tồn tại
rm -rf ./old_builds/*   # ← BÂY GIỜ ĐANG Ở THƯ MỤC KHÁC!
                        # Xóa nhầm file ở thư mục hiện tại 💀

Ví dụ có set -e:

bash
#!/bin/bash
set -euo pipefail

cd /opt/myapp           # ← cd fail → script DỪNG NGAY
rm -rf ./old_builds/*   # ← Không bao giờ chạy đến đây ✅

2.3 Quoting — Luật sống còn

Cú phápHành viKhi nào dùng
"${VAR}"Expand biến, giữ nguyên khoảng trắngMặc định — luôn dùng cái này
'${VAR}'Không expand gì cảKhi cần chuỗi literal
$(command)Chạy lệnh, trả về outputCommand substitution
${VAR:-default}Dùng default nếu VAR rỗngGiá trị mặc định an toàn
${VAR:?msg}Báo lỗi nếu VAR rỗngValidate bắt buộc

💡 Quy tắc đơn giản

Luôn dùng double quotes quanh biến: "${MY_VAR}". Nếu bạn viết $MY_VAR không có quotes — bạn đang viết bug chờ phát nổ. Phần Production Incident ở cuối bài sẽ cho bạn thấy hậu quả.

3. Conditionals & Loops — Thực Chiến 🔄

3.1 Health check với retry logic

Đây là pattern bạn sẽ dùng hàng ngày trong production:

bash
check_health() {
    local url="$1"
    local max_retries="${2:-5}"
    local wait_seconds="${3:-3}"

    for ((i=1; i<=max_retries; i++)); do
        if curl -sf "${url}/health" > /dev/null 2>&1; then
            echo "✅ Health check passed (attempt ${i})"
            return 0
        fi
        echo "⏳ Attempt ${i}/${max_retries} failed, waiting ${wait_seconds}s..."
        sleep "${wait_seconds}"
    done

    echo "❌ Health check failed after ${max_retries} attempts" >&2
    return 1
}

# Sử dụng
check_health "http://localhost:8000"

Phân tích pattern:

ElementGiải thích
localBiến local trong function — tránh ô nhiễm global scope
${2:-5}Tham số thứ 2, mặc định 5 nếu không truyền
curl -sf-s silent, -f fail on HTTP error
> /dev/null 2>&1Bỏ cả stdout và stderr — chỉ quan tâm exit code
return 0 / return 10 = success, 1 = failure (Unix convention)

3.2 Conditional patterns thường gặp

bash
# Kiểm tra file/directory tồn tại
[[ -f "/etc/myapp/config.yml" ]] || { echo "Config not found!" >&2; exit 1; }
[[ -d "${DEPLOY_DIR}" ]]        || mkdir -p "${DEPLOY_DIR}"

# Kiểm tra command có sẵn
command -v docker &>/dev/null   || { echo "Docker required!" >&2; exit 1; }

# So sánh chuỗi
if [[ "${ENV}" == "production" ]]; then
    echo "⚠️ Running in PRODUCTION"
fi

# So sánh số
if (( DISK_USAGE > 90 )); then
    echo "🚨 Disk usage critical: ${DISK_USAGE}%"
fi

⚠️ [[ ]] vs [ ]

Luôn dùng [[ ]] (double bracket) thay vì [ ] (single bracket):

  • [[ ]] hỗ trợ pattern matching, regex, và an toàn hơn với chuỗi rỗng
  • [ ] là POSIX nhưng dễ gây lỗi với khoảng trắng và chuỗi rỗng
bash
# ❌ Sai — lỗi nếu $NAME rỗng
[ $NAME == "admin" ]

# ✅ Đúng — an toàn với mọi giá trị
[[ "${NAME}" == "admin" ]]

4. Deploy Script Thực Tế 🚀

Đây là deploy script production-ready — pattern mà các team DevOps thực sự sử dụng:

bash
#!/bin/bash
set -euo pipefail

# === CONFIG ===
APP_NAME="myapp"
DEPLOY_DIR="/opt/${APP_NAME}"
BACKUP_DIR="/opt/backups/${APP_NAME}"
GIT_BRANCH="${1:-main}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

# === FUNCTIONS ===
log() { echo "[$(date '+%H:%M:%S')] $*"; }
err() { echo "[$(date '+%H:%M:%S')] ERROR: $*" >&2; }

rollback() {
    err "Deploy failed! Rolling back..."
    if [[ -d "${BACKUP_DIR}/latest" ]]; then
        cp -r "${BACKUP_DIR}/latest/." "${DEPLOY_DIR}/"
        sudo systemctl restart "${APP_NAME}"
        log "Rollback complete"
    else
        err "No backup found! Manual intervention required."
    fi
    exit 1
}

check_health() {
    local url="$1"
    local max_retries="${2:-5}"
    local wait_seconds="${3:-3}"

    for ((i=1; i<=max_retries; i++)); do
        if curl -sf "${url}/health" > /dev/null 2>&1; then
            log "✅ Health check passed (attempt ${i})"
            return 0
        fi
        log "⏳ Attempt ${i}/${max_retries} failed, waiting ${wait_seconds}s..."
        sleep "${wait_seconds}"
    done

    err "Health check failed after ${max_retries} attempts"
    return 1
}

# Trap errors for automatic rollback
trap rollback ERR

# === MAIN ===
log "Starting deploy of ${APP_NAME} (branch: ${GIT_BRANCH})"

# 1. Backup current version
log "Backing up current version..."
mkdir -p "${BACKUP_DIR}"
cp -r "${DEPLOY_DIR}" "${BACKUP_DIR}/${TIMESTAMP}"
ln -sfn "${BACKUP_DIR}/${TIMESTAMP}" "${BACKUP_DIR}/latest"

# 2. Pull latest code
log "Pulling latest code..."
cd "${DEPLOY_DIR}"
git fetch origin
git checkout "${GIT_BRANCH}"
git pull origin "${GIT_BRANCH}"

# 3. Install dependencies
log "Installing dependencies..."
source .venv/bin/activate
pip install -r requirements.txt --quiet

# 4. Restart service
log "Restarting service..."
sudo systemctl restart "${APP_NAME}"

# 5. Health check
log "Running health check..."
sleep 3
check_health "http://localhost:8000"

log "✅ Deploy complete!"

Phân tích các pattern quan trọng

PatternDòngTại sao quan trọng?
Backup trước khi thay đổicp -r + ln -sfnLuôn có bản backup để rollback
trap rollback ERRTự động rollbackBất kỳ lệnh nào fail → rollback ngay
ln -sfn ... latestSymlink tới backup mới nhấtRollback chỉ cần copy từ latest/
Health check sau restartcheck_healthXác nhận service thực sự hoạt động
Log có timestamplog() functionDebug khi deploy fail lúc nửa đêm

💡 Nguyên tắc deploy an toàn

  1. Backup trước mọi thay đổi
  2. Trap để tự động rollback khi lỗi
  3. Health check sau khi restart — đừng bao giờ tin rằng "restart xong là chạy được"
  4. Log mọi bước — bạn sẽ cần nó khi debug lúc 3 giờ sáng

5. shellcheck — Linter Cho Bash 🔍

shellcheckstatic analysis tool cho bash — nó đọc script của bạn và chỉ ra bug trước khi bạn chạy script đó trên production.

5.1 Cài đặt và sử dụng

bash
# Cài đặt
sudo apt install shellcheck

# Chạy kiểm tra
shellcheck deploy.sh

# Chạy với severity level
shellcheck --severity=warning deploy.sh

# Tích hợp vào CI/CD
shellcheck scripts/*.sh || exit 1

5.2 Các lỗi phổ biến shellcheck bắt được

bash
# SC2086: Double quote to prevent globbing and word splitting
echo $DEPLOY_DIR          # ⚠️ shellcheck cảnh báo
echo "${DEPLOY_DIR}"      # ✅ Đã fix

# SC2034: Variable appears unused
UNUSED_VAR="hello"        # ⚠️ Khai báo nhưng không dùng

# SC2046: Quote this to prevent word splitting
docker rm $(docker ps -aq)              # ⚠️ Cảnh báo
docker rm "$(docker ps -aq)"            # ✅ Đã fix

# SC2164: Use cd ... || exit in case cd fails
cd /opt/myapp                           # ⚠️ Nếu fail thì sao?
cd /opt/myapp || { echo "cd failed"; exit 1; }  # ✅ Đã fix

💡 Quy tắc bắt buộc

Luôn chạy shellcheck trước khi commit bash script. Nó bắt được 90% bug phổ biến — từ unquoted variables đến logic errors. Nhiều team cấu hình CI/CD reject PR nếu shellcheck báo lỗi.

6. 🔥 Production Incident — Unquoted Variable Xóa Production Data

🔥 INCIDENT THẬT — Mất toàn bộ dữ liệu Production

Tình huống

Một engineer viết cleanup script để dọn file tạm:

bash
#!/bin/bash
CLEANUP_DIR="/opt/myapp/tmp"
rm -rf $CLEANUP_DIR/*

Script chạy hàng ngày qua cron job. Hoạt động bình thường 3 tháng liên tục.

Ngày xảy ra sự cố

Team thay đổi cách load environment variables. File .env không được source đúng cách. Biến CLEANUP_DIR bây giờ là chuỗi rỗng.

bash
# CLEANUP_DIR="" (rỗng vì .env không được load)

# Script thực thi:
rm -rf /*

# Kết quả: 💀💀💀 XÓA TOÀN BỘ HỆ THỐNG TỪ ROOT

Root Cause Analysis

Nguyên nhânGiải thích
Unquoted variable$CLEANUP_DIR không có quotes → expand thành rỗng
Thiếu set -uKhông báo lỗi khi biến rỗng
Thiếu validationKhông kiểm tra biến có giá trị hợp lệ trước khi rm -rf
Thiếu safety checkKhông kiểm tra path có phải root / không

Fix — Script an toàn

bash
#!/bin/bash
set -euo pipefail

# Fail NGAY nếu biến rỗng (${VAR:?} = crash if empty)
CLEANUP_DIR="${CLEANUP_DIR:?ERROR: CLEANUP_DIR not set}"

# Safety check: không cho xóa root hoặc path quá ngắn
if [[ "${CLEANUP_DIR}" == "/" ]] || [[ "${#CLEANUP_DIR}" -lt 5 ]]; then
    echo "FATAL: CLEANUP_DIR looks dangerous: '${CLEANUP_DIR}'" >&2
    exit 1
fi

# Kiểm tra thư mục tồn tại
[[ -d "${CLEANUP_DIR}" ]] || { echo "Dir not found: ${CLEANUP_DIR}" >&2; exit 1; }

# An toàn: quote biến + dùng :? để fail nếu rỗng
rm -rf "${CLEANUP_DIR:?}"/*

echo "Cleaned up ${CLEANUP_DIR}"

Bài học — Ba lớp phòng thủ

  1. set -euo pipefail — Luôn có ở đầu mọi script
  2. Quote mọi biến"${VAR}" không bao giờ $VAR
  3. Validate trước khi hành động — Đặc biệt với rm -rf, kiểm tra path hợp lệ

7. One-Liners Hữu Ích

Những one-liner này bạn sẽ dùng hàng ngày khi quản trị hệ thống:

7.1 Quản lý Disk & Files

bash
# Tìm file lớn > 100MB
find /var/log -type f -size +100M -exec ls -lh {} \;

# Top 10 thư mục chiếm nhiều disk nhất
du -sh /var/* | sort -rh | head -10

# Xóa file log cũ hơn 30 ngày
find /var/log/myapp -name "*.log" -mtime +30 -delete

7.2 Monitoring & Debugging

bash
# Theo dõi log real-time, chỉ hiện ERROR
tail -f /var/log/myapp/app.log | grep --line-buffered ERROR

# Đếm connections theo IP
ss -tn | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head

# Quick HTTP health check (chỉ trả về status code)
curl -sf -o /dev/null -w "%{http_code}" http://localhost:8000/health

# Kiểm tra disk usage, cảnh báo nếu > 80%
df -h | awk '$5+0 > 80 {print "⚠️ " $1 " is " $5 " full"}'

7.3 Process Management

bash
# Tìm process ngốn CPU nhất
ps aux --sort=-%cpu | head -5

# Tìm process ngốn RAM nhất
ps aux --sort=-%mem | head -5

# Kill tất cả process của một user
pkill -u deploybot

# Kiểm tra port đang được dùng
ss -tlnp | grep ":8080"

8. Bài Tập Luyện Tập 📝

Bài 1 — Spot the Bug 🐛

Script sau có 3 lỗi nghiêm trọng. Tìm và sửa tất cả:

bash
#!/bin/bash

BACKUP_DIR=/opt/backups
APP_DIR=/opt/myapp

cd $APP_DIR
tar czf $BACKUP_DIR/backup_$(date +%Y%m%d).tar.gz .

echo "Backup complete: $BACKUP_FILE"
💡 Gợi ý
  1. Thiếu gì ở đầu script?
  2. Biến nào không được quote?
  3. Biến nào chưa khai báo mà đã dùng?
✅ Lời giải

Lỗi 1: Thiếu set -euo pipefail — nếu cd fail, tar sẽ chạy ở thư mục sai

Lỗi 2: $APP_DIR$BACKUP_DIR không được quote — nguy hiểm nếu path có khoảng trắng

Lỗi 3: $BACKUP_FILE chưa bao giờ khai báo — với set -u sẽ crash, không có thì in chuỗi rỗng

Script đã fix:

bash
#!/bin/bash
set -euo pipefail

BACKUP_DIR="/opt/backups"
APP_DIR="/opt/myapp"
BACKUP_FILE="${BACKUP_DIR}/backup_$(date +%Y%m%d).tar.gz"

cd "${APP_DIR}" || { echo "Cannot cd to ${APP_DIR}" >&2; exit 1; }
tar czf "${BACKUP_FILE}" .

echo "Backup complete: ${BACKUP_FILE}"

Bài 2 — Viết Health Check Function

Viết một function wait_for_service nhận 2 tham số:

  1. URL của service
  2. Timeout tính bằng giây (mặc định: 30)

Function phải:

  • Thử curl mỗi 2 giây
  • In trạng thái mỗi lần thử
  • Return 0 nếu service lên, return 1 nếu timeout
✅ Lời giải
bash
wait_for_service() {
    local url="${1:?Usage: wait_for_service URL [TIMEOUT]}"
    local timeout="${2:-30}"
    local interval=2
    local elapsed=0

    echo "Waiting for ${url} (timeout: ${timeout}s)..."

    while (( elapsed < timeout )); do
        if curl -sf "${url}" > /dev/null 2>&1; then
            echo "✅ Service is up after ${elapsed}s"
            return 0
        fi
        echo "${elapsed}s / ${timeout}s — not ready yet..."
        sleep "${interval}"
        (( elapsed += interval ))
    done

    echo "❌ Timeout after ${timeout}s — service not responding" >&2
    return 1
}

# Sử dụng
wait_for_service "http://localhost:8000/health" 60

Điểm quan trọng:

  • ${1:?...} — crash với message rõ ràng nếu thiếu tham số
  • local — tránh ô nhiễm global scope
  • Exit code chuẩn Unix: 0 = success, 1 = failure

9. Production Anti-Pattern — "Script Bash 500 Dòng" 🚫

⚠️ Cạm bẫy

Tình huống

Team maintain một deploy script Bash 500 dòng với:

  • Nested if/else sâu 4 cấp
  • "Parse" JSON bằng sedawk
  • Array dùng làm "database" tạm
  • Không có test, không có error handling nhất quán
  • Mỗi lần API response format thay đổi → script vỡ

Tại sao đây là thảm họa

bash
# 💀 Parse JSON bằng sed — VỠ ngay khi format thay đổi
STATUS=$(curl -s http://api/status | sed 's/.*"status":"\([^"]*\)".*/\1/')

# 💀 Nested if/else — không ai hiểu logic
if [[ "$STATUS" == "running" ]]; then
    if [[ "$MEMORY" -gt 80 ]]; then
        if [[ "$CONNECTIONS" -gt 1000 ]]; then
            # 3 cấp sâu — bạn đã lạc rồi
        fi
    fi
fi

Giải pháp: Chuyển sang Python khi > 100 dòng

python
#!/usr/bin/env python3
"""Deploy script — readable, testable, maintainable."""
import subprocess
import requests
import sys

def check_health(url: str, retries: int = 5) -> bool:
    """Health check with retry — clear, testable."""
    for i in range(retries):
        try:
            resp = requests.get(f"{url}/health", timeout=5)
            if resp.status_code == 200:
                print(f"✅ Health check passed (attempt {i+1})")
                return True
        except requests.RequestException:
            pass
        print(f"⏳ Attempt {i+1}/{retries} failed...")
    return False

def run_command(cmd: str) -> None:
    """Run shell command with proper error handling."""
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"❌ Command failed: {cmd}", file=sys.stderr)
        print(result.stderr, file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    run_command("git pull origin main")
    run_command("pip install -r requirements.txt --quiet")
    run_command("sudo systemctl restart myapp")

    if not check_health("http://localhost:8000"):
        print("❌ Deploy failed — rolling back", file=sys.stderr)
        run_command("sudo systemctl restart myapp")
        sys.exit(1)

    print("✅ Deploy complete!")

Python thắng Bash khi:

  • Cần parse JSON/YAML → json.loads() thay vì sed
  • Cần error handling phức tạp → try/except thay vì trap
  • Cần unit test → pytest cho deploy logic
  • Script dài hơn 100 dòng → đọc được, maintain được

10. Checklist — Bash Script An Toàn

✅ Checklist triển khai

Bắt buộc cho mọi script

  • [ ] Có #!/bin/bash ở dòng đầu
  • [ ] Có set -euo pipefail ngay sau shebang
  • [ ] Mọi biến được quote: "${VAR}" không phải $VAR
  • [ ] Chạy shellcheck trước khi commit

Destructive operations (rm, mv, overwrite)

  • [ ] Validate biến không rỗng trước rm -rf
  • [ ] Dùng ${VAR:?} cho biến bắt buộc
  • [ ] Backup trước khi thay đổi
  • [ ] Có trap để cleanup/rollback khi lỗi

Functions

  • [ ] Dùng local cho biến trong function
  • [ ] Validate parameters với ${1:?message}
  • [ ] Return 0 cho success, non-zero cho failure

Khi nào chuyển sang Python?

  • [ ] Script > 100 dòng → Python
  • [ ] Parse JSON/YAML → Python + jq
  • [ ] Cần unit test → Python + pytest
  • [ ] Logic phức tạp (nested if > 2 cấp) → Python

🧠 Quiz

Câu 1: Tại sao set -euo pipefail quan trọng?

  • [ ] A) Tăng tốc độ chạy script
  • [x] B) Dừng script khi có lỗi, biến undefined, hoặc lỗi trong pipe
  • [ ] C) Tự động backup trước khi chạy
  • [ ] D) Cho phép chạy script không cần quyền root

💡 Giải thích: -e dừng khi lỗi, -u báo biến undefined, -o pipefail bắt lỗi trong pipe. Không có nó, script tiếp tục chạy với state hỏng — dẫn tới data loss hoặc hành vi không đoán trước.

Câu 2: Điều gì xảy ra khi CLEANUP_DIR rỗng và chạy rm -rf $CLEANUP_DIR/*?

  • [ ] A) Lệnh rm báo lỗi và dừng
  • [ ] B) Không xóa gì cả
  • [x] C) Chạy rm -rf /* — xóa toàn bộ hệ thống từ root
  • [ ] D) Xóa thư mục hiện tại

💡 Giải thích: Khi CLEANUP_DIR rỗng và không có quotes, $CLEANUP_DIR/* expand thành /* — tức là toàn bộ filesystem từ root. Đây là lý do phải quote mọi biến và dùng set -u + ${VAR:?}.

Câu 3: Khi nào nên chuyển từ Bash sang Python?

  • [ ] A) Khi script dài hơn 10 dòng
  • [ ] B) Khi cần chạy trên nhiều server
  • [x] C) Khi script > 100 dòng, cần parse JSON, hoặc cần unit test
  • [ ] D) Khi script cần quyền root

💡 Giải thích: Bash tốt cho automation ngắn gọn (< 100 dòng). Khi logic phức tạp hơn, Python mang lại error handling tốt hơn, data structure phong phú, testing framework, và khả năng maintain lâu dài.