Gejala Insiden: Worker Terbunuh SIGKILL (Exit Code 137)

Pada background worker (misalnya Celery) atau Django management command yang memproses dataset ratusan ribu hingga jutaan baris, sering ditemukan pola kegagalan proses yang terhenti mendadak. Di environment Linux/container, sistem operasi mengirimkan sinyal SIGKILL dan kontainer berhenti dengan Exit Code 137 (128 + 9).

Grafik metrik sistem menunjukkan pola alokasi memori (Resident Set Size / RSS) yang meningkat linear terhadap waktu hingga menyentuh cgroup limit kernel. Linux Out-Of-Memory (OOM) Killer mengidentifikasi proses worker sebagai konsumen heap terbesar dan mematikannya untuk menjaga stabilitas kernel.

Root Cause Teknis Alokasi Memori Django

1. Mekanisme Internal QuerySet: _result_cache

Secara default, Django QuerySet bersifat lazy dan caching. Ketika QuerySet dievaluasi (misalnya melalui perulangan for row in Order.objects.all():), Django mengeksekusi query database, mengambil seluruh baris data, dan menginstansiasi objek model Python satu per satu.

Setiap instansiasi model disimpan ke dalam atribut internal queryset._result_cache. Mekanisme ini berguna pada skenario web request standar agar eksekusi ulang pada QuerySet yang sama tidak memicu round-trip database tambahan. Namun, pada background worker yang memproses 1.000.000 baris:

  • Setiap objek Python membawa overhead representasi internal (struktur PyObject, dictionary atribut model __dict__, sinyal field, dan tracking perubahan status).
  • Semua instans model ditahan dalam memori hingga siklus hidup variabel QuerySet selesai. Garbage collector tidak dapat mereklamasi memori tersebut karena referensi masih terikat kuat pada _result_cache.

2. Akumulasi Memori pada connection.queries

Jika worker dijalankan di environment dengan konfigurasi settings.DEBUG = True, Django secara otomatis mencatat setiap query SQL yang dieksekusi ke dalam list in-memory di django.db.connection.queries.

Pada long-running task atau loop batching yang mengeksekusi ribuan query berulang, daftar string query SQL ini terus bertambah tanpa batas, menyebabkan unintentional memory leak meskipun objek QuerySet utama sudah diolah.

Isolasi Alokasi Heap Menggunakan tracemalloc

Untuk membuktikan bahwa alokasi memori didominasi oleh in-memory cache ORM Django, gunakan modul standard library Python tracemalloc untuk melacak baris kode yang mengalokasikan heap terbesar.

import tracemalloc
from myapp.models import Order

tracemalloc.start()
snapshot_before = tracemalloc.take_snapshot()

# Simulasi loop QuerySet standar tanpa iterator
orders = Order.objects.filter(status="PENDING")
for order in orders:
    _ = order.total_amount

snapshot_after = tracemalloc.take_snapshot()
top_stats = snapshot_after.compare_to(snapshot_before, "lineno")

print("[ Top 5 Memory Consumers ]")
for stat in top_stats[:5]:
    print(stat)

Output tipikal akan mengarah langsung pada file internal Django ORM:

[ Top 5 Memory Consumers ]
.../django/db/models/query.py:72: size=485 MiB (+485 MiB), count=1000000 (+1000000)
.../django/db/models/base.py:534: size=120 MiB (+120 MiB), count=1000000 (+1000000)

Snapshot tersebut mengonfirmasi bahwa alokasi 485 MiB berasal dari query.py tempat penyimpanan instansiasi model ke dalam _result_cache.

Implementasi Solusi: Streaming QuerySet & Memory Hygiene

1. Pemanfaatan .iterator(chunk_size=...)

Method .iterator() memberitahu Django ORM untuk membaca data secara bertahap langsung dari cursor tanpa menyimpan hasilnya ke _result_cache. Objek model diinstansiasi satu per satu, diproses dalam loop, lalu referensinya dilepas sehingga garbage collector dapat membersihkan memori secara berkala.

Pada database backend seperti PostgreSQL, memberikan parameter chunk_size akan mengaktifkan server-side cursor (bernama). Database memegang result-set dan hanya mentransfer sejumlah record sesuai ukuran chunk melalui network buffer.

# Memproses batch besar dengan memory footprint konstan O(1)
CHUNK_SIZE = 2000
orders = Order.objects.filter(status="PENDING").iterator(chunk_size=CHUNK_SIZE)

for order in orders:
    process_order(order)
    # Objek 'order' dilepas dari referensi pada iterasi berikutnya

2. Proyeksi Kolom: .only(), .values(), atau .values_list()

Jika logika bisnis hanya memerlukan 2 field dari tabel yang memiliki 40 kolom, hindari memuat seluruh kolom. Hindari instansiasi model penuh jika pemrosesan berbasis dictionary atau tuple sudah mencukupi:

# Jauh lebih hemat memori dibanding menginstansiasi Model instances
orders = (
    Order.objects.filter(status="PENDING")
    .values("id", "total_amount")
    .iterator(chunk_size=2000)
)

for row in orders:
    update_ledger(row["id"], row["total_amount"])

3. Pembersihan Query Log dan Koneksi Database

Pada skrip worker yang mengeksekusi sub-queries atau update di dalam perulangan batch, pastikan memory logging dibersihkan secara periodik:

from django.db import connection, reset_queries
from django.conf import settings

def run_large_batch():
    # Pastikan DEBUG dimatikan di environment production
    if settings.DEBUG:
        reset_queries()

    # Eksekusi batch
    for order in Order.objects.filter(status="PENDING").iterator(chunk_size=2000):
        order.process()
        
        # Reset log query jika DEBUG tidak sengaja aktif
        if settings.DEBUG:
            reset_queries()
            
    # Tutup koneksi lama untuk melepaskan resource DB socket
    connection.close_if_unusable_or_obsolete()

Verifikasi dan Trade-offs

Hasil Profiling Pasca-Perbaikan

Dengan menerapkan .iterator(chunk_size=2000) pada dataset 1.000.000 record:

  • Penggunaan Memori (RSS): Turun dari lonjakan > 1.5 GB menjadi konstan di kisaran 45-60 MB selama seluruh proses berjalan.
  • Status Eksekusi: Selesai tuntas tanpa intervensi OOM Killer (Exit Code 0).

Trade-offs yang Perlu Diperhatikan

  1. Tidak Ada Caching: Jika QuerySet yang menggunakan .iterator() diiterasi untuk kedua kalinya, Django akan menembak query ulang ke database.
  2. Server-Side Cursor Lock & Resource: Server-side cursor menahan koneksi database tetap aktif sepanjang iterasi. Pastikan koneksi tidak terkena statement_timeout atau idle_in_transaction_session_timeout jika pemrosesan setiap chunk memakan waktu CPU yang lama.
  3. Relasi Eager Loading (prefetch_related): Pada Django versi sebelum 4.1, prefetch_related() tidak kompatibel dengan .iterator(). Mulai Django 4.1+, prefetch_related() didukung pada .iterator() dengan syarat nilai chunk_size wajib ditentukan eksplisit.