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ự:
- Đóng mọi listener, nên server không nhận kết nối mới.
- Đóng các kết nối đang idle.
- Chờ các kết nối đang active trở về idle rồi đóng chúng. Nếu
ctxhế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.NotifyContexttrả về một context. Context này bị hủy khi process nhậnSIGINT(Ctrl+C) hoặcSIGTERM(lệnh mà Docker và Kubernetes gửi).ListenAndServechạy trong goroutine riêng. Sau khi bạn gọiShutdown, nó trả về ngayhttp.ErrServerClosed. Đây là lỗi bình thường, nên ta lọc nó ra.- Channel
serverErrcó buffer 1. Nó giúp bắt lỗi khởi động, ví dụ port đã bị chiếm. Nhờ vậymainkhô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.shutdownCtxdùngcontext.Background()làm gốc chứ không dùngctx. Lý do làctxđã bị hủy, nếu dùng nó thìShutdownsẽ 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
ListenAndServetrongmainvà gọiShutdowntrong goroutine khác. VìListenAndServetrả về ngay khiShutdownbắt đầu,mainsẽ kết thúc trong khiShutdownvẫn đang chờ request. Process thoát và request vẫn bị cắt. Quy tắc làmainphải chờShutdowntrả 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
BaseContexttheo 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ếpSIGTERMcho process Go. Hãy dùng dạng execCMD ["/app/server"]hoặc gọiexec /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.Listentrên port0để hệ điều hành chọn port trống. Nhờ vậy test chạy song song không bị xung đột.- Channel
startedbảo đảmShutdownchỉ được gọi khi request đã thực sự nằm trong handler. - Test kiểm tra ba điều:
Shutdowntrả 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ẳngctxcủasignal.NotifyContextkhiếnShutdowntrả vềcontext canceledngay lập tức. - Coi
http.ErrServerClosedlà lỗi. Gọilog.Fatalvớ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ửiSIGKILL. - 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.Exithoặclog.Fatalsau khi đã bắt đầu shutdown. Cả hai bỏ qua mọidefer, 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.