Error dial tcp: lookup ... dial tcp: cannot assign requested address pada layanan backend berbasis Go merupakan tanda terjadinya ephemeral port exhaustion. Masalah ini lazim muncul ketika throughput permintaan HTTP keluar (egress) meningkat pesat, sementara koneksi TCP tidak didaur ulang secara efisien oleh runtime Go. Akibatnya, sistem kehabisan alokasi port sumber (source port) yang tersedia di level kernel sistem operasi.

1. Gejala Klinis pada Sistem Produksi

Saat aplikasi Go mengalami kebocoran socket, penurunan performa biasanya terjadi tiba-tiba. Berikut adalah indikator utama yang muncul di sistem:

  • Lonjakan File Descriptor (FD): Metrik proses menunjukkan peningkatan FD secara linier tanpa pernah kembali ke batas normal (baseline).
  • Kegagalan Egress HTTP: Panggilan ke downstream API gagal serentak dengan pesan error cannot assign requested address.
  • Banjir Status Socket: Ribuan koneksi tertahan pada status TIME_WAIT atau CLOSE_WAIT.

Secara default pada Linux, rentang port lokal ditentukan oleh kernel parameter net.ipv4.ip_local_port_range (umumnya bernilai 32768 60999, menyediakan ~28.232 port). Jika koneksi baru dibuka dan ditutup dengan frekuensi ratusan atau ribuan per detik tanpa connection reuse, kumpulan port ini akan cepat habis sebelum siklus TCP 2MSL (Maximum Segment Lifetime) selesai.

2. Langkah Investigasi Sistemik

Gunakan utilitas jaringan Linux untuk memvalidasi apakah masalah berasal dari port exhaustion dan mengidentifikasi proses Go yang bertanggung jawab.

Periksa Ringkasan Socket dengan ss

Jalankan perintah berikut untuk melihat status socket secara agregat:

# Ringkasan socket aktif
ss -s

# Total socket TCP per status
ss -tan | awk '{print $1}' | sort | uniq -c

Evaluasi hasilnya:

  • Jumlah TIME_WAIT tinggi: Klien Go aktif membuka dan langsung menutup koneksi (mengirim FIN lebih dulu), sehingga socket tertahan di state TIME_WAIT OS hingga durasi timeout berakhir.
  • Jumlah CLOSE_WAIT tinggi: Server downstream telah menutup koneksi, tetapi aplikasi Go belum menutup descriptor socket di level aplikasi (file descriptor menggantung).

Analisis Socket per Proses dengan lsof

Ambil PID dari aplikasi Go, lalu periksa socket yang dipegang oleh proses tersebut:

# Identifikasi jumlah file descriptor terbuka
lsof -p <PID> | wc -l

# Tampilkan rincian socket yang terhubung ke IP downstream
lsof -p <PID> -a -i TCP

3. Root Cause: Mengapa Connection Reuse Gagal di Go

Paket net/http di Go secara default menyediakan pooling koneksi melalui http.Transport. Namun, koneksi TCP hanya dapat dikembalikan ke pool idle jika dua kondisi berikut terpenuhi:

  1. Seluruh payload pada resp.Body harus dibaca sampai tuntas (mencapai io.EOF).
  2. Metode resp.Body.Close() harus dipanggil.

Jika handler membaca hanya sebagian JSON lalu langsung me-return fungsi, atau lupa menutup resp.Body, transport Go menganggap aliran data belum selesai atau socket berada dalam kondisi tidak bersih. Akibatnya, transport memutus koneksi secara paksa alih-alih memasukkannya kembali ke idle pool.

Penyebab sekunder adalah konfigurasi default http.DefaultTransport:

DefaultMaxIdleConnsPerHost = 2

Nilai default ini membatasi koneksi idle hanya 2 socket per host. Jika aplikasi mengirimkan 100 concurrent requests ke satu domain downstream, 98 koneksi lainnya akan langsung diputus setelah request selesai, memicu banjir socket TIME_WAIT.

4. Perbaikan Kode: Drain, Close, dan Transport Tuning

Pola Drain dan Close yang Benar

Pastikan body selalu di-drain menggunakan io.Copy(io.Discard, resp.Body) sebelum atau saat penutupan:

package client

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

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

	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	// Pastikan Close dipanggil untuk melepaskan FD
	defer resp.Body.Close()

	// Jika hanya membaca sebagian atau parsing gagal,
	// buang sisa stream agar koneksi TCP dapat digunakan ulang
	defer func() {
		_, _ = io.Copy(io.Discard, resp.Body)
	}()

	// Proses data...
	return nil
}

Konfigurasi http.Transport Produksi

Hindari penggunaan http.DefaultClient pada lingkungan beban tinggi. Buat instance client mandiri dengan transport yang disesuaikan:

package client

import (
	"net"
	"net/http"
	"time"
)

func NewOptimizedClient() *http.Client {
	transport := &http.Transport{
		Proxy: http.ProxyFromEnvironment,
		DialContext: (&net.Dialer{
			Timeout:   5 * time.Second,
			KeepAlive: 30 * time.Second,
		}).DialContext,
		ForceAttemptHTTP2:     true,
		MaxIdleConns:          500,
		MaxIdleConnsPerHost:   100, // Diperbesar dari default: 2
		IdleConnTimeout:       90 * time.Second,
		TLSHandshakeTimeout:   5 * time.Second,
		ExpectContinueTimeout: 1 * time.Second,
	}

	return &http.Client{
		Transport: transport,
		Timeout:   10 * time.Second,
	}
}

5. Unit Test Verifikasi Connection Reuse

Gunakan paket bawaan net/http/httptrace untuk memverifikasi secara programatik bahwa koneksi benar-benar digunakan kembali:

package client_test

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"net/http/httptrace"
	"testing"
	"time"
)

func TestConnectionReuse(t *testing.T) {
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("payload data"))
	}))
	defer ts.Close()

	client := &http.Client{
		Transport: &http.Transport{
			MaxIdleConnsPerHost: 10,
			IdleConnTimeout:     30 * time.Second,
		},
	}

	// Request pertama: inisialisasi koneksi
	req1, _ := http.NewRequest(http.MethodGet, ts.URL, nil)
	resp1, err := client.Do(req1)
	if err != nil {
		t.Fatalf("request 1 gagal: %v", err)
	}
	_, _ = io.Copy(io.Discard, resp1.Body)
	resp1.Body.Close()

	// Request kedua: verifikasi reuse lewat httptrace
	var reused bool
	trace := &httptrace.ClientTrace{
		GotConn: func(info httptrace.GotConnInfo) {
			reused = info.Reused
		},
	}

	ctx := httptrace.WithClientTrace(context.Background(), trace)
	req2, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil)
	resp2, err := client.Do(req2)
	if err != nil {
		t.Fatalf("request 2 gagal: %v", err)
	}
	_, _ = io.Copy(io.Discard, resp2.Body)
	resp2.Body.Close()

	if !reused {
		t.Errorf("koneksi TCP tidak di-reuse: info.Reused bernilai false")
	}
}

Ringkasan

Port exhaustion pada HTTP client Go terjadi ketika siklus hidup koneksi TCP terputus di tengah jalan. Penanganan yang tepat memerlukan disiplin membaca response body hingga EOF menggunakan io.Discard, menutup body via defer resp.Body.Close(), dan menaikkan MaxIdleConnsPerHost pada http.Transport sesuai proyeksi konkurensi aplikasi.