Backend-разработка

Построение внутреннего API-шлюза на основе Docker и Go без сторонних решений: маршрутизация, балансировка и circuit breaker

Ruslan Ismailov Опубликовано 14 мин чтения
П

Зачем строить собственный API Gateway и когда это оправдано

Готовые решения — Kong, Traefik, AWS API Gateway — закрывают большинство задач. Но бывают ситуации, когда собственный API Gateway на Go оказывается оправданным выбором:

  • Нужен полный контроль над логикой маршрутизации и балансировки без overhead'а чужого кода.
  • Требования к производительности настолько специфичны, что универсальные решения не подходят.
  • Команда хочет глубоко понять устройство шлюза изнутри, а не управлять чёрным ящиком.
  • Инфраструктура изолирована и не допускает сторонних зависимостей.
  • Нужна нестандартная логика circuit breaker или маршрутизация на основе бизнес-правил.

Если ни одно из условий не выполняется — используйте готовые решения. В противном случае читаем дальше.

Архитектура решения: компоненты и схема взаимодействия

Наш шлюз состоит из следующих компонентов:

  • Config Loader — загружает и перезагружает конфигурацию маршрутов из YAML-файла без рестарта процесса.
  • Router — сопоставляет входящий HTTP-запрос с маршрутом по prefix/path.
  • Load Balancer — выбирает бэкенд из пула (round-robin или weighted round-robin).
  • Circuit Breaker — отслеживает ошибки бэкенда и временно исключает его из ротации.
  • Proxy — проксирует запрос к выбранному бэкенду и возвращает ответ клиенту.
  • Metrics Collector — собирает статистику: RPS, latency, ошибки.

Схема взаимодействия (текстовое описание):

Client → [API Gateway: Router → Load Balancer → Circuit Breaker → Proxy] → Backend Service A / B / C → Response → Client

Все компоненты работают внутри одного Go-процесса, развёрнутого в Docker-контейнере. Бэкенды — отдельные микросервисы в своих контейнерах, объединённые через Docker Compose в одну сеть.

Реализация маршрутизации запросов на Go с динамической конфигурацией

Начнём с описания конфигурации в YAML:

routes:
  - prefix: /api/users
    backends:
      - url: http://users-service-1:8080
        weight: 2
      - url: http://users-service-2:8080
        weight: 1
  - prefix: /api/orders
    backends:
      - url: http://orders-service:8081
        weight: 1

Структуры данных для конфигурации:

package config

import (
    "os"
    "sync"
    "time"

    "gopkg.in/yaml.v3"
)

type Backend struct {
    URL    string `yaml:"url"`
    Weight int    `yaml:"weight"`
}

type Route struct {
    Prefix   string    `yaml:"prefix"`
    Backends []Backend `yaml:"backends"`
}

type Config struct {
    Routes []Route `yaml:"routes"`
}

type DynamicConfig struct {
    mu      sync.RWMutex
    current *Config
    path    string
}

func NewDynamicConfig(path string) (*DynamicConfig, error) {
    dc := &DynamicConfig{path: path}
    if err := dc.reload(); err != nil {
        return nil, err
    }
    go dc.watch()
    return dc, nil
}

func (dc *DynamicConfig) reload() error {
    data, err := os.ReadFile(dc.path)
    if err != nil {
        return err
    }
    var cfg Config
    if err := yaml.Unmarshal(data, &cfg); err != nil {
        return err
    }
    dc.mu.Lock()
    dc.current = &cfg
    dc.mu.Unlock()
    return nil
}

func (dc *DynamicConfig) watch() {
    ticker := time.NewTicker(10 * time.Second)
    for range ticker.C {
        _ = dc.reload()
    }
}

func (dc *DynamicConfig) Get() *Config {
    dc.mu.RLock()
    defer dc.mu.RUnlock()
    return dc.current
}

Роутер ищет маршрут по longest-prefix match:

package router

import (
    "net/http"
    "sort"
    "strings"

    "gateway/config"
)

type Router struct {
    cfg *config.DynamicConfig
}

func New(cfg *config.DynamicConfig) *Router {
    return &Router{cfg: cfg}
}

func (r *Router) Match(req *http.Request) (*config.Route, bool) {
    routes := r.cfg.Get().Routes
    // сортируем по убыванию длины prefix для longest-match
    sort.Slice(routes, func(i, j int) bool {
        return len(routes[i].Prefix) > len(routes[j].Prefix)
    })
    for _, route := range routes {
        if strings.HasPrefix(req.URL.Path, route.Prefix) {
            rv := route
            return &rv, true
        }
    }
    return nil, false
}

Балансировка нагрузки: round-robin и weighted round-robin своими руками

Round-robin — простейший алгоритм: каждый запрос идёт к следующему бэкенду по кругу.

package balancer

import (
    "sync/atomic"

    "gateway/config"
)

type RoundRobin struct {
    counter uint64
}

func (rr *RoundRobin) Pick(backends []config.Backend) config.Backend {
    n := uint64(len(backends))
    idx := atomic.AddUint64(&rr.counter, 1) % n
    return backends[idx]
}

Weighted round-robin — бэкенды с большим весом получают пропорционально больше запросов. Реализуем через разворачивание списка:

package balancer

import (
    "sync/atomic"

    "gateway/config"
)

type WeightedRoundRobin struct {
    counter  uint64
    expanded []config.Backend
}

func NewWeightedRoundRobin(backends []config.Backend) *WeightedRoundRobin {
    var expanded []config.Backend
    for _, b := range backends {
        w := b.Weight
        if w <= 0 {
            w = 1
        }
        for i := 0; i < w; i++ {
            expanded = append(expanded, b)
        }
    }
    return &WeightedRoundRobin{expanded: expanded}
}

func (w *WeightedRoundRobin) Pick() config.Backend {
    n := uint64(len(w.expanded))
    idx := atomic.AddUint64(&w.counter, 1) % n
    return w.expanded[idx]
}

При перезагрузке конфигурации балансировщик пересоздаётся с новым пулом бэкендов — это атомарно через sync/atomic и sync.RWMutex.

Реализация паттерна Circuit Breaker на Go

Circuit Breaker отслеживает количество последовательных ошибок и переводит бэкенд в состояние OPEN, временно исключая его из ротации. Через заданный timeout происходит попытка восстановления (состояние HALF-OPEN).

package breaker

import (
    "sync"
    "time"
)

type State int

const (
    StateClosed   State = iota // нормальная работа
    StateOpen                  // бэкенд недоступен
    StateHalfOpen              // проверяем восстановление
)

type CircuitBreaker struct {
    mu           sync.Mutex
    state        State
    failures     int
    maxFailures  int
    timeout      time.Duration
    lastFailure  time.Time
}

func New(maxFailures int, timeout time.Duration) *CircuitBreaker {
    return &CircuitBreaker{
        maxFailures: maxFailures,
        timeout:     timeout,
        state:       StateClosed,
    }
}

func (cb *CircuitBreaker) Allow() bool {
    cb.mu.Lock()
    defer cb.mu.Unlock()

    switch cb.state {
    case StateClosed:
        return true
    case StateOpen:
        if time.Since(cb.lastFailure) > cb.timeout {
            cb.state = StateHalfOpen
            return true
        }
        return false
    case StateHalfOpen:
        return true
    }
    return false
}

func (cb *CircuitBreaker) RecordSuccess() {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    cb.failures = 0
    cb.state = StateClosed
}

func (cb *CircuitBreaker) RecordFailure() {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    cb.failures++
    cb.lastFailure = time.Now()
    if cb.failures >= cb.maxFailures {
        cb.state = StateOpen
    }
}

Каждый бэкенд получает свой экземпляр CircuitBreaker. Перед проксированием вызываем Allow() — если false, пропускаем бэкенд и берём следующий из пула.

Интеграция с REST API бэкендов и проксирование запросов

Основной обработчик собирает все компоненты вместе:

package proxy

import (
    "io"
    "net/http"
    "net/url"
    "time"

    "gateway/balancer"
    "gateway/breaker"
    "gateway/config"
    "gateway/router"
)

type Handler struct {
    router   *router.Router
    breakers map[string]*breaker.CircuitBreaker
    client   *http.Client
}

func NewHandler(r *router.Router) *Handler {
    return &Handler{
        router:   r,
        breakers: make(map[string]*breaker.CircuitBreaker),
        client: &http.Client{
            Timeout: 10 * time.Second,
        },
    }
}

func (h *Handler) getBreaker(backendURL string) *breaker.CircuitBreaker {
    if cb, ok := h.breakers[backendURL]; ok {
        return cb
    }
    cb := breaker.New(5, 30*time.Second)
    h.breakers[backendURL] = cb
    return cb
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    route, ok := h.router.Match(r)
    if !ok {
        http.Error(w, "no route found", http.StatusNotFound)
        return
    }

    wrr := balancer.NewWeightedRoundRobin(route.Backends)
    var lastErr error

    for attempt := 0; attempt < len(route.Backends); attempt++ {
        backend := wrr.Pick()
        cb := h.getBreaker(backend.URL)

        if !cb.Allow() {
            continue
        }

        targetURL, err := url.Parse(backend.URL)
        if err != nil {
            continue
        }
        targetURL.Path = r.URL.Path
        targetURL.RawQuery = r.URL.RawQuery

        req, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL.String(), r.Body)
        if err != nil {
            lastErr = err
            cb.RecordFailure()
            continue
        }
        req.Header = r.Header.Clone()

        resp, err := h.client.Do(req)
        if err != nil {
            lastErr = err
            cb.RecordFailure()
            continue
        }
        defer resp.Body.Close()

        if resp.StatusCode >= 500 {
            cb.RecordFailure()
            continue
        }

        cb.RecordSuccess()
        for k, v := range resp.Header {
            w.Header()[k] = v
        }
        w.WriteHeader(resp.StatusCode)
        io.Copy(w, resp.Body)
        return
    }

    if lastErr != nil {
        http.Error(w, "all backends unavailable: "+lastErr.Error(), http.StatusBadGateway)
    } else {
        http.Error(w, "all backends unavailable", http.StatusBadGateway)
    }
}

Контейнеризация шлюза с Docker и оркестрация через Docker Compose

Dockerfile для шлюза использует многоэтапную сборку:

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o gateway ./cmd/gateway

FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/gateway .
COPY config.yaml .
EXPOSE 8000
CMD ["./gateway"]

Docker Compose объединяет шлюз и бэкенды в одну сеть:

version: "3.9"

services:
  gateway:
    build: ./gateway
    ports:
      - "8000:8000"
    volumes:
      - ./config.yaml:/app/config.yaml
    networks:
      - internal
    depends_on:
      - users-service-1
      - users-service-2
      - orders-service

  users-service-1:
    image: mycompany/users-service:latest
    networks:
      - internal
    environment:
      - PORT=8080

  users-service-2:
    image: mycompany/users-service:latest
    networks:
      - internal
    environment:
      - PORT=8080

  orders-service:
    image: mycompany/orders-service:latest
    networks:
      - internal
    environment:
      - PORT=8081

networks:
  internal:
    driver: bridge

Благодаря volume-mount конфигурации, динамический перезагрузчик подхватывает изменения в config.yaml без рестарта контейнера.

Логирование, метрики и базовый мониторинг

Для логирования используем структурированный вывод через стандартный log/slog (Go 1.21+):

package middleware

import (
    "log/slog"
    "net/http"
    "time"
)

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
        next.ServeHTTP(rw, r)
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", rw.statusCode,
            "duration_ms", time.Since(start).Milliseconds(),
        )
    })
}

Для метрик добавляем простой endpoint /metrics, который отдаёт счётчики в формате, совместимом с Prometheus:

package metrics

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

var (
    TotalRequests  uint64
    TotalErrors    uint64
    OpenBreakers   uint64
)

func Handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "# HELP gateway_requests_total Total requests\n")
    fmt.Fprintf(w, "gateway_requests_total %d\n", atomic.LoadUint64(&TotalRequests))
    fmt.Fprintf(w, "# HELP gateway_errors_total Total errors\n")
    fmt.Fprintf(w, "gateway_errors_total %d\n", atomic.LoadUint64(&TotalErrors))
    fmt.Fprintf(w, "# HELP gateway_open_breakers Open circuit breakers\n")
    fmt.Fprintf(w, "gateway_open_breakers %d\n", atomic.LoadUint64(&OpenBreakers))
}

Endpoint /metrics легко подключается к Prometheus + Grafana для визуализации. Это даёт минимально достаточный мониторинг для production-среды.

Ограничения подхода и когда лучше использовать готовые решения

Самописный API Gateway — это сознательный trade-off. Честно перечислим ограничения:

  • Нет встроенной аутентификации: JWT, OAuth2, API-ключи придётся реализовывать самостоятельно или через middleware.
  • Нет rate limiting из коробки: потребуется отдельный модуль, часто с Redis в качестве хранилища счётчиков.
  • Нет UI и декларативного DSL: конфигурация через YAML проще, чем Kong Admin API, но не сравнится по удобству.
  • Тестирование и поддержка: каждая фича требует написания тестов. Баги в circuit breaker могут привести к каскадным сбоям.
  • Сложность масштабирования: горизонтальное масштабирование самого шлюза потребует внешнего хранилища состояния circuit breaker'ов.

Когда использовать готовые решения:

  • Команда небольшая и нет ресурсов поддерживать кастомный шлюз.
  • Нужен богатый набор плагинов: трансформация запросов, canary releases, mTLS.
  • Инфраструктура уже работает на Kubernetes — Traefik или ingress-nginx решат задачу быстрее.
  • Проект в начале пути и требования к шлюзу ещё не устоялись.

Собственный API Gateway на Go — это ценный учебный и рабочий инструмент для команд, которые точно знают, чего хотят, и готовы нести ответственность за каждую строку кода в критическом компоненте инфраструктуры.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →