💀

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 시)

  1. 진행 중인 잡을 :timeout 안에 끝내려 시도
  2. 안 끝나면 잡을 큐에 다시 넣고 (re-enqueue) 종료
  3. 새 컨테이너가 큐에서 꺼내 처음부터 재실행

→ 이 경로라면 잡이 사라지지 않는다.

그러나 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 윈도우 안에 들어가게 만드는 것 이 가장 확실한 방어다.

진짜 제대로 막는 법

  1. 잡을 짧게 유지 (가장 중요)
  • 무의미한 sleep 제거

  • 1건당 update / broadcast 같은 무거운 호출은 N건 단위로 batch

  • 큰 잡은 chunk 로 쪼개서 N개 잡으로 enqueue

  1. Idempotent 하게 만들기
  • 처음부터 재실행해도 같은 결과가 나오도록

  • chunk 단위로 진행 상태를 DB에 기록 → 재실행 시 이어가기

  1. Reliable fetch 도입
  • Sidekiq Pro super_fetch (유료)

  • sidekiq-reliable-fetch (OSS, 메인테넌스 주의)

  • SIGKILL 시에도 잡 손실을 막아줌

  1. 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 으로 간주
    • 진행이 있다면 ループ 내의 updateupdated_at 이 계속 갱신되므로 오탐 걱정 없음
    • status 를 44 로 떨어뜨리고 다시 enqueue → 화면의 "실행중" 고착도 풀림
    • 잡 자체가 idempotent 해야 안전 (처음부터 다시 돌려도 OK)
  • 주기는 상황에 맞게. 1시간이면 사용자 체감 stuck 시간이 최대 1시간이라는 뜻. 민감한 화면이면 10~15분으로 더 짧게

  1. ensure 보험 (보조)
  • SIGKILL 외 경로에서는 status 복구 가능

  • SIGKILL 자체는 못 막지만, 망가진 종료 경로 일부를 커버

정리

  • 비동기 잡은 "재시도되니까 안전" 이 아니다. OSS Sidekiq 기본 설정에서는 SIGKILL 시 잡이 사라진다

  • 배포 타이밍과 잡 길이는 직결된다. 잡이 길수록 배포 때마다 죽을 확률이 올라간다

  • "잡이 영원히 실행중" 같은 화면은 대부분 SIGKILL + ensure 미실행 + 잡 자체의 길이 의 조합이다

  • 가장 효과적인 방어는 멋진 인프라가 아니라 잡을 짧게 만드는 것이다

핵심 포인트

1

sleep 0.5 × 6424건 ≈ 54분짜리 잡이 있다고 가정

2

실행 중에 develop에 머지 + 배포가 떨어짐

3

ECS가 기존 컨테이너에 SIGTERM 송신

4

Sidekiq, :timeout (25초) 내에 종료 시도 → 못 끝냄

5

stopTimeout 초과 → ECS가 SIGKILL

6

SIGKILL이라 ensure / rescue 실행 안 됨 → DB에 status=1 그대로 잔존

7

OSS Sidekiq 기본 fetcher는 non-reliable이라 잡 자체도 큐에 안 돌아옴

8

사용자는 새로고침해도 영원히 "실행중" 화면을 본다

장점

  • 잡 분리는 사용자 응답 시간을 단축시킨다 (원래의 장점)
  • 실패 복구를 retry로 자동화할 수 있다
  • 잡 길이만 신경 쓰면 대부분의 stuck 사고는 막을 수 있다

단점

  • OSS Sidekiq 기본 설정은 SIGKILL 시 잡 손실이 발생한다
  • 잡이 길어질수록 배포 때마다 죽을 확률이 비선형으로 올라간다
  • SIGKILL은 ensure/rescue 어디로도 잡히지 않으므로 코드 레벨에서 100% 보장 불가
  • Reliable fetch 도입은 유료 또는 OSS 메인테넌스 리스크 있음

사용 사례

대용량 데이터 동기화 잡 CSV 임포트 / 엑스포트 리포트 집계 잡 외부 API 대량 호출 잡 잘못된 throttling (sleep) 이 들어간 잡