Graceful Shutdown trong Go — Signal, Context & bài học thực tế

Phong Hy

Một trong những lỗi backend engineer thường gặp nhất là kill thẳng container khi deploy — dẫn đến request đang xử lý bị drop, database connection bị orphan, worker chết giữa chừng. Bài này mình sẽ chỉ cách implement graceful shutdown trong Go đúng chuẩn production.

Signal — "trời kêu ai nấy tắt"

Linux gửi signal cho process khi muốn nó dừng. Hai signal quan trọng nhất:

  • SIGTERM (15): "Xin hãy dừng lại" — clean shutdown, đây là signal Docker gửi khi bạn docker stop.
  • SIGINT (2): Ctrl+C — thường dùng dev.
  • SIGKILL (9): Không thể catch, không thể ignore — kill thẳng.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

sig := <-sigCh
log.Printf("Nhận signal %v, bắt đầu shutdown...", sig)

HTTP Server — Shutdown với context deadline

http.Server.Shutdown() chính là method bạn cần. Nó graceful drain connections cũ, chặn connection mới:

srv := &http.Server{Addr: ":8080"}

// Chạy server trong goroutine riêng
go func() {
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("HTTP server error: %v", err)
    }
}()

// Block chờ signal
<-sigCh

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

if err := srv.Shutdown(ctx); err != nil {
    log.Printf("Shutdown timeout: %v", err)
}

Điểm mấu chốt: timeout 30 giây. Nếu server không drain hết connection trong 30s, context cancel và server shutdown force. Luôn set timeout, đừng để chờ vô hạn.

Worker Pool — Graceful Shutdown với WaitGroup

Nếu bạn có background workers (xử lý queue, consume Kafka), pattern sẽ khác:

var wg sync.WaitGroup
stopCh := make(chan struct{})

// Worker loop
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        for {
            select {
            case <-stopCh:
                log.Printf("Worker %d dừng", id)
                return
            case job := <-jobCh:
                processJob(job)
            }
        }
    }(i)
}

// Chờ signal
<-sigCh
close(stopCh)       // Báo tất cả worker dừng
wg.Wait()            // Chờ workers finish job hiện tại
log.Println("All workers stopped cleanly")

Pattern kết hợp — Server + Workers

Production thường có cả HTTP server lẫn background workers. Pattern mình hay dùng:

func main() {
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    // Khởi tạo dependencies
    db, _ := sql.Open("postgres", dsn)
    defer db.Close()

    srv := startHTTPServer()
    workers := startWorkers(ctx, db)

    <-ctx.Done()  // Chờ signal

    log.Println("Shutting down...")

    // 1. Stop nhận job mới
    close(workers.inputCh)

    // 2. Shutdown HTTP (drain connections)
    shutCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    srv.Shutdown(shutCtx)
    cancel()

    // 3. Chờ workers finish
    workers.wg.Wait()

    log.Println("Shutdown complete")
}

Bài học production từ deploy thực tế

  1. Luôn test graceful shutdown trong CI. Viết integration test: start server, gửi request dài, gửi SIGTERM, kiểm tra response vẫn complete.

  2. Health check + readiness probe. Load balancer cần biết server đang draining. Khi nhận SIGTERM, trả 503 trên /ready endpoint — load balancer sẽ ngừng route traffic tới bạn.

  3. Database connection pool. Set connMaxLifetime hợp lý, shutdown trước khi sql.DB.Close(). Drain connection pool trước.

  4. Kubernetes terminationGracePeriodSeconds. Mặc định 30s. Nếu bạn nghĩ worker cần 60s, set trong deployment YAML:

spec:
  terminationGracePeriodSeconds: 60

Kết luận

Graceful shutdown không phải tính năng "có cũng được". Trong production, nó quyết định bạn có mất request, corrupt dữ liệu, hay không. Pattern cốt lõi: signal.NotifyContext + http.Server.Shutdown + sync.WaitGroup. Chỉ 3 thứ đó đã cover 90% use case.

Bắt đầu implement hôm nay — đừng để deploy đầu tiên mới nhận ra server mình tắt kiểu "cúp cầu dao".