- Queue-based async architecture design - API spec (POST /provision, GET /status) - Implementation phases (5 steps, ~8 hours) - Troubleshooting guide Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1327 lines
35 KiB
Markdown
1327 lines
35 KiB
Markdown
# 서버 프로비저닝 API 아키텍처
|
|
|
|
> 비동기 Queue 기반 서버 생성 시스템 설계 문서
|
|
|
|
## 📋 목차
|
|
|
|
1. [개요](#개요)
|
|
2. [현재 문제점](#현재-문제점)
|
|
3. [목표 아키텍처](#목표-아키텍처)
|
|
4. [Cloudflare Queue 설명](#cloudflare-queue-설명)
|
|
5. [API 스펙](#api-스펙)
|
|
6. [Worker 구조](#worker-구조)
|
|
7. [구현 단계](#구현-단계)
|
|
8. [보안](#보안)
|
|
9. [모니터링](#모니터링)
|
|
10. [트러블슈팅](#트러블슈팅)
|
|
|
|
---
|
|
|
|
## 개요
|
|
|
|
**현재 상황:**
|
|
- 서버 프로비저닝이 동기 처리 방식
|
|
- Telegram Webhook의 10초 타임아웃 제한
|
|
- Linode/Vultr API 응답 시간: 15-30초
|
|
- 타임아웃 발생 시 사용자 경험 저하
|
|
|
|
**목표:**
|
|
- Cloudflare Queue 기반 비동기 처리
|
|
- 즉시 응답 (주문 접수 확인)
|
|
- 백그라운드 서버 생성
|
|
- 완료 시 Telegram 메시지 알림
|
|
|
|
**기대 효과:**
|
|
- ✅ Webhook 타임아웃 해결
|
|
- ✅ 사용자 경험 개선 (즉시 응답)
|
|
- ✅ 시스템 안정성 향상 (재시도 메커니즘)
|
|
- ✅ 확장성 확보 (Queue 기반)
|
|
|
|
---
|
|
|
|
## 현재 문제점
|
|
|
|
### 동기 처리 방식의 한계
|
|
|
|
```
|
|
사용자 → Telegram Webhook → executeServerProvision()
|
|
↓ (15-30초)
|
|
Linode/Vultr API
|
|
↓
|
|
응답 타임아웃 ❌
|
|
```
|
|
|
|
**구체적 문제:**
|
|
|
|
| 문제 | 영향 | 예시 |
|
|
|------|------|------|
|
|
| **Webhook 10초 제한** | 응답 실패 | Linode API 15초 → Telegram 타임아웃 |
|
|
| **사용자 대기 시간** | UX 저하 | "응답 없음" 메시지, 재시도 시도 |
|
|
| **재시도 없음** | 일시적 장애 시 실패 | API 503 에러 → 주문 실패 |
|
|
| **동시 처리 불가** | 성능 병목 | 여러 사용자 동시 주문 시 순차 처리 |
|
|
|
|
**현재 코드 위치:**
|
|
- `src/server-provision.ts:executeServerProvision()` - 동기 실행
|
|
- `src/index.ts` (callback_query 핸들러) - Webhook 내에서 직접 호출
|
|
|
|
---
|
|
|
|
## 목표 아키텍처
|
|
|
|
### 비동기 Queue 기반 흐름
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────────────┐
|
|
│ 사용자 요청 처리 (즉시 응답) │
|
|
└──────────────────────────────────────────────────────────────┘
|
|
|
|
사용자 클릭 "확인" 버튼
|
|
↓
|
|
telegram-bot-workers (Producer)
|
|
1. server_orders INSERT (status='pending')
|
|
2. Queue.send({ order_id, user_id })
|
|
3. Telegram 즉시 응답: "주문 접수 (#123)"
|
|
↓
|
|
사용자에게 즉시 응답 ✅ (1-2초)
|
|
|
|
|
|
┌──────────────────────────────────────────────────────────────┐
|
|
│ 백그라운드 서버 생성 (비동기 처리) │
|
|
└──────────────────────────────────────────────────────────────┘
|
|
|
|
Cloudflare Queue
|
|
↓ (메시지 전달)
|
|
server-provision-worker (Consumer)
|
|
1. Queue 메시지 수신
|
|
2. server_orders 조회 (status='pending')
|
|
3. 잔액 재확인
|
|
4. Linode/Vultr API 호출 (15-30초)
|
|
5. 결제 처리 (Optimistic Locking)
|
|
6. server_orders 업데이트 (status='active')
|
|
7. user_servers INSERT
|
|
8. Telegram 알림: "서버 생성 완료 🎉"
|
|
↓
|
|
완료 알림 전송 (백그라운드)
|
|
|
|
|
|
┌──────────────────────────────────────────────────────────────┐
|
|
│ 실패 처리 (자동 재시도) │
|
|
└──────────────────────────────────────────────────────────────┘
|
|
|
|
API 실패 (503, timeout 등)
|
|
↓
|
|
Queue 자동 재시도 (3회, 지수 백오프)
|
|
↓
|
|
재시도 실패 시 → Dead Letter Queue
|
|
↓
|
|
관리자 알림 (notifyAdmin)
|
|
```
|
|
|
|
---
|
|
|
|
## Cloudflare Queue 설명
|
|
|
|
### Queue란?
|
|
|
|
**메시지 큐(Message Queue)**: 비동기 작업을 안전하게 전달하고 처리하는 시스템
|
|
|
|
```
|
|
Producer (생산자) Queue (대기열) Consumer (소비자)
|
|
↓ ↓ ↓
|
|
메시지 전송 → 메시지 저장 → 메시지 처리
|
|
```
|
|
|
|
**Cloudflare Queue 특징:**
|
|
- 완전 관리형 (서버리스)
|
|
- 자동 재시도 (exponential backoff)
|
|
- Dead Letter Queue 지원
|
|
- 무료 티어: 100만 요청/월
|
|
|
|
### Producer (메시지 전송)
|
|
|
|
**역할:** Telegram 봇이 주문을 Queue에 전송
|
|
|
|
```typescript
|
|
// telegram-bot-workers/src/index.ts (callback_query 핸들러)
|
|
|
|
// 기존: 동기 실행 (타임아웃 발생)
|
|
const result = await executeServerProvision(env, userId, telegramUserId, orderId);
|
|
|
|
// 변경: Queue에 전송 (즉시 응답)
|
|
await env.SERVER_PROVISION_QUEUE.send({
|
|
order_id: orderId,
|
|
user_id: userId,
|
|
telegram_user_id: telegramUserId,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
return "📋 주문 접수 완료! 서버 생성 중입니다... (1-2분 소요)";
|
|
```
|
|
|
|
**메시지 구조:**
|
|
```typescript
|
|
interface ProvisionMessage {
|
|
order_id: number; // server_orders.id
|
|
user_id: number; // users.id
|
|
telegram_user_id: string; // Telegram Chat ID (알림용)
|
|
timestamp: number; // 전송 시각 (디버깅)
|
|
}
|
|
```
|
|
|
|
### Consumer (메시지 처리)
|
|
|
|
**역할:** 백그라운드에서 실제 서버 생성 처리
|
|
|
|
```typescript
|
|
// server-provision-worker/src/consumer.ts
|
|
|
|
export default {
|
|
async queue(batch: MessageBatch<ProvisionMessage>, env: Env): Promise<void> {
|
|
for (const message of batch.messages) {
|
|
const { order_id, user_id, telegram_user_id } = message.body;
|
|
|
|
try {
|
|
// 1. 실제 서버 생성 (기존 로직 재사용)
|
|
const result = await executeServerProvision(
|
|
env,
|
|
user_id,
|
|
telegram_user_id,
|
|
order_id
|
|
);
|
|
|
|
// 2. 성공 시 사용자 알림
|
|
if (result.success) {
|
|
await sendMessage(
|
|
env.BOT_TOKEN,
|
|
Number(telegram_user_id),
|
|
`🎉 서버 생성 완료!\n\n` +
|
|
`• IP: ${result.ip_address}\n` +
|
|
`• Root 비밀번호: ${result.root_password}\n\n` +
|
|
`⚠️ 비밀번호는 이 메시지에서만 확인 가능합니다.`
|
|
);
|
|
message.ack(); // 성공 확인
|
|
} else {
|
|
throw new Error(result.error || 'Unknown error');
|
|
}
|
|
|
|
} catch (error) {
|
|
logger.error('서버 생성 실패', error as Error, { order_id });
|
|
|
|
// 3. 실패 시 재시도 (자동)
|
|
message.retry(); // Queue가 자동으로 재시도
|
|
}
|
|
}
|
|
},
|
|
};
|
|
```
|
|
|
|
### 재시도 및 Dead Letter Queue
|
|
|
|
**자동 재시도 설정:**
|
|
```toml
|
|
# wrangler.toml (server-provision-worker)
|
|
|
|
[[queues.consumers]]
|
|
queue = "server-provision-queue"
|
|
max_retries = 3 # 최대 3회 재시도
|
|
max_batch_size = 10 # 배치 크기
|
|
max_batch_timeout = 30 # 배치 타임아웃 (초)
|
|
max_concurrency = 5 # 동시 처리 수
|
|
dead_letter_queue = "provision-dlq" # 실패한 메시지 전송
|
|
```
|
|
|
|
**재시도 전략 (Exponential Backoff):**
|
|
```
|
|
1회 실패 → 2초 후 재시도
|
|
2회 실패 → 4초 후 재시도
|
|
3회 실패 → 8초 후 재시도
|
|
4회 실패 → Dead Letter Queue로 전송
|
|
```
|
|
|
|
**Dead Letter Queue 처리:**
|
|
```typescript
|
|
// server-provision-worker/src/dlq-handler.ts
|
|
|
|
export default {
|
|
async queue(batch: MessageBatch<ProvisionMessage>, env: Env): Promise<void> {
|
|
for (const message of batch.messages) {
|
|
const { order_id, telegram_user_id } = message.body;
|
|
|
|
// 1. DB 업데이트 (status='failed')
|
|
await env.DB.prepare(
|
|
"UPDATE server_orders SET status = 'failed', error_message = ? WHERE id = ?"
|
|
).bind('최대 재시도 횟수 초과', order_id).run();
|
|
|
|
// 2. 사용자 알림
|
|
await sendMessage(
|
|
env.BOT_TOKEN,
|
|
Number(telegram_user_id),
|
|
`❌ 서버 생성 실패 (주문 #${order_id})\n\n` +
|
|
`일시적인 문제로 서버를 생성할 수 없습니다.\n` +
|
|
`관리자에게 문의하세요.`
|
|
);
|
|
|
|
// 3. 관리자 알림
|
|
await notifyAdmin(
|
|
'api_error',
|
|
{
|
|
service: 'server-provision',
|
|
error: '서버 생성 최종 실패',
|
|
context: `주문 ID: ${order_id}\n사용자: ${telegram_user_id}`,
|
|
},
|
|
{ telegram: { sendMessage }, adminId: env.SERVER_ADMIN_ID, env }
|
|
);
|
|
|
|
message.ack(); // DLQ에서 제거
|
|
}
|
|
},
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## API 스펙
|
|
|
|
### POST /provision (내부 API)
|
|
|
|
**목적:** Queue 메시지 전송 (Producer)
|
|
|
|
**요청:**
|
|
```http
|
|
POST /provision
|
|
Content-Type: application/json
|
|
Authorization: Bearer {WEBHOOK_SECRET}
|
|
|
|
{
|
|
"order_id": 123,
|
|
"user_id": 456,
|
|
"telegram_user_id": "789012345"
|
|
}
|
|
```
|
|
|
|
**응답 (성공):**
|
|
```json
|
|
{
|
|
"success": true,
|
|
"order_id": 123,
|
|
"message": "주문이 접수되었습니다. 서버 생성 중..."
|
|
}
|
|
```
|
|
|
|
**응답 (실패):**
|
|
```json
|
|
{
|
|
"success": false,
|
|
"error": "주문을 찾을 수 없습니다."
|
|
}
|
|
```
|
|
|
|
**에러 코드:**
|
|
| 코드 | 설명 |
|
|
|------|------|
|
|
| 400 | 잘못된 요청 (필수 파라미터 누락) |
|
|
| 401 | 인증 실패 (WEBHOOK_SECRET 불일치) |
|
|
| 404 | 주문 없음 (order_id 불일치) |
|
|
| 500 | Queue 전송 실패 |
|
|
|
|
### GET /status/:order_id (사용자 API)
|
|
|
|
**목적:** 주문 상태 조회
|
|
|
|
**요청:**
|
|
```http
|
|
GET /status/123
|
|
Authorization: Bearer {WEBHOOK_SECRET}
|
|
```
|
|
|
|
**응답:**
|
|
```json
|
|
{
|
|
"order_id": 123,
|
|
"status": "provisioning",
|
|
"spec_id": 456,
|
|
"region": "tokyo",
|
|
"price_paid": 15000,
|
|
"created_at": "2026-01-24T10:30:00Z",
|
|
"updated_at": "2026-01-24T10:32:15Z",
|
|
"ip_address": null,
|
|
"error_message": null
|
|
}
|
|
```
|
|
|
|
**상태 값:**
|
|
| 상태 | 설명 | 사용자 메시지 |
|
|
|------|------|--------------|
|
|
| `pending` | 주문 접수 | "주문 대기 중" |
|
|
| `provisioning` | 서버 생성 중 | "서버 생성 중... (1-2분 소요)" |
|
|
| `active` | 생성 완료 | "서버 운영 중 ✅" |
|
|
| `failed` | 생성 실패 | "서버 생성 실패 ❌" |
|
|
| `cancelled` | 사용자 취소 | "주문 취소됨" |
|
|
| `terminated` | 서버 종료 | "서버 종료됨" |
|
|
|
|
### Queue 메시지 스키마
|
|
|
|
**메시지 타입:**
|
|
```typescript
|
|
interface ProvisionMessage {
|
|
order_id: number; // server_orders.id (필수)
|
|
user_id: number; // users.id (필수)
|
|
telegram_user_id: string; // Telegram Chat ID (필수)
|
|
timestamp: number; // Unix timestamp (선택)
|
|
retry_count?: number; // 재시도 횟수 (내부 사용)
|
|
}
|
|
```
|
|
|
|
**메시지 크기 제한:**
|
|
- 최대 128KB (Cloudflare Queue 제한)
|
|
- 현재 메시지: ~100 bytes
|
|
|
|
**메시지 보관 기간:**
|
|
- 기본: 4일 (96시간)
|
|
- Dead Letter Queue: 14일
|
|
|
|
---
|
|
|
|
## Worker 구조
|
|
|
|
### 디렉토리 구조
|
|
|
|
```
|
|
server-provision/
|
|
├── wrangler.toml # Worker 설정 + Queue 바인딩
|
|
└── src/
|
|
├── index.ts # Producer API (POST /provision, GET /status)
|
|
├── consumer.ts # Queue Consumer (서버 생성 처리)
|
|
├── dlq-handler.ts # Dead Letter Queue 핸들러
|
|
├── server-provision.ts # 기존 로직 (재사용)
|
|
├── types.ts # 타입 정의
|
|
└── utils/
|
|
├── logger.ts # 로깅 (기존 재사용)
|
|
├── optimistic-lock.ts # Optimistic Locking (기존 재사용)
|
|
└── retry.ts # Retry 로직 (기존 재사용)
|
|
```
|
|
|
|
### wrangler.toml
|
|
|
|
```toml
|
|
name = "server-provision-worker"
|
|
main = "src/index.ts"
|
|
compatibility_date = "2024-01-01"
|
|
|
|
# Queue Producer 설정
|
|
[[queues.producers]]
|
|
queue = "server-provision-queue"
|
|
binding = "SERVER_PROVISION_QUEUE"
|
|
|
|
# Queue Consumer 설정 (이 Worker가 메시지 처리)
|
|
[[queues.consumers]]
|
|
queue = "server-provision-queue"
|
|
max_retries = 3
|
|
max_batch_size = 10
|
|
max_batch_timeout = 30
|
|
max_concurrency = 5
|
|
dead_letter_queue = "provision-dlq"
|
|
|
|
# Dead Letter Queue Consumer
|
|
[[queues.consumers]]
|
|
queue = "provision-dlq"
|
|
max_retries = 0 # DLQ는 재시도 없음
|
|
|
|
# D1 Database 바인딩 (기존 telegram-conversations)
|
|
[[d1_databases]]
|
|
binding = "DB"
|
|
database_name = "telegram-conversations"
|
|
database_id = "YOUR_DATABASE_ID"
|
|
|
|
# CLOUD_DB 바인딩
|
|
[[d1_databases]]
|
|
binding = "CLOUD_DB"
|
|
database_name = "cloud-instances-db"
|
|
database_id = "YOUR_CLOUD_DB_ID"
|
|
|
|
# KV Namespace 바인딩 (Rate Limiting 등)
|
|
[[kv_namespaces]]
|
|
binding = "RATE_LIMIT_KV"
|
|
id = "YOUR_KV_ID"
|
|
|
|
# 환경 변수
|
|
[vars]
|
|
OPENAI_API_BASE = "https://gateway.ai.cloudflare.com/v1/..."
|
|
SERVER_ADMIN_ID = "123456789"
|
|
|
|
# Secrets (wrangler secret put으로 설정)
|
|
# - BOT_TOKEN
|
|
# - WEBHOOK_SECRET
|
|
# - LINODE_API_KEY
|
|
# - VULTR_API_KEY
|
|
# - OPENAI_API_KEY
|
|
```
|
|
|
|
### src/index.ts (Producer)
|
|
|
|
```typescript
|
|
import { sendToQueue } from './queue-producer';
|
|
import type { Env, ProvisionMessage } from './types';
|
|
|
|
export default {
|
|
// HTTP 요청 처리 (POST /provision, GET /status)
|
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
const url = new URL(request.url);
|
|
|
|
// POST /provision - Queue에 메시지 전송
|
|
if (request.method === 'POST' && url.pathname === '/provision') {
|
|
return await handleProvision(request, env);
|
|
}
|
|
|
|
// GET /status/:order_id - 주문 상태 조회
|
|
if (request.method === 'GET' && url.pathname.startsWith('/status/')) {
|
|
return await handleStatus(request, env);
|
|
}
|
|
|
|
return new Response('Not Found', { status: 404 });
|
|
},
|
|
|
|
// Queue Consumer (consumer.ts 파일에서 import)
|
|
async queue(batch: MessageBatch<ProvisionMessage>, env: Env): Promise<void> {
|
|
const { default: consumer } = await import('./consumer');
|
|
return consumer.queue(batch, env);
|
|
},
|
|
};
|
|
|
|
async function handleProvision(request: Request, env: Env): Promise<Response> {
|
|
// 인증 검증
|
|
const authHeader = request.headers.get('Authorization');
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
return new Response('Unauthorized', { status: 401 });
|
|
}
|
|
const token = authHeader.substring(7);
|
|
if (token !== env.WEBHOOK_SECRET) {
|
|
return new Response('Forbidden', { status: 403 });
|
|
}
|
|
|
|
// 요청 파싱
|
|
const body = await request.json() as {
|
|
order_id: number;
|
|
user_id: number;
|
|
telegram_user_id: string;
|
|
};
|
|
|
|
if (!body.order_id || !body.user_id || !body.telegram_user_id) {
|
|
return new Response('Bad Request: Missing required fields', { status: 400 });
|
|
}
|
|
|
|
// Queue에 메시지 전송
|
|
try {
|
|
await env.SERVER_PROVISION_QUEUE.send({
|
|
order_id: body.order_id,
|
|
user_id: body.user_id,
|
|
telegram_user_id: body.telegram_user_id,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
order_id: body.order_id,
|
|
message: '주문이 접수되었습니다. 서버 생성 중...',
|
|
}), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
} catch (error) {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: 'Queue 전송 실패',
|
|
}), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handleStatus(request: Request, env: Env): Promise<Response> {
|
|
// 인증 검증 (동일)
|
|
const authHeader = request.headers.get('Authorization');
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
return new Response('Unauthorized', { status: 401 });
|
|
}
|
|
const token = authHeader.substring(7);
|
|
if (token !== env.WEBHOOK_SECRET) {
|
|
return new Response('Forbidden', { status: 403 });
|
|
}
|
|
|
|
// order_id 추출
|
|
const url = new URL(request.url);
|
|
const orderId = Number(url.pathname.split('/').pop());
|
|
if (!orderId) {
|
|
return new Response('Bad Request: Invalid order_id', { status: 400 });
|
|
}
|
|
|
|
// DB 조회
|
|
const order = await env.DB.prepare(
|
|
`SELECT id, status, spec_id, region, price_paid, created_at, updated_at, ip_address, error_message
|
|
FROM server_orders WHERE id = ?`
|
|
).bind(orderId).first();
|
|
|
|
if (!order) {
|
|
return new Response('Not Found', { status: 404 });
|
|
}
|
|
|
|
return new Response(JSON.stringify(order), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
```
|
|
|
|
### src/consumer.ts (Consumer)
|
|
|
|
```typescript
|
|
import { createLogger } from './utils/logger';
|
|
import { executeServerProvision } from './server-provision';
|
|
import { sendMessage } from './telegram';
|
|
import type { Env, ProvisionMessage } from './types';
|
|
|
|
const logger = createLogger('provision-consumer');
|
|
|
|
export default {
|
|
async queue(batch: MessageBatch<ProvisionMessage>, env: Env): Promise<void> {
|
|
logger.info('Queue 메시지 수신', { count: batch.messages.length });
|
|
|
|
for (const message of batch.messages) {
|
|
const { order_id, user_id, telegram_user_id } = message.body;
|
|
|
|
logger.info('서버 생성 시작', {
|
|
order_id,
|
|
user_id,
|
|
telegram_user_id,
|
|
attempt: message.attempts,
|
|
});
|
|
|
|
try {
|
|
// 1. 실제 서버 생성 (기존 로직 재사용)
|
|
const result = await executeServerProvision(
|
|
env,
|
|
user_id,
|
|
telegram_user_id,
|
|
order_id
|
|
);
|
|
|
|
if (result.success) {
|
|
// 2. 성공 시 사용자 알림
|
|
await sendMessage(
|
|
env.BOT_TOKEN,
|
|
Number(telegram_user_id),
|
|
`🎉 <b>서버 생성 완료!</b>\n\n` +
|
|
`• 사양: ${result.plan_label}\n` +
|
|
`• 리전: ${result.region}\n` +
|
|
`• IP: <code>${result.ip_address}</code>\n` +
|
|
`• Root 비밀번호: <code>${result.root_password}</code>\n\n` +
|
|
`⚠️ <b>비밀번호는 이 메시지에서만 확인 가능합니다.</b>\n` +
|
|
`분실 시 서버 콘솔에서 재설정하세요.\n\n` +
|
|
`SSH 접속: <code>ssh root@${result.ip_address}</code>`
|
|
);
|
|
|
|
logger.info('서버 생성 성공', {
|
|
order_id,
|
|
instance_id: result.instance_id,
|
|
ip_address: result.ip_address,
|
|
});
|
|
|
|
message.ack(); // 성공 확인
|
|
|
|
} else {
|
|
// 실패 케이스 (executeServerProvision 내부 에러)
|
|
throw new Error(result.error || 'Unknown error');
|
|
}
|
|
|
|
} catch (error) {
|
|
logger.error('서버 생성 실패', error as Error, {
|
|
order_id,
|
|
attempt: message.attempts,
|
|
});
|
|
|
|
// 재시도 (Queue가 자동으로 exponential backoff 적용)
|
|
message.retry();
|
|
}
|
|
}
|
|
},
|
|
};
|
|
```
|
|
|
|
### src/dlq-handler.ts (Dead Letter Queue)
|
|
|
|
```typescript
|
|
import { createLogger } from './utils/logger';
|
|
import { notifyAdmin } from './services/notification';
|
|
import { sendMessage } from './telegram';
|
|
import type { Env, ProvisionMessage } from './types';
|
|
|
|
const logger = createLogger('provision-dlq');
|
|
|
|
export default {
|
|
async queue(batch: MessageBatch<ProvisionMessage>, env: Env): Promise<void> {
|
|
logger.warn('DLQ 메시지 수신', { count: batch.messages.length });
|
|
|
|
for (const message of batch.messages) {
|
|
const { order_id, user_id, telegram_user_id } = message.body;
|
|
|
|
logger.error('서버 생성 최종 실패', new Error('최대 재시도 횟수 초과'), {
|
|
order_id,
|
|
user_id,
|
|
telegram_user_id,
|
|
});
|
|
|
|
// 1. DB 업데이트 (status='failed')
|
|
await env.DB.prepare(
|
|
"UPDATE server_orders SET status = 'failed', error_message = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
|
|
).bind('최대 재시도 횟수 초과 (DLQ)', order_id).run();
|
|
|
|
// 2. 사용자 알림
|
|
await sendMessage(
|
|
env.BOT_TOKEN,
|
|
Number(telegram_user_id),
|
|
`❌ <b>서버 생성 실패</b> (주문 #${order_id})\n\n` +
|
|
`일시적인 문제로 서버를 생성할 수 없습니다.\n` +
|
|
`관리자에게 문의하세요.`
|
|
);
|
|
|
|
// 3. 관리자 알림
|
|
await notifyAdmin(
|
|
'api_error',
|
|
{
|
|
service: 'server-provision',
|
|
error: '서버 생성 최종 실패 (DLQ)',
|
|
context: `주문 ID: ${order_id}\n사용자 ID: ${user_id}\nTelegram ID: ${telegram_user_id}`,
|
|
},
|
|
{
|
|
telegram: {
|
|
sendMessage: (chatId: number, text: string) =>
|
|
sendMessage(env.BOT_TOKEN, chatId, text),
|
|
},
|
|
adminId: env.SERVER_ADMIN_ID || env.DEPOSIT_ADMIN_ID || '',
|
|
env,
|
|
}
|
|
);
|
|
|
|
message.ack(); // DLQ에서 제거
|
|
}
|
|
},
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 구현 단계
|
|
|
|
### Phase 1: Queue 생성 및 기본 설정
|
|
|
|
**목표:** Cloudflare Queue 인프라 구축
|
|
|
|
**작업:**
|
|
1. Queue 생성
|
|
```bash
|
|
wrangler queues create server-provision-queue
|
|
wrangler queues create provision-dlq
|
|
```
|
|
|
|
2. `wrangler.toml` 설정 추가
|
|
```toml
|
|
[[queues.producers]]
|
|
queue = "server-provision-queue"
|
|
binding = "SERVER_PROVISION_QUEUE"
|
|
```
|
|
|
|
3. 환경 변수 설정
|
|
```bash
|
|
wrangler secret put WEBHOOK_SECRET
|
|
wrangler secret put BOT_TOKEN
|
|
wrangler secret put LINODE_API_KEY
|
|
wrangler secret put VULTR_API_KEY
|
|
```
|
|
|
|
**검증:**
|
|
```bash
|
|
# Queue 목록 확인
|
|
wrangler queues list
|
|
|
|
# Queue 정보 확인
|
|
wrangler queues describe server-provision-queue
|
|
```
|
|
|
|
**예상 소요:** 30분
|
|
|
|
---
|
|
|
|
### Phase 2: Producer 구현 (메시지 전송)
|
|
|
|
**목표:** Telegram 봇에서 Queue로 메시지 전송
|
|
|
|
**작업:**
|
|
1. `src/index.ts` (telegram-bot-workers) 수정
|
|
- callback_query 핸들러에서 `executeServerProvision()` 제거
|
|
- Queue 메시지 전송 로직 추가
|
|
|
|
```typescript
|
|
// 기존 코드 (타임아웃 발생)
|
|
const result = await executeServerProvision(env, userId, telegramUserId, orderId);
|
|
|
|
// 새 코드 (즉시 응답)
|
|
await env.SERVER_PROVISION_QUEUE.send({
|
|
order_id: orderId,
|
|
user_id: userId,
|
|
telegram_user_id: telegramUserId,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
return "📋 주문 접수 완료! 서버 생성 중입니다... (1-2분 소요)";
|
|
```
|
|
|
|
2. 타입 정의 추가 (`src/types.ts`)
|
|
```typescript
|
|
export interface ProvisionMessage {
|
|
order_id: number;
|
|
user_id: number;
|
|
telegram_user_id: string;
|
|
timestamp: number;
|
|
}
|
|
```
|
|
|
|
**검증:**
|
|
```bash
|
|
# 로컬 테스트
|
|
wrangler dev
|
|
|
|
# Queue에 메시지 전송 테스트
|
|
curl -X POST http://localhost:8787/provision \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer test-secret" \
|
|
-d '{"order_id":123,"user_id":456,"telegram_user_id":"789012345"}'
|
|
|
|
# Queue 메시지 확인
|
|
wrangler queues consumer server-provision-queue pull
|
|
```
|
|
|
|
**예상 소요:** 1시간
|
|
|
|
---
|
|
|
|
### Phase 3: Consumer 구현 (서버 생성)
|
|
|
|
**목표:** Queue 메시지를 받아 실제 서버 생성
|
|
|
|
**작업:**
|
|
1. 새 Worker 프로젝트 생성
|
|
```bash
|
|
mkdir server-provision
|
|
cd server-provision
|
|
npm init -y
|
|
npm install wrangler --save-dev
|
|
```
|
|
|
|
2. `src/consumer.ts` 구현
|
|
- Queue 메시지 수신
|
|
- `executeServerProvision()` 호출 (기존 로직 재사용)
|
|
- 성공 시 Telegram 알림
|
|
- 실패 시 `message.retry()`
|
|
|
|
3. `src/server-provision.ts` 복사 (기존 파일 재사용)
|
|
- `telegram-bot-workers/src/server-provision.ts` → `server-provision/src/`
|
|
|
|
4. 의존성 추가
|
|
```bash
|
|
npm install --save \
|
|
@cloudflare/workers-types \
|
|
# 기타 필요한 패키지
|
|
```
|
|
|
|
**검증:**
|
|
```bash
|
|
# 로컬 Consumer 실행
|
|
wrangler dev
|
|
|
|
# Queue에 테스트 메시지 전송 (Phase 2 사용)
|
|
# → Consumer가 자동으로 처리하는지 확인
|
|
|
|
# 로그 확인
|
|
wrangler tail
|
|
```
|
|
|
|
**예상 소요:** 2시간
|
|
|
|
---
|
|
|
|
### Phase 4: Dead Letter Queue 처리
|
|
|
|
**목표:** 재시도 실패 시 최종 처리
|
|
|
|
**작업:**
|
|
1. `src/dlq-handler.ts` 구현
|
|
- DLQ 메시지 수신
|
|
- `server_orders.status` → `'failed'` 업데이트
|
|
- 사용자 알림
|
|
- 관리자 알림 (`notifyAdmin`)
|
|
|
|
2. `wrangler.toml`에 DLQ Consumer 추가
|
|
```toml
|
|
[[queues.consumers]]
|
|
queue = "provision-dlq"
|
|
max_retries = 0 # DLQ는 재시도 없음
|
|
```
|
|
|
|
**검증:**
|
|
```bash
|
|
# 강제 실패 테스트 (API 키 제거)
|
|
wrangler secret delete LINODE_API_KEY
|
|
|
|
# Queue 메시지 전송 → 재시도 3회 → DLQ 전송 확인
|
|
# 로그 확인
|
|
wrangler tail --format pretty
|
|
```
|
|
|
|
**예상 소요:** 1.5시간
|
|
|
|
---
|
|
|
|
### Phase 5: 통합 테스트 및 배포
|
|
|
|
**목표:** 전체 플로우 검증 및 프로덕션 배포
|
|
|
|
**작업:**
|
|
1. 통합 테스트
|
|
- 정상 케이스: 주문 → Queue → 서버 생성 → 알림
|
|
- 실패 케이스: API 에러 → 재시도 → DLQ → 알림
|
|
- 타임아웃 케이스: Telegram Webhook 10초 내 응답 확인
|
|
|
|
2. 모니터링 설정
|
|
- Cloudflare Dashboard → Queue 메트릭 확인
|
|
- `wrangler tail` 로그 확인
|
|
- 관리자 알림 동작 확인
|
|
|
|
3. 프로덕션 배포
|
|
```bash
|
|
# telegram-bot-workers 배포 (Producer)
|
|
cd telegram-bot-workers
|
|
wrangler deploy
|
|
|
|
# server-provision-worker 배포 (Consumer)
|
|
cd ../server-provision
|
|
wrangler deploy
|
|
```
|
|
|
|
4. 문서 업데이트
|
|
- `CLAUDE.md` - 새 아키텍처 반영
|
|
- `README.md` - 배포 가이드 추가
|
|
- `docs/server-provision-architecture.md` - 본 문서 최종 업데이트
|
|
|
|
**검증 체크리스트:**
|
|
- [ ] Telegram 봇에서 서버 주문 시 즉시 응답 (1-2초)
|
|
- [ ] 백그라운드 서버 생성 완료 (1-2분)
|
|
- [ ] 완료 알림 Telegram 메시지 수신
|
|
- [ ] API 실패 시 자동 재시도 확인 (로그)
|
|
- [ ] DLQ 처리 시 관리자 알림 수신
|
|
- [ ] DB `server_orders.status` 올바르게 업데이트
|
|
- [ ] Cloudflare Dashboard Queue 메트릭 정상
|
|
|
|
**예상 소요:** 3시간
|
|
|
|
---
|
|
|
|
## 보안
|
|
|
|
### Service Binding (Worker 간 통신)
|
|
|
|
**문제:** 공개 URL로 Worker 호출 시 보안 위험
|
|
|
|
**해결:** Cloudflare Service Binding 사용
|
|
|
|
**설정:**
|
|
```toml
|
|
# telegram-bot-workers/wrangler.toml
|
|
|
|
[[services]]
|
|
binding = "SERVER_PROVISION_API"
|
|
service = "server-provision-worker"
|
|
environment = "production"
|
|
```
|
|
|
|
**사용 예시:**
|
|
```typescript
|
|
// telegram-bot-workers/src/index.ts
|
|
|
|
// ❌ 공개 URL (보안 위험)
|
|
const response = await fetch('https://server-provision.workers.dev/provision', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${env.WEBHOOK_SECRET}` },
|
|
body: JSON.stringify({ order_id, user_id, telegram_user_id }),
|
|
});
|
|
|
|
// ✅ Service Binding (내부 통신)
|
|
const response = await env.SERVER_PROVISION_API.fetch('https://internal/provision', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ order_id, user_id, telegram_user_id }),
|
|
});
|
|
```
|
|
|
|
**장점:**
|
|
- 외부 네트워크 노출 없음
|
|
- 인증 헤더 불필요 (내부 통신)
|
|
- 속도 향상 (Cloudflare 내부 네트워크)
|
|
|
|
### API 키 관리
|
|
|
|
**원칙:** 모든 민감 정보는 Secrets로 관리
|
|
|
|
**설정:**
|
|
```bash
|
|
# Telegram
|
|
wrangler secret put BOT_TOKEN
|
|
wrangler secret put WEBHOOK_SECRET
|
|
|
|
# Cloud Provider
|
|
wrangler secret put LINODE_API_KEY
|
|
wrangler secret put VULTR_API_KEY
|
|
|
|
# Admin
|
|
wrangler secret put SERVER_ADMIN_ID
|
|
```
|
|
|
|
**절대 금지:**
|
|
- ❌ 코드에 API 키 하드코딩
|
|
- ❌ `wrangler.toml`에 평문 저장
|
|
- ❌ Git에 `.env` 파일 커밋
|
|
|
|
### Rate Limiting
|
|
|
|
**목적:** Queue 메시지 폭주 방지
|
|
|
|
**구현:**
|
|
```typescript
|
|
// telegram-bot-workers/src/index.ts (callback_query 핸들러)
|
|
|
|
// 사용자별 주문 제한 (10개/시간)
|
|
const rateLimitKey = `server_order_${userId}`;
|
|
const orderCount = await env.RATE_LIMIT_KV.get(rateLimitKey);
|
|
|
|
if (orderCount && Number(orderCount) >= 10) {
|
|
return "⚠️ 시간당 주문 제한 (10개)을 초과했습니다.";
|
|
}
|
|
|
|
await env.RATE_LIMIT_KV.put(
|
|
rateLimitKey,
|
|
String((Number(orderCount) || 0) + 1),
|
|
{ expirationTtl: 3600 } // 1시간
|
|
);
|
|
|
|
// Queue 메시지 전송
|
|
await env.SERVER_PROVISION_QUEUE.send({ ... });
|
|
```
|
|
|
|
---
|
|
|
|
## 모니터링
|
|
|
|
### Cloudflare Dashboard
|
|
|
|
**Queue 메트릭 확인:**
|
|
1. Cloudflare Dashboard 로그인
|
|
2. Workers & Pages → Queues
|
|
3. `server-provision-queue` 선택
|
|
|
|
**주요 지표:**
|
|
| 메트릭 | 설명 | 정상 범위 |
|
|
|--------|------|----------|
|
|
| **Messages Sent** | 전송된 메시지 수 | - |
|
|
| **Messages Received** | 처리된 메시지 수 | Sent와 동일 |
|
|
| **Messages Retried** | 재시도된 메시지 수 | <5% |
|
|
| **Messages DLQ** | DLQ로 전송된 메시지 수 | <1% |
|
|
| **Queue Depth** | 대기 중인 메시지 수 | <100 |
|
|
| **Consumer Lag** | 처리 지연 시간 | <5분 |
|
|
|
|
**알람 설정 (권장):**
|
|
- Queue Depth > 500: 백로그 증가
|
|
- Messages DLQ > 10: 시스템 장애 가능성
|
|
- Consumer Lag > 10분: 성능 문제
|
|
|
|
### 로그 확인
|
|
|
|
**실시간 로그 스트리밍:**
|
|
```bash
|
|
# Producer 로그
|
|
cd telegram-bot-workers
|
|
wrangler tail --format pretty
|
|
|
|
# Consumer 로그
|
|
cd server-provision
|
|
wrangler tail --format pretty
|
|
```
|
|
|
|
**로그 필터링:**
|
|
```bash
|
|
# 에러만 확인
|
|
wrangler tail --format pretty | grep ERROR
|
|
|
|
# 특정 order_id 추적
|
|
wrangler tail --format pretty | grep "order_id: 123"
|
|
|
|
# JSON 형식 (파싱용)
|
|
wrangler tail --format json > logs.json
|
|
```
|
|
|
|
### 성공/실패 지표
|
|
|
|
**DB 쿼리로 확인:**
|
|
```sql
|
|
-- 상태별 주문 수
|
|
SELECT status, COUNT(*) as count
|
|
FROM server_orders
|
|
WHERE created_at > datetime('now', '-1 day')
|
|
GROUP BY status;
|
|
|
|
-- 최근 실패 주문
|
|
SELECT id, error_message, created_at
|
|
FROM server_orders
|
|
WHERE status = 'failed'
|
|
ORDER BY created_at DESC
|
|
LIMIT 10;
|
|
|
|
-- 평균 프로비저닝 시간
|
|
SELECT
|
|
AVG((julianday(provisioned_at) - julianday(created_at)) * 24 * 60) as avg_minutes
|
|
FROM server_orders
|
|
WHERE status = 'active' AND provisioned_at IS NOT NULL;
|
|
```
|
|
|
|
**로컬 실행:**
|
|
```bash
|
|
wrangler d1 execute telegram-conversations --command "SELECT ..."
|
|
```
|
|
|
|
**대시보드 구축 (선택):**
|
|
- Cloudflare Analytics Engine 연동
|
|
- Grafana + Prometheus
|
|
- Custom Admin Panel
|
|
|
|
---
|
|
|
|
## 트러블슈팅
|
|
|
|
### 일반적인 문제
|
|
|
|
#### 1. Queue 메시지가 처리되지 않음
|
|
|
|
**증상:**
|
|
- Telegram 봇에서 "주문 접수" 메시지 받음
|
|
- 서버 생성 완료 알림이 오지 않음
|
|
- Queue Depth가 계속 증가
|
|
|
|
**원인:**
|
|
- Consumer Worker가 배포되지 않음
|
|
- Consumer Worker가 에러로 중단됨
|
|
- D1 Database 바인딩 누락
|
|
|
|
**해결:**
|
|
```bash
|
|
# Consumer Worker 배포 확인
|
|
wrangler deployments list --name server-provision-worker
|
|
|
|
# Consumer 로그 확인
|
|
wrangler tail --name server-provision-worker
|
|
|
|
# Queue Consumer 설정 확인
|
|
wrangler queues list
|
|
wrangler queues describe server-provision-queue
|
|
```
|
|
|
|
---
|
|
|
|
#### 2. 재시도가 즉시 발생함 (Exponential Backoff 안됨)
|
|
|
|
**증상:**
|
|
- 로그에 "재시도" 메시지가 1초 간격으로 연속 출력
|
|
- Queue 메트릭에서 Messages Retried 급증
|
|
|
|
**원인:**
|
|
- `message.retry()` 대신 `message.ack()` + 재전송 사용
|
|
- Cloudflare Queue의 자동 재시도 메커니즘 무시
|
|
|
|
**해결:**
|
|
```typescript
|
|
// ❌ 잘못된 패턴
|
|
try {
|
|
await provision();
|
|
} catch (error) {
|
|
// 즉시 재전송 (Exponential Backoff 없음)
|
|
await env.SERVER_PROVISION_QUEUE.send(message.body);
|
|
message.ack();
|
|
}
|
|
|
|
// ✅ 올바른 패턴
|
|
try {
|
|
await provision();
|
|
message.ack(); // 성공 시
|
|
} catch (error) {
|
|
message.retry(); // 실패 시 (Queue가 자동으로 Exponential Backoff 적용)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
#### 3. DLQ 메시지가 무한 누적
|
|
|
|
**증상:**
|
|
- DLQ에 메시지가 쌓이지만 처리되지 않음
|
|
- `server_orders.status`가 여전히 `provisioning`
|
|
|
|
**원인:**
|
|
- DLQ Consumer가 배포되지 않음
|
|
- DLQ 핸들러 로직 누락
|
|
|
|
**해결:**
|
|
```bash
|
|
# DLQ Consumer 설정 확인
|
|
wrangler queues describe provision-dlq
|
|
|
|
# wrangler.toml에 DLQ Consumer 추가 확인
|
|
[[queues.consumers]]
|
|
queue = "provision-dlq"
|
|
max_retries = 0
|
|
|
|
# DLQ 메시지 수동 처리 (임시)
|
|
wrangler queues consumer provision-dlq pull
|
|
```
|
|
|
|
---
|
|
|
|
#### 4. Optimistic Locking 에러 (잔액 차감 실패)
|
|
|
|
**증상:**
|
|
- 서버는 생성되었지만 잔액이 차감되지 않음
|
|
- `server_orders.status` = `failed`
|
|
- 로그: "Version mismatch on balance update"
|
|
|
|
**원인:**
|
|
- 동시에 여러 출금 요청 발생
|
|
- Optimistic Locking 재시도 실패
|
|
|
|
**해결:**
|
|
```typescript
|
|
// utils/optimistic-lock.ts에서 재시도 횟수 증가
|
|
export async function executeWithOptimisticLock<T>(
|
|
db: D1Database,
|
|
operation: (attempt: number) => Promise<T>,
|
|
maxRetries = 5 // 기본 3회 → 5회로 증가
|
|
): Promise<T> {
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**관리자 대응:**
|
|
1. 서버 생성 완료 확인 (Linode/Vultr 대시보드)
|
|
2. DB에서 수동 잔액 차감
|
|
```sql
|
|
-- user_deposits.balance 수동 차감
|
|
UPDATE user_deposits SET balance = balance - 15000 WHERE user_id = 123;
|
|
|
|
-- deposit_transactions 기록
|
|
INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at)
|
|
VALUES (123, 'withdrawal', 15000, 'confirmed', '수동 처리: 서버 #456', CURRENT_TIMESTAMP);
|
|
|
|
-- server_orders 상태 업데이트
|
|
UPDATE server_orders SET status = 'active' WHERE id = 456;
|
|
```
|
|
|
|
---
|
|
|
|
#### 5. Telegram 알림이 전송되지 않음
|
|
|
|
**증상:**
|
|
- 서버 생성 완료되었지만 Telegram 메시지 없음
|
|
- DB `server_orders.status` = `active`
|
|
|
|
**원인:**
|
|
- `BOT_TOKEN` 만료/잘못됨
|
|
- `telegram_user_id` 형식 오류 (문자열 vs 숫자)
|
|
- Telegram API Rate Limit
|
|
|
|
**해결:**
|
|
```bash
|
|
# BOT_TOKEN 재설정
|
|
wrangler secret put BOT_TOKEN
|
|
|
|
# Consumer 로그 확인
|
|
wrangler tail --name server-provision-worker | grep "Telegram"
|
|
|
|
# 수동 알림 전송 (임시)
|
|
curl -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
|
-d "chat_id=123456789" \
|
|
-d "text=서버 생성 완료 (#456)"
|
|
```
|
|
|
|
---
|
|
|
|
### 로그 명령어
|
|
|
|
**Producer (telegram-bot-workers):**
|
|
```bash
|
|
# 실시간 로그
|
|
wrangler tail --name telegram-summary-bot --format pretty
|
|
|
|
# JSON 로그 (파싱용)
|
|
wrangler tail --name telegram-summary-bot --format json
|
|
|
|
# 특정 사용자 추적
|
|
wrangler tail --name telegram-summary-bot | grep "telegram_user_id: 123456789"
|
|
```
|
|
|
|
**Consumer (server-provision-worker):**
|
|
```bash
|
|
# 실시간 로그
|
|
wrangler tail --name server-provision-worker --format pretty
|
|
|
|
# 에러만 확인
|
|
wrangler tail --name server-provision-worker | grep -E "ERROR|failed"
|
|
|
|
# 특정 order_id 추적
|
|
wrangler tail --name server-provision-worker | grep "order_id: 123"
|
|
```
|
|
|
|
**Queue 상태:**
|
|
```bash
|
|
# Queue 메트릭
|
|
wrangler queues describe server-provision-queue
|
|
|
|
# DLQ 메트릭
|
|
wrangler queues describe provision-dlq
|
|
|
|
# 수동으로 메시지 가져오기 (디버깅)
|
|
wrangler queues consumer server-provision-queue pull --batch-size 1
|
|
```
|
|
|
|
---
|
|
|
|
## 참고 자료
|
|
|
|
**Cloudflare 공식 문서:**
|
|
- [Queues Documentation](https://developers.cloudflare.com/queues/)
|
|
- [Queue Consumer](https://developers.cloudflare.com/queues/configuration/consumer-concurrency/)
|
|
- [Dead Letter Queues](https://developers.cloudflare.com/queues/configuration/dead-letter-queues/)
|
|
- [Service Bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/)
|
|
|
|
**프로젝트 내부 문서:**
|
|
- `CLAUDE.md` - 개발자 가이드
|
|
- `README.md` - 사용자 가이드
|
|
- `docs/ARCHITECTURE.md` - 시스템 아키텍처
|
|
|
|
**관련 코드:**
|
|
- `src/server-provision.ts` - 기존 프로비저닝 로직
|
|
- `src/services/linode-api.ts` - Linode API 클라이언트
|
|
- `src/services/vultr-api.ts` - Vultr API 클라이언트
|
|
- `utils/optimistic-lock.ts` - Optimistic Locking 유틸리티
|
|
- `utils/retry.ts` - Retry 로직
|
|
|
|
---
|
|
|
|
**문서 작성일:** 2026-01-24
|
|
**작성자:** Claude Code (Coder Agent)
|
|
**버전:** 1.0
|
|
**최종 업데이트:** 2026-01-24
|