o2o-site-AEO/docs/DEPLOY.md
Mina Choi 9b4fe4030b [feat] deploy,backend,site: 킹서버 배포 + 설정을 최상위 .env 하나로 통합
킹서버(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·실키 미주입 확인.
2026-09-01 10:04:36 +09:00

184 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 배포 · 스토리지
> **현재 결정 (2026-08-31): 발행 사이트는 서버 안에서 nginx 가 정적 서빙한다.**
> Azure Blob 은 코드에 있으나 **켜지 않는다**(`AZURE_STORAGE_CONNECTION_STRING` 비움).
> 클라우드는 고도화 때 붙인다 — 근거는 [ARCHITECTURE.md 3절](ARCHITECTURE.md).
> 이 문서의 **3절이 지금 밟는 절차**이고, **4절은 나중에 켤 때** 본다.
## 1. 산출물을 어디에 둘 것인가
역할이 셋인데 하나로 묶어 생각하면 헷갈린다. **원본 / 작업공간 / 배달**을 나눈다.
| 층 | 무엇 | 어디 | 잃어버리면 |
|---|---|---|---|
| 원본 | DB (`site_versions.snapshot`) + `out/payloads/*.json` | PostgreSQL + 서버 디스크 | 진짜 손실. 백업 대상은 **여기뿐** |
| 작업공간 · 배달 | `out/` 정적 트리 | Docker named volume `site-out` (nginx 가 읽는다) | 괜찮다 — payload 로 다시 굽는다 |
| 배달(백업) | 같은 파일의 사본 | Azure Blob `$web` | 다시 올리면 된다 |
**결론: 공개 서빙은 nginx 가 맡는다(2026-08-31 결정).** 파일이 이미 `site-out` 볼륨에 있는데
Blob 으로 보냈다가 되받아 오면 요청마다 왕복이 하나 더 붙고, Blob 은 커스텀 도메인 TLS 도 못 붙여
CDN 을 억지로 끼워야 한다. `azure_static.py` 의 업로드는 **배달 백업**으로 남긴다 —
웹서버를 여러 대로 늘릴 때 다시 볼 문제다.
### 트래픽은 클라우드가 더 걸리지 않는다
방문자가 받는 바이트는 어디서 주든 똑같다. 달라지는 건 **누가 그 비용을 어떤 형태로 내는가**다.
- 서버 직접 서빙: 대역폭은 보통 VM 요금에 포함(무료처럼 보인다). 대신 CPU·가용성·TLS 갱신·
캐시 헤더·장애 시 전 사이트 동시 다운을 우리가 떠안는다.
- Blob+CDN: egress 를 GB 단위로 낸다. 대신 서버가 트래픽을 아예 안 받는다.
실측 기준 1회 방문 바이트:
```
첫 방문 HTML 50~100KB + CSS 35KB + JS 320KB ≈ 420KB
재방문 HTML 만 (assets 는 immutable 1년 캐시) ≈ 60KB, 대개 304
이미지 0KB ← 네이버 CDN 이 대신 낸다
```
사이트 1,000개 × 월 100뷰 = 월 10만 PV ≈ **월 24GB**. Azure 무료 egress 한도(월 100GB) 안이다.
즉 **지금 규모에서 트래픽 비용은 판단 근거가 아니다.** 운영 편의로 고르면 된다.
⚠️ 단, 이미지를 `pstatic.net` 에서 핫링크하고 있다. 우리 트래픽 비용을 0으로 만드는 대신
네이버가 referer 차단하거나 이미지를 내리면 **전 사이트가 동시에 깨진다.** 발행 시점에
이미지를 우리 Blob 으로 복사해 두는 게 중기 과제다.
### 사이트 하나에 들어가는 파일
2026-08-31 부터 사이트는 **한 장**이다(라우터 없음). 사이트당 30개 안팎이던 HTML 이 둘로 줄었다.
```
site-out/
assets/ 공용 번들(전 사이트 공유) robots.txt 크롤러가 읽는 유일한 자리
fonts/ 공용 sitemap.xml 전 사이트 URL 한 파일
s/<slug>/index.html 사이트 전체
s/<slug>/llms.txt 확인된 사실 목록(AEO)
```
사이트별 `sitemap.xml`·`robots.txt` 는 없앴다 — 한 장짜리의 사이트맵은 URL 이 하나뿐이고,
`<host>/s/<slug>/robots.txt` 는 애초에 아무도 읽지 않는다(RFC 9309: 오리진 루트만).
## 2. CSS 주소는 불변이 아니다
`/assets/index-DvNTmLhy.css` 의 해시는 **렌더러 CSS 가 바뀌면 바뀐다.** 프리렌더가
`dist/client/.vite/manifest.json` 을 읽어 HTML 에 박으므로 새로 굽는 HTML 은 항상 맞다.
문제는 **이미 구워져 올라간 HTML** 이다.
```
프론트 수정 → 새 번들(새 해시)
로컬 out/assets : 통째로 교체 (옛 해시 삭제) ← 재굽기 안 한 사이트는 CSS 404
Azure : 새 해시 추가, 옛 해시 유지 ← 안 깨지지만 옛 디자인 그대로 박제
```
프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 그래서 로컬 out/ 은 재시작만
하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게
`solution/backend/scripts/republish_all.py` 다.
**규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
```bash
docker compose restart solution-frontend # 기동하며 전체 재굽기
docker compose logs -f solution-frontend # "[watch] 기동" 배치가 끝날 때까지 대기
docker compose exec solution-worker python scripts/republish_all.py
```
## 3. 서버에 올리는 순서 (지금 밟는 경로)
굽는 것과 서빙하는 것을 **한 번에 바꾸지 않는다.** 깨졌을 때 어느 쪽인지 못 가린다.
### 0단계 — 도메인부터 정한다 (코드가 하드코딩이라 먼저다)
발행 호스트는 **백엔드·프론트 두 곳**에 있고 값이 같아야 한다.
| 어디 | 무엇 | 기본값 |
|---|---|---|
| 백엔드 | `SITE_PUBLIC_HOST` (`site_payload.py` 의 `DEFAULT_HOST`) | `w4ai.o2o.kr` |
| 프론트(빌더·`web` 컨테이너) | `VITE_PUBLISH_HOST` | compose 가 루트의 `SITE_PUBLIC_HOST` 를 흘려보낸다 |
★ 프론트 `.env` 에 따로 적지 않는다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
이 값이 canonical·og:url·sitemap·IndexNow 통보에 전부 들어간다. 다른 주소에 올릴 거면
`SITE_PUBLIC_HOST` 를 먼저 덮는다. 안 고치면 발행은 성공하는데 사이트맵과 색인 통보가
존재하지 않는 주소를 가리킨다 — **조용히 틀리는 종류라 아무도 눈치채지 못한다.**
★ `origin` 은 payload JSON 에 **구워져 들어간다**(`out/payloads/<slug>.json`). 호스트를 바꾼 뒤
프리렌더만 다시 돌리면 옛 주소가 그대로 나온다. 반드시 **백엔드에서 재발행**해 payload 를
다시 만들어야 한다.
### 1단계 — 서버에서 "굽기"만 재현 (Azure 끔)
배포 대상 서버(접속·경로·이미 물린 포트)는 [SERVERS.md](SERVERS.md) 가 단일 출처다.
```bash
# 서버에서
cp .env.example .env # DB_*, JWT_*, 외부 API 키 채우기
# AZURE_STORAGE_CONNECTION_STRING 은 비워 둔다 ← 이번 단계에서는 안 올린다
# INDEXNOW_KEY 도 비워 둔다 ← 없는 주소를 색인 통보하지 않는다
docker compose up -d
docker compose logs -f solution-worker
```
확인: 사이트 1개 발행 → `curl -I http://<서버>:3000/s/<slug>` 200 ·
`out/s/<slug>/index.html` 생성 · `out/payloads/.status/<slug>.json` 의 `ok: true`.
여기서 막히면 Azure 문제가 아니다. **DB 연결 / payload 디렉토리 마운트 / node_modules** 셋 중 하나다.
### 2단계 — 정적 서빙을 nginx 로 교체 (구현됨)
`serve-sites.mjs` 는 개발용이다. 운영은 `o2o-web4ai-solution-site` 컨테이너가 맡는다(`nginx/site.conf`).
★ **산출물은 named volume `site-out` 에 있다.** 프리렌더가 쓰고 nginx·워커가 읽는다.
호스트 경로가 등장하지 않으므로 재배포로 코드를 갈아엎어도 사이트가 죽지 않는다.
`docker compose down` 으로는 안 지워진다 — `down -v` 만 지운다.
```bash
docker compose up -d # nginx 포함 전부
docker compose exec solution-site ls /usr/share/nginx/html/s # 들여다볼 때
```
확인은 스크립트가 한다. 색인을 기다리지 않고 **지금 볼 수 있는 것만** 본다 —
파일이 그 자리에 있는지, JSON-LD 가 HTML 소스에 들어 있는지,
그리고 **크롤러 UA 로 받았을 때 200 이 나오는지**(CDN·WAF 가 봇을 막는 설정이 기본값인 경우가 있는데
브라우저로는 절대 안 보인다).
```bash
docker compose exec solution-worker python scripts/check_search_ready.py https://<도메인>
```
`/s/<slug>` (끝 슬래시 없음)이 열리는지도 이 스크립트가 본다 — 사장님이 주소창에 치는 형태가 그거다.
### 2-1단계 — TLS
`nginx/site.conf` 에 443 과 인증서 볼륨을 추가한다. Blob 단독으로는 커스텀 도메인 TLS 가
안 되지만 nginx 는 Let's Encrypt 로 끝난다 — CDN 을 억지로 끼울 이유가 없다.
---
## 4. 나중에 — Azure Blob 을 켤 때
**언제 켜나:** 실사용 고객이 붙어 "서버 하나가 죽으면 전 사이트가 동시에 내려간다"를
더는 감수할 수 없을 때. 그 전에는 켜지 않는다 ([ARCHITECTURE.md 3절](ARCHITECTURE.md)).
### 4-1단계 — Azure Blob 붙이기
```bash
# 접두사 함정: HTML 이 /assets/… 를 루트 절대경로로 가리킨다.
# CDN 오리진 경로를 세팅하기 전까지는 접두사를 비운다.
AZURE_STORAGE_CONNECTION_STRING=<연결문자열>
AZURE_STORAGE_CONTAINER='$web'
AZURE_STORAGE_PREFIX=
```
```bash
docker compose exec solution-worker python scripts/republish_all.py --dry-run # 대상 확인
docker compose exec solution-worker python scripts/republish_all.py # 전체 업로드
```
확인: `https://<account>.z*.web.core.windows.net/s/<slug>` 가 **CSS 까지 입혀서** 뜨는지.
스타일이 없으면 접두사/경로 문제다(2절 참고).
### 4-2단계 — 도메인 · TLS · 색인
1. DNS 를 Blob 정적 웹사이트(또는 앞단 CDN)로 연결
2. HTTPS 확인 — 커스텀 도메인 + TLS 는 Blob 단독으로는 안 되고 CDN/Front Door 가 필요하다
3. `SITE_PUBLIC_HOST` 가 실제 도메인과 같은지 재확인 → 다르면 고치고 **전체 재발행**
4. 그 다음에야 `INDEXNOW_KEY` 를 채운다. 백엔드와 `o2o-web4ai-solution-frontend` 이 **같은 값**이어야 한다
(프리렌더가 루트에 `<key>.txt` 를 굽고 검색엔진이 대조한다 — 어긋나면 403)
5. `https://<도메인>/<key>.txt` 와 `https://<도메인>/robots.txt` 가 열리는지 확인
6. 구글은 IndexNow 미지원 → Search Console 에 `https://<도메인>/sitemap.xml` 수동 제출
## 5. 되돌리기
`out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면
`docker compose restart solution-frontend` → 전체 재굽기 → `republish_all.py`.
지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다.