Sidekiq: 릴리스와 비동기 잡의 함정
SIGTERM/SIGKILL — 긴 Sidekiq 잡이 배포 때마다 죽는 이유
시작 — 실제 사고
어느 날 화면이 "동기화 중" 표시로 영원히 고착되어 있는 걸 발견했다. DB를 확인하니:
integ.status # => 1 (= 실행중)
integ.success_count # => 4808 / 6424
integ.latest_sync_at # => 어제
Sidekiq의 Workers / Queue / Retry / Dead 어디에도 해당 잡이 없다. 잡은 사라졌는데 DB 상태는 "실행중" 그대로 — 잡이 도중에 죽었다는 뜻이다.
원인을 좇아가면 OS 시그널이 나온다
OS가 프로세스를 종료시키는 두 가지 방법:
SIGTERM (graceful)
"야, 일 정리하고 좀 있다가 종료해줘"
OS가 신호만 보냄. 프로세스가 자기 손으로 종료 처리
Sidekiq 측: 새 잡 받기 중단 → 현재 잡은 끝날 때까지 기다림 → 안 끝나면 retry 큐로 되돌리고 종료
Ruby 입장:
at_exit,ensure,rescue모두 실행됨
SIGKILL (forced)
"닥치고 죽어"
OS가 즉시 강제 종료. 프로세스에게 선택권 없음
진행 중인 코드 어디에서든 즉사
ensure,rescue,at_exit전부 실행 안 됨DB 트랜잭션 commit 안 된 상태로 끝남
시그널 핸들러로도 못 잡음
ECS / k8s 배포 시 무엇이 일어나나
1. 새 컨테이너 띄움
2. 헬스체크 OK → 기존 컨테이너에 SIGTERM 보냄
3. stopTimeout (예: 30~120초) 동안 기다림
4. 그래도 안 죽으면 → SIGKILL
이 "stopTimeout 동안 기다림" 시간이 graceful shutdown 윈도우다.
Sidekiq 측 동작:
1. polling 중단
2. 진행 중인 잡 있으면 :timeout 설정값(기본 25초) 동안 기다림
3. 25초 안에 끝나면 → 정상 종료 ✅
4. 안 끝나면 → 잡을 retry 큐에 다시 넣고 종료 시도
5. 그래도 stopTimeout 넘으면 → ECS가 SIGKILL → 즉사, ensure 안 탐 ❌
1시간짜리 잡이 어떻게 stuck 되는가
class HeavySyncService
def run
@integ.update(status: 1) # ← 진입 시 실행중 마킹
begin
6424.times do |i|
sleep 0.5 # ← 1건당 0.5초
process_one(i) # 실제 처리도 추가로 시간
end
@integ.update(status: 77) # ← 정상 종료 시 success
rescue => e
@integ.update(status: 44) # ← 예외 발생 시 failed
raise e
end
end
end
6424건 × 0.5초 ≈ 54분. 이 잡이 돌고 있는 도중에 누군가가 develop 에 머지하고 배포가 떨어진다면:
12:00 잡 시작 → status=1
12:30 배포 → ECS가 기존 컨테이너에 SIGTERM
→ Sidekiq, :timeout(25초) 안에 끝내려 시도. 못 끝냄.
12:31 stopTimeout 초과 → ECS가 SIGKILL
→ 잡 즉사. ensure 안 탐. status=1 그대로.
사용자가 보는 건? 화면을 새로고침해도 영원히 "동기화 중 (4808/6424)".
"근데 잡이 죽으면 큐에 다시 들어가서 재실행되는 거 아니야?"
반은 맞고 반은 틀리다.
Sidekiq 의 표준 메커니즘 (SIGTERM 시)
- 진행 중인 잡을
:timeout안에 끝내려 시도 - 안 끝나면 잡을 큐에 다시 넣고 (re-enqueue) 종료
- 새 컨테이너가 큐에서 꺼내 처음부터 재실행
→ 이 경로라면 잡이 사라지지 않는다.
그러나 SIGKILL 시
Sidekiq 자체가 즉사 → 잡을 큐에 되돌릴 주체가 없음
OSS Sidekiq 의 기본 fetcher 는 BRPOP 으로 잡을 꺼낸 순간 Redis 에서 삭제함 (non-reliable)
→ 워커가 SIGKILL 되면 그 잡은 그냥 사라진다
Sidekiq Pro 의 super_fetch 또는 sidekiq-reliable-fetch (OSS) 를 쓰면 잡을 별도 set 에 보관하다가 워커가 죽으면 복구하지만, OSS 기본 Sidekiq 는 그렇지 않다.
60초 짜리 잡과 1시간 짜리 잡의 차이
| 잡 길이 | 배포 시 동작 |
|---|---|
| 1초 | 거의 항상 graceful 안에 끝남. 무시해도 됨 |
| 60초 | stopTimeout 안에 끝날 확률 매우 높음. 설사 SIGKILL 맞아도 다음 정기 실행에서 자연 복구 |
| 5분 | 가끔 graceful 윈도우 못 들어감. 운영상 신경 쓰임 |
| 30분~1시간 | 거의 매번 SIGKILL. 매번 stuck. 시한폭탄 |
핵심: 잡의 길이를 graceful shutdown 윈도우 안에 들어가게 만드는 것 이 가장 확실한 방어다.
진짜 제대로 막는 법
- 잡을 짧게 유지 (가장 중요)
무의미한
sleep제거1건당 update / broadcast 같은 무거운 호출은 N건 단위로 batch
큰 잡은 chunk 로 쪼개서 N개 잡으로 enqueue
- Idempotent 하게 만들기
처음부터 재실행해도 같은 결과가 나오도록
chunk 단위로 진행 상태를 DB에 기록 → 재실행 시 이어가기
- Reliable fetch 도입
Sidekiq Pro
super_fetch(유료)sidekiq-reliable-fetch(OSS, 메인테넌스 주의)SIGKILL 시에도 잡 손실을 막아줌
- Stuck 감지 & 자동 재실행 잡 (현실적인 최선책)
아이디어: "Sidekiq 이 도중에 죽었다면 DB 에
status=1로 멈춰 있는 레코드가 있을 것이다. 그럼 그걸 정기적으로 찾아서 재실행하면 된다."예: 1시간마다 스케줄러(cron / sidekiq-cron / whenever 등) 로 다음 잡을 돌림
class RecoverStuckSyncsJob < ApplicationJob
queue_as :default
STALE_AFTER = 1.hour
def perform
Integration.where(status: :running)
.where('updated_at < ?', STALE_AFTER.ago)
.find_each do |integ|
Rails.logger.warn("[StuckRecovery] #{integ.id} stale since #{integ.updated_at}")
integ.update!(status: :failed, alert: '자동 복구: 직전 실행이 중단되었습니다')
SyncJob.perform_later(integ.id) # 재enqueue
end
end
end
포인트:
updated_at이 일정 시간 이상 멈춰 있으면 (= 진행이 없으면) stuck 으로 간주- 진행이 있다면 ループ 내의
update로updated_at이 계속 갱신되므로 오탐 걱정 없음 status를 44 로 떨어뜨리고 다시 enqueue → 화면의 "실행중" 고착도 풀림- 잡 자체가 idempotent 해야 안전 (처음부터 다시 돌려도 OK)
주기는 상황에 맞게. 1시간이면 사용자 체감 stuck 시간이 최대 1시간이라는 뜻. 민감한 화면이면 10~15분으로 더 짧게
ensure보험 (보조)
SIGKILL 외 경로에서는 status 복구 가능
SIGKILL 자체는 못 막지만, 망가진 종료 경로 일부를 커버
정리
비동기 잡은 "재시도되니까 안전" 이 아니다. OSS Sidekiq 기본 설정에서는 SIGKILL 시 잡이 사라진다
배포 타이밍과 잡 길이는 직결된다. 잡이 길수록 배포 때마다 죽을 확률이 올라간다
"잡이 영원히 실행중" 같은 화면은 대부분 SIGKILL + ensure 미실행 + 잡 자체의 길이 의 조합이다
가장 효과적인 방어는 멋진 인프라가 아니라 잡을 짧게 만드는 것이다
핵심 포인트
sleep 0.5 × 6424건 ≈ 54분짜리 잡이 있다고 가정
실행 중에 develop에 머지 + 배포가 떨어짐
ECS가 기존 컨테이너에 SIGTERM 송신
Sidekiq, :timeout (25초) 내에 종료 시도 → 못 끝냄
stopTimeout 초과 → ECS가 SIGKILL
SIGKILL이라 ensure / rescue 실행 안 됨 → DB에 status=1 그대로 잔존
OSS Sidekiq 기본 fetcher는 non-reliable이라 잡 자체도 큐에 안 돌아옴
사용자는 새로고침해도 영원히 "실행중" 화면을 본다
장점
- ✓ 잡 분리는 사용자 응답 시간을 단축시킨다 (원래의 장점)
- ✓ 실패 복구를 retry로 자동화할 수 있다
- ✓ 잡 길이만 신경 쓰면 대부분의 stuck 사고는 막을 수 있다
단점
- ✗ OSS Sidekiq 기본 설정은 SIGKILL 시 잡 손실이 발생한다
- ✗ 잡이 길어질수록 배포 때마다 죽을 확률이 비선형으로 올라간다
- ✗ SIGKILL은 ensure/rescue 어디로도 잡히지 않으므로 코드 레벨에서 100% 보장 불가
- ✗ Reliable fetch 도입은 유료 또는 OSS 메인테넌스 리스크 있음