← Ascendy

backend

재시도는 있는데 '포기'가 없었다 — 파일 네 개가 만든 무한 루프

· Ascendy Engineering


TL;DR

이 글에 대하여. 백엔드 팀 인테이크를 정제한 사후 기록이다. 규모를 특정할 수 있는 절대 수치, 내부 태스크·컬럼 이름, 타임아웃과 상한의 실제 상수값은 일반화했다. 방어 장치가 공허하게 통과하던 배포했는데 아무것도 배포되지 않았다, 그리고 이 사고를 실제로 잡아낸 주기 리포트는 닫힌 루프일 때만 가치가 있다와 이어진다.

수백 건의 실패, 범인은 파일 네 개

주기 모니터링 리포트에 미디어 처리 실패가 수백 건 찍혔다. 숫자만 보면 파이프라인 전체가 흔들리는 것처럼 보인다.

추적해 보니 서로 다른 파일 네 개가 만든 노이즈였다. 손상된 미디어 몇 개가 같은 실패를 끝없이 반복하면서 지표를 채우고 있었다.

피해는 두 갈래였다. 하나는 워커 슬롯 — 그 파일들이 처리 슬롯을 붙잡고 타임아웃까지 버티는 동안 다른 작업이 밀렸다. 다른 하나가 더 나쁘다. 실패 지표가 도배되면서 진짜 장애 신호가 묻혔다. 실패 수백 건이 일상이 되면, 그 안에 섞인 진짜 한 건은 아무도 못 본다.

루프의 구조 — 세 부품 모두 개별적으로는 옳았다

파이프라인에는 이런 장치들이 있었다.

  1. 하드 타임아웃 — 작업이 무한정 매달리지 않도록 일정 시간이 지나면 강제 종료한다.
  2. 좀비 리퍼 — 워커가 죽어서 영영 안 끝나는 작업을 주기적으로 찾아 대기 상태로 되돌린다.
  3. 재큐 — 대기 상태로 돌아온 작업을 다시 큐에 넣는다.

셋 다 각각으로는 반박할 데가 없는 설계다. 하드 타임아웃이 없으면 워커가 영원히 물린다. 리퍼가 없으면 워커 사망 시 작업이 증발한다. 재큐가 없으면 일시적 장애로 작업이 유실된다.

그런데 손상 파일 하나를 넣으면 이렇게 된다.

디코드 시도 → 진행 안 됨 → 하드 타임아웃 → SIGKILL

좀비 리퍼: "이 작업 멈췄네" → 대기 상태로 리셋

재큐 → 디코드 시도 → 진행 안 됨 → 하드 타임아웃 → SIGKILL

  (무한)

각 부품이 자기 일을 정확히 했는데, 합쳐 놓으니 영구 기관이 됐다. 어떤 부품도 잘못 동작하지 않았다 — 아무도 “몇 번째 시도인지”를 세지 않았을 뿐이다.

재시도 기계는 있었지만 포기 기준이 없었다

여기가 이 사고의 한 문장이다. 시스템에 재시도는 정교하게 구현돼 있었고, 포기는 구현돼 있지 않았다.

재시도 로직을 짤 때 우리는 보통 “어떻게 다시 시도할까”에 집중한다. 백오프, 지터, 큐 분리, 우선순위. 그런데 재시도 설계의 절반은 “언제 그만둘까”라는 질문이고, 이쪽은 잘 잊힌다. 그만두는 조건이 없으면 재시도는 복구 장치가 아니라 증폭기가 된다.

그리고 상한이 아예 없었던 것도 아니라는 점이 더 흥미롭다. 파이프라인 뒤쪽 스텝에는 이전 비용 사고를 겪고 나서 붙여둔 시도 상한이 있었다. 그런데 이번 hang은 그보다 앞 단계에서 났다. 앞에서 죽으니 뒤쪽 상한에는 한 번도 도달하지 못했다.

방어가 있는데 그 방어가 있는 지점까지 실행이 도달하지 못한다 — 이건 배포 안전망이 공허하게 통과하던 건과 정확히 같은 모양이다. 방어 장치를 세는 게 아니라, 방어가 실제로 걸리는 경로 위에 있는지를 봐야 한다.

상한은 실행 경계에서 강제해야 한다

그럼 상한을 어디에 둘까. 직관적으로는 큐에 넣는 쪽이 편하다 — 스캐너에서 “시도 횟수가 넘은 항목은 큐에 안 넣기”로 거르면 되니까.

그런데 이건 우회된다. 재큐 경로는 스캐너 하나가 아니다. 리퍼가 되살리는 경로, 수동 재처리, 다른 서비스가 직접 넣는 경로 — 입구가 여러 개면 입구마다 필터를 붙여야 하고, 하나라도 빠뜨리면 루프가 산다.

그래서 상한은 실행 경계, 즉 워커가 그 작업을 집어 드는 지점에서 강제해야 한다. 작업을 claim하는 원자적 UPDATE 안에서 시도 카운터 증가와 상한 검사를 같이 한다.

-- 개념적 형태: claim과 카운팅과 상한 검사가 한 문장 안에서 원자적으로.
UPDATE 작업
   SET 상태 = '처리중',
       시도횟수 = 시도횟수 + 1
 WHERE 아이디 = :아이디
   AND 상태 = '대기'
   AND 시도횟수 < :상한          -- 여기가 진짜 차단기
RETURNING 아이디;

이 형태의 장점은 입구가 몇 개든 상관없다는 것이다. 어떤 경로로 큐에 들어왔든 실행되려면 이 문장을 통과해야 하고, 상한을 넘긴 항목은 여기서 claim 자체가 실패한다.

SIGKILL은 finally를 실행하지 않는다

두 번째로 미묘한 지점. 시도 카운터를 언제 적느냐다.

자연스러운 구현은 작업이 끝난 뒤에 결과와 함께 적는 것이다. 성공이면 완료, 실패면 실패 상태와 시도 횟수 증가. try/finally에 넣어두면 예외에도 안전하다고 느껴진다.

하드 타임아웃은 SIGKILL로 온다. 그리고 SIGKILL은 finally를 실행하지 않는다. 프로세스가 그냥 사라진다. 그러니 “끝난 뒤 카운터 증가”는 정확히 이 사고의 상황에서만 실행되지 않는다. 매번 죽는 작업은 매번 카운터가 안 올라가고, 영원히 첫 시도로 남는다.

그래서 카운터는 작업을 집어 드는 순간, 커밋된 상태로 적어야 한다. 위의 claim UPDATE가 그 자리다. 낙관적으로 보이지만 — 시작만 하고 못 끝낸 시도도 시도로 세겠다는 뜻이니 — 킬에 살아남는 유일한 지점이 거기다.

상태를 바꾸는 것만으론 dead-letter가 아니다

세 번째. “그럼 상한 넘으면 failed로 바꾸면 되겠네”라고 생각하기 쉽다.

그런데 스캐너가 failed 항목을 재처리 대상으로 본다면? 상태만 바꾼 항목은 다음 순회에서 다시 큐에 들어간다. 상태 전이는 이름표일 뿐, 차단기가 아니다.

진짜 차단기는 카운터와 그것을 읽는 술어다. 상태는 사람이 읽기 좋은 라벨로 두고, 실행을 끊는 건 시도횟수 < 상한 조건이 하도록 한다.

다만 여기서 한 가지를 분명히 갈라야 한다 — 실행 재시도를 막는 것과 시스템 루프를 끝내는 것은 다르다. 실행 경계의 술어는 어떤 입구로 들어왔든 실행을 막아준다. 그런데 스캐너가 상한에 도달한 항목을 계속 대상으로 잡는다면, 이런 게 남는다.

스캔 → 큐 적재 → claim 실패 → (다음 주기) 스캔 → 큐 적재 → claim 실패 → …

워커가 그 파일을 다시 디코드하지는 않으니 최악의 피해(슬롯 점유)는 사라진다. 하지만 큐와 브로커는 계속 돌고, claim 실패 로그도 계속 쌓인다. 지표를 도배해 진짜 신호를 묻던 원래 문제가 작은 규모로 남는 것이다.

그래서 둘 다 해야 한다. 상한 술어를 실행 경계에 두어 어떤 입구도 우회하지 못하게 하고, 큐에 넣는 쪽에서도 같은 술어(또는 명시적인 종료 상태)로 대상에서 빼서 큐가 헛돌지 않게 한다. 앞의 것은 정확성을 위한 것이고, 뒤의 것은 조용함을 위한 것이다. 큐잉 쪽 필터만으로는 우회되고, 실행 경계만으로는 소음이 남는다.

동시성 함정 셋 — 리뷰가 잡아낸 것들

이 수정 자체는 간단해 보이는데, 구현하면서 동시성 함정 세 개를 밟았다. 셋 다 다른 계열 모델의 적대적 코드리뷰가 잡았다.

① 서브쿼리를 두 UPDATE가 공유하면 예산이 두 배로 샌다. “이번 배치에서 처리할 대상”을 서브쿼리로 정의하고 두 개의 UPDATE가 각각 그걸 참조하면, 서브쿼리는 각 문장에서 다시 평가된다. 두 번째 평가 시점의 대상 집합은 첫 번째와 다를 수 있고, 결과적으로 의도한 것보다 많은 항목이 처리된다. 대상 코호트를 먼저 구체화해서 고정한 뒤 그 목록으로 작업해야 한다.

② select-then-update는 이중 처리된다. “조건에 맞는 것 골라오기 → 골라온 것 갱신하기”로 나누면, 그 사이에 리퍼가 한 번 더 돌아 같은 항목을 집는다. 조건부 UPDATE + RETURNING으로 고르기와 갱신을 한 문장에 넣어야 한다.

여기서 오해하기 쉬운 게 있다. 이건 claim 경쟁을 푸는 것이지 동시 실행을 막는 게 아니다. 리퍼가 “멈춘 것 같은” 행을 대기 상태로 되돌렸는데 사실 원래 워커가 느릴 뿐 살아 있었다면, 두 번째 워커가 아무리 원자적으로 claim해도 둘이 같은 작업의 외부 효과를 동시에 수행할 수 있다. 원자적 claim은 DB 행 하나에 대한 경쟁만 정리할 뿐, 이미 밖에서 돌고 있는 프로세스를 멈추지 못한다.

이걸 막으려면 claim할 때 임차 세대(lease generation) 값을 함께 증가시키고, 완료 쓰기와 외부 효과 직전에 내 세대가 아직 유효한지 확인해야 한다(펜싱 토큰). 세대가 밀린 워커의 쓰기는 거부된다. 아니면 아예 외부 효과 자체를 멱등하게 만들어 두 번 실행돼도 결과가 같게 한다. 이 글의 수정 범위는 재시도 상한이었고 이 부분은 별도 과제로 남았다 — 다만 리퍼가 있는 시스템이라면 반드시 같이 봐야 하는 항목이다.

③ 읽고-검증-쓰기는 그 사이의 갱신을 덮어쓴다. 값을 읽고 조건을 확인한 뒤 쓰는 사이에 다른 트랜잭션이 같은 행을 바꾸면, 내 쓰기가 그걸 조용히 덮는다. compare-and-swap으로, 내가 읽었던 값이 아직 그대로일 때만 쓰도록 조건을 건다.

셋을 관통하는 건 하나다. “확인”과 “행동” 사이에 시간이 있으면 그 사이에 세상이 바뀐다. 두 개를 한 문장으로 합치거나, 내가 본 값이 유효한지 쓰는 순간 다시 확인하거나 — 둘 중 하나여야 한다.

스스로 멈추게 만들기

마지막으로 배포 방식 하나. 이런 수정을 하면 “그럼 이미 쌓인 문제 항목들은 어떻게 정리하지?”가 따라온다. 운영자가 스크립트로 밀어주는 방법도 있다.

이번엔 그렇게 하지 않고, 배포만 하면 문제 항목들이 상한에 도달해 스스로 멈추도록 설계했다. 손상 파일은 다음 시도에서 카운터가 올라가고, 몇 번 더 돌다 상한에 걸린다. 그리고 앞 절에서 말한 대로 스캐너의 대상 조건에도 같은 술어가 들어가 있어야 그때부터 스캔에서 아예 빠진다 — 실행 경계만 막아두면 큐는 계속 헛돈다.

운영 개입이 0이다. 정리 스크립트는 그 자체로 또 하나의 위험한 물건이고(잘못 짜면 멀쩡한 항목까지 건드린다), 사람이 언제 돌릴지 기억해야 하는 일이 하나 늘어난다. 고침이 배포와 함께 자기 자신을 정리하도록 만들 수 있으면 그쪽이 낫다.

가져갈 것

방어 장치를 몇 개 가지고 있느냐보다, 그 장치들이 서로 무엇을 만들고 있는지가 더 중요할 때가 있다.


저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.


Tags: celery, retry, dead-letter, concurrency, postmortem, reliability, queue