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:
services:
sapa:
stop_grace_period: 30sUrutan Drain yang Benar #
Menghentikan aplikasi yang melayani trafik adalah urutan empat langkah, bukan satu panggilan:
- Turunkan readiness: endpoint health mulai mengembalikan status gagal sehingga load balancer dan orchestrator berhenti mengirim trafik baru ke instance ini.
- Tunggu penarikan: beri jeda beberapa detik agar load balancer mencatat readiness turun dan menyelesaikan pengalihan trafiknya.
- Tutup pintu, kuras yang ada:
http.Server.Shutdownmenutup listener (koneksi baru ditolak) lalu menunggu request aktif selesai sampai tenggat. - 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:
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:
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 : trueLima baris output tersebut adalah urutan drain yang berhasil. Mari kita telaah bagian pentingnya:
- 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. - Baris 38–46 (handler
/healthz): bilareadyturun, handler mengembalikan status 503; handler/proses(baris 47–50) menyimulasikan pekerjaan 100 milidetik yang sedang berjalan saat instance diminta berhenti. - Baris 60–63 (goroutine request aktif): request
/prosesdikirim sebelumShutdownsehingga benar-benar sedang diproses — hasilnya dikumpulkan lewat channel agar urutan pencetakan tetap rapi. - Baris 66–69 (
ts.Config.Shutdown(ctx)): serverhttptestmenjalankanhttp.Serversungguhan, jadi pemanggilan ini identik dengan production. Errornilberarti seluruh trafik aktif selesai sebelum tenggat 2 detik. - Baris 71–73 (verifikasi penolakan): request setelah
Shutdowngagal 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
):
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 #
- http.Server.Shutdown — dokumentasi resmi perilaku drain server HTTP.
- Dokumentasi package os/signal
— referensi resmi
signal.NotifyContextuntukSIGTERM. - Go Concurrency Patterns: Context — latar pola pembatalan yang dipakai seluruh drain.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
shutdown-demo.go
Unduh berkaspackage 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()
}