Graceful shutdown cho HTTP server Go

1. Tổng quan

Khi deploy phiên bản mới hoặc scale down, process của bạn sẽ nhận SIGTERM. Nếu server thoát ngay, các request đang xử lý bị cắt giữa chừng. Client nhận lỗi connection reset, và đơn hàng có thể chỉ được ghi một nửa.

Bài này hướng dẫn bạn tắt HTTP server Go đúng cách. Server sẽ ngừng nhận kết nối mới, chờ request đang chạy hoàn tất trong một khoảng thời gian giới hạn, rồi đóng tài nguyên theo đúng thứ tự. Mọi ví dụ chỉ dùng thư viện chuẩn và yêu cầu Go 1.22 trở lên.

2. Vấn đề khi tắt server đột ngột

Hãy bắt đầu với cách viết quen thuộc nhất:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func main() {
	http.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second) // giả lập truy vấn DB chậm
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Chạy server rồi gọi curl localhost:8080/orders/42. Trong lúc request đang chờ, bạn nhấn Ctrl+C ở terminal của server. Kết quả phía client:

curl: (52) Empty reply from server

Go không tự cài handler cho SIGINT hay SIGTERM. Khi nhận tín hiệu, runtime kết thúc process ngay lập tức. Không có defer nào được chạy và không request nào được chờ.

3. Graceful shutdown với http.Server.Shutdown

http.Server có sẵn method Shutdown(ctx). Method này làm ba việc theo thứ tự:

  1. Đóng mọi listener, nên server không nhận kết nối mới.
  2. Đóng các kết nối đang idle.
  3. Chờ các kết nối đang active trở về idle rồi đóng chúng. Nếu ctx hết hạn trước, method trả về lỗi của context.

Để dùng được method này, bạn cần tự bắt tín hiệu hệ điều hành.

3.1. Bắt tín hiệu bằng signal.NotifyContext

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second)
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	serverErr := make(chan error, 1)
	go func() {
		log.Println("listening on", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			serverErr <- err
		}
	}()

	select {
	case err := <-serverErr:
		log.Fatalf("server error: %v", err)
	case <-ctx.Done():
		log.Println("shutdown signal received")
	}
	stop() // nhận Ctrl+C lần hai sẽ kill process ngay

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("graceful shutdown failed: %v, forcing close", err)
		_ = srv.Close()
	}
	log.Println("server stopped")
}

Các điểm chính trong đoạn code:

  • signal.NotifyContext trả về một context. Context này bị hủy khi process nhận SIGINT (Ctrl+C) hoặc SIGTERM (lệnh mà Docker và Kubernetes gửi).
  • ListenAndServe chạy trong goroutine riêng. Sau khi bạn gọi Shutdown, nó trả về ngay http.ErrServerClosed. Đây là lỗi bình thường, nên ta lọc nó ra.
  • Channel serverErr có buffer 1. Nó giúp bắt lỗi khởi động, ví dụ port đã bị chiếm. Nhờ vậy main không bị treo chờ một tín hiệu không bao giờ đến.
  • stop() được gọi ngay sau khi nhận tín hiệu. Hàm này khôi phục hành vi mặc định, nên nếu bạn nhấn Ctrl+C lần nữa, process sẽ thoát ngay. Cách này rất tiện khi shutdown bị treo.
  • shutdownCtx dùng context.Background() làm gốc chứ không dùng ctx. Lý do là ctx đã bị hủy, nếu dùng nó thì Shutdown sẽ trả về lỗi ngay lập tức.
  • Nếu hết 10 giây mà vẫn còn request, srv.Close() đóng cưỡng bức mọi kết nối.

Lặp lại thử nghiệm ở mục 2: gọi curl, rồi nhấn Ctrl+C trong lúc request đang chờ. Log của server:

2026/10/24 10:00:00 listening on :8080
^C2026/10/24 10:00:01 shutdown signal received
2026/10/24 10:00:03 server stopped

Phía client nhận đầy đủ response:

order 42

Server chờ khoảng 2 giây còn lại của request rồi mới thoát.

Lưu ý: Lỗi rất hay gặp là đảo vai trò hai goroutine. Một số người gọi ListenAndServe trong main và gọi Shutdown trong goroutine khác. Vì ListenAndServe trả về ngay khi Shutdown bắt đầu, main sẽ kết thúc trong khi Shutdown vẫn đang chờ request. Process thoát và request vẫn bị cắt. Quy tắc là main phải chờ Shutdown trả về.

4. Những gì Shutdown không tự xử lý

4.1. Kết nối bị hijack: WebSocket, HTTP/1.1 upgrade

Shutdown không theo dõi các kết nối đã bị hijack, ví dụ WebSocket. Server cũng không chờ chúng. Muốn đóng các kết nối này, bạn đăng ký callback bằng RegisterOnShutdown:

type Hub struct {
	mu    sync.Mutex
	conns map[*websocketConn]struct{}
}

func (h *Hub) CloseAll() {
	h.mu.Lock()
	defer h.mu.Unlock()
	for c := range h.conns {
		c.SendCloseFrame("server shutting down")
		c.Close()
	}
}

// trong main:
hub := &Hub{conns: make(map[*websocketConn]struct{})}
srv.RegisterOnShutdown(hub.CloseAll)

websocketConn ở đây đại diện cho kiểu kết nối của thư viện WebSocket mà bạn dùng. Callback được chạy trong một goroutine riêng ngay khi Shutdown bắt đầu. Shutdown không chờ callback này kết thúc, nên nếu bạn cần chờ, hãy tự đồng bộ bằng sync.WaitGroup.

4.2. Handler chạy lâu: streaming, long polling

Một handler stream dữ liệu vô hạn, ví dụ Server-Sent Events, sẽ không bao giờ trở về idle. Khi đó Shutdown luôn chờ đến hết timeout. Bạn có thể dùng BaseContext để mọi request context bị hủy khi server bắt đầu tắt:

appCtx, cancelApp := context.WithCancel(context.Background())

srv := &http.Server{
	Addr:    ":8080",
	Handler: mux,
	BaseContext: func(net.Listener) context.Context {
		return appCtx
	},
}
srv.RegisterOnShutdown(cancelApp)

mux.HandleFunc("GET /orders/stream", func(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/event-stream")
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-r.Context().Done():
			return // server đang tắt hoặc client ngắt kết nối
		case t := <-ticker.C:
			fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
			w.(http.Flusher).Flush()
		}
	}
})

Mỗi request context đều được tạo từ appCtx. Khi Shutdown chạy, cancelApp được gọi và mọi r.Context() bị hủy. Handler stream thấy Done() và trả về. Sau đó Shutdown hoàn tất sớm.

Lưu ý: Không nên áp dụng cách này cho mọi handler. Nếu bạn hủy context của một request đang ghi đơn hàng vào DB, chính lệnh ghi đó sẽ bị hủy. Mục tiêu “chờ request xong” không còn đạt được. Chỉ dùng BaseContext theo cách này khi tất cả handler đều có thể dừng giữa chừng một cách an toàn.

4.3. Goroutine nền và tài nguyên phụ

Shutdown chỉ quản lý kết nối HTTP. Worker gửi email, consumer Kafka và connection pool DB là trách nhiệm của bạn. Thứ tự đóng nên ngược với thứ tự khởi tạo:

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

cancelWorkers() // báo cho goroutine nền dừng lại
workersDone := make(chan struct{})
go func() { wg.Wait(); close(workersDone) }()

select {
case <-workersDone:
case <-shutdownCtx.Done():
	log.Println("workers did not stop in time")
}

if err := db.Close(); err != nil {
	log.Printf("db close: %v", err)
}

HTTP server được tắt trước vì handler còn cần DB. DB được đóng cuối cùng. Toàn bộ quá trình dùng chung shutdownCtx, nên tổng thời gian không vượt quá ngân sách 10 giây.

5. Chạy trên Kubernetes

Khi một pod bị xóa, Kubernetes làm hai việc song song: gửi SIGTERM cho container và gỡ pod khỏi danh sách endpoint của Service. Việc gỡ endpoint cần vài giây để lan tới kube-proxy và ingress. Nếu server đóng listener ngay khi nhận SIGTERM, các request vẫn đang được route tới pod sẽ bị từ chối.

Cách khắc phục là cho readiness probe báo lỗi trước, chờ một chút, rồi mới gọi Shutdown:

var shuttingDown atomic.Bool

mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
	if shuttingDown.Load() {
		http.Error(w, "shutting down", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
})

// sau khi nhận tín hiệu:
<-ctx.Done()
shuttingDown.Store(true)
time.Sleep(5 * time.Second) // chờ load balancer gỡ pod
srv.Shutdown(shutdownCtx)

Bạn cũng cần cấu hình terminationGracePeriodSeconds lớn hơn tổng thời gian chờ cộng timeout shutdown. Với ví dụ này là 5 + 10 = 15 giây:

spec:
  terminationGracePeriodSeconds: 30
  containers:
    - name: order-service
      image: order-service:1.4.0
      readinessProbe:
        httpGet:
          path: /readyz
          port: 8080
        periodSeconds: 2

Hết thời gian grace, Kubernetes gửi SIGKILL. Tín hiệu này không bắt được, và mọi logic shutdown của bạn sẽ bị bỏ qua.

Lưu ý: Nếu bạn chạy binary qua shell script, ví dụ CMD ./start.sh, thì shell là PID 1 và có thể không chuyển tiếp SIGTERM cho process Go. Hãy dùng dạng exec CMD ["/app/server"] hoặc gọi exec /app/server ở cuối script.

6. Kiểm thử hành vi shutdown

Bạn có thể viết test xác nhận rằng Shutdown chờ request đang chạy hoàn tất. Test này chỉ cần package testing:

package main

import (
	"context"
	"io"
	"net"
	"net/http"
	"testing"
	"time"
)

func TestShutdownWaitsForInFlightRequest(t *testing.T) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}

	started := make(chan struct{})
	srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		close(started)
		time.Sleep(500 * time.Millisecond)
		io.WriteString(w, "order 42")
	})}
	go srv.Serve(ln)

	type result struct {
		body string
		err  error
	}
	resCh := make(chan result, 1)
	go func() {
		resp, err := http.Get("http://" + ln.Addr().String() + "/orders/42")
		if err != nil {
			resCh <- result{err: err}
			return
		}
		defer resp.Body.Close()
		b, err := io.ReadAll(resp.Body)
		resCh <- result{body: string(b), err: err}
	}()

	<-started // đảm bảo request đã vào handler
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		t.Fatalf("Shutdown returned error: %v", err)
	}

	res := <-resCh
	if res.err != nil {
		t.Fatalf("request failed: %v", res.err)
	}
	if res.body != "order 42" {
		t.Fatalf("got body %q, want %q", res.body, "order 42")
	}

	if _, err := http.Get("http://" + ln.Addr().String()); err == nil {
		t.Fatal("expected new connections to be refused after shutdown")
	}
}

Các điểm chính trong test:

  • net.Listen trên port 0 để hệ điều hành chọn port trống. Nhờ vậy test chạy song song không bị xung đột.
  • Channel started bảo đảm Shutdown chỉ được gọi khi request đã thực sự nằm trong handler.
  • Test kiểm tra ba điều: Shutdown trả về nil, client nhận đủ body, và kết nối mới bị từ chối.
go test -v -run TestShutdown
=== RUN   TestShutdownWaitsForInFlightRequest
--- PASS: TestShutdownWaitsForInFlightRequest (0.50s)
PASS

Thời gian chạy khoảng 0,5 giây, đúng bằng thời gian handler ngủ. Điều này cho thấy Shutdown đã chờ request.

7. Lỗi hay gặp

  • Dùng context đã bị hủy cho Shutdown. Truyền thẳng ctx của signal.NotifyContext khiến Shutdown trả về context canceled ngay lập tức.
  • Coi http.ErrServerClosed là lỗi. Gọi log.Fatal với lỗi này sẽ thoát process trước khi shutdown xong.
  • Không đặt timeout. Dùng context.Background() không có deadline thì một handler bị treo sẽ giữ process mãi, cho tới khi Kubernetes gửi SIGKILL.
  • Quên ReadHeaderTimeout. Client chậm có thể giữ kết nối ở trạng thái active, và shutdown luôn chạy đến hết timeout.
  • Dùng os.Exit hoặc log.Fatal sau khi đã bắt đầu shutdown. Cả hai bỏ qua mọi defer, nên DB và file không được đóng.

8. Kết luận

Graceful shutdown trong Go cần ba thành phần: signal.NotifyContext để bắt SIGTERM, srv.Shutdown với context có timeout, và main phải chờ Shutdown trả về. Từ nền tảng đó, bạn bổ sung các phần mà Shutdown không tự xử lý. Đó là RegisterOnShutdown cho WebSocket, BaseContext cho handler streaming, và việc đóng worker cùng DB theo thứ tự ngược với khởi tạo. Khi chạy trên Kubernetes, hãy cho readiness báo lỗi và chờ vài giây trước khi đóng listener, đồng thời đặt terminationGracePeriodSeconds đủ lớn. Cuối cùng, hãy giữ một test như ở mục 6 để phát hiện sớm khi có ai đó vô tình làm hỏng luồng shutdown.

golanghttpgraceful-shutdownkubernetesconcurrency