당신이 한 번도 테스트하지 않은 모든 그레이스풀 셧다운 경로
웹 서비스에는 셧다운 핸들러가 있다. 버퍼를 플러시하고, 연결을 닫고, 체크포인트를 기록한다. 한 번쯤 테스트했을지도 모른다. 프로덕션에서는 계획된 배포 중 1년에 한 번 정도 실행될 것이다. 나머지 시간에는 서비스가 OOM 킬, 노드 축출, 정전, 또는 타임아웃으로 SIGKILL을 받는 배포로 죽는다.
크래시 온리 소프트웨어는 이를 뒤집는다. 그레이스풀 셧다운은 없다. 오직 크래시와 복구만 있을 뿐이다. SIGKILL, 세그폴트, 커널 패닉 이후에도 동일한 경로가 실행된다. 복구 경로가 작동하면 서비스는 안전하다. 작동하지 않으면 새벽 3시의 장애가 아니라 즉시 알게 된다.
크래시 온리의 실제 의미
이 용어는 Candea와 Fox의 2003년 crash-only computing 논문에서 유래했다. 그들의 주장은 단순했다: 컴포넌트가 어차피 크래시할 거라면, 크래시와 복구가 유일한 상태가 되도록 설계하라는 것이다. 웜 셧다운도 없다. 클린 종료도 없다. “요청부터 마저 끝내 주세요”라는 것도 없다.
웹 서비스에 적용하면 세 가지를 의미한다:
-
모든 내구성 있는 상태는 프로세스 외부에 존재한다. 메모리는 정의상 일시적이다. 재시작 후에도 필요한 모든 것은 작업을 승인하기 전에 데이터베이스, write-ahead log, 또는 내구성 있는 메시지 큐에 있어야 한다.
-
복구가 유일한 시작 경로다. 처음 시작하든 크래시 후 재시작하든 동일한 코드가 실행된다. 특별한 “체크포인트에서 복원” 모드는 없다. 오직 “로그를 읽고 따라잡기”만 있다.
-
요청은 원자적이거나 멱등성을 가져야 한다. 클라이언트는 재시도한다. 부분적인 요청은 손상된 상태를 남기지 않는다. 서비스는 이전 시도가 완료되었는지, 크래시했는지, 중간에 죽었는지 신경 쓰지 않는다.
그레이스풀 셧다운이 버그를 숨기는 이유
그레이스풀 셧다운은 거짓된 안전감을 준다. 핸들러가 실행되므로 서비스가 깔끔하게 종료된다고 믿는다. 하지만 그 핸들러는 환상이다. Linux는 30초 후에 작업이 끝났든 아니든 SIGKILL을 보낸다. Kubernetes는 경고 없이 파드를 축출한다. 데이터 센터는 정전된다.
셧다운 경로가 있으면 두 개의 코드 경로가 생긴다: 테스트한 해피 경로와 실제로 실행되는 크래시 경로. 이 둘은 갈라진다. 버그는 그 틈에 숨는다. “그레이스풀하게” 버퍼를 디스크에 플러시했지만 fsync는 하지 않아서 정전 후 파일이 비어 있던 서비스를 본 적이 있다. 셧다운 핸들러는 올바르게 보였다. 문제는 그게 중요한 경로가 아니었다는 것이다.
크래시 온리는 그 환상을 제거한다. 경로는 하나뿐이다. 틀리면 서비스가 시작되지 않으므로 즉시 알게 된다.
실제로는 어떻게 보이는가
다음은 Python으로 작성한 최소한의 크래시 온리 HTTP 워커다. Redis 큐에서 작업을 가져와 처리하고 결과를 저장한다. 셧다운 핸들러는 없다.
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()
핵심적인 세부 사항:
recover()는 모든 시작 시 실행된다. 특별한 경우가 아니다.- 작업은 클라이언트가 생성한 ID와
INSERT OR REPLACE를 사용한다. 재시도는 안전하다. atexit도, SIGTERM 핸들러도, 연결 드레이닝도 없다. 프로세스는 언제든 죽을 수 있고 안전하게 재시작할 수 있다.
이를 그레이스풀 셧다운을 가진 일반적인 서비스와 비교해 보자:
# 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)
프로세스가 db_conn.commit()과 db_conn.close() 사이에 SIGKILL을 받아도 끔찍한 일은 일어나지 않는다. 하지만 원자적이어야 할 두 번의 쓰기 사이에서 죽으면 상태가 손상된다. 셧다운 핸들러는 당신이 얻지 못한 자신감을 준다.
아무도 이야기하지 않는 트레이드오프
크래시 온리는 공짜가 아니다. 비용은 세 곳에서 나타난다.
스토리지 증폭. 모든 변경은 승인하기 전에 내구성을 가져야 한다. 이는 fsync, write-ahead log, 또는 복제된 쓰기를 의미한다. 지연 시간이 증가한다. 마이크로초만 걸리던 메모리 전용 업데이트가 이제 밀리초가 걸린다.
멱등성은 선택이 아닌 필수다. 상태를 변경하는 모든 작업은 재시도를 처리해야 한다. 이는 추가 코드와 추가적인 사고를 요구한다. 순진한 INSERT는 INSERT ... ON CONFLICT가 된다. 파일 쓰기는 임시 파일 생성 후 rename하는 절차가 된다.
복구 시간은 무한대가 될 수 있다. 내구성 있는 로그가 커지면 시작이 느려진다. 로그 컴팩션, 스냅숏, 또는 청크 단위 재생이 필요하다. 이런 메커니즘은 직접 작성하고 테스트해야 하는 코드다. 아이러니하게도, 이들 역시 크래시 온리여야 하는 경로다.
크래시 온리는 장애 모드를 단순화하지만 제거하지는 않는다. 예측 불가능한 셧다운 버그를 예측 가능한 복구 지연 시간으로 전환한다. 이는 보통 좋은 트레이드오프다. 하지만 트레이드오프다.
서비스를 크래시 온리로 전환하는 방법
모든 것을 다시 만들 필요는 없다. 점진적으로 전환할 수 있다.
셧다운 핸들러를 감사하라. SIGTERM 핸들러가 있다면 이렇게 물어봐라: 실행되지 않으면 어떻게 되는가? 답이 데이터 손실이나 손상이라면, 그게 진짜 버그다. 셧다운 핸들러가 아니라 복구 경로를 고쳐라.
상태 머신을 명시적으로 만들어라. 크래시 시 손실될 모든 메모리 내 구조체를 적어라. 각각에 대해 결정하라: 로그에서 재구성할 것인지, 데이터베이스에서 다시 로드할 것인지, 손실을 받아들일 것인지. “손실을 받아들인다”는 캐시와 메트릭에 대해 유효한 답이다.
멱등성 키를 사용하라. 모든 변경 엔드포인트는 클라이언트가 생성한 멱등성 키를 받아야 한다. 서버는 (key, result)를 저장하고 재시도 시 저장된 결과를 반환한다. Stripe가 이 주제의 바이블을 썼다. 대부분의 웹 프레임워크에는 이를 위한 미들웨어가 있다.
크래시 경로를 테스트하고, 셧다운 경로는 테스트하지 마라. 통합 테스트에서 요청 중간에 서비스에 SIGKILL을 보내라. 재시작하라. 시스템이 일관적인지 검증하라. 그레이스풀 셧다운만 테스트하면 소설을 테스트하는 셈이다.
크래시 온리가 과한 경우
모든 프로세스가 이것을 필요로 하는 것은 아니다. 정적 파일 서버는 복구 로직 없이 죽고 재시작할 수 있다. 일회성 CLI 도구는 멱등성 키가 필요 없다. 서비스가 상태를 가지지 않고 모든 요청이 자급자족이라면 이미 크래시 온리다. 미학을 위해 복잡성을 추가하지 마라.
목표는 순수성이 아니다. 목표는 하나의 테스트된 경로와 하나의 가상의 경로 대신, 하나의 테스트된 경로를 갖는 것이다.
FAQ
크래시 온리가 SIGTERM을 무시하라는 의미인가?
아니다. SIGTERM으로 여전히 종료할 수 있다. 다만 핸들러에서 사소하지 않은 작업을 하지 마라. 원한다면 소켓을 닫아도 되지만, 이미 내구성 있게 만들지 않은 상태를 플러시하지 마라.
커넥션 드레이닝은 어떻게 되는가?
로드 밸런서는 파드가 죽기 전에 트래픽 전송을 중단해야 한다. 이는 프로세스가 아닌 인프라 계층에서 일어난다. 드레인은 짧게 유지하라. Kubernetes의 기본값은 30초다. 그 이후에는 어차피 SIGKILL을 받는다.
이것이 데이터베이스에도 적용되는가?
데이터베이스가 원래의 크래시 온리 시스템이다. PostgreSQL의 WAL, SQLite의 rollback journal, MySQL의 redo log, 이 모두 프로세스가 언제든 죽을 수 있다고 가정한다. 복구 코드가 시작 코드다. 데이터베이스 엔지니어들은 이를 수십 년 전부터 알고 있었다.
진행 중인 HTTP 요청은 어떻게 되는가?
클라이언트는 실패 시 재시도해야 한다. 엔드포인트가 멱등성을 가지면 재시도는 안전하다. 멱등성이 없다면 크래시 온리가 구해주지 않는다. 해결책은 멱등성이지, 더 긴 셧다운 타임아웃이 아니다.
셧다운 핸들러를 삭제하는 것부터 시작하라
숨겨진 크래시 버그를 찾는 가장 빠른 방법은 그 허구를 제거하는 것이다. SIGTERM 핸들러를 주석 처리하라. 통합 테스트를 실행하라. 요청 중간에 SIGKILL을 보내라. 무엇이 깨지는지 보아라. 그것들을 고쳐라. 그게 당신의 진짜 시스템이다. 나머지는 구멍을 가리는 안전 담요일 뿐이다.
크래시 온리는 장애를 사라지게 하지 않는다. 지루하게 만든다. 그리고 지루한 장애가 바로 잠을 자며 넘길 수 있는 장애다.