ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • vLLM 이 매 iter 도중 자살할 수 있다 — 헬스 보장과 진단 자산을 한 함수에 (Ralph Loop 시리즈 3편)
    IT 2026. 5. 16. 23:00
    vLLM 이 매 iter 도중 자살할 수 있다 — 헬스 보장과 진단 자산을 한 함수에 (Ralph Loop 시리즈 3편)

    장기 실행 자율 스크립트의 가장 짜증나는 실패 모드는 의존성 서비스가 도중에 죽고 우리는 한참 후에 알아차리는 케이스다. Ralph Loop 를 돌리던 어느 날, 30분간 진척이 0이 되어 의아해하다가 발견했다 — vLLM 이 KV cache 의 off-by-one assertion 으로 자살하고 docker compose 의 restart: "no" 정책 때문에 자동 재기동이 안 되어 컨테이너가 죽은 채로 방치된 상황이었다. 그동안 opencode 는 죽은 endpoint 에 호출을 던지고 5분 timeout 으로 종료하기를 반복하고 있었다.

    이 글은 그 이후 추가한 ensure_vllm_alive() 함수 — 매 iter 시작 시 + opencode 직후에 vLLM 헬스를 확인하고, 죽었으면 진단을 캡처한 뒤 자동 재기동하는 한 함수 — 를 다룬다. 단순한 헬스체크가 아니라 장애 발생 시점의 운영 상태를 한 번에 누적 해서 사후 분석 자산으로 만드는 패턴이 핵심이다. 시리즈 3편이고, 1편은 컨텍스트 3축, 2편은 부팅·셧다운 lifecycle 을 다뤘다.

    왜 시작 헬스체크 한 번으로 안 되는가

    2편에서 ralph-loop 가 부팅 시 GPU acquire → vLLM 기동 → 헬스체크 의 3단계를 거친다고 했다. 그 헬스체크가 통과하면 곧바로 메인 루프에 들어가 opencode 를 호출한다. 처음엔 이걸로 충분하다고 생각했다 — 한 번 띄운 vLLM 이 한 사이클 동안은 살아있을 거라고 가정한 것.

    그런데 vLLM v019 의 v1 엔진에는 긴 컨텍스트 + 특정 토큰 패턴 조합에서 KV cache 매니저가 off-by-one assertion 을 던지고 EngineCore 가 죽는 알려진 버그가 있었다. 우리 prompt 가 매 iter 마다 PLAN.md (5.8KB) + CHECKLIST.md (점점 커짐) + git log + 시스템 프롬프트 + 도구 설명 으로 늘어나면서 어느 임계점에서 이 버그를 트리거했다. 두 번 죽음을 관찰했다 — 한 번은 09:48 (24 < 25), 한 번은 10:46 (25 < 26). 매번 정확히 +1 차이의 off-by-one 패턴.

    EngineCore 가 죽으면 APIServer 가 따라 종료하면서 컨테이너가 정상 종료(Exit 0)된다. 그런데 우리 docker-compose.yml 의 restart: "no" 가 자동 재기동을 막는다. 컨테이너는 죽고, GPU 스케줄러의 lock 은 여전히 살아있다. 즉 lock 은 점유 중이라고 보고하지만 실제 컨테이너는 죽어있는 desync 상태 가 생긴다.

    opencode 입장에서 endpoint 에 요청을 보내면 connection 자체는 OS 레벨에서 거부되거나 timeout 이 떨어진다. opencode 는 그걸 일반 응답 실패로 처리하고 stdout 에 짧은 에러를 출력한 뒤 종료한다. ralph-loop 의 검증 후크가 미체크/미커밋을 잡아 시도 카운트를 증가시키지만, 같은 항목으로 3-strike 가 발동해 자동 스킵 되어버린다. 결과적으로 vLLM 이 죽어있는 채로 ralph-loop 는 계속 돌면서 모든 항목을 허위 스킵 처리하는 사고가 발생한다.

    해결은 단순했다 — 매 iter 시작 시 vLLM 헬스를 다시 확인하고, 죽었으면 즉시 재기동. 그리고 이왕 헬스체크 함수를 만드는 김에 죽음의 진단 정보까지 함께 캡처 하는 게 더 큰 가치였다.

    핵심 함수 — `ensure_vllm_alive()` 의 3단 구조

    이 함수는 ralph-loop.sh 안에서 가장 긴 함수다. 65줄 정도. 구조는 단순하다 — 헬스체크 (3줄) + 진단 캡처 (30줄) + 자동 재기동 (15줄) + 재확인 (5줄).

    DEATH_LOG="$LOG_DIR/vllm-deaths.log"
    
    ensure_vllm_alive() {
      local context_iter="$1"
      local context_item="$2"
      local context_prompt_size="$3"
    
      # 1단 — 헬스체크 (살아있으면 즉시 return)
      if curl -sS --max-time 3 "$VLLM_BASE_URL/models" > /dev/null 2>&1; then
        return 0
      fi
    
      echo "💀 vLLM 응답 없음 — 진단 로그 캡처 + 재기동 시도"
    
      # 2단 — 진단 캡처 (한 번에 9가지 운영 정보)
      {
        echo "===== vLLM down detected at $(date '+%Y-%m-%d %H:%M:%S') ====="
        echo "context: iter=$context_iter, item=$context_item"
        echo "직전 prompt 크기: ${context_prompt_size:-(N/A)} chars"
        echo ""
        echo "--- CHECKLIST 상태 ---"
        echo "  크기: $(wc -c < CHECKLIST.md) chars / $(wc -l < CHECKLIST.md) lines"
        echo "  완료(x): $(grep -cE '^[ ]*- \[x\]' CHECKLIST.md), 잔여([ ]): $(grep -cE '^[ ]*- \[ \]' CHECKLIST.md), 스킵(!): $(grep -cE '^[ ]*- \[!\]' CHECKLIST.md)"
        echo ""
        echo "--- PLAN.md ---"
        echo "  크기: $(wc -c < PLAN.md) chars / $(wc -l < PLAN.md) lines"
        echo ""
        echo "--- 직전 5 커밋 ---"
        git log --oneline -5 | sed 's/^/  /'
        echo ""
        echo "--- docker container 상태 ---"
        docker ps -a --filter name=vllm-spark-head --format '  {{.Status}} / {{.RunningFor}}'
        echo ""
        echo "--- vLLM container 마지막 60줄 ---"
        docker logs --tail 60 vllm-spark-head 2>&1 | sed 's/^/  /'
        echo ""
        echo "--- nvidia-smi ---"
        nvidia-smi --query-gpu=name,memory.used,memory.free,memory.total --format=csv,noheader | sed 's/^/  /'
        echo ""
        echo "--- GPU 스케줄러 status ---"
        "$GPU_SCHED" status 2>&1 | sed 's/^/  /'
        echo "===== END ====="
        echo ""
      } >> "$DEATH_LOG"
    
      echo "📝 진단 로그 기록: $DEATH_LOG"
    
      # 3단 — 자동 재기동 (1차 → 30초 wait → 2차)
      echo "🔁 vLLM 재기동 시도 (1차)"
      if ! "$START_VLLM" 2>&1 | tail -10; then
        echo "❌ 재기동 1차 실패 — 30초 후 재시도"
        sleep 30
        echo "🔁 vLLM 재기동 시도 (2차)"
        if ! "$START_VLLM" 2>&1 | tail -10; then
          return 1
        fi
      fi
    
      # 4단 — 재확인
      if ! curl -sS --max-time 5 "$VLLM_BASE_URL/models" > /dev/null 2>&1; then
        return 1
      fi
    
      echo "✅ vLLM 복구 완료, iter $context_iter 재개"
      return 0
    }

    코드 설명 — 함수는 살아있으면 1단에서 즉시 return 0 으로 끝난다 (정상 케이스, 매 iter 시작 시 거의 항상 이 경로). 죽어있을 때만 2~4단이 실행된다. 2단의 진단 캡처는 한 번의 함수 호출로 9가지 운영 정보 를 한 파일에 누적 append 한다 — 호출 시점·iter 번호·작업 항목·prompt 크기·CHECKLIST 메트릭·PLAN 크기·직전 5 커밋·docker 컨테이너 상태·vLLM 컨테이너 마지막 60줄 로그·nvidia-smi 메모리·GPU 스케줄러 상태. 3단의 재기동은 1차 실패 후 30초 wait + 2차 재시도까지 시도하고 그래도 실패하면 return 1 (호출자가 루프 중단 결정). 4단의 재확인은 재기동 명령이 성공해도 endpoint 가 진짜 응답하는지 한 번 더 확인한다.

    왜 9가지나 캡처하는가 — 한 번 죽으면 다시 못 보는 정보

    처음엔 진단 캡처가 너무 과한가 싶었다. docker logs 만 있으면 충분하지 않을까? 하지만 한 번 사고를 겪고 나니 이유가 분명해졌다.

    vLLM 이 죽는 시점의 운영 컨텍스트는 시간 함수 다. 우리가 30분 후에 사후 분석을 시작하면:

    • docker logs 는 ring buffer 라 옛날 로그가 잘려나간다 (특히 INFO 가 매초 출력되는 vLLM)
    • nvidia-smi지금 의 GPU 메모리 상태만 보여줌. 30분 전 다른 작업이 동시 점유 중이었는지 알 길 없음
    • CHECKLIST.md 는 그 사이 다른 항목까지 처리되어 죽음 시점의 크기·진척과 다름
    • git log 는 그 사이 커밋이 더 추가되어 직전 5 커밋이 다름
    • GPU 스케줄러 status 는 다른 작업이 acquire/release 한 결과로 다른 lock 상태

    죽음 시점에 캡처 안 하면 영영 못 보는 정보들이 9가지 나 있다. 사후 분석은 이 9가지 조각을 맞춰서 왜 그 시점에 그 죽음이 발생했는가 를 추론한다. 우리 케이스에서 prompt 크기와 CHECKLIST 크기를 봤더니 iter 가 진행될수록 누적된 prompt 가 vLLM 의 KV cache 임계를 트리거했다 는 가설이 자연스럽게 떠올랐다.

    그림 설명 — 9가지 운영 정보 각각이 시간이 지나면 어떻게 변하거나 사라지는지 정리한 표다. ①~⑨ 모두 죽음 시점에 캡처해야 사후에 추론할 수 있다. ⑦ docker logs 는 vLLM 이 매초 INFO 를 출력해 ring buffer 가 빨리 차서 옛날 로그가 잘려나간다. ⑧ nvidia-smi 와 ⑨ GPU 스케줄러 status 는 다른 GPU 작업의 활동에 따라 변동한다. ② prompt 크기와 ③ CHECKLIST 카운트는 다음 iter 에서 다른 값으로 덮인다. 한 번에 한 곳에 누적 하지 않으면 영영 잃는 정보다.

    실제 진단 로그 — 9가지가 한 번에 보이는 모양

    위 함수가 실제로 만든 진단 로그의 한 항목이다 (일부 발췌).

    ===== vLLM down detected at 2026-05-04 11:46:38 =====
    context: iter=30, item=src/game.js 상태 머신 골격...
    직전 prompt 크기: 12347 chars
    
    --- CHECKLIST 상태 ---
      크기: 3722 chars / 71 lines
      완료(x): 6, 잔여([ ]): 22, 스킵(!): 14
    
    --- PLAN.md ---
      크기: 5826 chars / 152 lines
    
    --- 직전 5 커밋 ---
      06a5f1d skip: tests/collision.test.js (3회 실패)
      76e39d7 skip: src/collision.js — aabb() (3회 실패)
      5397ac3 feat: requestAnimationFrame 메인 루프 통합
      68d4603 skip: 차 좌우 이동 + 도로 경계 클램프 (3회 실패)
      99b68ef skip: src/input.js — 좌우 화살표/A/D 키 핸들러 (3회 실패)
    
    --- docker container 상태 ---
      Exited (0) 43 minutes ago / 6 days ago
    
    --- vLLM container 마지막 60줄 ---
      ...
      (EngineCore pid=123) ERROR 05-04 01:46:37 [core.py:1110] AssertionError: num_required_blocks 25 < len(req_blocks) 26
      (APIServer pid=1) ERROR 05-04 01:46:37 [async_llm.py:707] AsyncLLM output_handler failed.
      (APIServer pid=1) ERROR 05-04 01:46:37 [async_llm.py:707] vllm.v1.engine.exceptions.EngineDeadError: EngineCore encountered an issue.
      (APIServer pid=1) INFO:     Shutting down
      (APIServer pid=1) INFO:     Application shutdown complete.
      ...
    
    --- nvidia-smi ---
      NVIDIA GB10, 88 GiB, 8 GiB, 96 GiB
    
    --- GPU 스케줄러 status ---
      실행 중인 작업:
      qwen3-asr-daemon  p=68  8GB  실행중
      vllm-chat (lock 살아있음 — 컨테이너는 죽었는데도)
    
    ===== END =====

    로그 설명 — 한 번에 죽음의 모든 면을 본다. iter 30 에서 작업 항목은 src/game.js 상태 머신 이었고 직전 prompt 가 12347 chars 였다. CHECKLIST 는 그 시점 71 lines 였다. docker container 는 43분 전 Exit 0 으로 정상 종료. vLLM container 의 마지막 60줄에 정확한 죽음 원인 — AssertionError: num_required_blocks 25 < len(req_blocks) 26 — 이 박혀있다. nvidia-smi 는 88GB / 96GB 사용 중 (vllm-chat 점유 80GB 가 빠지지 않은 상태). GPU 스케줄러 status 에는 vllm-chat lock 이 살아있는데 docker container 는 죽어있는 desync 가 그대로 보인다.

    이 한 항목만으로 vLLM v019 의 KV cache assertion 버그가 prompt 길이가 임계점에 도달했을 때 트리거됨, docker compose restart no 라 자동 복구 안 됨, GPU 스케줄러 lock 과 컨테이너 desync 발생 의 세 줄 결론이 나온다. 30분 후에 사후 분석한 게 아니라 죽음 시점에 한 번에 잡았기 때문에 가능했다.

    매 iter 두 번 호출 — 시작 시 + opencode 직후

    이 함수는 메인 루프 안에서 두 번 호출된다.

    while [ ... ]; do
      iter=$((iter + 1))
    
      # 다음 항목 추출
      next=$(grep -n '^- \[ \]' CHECKLIST.md | head -1 || true)
      ...
    
      # 1번째 호출 — iter 시작 시
      if ! ensure_vllm_alive "$iter" "$item" ""; then
        echo "❌ vLLM 복구 불가 — 루프 중단"
        exit 4
      fi
    
      # opencode 호출
      prompt_size=$(printf '%s' "$prompt" | wc -c)
      timeout 300 opencode run --model "$MODEL" -f PLAN.md -f CHECKLIST.md -- "$prompt"
    
      # 2번째 호출 — opencode 직후
      if ! curl -sS --max-time 3 "$VLLM_BASE_URL/models" > /dev/null 2>&1; then
        echo "💀 opencode 호출 직후 vLLM 죽음 감지"
        ensure_vllm_alive "$iter (post-opencode)" "$item" "$prompt_size" || exit 4
      fi
    
      # 검증 후크 (다음 글에서)
      ...
    done

    코드 설명 — 두 번 호출하는 이유는 언제 죽었는지 의 정밀도를 높이기 위해서다. 1번째 호출 (iter 시작) 은 직전 iter 와 이번 iter 사이에 외부 요인 (다른 GPU 작업, 시스템 재시작 등) 으로 vLLM 이 죽었나 잡는다. 2번째 호출 (opencode 직후) 은 우리 prompt 가 직접 트리거했나 잡는다 — 이때 직전 prompt 크기를 함께 캡처해서 prompt 길이와 죽음의 상관관계 분석에 쓴다. 둘 다 같은 함수를 호출하지만 context 인자가 다르다 — 1번째는 빈 문자열, 2번째는 "$iter (post-opencode)" 와 prompt_size 값. 그래서 vllm-deaths.log 만 봐도 어느 단계에서 죽었는지 즉시 구분된다.

    start_vllm.sh 의 idempotent 성질에 의존

    재기동 명령으로 호출하는 start_vllm.sh 는 GPU 스케줄러 프로젝트에 따로 있는 스크립트인데, 이게 이미 살아있으면 즉시 skip 하는 idempotent 한 동작을 한다. 그 덕에 재기동 호출이 부작용 없이 안전 하다.

    # start_vllm.sh 의 핵심 로직 (발췌)
    # 이미 healthy 면 skip
    if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then
        echo "[start_vllm] vLLM 이미 실행 중"
        exit 0
    fi
    
    # 컨테이너 기동
    cd "$VLLM_DIR"
    docker compose --profile head up -d 2>&1 | tail -5
    
    # 헬스체크 폴링 (vLLM 콜드 스타트 ~3분)
    for i in $(seq 1 90); do
        if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then
            break
        fi
        sleep 4
    done

    코드 설명 — 살아있으면 0줄 변경으로 즉시 종료, 죽어있으면 컨테이너 재기동 후 6분(90×4초) 까지 헬스체크 폴링. 이 idempotent 성질 덕에 ralph-loop 의 ensure_vllm_alive 가 부담 없이 호출할 수 있다 — 살아있다면 ms 안에 끝나고, 죽어있다면 재기동까지 6분 안에 끝낸다. 호출자는 항상 같은 인터페이스로 부른다.

    왜 1차 실패 후 30초 wait 인가

    2단의 자동 재기동에 1차 → 30초 wait → 2차 의 backoff 가 들어있다.

    if ! "$START_VLLM" 2>&1 | tail -10; then
      echo "❌ 재기동 1차 실패 — 30초 후 재시도"
      sleep 30
      echo "🔁 vLLM 재기동 시도 (2차)"
      if ! "$START_VLLM" 2>&1 | tail -10; then
        return 1
      fi
    fi

    이 30초가 도움이 됐던 케이스가 한 번 있었다 — vLLM 이 죽고 거의 동시에 다른 GPU 작업 (vlm-analysis 같은 background 작업) 이 들어와 잠깐 GPU 메모리를 80GB 잡고 있었다. 1차 재기동은 메모리 부족으로 실패. 30초 wait 동안 그 background 작업이 끝났고, 2차에서 80GB 잡고 정상 기동. 이 시나리오에서 30초가 없었다면 즉시 2차 실패하고 함수가 return 1 → 루프 중단으로 갔을 것이다.

    30초가 모든 일시 자원 충돌을 해결하진 않지만 대부분의 background 작업이 1분 안에 끝나는 우리 환경에서 적절한 trade-off 였다. 더 보수적이라면 60초·90초로 늘릴 수 있는데, 그러면 정상 재기동도 30초 늦어진다.

    실제 효과 — 38 iter 동안 vLLM 자살 2회를 모두 자동 복구

    이 함수를 도입한 후 ralph-loop 를 다시 돌렸더니 vLLM 이 두 번 죽었지만 둘 다 자동 복구했다. vllm-deaths.log 는 두 항목으로 누적됐고, ralph-loop 는 멈추지 않고 끝까지 갔다. 사용자(나) 가 알아챈 건 cron 진척 보고에서 death events: 2 표시를 본 시점이지, 그 사이 ralph-loop 가 멈춘 적 없다.

    장애가 0 에 가까워지는 게 아니라 — 알려진 vLLM v019 버그를 우리가 패치할 수 없으니 — 장애가 발생해도 운영이 안 멈추는 게 진짜 목표였다. 이 함수가 그 목표를 달성했다.

    결론 — 진단을 작업의 일부로 만들기

    운영 시스템에서 흔한 패턴은 "장애 발생 → 알람 → 사람이 들어가 디버깅" 이다. 그런데 자율 시스템에서는 사람이 들어갈 시간이 없을 수 있다 — ralph-loop 같은 장기 실행 루프는 사람이 잘 때, 출근했을 때, 다른 일 하고 있을 때 돌고 있다.

    그래서 장애 발생 시점에 진단을 자동으로 캡처해서 한 곳에 누적 하는 게 — 알람보다 훨씬 — 가치 있다. 사람이 깨어나서 보면 그 사이 일어난 모든 장애의 9가지 정보가 vllm-deaths.log 에 시간순으로 정리되어 있다. 한 번 보면 패턴 추론이 가능하다 — "prompt 크기가 12000 chars 넘으면 자주 죽네" 같은 가설을 만들 수 있다.

    이게 단순한 헬스체크 함수가 아닌 이유다. 헬스체크 + 자동 복구만이라면 한 줄 curl + restart 로 끝났을 것이다. 그 사이에 9줄짜리 진단 캡처 블록을 끼워 넣은 게 — 이 함수의 진짜 가치다. 장애를 데이터로 바꾸는 운영 철학.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.