Server produksi hampir tidak pernah dimatikan dengan menutup terminal: systemd, Docker, dan orchestrator Kubernetes mengirim sinyal SIGTERM lalu menunggu proses keluar sendiri. Proses yang mati mendadak memutus permintaan yang sedang diproses — pembeli yang sedang checkout menerima error, dan setengah pekerjaan hilang. Go menyediakan http.Server.Shutdown untuk menghentikan server dengan tertib: permintaan baru ditolak, permintaan aktif diberi waktu menyelesaikan diri, baru proses keluar. Chapter ini membuktikan perilaku tersebut memakai server httptest yang benar-benar menjalankan http.Server.
Masalah: Mati Mendadak #
Saat proses dihentikan paksa, semua koneksi TCP terputus seketika. Klien yang sedang menunggu respons menerima koneksi reset, dan handler yang sedang menulis ke database berhenti di tengah jalan tanpa kesempatan menyelesaikan commit. Pola deploy rolling membuat situasi ini rutin terjadi: tiap pembaruan versi, instance lama harus mati sementara instance baru mulai menerima trafik.
Solusinya adalah membedakan dua fase pemberhentian. Fase pertama menutup pintu: listener berhenti menerima koneksi baru. Fase kedua menguras (drain): koneksi aktif diberi waktu sampai permintaannya tuntas. http.Server.Shutdown(ctx) menjalankan kedua fase itu dalam satu panggilan.
Pola Standar: Serve di Goroutine, Shutdown di Main #
Fungsi ListenAndServe memblokir sampai server berhenti, jadi penukaran sinyal dengan shutdown membutuhkan dua goroutine: main menunggu sinyal, sementara server berjalan di goroutine terpisah. Pola lengkapnya seperti berikut (fragmen tidak dijalankan karena memakai port sungguhan):
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
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() // menunggu SIGINT atau SIGTERM
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("drain gagal: %v", err)
}Pola ini memuat empat keputusan penting. Pertama, signal.NotifyContext mengubah SIGINT dan SIGTERM menjadi penutupan channel ctx.Done(), sehingga menunggu sinyal cukup dengan <-ctx.Done(). Kedua, ListenAndServe dijalankan di goroutine sendiri agar main bebas menunggu sinyal. Ketiga, error http.ErrServerClosed dibiarkan lewat karena justru itu tanda bahwa Shutdown berhasil meminta server berhenti. Keempat, drain diberi tenggat context.WithTimeout — server yang macet total tetap dihentikan setelah batas waktu terlampaui.
Membedah Shutdown #
Secara internal, Shutdown melakukan tiga hal berurutan: menutup semua listener sehingga permintaan baru ditolak di pintu masuk, menutup koneksi idle yang menganggur, lalu menunggu setiap koneksi aktif menjadi idle atau tertutup. Parameter context menjadi tenggat drain: kalau semua koneksi selesai sebelum tenggat, Shutdown mengembalikan nil — kondisi drained. Kalau tenggat terlampaui lebih dulu, Shutdown mengembalikan error dari context dan koneksi aktif dibiarkan tetap berjalan — kondisi dibatalkan, yang biasanya dilanjutkan dengan penutupan paksa.
Server httptest menyimpan http.Server sungguhan pada field Config, sehingga ts.Config.Shutdown(ctx) menguji mekanisme yang persis sama dengan server produksi — tanpa perlu port sungguhan dan tetap deterministik.
Program Demonstrasi #
Berkas contoh.go membagi dua skenario. Bagian pertama memanggil Shutdown tanpa tenggat sambil ada permintaan aktif; bagian kedua memberi tenggat 10 milidetik untuk kerja 100 milidetik sehingga drain gagal:
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"time"
)
// buatHandler menghasilkan handler yang menghitung permintaan aktif
// dan memakan 100 milidetik untuk mensimulasikan kerja yang sedang berjalan.
func buatHandler(penghitung *atomic.Int64) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
penghitung.Add(1)
defer penghitung.Add(-1)
time.Sleep(100 * time.Millisecond)
fmt.Fprintln(w, "selesai dilayani")
})
}
// tungguMasuk menunggu sampai handler pertama benar-benar mulai bekerja.
func tungguMasuk(penghitung *atomic.Int64) {
for penghitung.Load() == 0 {
time.Sleep(2 * time.Millisecond)
}
}
func main() {
// ===== Bagian 1: Shutdown menunggu permintaan aktif selesai =====
var aktif atomic.Int64
ts := httptest.NewServer(buatHandler(&aktif))
status := make(chan int, 1)
go func() {
resp, err := ts.Client().Get(ts.URL)
if err != nil {
status <- 0
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
status <- resp.StatusCode
}()
tungguMasuk(&aktif)
fmt.Println("permintaan aktif saat Shutdown dipanggil:", aktif.Load())
err := ts.Config.Shutdown(context.Background())
fmt.Println("Shutdown selesai tanpa error:", err == nil)
kode := <-status
fmt.Println("permintaan aktif tetap dilayani sampai tuntas, status:", kode)
fmt.Println("permintaan aktif setelah Shutdown:", aktif.Load())
_, err = ts.Client().Get(ts.URL)
fmt.Println("permintaan baru setelah Shutdown ditolak:", err != nil)
ts.Close()
// ===== Bagian 2: tenggat drain terlalu pendek membatalkan drain =====
var aktif2 atomic.Int64
ts2 := httptest.NewServer(buatHandler(&aktif2))
go func() {
resp, err := ts2.Client().Get(ts2.URL)
if err != nil {
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
tungguMasuk(&aktif2)
tenggat, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
err2 := ts2.Config.Shutdown(tenggat)
fmt.Println("Shutdown dengan tenggat 10 ms gagal:", err2 != nil)
fmt.Println("error shutdown:", err2)
fmt.Println("error bertipe deadline:", errors.Is(err2, context.DeadlineExceeded))
time.Sleep(150 * time.Millisecond) // beri waktu permintaan selesai sendiri
ts2.Close()
fmt.Println("server ditutup paksa dengan Close")
}Output yang Dihasilkan:
permintaan aktif saat Shutdown dipanggil: 1
Shutdown selesai tanpa error: true
permintaan aktif tetap dilayani sampai tuntas, status: 200
permintaan aktif setelah Shutdown: 0
permintaan baru setelah Shutdown ditolak: true
Shutdown dengan tenggat 10 ms gagal: true
error shutdown: context deadline exceeded
error bertipe deadline: true
server ditutup paksa dengan CloseMari kita telaah bagian-bagian penting program di atas:
- Baris 16-23:
buatHandlermenaikkanpenghitungsaat handler mulai dan menurunkannya lewatdefersaat handler selesai;time.Sleep(100 * time.Millisecond)mensimulasikan kerja sedang berjalan. - Baris 26-30:
tungguMasukmenunggu sampai handler pertama benar-benar aktif, sehinggaShutdownpasti dipanggil saat masih ada permintaan berjalan. - Baris 37-47: goroutine klien mengirim satu permintaan lalu melaporkan statusnya lewat channel
status. - Baris 49-53:
mainmemanggilts.Config.Shutdown(context.Background())— drain tanpa tenggat, jadiShutdownmenunggu sampai permintaan aktif tuntas. - Baris 55-57: status
200dan penghitung0membuktikan dua hal sekaligus: permintaan dilayani sampai habis, danShutdownbaru kembali setelahnya. - Baris 59-61: permintaan baru setelah
Shutdownditolak — listener sudah tertutup. - Baris 78-83: drain diberi tenggat 10 milidetik padahal handler butuh 100 milidetik;
Shutdownkembali dengancontext deadline exceededdan permintaan aktif dibiarkan berjalan. - Baris 85-87: setelah memberi waktu permintaan selesai sendiri,
ts2.Close()menutup paksa sisa server — pola cadangan ketika drain dibatalkan.
Dengan pola ini, instance lama keluar dengan meninggalkan nol permintaan terputus. Keamanan lapisan berikutnya menyangkut data yang berpindah: trafik publik sebaiknya dienkripsi dengan TLS, topik chapter berikutnya.
Referensi #
- Dokumentasi http.Server.Shutdown — kontrak resmi drain dan nilai kembalian.
- Dokumentasi package os/signal
—
signal.NotifyContextdan daftar sinyal. - Dokumentasi net/http/httptest
— field
Configtempathttp.Serversungguhan diuji. - Dokumentasi package context — tenggat waktu untuk drain.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
contoh.go
Unduh berkaspackage main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"time"
)
// buatHandler menghasilkan handler yang menghitung permintaan aktif
// dan memakan 100 milidetik untuk mensimulasikan kerja yang sedang berjalan.
func buatHandler(penghitung *atomic.Int64) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
penghitung.Add(1)
defer penghitung.Add(-1)
time.Sleep(100 * time.Millisecond)
fmt.Fprintln(w, "selesai dilayani")
})
}
// tungguMasuk menunggu sampai handler pertama benar-benar mulai bekerja.
func tungguMasuk(penghitung *atomic.Int64) {
for penghitung.Load() == 0 {
time.Sleep(2 * time.Millisecond)
}
}
func main() {
// ===== Bagian 1: Shutdown menunggu permintaan aktif selesai =====
var aktif atomic.Int64
ts := httptest.NewServer(buatHandler(&aktif))
status := make(chan int, 1)
go func() {
resp, err := ts.Client().Get(ts.URL)
if err != nil {
status <- 0
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
status <- resp.StatusCode
}()
tungguMasuk(&aktif)
fmt.Println("permintaan aktif saat Shutdown dipanggil:", aktif.Load())
err := ts.Config.Shutdown(context.Background())
fmt.Println("Shutdown selesai tanpa error:", err == nil)
kode := <-status
fmt.Println("permintaan aktif tetap dilayani sampai tuntas, status:", kode)
fmt.Println("permintaan aktif setelah Shutdown:", aktif.Load())
_, err = ts.Client().Get(ts.URL)
fmt.Println("permintaan baru setelah Shutdown ditolak:", err != nil)
ts.Close()
// ===== Bagian 2: tenggat drain terlalu pendek membatalkan drain =====
var aktif2 atomic.Int64
ts2 := httptest.NewServer(buatHandler(&aktif2))
go func() {
resp, err := ts2.Client().Get(ts2.URL)
if err != nil {
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
tungguMasuk(&aktif2)
tenggat, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
err2 := ts2.Config.Shutdown(tenggat)
fmt.Println("Shutdown dengan tenggat 10 ms gagal:", err2 != nil)
fmt.Println("error shutdown:", err2)
fmt.Println("error bertipe deadline:", errors.Is(err2, context.DeadlineExceeded))
time.Sleep(150 * time.Millisecond) // beri waktu permintaan selesai sendiri
ts2.Close()
fmt.Println("server ditutup paksa dengan Close")
}