킹서버(o2oadmin@172.30.1.36)에 처음 올리면서, 서버에 올려야만 드러나는 결함 넷을 잡았다.
전부 "화면은 뜨는데 안 되는" 종류라 로컬에서는 끝까지 보이지 않는다.
- CORS 허용 오리진(client_url)만 env override 가 없었다. 도커가 굽던 config.local.toml 은
플레이스홀더라 허용 목록이 localhost:3000~3005 뿐이고, 배포 주소에서는 모든 API 호출이
프리플라이트에서 죽었다. 서버 로그에는 400 만 남아 원인이 CORS 라는 게 안 보인다
- .env 경로가 세 단계라 solution/.env(없는 파일)를 보고 있었다. 백엔드를 solution/ 아래로
옮길 때 안 고쳐진 자리. toml 이 값을 들고 있어 로컬에서 드러나지 않았다
- admin 의 "빌더 열기" 가 VITE_SOLUTION_URL 미주입으로 localhost:3000 을 가리켰다
- PUBLIC_API_BASE_URL 은 브라우저가 부르는 주소인데 기본값이 localhost 라 서버에서 즉시 틀린다
설정 — toml 층 제거, pydantic-settings 로 전환 (FastAPI 공식 방식)
- config_loader.py · config.{local,test}.toml.example 삭제, 기본값은 config_models 로
- BaseSettings + env_file. `_apply_*_env_override` 4개 제거 — 키를 손으로 나열하는 구조라
하나 빠뜨리면 조용히 틀렸고, 실제로 client_url 이 빠져 있었다
- 환경변수 이름은 validation_alias 로 못 박음. 필드명만 두면 `port` 가 흔한 `PORT` 를 먹는다
- 테스트 DB 분리(web4ai_test_db)는 config.test.toml 이 하던 몫이라 APP_ENV 기본값으로 이관
- lru_cache 로 .env 재읽기 방지. 새 코드는 Depends(get_*) 주입 가능
- 호출부 21개 파일 무변경 — server_configs 가 같은 이름을 계속 내보낸다
배포 — 킹서버는 :80 을 호스트 nginx 가 물고 있고 사내망에 열린 건 30xxx 뿐이다
- 컴포즈 포트를 전부 .env 변수로 추출(기본값은 기존 값 그대로, 로컬 무영향)
- 컨테이너 이름을 폴더 구조에 맞춤 — solution-backend·solution-worker·solution-frontend·
solution-site·admin-backend·admin-frontend. api·web·nginx 는 어느 폴더 코드인지
이름만으로 알 수 없었고, 백엔드 셋이 이미지 한 벌을 나눠 써서 특히 헷갈렸다
- worker 에 container_name 을 붙여 `-1` 접미사 제거(동시성은 WORKER_CONCURRENCY 가 맡는다)
- 어드민 앱·API 는 compose 프로필 뒤로 — 지금 안 쓴다. 켤 때 --profile admin
- deploy.sh: 서비스 하나를 지정해도 백엔드 형제를 함께 교체한다. 이미지 한 벌을 나눠 써서
하나만 바꾸면 옛 코드로 도는 컨테이너가 남는데 `ps` 로는 셋 다 살아 있다
- log.sh: 1=전체, 2번부터 개별. compose v2.20 이 커스텀 --format 을 파싱하지 못해 상태가
전부 "미기동" 으로 보이던 것도 --services --filter 로 교정
- docs/SERVERS.md 신설(접속·경로·포트·DB·sudo 없음), docs/DEVLOG.md 신설
정리
- 개발 전용 자동 로그인 제거 — 편의 하나에 검색 경로의 비동기 대기가 딸려 있었고,
평문 비밀번호를 .env 에 두라고 권하는 모양새였다
- API 이름을 디렉토리에 맞춤: 사장님/내부 → 솔루션 API · 어드민 API (21곳)
- .env.example 을 읽는 폴더 기준 구역으로 재편 (solution/backend · solution/frontend ·
solution/site · compose)
- AGENTS.md 에 negosium 브랜치·커밋 규약 명시
검증(킹서버 실측) — 컨테이너 4개 새 이름으로 기동, 솔루션 API·사장님 앱 200,
발행 사이트 404(발행물 없음, 정상), CORS 허용/차단 각 확인, toml 없이 부팅,
APP_ENV=test 시 web4ai_test_db·실키 미주입 확인.
235 lines
9.9 KiB
YAML
235 lines
9.9 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_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:
|