최초 커밋에서 CLAUDE.md 가 빠져 있었다. 레포 .gitignore 에서는 뺐지만 이 머신의 ~/.gitignore_global 5번 줄이 CLAUDE.md 를 전역으로 무시한다. 전역 설정은 사람마다 달라 레포가 의존할 수 없으므로 `!CLAUDE.md` 로 레포가 스스로 되살린다. 내용 자체는 AGENTS.md 로 이미 커밋돼 있었다 — 빠진 건 링크뿐이다.
257 lines
16 KiB
YAML
257 lines
16 KiB
YAML
# ★ 프로젝트 이름을 여기 못 박는다. 안 적으면 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_* 가 없으면 `<DB_USER>` 로 접속을 시도하다 실패한다 — 반드시 주입해야 한다.
|
|
# 기본값은 로컬 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:-web4ai_db}
|
|
# ── JWT 서명 키 ──
|
|
# ★ DB 와 같은 이유로 반드시 주입해야 한다. 이미지의 config.local.toml 은 플레이스홀더라
|
|
# 주입하지 않으면 "<JWT_ACCESS_SECRET>" 이라는 공개된 문자열이 서명 키가 된다(부팅 시 경고 로그).
|
|
# 운영 배포에서는 .env 나 배포 시크릿으로 반드시 채운다. 두 컨테이너가 같은 키를 봐야 한다.
|
|
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-}
|
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-}
|
|
# ── 발행 호스트 ──
|
|
# 커스텀 도메인이 없는 사이트의 공개 주소(`https://<이 값>/s/<slug>`).
|
|
# ★ API 와 워커가 **같은 값**을 봐야 한다. API 가 화면에 보여준 주소와 워커가 구운
|
|
# canonical·사이트맵이 갈리면, 사장님 화면은 멀쩡한데 검색엔진만 엉뚱한 주소를 받는다.
|
|
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
|
|
# ── 발행 산출물 ──
|
|
# 빌드 잡이 발행마다 payload JSON 을 여기 떨어뜨린다. 호스트의 frontend/site/payloads 로
|
|
# 바인드돼 있어서, o2o-web4ai-web 의 감시 프로세스가 **바뀐 payload 만** 골라 굽는다 —
|
|
# docker cp 로 옮기는 수동 단계가 없다.
|
|
#
|
|
# ★ 이 디렉토리는 양방향이다. 프리렌더가 사이트마다 결과를
|
|
# `.status/<slug>.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)와 백엔드가 **같은 값**을 봐야 한다. 프리렌더는 이 키로 루트에
|
|
# `<key>.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
|
|
# 프리렌더가 루트에 `<key>.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:
|