English {#english}
Plan: Step 1 Pulumi (Hetzner) → Step 2 Ansible (Docker · Frappe)
Purpose: Define phases, prerequisites, tasks, and outputs for creating a Hetzner server with Pulumi and then running Docker, Frappe, tenant site, and API Key with Ansible.
No code generation — plan, scope, and paths only.
- Pulumi test: Lowest-cost/spec (§1.5); delete server after test.
- Pulumi production: Service spec (§1.6), cost example (§1.7), 1,000 concurrent-user strategy (§1.8).
Base paths
| Layer | Base path | Use |
|---|---|---|
| Pulumi | /Users/marco/prego-pulumi | Hetzner·Cloudflare IaC, stacks (sg/us/eu), run scripts |
| Ansible | /Users/marco/prego-ansible | Inventory, roles (Docker, Frappe), playbook, bench/API Key scripts |
Step 1: Pulumi (Hetzner server)
Prerequisites: Hetzner API token, SSH key registered in Hetzner, Pulumi stack (sg/us/eu), Cloudflare accountId and Zone if using R2/DNS.
Order: (1) Firewall (SSH 22 only; 80/443 optional if using Tunnel). (2) Server (test: §1.5; prod: §1.6). (3) Management DNS A record. (4) Optional R2 bucket.
Outputs: Server public IP (→ Ansible ansible_host), management FQDN. Run from prego-pulumi; pulumi stack select sg, then preview/up.
Step 2: Ansible (Docker · Frappe)
Prerequisites: Step 1 server IP in inventory, SSH access.
Order: (1) Docker role. (2) Frappe(+ HR) role. (3) Tenant site (bench new-site). (4) Administrator API Key.
Outputs: Server with Docker + Frappe + tenant site; API Key for Zuplo/Control Plane. Run from prego-ansible; ansible-playbook with inventory.
Image-based provisioning (§2.5): After one full Ansible success, create Hetzner image from that server; later nodes use that image and Ansible runs only new-site + API Key (or minimal playbook). Golden image = bench init only, no tenant-specific data.
§3 Ansible scope and out-of-scope
Vectorize, tenant canonical DNS, tenant user CNAME: not in Ansible; handled by Pulumi or existing workflow/scripts (e.g. cloudflare-tenant-dns.ts). See §3.1–§3.2 in Korean for table and rationale.
Full tables (§1.1–§1.8, §2.1–§2.5, §3) and exact wording are in the Korean section below.
한국어 {#korean}
기획서: 1단계 Pulumi(Hetzner 서버) → 2단계 Ansible(Docker·Frappe) 실행 순서
목적: Pulumi로 Hetzner 서버를 생성한 뒤, Ansible로 Docker·Frappe·테넌트 사이트·API Key까지 진행하기 위한 단계·사전 조건·작업·결과를 정의한다.
코드 생성 없음 — 기획·범위·경로만 정리.
- Pulumi 테스트 단계: 연습용 최저가·최저 사양(§1.5). 테스트 직후 삭제 권장.
- Pulumi 상용 단계: 실제 서비스용 사양(§1.6)·비용 예시(§1.7)·1,000명 동시 접속 전략(§1.8).
Base 경로
| 구분 | Base 경로 | 용도 |
|---|---|---|
| Pulumi | /Users/marco/prego-pulumi | Hetzner·Cloudflare IaC, 스택(sg/us/eu), 실행 스크립트 |
| Ansible | /Users/marco/prego-ansible | 인벤토리, roles(Docker, Frappe), playbook, bench/API Key 스크립트 |
1단계: Pulumi로 Hetzner 서버 생성
1.1 사전 조건
| 항목 | 설명 | 확인 방법 |
|---|---|---|
| Hetzner API 토큰 | Hetzner Cloud 콘솔에서 발급. 프로젝트별로 사용. | HCLOUD_TOKEN 환경변수 또는 Pulumi config (pulumi config set hcloud:token <secret>) |
| SSH 키 (Hetzner 등록) | 서버에 주입할 공개키. Hetzner 콘솔 Security → SSH Keys에 이름으로 등록. | 예: prego-admin — Pulumi 코드에서 해당 이름으로 조회 |
| Pulumi 스택 | 리전별 스택: sg(Phase 1), us(Phase 4), eu(Phase 5). | pulumi stack select sg (또는 us/eu). 스택 설정은 /Users/marco/prego-pulumi/Pulumi.sg.yaml 등 |
| Cloudflare accountId | R2·DNS 사용 시 필요. Zone이 속한 계정 ID. | CLOUDFLARE_ACCOUNT_ID 또는 pulumi config set cloudflare:accountId <id> |
| Cloudflare Zone | 관리용 DNS를 넣을 도메인. 예: pregoi.com. | Zone이 이미 존재하고, API 토큰으로 해당 Zone 접근 가능해야 함 |
1.2 작업 순서
| 순서 | 작업 | 담당(Base) | 비고 |
|---|---|---|---|
| 1 | 방화벽 생성 | Pulumi (/Users/marco/prego-pulumi) | SSH(22)만 허용. Cloudflare Tunnel 사용 시 80/443은 서버에 열지 않아도 됨. |
| 2 | Hetzner 서버 프로비저닝 | Pulumi | 테스트 시 §1.5, 상용 시 §1.6 사양 적용. (현재 코드 기본: cx21, ubuntu-24.04, 스택별 location) |
| 3 | Cloudflare 관리용 DNS A 레코드 생성 | Pulumi | 예: node-01.pregoi.com → 서버 공인 IP. 관리·헬스체크용이므로 proxy 비활성(Proxied=false) |
| 4 | (선택) R2 버킷 | Pulumi | 정적 에셋·폰트 등 사용 시. accountId 있을 때만 생성. |
1.3 결과물 (2단계 입력으로 전달)
| 출력 | 설명 | 2단계에서의 사용 |
|---|---|---|
| 서버 공인 IP | Hetzner 서버의 IPv4 주소 | Ansible 인벤토리 ansible_host |
| 관리용 FQDN | 예: node-01.pregoi.com | SSH 접속·헬스체크·문서화 |
Pulumi 출력 예: pulumi export("server_ip", ...), pulumi export("management_dns", ...).
이 IP를 2단계 Ansible 인벤토리와 SSH 접근 검증에 사용.
1.4 실행 위치·명령 (참고)
- 작업 디렉터리:
/Users/marco/prego-pulumi - 스택 선택:
pulumi stack select sg(또는 us/eu) - 실행:
./run-up.sh preview,./run-up.sh up(또는pulumi preview/pulumi up)
1.5 Pulumi 테스트 단계 (연습용 최저가·최저 사양)
Pulumi 프로비저닝 로직만 검증할 때 사용. 테스트 직후 서버 삭제하여 비용 최소화.
| 항목 | 내용 | 비고 |
|---|---|---|
| Type | Shared Resources | 시간당 과금 기준 가장 저렴 |
| Location | Falkenstein (FSN1) 또는 Helsinki (HEL1) | 유럽 지역이 대개 가장 저렴 |
| Image | Ubuntu 24.04 | 표준·Pulumi 예제와 호환 |
| Architecture | Arm64 | x86보다 저렴 |
| Server Plan | CPX22 (4 vCPU, 8GB RAM, x86) 또는 CAX11 (2 vCPU, 4GB RAM, Arm64) | CAX11 재고 부족 시 CPX22 사용. Hetzner Shared 라인업 |
| Networking | IPv4 체크, IPv6 사용, 둘 다 사용 | |
| Private Networks | VPC 테스트 시 미리 생성한 네트워크 선택 | Pulumi로 VPC 구성 연습 시 |
| SSH keys | 로컬 PC 공용키(id_rsa.pub) Hetzner에 등록 완료 | Pulumi에서 해당 키 이름으로 조회 |
1.6 Pulumi 상용 단계 (실제 서비스 사양)
실제 서비스용으로 적용할 설정. 구현 시 Pulumi/Ansible에서 아래 값으로 적용.
| 항목 | 내용 | 비고 |
|---|---|---|
| Server Plan | CPX31 (x86) 이상 | Frappe MariaDB·Redis 워커 메모리 사용이 크므로 최소 8GB RAM 권장 |
| Volumes | 20GB ~ 50GB 추가 | Docker 이미지·테넌트 DB·업로드 파일 분리. OS와 별도 볼륨으로 관리 |
| Backups | Enable (체크) | 서버 비용의 20% 추가, 매일 자동 스냅샷. 데이터 보호 필수 |
| Firewalls | Stateful Firewall | 80(HTTP), 443(HTTPS), 22(SSH) 만 허용, 나머지 차단 |
| Placement groups | Spread | 서버 2대 이상 시 서로 다른 물리 호스트에 배치해 가동률 극대화 |
설정·운영 팁
| 항목 | 내용 |
|---|---|
| Cloud-init | Pulumi user_data에 apt-get update && apt-get install -y docker.io 등 스크립트 삽입 시 서버 생성 직후 Docker 자동 설치. Cloud-init 공식 문서 참고. |
| Labels | project: frappe-test, env: dev 등 레이블 부여 시 Hetzner 콘솔에서 필터·비용 추적 용이. |
| Name | Pulumi에서 동적 생성 권장. 수동 시 dev-frappe-01 등 규칙 통일. |
1.7 상용 비용 예시 (CPX31 기준)
| 항목 | 상세 | 월 예상 비용 (EUR) | 비고 |
|---|---|---|---|
| Server Plan | CPX31 (4 vCPU / 8GB RAM) | €13.60 | 기본 서버 |
| Primary IP | IPv4 1개 | €0.60 | 필수 할당 시 |
| Volumes | SSD 50GB 추가 | €2.25 | GB당 약 €0.045 |
| Backups | 서버 비용의 20% | €2.72 | 자동 스냅샷 |
1.8 상용: CPX31에서 1,000명 동시 접속 전략
CPX31(8GB RAM) 단일 서버에서 1,000명 동시 접속은 일반 설정으로는 거의 불가능에 가깝다. Frappe 아키텍처를 활용한 리소스 효율화로 설계할 때 참고.
1. 공유 DB 및 커넥션 풀링 (DB Efficiency)
| 조치 | 내용 |
|---|---|
| Single MariaDB Instance | 테넌트마다 DB 컨테이너를 두면 8GB로는 부족. 하나의 MariaDB 안에서 bench new-site --db-name으로 DB만 분리. |
| Max Connections | my.cnf에서 max_connections 1,500 이상, innodb_buffer_pool_size를 RAM의 약 40%(약 3GB)로 고정해 스왑 방지. |
| Alpine 기반 이미지 | DB·Redis 모두 Alpine 기반 이미지로 컨테이너 메모리 사용 최소화. |
2. 스왑 및 커널 파라미터 (OS 안정성)
| 조치 | 내용 |
|---|---|
| Swap 4GB | SSD 볼륨에 /swapfile 생성, swappiness=10으로 RAM 우선 사용. 성능보다 생존 목적. |
| File Descriptors | 1,000명 접속 시 오픈 파일 수 제한 가능. Cloud-init에 ulimit -n 65535 포함. |
3. Docker Compose 리소스 격리
특정 테넌트 부하가 전체를 다운시키지 않도록 제한.
| 조치 | 내용 |
|---|---|
| Memory limits | deploy.resources.limits.memory: 1.5G, reservations.memory: 512M 등으로 컨테이너별 상한·하한 설정. |
| CPU Shares | cpu_shares: 512 등으로 유휴 시에만 CPU 우선순위 배분. |
4. 1,000명 동시 접속을 위한 필수 아키텍처
| 조치 | 내용 |
|---|---|
| 전면 캐시 계층 | Frappe 앞에 Cloudflare 또는 Nginx/Caddy 캐시 배치. 정적 파일(JS/CSS/이미지)이 Frappe Python 워커까지 가지 않도록 차단. |
| Gunicorn Worker | (2 x CPU) + 1 공식을 넘어, 메모리 허용 범위에서 12~16개까지 검토. 응답 지연 시 큐잉되도록 설정. |
| Redis 통합 | 캐시·큐·Socketio용 Redis를 단일 인스턴스로 두어 메모리 파편화 감소. |
5. 추천 추가 옵션: 전용 CPU (Dedicated vCPU)
1,000명 접속 시 Steal time으로 서비스 정지가 우려되면 CCX11 (Dedicated) 라인업 검토. Shared 리소스는 고부하 시 인접 서버 영향 가능.
2단계: Ansible으로 Docker·Frappe 설치
2.1 사전 조건
| 항목 | 설명 | 확인 방법 |
|---|---|---|
| 1단계 결과: 서버 IP | Pulumi로 생성된 서버의 공인 IP | 1단계 pulumi export 또는 Pulumi Cloud 출력 |
| Ansible 인벤토리 | 위 IP를 ansible_host로 갖는 호스트/그룹 정의 | /Users/marco/prego-ansible/inventory/ (또는 기획에 맞는 경로) |
| SSH 접근 가능 | 해당 IP로 SSH 접속(예: ssh root@<ip>) 성공 | 1단계에서 주입한 SSH 키로 인증 |
2.2 작업 순서
| 순서 | 작업 | 담당(Base) | 비고 |
|---|---|---|---|
| 1 | Docker 설치 role | Ansible (/Users/marco/prego-ansible) | role: Docker CE 설치·서비스 기동. Ubuntu 24.04 기준. |
| 2 | Frappe(+ HR) 환경 role | Ansible | Frappe/ERPNext 또는 Frappe + HR 앱 설치. Docker 기반 또는 bench 기반으로 설계에 따라 결정. |
| 3 | 테넌트 사이트 생성 | Ansible (task/script) | bench new-site <tenant_site> 실행. tenant_site는 테넌트 ID 또는 사이트명 규칙에 따름. |
| 4 | Administrator API Key 생성 | Ansible (CLI/스크립트) | Frappe CLI 또는 스크립트로 API Key 발급. 자동화 권장 — 수동이면 4·5단계에 키 전달이 어려움. |
2.3 결과물 (4·5단계에 전달)
| 출력 | 설명 | 이후 단계에서의 사용 |
|---|---|---|
| 서버 상태 | Docker + Frappe + 테넌트 사이트 기동 완료 | Frappe API 호출 가능한 상태 |
| API Key 문자열 | 해당 사이트·Administrator용 API Key | Zuplo 등 API 게이트웨이·Control Plane 등에서 테넌트별 인증 |
2.4 실행 위치·인벤토리 (참고)
- 작업 디렉터리:
/Users/marco/prego-ansible - 인벤토리: 1단계에서 받은 IP로 호스트 정의. 예:
inventory/hosts.yml또는inventory/sg/hosts.yml(리전별) - 실행:
ansible-playbook -i inventory/ playbook.yml(playbook·role 경로는 기획에 따라 결정) - 단계별 구현 방안: ansible-implementation-plan.md 참조.
2.5 이미지 기반 프로비저닝 (상용·설치 시간 단축)
테스트가 끝나고 상용 서버에 한 번 전체 설치(Ansible playbook)가 성공한 뒤, 그 서버에서 **이미지(스냅샷)**를 만들어 두면, 이후 새 노드를 띄울 때 설치 시간을 크게 줄일 수 있다.
| 구분 | 풀 설치(매번 Ansible 전체) | 이미지 기반 |
|---|---|---|
| 소요 시간 | Docker + MariaDB 컨테이너 + bench init(수 분~수십 분) + new-site + API Key | Pulumi로 이미지에서 서버 생성 → Ansible은 테넌트만 추가(new-site, API Key) 또는 최소 설정만 실행 |
| 적합 | 첫 노드, 테스트, 이미지 없을 때 | 두 번째 노드 이후, 스케일 아웃, 동일 스택/리전 |
권장 흐름
- 1회: 상용 사양으로 Pulumi Up → Ansible playbook 전체 실행(Docker, MariaDB, Frappe bench init, 필요 시 첫 테넌트까지). 설치가 정상 완료된 App 서버를 Golden 상태로 둠.
- 이미지 생성: Hetzner 콘솔에서 해당 App 서버를 선택 → Create image (또는 Snapshot). 이미지 이름·라벨 규칙 통일(예:
prego-app-YYYYMMDD,prego-frappe-bench-v15). DB 서버는 별도이므로 DB 서버용 이미지를 따로 만들 수도 있음(선택). - 이후 노드: Pulumi에서 새 서버 생성 시
image를 Ubuntu 기본 대신 위 커스텀 이미지 ID/이름으로 지정. 생성된 서버에는 이미 Docker·Frappe bench가 있으므로, Ansible은 frappe_site 역할만 실행(또는 new-site·API Key만 수행하는 경량 playbook)하면 됨. - 이미지 갱신: Frappe/bench 버전 업그레이드·보안 패치 후 같은 방식으로 새 이미지를 만들고, 이후 Pulumi에서 새 노드는 새 이미지를 쓰도록 변경.
주의
- 이미지에 **테넌트별 정보(사이트명·DB)**가 들어가면 안 되므로, Golden 이미지는 bench init까지만 넣고, 첫 사이트는 넣지 않거나 테스트용 한 개만 넣고 제거한 뒤 이미지화하는 편이 안전함.
- DB 서버는 App과 분리되어 있으므로, App 이미지에는 DB 연결 정보(host/port)만 설정 가능한 상태로 두고, 실제
db_host는 Ansible/인벤토리로 주입.
이렇게 하면 설치 시간이 줄어들고, 상용에서 노드를 추가할 때는 서버 생성 + 짧은 Ansible(테넌트 추가)만 하면 된다.
3. Ansible 범위 및 비범위 — Cloudflare Vectorize·테넌트 DNS
다음 항목들은 현재 Pulumi에는 구현되어 있지 않고, 기획서 또는 Prego 워크플로/스크립트에서만 다룬다. Ansible에서 구현하는 것이 적합한지 분석한 결과를 정리한다. 코드 생성 없음 — 담당 계층·권장만 명시.
3.1 Ansible의 역할 정리
| Ansible이 담당하는 것 | 설명 |
|---|---|
| 서버 위 상태 | 대상 호스트(VM) 위에서만 의미가 있는 구성: Docker CE 설치, Frappe/bench 설치·실행, bench new-site, API Key 생성. |
| 실행 맥락 | SSH로 서버에 접속해 패키지·서비스·디렉터리·CLI 명령을 실행. 인벤토리는 ansible_host(server_ip) 기준. |
즉, Cloudflare 계정/전역 리소스나 도메인·DNS 레코드는 서버 안에 존재하지 않으므로, Ansible의 “대상 호스트”가 아니다.
3.2 Cloudflare Vectorize — Ansible 부적합
| 항목 | 내용 |
|---|---|
| 현재 상태 | 기획서(rag-ai-edge-architecture-plan.md §4)에만 “Pulumi로 Vectorize·D1 프로비저닝” 설계가 있음. prego-pulumi에는 Vectorize 리소스 미구현. |
| Vectorize 성격 | Cloudflare 엣지의 관리형 벡터 DB. 서버(VM)에 설치하는 소프트웨어가 아니며, Cloudflare API로 인덱스 생성·설정함. |
| Ansible 적합성 | ❌ Ansible에서 구현 비권장. Ansible은 “호스트 위 상태”를 다루므로, Cloudflare 계정 단위 리소스(Vectorize 인덱스) 생성·관리는 Ansible 역할이 아님. |
| 담당 권장 | Pulumi(prego-pulumi) 에서 pulumi_cloudflare로 Vectorize 인덱스 리소스 추가하거나, Cloudflare API를 호출하는 별도 스크립트/워크플로 단계. Worker(rag-query, rag-ingestion)는 이미 wrangler 바인딩으로 인덱스 이름만 참조하므로, 인덱스가 “어디서 생성되느냐”만 IaC 또는 프로비저닝 스크립트로 통일하면 됨. |
3.3 테넌트 canonical DNS (tenant-xxx.pregoi.com) — Ansible 비권장
| 항목 | 내용 |
|---|---|
| 현재 상태 | Prego 레포 infra/cloudflare-tenant-dns.ts + 워크플로 tenant-dns 단계에서 처리. Pulumi는 관리용 node-NN.pregoi.com A 레코드만 담당. |
| canonical DNS 성격 | 테넌트별 canonical FQDN(예: tenant-a1b2c3d4.pregoi.com)에 대한 A 또는 CNAME 레코드는 Cloudflare Zone에 생성되는 DNS 레코드. 서버 내부 상태가 아님. |
| Ansible 적합성 | ❌ Ansible에서 구현 비권장. 기술적으로는 Ansible 제어기에서 delegate_to: localhost + uri 모듈로 Cloudflare API를 호출해 레코드를 만들 수는 있으나, (1) Ansible에 Cloudflare API 토큰·Zone ID를 넘겨야 하고, (2) “서버 구성”과 “DNS 레코드 생성”이 한 playbook에 섞여 책임이 흐려짐. |
| 담당 권장 | 현행 유지: 워크플로 단계에서 cloudflare-tenant-dns.ts 호출. 또는 테넌트 DNS를 인프라 코드로 통일하고 싶다면 Pulumi에서 테넌트별 리소스(또는 동적 블록)로 canonical 레코드 생성 검토. Ansible은 server_ip·site_name 등 “결과값”만 변수로 받아 서버 설정에만 사용. |
3.4 테넌트 사용자 CNAME (acme.pregoi.com → canonical) — Ansible 비권장
| 항목 | 내용 |
|---|---|
| 현재 상태 | 동일하게 cloudflare-tenant-dns.ts의 create --subdomain acme 등으로 CNAME 생성. tenant-subdomain-dns-design.md 방식 C. |
| 성격 | 사용자 서브도메인을 canonical로 연결하는 Cloudflare Zone 내 CNAME 레코드. 서버와 무관. |
| Ansible 적합성 | ❌ Ansible에서 구현 비권장. 이유는 §3.3과 동일. DNS는 오케스트레이션/워크플로 또는 IaC(Pulumi)가 담당하는 것이 계층 분리와 보안(시크릿 관리)에 유리함. |
| 담당 권장 | 현행 유지: 워크플로에서 cloudflare-tenant-dns.ts로 canonical + 선택적 subdomain CNAME 생성. Ansible은 DNS 생성에 관여하지 않음. |
3.5 요약 표
| 기능 | Ansible 구현 | 권장 담당 |
|---|---|---|
| Cloudflare Vectorize 인덱스 | ❌ 부적합 | Pulumi 또는 전용 프로비저닝 스크립트/워크플로 |
| 테넌트 canonical DNS (tenant-xxx.pregoi.com) | ❌ 비권장 | 현행: cloudflare-tenant-dns.ts + 워크플로. 대안: Pulumi |
| 테넌트 사용자 CNAME (acme.pregoi.com → canonical) | ❌ 비권장 | 현행: cloudflare-tenant-dns.ts + 워크플로 |
단계 간 데이터 전달 요약
[1단계 Pulumi] 입력: Hetzner 토큰, SSH 키 이름, Pulumi 스택(sg/us/eu), Cloudflare accountId 출력: server_ip, management_dns (선택: R2 버킷) ↓[2단계 Ansible] 입력: server_ip → 인벤토리 ansible_host, SSH 접근 가능 출력: Docker + Frappe + 테넌트 사이트, API Key 문자열 ↓[4·5단계] 입력: API Key 문자열 (Zuplo 등록, Control Plane·클라이언트 연동 등)구현 시 참고 (경로만 명시, 코드 없음)
| 항목 | 경로 또는 위치 |
|---|---|
| 상용 미구현 항목 체크리스트 | pulumi-hetzner-production-todo.md — §1.6 Volumes, Backups, CPX31, Placement group, Cloud-init 구현 순서 |
| Pulumi 프로젝트 루트 | /Users/marco/prego-pulumi |
| Pulumi 진입점·리소스 정의 | /Users/marco/prego-pulumi/__main__.py |
| Pulumi 스택 설정 | /Users/marco/prego-pulumi/Pulumi.sg.yaml, Pulumi.us.yaml, Pulumi.eu.yaml |
| Pulumi 리전 설정 | /Users/marco/prego-pulumi/config/region.py |
| Ansible 프로젝트 루트 | /Users/marco/prego-ansible |
| Ansible 인벤토리 | /Users/marco/prego-ansible/ 하위 (예: inventory/, inventory/sg/hosts.yml 등 — 구조는 구현 시 결정) |
| Ansible roles | /Users/marco/prego-ansible/roles/ (예: docker, frappe 등 — 구현 시 생성) |
| Ansible playbook | /Users/marco/prego-ansible/ 루트 또는 playbooks/ (구현 시 결정) |
문서 위치: docs/planning/pulumi-ansible-step1-step2-plan.md