Construcción de un API Gateway interno con Docker y Go sin soluciones de terceros: enrutamiento, balanceo y circuit breaker
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í →