BÀI VIẾT // [DEVOPS] Debug script trong Bash: Tìm và sửa lỗi hiệu quả cho DevOps
Debug script trong Bash: Tìm và sửa lỗi hiệu quả cho DevOps

Debug script trong Bash: Tìm và sửa lỗi hiệu quả cho DevOps

Hướng dẫn kỹ thuật debug Bash script cho DevOps: bash -x, bash -n, set -euo pipefail, tùy biến PS4, bắt lỗi với trap ERR và phân tích tĩnh với ShellCheck.

DEVOPS BASH

Debug script trong Bash cho DevOps

bài trước chúng ta đã làm quen với việc lên lịch chạy script tự động bằng cron. Khi một script được đưa lên server để chạy nền hoặc tích hợp vào pipeline CI/CD, việc script phát sinh lỗi mà không để lại dấu vết rõ ràng là một trong những nguyên nhân phổ biến nhất gây gián đoạn hệ thống.

Khác với các ngôn ngữ biên dịch hoặc có runtime phong phú (như Go, Python), Bash script theo mặc định có xu hướng tiếp tục chạy ngay cả khi một câu lệnh bị lỗi. Một biến bị gõ sai tên, một đường dẫn không tồn tại hay một pipe trả về mã lỗi âm thầm đều có thể biến một script dọn dẹp đơn giản thành thảm họa xóa nhầm dữ liệu.

Bài viết này tổng hợp toàn bộ các kỹ thuật debug từ cơ bản đến nâng cao trong Bash: từ kiểm tra cú pháp khô (dry-run), theo dõi dấu vết thực thi (execution tracing), tùy biến định dạng trace với PS4, kích hoạt chế độ nghiêm ngặt (Strict Mode), bắt lỗi với trap ERR, cho đến kiểm tra tự động bằng ShellCheck.


Kiểm tra cú pháp nhanh với cờ bash -n

Trước khi chạy thử một script có thể làm thay đổi hệ thống (như ghi đè file hay gọi API xóa tài nguyên), bạn luôn nên kiểm tra cú pháp trước bằng cờ -n (noexec).

1
bash -n deploy.sh

Cờ -n chỉ đọc và parse toàn bộ file để kiểm tra cấu trúc cú pháp (như thiếu từ khóa fi, quên đóng ngoặc }, cú pháp case...esac sai) mà hoàn toàn không thực thi bất kỳ câu lệnh nào.

Ví dụ với đoạn script có lỗi quên đóng ngoặc:

1
2
3
4
5
#!/usr/bin/env bash

if [ "$ENV" = "production" ]; then
    echo "Deploying to production..."
# Quên fi

Khi chạy kiểm tra cú pháp:

1
2
$ bash -n deploy.sh
deploy.sh: line 6: syntax error: unexpected end of file

Trong quy trình CI/CD, bạn có thể thêm một bước kiểm tra toàn bộ file .sh trong repository bằng lệnh:

1
find . -type f -name "*.sh" -exec bash -n {} +

Tracing thực thi từng bước với bash -x và set -x

Kỹ thuật debug phổ biến nhất trong Bash là Execution Tracing với cờ -x (xtrace). Khi bật cờ này, Bash sẽ in ra từng lệnh sau khi đã hoàn thành việc mở rộng biến (parameter expansion), thay thế lệnh (command substitution) và tách từ (word splitting), ngay trước khi lệnh đó được thực thi.

Cách 1: Chạy toàn bộ script ở chế độ trace

1
bash -x script.sh [arguments]

Hoặc thêm cờ -x trực tiếp vào dòng Shebang đầu file:

1
#!/usr/bin/env bash -x

Cách 2: Bật tắt trace có chọn lọc trong script

Nếu script dài hàng trăm dòng và bạn chỉ muốn theo dõi một đoạn logic cụ thể, hãy dùng cặp lệnh set -x (bật trace) và set +x (tắt trace):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/usr/bin/env bash

echo "Bắt đầu khởi tạo môi trường..."

# Đoạn logic phức tạp cần theo dõi chi tiết
set -x
target_dir="/opt/app/releases/$(date +%Y%m%d)"
mkdir -p "$target_dir"
cp -r ./dist/* "$target_dir/"
set +x

echo "Hoàn thành sao chép file."

Khi thực thi, chỉ phần nằm giữa set -xset +x mới in chi tiết từng dòng lệnh với tiền tố dấu +:

1
2
3
4
5
6
7
Bắt đầu khởi tạo môi trường...
++ date +%Y%m%d
+ target_dir=/opt/app/releases/20260702
+ mkdir -p /opt/app/releases/20260702
+ cp -r ./dist/app.bin ./dist/config.json /opt/app/releases/20260702/
+ set +x
Hoàn thành sao chép file.

Nâng cấp đầu ra debug chuyên nghiệp với biến PS4

Theo mặc định, khi bật -x, Bash chỉ in dấu + trước mỗi dòng lệnh. Khi script gọi nhiều function hoặc source nhiều file khác nhau, rất khó để biết lệnh đó nằm ở file nào, hàm nào và dòng bao nhiêu.

Biến môi trường PS4 (Prompt String 4) cho phép bạn tùy biến định dạng tiền tố in ra khi debug. Các biến hữu ích gồm:

Ký hiệu / BiếnÝ nghĩa
$0 hoặc ${BASH_SOURCE[0]}Tên file script hiện tại
$LINENOSố dòng đang thực thi
${FUNCNAME[0]}Tên function đang chạy
+Dấu cộng tăng theo độ sâu subshell

Bạn có thể cấu hình PS4 ngay đầu script hoặc truyền trực tiếp từ dòng lệnh:

1
export PS4='+ [${BASH_SOURCE[0]##*/}:${LINENO}] ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'

Ví dụ kiểm chứng:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/usr/bin/env bash
export PS4='+ [${BASH_SOURCE[0]##*/}:${LINENO}] ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
set -x

build_package() {
    local version="$1"
    echo "Đang đóng gói phiên bản $version..."
}

main() {
    local app_version="v2.4.0"
    build_package "$app_version"
}

main

Kết quả in ra cực kỳ rõ ràng, kèm chính xác tên hàm và số dòng:

1
2
3
4
5
+ [build.sh:15] main(): local app_version=v2.4.0
+ [build.sh:16] main(): build_package v2.4.0
+ [build.sh:8] build_package(): local version=v2.4.0
+ [build.sh:9] build_package(): echo 'Đang đóng gói phiên bản v2.4.0...'
Đang đóng gói phiên bản v2.4.0...

Chế độ nghiêm ngặt trong Bash (Strict Mode)

Trong môi trường DevOps, hầu hết các lỗi nghiêm trọng xuất phát từ việc script âm thầm bỏ qua lỗi và tiếp tục chạy. Hãy đưa dòng cấu hình Strict Mode tiêu chuẩn vào đầu mọi script:

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

Ý nghĩa chi tiết của từng tùy chọn:

  • set -e (errexit): Dừng script ngay lập tức nếu bất kỳ câu lệnh đơn lẻ nào trả về exit code khác 0.
  • set -u (nounset): Báo lỗi và dừng script ngay nếu truy cập vào một biến chưa được định nghĩa (ngăn chặn các lỗi tai hại như rm -rf "$UNSET_VAR/*" biến thành rm -rf "/*").
  • set -o pipefail: Theo mặc định, pipeline cmd1 | cmd2 | cmd3 chỉ trả về exit code của cmd3. Tùy chọn này đảm bảo nếu cmd1 hoặc cmd2 bị lỗi, toàn bộ pipeline sẽ trả về mã lỗi của lệnh thất bại đầu tiên.
  • IFS=$'\n\t': Đặt ký tự phân tách trường nội bộ chỉ gồm dấu xuống dòng và tab, tránh lỗi chia tách từ ngoài ý muốn khi biến chứa khoảng trắng.

Cạm bẫy khi dùng set -e và cách xử lý

Khi dùng set -e, nếu một lệnh kiểm tra dự kiến có thể trả về exit code khác 0 (ví dụ grep không tìm thấy dòng nào trả về 1), script sẽ bị dừng ngoài ý muốn. Cách xử lý đúng:

1
2
3
4
5
6
7
8
9
# Cách 1: Thêm || true nếu lệnh không bắt buộc thành công
grep "ERROR" /var/log/app.log || true

# Cách 2: Bọc trong điều kiện if (set -e không ngắt trong if test)
if grep -q "ERROR" /var/log/app.log; then
    echo "Phát hiện lỗi trong log."
else
    echo "Log an toàn."
fi

Bắt lỗi và in Stack Trace với trap ERR

Để script tự động ghi nhận ngữ cảnh khi gặp sự cố, bạn có thể kết hợp trap với tín hiệu ERR. Khi bất kỳ lệnh nào bị lỗi, hàm xử lý lỗi sẽ được gọi tự động và in ra dấu vết stack trace gồm file, tên hàm và số dòng.

 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
#!/usr/bin/env bash
set -euo pipefail

# Hàm in stack trace khi có lỗi phát sinh
handle_error() {
    local exit_code="$1"
    local line_no="$2"
    local command="$3"
    
    echo "==========================================" >&2
    echo "PHÁT HIỆN LỖI TRONG SCRIPT!" >&2
    echo "Lệnh thất bại : $command" >&2
    echo "Số dòng       : $line_no" >&2
    echo "Mã lỗi        : $exit_code" >&2
    echo "Dấu vết gọi hàm (Call Stack):" >&2
    
    local frame=0
    while caller $frame; do
        ((frame++))
    done >&2
    echo "==========================================" >&2
}

# Đăng ký trap cho tín hiệu ERR
trap 'handle_error $? $LINENO "$BASH_COMMAND"' ERR

step_one() {
    echo "Thực hiện bước 1..."
}

step_two() {
    echo "Thực hiện bước 2: gọi lệnh lỗi..."
    ls /duong-dan-khong-ton-tai-tren-he-thong
}

main() {
    step_one
    step_two
    echo "Hoàn thành."
}

main

Khi chạy script trên, ngay khi lệnh ls thất bại, hệ thống sẽ in ra bảng thông báo lỗi cùng stack trace chuẩn xác:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Thực hiện bước 1...
Thực hiện bước 2: gọi lệnh lỗi...
ls: cannot access '/duong-dan-khong-ton-tai-tren-he-thong': No such file or directory
==========================================
PHÁT HIỆN LỖI TRONG SCRIPT!
Lệnh thất bại : ls /duong-dan-khong-ton-tai-tren-he-thong
Số dòng       : 31
Mã lỗi        : 2
Dấu vết gọi hàm (Call Stack):
31 step_two script.sh
36 main script.sh
40 main script.sh
==========================================

Phân tích tĩnh tự động với ShellCheck

ShellCheck là công cụ phân tích tĩnh (linter) mã nguồn Bash mạnh mẽ và phổ biến nhất hiện nay. ShellCheck có thể phát hiện hàng trăm lỗi tiềm ẩn: quên bọc ngoặc kép biến, sai cú pháp điều kiện, dùng sai lệnh nội tại, lỗi tính tương thích POSIX, v.v.

Cài đặt ShellCheck

1
2
3
4
5
# Trên Ubuntu / Debian
sudo apt-get update && sudo apt-get install -y shellcheck

# Trên macOS qua Homebrew
brew install shellcheck

Chạy kiểm tra script

1
shellcheck deploy.sh

Ví dụ một đoạn script chứa lỗi biến không được bọc ngoặc kép:

1
2
3
#!/usr/bin/env bash
filename=$1
rm -rf /tmp/data/$filename

ShellCheck sẽ lập tức chỉ ra cảnh báo nguy cơ:

1
2
3
In script.sh line 3:
rm -rf /tmp/data/$filename
                 ^-- SC2086 (info): Double quote to prevent globbing and word splitting.

Tích hợp ShellCheck vào Git Pre-commit Hook hoặc pipeline CI giúp toàn bộ script trong team luôn đạt chuẩn chất lượng trước khi được merge vào branch chính.


Ví dụ thực hành: Script triển khai ứng dụng an toàn

Dưới đây là một script DevOps hoàn chỉnh kết hợp đầy đủ: kiểm tra cú pháp, Strict Mode, tùy biến PS4, bắt lỗi trap ERR và kiểm tra tham số đầu vào.

 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
#!/usr/bin/env bash
# ==============================================================================
# Script: deploy-service.sh
# Mục đích: Triển khai bản phát hành mới với cơ chế debug và bẫy lỗi an toàn
# ==============================================================================

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

# Cấu hình định dạng tiền tố khi debug (bật khi truyền biến DEBUG=1)
if [[ "${DEBUG:-0}" == "1" ]]; then
    export PS4='+ [${BASH_SOURCE[0]##*/}:${LINENO}] ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
    set -x
fi

# Bẫy lỗi và dọn dẹp tài nguyên
cleanup() {
    local exit_code=$?
    if [[ $exit_code -ne 0 ]]; then
        echo "[ERROR] Quá trình triển khai gặp lỗi với mã thoát: $exit_code" >&2
    fi
}
trap cleanup EXIT

handle_error() {
    local exit_code="$1"
    local line_no="$2"
    local cmd="$3"
    echo "[CRITICAL] Lệnh '$cmd' tại dòng $line_no thất bại (exit code: $exit_code)" >&2
}
trap 'handle_error $? $LINENO "$BASH_COMMAND"' ERR

# Kiểm tra tham số đầu vào
APP_NAME="${1:-}"
RELEASE_VERSION="${2:-}"

if [[ -z "$APP_NAME" || -z "$RELEASE_VERSION" ]]; then
    echo "Sử dụng: $0 <app_name> <release_version>" >&2
    exit 1
fi

DEPLOY_DIR="/opt/apps/$APP_NAME/releases/$RELEASE_VERSION"

echo "==> Bắt đầu triển khai $APP_NAME bản $RELEASE_VERSION..."
mkdir -p "$DEPLOY_DIR"

echo "==> Đang tải gói cài đặt..."
# Giả lập thao tác tạo file cấu hình
echo "version=$RELEASE_VERSION" > "$DEPLOY_DIR/version.env"
echo "deployed_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$DEPLOY_DIR/version.env"

echo "==> Cập nhật symlink 'current'..."
ln -sfn "$DEPLOY_DIR" "/opt/apps/$APP_NAME/current"

echo "==> Triển khai $APP_NAME thành công!"

Chạy script với cờ debug:

1
DEBUG=1 ./deploy-service.sh web-api v1.0.0

Ghi chú triển khai

  • Bảo vệ Secret khi bật debug: Cờ set -x sẽ in toàn bộ giá trị của biến ra màn hình/log. Nếu script của bạn có xử lý password, token hoặc API key, hãy nhớ dùng set +x để tắt trace trước khi đọc secret và chỉ bật lại set -x sau khi hoàn tất.
  • Tách riêng luồng Log Debug: Trong production hoặc CI/CD, bạn có thể chuyển hướng đầu ra của xtrace (luồng file descriptor 2 / stderr) vào một file log riêng biệt để dễ kiểm tra lại: BASH_XTRACEFD=3 bash -x script.sh 3> /var/log/script-trace.log.
  • Luôn kiểm tra ShellCheck trong CI: Thêm một step chạy shellcheck trong GitHub Actions hoặc GitLab CI để ngăn chặn các lỗi sơ đẳng trước khi mã nguồn đến tay người dùng.
  • Không lạm dụng set -e mà thiếu trap: set -e giúp dừng script khi lỗi, nhưng nếu không có hàm cleanup đi kèm, script có thể để lại các file lock, thư mục tạm hoặc tài nguyên treo trên hệ thống.

Lời kết

Debug là một kỹ năng không thể thiếu để biến các script Bash từ những đoạn mã tự phát thành các công cụ tự động hóa chuẩn mực, đáng tin cậy trong môi trường DevOps. Bằng cách kết hợp bash -n, set -x, tùy biến PS4, kích hoạt Strict Mode và kiểm tra với ShellCheck, bạn có thể kiểm soát và xử lý triệt để mọi lỗi phát sinh.

Ở bài tiếp theo, chúng ta sẽ đi sâu vào Bài 10 — Error Handling trong Bash: tìm hiểu chi tiết các chiến lược bắt lỗi, cơ chế thử lại (retry pattern) và dọn dẹp tài nguyên tự động.

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