Ctrl K
ComunidadGuías…

docker-compose.prod.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# ══════════════════════════════════════════════════════════════════════
#  Geny — Docker Compose (Production with Nginx Reverse Proxy / Full)
#
#  도메인을 연결하여 단일 포트(80)로 backend / frontend를 라우팅합니다.
#  로컬 오디오 엔진 (TTS / STT) 은 --profile audio-local 로 활성화할 수 있습니다.
#
#    /api/*            → backend:8000
#    /health           → backend:8000
#    /static/assets/*  → backend:8000
#    /*                → frontend:3000
#
#  Usage:
#    docker compose -f docker-compose.prod.yml up --build -d
#    docker compose -f docker-compose.prod.yml --profile audio-local up --build -d
#    docker compose -f docker-compose.prod.yml down
#    docker compose -f docker-compose.prod.yml logs -f
#
#  경량 버전 (TTS 없음):
#    docker compose -f docker-compose.prod-core.yml up --build -d
#
#  도메인 / SSL 설정:
#    DOMAIN=geny.example.com  → nginx server_name 에 반영
#    SSL 은 별도의 certbot 또는 Cloudflare 등으로 처리 후
#    nginx/nginx.conf 에 443 블록을 추가하세요.
#
#  Port 변경:
#    NGINX_PORT=58443 docker compose -f docker-compose.prod.yml up --build
#

# ══════════════════════════════════════════════════════════════════════

services:

  # ── Qdrant (knowledge repository vector store) ────────────────────
  qdrant:
    # Keep within one minor of the qdrant-client the backend installs
    # (1.18.x) — mismatch beyond that raises compatibility warnings.
    image: qdrant/qdrant:v1.18.2
    container_name: geny-qdrant-prod
    restart: unless-stopped
    volumes:
      - geny-qdrant-prod:/qdrant/storage
    networks:
      - geny-net-prod

  # ── PostgreSQL Database ───────────────────────────────────────────
  postgres:
    image: postgres:16-alpine
    container_name: geny-postgres-prod
    init: true
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-geny}
      POSTGRES_USER: ${POSTGRES_USER:-geny}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-geny123}
      POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
      TZ: ${TIMEZONE:-Asia/Seoul}
    volumes:
      - geny-pgdata-prod:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-geny} -d ${POSTGRES_DB:-geny}"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    networks:
      - geny-net-prod

  # ── Backend (FastAPI + LangGraph + Claude CLI) ────────────────────
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: geny-backend-prod
    init: true
    restart: unless-stopped
    expose:
      - "8000"
    env_file:
      - path: ./backend/.env
        required: false
    environment:
      - APP_HOST=0.0.0.0
      - APP_PORT=8000
      - GENY_AGENT_STORAGE_ROOT=/data/geny_agent_sessions
      # ── BLAS threading ────────────────────────────────────────────────
      # Belt to main.py's braces. OpenBLAS's worker pool does not survive a
      # fork, and this process forks constantly (CLI, docker exec, tools);
      # the next matmul then spins on workers that never come — one core at
      # 100% inside a microsecond of real work, holding the memory engine's
      # global lock. That wedged production for 27 hours while /health kept
      # answering "healthy". No pool, no failure class; these matrices are
      # far too small for threaded BLAS to pay for its own dispatch.
      # ── CLI hot-spare ─────────────────────────────────────────────
      # OFF. 2.65.1 fixed one way a spare could be stale (a reaped child
      # still reading as alive) and turns ran clean for a while — then the
      # same signature came back: the CLI starts, lists its tools, exits,
      # and the turn stalls until the watchdog abandons it at ~320s.
      # Flipping this to 0 has made the session answer in ~10s every time it
      # has been tried, so it stays off until the CLI's own exit is
      # understood. The prewarm buys time-to-first-token, not correctness.
      - GENY_CLI_PREWARM=${GENY_CLI_PREWARM:-0}
      - OPENBLAS_NUM_THREADS=1
      - OMP_NUM_THREADS=1
      - MKL_NUM_THREADS=1
      - NUMEXPR_NUM_THREADS=1
      # ── Geny Drive (agent cloud storage) ──────────────────────────────
      # Per-agent workspace quota and per-file cap. Declared explicitly so
      # the deployed limits are visible/tunable here instead of living as
      # hidden code defaults — nginx's storage location caps the request
      # body at 512m; keep MAX_FILE_MB strictly below that (multipart
      # framing adds overhead, so a file at exactly the nginx cap 413s
      # before the backend ever sees it — 500 leaves 12m of headroom).
      - GENY_WORKSPACE_QUOTA_MB=${GENY_WORKSPACE_QUOTA_MB:-10240}
      - GENY_WORKSPACE_MAX_FILE_MB=${GENY_WORKSPACE_MAX_FILE_MB:-500}
      # Knowledge repository vector store
      - GENY_QDRANT_URL=http://qdrant:6333
      # PostgreSQL
      - POSTGRES_HOST=postgres
      - POSTGRES_PORT=5432
      - POSTGRES_DB=${POSTGRES_DB:-geny}
      - POSTGRES_USER=${POSTGRES_USER:-geny}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-geny123}
      - AUTO_MIGRATION=true
      - TIMEZONE=${TIMEZONE:-Asia/Seoul}
      # Inbound chat gateway (geny-executor >=2.11.0). Set the token in the
      # root .env to let users DM the VTuber from Telegram; empty = off.
      - GATEWAY_TELEGRAM_BOT_TOKEN=${GATEWAY_TELEGRAM_BOT_TOKEN:-}
      - GATEWAY_TELEGRAM_ALLOWED_CHAT_IDS=${GATEWAY_TELEGRAM_ALLOWED_CHAT_IDS:-}
      - GATEWAY_DISCORD_BOT_TOKEN=${GATEWAY_DISCORD_BOT_TOKEN:-}
      - GATEWAY_DISCORD_ALLOWED_CHANNEL_IDS=${GATEWAY_DISCORD_ALLOWED_CHANNEL_IDS:-}
      - GATEWAY_SLACK_APP_TOKEN=${GATEWAY_SLACK_APP_TOKEN:-}
      - GATEWAY_SLACK_BOT_TOKEN=${GATEWAY_SLACK_BOT_TOKEN:-}
      - GATEWAY_SLACK_ALLOWED_CHANNEL_IDS=${GATEWAY_SLACK_ALLOWED_CHANNEL_IDS:-}
      # ── Persistent-storage paths (Phase 1, 2026-05-19) ─────────────
      # Every path below is backed by a named volume so its data
      # survives ``docker compose up --build``. Without these env
      # vars the stores fall back to in-repo defaults that live on
      # the container's writable layer and get wiped on rebuild.
      - ENVIRONMENT_STORAGE_PATH=${ENVIRONMENT_STORAGE_PATH:-/data/environments}
      # Custom MCP server definitions must outlive container recreation —
      # the legacy in-image path (/app/mcp/custom) was wiped on every
      # deploy, silently deleting every server registered through the
      # MCP tab. Rides the existing geny-mcp-credentials-prod volume.
      - MCP_CUSTOM_STORAGE_PATH=${MCP_CUSTOM_STORAGE_PATH:-/data/mcp/custom}
      - TRIGGER_PRESET_STORAGE_PATH=${TRIGGER_PRESET_STORAGE_PATH:-/data/trigger_presets}
      - GENY_TOOL_PRESETS_DIR=/data/tool_presets
      - GENY_CHAT_CONVERSATIONS_DIR=/data/chat_conversations
      - GENY_GAME_STATE_DB=/data/creature_state/geny_state.sqlite3
      - GENY_CONFIG_VARIABLES_DIR=/data/config_variables
      - GENY_MCP_CREDENTIALS=/data/mcp/credentials.json
      # Model-account secrets: API keys, setup tokens, Codex OAuth token
      # sets. Same named volume as the MCP store, for the same reason — the
      # container's writable layer is discarded on every rebuild, and losing
      # this file signs every Claude and Codex account out and erases every
      # key, with no error until the next turn.
      - GENY_LLM_SECRETS=/data/mcp/llm_accounts.json
      # Per-account CLAUDE_CONFIG_DIRs. Each Claude Code login owns one, and
      # it IS the login — on the container's writable layer every account
      # would be signed out by a redeploy.
      - GENY_LLM_ACCOUNTS_ROOT=/data/llm-accounts
      - GENY_VOICE_STUDIO_DATA_DIR=/data/voice_studio
      # ── GAPT integration (sandbox/project/deploy delegation) ────────
      # geny-backend calls the GAPT control plane over gapt-net and runs
      # the agent CLI inside GAPT workspace containers via docker exec
      # (executor ContainerCLIRunner). Empty GAPT_BASE_URL = off (host
      # execution unchanged).
      - GAPT_BASE_URL=${GAPT_BASE_URL-http://gapt-server:8088}
      - GAPT_ADMIN_ID=${GAPT_ADMIN_ID:-admin}
      - GAPT_ADMIN_PASSWORD=${GAPT_ADMIN_PASSWORD:-admin}
      # SSO bypass: an authenticated Geny user opens GAPT without a 2nd login
      # (Geny establishes the GAPT session). Default on; set false to require
      # GAPT's own login (e.g. when GAPT is exposed independently of Geny).
      - GENY_GAPT_SSO_BYPASS=${GENY_GAPT_SSO_BYPASS:-true}
      # Opt-in: run agent sessions inside GAPT workspace sandboxes (sysbox).
      # Default off → host execution unchanged. Flip to 1 to enable.
      - GENY_GAPT_WORKSPACES=${GENY_GAPT_WORKSPACES:-0}
      # One session = one GAPT bind workspace: the HOST path of the
      # agent-sessions volume (…/docker/volumes/<proj>_geny-agent-sessions-…/_data).
      # Set it (usually in .env) to unify each session's workspace with its
      # sandbox /workspace; empty keeps the legacy split-filesystem worktree.
      - GENY_AGENT_SESSIONS_HOST_DIR=${GENY_AGENT_SESSIONS_HOST_DIR:-}
      - GENY_GAPT_PROJECT_SLUG=${GENY_GAPT_PROJECT_SLUG:-geny}
      # Sandbox Tool Packs self-service: an agent that builds+saves a pack has it
      # auto-enabled + auto-opted-into its OWN env, so its future sessions auto-
      # load it (zero owner steps). Default on; set false for the manual gate.
      - GENY_PACK_AUTOLOAD_OWN=${GENY_PACK_AUTOLOAD_OWN:-true}
      # ── geny-avatar integration (Avatar settings category + key sync) ──
      # Reachable over geny-net-prod; the URL includes the avatar's basePath so
      # the client appends /api/…. Empty = avatar integration off.
      - GENY_AVATAR_BASE_URL=${GENY_AVATAR_BASE_URL-http://avatar-editor:3000/avatar-editor}
    volumes:
      # Host docker socket — the executor's ContainerCLIRunner shells out
      # to `docker exec` into GAPT workspace sandboxes (gapt-ws-<id>).
      - /var/run/docker.sock:/var/run/docker.sock
      - geny-tts-cache-prod:/app/cache/tts
      - geny-voices-prod:/app/static/voices
      # Chat attachment uploads (cycle 20260424_3 PR-1) — without a named
      # volume here every container restart wiped user-uploaded images.
      - geny-uploads-prod:/app/static/uploads
      # Geny agent storage root — holds every session's chat history,
      # plus the per-user Opsidian vaults (_user_opsidian/<user>/...)
      # with their notes, _attachments/, and _captures.jsonl. Without
      # this volume the entire backend "memory" — all user notes, all
      # audio captures, all transcripts — lives on the container's
      # writable layer and gets wiped on every ``docker compose up
      # --build backend``. Discovered 2026-05-14 when 13 inbox audio
      # memos vanished after a routine backend rebuild despite
      # ``GENY_AGENT_STORAGE_ROOT`` being set in the env block above.
      - geny-agent-sessions-prod:/data/geny_agent_sessions
      # ── Phase-1 persistence volumes (2026-05-19) ────────────────────
      # Each named volume backs one of the env-var-aware stores
      # declared in the ``environment:`` block above. Without these
      # mounts the env vars resolve to paths on the container's
      # writable layer, which ``docker compose up --build`` wipes.
      # See store source for the resolver pattern (e.g. backend/
      # service/tool_preset/store.py::_resolve_default_dir).
      - geny-environments-prod:/data/environments
      - geny-trigger-presets-prod:/data/trigger_presets
      - geny-tool-presets-prod:/data/tool_presets
      - geny-chat-conversations-prod:/data/chat_conversations
      - geny-creature-state-prod:/data/creature_state
      - geny-config-variables-prod:/data/config_variables
      - geny-mcp-credentials-prod:/data/mcp
      - geny-llm-accounts-prod:/data/llm-accounts
      - geny-voice-studio-prod:/data/voice_studio
      # Baked puppet zips dropped here by avatar-editor. Backend reads + moves to
      # installed/ subdir after install (rw — was :ro before C.3 added install).
      - geny-baked-exports-prod:/data/baked-imports
      # Phase G of the LLM backend upgrade cycle — Claude Code OAuth
      # credentials live in /root/.claude/ inside the container. Two
      # operator paths:
      #   1) Default (``CLAUDE_HOST_DIR`` unset): use the named volume
      #      ``geny-claude-creds-prod``. Survives container rebuilds.
      #      The user logs in from inside the container via the
      #      Settings → LLM Backends → Claude Code modal ("Sign in
      #      here") — `claude auth login` writes to this volume.
      #   2) Override (``CLAUDE_HOST_DIR=/abs/path``): bind-mount a
      #      host directory. Use this when the operator has already
      #      run `claude auth login` on the host and wants the
      #      container to share that subscription.
      # The earlier ``${HOME}/.claude`` default was sudo-broken — under
      # ``sudo docker compose`` HOME=/root, so the bind would silently
      # create an empty /root/.claude on the host. Named volume avoids
      # that footgun entirely.
      - ${CLAUDE_HOST_DIR:-geny-claude-creds-prod}:/root/.claude:rw
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
      # 15s interval + 3 retries → a wedge is flagged unhealthy in <1min, then
      # the autoheal sidecar restarts it. (Was 30s; tightened after a session
      # memory-load froze the event loop and the container sat dead for hours.)
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 40s
    # Let py-spy / gdb attach to the running interpreter for live diagnosis of
    # an event-loop stall (a hung task can't be sampled otherwise). Inert
    # during normal operation.
    cap_add:
      - SYS_PTRACE
    labels:
      # Watched by the `autoheal` sidecar below: when the healthcheck goes
      # unhealthy the container is auto-restarted instead of sitting dead —
      # turns a multi-hour outage into a ~30s blip.
      - autoheal=true
    networks:
      - geny-net-prod
      # Reach the GAPT control plane (gapt-server:8088) + workspace
      # sandbox containers for the bridged MCP callback.
      - gapt-net

  # ── Autoheal (watchdog) ───────────────────────────────────────────
  # Monitors containers labelled ``autoheal=true`` and restarts any that Docker
  # reports ``unhealthy``. Docker's own ``restart: unless-stopped`` only fires
  # on process EXIT — a process that is alive but WEDGED (event loop blocked on
  # a slow/stuck syscall) never exits, so it would otherwise stay dead until a
  # human intervenes. This closes that gap.
  autoheal:
    image: willfarrell/autoheal:1.2.0
    container_name: geny-autoheal-prod
    restart: unless-stopped
    environment:
      - AUTOHEAL_CONTAINER_LABEL=autoheal
      - AUTOHEAL_INTERVAL=10          # check every 10s
      - AUTOHEAL_START_PERIOD=45      # grace after a (re)start before watching
      - AUTOHEAL_DEFAULT_STOP_TIMEOUT=40  # SIGTERM grace before SIGKILL (a
                                          # wedged proc in D-state may need the
                                          # kill; 40s lets in-flight I/O drain)
      - TZ=${TIMEZONE:-Asia/Seoul}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

  # ── Frontend (Next.js) ────────────────────────────────────────────
  frontend:
    build:
      # Root context: the page renders shared/chat, which lives beside
      # frontend/ and must be in the image.
      context: .
      dockerfile: frontend/Dockerfile
      args:
        - API_URL=http://backend:8000
        - NEXT_PUBLIC_API_URL=
    container_name: geny-frontend-prod
    init: true
    restart: unless-stopped
    expose:
      - "3000"
    environment:
      - API_URL=http://backend:8000
      - NEXT_PUBLIC_API_URL=
      - PORT=3000
      - HOSTNAME=0.0.0.0
    depends_on:
      backend:
        condition: service_healthy
    networks:
      - geny-net-prod

  # ── Nginx Reverse Proxy ───────────────────────────────────────────
  nginx:
    image: nginx:1.27-alpine
    container_name: geny-nginx-prod
    init: true
    restart: unless-stopped
    ports:
      - "${NGINX_PORT:-58443}:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      frontend:
        condition: service_started
      backend:
        condition: service_healthy
      avatar-editor:
        condition: service_started
    networks:
      - geny-net-prod

  # ── OmniVoice (Optional — Geny 자체 TTS 마이크로서비스, GPU 필요) ─
  # geny-monorepo 내 omnivoice/ 에서 빌드. 외부 노출 없이 backend 가
  # http://omnivoice:9881 로 호출. HF 모델 캐시는 영구 볼륨에.
  omnivoice:
    build:
      context: ./omnivoice
      dockerfile: Dockerfile
    image: geny-omnivoice:prod
    container_name: geny-omnivoice-prod
    profiles: ["audio-local"]
    init: true
    restart: unless-stopped
    expose:
      - "9881"
    environment:
      - OMNIVOICE_MODEL=${OMNIVOICE_MODEL:-k2-fsa/OmniVoice}
      - OMNIVOICE_DEVICE=${OMNIVOICE_DEVICE:-cuda:0}
      - OMNIVOICE_DTYPE=${OMNIVOICE_DTYPE:-float16}
      # GPU 동시 합성 슬롯 (asyncio.Semaphore). 2026-04 RTX 5070
      # (Blackwell, sm_120, 12GB, Tensor cores) 로 업그레이드하며 4 로 상향.
      # 더 작은 GPU 또는 공유 호스트에서는 1~2 로 내릴 것.
      - OMNIVOICE_MAX_CONCURRENCY=${OMNIVOICE_MAX_CONCURRENCY:-1}
      # Diffusion outer-loop step 수. 원본 기본값 32. 16 = 품질/속도 균형
      # (5070 측정 기준 RTF 충분히 안정). 음질 우선이면 24, 속도 우선이면
      # 8~12 까지 내릴 수 있음.
      - OMNIVOICE_DEFAULT_NUM_STEP=${OMNIVOICE_DEFAULT_NUM_STEP:-24}
      # CUDA 컨텍스트 메모리 단편화 방지. 0.85 = 12GB 5070 에서 ~10.2GB
      # 예약 (전용 GPU 호스트 기준). 공유 시 0.5~0.7 로 낮춰서 운영.
      # 0 으로 두면 비활성화.
      # 2026-05-29: bumped from 0.5 → 0.65 after observing post-reboot
      # idle GPU usage of ~1.7 GiB (other containers) on the 12 GiB
      # 5070. 0.65 → 7.48 GiB cap; combined with neighbours peaks ~9.2
      # GiB (~80 % of total), leaves the OS comfortable headroom.
      - OMNIVOICE_GPU_MEMORY_FRACTION=${OMNIVOICE_GPU_MEMORY_FRACTION:-0.65}
      # CUDA caching-allocator: switch to expandable segments to avoid the
      # fragmentation-induced OOM the production GPU was hitting at the
      # 50% fraction cap (5.74 / 5.75 GiB used, no contiguous block left
      # for a 20 MiB allocation). See pytorch.org/docs/stable/notes/
      # cuda.html#environment-variables.
      - PYTORCH_CUDA_ALLOC_CONF=${PYTORCH_CUDA_ALLOC_CONF:-expandable_segments:True}
      - OMNIVOICE_AUTO_ASR=${OMNIVOICE_AUTO_ASR:-false}
      - OMNIVOICE_LOG_LEVEL=${OMNIVOICE_LOG_LEVEL:-info}
      - HF_ENDPOINT=${HF_ENDPOINT:-https://huggingface.co}
    volumes:
      - geny-voices-prod:/voices:ro
      - geny-omnivoice-models-prod:/models
      # Output-equivalence baselines persist across container restarts so
      # every Tier-A PR can be diffed against the same reference PCM.
      - ./omnivoice/baselines:/baselines
    shm_size: 8G
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:9881/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 300s
    networks:
      - geny-net-prod

  # ── Avatar Editor (geny-avatar submodule, Next.js) — Prod ─────────
  # In prod, nginx terminates external traffic and proxies the
  # /avatar-editor/ prefix to this service over the geny-net-prod
  # network. avatar-editor is therefore internal-only (no host port)
  # and built with NEXT_PUBLIC_BASE_PATH=/avatar-editor so all routes,
  # static assets, and apiUrl() fetches resolve under that prefix.
  avatar-editor:
    build:
      context: ./vendor/geny-avatar
      dockerfile: Dockerfile
      args:
        # NEXT_PUBLIC_* are inlined into the JS bundle by next build —
        # they MUST be passed as build args, not just runtime env, or
        # the bundle ships with empty values. basePath drives routing,
        # GENY_HOST gates the "send to Geny" button + /api/send-to-geny
        # route in the avatar-editor app.
        NEXT_PUBLIC_BASE_PATH: /avatar-editor
        NEXT_PUBLIC_GENY_HOST: "true"
    container_name: geny-avatar-editor-prod
    init: true
    restart: unless-stopped
    expose:
      - "3000"
    environment:
      - NEXT_PUBLIC_BASE_PATH=/avatar-editor
      - NEXT_PUBLIC_GENY_HOST=true
      - GENY_BAKED_EXPORTS_DIR=/exports
      # API 설정 모달이 쓰는 config.json — 전용 볼륨에 두어 이미지
      # 재빌드/컨테이너 재생성에도 키 설정이 유지되게 한다.
      - GENY_AVATAR_CONFIG_PATH=/config/config.json
      # AI provider keys consumed by the editor's Generate panel API
      # routes (lib/ai/providers/registry.ts). Empty default means the
      # provider shows up as "<KEY> not set" in the dropdown without
      # crashing. Set in your .env to enable.
      - GEMINI_API_KEY=${GEMINI_API_KEY:-}
      - OPENAI_API_KEY=${OPENAI_API_KEY:-}
      - REPLICATE_API_TOKEN=${REPLICATE_API_TOKEN:-}
      - PORT=3000
      - HOSTNAME=0.0.0.0
      - NODE_ENV=production
    volumes:
      - geny-baked-exports-prod:/exports
      - geny-avatar-config-prod:/config
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/avatar-editor/ || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s
    networks:
      - geny-net-prod

  # ── Whisper STT (Optional — vLLM 기반 음성 전사, GPU 필요) ────────
  # geny-monorepo 내 whisper-stt/ 에서 빌드. 외부 노출 없이 backend 가
  # http://whisper-stt:8001 로 호출. vLLM 의 OpenAI 호환
  # /v1/audio/transcriptions endpoint 를 그대로 사용.
  #
  # GPU 분할 (RTX 5070 12GB):
  #   omnivoice  → 0.50 (≈ 6 GB)
  #   whisper    → 0.35 (≈ 4 GB)
  #   ────────────────────
  #   합계         0.85  (≈ 10 GB)  ← 15% 여유로 OOM 방지
  whisper-stt:
    build:
      context: ./whisper-stt
      dockerfile: Dockerfile
    image: geny-whisper-stt:prod
    container_name: geny-whisper-stt-prod
    profiles: ["audio-local"]
    init: true
    restart: unless-stopped
    expose:
      - "8001"
    environment:
      - WHISPER_MODEL=${WHISPER_MODEL:-openai/whisper-large-v3}
      - WHISPER_PORT=${WHISPER_PORT:-8001}
      # vLLM 의 GPU 메모리 점유율. OmniVoice 와 같은 GPU 위에서
      # 공존 가능하도록 보수적 분할.
      - WHISPER_GPU_MEMORY_FRACTION=${WHISPER_GPU_MEMORY_FRACTION:-0.35}
      - WHISPER_DTYPE=${WHISPER_DTYPE:-auto}
      # FlashAttention 2 stalls on Blackwell (sm_120, RTX 50 series)
      # on the encoder-decoder Whisper path — observed as a 24+ min
      # hang right after "Using FLASH_ATTN attention backend". The
      # entrypoint passes this through as `vllm serve
      # --attention-backend <value>`. Default TRITON_ATTN since
      # Triton compiles its kernel JIT and doesn't need a
      # precompiled FA2 binary for sm_120. Switch to FLASH_ATTN /
      # FLASHINFER once vllm ships Blackwell-ready binaries.
      - WHISPER_ATTENTION_BACKEND=${WHISPER_ATTENTION_BACKEND:-TRITON_ATTN}
      # HuggingFace 모델 다운로드 (public 모델이라 토큰 없이 가능,
      # rate-limit 회피용으로만 옵션 제공).
      - HUGGINGFACE_HUB_TOKEN=${HUGGINGFACE_HUB_TOKEN:-}
      - HF_ENDPOINT=${HF_ENDPOINT:-https://huggingface.co}
    volumes:
      - geny-whisper-cache-prod:/cache/huggingface
    shm_size: 4G
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8001/health"]
      interval: 30s
      timeout: 10s
      retries: 5
      # 첫 부팅: 모델 다운로드 (~3 GB) + vLLM 로드. 이후 부팅은
      # 캐시 hit 으로 ~30 s. 충분히 크게.
      start_period: 600s
    networks:
      - geny-net-prod


networks:
  geny-net-prod:
    driver: bridge
  # Shared with the GAPT stack (deploy/gapt + gapt/compose). GAPT's
  # compose creates it (name: gapt-net); Geny joins it as external so
  # geny-backend can reach gapt-server + workspace sandboxes.
  gapt-net:
    external: true

volumes:
  geny-qdrant-prod:
  geny-pgdata-prod:
    driver: local
  # avatar-editor 의 런타임 config.json (API 키 설정 모달)
  geny-avatar-config-prod:
    driver: local
  geny-tts-cache-prod:
    driver: local
  geny-voices-prod:
    driver: local
  geny-uploads-prod:
    driver: local
  # Holds the backend storage root (``GENY_AGENT_STORAGE_ROOT`` →
  # ``/data/geny_agent_sessions``). Includes every session's chat
  # log, per-user Opsidian vault (notes + attachments + capture
  # logs), and the agent memory provider's file backend. Mandatory
  # for cross-rebuild persistence — see backend.volumes comment.
  geny-agent-sessions-prod:
    driver: local
  geny-omnivoice-models-prod:
    driver: local
  # Whisper 모델 가중치 영구 캐시. 첫 부팅 시 ~3 GB 다운로드.
  geny-whisper-cache-prod:
    driver: local
  # Shared between avatar-editor (rw) and backend (ro) — prod variant.
  geny-baked-exports-prod:
    driver: local
  # Phase G — backend's /root/.claude (Claude Code OAuth credentials)
  # when CLAUDE_HOST_DIR isn't overridden. See backend.volumes for the
  # full rationale.
  geny-claude-creds-prod:
    driver: local
  # ── Phase-1 persistent-storage volumes (2026-05-19) ─────────────────
  # Backs the env-var-aware stores. See backend.environment for the
  # env-var mapping and backend.volumes for the mount points. Without
  # these named volumes, every ``docker compose up --build`` wipes the
  # environment configs, tool presets, chat history, creature state,
  # MCP OAuth tokens, and per-template variable overrides.
  geny-environments-prod:
    driver: local
  geny-trigger-presets-prod:
    driver: local
  geny-tool-presets-prod:
    driver: local
  geny-chat-conversations-prod:
    driver: local
  geny-creature-state-prod:
    driver: local
  geny-config-variables-prod:
    driver: local
  # Per-account Claude Code credential directories. This volume IS the set of
  # logins — losing it means signing every account in again by hand.
  geny-llm-accounts-prod:
  geny-mcp-credentials-prod:
  geny-voice-studio-prod:
    driver: local