Khi container bị dừng, ứng dụng của bạn có vài giây để kết thúc tử tế. 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. 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. 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.

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

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ủ.

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.

Thử ba mươi giây

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. Ba mươi giây đó là phép kiểm rẻ nhất cho phần này.

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.