Command Box bài học 7: Xử lý tín hiệu hệ thống (SIGINT, SIGTERM) để Command Box không bị treo ngang xương
Bạn đang chạy lệnh cbox order reindex --batch=10000 để đồng bộ lại dữ liệu đơn hàng lên Elasticsearch. Chạy được một nửa, bạn chợt phát hiện mình truyền nhầm cờ môi trường. Phản xạ tự nhiên của mọi kỹ sư là bấm tổ hợp phím Ctrl + C trên bàn phím để dừng ngay lập tức.
Nhưng nếu Command Box của bạn không được trang bị cơ chế xử lý tín hiệu hệ thống (OS Signals), cái bấm Ctrl + C đó sẽ cắt đứt tiến trình một cách thô bạo ngay giữa chừng: một nửa lô dữ liệu đã ghi vào DB, nửa còn lại biến mất; con trỏ chuột trên màn hình terminal bị ẩn đi vĩnh viễn; hoặc tệ hơn là khóa phân tán (Distributed Lock) trên Redis chưa kịp nhả ra, khiến lần chạy tiếp theo bị treo cứng vì tưởng tiến trình cũ vẫn đang chạy!
Bài học thứ 7 này sẽ hướng dẫn bạn thiết kế cơ chế Graceful Shutdown (Hạ cánh an toàn) cho Command Box — biến cú bấm Ctrl + C từ một vụ "tai nạn giao thông" thành một quy trình dừng hệ thống chuẩn mực.

(Hình minh họa: Khi có sự kiện ngắt hoặc lỗi luồng đường ống, OS Kernel sẽ bắn các tín hiệu POSIX trực tiếp vào tiến trình để yêu cầu xử lý hoặc dừng thực thi).
1. Phân biệt 4 Tín hiệu sinh tử trong thế giới CLI
Khi một tiến trình chạy trên Linux/macOS (hoặc WSL2), hệ điều hành giao tiếp với nó thông qua các tín hiệu POSIX (Signals). Một người thiết kế Command Box bắt buộc phải nắm rõ 4 tín hiệu sau:
| Tín hiệu | Mã số | Nguồn phát sinh | Có bắt (Trap) được không? | Ý nghĩa đối với Command Box |
|---|---|---|---|---|
| SIGINT (Interrupt) | 2 | Người dùng bấm Ctrl + C trực tiếp trên bàn phím Terminal. |
Có | Người dùng muốn hủy lệnh đang chạy. Cần dừng nhận việc mới, dọn dẹp nhanh và trả lại màn hình. |
| SIGTERM (Terminate) | 15 | Lệnh kill <pid>, Docker stop, hoặc khi GitLab CI / Kubernetes hủy job do quá thời gian. |
Có | Hệ thống quản lý yêu cầu tắt tiến trình một cách lịch sự trước khi cưỡng chế. |
| SIGKILL (Kill) | 9 | Lệnh kill -9 <pid> hoặc OS OOM Killer (khi hết RAM). |
❌ KHÔNG | Án tử hình tức khắc từ Kernel. Tiến trình tắt phụt ngay lập tức, không một dòng code dọn dẹp nào chạy được. |
| SIGPIPE (Broken Pipe) | 13 | Khi dùng dấu gạch đứng (|), ví dụ cbox logs \| head -n 5. Lệnh head đọc đủ 5 dòng rồi đóng cửa, nhưng cbox vẫn cố ghi dòng thứ 6. |
Có | Cần dừng ghi ra stdout một cách êm thấm mà không in ra thông báo lỗi rác màn hình. |
2. 3 "Thảm họa" khi bấm Ctrl + C trên một CLI thiếu Graceful Shutdown
Nếu bạn để mặc hệ điều hành tự xử lý SIGINT theo cách mặc định (giết chết tiến trình ngay tại dòng lệnh đang chạy dở), bạn sẽ đối mặt với 3 hậu quả thực tế sau:
-
Hỏng trạng thái dữ liệu và Treo khóa (Dangling Locks & Dirty State): Giả sử lệnh
cbox db migratetạo một bản ghiis_running = 1trong bảngschema_migrationsđể chống chạy trùng. Khi bấmCtrl + C, tiến trình chết trước khi kịp chạy câu lệnhUPDATE is_running = 0. Lần sau bạn chạy lại, tool báo "Đang có tiến trình khác migrate!" và từ chối làm việc, buộc bạn phải mở Navicat vào DB xóa tay. -
Làm hỏng giao diện Terminal (Broken TTY State): Khi bạn dùng các thư viện vẽ giao diện đẹp như Spinner, Progress Bar hay Menu chọn (như ở Bài học 5), thư viện sẽ gửi mã ANSI để ẩn con trỏ chuột (Hide Cursor) hoặc chuyển Terminal sang chế độ Raw Mode. Nếu tiến trình bị ngắt đột ngột mà không khôi phục lại trạng thái cũ, sau khi thoát ra, người dùng gõ phím trên terminal sẽ không thấy chữ hiện lên hoặc mất hẳn dấu nháy chuột!
-
Bỏ lại Tiến trình con chạy mồ côi (Zombie SSH / Docker): Lệnh
cbox log tailcủa bạn gọi ngầmssh root@server "tail -f /var/log/nginx/error.log". Khi bạn bấmCtrl + Cở máy local, nếu không truyền tín hiệu ngắt chuẩn xác theo nhóm tiến trình (Process Group), câu lệnhtail -ftrên server từ xa vẫn tiếp tục chạy ngầm ngày này qua tháng khác.
3. Chiến lược "Double Tap" (Ctrl + C hai lần): Đỉnh cao của CLI UX
Hãy quan sát cách docker compose up hoặc kubectl hoạt động khi bạn bấm Ctrl + C:
-
Lần bấm thứ nhất (
Ctrl + C): Công cụ bắt lấy tín hiệu, in ra dòng chữ: "⚠ Đang dừng an toàn và hoàn tất nốt giao dịch hiện tại... (Bấm Ctrl+C lần nữa để thoát ngay lập tức)". Lúc này nó đóng các kết nối, lưu file nháp rồi mới thoát. -
Lần bấm thứ hai (
Ctrl + C): Nếu mạng bị treo làm bước dọn dẹp cũng bị đơ, người dùng mất kiên nhẫn bấmCtrl + Cphát thứ hai. Lúc này công cụ bỏ qua mọi thủ tục dọn dẹp và thoát cưỡng bức ngay lập tức (Force Exit).
Đây chính là mô hình chuẩn mực nhất mà mọi Command Box chuyên nghiệp đều phải áp dụng!
4. Thực hành viết code: Cài đặt bộ bắt tín hiệu "Double-Tap" và Dọn dẹp trong Go
Từ phiên bản Go 1.16+, package os/signal cung cấp hàm signal.NotifyContext, nhưng để làm được cơ chế Double-Tap (Bấm lần 1 dừng êm, bấm lần 2 thoát gấp) kèm theo chuẩn mã thoát Unix (Exit Code 130 cho SIGINT), chúng ta sẽ thiết kế bộ điều phối tại tầng khởi chạy như sau:
Go
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
const (
ExitCodeSuccess = 0
ExitCodeError = 1
ExitCodeInterrupt = 130 // Chuẩn Unix: 128 + 2 (SIGINT)
)
func main() {
os.Exit(run())
}
func run() int {
// 1. Tạo Context gốc có khả năng bị hủy
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 2. Tạo kênh lắng nghe tín hiệu SIGINT (Ctrl+C) và SIGTERM (Kill/Docker stop)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// 3. Goroutine gác cổng: Xử lý chiến lược Double-Tap
go func() {
sig := <-sigChan // Đợi lần bấm Ctrl+C đầu tiên
fmt.Fprintf(os.Stderr, "\n⚠ Nhận tín hiệu [%v]. Đang hoàn tất tác vụ dở dang và dọn dẹp...\n", sig)
fmt.Fprintf(os.Stderr, "💡 (Bấm Ctrl+C thêm lần nữa để buộc thoát ngay lập tức)\n")
// Phát tín hiệu hủy xuống toàn bộ các hàm đang lắng nghe ctx.Done()
cancel()
// Mở chốt chờ lần bấm Ctrl+C thứ 2 HOẶC quá 10 giây dọn dẹp mà chưa xong
select {
case <-sigChan:
fmt.Fprintln(os.Stderr, "\n✖ Buộc thoát ngay lập tức (Force Exit)!")
// Khôi phục lại con trỏ chuột trên Terminal (nếu đang bị ẩn bởi Spinner)
fmt.Fprint(os.Stderr, "\033[?25h")
os.Exit(ExitCodeInterrupt)
case <-time.After(10 * time.Second):
fmt.Fprintln(os.Stderr, "\n✖ Quá thời gian dọn dẹp (10s Grace Period). Tự động ngắt!")
fmt.Fprint(os.Stderr, "\033[?25h")
os.Exit(ExitCodeInterrupt)
}
}()
// 4. Thực thi lệnh mẫu: Đồng bộ 5 lô dữ liệu
err := syncBatchesGracefully(ctx, 5)
if err != nil {
if ctx.Err() != nil {
fmt.Fprintln(os.Stderr, "✔ Đã dừng tiến trình an toàn, không có dữ liệu nào bị hỏng.")
return ExitCodeInterrupt
}
fmt.Fprintf(os.Stderr, "✖ Lỗi thực thi: %v\n", err)
return ExitCodeError
}
return ExitCodeSuccess
}
// Hàm nghiệp vụ ở tầng Core biết lắng nghe tín hiệu dừng theo từng chặng (Checkpoint)
func syncBatchesGracefully(ctx context.Context, totalBatches int) error {
// Giả lập mở khóa Redis (Distributed Lock) và đảm bảo luôn mở khóa khi thoát
acquireRedisLock("lock:order_sync")
defer func() {
// Lưu ý: Dùng context.Background() mới cho hàm dọn dẹp vì ctx chính đã bị hủy!
cleanupCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
releaseRedisLock(cleanupCtx, "lock:order_sync")
}()
for i := 1; i <= totalBatches; i++ {
// CHECKPOINT: Kiểm tra xem người dùng có bấm Ctrl+C trước khi qua lô mới không
select {
case <-ctx.Done():
return ctx.Err() // Dừng ngay, không chạy tiếp lô i nữa
default:
}
fmt.Printf("🔄 Đang xử lý lô dữ liệu %d/%d...\n", i, totalBatches)
// Giả lập công việc tốn 2 giây cho mỗi lô (không bị cắt ngang giữa chừng)
time.Sleep(2 * time.Second)
fmt.Printf(" ✔ Đã lưu an toàn lô %d vào Database.\n", i)
}
return nil
}
func acquireRedisLock(key string) {
fmt.Println("🔒 Đã khóa phiên làm việc:", key)
}
func releaseRedisLock(ctx context.Context, key string) {
fmt.Println("🔓 Đã giải phóng khóa an toàn:", key)
}
5. Cạm bẫy lớn nhất khi viết code dọn dẹp (Cleanup Context Trap)
Hãy nhìn kỹ vào hàm defer ở đoạn code trên. Đây là lỗi mà 90% lập trình viên Go có kinh nghiệm vẫn mắc phải khi xử lý SIGINT:
Khi người dùng bấm Ctrl + C, biến ctx truyền vào hàm syncBatchesGracefully(ctx) đã chuyển sang trạng thái Canceled (Đã hủy). Nếu trong hàm defer, bạn lại truyền chính cái ctx đã chết đó vào câu lệnh nhả khóa Redis (redisClient.Del(ctx, "lock_key")) hoặc câu lệnh tx.Rollback(ctx) của Database, thư viện Redis/Database sẽ thấy ctx đã bị hủy và lập tức từ chối thực hiện lệnh nhả khóa!
Quy tắc bất di bất dịch: Bên trong các khối
deferdùng để dọn dẹp tài nguyên sau khi bị ngắt, tuyệt đối không dùng lạictxcủa hàm chính. Hãy luôn khởi tạo mộtcontext.WithTimeout(context.Background(), 3*time.Second)hoàn toàn mới chỉ dành riêng cho việc dọn dẹp!
💡 Bài tập thực hành cuối bài (Dành cho độc giả):
Hãy chạy thử đoạn code mẫu ở mục 4 trên máy của bạn. Trong lúc màn hình đang in
"🔄 Đang xử lý lô dữ liệu 2/5...", hãy bấmCtrl + Cmột lần và quan sát: Chương trình sẽ không chết ngay mà đợi in xong dòng"✔ Đã lưu an toàn lô 2", sau đó chạy tiếp dòng"🔓 Đã giải phóng khóa an toàn"rồi mới dừng hẳn. Tiếp theo, hãy chạy lại và bấmCtrl + Cliên tiếp 2 lần thật nhanh để trải nghiệm cảm giác Force Exit!
All rights reserved