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
220 lines
8.3 KiB
YAML
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:
|