Sv hup 유니콘 드랍 트래픽

사이트를 다시 로드할 때 약 40초간의 다운타임이 발생하고 있습니다. 제 기술 수준을 좀 벗어난 문제라 클로드(Claude)에게 도움을 요청했습니다.

클로드는 문제가 유니콘(unicorn) 런처가 구버전 마스터 프로세스에 잘못된 신호(SIGTERM 대신 SIGQUIT)를 보내고 있기 때문이라고 주장합니다. 이로 인해 새 마스터가 요청을 처리하기 전에 구버전 마스터가 종료됩니다.

수정 내용은 6줄의 업데이트로 알려졌지만, 먼저 여기서 건전성 검사(sanity check)를 하고 싶습니다.

클로드가 생성한 보고서 보기

요약

pitchfork 코드 경로에서 config/unicorn_launcheron_reload_pitchfork()는 워커 _프로세스_가 존재하는 즉시 나가는(outgoing) 마스터를 퇴역(retire)시킵니다. 이 과정에서 새 마스터가 요청을 처리할 수 있는지 확인하지 않으며, kill -s QUIT(그라운셜 종료) 대신 kill(SIGTERM, 즉시 종료)을 사용하여 퇴역시킵니다.

그 바로 위 유니콘 경로인 on_reload_unicorn()은 두 가지를 모두 올바르게 수행합니다 — curl $LOCAL_WEB을 실행하여 새 마스터를 워밍업하고, 그런 다음 QUIT 신호를 보냅니다.

단일 sv hup unicorn 명령의 관찰된 결과는 약 40초 동안 어떤 요청도 완료되지 않는 상태이며, 이는 새 세대가 Booted Rails를 로깅하는 시점에 정확히 종료됩니다.

환경

  • Discourse 2026.8.0-latest.1, Rails 8.0.5.1, Ruby 3.4.10
  • pitchfork 0.18.2, RUN_PITCHFORK 미설정 (따라서 pitchfork 분기가 활성화됨)
  • 스탠드얼론 컨테이너, nginx → http://127.0.0.1:3000
  • 2 vCPU / 4 GB 드롭렛; 앱 부팅에 약 40초 소요

코드

config/unicorn_launcher:

function on_reload_unicorn() {
  ...
  while [ ... workers not up ... ]; do sleep 1; done

  curl $LOCAL_WEB &>/dev/null      # warm the new master
  kill -s QUIT $UNICORN_PID        # graceful
}

function on_reload_pitchfork() {
  log "Reloading pitchfork ($UNICORN_PID)"
  OLD_PID=$UNICORN_PID
  pitchfork "${PITCHFORK_ARGS[@]}" &
  UNICORN_PID=$!

  count=0
  while [ "$count" -lt 180 -a -z "$(pgrep -f -P $UNICORN_PID worker)" ]; do
    log "Waiting for new pitchfork workers under $UNICORN_PID to start up..."
    count=$((count + 1))
    sleep 1
  done

  kill $OLD_PID 2>/dev/null        # SIGTERM, and no warm-up first
}

pgrep -f -P $UNICORN_PID worker는 워커 프로세스가 _생성(spawn)_되는 즉시 반환됩니다. pitchfork에서는 워커가 애플리케이션 로딩이 완료되기 훨씬 전에 존재하므로, 루프가 조기에 종료되고 새 마스터가 여전히 부팅 중인 동안 구버전 마스터가 해체됩니다.

관찰된 동작

production.log, 리로드 1회 (타임스탬프 UTC):

23:16:49  Completed 200 OK in 95ms        <- outgoing generation, warm
          (44 seconds; not one Completed line)
23:17:33  Booted Rails 8.0.5.1 application in production environment
23:17:36  Completed 200 OK in 4128ms      <- first render on the new generation, cold
23:17:39  Completed 200 OK in 200ms

해당 시간 동안 nginx를 통해 약 2초 간격으로 GET /를 폴링한 모니터는 다음과 같이 기록했습니다:

23:17:11  499  19.697s   (client gave up; upstream never answered)
23:17:29  503  16.301s
23:17:31  502   1.448s

그리고 502 오류에 대한 nginx 오류 로그:

2026/08/14 23:17:31 [error] 70#70: *110675 recv() failed (104: Connection reset by peer)
while reading response header from upstream, upstream: "http://127.0.0.1:3000/"

모든 실패한 라인에서 upstream_response_timerequest_time과 같으므로 nginx가 병목이 아닙니다 — 요청은 응답할 수 없는 프로세스에 도달하고 있습니다. 23:17:31의 리셋은 큐에 연결이 남아 있는 구버전 마스터를 SIGTERM이 해체하는 것과 일치합니다.

이것은 재현 가능합니다: 한 번의 배포에서 두 번의 리로드는 각각 Booted Rails 라인에서 종료되는 동일한 약 40초의 윈도우를 생성했습니다.

리소스 압박이 아닙니다: dmesg에 OOM 킬이 없고, 스왑은 2048MB 중 141MB 사용, 로드 평균 0.07, 그리고 두 번의 부팅 모두 정상적으로 완료되었습니다.

권장 수정 사항

유니콘 분기를 모방하여, 구버전을 퇴역시키기 전에 새 마스터가 실제로 요청을 처리하는지 기다리고, 그라운셜하게 퇴역시킵니다:

   count=0
   while [ "$count" -lt 180 -a -z "$(pgrep -f -P $UNICORN_PID worker)" ]; do
     log "Waiting for new pitchfork workers under $UNICORN_PID to start up..."
     count=$((count + 1))
     sleep 1
   done

-  kill $OLD_PID 2>/dev/null
+  count=0
+  until curl -sf -o /dev/null "$LOCAL_WEB" || [ "$count" -ge 180 ]; do
+    log "Waiting for the new pitchfork master to serve a request..."
+    count=$((count + 1))
+    sleep 1
+  done
+
+  kill -s QUIT $OLD_PID 2>/dev/null

TERM 대신 QUIT를 사용하는 부분은 모호하지 않습니다: pitchfork는 유니콘의 신호 시맨틱을 상속받으며, 여기서 QUIT는 드레인(drain)하고 TERM은 즉시 정지입니다.

PS: 이것은 제 첫 번째 AI 보조 보고서입니다. 예의를 어기고 있다면 알려주세요. :sweat_smile:

잘못된 시그널이 전송되는지, 그리고 이로 인해 리스폰이 덜 매끄러워지는지 확인해 주실 수 있나요?

그것이 잘못된 건가요?

여기에는 약간의 레이스 컨디션이 있지만, 피치포크 워커가 준비되는데 0.1초 미만이면 충분합니다. 피치포크는 먼저 애플리케이션을 몰드(mold)에 로드한 뒤, 워커를 생성하기 전에 애플리케이션이 시작될 때까지 기다립니다.

이 부분도 확실하지 않은데, 문서에는 다른 내용이 나와 있습니다.

시그널 처리

일반적으로 시그널은 모니터 프로세스에만 보내야 합니다. 그러나 워커 프로세스와 통신하기 위해 피치포크가 내부적으로 사용하는 시그널도 여기 문서화되어 있습니다.

모니터 프로세스

  • INT - 빠른 종료, 모든 워커를 즉시 종료합니다

  • QUIT/TERM - 그레시풀 종료, 워커가 현재 요청을 완료할 때까지 기다린 후 종료합니다.