Làm chủ Linux Signals: Cách sử dụng Bash Trap để viết script ổn định hơn

Linux tutorial - IT technology blog
Linux tutorial - IT technology blog

Cái giá của việc không xử lý ngắt script

Tất cả chúng ta đều đã từng gặp tình huống này: bạn đang chạy sao lưu cơ sở dữ liệu 50GB, và giữa chừng, bạn phát hiện ra lỗi đánh máy trong đường dẫn đích. Bạn nhấn Ctrl+C để dừng tiến trình ngay lập tức. Script dừng lại, nhưng nó để lại một bản lưu trữ dở dang 20GB, một file lock cũ trong /tmp, và một điểm mount vẫn đang kết nối tới máy chủ từ xa. Đây không chỉ là sự bừa bộn; đó là mầm mống cho việc cạn kiệt dung lượng đĩa và làm hỏng các luồng tự động hóa (pipelines).

Sau khi quản lý một dàn 15 máy chủ Linux VPS trong vài năm qua, tôi rút ra rằng các script hiếm khi tự kết thúc một cách an toàn. Nếu bạn không chỉ rõ cách dọn dẹp, script sẽ không làm điều đó. Khi một tiến trình kết thúc bất ngờ, sự thiếu giao tiếp giữa hệ điều hành và mã nguồn của bạn sẽ tạo ra các trạng thái “zombie”. Để khắc phục, bạn cần hiểu cách Linux giao tiếp với các tiến trình thông qua signals.

Linux Signals: Hệ thần kinh của Script

Signals là các ngắt phần mềm (software interrupts) được gửi đến một chương trình đang chạy để kích hoạt một hành vi cụ thể. Chúng là cách hệ điều hành hét lên “Này, chú ý đây!” với script của bạn. Ví dụ, khi bạn nhấn Ctrl+C, terminal sẽ gửi một SIGINT (Signal Interrupt). Trong khi khởi động lại hệ thống, kernel sẽ gửi SIGTERM (Signal Terminate) để các ứng dụng có vài giây lưu dữ liệu và đóng các file đang mở.

Hầu hết các lập trình viên chỉ cần quan tâm đến sáu loại signals sau:

  • SIGHUP (1): Ngắt kết nối (Hangup). Dùng để yêu cầu một dịch vụ tải lại file cấu hình mà không cần dừng toàn bộ tiến trình.
  • SIGINT (2): Ngắt (Interrupt). Đây là signal tiêu chuẩn được gửi bởi Ctrl+C.
  • SIGQUIT (3): Thoát (Quit). Tương tự như ngắt, nhưng thường ép buộc tạo core dump để gỡ lỗi.
  • SIGKILL (9): Lựa chọn “hạt nhân”. Nó tiêu diệt tiến trình ngay lập tức. Bạn không thể bắt (catch), chặn hoặc bỏ qua signal này.
  • SIGTERM (15): Yêu cầu dừng lịch sự. Đây là signal mặc định được gửi bởi lệnh kill.
  • EXIT (0): Một signal đặc biệt chỉ có trong Bash. Nó được kích hoạt bất cứ khi nào script kết thúc, dù là do bị lỗi, bị dừng hay hoàn thành thành công.

Kiểm tra thư viện Signal trên hệ thống

Bạn không cần các công cụ phức tạp để xem máy mình hỗ trợ những signals nào. Lệnh kill, nằm trong gói util-linux trên hầu hết các bản phân phối từ Ubuntu đến Arch, xử lý việc này dễ dàng. Hầu hết các nhân Linux hiện đại hỗ trợ 64 signals khác nhau.

Để xem danh sách đầy đủ trên máy của bạn, hãy chạy:

kill -l

Nếu bạn cần ID cụ thể cho một tên signal (hoặc ngược lại), hãy dùng:

kill -l SIGINT
# Kết quả: 2

Khi quản lý tiến trình, bạn sẽ dùng kill -15 <PID> để thoát một cách an toàn. Mục tiêu của chúng ta là viết các script thực sự lắng nghe yêu cầu đó thay vì phớt lờ nó.

Triển khai lệnh Trap

Lệnh trap là một lệnh tích hợp (built-in) của Bash giúp chặn các signals và chạy một hàm cụ thể trước khi script thoát. Logic rất đơn giản: trap 'các_lệnh' SIGNALS.

1. Tự động dọn dẹp tài nguyên

Hãy giải quyết vấn đề “file rác”. Thay vì rải các lệnh rm ở mọi điểm thoát có thể, hãy định nghĩa một hàm dọn dẹp duy nhất. Điều này đảm bảo rằng ngay cả khi script thất bại ở dòng 50, các file tạm thời của bạn vẫn được xóa.

#!/bin/bash

# Tạo một file tạm duy nhất
TEMP_FILE=$(mktemp /tmp/backup_log.XXXXXX)
echo "Đang ghi log vào $TEMP_FILE..."

# Định nghĩa logic dọn dẹp
cleanup() {
    echo -e "\nNhận được tín hiệu thoát. Đang xóa $TEMP_FILE..."
    rm -f "$TEMP_FILE"
}

# Bắt signal EXIT
trap cleanup EXIT

# Giả lập một tác vụ chạy trong 10 giây
sleep 10
echo "Tác vụ hoàn tất!"

Dù script kết thúc bình thường hay bạn ép nó dừng lại, hàm cleanup vẫn sẽ được thực thi. Thư mục /tmp của bạn luôn sạch sẽ.

2. Phân biệt giữa việc dừng do người dùng và do hệ thống

Đôi khi bạn cần biết tại sao script lại dừng. Bạn có thể muốn ghi log cảnh báo nếu người dùng ngắt tác vụ thủ công so với việc hệ thống tắt máy để bảo trì.

#!/bin/bash

trap 'echo "Bị ngắt bởi người dùng (Ctrl+C)"; exit 1' SIGINT
trap 'echo "Yêu cầu tắt hệ thống"; exit 1' SIGTERM

echo "ID tiến trình: $$"
while true; do sleep 1; done

Quan trọng là tôi đã bao gồm exit 1 trong trap. Nếu bạn bắt một signal nhưng không gọi exit một cách rõ ràng, script sẽ cố gắng tiếp tục từ nơi nó bị dừng. Đây hiếm khi là điều bạn muốn khi nhận được tín hiệu kết thúc.

3. Dọn dẹp các tiến trình con

Một vấn đề đau đầu phổ biến trong tự động hóa phức tạp là tiến trình “mồ côi” (orphan). Nếu script chính của bạn bắt đầu một tác vụ chạy ngầm rồi bị dừng, tác vụ ngầm đó có thể tiếp tục chạy vô hạn, gây tốn CPU. Hãy dùng trap để tiêu diệt các tiến trình con khi tiến trình cha kết thúc.

#!/bin/bash

# Bắt đầu một tác vụ chạy ngầm (ví dụ: theo dõi log hoặc proxy)
sleep 100 &
CHILD_PID=$!

cleanup() {
    echo "Đang dọn dẹp tiến trình con $CHILD_PID..."
    kill $CHILD_PID 2>/dev/null
}

trap cleanup EXIT SIGINT SIGTERM

wait $CHILD_PID

Kiểm tra và gỡ lỗi Trap

Mã nguồn không được kiểm tra là mã nguồn đã hỏng. Để xác minh các trap của bạn, hãy mở hai cửa sổ terminal. Chạy script ở cửa sổ thứ nhất, sau đó gửi các signals từ cửa sổ thứ hai.

# Terminal 2
pgrep -f my_script.sh
kill -SIGTERM <PID>

Nếu trap không kích hoạt ngay lập tức, có khả năng là do Bash đang đợi một lệnh “nghẽn” (blocking)—như một lệnh sleep dài hoặc một file thực thi bên ngoài—kết thúc. Để script phản hồi nhanh hơn, hãy chạy các lệnh nặng ở chế độ ngầm và sử dụng wait. Lệnh wait rất đặc biệt vì nó sẽ bị ngắt ngay lập tức khi nhận được một signal.

Để xem những trap nào đang hoạt động trong shell của bạn, chỉ cần nhập:

trap

Các quy tắc tốt nhất cho môi trường Production

Xử lý signals là điểm khác biệt giữa một script viết vội và một hệ thống tự động hóa chuyên nghiệp. Hãy tuân thủ các quy tắc sau để giữ hệ thống ổn định:

  • Giữ logic dọn dẹp tinh gọn: Trap của bạn nên chạy dưới 2-3 giây. Tránh thực hiện các lệnh gọi API phức tạp hoặc yêu cầu mạng, vì stack mạng có thể cũng đang bị đóng.
  • Đừng cố bắt SIGKILL: Điều đó là không thể. Nếu script của bạn bị dính kill -9, thường có nghĩa là trình xử lý SIGTERM của bạn quá chậm hoặc bị kẹt.
  • Sử dụng ‘Ignore’ cho các phần quan trọng: Nếu bạn đang ghi một file cấu hình 1KB mà không được phép bị hỏng, hãy dùng trap '' SIGINT để bỏ qua các ngắt trong khi ghi, sau đó khôi phục lại bằng trap - SIGINT.
  • Ghi log tác nhân kích hoạt: Luôn ghi log lại signal nào đã kích hoạt việc thoát. Nó sẽ giúp bạn tiết kiệm hàng giờ gỡ lỗi khi một cron job thất bại và bạn không biết đó là do hết thời gian chờ (timeout) hay bị dừng thủ công.

Bằng cách làm chủ các kỹ thuật này, bạn đảm bảo hệ thống tự động hóa Linux của mình luôn mạnh mẽ, hệ thống tệp luôn gọn gàng và các tiến trình giao tiếp với độ chính xác chuyên nghiệp.

Share: