o2o-site-AEO/docker-compose.yml
Mina Choi 08a95e2bd7 .gitignore: CLAUDE.md 심볼릭 링크를 전역 무시에서 되살린다
최초 커밋에서 CLAUDE.md 가 빠져 있었다. 레포 .gitignore 에서는 뺐지만
이 머신의 ~/.gitignore_global 5번 줄이 CLAUDE.md 를 전역으로 무시한다.
전역 설정은 사람마다 달라 레포가 의존할 수 없으므로 `!CLAUDE.md` 로
레포가 스스로 되살린다.

내용 자체는 AGENTS.md 로 이미 커밋돼 있었다 — 빠진 건 링크뿐이다.
2026-08-31 13:58:34 +09:00

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: