# ★ 프로젝트 이름을 못 박는다 — 안 적으면 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 \ # 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_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: 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/out/payloads 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" } solution-worker: build: context: . dockerfile: solution/backend/Dockerfile image: o2o-web4ai-backend container_name: o2o-web4ai-solution-worker 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: - 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/out/payloads 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" } # 사장님 앱(:3000) + 발행 사이트 프리렌더·정적서버(:3001, `/s/*` 프록시) solution-frontend: image: node:24-alpine container_name: o2o-web4ai-solution-frontend 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/frontend environment: PORT: 3001 INDEXNOW_KEY: ${INDEXNOW_KEY:-} # ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를 # 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다. VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} # ★ 이 둘은 **브라우저가** 부르는 주소다. 컨테이너 안에서 보는 주소가 아니라 # 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다. VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800} VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000} 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/frontend/node_modules # ★ 산출물은 named volume. 호스트 경로면 재배포로 코드를 갈아엎는 순간 전 사이트가 404 다. - 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:3000} 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" depends_on: - solution-frontend restart: unless-stopped logging: driver: json-file options: { max-size: "10m", max-file: "5" } # 발행 사이트 정적 서빙. 굽는 쪽(web)과 분리 — 볼륨 하나를 사이에 두고 서로를 모른다. solution-site: image: nginx:alpine 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-frontend restart: unless-stopped logging: driver: json-file options: { max-size: "10m", max-file: "5" } volumes: # ★ `down -v` 만 지운다. 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다. site-out: