SpoonOS 에이전트 프로덕션 배포 — 로컬 스크립트를 Docker로 24/7 돌리기

네오 블록체인·2026년 7월 3일

SpoonOS 로고

이미지 출처: XSpoonAi/spoon-core

"Hello, Agent"에서 python agent.py로 첫 에이전트를 띄웠다. 콘솔에 답이 찍히면 성공이었다. 그런데 진짜 봇은 내 노트북이 잠들면 같이 죽으면 안 된다. 가스비가 떨어지면 알림을 보내고, 특정 조건이면 스왑을 실행하는 봇은 항상 켜져 있어야 의미가 있다. 이 글에서는 로컬에서 돌던 SpoonOS 스크립트를 컨테이너로 감싸 서버에서 24/7 돌리는 전 과정을 따라간다 — 프로젝트 구조, 시크릿 관리, 장기 실행 루프, Dockerfile, 로깅, 그리고 에이전트가 지갑 키를 쥐고 있을 때의 보안까지.

로컬 스크립트와 프로덕션 서비스의 갭

agent.py 한 줄 실행과 "프로덕션 배포" 사이에는 생각보다 큰 강이 있다. 무엇이 달라지는지부터 정리하자.

항목로컬 스크립트프로덕션 서비스
수명실행하면 한 번 돌고 끝항상 켜져 있고, 죽으면 자동 재시작
시크릿.env 파일시크릿 매니저 / 주입된 환경변수
지갑 키평문 파일도 대충 넘어감유출 = 자금 손실, 격리 필수
로그print()구조화 로그 + 수집·검색
비용내가 지켜봄LLM 호출 폭주 방지 장치 필요
장애그냥 다시 실행헬스체크·알림·graceful shutdown

핵심은 "한 번 답하는 것"에서 "계속 살아 있으면서 안전하게 돈을 다루는 것"으로 관심사가 바뀐다는 점이다. 특히 온체인 에이전트는 지갑 키를 쥐고 있으므로, 일반 웹 서비스보다 보안 기준이 한 단계 높다.

완성 후 디렉토리 구조

이 글이 끝나면 아래 구조가 된다.

spoon-agent/
├── app/
│   ├── __init__.py
│   ├── agent.py          # 에이전트 정의
│   ├── runner.py         # 장기 실행 루프 (엔트리포인트)
│   ├── tools/
│   │   └── balance_tool.py
│   └── logging_conf.py   # 구조화 로깅 설정
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
├── .env.example          # 커밋함 (키 값은 비움)
└── .dockerignore

로컬 튜토리얼의 agent.py 하나가 app/ 패키지로 승격되고, 실행 루프(runner.py) 와 로깅 설정이 분리됐다. "많은 작은 파일"이 유지보수에 유리하다.

1단계 — 에이전트를 재사용 가능한 팩토리로

로컬 코드는 main() 안에서 에이전트를 만들고 바로 실행했다. 프로덕션에서는 에이전트 생성과 실행 루프를 분리해야 재사용·테스트가 쉽다. 먼저 에이전트를 만들어 돌려주는 팩토리 함수만 남긴다.

# app/agent.py
from spoon_ai.agents import SpoonReactAI
from spoon_ai.chat import ChatBot
from spoon_ai.tools import ToolManager

from app.tools.balance_tool import NeoXBalanceTool


def build_agent() -> SpoonReactAI:
    """실행 루프와 무관하게 에이전트 인스턴스만 조립한다."""
    tools = ToolManager([NeoXBalanceTool()])

    return SpoonReactAI(
        llm=ChatBot(
            model_name="gpt-4o-mini",
            llm_provider="openai",
        ),
        available_tools=tools,
    )

API 키를 코드에 넣지 않는다는 규칙은 로컬과 동일하다. ChatBot은 환경변수(OPENAI_API_KEY)를 자동으로 읽는다.

2단계 — 장기 실행 루프 (엔트리포인트)

이제 "한 번 답하고 끝"이 아니라, 주기적으로 조건을 확인하고 행동하는 루프를 만든다. 예시는 "N분마다 대상 주소의 GAS 잔고를 확인하고, 임계값 밑으로 떨어지면 경고를 남기는" 감시 봇이다.

# app/runner.py
import asyncio
import os
import signal

from app.agent import build_agent
from app.logging_conf import setup_logging

logger = setup_logging()

# 폴링 간격(초). 환경변수로 주입해 재빌드 없이 조정한다.
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "300"))
WATCH_ADDRESS = os.getenv("WATCH_ADDRESS", "")

_shutdown = asyncio.Event()


def _handle_signal() -> None:
    # SIGTERM/SIGINT을 받으면 루프를 깔끔히 종료한다.
    logger.info("shutdown signal received")
    _shutdown.set()


async def main() -> None:
    loop = asyncio.get_running_loop()
    for sig in (signal.SIGTERM, signal.SIGINT):
        loop.add_signal_handler(sig, _handle_signal)

    agent = build_agent()
    logger.info("agent started", extra={"interval": POLL_INTERVAL})

    while not _shutdown.is_set():
        try:
            response = await agent.run(
                f"Neo X 테스트넷에서 {WATCH_ADDRESS} 주소의 GAS 잔고를 확인하고, "
                f"1 GAS 미만이면 '⚠️ 잔고 부족'을, 아니면 '정상'을 한 줄로 답해줘."
            )
            logger.info("poll result", extra={"result": response})
        except Exception:
            # 한 번의 실패로 봇 전체가 죽지 않도록 삼킨다.
            logger.exception("poll iteration failed")

        # 종료 신호가 오면 대기 중에도 즉시 깨어난다.
        try:
            await asyncio.wait_for(_shutdown.wait(), timeout=POLL_INTERVAL)
        except asyncio.TimeoutError:
            pass

    logger.info("agent stopped cleanly")


if __name__ == "__main__":
    asyncio.run(main())

이 루프의 프로덕션 포인트 세 가지를 짚자.

  1. 예외를 삼킨다: 폴링 한 번이 실패해도(RPC 끊김, LLM 타임아웃) try/except로 잡아 로그만 남기고 다음 주기로 넘어간다. 한 번의 오류로 봇이 죽으면 안 된다.
  2. graceful shutdown: SIGTERM(도커가 컨테이너를 멈출 때 보내는 신호)을 받으면 현재 주기를 마치고 깔끔히 종료한다. 트랜잭션 도중 강제 종료로 인한 어정쩡한 상태를 피한다.
  3. 간격을 환경변수로: POLL_INTERVAL을 코드가 아니라 환경변수로 받아, 재빌드 없이 운영 중 조정한다.

3단계 — 구조화 로깅

print()는 프로덕션에서 쓸모가 없다. 로그를 JSON 한 줄씩 남기면 CloudWatch·Loki·Datadog 같은 도구가 그대로 파싱한다.

# app/logging_conf.py
import json
import logging
import sys


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": self.formatTime(record),
            "level": record.levelname,
            "msg": record.getMessage(),
        }
        # extra로 넘긴 필드를 합친다.
        for key, value in record.__dict__.items():
            if key in ("result", "interval", "address"):
                payload[key] = value
        if record.exc_info:
            payload["exc"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False)


def setup_logging() -> logging.Logger:
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(JsonFormatter())

    logger = logging.getLogger("spoon-agent")
    logger.setLevel(logging.INFO)
    logger.addHandler(handler)
    logger.propagate = False
    return logger

민감 정보는 절대 로그에 남기지 않는다. LLM 프롬프트에 지갑 키·API 키가 섞여 들어가지 않도록, 로그에 찍는 건 결과 요약과 메타데이터뿐이다. 표준출력(stdout)으로만 내보내면 도커가 알아서 수집한다 — 컨테이너 안에서 파일 로그를 직접 관리하지 않는다.

4단계 — Dockerfile (멀티스테이지)

이제 컨테이너로 감싼다. 빌드 단계와 실행 단계를 나눠 이미지를 가볍게 만든다.

# ---- build stage ----
FROM python:3.12-slim AS builder
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# ---- runtime stage ----
FROM python:3.12-slim AS runtime
WORKDIR /app
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

# 루트로 돌리지 않는다 — 컨테이너 탈취 시 피해를 줄인다.
RUN useradd --create-home --uid 10001 spoon

COPY --from=builder /install /usr/local
COPY app/ ./app/

USER spoon
# 엔트리포인트는 장기 실행 루프
CMD ["python", "-m", "app.runner"]

requirements.txt에는 로컬에서 쓰던 의존성을 고정 버전으로 박는다.

spoon-ai-sdk==0.4.*
python-dotenv==1.0.*
web3==7.*

포인트 두 가지:

  • PYTHONUNBUFFERED=1: 파이썬 로그가 버퍼에 갇히지 않고 즉시 stdout으로 나가게 한다. 이게 없으면 도커 로그가 지연되거나 크래시 시 마지막 로그를 잃는다.
  • 비루트 유저(spoon): 컨테이너를 루트로 돌리지 않는다. 온체인 키를 다루는 프로세스라면 더더욱 최소 권한 원칙을 지킨다.

5단계 — docker-compose로 실행

배포 단위를 docker-compose.yml로 선언한다. 재시작 정책과 시크릿 주입이 여기 들어간다.

services:
  spoon-agent:
    build: .
    restart: unless-stopped          # 죽으면 자동 재시작
    env_file:
      - .env                         # 서버에만 두고 절대 커밋 안 함
    environment:
      POLL_INTERVAL: "300"
      WATCH_ADDRESS: "0xYourWatchAddress"
    logging:
      driver: json-file
      options:
        max-size: "10m"              # 로그 무한 증식 방지
        max-file: "3"
    mem_limit: 512m                  # LLM 클라이언트 메모리 상한
    stop_grace_period: 30s           # graceful shutdown 시간 확보
  • restart: unless-stopped: 프로세스가 죽거나 서버가 재부팅돼도 컨테이너가 다시 뜬다. 24/7 봇의 핵심 한 줄이다.
  • stop_grace_period: 앞서 만든 SIGTERM 핸들러가 마무리할 시간을 준다. 이 시간이 지나면 도커가 강제(SIGKILL) 종료한다.
  • max-size/max-file: 로그 로테이션. 이걸 안 걸면 오래 돌수록 디스크가 로그로 가득 찬다.

띄우고 로그를 확인한다.

docker compose up -d --build
docker compose logs -f spoon-agent

JSON 로그가 주기적으로 찍히면 봇이 살아 있는 것이다.

6단계 — 온체인 키 보안 (가장 중요)

지금까지 예시는 조회만 했다. 하지만 실제 봇은 스왑·전송처럼 서명하는 트랜잭션을 보낸다. 그 순간 봇은 지갑 개인키를 쥐게 되고, 이 키가 유출되면 곧 자금 손실이다. 프로덕션에서 반드시 지키는 원칙을 정리한다.

  1. 핫월렛은 최소 잔고만: 봇 지갑에는 자동화에 필요한 소액만 둔다. 큰 자금은 별도 콜드월렛에. "GAS 받기 실전"에서 만든 지갑을 그대로 봇에 넣지 말고, 전용 봇 지갑을 새로 판다.
  2. 키를 이미지에 굽지 않는다: Dockerfile·깃 저장소·로그 어디에도 키를 넣지 않는다. 오직 런타임 환경변수 또는 시크릿 매니저로만 주입한다.
  3. 시크릿 매니저 사용: 단순 .env를 넘어, 규모가 커지면 AWS Secrets Manager·GCP Secret Manager·HashiCorp Vault로 키를 주입한다. compose 예시의 .env는 시작점일 뿐이다.
  4. 정책 가드레일: LLM이 임의로 큰 금액을 보내지 못하도록, 도구 코드 안에 상한을 하드코딩한다. LLM의 판단만 믿지 않는다.
# 트랜잭션 도구 안의 방어 로직 예시
MAX_GAS_PER_TX = 5.0  # 봇이 한 번에 보낼 수 있는 상한

async def execute(self, to: str, amount: float) -> str:
    if amount > MAX_GAS_PER_TX:
        return f"거부: 1회 전송 상한({MAX_GAS_PER_TX} GAS) 초과 요청 {amount}"
    # ... 서명·전송

이건 "약속과 현실"에서 짚었던 "에이전트에게 지갑을 맡기는 게 정말 안전한가"라는 질문에 대한 실무적 답이다. 가드레일은 프롬프트가 아니라 코드에 둔다. 프롬프트 인젝션으로 LLM을 속일 수는 있어도, 도구 코드의 if문은 못 넘는다.

7단계 — 비용 폭주 막기

LLM 호출은 곧 돈이다. 로컬에선 내가 지켜보지만, 24/7 봇은 버그 하나로 초당 수십 번 LLM을 때릴 수 있다. 두 가지 방어선을 둔다.

  • 폴링 간격 하한: POLL_INTERVAL을 너무 짧게 두지 않는다. 잔고 감시라면 5분(300초)이면 충분하다.
  • 호출 카운터·일일 상한: 하루 호출 수를 세고, 상한을 넘으면 루프를 멈추고 알림만 보낸다. 무한 루프 버그가 지갑이 아니라 API 청구서를 터뜨리는 걸 막는다.

"듀얼 토큰 경제학"에서 GAS 비용을 계산했듯이, 프로덕션 에이전트는 온체인 가스비 + LLM 토큰비 두 가지 비용을 동시에 관리해야 한다.

8단계 — 어디에 올리나

컨테이너 하나짜리 봇은 배포처 선택이 자유롭다.

배포처적합한 경우
VPS (직접 docker compose)가장 단순. 소규모 봇 한두 개
AWS ECS / Fargate관리형, 오토스케일·시크릿 매니저 통합
Fly.io / Railway빠른 배포, 소규모 사이드 프로젝트
Kubernetes봇이 여러 개로 늘고 오케스트레이션이 필요할 때

시작은 VPS + docker compose로 충분하다. 봇이 늘어나면 그때 ECS나 k8s를 고민해도 늦지 않다. 과잉 설계는 피한다.

자주 만나는 시행착오

  1. 컨테이너가 바로 죽고 로그가 안 남는다.
    PYTHONUNBUFFERED=1이 빠졌거나, 엔트리포인트 모듈 경로(app.runner)가 틀렸다. docker compose logs로 스택트레이스를 먼저 확인한다.
  2. .env가 컨테이너 안에서 안 읽힌다.
    compose의 env_file은 호스트의 .env를 컨테이너 환경변수로 주입한다. 코드에서 load_dotenv()로 파일을 또 찾을 필요가 없다 — 이미 환경변수로 들어와 있다.
  3. SIGTERM을 무시하고 강제 종료된다.
    asyncio의 시그널 핸들러는 loop.add_signal_handler로 등록해야 동작한다. 또한 긴 블로킹 작업 중이면 신호를 못 받으니, 대기를 wait_for로 감싼다(위 예시 참고).
  4. 로그가 디스크를 다 먹었다.
    compose에 max-size/max-file을 안 걸면 json-file 드라이버가 무한히 쌓인다. 로테이션은 필수다.
  5. 재시작 루프에 빠진다(계속 죽고 다시 뜸).
    시작 시점 오류(키 누락, import 실패)면 restart: unless-stopped가 무한 재시작을 반복한다. 로그를 보고 근본 원인을 고친다 — 재시작 정책은 일시적 장애 복구용이지 버그 가리개가 아니다.
  6. 봇 지갑이 털렸다.
    십중팔구 키를 깃에 커밋했거나 로그에 찍었다. 즉시 키를 폐기하고 새 지갑으로 옮긴다. 핫월렛에 소액만 뒀다면 피해가 제한된다 — 그래서 6단계 원칙이 중요하다.

마무리

프로덕션 배포의 본질은 화려한 인프라가 아니라 "죽지 않고, 새지 않고, 폭주하지 않게" 만드는 몇 가지 습관이다. 예외를 삼키는 루프, graceful shutdown, 구조화 로그, 비루트 컨테이너, 자동 재시작, 그리고 무엇보다 코드에 박은 온체인 가드레일 — 이 목록이 로컬 스크립트와 프로덕션 서비스를 가른다.

"Hello, Agent"에서 30분 만에 에이전트를 띄웠다면, 이 글의 재료로 그 에이전트를 서버에 올려 며칠씩 재워두지 않고 돌릴 수 있다. 다음 단계는 "SpoonGraph 멀티 에이전트"를 컨테이너로 감싸 human-in-the-loop 승인까지 붙이거나, "MCP 연동"으로 Slack에 봇의 판단을 흘려보내는 것이다. 로컬에서 "된다"를 확인했으면, 이제 "계속 된다"를 만들 차례다.

참고 자료

profile
스마트 이코노미를 위한 퍼블릭 블록체인, 네오에 대한 모든것

0개의 댓글