Saat service Go Fiber menerima upload JSON besar dalam volume tinggi, gejala yang sering muncul bukan langsung crash, melainkan pola yang memburuk perlahan: RSS naik terus, GC makin sering, latency membengkak, lalu pod atau container restart karena OOM. Dalam banyak kasus, masalahnya bukan memory leak klasik karena objek tidak pernah dibebaskan, tetapi retention: body request atau buffer byte tetap tertahan lebih lama dari yang dibutuhkan karena pola parsing, logging, caching, atau penyimpanan referensi slice.
Artikel ini membahas cara debug memory leak dari bodyParser dan buffer request di Go Fiber secara praktis. Fokusnya adalah studi kasus backend nyata: bagaimana mengenali gejala, mereproduksi masalah secara lokal, membaca profil memori dengan pprof, memeriksa metrik runtime Go, dan menerapkan perbaikan yang aman tanpa menebak-nebak.
Gejala yang Terlihat di Produksi
Kasus umumnya terlihat seperti ini:
- Endpoint upload JSON bekerja normal saat traffic rendah.
- Saat ukuran payload membesar dan concurrency meningkat, penggunaan memori proses naik terus.
- Garbage collector berjalan lebih sering, terlihat dari pause atau CPU yang meningkat.
- Latency P95/P99 ikut naik karena proses parsing dan tekanan GC.
- Akhirnya proses dibunuh oleh OOM killer atau pod direstart oleh orchestrator.
Yang perlu dicatat, RSS yang tidak segera turun bukan otomatis bug. Go runtime memang mengelola heap sendiri dan tidak selalu langsung mengembalikan memori ke OS. Namun jika heap live set terus membesar, jumlah alokasi melonjak, dan objek besar tampak bertahan lintas request, itu tanda kuat ada retensi data request yang tidak perlu.
Akar Masalah Teknis: Body Request Ditahan Terlalu Lama
Pada alur request JSON, ada beberapa pola yang sering menjadi akar masalah:
1. Menyimpan referensi body atau slice byte setelah request selesai
Contoh pola berbahaya:
type Job struct {
Raw []byte
}
func handler(c *fiber.Ctx) error {
body := c.Body()
// Buruk: menyimpan referensi body request untuk diproses async
queue = append(queue, Job{Raw: body})
return c.SendStatus(fiber.StatusAccepted)
}Masalahnya, []byte adalah slice yang menunjuk ke backing array. Jika slice itu disimpan ke antrean, cache, goroutine, atau channel untuk diproses belakangan, maka buffer besar dari request dapat tetap hidup lebih lama. Bahkan bila Anda hanya menyimpan sebagian kecil hasil potongan slice, backing array besar bisa ikut tertahan.
2. Parsing body lalu tetap menyimpan body mentah untuk logging, retry, atau audit
Pola lain yang sering terjadi:
type Payload struct {
UserID string `json:"user_id"`
}
func handler(c *fiber.Ctx) error {
var p Payload
if err := c.BodyParser(&p); err != nil {
return err
}
raw := c.Body()
saveForAudit(raw)
return c.JSON(fiber.Map{"ok": true})
}Di sini parsing sukses, tetapi body mentah masih diambil dan disimpan lagi. Jika payload besar, Anda membayar biaya memori dua kali: hasil parsing ke struct dan retensi body mentah. Bila audit memang perlu, simpan dalam bentuk yang lebih kecil, hash payload, metadata, atau tulis ke storage streaming, bukan menahan byte besar di memori aplikasi lebih lama dari perlu.
3. Menyalin buffer secara berulang tanpa kebutuhan jelas
Tidak semua copy itu buruk. Kadang copy justru diperlukan agar referensi tidak menunjuk ke buffer request. Yang berbahaya adalah copy yang tidak terkontrol, misalnya:
- Body disalin untuk logger.
- Disalin lagi untuk validasi.
- Disalin lagi untuk queue async.
- Disalin lagi untuk cache atau tracing.
Pada payload besar, tiap copy bisa memperbesar tekanan heap secara signifikan.
4. Menahan objek hasil parse terlalu lama
Bukan hanya []byte mentah yang bisa menahan memori. Struct hasil decode JSON juga dapat besar jika berisi array, map, atau string berukuran besar. Bila objek itu disimpan ke worker pool, antrean internal, atau in-memory cache tanpa batas, gejalanya mirip memory leak.
5. Menganggap body parser sebagai penyebab tunggal
BodyParser sering dicurigai lebih dulu karena muncul di jalur request, tetapi akar masalah umumnya ada pada retensi setelah parsing, bukan semata fungsi parser itu sendiri. Parser memang membuat alokasi untuk membentuk struct, tetapi seharusnya objek itu bisa dibersihkan GC jika tidak ada referensi yang masih hidup.
Langkah Investigasi yang Sistematis
Jangan langsung mengganti framework atau menebak-nebak. Investigasi yang efektif biasanya mengikuti urutan berikut.
1. Konfirmasi pola dengan metrik runtime Go
Ambil metrik seperti:
HeapAlloc: ukuran heap yang sedang terpakai.HeapInuse: halaman heap yang sedang digunakan.NumGC: frekuensi GC.PauseTotalNsatau metrik pause GC dari observability stack.- Jumlah goroutine bila ada proses async yang menahan payload.
Jika HeapAlloc dan live objects naik seiring traffic besar, lalu tidak turun setelah beban reda, itu petunjuk ada objek yang tertahan.
2. Tambahkan logging ukuran payload
Sering kali tim hanya tahu traffic naik, tetapi tidak tahu distribusi ukuran body. Log atau metrikkan ukuran payload per endpoint:
func requestSizeLogger(c *fiber.Ctx) error {
size := len(c.Body())
// Kirim ke logger atau metrics histogram
// contoh: upload_request_bytes.Observe(float64(size))
_ = size
return c.Next()
}Jangan log isi body untuk endpoint besar. Cukup ukuran, content type, status code, dan durasi request.
3. Aktifkan pprof dan ambil heap profile
Tujuannya bukan sekadar melihat total memory, tetapi siapa yang mengalokasikan dan siapa yang masih menahan objek. Di aplikasi Go, aktifkan endpoint pprof pada port internal yang tidak diekspos publik.
import (
"net/http"
_ "net/http/pprof"
)
func startPprof() {
go func() {
_ = http.ListenAndServe("127.0.0.1:6060", nil)
}()
}Lalu ambil profil saat beban sedang tinggi dan setelah beban selesai:
go tool pprof http://127.0.0.1:6060/debug/pprof/heap
go tool pprof -http=:8081 http://127.0.0.1:6060/debug/pprof/heapYang dicari:
- Fungsi di jalur parsing JSON atau utilitas penyimpanan payload.
- Alokasi besar dari
[]byte,string,map, atau array hasil decode. - Objek yang tetap hidup setelah traffic berhenti.
Heap profile menunjukkan objek yang masih hidup pada saat profil diambil, bukan semua alokasi historis. Untuk membedakan lonjakan alokasi sementara dan retensi nyata, bandingkan profil saat puncak traffic dan beberapa saat setelah traffic mereda.
4. Lihat allocs profile bila perlu
Jika heap profile belum jelas, profil alokasi total dapat membantu menemukan jalur yang paling boros walau objeknya tidak bertahan lama. Ini berguna saat latency naik karena terlalu banyak alokasi dan GC, meskipun bukan leak murni.
5. Reproduksi lokal dengan load test sederhana
Masalah ini jauh lebih mudah dipahami jika bisa direproduksi lokal. Buat payload JSON besar, lalu kirim dengan concurrency moderat sampai tinggi.
python - <<'PY'
import json
payload = {
"user_id": "123",
"items": [{"name": "x", "note": "a" * 5000} for _ in range(2000)]
}
with open("big.json", "w") as f:
json.dump(payload, f)
PY# contoh sederhana dengan xargs + curl
seq 1 200 | xargs -P 20 -I{} curl -s -o /dev/null \
-X POST http://127.0.0.1:3000/upload \
-H 'Content-Type: application/json' \
--data-binary @big.jsonAnda tidak butuh angka benchmark yang rumit. Yang penting adalah pola: apakah RSS, heap, dan GC bereaksi tajam saat endpoint menerima body besar.
Contoh Pola Kode yang Memicu Retensi
Contoh buruk: body disimpan untuk proses async
type UploadTask struct {
Payload []byte
}
var taskCh = make(chan UploadTask, 1000)
func uploadHandler(c *fiber.Ctx) error {
body := c.Body()
select {
case taskCh <- UploadTask{Payload: body}:
return c.SendStatus(fiber.StatusAccepted)
default:
return c.Status(fiber.StatusServiceUnavailable).SendString("queue full")
}
}Ini terlihat efisien karena tidak ada copy, tetapi justru berisiko menahan buffer request besar di memori hingga worker selesai. Jika channel menumpuk, memory akan naik cepat.
Versi yang lebih aman
Jika benar-benar perlu proses async, pilih salah satu:
- Simpan hanya field yang memang dibutuhkan, bukan body mentah penuh.
- Lakukan copy terkontrol ke buffer yang lebih kecil setelah diekstrak.
- Tulis payload ke storage sementara lalu antrekan referensinya.
type UploadPayload struct {
UserID string `json:"user_id"`
Items []struct {
Name string `json:"name"`
} `json:"items"`
}
type UploadTask struct {
UserID string
ItemCount int
}
var taskCh = make(chan UploadTask, 1000)
func uploadHandler(c *fiber.Ctx) error {
var p UploadPayload
if err := c.BodyParser(&p); err != nil {
return c.Status(fiber.StatusBadRequest).SendString("invalid json")
}
task := UploadTask{
UserID: p.UserID,
ItemCount: len(p.Items),
}
select {
case taskCh <- task:
return c.SendStatus(fiber.StatusAccepted)
default:
return c.Status(fiber.StatusServiceUnavailable).SendString("queue full")
}
}Perbaikan ini bekerja karena object graph yang diteruskan ke worker jauh lebih kecil. Setelah handler selesai dan tidak ada referensi lain, body request dan objek besar hasil parse bisa dibersihkan GC.
Perbaikan Aplikatif di Go Fiber
1. Batasi ukuran body request
Ini lapisan pertahanan pertama. Jika aplikasi memang tidak butuh JSON ratusan MB, jangan biarkan request sebesar itu masuk ke jalur parsing.
app := fiber.New(fiber.Config{
BodyLimit: 10 * 1024 * 1024, // contoh 10 MB
})Pilih batas berdasarkan kebutuhan bisnis nyata, bukan asal besar. Trade-off-nya jelas: terlalu kecil akan menolak request valid, terlalu besar membuat service rentan pada lonjakan memori.
2. Hindari retain body mentah bila tidak perlu
Prinsipnya sederhana:
- Parse body ke struct.
- Ambil hanya field yang diperlukan.
- Jangan simpan
c.Body()ke state jangka panjang kecuali benar-benar wajib.
Jika Anda perlu audit, pertimbangkan menyimpan metadata seperti:
- ukuran payload,
- hash payload,
- beberapa field penting,
- ID objek di object storage bila body harus disimpan utuh.
3. Waspadai slice yang menunjuk ke backing array besar
Contoh jebakan:
body := c.Body()
smallPart := body[:128]
cache = append(cache, smallPart)Meski hanya 128 byte yang dipakai, smallPart masih bisa menunjuk ke backing array besar dari body asli. Jika Anda memang perlu menyimpan potongan kecil secara terpisah, lakukan copy eksplisit:
b := c.Body()
small := append([]byte(nil), b[:128]...)
cache = append(cache, small)Trade-off-nya: ada alokasi baru kecil, tetapi Anda melepaskan referensi ke array besar, yang sering jauh lebih hemat memori secara keseluruhan.
4. Kurangi logging yang menyentuh body besar
Middleware logging kadang tanpa sengaja memperparah masalah, misalnya dengan mengubah body menjadi string atau menyimpan raw payload ke logger sinkron. Untuk endpoint upload besar, log yang ideal adalah metadata: ukuran, request ID, durasi, dan hasil validasi. Mengonversi body besar ke string juga menambah alokasi baru.
5. Gunakan alur streaming bila payload memang besar
Jika use case mengharuskan memproses data besar, pendekatan berbasis buffer penuh di memori bukan pilihan terbaik. Pertimbangkan alur streaming atau desain API yang menghindari JSON raksasa dalam satu request. Contohnya:
- Unggah file ke object storage, lalu kirim pointer atau manifest ke API.
- Pecah payload menjadi batch lebih kecil.
- Gunakan format input yang memungkinkan pemrosesan bertahap.
Streaming cocok dipilih bila ukuran data besar secara inheren, bukan sekadar outlier. Trade-off-nya adalah implementasi lebih kompleks dan validasi request bisa berubah.
6. Kendalikan antrean async
Jika handler meneruskan data ke worker:
- batasi ukuran channel atau queue,
- hindari menyimpan payload penuh di memory queue,
- gunakan backpressure atau tolak request ketika sistem sudah penuh.
Tanpa batas, antrean async sering menjadi tempat paling mudah bagi body besar untuk menumpuk.
Validasi Perbaikan: Jangan Berhenti di Asumsi
Setelah perbaikan diterapkan, ulangi load test yang sama dan ambil profil lagi. Yang ingin dilihat:
- Heap live set lebih stabil setelah beban selesai.
- Frekuensi GC menurun atau setidaknya tidak melonjak setajam sebelumnya.
- Latency membaik karena tekanan alokasi berkurang.
- Heap profile tidak lagi menunjukkan retensi besar dari body request atau jalur penyimpanan payload.
Bandingkan sebelum dan sesudah dengan cara yang sama: payload yang sama, concurrency yang sama, dan durasi test yang mirip. Tanpa pembandingan ini, mudah sekali menyimpulkan perbaikan padahal hanya kebetulan traffic berubah.
Checklist Debugging yang Praktis
- Catat gejala: RSS, restart OOM, peningkatan GC, dan latency.
- Identifikasi endpoint yang menerima body besar.
- Tambahkan metrik ukuran payload dan concurrency per endpoint.
- Aktifkan
pprofdan ambil heap profile saat puncak traffic. - Cari referensi
[]byte, string besar, map, atau hasil parse yang hidup terlalu lama. - Tinjau worker, queue, cache, logger, dan audit trail yang mungkin menyimpan body.
- Batasi body size dan hapus retensi buffer yang tidak perlu.
- Ulangi load test dan bandingkan profil sebelum/sesudah.
Kesalahan Umum yang Sering Terlewat
- Mengira semua kenaikan RSS adalah leak. Bisa jadi hanya heap growth sementara, jadi selalu cek profile dan metrik live heap.
- Menyimpan potongan kecil dari slice besar. Potongan kecil tetap dapat menahan backing array besar.
- Mengubah body ke string untuk logging. Ini menambah copy besar dan memperbesar tekanan heap.
- Mengantrekan payload penuh ke worker. Saat backlog naik, memory ikut naik.
- Hanya fokus pada parser. Sering kali yang bocor adalah lifecycle data setelah parsing.
Penutup
Debug memory leak dari bodyParser dan buffer request di Go Fiber hampir selalu berujung pada satu pertanyaan inti: data request ini sebenarnya hidup sampai kapan, dan siapa yang masih memegang referensinya? Begitu pertanyaan itu dijawab lewat pprof, metrik runtime Go, dan reproduksi lokal yang konsisten, akar masalah biasanya terlihat jelas.
Perbaikannya juga biasanya tidak rumit secara konsep: batasi ukuran body, jangan retain buffer mentah tanpa alasan kuat, hindari copy besar berulang, dan pindah ke alur streaming atau penyimpanan eksternal jika payload memang besar secara alami. Yang penting, selalu validasi hasilnya lewat profil sebelum dan sesudah, bukan berdasarkan dugaan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!