0

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.

image.png

(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:

  1. Hỏng trạng thái dữ liệu và Treo khóa (Dangling Locks & Dirty State): Giả sử lệnh cbox db migrate tạo một bản ghi is_running = 1 trong bảng schema_migrations để chống chạy trùng. Khi bấm Ctrl + C, tiến trình chết trước khi kịp chạy câu lệnh UPDATE 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.

  2. 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!

  3. Bỏ lại Tiến trình con chạy mồ côi (Zombie SSH / Docker): Lệnh cbox log tail của bạn gọi ngầm ssh root@server "tail -f /var/log/nginx/error.log". Khi bạn bấm Ctrl + 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ệnh tail -f trê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ấm Ctrl + C phá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 defer dùng để dọn dẹp tài nguyên sau khi bị ngắt, tuyệt đối không dùng lại ctx của hàm chính. Hãy luôn khởi tạo một context.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ấm Ctrl + C mộ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ấm Ctrl + C liên tiếp 2 lần thật nhanh để trải nghiệm cảm giác Force Exit!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí