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.ReadAllmengalokasikan 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
[]bytemenyalin data ke heap. Selanjutnya,json.Unmarshalkembali 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.pprofGunakan 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: 128MiBKombinasi GOMEMLIMIT, pembatasan http.MaxBytesReader, dan pembacaan stream melalui json.NewDecoder mengeliminasi lonjakan memori tak terkontrol pada aplikasi Go di lingkungan container.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!