BÀI VIẾT // [DEVOPS] Error Handling trong Bash: Bắt lỗi, thử lại và phục hồi sự cố cho DevOps
Error Handling trong Bash: Bắt lỗi, thử lại và phục hồi sự cố cho DevOps

Error Handling trong Bash: Bắt lỗi, thử lại và phục hồi sự cố cho DevOps

Hướng dẫn toàn diện về xử lý ngoại lệ trong Bash: quy ước Exit Code, bẫy lỗi với trap EXIT/ERR, cạm bẫy của set -e, cơ chế Retry tự động và dọn dẹp tài nguyên an toàn.

DEVOPS BASH

Error Handling trong Bash cho DevOps

Trong bài viết trước, chúng ta đã tìm hiểu các công cụ và phương pháp debug để truy tìm nguyên nhân khi script gặp lỗi. Tuy nhiên, trong môi trường sản xuất (Production) hoặc các pipeline tự động hóa CI/CD, việc chỉ debug khi có sự cố xảy ra là chưa đủ. Một kịch bản DevOps chuyên nghiệp cần phải có khả năng chủ động kiểm soát lỗi (Error Handling) ngay trong lúc vận hành.

Mạng chập chờn, ổ cứng hết dung lượng lưu trữ, dịch vụ phụ thuộc chưa kịp khởi động hay phân quyền file không hợp lệ là những rủi ro luôn hiện hữu. Nếu không có cơ chế xử lý lỗi phù hợp, một script có thể:

  • Tiếp tục chạy trong trạng thái sai lệch, dẫn đến hỏng dữ liệu dây chuyền.
  • Đột ngột dừng lại mà không giải phóng các file khóa (lockfile) hay thư mục tạm, làm tắc nghẽn các lần chạy kế tiếp.
  • Thất bại ngay lập tức trước các sự cố tạm thời (transient errors) mà lẽ ra có thể tự phục hồi sau vài giây thử lại.

Bài viết này sẽ hướng dẫn chi tiết cách xây dựng chiến lược xử lý ngoại lệ vững chắc cho Bash script: từ việc làm chủ Exit Code, phân tích các cạm bẫy của set -e, quản lý dọn dẹp tài nguyên với trap, cho đến cài đặt Retry Pattern và phân cấp log chuẩn mực.


Hiểu rõ Exit Code và quy ước mã lỗi

Mọi câu lệnh hoặc chương trình khi kết thúc thực thi trên hệ điều hành Linux/Unix đều trả về một giá trị số nguyên từ 0 đến 255, gọi là Exit Status hay Exit Code. Giá trị này được lưu trong biến đặc biệt $?.

  • 0: Biểu thị câu lệnh thực thi thành công hoàn toàn.
  • 1 - 255: Biểu thị câu lệnh gặp lỗi hoặc có điều kiện kết thúc bất thường.

Các mã thoát tiêu chuẩn trên Linux

Hệ thống Linux và Bash có các quy ước mã lỗi mặc định mà mọi kỹ sư DevOps cần nắm rõ:

Mã thoát (Exit Code)Ý nghĩa chuẩnVí dụ nguyên nhân
0Thành côngLệnh hoàn thành không có lỗi
1Lỗi chung (General error)Thao tác không hợp lệ, ví dụ chia cho 0
2Dùng sai cú pháp shell built-inThiếu tham số bắt buộc trong lệnh nội tại
126Lệnh tìm thấy nhưng không có quyền thực thiFile script chưa được cấp quyền chmod +x
127Lệnh không tìm thấy (Command not found)Gõ sai tên binary hoặc thiếu đường dẫn trong $PATH
128Tham số thoát không hợp lệ cho lệnh exitGọi exit 3.14 (chỉ chấp nhận số nguyên)
128 + NTiến trình bị dừng bởi tín hiệu số N130 (128+2) khi bị Ctrl+C (SIGINT), 137 (128+9) khi bị SIGKILL
255Mã thoát vượt giới hạn cho phépTrả về ngoài dải 0–255

Định nghĩa mã lỗi tùy biến trong dự án

Để script trả về mã lỗi rõ ràng cho pipeline CI/CD hoặc hệ thống giám sát phân biệt nguyên nhân thất bại, bạn nên định nghĩa các hằng số exit code rõ ràng ở đầu script (khuyến nghị dùng dải từ 10 đến 99 để tránh xung đột với mã hệ thống):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Định nghĩa mã lỗi chuẩn của script
readonly EXIT_SUCCESS=0
readonly EXIT_INVALID_ARGS=10
readonly EXIT_CONFIG_MISSING=11
readonly EXIT_NETWORK_TIMEOUT=12
readonly EXIT_DISK_FULL=13
readonly EXIT_DEPENDENCY_ERROR=14

validate_config() {
    local config_file="$1"
    if [[ ! -f "$config_file" ]]; then
        echo "[ERROR] File cấu hình $config_file không tồn tại." >&2
        exit "$EXIT_CONFIG_MISSING"
    fi
}

Kiểm soát luồng thực thi với short-circuit và set -e

Sử dụng toán tử điều kiện rút gọn (Short-circuit Evaluation)

Toán tử && (AND) và || (OR) cho phép kiểm tra và xử lý lỗi nhanh chóng trên một dòng mà không cần viết cả khối if...fi dài dòng:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Chạy lệnh tiếp theo nếu lệnh trước THÀNH CÔNG
mkdir -p /opt/backup && echo "Tạo thư mục backup thành công."

# Thực hiện hành động cứu vãn hoặc thoát nếu lệnh trước THẤT BẠI
cd /opt/app/releases || {
    echo "[FATAL] Không thể chuyển vào thư mục release." >&2
    exit 1
}

# Kiểm tra sự tồn tại của file cấu hình
[[ -f ".env" ]] || cp ".env.example" ".env"

Cơ chế fail-fast với set -e

Mặc định, Bash sẽ tiếp tục chạy các lệnh tiếp theo ngay cả khi lệnh phía trước bị lỗi nghiêm trọng. Kích hoạt set -e (hoặc set -o errexit) yêu cầu shell dừng thực thi script ngay khi một câu lệnh đơn lẻ trả về exit code khác 0.

1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash
set -e

echo "Đang chuẩn bị dữ liệu..."
tar -czf backup.tar.gz /data/db-dump/
# Nếu lệnh tar trên bị lỗi (ví dụ không đủ quyền), script sẽ ngắt ngay tại đây,
# ngăn không cho các thao tác xóa bên dưới được thực thi!
rm -rf /data/db-dump/*
echo "Hoàn thành."

Cạm bẫy tiềm ẩn của set -e trong thực tế

Mặc dù set -e rất hữu ích, đây cũng là một trong những tính năng gây hiểu lầm và khó lường nhất trong Bash nếu không hiểu rõ quy tắc kích hoạt của nó.

Cạm bẫy 1: Biểu thức toán học trả về 0

Trong biểu thức toán học (( ... )), giá trị tính toán bằng 0 được shell coi là mã thoát 1 (thất bại logic theo quy ước C-style):

1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash
set -e

count=0
# Lệnh dưới đây tăng count từ 0 lên 1, nhưng giá trị biểu thức cũ là 0,
# khiến $(( count++ )) trả về exit code 1 -> SCRIPT BỊ DỪNG NGAY LẬP TỨC!
(( count++ )) 

echo "Dòng này sẽ KHÔNG bao giờ được in ra!"

Cách khắc phục:

1
2
3
4
5
# Cách 1: Tăng giá trị với tiền tố
(( ++count ))

# Cách 2: Phép gán tường minh
count=$(( count + 1 ))

Cạm bẫy 2: Lệnh kiểm tra trả về false có chủ đích

Các lệnh như grep, diff, hay test thường trả về exit code 1 khi không tìm thấy kết quả hoặc có sự khác biệt. Dưới set -e, script sẽ dừng đột ngột:

1
2
3
4
5
6
#!/usr/bin/env bash
set -e

# Tìm kiếm chuỗi trong file log
# Nếu không có chữ WARNING, grep trả về 1 -> Script bị dừng!
grep "WARNING" /var/log/app.log

Cách khắc phục:

1
2
3
4
5
6
7
8
9
# Cách 1: Thêm || true để bỏ qua mã lỗi nếu không quan trọng
grep "WARNING" /var/log/app.log || true

# Cách 2: Sử dụng trong khối điều kiện if (set -e bị vô hiệu hóa trong biểu thức test của if/while)
if grep -q "WARNING" /var/log/app.log; then
    echo "Tìm thấy cảnh báo trong log."
else
    echo "Không có cảnh báo nào."
fi

Cạm bẫy 3: Pipeline chỉ kiểm tra lệnh cuối cùng

Khi chạy cmd1 | cmd2 | cmd3, theo mặc định set -e chỉ kiểm tra mã thoát của cmd3. Nếu cmd1 sinh lỗi nhưng cmd3 chạy xong thành công, script vẫn tiếp tục:

1
2
# Giả sử mysqldump bị lỗi xác thực nhưng gzip vẫn nén dữ liệu rỗng thành công!
mysqldump -u root -p invalid_db | gzip > dump.sql.gz

Cách khắc phục: Luôn kết hợp set -eo pipefail:

1
set -euo pipefail

Bẫy lỗi và dọn dẹp tài nguyên với trap

Một trong những nguyên tắc quan trọng nhất của quản trị hệ thống là: Dù script thành công, thất bại hay bị người dùng ngắt giữa chừng (Ctrl+C), toàn bộ tài nguyên tạm thời đều phải được dọn dẹp sạch sẽ.

Lệnh trap trong Bash cho phép bạn bắt các tín hiệu hệ thống (signals) hoặc sự kiện shell để thực thi mã xử lý tương ứng.

Các tín hiệu và sự kiện phổ biến

Tín hiệu / Sự kiệnThời điểm kích hoạt
EXIT (hoặc 0)Khi script kết thúc (bất kể thành công hay thất bại qua exit)
ERRKhi một lệnh trả về exit code khác 0 (dưới cơ chế tương tự set -e)
SIGINT (Tín hiệu 2)Khi người dùng bấm tổ hợp phím Ctrl+C
SIGTERM (Tín hiệu 15)Khi tiến trình nhận yêu cầu dừng từ hệ thống (kill hoặc Docker stop)

Mô hình Cleanup Function chuẩn mực

Dưới đây là cấu trúc mẫu để thiết lập hàm dọn dẹp an toàn:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#!/usr/bin/env bash
set -euo pipefail

# Khởi tạo thư mục tạm an toàn
readonly WORK_DIR=$(mktemp -d -t deploy-workspace-XXXXXX)
readonly LOCK_FILE="/tmp/my-service-deploy.lock"

cleanup() {
    local exit_code=$?
    echo "[CLEANUP] Bắt đầu giải phóng tài nguyên..."
    
    # Xóa thư mục tạm
    if [[ -d "$WORK_DIR" ]]; then
        rm -rf "$WORK_DIR"
        echo "[CLEANUP] Đã xóa thư mục tạm: $WORK_DIR"
    fi
    
    # Giải phóng file lock
    if [[ -f "$LOCK_FILE" ]]; then
        rm -f "$LOCK_FILE"
        echo "[CLEANUP] Đã giải phóng lock file."
    fi
    
    if [[ $exit_code -ne 0 ]]; then
        echo "[CLEANUP] Script kết thúc với trạng thái lỗi: $exit_code" >&2
    else
        echo "[CLEANUP] Dọn dẹp hoàn tất an toàn."
    fi
    
    exit "$exit_code"
}

# Đăng ký hàm cleanup cho cả kết thúc bình thường và các tín hiệu dừng
trap cleanup EXIT SIGINT SIGTERM

Xây dựng cơ chế Retry Pattern tự động

Trong môi trường hạ tầng phân tán (Cloud, Kubernetes, Microservices), các lỗi tạm thời (transient errors) do nghẽn mạng hoặc dịch vụ đang khởi động xảy ra rất thường xuyên. Thay vì để script thất bại ngay lập tức, cơ chế Retry Pattern với thời gian chờ (delay / backoff) giúp tăng độ tin cậy đáng kể.

Dưới đây là một hàm retry đa năng, hỗ trợ truyền lệnh tùy ý cùng số lần thử và khoảng cách chờ:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# ==============================================================================
# Hàm: retry
# Tham số:
#   $1: Số lần thử tối đa (max_attempts)
#   $2: Thời gian chờ giữa các lần tính bằng giây (delay_seconds)
#   $3+: Lệnh cần thực thi
# ==============================================================================
retry() {
    local max_attempts="$1"
    local delay="$2"
    shift 2
    local command=("$@")
    
    local attempt=1
    until "${command[@]}"; do
        local exit_code=$?
        if (( attempt >= max_attempts )); then
            echo "[ERROR] Lệnh '${command[*]}' thất bại sau $attempt lần thử (Exit code: $exit_code)." >&2
            return "$exit_code"
        fi
        
        echo "[WARN] Lệnh '${command[*]}' thất bại (lần $attempt/$max_attempts). Thử lại sau ${delay}s..." >&2
        sleep "$delay"
        (( attempt++ ))
    done
    
    return 0
}

Ví dụ áp dụng Retry trong DevOps

1
2
3
4
5
# Kiểm tra dịch vụ Database sẵn sàng (tối đa 5 lần, mỗi lần cách nhau 3 giây)
retry 5 3 curl -fsS "http://localhost:5432/healthz"

# Tải file artifact từ remote storage với retry
retry 3 5 aws s3 cp "s3://my-bucket/artifacts/app.tar.gz" "./app.tar.gz"

Chiến lược Fail-Fast và phân cấp ghi log chuẩn mực

Xử lý lỗi hiệu quả đòi hỏi sự cân bằng giữa hai hướng tiếp cận:

  1. Fail-Fast (Thất bại sớm): Khi gặp các lỗi nghiêm trọng không thể phục hồi (như sai thông tin xác thực, thiếu tham số cấu hình, ổ cứng đầy), script phải dừng ngay lập tức kèm thông báo lỗi rõ ràng.
  2. Graceful Fallback (Dự phòng mềm dẻo): Khi tài nguyên chính không khả dụng nhưng có phương án thay thế (như chuyển sang server phụ hoặc dùng dữ liệu cache cũ), script ghi nhận cảnh báo và chuyển hướng luồng xử lý.

Module ghi log chuẩn có phân cấp (Logging Framework)

Ghi log với timestamp chuẩn ISO 8601, cấp độ nghiêm trọng (Log Level) và định tuyến chuẩn (stdout cho INFO, stderr cho WARN/ERROR):

1
2
3
4
log_info()  { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] [INFO]  $*" ; }
log_warn()  { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] [WARN]  $*" >&2 ; }
log_error() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] [ERROR] $*" >&2 ; }
log_fatal() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] [FATAL] $*" >&2 ; exit 1 ; }

Ví dụ thực hành: Script Backup và đồng bộ dữ liệu an toàn

Dưới đây là một kịch bản DevOps hoàn chỉnh ứng dụng toàn bộ các kiến thức trên: sử dụng Strict Mode, khóa tiến trình chống chạy trùng lặp, bẫy tín hiệu dọn dẹp trap, cơ chế retry khi tải dữ liệu lên remote server và định nghĩa exit code bài bản.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
#!/usr/bin/env bash
# ==============================================================================
# Script: backup-and-sync.sh
# Mô tả : Sao lưu dữ liệu và đồng bộ lên server từ xa an toàn, chống gián đoạn
# ==============================================================================

set -euo pipefail
IFS=$'\n\t'

# --- Định nghĩa mã lỗi ---
readonly EXIT_OK=0
readonly EXIT_LOCK_FAILED=20
readonly EXIT_BACKUP_FAILED=21
readonly EXIT_SYNC_FAILED=22

# --- Cấu hình đường dẫn ---
readonly SOURCE_DIR="/opt/production/data"
readonly LOCK_FILE="/tmp/backup-sync.lock"
readonly WORK_DIR=$(mktemp -d -t backup-stage-XXXXXX)
readonly TIMESTAMP=$(date +%Y%m%d_%H%M%S)
readonly ARCHIVE_NAME="backup-${TIMESTAMP}.tar.gz"

# --- Hàm ghi log ---
log_info()  { echo "[$(date +'%Y-%m-%d %H:%M:%S')] [INFO]  $*"; }
log_warn()  { echo "[$(date +'%Y-%m-%d %H:%M:%S')] [WARN]  $*" >&2; }
log_error() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; }

# --- Cơ chế dọn dẹp an toàn ---
cleanup() {
    local status=$?
    log_info "Tiến hành dọn dẹp môi trường tạm..."
    
    if [[ -d "$WORK_DIR" ]]; then
        rm -rf "$WORK_DIR"
        log_info "Đã xóa thư mục tạm $WORK_DIR."
    fi
    
    if [[ -f "$LOCK_FILE" ]]; then
        rm -f "$LOCK_FILE"
        log_info "Đã mở khóa tiến trình ($LOCK_FILE)."
    fi
    
    if [[ $status -ne 0 ]]; then
        log_error "Kịch bản sao lưu kết thúc bất thường với mã: $status"
    fi
}
trap cleanup EXIT SIGINT SIGTERM

# --- Hàm thử lại (Retry Pattern) ---
retry_command() {
    local max_tries="$1"
    local wait_sec="$2"
    shift 2
    local cmd=("$@")
    
    local count=1
    until "${cmd[@]}"; do
        local code=$?
        if (( count >= max_tries )); then
            log_error "Lệnh '${cmd[*]}' thất bại sau $count lần thử."
            return "$code"
        fi
        log_warn "Thao tác chưa thành công (lần $count/$max_tries). Đang thử lại sau ${wait_sec}s..."
        sleep "$wait_sec"
        (( count++ ))
    done
    return 0
}

# --- Luồng thực thi chính ---
main() {
    log_info "Bắt đầu quy trình sao lưu dữ liệu..."

    # 1. Kiểm tra khóa tiến trình (Tránh chạy đồng thời)
    if [[ -f "$LOCK_FILE" ]]; then
        local running_pid
        running_pid=$(cat "$LOCK_FILE" 2>/dev/null || echo "")
        if [[ -n "$running_pid" ]] && kill -0 "$running_pid" 2>/dev/null; then
            log_error "Một tiến trình sao lưu khác (PID: $running_pid) đang chạy. Hủy tác vụ!"
            exit "$EXIT_LOCK_FAILED"
        fi
    fi
    echo $$ > "$LOCK_FILE"

    # 2. Kiểm tra thư mục nguồn
    if [[ ! -d "$SOURCE_DIR" ]]; then
        log_error "Thư mục nguồn $SOURCE_DIR không tồn tại!"
        exit "$EXIT_BACKUP_FAILED"
    fi

    # 3. Nén dữ liệu vào thư mục tạm
    log_info "Đang nén dữ liệu từ $SOURCE_DIR..."
    tar -czf "$WORK_DIR/$ARCHIVE_NAME" -C "$SOURCE_DIR" . || {
        log_error "Quá trình nén file thất bại!"
        exit "$EXIT_BACKUP_FAILED"
    }
    log_info "Đã tạo bản nén thành công: $ARCHIVE_NAME ($(du -h "$WORK_DIR/$ARCHIVE_NAME" | cut -f1))"

    # 4. Giả lập đồng bộ lên Backup Server từ xa với cơ chế Retry
    log_info "Bắt đầu đồng bộ bản lưu trữ sang máy chủ backup..."
    
    # Giả lập thao tác sync bằng lệnh sao chép nội bộ (có retry)
    mkdir -p "/opt/remote-storage/backups"
    if ! retry_command 3 2 cp "$WORK_DIR/$ARCHIVE_NAME" "/opt/remote-storage/backups/"; then
        log_error "Không thể đồng bộ file lên máy chủ lưu trữ sau nhiều lần thử!"
        exit "$EXIT_SYNC_FAILED"
    fi

    log_info "Đồng bộ thành công bản sao lưu vào kho lưu trữ từ xa."
    log_info "Toàn bộ quy trình hoàn tất mỹ mãn!"
}

main "$@"

Ghi chú triển khai

  • Không bọc lệnh trap bằng các tác vụ dễ phát sinh lỗi: Bản thân hàm trong trap cần viết cực kỳ đơn giản, an toàn và có kiểm tra sự tồn tại của file trước khi gọi rm để tránh gây ra lỗi lặp đệ quy.
  • Tránh nhầm lẫn giữa $? và subshell: Khi gán result=$(command), giá trị $? ngay sau đó phản ánh mã thoát của lệnh gán trong subshell. Hãy lưu mã lỗi vào một biến cục bộ local status=$? ngay dòng đầu tiên trước khi thực hiện bất kỳ lệnh echo hay kiểm tra nào khác.
  • Xử lý tín hiệu trong Docker Container: Khi chạy script làm ENTRYPOINT trong Docker, hãy đảm bảo script chạy ở PID 1 (hoặc sử dụng công cụ như tini / dumb-init) để các tín hiệu SIGTERM từ docker stop được chuyển đúng đến lệnh trap trong Bash.
  • Phân định rõ Stderr và Stdout: Luôn chuyển hướng thông báo lỗi sang >&2 (stderr). Điều này giúp các pipeline CI/CD hoặc các lệnh khác trong chuỗi pipe không bị parse nhầm nội dung log lỗi vào luồng dữ liệu nghiệp vụ.

Lời kết

Xử lý lỗi chủ động là ranh giới phân định giữa một script nghiệp dư dễ gãy vụn và một công cụ tự động hóa chuẩn mực sẵn sàng cho môi trường Production. Việc kết hợp chặt chẽ giữa set -euo pipefail, bẫy tín hiệu trap dọn dẹp tài nguyên và cơ chế tự động thử lại retry sẽ giúp hạ tầng DevOps của bạn vận hành trơn tru và có khả năng tự phục hồi mạnh mẽ.

Ở bài tiếp theo, chúng ta sẽ bước vào giai đoạn tự động hóa nâng cao với Bài 11 — Tích hợp Bash Script vào quy trình CI/CD: thiết kế pipeline build, test và deploy tự động an toàn trên GitHub Actions và GitLab CI.

THẢO LUẬN & BÌNH LUẬN