Setiap Jalur Penutupan yang Anggun yang Tidak Pernah Anda Uji

Layanan web Anda punya shutdown handler. Ia melakukan flush buffer, menutup koneksi, menulis checkpoint. Anda mengujinya sekali, mungkin. Di produksi, ia mungkin berjalan sekali setahun selama deploy yang direncanakan. Sisanya, layanan Anda mati karena OOM kill, eviksi node, kehilangan daya, atau deploy yang timeout dan kena SIGKILL.

Perangkat lunak crash-only membalikkan ini. Tidak ada penutupan yang anggun. Hanya ada crash dan recover. Jalur yang sama berjalan setelah SIGKILL, segfault, atau kernel panic. Jika jalur recovery Anda berfungsi, layanan Anda aman. Jika tidak, Anda mengetahuinya segera, bukan saat outage jam 3 pagi.

Apa yang Sebenarnya Dimaksud dengan Crash-Only

Istilah ini berasal dari paper Candea dan Fox tahun 2003 tentang crash-only computing. Argumen mereka sederhana: jika komponen akan crash, rancanglah mereka agar crash dan recovery adalah satu-satunya state. Tidak ada warm shutdown. Tidak ada clean exit. Tidak ada “tolong selesaikan request dulu.”

Untuk layanan web, ini berarti tiga hal:

  1. Semua durable state berada di luar proses. Memory bersifat ephemeral secara definisi. Apa pun yang Anda butuhkan setelah restart harus berada di database, write-ahead log, atau durable message queue sebelum Anda mengakui pekerjaannya.

  2. Recovery adalah satu-satunya jalur startup. Kode yang sama berjalan baik saat Anda memulai baru atau restart setelah crash. Tidak ada mode khusus “restore dari checkpoint.” Hanya ada “baca log dan catch up.”

  3. Request bersifat atomic atau idempotent. Client melakukan retry. Request parsial tidak meninggalkan state yang corrupted. Layanan tidak peduli apakah upaya sebelumnya selesai, crash, atau di-kill di tengah jalan.

Mengapa Penutupan yang Anggun Menyembunyikan Bug

Penutupan yang anggun memberi Anda rasa aman yang palsu. Anda percaya layanan Anda menutup dengan bersih karena handler Anda berjalan. Tapi handler itu adalah fantasi. Linux mengirim SIGKILL setelah 30 detik, mau Anda selesai atau belum. Kubernetes melakukan eviksi pod tanpa peringatan. Data center Anda kehilangan daya.

Ketika Anda punya jalur shutdown, Anda punya dua jalur kode: yang happy yang Anda uji, dan yang crash yang sebenarnya berjalan. Mereka menyimpang. Bug bersembunyi di celah tersebut. Saya pernah melihat layanan yang secara “anggun” melakukan flush buffer ke disk tapi tidak pernah fsync, jadi file-nya kosong setelah kehilangan daya. Handler shutdown-nya terlihat benar. Hanya saja itu bukan jalur yang penting.

Crash-only menghilangkan fantasi tersebut. Hanya ada satu jalur. Jika salah, Anda segera tahu karena layanan Anda tidak bisa start.

Seperti Apa Ini dalam Praktik

Berikut adalah HTTP worker crash-only minimal dalam Python. Ia mengambil job dari queue Redis, memprosesnya, dan menyimpan hasil. Tidak ada shutdown handler.

import json
import redis
import sqlite3
from http.server import HTTPServer, BaseHTTPRequestHandler

DB_PATH = "/data/results.db"
REDIS_URL = "redis://queue:6379"

# Recovery: the ONLY startup path.
def recover():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS results (
            job_id TEXT PRIMARY KEY,
            result TEXT,
            processed_at INTEGER
        )
    """)
    conn.commit()
    return conn

# Every job is identified by a client-generated UUID.
# If we crash mid-processing, the client retries with the same ID.
# INSERT OR REPLACE makes the store idempotent.
def process_job(conn, job_id, payload):
    result = f"processed-{payload['value'] * 2}"
    conn.execute(
        "INSERT OR REPLACE INTO results (job_id, result, processed_at) VALUES (?, ?, strftime('%s','now'))",
        (job_id, result)
    )
    conn.commit()
    return result

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        job = json.loads(body)
        process_job(db_conn, job["id"], job["payload"])
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok")

# Crash-only: we do not register signal handlers for graceful shutdown.
# We connect, we recover, we serve. When we die, we die.
db_conn = recover()
server = HTTPServer(("0.0.0.0", 8080), Handler)
server.serve_forever()

Detail kuncinya:

  • recover() berjalan di setiap start. Ini bukan kasus khusus.
  • Job menggunakan ID yang dihasilkan client dan INSERT OR REPLACE. Retry aman dilakukan.
  • Tidak ada atexit, tidak ada handler SIGTERM, tidak ada connection draining. Proses bisa mati kapan saja dan restart dengan aman.

Bandingkan ini dengan layanan tipikal dengan penutupan yang anggun:

# The trap: this code makes you feel safe while hiding the real bug.
def graceful_shutdown(signum, frame):
    # What if we're killed here, before the commit?
    db_conn.commit()
    db_conn.close()
    server.shutdown()

signal.signal(signal.SIGTERM, graceful_shutdown)

Jika proses menerima SIGKILL di antara db_conn.commit() dan db_conn.close(), tidak terjadi apa-apa yang buruk. Tapi jika ia mati di antara dua write yang seharusnya atomic, Anda telah merusak state. Handler shutdown memberi Anda keyakinan yang belum Anda dapatkan.

Trade-Off yang Tidak Pernah Dibicarakan

Crash-only tidak gratis. Biayanya muncul di tiga tempat.

Storage amplification. Setiap mutasi harus durable sebelum Anda mengakuinya. Itu berarti fsync, write-ahead log, atau replicated write. Latency Anda naik. Update yang hanya di memory yang butuh microdetik sekarang butuh milidetik.

Idempotency adalah wajib, bukan opsional. Setiap operasi yang mengubah state harus menangani retry. Ini kode ekstra dan pemikiran ekstra. INSERT naif menjadi INSERT ... ON CONFLICT. Penulisan file menjadi tarian temp-file-and-rename.

Recovery time tidak terbatas. Jika durable log Anda tumbuh, startup melambat. Anda perlu log compaction, snapshot, atau replay yang di-chunk. Mekanisme tersebut adalah kode yang harus Anda tulis dan uji. Mereka juga, ironisnya, jalur yang harus bersifat crash-only juga.

Crash-only menyederhanakan failure mode Anda tapi tidak menghilangkannya. Ia mengonversi bug shutdown yang tidak terduga menjadi recovery latency yang terduga. Itu biasanya trade yang bagus. Tapi itu adalah trade.

Cara Menggerakkan Layanan Menuju Crash-Only

Anda tidak harus membangun ulang semuanya. Anda bisa menggeser secara bertahap.

Audit handler shutdown Anda. Jika Anda punya handler SIGTERM, tanyakan: apa yang terjadi jika ia tidak berjalan? Jika jawabannya adalah data loss atau corruption, itulah bug sebenarnya Anda. Perbaiki jalur recovery, bukan handler shutdown.

Buat state machine Anda eksplisit. Tuliskan setiap struktur in-memory yang akan hilang saat crash. Untuk masing-masing, putuskan: rekonstruksi dari log, reload dari database, atau terima kehilangan. “Terima kehilangan” adalah jawaban yang valid untuk cache dan metric.

Gunakan idempotency key. Setiap endpoint yang mengubah state harus menerima idempotency key yang dihasilkan client. Server menyimpan (key, result) dan mengembalikan result yang tersimpan saat retry. Stripe menulis buku tentang ini. Sebagian besar web framework punya middleware untuk itu sekarang.

Uji jalur crash, bukan jalur shutdown. Dalam integration test Anda, kirim SIGKILL ke layanan Anda di tengah request. Restart ia. Assert bahwa sistem konsisten. Jika Anda hanya menguji shutdown yang anggun, Anda menguji fiksi.

Kapan Crash-Only Terlalu Berlebihan

Tidak setiap proses membutuhkan ini. Static file server bisa mati dan restart tanpa logika recovery sama sekali. CLI tool one-off tidak perlu idempotency key. Jika layanan Anda stateless dan setiap request mandiri, Anda sudah bersifat crash-only. Jangan tambahkan kompleksitas demi estetika.

Tujuannya bukan kemurnian. Tujuannya adalah memiliki satu jalur yang teruji, bukan satu jalur yang teruji dan satu jalur imajiner.

FAQ

Apakah crash-only berarti saya mengabaikan SIGTERM?

Tidak. Anda masih bisa exit saat SIGTERM. Hanya saja jangan lakukan pekerjaan non-trivial di handler-nya. Tutup socket jika Anda mau, tapi jangan melakukan flush state yang belum Anda jadikan durable.

Bagaimana dengan connection draining?

Load balancer perlu berhenti mengirim traffic sebelum pod mati. Itu terjadi di layer infrastruktur, bukan di proses Anda. Jadikan drain singkat. Kubernetes default ke 30 detik. Setelah itu, Anda tetap dapat SIGKILL.

Apakah ini berlaku untuk database?

Database adalah sistem crash-only yang orisinal. WAL PostgreSQL, rollback journal SQLite, redo log MySQL, semuanya mengasumsikan proses bisa mati kapan saja. Kode recovery adalah kode startup. Engineer database telah mengetahui ini selama puluhan tahun.

Bagaimana dengan HTTP request yang sedang berlangsung?

Client harus melakukan retry saat gagal. Jika endpoint Anda idempotent, retry aman dilakukan. Jika tidak idempotent, crash-only tidak akan menyelamatkan Anda. Perbaikannya adalah idempotency, bukan timeout shutdown yang lebih lama.

Mulai dengan Menghapus Handler Shutdown Anda

Cara tercepat untuk menemukan bug crash tersembunyi Anda adalah menghilangkan fiksi tersebut. Komentari handler SIGTERM Anda. Jalankan integration test Anda. Kirim SIGKILL di tengah request. Lihat apa yang rusak. Perbaiki hal-hal tersebut. Itulah sistem sebenarnya Anda. Yang lainnya hanyalah selimut penghibur yang menyembunyikan lubang.

Crash-only tidak membuat kegagalan hilang. Ia membuatnya membosankan. Dan kegagalan yang membosankan adalah yang bisa Anda lewati saat tidur.