Masalah goroutine leak di aplikasi Go Fiber sering tidak langsung terlihat saat trafik masih rendah. Gejalanya muncul pelan-pelan: memori naik bertahap, runtime melaporkan jumlah goroutine terus bertambah, latency memburuk, dan pekerjaan latar belakang tertinggal karena terlalu banyak goroutine yang masih menunggu hasil I/O, channel, atau timeout yang tidak pernah dipicu dengan benar.

Pola yang sering jadi akar masalah adalah context turunan dibuat di handler, tetapi tidak pernah dibatalkan saat request selesai, timeout tercapai, atau klien sudah menutup koneksi. Akibatnya, operasi database, HTTP client, atau goroutine pembantu tetap hidup lebih lama dari yang seharusnya. Di artikel ini, kita bedah akar masalahnya, cara mereproduksi, teknik investigasi, dan langkah perbaikannya di Go Fiber.

Gejala Nyata Saat Goroutine Bocor

Pada sistem produksi, kebocoran goroutine jarang muncul sebagai error tunggal yang jelas. Lebih sering, tim melihat kombinasi gejala berikut:

  • Memori naik perlahan karena banyak goroutine menahan referensi ke buffer, response body, channel, atau hasil query.
  • Jumlah goroutine terus bertambah dan tidak kembali ke baseline setelah trafik turun.
  • Latency endpoint memburuk karena scheduler runtime harus mengelola goroutine aktif yang makin banyak.
  • Worker/background task tertinggal karena pool koneksi, CPU time, atau antrian internal dipenuhi pekerjaan yang seharusnya sudah dihentikan.
  • Timeout berantai pada komponen lain, misalnya DB atau upstream HTTP, karena request yang sudah tidak relevan masih berjalan.

Kalau Anda melihat pola ini bersamaan, jangan hanya fokus pada tuning GC atau menambah resource. Sangat mungkin ada operasi yang tidak ikut berhenti saat context request selesai.

Root Cause: Context Turunan Tidak Dibatalkan

Di Go, context.Context dipakai untuk membawa sinyal pembatalan, deadline, dan metadata request-scope. Jika kita membuat turunan dengan context.WithCancel, context.WithTimeout, atau context.WithDeadline, maka fungsi cancel harus dipanggil ketika konteks itu tidak lagi dibutuhkan.

Masalahnya, pada handler Fiber, developer kadang:

  • membuat ctx, cancel := context.WithTimeout(...) tetapi lupa defer cancel(),
  • menjalankan goroutine yang memakai context itu tanpa select pada ctx.Done(),
  • memanggil DB atau HTTP client dengan context yang tidak terhubung ke lifecycle request,
  • menunggu channel tanpa jalur keluar saat request sudah selesai.

Secara konsep, goroutine leak bukan hanya goroutine yang hidup selamanya. Goroutine yang bertahan jauh lebih lama dari lifecycle request juga merusak sistem, terutama jika jumlahnya terakumulasi.

Skenario Umum di Handler

  • DB query lambat dipanggil dengan context latar belakang, bukan context request, sehingga query tetap berjalan saat klien sudah pergi.
  • HTTP call ke upstream tidak memakai timeout eksplisit dan tidak menerima sinyal cancel dari request.
  • Goroutine pembantu menulis ke channel hasil, tetapi pembacanya sudah tidak ada lagi.
  • Worker async per request dibuat tanpa mekanisme stop, lalu menunggu ticker, channel, atau retry loop tanpa akhir yang jelas.

Reproduksi Minimal: Contoh Sebelum Perbaikan

Contoh berikut bukan replika lengkap perilaku Fiber internal, tetapi cukup untuk menunjukkan pola kebocoran: handler membuat context timeout, menjalankan goroutine yang menunggu operasi lambat, tetapi goroutine tidak berhenti saat request selesai dan cancel tidak dipanggil.

package main

import (
    "context"
    "log"
    "runtime"
    "time"

    "github.com/gofiber/fiber/v2"
)

func main() {
    app := fiber.New()

    app.Get("/leak", func(c *fiber.Ctx) error {
        ctx, _ := context.WithTimeout(context.Background(), 2*time.Second)

        resultCh := make(chan string, 1)

        go func() {
            // Simulasi operasi lambat yang seharusnya berhenti jika request selesai.
            select {
            case <-time.After(30 * time.Second):
                resultCh <- "done"
            case <-ctx.Done():
                return
            }
        }()

        select {
        case res := <-resultCh:
            return c.SendString(res)
        case <-time.After(100 * time.Millisecond):
            // Handler selesai cepat, tetapi goroutine di atas masih bisa hidup lama.
            return c.Status(fiber.StatusGatewayTimeout).SendString("timeout")
        }
    })

    go func() {
        for range time.Tick(5 * time.Second) {
            log.Printf("goroutines=%d", runtime.NumGoroutine())
        }
    }()

    log.Fatal(app.Listen(":3000"))
}

Ada dua masalah penting di sini:

  1. cancel tidak pernah dipanggil, sehingga resource terkait context baru benar-benar selesai ketika timeout internal tercapai.
  2. Handler selesai dalam 100 ms, tetapi goroutine bisa tetap menunggu sampai 30 detik atau sampai timeout context 2 detik. Pada trafik tinggi, akumulasi goroutine seperti ini cukup untuk merusak performa.

Pada kasus nyata, operasi di dalam goroutine biasanya bukan time.After, melainkan query database, request HTTP, polling channel, atau retry loop.

Mengapa Ini Terjadi di Go Fiber?

Fiber adalah framework HTTP yang cepat, tetapi kebocoran goroutine di atas bukan masalah unik Fiber. Penyebab utamanya adalah pengelolaan lifecycle context di level aplikasi. Karena itu, perbaikannya juga harus dilakukan di kode handler, service, repository, dan integrasi I/O.

Yang perlu diperhatikan adalah: jangan mengasumsikan bahwa semua pekerjaan turunan akan otomatis berhenti hanya karena handler sudah mengembalikan response. Jika goroutine Anda memakai context yang salah, tidak punya timeout, atau tidak memeriksa ctx.Done(), maka pekerjaan itu bisa terus hidup di belakang layar.

Catatan: jika Anda membuat context turunan sendiri dengan WithCancel atau WithTimeout, biasakan selalu memanggil cancel. Ini penting bukan hanya untuk pembatalan, tetapi juga untuk melepas resource internal yang terkait dengan timer dan propagasi context.

Teknik Investigasi di Produksi dan Staging

1. Pantau runtime.NumGoroutine

Langkah paling cepat adalah mencatat jumlah goroutine secara periodik. Ini bukan alat diagnosis final, tetapi bagus sebagai sinyal awal.

go func() {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        log.Printf("goroutines=%d", runtime.NumGoroutine())
    }
}()

Kalau nilainya terus naik saat beban stabil, atau tidak turun setelah load test berhenti, ada indikasi kebocoran.

2. Gunakan pprof untuk Melihat Tumpukan Goroutine

pprof adalah alat paling berguna untuk melihat goroutine sedang menunggu di mana. Integrasikan endpoint profiling sesuai kebijakan keamanan internal Anda, lalu ambil profil goroutine saat sistem mulai melambat.

Yang dicari biasanya adalah pola stack trace berulang, misalnya banyak goroutine berhenti di:

  • HTTP client yang menunggu response,
  • driver database yang menunggu network read,
  • receive atau send pada channel,
  • select yang tidak pernah menerima sinyal selesai.

Jika puluhan atau ratusan goroutine memiliki stack yang hampir sama dan berasal dari handler yang sama, Anda sudah dekat dengan sumber masalah.

3. Tambahkan Request ID ke Log

Tanpa request correlation, sulit membedakan goroutine mana yang masih hidup dari request mana. Simpan request ID di context atau log setiap tahap penting:

  • request masuk,
  • sub-call ke DB atau HTTP upstream dimulai,
  • timeout/cancel terjadi,
  • goroutine keluar.

Tujuannya bukan membuat log sangat ramai, tetapi memberi jejak yang cukup untuk menjawab pertanyaan: apakah pekerjaan turunan berhenti saat request selesai?

4. Trace Sederhana di Sekitar Goroutine

Untuk kasus sulit, log lifecycle goroutine secara eksplisit:

go func(reqID string) {
    log.Printf("req_id=%s worker=start", reqID)
    defer log.Printf("req_id=%s worker=stop", reqID)

    select {
    case <-ctx.Done():
        log.Printf("req_id=%s worker=ctx_done err=%v", reqID, ctx.Err())
        return
    case msg := <-workCh:
        log.Printf("req_id=%s worker=got_msg msg=%s", reqID, msg)
    }
}(reqID)

Jika log worker=start terus muncul tetapi worker=stop tidak pernah mengikuti dalam jumlah seimbang, itu pertanda kuat goroutine tidak keluar dengan benar.

Perbaikan: Propagasi Context yang Benar

Prinsip dasarnya sederhana: semua operasi turunan request harus menerima context yang sama atau turunan yang masih terhubung dengan lifecycle request. Selain itu, setiap context turunan yang membuat cancel function harus diakhiri dengan defer cancel().

Contoh Sesudah Perbaikan

package main

import (
    "context"
    "log"
    "runtime"
    "time"

    "github.com/gofiber/fiber/v2"
)

func main() {
    app := fiber.New()

    app.Get("/fixed", func(c *fiber.Ctx) error {
        reqID := c.Get("X-Request-ID")
        if reqID == "" {
            reqID = "generated-id"
        }

        // Gunakan context request yang relevan untuk operasi turunan.
        baseCtx := context.Background()
        ctx, cancel := context.WithTimeout(baseCtx, 2*time.Second)
        defer cancel()

        resultCh := make(chan string, 1)

        go func() {
            log.Printf("req_id=%s worker=start", reqID)
            defer log.Printf("req_id=%s worker=stop", reqID)

            select {
            case <-time.After(30 * time.Second):
                select {
                case resultCh <- "done":
                case <-ctx.Done():
                    return
                }
            case <-ctx.Done():
                return
            }
        }()

        select {
        case res := <-resultCh:
            return c.SendString(res)
        case <-ctx.Done():
            return c.Status(fiber.StatusGatewayTimeout).SendString(ctx.Err().Error())
        }
    })

    go func() {
        ticker := time.NewTicker(5 * time.Second)
        defer ticker.Stop()
        for range ticker.C {
            log.Printf("goroutines=%d", runtime.NumGoroutine())
        }
    }()

    log.Fatal(app.Listen(":3000"))
}

Perbaikan di atas bekerja karena:

  • defer cancel() memastikan context dibersihkan saat handler selesai, bahkan jika return lebih awal.
  • Goroutine memeriksa ctx.Done(), sehingga ia punya jalur berhenti yang eksplisit.
  • Saat mengirim ke channel, kode juga memakai select agar tidak macet jika penerima sudah tidak menunggu.

Dalam aplikasi nyata, baseCtx sebaiknya berasal dari context request yang memang mewakili lifecycle permintaan, lalu diteruskan ke layer service, repository, dan client eksternal. Hindari mengganti semuanya dengan context.Background() di tengah jalan, karena itu memutus rantai pembatalan.

Kasus Umum: DB, HTTP Client, dan Channel

DB Query

Jika driver atau library database mendukung operasi berbasis context, selalu gunakan varian yang menerima ctx. Jika tidak, timeout request di level handler tidak otomatis menghentikan query yang sedang berjalan.

Kesalahan umum: repository menerima context.Context, tetapi implementasinya malah membuat context baru dari context.Background(). Ini membuat pembatalan dari request tidak pernah sampai ke DB call.

HTTP Client ke Upstream

Untuk panggilan HTTP keluar, gabungkan dua lapisan proteksi:

  • context timeout/cancel untuk lifecycle per request,
  • timeout di client/transport untuk mencegah koneksi menggantung terlalu lama pada level jaringan.

Jangan hanya mengandalkan salah satunya. Context membantu pembatalan yang terikat ke request, sedangkan timeout di client memberi batas keras untuk operasi jaringan tertentu.

Channel dan Worker Internal

Operasi channel sering jadi sumber kebocoran yang tersembunyi. Misalnya, goroutine menunggu data dari channel kerja, tetapi saat request selesai tidak ada sinyal stop. Solusinya:

  • gunakan select dengan ctx.Done(),
  • pastikan ada pihak yang menutup channel jika itu bagian dari desain,
  • hindari goroutine per request untuk pekerjaan yang sebenarnya lebih cocok dikelola oleh worker pool jangka panjang.

Trade-off dan Batasan

Tidak semua goroutine yang bertahan lama adalah bug. Ada goroutine yang memang sengaja hidup sepanjang umur proses, seperti worker pool global, scheduler internal, atau koneksi pemantauan. Karena itu, fokus investigasi harus pada:

  • goroutine yang bertambah seiring jumlah request,
  • stack trace yang menunjukkan asal dari endpoint tertentu,
  • goroutine yang tidak kembali turun setelah request selesai.

Selain itu, menambahkan timeout terlalu agresif juga punya trade-off. Timeout yang terlalu pendek bisa meningkatkan error pada upstream yang sebenarnya sehat tetapi sesekali lambat. Jadi, tetapkan timeout berdasarkan karakteristik operasi, bukan angka acak yang sama untuk semua call.

Checklist Pencegahan

  1. Setiap context.WithCancel, WithTimeout, atau WithDeadline harus punya defer cancel() atau pemanggilan cancel() yang jelas.
  2. Jangan mengganti context request dengan context.Background() di layer service atau repository kecuali memang disengaja dan dipahami risikonya.
  3. Semua goroutine turunan request harus punya jalur keluar melalui ctx.Done().
  4. Saat kirim/terima dari channel, gunakan select jika ada kemungkinan pihak lain sudah tidak menunggu.
  5. Terapkan timeout eksplisit pada operasi DB dan HTTP client.
  6. Pantau runtime.NumGoroutine() sebagai metrik dasar.
  7. Sediakan akses pprof secara aman untuk analisis saat insiden.
  8. Tambahkan request ID agar lifecycle pekerjaan turunan bisa ditelusuri.
  9. Hindari membuat goroutine per request jika pekerjaan bisa dikerjakan sinkron atau lewat worker pool terkontrol.

Langkah Debugging yang Praktis

  1. Catat baseline jumlah goroutine saat sistem idle.
  2. Lakukan load test ringan ke endpoint yang dicurigai.
  3. Amati apakah goroutine naik terus dan tidak kembali turun.
  4. Ambil goroutine profile lewat pprof.
  5. Kelompokkan stack trace yang dominan: DB, HTTP, channel, sleep, atau select.
  6. Telusuri handler dan service yang membuat context turunan atau goroutine baru.
  7. Tambahkan log request ID + start/stop worker.
  8. Perbaiki dengan propagasi context, defer cancel(), timeout, dan select pada ctx.Done().
  9. Ulangi load test dan pastikan jumlah goroutine kembali stabil.

Penutup

Debug goroutine bocor dari context yang tak dibatalkan di Go Fiber hampir selalu berujung pada disiplin lifecycle: context harus dipropagasikan dengan benar, context turunan harus dibatalkan, dan setiap goroutine harus tahu kapan harus berhenti. Gejala seperti memori naik, goroutine bertambah, latency memburuk, dan worker tertinggal bukan masalah terpisah; sering kali semuanya berasal dari request-scope work yang tidak ikut selesai saat request berakhir.

Kalau Anda menangani insiden seperti ini, mulai dari bukti yang paling murah: runtime.NumGoroutine(), log request ID, dan pprof. Setelah pola stack trace terlihat, perbaikannya biasanya jelas dan terukur. Dalam banyak kasus, satu defer cancel() yang hilang atau satu goroutine tanpa ctx.Done() sudah cukup untuk membuat sistem tampak sehat di awal, lalu pelan-pelan runtuh di bawah beban.