Crash akibat Out of Memory (OOM) dengan exit code 137 merupakan masalah umum pada aplikasi Next.js App Router yang menangani upload atau proxy file berukuran besar. Masalah ini terjadi ketika Route Handler menampung data payload langsung di dalam V8 heap memory alih-alih mengalirkannya (streaming) secara berkesinambungan ke storage target.
Gejala Insiden: Exit Code 137 dan V8 Heap Exhaustion
Pada lingkungan berbasis container seperti Kubernetes atau Docker, kernel Linux mengeksekusi OOM Killer saat pemakaian memori container melampaui limit yang ditentukan di konfigurasi resource. Proses dihentikan paksa melalui sinyal SIGKILL (sinyal 9), menghasilkan exit code 137 (128 + 9).
Karakteristik metrik sistem saat insiden terjadi meliputi:
- Lonjakan V8 Heap: Metrik
nodejs_heap_size_used_bytesnaik tajam secara linier sebanding dengan ukuran file yang diunggah dikalikan jumlah koneksi paralel. - Garbage Collection (GC) Thrashing: V8 GC berjalan terus-menerus (high CPU usage pada GC pause) mencoba membebaskan memori yang masih memiliki referensi aktif.
- Kematian Berkala: Pod atau container mengalami restart mendadak tepat saat beban concurrent upload meningkat.
Investigasi Root Cause: In-Memory Buffering Tanpa Backpressure
Penyebab utama kebocoran memori ini adalah konsumsi body request menggunakan metode yang memaksa seluruh payload dimuat ke RAM. Contoh metode ini adalah request.arrayBuffer(), request.blob(), atau penggabungan manual chunk menggunakan Buffer.concat().
Ketika client mengirim file sebesar 500 MB dan Route Handler membaca seluruh konten ke dalam buffer, minimal 500 MB memori dialokasikan langsung pada V8 heap. Jika 4 request berjalan bersamaan pada container dengan limit RAM 1 GB, alokasi memori melampaui batas dan container langsung dihentikan.
Masalah kedua adalah hilangnya backpressure. Backpressure adalah mekanisme sinyal yang memberi tahu data producer (client upload) untuk menahan laju transfer jika consumer (penulisan ke target storage seperti S3 atau disk) lebih lambat dari laju transfer jaringan masuk. Tanpa backpressure, chunk data akan menumpuk di antrean memori internal runtime.
Perbandingan Kode: Buffer vs Stream
Kode di bawah menunjukkan kesalahan fatal akumulasi buffer dibandingkan implementasi streaming native.
Sebelum: Anti-Pattern Menggunakan in-memory Buffer
// app/api/upload/route.ts (Anti-pattern: Menyebabkan OOM)
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
try {
// FATAL: Membaca seluruh stream ke RAM sekaligus
const blob = await req.arrayBuffer();
const buffer = Buffer.from(blob);
// Simulasi penyimpanan data utuh
await saveToStorage(buffer);
return NextResponse.json({ success: true, size: buffer.length });
} catch (error) {
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}
async function saveToStorage(data: Buffer) {
// Operasi I/O yang memakan buffer penuh
return Promise.resolve();
}
Sesudah: Solusi Streaming dengan Web Streams API
// app/api/upload/route.ts (Efisien: RAM stabil < 50 MB)
import { NextRequest, NextResponse } from 'next/server';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { Readable } from 'node:stream';
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function POST(req: NextRequest) {
if (!req.body) {
return NextResponse.json({ error: 'Empty body' }, { status: 400 });
}
try {
// Konversi Web ReadableStream ke Node.js Readable stream
// Menjaga backpressure native tetap bekerja
const nodeReadable = Readable.fromWeb(req.body as any);
// lib-storage menangani multipart upload otomatis via stream chunking
const parallelUpload = new Upload({
client: s3,
params: {
Bucket: process.env.S3_BUCKET_NAME!,
Key: `uploads/${crypto.randomUUID()}`,
Body: nodeReadable,
},
// Tuning buffer per-part di memory (default: 5MB)
partSize: 5 * 1024 * 1024,
queueSize: 4, // Maksimal 4 part di-buffer bersamaan
});
await parallelUpload.done();
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Stream error' }, { status: 500 });
}
}
Tuning Backpressure dan highWaterMark
Jika routing memerlukan transformasi data sebelum diteruskan, gunakan TransformStream native dengan konfigurasi highWaterMark yang terukur. Nilai highWaterMark menentukan batas kapasitas antrean internal chunk sebelum pembacaan dari upstream dijeda.
// Contoh kontrol buffer antrean menggunakan TransformStream
const transformer = new TransformStream(
{
transform(chunk, controller) {
// Modifikasi chunk data tanpa akumulasi
controller.enqueue(chunk);
},
},
// QueuingStrategy untuk backpressure
new ByteLengthQueuingStrategy({ highWaterMark: 64 * 1024 }) // Batas antrean 64 KB
);
await req.body.pipeThrough(transformer).pipeTo(writableDestinationStream);
Verifikasi Profil Memori Menggunakan Node.js Heap Dump
Untuk memastikan tidak ada referensi tertinggal yang mencegah GC membebaskan memori, lakukan profiling heap langsung pada runtime Node.js Next.js.
- Jalankan server Next.js dengan flag inspect aktif:
NODE_OPTIONS="--inspect=0.0.0.0:9229" next start - Buka Google Chrome dan navigasikan ke URL
chrome://inspect. Klik Configure lalu daftarkan target IP/port server. - Jalankan load test menggunakan alat benchmark seperti k6 atau autocannon yang mengirimkan multipart payload atau file stream secara serentak.
- Ambil heap snapshot pada tab Memory di Chrome DevTools pada tiga fase: baseline (idle), saat puncak load concurrency, dan sesudah load selesai.
Periksa objek pada tabel Constructor. Pada implementasi yang bocor, objek tipe ArrayBuffer, Uint8Array, atau Buffer akan mendominasi retainers list dan tidak berkurang pasca-eksekusi. Pada implementasi streaming murni, heap size tetap berada pada baseline datar (flat line) tanpa terpengaruh ukuran file masuk.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!