Lonjakan koneksi berstatus ESTABLISHED yang tidak kunjung turun pada backend sering berujung pada kehabisan alokasi File Descriptor (FD exhaustion). Dampaknya fatal: server menolak koneksi baru dan memunculkan log error EMFILE: too many open files atau ENFILE: file table overflow. Masalah ini umumnya dipicu oleh koneksi TCP half-open dari aplikasi mobile, seperti React Native, yang terputus secara tidak bersih tanpa transmisi paket termination standar.

Gejala dan Investigasi Socket Leak

Socket leak terdeteksi saat metrik koneksi aktif terus meningkat linear terhadap waktu, meski trafik request per second (RPS) berada di angka normal atau rendah. Pada tahap kritis, proses backend berhenti melayani traffic TCP baru.

Verifikasi status alokasi socket dan file descriptor pada server Linux menggunakan perintah berikut:

# Periksa jumlah file descriptor yang digunakan oleh proses backend (misal PID 14205)
ls -l /proc/14205/fd | wc -l

# Periksa metrik global alokasi file descriptor sistem operasi
cat /proc/sys/fs/file-nr
# Output: <allocated_fd> <unused_fd> <max_fd>

# Hitung total socket TCP dengan status ESTABLISHED
ss -t state established | wc -l

Jika output ss menunjukkan ribuan koneksi ESTABLISHED yang mengarah ke IP publik operator seluler, sementara tidak ada throughput data yang mengalir, socket tersebut berada dalam status half-open (atau zombie socket).

Akar Masalah: Mobile Process Suspend dan TCP Half-Open

Koneksi TCP normal ditutup melalui mekanisme four-way handshake menggunakan flag FIN, atau pemutusan paksa via flag RST. Pada perangkat mobile (Android dan iOS), manajemen daya OS menerapkan terminasi dan penangguhan proses secara agresif.

Saat pengguna memindahkan aplikasi React Native ke background, mengunci layar, atau berpindah ke jaringan seluler yang lemah (zero coverage):

  1. OS mobile membekukan (suspend) thread proses JavaScript dan native secara instan guna menghemat baterai.
  2. Runtime aplikasi tidak memiliki alokasi waktu CPU untuk menjalankan cleanup handler atau menutup socket secara graceful.
  3. Paket FIN atau RST tidak pernah dikirimkan keluar antarmuka jaringan perangkat.
  4. Server menganggap koneksi masih aktif karena TCP secara default adalah protokol yang diam (silent protocol). Jika tidak ada pihak yang mengirim data, socket akan tetap berstatus ESTABLISHED selamanya jika keepalive dinonaktifkan.

Nilai default TCP keepalive pada kernel Linux adalah 7.200 detik (2 jam). Rentang waktu ini terlalu lama untuk lingkungan mobile, sehingga socket yang tertinggal menumpuk dan menghabiskan batas file descriptor proses (ulimit -n).

Perbaikan Sisi Server: Tuning TCP Keepalive Kernel Linux

Tuning parameter kernel dilakukan agar subsistem networking Linux secara proaktif memverifikasi keberadaan peer melalui paket probe keepalive, dan membersihkan dead socket dalam hitungan menit.

Buka berkas konfigurasi sysctl:

# /etc/sysctl.d/99-tcp-keepalive.conf

# Waktu tunggu idle sebelum probe keepalive pertama dikirim (detik)
net.ipv4.tcp_keepalive_time = 300

# Interval antar probe jika probe sebelumnya tidak dibalas (detik)
net.ipv4.tcp_keepalive_intvl = 15

# Jumlah probe gagal sebelum koneksi dinyatakan mati dan di-drop
net.ipv4.tcp_keepalive_probes = 5

Terapkan konfigurasi tanpa reboot:

sudo sysctl --system

Dengan konfigurasi di atas, koneksi mati akan terdeteksi dan dibersihkan dalam waktu: 300 + (15 * 5) = 375 detik (6 menit 15 detik), memangkas batas default 2 jam.

Konfigurasi Reverse Proxy (Nginx / Envoy)

Jika backend berada di balik reverse proxy, proxy layer harus dikonfigurasi untuk mendeteksi client putus dan memutus upstream socket secara sinkron.

Nginx

Aktifkan keepalive probe level socket dan atur timeout koneksi client pada blok konfigurasi Nginx:

http {
    # Batas idle keepalive HTTP client
    keepalive_timeout 65s;
    keepalive_requests 1000;

    # Deteksi socket putus pada level TCP socket
    client_header_timeout 30s;
    client_body_timeout 30s;
    send_timeout 30s;

    # Khusus upstream proxying (Nginx >= 1.15.6)
    proxy_socket_keepalive on;
}

Envoy

Pada Envoy Proxy, atur common_http_protocol_options pada listener filter chain:

typed_config:
  "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
  common_http_protocol_options:
    idle_timeout: 60s
    headers_with_underscores_action: REJECT_REQUEST
  stream_idle_timeout: 30s
  request_timeout: 30s

Konfigurasi Runtime Backend (Node.js & Go)

Selain level kernel dan proxy, runtime aplikasi backend harus menerapkan idle timeout pada socket level.

Node.js HTTP Server

Secara default, Node.js HTTP server memiliki keepAliveTimeout. Sesuaikan nilainya agar sinkron dengan reverse proxy:

const http = require('http');

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ status: 'ok' }));
});

// Durasi socket menunggu data baru sebelum dianggap idle
server.keepAliveTimeout = 65000;
// Harus lebih besar dari keepAliveTimeout untuk mitigasi race condition HTTP keep-alive
server.headersTimeout = 66000;

server.listen(3000);

Go HTTP Server

Di Go, setel parameter IdleTimeout pada struct http.Server:

package main

import (
	"net/http"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("pong"))
	})

	server := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  15 * time.Second,
		WriteTimeout: 15 * time.Second,
		IdleTimeout:  60 * time.Second, // Menutup TCP socket idle setelah 60 detik
	}

	server.ListenAndServe()
}

Mitigasi Client-Side: React Native AppState Lifecycle

Pencegahan paling efektif untuk koneksi persistent (WebSocket atau long-polling) adalah melakukan pemutusan koneksi secara sadar sesaat sebelum OS men-suspend aplikasi ke background.

Gunakan API AppState untuk memantau status aplikasi:

import React, { useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';

export const useSocketLifecycle = (socketClient: { connect: () => void; disconnect: () => void }) => {
  const appState = useRef<AppStateStatus>(AppState.currentState);

  useEffect(() => {
    const subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
      // Transisi dari Active ke Background atau Inactive
      if (
        appState.current === 'active' &&
        nextAppState.match(/inactive|background/)
      ) {
        // Kirim penutupan socket eksplisit sebelum OS mematikan thread network
        socketClient.disconnect();
      }

      // Transisi kembali dari Background ke Active
      if (
        appState.current.match(/inactive|background/) &&
        nextAppState === 'active'
      ) {
        socketClient.connect();
      }

      appState.current = nextAppState;
    });

    return () => {
      subscription.remove();
      socketClient.disconnect();
    };
  }, [socketClient]);
};
Penting: Pada sistem operasi iOS, eksekusi kode saat status inactive atau background memiliki time budget sangat sempit (< 5 detik). Jangan lakukan operasi asynchronous yang lambat sebelum memanggil fungsi penutup socket. Pastikan fungsi disconnect() langsung memicu frame penutupan TCP / WebSocket tanpa menunggu state dependensi lain.

Checklist Validasi Produksi

  1. Pastikan batas file descriptor proses backend memadai: periksa via ulimit -n (rekomendasi minimal 65535 untuk edge/gateway service).
  2. Pantau gauge metrics node_sockstat_TCP_inuse dan node_sockstat_TCP_tw via Prometheus Node Exporter untuk mendeteksi penumpukan koneksi sebelum terjadi crash.
  3. Terapkan proteksi multi-layer: TCP Keepalive di Linux OS, timeout ketat di Nginx/Envoy, idle timeout pada application runtime, dan graceful cleanup via AppState di React Native.