Servicios escalables en Go: configuración, observabilidad y despliegue

Capítulo 12

Tiempo estimado de lectura: 15 minutos

+ Ejercicio

Configuración lista para producción (por entorno)

Un servicio escalable no “cablea” valores (puertos, credenciales, URLs) en el código: los recibe desde el entorno (variables, archivos montados, secretos del proveedor cloud). La meta es que el mismo binario se ejecute en dev/staging/prod cambiando solo configuración.

Principios prácticos

  • Config por variables de entorno: 12-factor style. Fácil de inyectar en contenedores y plataformas cloud.
  • Valores por defecto seguros: por ejemplo, bind a 0.0.0.0:8080, timeouts razonables.
  • Validación al inicio: fallar rápido si falta algo crítico (p. ej., DSN).
  • Separar config de lógica: un paquete config que solo parsea/valida.

Ejemplo: struct de configuración + carga desde env

package config

import (
	"errors"
	"os"
	"strconv"
	"time"
)

type Config struct {
	Env            string        // dev|staging|prod
	HTTPAddr       string        // ":8080"
	ShutdownTimeout time.Duration
	LogLevel       string        // debug|info|warn|error
	DBPath         string        // persistencia simple (archivo)
}

func Load() (Config, error) {
	cfg := Config{
		Env:            getenv("APP_ENV", "dev"),
		HTTPAddr:       getenv("HTTP_ADDR", ":8080"),
		LogLevel:       getenv("LOG_LEVEL", "info"),
		DBPath:         getenv("DB_PATH", "./data.db"),
		ShutdownTimeout: getenvDuration("SHUTDOWN_TIMEOUT", 10*time.Second),
	}
	if cfg.Env == "prod" && cfg.DBPath == "" {
		return Config{}, errors.New("DB_PATH requerido en prod")
	}
	return cfg, nil
}

func getenv(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

func getenvDuration(key string, def time.Duration) time.Duration {
	v := os.Getenv(key)
	if v == "" {
		return def
	}
	// acepta segundos como entero para simplicidad
	n, err := strconv.Atoi(v)
	if err != nil {
		return def
	}
	return time.Duration(n) * time.Second
}

Esta carga es deliberadamente simple. En servicios reales puedes soportar formatos como 1m30s y validaciones más estrictas, pero la idea clave es: todo entra por configuración y se valida al inicio.

Manejo de señales y apagado elegante (graceful shutdown)

En producción, tu proceso recibirá señales del sistema (por despliegues, escalado, mantenimiento). Un servicio robusto debe: dejar de aceptar tráfico, terminar requests en curso dentro de un timeout y cerrar recursos (archivos, conexiones, goroutines) sin corrupción.

Patrón recomendado

  • Crear un context.Context raíz cancelable.
  • Escuchar SIGINT/SIGTERM con signal.NotifyContext.
  • Arrancar el servidor HTTP en una goroutine.
  • Al cancelar, llamar Server.Shutdown(ctxTimeout).
  • Cerrar dependencias (storage, colas, etc.) después del shutdown.

Ejemplo mínimo

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)

defer stop()

srv := &http.Server{
	Addr:         cfg.HTTPAddr,
	Handler:      handler,
	ReadTimeout:  5 * time.Second,
	WriteTimeout: 10 * time.Second,
	IdleTimeout:  60 * time.Second,
}

errCh := make(chan error, 1)

go func() {
	errCh <- srv.ListenAndServe()
}()

select {
case <-ctx.Done():
	// señal recibida
case err := <-errCh:
	if err != nil && err != http.ErrServerClosed {
		return err
	}
}

shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)

defer cancel()

_ = srv.Shutdown(shutdownCtx)
// cerrar storage, flush de logs, etc.

Importante: Shutdown espera a que terminen requests activos (hasta el timeout). Si tienes goroutines internas (workers), también deben escuchar el ctx para detenerse.

Salud del servicio: health checks (liveness y readiness)

Los orquestadores (Kubernetes, ECS, Nomad) y balanceadores necesitan endpoints para decidir si un contenedor está vivo y si está listo para recibir tráfico.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

  • Liveness: “¿el proceso está funcionando?” Si falla, se reinicia.
  • Readiness: “¿puede atender tráfico?” Si falla, se saca del balanceo sin reiniciar necesariamente.

Implementación práctica

Define dos rutas: /healthz (liveness) y /readyz (readiness). Liveness suele responder 200 si el proceso está arriba. Readiness valida dependencias mínimas (por ejemplo, acceso a persistencia).

type Readiness interface {
	Ready(ctx context.Context) error
}

func healthz(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("ok"))
}

func readyz(dep Readiness) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
		defer cancel()
		if err := dep.Ready(ctx); err != nil {
			w.WriteHeader(http.StatusServiceUnavailable)
			_, _ = w.Write([]byte("not ready"))
			return
		}
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ready"))
	}
}

La clave es que readiness sea rápido y no bloquee. Si tu dependencia es lenta, usa timeouts cortos y caché del estado.

Observabilidad mínima: logging estructurado, trazas básicas y métricas

Escalar implica entender qué ocurre sin entrar al servidor. La observabilidad se apoya en tres pilares: logs, trazas y métricas. Aquí implementaremos una base ligera y compatible con herramientas comunes.

Logging estructurado (JSON) con slog

El logging estructurado facilita filtrar por campos (request_id, status, latency). En Go moderno puedes usar log/slog.

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))

logger.Info("service starting",
	"env", cfg.Env,
	"addr", cfg.HTTPAddr,
)

Middleware de request logging

func requestLogger(logger *slog.Logger, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rid := r.Header.Get("X-Request-Id")
		if rid == "" {
			rid = strconv.FormatInt(time.Now().UnixNano(), 36)
		}

		ww := &wrapWriter{ResponseWriter: w, status: 200}
		next.ServeHTTP(ww, r)

		logger.Info("http_request",
			"method", r.Method,
			"path", r.URL.Path,
			"status", ww.status,
			"bytes", ww.bytes,
			"duration_ms", time.Since(start).Milliseconds(),
			"request_id", rid,
		)
	})
}

type wrapWriter struct {
	http.ResponseWriter
	status int
	bytes  int
}

func (w *wrapWriter) WriteHeader(code int) {
	w.status = code
	w.ResponseWriter.WriteHeader(code)
}

func (w *wrapWriter) Write(p []byte) (int, error) {
	n, err := w.ResponseWriter.Write(p)
	w.bytes += n
	return n, err
}

Si tu plataforma inyecta un request id (por ejemplo, un API gateway), lo reutilizas; si no, lo generas.

Trazas básicas con OpenTelemetry (concepto y esqueleto)

Las trazas conectan eventos de una request a través de componentes (handlers, storage, llamadas HTTP). En un microservicio, esto es esencial para entender latencias y cuellos de botella. La implementación completa depende del exportador (OTLP, Jaeger, etc.), pero el patrón es: inicializar un tracer provider, instrumentar handlers y crear spans en operaciones relevantes.

// Pseudocódigo de instrumentación (sin exportador específico)
tracer := otel.Tracer("svc")

func handler(w http.ResponseWriter, r *http.Request) {
	ctx, span := tracer.Start(r.Context(), "GET /items")
	defer span.End()

	items, err := store.List(ctx)
	if err != nil {
		span.RecordError(err)
		w.WriteHeader(500)
		return
	}
	_ = items
}

Incluso con instrumentación mínima (un span por handler y spans por I/O), ya obtienes valor: latencias por endpoint y errores asociados.

Métricas: contadores y latencias

Las métricas responden “¿cuánto?” y “¿con qué frecuencia?”. Un mínimo útil: contador de requests, contador de errores y un histograma (o resumen) de latencias por endpoint.

Si usas Prometheus, lo típico es exponer /metrics. A nivel conceptual:

  • http_requests_total{path,method,status}
  • http_request_duration_seconds_bucket{path,method}
  • db_errors_total

En el proyecto integrador verás una versión simple con contadores en memoria (útil para aprender el flujo). En producción, reemplázala por un cliente real de métricas.

Empaquetado y ejecución en cloud

Binarios estáticos y portabilidad

Go facilita desplegar un único binario. Para contenedores minimalistas (distroless/scratch) conviene compilar estático (cuando sea posible) y desactivar CGO.

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o app ./cmd/app

Si dependes de librerías que requieren CGO, el binario puede no ser completamente estático. Aun así, el enfoque de “un binario por servicio” sigue siendo válido.

Variables de entorno y secretos

  • Config no sensible (puerto, nivel de log): variables de entorno.
  • Secretos (tokens, claves): gestor de secretos del proveedor o archivos montados; evita incluirlos en la imagen.
  • Rotación: el servicio debe tolerar reinicios; por eso el shutdown elegante es clave.

Principios de microservicios aplicados

  • Responsabilidad única: el servicio hace una cosa bien (por ejemplo, gestionar “items”). Evita mezclar dominios.
  • Comunicación explícita: HTTP/JSON o gRPC; contratos versionados; timeouts siempre.
  • Resiliencia: timeouts, límites de concurrencia, reintentos con backoff (solo cuando sea seguro), circuit breakers si aplica.
  • Observabilidad desde el día 1: logs estructurados, health checks, métricas y trazas mínimas.

Proyecto integrador: servicio HTTP concurrente con persistencia simple, pruebas y observabilidad mínima

Construirás un servicio “Items” con operaciones básicas y componentes listos para producción: configuración por entorno, shutdown elegante, health checks, logging estructurado y métricas simples. La persistencia será un archivo JSON (simple pero suficiente para practicar patrones de I/O y concurrencia segura).

Objetivo funcional

  • POST /items: crea un item.
  • GET /items: lista items.
  • GET /items/{id}: obtiene un item.
  • GET /healthz y GET /readyz.
  • GET /metrics: métricas simples en texto.

Estructura sugerida

RutaResponsabilidad
/cmd/app/main.goWiring: config, logger, server, señales
/internal/configCarga/validación de configuración
/internal/httpapiHandlers, middlewares, rutas
/internal/storePersistencia (archivo JSON) + concurrencia
/internal/obsMétricas simples (contadores/latencias)

Paso 1: modelo y store con concurrencia

El store debe ser seguro ante concurrencia (múltiples requests). Usaremos un sync.RWMutex y persistencia a disco con escritura atómica (escribir a archivo temporal y renombrar).

package store

import (
	"context"
	"encoding/json"
	"errors"
	"os"
	"path/filepath"
	"sync"
	"time"
)

type Item struct {
	ID        string    `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
}

type Store struct {
	mu    sync.RWMutex
	path  string
	items map[string]Item
}

func New(path string) (*Store, error) {
	s := &Store{path: path, items: map[string]Item{}}
	if err := s.load(); err != nil {
		return nil, err
	}
	return s, nil
}

func (s *Store) Ready(ctx context.Context) error {
	// readiness simple: poder crear/abrir el archivo
	s.mu.RLock()
	defer s.mu.RUnlock()
	_, err := os.Stat(s.path)
	if err == nil {
		return nil
	}
	if errors.Is(err, os.ErrNotExist) {
		// si no existe, intentamos crear directorio
		dir := filepath.Dir(s.path)
		if dir != "." {
			return os.MkdirAll(dir, 0o755)
		}
		return nil
	}
	return err
}

func (s *Store) load() error {
	b, err := os.ReadFile(s.path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil
		}
		return err
	}
	var list []Item
	if err := json.Unmarshal(b, &list); err != nil {
		return err
	}
	for _, it := range list {
		s.items[it.ID] = it
	}
	return nil
}

func (s *Store) saveLocked() error {
	list := make([]Item, 0, len(s.items))
	for _, it := range s.items {
		list = append(list, it)
	}
	b, err := json.MarshalIndent(list, "", "  ")
	if err != nil {
		return err
	}
	tmp := s.path + ".tmp"
	if err := os.WriteFile(tmp, b, 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, s.path)
}

func (s *Store) Create(ctx context.Context, it Item) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.items[it.ID]; ok {
		return errors.New("item ya existe")
	}
	s.items[it.ID] = it
	return s.saveLocked()
}

func (s *Store) Get(ctx context.Context, id string) (Item, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	it, ok := s.items[id]
	if !ok {
		return Item{}, errors.New("no encontrado")
	}
	return it, nil
}

func (s *Store) List(ctx context.Context) ([]Item, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	out := make([]Item, 0, len(s.items))
	for _, it := range s.items {
		out = append(out, it)
	}
	return out, nil
}

Paso 2: métricas simples en memoria

Para entender el flujo sin dependencias externas, implementa contadores y latencias agregadas. Luego podrás reemplazarlo por Prometheus/OpenTelemetry Metrics.

package obs

import (
	"fmt"
	"sync/atomic"
	"time"
)

type Metrics struct {
	Requests uint64
	Errors   uint64
	LatencyMsTotal uint64
}

func (m *Metrics) IncRequests() { atomic.AddUint64(&m.Requests, 1) }
func (m *Metrics) IncErrors()   { atomic.AddUint64(&m.Errors, 1) }
func (m *Metrics) AddLatency(d time.Duration) {
	atomic.AddUint64(&m.LatencyMsTotal, uint64(d.Milliseconds()))
}

func (m *Metrics) Render() string {
	req := atomic.LoadUint64(&m.Requests)
	err := atomic.LoadUint64(&m.Errors)
	lat := atomic.LoadUint64(&m.LatencyMsTotal)
	avg := uint64(0)
	if req > 0 {
		avg = lat / req
	}
	return fmt.Sprintf("requests_total %d\nerrors_total %d\navg_latency_ms %d\n", req, err, avg)
}

Paso 3: API HTTP concurrente + middlewares

Usa un ServeMux y compón middlewares: request logging y métricas. Para rutas con parámetro (/items/{id}) puedes parsear el path de forma simple.

package httpapi

import (
	"encoding/json"
	"net/http"
	"strings"
	"time"
	"log/slog"

	"example/internal/obs"
	"example/internal/store"
)

type API struct {
	Log     *slog.Logger
	Store   *store.Store
	Metrics *obs.Metrics
}

func (a *API) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", healthz)
	mux.HandleFunc("/readyz", readyz(a.Store))
	mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		_, _ = w.Write([]byte(a.Metrics.Render()))
	})
	mux.HandleFunc("/items", a.items)
	mux.HandleFunc("/items/", a.itemByID)

	h := http.Handler(mux)
	h = a.metricsMiddleware(h)
	h = requestLogger(a.Log, h)
	return h
}

func (a *API) metricsMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		a.Metrics.IncRequests()
		ww := &wrapWriter{ResponseWriter: w, status: 200}
		next.ServeHTTP(ww, r)
		a.Metrics.AddLatency(time.Since(start))
		if ww.status >= 500 {
			a.Metrics.IncErrors()
		}
	})
}

func (a *API) items(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		list, err := a.Store.List(r.Context())
		if err != nil {
			w.WriteHeader(500)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(list)
	case http.MethodPost:
		var in struct{ ID, Name string }
		if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
			w.WriteHeader(400)
			return
		}
		it := store.Item{ID: in.ID, Name: in.Name, CreatedAt: time.Now().UTC()}
		if err := a.Store.Create(r.Context(), it); err != nil {
			w.WriteHeader(409)
			return
		}
		w.WriteHeader(201)
	default:
		w.WriteHeader(405)
	}
}

func (a *API) itemByID(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		w.WriteHeader(405)
		return
	}
	id := strings.TrimPrefix(r.URL.Path, "/items/")
	if id == "" {
		w.WriteHeader(400)
		return
	}
	it, err := a.Store.Get(r.Context(), id)
	if err != nil {
		w.WriteHeader(404)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(it)
}

Paso 4: main wiring (config + server + señales)

package main

import (
	"context"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"example/internal/config"
	"example/internal/httpapi"
	"example/internal/obs"
	"example/internal/store"
)

func main() {
	cfg, err := config.Load()
	if err != nil {
		panic(err)
	}

	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
	st, err := store.New(cfg.DBPath)
	if err != nil {
		logger.Error("store init failed", "error", err)
		os.Exit(1)
	}

	m := &obs.Metrics{}
	api := &httpapi.API{Log: logger, Store: st, Metrics: m}

	srv := &http.Server{
		Addr:         cfg.HTTPAddr,
		Handler:      api.Routes(),
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	errCh := make(chan error, 1)
	go func() { errCh <- srv.ListenAndServe() }()

	logger.Info("service started", "addr", cfg.HTTPAddr, "env", cfg.Env)

	select {
	case <-ctx.Done():
		logger.Info("shutdown signal received")
	case err := <-errCh:
		if err != nil && err != http.ErrServerClosed {
			logger.Error("server error", "error", err)
			os.Exit(1)
		}
	}

	shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
	defer cancel()
	_ = srv.Shutdown(shutdownCtx)
	logger.Info("server stopped")
}

Paso 5: pruebas esenciales (store y handlers)

En un servicio escalable, las pruebas mínimas deben cubrir: (1) persistencia y concurrencia del store, (2) contratos HTTP básicos (status codes y payloads). Aquí tienes dos ejemplos.

Prueba del store: create/get

package store_test

import (
	"context"
	"os"
	"testing"
	"time"

	"example/internal/store"
)

func TestStoreCreateGet(t *testing.T) {
	f, err := os.CreateTemp("", "items-*.json")
	if err != nil { t.Fatal(err) }
	path := f.Name()
	_ = f.Close()
	defer os.Remove(path)

	s, err := store.New(path)
	if err != nil { t.Fatal(err) }

	it := store.Item{ID: "1", Name: "a", CreatedAt: time.Now().UTC()}
	if err := s.Create(context.Background(), it); err != nil { t.Fatal(err) }

	got, err := s.Get(context.Background(), "1")
	if err != nil { t.Fatal(err) }
	if got.Name != "a" { t.Fatalf("expected name a, got %s", got.Name) }
}

Prueba HTTP: POST y luego GET

package httpapi_test

import (
	"bytes"
	"net/http"
	"net/http/httptest"
	"os"
	"testing"
	"log/slog"

	"example/internal/httpapi"
	"example/internal/obs"
	"example/internal/store"
)

func TestItemsFlow(t *testing.T) {
	f, _ := os.CreateTemp("", "items-*.json")
	path := f.Name()
	_ = f.Close()
	defer os.Remove(path)

	st, err := store.New(path)
	if err != nil { t.Fatal(err) }

	api := &httpapi.API{
		Log: slog.New(slog.NewTextHandler(os.Stdout, nil)),
		Store: st,
		Metrics: &obs.Metrics{},
	}
	h := api.Routes()

	req := httptest.NewRequest(http.MethodPost, "/items", bytes.NewBufferString(`{"id":"1","name":"x"}`))
	w := httptest.NewRecorder()
	h.ServeHTTP(w, req)
	if w.Code != 201 { t.Fatalf("expected 201, got %d", w.Code) }

	req2 := httptest.NewRequest(http.MethodGet, "/items/1", nil)
	w2 := httptest.NewRecorder()
	h.ServeHTTP(w2, req2)
	if w2.Code != 200 { t.Fatalf("expected 200, got %d", w2.Code) }
}

Paso 6: ejecución por entorno (ejemplos)

Ejecutar en local:

export APP_ENV=dev
export HTTP_ADDR=:8080
export DB_PATH=./data/items.json
export LOG_LEVEL=info
export SHUTDOWN_TIMEOUT=10

go run ./cmd/app

Probar endpoints:

curl -i http://localhost:8080/healthz
curl -i http://localhost:8080/readyz
curl -i -X POST http://localhost:8080/items -d '{"id":"1","name":"demo"}'
curl -i http://localhost:8080/items
curl -i http://localhost:8080/metrics

Checklist de “listo para producción” para este proyecto

  • Config por entorno validada al inicio.
  • Time-outs HTTP configurados.
  • Shutdown elegante con señales y timeout.
  • Health checks separados (liveness/readiness).
  • Logging estructurado por request.
  • Métricas mínimas expuestas.
  • Persistencia segura ante concurrencia y escritura atómica.
  • Pruebas de store y flujo HTTP básico.

Ahora responde el ejercicio sobre el contenido:

En un servicio HTTP en Go listo para producción, ¿qué comportamiento describe mejor un apagado elegante (graceful shutdown) al recibir SIGINT/SIGTERM?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

El apagado elegante implica capturar señales, cancelar un contexto, ejecutar Server.Shutdown con un timeout para completar requests en curso y luego cerrar dependencias y workers de manera segura.

Portada de libro electrónico gratuitaGo desde Cero: Programación Moderna, Rápida y Escalable
100%

Go desde Cero: Programación Moderna, Rápida y Escalable

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.