Implementasi container Linux berbasis Incus di atas host NixOS menghadirkan efisiensi infrastruktur yang tinggi melalui isolasi ringan dan manajemen konfigurasi deklaratif. Masalah muncul ketika siklus nixos-rebuild switch atau peremajaan container mengeksekusi penghentian proses secara mendadak. Sinyal terminasi default sering kali memotong eksekusi worker di tengah proses, meninggalkan distributed lock menggantung di Redis (zombie lock) dan memicu penarikan ulang task yang belum rampung oleh worker lain (task race condition).
Akar Masalah: Rebuild Deklaratif dan Terminasi Worker
Saat konfigurasi NixOS pada host atau instance Incus diperbarui, systemd menerapkan perubahan unit dengan mengirimkan SIGTERM dan langsung menindaklanjutinya dengan SIGKILL jika batas waktu default (biasanya 90 detik, atau lebih pendek jika dibatasi Incus) terlampaui. Worker yang dihentikan paksa saat memegang kunci eksekusi terdistribusi tidak sempat menjalankan blok pembersihan (finally atau defer).
Dampak langsung dari siklus ini meliputi:
- Zombie Lock: Kunci di Redis atau database tetap terkunci selama sisa Time-To-Live (TTL). Task berikutnya tertahan dan memicu antrean macet.
- Task Race: Message broker menganggap worker mati dan mendistribusikan ulang task ke node lain sementara mutasi data pertama belum sepenuhnya rollback atau selesai.
Konfigurasi Declarative Systemd Draining di NixOS
Untuk mencegah terminasi mendadak, unit worker harus dikonfigurasi secara deklaratif di NixOS agar menangani graceful draining. Worker wajib menerima SIGTERM, berhenti menarik task baru dari broker, merampungkan task aktif, melepaskan lock, lalu keluar secara normal.
Tambahkan konfigurasi berikut pada deklarasi NixOS instance Incus:
# /etc/nixos/configuration.nix atau modul worker
{ pkgs, ... }:
{
systemd.services.queue-worker = {
description = "Distributed Queue Worker";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.python3}/bin/python /srv/worker/main.py";
KillSignal = "SIGTERM";
TimeoutStopSec = 45;
KillMode = "mixed";
SendSIGKILL = true;
Restart = "on-failure";
RestartSec = 5;
};
};
}
Pengaturan TimeoutStopSec = 45; memberikan jendela waktu yang cukup bagi worker untuk menyelesaikan task, sementara KillMode = "mixed"; memastikan hanya proses utama yang menerima SIGTERM awal, sedangkan sub-proses yang membangkang akan dihentikan paksa setelah batas waktu habis.
Distributed Lock Berbasis Leasing dan Background Heartbeat
Menyetel TTL lock terlalu lama menyebabkan latency pemulihan yang tinggi ketika worker mengalami hard crash. Sebaliknya, TTL yang terlalu pendek berisiko melepaskan lock sebelum task selesai dikerjakan. Solusi standar industri adalah menggunakan TTL pendek (misalnya 5 detik) yang diperpanjang secara periodik via background heartbeat thread/goroutine.
Berikut implementasi akuisisi, perpanjangan masa berlaku (lease renewal), dan pelepasan lock menggunakan Redis dan Lua script agar atomic:
import time
import uuid
import threading
import redis
class DistributedLock:
def __init__(self, redis_client: redis.Redis, lock_key: str, ttl_ms: int = 5000):
self.redis = redis_client
self.key = lock_key
self.ttl_ms = ttl_ms
self.owner_id = str(uuid.uuid4())
self._running = False
self._renew_thread = None
self._release_lua = self.redis.register_script("""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""")
self._renew_lua = self.redis.register_script("""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('pexpire', KEYS[1], ARGV[2])
else
return 0
end
""")
def acquire(self) -> bool:
acquired = self.redis.set(self.key, self.owner_id, nx=True, px=self.ttl_ms)
if acquired:
self._running = True
self._renew_thread = threading.Thread(target=self._heartbeat, daemon=True)
self._renew_thread.start()
return True
return False
def _heartbeat(self):
interval = (self.ttl_ms / 1000.0) / 2.0
while self._running:
time.sleep(interval)
if not self._running:
break
result = self._renew_lua(keys=[self.key], args=[self.owner_id, self.ttl_ms])
if result != 1:
# Kunci hilang atau diambil alih worker lain
self._running = False
break
def release(self):
self._running = False
if self._renew_thread and self._renew_thread.is_alive():
self._renew_thread.join(timeout=1.0)
self._release_lua(keys=[self.key], args=[self.owner_id])
Jika node Incus dimatikan mendadak, thread heartbeat berhenti, dan lock secara otomatis kedaluwarsa tepat pada detik ke-5 tanpa meninggalkan zombie lock berkepanjangan.
Strategi Idempotency Key pada Consumer
Distributed lock mengurangi resiko konkurensi, namun tidak menjamin exactly-once delivery pada sistem terdistribusi. Mekanisme pengiriman pesan broker (seperti RabbitMQ, Redis Streams, atau Kafka) bersifat at-least-once. Jika worker terputus tepat setelah selesai menulis mutasi tetapi sebelum mengirim ACK ke broker, task akan didistribusikan ulang.
Setiap task wajib membawa idempotency_key unik. Consumer harus memvalidasi status mutasi di dalam database transaction:
-- Skema tabel deduplikasi
CREATE TABLE processed_tasks (
task_id VARCHAR(64) PRIMARY KEY,
status VARCHAR(20) NOT NULL,
processed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Handler eksekusi di consumer
BEGIN;
INSERT INTO processed_tasks (task_id, status)
VALUES ($1, 'PROCESSING')
ON CONFLICT (task_id) DO NOTHING;
-- Jika tidak ada row terpengaruh, task adalah duplikat
-- Worker dapat langsung commit/ACK tanpa re-proses
COMMIT;
Skrip Pengujian Failover
Pengujian ketahanan kluster dilakukan dengan memicu hard restart pada container Incus ketika worker sedang memproses payload panjang. Gunakan skrip Bash berikut untuk memverifikasi bahwa lock bersih secara otomatis dan task diproses ulang tanpa tabrakan data:
#!/usr/bin/env bash
set -euo pipefail
CONTAINER_NAME="worker-node-01"
REDIS_HOST="10.0.100.1"
LOCK_KEY="lock:task:payment-9821"
echo "[1] Memulai task di container $CONTAINER_NAME..."
incus exec "$CONTAINER_NAME" -- python /srv/worker/job_runner.py &
WORKER_PID=$!
sleep 2
echo "[2] Verifikasi status lock aktif di Redis:"
redis-cli -h "$REDIS_HOST" GET "$LOCK_KEY"
echo "[3] Memaksa restart container (simulasi crash)..."
incus restart "$CONTAINER_NAME" --force
echo "[4] Menunggu TTL kedaluwarsa..."
sleep 6
STATUS=$(redis-cli -h "$REDIS_HOST" GET "$LOCK_KEY")
if [ -z "$STATUS" ]; then
echo "[PASS] Zombie lock terhapus otomatis via TTL expiration."
else
echo "[FAIL] Zombie lock masih tertinggal: $STATUS"
exit 1
fi
Melalui kombinasi penanganan TimeoutStopSec pada konfigurasi NixOS systemd, distributed lease lock dengan heartbeat renewal, dan pencatatan state idempotensi di database, kluster worker di atas container Incus dapat bertahan dari restart mendadak tanpa menyebabkan task race maupun kebuntuan sistem.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!