GO HTTP SERVER BEST PRACTICES

Veri Akademi8 dk okuma
HTTP request'in middleware zincirinden geçerek handler'a ulaşma akışını gösteren konsept

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.

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

2. Ü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.
net/http Server struct'ındaki ReadTimeout WriteTimeout IdleTimeout alanlarını gösteren konfigürasyon paneli

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

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

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

6. 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ış.
SIGTERM sinyali sonrası graceful shutdown lifecycle aşamaları ve aktif isteklerin tamamlanma akışı

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

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

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

Sıkça Sorulan Sorular

http.ListenAndServe doğrudan production'da neden kullanılmamalı?

Çünkü altta yatan http.Server struct'ı varsayılan değerlerle kurulur ve bu varsayılanlarda hiçbir timeout tanımlı değildir. Yavaş veya kötü niyetli bir istemci tek bir TCP bağlantısını saatlerce açık tutarak goroutine ve file descriptor tüketebilir. Production için her zaman http.Server struct'ı elle konfigüre edilip ListenAndServe metodu o struct üzerinden çağrılmalıdır.

ReadTimeout ile ReadHeaderTimeout arasındaki fark nedir?

ReadHeaderTimeout sadece HTTP header'larının okunması için verilen süredir ve Slowloris türü saldırılara karşı birincil savunmadır. ReadTimeout ise header dahil tüm istek gövdesinin okunması için verilen toplam süredir. Büyük upload endpoint'leri için ReadTimeout'u global olarak yükseltmek yerine, ReadHeaderTimeout'u kısa tutup ilgili handler içinde body okuma süresini ayrıca yönetmek daha güvenlidir.

srv.Shutdown çağrısı tüm istekleri garantili olarak bitirir mi?

Hayır. Shutdown'a verdiğiniz context deadline'a kadar bekler; bu süre içinde biten istekler temiz kapanır, bitmeyenler bağlantı kapatılarak iptal edilir. Ayrıca handler'larınız r.Context() iptalini dinlemiyorsa Shutdown onları zorla durduramaz; bu yüzden context propagation ve handler içi iptal kontrolü graceful shutdown'ın eşit derecede önemli parçasıdır.

Kubernetes terminationGracePeriodSeconds ile shutdown timeout'u nasıl ilişkilendirilmeli?

Uygulamanın Shutdown context timeout'u, Kubernetes'in terminationGracePeriodSeconds değerinden birkaç saniye daha kısa olmalıdır. Aksi takdirde Kubelet SIGKILL gönderir ve graceful shutdown yarıda kesilir. Tipik bir kombinasyon 30 saniye grace period'a karşılık 25 saniye shutdown timeout'tur, böylece geriye log flush'ı ve metric scrape'i için pay kalır.

http.TimeoutHandler kullanmak yeterli mi, yoksa context propagation şart mı?

TimeoutHandler sadece handler'ın response yazma süresini sınırlar ve süre dolduğunda istemciye 503 döner; ancak handler içinde başlatılmış downstream çağrıları (DB query, HTTP request) iptal olmaz. Gerçek koruma için outbound çağrılara r.Context() geçirilmelidir; iki katman birlikte uygulandığında istemci ayrıldığında veya timeout'a düştüğünde tüm zincir temizlenir.

http.MaxBytesReader olmadan ne tür risklere açık kalırız?

Body boyutu sınırsız okunan bir handler, kötü niyetli veya hatalı bir istemcinin gönderdiği büyük payload ile bellek tüketimine yol açabilir. JSON decoder'lar çoğu zaman tüm body'i memory'e alır. MaxBytesReader ile gövdeyi örneğin 1MB ile sınırlamak hem belleği korur hem de istemciye 413 Request Entity Too Large dönerek protokol seviyesinde net bir hata sinyali verir.

IdleTimeout'u çok yüksek ya da çok düşük ayarlamak ne fark eder?

Çok yüksek IdleTimeout, keep-alive bağlantılarının uzun süre boşta tutulup file descriptor'ı tüketmesine yol açar. Çok düşük ayarlanması ise her istekte yeni TCP+TLS el sıkışması yapılmasına ve connection churn ile CPU yükünün artmasına sebep olur. 60-120 saniye aralığı çoğu API workload'u için makul bir başlangıç noktasıdır ve trafik desenine göre ayarlanmalıdır.

Panic'leri handler içinde manuel yakalamam gerekiyor mu?

http.Server her handler'ı kendi goroutine'inde çalıştırır ve panic durumunda bağlantıyı kapatır, ancak panic detayını log'lar ve durduğunda metric'e dönüştürmez. Production için en dış katmanda recovery middleware'i yazmak; panic stack trace'ini yapılandırılmış log'a düşürmek ve panic sayacını metric olarak çıkarmak hem incident analizini hızlandırır hem de sessiz hataları görünür kılar.