# ★ 프로젝트 이름을 여기 못 박는다. 안 적으면 compose 가 **디렉터리 이름**을 프로젝트 이름으로 # 쓰는데, 그러면 폴더를 옮기거나 이름을 바꾸는 순간 컨테이너와 볼륨이 통째로 새로 생긴다 # (`o2o-site_site-out` → `o2o-web4ai_site-out`). 이름을 고정하면 폴더가 어디에 있든 같은 것을 본다. name: o2o-web4ai # o2o-web4ai 백엔드 (API + 작업 큐 워커). # # DB 는 compose 에서 관리하지 않는다 — 컨테이너는 host.docker.internal 로 호스트의 PostgreSQL 에 붙는다 # (로컬 dev 는 negosium-db 컨테이너가 5432 를 열어두고 있다). 접속값은 config.local.toml 을 # compose 의 DB_* env 가 덮어쓴다. # # docker compose up -d # API: http://localhost:9800/docs # 워커: 포트 없음 — docker compose logs -f o2o-web4ai-worker 로 확인 # # DB 준비(최초 1회): # docker exec -i -e PGPASSWORD=password negosium-db \ # psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 < postgres-init/init-data/init.sql # (기존 DB 보정은 postgres-init/alters/*.sql 을 날짜순으로 적용) # # 외부 API 키(PERPLEXITY/KAKAO/GEMINI/TOUR)는 레포 최상위 .env 에서 읽는다 — cp .env.example .env 후 채운다. # ※ 컨테이너 안에서는 .env 파일을 직접 읽지 못한다(레포 루트가 이미지 밖). compose 의 env_file 이 # 실제 환경변수로 주입하고, server_configs 의 env override 가 toml 값을 덮어쓴다. # API 와 워커가 공유하는 환경변수. 둘은 **같은 DB 를 본다** — 한쪽만 다른 DB 를 가리키면 # API 가 넣은 잡을 워커가 영영 못 본다. 그래서 한 곳에서 정의하고 양쪽이 가져다 쓴다. # # ★ SCHEDULER_ENABLED 는 여기 두지 않는다. 서비스마다 값이 달라야 하는 유일한 항목이라 # 공용 블록에 섞으면 실수로 워커에서도 크론이 도는 사고가 난다. x-common-env: &common-env APP_ENV: local PYTHONUNBUFFERED: "1" # 컨테이너 로그 실시간 출력(stdout 버퍼링 끔) # ── DB 접속 ── # 이미지 안 config.local.toml 은 example 사본(플레이스홀더)이라 값이 비어 있다. # 아래 DB_* 가 없으면 `` 로 접속을 시도하다 실패한다 — 반드시 주입해야 한다. # 기본값은 로컬 dev(negosium-db 컨테이너, postgres/password). 배포는 .env 나 셸 env 로 덮는다. DB_HOST: ${DB_HOST:-host.docker.internal} # 컨테이너→호스트 DB (toml 의 127.0.0.1 override) DB_PORT: ${DB_PORT:-5432} DB_USER: ${DB_USER:-postgres} DB_PASSWORD: ${DB_PASSWORD:-password} DB_NAME: ${DB_NAME:-o2o_site_db} # ── JWT 서명 키 ── # ★ DB 와 같은 이유로 반드시 주입해야 한다. 이미지의 config.local.toml 은 플레이스홀더라 # 주입하지 않으면 "" 이라는 공개된 문자열이 서명 키가 된다(부팅 시 경고 로그). # 운영 배포에서는 .env 나 배포 시크릿으로 반드시 채운다. 두 컨테이너가 같은 키를 봐야 한다. JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-} # ── 발행 호스트 ── # 커스텀 도메인이 없는 사이트의 공개 주소(`https://<이 값>/s/`). # ★ API 와 워커가 **같은 값**을 봐야 한다. API 가 화면에 보여준 주소와 워커가 구운 # canonical·사이트맵이 갈리면, 사장님 화면은 멀쩡한데 검색엔진만 엉뚱한 주소를 받는다. SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} # ── 발행 산출물 ── # 빌드 잡이 발행마다 payload JSON 을 여기 떨어뜨린다. 호스트의 frontend/site/payloads 로 # 바인드돼 있어서, o2o-web4ai-web 의 감시 프로세스가 **바뀐 payload 만** 골라 굽는다 — # docker cp 로 옮기는 수동 단계가 없다. # # ★ 이 디렉토리는 양방향이다. 프리렌더가 사이트마다 결과를 # `.status/.json` 에 써 주고, 백엔드가 그걸 읽어 렌더 상태를 사이트 조회 API 로 # 내보낸다(services/render_report). 백엔드에 마운트된 유일한 디렉토리라 여기를 쓴다 — # 이게 없으면 프리렌더가 깨져도 DB 는 "발행됨"이라 답하고 아무도 모른다. SITE_PAYLOAD_DIR: /app/out/payloads SITE_OUTPUT_DIR: /app/out/sites # ── 색인 통보 ── # 발행 즉시 네이버·Bing 에 알린다(구글은 IndexNow 를 지원하지 않는다 — Search Console 사이트맵 제출이 따로다). # ★ 프리렌더(o2o-web4ai-web)와 백엔드가 **같은 값**을 봐야 한다. 프리렌더는 이 키로 루트에 # `.txt` 를 굽고, 검색엔진은 통보를 받으면 그 파일을 열어 대조한다. 어긋나면 403 이다. INDEXNOW_KEY: ${INDEXNOW_KEY:-} services: # ── API 서버 ──────────────────────────────────────────────────────────── # 요청 접수/조회만 한다. 수집·비전분석·빌드는 잡으로 큐에 넣고 즉시 응답하며, # 클라이언트는 GET /v1/job/{id} 를 폴링한다. api: build: context: ./backend dockerfile: Dockerfile image: o2o-web4ai-backend # 워커와 공유하는 이미지 태그(한 번만 빌드된다) container_name: o2o-web4ai-api command: ["python", "web_main.py"] env_file: - .env # 외부 API 키. 없으면 cp .env.example .env environment: <<: *common-env # ★ APScheduler 크론(지역정보 갱신 등)은 **이 컨테이너에서만** 돈다. # 크론은 '시각'으로 발화하므로 프로세스가 여럿이면 같은 시각에 중복 실행된다 — # 그래서 단일 컨테이너로 고정한다. 아래 워커의 잡 큐와는 성격이 다르다: # 잡 큐는 DB 가 원자적으로 한 명에게만 배분(FOR UPDATE SKIP LOCKED)하므로 몇 개를 띄워도 안전하다. SCHEDULER_ENABLED: "1" volumes: # ★ 컨테이너 안에만 두면 컨테이너를 지울 때 발행 산출물이 같이 사라진다. # SSG 를 돌리는 쪽(호스트의 frontend/site)이 직접 읽어야 하므로 밖으로 뺀다. - ./frontend/site/payloads:/app/out/payloads ports: - "${API_BIND:-0.0.0.0}:9800:9800" # prod 는 API_BIND=127.0.0.1 로 내부만 개방(리버스프록시 뒤) extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped logging: driver: json-file options: { max-size: "10m", max-file: "5" } # ── 작업 큐 워커 ──────────────────────────────────────────────────────── # 수집 파이프라인 · 사진 비전 분석 · 사이트 빌드를 처리한다. 한 건에 3~10분 걸리므로 # API 요청 안에서 처리하지 않는다. # # ★ 스케일 안전: docker compose up -d --scale o2o-web4ai-worker=3 # 큐가 `UPDATE ... WHERE job_id = (SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING` 단일 문장으로 # 할당하므로 워커가 몇 개든 같은 잡이 두 번 돌지 않는다. 컨테이너가 죽어도 lease(JOB_LEASE_SEC)가 # 만료되면 reaper 가 회수해 재큐한다 — 잡이 증발하지 않는다. # (스케일하려면 container_name 을 두면 안 된다 — 이름이 충돌한다. 그래서 여기엔 없다. # 프로젝트 이름이 붙어 o2o-web4ai-worker-1 로 뜬다.) worker: build: context: ./backend dockerfile: Dockerfile image: o2o-web4ai-backend # api 와 동일 이미지 — 두 번 빌드되지 않는다 command: ["python", "worker_main.py"] env_file: - .env environment: <<: *common-env SCHEDULER_ENABLED: "0" # 크론은 API 컨테이너 담당. 워커는 잡 큐만 돈다 WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1} # 한 프로세스가 동시에 처리할 잡 수 JOB_DEADLINE_SEC: ${JOB_DEADLINE_SEC:-900} # 잡 1건 처리 상한. Perplexity(10~30s)+크롤링+Vision(사진 20~50장) 감안 JOB_LEASE_SEC: ${JOB_LEASE_SEC:-120} # 소유권 임대. heartbeat 가 1/3 주기로 갱신, 워커 사망 시 이만큼 뒤 회수 # ★ 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어서 그대로 두면 # 멀쩡히 잡을 돌면서도 계속 unhealthy 로 뜬다 — 진짜 고장과 구분이 안 되므로 끈다. # (Dockerfile 주석의 "워커는 이 헬스체크를 쓰지 않는다"를 compose 에서 실제로 반영하는 자리.) # 워커용 하트비트 기반 체크는 필요해질 때 추가한다. healthcheck: disable: true volumes: # ★ 컨테이너 안에만 두면 컨테이너를 지울 때 발행 산출물이 같이 사라진다. # SSG 를 돌리는 쪽(호스트의 frontend/site)이 직접 읽어야 하므로 밖으로 뺀다. - ./frontend/site/payloads:/app/out/payloads # 발행 검수 통과 후 워커가 이 산출물을 Azure Blob에 업로드한다. - site-out:/app/out/sites:ro extra_hosts: - "host.docker.internal:host-gateway" # graceful 종료: SIGTERM → 새 잡 claim 중단 → 하던 잡 마무리 → 종료. # 이 시간을 넘기면 Docker 가 SIGKILL 하지만, 그래도 안전하다 — lease 가 만료되면 # reaper 가 그 잡을 재큐한다(작업이 유실되지 않고 다음 워커가 이어받는다). # 값은 '배포 속도 vs 하던 일 마무리' 트레이드오프다. JOB_DEADLINE_SEC(900)까지 올리면 # 어떤 잡이든 끝까지 기다리지만 docker compose down 이 최대 15분 매달린다. stop_grace_period: 300s depends_on: - api # 이미지 빌드/기동 순서만 맞춘다(런타임 의존은 DB 뿐) restart: unless-stopped logging: driver: json-file options: { max-size: "10m", max-file: "5" } # ── 발행 사이트 빌더 + 정적 서버 ──────────────────────────────────────── # 발행 잡이 payload JSON 을 떨어뜨리면(SITE_PAYLOAD_DIR) 이 컨테이너가 그걸 보고 # 정적 HTML 을 굽고, 같은 결과물을 그대로 서빙한다. # # ★ 왜 백엔드가 직접 굽지 않나: 굽는 데 Node 와 프론트 의존성이 필요하다. 파이썬 이미지에 # 그걸 넣으면 백엔드가 프론트 빌드 도구를 떠안는다. payload 디렉토리를 사이에 두고 # 백엔드는 파일만 쓰고, 여기는 파일만 읽는다 — 두 쪽이 서로를 모른다. web: image: node:24-alpine container_name: o2o-web4ai-web working_dir: /app/frontend/site # 의존성이 없으면 설치부터 한다(최초 1회). 그 뒤 감시와 서버를 동시에 띄운다. # ★ `&` 는 그 앞의 명령 전체를 배경으로 돌린다 — 앞에 붙인 `cd` 까지 함께 묶여 나가서 # 뒤 명령이 엉뚱한 디렉토리에서 돈다(실측: /app/frontend 에서 스크립트를 찾다 실패). # 그래서 cd 를 먼저 끝내고, 배경으로 보내는 것은 서버 하나뿐이다. command: - sh - -c - | cd /app/frontend # ★ `-d node_modules` 로 판단하면 안 된다. 익명 볼륨은 **빈 디렉토리로 이미 존재**해서 # 설치를 건너뛰고 `vite: not found`(exit 127)로 죽는다. 실행 파일이 있는지를 본다. [ -x node_modules/.bin/vite ] || npm install # 외부 진입점은 admin Vite(:3000) 하나다. `/s/*`만 같은 컨테이너의 # 정적 서버(:3001)로 프록시하고, payload 감시는 백그라운드에서 계속 돈다. node site/scripts/serve-sites.mjs & # 감시 프로세스가 기동 때 번들을 한 번 만들고, 그 뒤로는 바뀐 payload 만 굽는다. # (예전에는 발행 한 건마다 vite 번들 + 전체 사이트를 다시 구웠다 — 사이트가 늘면 못 쓴다.) node site/scripts/watch-payloads.mjs & exec npm run dev -w admin environment: PORT: 3001 # 프리렌더가 루트에 `.txt` 를 굽는다. 백엔드와 같은 값이어야 한다. INDEXNOW_KEY: ${INDEXNOW_KEY:-} volumes: # 소스와 산출물을 통째로 마운트한다 — 발행 payload 가 호스트에 그대로 보여야 # 개발 중 무슨 일이 일어났는지 파일로 확인할 수 있다. - ./frontend:/app/frontend # ★ node_modules 만은 컨테이너 것을 쓴다(익명 볼륨으로 마운트를 덮는다). # 호스트가 macOS(arm64-darwin)라 그 안의 rollup·esbuild 네이티브 바이너리는 # 리눅스 컨테이너에서 못 쓴다 — 실측: `Cannot find module '@rollup/rollup-linux-arm64-musl'` # 로 프리렌더가 통째로 실패했고, 발행해도 사이트가 안 구워졌다. - /app/frontend/node_modules - /app/frontend/site/node_modules - /app/frontend/admin/node_modules # ★ 산출물만 named volume 으로 뺀다(./frontend 마운트 위에 덮인다). # 호스트 경로에 두면 재배포로 코드를 갈아엎는 순간 out/ 이 비어 전 사이트가 404 다. # nginx 가 같은 볼륨을 읽는다. - site-out:/app/frontend/site/out ports: # admin(:3000)이 공개 진입점. `/s/*`는 Vite proxy가 내부 :3001로 전달한다. - "3000:3000" restart: unless-stopped logging: driver: json-file options: { max-size: "10m", max-file: "5" } # ── 발행 사이트 정적 서빙 ─────────────────────────────────────────────── # # ★ 굽는 쪽(o2o-web4ai-web)과 서빙하는 쪽을 분리한다. 프리렌더는 파일만 쓰고 여기는 파일만 # 읽는다 — 볼륨 하나를 사이에 두고 서로를 모른다. # # ★ Azure Blob 으로 프록시하지 않는다. 파일이 이미 이 볼륨에 있는데 클라우드로 보냈다가 # 되받아 오면 요청마다 왕복이 하나 더 붙고, Blob 은 커스텀 도메인 TLS 도 못 붙인다. # Blob 업로드(azure_static)는 배달 백업으로 남겨 둔다. nginx: image: nginx:alpine container_name: o2o-web4ai-nginx volumes: - site-out:/srv/sites:ro - ./nginx/site.conf:/etc/nginx/conf.d/default.conf:ro ports: - "80:80" # TLS 를 붙이면 여기에 443 과 인증서 볼륨을 추가한다(certbot 또는 발급받은 인증서). # - "443:443" depends_on: - web restart: unless-stopped logging: driver: json-file options: { max-size: "10m", max-file: "5" } # ── (예정) 크롤링 전용 워커 ────────────────────────────────────────────── # Phase 1 은 collector 가 MockAdapter 만 등록하므로 브라우저가 필요 없다. # 크롤링 법무 검토(docs/DECISIONS.md 1-1)가 끝나 HeadlessAdapter 를 붙이면 여기에 서비스를 하나 더 만든다. # 선례는 o2o-negosium 의 lps-worker — 그대로 따라가면 된다: # # o2o-web4ai-crawler: # build: { context: ./backend, dockerfile: Dockerfile.worker } # Chrome + Xvfb + 한글폰트 (~1.5GB) # platform: linux/amd64 # google-chrome-stable(Linux)은 amd64 전용. arm64 맥에선 명시 없으면 빌드 실패 # shm_size: "1gb" # Chrome 는 /dev/shm 을 많이 씀 — 부족하면 탭 크래시 # environment: { DISPLAY: ":99" } # # API 이미지(~200MB)에 Chrome 을 넣으면 배포마다 1.5GB 를 밀게 되므로 반드시 이미지를 갈라야 한다. # ── 볼륨 ────────────────────────────────────────────────────────────────── volumes: # 발행 산출물. 프리렌더가 쓰고, 워커(색인 통보·Azure 업로드)와 nginx 가 읽는다. # # ★ `docker compose down` 으로는 지워지지 않는다. `down -v` 만 지운다. # ★ 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다(DEPLOY.md 1절). # 지켜야 할 것은 DB 와 out/payloads 뿐이고, 그 둘은 여전히 호스트에 bind mount 다. site-out: