킹서버(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·실키 미주입 확인.
184 lines
10 KiB
Markdown
184 lines
10 KiB
Markdown
# 배포 · 스토리지
|
||
|
||
> **현재 결정 (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/`** 뿐이다.
|