diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..845c3e4b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +# Monorepo root context hints (when building from repo root) +.git +**/.git +**/node_modules +packages/web/.next +packages/mobile +**/.turbo +**/target +**/.venv +**/__pycache__ +*.pyc +.env +.env.* +!.env.example +!.env.dev.example +logs +*.log diff --git a/CLAUDE.md b/CLAUDE.md index 0b617d27..cdbc3fc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -408,8 +408,8 @@ bun run dev:local-deps # Docker deps only (see scripts/local-deps-up.sh) bun run dev:local-fe # Next only (same as dev:web) bun run dev:local-be # API + AI together; logs -> .logs/local/api.log & ai.log (tail -f in other terminals) # Local host env templates: packages/api-server/.env.dev.example , packages/ai-server/.dev.env.example -# Port alignment (호스트 실행): MEILISEARCH_URL=http://localhost:7700 ; DECODED_AI_GRPC_URL=http://localhost:50052 (AI APP_ENV=dev) -# API GRPC_PORT must equal AI GRPC_BACKEND_PORT ; AI Redis localhost:6303 + SEARXNG localhost:4000 with local-deps +# Port alignment (호스트 실행): MEILISEARCH_URL=http://localhost:7700 ; AI_SERVER_GRPC_URL=http://localhost:50052 (AI APP_ENV=dev) +# API API_SERVER_GRPC_PORT must equal AI API_SERVER_GRPC_PORT ; 레거시 GRPC_PORT / GRPC_BACKEND_* 도 아직 지원. AI Redis localhost:6303 + SEARXNG localhost:4000 with local-deps # just local-help # prints tail -f hints (root Justfile) # Web package only diff --git a/README.md b/README.md index f107b616..562d179d 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ uv run python -m src.main From repo root: `bun run dev:ai-server` (requires `uv` on PATH). -Docker Compose lives under `packages/ai-server/`; **build context** is that directory. See [`packages/ai-server/README.md`](packages/ai-server/README.md). +**Backend stack (Docker)** — `api` + `ai` + Meilisearch + Redis + SearXNG in one Compose: [`packages/api-server/docker/stack/README.md`](packages/api-server/docker/stack/README.md), **`scripts/deploy-backend.sh`**. +AI package docs: [`packages/ai-server/README.md`](packages/ai-server/README.md). ### Monorepo scripts @@ -98,6 +99,7 @@ bun run dev:api-server # Rust API (cargo watch) bun run dev:ai-server # Python AI (uv) bun run build # Production build (Turborepo) bun run lint # Lint tasks where defined +bun run deploy:backend -- dev up --build # multi-container stack (see docker/stack README) ``` ## Packages (summary) @@ -126,6 +128,7 @@ Expo 54 React Native. - **[CLAUDE.md](CLAUDE.md)** — conventions, routes, commands, design system - **[docs/BACKEND-ONBOARDING.md](docs/BACKEND-ONBOARDING.md)** — API server in the monorepo +- **[packages/api-server/docker/stack/README.md](packages/api-server/docker/stack/README.md)** — Docker Compose stack (api, ai, meili, redis, searxng), deploy script - **`packages/api-server/`** — Rust API docs, ADRs, `AGENTS.md` - **`packages/ai-server/README.md`** — AI service architecture and Docker diff --git a/package.json b/package.json index 6f7d9fe7..e712dfa3 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "build:backend": "bun run build:api-server", "lint": "turbo run lint", "test": "turbo run test", - "ci:local": "bash scripts/git-pre-push.sh" + "ci:local": "bash scripts/git-pre-push.sh", + "deploy:backend": "bash scripts/deploy-backend.sh" }, "devDependencies": { "@types/react": "^19.2.0", diff --git a/packages/ai-server/.dev.env.example b/packages/ai-server/.dev.env.example index 485db270..fab16a89 100644 --- a/packages/ai-server/.dev.env.example +++ b/packages/ai-server/.dev.env.example @@ -1,6 +1,9 @@ -# 로컬 개발 (호스트에서 uv run) + Docker 는 redis/searxng 만 +# 로컬 개발 (호스트에서 uv run) + Docker 는 redis/searxng 만 (scripts/local-deps-up.sh) +# Docker 전체 스택: packages/api-server/docker/stack — Compose 가 아래 URL 들을 덮어씀 +# REDIS_HOST=redis, SEARXNG_API_URL=http://searxng:8080, API_SERVER_HTTP_URL=http://api:8080, API_SERVER_GRPC_HOST=api +# AI gRPC 리슨 포트는 AI_GRPC_LISTEN_PORT 로 지정 가능(기본: dev=50052, 그 외=50051). api 의 AI_SERVER_GRPC_URL 과 포트를 맞출 것. # 사용: cp .dev.env.example .dev.env 후 토큰·키를 채우세요. -# api-server .env.dev 의 GRPC_PORT 와 GRPC_BACKEND_PORT 를 맞추세요. +# api-server .env.dev 의 API_SERVER_GRPC_PORT 와 아래 API_SERVER_GRPC_PORT 를 맞추세요. APP_ENV=dev @@ -17,13 +20,13 @@ REDIS_DB=0 QUEUE_BATCH_SIZE=10 -DECODED_BACKEND_URL=http://localhost:8000 -DECODED_BACKEND_ACCESS_TOKEN=your_backend_token +API_SERVER_HTTP_URL=http://localhost:8000 +API_SERVER_ACCESS_TOKEN=your_backend_token SELENIUM_URL=http://localhost:4444 -GRPC_BACKEND_HOST=localhost -GRPC_BACKEND_PORT=50053 +API_SERVER_GRPC_HOST=localhost +API_SERVER_GRPC_PORT=50053 PERPLEXITY_API_KEY= PERPLEXITY_API_URL=https://api.perplexity.ai diff --git a/packages/ai-server/.env.example b/packages/ai-server/.env.example index 340e0470..2e526ec9 100644 --- a/packages/ai-server/.env.example +++ b/packages/ai-server/.env.example @@ -1,7 +1,7 @@ # AI Server — 예시 (복사: cp .env.example .env) # 필수 필드는 Environment (src/config/_environment.py) 와 이름이 일치해야 합니다. -# APP_ENV=dev 일 때 Queue gRPC 는 main.py 에서 50052 로 바인딩됩니다 (DECODED_AI_GRPC_URL 과 맞출 것). +# APP_ENV=dev 일 때 Queue gRPC 는 main.py 에서 50052 로 바인딩됩니다 (api-server 의 AI_SERVER_GRPC_URL 과 맞출 것). APP_ENV=dev HOST=0.0.0.0 @@ -22,14 +22,14 @@ REDIS_DB=0 QUEUE_BATCH_SIZE=10 # Rust API HTTP -DECODED_BACKEND_URL=http://localhost:8000 -DECODED_BACKEND_ACCESS_TOKEN=your_backend_token +API_SERVER_HTTP_URL=http://localhost:8000 +API_SERVER_ACCESS_TOKEN=your_backend_token SELENIUM_URL=http://localhost:4444 -# Rust API 백엔드 gRPC — api-server .env.dev 의 GRPC_PORT 와 동일 -GRPC_BACKEND_HOST=localhost -GRPC_BACKEND_PORT=50053 +# Rust API 콜백 gRPC — api-server 의 API_SERVER_GRPC_PORT 와 동일 +API_SERVER_GRPC_HOST=localhost +API_SERVER_GRPC_PORT=50053 PERPLEXITY_API_KEY= PERPLEXITY_API_URL=https://api.perplexity.ai diff --git a/packages/ai-server/Dockerfile.ai.prod b/packages/ai-server/Dockerfile.ai.prod index 3b8c62f3..66aaf560 100644 --- a/packages/ai-server/Dockerfile.ai.prod +++ b/packages/ai-server/Dockerfile.ai.prod @@ -4,6 +4,7 @@ FROM python:3.11-slim # 필요한 시스템 패키지 설치 RUN apt-get update && apt-get install -y \ gcc \ + curl \ && rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir uv diff --git a/packages/ai-server/README.md b/packages/ai-server/README.md index ed16886e..4d768269 100644 --- a/packages/ai-server/README.md +++ b/packages/ai-server/README.md @@ -785,7 +785,7 @@ ARQ Worker → 백그라운드 처리 → Backend 콜백 ### 1. 환경 변수 설정 - `.prod.env`, `.dev.env` 등 환경 파일을 준비하세요. -- 예시 변수: `APP_ENV`, `REDIS_PASSWORD`, `DECODED_BACKEND_ACCESS_TOKEN` 등 +- 예시 변수: `APP_ENV`, `REDIS_PASSWORD`, `API_SERVER_ACCESS_TOKEN` 등 ### 2. Docker Compose로 실행 diff --git a/packages/ai-server/src/config/_environment.py b/packages/ai-server/src/config/_environment.py index beead67c..595e67a5 100644 --- a/packages/ai-server/src/config/_environment.py +++ b/packages/ai-server/src/config/_environment.py @@ -11,6 +11,19 @@ logger = logging.getLogger(__name__) +def _apply_legacy_env_aliases() -> None: + """If new env keys are unset, copy from deprecated names (api-server / docs migration).""" + pairs = [ + ("API_SERVER_HTTP_URL", "DECODED_BACKEND_URL"), + ("API_SERVER_ACCESS_TOKEN", "DECODED_BACKEND_ACCESS_TOKEN"), + ("API_SERVER_GRPC_HOST", "GRPC_BACKEND_HOST"), + ("API_SERVER_GRPC_PORT", "GRPC_BACKEND_PORT"), + ] + for new_key, old_key in pairs: + if new_key not in os.environ and old_key in os.environ: + os.environ[new_key] = os.environ[old_key] + + # Environment Settings class Environment(BaseModel): model_config = ConfigDict(extra="allow") @@ -29,13 +42,13 @@ class Environment(BaseModel): QUEUE_BATCH_SIZE: int = 10 - DECODED_BACKEND_URL: str - DECODED_BACKEND_ACCESS_TOKEN: str + API_SERVER_HTTP_URL: str + API_SERVER_ACCESS_TOKEN: str SELENIUM_URL: str - GRPC_BACKEND_HOST: str - GRPC_BACKEND_PORT: int + API_SERVER_GRPC_HOST: str + API_SERVER_GRPC_PORT: int # External API Configuration PERPLEXITY_API_KEY: str = "" @@ -103,6 +116,7 @@ def from_environ(*, env_file: Optional[str] = None): load_dotenv(dev) elif exists(dotenv_path): load_dotenv(dotenv_path) + _apply_legacy_env_aliases() return Environment(**os.environ) @property @@ -131,11 +145,11 @@ def queue_batch_size(self) -> int: @property def backend_url(self) -> str: - return self.DECODED_BACKEND_URL + return self.API_SERVER_HTTP_URL @property def decoded_backend_access_token(self) -> str: - return self.DECODED_BACKEND_ACCESS_TOKEN + return self.API_SERVER_ACCESS_TOKEN @property def llm_host(self) -> str: @@ -153,13 +167,21 @@ def llm_model_name(self) -> str: def selenium_url(self) -> str: return self.SELENIUM_URL + @property + def api_server_grpc_host(self) -> str: + return self.API_SERVER_GRPC_HOST + + @property + def api_server_grpc_port(self) -> int: + return self.API_SERVER_GRPC_PORT + @property def grpc_backend_host(self) -> str: - return self.GRPC_BACKEND_HOST + return self.API_SERVER_GRPC_HOST @property def grpc_backend_port(self) -> int: - return self.GRPC_BACKEND_PORT + return self.API_SERVER_GRPC_PORT # External API Properties @property @@ -216,12 +238,12 @@ def refresh_backend_token(self, token: str): try: set_key( join(getcwd(), ".env"), - "DECODED_BACKEND_ACCESS_TOKEN", + "API_SERVER_ACCESS_TOKEN", token, ) except Exception as e: logger.warning(f"Error updating .env file: {e}") # 중요: 인스턴스 변수 직접 업데이트 - self.DECODED_BACKEND_ACCESS_TOKEN = token - os.environ["DECODED_BACKEND_ACCESS_TOKEN"] = token + self.API_SERVER_ACCESS_TOKEN = token + os.environ["API_SERVER_ACCESS_TOKEN"] = token diff --git a/packages/ai-server/src/config/helpers/_override.py b/packages/ai-server/src/config/helpers/_override.py index d9d5c007..b44a6cf9 100644 --- a/packages/ai-server/src/config/helpers/_override.py +++ b/packages/ai-server/src/config/helpers/_override.py @@ -12,7 +12,7 @@ def use_dev_mock_overrides(application: "Application") -> "Application": # 필요한 필드만 오버라이드 overridden_env = original_env.model_copy( update={ - "DECODED_BACKEND_URL": "http://localhost:8000", + "API_SERVER_HTTP_URL": "http://localhost:8000", "REDIS_HOST": "localhost", "REDIS_PORT": 6300, "REDIS_PASSWORD": "password", @@ -32,7 +32,7 @@ def use_prod_mock_overrides(application: "Application") -> "Application": # 필요한 필드만 오버라이드 overridden_env = original_env.model_copy( update={ - "DECODED_BACKEND_URL": "https://api.decoded.style", + "API_SERVER_HTTP_URL": "https://api.decoded.style", "REDIS_HOST": "localhost", "REDIS_PORT": 6379, "REDIS_PASSWORD": "password", diff --git a/packages/ai-server/src/main.py b/packages/ai-server/src/main.py index 2539c2d5..98229f19 100644 --- a/packages/ai-server/src/main.py +++ b/packages/ai-server/src/main.py @@ -132,10 +132,14 @@ async def main(): except Exception as e: logger.warning(f"Failed to connect backend client: {str(e)}. Will retry on first use.") - # GRPC server configuration + # GRPC server configuration (Docker: set AI_GRPC_LISTEN_PORT to match api's AI_SERVER_GRPC_URL) grpc_host = "0.0.0.0" - grpc_port = 50052 if env == "dev" else 50051 - + grpc_port_env = os.environ.get("AI_GRPC_LISTEN_PORT", "").strip() + if grpc_port_env: + grpc_port = int(grpc_port_env) + else: + grpc_port = 50052 if env == "dev" else 50051 + logger.debug(f"API Server: http://0.0.0.0:10000") logger.info(f"GRPC Server: {grpc_host}:{grpc_port}") diff --git a/packages/api-server/.env.dev.example b/packages/api-server/.env.dev.example index 352e4f52..14e38e8e 100644 --- a/packages/api-server/.env.dev.example +++ b/packages/api-server/.env.dev.example @@ -1,6 +1,6 @@ # 로컬 개발용 템플릿 (호스트에서 API 실행 + Docker 는 deps 만) # 사용: cp .env.dev.example .env.dev 후 비밀 값·Supabase URL 을 채우세요. -# ai-server `.dev.env` 의 GRPC_BACKEND_PORT 는 이 파일의 GRPC_PORT 와 같아야 합니다. +# ai-server `.dev.env` 의 API_SERVER_GRPC_PORT 는 이 파일의 API_SERVER_GRPC_PORT 와 같아야 합니다. ENV=development HOST=0.0.0.0 @@ -15,7 +15,7 @@ DB_MIN_CONNECTIONS=5 DB_CONNECT_TIMEOUT=30 DB_IDLE_TIMEOUT=600 -GRPC_PORT=50053 +API_SERVER_GRPC_PORT=50053 SUPABASE_URL=https://[PROJECT-REF].supabase.co SUPABASE_ANON_KEY= @@ -34,7 +34,7 @@ R2_PUBLIC_URL= RAKUTEN_API_KEY= RAKUTEN_PUBLISHER_ID= -DECODED_AI_GRPC_URL=http://localhost:50052 +AI_SERVER_GRPC_URL=http://localhost:50052 OPENAI_API_KEY= OPENAI_EMBEDDING_MODEL=text-embedding-3-small diff --git a/packages/api-server/.env.example b/packages/api-server/.env.example index 7661a3d6..8cec9ef0 100644 --- a/packages/api-server/.env.example +++ b/packages/api-server/.env.example @@ -15,8 +15,9 @@ DB_MIN_CONNECTIONS=5 DB_CONNECT_TIMEOUT=30 DB_IDLE_TIMEOUT=600 -# gRPC — 이 API가 리스닝하는 백엔드 gRPC 포트. ai-server `.dev.env` 의 GRPC_BACKEND_PORT 와 반드시 동일해야 함. -GRPC_PORT=50053 +# gRPC — API가 AI 콜백용으로 리스닝하는 포트. ai-server 의 API_SERVER_GRPC_PORT 와 반드시 동일해야 함. +# (레거시: GRPC_PORT) +API_SERVER_GRPC_PORT=50053 # Supabase Auth SUPABASE_URL=https://[PROJECT-REF].supabase.co @@ -39,8 +40,9 @@ R2_PUBLIC_URL=https://pub-xxxxx.r2.dev RAKUTEN_API_KEY=your-rakuten-key RAKUTEN_PUBLISHER_ID=your-publisher-id -# AI Queue gRPC — 로컬: ai-server 가 APP_ENV=dev 일 때 Queue 서버는 보통 50052 (packages/ai-server/src/main.py) -DECODED_AI_GRPC_URL=http://localhost:50052 +# AI Queue gRPC (API → ai-server) — 로컬: ai-server 가 APP_ENV=dev 일 때 Queue 는 보통 50052 (packages/ai-server/src/main.py) +# (레거시: DECODED_AI_GRPC_URL) +AI_SERVER_GRPC_URL=http://localhost:50052 # Vector Search (OpenAI Embeddings) OPENAI_API_KEY=sk-... diff --git a/packages/api-server/REQUIREMENT.md b/packages/api-server/REQUIREMENT.md index e6129dd2..7eeef5f2 100644 --- a/packages/api-server/REQUIREMENT.md +++ b/packages/api-server/REQUIREMENT.md @@ -200,7 +200,7 @@ MEILISEARCH_MASTER_KEY=your-master-key GROQ_API_KEY=your-groq-api-key # decoded-ai gRPC 서버 (이미지 분석용) -DECODED_AI_GRPC_URL=http://localhost:50051 +AI_SERVER_GRPC_URL=http://localhost:50051 # Affiliate Links (Rakuten) RAKUTEN_API_KEY=your-rakuten-api-key @@ -1249,7 +1249,7 @@ Content-Type: multipart/form-data - **클라이언트 (decoded-api -> decoded-ai)**: `DecodedAIGrpcClient` (`AnalyzeLink`, `ExtractOGData`) - **서버 (decoded-api <- decoded-ai)**: `BackendGrpcServer` (`ProcessedBatchUpdate` 콜백 수신) - **프로토콜**: `ai.proto` (AI 서비스), `backend.proto` (백엔드 콜백 서비스) -- **설정**: `DECODED_AI_GRPC_URL`, `GRPC_BACKEND_PORT` (기본값: 50052) +- **설정**: `AI_SERVER_GRPC_URL`, `API_SERVER_GRPC_PORT` (ai-server와 동일 값; 레거시 `DECODED_AI_GRPC_URL` / `GRPC_BACKEND_PORT` 지원) - **데이터베이스**: `solutions` 테이블에 `link_type`, `metadata`, `qna`, `keywords` 컬럼 (JSONB) - **LinkMetadata 구조**: `link_type` 필드 추가, `og_*` 필드 제거, `metadata` map으로 타입별 동적 메타데이터 저장 @@ -2721,7 +2721,7 @@ PERPLEXITY_API_KEY=your-perplexity-api-key PERPLEXITY_MODEL=sonar # optional # decoded-ai gRPC 서버 (이미지 분석용) -DECODED_AI_GRPC_URL=http://localhost:50051 +AI_SERVER_GRPC_URL=http://localhost:50051 ``` **주요 특징:** diff --git a/packages/api-server/docker/prod/Dockerfile b/packages/api-server/docker/prod/Dockerfile index 75dd7863..5d792251 100644 --- a/packages/api-server/docker/prod/Dockerfile +++ b/packages/api-server/docker/prod/Dockerfile @@ -8,39 +8,23 @@ RUN apt-get update && apt-get install -y \ libssl-dev \ build-essential \ protobuf-compiler \ + curl \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -# 의존성 캐싱 최적화 -# 먼저 Cargo.toml과 Cargo.lock만 복사 -COPY Cargo.toml Cargo.lock build.rs ./ -COPY entity/Cargo.toml ./entity/ -COPY migration/Cargo.toml ./migration/ -COPY proto ./proto - -# 더미 main.rs로 의존성 빌드 -RUN mkdir -p src entity/src migration/src && \ - echo "fn main() {}" > src/main.rs && \ - echo "fn main() {}" > entity/src/lib.rs && \ - echo "fn main() {}" > migration/src/lib.rs && \ - cargo build --release && \ - rm -rf src entity/src migration/src - -# 실제 소스코드 복사 및 빌드 +# 전체 소스 한 번에 복사 후 release 빌드 (더미 캐시 단계는 migration/entity와 충돌할 수 있어 사용하지 않음) COPY . . -# Release 빌드 (최적화 옵션) RUN cargo build --release && \ strip target/release/decoded-api -# Stage 2: Runtime -FROM debian:bookworm-slim +# Stage 2: Runtime — builder(rust:1.91-slim)와 동일 계열 glibc 필요 (bookworm 런타임은 GLIBC 불일치 가능) +FROM rust:1.91-slim -# 런타임 의존성만 설치 +# 런타임 의존성만 설치 (Rust 이미지는 빌드 도구 포함이므로 프로덕션에서는 distroless 대신 glibc 정합용으로 사용) RUN apt-get update && apt-get install -y \ ca-certificates \ - libssl3 \ curl \ && rm -rf /var/lib/apt/lists/* @@ -52,9 +36,6 @@ RUN useradd -m -u 1001 appuser # 빌드된 바이너리 복사 COPY --from=builder /app/target/release/decoded-api . -# LLM 템플릿 파일 복사 (필요한 경우) -COPY --from=builder /app/src/services/llm/templates ./templates - # 소유권 변경 RUN chown -R appuser:appuser /app diff --git a/packages/api-server/docker/stack/README.md b/packages/api-server/docker/stack/README.md new file mode 100644 index 00000000..ac3c125a --- /dev/null +++ b/packages/api-server/docker/stack/README.md @@ -0,0 +1,48 @@ +# Decoded backend stack (multi-container) + +`api` (Rust), `ai` (Python), `meilisearch`, `redis`, `searxng` — **한 Compose 프로젝트** (`name: decoded-backend`)에서 함께 기동합니다. 서비스 간 주소는 Docker DNS 이름(`api`, `ai`, `meilisearch`, `redis`, `searxng`)을 사용합니다. + +## Build / run (모노레포 루트) + +1. `packages/api-server/.env.dev` + `packages/ai-server/.dev.env` 준비 (staging/prod는 각각 `.env.staging` / `.staging.env`, `.env.prod` / `.prod.env`). +2. 배포 스크립트: + +```bash +./scripts/deploy-backend.sh dev up --build +./scripts/deploy-backend.sh staging up +./scripts/deploy-backend.sh prod down +``` + +## 서비스·포트 (dev 기준) + +| 서비스 | 설명 | 호스트 포트 (dev) | +|--------|------|-------------------| +| `api` | Axum HTTP, Meilisearch 클라이언트, AI gRPC 클라이언트 | `8080` | +| `ai` | FastAPI `:10000`, gRPC 인바운드 `:50051` | `10000` (gRPC는 내부만 `expose`) | +| `meilisearch` | 검색 | `7700` | +| `redis` | AI 큐/캐시 | `6303` → 컨테이너 `6379` | +| `searxng` | 메타데이터 검색 | `4000` → 컨테이너 `8080` | + +Compose `environment`로 덮어쓰는 값: `MEILISEARCH_URL`, `AI_SERVER_GRPC_URL`, `API_SERVER_GRPC_PORT` (Rust gRPC 수신; ai의 `API_SERVER_GRPC_PORT`와 동일해야 함), `REDIS_HOST`, `SEARXNG_API_URL`, `API_SERVER_HTTP_URL`, `API_SERVER_GRPC_HOST`, `AI_GRPC_LISTEN_PORT` 등. 로컬 호스트 전용 `.env`에 `API_SERVER_GRPC_PORT`가 다르면(예: 50053) 컨테이너에서도 compose가 위 값으로 맞춥니다. + +## 로그 (서비스별) + +```bash +./scripts/deploy-backend.sh dev logs -f api +./scripts/deploy-backend.sh dev logs -f ai +``` + +## 수동 compose + +```bash +docker compose --env-file packages/api-server/.env.dev \ + -f packages/api-server/docker/stack/docker-compose.yml up --build +``` + +## Meilisearch 키 + +`deploy-backend.sh`는 API env를 `--env-file`로 넘겨 `${MEILISEARCH_MASTER_KEY}` 보간에 사용합니다. prod는 `packages/api-server/.env.prod`에 키가 있어야 합니다. + +## 환경 변수 이름 + +루트 [CLAUDE.md](../../../../CLAUDE.md) — `AI_SERVER_GRPC_URL`, `API_SERVER_GRPC_PORT`, `AI_GRPC_LISTEN_PORT`(AI gRPC 리슨 포트, compose에서 `50051`로 통일 가능) 등. diff --git a/packages/api-server/docker/stack/docker-compose.prod.yml b/packages/api-server/docker/stack/docker-compose.prod.yml new file mode 100644 index 00000000..b1184a0f --- /dev/null +++ b/packages/api-server/docker/stack/docker-compose.prod.yml @@ -0,0 +1,123 @@ +# Production-style: api + ai + Meilisearch + Redis + SearXNG (Meili/Redis/SearXNG not published to host). +# Env: packages/api-server/.env.prod + packages/ai-server/.prod.env +# From repo root: bash scripts/deploy-backend.sh prod up +# Set MEILISEARCH_MASTER_KEY in packages/api-server/.env.prod (used with deploy-backend.sh --env-file). + +name: decoded-backend-prod + +services: + api: + build: + context: ../.. + dockerfile: docker/prod/Dockerfile + container_name: decoded-backend-api-prod + restart: unless-stopped + ports: + - "8080:8080" + env_file: + - ../../.env.prod + environment: + PORT: "8080" + HOST: "0.0.0.0" + MEILISEARCH_URL: http://meilisearch:7700 + AI_SERVER_GRPC_URL: http://ai:50051 + API_SERVER_GRPC_PORT: "50052" + ENV: production + depends_on: + meilisearch: + condition: service_healthy + networks: + - decoded-backend-prod + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + + ai: + build: + context: ../../../ai-server + dockerfile: Dockerfile.ai.prod + container_name: decoded-backend-ai-prod + restart: unless-stopped + expose: + - "50051" + env_file: + - ../../../ai-server/.prod.env + environment: + REDIS_HOST: redis + REDIS_PORT: "6379" + SEARXNG_API_URL: http://searxng:8080 + API_SERVER_HTTP_URL: http://api:8080 + API_SERVER_GRPC_HOST: api + API_SERVER_GRPC_PORT: "50052" + AI_GRPC_LISTEN_PORT: "50051" + APP_ENV: prod + ENV: production + depends_on: + api: + condition: service_healthy + redis: + condition: service_healthy + searxng: + condition: service_started + networks: + - decoded-backend-prod + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:10000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + + meilisearch: + image: getmeili/meilisearch:v1.11 + container_name: decoded-backend-meilisearch-prod + restart: unless-stopped + volumes: + - meilisearch-data-prod:/meili_data + networks: + - decoded-backend-prod + environment: + MEILI_ENV: production + MEILI_MASTER_KEY: ${MEILISEARCH_MASTER_KEY:?set MEILISEARCH_MASTER_KEY in packages/api-server/.env.prod (used with deploy-backend.sh --env-file)} + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:7700/health"] + interval: 10s + timeout: 5s + retries: 3 + + redis: + image: redis:7-alpine + container_name: decoded-backend-redis-prod + restart: unless-stopped + networks: + - decoded-backend-prod + env_file: + - ../../../ai-server/.prod.env + command: + - /bin/sh + - -c + - redis-server --requirepass "$${REDIS_PASSWORD}" --maxmemory 512mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 3 + + searxng: + image: docker.io/searxng/searxng:latest + container_name: decoded-backend-searxng-prod + restart: unless-stopped + volumes: + - ../../../ai-server/searxng:/etc/searxng:rw + networks: + - decoded-backend-prod + +networks: + decoded-backend-prod: + driver: bridge + +volumes: + meilisearch-data-prod: diff --git a/packages/api-server/docker/stack/docker-compose.staging.yml b/packages/api-server/docker/stack/docker-compose.staging.yml new file mode 100644 index 00000000..31bb0dbc --- /dev/null +++ b/packages/api-server/docker/stack/docker-compose.staging.yml @@ -0,0 +1,128 @@ +# Staging: api + ai + Meilisearch + Redis + SearXNG +# Env: packages/api-server/.env.staging + packages/ai-server/.staging.env +# From repo root: bash scripts/deploy-backend.sh staging up + +name: decoded-backend-staging + +services: + api: + build: + context: ../.. + dockerfile: docker/prod/Dockerfile + container_name: decoded-backend-api-staging + restart: unless-stopped + ports: + - "8081:8080" + env_file: + - ../../.env.staging + environment: + PORT: "8080" + HOST: "0.0.0.0" + MEILISEARCH_URL: http://meilisearch:7700 + AI_SERVER_GRPC_URL: http://ai:50051 + API_SERVER_GRPC_PORT: "50052" + ENV: staging + depends_on: + meilisearch: + condition: service_healthy + networks: + - decoded-backend-staging + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + + ai: + build: + context: ../../../ai-server + dockerfile: Dockerfile.ai.prod + container_name: decoded-backend-ai-staging + restart: unless-stopped + expose: + - "50051" + env_file: + - ../../../ai-server/.staging.env + environment: + REDIS_HOST: redis + REDIS_PORT: "6379" + SEARXNG_API_URL: http://searxng:8080 + API_SERVER_HTTP_URL: http://api:8080 + API_SERVER_GRPC_HOST: api + API_SERVER_GRPC_PORT: "50052" + AI_GRPC_LISTEN_PORT: "50051" + APP_ENV: prod + ENV: staging + depends_on: + api: + condition: service_healthy + redis: + condition: service_healthy + searxng: + condition: service_started + networks: + - decoded-backend-staging + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:10000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + + meilisearch: + image: getmeili/meilisearch:v1.11 + container_name: decoded-backend-meilisearch-staging + restart: unless-stopped + ports: + - "7701:7700" + volumes: + - meilisearch-data-staging:/meili_data + networks: + - decoded-backend-staging + environment: + MEILI_ENV: development + MEILI_MASTER_KEY: ${MEILISEARCH_MASTER_KEY:-staging-meili-key} + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:7700/health"] + interval: 10s + timeout: 5s + retries: 3 + + redis: + image: redis:7-alpine + container_name: decoded-backend-redis-staging + restart: unless-stopped + ports: + - "6304:6379" + networks: + - decoded-backend-staging + env_file: + - ../../../ai-server/.staging.env + command: + - /bin/sh + - -c + - redis-server --requirepass "$${REDIS_PASSWORD}" --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 3 + + searxng: + image: docker.io/searxng/searxng:latest + container_name: decoded-backend-searxng-staging + restart: unless-stopped + ports: + - "4001:8080" + volumes: + - ../../../ai-server/searxng:/etc/searxng:rw + networks: + - decoded-backend-staging + +networks: + decoded-backend-staging: + driver: bridge + +volumes: + meilisearch-data-staging: diff --git a/packages/api-server/docker/stack/docker-compose.yml b/packages/api-server/docker/stack/docker-compose.yml new file mode 100644 index 00000000..9c2c5e16 --- /dev/null +++ b/packages/api-server/docker/stack/docker-compose.yml @@ -0,0 +1,128 @@ +# Decoded backend: api + ai + Meilisearch + Redis + SearXNG (multi-container). +# From repo root: bash scripts/deploy-backend.sh dev up --build +# Requires packages/api-server/.env.dev + packages/ai-server/.dev.env + +name: decoded-backend + +services: + api: + build: + context: ../.. + dockerfile: docker/prod/Dockerfile + container_name: decoded-backend-api-dev + restart: unless-stopped + ports: + - "8080:8080" + env_file: + - ../../.env.dev + environment: + PORT: "8080" + HOST: "0.0.0.0" + MEILISEARCH_URL: http://meilisearch:7700 + AI_SERVER_GRPC_URL: http://ai:50051 + API_SERVER_GRPC_PORT: "50052" + depends_on: + meilisearch: + condition: service_healthy + networks: + - decoded-backend + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + + ai: + build: + context: ../../../ai-server + dockerfile: Dockerfile.ai.prod + container_name: decoded-backend-ai-dev + restart: unless-stopped + ports: + - "10000:10000" + expose: + - "50051" + env_file: + - ../../../ai-server/.dev.env + environment: + REDIS_HOST: redis + REDIS_PORT: "6379" + SEARXNG_API_URL: http://searxng:8080 + API_SERVER_HTTP_URL: http://api:8080 + API_SERVER_GRPC_HOST: api + API_SERVER_GRPC_PORT: "50052" + AI_GRPC_LISTEN_PORT: "50051" + APP_ENV: prod + depends_on: + api: + condition: service_healthy + redis: + condition: service_healthy + searxng: + condition: service_started + networks: + - decoded-backend + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:10000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + + meilisearch: + image: getmeili/meilisearch:v1.11 + container_name: decoded-backend-meilisearch-dev + restart: unless-stopped + ports: + - "7700:7700" + volumes: + - meilisearch-data-dev:/meili_data + networks: + - decoded-backend + environment: + MEILI_ENV: development + MEILI_MASTER_KEY: ${MEILISEARCH_MASTER_KEY:-dev-master-key} + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:7700/health"] + interval: 10s + timeout: 5s + retries: 3 + + redis: + image: redis:7-alpine + container_name: decoded-backend-redis-dev + restart: unless-stopped + ports: + - "6303:6379" + networks: + - decoded-backend + env_file: + - ../../../ai-server/.dev.env + command: + - /bin/sh + - -c + - redis-server --requirepass "$${REDIS_PASSWORD}" --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 3 + + searxng: + image: docker.io/searxng/searxng:latest + container_name: decoded-backend-searxng-dev + restart: unless-stopped + ports: + - "4000:8080" + volumes: + - ../../../ai-server/searxng:/etc/searxng:rw + networks: + - decoded-backend + +networks: + decoded-backend: + driver: bridge + +volumes: + meilisearch-data-dev: diff --git a/packages/api-server/src/bin/decoded_ai_grpc_test.rs b/packages/api-server/src/bin/decoded_ai_grpc_test.rs index c891b7cc..01bbcac3 100644 --- a/packages/api-server/src/bin/decoded_ai_grpc_test.rs +++ b/packages/api-server/src/bin/decoded_ai_grpc_test.rs @@ -8,7 +8,9 @@ async fn main() -> Result<(), Box> { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) .init(); - let url = "http://127.0.0.1:50052".to_string(); + let url = std::env::var("AI_SERVER_GRPC_URL") + .or_else(|_| std::env::var("DECODED_AI_GRPC_URL")) + .unwrap_or_else(|_| "http://127.0.0.1:50052".to_string()); let client = DecodedAIGrpcClient::new(url)?; let urls = [ diff --git a/packages/api-server/src/config.rs b/packages/api-server/src/config.rs index 4c44a249..5196f02f 100644 --- a/packages/api-server/src/config.rs +++ b/packages/api-server/src/config.rs @@ -9,6 +9,13 @@ use crate::services::{ SearchClient, StorageClient, }; +/// Reads `primary` env, then legacy alias if unset (migration from older names). +fn env_primary_or_legacy(primary: &str, legacy: &str) -> Option { + std::env::var(primary) + .ok() + .or_else(|| std::env::var(legacy).ok()) +} + /// 애플리케이션 설정 #[derive(Debug, Clone)] pub struct AppConfig { @@ -111,8 +118,8 @@ impl AppConfig { port: std::env::var("PORT") .unwrap_or_else(|_| "8000".to_string()) .parse()?, - grpc_port: std::env::var("GRPC_PORT") - .unwrap_or_else(|_| "50052".to_string()) + grpc_port: env_primary_or_legacy("API_SERVER_GRPC_PORT", "GRPC_PORT") + .unwrap_or_else(|| "50052".to_string()) .parse()?, rust_log: std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()), log_format: { @@ -181,12 +188,12 @@ impl AppConfig { .unwrap_or_else(|_| String::new()), }, ai_service: AiServiceConfig { - url: std::env::var("DECODED_AI_GRPC_URL") - .unwrap_or_else(|_| "http://localhost:50051".to_string()), + url: env_primary_or_legacy("AI_SERVER_GRPC_URL", "DECODED_AI_GRPC_URL") + .unwrap_or_else(|| "http://localhost:50051".to_string()), }, agent_service: AgentServiceConfig { - url: std::env::var("DECODED_AGENT_URL") - .unwrap_or_else(|_| "http://localhost:11000".to_string()), + url: env_primary_or_legacy("AGENT_SERVICE_URL", "DECODED_AGENT_URL") + .unwrap_or_else(|| "http://localhost:11000".to_string()), }, embedding: EmbeddingConfig { openai_api_key: std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| String::new()), diff --git a/scripts/deploy-backend.sh b/scripts/deploy-backend.sh new file mode 100755 index 00000000..a34a8b9b --- /dev/null +++ b/scripts/deploy-backend.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Decoded backend Docker 배포 (api + ai + meili + redis + searxng) — dev / staging / prod +# +# 사용 (모노레포 루트에서): +# bash scripts/deploy-backend.sh dev up +# bash scripts/deploy-backend.sh staging up --build +# bash scripts/deploy-backend.sh prod down +# bash scripts/deploy-backend.sh dev logs +# bash scripts/deploy-backend.sh prod ps +# +# 액션: up | down | build | pull | ps | logs | restart | config +# 기본 액션: up -d +# +# 필수 env 파일: +# dev: packages/api-server/.env.dev + packages/ai-server/.dev.env +# staging: packages/api-server/.env.staging + packages/ai-server/.staging.env +# prod: packages/api-server/.env.prod + packages/ai-server/.prod.env +# +# Meilisearch: compose의 ${MEILISEARCH_MASTER_KEY}는 API env 파일로 보간됨 (--env-file). +# prod는 .env.prod에 MEILISEARCH_MASTER_KEY 필수(:?). + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +STACK="$ROOT/packages/api-server/docker/stack" + +usage() { + echo "Usage: $0 [up|down|build|pull|ps|logs|restart|config] [extra docker compose args...]" >&2 + echo "Examples: $0 dev up --build $0 staging logs -f $0 prod restart" >&2 + exit 1 +} + +ENV="${1:-}" +shift || true +ACTION="${1:-up}" +[[ -n "$ENV" ]] || usage +if [[ "$ACTION" =~ ^(up|down|build|pull|ps|logs|restart|config)$ ]]; then + shift || true +else + ACTION="up" +fi +EXTRA=("$@") + +case "$ENV" in + dev) + COMPOSE="$STACK/docker-compose.yml" + API_ENV="$ROOT/packages/api-server/.env.dev" + AI_ENV="$ROOT/packages/ai-server/.dev.env" + ;; + staging) + COMPOSE="$STACK/docker-compose.staging.yml" + API_ENV="$ROOT/packages/api-server/.env.staging" + AI_ENV="$ROOT/packages/ai-server/.staging.env" + ;; + prod) + COMPOSE="$STACK/docker-compose.prod.yml" + API_ENV="$ROOT/packages/api-server/.env.prod" + AI_ENV="$ROOT/packages/ai-server/.prod.env" + ;; + *) + usage + ;; +esac + +require_env_files() { + local missing=0 + if [[ ! -f "$API_ENV" ]]; then + echo "Missing: $API_ENV (copy from packages/api-server/.env.dev.example or sibling)" >&2 + missing=1 + fi + if [[ ! -f "$AI_ENV" ]]; then + echo "Missing: $AI_ENV (copy from packages/ai-server/.dev.env.example or sibling)" >&2 + missing=1 + fi + if [[ "$missing" -ne 0 ]]; then + exit 1 + fi +} + +compose() { + # --env-file: ${MEILISEARCH_MASTER_KEY} 등 compose 파일 내 보간용 (컨테이너 전체에 노출되지 않음) + # set -u + 빈 EXTRA[@]는 일부 bash(예: macOS 3.2)에서 실패하므로 배열로 합쳐서 실행 + local -a cmd + if [[ -f "$API_ENV" ]]; then + cmd=(docker compose --env-file "$API_ENV" -f "$COMPOSE" "$@") + else + cmd=(docker compose -f "$COMPOSE" "$@") + fi + if (( ${#EXTRA[@]} > 0 )); then + cmd+=("${EXTRA[@]}") + fi + "${cmd[@]}" +} + +cd "$ROOT" + +case "$ACTION" in + up) + require_env_files + compose up -d + echo "OK ($ENV): docker compose -f ${COMPOSE#$ROOT/} up -d" + ;; + down) + compose down + echo "OK ($ENV): stack stopped" + ;; + build) + require_env_files + compose build + ;; + pull) + compose pull + ;; + ps) + compose ps + ;; + logs) + compose logs + ;; + restart) + compose restart + ;; + config) + require_env_files + compose config + ;; + *) + echo "Unknown action: $ACTION" >&2 + usage + ;; +esac diff --git a/scripts/local-deps-down.sh b/scripts/local-deps-down.sh index 1dbbaeb4..af8ba071 100644 --- a/scripts/local-deps-down.sh +++ b/scripts/local-deps-down.sh @@ -2,6 +2,6 @@ # local-deps-up 으로 올린 의존 컨테이너만 중지 (제거는 하지 않음). set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -docker compose -f "$ROOT/packages/api-server/docker/dev/docker-compose.yml" stop meilisearch 2>/dev/null || true -docker compose -f "$ROOT/packages/ai-server/docker-compose-ai-dev.yml" stop redis-server searxng 2>/dev/null || true -echo "Stopped: meilisearch, redis-server, searxng (if they were running)." +STACK_COMPOSE="$ROOT/packages/api-server/docker/stack/docker-compose.yml" +docker compose -f "$STACK_COMPOSE" stop meilisearch redis searxng 2>/dev/null || true +echo "Stopped: meilisearch, redis, searxng (if they were running)." diff --git a/scripts/local-deps-up.sh b/scripts/local-deps-up.sh index 4ba2912e..f4c43fc4 100644 --- a/scripts/local-deps-up.sh +++ b/scripts/local-deps-up.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash -# Meilisearch(api-server) + Redis·SearXNG(ai-server)만 Docker로 기동. 앱 런타임은 로컬. +# Meilisearch + Redis + SearXNG 만 Docker로 기동 (전체 스택과 동일한 compose, 서비스만 부분 기동). 앱 런타임은 로컬. +# 전체 백엔드 컨테이너: scripts/deploy-backend.sh dev up set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -docker compose -f "$ROOT/packages/api-server/docker/dev/docker-compose.yml" up -d meilisearch -docker compose -f "$ROOT/packages/ai-server/docker-compose-ai-dev.yml" up -d redis-server searxng -echo "OK: Meilisearch (api dev compose), redis-server + searxng (ai-dev compose). Ports: see those YAML files / .env (e.g. 7700, 6303, 4000)." +STACK_COMPOSE="$ROOT/packages/api-server/docker/stack/docker-compose.yml" +docker compose -f "$STACK_COMPOSE" up -d meilisearch redis searxng +echo "OK: meilisearch + redis + searxng (packages/api-server/docker/stack/docker-compose.yml). Ports e.g. 7700, 6303, 4000 — see stack README."