Setiap deploy, pembaruan konfigurasi, dan penskalaan otomatis mengirimkan sinyal yang sama ke aplikasi kita: berhentilah — sekarang. Pengirimnya bukan terminal yang kita tutup, melainkan systemd lewat systemctl restart, Docker lewat docker stop, dan Kubernetes lewat penghapusan pod. Proses yang tidak siap menerima sinyal tersebut mati seketika dan memutus semua koneksi yang sedang berjalan: checkout pembeli gagal di tengah jalan, commit database terputus, dan upload besar hilang. Chapter ini menyusun urutan penghentian yang benar untuk production — turunkan health check lebih dulu, tolak trafik baru, selesaikan yang aktif, tutup koneksi database — lalu membuktikan urutannya lewat server httptest yang benar-benar menjalankan http.Server.

Sinyal SIGTERM dan Tenggat Docker #

Alat operasional menghentikan proses dengan sinyal SIGTERM — permintaan berhenti yang sopan yang bisa kita tangkap. docker stop memberi tenggat 10 detik: bila proses belum keluar setelah tenggat, Docker mengirim SIGKILL yang tidak bisa ditolak. Tenggat ini diatur lewat stop_grace_period pada compose.yaml:

yaml
services:
  sapa:
    stop_grace_period: 30s

Urutan Drain yang Benar #

Menghentikan aplikasi yang melayani trafik adalah urutan empat langkah, bukan satu panggilan:

  1. Turunkan readiness: endpoint health mulai mengembalikan status gagal sehingga load balancer dan orchestrator berhenti mengirim trafik baru ke instance ini.
  2. Tunggu penarikan: beri jeda beberapa detik agar load balancer mencatat readiness turun dan menyelesaikan pengalihan trafiknya.
  3. Tutup pintu, kuras yang ada: http.Server.Shutdown menutup listener (koneksi baru ditolak) lalu menunggu request aktif selesai sampai tenggat.
  4. Tutup dependensi: pool koneksi database, publisher message queue, dan berkas log ditutup setelah tidak ada pekerjaan yang memakainya.

Shutdown menangani langkah ketiga; langkah lainnya adalah tanggung jawab kode kita. Pola dasar signal.NotifyContext + Shutdown sudah dibedah tuntas di chapter Graceful Shutdown pada section HTTP; chapter ini fokus pada lapisan production di sekelilingnya.

Demo: Drain dari Awal sampai Ditolak #

Program shutdown-demo.go pada bagian bawah halaman memainkan seluruh urutan pada satu server httptest — readiness naik, readiness turun, request aktif sedang diproses, Shutdown dipanggil, lalu request baru dicoba:

go
package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync/atomic"
	"time"
)

// ringkas mengambil satu URL dan meringkas status serta isi responsnya.
func ringkas(c *http.Client, url string) string {
	resp, err := c.Get(url)
	if err != nil {
		return "ditolak"
	}
	defer resp.Body.Close()
	isi, _ := io.ReadAll(resp.Body)
	return fmt.Sprintf("%s (%s)", resp.Status, strings.TrimSpace(string(isi)))
}

func main() {
	// ready menyatakan apakah instance siap menerima trafik baru.
	var ready atomic.Bool
	ready.Store(true)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		if !ready.Load() {
			w.WriteHeader(http.StatusServiceUnavailable)
			fmt.Fprintln(w, "siap: tidak")
			return
		}
		fmt.Fprintln(w, "siap: ya")
	})
	mux.HandleFunc("GET /proses", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(100 * time.Millisecond) // simulasi pekerjaan yang sedang berjalan
		fmt.Fprintln(w, "pekerjaan selesai")
	})

	ts := httptest.NewServer(mux)

	// Fase 1: readiness masih naik, load balancer boleh mengirim trafik.
	fmt.Println("health check awal      :", ringkas(ts.Client(), ts.URL+"/healthz"))

	// Fase 2: turunkan readiness lebih dulu sebelum shutdown.
	ready.Store(false)
	fmt.Println("health setelah turun   :", ringkas(ts.Client(), ts.URL+"/healthz"))

	// Fase 3: satu request sedang diproses saat sinyal berhenti datang.
	hasil := make(chan string, 1)
	go func() {
		hasil <- ringkas(ts.Client(), ts.URL+"/proses")
	}()
	time.Sleep(20 * time.Millisecond) // pastikan request sudah masuk handler

	// Fase 4: drain — tolak trafik baru, tunggu trafik aktif tuntas.
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	err := ts.Config.Shutdown(ctx)
	fmt.Println("Shutdown error         :", err)

	// Fase 5: request aktif tuntas; request baru ditolak di pintu masuk.
	fmt.Println("request aktif selesai  :", <-hasil)
	_, errBaru := ts.Client().Get(ts.URL + "/healthz")
	fmt.Println("request baru ditolak   :", errBaru != nil)

	ts.Close()
}

Output yang Dihasilkan:

text
health check awal      : 200 OK (siap: ya)
health setelah turun   : 503 Service Unavailable (siap: tidak)
Shutdown error         : <nil>
request aktif selesai  : 200 OK (pekerjaan selesai)
request baru ditolak   : true

Lima baris output tersebut adalah urutan drain yang berhasil. Mari kita telaah bagian pentingnya:

  1. Baris 24–32 (atomic.Bool ready): status readiness disimpan sebagai flag atomik yang dibaca setiap request health — mekanisme yang sama dengan yang dipantau load balancer produksi.
  2. Baris 38–46 (handler /healthz): bila ready turun, handler mengembalikan status 503; handler /proses (baris 47–50) menyimulasikan pekerjaan 100 milidetik yang sedang berjalan saat instance diminta berhenti.
  3. Baris 60–63 (goroutine request aktif): request /proses dikirim sebelum Shutdown sehingga benar-benar sedang diproses — hasilnya dikumpulkan lewat channel agar urutan pencetakan tetap rapi.
  4. Baris 66–69 (ts.Config.Shutdown(ctx)): server httptest menjalankan http.Server sungguhan, jadi pemanggilan ini identik dengan production. Error nil berarti seluruh trafik aktif selesai sebelum tenggat 2 detik.
  5. Baris 71–73 (verifikasi penolakan): request setelah Shutdown gagal di tingkat koneksi (listener sudah tertutup) — ditolak di pintu masuk, bukan dibatalkan di tengah jalan.

Pola Lengkap di Aplikasi Nyata #

Berikut kerangka main yang menggabungkan seluruh langkah — fragmen, bukan program lengkap (butuh database/sql dan driver pgx dari section Database dan SQL ):

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

	var siap atomic.Bool
	siap.Store(true)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		if !siap.Load() {
			w.WriteHeader(http.StatusServiceUnavailable)
			return
		}
	})

	db, err := sql.Open("pgx", os.Getenv("APP_DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}

	srv := &http.Server{Addr: ":8080", Handler: mux}
	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	}()

	<-ctx.Done() // SIGTERM dari systemctl restart atau docker stop

	siap.Store(false)           // 1. readiness turun
	time.Sleep(3 * time.Second) // 2. tunggu load balancer menarik instance

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil { // 3. drain koneksi aktif
		log.Printf("drain tidak tuntas: %v", err)
	}
	db.Close() // 4. tutup pool koneksi database
}

Fragmen tersebut menambahkan tiga hal di atas pola dasar: flag siap yang menurunkan readiness sebelum drain, jeda penarikan untuk load balancer, dan db.Close() setelah seluruh request selesai. Pekerjaan latar (worker, ticker) ikut menutup lewat ctx yang sama — goroutine yang memantau ctx.Done() akan berhenti dengan tertib, pola yang dibahas di chapter Context .

Aplikasi kini berhenti dengan tertib setiap kali diganti. Pertanyaan berikutnya: bagaimana versi baru tersebut dirilis, diberi nomor, dan diverifikasi sebelum sampai ke server — chapter Release dan Versioning menyusun alurnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

shutdown-demo.go

Unduh berkas
go
package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync/atomic"
	"time"
)

// ringkas mengambil satu URL dan meringkas status serta isi responsnya.
func ringkas(c *http.Client, url string) string {
	resp, err := c.Get(url)
	if err != nil {
		return "ditolak"
	}
	defer resp.Body.Close()
	isi, _ := io.ReadAll(resp.Body)
	return fmt.Sprintf("%s (%s)", resp.Status, strings.TrimSpace(string(isi)))
}

func main() {
	// ready menyatakan apakah instance siap menerima trafik baru.
	var ready atomic.Bool
	ready.Store(true)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		if !ready.Load() {
			w.WriteHeader(http.StatusServiceUnavailable)
			fmt.Fprintln(w, "siap: tidak")
			return
		}
		fmt.Fprintln(w, "siap: ya")
	})
	mux.HandleFunc("GET /proses", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(100 * time.Millisecond) // simulasi pekerjaan yang sedang berjalan
		fmt.Fprintln(w, "pekerjaan selesai")
	})

	ts := httptest.NewServer(mux)

	// Fase 1: readiness masih naik, load balancer boleh mengirim trafik.
	fmt.Println("health check awal      :", ringkas(ts.Client(), ts.URL+"/healthz"))

	// Fase 2: turunkan readiness lebih dulu sebelum shutdown.
	ready.Store(false)
	fmt.Println("health setelah turun   :", ringkas(ts.Client(), ts.URL+"/healthz"))

	// Fase 3: satu request sedang diproses saat sinyal berhenti datang.
	hasil := make(chan string, 1)
	go func() {
		hasil <- ringkas(ts.Client(), ts.URL+"/proses")
	}()
	time.Sleep(20 * time.Millisecond) // pastikan request sudah masuk handler

	// Fase 4: drain — tolak trafik baru, tunggu trafik aktif tuntas.
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	err := ts.Config.Shutdown(ctx)
	fmt.Println("Shutdown error         :", err)

	// Fase 5: request aktif tuntas; request baru ditolak di pintu masuk.
	fmt.Println("request aktif selesai  :", <-hasil)
	_, errBaru := ts.Client().Get(ts.URL + "/healthz")
	fmt.Println("request baru ditolak   :", errBaru != nil)

	ts.Close()
}