Watchdog 패턴: Bare VPS에서 장기 실행 AI Agent를 유지하는 방법
요약
Bare VPS 환경에서 장기 실행 AI Agent의 안정성을 보장하기 위한 Watchdog 패턴을 소개합니다. Kubernetes나 systemd 같은 복잡한 도구 대신, cron과 쉘 스크립트, flock을 활용하여 프로세스 생존을 관리하고 레이스 컨디션을 방지하는 실용적인 엔지니어링 방법을 다룹니다.
핵심 포인트
- 단일 VPS 환경에서 Agent의 생존을 유지하는 실용적인 Watchdog 패턴 제안
- flock을 사용하여 Watchdog 실행 간의 레이스 컨디션 및 중복 실행 방지
- systemd 대신 쉘 스크립트와 cron을 활용한 높은 가독성과 빠른 반복 주기 확보
- kill -0을 통한 프로세스 생존 여부 확인 및 안정적인 재시작 로직 구현
AI Agent를 실행하는 대부분의 튜토리얼은 python agent.py를 실행하고 성공적인 데모를 보여주는 단계에서 멈춥니다. 프로세스가 소리 없이 죽어버리거나, 커널 업데이트 후 VPS가 재부팅되거나, 혹은 Agent가 쓰기 작업 도중 OOM-killed (Out Of Memory)되어 손상된 상태 파일(state file)을 남겨버리는 6일 뒤의 상황에 대해서는 아무도 이야기하지 않습니다. 만약 당신의 Agent가 큐(queue)를 폴링하거나, 메일박스를 감시하거나, 트레이딩 루프를 실행하는 등 지속적으로 실행되어야 한다면, 생존을 유지하는 것이 실제 엔지니어링 문제이며, 이는 화려하지 않다는 이유로 대부분 무시되곤 합니다.
이것은 제가 단일 소형 VPS에서 수개월 동안 장기 실행 Agent 데몬을 운영하며 정착한 패턴입니다. Kubernetes도, 관리형 큐(managed queue)도 없이, 오직 Watchdog 스크립트, cron, 그리고 우리 대부분보다 오래된 몇 가지 Unix 기본 요소(primitives)만을 사용합니다. 지루한 방식이지만, 바로 그 점 때문에 효과가 있습니다.
왜 그냥 systemd를 사용하지 않나요?
이미 대규모 플릿(fleet)을 관리하고 있거나, 적절한 Restart=on-failure 설정, cgroups를 통한 리소스 제한, 그리고 journal 통합이 필요하다면 systemd가 정답입니다. 사용할 수 있는 환경이라면 그것을 사용하십시오.
하지만 많은 소규모 Agent 배포는 베이스 이미지(base image)를 제어할 수 없고, 빠르게 반복(iteration)해야 하며, 재시작 로직을 unit-file 구문을 배우는 대신 쉘 스크립트를 읽음으로써 30초 안에 확인할 수 있기를 원하는 단일 저가형 VPS에서 운영됩니다. cron 기반의 Watchdog은 우아함을 일부 포기하는 대신 가치 있는 것을 얻습니다. 팀의 누구라도 watchdog.sh를 처음부터 끝까지 읽고 프로세스가 죽었을 때 정확히 어떤 일이 일어나는지 이해할 수 있다는 점입니다. 새벽 3시에 휴대폰으로 장애를 디버깅할 때는 이 가독성이 생각보다 훨씬 더 중요합니다.
두 접근 방식은 서로 배타적이지도 않습니다. 나중에 동일한 Watchdog 스크립트를 systemd 유닛(unit)으로 감싸서 사용할 수도 있으며, 이로 인해 손해를 보는 것은 없습니다.
핵심 루프 (The core loop)
Watchdog의 유일한 임무는 다음과 같습니다: 프로세스가 실제로 실행 중인가? 실행 중이 아니라면 왜 멈췄는가? 그리고 지금 당장 다시 시작해도 안전한가?
#!/usr/bin/env bash
set -euo pipefail
...
이를 5분마다 실행되도록 cron에 등록하십시오:
*/5 * * * * /opt/agent/watchdog.sh
@reboot sleep 30 && /opt/agent/watchdog.sh
여기서 세 가지 세부 사항은 정상 경로 (happy-path) 로직보다 더 중요합니다:
flock -n 200은 스스로 초래하는 레이스 컨디션 (race condition) 을 방지합니다. Cron은 다음 실행을 시작하기 전에 이전 호출이 완료되기를 기다리지 않습니다. 만약 에이전트가 시작되는 데(모델 로딩, 캐시 워밍 (warming), 마이그레이션 실행 등) 5분보다 오래 걸린다면, 두 개의 Watchdog이 모두 프로세스가 죽었다고 판단하여 각각 복사본을 실행하는 상황이 발생할 수 있습니다. 그러면 이제 두 개의 에이전트가 동일한 상태 파일 (state file) 을 두고 싸우게 됩니다. 전용 잠금 파일 (lock file) 에 flock을 사용하면 각 Watchdog은 상호 배제 (mutually exclusive) 방식으로 실행되며, 잠금을 획득할 수 없는 경우 별도의 재시도 루프 없이 단일 exit 0으로 종료됩니다.
kill -0은 건강 상태 (health) 가 아니라 생존 여부 (liveness) 를 확인합니다. 프로세스가 실행 중이더라도 데드락 (deadlock) 에 걸렸거나, 죽은 API에 대해 재시도 폭풍 (retry storm) 을 일으키고 있거나, 손상된 상태 파일에서 무한 루프를 돌고 있다면 여전히 무용지물일 수 있습니다. kill -0은 단지 해당 PID가 존재하는지만 알려줍니다. 만약 에이전트가 종료되지 않고 멈춰 있을 수 있다면 하트비트 (heartbeat) 를 추가하십시오. 에이전트가 N초마다 타임스탬프 파일을 touch하게 하고, Watchdog이 오래된 하트비트를 PID가 없는 것과 동일하게 취급하도록 만드십시오.
HEARTBEAT_FILE="$APP_DIR/heartbeat"
stale=$(( $(date +%s) - $(stat -f %m "$HEARTBEAT_FILE" 2>/dev/null || echo 0) ))
if [[ $stale -gt 600 ]]; then
...
@reboot에는 지연 시간이 필요합니다. VPS 재부팅 후, cron은 네트워킹, DNS, 디스크 마운트가 반드시 준비되기 전에 @reboot 작업을 실행합니다. 순수한 @reboot /opt/agent/watchdog.sh는 때때로 아직 활성화되지 않은 네트워크를 대상으로 에이전트를 시작하게 만들며, 에이전트가 시작 실패를 처리하는 방식에 따라 다음 5분 주기 실행이 깔끔하게 복구할 수 없는 잘못된 상태에 빠질 수 있습니다. Watchdog이 실행되기 전 단순한 sleep 30을 넣는 것은 투박하지만 신뢰할 수 있는 방법입니다.
상태 (State) 는 프로세스뿐만 아니라 충돌 (crash) 상황에서도 살아남아야 합니다
프로세스를 재시작하지만 손상된 상태를 전달하는 Watchdog은 상황을 개선하는 것이 아니라 악화시킨 것입니다. 두 가지 규칙이 대부분의 상황을 커버합니다.
첫째, 상태를 원자적으로 기록(write state atomically)하세요. 에이전트가 시작될 때 읽어들이는 파일에 직접 쓰지 마세요. 임시 파일에 쓴 다음 이름을 변경(rename)해야 합니다:
import json, os, tempfile
def save_state(path, data):
...
동일한 파일 시스템 내에서의 os.rename은 OS 수준에서 원자적(atomic)입니다. 쓰기 도중 충돌(crash)이 발생하더라도 이전 파일이 남거나 새 파일이 남을 뿐, 다음 부팅 시 파싱 에러(parse error)를 일으키는 절반만 작성된 JSON 블롭(blob)이 남지는 않습니다. 이 한 가지 변화만으로도 제가 에이전트 데몬(agent daemon)에서 목격한 가장 흔한 실패 모드, 즉 상태 기록 중 SIGKILL이나 전원 손실로 인해 다음 시작이 불가능해지는(bricking) 현상을 제거할 수 있습니다.
둘째, 에이전트 상태를 위해 SQLite를 사용하고 있다면(관리할 서버가 없고 충분히 빠르기 때문에 단일 프로세스 데몬에 권장되는 기본값입니다), WAL 모드를 활성화하세요:
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
WAL 모드를 사용하면 읽기 작업자와 쓰기 작업자가 서로를 차단(blocking)하지 않고 작동할 수 있으며, 결정적으로 트랜잭션(transaction) 도중 프로세스가 종료되어도 데이터베이스가 훨씬 더 탄력적(resilient)으로 유지됩니다. 메인 데이터베이스 파일을 일관성 없는 상태로 남겨두는 대신, 다음 오픈 시 Write-Ahead Log (WAL)가 깔끔하게 재실행(replay)되거나 폐기됩니다. synchronous=NORMAL은 약간의 내구성(durability)을 희생하는 대신(완전한 전원 손실 시 마지막 트랜잭션을 잃을 수 있음), 유의미하게 더 나은 처리량(throughput)을 제공합니다. 이는 금융 트랜잭션을 직접 처리하지 않는 대부분의 에이전트 워크로드(workload)에 적합한 트레이드오프(trade-off)입니다.
로그 로테이션(Log rotation)은 선택이 아닌 필수입니다
영원히 실행되는 에이전트는 영원히 로그를 작성합니다. 작은 VPS에서는 로테이션되지 않은 로그 파일이 결국 디스크를 가득 채우게 되며, 디스크가 가득 차면 로그 한 줄이 누락되는 것보다 훨씬 더 기괴한 실패들이 발생합니다. SQLite 쓰기가 실패하고, 임시 파일을 생성할 수 없게 되며, Watchdog 자체의 락 파일(lock file) 작업조차 오작동하기 시작할 수 있습니다. 여기서 로깅 프레임워크(logging framework)를 찾지 마세요. logrotate는 거의 모든 리눅스 배포판에 이미 설치되어 있으며 약 6줄 정도의 설정만 필요합니다:
/opt/agent/logs/agent.log {
daily
rotate 14
...
copytruncate는 프로세스의 종료를 깔끔하게 제어할 수 없는 경우에 특히 중요합니다. 이 방식은 로그 파일을 이름을 바꾸는 대신 제자리에서 잘라내기(truncate) 때문에, 장기 실행 중인 Python 프로세스가 로그 변경 사실을 알 필요가 없으며 파일 핸들(file handle)을 다시 열 필요도 없습니다.
이를 통해 얻는 이점
이 모든 과정이 정교한 것은 아닙니다. 쉘 스크립트(shell script), 크론 테이블(cron table), 원자적 이름 변경(atomic rename), 두 개의 SQLite 프라그마(pragmas), 그리고 logrotate 설정이 전부입니다. 총합하여 아마 40줄 정도의 설정일 것입니다. 하지만 이들이 결합되면 "에이전트가 밤사이에 죽었지만 보고서가 이틀이나 늦어질 때까지 아무도 몰랐던 상황"을, 정확히 언제 왜 발생했는지를 기록하는 로그 한 줄과 함께 5분 만에 스스로 복구되는 상황으로 바꿔줍니다. 단일 VPS 배포 환경에서는 이것이 실제로 필요한 신뢰성 공학 (reliability engineering)의 대부분입니다. 나머지는 로그를 모니터링하고, 애초에 Watchdog을 계속 바쁘게 만드는 원인을 찾아 해결하는 일입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기