Chaque chemin d’arrêt gracieux que vous n’avez jamais testé
Votre service web a un shutdown handler. Il vide les buffers, ferme les connexions, écrit des checkpoints. Vous l’avez testé une fois, peut-être. En production, il s’exécute probablement une fois par an lors d’un déploiement planifié. Le reste du temps, votre service meurt d’un OOM kill, d’une éviction de nœud, d’une coupure de courant, ou d’un déploiement qui dépasse le délai et reçoit un SIGKILL.
Le logiciel crash-only inverse cela. Il n’y a pas d’arrêt gracieux. Il n’y a que crash et récupération. Le même chemin s’exécute après un SIGKILL, un segfault, ou un kernel panic. Si votre chemin de récupération fonctionne, votre service est en sécurité. Si ce n’est pas le cas, vous le découvrez immédiatement, pas lors d’une panne à 3 heures du matin.
Ce que crash-only signifie réellement
Le terme vient de l’article de 2003 de Candea et Fox sur le crash-only computing. Leur argument était simple : si les composants vont de toute façon crasher, concevez-les de sorte que le crash et la récupération soient les seuls états. Pas d’arrêt à chaud. Pas de sortie propre. Pas de “veuillez terminer vos requêtes d’abord.”
Pour un service web, cela signifie trois choses :
-
Tout état durable vit en dehors du processus. La mémoire est éphémère par définition. Tout ce dont vous avez besoin après un redémarrage doit être dans une base de données, un write-ahead log, ou une file de messages durable avant que vous ne accusiez réception du travail.
-
La récupération est le seul chemin de démarrage. Le même code s’exécute que vous démarriez à neuf ou que vous redémarriez après un crash. Il n’y a pas de mode spécial “restaurer depuis un checkpoint”. Il n’y a que “lire le log et rattraper le retard.”
-
Les requêtes sont atomiques ou idempotentes. Un client réessaie. Une requête partielle ne laisse aucun état corrompu. Le service se fiche de savoir si la tentative précédente a terminé, a crashé, ou a été tuée en plein vol.
Pourquoi l’arrêt gracieux cache des bugs
L’arrêt gracieux vous donne un faux sentiment de sécurité. Vous croyez que votre service s’arrête proprement parce que votre handler s’exécute. Mais le handler est une illusion. Linux envoie SIGKILL après 30 secondes que vous ayez terminé ou non. Kubernetes évince des pods sans prévenir. Votre datacenter perd le courant.
Quand vous avez un chemin d’arrêt, vous vous retrouvez avec deux chemins de code : celui du bonheur que vous testez, et celui du crash qui s’exécute réellement. Ils divergent. Les bugs se cachent dans l’écart. J’ai vu des services qui vidaient “gracieusement” un buffer sur disque mais ne faisaient jamais de fsync, donc le fichier était vide après une coupure de courant. Le shutdown handler semblait correct. Ce n’était simplement pas le chemin qui comptait.
Le crash-only supprime l’illusion. Il n’y a qu’un seul chemin. S’il est faux, vous le savez immédiatement parce que votre service ne démarre pas.
À quoi cela ressemble en pratique
Voici un worker HTTP crash-only minimal en Python. Il récupère des jobs dans une file Redis, les traite, et stocke les résultats. Il n’y a pas de 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()
Les détails clés :
recover()s’exécute à chaque démarrage. Ce n’est pas un cas spécial.- Les jobs utilisent des IDs générés par le client et
INSERT OR REPLACE. Les réessais sont sûrs. - Il n’y a pas de
atexit, pas de SIGTERM handler, pas de vidage de connexions. Le processus peut mourir à n’importe quel moment et redémarrer en sécurité.
Comparez cela à un service typique avec arrêt gracieux :
# 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)
Si le processus reçoit SIGKILL entre db_conn.commit() et db_conn.close(), rien de terrible ne se passe. Mais s’il meurt entre deux écritures qui devraient être atomiques, vous avez corrompu l’état. Le shutdown handler vous donne une confiance que vous n’avez pas méritée.
Les compromis dont personne ne parle
Le crash-only n’est pas gratuit. Le coût se manifeste en trois endroits.
Amplification du stockage. Chaque mutation doit être durable avant que vous ne l’accusiez réception. Cela signifie des fsyncs, des write-ahead logs, ou des écritures répliquées. Votre latence augmente. Une mise à jour en mémoire qui prenait des microsecondes prend maintenant des millisecondes.
L’idempotence est obligatoire, pas optionnelle. Chaque opération qui modifie l’état doit gérer les réessais. C’est du code supplémentaire et de la réflexion supplémentaire. Un INSERT naïf devient INSERT ... ON CONFLICT. Une écriture de fichier devient une danse de fichier-temporaire-et-renommage.
Le temps de récupération est illimité. Si votre log durable grossit, le démarrage ralentit. Vous avez besoin de compaction de logs, de snapshots, ou de replay par morceaux. Ces mécanismes sont du code que vous devez écrire et tester. Ils sont aussi, ironiquement, des chemins qui doivent eux-mêmes être crash-only.
Le crash-only simplifie vos modes de défaillance mais ne les élimine pas. Il convertit des bugs d’arrêt imprévisibles en une latence de récupération prévisible. C’est généralement un bon compromis. Mais c’est un compromis.
Comment déplacer un service vers le crash-only
Vous n’avez pas besoin de tout reconstruire. Vous pouvez glisser progressivement.
Auditez vos shutdown handlers. Si vous avez un SIGTERM handler, demandez-vous : que se passe-t-il s’il ne s’exécute pas ? Si la réponse est une perte de données ou une corruption, c’est votre vrai bug. Corrigez le chemin de récupération, pas le shutdown handler.
Rendez votre state machine explicite. Notez chaque structure en mémoire qui serait perdue lors d’un crash. Pour chacune, décidez : reconstruire depuis le log, recharger depuis la base de données, ou accepter la perte. “Accepter la perte” est une réponse valide pour les caches et les métriques.
Utilisez des clés d’idempotence. Chaque endpoint modificateur devrait accepter une clé d’idempotence générée par le client. Le serveur stocke (clé, résultat) et renvoie le résultat stocké lors d’un réessai. Stripe a écrit le livre là-dessus. La plupart des frameworks web ont maintenant du middleware pour cela.
Testez le chemin de crash, pas le chemin d’arrêt. Dans vos tests d’intégration, envoyez SIGKILL à votre service au milieu d’une requête. Redémarrez-le. Vérifiez que le système est cohérent. Si vous ne testez que l’arrêt gracieux, vous testez de la fiction.
Quand le crash-only est de trop
Pas tous les processus en ont besoin. Un serveur de fichiers statiques peut mourir et redémarrer sans aucune logique de récupération. Un outil CLI ponctuel n’a pas besoin de clés d’idempotence. Si votre service est stateless et que chaque requête est autonome, vous êtes déjà crash-only. N’ajoutez pas de complexité pour l’esthétique.
Le but n’est pas la pureté. Le but est d’avoir un seul chemin testé au lieu d’un chemin testé et d’un chemin imaginaire.
FAQ
Est-ce que crash-only signifie que j’ignore SIGTERM ?
Non. Vous pouvez toujours sortir sur SIGTERM. Ne faites juste pas de travail non trivial dans le handler. Fermez une socket si vous voulez, mais ne videz pas un état que vous n’avez pas déjà rendu durable.
Et le draining de connexions ?
Les load balancers doivent arrêter d’envoyer du trafic avant qu’un pod ne meure. Cela se passe à la couche infrastructure, pas dans votre processus. Gardez le drain court. Kubernetes a 30 secondes par défaut. Après cela, vous recevez SIGKILL de toute façon.
Est-ce que cela s’applique aux bases de données ?
Les bases de données sont les systèmes crash-only originels. Le WAL de PostgreSQL, le journal de rollback de SQLite, le redo log de MySQL, ils supposent tous que le processus peut mourir à n’importe quel moment. Le code de récupération est le code de démarrage. Les ingénieurs de bases de données le savent depuis des décennies.
Et les requêtes HTTP en cours ?
Les clients devraient réessayer en cas d’échec. Si votre endpoint est idempotent, les réessais sont sûrs. S’il n’est pas idempotent, le crash-only ne vous sauvera pas. La solution est l’idempotence, pas un délai d’arrêt plus long.
Commencez par supprimer votre shutdown handler
La façon la plus rapide de trouver vos bugs de crash cachés est de supprimer la fiction. Mettez votre SIGTERM handler en commentaire. Lancez vos tests d’intégration. Envoyez SIGKILL au milieu d’une requête. Voyez ce qui casse. Corrigez ces choses-là. C’est votre vrai système. Tout le reste est une couverture de confort qui cache les trous.
Le crash-only ne fait pas disparaître les défaillances. Il les rend ennuyeuses. Et les défaillances ennuyeuses sont celles à travers lesquelles vous pouvez dormir.