Gejala Masalah: OOMKilled (Exit Code 137) di Kubernetes

Pod Go dengan batas memori ketat (resources.limits.memory: 256MiB) mengalami crash restart berulang kali dengan status OOMKilled dan exit code 137. Masalah ini timbul saat endpoint menerima lonjakan request batch payload secara bersamaan.

Metrik cAdvisor pada Prometheus menunjukkan lonjakan curam pada container_memory_working_set_bytes hingga menabrak ambang batas 256MiB dalam hitungan milidetik. Go runtime tidak sempat mengeksekusi Garbage Collector (GC) karena alokasi memori meledak seketika di tingkat kernel (cgroup).

Root Cause: Mekanisme Alokasi Memori io.ReadAll

Penyebab utama ada pada pemrosesan HTTP body menggunakan io.ReadAll(r.Body) sebelum memanggil json.Unmarshal. Pola ini memicu masalah alokasi berlipat ganda:

  • Eksplosif Buffer Growth: io.ReadAll mengalokasikan slice byte awal yang kecil dan memperbesarnya secara eksponensial (biasanya 2x lipat) setiap kali kapasitas penuh saat membaca stream. Jika payload berukuran 10MiB, memori yang dialokasikan selama proses penyesuaian ukuran buffer bisa mencapai total akumulatif lebih dari 20-30MiB sebelum buffer final terbentuk.
  • Duplikasi Alokasi Heap: Membaca seluruh data ke slice []byte menyalin data ke heap. Selanjutnya, json.Unmarshal kembali mengalokasikan memori baru untuk struktur Go (struct, map, string, slice) yang menampung representasi objek tersebut. Memori yang dibutuhkan seketika melonjak minimal dua kali lipat ukuran payload mentah.
  • Bypass Runtime GC via Cgroup Limit: Pada pod dengan konkurensi 10–20 request bersamaan, lonjakan memori sementara (burst allocation) langsung melampaui limit cgroup v2 Linux. Kernel OOM Killer langsung menghentikan proses Go (SIGKILL) tanpa memberikan kesempatan bagi runtime Go untuk memicu GC.

Profiling Alokasi Heap Menggunakan pprof

Jalankan profiling heap untuk mengonfirmasi titik asal alokasi memori berlebih. Aktifkan endpoint net/http/pprof di aplikasi.

import _ "net/http/pprof"

// Jalankan pprof server pada port terpisah
go func() {
	_ = http.ListenAndServe("localhost:6060", nil)
}()

Ambil profil memori saat beban batch request dikirimkan ke pod:

curl -sK http://localhost:6060/debug/pprof/heap > heap.pprof
go tool pprof -alloc_space heap.pprof

Gunakan perintah top dan list di dalam shell pprof interaktif untuk memeriksa call trace:

(pprof) top 5
Showing nodes accounting for 412.50MB, 88.5% of 466.00MB total
      flat  flat%   sum%        cum   cum%  
  280.50MB 60.19% 60.19%   280.50MB 60.19%  bytes.makeSlice
  132.00MB 28.33% 88.52%   412.50MB 88.52%  io.ReadAll

(pprof) list handleBatchUpload
Total: 466.00MB
ROUTINE ======================== main.handleBatchUpload
  132.00MB   412.50MB (flat, cum) 88.52% of Total
         .          .     28: func handleBatchUpload(w http.ResponseWriter, r *http.Request) {
  132.00MB   412.50MB     29:	data, err := io.ReadAll(r.Body)
         .          .     30:	defer r.Body.Close()

Profil di atas membuktikan bahwa alokasi terbanyak berasal dari bytes.makeSlice yang dipanggil secara internal oleh io.ReadAll.

Perbandingan Implementasi: Sebelum vs Sesudah

Kode Bermasalah (io.ReadAll + json.Unmarshal)

Kode ini memuat seluruh stream ke RAM sekaligus tanpa batas ukuran input.

func handleBatchUpload(w http.ResponseWriter, r *http.Request) {
	defer r.Body.Close()

	// BAHAYA: Mengalokasikan seluruh body di heap sekaligus
	bodyBytes, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Gagal membaca body", http.StatusBadRequest)
		return
	}

	var payload BatchPayload
	// Duplikasi alokasi memori kedua kalinya
	if err := json.Unmarshal(bodyBytes, &payload); err != nil {
		http.Error(w, "Invalid JSON", http.StatusUnprocessableEntity)
		return
	}

	process(payload)
	w.WriteHeader(http.StatusOK)
}

Kode Perbaikan (http.MaxBytesReader + json.NewDecoder)

Perbaikan dilakukan dengan membatasi payload secara eksplisit dan membaca stream secara inkremental.

func handleBatchUploadSecure(w http.ResponseWriter, r *http.Request) {
	// 1. Batasi ukuran request agar pod tidak diserang payload besar (> 10MiB)
	const maxPayloadSize = 10 << 20 // 10 MiB
	r.Body = http.MaxBytesReader(w, r.Body, maxPayloadSize)
	defer r.Body.Close()

	// 2. Decode langsung dari io.Reader stream tanpa buffer byte ganda
	var payload BatchPayload
	decoder := json.NewDecoder(r.Body)
	decoder.DisallowUnknownFields()

	if err := decoder.Decode(&payload); err != nil {
		http.Error(w, "Payload tidak valid atau melampaui batas", http.StatusBadRequest)
		return
	}

	process(payload)
	w.WriteHeader(http.StatusOK)
}

Optimasi Lanjutan: sync.Pool untuk Raw Buffer

Gunakan sync.Pool jika bisnis proses mewajibkan penyimpanan byte array mentah (misalnya untuk kalkulasi checksum HMAC atau logging raw payload) tanpa membebani runtime allocator secara terus menerus.

var bufferPool = sync.Pool{
	New: func() any {
		// Sediakan buffer berukuran 64KiB siap pakai
		buf := make([]byte, 64*1024)
		return &buf
	},
}

func readPayloadPooled(r *http.Request) ([]byte, error) {
	bufPtr := bufferPool.Get().(*[]byte)
	buf := *bufPtr
	defer func() {
		*bufPtr = buf[:0] // Reset index
		bufferPool.Put(bufPtr)
	}()

	// Salin stream ke buffer pool secara terkendali
	n, err := io.ReadFull(r.Body, buf)
	if err != nil && err != io.ErrUnexpectedEOF {
		return nil, err
	}
	return buf[:n], nil
}

Konfigurasi GOMEMLIMIT di Lingkungan Kubernetes

Mulai Go 1.19, tetapkan environment variable GOMEMLIMIT pada manifest pod Kubernetes. Ini membuat GC Go bertindak proaktif saat memori mendekati batas pod sebelum Linux cgroup memicu OOMKilled.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: go-backend
spec:
  template:
    spec:
      containers:
      - name: api
        image: api-service:latest
        env:
        # Berikan ruang toleransi 15-20% di bawah cgroup memory limit
        - name: GOMEMLIMIT
          value: "210MiB"
        resources:
          limits:
            memory: 256MiB
          requests:
            memory: 128MiB

Kombinasi GOMEMLIMIT, pembatasan http.MaxBytesReader, dan pembacaan stream melalui json.NewDecoder mengeliminasi lonjakan memori tak terkontrol pada aplikasi Go di lingkungan container.