-
자율 스크립트의 부팅과 셧다운 — 외부 자원을 잡았다면 정확히 한 번만 놓아라 (Ralph Loop 시리즈 2편)IT 2026. 5. 16. 22:00
자율 코딩 루프 같은 장기 실행 스크립트의 시작과 끝은 의외로 까다롭다. 시작에서 외부 자원 — GPU lock, vLLM 컨테이너, 임시 파일 — 을 잡는 건 어렵지 않은데, 스크립트가 어떻게 끝나든 정확히 한 번 정리(cleanup)되도록 보장 하는 게 진짜 일이다. 정상 종료, Ctrl-C, kill -TERM, 스크립트 자체
exit 4, 또는 bash 의set -e가 발동시킨 갑작스런 종료까지 — 이 다섯 가지 종료 경로가 모두 같은 cleanup 함수로 합류하면서 동시에 중복 호출은 차단 되어야 한다.이 글은
ralph-loop.sh의 startup·shutdown 시퀀스를 본다. 한 30줄 정도의 코드인데 그 안에 자율 스크립트가 외부 자원을 다룰 때 빠지기 쉬운 실수 4가지의 답이 들어있다. 시리즈 2편이고, 1편은 컨텍스트 3축 분리 를 다뤘다.왜 이게 어려운가 — 외부 자원의 "정확히 한 번"
Ralph Loop 가 시작되면 GPU 스케줄러에
vllm-chat작업을 acquire 한다. 이건 다른 GPU 작업 (vault-search, voice-pipeline 등) 이 우리가 쓰는 동안 vLLM 을 죽이지 못하게 막는 lock 이다. 그리고 vLLM Docker 컨테이너를 기동한다 (이미 떠 있으면 skip).이제 작업이 끝나서 스크립트가 종료될 때 — 평범하게 끝나든, 사용자가 Ctrl-C 누르든, 다른 셸에서
kill으로 죽이든 — GPU lock 은 반드시 release 되어야 한다. release 안 하고 죽으면 lock 이 stale 상태로 남아 다음 실행이 "이미 점유 중" 응답을 받고 acquire 에 실패한다. 그러면 사람이 직접 lock 파일 지워줘야 한다.여기서 한 단계 더 — release 가 정확히 한 번만 호출되어야 한다. 두 번 호출되면 그것 자체가 에러는 아니지만, 두 번째 호출 시점에 lock 이 이미 다른 작업에 의해 새로 잡혔다면 그 새 작업의 lock 을 우리가 지워버리는 사고가 발생한다. "한 번 이상" 도, "한 번 이하" 도 안 되고 정확히 한 번.
그림 설명 — 다섯 가지 종료 경로 (정상 / Ctrl-C / kill / exit 1·4 / set -e 갑작스런 종료) 가 모두 trap 두 개로 분기된다. INT/TERM trap 은 SIGINT/SIGTERM 받을 때 발동하고 cleanup 호출 후
exit 130으로 종료하는데, 이 종료 자체가 다시 EXIT trap 을 발동시킨다. 즉 Ctrl-C 한 번에 cleanup 이 두 번 호출되는 경로가 자연스럽게 생긴다. 두 번째 호출에서 GPU release 를 또 하면 사고. 그래서 cleanup 안에releasedflag 가 들어있고, 이미 release 한 상태라면 두 번째 호출은 즉시 return 한다. 다섯 가지 어떤 경로로 끝나든 GPU release 는 정확히 한 번만 발동한다.핵심 코드 — 30줄 안에 모두 들어있다
ralph-loop.sh의 startup·shutdown 부분이다. 총 30줄 정도.#!/usr/bin/env bash set -euo pipefail # ← 한 줄이 갑작스런 종료를 만든다 REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$REPO_DIR" GPU_SCHED="$HOME/projects/gpu-scheduler/gpu-scheduler.sh" START_VLLM="$HOME/projects/gpu-scheduler/start_vllm.sh" JOB_NAME="vllm-chat" iter=0 released=false # ← idempotent flag # cleanup 함수 — 무엇이 호출하든 정확히 한 번만 release cleanup() { if [ "$released" = false ]; then echo "" echo "🛑 ralph-loop 종료 (iter=$iter) — vLLM lock 해제" "$GPU_SCHED" release "$JOB_NAME" 2>/dev/null || true released=true fi } trap 'cleanup; exit 130' INT TERM # ← Ctrl-C, kill -TERM trap 'cleanup' EXIT # ← 모든 종료 (정상·exit·set -e) # 부팅 — GPU acquire (먼저 자원 잡고) echo "🔒 GPU acquire ($JOB_NAME)" if ! "$GPU_SCHED" acquire "$JOB_NAME"; then echo "❌ GPU acquire 실패 — freeze 모드 또는 더 높은 우선순위 작업 보유" exit 1 fi # 부팅 — vLLM 컨테이너 기동 (이미 살아있으면 skip) echo "🚀 vLLM 기동 점검" if ! "$START_VLLM"; then echo "❌ vLLM 기동 실패" exit 1 fi # 부팅 — 최종 헬스체크 echo "🔍 vLLM endpoint 점검" if ! curl -sS --max-time 5 "$VLLM_BASE_URL/models" > /dev/null; then echo "❌ vLLM endpoint 응답 없음" exit 1 fi echo "✅ vLLM 응답 OK" # 메인 루프 — 다음 글들에서 다룸 while [ ... ]; do ... done코드 설명 — 위에서 아래로 읽으면 자연스러운 시퀀스다. 첫째,
set -euo pipefail로 갑작스런 종료 정책을 활성화한다 (어느 명령이든 실패하면 즉시 종료한다). 둘째,released=false와cleanup()함수를 먼저 정의하고 trap 으로 두 종류의 신호에 묶는다. trap 등록은 acquire 보다 먼저 되어야 한다 — 만약 acquire 후 trap 등록 사이에 신호가 도착하면 cleanup 안 호출되고 lock 이 stale 로 남는다. 셋째, GPU acquire → vLLM 기동 → 헬스체크 의 3단계 부팅. 어느 한 단계라도 실패하면 즉시exit 1인데, 이 종료조차 EXIT trap 을 발동시켜 cleanup → release 가 자동으로 따라온다. 넷째, 메인 루프는 다음 글에서.왜 trap 이 두 개인가 — INT/TERM 과 EXIT 의 분리
이 부분이 처음엔 헷갈렸다.
EXITtrap 하나만 등록하면 모든 종료 경로를 잡을 수 있는데 왜 굳이INT TERM을 따로 두는가?차이는 exit code 다. EXIT trap 은 종료 직전에 발동하지만 종료 코드 자체를 바꾸지 않는다. 만약 사용자가 Ctrl-C (SIGINT) 를 누르면 bash 의 기본 동작은 종료 코드 130 인데, EXIT trap 만 있으면 cleanup 후 130 으로 정상 종료한다 — 여기까진 OK 다.
그러나 cleanup 안에서 다른 명령이 실패해서
set -e가 발동하면 종료 코드가 cleanup 의 마지막 명령 종료 코드로 덮인다. 또 어떤 운영 환경에서는 trap 등록 시 명시적으로 exit code 를 박아두는 게 안전하다. 그래서 INT/TERM 에는cleanup; exit 130으로 코드를 명시했다. 130 = 128 + 2(SIGINT) 의 표준 관례라 모니터링 도구가 사용자 중단으로 자동 분류해준다.즉 EXIT trap 은 모든 종료에서 발동하는 안전망이고, INT/TERM trap 은 사용자가 의도해서 죽인 경우의 명시적 처리다. 둘 다 같은 cleanup 을 부르지만 의미가 다르고, exit code 도 다르다.
idempotent flag — 한 줄의 안전장치
cleanup 안의
if [ "$released" = false ]가 핵심이다. Ctrl-C 시나리오를 따라가 보자.- 사용자가 Ctrl-C 누름 → SIGINT 발동
- INT trap 발동 →
cleanup호출 → released=false 상태 → release 실행 → released=true - INT trap 의
exit 130실행 - 이 exit 가 또 다시 EXIT trap 발동 →
cleanup두 번째 호출 - 두 번째 호출에서 released=true 상태 → 즉시 return → release 안 일어남
flag 한 줄이 없으면 release 가 두 번 호출된다. 첫 번째와 두 번째 사이에 다른 작업이 lock 을 잡았을 가능성이 있으면 그 작업의 lock 을 우리가 지워버린다. 운영 중인 프로덕션이라면 "왜 갑자기 vault-search 가 GPU 를 잃었지" 같은 미스터리한 사건이 된다. flag 한 줄이 그걸 막는다.
또 하나 —
"$GPU_SCHED" release "$JOB_NAME" 2>/dev/null || true끝의|| true가 있다. release 자체가 어떤 이유로든 실패해도 cleanup 은 끝까지 진행되어야 하기 때문이다. 만약set -e가 활성화돼있고 release 명령이 0이 아닌 종료 코드를 반환하면 cleanup 한복판에서 죽으면서 EXIT trap 이 또 발동하는 무한 루프 위험이 있다.|| true가 그걸 차단한다.왜 acquire 가 먼저, trap 이 그보다 먼저
이 순서도 중요한 디테일이다. 코드를 보면:
trap 'cleanup; exit 130' INT TERM # 1. trap 등록 먼저 trap 'cleanup' EXIT if ! "$GPU_SCHED" acquire "$JOB_NAME"; then # 2. 그 다음 acquire exit 1 fi왜? acquire 가 먼저 되고 trap 등록이 나중에 되면 — 그 사이의 마이크로초 동안 사용자가 Ctrl-C 를 누르면 — trap 이 등록 안 된 상태라 cleanup 이 안 일어난다. lock 만 잡고 죽는다. 가능성이 매우 낮지만 0은 아니다. trap 을 먼저 박고 그 다음 자원을 잡는 게 일반 원칙 이다.
반대로 release 는 cleanup 안에서 하니 순서를 신경 쓸 필요 없다 — trap 발동 시점에는 acquire 가 됐든 안 됐든 release 명령은 안전하다 (release 가 이미 풀린 lock 을 풀려 하면 그냥 "점유하고 있지 않음" 응답을 받고 끝).
실패 모드 — 이 디자인이 정말 작동하나
Ralph Loop 를 38 iter 돌리면서 의도적·비의도적으로 다양한 종료를 일으켰다. 결과:
- 정상 종료 (CHECKLIST 완료): while 루프 break → bash 정상 exit → EXIT trap 발동 → cleanup → release 1회. ✅
- Ctrl-C: SIGINT → INT trap → cleanup → release 1회 → exit 130 → EXIT trap → cleanup 두 번째 호출 → flag 차단. ✅
- 외부 kill -TERM: SIGTERM → TERM trap → 위와 동일. ✅
- vLLM 복구 불가 (exit 4): 스크립트 자체 exit → EXIT trap → cleanup → release 1회. ✅
- set -e 갑작스런 종료: 어느 명령 실패 → bash 즉시 종료 → EXIT trap → cleanup → release 1회. ✅
한 번 작동 안 한 케이스가 있었다 —
kill -9(SIGKILL) 이다. SIGKILL 은 process 가 가로챌 수 없는 신호라 trap 이 안 발동한다. 그래서 lock 이 stale 로 남고, 다음 실행에서acquire가 "이미 점유 중" 응답을 받았다. 운영 중에 두 번 이 일이 있었는데 손으로release호출해서 풀었다.# SIGKILL 후 stale lock 처리 ~/projects/gpu-scheduler/gpu-scheduler.sh release vllm-chat # → "vllm-chat: GPU를 점유하고 있지 않음" 또는 정상 releaseSIGKILL 은 본질적으로 막을 수 없는 신호라 — 우리 스크립트가 할 수 있는 건 그 가능성을 인지하고 운영 절차를 마련하는 것뿐이다. 시리즈 9편(통합편) 에서 ralph-loop 실행 전후의 운영 체크리스트를 다룰 예정.
다른 자율 스크립트에 그대로 가져갈 수 있는 패턴
이 cleanup·trap 패턴은 ralph-loop 만의 것이 아니라 외부 자원을 잡는 모든 장기 실행 bash 스크립트 에 적용된다. 일반화하면:
#!/usr/bin/env bash set -euo pipefail # 1. 자원 release 가 필요한 모든 자원에 대해 idempotent flag 정의 released=false # 2. cleanup 함수 — flag 검사 + release + flag 토글 cleanup() { if [ "$released" = false ]; then # release 명령들 (각각 || true 로 실패해도 다음 step 진행) your_resource_release || true released=true fi } # 3. trap 등록 — INT/TERM 은 명시적 exit code, EXIT 는 모든 종료 trap 'cleanup; exit 130' INT TERM trap 'cleanup' EXIT # 4. 그 후 자원 acquire your_resource_acquire || exit 1 # 5. 메인 작업 while [ ... ]; do ... done이 5단계는 GPU lock 뿐 아니라 — DB connection pool, tmpfile, network port bind, process group, 심지어 외부 API 의 lease (예: Hashicorp Vault token) 등 — 어떤 외부 자원에든 적용된다. 자원의 종류만 바뀔 뿐 패턴은 동일하다.
결론 — 30줄 안의 운영 안정성
전체 30줄짜리 부팅·셧다운 코드인데 그 안에 들어간 결정들이다:
set -euo pipefail로 갑작스런 종료 정책을 활성화한다.released=falseidempotent flag 로 release 가 정확히 한 번만 호출되도록 보장한다.- cleanup 함수 안에
|| true를 붙여 release 명령이 실패해도 cleanup 이 끝까지 진행되게 한다. - INT/TERM trap 과 EXIT trap 을 분리해 의미 차이와 exit code 를 명시한다.
- trap 등록을 acquire 보다 먼저 둔다.
- SIGKILL 은 막을 수 없음을 인정하고 운영 절차로 보완한다.
이 결정들은 별것 아닌 디테일처럼 보이지만 자율 에이전트 운영에서 결정적이다. 한 번 실행된 ralph-loop 가 자기 자원을 깨끗하게 정리하지 못하면 다음 실행이 시작도 못 한다. 그러면 사람이 매번 끼어들어야 하고, 그 순간 자율 이라는 말이 무너진다. 자율 시스템의 자율성은 시작과 끝의 깔끔함에서 시작된다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
AI 자율 루프의 dead-letter 패턴 — 3-strike 자동 스킵과 진척률 80%가 사실은 32%였던 이야기 (Ralph Loop 시리즈 7편) (0) 2026.05.18 AI 가 "다 했다" 라고 보고할 때 의심하라 — 외부 검증 후크 두 줄의 의미 (Ralph Loop 시리즈 6편) (0) 2026.05.17 opencode 한 줄 호출에 숨은 3가지 함정 — `--` 구분자, 5분 timeout, 직후 헬스체크 (Ralph Loop 시리즈 5편) (0) 2026.05.17 프롬프트만으로는 못 고친다 — 도구 가이드를 5단으로 줘도 schema 에러는 그대로였다 (Ralph Loop 시리즈 4편) (0) 2026.05.17 vLLM 이 매 iter 도중 자살할 수 있다 — 헬스 보장과 진단 자산을 한 함수에 (Ralph Loop 시리즈 3편) (0) 2026.05.16 AI 한테 코드를 자동으로 시킬 때 — 컨텍스트를 3축으로 쪼개라 (Ralph Loop 시리즈 1편) (0) 2026.05.16 시각 피드백의 시간차 — ripple·fly-up·confetti의 650/900/1100ms (0) 2026.05.15 SQLite로 streak를 영리하게 — substr DATE와 cursor 역순 (0) 2026.05.14 5축 25배지로 학습 동기를 설계하기 — 단기 도파민과 장기 약속 (1) 2026.05.14 끝까지 들으면 점수를 더 주는 챗봇 — 청취 완료 타이머의 디자인 (0) 2026.05.14