ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 자율 스크립트의 부팅과 셧다운 — 외부 자원을 잡았다면 정확히 한 번만 놓아라 (Ralph Loop 시리즈 2편)
    IT 2026. 5. 16. 22:00
    자율 스크립트의 부팅과 셧다운 — 외부 자원을 잡았다면 정확히 한 번만 놓아라 (Ralph Loop 시리즈 2편)

    자율 코딩 루프 같은 장기 실행 스크립트의 시작과 끝은 의외로 까다롭다. 시작에서 외부 자원 — 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 을 우리가 지워버리는 사고가 발생한다. "한 번 이상" 도, "한 번 이하" 도 안 되고 정확히 한 번.

    자율 스크립트의 5가지 종료 경로가 cleanup 한 함수로 합류하는 다이어그램

    그림 설명 — 다섯 가지 종료 경로 (정상 / 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 안에 released flag 가 들어있고, 이미 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=falsecleanup() 함수를 먼저 정의하고 trap 으로 두 종류의 신호에 묶는다. trap 등록은 acquire 보다 먼저 되어야 한다 — 만약 acquire 후 trap 등록 사이에 신호가 도착하면 cleanup 안 호출되고 lock 이 stale 로 남는다. 셋째, GPU acquire → vLLM 기동 → 헬스체크 의 3단계 부팅. 어느 한 단계라도 실패하면 즉시 exit 1 인데, 이 종료조차 EXIT trap 을 발동시켜 cleanup → release 가 자동으로 따라온다. 넷째, 메인 루프는 다음 글에서.

    왜 trap 이 두 개인가 — INT/TERM 과 EXIT 의 분리

    이 부분이 처음엔 헷갈렸다. EXIT trap 하나만 등록하면 모든 종료 경로를 잡을 수 있는데 왜 굳이 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 시나리오를 따라가 보자.

    1. 사용자가 Ctrl-C 누름 → SIGINT 발동
    2. INT trap 발동 → cleanup 호출 → released=false 상태 → release 실행 → released=true
    3. INT trap 의 exit 130 실행
    4. 이 exit 가 또 다시 EXIT trap 발동 → cleanup 두 번째 호출
    5. 두 번째 호출에서 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를 점유하고 있지 않음" 또는 정상 release

    SIGKILL 은 본질적으로 막을 수 없는 신호라 — 우리 스크립트가 할 수 있는 건 그 가능성을 인지하고 운영 절차를 마련하는 것뿐이다. 시리즈 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줄짜리 부팅·셧다운 코드인데 그 안에 들어간 결정들이다:

    1. set -euo pipefail 로 갑작스런 종료 정책을 활성화한다.
    2. released=false idempotent flag 로 release 가 정확히 한 번만 호출되도록 보장한다.
    3. cleanup 함수 안에 || true 를 붙여 release 명령이 실패해도 cleanup 이 끝까지 진행되게 한다.
    4. INT/TERM trap 과 EXIT trap 을 분리해 의미 차이와 exit code 를 명시한다.
    5. trap 등록을 acquire 보다 먼저 둔다.
    6. SIGKILL 은 막을 수 없음을 인정하고 운영 절차로 보완한다.

    이 결정들은 별것 아닌 디테일처럼 보이지만 자율 에이전트 운영에서 결정적이다. 한 번 실행된 ralph-loop 가 자기 자원을 깨끗하게 정리하지 못하면 다음 실행이 시작도 못 한다. 그러면 사람이 매번 끼어들어야 하고, 그 순간 자율 이라는 말이 무너진다. 자율 시스템의 자율성은 시작과 끝의 깔끔함에서 시작된다.


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

Designed by Tistory.