MINERVA 프로덕션 배포

Docker 멀티스테이지 + uvicorn + Azure

MINERVA를 프로덕션 환경에 배포하는 전체 파이프라인을 정리한다. Docker 멀티스테이지 빌드, gunicorn + uvicorn 워커 구성, 환경 변수 관리, 헬스체크, Azure 배포 전략을 다룬다.

Agent
저자

Kwangmin Kim

공개

2026년 05월 05일

1 배포 아키텍처

개발 환경과 프로덕션 환경의 차이를 정리한다.

개발 환경:
브라우저 → Vite(5173) ──proxy──→ uvicorn(8000) → FastAPI → Agent
                                  단일 워커, --reload

프로덕션 환경:
브라우저 → Nginx/LB(443) → gunicorn(8000) → FastAPI → Agent
                            4 워커, SSL 종료
                            Docker 컨테이너 내부
구성 요소 개발 프로덕션
프론트엔드 Vite dev server (HMR) Nginx 또는 FastAPI 정적 서빙
백엔드 서버 uvicorn –reload gunicorn + uvicorn worker
LLM Provider Ollama (로컬) Azure OpenAI
프록시 Vite Proxy Nginx Reverse Proxy
환경 변수 .env 파일 Azure Key Vault / 환경 변수
컨테이너 없음 Docker

2 Docker 멀티스테이지 빌드

React와 FastAPI를 하나의 Docker 이미지로 빌드한다. 멀티스테이지 빌드로 최종 이미지에 Node.js를 포함하지 않아 이미지 크기를 최소화한다.

# ============================================
# Stage 1: React 프론트엔드 빌드
# ============================================
FROM node:22-alpine AS frontend

WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci

COPY frontend/ ./
RUN npm run build
# 결과: /app/frontend/dist/

# ============================================
# Stage 2: Python 런타임
# ============================================
FROM python:3.11-slim AS runtime

WORKDIR /app

# 시스템 패키지 (build-essential은 일부 wheel 빌드용, 빌드 후 제거 가능)
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Python 의존성 — pip 직접 사용 (Poetry는 빌드 컨텍스트만 비대해짐)
# sg-data-standardization (사내 SSH)은 이미지에서 제외, 런타임에 editable install
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# 소스 코드
COPY src/ ./src/
COPY data/configs/ ./data/configs/

# React 빌드 결과
COPY --from=frontend /app/frontend/dist ./static

HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

ENV ENVIRONMENT=production \
    PYTHONUNBUFFERED=1

EXPOSE 8000

# 현 운영: 단일 uvicorn (warmup 후 caching·thread-safe lock으로 동시성 처리)
CMD ["uvicorn", "services.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
왜 Poetry가 아닌 pip인가

플랫폼 개발에서는 Poetry로 의존성을 잠그지만, 컨테이너 빌드 시점에는 poetry export -f requirements.txt로 추출한 잠긴 버전을 pip에 직접 설치한다. Poetry 자체를 이미지에 포함하면 ~50MB 추가되고, 빌드 단계마다 venv 격리 비용이 든다. 잠긴 버전은 이미 보장된 상태이므로 컨테이너에서는 pip만으로 충분하다.

sg-data-standardization은 이미지에 포함되지 않는다

표준화 도메인 라이브러리(sg-data-standardization)는 사내 git SSH 경로에 있어 빌드 환경에 SSH 키가 필요하다. CI에서 키를 주입하는 대신, 이미지에는 제외하고 컨테이너 시작 시 pip install -e .로 사이드카 설치하거나 사내 PyPI 미러에서 설치하는 방식을 사용한다. 외부 빌드(예: GitHub Actions)에서 이미지를 만들 수 없는 이유다.

2.1 빌드와 실행

# 이미지 빌드
docker build -t minerva-api:latest .

# 로컬 테스트 — 데이터·모델은 모두 volume으로 주입
docker run -d \
  -p 8000:8000 \
  --name minerva \
  --env-file .env.cloud \
  -v $(pwd)/data/docs:/app/data/docs:ro \
  -v $(pwd)/data/experiments:/app/data/experiments \
  -v $(pwd)/data/runtime:/app/data/runtime \
  -v $(pwd)/data/models:/app/data/models:ro \
  -v $(pwd)/data/faiss_index:/app/data/faiss_index:ro \
  minerva-api:latest

docker logs -f minerva
curl http://localhost:8000/health
ALBERT weight·FAISS 인덱스는 이미지에 포함하지 않는다

데이터 표준화 에이전트의 ALBERT 도메인 분류기 가중치는 ~200MB, FAISS 인덱스는 문서 양에 따라 수백 MB가 된다. 이를 이미지에 포함하면 빌드·배포 시간이 급증하므로 항상 volume 마운트한다 (data/models/albert/, data/faiss_index/). 모델 교체나 인덱스 재생성도 컨테이너 재시작 없이 처리 가능하다.

2.2 이미지 크기 최적화

최적화 효과
python:3.12-slim (alpine 대신) C 확장 호환성 유지하면서 경량화
멀티스테이지 (Node.js 제외) ~500MB 절감
--no-dev 의존성 제외 ~100MB 절감
rm -rf /var/lib/apt/lists/* ~50MB 절감
.dockerignore 빌드 컨텍스트 축소

3 정적 파일 서빙

프로덕션에서 React 빌드 결과를 서빙하는 방법은 두 가지이다.

3.1 방법 1: FastAPI에서 직접 서빙

별도 Nginx 없이 FastAPI가 정적 파일도 함께 서빙한다. 소규모 배포에 적합하다.

# main.py
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse

# API 라우터를 먼저 등록
app.include_router(qna_chatbot.router)
app.include_router(monitoring.router)
# ...

# SPA fallback: API가 아닌 모든 경로를 index.html로
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
    file_path = f"/app/static/{full_path}"
    if os.path.isfile(file_path):
        return FileResponse(file_path)
    return FileResponse("/app/static/index.html")

3.2 방법 2: Nginx Reverse Proxy

Nginx가 정적 파일을 서빙하고, API 요청만 FastAPI로 전달한다. 대규모 배포에 적합하다.

server {
    listen 443 ssl;
    server_name minerva.example.com;

    ssl_certificate /etc/ssl/cert.pem;
    ssl_certificate_key /etc/ssl/key.pem;

    # 정적 파일
    location / {
        root /usr/share/nginx/html;
        try_files $uri $uri/ /index.html;
    }

    # API 프록시
    location /agents/ {
        proxy_pass http://api:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_read_timeout 120s;
    }

    location /monitoring/ {
        proxy_pass http://api:8000;
        proxy_set_header Host $host;
    }

    location /health {
        proxy_pass http://api:8000;
    }
}

4 환경 변수 관리

4.1 Pydantic BaseSettings

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    # Azure OpenAI
    azure_openai_endpoint: str
    azure_openai_key: str
    azure_openai_api_version: str = "2024-12-01-preview"

    # LLM Provider
    llm_provider: str = "azure"

    # 앱 설정
    environment: str = "production"
    log_level: str = "INFO"
    warmup_on_startup: bool = True

    # CORS
    cors_origins: list[str] = ["https://minerva.example.com"]

    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}

4.2 환경별 .env 파일

# .env.local (Ollama + FAISS, 로컬 개발)
LLM_PROVIDER=ollama
ENVIRONMENT=development
MINERVA_LOG_LEVEL=DEBUG
CORS_ORIGINS=["http://localhost:5173"]
WARMUP_ON_STARTUP=false

# .env.cloud (Azure OpenAI + AI Search)
LLM_PROVIDER=azure
AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com/
AZURE_OPENAI_KEY=sk-xxx
AZURE_OPENAI_API_VERSION=2024-12-01-preview
ENVIRONMENT=production
MINERVA_LOG_LEVEL=INFO
CORS_ORIGINS=["https://minerva.example.com"]
WARMUP_ON_STARTUP=true
env 로드 우선순위 — .env > .env.cloud > .env.local

services/api/main.py_load_env_files()는 repo root에서 위 순서로 첫 번째 존재 파일만 로드한다. override=False로 설정되어 있어 시스템 환경변수(Docker --env-file, K8s Secret 등)가 이미 있으면 덮어쓰지 않는다. 즉:

  • 프로덕션 컨테이너는 --env-file 또는 K8s Secret으로 주입 → .env* 파일은 무시됨
  • 로컬 개발은 .env.cloud 또는 .env.local을 두면 자동 로드
  • 둘 다 있으면 .env.cloud가 우선 (PoC 시절 cloud 위주 운영의 흔적)

이 우선순위는 머신마다 .env* 여러 개를 두고도 cd switch-env.ps1 같은 PowerShell 스크립트로 빠르게 전환하는 워크플로를 가능하게 한다.

4.3 Docker Compose 환경 변수

# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "8000:8000"
    env_file:
      - .env.production
    volumes:
      - ./data/docs:/app/data/docs:ro
      - ./data/experiments:/app/data/experiments
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 4G

5 헬스체크

5.1 엔드포인트

@router.get("/health")
def health():
    agents_status = {}
    for name, agent in app.state.agents.items():
        try:
            agents_status[name] = "healthy"
        except Exception:
            agents_status[name] = "unhealthy"

    return {
        "status": "ok",
        "environment": settings.environment,
        "agents": agents_status,
    }

5.2 Docker HEALTHCHECK

HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

30초마다 헬스체크를 실행하고, 3회 연속 실패하면 컨테이너를 unhealthy로 표시한다. Docker Compose의 restart: unless-stopped와 결합하면 자동 복구가 가능하다.

6 워커 구성 — 단일 uvicorn 시작, 멀티워커는 스케일아웃 시

6.1 현 운영: 단일 uvicorn

MINERVA의 현재 운영은 컨테이너 1개당 uvicorn 단일 프로세스로 시작한다. agent 인스턴스 캐시가 in-memory (experiment, arm) 딕셔너리에 들어 있고 threading.Lock으로 동시 요청을 처리하므로, 단일 프로세스에서도 thread-pool 기반 비동기 I/O로 충분한 동시성을 확보한다.

uvicorn services.api.main:app --host 0.0.0.0 --port 8000

이 구성의 장점:

  • 워커 간 캐시 공유 불필요 — 모든 요청이 같은 in-memory 캐시에 접근
  • ALBERT weight·FAISS 인덱스를 한 번만 로드 → 메모리 절감
  • A/B 실험 arm별 에이전트 인스턴스 중복 생성 회피

6.2 스케일아웃 시: gunicorn + uvicorn worker

QPS가 단일 프로세스 한계(LLM I/O 대기 + 전후처리 CPU)를 초과하면 다음 단계로 멀티워커로 전환한다.

gunicorn services.api.main:app \
  --worker-class uvicorn.workers.UvicornWorker \
  --workers 4 \
  --bind 0.0.0.0:8000 \
  --timeout 120 \
  --graceful-timeout 30 \
  --keep-alive 30 \
  --access-logfile -
옵션 근거
--workers 4 4코어 머신 기준 (2*CPU+1은 LLM 대기 비중 높은 워크로드에 과다)
--timeout 120 LLM 응답이 최대 60초 + 여유
--graceful-timeout 30 배포 시 진행 중 요청 완료 대기
--keep-alive 30 프론트엔드 연결 재사용

6.3 멀티워커 전환 시 주의사항

각 워커가 독립적으로 에이전트를 초기화한다. 워커가 4개이면:

  • 벡터 인덱스: 4 × ~200MB = ~800MB (FAISS 로컬 사용 시. Azure AI Search이면 워커별 0)
  • BM25 인덱스: 4 × ~50MB = ~200MB
  • ALBERT 도메인 분류기: 4 × ~200MB = ~800MB (DS 에이전트 한정)
  • Reranker 모델: 4 × ~100MB = ~400MB

총 ~2GB 추가 메모리가 필요하다. A/B 실험에서 arm별 에이전트가 캐시되면 워커마다 별도 캐시를 가지므로 메모리가 더 늘어난다 — sticky_hash가 워커 간 사용자 일관성을 보장하지 않으므로, 멀티워커에서는 sticky 키에 worker_id를 포함하지 않도록 hash 키 구성을 재검증해야 한다.

7 동시 접속자 한계 — 어디가 진짜 병목인가

운영 중 “동시 접속 N명에서 막힌다”는 현상이 보고되면 흔히 프론트엔드(Vite/Nginx)가 의심받는다. 그러나 두 차원을 분리해서 봐야 정확하다.

7.1 두 차원 분리 — 같은 사실을 다르게 본다

차원 무엇을 측정 Vite dev vs Nginx 정적 차이
(A) 동시 접속 한계 N명 동시 접속이 가능한지 운영 정상화(build + Nginx) 후에는 백엔드 LLM이 결정, 프론트는 거의 영향 없음
(B) 페이지 로드 속도·CPU 부하 한 명의 페이지 로드 시간 / 서버 CPU 차이 실재 — Vite는 매 요청 TS→JS 변환, Nginx는 변환 0

(A)가 운영 정상화 전제가 핵심이다. Vite dev server를 운영에 그대로 쓰던 비정상 상태에서는 (A)·(B) 모두 한계의 원인이 되지만, build + Nginx로 정상화하면 (A)의 프론트 측면은 거의 사라지고 (B)만 실재한다.

7.2 계층별 동시 접속 한계 (정상 운영 기준)

계층 한계 추정 근거
정적 파일 서빙 (Nginx 또는 FastAPI StaticFiles) 수천 명 이상 OS의 sendfile로 정적 파일 전송. CPU·메모리 거의 무사용
Vite dev server (npm run dev) 수십 명 매 요청마다 ESM 모듈 변환 — 운영용 아님
FastAPI 라우터 (간단한 엔드포인트) 수백~수천 uvicorn thread pool로 비동기 I/O
LLM 호출 (Azure OpenAI 동기 대기) 단일 워커 약 10명 호출당 1~10초, thread pool로 보통 10 thread 동시
멀티워커 (gunicorn 4 worker) 약 40명 워커별 독립 thread pool
RAG 검색 (Azure AI Search) 수백 (서비스 SLA 의존) 외부 API의 RPS 제한

7.3 시나리오 A/B/C — “10명 → 50명” 변화의 진짜 원인

운영 중 “Vite를 Nginx로 바꿨더니 10명에서 50명으로 늘었다”는 보고는 다음 세 시나리오 중 어느 것에 해당하는지 분리해서 봐야 한다.

시나리오 프론트 백엔드 동시 접속 한계 추정
A Vite dev 운영 사용 (비정상) npm run dev 노출 단일 uvicorn (~10 thread) 약 10명 — Vite 변환 부하 + LLM 대기 누적
B Build + Nginx 정상화 npm run build + Nginx 정적 서빙 단일 uvicorn (~10 thread) 약 10명 — 프론트 한계는 사라지고 LLM이 단일 병목
C Build + Nginx + 멀티워커 동일 gunicorn --workers 4 약 40명 — “50명”이 여기서 나왔을 가능성이 가장 큼

A → B 전환만으로 한계가 10에서 50으로 뛰지는 않는다 (A의 Vite 부하가 사라져도 백엔드 LLM이 단일 병목으로 남기 때문). A → C 전환 (Vite 정상화 + 워커 확장)이 함께 일어났을 가능성이 크다. 동료가 본 50명의 진짜 원인은 백엔드 워커 구성 변화 쪽일 가능성이 높다.

7.4 Vite의 (B) 차원 효과는 실재한다

위 시나리오는 (A) 동시 접속 한계 차원이다. (B) 차원에서는 build + Nginx의 효과가 따로 있다:

  • 페이지 로드 속도: Vite dev는 매 요청 TS→JS 변환으로 첫 로드가 느려질 수 있다. Build 결과물은 minify·번들 완료 상태라 더 빠르게 로드된다.
  • 서버 CPU: Vite dev는 변환 CPU가 실시간 누적된다. Nginx 정적 서빙은 OS의 sendfile이 디스크→네트워크로 직접 전송해 CPU ~0.

(B)는 동시 접속과 무관하게 사용자 한 명의 체감 응답성운영 비용에 영향을 준다. DS 친화 비유로는 05편 Vite callout의 ML 컴파일 비유 참조.

7.5 실제 한계를 늘리는 방법

방법 효과 비용
프론트엔드 build → Nginx 서빙 (B) 페이지 로드 속도 ↑, CPU 부하 해소 — (A) 동시 접속 자체에는 영향 작음 빌드 단계 추가 (이미 Dockerfile에 포함)
gunicorn --workers N 동시 LLM 호출 N배 — (A) 동시 접속 한계의 결정적 변수 워커당 메모리 ~2GB
LLM 호출 비동기화 (async/await 전 경로) thread pool 제한 우회 코드 비동기화 작업
Azure OpenAI 배포 단위 분산 rate limit 우회 비용 N배
캐싱 (의미 동일 질문) LLM 호출 자체 회피 semantic cache 인프라
응답 스트리밍 (SSE) TTFT 단축 → 체감 응답성 이미 적용 (04편)

MINERVA의 현실적 시작점: 단일 uvicorn → 동시 10명 한계가 보이면 → gunicorn --workers 4로 확장 (40명) → 그 이상은 Azure OpenAI rate limit · 메모리 · 캐싱 전략으로 풀어야 한다. 프론트엔드 측면은 build + Nginx로 정상화하는 것이 (B) 차원의 사용자 체감과 운영 비용에는 중요하지만, (A) 동시 접속 한계의 결정적 변수는 백엔드 워커 구성이다.

8 Makefile — 명령 통합

머신·OS 차이를 흡수하기 위해 모든 dev/build/test 명령을 Makefile에 통합한다.

타겟 동작
make install backend(Poetry) + poc(Poetry) + frontend(npm) 의존성 일괄 설치
make dev-backend uvicorn :8000 reload 모드
make dev-frontend Vite :5173
make dev-poc Streamlit PoC (별도 venv)
make smoke-test Azure 자격증명 없이 import·경로 상수 검증
make rebuild-index NAME=qna Azure Search 인덱스 재생성
make clean venv·node_modules·캐시 삭제

make help로 전체 타겟을 노출시켜 새 개발자가 첫날에 환경을 빠르게 잡을 수 있게 한다.

9 배포 체크리스트

단계 확인 항목
빌드 전 .env.cloud 환경 변수 확인 (Azure endpoint·key·api_version)
빌드 전 data/docs/ 지식베이스 문서 최신화, data/configs/*.yaml 검증
빌드 전 ALBERT weight (data/models/albert/)·FAISS 인덱스 준비
빌드 docker build 성공 (sg-data-standardization 이미지 외 사이드카 설치 확인)
테스트 docker run/health 응답 확인
테스트 warmup 완료 로그 확인 (QnA + DS, ALBERT 로드 ~3-5초)
테스트 채팅 요청 → 응답 정상 확인
테스트 스트리밍 요청 → 토큰 수신 확인
테스트 /monitoring/metrics, /monitoring/ab/experiments 정상 응답
배포 docker compose up -d
배포 후 헬스체크 통과 확인
배포 후 기존 세션 정상 종료 확인 (graceful shutdown)
배포 후 data/runtime/runs.jsonl append 확인 (관측성 작동)

10 정본 핸드오프와 운영 변화 추적

배포 파이프라인이나 인프라 결정이 바뀌면 코드 리포의 docs/platform-refactor-handoff.md(정본)에 Phase별로 기록한다. 본 블로그 포스트는 시점 기준 스냅샷이므로, 운영 도중 발생한 의사결정·롤백·우회 패치는 정본 문서가 더 빠르고 정확하다. 새 머신·새 클러스터에서 배포를 시작할 때 가장 먼저 정본을 확인하는 것이 권장된다.

11 관련 주제

선행 지식 (Phase A)

이 시리즈

Subscribe

Enjoy this blog? Get notified of new posts by email: