-
vLLM 이 매 iter 도중 자살할 수 있다 — 헬스 보장과 진단 자산을 한 함수에 (Ralph Loop 시리즈 3편)IT 2026. 5. 16. 23:00
장기 실행 자율 스크립트의 가장 짜증나는 실패 모드는 의존성 서비스가 도중에 죽고 우리는 한참 후에 알아차리는 케이스다. 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가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글