Hình dung tắt một dịch vụ như đóng cửa hàng cuối ngày. Có hai cách. Một là cúp cầu dao — đèn tắt phụt, ai đang thanh toán dở cũng bị đuổi ra giữa chừng. Hai là khóa cửa trước với khách mới, để những người đang trong quầy tính tiền xong đã, rồi mới tắt đèn. Khi container bị dừng, ứng dụng của bạn có vài giây để chọn cách nào. Bài này về cách dùng chúng.
Khác biệt, đo bằng số
Một handler mất 2 giây, một request đang chạy giữa chừng:
srv.Close() -> request HỎNG sau 300ms: EOF
srv.Shutdown() -> Shutdown trả về sau 2.1s
request XONG sau 2s (mã 200)
Close() đóng mọi kết nối ngay lập tức — cúp cầu dao. Client nhận EOF, với người dùng đó là một trang lỗi.
Shutdown() ngừng nhận kết nối mới, chờ request đang chạy hoàn thành, rồi mới đóng — khóa cửa nhưng để khách trong tính tiền nốt. Client không hề biết có chuyện gì xảy ra.
Với dịch vụ triển khai cuốn chiếu nhiều lần mỗi ngày, đây là khác biệt giữa "không ai thấy gì" và "vài chục người nhận lỗi mỗi lần deploy".
Khuôn đầy đủ
func main() {
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server: %v", err)
}
}()
// chờ tín hiệu
tin := make(chan os.Signal, 1)
signal.Notify(tin, syscall.SIGINT, syscall.SIGTERM)
<-tin
log.Println("nhận tín hiệu, đang dừng...")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("shutdown quá hạn: %v", err)
srv.Close()
}
log.Println("đã dừng gọn")
}
Bốn chi tiết đáng chú ý:
ErrServerClosed không phải lỗi. ListenAndServe luôn trả về lỗi, và khi bạn Shutdown thì nó trả http.ErrServerClosed. Không kiểm cái này là log.Fatal chạy mỗi lần tắt bình thường.
Channel tín hiệu phải có đệm 1. signal.Notify không chặn khi gửi — channel không đệm mà chưa ai đọc thì tín hiệu bị vứt đi. Đây là chỗ tài liệu ghi rõ và người ta vẫn hay quên.
Hạn chờ cho Shutdown. Không có nó, một request treo giữ ứng dụng sống vĩnh viễn.
Close() sau khi quá hạn — thà cắt còn hơn không bao giờ dừng.
Từ Go 1.16 có signal.NotifyContext gọn hơn:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
Thứ tự đóng
Đóng từ ngoài vào trong:
1. ngừng nhận request mới (srv.Shutdown)
2. chờ request đang chạy xong
3. dừng worker nền (cancel context)
4. xả bộ đệm, gửi nốt số liệu
5. đóng kết nối CSDL (db.Close)
6. đóng tệp log
Đảo thứ tự là hỏng: đóng CSDL trước khi request xong thì những request đó thất bại — đúng thứ bạn đang cố tránh. Không ai tắt máy tính tiền trước khi người khách cuối trả tiền; đèn là thứ tắt sau chót.
Với nhiều thành phần, errgroup ở bài 41 gói gọn:
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return chayHTTP(ctx) })
g.Go(func() error { return chayWorker(ctx) })
log.Fatal(g.Wait())
Một cái dừng, ctx bị huỷ, cả hai cùng dừng.
Docker cho bạn mười giây
docker stop <container> # gửi SIGTERM, chờ 10s, rồi SIGKILL
Mười giây là mặc định — sau đó trung tâm thương mại cắt điện bất kể bạn xong hay chưa. SIGKILL không bắt được — mọi defer bị bỏ qua, giống log.Fatal ở bài 25.
Nên hạn chờ Shutdown của bạn phải nhỏ hơn con số đó. Với docker stop -t 30 hoặc terminationGracePeriodSeconds của Kubernetes, chỉnh cho khớp.
Và nhớ bài 55: dùng ENTRYPOINT ["/svc"] dạng exec. Dạng shell làm tiến trình Go thành con của /bin/sh, và nó không nhận SIGTERM.
Trong Kubernetes: chờ trước khi dừng
Có một chi tiết ít người biết. Khi pod bị xoá, hai việc xảy ra song song: kubelet gửi SIGTERM, và endpoint được gỡ khỏi service. Việc thứ hai mất vài trăm mili giây lan ra các node.
Nghĩa là trong khoảng đó, load balancer vẫn gửi request tới pod đang tắt — tấm biển ngoài phố vẫn ghi MỞ CỬA dù bạn vừa khóa cửa, nên khách vẫn bước vào.
Cách chữa là ngủ một chút trước khi bắt đầu shutdown:
<-ctx.Done()
log.Println("nhận tín hiệu, chờ 5s cho endpoint được gỡ")
time.Sleep(5 * time.Second)
srv.Shutdown(ctxShutdown)
Trông phản trực giác nhưng nó loại được phần lớn lỗi 502 lúc triển khai.
Cùng lý do, endpoint /readyz nên trả không sẵn sàng ngay khi nhận tín hiệu, trước khi ngủ — lật biển sang ĐÓNG CỬA trước đã.
Worker nền cũng phải dừng
func chayWorker(ctx context.Context) error {
tk := time.NewTicker(time.Minute)
defer tk.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-tk.C:
xuLy(ctx)
}
}
}
Đúng mẫu ở bài 33 và 39. Nếu xuLy mất vài phút, nó cần nhận ctx và tự kiểm — bài 39 đã nói ctx.Done() chỉ báo, không giết được goroutine.
Một phép kiểm rẻ nhất cho cả phần này: chạy thử rồi bấm giờ lúc dừng.
docker run -d --name t ten-anh
time docker stop t
Nếu real xấp xỉ 10 giây, ứng dụng của bạn không xử lý SIGTERM — Docker phải chờ hết hạn rồi SIGKILL. Nếu nó dừng trong dưới một giây, bạn đang đóng gọn.
Mẫu số chung
Vòng đời "tắt tử tế" này giống nhau ở mọi dịch vụ chạy dài, bất kể ngôn ngữ: bắt tín hiệu kết thúc, ngừng nhận việc mới, rút cạn việc đang chạy, tháo dỡ phụ thuộc theo thứ tự ngược lúc khởi động, tất cả trong một cửa sổ ân hạn có hạn trước khi nền tảng ra tay giết.
- Java/Spring có
@PreDestroy,DisposableBean, vàserver.shutdown=gracefulcủa Spring Boot; nền tảng làRuntime.addShutdownHook. - Node bắt
process.on('SIGTERM'); Python có signal handler, gunicorn có chế độ graceful. - Cái bẫy endpoint-đua-SIGTERM trong Kubernetes, và cách chữa bằng
preStop/ngủ/lật readiness, là bài học chung của mọi hệ chạy trên K8s — không riêng Go.
Nhưng điều sâu nhất đáng mang theo là thế này: tắt tử tế chỉ là nỗ lực tốt nhất, không phải bảo đảm. SIGKILL, hết bộ nhớ, mất điện, đứt mạng — tất cả không bắt được, defer không chạy, cầu dao bị giật mà không báo. Nên một hệ đúng đắn phải đúng cả khi bị dừng đột ngột: ghi dữ liệu mang tính lặp lại được (idempotent), trạng thái khôi phục được khi khởi động lại — đúng tinh thần "phần mềm chỉ-biết-sập" (crash-only software) của Candea và Fox. Graceful shutdown làm giảm số lỗi người dùng thấy lúc deploy; nó không thay thế được tính an toàn khi sập.
Sợi chỉ chung: thiết kế cho cú giết trước — đúng đắn ngay cả khi bị tắt phũ — rồi mới phủ lớp rút cạn tử tế lên trên để giấu các vết nối trong lúc deploy bình thường. Không bao giờ làm ngược lại, vì cái ngày mất điện thì lớp tử tế kia chẳng cứu được gì.
Ngày mai: bảo mật trong ứng dụng Go — và govulncheck chỉ báo lỗ hổng mã bạn thật sự gọi tới.
Bài tập làm thử
Bài 1 (đọc hiểu). Một handler HTTP mất 2 giây để xử lý. Một request đang chạy giữa chừng khi máy chủ nhận tín hiệu dừng. So sánh kết quả giữa việc gọi srv.Close() và srv.Shutdown(ctx) trong tình huống này, dựa trên số liệu đo trong bài.
Đáp án
srv.Close() đóng mọi kết nối ngay lập tức — request đang chạy bị hỏng sau khoảng 300ms với lỗi EOF, client nhận một trang lỗi. srv.Shutdown(ctx) ngừng nhận kết nối mới nhưng chờ request đang chạy hoàn thành — trong ví dụ đo được, Shutdown trả về sau 2,1 giây và request xong sau 2 giây với mã 200, client hoàn toàn không biết có chuyện gì xảy ra.
Bài 2 (sửa lỗi). Đoạn mã sau muốn nhận tín hiệu SIGTERM để tắt gọn, nhưng có một lỗi khiến tín hiệu đôi khi bị mất hoàn toàn. Tìm lỗi và sửa.
tin := make(chan os.Signal)
signal.Notify(tin, syscall.SIGINT, syscall.SIGTERM)
<-tin
Đáp án
tin := make(chan os.Signal, 1) // phải có đệm ít nhất 1
signal.Notify(tin, syscall.SIGINT, syscall.SIGTERM)
<-tin
Lỗi: channel tin không có đệm (make(chan os.Signal) tạo channel unbuffered). signal.Notify không chặn khi gửi tín hiệu vào channel — nếu channel không đệm và chưa có ai sẵn sàng đọc đúng lúc đó, tín hiệu bị vứt đi thay vì chờ. Đây là chi tiết tài liệu ghi rõ nhưng người ta hay quên. Phải khai channel với đệm tối thiểu 1.
Bài 3 (vận dụng thực tế). Viết đoạn mã main hoàn chỉnh dùng signal.NotifyContext (từ Go 1.16) để chờ tín hiệu dừng, sau đó gọi srv.Shutdown với hạn chờ 15 giây, và nếu quá hạn thì gọi srv.Close().
Đáp án
func main() {
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server: %v", err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
log.Println("nhận tín hiệu, đang dừng...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown quá hạn: %v", err)
srv.Close()
}
log.Println("đã dừng gọn")
}
Chú ý: phải kiểm !errors.Is(err, http.ErrServerClosed) vì ListenAndServe luôn trả lỗi, và khi Shutdown được gọi thì lỗi trả về là http.ErrServerClosed — không kiểm điều này thì log.Fatal sẽ chạy nhầm ở mọi lần tắt bình thường.
Bài 4 (bẫy/đánh đổi). Bạn build image Docker cho ứng dụng Go với ENTRYPOINT sh -c "/svc" (dạng shell). Ứng dụng có xử lý SIGTERM đầy đủ theo mẫu đúng, nhưng docker stop vẫn luôn mất đúng 10 giây rồi mới dừng (tức bị SIGKILL). Giải thích nguyên nhân và cách sửa.
Đáp án
Nguyên nhân nằm ở ENTRYPOINT dạng shell (sh -c "/svc"): nó làm tiến trình Go trở thành con của /bin/sh, và tín hiệu SIGTERM mà Docker gửi tới sẽ đến /bin/sh chứ tiến trình Go không nhận được — nên ứng dụng không bao giờ có cơ hội tắt gọn, và Docker phải chờ hết 10 giây mặc định rồi gửi SIGKILL (không bắt được, mọi defer bị bỏ qua). Cách sửa: dùng ENTRYPOINT dạng exec: ENTRYPOINT ["/svc"], để tiến trình Go nhận trực tiếp tín hiệu.
Bài 5 (đọc hiểu — bẫy Kubernetes). Trong Kubernetes, khi một pod bị xoá, kubelet gửi SIGTERM và gỡ endpoint khỏi service song song — việc gỡ endpoint có thể mất vài trăm mili giây để lan ra các node. Điều gì có thể xảy ra nếu ứng dụng bắt đầu Shutdown() ngay lập tức khi nhận SIGTERM, và cách khắc phục theo bài viết là gì?
Đáp án
Vì việc gỡ endpoint chưa lan ra kịp, load balancer vẫn có thể gửi request tới pod đang trong quá trình tắt — giống "tấm biển ngoài phố vẫn ghi MỞ CỬA dù bạn vừa khóa cửa", dẫn tới lỗi 502 cho những request đó. Cách khắc phục: ngủ một khoảng ngắn (ví dụ 5 giây) sau khi nhận tín hiệu, trước khi gọi srv.Shutdown(), để endpoint kịp được gỡ khỏi service; đồng thời endpoint /readyz nên trả về "không sẵn sàng" ngay khi nhận tín hiệu, trước cả bước ngủ, để load balancer sớm ngừng gửi request mới.