ผมเคยดูแลระบบ LLM gateway ที่รับโหลด 12 ล้าน request ต่อวัน ในช่วง Q1 ปี 2026 ทีมต้องย้าย traffic ทั้งหมดจาก upstream เดิมไปยัง HolySheep โดยไม่ให้เกิด downtime แม้แต่วินาทีเดียว บทความนี้คือ production playbook ที่เราใช้จริง — ตั้งแต่ canary routing แบบ consistent hashing, circuit breaker ที่ตัดสินใจภายใน 30 วินาที, ไปจนถึง cron job ที่ reconcile บิลทุกชั่วโมงเพื่อจับ drift ก่อนบิลงวดถัดไปปิด

ภาพรวมสถาปัตยกรรม Gray Release 3 เฟส

ชั้นที่ 1 — Gateway แบบ Canary Routing ด้วย Consistent Hashing

หัวใจของ gray release คือการเลือก upstream แบบ deterministic ต่อ request เพื่อให้ session เดิมไม่กระโดดไปมา เราใช้ MD5 ของ request_id + tenant_id แล้ว modulo ด้วยน้ำหนักรวม วิธีนี้ทำให้เวลาเลื่อน weight จาก 80/20 ไป 60/40 มีเพียง 20% ของ traffic ที่ย้าย ไม่ใช่ทั้งหมด

"""gateway.py — Enterprise Gray Release Gateway
ทดสอบ: python gateway.py
ต้องการ: pip install httpx
"""
import asyncio
import hashlib
import json
import time
import logging
from dataclasses import dataclass, field
from typing import Optional, Dict, Any
import httpx

logging.basicConfig(level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("gateway")

@dataclass
class ProviderConfig:
    name: str
    base_url: str
    api_key: str
    weight: int = 100           # เปอร์เซ็นต์ 0-100
    healthy: bool = True
    p95_latency_ms: float = 0.0
    success_count: int = 0
    failure_count: int = 0

class GrayReleaseGateway:
    def __init__(self) -> None:
        # ทั้งสอง pool ใช้ HolySheep แต่คนละ region/key
        # เพื่อทดสอบ failover ระหว่าง edge node
        self.providers: Dict[str, ProviderConfig] = {
            "primary": ProviderConfig(
                name="holysheep-primary",
                base_url="https://api.holysheep.ai/v1",
                api_key="YOUR_HOLYSHEEP_API_KEY",
                weight=80,
            ),
            "canary": ProviderConfig(
                name="holysheep-canary",
                base_url="https://api.holysheep.ai/v1",
                api_key="YOUR_HOLYSHEEP_API_KEY",
                weight=20,
            ),
        }
        self._lock = asyncio.Lock()

    def _select_provider(self, request_id: str) -> ProviderConfig:
        """Deterministic routing ด้วย consistent hashing"""
        key = f"tenant:{request_id}"
        bucket = int(hashlib.md5(key.encode()).hexdigest(), 16) % 100
        cumulative, fallback = 0, None
        for cfg in self.providers.values():
            if not cfg.healthy:
                continue
            cumulative += cfg.weight
            fallback = fallback or cfg
            if bucket < cumulative:
                return cfg
        return fallback or self.providers["primary"]

    async def update_weights(self, primary_w: int, canary_w: int) -> None:
        """เรียกตอน promote/demote canary"""
        async with self._lock:
            self.providers["primary"].weight = primary_w
            self.providers["canary"].weight = canary_w
        log.info("weights updated primary=%d canary=%d",
                 primary_w, canary_w)

    async def chat(self, request_id: str, payload: Dict[str, Any],
                   timeout: float = 8.0) -> Dict[str, Any]:
        provider = self._select_provider(request_id)
        start = time.perf_counter()
        try:
            async with httpx.AsyncClient(timeout=timeout) as client:
                resp = await client.post(
                    f"{provider.base_url}/chat/completions",
                    headers={
                        "Authorization": f"Bearer {provider.api_key}",
                        "X-Request-Id": request_id,
                    },
                    json=payload,
                )
                resp.raise_for_status()
                elapsed_ms = (time.perf_counter() - start) * 1000
                provider.p95_latency_ms = max(
                    provider.p95_latency_ms * 0.9, elapsed_ms)
                provider.success_count += 1
                return resp.json()
        except Exception as exc:
            provider.failure_count += 1
            log.error("provider=%s err=%s", provider.name, exc)
            # ส่งต่อให้ failover layer จัดการ
            raise

if __name__ == "__main__":
    async def _demo():
        gw = GrayReleaseGateway()
        for i in range(50):
            req_id = f"req-{i:04d}"
            provider = gw._select_provider(req_id)
            print(f"{req_id} -> {provider.name}")
    asyncio.run(_demo())

ชั้นที่ 2 — Circuit Breaker พร้อม Auto-Failover

Circuit breaker มี 3 state: CLOSED (ทำงานปกติ), OPEN (หยุดเรียก upstream เพื่อไม่ให้ท่วม), HALF_OPEN (ปล่อย probe จำนวนจำกัดเพื่อทดสอบ recovery) เราตั้ง threshold ไว้ที่ 5 ครั้งติดในหน้าต่าง 60 วินาที และ recovery timeout 30 วินาที ซึ่งค่านี้ได้มาจากการยิง load test จริงและดูว่า upstream ฟื้นตัวเร็วแค่ไหน

"""circuit_breaker.py — Auto-failover ระหว่าง HolySheep pool
ทดสอบ: python circuit_breaker.py
"""
import asyncio
import time
import random
from enum import Enum
from dataclasses import dataclass, field
from typing import Callable, Awaitable, Any

class State(str, Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

@dataclass
class CircuitBreaker:
    failure_threshold: int = 5
    recovery_timeout_s: float = 30.0
    half_open_max_probes: int = 3
    latency_budget_ms: float = 220.0

    state: State = State.CLOSED
    failure_streak: int = 0
    opened_at: float = 0.0
    probe_attempts: int = 0
    p95_latency_ms: float = 0.0
    total_calls: int = 0
    total_failures: int = 0

    def allow(self) -> bool:
        if self.state is State.CLOSED:
            return True
        if self.state is State.OPEN:
            if time.time() - self.opened_at >= self.recovery_timeout_s:
                self.state = State.HALF_OPEN
                self.probe_attempts = 0
                log.info("breaker -> HALF_OPEN")
                return True
            return False
        # HALF_OPEN
        return self.probe_attempts < self.half_open_max_probes

    def record(self, ok: bool, latency_ms: float) -> None:
        self.total_calls += 1
        # EWMA แบบ alpha=0.1 สำหรับ P95 approximation
        self.p95_latency_ms = (0.1 * latency_ms +
                               0.9 * self.p95