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_WAITatauCLOSE_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 -cEvaluasi 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 TCP3. 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:
- Seluruh payload pada
resp.Bodyharus dibaca sampai tuntas (mencapaiio.EOF). - 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 = 2Nilai 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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!