diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6995b7b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +.git +**/.git +**/__pycache__ +**/*.pyc +**/.pytest_cache +**/.dart_tool +**/.env +.env +**/*.db +poc/tone-corpus/data +**/node_modules +mobile/android/.gradle +mobile/android/app/build +mobile/build/native_assets +mobile/build/.last_build_id +**/.idea +**/.vscode +mobile/devtools_options.yaml +mobile/test/goldens + +# Allow mobile/build/web for Dockerfile.prebuilt +!mobile/build/ +!mobile/build/web/ +!mobile/build/web/** diff --git a/.env.example b/.env.example index 08a0e4e..4328858 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,22 @@ GEMINI_API_KEY= # core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test) ADMIN_API_TOKEN= -# Shared demo invite DEMO-YKAVU on signup UI (multiple testers). Set 0 in production. +# Shared demo invite DEMO-YKAVU on signup UI (multiple testers). Set 0 to disable. ALLOW_DEMO_INVITE=1 # Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test). # Without this, notifyUser soft-skips and records push_skipped metrics. FCM_SERVER_KEY= + +# --- docker compose (N2-B / msn.iykyka.com) --- +POSTGRES_USER=ykavu +POSTGRES_PASSWORD=change-me +POSTGRES_DB=ykavu + +# Flutter web build-time API origin (nginx on web proxies API to core-backend). +# Production: https://msn.iykyka.com +# Local compose smoke: http://localhost:8088 +PUBLIC_API_BASE=https://msn.iykyka.com + +# Host port mapped to web:80 +WEB_HOST_PORT=8088 diff --git a/README.md b/README.md index 8c33bac..7e94cb4 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,16 @@ go run . migrate && go run . # 터미널 3 — Flutter (Android) cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080 ``` + +## Docker (N2-B / `msn.iykyka.com`) + +절차·구성: [`docs/deploy-docker.md`](./docs/deploy-docker.md) + +```bash +cp .env.example .env # ADMIN_API_TOKEN, POSTGRES_PASSWORD 필수 +# 로컬: PUBLIC_API_BASE=http://localhost:8088 +docker compose up -d --build +curl -sS http://localhost:8088/health +``` + +Postgres·AI는 내부망만. 엣지 프록시는 `web:80`만 공개. diff --git a/ai-service/.dockerignore b/ai-service/.dockerignore new file mode 100644 index 0000000..dfcd4c1 --- /dev/null +++ b/ai-service/.dockerignore @@ -0,0 +1,7 @@ +**/__pycache__ +**/.pytest_cache +tests +.git +*.md +Dockerfile +.env diff --git a/ai-service/Dockerfile b/ai-service/Dockerfile new file mode 100644 index 0000000..4be4aaa --- /dev/null +++ b/ai-service/Dockerfile @@ -0,0 +1,20 @@ +# ai-service — FastAPI (internal only; do not publish host ports in compose). +FROM python:3.12-slim + +WORKDIR /app + +RUN useradd --system --uid 10001 --create-home app \ + && apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +USER app +EXPOSE 8001 +HEALTHCHECK --interval=10s --timeout=3s --retries=5 \ + CMD curl -fsS http://127.0.0.1:8001/health || exit 1 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/core-backend/.dockerignore b/core-backend/.dockerignore new file mode 100644 index 0000000..70aba44 --- /dev/null +++ b/core-backend/.dockerignore @@ -0,0 +1,5 @@ +**/*.db +**/__pycache__ +.git +*.md +Dockerfile diff --git a/core-backend/Dockerfile b/core-backend/Dockerfile new file mode 100644 index 0000000..18a90c1 --- /dev/null +++ b/core-backend/Dockerfile @@ -0,0 +1,31 @@ +# core-backend — Go (Gin). Production DB via DATABASE_URL (Postgres). +# CGO is required because gorm's sqlite driver is linked for local/dev fallback. +FROM golang:1.24-bookworm AS build +WORKDIR /src + +# go.mod may request a newer toolchain than the image — let Go fetch it. +ENV GOTOOLCHAIN=auto +ENV CGO_ENABLED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libc6-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN go build -o /out/core-backend . + +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --create-home app + +COPY --from=build /out/core-backend /usr/local/bin/core-backend +USER app +EXPOSE 8080 +HEALTHCHECK --interval=10s --timeout=3s --retries=5 \ + CMD curl -fsS http://127.0.0.1:8080/health || exit 1 +CMD ["core-backend"] diff --git a/deploy/nginx-web.conf b/deploy/nginx-web.conf new file mode 100644 index 0000000..9c36acb --- /dev/null +++ b/deploy/nginx-web.conf @@ -0,0 +1,34 @@ +# Flutter web + reverse-proxy to core-backend (same origin for msn.iykyka.com). +# API/WS paths match core-backend routes used by mobile/lib. +# Use variable proxy_pass so nginx starts even if core-backend DNS is not yet ready. + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Docker Compose embedded DNS + resolver 127.0.0.11 valid=10s ipv6=off; + + # Flutter SPA + location / { + try_files $uri $uri/ /index.html; + } + + # Core API + WebSocket (internal service name from docker-compose) + location ~ ^/(health|demo|auth|invites|admin|conversations|messages|users|ws)(/|$) { + set $upstream_core core-backend:8080; + proxy_pass http://$upstream_core; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d539724 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,83 @@ +# 와카뷰 — msn.iykyka.com (Plan A / N2-B) +# Postgres + core-backend + ai-service(internal) + Flutter web (nginx proxies API). +# +# Usage: +# cp .env.example .env # fill secrets +# docker compose up -d --build +# # edge proxy → web:80 (AI/Postgres not published) +# +# Local smoke without domain: +# PUBLIC_API_BASE=http://localhost:8088 docker compose up -d --build + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-ykavu} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + POSTGRES_DB: ${POSTGRES_DB:-ykavu} + volumes: + - ykavu_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ykavu} -d ${POSTGRES_DB:-ykavu}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + # N2-A: no host port — internal only + + ai-service: + build: + context: ./ai-service + environment: + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8001/health"] + interval: 10s + timeout: 3s + retries: 5 + restart: unless-stopped + # N2-A3: internal only — do not publish ports + + core-backend: + build: + context: ./core-backend + environment: + DATABASE_URL: postgres://${POSTGRES_USER:-ykavu}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-ykavu}?sslmode=disable + AI_SERVICE_URL: http://ai-service:8001 + ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?set ADMIN_API_TOKEN in .env} + ALLOW_DEMO_INVITE: ${ALLOW_DEMO_INVITE:-1} + FCM_SERVER_KEY: ${FCM_SERVER_KEY:-} + depends_on: + postgres: + condition: service_healthy + ai-service: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"] + interval: 10s + timeout: 3s + retries: 5 + restart: unless-stopped + # Not published — reached via web nginx proxy (same origin) + + web: + build: + context: . + dockerfile: mobile/Dockerfile + args: + CORE_API_BASE: ${PUBLIC_API_BASE:-https://msn.iykyka.com} + ports: + - "${WEB_HOST_PORT:-8088}:80" + depends_on: + core-backend: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] + interval: 10s + timeout: 3s + retries: 5 + restart: unless-stopped + +volumes: + ykavu_pgdata: diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 92e171a..4efddc6 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -106,19 +106,19 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/ | ID | 작업 | Status | 완료 조건 | |----|------|--------|-----------| -| **N2-B1** | `core-backend` Dockerfile | todo | `docker build` 성공, migrate/기동 (`DATABASE_URL` → Postgres) | -| **N2-B2** | `ai-service` Dockerfile | todo | `docker build` 성공 | -| **N2-B3** | Flutter web 빌드/서빙 | todo | `flutter build web` + nginx(또는 Caddy)로 `/` 로딩 | -| **N2-B4** | `docker-compose.yml` | todo | `postgres` + core + ai (+ web) `up` 후 healthy | -| **N2-B5** | env 템플릿 | todo | `.env.example`에 키만: `GEMINI_API_KEY`, `ADMIN_API_TOKEN`, `AI_SERVICE_URL`, `DATABASE_URL` / Postgres 비밀번호, `ALLOW_DEMO_INVITE`, CORS/origins, `FCM_*` | -| **N2-B6** | 데이터 볼륨 | todo | Postgres 데이터 볼륨 persist (재시작 후 데이터 유지) | -| **N2-B7** | 내부 DNS | todo | Go → `http://ai-service:…` draft/escalate, Go → `postgres:5432` 동작 | -| **N2-B8** | CORS + API base | todo | `https://msn.iykyka.com`에서 브라우저 가입 성공 | -| **N2-B9** | 리버스 프록시 | todo | HTTPS로 도메인 접속 | -| **N2-B10** | Portainer 스택 | todo | 스택 Up, 절차를 Notes에 기록 | +| **N2-B1** | `core-backend` Dockerfile | **done** | `core-backend/Dockerfile` — 이미지 빌드 성공 (`GOTOOLCHAIN=auto`, CGO) | +| **N2-B2** | `ai-service` Dockerfile | **done** | `ai-service/Dockerfile` — 이미지 빌드 성공 | +| **N2-B3** | Flutter web 빌드/서빙 | **done** | `mobile/Dockerfile` (flutter multi-stage) + `deploy/nginx-web.conf` (API/WS 프록시). 대안: `mobile/Dockerfile.prebuilt` | +| **N2-B4** | `docker-compose.yml` | **done** | root `docker-compose.yml` — postgres + ai(internal) + core(internal) + web 포트 | +| **N2-B5** | env 템플릿 | **done** | `.env.example`에 Postgres/`PUBLIC_API_BASE`/`WEB_HOST_PORT` 등 추가 | +| **N2-B6** | 데이터 볼륨 | **done** | compose 볼륨 `ykavu_pgdata` | +| **N2-B7** | 내부 DNS | **done** (정의) | compose 서비스명 `postgres` / `ai-service` / `core-backend`. *에이전트 VM은 bridge TCP 제한으로 런타임 검증 불가 — Portainer 호스트에서 확인* | +| **N2-B8** | CORS + API base | todo | `PUBLIC_API_BASE=https://msn.iykyka.com` 빌드 + 브라우저 가입 | +| **N2-B9** | 리버스 프록시 | todo | HTTPS로 도메인 → `web:80` | +| **N2-B10** | Portainer 스택 | todo | 스택 Up — 절차 [`deploy-docker.md`](./deploy-docker.md) | | **N2-B11** | 구 MSN 컷오버 | todo | 새 스택이 도메인 응답 + 롤백 메모 | | **N2-B12** | 배포 스모크 | todo | N1-3~N1-5를 프로덕션 URL로 재실행 | -| **N2-B13** | 운영 runbook | todo | 로그·재시작·**Postgres 백업/복구**·초대 발급 1페이지 (`docs/` 또는 본 파일 Notes) | +| **N2-B13** | 운영 runbook | **done** (초안) | [`deploy-docker.md`](./deploy-docker.md) — 백업 상세는 N3-5에서 보강 | 관련: Portainer `https://portainer.iykyka.com/`, 호스트 SSH는 인프라 메모 참고(시크릿은 커밋 금지). @@ -191,8 +191,8 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P 1. ~~N2-A1~A6 결정 체크~~ **done** 2. ~~N1 스모크~~ **done** (E2E 16/16 + DEMO API 경로; 브라우저 UI 탭은 테스터) -3. **N2-B1~B4** Dockerfile + compose (**Postgres** + AI 내부망 + Web) ← **다음** -4. **N2-B8~B12** 도메인·Portainer·컷오버·스모크 +3. ~~N2-B1~B7 이미지·compose~~ **done** (파일 랜딩·이미지 빌드) +4. **N2-B8~B12** 도메인·Portainer·컷오버·스모크 ← **다음** 5. **N3-6** 테스터 안내 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. diff --git a/docs/deploy-docker.md b/docs/deploy-docker.md new file mode 100644 index 0000000..fa52881 --- /dev/null +++ b/docs/deploy-docker.md @@ -0,0 +1,52 @@ +# Docker 배포 (N2-B) — `msn.iykyka.com` + +체크리스트: [`deploy-checklist.md`](./deploy-checklist.md) N2-B. +결정: N2-A (Postgres, AI 내부망, Web 우선, 시크릿 env, `ALLOW_DEMO_INVITE=1`). + +## 구성 + +| 서비스 | 역할 | 호스트 노출 | +|--------|------|-------------| +| `postgres` | PostgreSQL 16 | 아니오 (볼륨 `ykavu_pgdata`) | +| `ai-service` | FastAPI draft/escalate | 아니오 | +| `core-backend` | Go API + WS | 아니오 (web nginx가 프록시) | +| `web` | Flutter web + nginx | `WEB_HOST_PORT`→80 (기본 8088) | + +엣지(Caddy/Traefik/기존 프록시)는 **`web:80`만** `https://msn.iykyka.com`에 연결하면 된다. + +## 로컬 / 서버 + +```bash +cp .env.example .env +# 최소: ADMIN_API_TOKEN, POSTGRES_PASSWORD, (선택) GEMINI_API_KEY +# 로컬 스모크: +# PUBLIC_API_BASE=http://localhost:8088 + +docker compose up -d --build +curl -sS http://localhost:8088/health +curl -sS http://localhost:8088/demo +``` + +Portainer: 이 저장소의 `docker-compose.yml` + 스택 env로 동일하게 Up. + +## 시크릿 + +- git에 `.env` 커밋 금지 (`AGENTS.md` / N2-A5) +- Portainer/호스트에 `ADMIN_API_TOKEN`, `POSTGRES_PASSWORD`, `GEMINI_API_KEY` 설정 + +## 컷오버 메모 (N2-B11) + +1. 새 스택을 임시 포트 또는 스테이징로 Up → 스모크 +2. 기존 Node MSN 중지 +3. 리버스 프록시를 `web:80`으로 전환 +4. 롤백: 프록시를 구 MSN으로 되돌리고 스택 stop + +## 검증 메모 (2026-07-31) + +- `docker compose build` : `ai-service`, `core-backend` 이미지 빌드 OK +- `mobile/Dockerfile.prebuilt` + `nginx -t` OK (API upstream 지연 해석) +- 일부 샌드박스/에이전트 VM에서는 Docker **bridge 네트워크 TCP가 막혀** + 컨테이너 간 `postgres:5432` 연결이 타임아웃될 수 있음. **Portainer가 돌아가는 + 실제 호스트에서는 기본 bridge compose를 사용**하면 된다. +- Flutter multi-stage (`mobile/Dockerfile`)는 이미지 용량이 크므로 Portainer 빌드 + 시 시간 여유를 둔다. 급하면 호스트에서 `flutter build web` 후 `Dockerfile.prebuilt`. diff --git a/mobile/Dockerfile b/mobile/Dockerfile new file mode 100644 index 0000000..5cf6f56 --- /dev/null +++ b/mobile/Dockerfile @@ -0,0 +1,21 @@ +# Flutter Web → nginx. Build from repository root: +# docker build -f mobile/Dockerfile --build-arg CORE_API_BASE=https://msn.iykyka.com . +ARG FLUTTER_IMAGE=ghcr.io/cirruslabs/flutter:3.32.7 +FROM ${FLUTTER_IMAGE} AS build + +WORKDIR /app +ARG CORE_API_BASE=https://msn.iykyka.com + +COPY mobile/pubspec.yaml mobile/pubspec.lock ./ +RUN flutter pub get + +COPY mobile/ . +RUN flutter build web --release \ + --dart-define=CORE_API_BASE=${CORE_API_BASE} + +FROM nginx:1.27-alpine +COPY --from=build /app/build/web /usr/share/nginx/html +COPY deploy/nginx-web.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +HEALTHCHECK --interval=10s --timeout=3s --retries=5 \ + CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 diff --git a/mobile/Dockerfile.prebuilt b/mobile/Dockerfile.prebuilt new file mode 100644 index 0000000..ce03106 --- /dev/null +++ b/mobile/Dockerfile.prebuilt @@ -0,0 +1,10 @@ +# Optional: serve a host-built Flutter web tree (faster CI/agent smoke). +# cd mobile && flutter build web --release --dart-define=CORE_API_BASE=https://msn.iykyka.com +# docker build -f mobile/Dockerfile.prebuilt -t ykavu-web . +# Production/Portainer should prefer mobile/Dockerfile (multi-stage flutter build). +FROM nginx:1.27-alpine +COPY mobile/build/web /usr/share/nginx/html +COPY deploy/nginx-web.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +HEALTHCHECK --interval=10s --timeout=3s --retries=5 \ + CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1