Goroutine leak merupakan salah satu penyebab utama degradasi stabilitas pada backend berbasis Go. Berbeda dengan alokasi heap biasa yang dapat dibersihkan oleh Garbage Collector (GC), runtime Go tidak akan pernah membebaskan memori milik goroutine yang masih aktif atau terblokir selamanya. Akibatnya, setiap leak membawa overhead stack minimal 2 KB beserta seluruh objek heap yang direferensikan dalam stack frame tersebut.

Gejala Sistem: Dari Degenerasi Latensi hingga Pod OOMKilled

Kebocoran goroutine pada beban produksi umumnya menunjukkan pola degradasi spesifik pada observabilitas sistem:

  • Grafik Memori Linier: Metrik container_memory_working_set_bytes meningkat konstan tanpa kembali turun ke baseline setelah request rate mereda.
  • Latensi Naik Perlahan: Waktu Stop-The-World (STW) dan durasi mark phase pada GC bertambah lama seiring banyaknya stack goroutine yang harus dipindai oleh runtime.
  • Terminasi OOMKilled: Kubernetes mengirimkan sinyal SIGKILL (exit code 137) saat pemakaian memori container menabrak limit cgroup pods.
  • Metrik Goroutine Monoton Naik: Metrik go_goroutines di Prometheus terus bertambah sebanding dengan volume request yang gagal atau timed out.

Analisis Root Cause: Unbuffered Channel Blocking

Penyebab paling sering adalah goroutine worker yang mencoba mengirim data ke unbuffered channel setelah context pemanggil dibatalkan. Pengiriman pada unbuffered channel bersifat sinkron dan memblokir eksekusi sampai ada receiver yang membaca data.

Jika pemanggil menghentikan eksekusi lebih cepat karena batas waktu context habis, receiver channel ditutup atau diabaikan. Goroutine pengirim akan tertahan di status chan send selamanya tanpa ada komponen yang membangunkannya.

Langkah Profiling dengan net/http/pprof

Gunakan package standar net/http/pprof untuk mengambil snapshot runtime goroutine secara langsung.

1. Pasang Profiler Endpoint

Jalankan server HTTP pada port internal terpisah untuk mengekspos rute pprof tanpa mengeksposnya ke publik:

package main

import (
	"net/http"
	_ "net/http/pprof"
)

func initBackgroundProfiling() {
	go func() {
		_ = http.ListenAndServe("localhost:6060", nil)
	}()
}

2. Ambil Text Dump Goroutine

Ketika pods menunjukkan gejala kebocoran, ambil stack trace lengkap dari semua goroutine yang sedang berjalan:

curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutines_dump.txt

Nilai debug=2 memformat output menjadi teks biasa dengan stack trace lengkap setiap goroutine yang aktif.

3. Identifikasi Stack Trace Menggantung

Buka goroutines_dump.txt dan cari goroutine dengan jumlah duplikasi tinggi yang berada pada status terblokir:

4812 @ 0x43b2f5 0x44ae12 0x72a981 0x471841
#	0x72a980	main.fetchUserData.func1+0x100	/app/service/user.go:34
goroutine 18342 [chan send]:
main.fetchUserData.func1(0xc00010a060, 0xc000102060)
	/app/service/user.go:34 +0x100
created by main.fetchUserData
	/app/service/user.go:28 +0x85

Angka 4812 di awal menunjukkan terdapat 4.812 goroutine yang terhenti pada baris kode yang sama di /app/service/user.go:34 dengan status [chan send].

Solusi Kode: Sebelum vs Sesudah

Kode di bawah menunjukkan pola pemanggilan paralel data user yang rentan bocor dan perbaikannya.

Kode Sebelum Perbaikan (Vulnerable)

func fetchUserData(ctx context.Context, userID string) (*UserData, error) {
	ch := make(chan *UserData)
	errCh := make(chan error)

	go func() {
		data, err := queryExternalAPI(userID)
		if err != nil {
			errCh <- err // Terblokir jika ctx timeout terjadi sebelum baris ini
			return
		}
		ch <- data // Terblokir jika timeout
	}()

	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	case err := <-errCh:
		return nil, err
	case res := <-ch:
		return res, nil
	}
}

Jika queryExternalAPI memerlukan 2 detik sementara ctx memiliki batas waktu 500 ms, block select utama selesai membaca ctx.Done() dan keluar dari fungsi. Goroutine latar belakang kemudian terbangun, mencoba mengeksekusi ch <- data, lalu terblokir selamanya karena receiver sudah hilang.

Kode Sesudah Perbaikan (Corrected)

Gunakan statement select pada channel write untuk mendeteksi pembatalan context:

func fetchUserData(ctx context.Context, userID string) (*UserData, error) {
	ch := make(chan *UserData, 1)
	errCh := make(chan error, 1)

	go func() {
		data, err := queryExternalAPI(userID)
		if err != nil {
			select {
			case errCh <- err:
			case <-ctx.Done():
			}
			return
		}

		select {
		case ch <- data:
		case <-ctx.Done():
		}
	}()

	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	case err := <-errCh:
		return nil, err
	case res := <-ch:
		return res, nil
	}
}
Catatan: Menggunakan channel dengan buffer ukuran 1 (make(chan *UserData, 1)) juga mencegah goroutine terblokir jika pengiriman data dilakukan tepat sekali sebelum fungsi keluar. Kombinasi buffer dan select ctx.Done() memberikan keamanan ganda saat menangani operasi konkuren.

Pencegahan Otomatis via uber-go/goleak

Mencegah regresi goroutine leak dapat diotomatisasi pada tahap Continuous Integration (CI) menggunakan library go.uber.org/goleak di dalam pengujian unit.

Implementasi Unit Test

Pasang modul:

go get go.uber.org/goleak

Tambahkan verifikasi leak pada unit test:

package main

import (
	"context"
	"testing"
	"time"

	"go.uber.org/goleak"
)

func TestFetchUserData_ContextCancelNoLeak(t *testing.T) {
	defer goleak.VerifyNone(t)

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
	defer cancel()

	_, _ = fetchUserData(ctx, "user-123")

	// Beri waktu goroutine internal merespons sinyal cancel
	time.Sleep(50 * time.Millisecond)
}

Jika goroutine worker gagal berhenti ketika context timeout terpenuhi, goleak.VerifyNone menggagalkan test dan mencetak stack trace goroutine yang tertinggal.