API Gateway с нуля: проектирование, реализация и масштабирование

Почему готовые решения не подошли

Мы начали с оценки существующих решений. Требования «МаркетХаб» были специфичны:

  • Маршрутизация на основе содержимого тела запроса (разные маркетплейсы — разные форматы)
  • Трансформация запросов/ответов между форматами разных площадок
  • Кастомная логика rate limiting: лимиты не на IP, а на пару «продавец + маркетплейс»
  • Горячая перезагрузка конфигурации без рестарта
  • Задержка не более 5 мс на уровне gateway

Kong покрывал 70% задач, но кастомные плагины на Lua требовали отдельной экспертизы, которой в команде не было. Трансформация тел запросов через Lua-скрипты превращалась в нечитаемый код.

AWS API Gateway не подходил по задержке: p99 составлял 25–30 мс только на уровне gateway, что неприемлемо при общем бюджете латентности в 100 мс.

Envoy + WASM — ближайший кандидат, но WASM-плагины для сложной трансформации JSON работали в 3–4 раза медленнее нативного кода.

Архитектура: что внутри

Gateway состоит из пяти основных компонентов, каждый реализован как отдельный пакет:

  • router — маршрутизация и балансировка нагрузки
  • auth — JWT-валидация и RBAC
  • limiter — rate limiting (sliding window)
  • transform — трансформация запросов/ответов
  • circuit — circuit breaker для upstream-сервисов

Запрос проходит через цепочку middleware в строгом порядке:

Request → TLS Termination → Rate Limiter → Auth (JWT + RBAC)
  → Request Transform → Router → Circuit Breaker → Upstream
  → Response Transform → Cache → Response

Маршрутизация и балансировка

Роутер построен на radix tree для O(k) поиска маршрута, где k — длина пути. Конфигурация маршрутов загружается из YAML и может обновляться hot-reload через SIGHUP:

# config/routes.yaml
routes:
  - path: /api/v1/orders
    methods: [GET, POST]
    upstream:
      service: order-service
      endpoints:
        - addr: order-svc-1:8080
          weight: 70
        - addr: order-svc-2:8080
          weight: 30
    middlewares:
      - auth: { roles: [seller, admin] }
      - rate_limit: { key: seller_id, limit: 100, window: 60s }
      - transform: { request: order_normalize }

  - path: /api/v1/products/{marketplace_id}
    methods: [GET]
    upstream:
      service: catalog-service
      endpoints:
        - addr: catalog-svc:8080
          weight: 100
    middlewares:
      - cache: { ttl: 30s, vary: [marketplace_id, Accept-Language] }

  - path: /api/v1/webhooks/{provider}
    methods: [POST]
    upstream:
      service: webhook-processor
      route_by_body: true  # Маршрут зависит от содержимого тела
      body_routing_field: event_type
      body_routes:
        order.created: webhook-orders:8080
        order.updated: webhook-orders:8080
        payment.received: webhook-payments:8080
        stock.changed: webhook-inventory:8080

Реализация роутера:

// router/router.go
package router

import (
    "net/http"
    "sync/atomic"
)

type Router struct {
    tree    atomic.Pointer[radixTree]
    config  atomic.Pointer[Config]
}

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    tree := r.tree.Load()
    route, params := tree.Find(req.Method, req.URL.Path)
    if route == nil {
        http.Error(w, "Not Found", http.StatusNotFound)
        return
    }

    // Inject path params into context
    ctx := withParams(req.Context(), params)
    req = req.WithContext(ctx)

    // Build and execute middleware chain
    handler := route.BuildChain(route.UpstreamHandler())
    handler.ServeHTTP(w, req)
}

// HotReload перезагружает конфигурацию без даунтайма
func (r *Router) HotReload(newConfig *Config) error {
    newTree, err := buildRadixTree(newConfig.Routes)
    if err != nil {
        return fmt.Errorf("invalid config: %w", err)
    }
    r.tree.Store(newTree)
    r.config.Store(newConfig)
    log.Info("routes reloaded", "count", len(newConfig.Routes))
    return nil
}

Балансировка — weighted round-robin с health check. Каждые 5 секунд gateway проверяет /healthz каждого upstream и исключает нездоровые ноды из ротации:

// router/balancer.go
type WeightedEndpoint struct {
    Addr          string
    Weight        int
    currentWeight int
    alive         atomic.Bool
}

func (b *Balancer) Next() *WeightedEndpoint {
    b.mu.Lock()
    defer b.mu.Unlock()

    totalWeight := 0
    var best *WeightedEndpoint

    for _, ep := range b.endpoints {
        if !ep.alive.Load() {
            continue
        }
        ep.currentWeight += ep.Weight
        totalWeight += ep.Weight
        if best == nil || ep.currentWeight > best.currentWeight {
            best = ep
        }
    }

    if best == nil {
        return nil // Все upstream мертвы
    }
    best.currentWeight -= totalWeight
    return best
}

Аутентификация: JWT + RBAC

Gateway валидирует JWT-токены и проверяет роли без обращения к auth-сервису. Публичные ключи кэшируются и обновляются по JWKS-эндпоинту:

// auth/jwt.go
package auth

import (
    "github.com/golang-jwt/jwt/v5"
)

type Claims struct {
    jwt.RegisteredClaims
    UserID    string   `json:"uid"`
    SellerID  string   `json:"sid,omitempty"`
    Roles     []string `json:"roles"`
}

func (a *AuthMiddleware) Validate(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{},
        func(t *jwt.Token) (interface{}, error) {
            kid := t.Header["kid"].(string)
            return a.keyStore.GetKey(kid)
        },
        jwt.WithValidMethods([]string{"RS256", "ES256"}),
        jwt.WithLeeway(5*time.Second),
    )
    if err != nil {
        return nil, fmt.Errorf("invalid token: %w", err)
    }
    return token.Claims.(*Claims), nil
}

// RBAC middleware
func RequireRoles(roles ...string) Middleware {
    roleSet := make(map[string]bool, len(roles))
    for _, r := range roles {
        roleSet[r] = true
    }
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            claims := ClaimsFromContext(r.Context())
            for _, role := range claims.Roles {
                if roleSet[role] {
                    next.ServeHTTP(w, r)
                    return
                }
            }
            http.Error(w, "Forbidden", http.StatusForbidden)
        })
    }
}

Rate Limiting: Sliding Window

Классический fixed window имеет проблему всплесков на границе окон. Мы реализовали sliding window log на Redis:

// limiter/sliding_window.go
func (l *SlidingWindowLimiter) Allow(key string, limit int, window time.Duration) (bool, error) {
    now := time.Now()
    windowStart := now.Add(-window)

    pipe := l.redis.Pipeline()
    // Удаляем устаревшие записи
    pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart.UnixMicro()))
    // Добавляем текущий запрос
    pipe.ZAdd(ctx, key, redis.Z{Score: float64(now.UnixMicro()), Member: now.UnixMicro()})
    // Считаем количество запросов в окне
    countCmd := pipe.ZCard(ctx, key)
    // TTL чтобы ключи не жили вечно
    pipe.Expire(ctx, key, window+time.Second)

    _, err := pipe.Exec(ctx)
    if err != nil {
        return false, err
    }

    count := countCmd.Val()
    return count <= int64(limit), nil
}

Ключ rate limit формируется динамически из конфигурации маршрута. Для «МаркетХаб» типичный ключ — seller:{seller_id}:marketplace:{marketplace_id}, что позволяет ограничивать каждого продавца отдельно на каждой площадке.

Circuit Breaker

Когда upstream-сервис начинает сбоить, нет смысла продолжать слать ему запросы. Circuit breaker переключается в три состояния: Closed (норма), Open (отказ), Half-Open (проверка):

// circuit/breaker.go
type State int

const (
    Closed   State = iota
    Open
    HalfOpen
)

type Breaker struct {
    mu            sync.Mutex
    state         State
    failures      int
    successes     int
    threshold     int    // Порог ошибок для открытия
    halfOpenMax   int    // Количество пробных запросов
    timeout       time.Duration
    lastFailure   time.Time
}

func (b *Breaker) Execute(fn func() error) error {
    b.mu.Lock()
    state := b.state

    switch state {
    case Open:
        if time.Since(b.lastFailure) > b.timeout {
            b.state = HalfOpen
            b.successes = 0
            b.mu.Unlock()
            return b.doHalfOpen(fn)
        }
        b.mu.Unlock()
        return ErrCircuitOpen

    case HalfOpen:
        b.mu.Unlock()
        return b.doHalfOpen(fn)

    default: // Closed
        b.mu.Unlock()
        return b.doClosed(fn)
    }
}

func (b *Breaker) doClosed(fn func() error) error {
    err := fn()
    b.mu.Lock()
    defer b.mu.Unlock()

    if err != nil {
        b.failures++
        b.lastFailure = time.Now()
        if b.failures >= b.threshold {
            b.state = Open
            log.Warn("circuit opened", "failures", b.failures)
        }
        return err
    }
    b.failures = 0
    return nil
}

Кэширование

GET-запросы кэшируются на уровне gateway в Redis с учётом Vary-заголовков. Ключ кэша — хеш от пути, query-параметров и значений Vary-заголовков:

// cache/middleware.go
func (c *CacheMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        c.next.ServeHTTP(w, r)
        return
    }

    cacheKey := c.buildKey(r)
    cached, err := c.store.Get(r.Context(), cacheKey)
    if err == nil {
        w.Header().Set("X-Cache", "HIT")
        w.Header().Set("Content-Type", cached.ContentType)
        w.WriteHeader(cached.StatusCode)
        w.Write(cached.Body)
        return
    }

    // Записываем ответ и кэшируем
    rec := &responseRecorder{ResponseWriter: w}
    c.next.ServeHTTP(rec, r)

    if rec.statusCode >= 200 && rec.statusCode < 300 {
        c.store.Set(r.Context(), cacheKey, &CachedResponse{
            StatusCode:  rec.statusCode,
            ContentType: rec.Header().Get("Content-Type"),
            Body:        rec.body.Bytes(),
        }, c.ttl)
    }
    w.Header().Set("X-Cache", "MISS")
}

Логирование и трейсинг

Каждый запрос получает trace ID (совместимый с OpenTelemetry), который прокидывается во все upstream-вызовы:

// tracing/middleware.go
func TracingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("X-Trace-Id")
        if traceID == "" {
            traceID = generateTraceID()
        }

        ctx := context.WithValue(r.Context(), traceIDKey, traceID)
        r = r.WithContext(ctx)

        w.Header().Set("X-Trace-Id", traceID)

        start := time.Now()
        rec := &responseRecorder{ResponseWriter: w, statusCode: 200}
        next.ServeHTTP(rec, r)

        log.Info("request",
            "trace_id", traceID,
            "method", r.Method,
            "path", r.URL.Path,
            "status", rec.statusCode,
            "duration_ms", time.Since(start).Milliseconds(),
            "upstream", UpstreamFromContext(ctx),
            "seller_id", SellerIDFromContext(ctx),
        )
    })
}

API Versioning

Мы поддерживаем версионирование через URL-префикс (/api/v1/, /api/v2/) и заголовок Accept-Version. При появлении новой версии старая продолжает работать параллельно с автоматической трансформацией между форматами:

# Версионированные маршруты
routes:
  - path: /api/v1/orders
    upstream:
      service: order-service
    middlewares:
      - transform: { response: v1_compat }

  - path: /api/v2/orders
    upstream:
      service: order-service
    # v2 — нативный формат, трансформация не нужна

Бенчмарки под нагрузкой

Тестирование на 3 нодах (4 vCPU, 8 GB RAM каждая) за Nginx Ingress:

МетрикаНаш GatewayKong (для сравнения)
Пропускная способность48 000 RPS32 000 RPS
Latency p501.2 мс3.8 мс
Latency p994.1 мс12.5 мс
Latency p99.98.3 мс28.7 мс
RAM на ноду120 МБ340 МБ
CPU под нагрузкой65%82%

Zero-Downtime Deployment

Gateway деплоится в Kubernetes с rolling update. Ключевой момент — graceful shutdown: при получении SIGTERM gateway перестаёт принимать новые соединения, но дожидает завершения текущих:

// main.go
func main() {
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      gateway,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 30 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal("server error", "err", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Info("shutting down gracefully...")
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Error("forced shutdown", "err", err)
    }
    log.Info("server stopped")
}
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-gateway
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: gateway
          image: registry.markethub.internal/api-gateway:v2.3.1
          ports:
            - containerPort: 8080
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
            initialDelaySeconds: 3
            periodSeconds: 5
          resources:
            requests:
              cpu: "500m"
              memory: "128Mi"
            limits:
              cpu: "2000m"
              memory: "512Mi"

Итоги

Кастомный API Gateway — это серьёзная инвестиция. На проектирование и реализацию ушло 3 месяца работы двух Go-инженеров. Но результат окупился: мы получили gateway, который точно соответствует бизнес-логике «МаркетХаб», работает в 3 раза быстрее Kong и легко расширяется командой, которая его написала.

Если ваши требования покрываются Kong, Envoy или AWS API Gateway — используйте их. Пишите своё только если стандартные решения действительно не подходят, и у вас есть команда, готовая поддерживать этот компонент годами.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#aws api gateway#breaker#circuit#deployment#devops#envoy + wasm#kong#limiting
Комментарии 0

Оставить комментарий

загрузка...