상시 프리렌더와 중복 예약 안내를 없애고, 검수된 발행 버전을 보존한다. 미리보기는 실제 렌더 완료까지 스피너를 표시한다. 사이트 81건, 발행·롤백·서치콘솔 45건, 프로세스 수명 3건 통과. 빌더·사이트 빌드 및 compose 설정 검증 통과.
339 lines
16 KiB
YAML
339 lines
16 KiB
YAML
# ★ 프로젝트 이름을 못 박는다 — 안 적으면 compose 가 디렉터리 이름을 쓰고, 폴더를 옮기는 순간
|
|
# 컨테이너와 볼륨이 통째로 새로 생긴다.
|
|
name: o2o-web4ai
|
|
|
|
# DB 는 compose 밖이다(호스트 PostgreSQL).
|
|
# ★ 컨테이너 이름이 negosium-db 인 건 베낀 흔적이 아니다 — web4ai 는 DB 인스턴스를 따로 띄우지
|
|
# 않고, negosium 이 쓰는 postgres(5432) 안에 web4ai_db 라는 database 만 새로 만들어 쓴다
|
|
# (DECISIONS.md 3절). 그래서 아래 명령의 컨테이너 이름이 우리 것이 아니다.
|
|
# 최초 1회 — 컨테이너 이름은 환경마다 다르다(로컬 negosium-db · 킹서버 king_postgres_container):
|
|
# docker exec -i -e PGPASSWORD=password <postgres 컨테이너> \
|
|
# psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 < postgres-init/init-data/init.sql
|
|
|
|
x-common-env: &common-env
|
|
APP_ENV: local
|
|
PYTHONUNBUFFERED: "1"
|
|
# 설정 파일은 없다 — 값은 전부 여기(=최상위 .env)서 온다. 비우면 config_models 의 기본값이다.
|
|
DB_HOST: ${DB_HOST:-host.docker.internal}
|
|
DB_PORT: ${DB_PORT:-5432}
|
|
DB_USER: ${DB_USER:-postgres}
|
|
DB_PASSWORD: ${DB_PASSWORD:-password}
|
|
DB_NAME: ${DB_NAME:-web4ai_db}
|
|
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-}
|
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-}
|
|
# ★ 프론트(VITE_GOOGLE_CLIENT_ID)와 같은 값이어야 한다 — 백엔드는 이 값으로 구글 토큰의
|
|
# 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
|
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
|
# ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다.
|
|
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
|
|
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
|
|
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
|
# ★ 렌더러(solution/site/scripts/prerender.ts)가 songs·out 디렉토리를 **자기 파일 위치
|
|
# 기준 상대경로**로 찾는다(SITE_ROOT = dist/prerender/../..) — 워커 컨테이너 안에서 그
|
|
# 렌더러를 직접 띄우므로(render_service.py) 세 디렉토리가 실제 설치 자리
|
|
# (/app/solution/site/) 아래 나란히 있어야 한다. 아래 볼륨 마운트와 짝이 맞아야 한다.
|
|
SITE_PAYLOAD_DIR: /app/solution/site/payloads
|
|
SITE_OUTPUT_DIR: /app/solution/site/out
|
|
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
|
|
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
|
|
|
services:
|
|
solution-backend:
|
|
build:
|
|
context: .
|
|
dockerfile: solution/backend/Dockerfile
|
|
image: o2o-web4ai-backend
|
|
container_name: o2o-web4ai-solution-backend
|
|
command: ["python", "web_main.py"]
|
|
env_file:
|
|
- .env
|
|
environment:
|
|
<<: *common-env
|
|
# ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다.
|
|
SCHEDULER_ENABLED: "1"
|
|
volumes:
|
|
- ./solution/site/payloads:/app/solution/site/payloads
|
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 워커가 렌더러를
|
|
# 돌릴 때 사이트 디렉토리로 복사된다(services/song_service · site/scripts/prerender.ts).
|
|
- ./solution/site/songs:/app/solution/site/songs
|
|
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
|
|
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
|
|
- ./postgres-init:/app/postgres-init:ro
|
|
ports:
|
|
- "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800"
|
|
extra_hosts:
|
|
- "host.docker.internal:host-gateway"
|
|
restart: unless-stopped
|
|
logging:
|
|
driver: json-file
|
|
options: { max-size: "10m", max-file: "5" }
|
|
|
|
# ★ BUILD·ROLLBACK 잡이 렌더러(solution/site)를 subprocess 로 직접 돌린다
|
|
# (services/render_service.py) — 예전에 solution-prerender 컨테이너가 하던 일이다.
|
|
# 그래서 이 서비스만 **다른 이미지**(Dockerfile.worker, Node 런타임 + 컴파일된 렌더러
|
|
# 포함)를 쓴다. solution-backend·admin-backend 는 그 Node 를 쓸 일이 없다.
|
|
solution-worker:
|
|
build:
|
|
context: .
|
|
dockerfile: solution/backend/Dockerfile.worker
|
|
image: o2o-web4ai-worker
|
|
container_name: o2o-web4ai-solution-worker
|
|
command: ["sh", "-c", "node /app/solution/site/dist/prerender/prerender.js --seed-assets --out=/app/solution/site/out && exec python worker_main.py"]
|
|
env_file:
|
|
- .env
|
|
environment:
|
|
<<: *common-env
|
|
SCHEDULER_ENABLED: "0"
|
|
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}
|
|
JOB_DEADLINE_SEC: ${JOB_DEADLINE_SEC:-900}
|
|
JOB_LEASE_SEC: ${JOB_LEASE_SEC:-120}
|
|
# 렌더러 subprocess 가 굽는 동안 기다리는 시간(사진 내려받기 포함). BUILD 의
|
|
# job_deadline_sec 보다 짧아야 한다 — 안 그러면 잡 전체가 먼저 타임아웃된다.
|
|
RENDER_TIMEOUT_SEC: ${RENDER_TIMEOUT_SEC:-180}
|
|
# 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어 그대로 두면 늘 unhealthy 다.
|
|
healthcheck:
|
|
disable: true
|
|
volumes:
|
|
- ./solution/site/payloads:/app/solution/site/payloads
|
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 렌더러가
|
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
|
- ./solution/site/songs:/app/solution/site/songs
|
|
# ★ 이제 이 컨테이너가 굽는 쪽이다 — 읽기전용이 아니다(예전 solution-prerender 가
|
|
# 쓰던 자리를 그대로 이어받는다).
|
|
- site-out:/app/solution/site/out
|
|
extra_hosts:
|
|
- "host.docker.internal:host-gateway"
|
|
stop_grace_period: 300s
|
|
depends_on:
|
|
- solution-backend
|
|
restart: unless-stopped
|
|
logging:
|
|
driver: json-file
|
|
options: { max-size: "10m", max-file: "5" }
|
|
|
|
# 어드민 API(:9801). 진입점은 admin/backend/{main,app}.py 이고 도메인 코드는
|
|
# solution/backend 것을 PYTHONPATH 로 그대로 쓴다 — place·fact 를 두 번 구현하지 않으면서
|
|
# 프로세스와 포트만 가른다. 여기 붙는 모든 엔드포인트는 role >= DEVELOPER 다.
|
|
admin-backend:
|
|
image: o2o-web4ai-backend
|
|
container_name: o2o-web4ai-admin-backend
|
|
# ★ 기본으로 뜨지 않는다. 어드민 화면을 안 쓰는 동안은 이 API 도 부를 사람이 없다.
|
|
# 켤 때: docker compose --profile admin up -d
|
|
profiles: ["admin"]
|
|
working_dir: /app/admin/backend
|
|
command: ["python", "main.py"]
|
|
env_file:
|
|
- .env
|
|
environment:
|
|
<<: *common-env
|
|
SCHEDULER_ENABLED: "0"
|
|
ADMIN_API_PORT: "9801"
|
|
# ★ 이미지의 HEALTHCHECK 는 :9800 을 찌른다(api 용). 이 컨테이너는 9801 이라
|
|
# 그대로 두면 멀쩡히 돌면서 영원히 unhealthy 다 — 포트만 바꿔 다시 건다.
|
|
healthcheck:
|
|
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:9801/healthz', timeout=4).status==200 else 1)"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
start_period: 20s
|
|
retries: 3
|
|
volumes:
|
|
- ./solution/site/payloads:/app/solution/site/payloads
|
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
|
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
|
- ./solution/site/songs:/app/solution/site/songs
|
|
ports:
|
|
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
|
|
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
|
|
extra_hosts:
|
|
- "host.docker.internal:host-gateway"
|
|
depends_on:
|
|
- solution-backend
|
|
restart: unless-stopped
|
|
logging:
|
|
driver: json-file
|
|
options: { max-size: "10m", max-file: "5" }
|
|
|
|
# 사장님 앱 dev 서버 + 발행본 정적서버(:3001). **로컬 전용**이다 — `--profile dev`.
|
|
# 운영에서 이게 뜨면 안 된다(위 solution-prerender 주석).
|
|
solution-frontend:
|
|
image: node:24-alpine
|
|
container_name: o2o-web4ai-solution-frontend
|
|
profiles: ["dev"]
|
|
working_dir: /app
|
|
command:
|
|
- sh
|
|
- -c
|
|
- |
|
|
cd /app
|
|
[ -x node_modules/.bin/vite ] || npm install
|
|
node solution/site/scripts/serve-sites.mjs &
|
|
exec npm run dev -w @o2o/frontend
|
|
environment:
|
|
PORT: 3001
|
|
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
|
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
|
# ★ 이 둘은 **브라우저가** 부르는 주소다. 컨테이너 안에서 보는 주소가 아니라
|
|
# 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다.
|
|
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
|
|
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
|
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
|
|
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
|
|
# 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처).
|
|
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
|
volumes:
|
|
- ./package.json:/app/package.json
|
|
- ./package-lock.json:/app/package-lock.json
|
|
- ./tsconfig.base.json:/app/tsconfig.base.json
|
|
- ./solution:/app/solution
|
|
- ./admin:/app/admin
|
|
- /app/node_modules
|
|
- /app/solution/site/node_modules
|
|
- /app/solution/frontend/node_modules
|
|
- /app/admin/frontend/node_modules
|
|
- site-out:/app/solution/site/out
|
|
ports:
|
|
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-3000}:3000"
|
|
restart: unless-stopped
|
|
logging:
|
|
driver: json-file
|
|
options: { max-size: "10m", max-file: "5" }
|
|
|
|
# 내부 운영 앱(:3002). 사장님 번들과 갈라 두는 것이 이 서비스의 존재 이유다.
|
|
admin-frontend:
|
|
image: node:24-alpine
|
|
container_name: o2o-web4ai-admin-frontend
|
|
profiles: ["admin"]
|
|
working_dir: /app
|
|
command:
|
|
- sh
|
|
- -c
|
|
- |
|
|
cd /app
|
|
[ -x node_modules/.bin/vite ] || npm install
|
|
exec npm run dev -w @o2o/admin
|
|
environment:
|
|
# ★ 어드민 API(:9801)를 본다. :9800 을 보면 API 를 가른 의미가 없다.
|
|
VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9801}
|
|
# 사장님 앱은 다른 오리진이라 절대 URL 로 연다(admin/frontend/src/lib/solutionUrl.ts).
|
|
# 이걸 안 넘기면 "빌더 열기" 가 서버에서 localhost:3000 을 가리켜 죽은 링크가 된다.
|
|
VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
|
volumes:
|
|
- ./package.json:/app/package.json
|
|
- ./package-lock.json:/app/package-lock.json
|
|
- ./tsconfig.base.json:/app/tsconfig.base.json
|
|
- ./solution:/app/solution
|
|
- ./admin:/app/admin
|
|
- /app/node_modules
|
|
- /app/solution/site/node_modules
|
|
- /app/solution/frontend/node_modules
|
|
- /app/admin/frontend/node_modules
|
|
ports:
|
|
# ★ 운영에서는 내부망에만 연다. 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
|
|
- "${ADMIN_BIND:-127.0.0.1}:${ADMIN_PORT:-3002}:3002"
|
|
restart: unless-stopped
|
|
logging:
|
|
driver: json-file
|
|
options: { max-size: "10m", max-file: "5" }
|
|
|
|
# 공개 진입점 하나. 사장님 앱(구운 번들) · 발행 사이트(볼륨) · API 프록시를 한 오리진으로 준다.
|
|
solution-site:
|
|
build:
|
|
context: .
|
|
dockerfile: nginx/Dockerfile
|
|
args:
|
|
# ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다
|
|
# → 주소를 바꿨으면 `./deploy.sh solution-site`.
|
|
# ★ 기본값이 앱과 **같은 오리진**이다. nginx 가 /v1 을 프록시하므로 :9800 을 박으면
|
|
# 스스로 크로스 오리진을 만들어 로그인만 조용히 실패한다(위 주석 · .env.example).
|
|
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost}
|
|
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
|
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
|
# ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다.
|
|
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
|
|
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
|
|
# 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다.
|
|
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
|
image: o2o-web4ai-solution-site
|
|
container_name: o2o-web4ai-solution-site
|
|
volumes:
|
|
- site-out:/srv/sites:ro
|
|
# ★ 클론 직후: cp nginx/site.conf.example nginx/site.conf
|
|
# 파일이 없으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다.
|
|
- ./nginx/site.conf:/etc/nginx/conf.d/default.conf:ro
|
|
ports:
|
|
# ★ :80 이 비어 있다는 보장이 없다(킹서버는 호스트 nginx 가 물고 있다).
|
|
# 앞단 프록시를 세울 거면 여기서 포트만 옮기고 프록시가 이쪽을 가리키게 한다.
|
|
- "${SITE_HTTP_BIND:-0.0.0.0}:${SITE_HTTP_PORT:-80}:80"
|
|
depends_on:
|
|
- solution-backend
|
|
restart: unless-stopped
|
|
logging:
|
|
driver: json-file
|
|
options: { max-size: "10m", max-file: "5" }
|
|
|
|
# ── 온톨로지(o2o-site-ontology) — 발행본 메타 키워드·제목 업종어 ──────────────
|
|
# ★ 자체 DB 가 따로 있다. 호스트 postgres 를 같이 쓰지 않는 이유는 pgvector 확장이 필요해서다.
|
|
# 우리 DB 에 확장을 걸면 web4ai_db 가 그 확장에 묶인다 — 남의 서비스 사정을 우리 DB 가 떠안는다.
|
|
ontology-postgres:
|
|
image: pgvector/pgvector:pg16
|
|
container_name: o2o-web4ai-ontology-postgres
|
|
environment:
|
|
POSTGRES_USER: ontology
|
|
POSTGRES_PASSWORD: ontology
|
|
POSTGRES_DB: ontology
|
|
volumes:
|
|
- ontology-pgdata:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ['CMD-SHELL', 'pg_isready -U ontology -d ontology']
|
|
interval: 5s
|
|
timeout: 3s
|
|
retries: 20
|
|
restart: unless-stopped
|
|
|
|
ontology-redis:
|
|
image: redis:7-alpine
|
|
container_name: o2o-web4ai-ontology-redis
|
|
healthcheck:
|
|
test: ['CMD', 'redis-cli', 'ping']
|
|
interval: 5s
|
|
timeout: 3s
|
|
retries: 20
|
|
restart: unless-stopped
|
|
|
|
ontology:
|
|
build:
|
|
context: ./ontology
|
|
container_name: o2o-web4ai-ontology
|
|
environment:
|
|
PORT: "3100"
|
|
# 컨테이너끼리는 서비스 이름으로 만난다 — 호스트 포트(55432·56379)는 사람이 들여다볼 때만 쓴다.
|
|
DATABASE_URL: postgres://ontology:ontology@ontology-postgres:5432/ontology
|
|
REDIS_HOST: ontology-redis
|
|
REDIS_PORT: "6379"
|
|
# API 키 없이 도는 기본값(로컬 임베딩 + mock LLM). 키를 쓰려면 .env 에서 덮어쓴다.
|
|
EMBEDDING_PROVIDER: ${ONTOLOGY_EMBEDDING_PROVIDER:-local}
|
|
EMBEDDING_LOCAL_MODEL: ${ONTOLOGY_EMBEDDING_MODEL:-Xenova/multilingual-e5-small}
|
|
LLM_PROVIDER: ${ONTOLOGY_LLM_PROVIDER:-mock}
|
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
|
volumes:
|
|
# 임베딩 모델 캐시. 볼륨이 없으면 컨테이너를 새로 만들 때마다 120MB 를 다시 받는다.
|
|
- ontology-model:/app/.cache
|
|
ports:
|
|
- "127.0.0.1:3100:3100"
|
|
depends_on:
|
|
ontology-postgres:
|
|
condition: service_healthy
|
|
ontology-redis:
|
|
condition: service_healthy
|
|
restart: unless-stopped
|
|
logging:
|
|
driver: json-file
|
|
options: { max-size: "10m", max-file: "5" }
|
|
|
|
volumes:
|
|
# ★ `down -v` 만 지운다. 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다.
|
|
site-out:
|
|
# 온톨로지 DB 와 임베딩 모델 캐시. 모델 캐시가 날아가면 첫 요청이 120MB 를 다시 받는다.
|
|
ontology-pgdata:
|
|
ontology-model:
|