Desarrollo backend

Construcción de un API Gateway interno con Docker y Go sin soluciones de terceros: enrutamiento, balanceo y circuit breaker

Ruslan Ismailov Publicado 14 min de lectura
C

Por qué construir tu propio API Gateway y cuándo tiene sentido

Las soluciones listas — Kong, Traefik, AWS API Gateway — cubren la mayoría de los casos. Pero hay situaciones en las que un API Gateway propio en Go resulta justificado:

  • Necesitas control total sobre la lógica de enrutamiento y balanceo sin el overhead de código ajeno.
  • Los requisitos de rendimiento son tan específicos que las soluciones universales no encajan.
  • El equipo quiere entender a fondo el funcionamiento interno del gateway, no gestionar una caja negra.
  • La infraestructura está aislada y no admite dependencias externas.
  • Se necesita una lógica de circuit breaker no estándar o enrutamiento basado en reglas de negocio.

Si ninguna de estas condiciones aplica, usa soluciones existentes. En caso contrario, sigue leyendo.

Arquitectura de la solución: componentes y esquema de interacción

Nuestro gateway está compuesto por los siguientes componentes:

  • Config Loader — carga y recarga la configuración de rutas desde un archivo YAML sin reiniciar el proceso.
  • Router — hace coincidir la solicitud HTTP entrante con una ruta por prefix/path.
  • Load Balancer — selecciona un backend del pool (round-robin o weighted round-robin).
  • Circuit Breaker — monitorea los errores del backend y lo excluye temporalmente de la rotación.
  • Proxy — reenvía la solicitud al backend seleccionado y devuelve la respuesta al cliente.
  • Metrics Collector — recopila estadísticas: RPS, latencia, errores.

Esquema de interacción (descripción textual):

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

Todos los componentes operan dentro de un único proceso Go desplegado en un contenedor Docker. Los backends son microservicios independientes en sus propios contenedores, unidos mediante Docker Compose en una misma red.

Implementación del enrutamiento de solicitudes en Go con configuración dinámica

Empecemos con la descripción de la configuración en 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

Estructuras de datos para la configuración:

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
}

El router busca la ruta por 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
    // ordenamos por longitud de prefix descendente para 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
}

Balanceo de carga: round-robin y weighted round-robin desde cero

Round-robin es el algoritmo más simple: cada solicitud va al siguiente backend en orden circular.

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 — los backends con mayor peso reciben proporcionalmente más solicitudes. Lo implementamos expandiendo la lista:

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]
}

Al recargar la configuración, el balanceador se recrea con el nuevo pool de backends de forma atómica mediante sync/atomic y sync.RWMutex.

Implementación del patrón Circuit Breaker en Go

El Circuit Breaker monitorea el número de errores consecutivos y pone al backend en estado OPEN, excluyéndolo temporalmente de la rotación. Tras un timeout definido, se intenta la recuperación (estado HALF-OPEN).

package breaker

import (
    "sync"
    "time"
)

type State int

const (
    StateClosed   State = iota // funcionamiento normal
    StateOpen                  // backend no disponible
    StateHalfOpen              // verificando recuperación
)

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
    }
}

Cada backend obtiene su propia instancia de CircuitBreaker. Antes de hacer proxy, llamamos a Allow() — si devuelve false, saltamos ese backend y tomamos el siguiente del pool.

Integración con backends REST API y proxy de solicitudes

El handler principal integra todos los componentes:

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)
    }
}

Contenedorización del gateway con Docker y orquestación con Docker Compose

El Dockerfile del gateway utiliza compilación multi-stage:

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 une el gateway y los backends en una misma red:

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

Gracias al volume mount de la configuración, el recargador dinámico detecta los cambios en config.yaml sin necesidad de reiniciar el contenedor.

Logging, métricas y monitoreo básico

Para el logging utilizamos salida estructurada mediante el paquete estándar 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(),
        )
    })
}

Para las métricas añadimos un endpoint simple /metrics que expone contadores en formato compatible con 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))
}

El endpoint /metrics se conecta fácilmente a Prometheus + Grafana para visualización, proporcionando un monitoreo mínimo suficiente para entornos de producción.

Limitaciones del enfoque y cuándo es mejor usar soluciones existentes

Un API Gateway propio es un trade-off consciente. Enumeremos honestamente sus limitaciones:

  • Sin autenticación integrada: JWT, OAuth2 y API keys deberán implementarse manualmente o mediante middleware.
  • Sin rate limiting de serie: se necesitará un módulo aparte, frecuentemente con Redis como almacén de contadores.
  • Sin UI ni DSL declarativo: la configuración por YAML es más sencilla que la Kong Admin API, pero no tan cómoda.
  • Testing y mantenimiento: cada funcionalidad requiere escribir pruebas. Los bugs en el circuit breaker pueden provocar fallos en cascada.
  • Complejidad de escalado: el escalado horizontal del propio gateway requerirá un almacenamiento externo del estado de los circuit breakers.

Cuándo usar soluciones existentes:

  • El equipo es pequeño y no hay recursos para mantener un gateway personalizado.
  • Se necesita un amplio ecosistema de plugins: transformación de solicitudes, canary releases, mTLS.
  • La infraestructura ya funciona en Kubernetes — Traefik o ingress-nginx resolverán la tarea más rápido.
  • El proyecto está en sus inicios y los requisitos del gateway aún no están definidos.

Un API Gateway propio en Go es una herramienta de aprendizaje y trabajo valiosa para equipos que saben exactamente lo que quieren y están dispuestos a asumir la responsabilidad de cada línea de código en un componente crítico de la infraestructura.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →