TuBrief
구독 채널
비디오
커뮤니티

Docker 컨테이너로 AI 에이전트 호출 비용 절감하는법

TuBrief 편집팀
2026년 7월 23일
0
컴퓨터/소프트웨어

원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.

한국어EnglishEspañol中文العربيةहिन्दीDeutschFrançaisPortuguêsРусскийBahasa Indonesia日本語

관련 영상

Ship 26 NYC - Brex의 감사 에이전트를 거칠게 다루기15:53

Ship 26 NYC - Brex의 감사 에이전트를 거칠게 다루기

Vercel

커뮤니티의 다른 글

사내 시스템에 llm api 붙일 때 마주하는 현실적인 한계와 대응법

2026년 9월 13일

레거시 백엔드에 GPT-6 Astra 붙일 때 예산 승인과 보안 통과를 먼저 끝내는 법이 있습니다

2026년 9월 13일

에이전트끼리 대화하다 6천만 원 청구서가 나오는 이유

2026년 9월 13일

사내 RAG 벡터 검색에 Okta 권한 필터를 직접 거는 방법

2026년 9월 13일

브라우저 에이전트에게 내 구글 계정을 통째로 넘기면 안 되는 이유

2026년 9월 12일

Apple Won the AI Race

2026년 9월 12일

댓글 (0)

Log in to leave a comment

아직 작성된 글이 없습니다

© 2026 . All rights reserved.

TuBrief
구독 채널
비디오
커뮤니티
로그인

Docker 컨테이너로 AI 에이전트 호출 비용 절감하는법

에이전트에게 subprocess나 exec()로 Bash를 직접 열어주는 건 집 열쇠를 통째로 넘기는 짓입니다. 프롬프트 주입 공격 한 번에 호스트 서버 전체가 뚫리거나, 에이전트가 환각에 빠져 rm -rf /를 실행할 수도 있습니다. 그렇다고 모든 툴을 OpenAPI 스키마로 잘게 쪼개서 등록하면? 도구 몇 개만 얹어도 LLM 컨텍스트 윈도우가 터져 나갑니다.

결국 답은 세션마다 0.1초 만에 떴다 사라지는 단명형(Ephemeral) Docker 컨테이너를 붙이는 겁니다. 보안 구획을 확실히 나누고, 대용량 데이터를 컨테이너 내부 파이프라인으로 처리하면 API 토큰 사용량을 70% 이상 깎아낼 수 있습니다.

Ephemeral Bash 샌드박스 구축하기

에이전트가 무한 루프를 돌리거나 포크 폭탄을 터뜨려도 호스트 서버는 멀쩡해야 합니다. Linux 커널 Cgroups 레벨에서 CPU와 메모리를 강제로 짓눌러 놓지 않으면, 에이전트 스레드 하나가 폭주하면서 클라우드 비용 폭탄으로 이어집니다.

Python Docker SDK로 구축하는 격리 샌드박스 구조입니다.

import atexit
import os
import signal
import docker
from docker.errors import DockerException

class EphemeralBashSandbox:
    def __init__(self, workspace_host_path: str, image: str = "python:3.11-slim"):
        self.client = docker.from_env()
        self.image = image
        self.workspace_host_path = os.path.abspath(workspace_host_path)
        self.container = None
        self._start_sandbox()
        
        atexit.register(self.cleanup)
        signal.signal(signal.SIGINT, self._signal_handler)
        signal.signal(signal.SIGTERM, self._signal_handler)

    def _start_sandbox(self):
        self.container = self.client.containers.run(
            image=self.image,
            command="/bin/bash",
            detach=True,
            stdin_open=True,
            tty=True,
            network_disabled=True,
            read_only=True,
            mem_limit="512m",
            cpu_quota=50000,
            pids_limit=50,
            user="1000:1000",
            volumes={
                self.workspace_host_path: {
                    "bind": "/workspace",
                    "mode": "rw"
                },
                "/tmp": {
                    "bind": "/tmp",
                    "mode": "rw"
                }
            },
            working_dir="/workspace",
            environment={"HOME": "/tmp"}
        )

    def execute_command(self, cmd: str, timeout: int = 30) -> tuple[int, str, str]:
        if not self.container:
            raise RuntimeError("Sandbox container is not active.")
        
        exec_res = self.container.exec_run(
            cmd=["/bin/bash", "-c", cmd],
            workdir="/workspace",
            demux=True
        )
        
        exit_code = exec_res.exit_code
        stdout = exec_res.output[0].decode('utf-8', errors='replace') if exec_res.output and exec_res.output[0] else ""
        stderr = exec_res.output[1].decode('utf-8', errors='replace') if exec_res.output and exec_res.output[1] else ""
        return exit_code, stdout, stderr

    def cleanup(self):
        if self.container:
            try:
                self.container.stop(timeout=2)
                self.container.remove(force=True)
            except DockerException:
                pass
            finally:
                self.container = None

    def _signal_handler(self, signum, frame):
        self.cleanup()
        os._exit(0)

명령을 실행할 때마다 docker run을 새로 때리면 4.7초라는 끔찍한 콜드 스타트 지연이 생깁니다. 서비스에 절대 못 씁니다. 대신 백그라운드 데몬 모드(detach=True, stdin_open=True, tty=True)로 샌드박스를 켜두고, exec_run으로 명령어만 주입해야 응답 속도가 100ms 안쪽으로 떨어집니다.

핵심 설정 포인트는 세 가지입니다.

  1. mem_limit="512m", cpu_quota=50000, pids_limit=50으로 하드웨어 자원을 소형 스레드 수준으로 제한합니다.
  2. network_disabled=True와 read_only=True로 내부망 조회를 막고, 작업용 /workspace와 /tmp 공간만 제한적으로 마운트합니다.
  3. user="1000:1000"으로 non-root 권한을 강제하고, POSIX 시그널 핸들러를 걸어 프로세스가 죽을 때 컨테이너가 깔끔하게 파기되도록 만듭니다.

OpenAPI 스키마 대신 CLI 파이프라인으로 토큰 절약하기

개별 API 스키마를 JSON으로 정의해 주입하는 기존 방식은 도구 하나당 550~1,400토큰을 잡아먹습니다. 툴을 20개만 얹어도 질문 하나 던지기 전에 20,000토큰이 날아갑니다.

검색 엔진 You.com의 기술 보고서에 따르면, 단순 JSON 스키마 주입 대신 Bash 기반 스크립트 실행 방식(CodeAct)을 도입했을 때 토큰 사용량이 61% 줄고 처리 속도는 40% 빨라졌습니다.

평가 항목 JSON 스키마 주입 Model Context Protocol (MCP) Bash CLI 파이프라인
도구 정의 토큰 도구당 ~550–1,400 토큰 도구당 ~550–1,400 토큰 메타 인터페이스 1개 (~100 토큰)
중간 데이터 컨텍스트 오염 매우 높음 (전체 페이로드 전달) 높음 (전체 페이로드 전달) 없음 (샌드박스 내부 정제 후 반환)
LLM 왕복 횟수 N회 순차 왕복 N회 순차 왕복 1회 (다단계 스크립트 묶음)
태스크 완수 지연시간 기준점 서버 직렬화 오버헤드 발생 평균 48.5% 감소
토큰 절감율 0% (기준) 0% 61%–98.7% 절감

Anthropic 팀의 내부 분석 데이터도 비슷한 결과를 보여줍니다. 대용량 파일 분석 작업을 코드 실행 방식으로 전환하자 토큰 사용량이 최대 98.7%까지 감소했습니다. LLM과 도구 사이를 오가는 왕복 횟수가 1회로 줄어들면서 지연 시간 역시 절반 가까이 단축됩니다.

LLM에 내려주는 시스템 프롬프트에는 CLI 텍스트 파이프라인 제약 조건을 명확히 걸어야 합니다.

You operate inside a sandboxed Linux Bash environment.
To process data files or API responses, follow these constraints:
1. NEVER output raw bulk data to stdout. Pipe large JSON or CSV outputs through jq, awk, or grep.
2. Always inspect data structure first using head -n 5 or jq 'keys'.
3. Perform aggregate operations (SUM, COUNT, GROUP BY) using bash utilities or python scripts inside the sandbox, and print ONLY the final summary result.
4. Chain multiple operations into a single bash script execution to minimize inference turns.

10만 행짜리 CSV를 분석할 때 원본 데이터를 컨텍스트에 집어넣는 건 돈을 길바닥에 버리는 짓입니다. head -n 5로 구조를 파악하고, 샌드박스 안에서 awk나 python으로 집계한 뒤 최종 결과 한 줄만 모델에 반환하도록 유도하세요.

명령어 환각과 CLI 미설치 에러 보정하기

에이전트에게 Bash를 맡기면 백프로 Exit Code 127(Command Not Found)이나 잘못된 옵션 플래그를 내뱉습니다.

에이전트 평가 프레임워크 PASTE 분석팀 보고에 따르면, 명령 실행이 실패했을 때 세션을 바로 종료하지 않고 자동 보정 루프를 태웠을 때 실행 실패율이 5% 미만으로 떨어졌습니다.

import re
from typing import Callable, Optional

class SelfHealingBashRunner:
    def __init__(self, sandbox: EphemeralBashSandbox, llm_repair_fn: Callable[[str, str], str]):
        self.sandbox = sandbox
        self.llm_repair_fn = llm_repair_fn
        self.max_retries = 3

    def run_with_healing(self, initial_cmd: str) -> tuple[bool, str]:
        current_cmd = initial_cmd
        
        for attempt in range(self.max_retries):
            exit_code, stdout, stderr = self.sandbox.execute_command(current_cmd)
            
            if exit_code == 0:
                return True, stdout
            
            if exit_code == 127 or "command not found" in stderr.lower():
                missing_binary = self._extract_missing_command(stderr)
                if missing_binary:
                    install_success = self._try_install_package(missing_binary)
                    if install_success:
                        continue
            
            repair_prompt = (
                f"The executed Bash command failed.\n"
                f"Failed Command: {current_cmd}\n"
                f"Exit Code: {exit_code}\n"
                f"Stderr Output: {stderr}\n"
                f"Stdout Output: {stdout}\n"
                f"Analyze the error. Return ONLY a corrected single-line Bash command to fulfill the objective."
            )
            current_cmd = self.llm_repair_fn(stderr, repair_prompt).strip()
        
        return False, f"Failed after {self.max_retries} attempts. Last Stderr: {stderr}"

    def _extract_missing_command(self, stderr: str) -> Optional[str]:
        match = re.search(r"([a-zA-Z0-9_-]+):\s*command not found", stderr) or re.search(r"command not found:\s*([a-zA-Z0-9_-]+)", stderr)
        return match.group(1) if match else None

    def _try_install_package(self, binary_name: str) -> bool:
        install_cmd = f"apt-get update && apt-get install -y {binary_name} || pip install {binary_name}"
        exit_code, _, _ = self.sandbox.execute_command(install_cmd)
        return exit_code == 0

에러 복구 모듈의 작동 흐름은 단순합니다.

  1. Exit Code가 127이면 정규식으로 필요한 바이너리 이름을 뽑아낸 뒤 apt-get이나 pip로 실시간 설치합니다.
  2. 구문 오류나 잘못된 플래그 문제라면 발생한 stderr 메시지를 LLM에 다시 던져 스스로 수정 코드를 짜게 만듭니다.
  3. 검증된 스크립트는 컨테이너 내부 /usr/local/bin/에 실행 권한(chmod +x)을 주어 보관합니다. 다음번엔 이 바이너리만 바로 호출하면 되므로 토큰을 또 쓸 필요가 없습니다.

파일 동시성 충돌 방지하기

샌드박스 내부 스크립트가 무한 루프에 빠지면 세션 전체가 락에 걸립니다. 계층적 타임아웃을 둬야 합니다. 일반 명령은 30초, 패키지 설치는 60초, 전체 세션은 300초로 끊는 식입니다. 타임아웃을 넘기면 모니터링 스레드가 해당 프로세스 PID에 SIGKILL을 날려 문제 프로세스만 깔끔하게 날려야 합니다.

여러 에이전트 인스턴스가 동일한 공유 볼륨 파일에 접근할 때 생기는 race condition도 문제입니다. 단순히 open(path, 'w')를 때리면 잠금을 얻기도 전에 파일이 0바이트로 날아가 버립니다. POSIX 커널 수준의 fcntl.flock 시스템 콜 기반 잠금이 필수적입니다.

import fcntl
import os
import time
from contextlib import contextmanager

class SafeFileLockTimeout(Exception):
    pass

@contextmanager
def safe_file_lock(lock_file_path: str, timeout: float = 10.0, poll_interval: float = 0.05):
    lock_dir = os.path.dirname(os.path.abspath(lock_file_path))
    if lock_dir:
        os.makedirs(lock_dir, exist_ok=True)
    
    fd = os.open(lock_file_path, os.O_RDWR | os.O_CREAT, 0o666)
    start_time = time.time()
    
    try:
        while True:
            try:
                fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
                break
            except (OSError, IOError):
                if time.time() - start_time >= timeout:
                    raise SafeFileLockTimeout(
                        f"Timed out after {timeout} seconds waiting for lock on: {lock_file_path}"
                    )
                time.sleep(poll_interval)
        yield fd
    finally:
        try:
            fcntl.flock(fd, fcntl.LOCK_UN)
        except (OSError, IOError):
            pass
        os.close(fd)

os.open을 os.O_RDWR | os.O_CREAT 플래그로 여는 게 핵심입니다. 잠금을 획득하기 전 파일이 절삭되는 현상을 막아줍니다. 이후 fcntl.LOCK_EX | fcntl.LOCK_NB로 비동기 잠금을 시도하고, 타임아웃 도달 시 예외를 던져 대기 스레드가 무한정 멈춰있는 상황을 방지합니다.