Anatomi Insiden: Ketika File Descriptor Habis

Error sistem operasi EMFILE (Too many open files) terjadi ketika suatu proses mencoba mengalokasikan file descriptor (FD) baru melampaui batas lunak (soft limit) yang dialokasikan oleh kernel Linux. Pada arsitektur microservice atau backend API, pemicu paling umum bukan pembacaan file statis dari disk, melainkan kebocoran socket jaringan pada HTTP client keluar (outbound).

Ketika beban trafik meningkat, backend tiba-tiba gagal menghubungi database, cache, atau downstream service pihak ketiga dengan pesan error seperti:

dial tcp 10.0.1.25:443: socket: too many open files

Dalam model sistem operasi UNIX, socket jaringan direpresentasikan sebagai file descriptor. Jika socket dialokasikan namun tidak ditutup secara eksplisit oleh aplikasi atau tidak dikembalikan ke pool koneksi, descriptor tersebut tetap terkunci di tabel kernel sampai proses dimatikan.

Alur Investigasi dan Diagnosis Sistem

Saat insiden terjadi, investigasi terstruktur dilakukan mulai dari verifikasi limit proses hingga identifikasi state TCP pada socket yang bocor.

1. Periksa Batas Limit OS dan Utilisasi Global

Periksa batas alokasi file descriptor pada sistem dan proses target:

# Cek batas file descriptor per proses saat ini
ulimit -n

# Periksa konsumsi file descriptor pada level kernel sistem
cat /proc/sys/fs/file-nr
# Output format: [allocated FDs] [unused FDs] [max FDs]

2. Hitung dan Analisis File Descriptor Proses Target

Temukan PID aplikasi yang mengalami degradasi, lalu hitung jumlah file descriptor yang sedang terbuka:

# Ambil PID aplikasi
PID=$(pgrep -f "api-service")

# Hitung jumlah file descriptor yang aktif
ls -1 /proc/$PID/fd | wc -l

# Bandingkan dengan soft limit proses
cat /proc/$PID/limits | grep "Max open files"

3. Identifikasi Status Socket via lsof dan ss

Gunakan lsof dan ss untuk memetakan jenis file descriptor yang mendominasi alokasi:

# Filter file descriptor berbasis socket jaringan
lsof -p $PID -a -iTCP

# Periksa sebaran state TCP pada proses tersebut
ss -tanp | grep "pid=$PID" | awk '{print $1}' | sort | uniq -c

Jika hasil penghitungan menunjukkan ratusan hingga ribuan socket tertahan pada status CLOSE_WAIT, akar masalah berada pada sisi aplikasi lokal.

Penting: State CLOSE_WAIT menandakan bahwa host remote (server tujuan) telah mengirimkan paket FIN dan kernel lokal telah membalas dengan ACK. Namun, aplikasi lokal Anda belum memanggil syscall close() pada socket file descriptor tersebut. Socket akan tertahan dalam tabel file descriptor tanpa batas waktu hingga aplikasi menutupnya atau proses crash.

Akar Masalah: Kebocoran Socket pada HTTP Client

Penyebab paling dominan dari akumulasi CLOSE_WAIT dan kehabisan file descriptor pada HTTP client adalah penanganan response body yang tidak tuntas. Dua kesalahan utama yang sering terjadi:

  1. Body stream tidak ditutup: Driver runtime atau pustaka jaringan menolak mengembalikan socket ke pool koneksi jika pembacaan body belum selesai atau response stream tidak dieksekusi pemanggilan close().
  2. Body stream tidak dibaca habis (drained): Meskipun method close() dipanggil, jika data payload yang tersisa tidak dibaca hingga EOF (End-Of-File), koneksi TCP HTTP/1.1 tidak dapat digunakan kembali (reuse) untuk request berikutnya dan koneksi dipaksa putus secara tidak bersih.

Implementasi Perbaikan: Studi Kasus Go

Implementasi Bermasalah (Leaky Client)

package main

import (
	"fmt"
	"net/http"
)

func fetchDownstream(url string) error {
	// Salah: Menggunakan DefaultClient tanpa timeout
	resp, err := http.Get(url)
	if err != nil {
		return err
	}

	// Fatal: Lupa menutup resp.Body jika status code tidak sesuai ekspektasi
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("server error: %d", resp.StatusCode)
	}

	// Fatal: resp.Body tidak di-drain dan tidak di-close jika fungsi exit lebih awal
	return nil
}

Implementasi yang Benar (Idiomatic Resource Cleanup & Transport Tuning)

package main

import (
	"context"
	"fmt"
	"io"
	"net"
	"net/http"
	"time"
)

// Inisialisasi HTTP Client terisolasi dengan pool transport yang terkontrol
var safeClient = &http.Client{
	Timeout: 5 * time.Second,
	Transport: &http.Transport{
		Proxy: http.ProxyFromEnvironment,
		DialContext: (&net.Dialer{
			Timeout:   2 * time.Second,
			KeepAlive: 30 * time.Second,
		}).DialContext,
		MaxIdleConns:        100,
		MaxIdleConnsPerHost: 20,
		IdleConnTimeout:     90 * time.Second,
		TLSHandshakeTimeout: 2 * time.Second,
	},
}

func fetchDownstreamCorrected(ctx context.Context, url string) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}

	resp, err := safeClient.Do(req)
	if err != nil {
		return err
	}

	// Pastikan Body selalu ditutup terlepas dari alur eksekusi
	defer resp.Body.Close()

	// Wajib: Drain response body agar underlying connection dapat di-reuse oleh Transport pool
	_, _ = io.Copy(io.Discard, resp.Body)

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("downstream returned status: %d", resp.StatusCode)
	}

	return nil
}

Konfigurasi Sistem Operasi: ulimit dan Systemd

Meningkatkan limit file descriptor tidak menyelesaikan bug kebocoran memori atau socket, namun konfigurasi default Linux sering kali terlalu rendah (biasanya 1024) untuk server berproduktivitas tinggi.

Penyesuaian via Limits Configuration

Tambahkan konfigurasi pada /etc/security/limits.conf:

# <domain>      <type>  <item>         <value>
*               soft    nofile         65535
*               hard    nofile         65535

Penyesuaian via Systemd Service

Jika aplikasi dijalankan melalui unit file systemd, limit bawaan sering diabaikan. Tambahkan direktif LimitNOFILE pada service unit:

[Unit]
Description=API Gateway Service
After=network.target

[Service]
Type=simple
User=appuser
ExecStart=/usr/local/bin/api-service
LimitNOFILE=65535
Restart=always

[Install]
WantedBy=multi-user.target

Muat ulang konfigurasi dan restart service:

sudo systemctl daemon-reload
sudo systemctl restart api-service

Observability: Monitoring Metrik File Descriptor

Pencegahan regresi insiden memerlukan alerting berbasis metrik sebelum proses mencapai ambang batas saturasi file descriptor.

Prometheus Metric Exporter

Gunakan runtime metrics standar Linux/Go exporter yang menyediakan metrik:

  • process_open_fds: Jumlah file descriptor yang saat ini dialokasikan oleh proses.
  • process_max_fds: Batas maksimum file descriptor yang diizinkan untuk proses tersebut (soft limit).

Prometheus Alerting Rule

Pasang alert peringatan jika kapasitas file descriptor terisi melebihi 80% selama 5 menit berturut-turut:

groups:
  - name: process_fd_alerts
    rules:
      - alert: HighFileDescriptorUsage
        expr: (process_open_fds / process_max_fds) * 100 > 80
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Tingginya utilisasi file descriptor pada instance {{ $labels.instance }}"
          description: "Proses {{ $labels.job }} menggunakan {{ $value | printf `%.2f` }}% kapasitas FD. Potensi socket leak atau perlunya scaling."

Verifikasi Perbaikan

Setelah implementasi pembersihan stream (drain & close) dipasang, lakukan validasi beban menggunakan tools load-testing seperti k6 atau vegeta:

# Monitoring real-time utilisasi FD saat pengujian beban berlangsung
watch -n 1 "ls -1 /proc/$(pgrep -f api-service)/fd | wc -l"

Pada kode yang sehat, grafik file descriptor akan naik stabil hingga batas connection pool (misalnya berada di kisaran pooling koneksi TCP yang telah dikonfigurasi) lalu membentuk garis datar (plateau). Jika grafik menunjukkan tren kenaikan monoton seiring jumlah request, kebocoran resource masih terjadi pada layer transport atau IO aplikasi.