Pada arsitektur backend Django yang menggunakan Celery untuk asynchronous task processing, kendala yang sering ditemui di lingkungan staging maupun production adalah kegagalan task database secara tiba-tiba setelah worker berada dalam kondisi idle. Task melempar eksepsi django.db.utils.OperationalError saat mengeksekusi query pertama.
Gejala Masalah
Worker Celery berhasil mengeksekusi task ketika trafik antrean padat. Namun, ketika worker dibiarkan idle selama beberapa menit tanpa menerima task, task berikutnya yang mengakses database langsung gagal dengan salah satu stack trace berikut:
django.db.utils.OperationalError: server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.
Atau variasi error lain pada driver psycopg2:
psycopg2.OperationalError: SSL error: decryption failed or bad record mac
---
psycopg2.OperationalError: EOF detected
Setelah task pertama gagal, task berikutnya pada worker process yang sama biasanya berjalan normal karena Django otomatis menginisiasi koneksi baru setelah mendeteksi koneksi sebelumnya mati.
Root Cause Analysis
Akar masalah ini berpusat pada perbedaan manajemen siklus hidup koneksi database antara siklus HTTP Django dan runtime Celery Worker.
1. Siklus Koneksi Django HTTP vs Celery
Pada aplikasi web Django standar, koneksi database diikat oleh handler sinyal request_started dan request_finished. Di akhir setiap HTTP request, Django memanggil internal method django.db.close_old_connections(). Fungsi ini memeriksa apakah koneksi telah melewati batas waktu CONN_MAX_AGE atau berada dalam status tidak usable, lalu menutup socket jika perlu.
Celery tidak berjalan di dalam siklus HTTP request Django. Celery menjalankan proses worker persisten (biasanya menggunakan pre-fork pool). Tanpa handler eksplisit, Django connection handler di dalam child process Celery mempertahankan socket TCP yang sama tanpa pernah memanggil close_old_connections() saat worker idle.
2. Silent Drop oleh State Table Firewall / NAT
Koneksi database PostgreSQL/MySQL melewati layer jaringan: worker OS network stack, Cloud NAT/Firewall (misal: AWS NAT Gateway yang memiliki connection idle timeout default 350 detik), dan database host server. Jika tidak ada transmisi paket data melewati socket yang idle, intermediate stateful firewall akan menghapus state koneksi tersebut secara sepihak (silent drop) tanpa mengirim paket TCP FIN atau RST ke client.
Akibatnya, Celery worker menganggap socket masih terbuka. Ketika query SQL baru dikirimkan, client menulis data ke broken socket dan membaca respons kosong, menghasilkan error EOF detected atau server closed the connection unexpectedly.
Langkah Perbaikan
1. Binding close_old_connections ke Celery Signals
Langkah primer adalah memastikan Celery mengecek dan menutup koneksi yang stale sebelum dan sesudah setiap task dieksekusi. Ini dicapai dengan menghubungkan django.db.close_old_connections ke sinyal task_prerun dan task_postrun dari Celery.
Tambahkan implementasi ini pada modul inisialisasi Celery Anda (misalnya celery.py di root konfigurasi project Django):
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):
# Menutup koneksi yang sudah obsolete sebelum eksekusi dimulai
close_old_connections()
@task_postrun.connect
def on_task_postrun(*args, **kwargs):
# Membersihkan koneksi setelah task selesai agar tidak menggantung saat idle
close_old_connections()
2. Konfigurasi CONN_MAX_AGE Khusus Worker
Di Django web service, CONN_MAX_AGE sering disetel ke angka non-zero (misalnya 60 atau 300 detik) untuk mengaktifkan connection pooling sederhana. Namun pada Celery, mempertahankan koneksi persisten lintas task berisiko jika interval task tidak teratur.
Jika Celery dijalankan terpisah, setel CONN_MAX_AGE = 0 untuk environment worker, atau gunakan connection pooler terdedikasi di sisi infra seperti PgBouncer.
# settings.py
import os
import sys
IS_CELERY_WORKER = 'celery' in sys.argv[0] or os.environ.get('RUNNING_CELERY') == 'true'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'production_db',
'USER': 'db_user',
'PASSWORD': 'secret_password',
'HOST': 'db-host.internal',
'PORT': '5432',
# Jangan reuse koneksi jika running sebagai worker Celery tanpa PgBouncer
'CONN_MAX_AGE': 0 if IS_CELERY_WORKER else 300,
}
}
3. Konfigurasi TCP Keepalive pada Database Backend
Untuk mencegah firewall memutus koneksi secara sepihak saat query berdurasi panjang berjalan atau saat worker memegang transaksi, atur parameter TCP Keepalive langsung melalui dictionary OPTIONS pada PostgreSQL database engine Django.
# settings.py
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
# ... konfigurasi host, user, password ...
'OPTIONS': {
# 1: Aktifkan TCP keepalives
'keepalives': 1,
# Kirim probe keepalive setelah socket idle 60 detik (default OS seringkali 7200s)
'keepalives_idle': 60,
# Interval antar probe jika probe sebelumnya tidak direspons (detik)
'keepalives_interval': 10,
# Jumlah kegagalan probe sebelum koneksi dianggap benar-benar putus
'keepalives_count': 5,
},
}
}
Parameter ini memaksa OS mengirim paket ACK kosong secara periodik sehingga status koneksi pada tabel NAT router/firewall tetap berstatus ESTABLISHED.
Verifikasi Solusi
Lakukan verifikasi perubahan tanpa menunggu timeout NAT alami:
- Jalankan Celery worker dengan log level debug:
celery -A myproject worker -l DEBUG. - Buat satu task sederhana yang mengakses model database:
MyModel.objects.first(). - Jalankan task sekali untuk membuka socket TCP awal. Dapatkan PID koneksi di server PostgreSQL:
SELECT pid, client_addr, client_port, state FROM pg_stat_activity WHERE application_name = 'myproject'; - Secara manual terminasi koneksi tersebut dari server PostgreSQL untuk mensimulasikan drop koneksi mendadak:
SELECT pg_terminate_backend(<pid>); - Kirim task kedua ke worker.
- Hasil yang Diharapkan: Karena
task_prerunmemicuclose_old_connections(), Django memvalidasi socket, mengenali koneksi telah ditutup oleh backend, dan menginisiasi TCP handshake baru secara mulus tanpa menghasilkanOperationalError.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!