English {#english}
Tenant Onboard Resource Allocation Flow and Case Management
Purpose: Define the stepwise flow after tenant onboard submit until resources (server, DB, app, routing) are assigned; how the Control Plane manages resource state and cases; scenario-based requirements (app/DB/Redis scale on saturation, plan-based install·Ansible·Zuplo limits). Separate current implementation from items to define or implement. §10 holds R1–R8 implementation status and deploy·verification checklist.
References: api-control-plane-implementation-plan, provision-tenant-workflow-design, provision-tenant-pipeline, resource-optimization-safe-adoption-plan, prego-control-plane (placement.ts, tenants.ts, webhooks-stripe.ts), .github/workflows/provision-tenant.yml.
1. Post-Submit Resource Allocation Flow (steps)
| Step | Name | Owner | Summary |
|---|---|---|---|
| 1 | API receive·record create | Control Plane | POST /v1/tenants or checkout.session.completed → D1 tenants_master, provision_jobs. decidePlacement(tenant_id, plan_tier, region) → create_new_server or target_server_id. Return 202 + job_id. |
| 2 | Queue·trigger | Control Plane | Queue message or workflow_dispatch (job_id, tenant_id, region, create_new_server, target_server_id, subdomain_slug, canonical_hostname). |
| 3 | Resolve server | GitHub Actions | target_server_id → GET /internal/nodes/:node_id for host. create_new_server → Pulumi up (new Hetzner server). R1 GET nodes; R3 POST /internal/nodes after Pulumi. |
| 4 | DNS | GitHub Actions | Cloudflare: tenant canonical + optional subdomain CNAME. |
| 5 | Ansible | GitHub Actions + Ansible | Dynamic inventory from resolve-server; playbook (DB/Redis, Frappe site, API Key). R5: billing/plan_limits → Ansible for plan memory/CPU/memswap. |
| 6 | Zuplo Sync | GitHub Actions | Register API Key; slug, subdomain, plan_tier. R6: plan-based Rate Limit (workflow or Runbook). |
| 7 | Callback | Control Plane | POST /internal/provision-complete → provision_jobs Completed/Failed, tenant_runtime, TENANT_ORIGINS KV. User: GET /v1/jobs/:id. |
2. Placement and Resource State (current)
Placement: First-Fit on D1 nodes (region, status=Active). No capacity (memory, tenant_count) or plan-based filtering yet. Limitation: No GET /internal/nodes/:node_id (R1); capacity and plan_tier not used.
Tables: nodes (node_id, region, host, status), server_metrics (pushed by nodes; not used in Placement), tenants_master, tenant_runtime, provision_jobs.
3. Scenarios (summary)
- A — App server memory saturated: Define saturation (e.g. memory_pct > 85%). Placement: use server_metrics so only “capacity-available” nodes are candidates; or set node to Maintenance. After Pulumi, register node (R3).
- B — DB/Redis scale: Define DB per-server tenant cap; scale or add Replica (separate plan). Redis per redis-separation plan.
- C — Plan-based install·limits: Plan-based placement (shared vs dedicated), plan_limits to Ansible (R5), Zuplo plan Rate Limit (R6).
- D — Case handling: Active node with capacity (R2); no node → create_new_server + R3; Maintenance excluded; plan_tier filter (R4); DB/Redis placement policy (R7).
4. Current vs Required (R1–R8)
Implemented: Submit → Placement (First-Fit), workflow_dispatch, workflow (resolve-server, Pulumi, Ansible, Zuplo, callback), POST /internal/nodes, server_metrics (dashboard only).
Required: R1 GET /internal/nodes/:node_id · R2 Placement capacity (server_metrics) · R3 Register node after Pulumi · R4 Plan-based placement (nodes.role) · R5 plan_limits to Ansible · R6 Zuplo plan Limit · R7 DB/Redis scale·placement policy · R8 Saturation detection·alert.
5–9. Docs, R1–R8 detail, Phase, Runbook, References
- §5: Runbook “post-submit flow” and single ref for resource-allocation·placement.
- §6: R1–R8 detailed requirements (API contract, schema, queries, workflow steps).
- §7: Phase 1 (R1, R3) → Phase 2 (R2, R5) → Phase 3 (R4, R6) → Phase 4 (R8, R7).
- §8: Runbook procedures (node register, Maintenance, saturation, plan limit, DB/Redis).
- §9: Reference docs.
10. R1–R8 Implementation Status and Checklist
Status: R1 (GET nodes), R2 (Placement capacity), R3 (node register after Pulumi), R4 (nodes.role, plan filter), R5 (plan_limits via billing/Ansible), R6 (Zuplo plan tier·Runbook §5), R7 (GET /internal/db-host, Runbook), R8 (saturation·alert, dashboard, Cron). Env for thresholds (e.g. 85%, 10 tenants).
Deploy: D1 migration 0014_nodes_role; Cron; server-metrics push; optional env PLACEMENT_, SATURATION_, DB_HOST_*.
Verify: scripts/smoke-verify-control-plane.sh (R1, R5, R7); dashboard; test provision.
Full step table, scenario tables, R1–R8 detail tables, Phase table, Runbook table, and verification table are in the Korean section below.
한국어 {#korean}
테넌트 온보드 제출 후 리소스 할당 플로우 및 케이스별 관리 기획서
목적: 테넌트 온보드 정보 submit 이후 리소스가 할당되는 단계별 플로우를 정리하고, Control Plane이 리소스 상태·케이스에 따라 내부적으로 어떻게 관리하는지, 시나리오별(서버 메모리 포화 시 앱/DB/Redis 스케일, 패키지별 설치·Ansible·Zuplo limit 등) 요건을 정의한다. 현재 구현 상태와 추가로 정의·구현이 필요한 항목을 구분한다. §10에 R1–R8 구현 완료 상태 및 배포·검증 체크리스트를 둔다.
참조: api-control-plane-implementation-plan, provision-tenant-workflow-design, provision-tenant-pipeline, resource-optimization-safe-adoption-plan, prego-control-plane (placement.ts, tenants.ts, webhooks-stripe.ts), .github/workflows/provision-tenant.yml.
1. Submit 후 리소스 할당 플로우 (단계별)
테넌트 온보드 정보(회사명·서브도메인·관리자·플랜 등)가 제출된 뒤, 실제 리소스(서버·DB·앱·라우팅)가 할당되기까지의 단계를 정리한다.
| 단계 | 단계명 | 담당 | 설명 | 비고 |
|---|---|---|---|---|
| 1 | API 수신·레코드 생성 | Control Plane | www 또는 Stripe Webhook에서 POST /v1/tenants(무료) 또는 checkout.session.completed(유료) 수신. D1에 tenants_master, provision_jobs 행 삽입. Placement 결정: decidePlacement(tenant_id, plan_tier, region) 호출 → create_new_server 또는 target_server_id(기존 노드). | 202 + job_id 반환. |
| 2 | 작업 큐·트리거 | Control Plane | (Option B) prego-provision-queue에 메시지 전송. (Option A) workflow_dispatch URL·GITHUB_TOKEN 있으면 GitHub Actions provision-tenant.yml 직접 호출. 입력: job_id, tenant_id, region, create_new_server, target_server_id, subdomain_slug, canonical_hostname. | Queue 사용 시 consumer가 idempotent하게 workflow_dispatch 호출. |
| 3 | 서버 해석(Resolve Server) | GitHub Actions | target_server_id 있음: Control Plane GET /internal/nodes/:node_id 로 노드 host(server_ip) 조회. create_new_server = true: Pulumi up으로 새 Hetzner 서버(+ DB 서버) 생성, stack은 region(sg/us/eu). 둘 다 없음: 실패. | R1 GET /internal/nodes/:node_id 구현. R3 Pulumi 성공 후 POST /internal/nodes 로 노드 등록. |
| 4 | DNS 생성 | GitHub Actions | Cloudflare에 tenant canonical (tenant-xxx.pregoi.com) 및 선택 subdomain CNAME 생성. | tenant-dns job. |
| 5 | Ansible 프로비저닝 | GitHub Actions + Ansible | resolve-server 출력(server_ip, db_server_ip)으로 인벤토리 동적 생성. Ansible playbook 실행: DB·Redis 역할(해당 시), Frappe 사이트 설치(bench new-site, API Key 발급). billing(company_name, abbr, plan_limits) GET /internal/billing → extra vars. | R5 구현: billing 응답에 plan_limits 포함, frappe_bench role에서 플랜별 메모리·CPU·memswap 적용. |
| 6 | Zuplo Sync | GitHub Actions | Ansible에서 발급한 API Key를 Zuplo에 등록. slug·subdomain·plan_tier 메타 전달. | R6 구현: 워크플로에서 plan_tier 조회 후 zuplo_sync —plan-tier 전달; Runbook §5에 플랜별 Rate Limit 수동 절차. |
| 7 | 콜백·상태 반영 | Control Plane | 워크플로가 POST /internal/provision-complete 호출. D1 provision_jobs.status → Completed/Failed, tenant_runtime 갱신, TENANT_ORIGINS KV에 hostname → origin 기록. | 사용자는 GET /v1/jobs/:id 로 상태·origin_url 조회. |
2. Control Plane의 리소스·케이스별 내부 관리 (현재 구현)
Control Plane이 리소스 상태와 케이스에 따라 내부적으로 어떻게 다루는지 정리한다.
2.1 Placement(배치) 결정 — 현재 로직
| 항목 | 현재 구현 | 데이터 소스 |
|---|---|---|
| 입력 | tenant_id, plan_tier, requested_region | tenants.ts 또는 webhooks-stripe.ts에서 호출. |
| 규칙 | Region: requested_region이 sg/us/eu이면 사용, 아니면 기본 sg. Enterprise일 때 requested_region 반영. | resolveRegion(). |
| 노드 선택 | First-Fit: nodes 테이블에서 region = ? AND status = 'Active' 인 첫 번째 node_id 1개 선택. | D1 nodes. |
| 결과 | 노드 있음 → create_new_server: false, target_server_id: node_id. 없음 → create_new_server: true, target_server_id 없음. | placement.ts. |
한계(현재)
- 용량(capacity) 미반영: 메모리·CPU·이미 올라간 테넌트 수를 보지 않고 “Active인 아무 노드 1개”만 선택.
- 플랜별 분리 없음: Free/Pro/Enterprise 동일 규칙. 전용 노드·공유 노드 구분 없음.
- 노드 정보 조회: workflow에서 GET /internal/nodes/:node_id 로 host를 가져오려 하나, Control Plane에는 POST /internal/nodes(등록)만 있고 GET by id 는 없을 수 있음. → 워크플로가 실패하거나 다른 경로로 host를 써야 함.
2.2 리소스 상태를 반영하는 테이블·API (현재)
| 리소스 | 저장 위치 | 용도 | 비고 |
|---|---|---|---|
| 노드(서버) | D1 nodes (node_id, region, host, status) | Placement 시 “이 region에 Active 노드가 있는가” 판단. | tenant_count·메모리/CPU 여유 등은 없음. |
| 노드 메트릭 | D1 server_metrics (node_id, cpu_pct, memory_pct, disk_pct, tenant_count, updated_at) | POST /internal/server-metrics 로 노드가 푸시. 대시보드·메트릭 API에서 집계. | Placement 로직에서는 아직 미사용. |
| 테넌트 | D1 tenants_master, tenant_runtime | 테넌트별 상태·호스트·origin_url. | 프로비저닝 완료 후 갱신. |
| 작업 | D1 provision_jobs | job_id, tenant_id, status, region, plan_tier, infra_mode(create_new_server / use_existing). | 워크플로 입력·콜백으로 갱신. |
정리하면, **“리소스 상태에 따른 케이스별 관리”**는 현재 Placement에서 region + Active 여부만 쓰고, 메모리 포화·테넌트 수·플랜별 전용/공유 등은 반영되지 않는다.
3. 시나리오별 요구 사항 정리
아래 시나리오는 현재 구현과 추가로 정의·구현이 필요한 것으로 나눈다.
3.1 시나리오 A: Hetzner 서버 1(앱 서버) 메모리 포화
| 질문 | 현재 구현 | 추천·추가 정의 |
|---|---|---|
| 감지 | server_metrics에 memory_pct·tenant_count 푸시 가능. 자동 “포화” 판정·알림은 없음. | 정의 필요: 포화 기준(예: memory_pct > 85% 또는 tenant_count > N). Control Plane 또는 Autoscaler가 주기 판단. |
| 대응 — 앱 서버 증설 | Placement에서 “노드 없으면 create_new_server”. 기존 노드가 있으면 무조건 그 노드에 배치하므로, “메모리 꽉 찬 노드는 제외” 로직 없음. | 추천: Placement 시 server_metrics 기반 “여유 있는 노드”만 후보로 두거나, 용량 초과 노드는 status=Maintenance 로 두어 제외. create_new_server 시 Pulumi로 앱 서버 1대 추가 프로비저닝 (이미 workflow에 Pulumi up 경로 있음). |
| 대응 — 새 노드 등록 | Pulumi로 새 서버 생성 후, 해당 서버를 POST /internal/nodes 로 등록하는 단계는 워크플로/부트스트랩에 없음. | 추천: Pulumi up 직후(또는 Ansible 초기 설정 후) 노드 등록 단계 추가. 그러면 다음 Placement부터 새 노드가 후보가 됨. |
3.2 시나리오 B: DB 서버·Redis 증설
| 질문 | 현재 구현 | 추천·추가 정의 |
|---|---|---|
| DB 서버 부족 | 인벤토리에서 db_host는 resolve-server 출력 또는 vars.PREGO_DB_SERVER_IP. “DB 서버를 늘린다”는 플로우 없음. | 추천: (1) 정책 정의: DB 서버 1대당 테넌트 상한 또는 연결 수 상한. (2) 초과 시 Pulumi로 DB 서버 추가 또는 기존 DB에 Replica 추가(별도 기획). (3) Placement와 별도로 “이 테넌트의 db_host” 를 결정하는 DB 배치 정책 (플랜별 전용/공유 등). |
| Redis 증설 | Redis는 인벤토리/역할에서 redis_host 등으로 설정. Redis 전용 서버 증설 플로우는 redis-separation 기획에 의존. | 추천: Redis 메모리/연결 수 기준 “Redis 증설” 트리거·절차를 runbook 또는 별도 기획에 정의. Control Plane이 “Redis 노드”를 관리할지, Ansible/Pulumi만 할지 결정. |
3.3 시나리오 C: 테넌트 등록 시 패키지에 따른 설치·제한
| 질문 | 현재 구현 | 추천·추가 정의 |
|---|---|---|
| 어느 앱 서버에 Frappe 설치할지 | Placement 결과로 결정: target_server_id(기존 노드) 또는 create_new_server(새 서버). **패키지(Free/Pro/Enterprise)**는 Placement 입력(plan_tier)으로 쓰이지만, “Enterprise는 전용 노드” 같은 플랜별 배치 규칙은 없음. | 추천: (1) 플랜별 배치 정책: 예) Free/Pro → 공유 노드 풀, Enterprise → 전용 노드 또는 전용 region. (2) Placement에서 plan_tier에 따라 후보 노드 필터(예: nodes.plan_tier 또는 node_capacity.plan 허용 목록). (3) 어느 DB/Redis에 붙일지도 플랜별로 정할 수 있음(현재는 region·인벤토리 기본값). |
| Ansible에 plan별 설정 전달 | billing에서 company_name, abbr만 extra vars로 전달. plan_limits(메모리·CPU·memswap 등)는 워크플로/Ansible에 동적 전달되지 않음. | 추천: (1) plan_limits 테이블 또는 설정: Free 1GB, Pro 2GB, Enterprise 8GB 등. (2) 워크플로에서 GET /internal/billing 또는 GET /internal/plan-limits 등으로 plan_tier별 limit 조회 후 Ansible -e plan_limits=... 전달. (3) Ansible에서 docker-compose·role에 plan_limits 반영(resource-optimization §2.4). |
| Zuplo Rate Limit | Zuplo Sync로 API Key·slug 등록. 플랜별 Rate Limit은 Zuplo 대시보드 또는 정책에서 수동/정적 설정 가능. Control Plane·워크플로에서 동적으로 “이 테넌트는 Pro이므로 N req/min” 설정하지 않음. | 추천: (1) 정책 정의: 플랜별 Rate Limit 수치(Free 100/min, Pro 1000/min 등). (2) Zuplo API로 테넌트/키별 limit 설정이 가능하면, 프로비저닝 완료 후 Control Plane 또는 워크플로에서 Zuplo API 호출로 limit 적용. (3) 불가 시 Runbook에 “테넌트 생성 후 Zuplo에서 수동 설정” 절차 명시. |
3.4 시나리오 D: 리소스 상태별 “케이스” 관리 (Control Plane 내부)
| 케이스 | 현재 구현 | 추천·추가 정의 |
|---|---|---|
| 케이스 1: 해당 region에 Active 노드 있음 | 해당 노드 1개 선택(target_server_id). 용량 무관. | 추천: “여유 있는 노드”만 후보로 두기 위해 server_metrics + tenant_count 조건 추가(예: memory_pct < 80, tenant_count < max_per_node). |
| 케이스 2: region에 Active 노드 없음 | create_new_server: true. Pulumi로 새 서버 생성. | 추천: Pulumi 성공 후 노드 등록 단계 필수. 새 노드의 용량·역할(app/db/redis)을 D1 또는 설정에 기록할지 정의. |
| 케이스 3: 노드가 Maintenance | nodes.status = ‘Maintenance’ 인 노드는 이미 First-Fit에서 제외(Active만 조회). | 유지. 필요 시 “Maintenance로 전환” Runbook 절차 명시. |
| 케이스 4: 플랜별 전용/공유 | 없음. | 추천: nodes에 plan_tier 또는 role(shared/dedicated) 컬럼 추가, Placement에서 plan_tier에 맞는 노드만 선택. |
| 케이스 5: DB/Redis 전용 리소스 | DB 호스트는 워크플로·인벤토리에서 결정. Control Plane이 “DB 노드 풀”을 관리하지 않음. | 추천: “DB 배치”를 Placement와 분리해도 됨. “이 region의 기본 db_host” + (선택) plan_tier별 db_host 매핑 테이블. Redis도 유사. |
4. 현재 구현 상태 vs 추가 정의 필요 항목 요약
4.1 현재 구현된 것
- Submit → Control Plane에서 tenants_master·provision_jobs 삽입, decidePlacement(region, Active 노드 1개 First-Fit) 호출.
- Option B: Queue 전송 또는 직접 workflow_dispatch. 입력에 create_new_server, target_server_id, region 등 전달.
- 워크플로: target_server_id 있으면 “기존 노드 host 조회”(GET /internal/nodes/:id 기대), create_new_server면 Pulumi up. 이후 인벤토리 생성 → Ansible → Zuplo Sync → provision-complete 콜백.
- 노드: POST /internal/nodes 로 등록. nodes 테이블에 node_id, region, host, status.
- server_metrics: 노드가 CPU/메모리/disk/tenant_count 푸시. 대시보드/메트릭 API에서만 사용, Placement에는 미사용.
- Ansible: tenant_id, canonical_hostname, company_name, abbr 전달. plan_limits·플랜별 자원 제한은 동적 전달 안 함.
- Zuplo: API Key·slug 등록. 플랜별 Rate Limit은 Control Plane/워크플로에서 설정 안 함.
4.2 추가로 정의·구현이 필요한 것
| 번호 | 항목 | 설명 |
|---|---|---|
| R1 | GET /internal/nodes/:node_id | 워크플로가 “기존 노드 host” 조회 시 사용. Control Plane에 GET by id 가 없으면 구현 또는 워크플로가 Pulumi output/변수만 쓰도록 변경. |
| R2 | Placement에 용량 반영 | server_metrics(또는 tenant_count)를 이용해 “여유 있는 노드”만 후보로 선택. 기준값(메모리 < 85%, tenant_count < N) 정의. |
| R3 | Pulumi 후 노드 등록 | create_new_server로 새 서버 생성 후, 해당 서버를 POST /internal/nodes 로 등록하는 단계를 워크플로 또는 부트스트랩에 추가. |
| R4 | 플랜별 배치 정책 | Free/Pro → 공유 노드, Enterprise → 전용 등. nodes에 plan_tier 또는 role 추가, decidePlacement에서 plan_tier별 필터. |
| R5 | plan_limits 동적 전달 | plan_tier별 메모리·CPU·memswap 한도 정의. GET /internal/plan-limits 또는 billing 확장으로 Ansible에 전달, docker/role에 반영. |
| R6 | Zuplo 플랜별 Limit | 플랜별 Rate Limit 수치 정의. 프로비저닝 후 Zuplo API로 키별 limit 설정하거나, Runbook에 수동 설정 절차 명시. |
| R7 | DB/Redis 증설·배치 정책 | “DB 서버 부족” 시 증설 절차, “테넌트별 db_host 결정 규칙”(플랜별·region별). Redis도 유사. 별도 기획 또는 Runbook으로 정의. |
| R8 | 포화 감지·알림 | server_metrics 기준 “노드 포화” 판정 및 알림(Slack·Webhook) 또는 Autoscaler 연동. |
5. 문서·Runbook 반영 제안
- Runbook: “테넌트 온보드 제출 후 플로우” 절에 §1 단계표 요약 반영. “리소스 할당은 Placement(기존 노드 또는 Pulumi 신규) → 워크플로 → Ansible → Zuplo → 콜백” 임을 명시.
- 기획서: 본 문서를 resource-allocation·placement 의 단일 참조로 두고, R1~R8 은 별도 구현 태스크 또는 기획(plan_limits, Zuplo limit, DB/Redis 정책)으로 쪼개어 진행.
6. R1~R8 상세 요건 (기획 확장)
6.1 R1: GET /internal/nodes/:node_id
| 항목 | 내용 |
|---|---|
| 목적 | 워크플로 resolve-server 단계에서 target_server_id로 노드의 host(server_ip) 를 조회. |
| 인증 | Bearer INTERNAL_API_KEY (기존 internal API와 동일). |
| 요청 | GET /internal/nodes/:node_id (path parameter). |
| 응답(200) | { "node_id": "...", "region": "sg", "host": "1.2.3.4", "status": "Active" }. host는 Ansible 인벤토리의 ansible_host·server_ip로 사용. |
| 응답(404) | node_id에 해당하는 행이 없으면 { "error": "Node not found" }. |
| 데이터 소스 | D1 nodes 테이블. 기존 스키마(node_id, region, host, status)로 충분. |
| 선택 | db_host를 노드별로 다르게 둘 경우, nodes에 db_host 컬럼 추가 후 응답에 포함. 또는 region별 기본 db_host는 vars/워크플로에서만 관리. |
6.2 R2: Placement에 용량 반영
| 항목 | 내용 |
|---|---|
| 목적 | ”메모리·테넌트 수 여유가 있는” 노드만 Placement 후보로 선택. |
| 데이터 소스 | D1 server_metrics (node_id, memory_pct, tenant_count, updated_at). 미푸시 노드는 용량 조건 없이 허용하거나, updated_at이 N분 이내인 노드만 후보로 둘지 정책 정의. |
| 조건 예시 | memory_pct < 85 AND tenant_count < max_tenants_per_node(예: 10). 상한은 설정(vars)·상수로 관리. |
| 쿼리 | nodes와 server_metrics를 LEFT JOIN, WHERE region = ? AND status = ‘Active’ AND (memory_pct IS NULL OR memory_pct < 85) AND (tenant_count IS NULL OR tenant_count < ?) ORDER BY tenant_count ASC(또는 memory_pct ASC) LIMIT 1. “여유 많은 순” 선택. |
| 폴백 | 해당 region에 조건 만족 노드가 없으면 create_new_server: true (현행과 동일). |
6.3 R3: Pulumi 후 노드 등록
| 항목 | 내용 |
|---|---|
| 목적 | create_new_server로 Pulumi가 새 서버를 띄운 뒤, 그 서버를 nodes에 등록해 다음 Placement부터 후보가 되도록 함. |
| 실행 시점 | 워크플로에서 Pulumi up 성공 직후, 또는 Ansible 프로비저닝이 끝난 뒤(서버가 “사용 가능” 상태일 때). |
| 방식 | (1) 워크플로 단계 추가: Pulumi output(server_ip, region)으로 POST /internal/nodes 호출. body: { "node_id": "node_sg_<timestamp>", "region": "<inputs.region>", "host": "<pulumi server_ip>", "status": "Active" }. node_id 생성 규칙은 정책(region_순번, Pulumi stack name 등)으로 통일. (2) 대안: Ansible playbook 마지막에 “노드 등록” 태스크로 Control Plane API 호출. |
| 주의 | 동일 server_ip로 중복 등록되지 않도록 node_id를 유일하게 부여하거나, POST /internal/nodes가 ON CONFLICT로 host만 갱신하도록 설계. |
6.4 R4: 플랜별 배치 정책
| 항목 | 내용 |
|---|---|
| 목적 | Free/Pro는 공유 노드, Enterprise는 전용(또는 전용 region) 등 plan_tier에 따른 노드 풀 분리. |
| 스키마 옵션 | (A) nodes에 allowed_plans TEXT (예: “free,professional”) 또는 role TEXT (예: “shared”, “dedicated”). (B) 별도 테이블 node_plan_capacity(node_id, plan_tier, max_tenants). |
| Placement 로직 | plan_tier가 “enterprise”이면 role=‘dedicated’ 또는 allowed_plans에 ‘enterprise’ 포함된 노드만 후보. free/professional이면 shared 또는 allowed_plans에 해당 플랜 포함된 노드. |
| 초기 도입 | 스키마 확장 전에는 모든 노드를 “공유”로 두고 R4 없이 진행 가능. 전용 노드가 생기면 nodes에 role 또는 allowed_plans 추가 후 Placement에 필터 적용. |
6.5 R5: plan_limits 동적 전달
| 항목 | 내용 |
|---|---|
| 목적 | Ansible에서 Docker 컨테이너 메모리·CPU·memswap 한도를 plan_tier별로 다르게 적용. (resource-optimization §2.4) |
| 저장소 | (A) D1 테이블 plan_limits(plan_tier, memory_mb, cpus, memswap_mb). (B) Control Plane wrangler vars 또는 GET /internal/plan-limits 응답으로 고정 JSON. (C) 워크플로 레포의 vars·기본값만 사용(Control Plane 연동 없음). |
| API(선택) | GET /internal/plan-limits 또는 GET /internal/billing?tenant_id=... 응답에 plan_limits 객체 포함. 예: { "free": { "memory_mb": 1024, "cpus": 1 }, "professional": { "memory_mb": 2048 }, "enterprise": { "memory_mb": 8192 } }. |
| 워크플로 | billing 또는 plan-limits 조회 후 -e plan_limits='{"memory_mb":2048}' 형태로 Ansible에 전달. playbook에서 변수로 docker memory/cpus 설정. |
| Ansible | role에서 plan_limits.memory_mb, plan_limits.cpus 등으로 docker_container resource 제한. 미전달 시 기본값(예: 1GB) 사용. |
6.6 R6: Zuplo 플랜별 Limit
| 항목 | 내용 |
|---|---|
| 목적 | 테넌트(API Key)별 Rate Limit을 플랜에 따라 다르게 적용. |
| 정책 예시 | Free 100 req/min, Basic 300, Professional 1000, Enterprise 5000 (수치는 사업 정책으로 확정). |
| 구현 경로 | (1) Zuplo API: 키 생성·업데이트 시 rate limit 설정 가능하면, 프로비저닝 완료 후(워크플로 또는 Control Plane) Zuplo API 호출로 tenant_id·plan_tier에 맞는 limit 적용. (2) Zuplo 대시보드: 플랜별로 “API Product” 또는 Policy를 나누고, 키를 해당 product에 매핑. (3) Runbook: API로 불가 시 “테넌트 생성 후 Zuplo에서 해당 키에 N req/min 수동 설정” 절차 문서화. |
| 연동 | 워크플로 zuplo-sync 단계 이후에 “Set rate limit by plan” 단계 추가하거나, Zuplo Sync 스크립트에 plan_tier 인자 전달 후 스크립트 내에서 Zuplo API 호출. |
6.7 R7: DB/Redis 증설·배치 정책
| 항목 | 내용 |
|---|---|
| 목적 | DB 서버 1대당 테넌트 상한 초과 시 DB 증설 절차, 테넌트별 db_host 결정 규칙. Redis도 유사. |
| 정책 | (1) region별 기본 db_host: 워크플로 vars 또는 Pulumi output. (2) 상한: DB 서버당 최대 M개 테넌트(또는 연결 수). 초과 시 “새 DB 서버 프로비저닝” Runbook 또는 Pulumi 스택 추가. (3) 플랜별 분리: Enterprise는 전용 DB 호스트 풀 등 — 별도 기획(saas-db-separation)과 정렬. |
| Control Plane 역할 | GET /internal/db-host?region= 구현됨. region별 env(DB_HOST_SG/US/EU)로 db_host 반환. plan_tier는 추후 확장. Runbook db-redis-scaling-policy-r7 §5. |
| Redis | redis-separation 기획에 따라 Redis 전용 서버 증설·redis_host 배치 정책을 Runbook 또는 별도 기획에 정의. |
6.8 R8: 포화 감지·알림
| 항목 | 내용 |
|---|---|
| 목적 | 노드의 memory_pct·tenant_count가 포화 기준을 넘으면 운영자에게 알림. (자동 스케일은 R2·R3와 조합.) |
| 포화 기준 | 예: memory_pct > 85% OR tenant_count >= max_tenants_per_node. |
| 실행 | (1) Cron Worker: 주기적으로 server_metrics 조회 후 기준 초과 노드가 있으면 Slack·Webhook·이메일 발송. (2) 기존 Autoscaler: 이미 scaling_events 등이 있으면 “포화 이벤트”를 같은 파이프라인에 넣어 알림만 하거나 scale-out 트리거로 사용. (3) 대시보드: /internal/dashboard에서 “포화 노드” 뱃지 표시. |
| 데이터 | server_metrics + 기준값(설정 또는 상수). |
7. 구현 Phase·우선순위·의존성
| Phase | 항목 | 우선순위 | 의존성 | 비고 |
|---|---|---|---|---|
| Phase 1 | R1 GET /internal/nodes/:node_id | P0 | 없음 | 워크플로가 이미 해당 API를 호출한다고 가정하므로 선구현 권장. 미구현 시 resolve-server가 기존 노드일 때 실패. |
| Phase 1 | R3 Pulumi 후 노드 등록 | P1 | R1(선택) | 새 서버가 다음 배치에 쓰이려면 필요. 워크플로 단계 추가만으로 가능. |
| Phase 2 | R2 Placement 용량 반영 | P1 | server_metrics 푸시 유지 | 노드가 메트릭을 푸시하고 있어야 의미 있음. 기준값(85%, max_tenants_per_node) 정의 필요. |
| Phase 2 | R5 plan_limits 동적 전달 | P2 | 없음 | Ansible role에서 변수 수신·docker 제한 적용 선행 후, 워크플로에서 전달만 추가해도 됨. |
| Phase 3 | R4 플랜별 배치 정책 | P2 | nodes 스키마 확장 | 전용 노드가 실제로 운영될 때 도입. |
| Phase 3 | R6 Zuplo 플랜별 Limit | P2 | Zuplo API/정책 확인 | API 지원 여부에 따라 구현 또는 Runbook만. |
| Phase 4 | R8 포화 감지·알림 | P3 | R2·server_metrics | 자동 스케일 없이 알림만 해도 운영에 도움. |
| Phase 4 | R7 DB/Redis 증설·배치 | P3 | saas-db-separation 등 | 별도 기획과 함께 정책·Runbook으로 정의 후 필요 시 구현. |
요약: 먼저 R1(노드 조회 API)과 R3(Pulumi 후 노드 등록)을 구현하면, 현재 워크플로가 기존 노드·신규 서버 모두에서 동작하고 새 서버가 재사용되도록 연결된다. 이어서 R2(용량 반영)·R5(plan_limits)를 넣고, R4·R6·R8·R7은 정책 확정 후 단계적으로 도입하는 것을 권장한다.
8. Runbook·운영 절차 제안
| 절차 | 내용 |
|---|---|
| 노드 수동 등록 | 새 Hetzner 서버를 Pulumi 외 경로로 생성한 경우, POST /internal/nodes 로 node_id·region·host·status를 등록. Bearer INTERNAL_API_KEY 필요. |
| 노드 Maintenance 전환 | 해당 노드에 새 테넌트를 배치하지 않으려면, nodes.status를 ‘Maintenance’로 변경(수동 UPDATE 또는 향후 PATCH API). Placement는 Active만 선택하므로 자동 제외. |
| 포화 시 대응 | R8 구현: 대시보드 Node health에 포화 뱃지·saturated_node_ids. ALERT_WEBHOOK_URL 설정 시 Cron(매시)으로 포화 노드 목록 POST. 수동 대응은 (1) 새 서버(Pulumi) 후 노드 등록, (2) 또는 노드 Maintenance. |
| 플랜별 Limit(수동) | provision-tenant-pipeline §5: Zuplo 플랜별 Rate Limit 정책 표·수동 설정 절차. |
| 노드 role(전용/공유) | R4 구현: POST /internal/nodes 시 role=‘shared' |
| DB/Redis 증설 | db-redis-scaling-policy-r7: 정책·수동 증설 절차. |
9. 참조 문서
- api-control-plane-implementation-plan §5·§6
- provision-tenant-workflow-design
- provision-tenant-pipeline
- resource-optimization-safe-adoption-plan §2.4 (plan_limits)
- tenant-onboarding-flow
- db-redis-scaling-policy-r7 (R7)
- prego-control-plane-dashboard
10. 구현 완료 상태 (R1–R8) 및 배포·검증 체크리스트
구현 완료 요약: R1(노드 조회 API)·R2(Placement 용량)·R3(Pulumi 후 노드 등록)·R4(플랜별 role)·R5(plan_limits)·R6(Zuplo 플랜별 Limit)·R7(GET /internal/db-host·워크플로 연동)·R8(포화 감지·알림). R2/R8 기준값 및 R7 db_host는 env로 조정 가능. prego-control-plane, Prego provision-tenant.yml, prego-ansible, Runbook 반영 완료.
아래 R1–R8은 기획서 §6·§7에 따라 구현 완료된 항목이다. 배포 후 단계별 검증 방법은 prego-control-plane-dashboard §배포 후 검증 (R1–R8) 참고. R1·R5·R7 스모크 검증 스크립트: scripts/smoke-verify-control-plane.sh (CONTROL_PLANE_URL, INTERNAL_API_KEY).
| 번호 | 항목 | 구현 위치 | 검증 요약 |
|---|---|---|---|
| R1 | GET /internal/nodes/:node_id | prego-control-plane: internal.ts handleNodeGet, index.ts 라우트 | Bearer + node_id 로 200 시 host·region·status·role 반환, 없으면 404. |
| R2 | Placement 용량 반영 | prego-control-plane: placement.ts | server_metrics LEFT JOIN, memory_pct·tenant_count 상한(기본 85, 10). env PLACEMENT_MEMORY_PCT_MAX, PLACEMENT_MAX_TENANTS_PER_NODE 로 변경 가능. |
| R3 | Pulumi 후 노드 등록 | Prego .github/workflows/provision-tenant.yml | create_new_server 성공 시 “Register new node” 단계에서 POST /internal/nodes (node_region_timestamp, Active). |
| R4 | 플랜별 배치 정책 | prego-control-plane: migration 0014_nodes_role.sql, placement.ts, internal.ts (nodes role) | nodes.role = shared|dedicated. Enterprise → dedicated만; 그 외 → shared 또는 NULL. |
| R5 | plan_limits 동적 전달 | prego-control-plane: internal.ts (getPlanLimitsForTier, billing·plan-limits), workflow billing_extra.json, prego-ansible frappe_bench | billing 응답 plan_limits, Ansible -e @billing_extra.json, role에서 memory/cpus/memswap 오버라이드. |
| R6 | Zuplo 플랜별 Limit | Prego infra/zuplo_sync.ts —plan-tier, workflow plan_tier 조회·전달, provision-tenant-pipeline.md §5 | 메타에 planTier 저장; Runbook에 플랜별 한도 표·수동 설정 절차. |
| R7 | DB/Redis 증설·배치 | prego-control-plane GET /internal/db-host (env DB_HOST_SG/US/EU), Runbook db-redis-scaling-policy-r7.md | region별 db_host 반환(선택). 정책·수동 DB 증설 절차, Redis는 redis-migration·별도 기획 참조. |
| R8 | 포화 감지·알림 | prego-control-plane: internal.ts getSaturatedNodeIds·metrics/summary saturated, dashboard-html 뱃지, index.ts scheduled, wrangler [triggers] crons | 대시보드 포화 뱃지·행 강조; ALERT_WEBHOOK_URL 시 매시 Cron POST. 포화 기준 env SATURATION_MEMORY_PCT, SATURATION_MAX_TENANTS (기본 85, 10). |
배포 후 권장 순서: (1) CONTROL_PLANE_URL=... INTERNAL_API_KEY=... ./scripts/smoke-verify-control-plane.sh 로 R1·R5·R7 스모크 확인 (2) prego-control-plane-dashboard 접속 후 API 키 입력·Node health·메트릭 확인 (3) 필요 시 테스트 프로비저닝 1회로 워크플로·노드 등록·db_host 연동 확인.
배포 전 체크리스트
- D1 마이그레이션:
0014_nodes_role.sql적용 (wrangler d1 migrations apply prego-d1 --remote). - Cron: wrangler.toml
[triggers] crons배포 시 매시 정각(UTC) 실행. 알림 불필요 시ALERT_WEBHOOK_URL미설정. - 노드 메트릭: Placement·R8이 동작하려면 노드에서 POST /internal/server-metrics 푸시 필요.
- 전용 노드: Enterprise 전용 배치를 쓰려면 해당 노드를 POST /internal/nodes 로
role: 'dedicated'등록. - 선택 env: R2/R8 기준값 조정 시
PLACEMENT_MEMORY_PCT_MAX,PLACEMENT_MAX_TENANTS_PER_NODE,SATURATION_MEMORY_PCT,SATURATION_MAX_TENANTS(미설정 시 85, 10). R7 db_host 사용 시DB_HOST_SG(또는 US/EU) 설정. 전체 목록은 prego-control-plane README §Setup — Optional configuration 표 참고.
참고: prego-control-plane-dashboard §배포 후 검증 · db-redis-scaling-policy-r7 §5 (GET /internal/db-host) · provision-tenant-pipeline (db_host·R6 §5).
선택적 후속: placement·plan_limits·db-host 단위/통합 테스트; R7 DB 노드 풀 테이블(D1); plan_tier별 db_host 확장.