o2o-site-AEO/docker-compose.yml
Mina Choi c6b45fdda4 백엔드: 내부 운영 API 를 :9801 진입점으로 가른다
admin 이 사장님 API(:9800)를 그대로 보고 있었다. 화면만 갈라 두면 내부 요청이
사장님이 닿는 서버로 나가고, 권한도 엔드포인트마다 흩어진 채로 남는다.

## 코드는 한 벌, 진입점만 둘

  web_main.py   → router/router.py         :9800  사장님
  admin_main.py → router/admin_router.py   :9801  내부

services·crud·models 은 공유한다. **admin 전용 라우터가 0개**이기 때문이다 — 세어봤다:
admin 화면이 부르는 훅(useGetPlace·useListPlaces·useListLinks·useConfirmLink·
useListFacts·useGetSchema·useTransitionFact)이 전부 place·fact 라우터이고,
그 둘은 사장님 빌더도 쓴다. 엔드포인트를 새로 쓰면 같은 DB 의 같은 테이블을 두 벌
구현하는 것뿐이라, 같은 router 객체를 다시 마운트하고 앱 단위로 권한만 덧걸었다.

## 왜 경로 접두어가 아니라 포트인가

/v1/admin/... 는 같은 프로세스 안이라 **사장님이 닿는 서버에 내부 엔드포인트가 존재한다.**
포트를 가르면 사장님이 닿는 네트워크에 아예 없다. compose 에서 이 포트는 127.0.0.1
에만 연다(ADMIN_API_BIND) — 0.0.0.0 으로 열면 가른 의미가 없다.

## 권한

RequireDeveloper 를 앱 단위로 건다. auth 라우터만 게이트 밖이다 —
로그인 자체를 막으면 아무도 들어올 수 없다.

  검증 /v1/place/list :  USER(1) 403 · OWNER(2) 403 · DEVELOPER(3) 200

OWNER 가 막히는 게 핵심이다. 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.

## 그 밖

- 이미지의 HEALTHCHECK 는 :9800 을 찌른다. 그대로 두면 이 컨테이너가 멀쩡히 돌면서
  영원히 unhealthy 라, 포트만 바꿔 다시 걸었다.
- compose 주석에 negosium-db 가 나오는 이유를 적었다 — 베낀 흔적이 아니라 DB 인스턴스를
  따로 안 띄우고 그 postgres 안에 web4ai_db 만 만들어 쓰기 때문이다(DECISIONS.md 3절).
  줄이면서 이유를 날려 읽는 사람이 오해하게 만들었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:58:50 +09:00

220 lines
8.3 KiB
YAML

# ★ 프로젝트 이름을 못 박는다 — 안 적으면 compose 가 디렉터리 이름을 쓰고, 폴더를 옮기는 순간
# 컨테이너와 볼륨이 통째로 새로 생긴다.
name: o2o-web4ai
# DB 는 compose 밖이다(호스트 PostgreSQL).
# ★ 컨테이너 이름이 negosium-db 인 건 베낀 흔적이 아니다 — web4ai 는 DB 인스턴스를 따로 띄우지
# 않고, negosium 이 쓰는 postgres(5432) 안에 web4ai_db 라는 database 만 새로 만들어 쓴다
# (DECISIONS.md 3절). 그래서 아래 명령의 컨테이너 이름이 negosium-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
x-common-env: &common-env
APP_ENV: local
PYTHONUNBUFFERED: "1"
# 이미지 안 config.local.toml 은 플레이스홀더다 — 아래 값이 없으면 접속·서명이 실패한다.
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_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다.
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
SITE_PAYLOAD_DIR: /app/out/payloads
SITE_OUTPUT_DIR: /app/out/sites
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
services:
api:
build:
context: ./solution/backend
dockerfile: Dockerfile
image: o2o-web4ai-backend
container_name: o2o-web4ai-api
command: ["python", "web_main.py"]
env_file:
- .env
environment:
<<: *common-env
# ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다.
SCHEDULER_ENABLED: "1"
volumes:
- ./solution/site/payloads:/app/out/payloads
ports:
- "${API_BIND:-0.0.0.0}:9800:9800"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
worker:
build:
context: ./solution/backend
dockerfile: Dockerfile
image: o2o-web4ai-backend
command: ["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}
# 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어 그대로 두면 늘 unhealthy 다.
healthcheck:
disable: true
volumes:
- ./solution/site/payloads:/app/out/payloads
- site-out:/app/out/sites:ro
extra_hosts:
- "host.docker.internal:host-gateway"
stop_grace_period: 300s
depends_on:
- api
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 내부 운영 API(:9801). 코드는 api 와 같은 이미지·같은 소스이고 진입점만 다르다
# (admin_main.py → router/admin_router.py). place·fact 를 두 번 구현하지 않으면서
# 프로세스와 포트만 가른다. 여기 붙는 모든 엔드포인트는 role >= DEVELOPER 다.
api-admin:
image: o2o-web4ai-backend
container_name: o2o-web4ai-api-admin
command: ["python", "admin_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/out/payloads
ports:
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
- "${ADMIN_API_BIND:-127.0.0.1}:9801:9801"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- api
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 사장님 앱(:3000) + 발행 사이트 프리렌더·정적서버(:3001, `/s/*` 프록시)
web:
image: node:24-alpine
container_name: o2o-web4ai-web
working_dir: /app
command:
- sh
- -c
- |
cd /app
# ★ `-d node_modules` 로 판단하면 안 된다. 익명 볼륨은 빈 디렉토리로 이미 존재해서
# 설치를 건너뛰고 `vite: not found`(exit 127)로 죽는다.
[ -x node_modules/.bin/vite ] || npm install
node solution/site/scripts/serve-sites.mjs &
node solution/site/scripts/watch-payloads.mjs &
exec npm run dev -w @o2o/front
environment:
PORT: 3001
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
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
# ★ node_modules 는 컨테이너 것을 쓴다. 호스트가 macOS(arm64-darwin)라 그 안의
# rollup·esbuild 네이티브 바이너리를 리눅스 컨테이너가 못 쓴다.
- /app/node_modules
- /app/solution/site/node_modules
- /app/solution/frontend/node_modules
- /app/admin/node_modules
# ★ 산출물은 named volume. 호스트 경로면 재배포로 코드를 갈아엎는 순간 전 사이트가 404 다.
- site-out:/app/solution/site/out
ports:
- "3000:3000"
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 내부 운영 앱(:3002). 사장님 번들과 갈라 두는 것이 이 서비스의 존재 이유다.
admin:
image: node:24-alpine
container_name: o2o-web4ai-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}
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/node_modules
ports:
# ★ 운영에서는 내부망에만 연다. 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- "${ADMIN_BIND:-127.0.0.1}:3002:3002"
depends_on:
- web
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 발행 사이트 정적 서빙. 굽는 쪽(web)과 분리 — 볼륨 하나를 사이에 두고 서로를 모른다.
nginx:
image: nginx:alpine
container_name: o2o-web4ai-nginx
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:80"
depends_on:
- web
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
volumes:
# ★ `down -v` 만 지운다. 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다.
site-out: