Error accept4: too many open files (errno EMFILE) pada backend berbasis Go Fiber menandakan proses kehabisan File Descriptor (FD) yang dialokasikan oleh kernel sistem operasi. Pada arsitektur Linux, socket jaringan, file, dan pipe direpresentasikan sebagai file descriptor. Ketika alokasi FD habis, runtime Go tidak dapat menerima koneksi TCP masuk baru maupun membuka socket keluar, memicu kegagalan bertingkat pada aplikasi.

1. Gejala Insiden: Pod Failure dan Socket Rejection

Insiden bermula sesaat setelah rilis service Go Fiber yang mengonsumsi API downstream baru. Ketika traffic meningkat, gejala klinis berikut muncul di cluster Kubernetes:

  • Liveness dan Readiness Probe Gagal: Kubelet mengirim sinyal connection refused atau timeout ke port health check /healthz. Runtime tidak merespons karena antrean TCP backlog penuh dan kernel menolak accept() socket baru. Pod masuk status CrashLoopBackOff.
  • Log Aplikasi: Log service dipenuhi error berikut:
http: Accept error: accept tcp [::]:8080: accept4: too many open files; retrying in 5ms
  • Kegagalan Outbound Request: Service gagal melakukan panggilan HTTP ke dependensi internal, memicu lonjakan error HTTP 500 dan 502 di API Gateway.

2. Observasi dan Diagnostik: Prometheus, ss, dan lsof

Sebelum mematikan instance untuk mitigasi, langkah diagnostik dilakukan untuk memvalidasi apakah kebocoran terjadi pada file I/O atau network socket.

Query Prometheus

Rasio penggunaan FD dipantau melalui metrik bawaan runtime Go via Prometheus collector:

# Hitung rasio pemakaian FD terhadap limit maksimum
(process_open_fds{job="order-service"} / process_max_fds{job="order-service"}) * 100 > 80

Grafik metrik menunjukkan kenaikan linear process_open_fds dari rata-rata baseline 120 hingga menyentuh batas lunak (soft limit) process_max_fds (misal: 1024 atau 65535) tanpa ada kurva penurunan saat traffic melandai.

Inspeksi Socket pada Container Target

Melalui pod debug (ephemeral container) atau exec langsung ke container, periksa status koneksi jaringan menggunakan ss:

# Ringkasan socket pada namespace jaringan container
ss -s

# Filter socket TCP spesifik ke arah downstream service
ss -tan state time-wait sport = :8080 or dport = :9000 | head -n 30

Hasil ss -s memperlihatkan ribuan socket dalam status TIME_WAIT dan ESTABLISHED. Untuk melihat proses pemilik socket tersebut, jalankan lsof:

# Hitung jumlah socket TCP terbuka oleh PID aplikasi (PID 1)
lsof -p 1 -a -i TCP | wc -l

# Tampilkan 10 socket terakhir beserta IP tujuannya
lsof -p 1 -a -i TCP | tail -n 10

Output lsof memverifikasi bahwa ribuan FD terbuka mengarah ke host downstream yang sama pada port HTTP upstream, bukan berasal dari kebocoran pembukaan berkas statis (log/file read).

3. Mitigasi Darurat: Rollback Deployment

Saat batas FD terlampaui di production, restart pod sederhana (restart pod manual) hanya memberi jeda sementara sebelum pod baru kembali crash akibat lonjakan traffic yang mengantre. Tindakan pertama adalah menghentikan pemicu beban melalui rollback ke image stabil sebelumnya.

# Eksekusi rollback Kubernetes deployment
kubectl rollout undo deployment/order-service -n production

# Pantau transisi pod
kubectl rollout status deployment/order-service -n production --watch

Setelah rollback selesai, grafik Prometheus menunjukkan process_open_fds kembali ke level normal (baseline < 200). Probe kembali hijau, dan error EMFILE berhenti total.

4. Postmortem: Akar Masalah pada HTTP Client

Go Fiber dibangun di atas fasthttp, bukan package net/http standar. Kesalahan fatal terjadi di handler layer rilis baru, di mana instance HTTP client diinisialisasi secara berulang di setiap request masuk.

Anti-Pattern: Instansiasi Client per Request

// SALAH: Instansiasi client baru pada setiap request
func handleCheckout(c *fiber.Ctx) error {
    client := &fasthttp.Client{} // FATAL: Client dan transport pool baru dibuat di tiap goroutine

    req := fasthttp.AcquireRequest()
    resp := fasthttp.AcquireResponse()
    defer fasthttp.ReleaseRequest(req)
    defer fasthttp.ReleaseResponse(resp)

    req.SetRequestURI("http://payment-service:9000/charge")
    req.Header.SetMethod("POST")

    if err := client.Do(req, resp); err != nil {
        return c.Status(fiber.StatusInternalServerError).SendString(err.Error())
    }

    return c.Send(resp.Body())
}

Dampaknya:

  1. Setiap kali &fasthttp.Client{} dibuat lokal, connection pool internal terisolasi di instance tersebut dan tidak dibagi ke handler lain.
  2. Setelah request selesai, connection pool lokal masuk fase garbage collection sementara socket TCP underlying masih berada dalam siklus penutupan kernel (TIME_WAIT).
  3. Socket yang belum sepenuhnya di-reap oleh OS menahan File Descriptor tetap terbuka. Saat throughput mencapai ratusan request per detik, kuota FD habis jauh lebih cepat dibanding durasi kernel melepas status TIME_WAIT (biasanya 60 detik).

5. Solusi: Singleton fasthttp.Client dengan Batasan Pool

HTTP client wajib dijadikan singleton di tingkat package atau dependency injection container agar connection pool dapat digunakan ulang (keep-alive). Konfigurasi batas koneksi per host dan timeout secara eksplisit.

package client

import (
    "time"
    "github.com/valyala/fasthttp"
)

// Inisialisasi fasthttp.Client sebagai singleton
var UpstreamClient = &fasthttp.Client{
    Name:                     "order-service-client",
    NoDefaultUserAgentHeader:  true,
    MaxConnsPerHost:          2048,               // Batas atas socket per host
    MaxIdleConnDuration:      30 * time.Second,  // Tutup koneksi idle jika tidak terpakai
    ReadTimeout:              5 * time.Second,   // Cegah leak FD karena downstream hanging
    WriteTimeout:             5 * time.Second,
    MaxConnWaitTimeout:       1 * time.Second,   // Error jika pool penuh
}
// ponytail: tuning MaxConnsPerHost sesuai throughput target; gunakan standard net/http jika butuh HTTP/2.

Gunakan singleton tersebut di dalam handler Fiber:

func handleCheckout(c *fiber.Ctx) error {
    req := fasthttp.AcquireRequest()
    resp := fasthttp.AcquireResponse()
    defer fasthttp.ReleaseRequest(req)
    defer fasthttp.ReleaseResponse(resp)

    req.SetRequestURI("http://payment-service:9000/charge")
    req.Header.SetMethod("POST")

    // Gunakan client singleton yang reuse connection pool
    if err := client.UpstreamClient.Do(req, resp); err != nil {
        return c.Status(fiber.StatusServiceUnavailable).SendString("Upstream error")
    }

    return c.Send(resp.Body())
}

6. Pencegahan: Alerting & Resource Tuning

Guna mendeteksi kebocoran socket sebelum memicu insiden berskala penuh, terapkan Prometheus AlertRule berikut:

groups:
- name: process-resource-alerts
  rules:
  - alert: ProcessNearFDLimit
    expr: (process_open_fds / process_max_fds) * 100 > 75
    for: 2m
    labels:
      severity: warning
    annotations:
      summary: "Pod {{ $labels.pod }} mendekati batas kapasitas FD ({{ $value }}%)"

Selain alerting, pastikan Kubernetes security context menetapkan nofile limit yang realistis via limits atau pod definition, serta audit seluruh outbound client (DB driver, HTTP, gRPC) agar selalu menggunakan pola shared instance atau connection pool tunggal.