Lonjakan antrean background task di Celery sering memicu kegagalan database dengan pesan: FATAL: too many connections for role "app_user". Masalah ini biasanya bukan disebabkan oleh kebocoran memori (memory leak), melainkan retensi koneksi database terbuka (connection leak) yang timbul akibat ketidakcocokan antara siklus hidup worker Celery dan mekanisme CONN_MAX_AGE bawaan Django.
Gejala Sistem dan Anatomi Kegagalan
Masalah muncul saat lonjakan task (spike) terjadi. Backend API dan worker mulai mengalami timeout bertahap. Ketika kapasitas max_connections pada PostgreSQL tercapai, log PostgreSQL menampilkan catatan berikut:
FATAL: remaining connection slots are reserved for non-superuser connections
FATAL: too many connections for role "app_user"Dampaknya, request HTTP standar pada web server (Gunicorn/Uvicorn) yang memerlukan koneksi database baru akan langsung gagal dan mengembalikan HTTP 500 Internal Server Error, meski beban CPU dan RAM server aplikasi masih dalam batas aman.
Root Cause: Lifecycle HTTP Django vs Celery Worker
Django mengelola koneksi database persisten melalui parameter CONN_MAX_AGE di dalam DATABASES settings. Pembersihan koneksi usang bergantung langsung pada sinyal siklus request-response HTTP.
1. Mekanisme pada Web Server (WSGI/ASGI)
Pada alur HTTP standar, Django memanfaatkan handler sinyal berikut:
request_started: Memeriksa koneksi yang kadaluwarsa melaluidb.close_old_connections().request_finished: Menutup koneksi jika umurnya melebihi batasCONN_MAX_AGEatau menandainya untuk reuse.
Siklus ini memastikan koneksi selalu diuji dan ditutup secara terprediksi setelah respons dikirimkan ke client.
2. Masalah pada Worker Celery
Celery mengeksekusi task di luar siklus request-response HTTP. Saat Celery worker mengimpor konfigurasi Django, Celery mewarisi nilai CONN_MAX_AGE yang sama. Namun, Celery tidak menembakkan sinyal request_started maupun request_finished.
Ketika satu worker process mengeksekusi task yang mengakses ORM Django:
- Worker membuka koneksi TCP ke PostgreSQL.
- Task selesai dieksekusi, tetapi sinyal
request_finishedtidak pernah dipanggil. - Koneksi tetap berada dalam status
idledi PostgreSQL. - Worker beralih mengerjakan task berikutnya atau menunggu task baru tanpa pernah menutup koneksi tersebut.
Jika Celery berjalan dengan concurrency tinggi (misalnya 4 worker node dengan opsi --concurrency=16), sebanyak 64 koneksi langsung tertahan dalam status idle secara permanen. Jika diterapkan auto-scaling worker pod di Kubernetes, lonjakan pod akan melipatgandakan jumlah koneksi terbuka hingga melampaui limit PostgreSQL.
Investigasi Menggunakan pg_stat_activity
Identifikasi sumber koneksi yang tertahan menggunakan query views PostgreSQL berikut:
SELECT
pid,
usename,
application_name,
client_addr,
state,
state_change,
NOW() - state_change AS idle_duration
FROM pg_stat_activity
WHERE datname = 'nama_database_anda'
ORDER BY idle_duration DESC;Periksa distribusi koneksi berdasarkan state dan application name:
SELECT
application_name,
state,
COUNT(*)
FROM pg_stat_activity
WHERE datname = 'nama_database_anda'
GROUP BY application_name, state
ORDER BY count DESC;Jika ditemukan puluhan hingga ratusan baris dengan status state: idle yang berasal dari IP node Celery dengan idle_duration berjam-jam, dipastikan Celery tidak menutup koneksi database setelah task selesai.
Solusi dan Langkah Perbaikan
1. Implementasi Celery Signal Hooks (Wajib)
Paksa Django untuk membersihkan koneksi usang di setiap awal dan akhir eksekusi task Celery menggunakan sinyal task_prerun dan task_postrun.
Tambahkan konfigurasi berikut pada inisialisasi Celery aplikasi (misal di celery.py atau app/celery.py):
import os
from celery import Celery
from celery.signals import task_prerun, task_postrun
from django.db import close_old_connections
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
app = Celery('myproject')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
@task_prerun.connect
def on_task_prerun(*args, **kwargs):
# Tutup koneksi yang sudah invalid atau melebihi CONN_MAX_AGE sebelum task jalan
close_old_connections()
@task_postrun.connect
def on_task_postrun(*args, **kwargs):
# Tutup koneksi usang segera setelah task selesai dieksekusi
close_old_connections()Catatan Teknis: Panggilan
close_old_connections()memeriksaconnection.close_if_unusable_or_obsolete(). Jika umur koneksi melebihi batasCONN_MAX_AGE, socket TCP akan ditutup secara eksplisit.
2. Pisahkan Konfigurasi CONN_MAX_AGE untuk Celery
Pendekatan paling aman untuk background worker adalah menonaktifkan connection reuse secara default (CONN_MAX_AGE = 0). Ini memastikan koneksi langsung ditutup setelah digunakan, mencegah kebocoran koneksi antar-task.
Ubah konfigurasi pada settings.py:
import sys
# Nilai default untuk WSGI/ASGI web server
CONN_MAX_AGE = 60
# Override jika proses dijalankan via Celery CLI
IS_CELERY_PROCESS = any('celery' in arg for arg in sys.argv)
if IS_CELERY_PROCESS:
CONN_MAX_AGE = 0
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'myproject_db',
'USER': 'app_user',
'PASSWORD': 'secret_password',
'HOST': '127.0.0.1',
'PORT': '5432',
'CONN_MAX_AGE': CONN_MAX_AGE,
}
}3. Solusi Arsitektur: Gunakan PgBouncer
Mengatur CONN_MAX_AGE = 0 menimbulkan overhead TCP handshake dan TLS negotiation untuk setiap task yang dieksekusi. Pada sistem dengan throughput ribuan task per detik, overhead ini dapat membebani CPU PostgreSQL.
Gunakan connection pooler eksternal seperti PgBouncer dengan mode Transaction Pooling:
- Aplikasi Django dan Celery terhubung ke PgBouncer pada port lokal (contoh:
6432). - PgBouncer mempertahankan pool koneksi persisten ke server PostgreSQL riil.
- Koneksi fisik ke database hanya dialokasikan saat ada transaksi aktif, lalu segera dikembalikan ke pool begitu transaksi selesai, meskipun worker Celery masih aktif.
Pengaturan Django saat menggunakan PgBouncer transaction pooling mewajibkan CONN_MAX_AGE = 0 di sisi Django dan menonaktifkan prepared statements jika menggunakan mode transaksi murni:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'myproject_db',
'USER': 'app_user',
'PASSWORD': 'secret_password',
'HOST': '127.0.0.1', # PgBouncer host
'PORT': '6432', # PgBouncer port
'CONN_MAX_AGE': 0, # PgBouncer yang mengelola lifecycle pool
'DISABLE_SERVER_SIDE_CURSORS': True,
}
}Ringkasan Tindakan
Untuk menuntaskan connection exhaustion akibat Celery:
- Pasang hook
close_old_connections()pada sinyaltask_prerundantask_postrunCelery. - Set
CONN_MAX_AGE = 0khusus untuk proses runtime worker Celery guna menghentikan retensi koneksiidle. - Terapkan PgBouncer jika volume task sangat tinggi untuk meniadakan latency overhead dari pembuatan koneksi baru.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!