diff --git a/docs/MULTI_TENANCY_ARCHITECTURE.md b/docs/MULTI_TENANCY_ARCHITECTURE.md index 76007ce0..255ff619 100644 --- a/docs/MULTI_TENANCY_ARCHITECTURE.md +++ b/docs/MULTI_TENANCY_ARCHITECTURE.md @@ -114,32 +114,46 @@ invyone (메타 DB) → 회사 정보·라우팅 테이블·SUPER_ADMIN 데이 서브도메인 기반 멀티테넌시는 **환경마다 DNS 해결 방식이 달라야** 한다. 이 절이 이 문서 전체에서 가장 중요. -### 4.1 운영 (프로덕션) +### 4.1 운영 (프로덕션) — **2026-04-24 실운영 중** -**와일드카드 DNS + Nginx 리버스 프록시.** 한 번 세팅하면 회사 무한히 추가해도 추가 작업 0. +**와일드카드 DNS (Porkbun) + Traefik v2.11 리버스 프록시 + Let's Encrypt DNS-01 와일드카드 TLS.** +한 번 세팅하면 회사 무한히 추가해도 추가 작업 0. ``` -[DNS Provider] - A *.invyone.com → 운영서버공인IP +[DNS Provider — Porkbun] + A *.invyone.com → 183.99.177.40 (운영서버 공인IP) + A solution.invyone.com → 183.99.177.40 (메인 사이트, 명시적) -[Nginx conf] - server { - listen 443 ssl; - server_name *.invyone.com; - ssl_certificate .../fullchain.pem; # Let's Encrypt wildcard - location / { - proxy_pass http://frontend:3000; - proxy_set_header Host $host; # ★ Host 헤더 보존 필수 - } - location /api/ { - proxy_pass http://backend:8081; - proxy_set_header Host $host; # ★ - } - } +[Traefik v2.11 — 운영서버 /opt/docker/traefik/docker-compose.yml] + - HTTP (80) → HTTPS 자동 redirect + - HTTPS (443) → K8s NodePort 로 전달 (HostRegexp 로 *.invyone.com 매칭) + - certResolvers: + le — HTTP-01 challenge (개별 도메인용, solution 등) + ledns — DNS-01 challenge via Porkbun API (와일드카드 *.invyone.com) + +[Traefik dynamic config — /opt/docker/traefik/dynamic/invyone-tenant.yml] + HostRegexp(`{sub:[a-z0-9-]+}.invyone.com`) + → service invyone-tenant-frontend (host.docker.internal:30000) + HostRegexp(...) && PathPrefix(`/api`) + → service invyone-tenant-api (host.docker.internal:30081) + tls.domains.sans: "*.invyone.com" # 와일드카드 인증서 발급 조건 + +[Porkbun API Key] + 서버의 /opt/docker/traefik/.env 에 보관 (git 에 없음) + PORKBUN_API_KEY / PORKBUN_SECRET_API_KEY ``` -- **Let's Encrypt 와일드카드 인증서**: `certbot certonly --manual --preferred-challenges=dns -d invyone.com -d '*.invyone.com'` -- 이후 사용자 `http://qnc.invyone.com` 접속 → Nginx 가 Host 헤더 보존해 backend 에 프록시 → SubdomainResolverFilter → 자동 라우팅. +- 사용자 `https://qnc.invyone.com` 접속 → Porkbun DNS 풀림 → Traefik HostRegexp 매칭 + → Host 헤더 보존해 backend 로 프록시 → SubdomainResolverFilter → 자동 라우팅. +- `solution.invyone.com` 은 Host() 명시 라우터로 Traefik priority 높아 와일드카드 위에 탐. +- Traefik 은 **K8s 바깥에 도커로 별도 실행** (`.gitea/workflows/deploy.yml` 과 무관 — 설정 변경은 서버에서 수동 + `docker compose restart traefik`) + +#### 재현 스냅샷 (2026-04-24) + +- **DNS 와일드카드**: Porkbun 콘솔에서 `A * 183.99.177.40` 한 줄 추가 (전파 즉시) +- **Traefik dynamic 라우트**: `notes/gbpark/2026-04-24-traefik-wildcard/invyone-tenant.yml` 참고 +- **Traefik static + env**: `notes/gbpark/2026-04-24-traefik-wildcard/docker-compose.yml` 참고 (Porkbun provider + environment 블록 포함) +- **Let's Encrypt acme-dns.json**: 서버 `/opt/docker/traefik/letsencrypt/` 에 저장 (90일 자동 갱신) ### 4.2 로컬 개발 (본인 머신에서 docker up) @@ -178,9 +192,9 @@ invyone (메타 DB) → 회사 정보·라우팅 테이블·SUPER_ADMIN 데이 | 환경 | 접속 방식 | 설정 필요도 | |---|---|---| -| **운영** | 와일드카드 DNS + Nginx + Let's Encrypt wildcard TLS | 최초 1회, 이후 0 | +| **운영** | Porkbun 와일드카드 DNS + Traefik v2.11 + Let's Encrypt DNS-01 wildcard | **완료 (2026-04-24)** | | **로컬 (직접 up)** | `*.localhost` 자동 | 0 | -| **원격 공유 개발 서버** | `nip.io` (추천) / hosts 편집 / 개발서버 Nginx | 경우 따라 | +| **원격 공유 개발 서버** | `nip.io` (추천) / hosts 편집 | 경우 따라 | --- @@ -222,29 +236,61 @@ cors: `frontend/lib/api/client.ts` 의 분기 **순서가 중요**: ```typescript -// 1) 테넌트 서브도메인 → 직접 백엔드 포트 (Next rewrite 우회) +// 1) 테넌트 서브도메인 → 동일 호스트의 /api (Traefik 이 프록시) +// Host 헤더 보존 + https mixed-content 회피. if (currentHost.endsWith(".invyone.com")) { - return `http://${currentHost}:8083/api`; + return `https://${currentHost}/api`; } -// 2) 프로덕션 메인 도메인 +// 2) (레거시) invion.com 메인 도메인 if (currentHost === "v1.invion.com") return "https://api.invion.com/api"; // 3) NEXT_PUBLIC_API_URL (docker-compose 주입) // 4) localhost 기본값 ``` -> ★ NEXT_PUBLIC_API_URL=/api 같은 rewrite 방식은 **Host 헤더를 변조**해 서브도메인 파싱이 실패한다. 테넌트 도메인은 반드시 직접 포트로. +> ★ NEXT_PUBLIC_API_URL=/api 같은 Next rewrite 는 **Host 헤더를 변조**해 서브도메인 파싱이 실패한다. 테넌트 도메인은 위 1) 분기로 처리해 브라우저가 직접 `https:///api` 로 Traefik 에 요청 → Traefik 이 Host 보존하며 backend 로 프록시. --- -## 7. 운영 배포 체크리스트 +## 6.1 TenantGuard — 미등록 서브도메인 차단 -Git push 한 번으로 자동 배포되는 구조라도, 아래 3가지는 **Git 바깥**이라 수동으로 확인해야 한다: +프론트 레이아웃에 `` 를 wrap. 페이지 마운트 시 호스트 분석 → 예약어(solution/www/admin 등) 제외 → 백엔드 `/api/tenant/check?subdomain=xxx` 호출 → `exists: false` 면 `/tenant-not-found` 로 replace. -- [ ] `.env` 의 `CORS_ALLOWED_ORIGINS` 에 **`*.invyone.com:[*]` 패턴 포함** -- [ ] DB 마이그레이션 `RUN_079`, `RUN_080`, `RUN_081` 운영 DB 에서 **1회 실행** -- [ ] Docker 이미지 재빌드 (Dockerfile 의 `postgresql16-client` 반영) — `docker compose build --no-cache backend-spring` -- [ ] `tenant.provisioning.require-super-admin=true` (프로덕션에선 필수, 개발은 false) -- [ ] (최초 1회) 와일드카드 DNS A 레코드 + Nginx wildcard server_name + Let's Encrypt wildcard 인증서 +| 파일 | 역할 | +|---|---| +| `backend-spring/.../tenant/TenantController.java` | `GET /api/tenant/check` 퍼블릭 API (메타 DB 강제 라우팅) | +| `frontend/lib/tenant/subdomain.ts` | `extractTenantSubdomain(host)` — 예약어면 null | +| `frontend/components/TenantGuard.tsx` | client-side guard, sessionStorage 로 재체크 방지 | +| `frontend/app/tenant-not-found/page.tsx` | 미등록 회사 에러 페이지 (v5 solid+glow) | +| `frontend/app/layout.tsx` | `{children}` | + +예약어 리스트는 백엔드의 `ProvisioningController.RESERVED_SUBDOMAINS` 와 **같은 값으로 유지**해야 함. + +--- + +## 7. 운영 배포 체크리스트 (2026-04-24 완료 상태) + +Git push 한 번으로 자동 배포되는 구조지만, 아래는 **Git 바깥**이라 신규 환경 붙일 때 수동 작업: + +- [x] `CORS_ALLOWED_ORIGINS` `k8s/configmap.yaml` 에 명시 (`*.invyone.com` 패턴 포함) +- [x] DB 마이그레이션 `RUN_079/080/081` 운영 DB 에 1회 실행 완료 +- [x] `docker/deploy/backend-spring.Dockerfile` 에 `postgresql16-client` 포함 +- [x] `TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true` (configmap + deployment env wiring) +- [x] Porkbun 와일드카드 DNS A 레코드 (`*.invyone.com → 183.99.177.40`) +- [x] Traefik dynamic config `*.invyone.com` 라우트 (서버 `/opt/docker/traefik/dynamic/invyone-tenant.yml`) +- [x] Porkbun API 키 서버 `/opt/docker/traefik/.env` 에 박음 +- [x] Let's Encrypt DNS-01 와일드카드 인증서 발급 (Apr 24 ~ Jul 23, 자동 갱신) +- [x] 프론트 `TenantGuard` + `/tenant-not-found` 페이지 + +### ⚠️ K8s Secret 수동 갱신 포인트 + +`.gitea/workflows/deploy.yml` 은 `k8s/secrets-real.yaml` 을 **apply 하지 않는다** (namespace/configmap/pvc/backend/frontend/networkpolicy 만). Secret 내용(DB 비번 등) 이 바뀌면 서버에서 1회 수동 적용: + +```bash +kubectl apply -f /path/to/secrets-real.yaml -n invyone +kubectl rollout restart deployment backend-spring -n invyone +``` + +DB 비번 회전 시 **반드시** Secret 도 같이 갱신해야 pod 이 새 비번으로 재연결. --- @@ -264,6 +310,7 @@ Git push 한 번으로 자동 배포되는 구조라도, 아래 3가지는 **Git - `notes/gbpark/2026-04-24-company-db-provisioning-execution-plan.md` — 초기 설계 + Codex 리뷰 반영 + 실행 체크리스트 - `notes/gbpark/2026-04-24-company-mgmt-ui-schema.md` — 회사관리 UI 필드 ↔ COMPANY_MNG 컬럼 ↔ derived 집계 매핑 +- `notes/gbpark/2026-04-24-traefik-wildcard/` — 운영 Traefik 와일드카드 설정 산출물 (docker-compose.yml, invyone-tenant.yml, db-rename.sh) ---