Построение внутреннего API-шлюза на основе Docker и Go без сторонних решений: маршрутизация, балансировка и circuit breaker
Зачем строить собственный 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. Подробнее обо мне →