GO HTTP SERVER BEST PRACTICES
Cuma akşamı 17:42. Prod ortamında Go ile yazılmış API servisi 200ms latency ile mutlu mutlu çalışıyor. 17:47'de upstream bir servis yavaşladı, bağlantılar birikti. 17:51'de file descriptor limiti doldu, sunucu artık yeni TCP bağlantısı kabul edemiyor. Pod restart edildi, restart sırasında işlenen istekler yarıda kesildi, kuyruktaki mesajlar duplicate process edildi. Tek satırlık `http.ListenAndServe(":8080", mux)` çağrısı — varsayılan ayarlarla — bir gecede üç farklı incident'a sebep oldu. Bu yazı tam olarak bu hikayenin nedenlerini ve `net/http` ile bunu nasıl engelleyeceğini anlatıyor.
Varsayılan http.Server Neden Tehlikeli?
Go'nun standart kütüphanesi muazzam ama `http.ListenAndServe` tek satırı, eğitim videolarında "ne kadar basit, değil mi?" diye gösterildiği gibi production'a açılmaz. Çünkü o tek satırın altında şu varsayılanlar vardır: ReadTimeout sıfır (sonsuz), WriteTimeout sıfır, IdleTimeout sıfır, MaxHeaderBytes 1MB. "Sıfır" Go'da çoğu yerde "varsayılan" demek; burada "limitsiz" demek.
Limitsiz timeout, yavaş ya da kötü niyetli bir istemcinin bir TCP bağlantısını saatlerce açık tutabilmesi anlamına gelir. Slowloris benzeri saldırılar bir yana, sıradan bir mobile client'ın 3G üzerinden yarıda kalmış bir POST'u bile sunucu kaynağını işgal eder. Klasik bir örnekle: 10.000 eşzamanlı yavaş bağlantı = 10.000 goroutine + 10.000 file descriptor.
Üretim Konfigürasyonu: Timeout'lar
Aşağıdaki yapılandırma incident sonrası standart hâle gelmesi gereken minimum şablondur:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 14,
}
- ReadHeaderTimeout: Slowloris'e karşı ilk savunma. Header'ları okumak 5 saniyeden fazla sürmemeli.
- ReadTimeout: Tüm istek gövdesinin okunma süresi. Upload endpoint'lerinde özel handler ile uzatılır, default kısa tutulur.
- WriteTimeout: Response yazma süresi. Stream eden endpoint'ler için bu timeout devre dışı bırakılır (per-handler).
- IdleTimeout: Keep-alive bağlantısının boşta kalma süresi. Çok düşük olursa connection churn artar, çok yüksek olursa fd sızar.
- MaxHeaderBytes: 16KB çoğu API için fazlasıyla yeterli. Cookie bomb saldırılarını bertaraf eder.

Graceful Shutdown: Pod Restart'ı Neden Yarıda Kesmemeli?
Kubernetes pod'unuza SIGTERM gönderir. Varsayılan kapatma davranışı: process anında ölür, açık bağlantılar TCP RST ile koparılır, mesaj kuyruklarındaki teslim sayacı patlamış olur. Bunun yerine sunucu yeni bağlantı kabulünü durdurup mevcut isteklerin bitmesini beklemeli.
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(
context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
}
Önemli bir detay: shutdown timeout, Kubernetes `terminationGracePeriodSeconds` (varsayılan 30s) değerinden bir miktar daha kısa olmalı. Aksi hâlde Kubelet SIGKILL'i bastırır ve graceful shutdown'ın hiçbir anlamı kalmaz. 25s mantıklı bir buffer.
Request Context ve Timeout Propagation
Handler'ınız bir downstream servisi çağırıyorsa, istemci bağlantısı koptuğunda o downstream çağrısı hâlâ kaynak harcıyor olabilir. `r.Context()` istemci ayrıldığında iptal edilir; downstream client'a bu context'i mutlaka geçirin:
http.NewRequestWithContext(r.Context(), ...)ile outbound HTTP çağrıları.db.QueryContext(r.Context(), ...)ile veritabanı çağrıları.- Goroutine içinde fire-and-forget iş varsa, o işin kendi bağımsız context'i olmalı — yoksa istemci ayrıldığında işiniz yarıda kalır.
Per-Handler Timeout: Endpoint'e Göre Kalibrasyon
Tek bir global `WriteTimeout` her endpoint için doğru değildir. Bir health check 100ms'de bitmeli, bir rapor export'u 2 dakika sürebilir. `http.TimeoutHandler` ile handler bazında timeout uygulayabilirsiniz:
mux.Handle("/report", http.TimeoutHandler(reportHandler, 90*time.Second, "report timeout"))
Tabii bu sadece handler döngüsünü iptal eder, downstream çağrılarının context'i hâlâ doğru propagate edilmeli. İki katmanı da uygulamak gerekiyor.
Body Boyutu, Header'lar ve Hidden Killers
`http.MaxBytesReader` ile request body'sini sınırlamadığınız sürece, bir istemci theoretical olarak gigabaytlık bir payload göndererek belleğinizi tüketebilir. JSON decoder'ınız `r.Body`'i direkt okuyorsa, önce wrap edin:
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)— 1MB limit.- Decoder'da
DisallowUnknownFields()kullanın; production'da silent veri kabulü bug kaynağıdır. - Panic'leri yakalamak için en üst seviyede recovery middleware'i şart. Tek bir handler panic'i `http.Server` tarafından yakalanır ama log'ları doğru almak ve metric'e çevirmek size kalmış.

Observability: Neyi Ölçmezsen Onaramazsın
Incident'in geri dönüşü, "neyin yavaşladığını" gerçek zamanlı görebilmektir. Minimum şu üç sinyali çıkarın: istek başına latency histogram'ı (route ile etiketli), aktif goroutine sayısı, açık file descriptor sayısı. Bunları Prometheus'a çıkardığınızda Slowloris benzeri durum saniyeler içinde anomali olarak görülür.
Go ile sıfırdan başlayan ya da web servis tasarımını derinleştirmek isteyenler için Go eğitimi içeriklerinden yararlanabilirsiniz; concurrency ve context model'i tam oturduğunda yukarıdaki örüntüler doğal refleks hâline gelir. Dil idiomlarına hâkim olmak için resmi stil rehberi de iyi bir başlangıç noktasıdır.
Reverse Proxy Arkasında Çalışırken Dikkat Edilecekler
Nginx, Envoy ya da bir ALB arkasındaysanız, sunucu timeout'larınızı proxy timeout'larıyla uyumlu kurmalısınız. Kural basit: aşağıdaki katmanın timeout'u, üstündeki katmanın timeout'undan daha kısa olmalı. Aksi takdirde upstream zaman aşımına uğradı zannıyla retry başlatır, sizin handler'ınız hâlâ çalışıyordur — duplicate işlem garantilidir.
- Client → LB: 60s
- LB → Go server WriteTimeout: 45s
- Go server → downstream HTTP client timeout: 30s
- Downstream → DB query timeout: 20s
Bu kademeli yapı, retry storm'larını ve fantom-duplicate işlemleri engelleyen en pratik kurallardan biridir. Go ile backend tasarımı başlığında bu zincir konseptini detaylı inceleyebilirsiniz.
Cuma Akşamına Geri Dönüş
Başta anlattığımız incident'ın kök nedeni tek bir tercihti: standart kütüphanenin "kolay" arayüzünü production'a olduğu gibi taşımak. ReadHeaderTimeout, WriteTimeout, IdleTimeout, MaxBytesReader, Shutdown, context propagation — bunların hepsi `net/http` içinde zaten var, sadece açıkça konfigüre edilmesi gerekiyor. Bir sonraki Cuma 17:42'de aynı upstream yine yavaşlarsa, sunucunuz bağlantıları zarifçe ret edecek, mevcut istekleri tamamlayacak ve metric'lerde bir spike olarak görünüp geçecek — incident raporuna konu olmayacak.



