DevOps

Configuración dinámica de microservicios en Kubernetes: ConfigMaps, recarga en caliente y Config Server centralizado

Ruslan Ismailov Publicado 14 min de lectura
C

Introducción: por qué la gestión de configuración es el punto débil de los microservicios

Cuando un monolito se transforma en decenas de microservicios, el primer dolor que siente el equipo DevOps es la configuración. Cada servicio tiene sus propias credenciales de base de datos, direcciones de servicios vecinos, feature flags, timeouts y límites. Los entornos se multiplican: dev, staging, producción. Y aparece el riesgo: un archivo de configuración mal desplegado puede tumbar todo el clúster.

En la práctica se ve así: un desarrollador cambia el endpoint de una API externa, sube el cambio al ConfigMap, pero el servicio no se entera, porque las variables de entorno ya fueron inyectadas al iniciar el pod. Hay que hacer un rolling restart manual. En un entorno multi-servicio de Kubernetes, esto se convierte en un problema sistémico que requiere una solución arquitectónica.

En este artículo analizaremos todo el stack: desde los ConfigMap y Secret básicos hasta el patrón Sidecar-Watcher, un Config Server centralizado en Go con almacenamiento en PostgreSQL y Redis, el versionado de configuración y la integración segura en el pipeline CI/CD.

Fundamentos: ConfigMaps y Secrets en Kubernetes

ConfigMap es el objeto estándar de Kubernetes para almacenar datos de configuración no confidenciales como pares clave-valor. Secret se utiliza para datos sensibles (contraseñas, tokens), se almacena en base64 y puede cifrarse en reposo mediante KMS.

Montaje de ConfigMap como variables de entorno

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  namespace: production
data:
  DATABASE_HOST: "postgres.production.svc.cluster.local"
  DATABASE_PORT: "5432"
  CACHE_TTL: "300"
  LOG_LEVEL: "info"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-service
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: payment-service
          image: payment-service:1.4.2
          envFrom:
            - configMapRef:
                name: app-config

Montaje de ConfigMap como archivo (Volume)

apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-config
data:
  nginx.conf: |
    worker_processes auto;
    events { worker_connections 1024; }
    http {
      server {
        listen 80;
        location /health { return 200 'ok'; }
      }
    }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-gateway
spec:
  template:
    spec:
      volumes:
        - name: nginx-conf
          configMap:
            name: nginx-config
      containers:
        - name: nginx
          image: nginx:1.25
          volumeMounts:
            - name: nginx-conf
              mountPath: /etc/nginx/nginx.conf
              subPath: nginx.conf

Limitación clave: al montar mediante envFrom, las variables de entorno se inyectan una sola vez al iniciar el contenedor. Modificar el ConfigMap no las actualizará sin reiniciar el pod. Al montar mediante Volume, el kubelet actualiza el archivo automáticamente (con un retraso de hasta 2 minutos), pero la aplicación debe detectar los cambios del archivo por sí misma.

El problema de la recarga en caliente: por qué ConfigMap no reinicia los pods

La recarga en caliente de Kubernetes ConfigMap es una de las preguntas más frecuentes entre los ingenieros DevOps. La respuesta es sencilla: Kubernetes no es responsable de la lógica de la aplicación. La plataforma actualiza el archivo montado en el pod (mediante symlink), pero no envía ninguna señal a la aplicación.

Existen tres estrategias para resolver esto:

  1. Rolling restart manual o mediante anotación — sencillo, pero puede causar downtime breve si PodDisruptionBudget no está bien configurado.
  2. La aplicación lee el archivo por temporizador o mediante inotify — funciona solo con montaje en Volume, requiere modificar el código del servicio.
  3. Controlador Sidecar u operador externo — la solución más elegante para entornos Kubernetes.

Rolling restart forzado mediante anotación (añadimos el hash del ConfigMap a la anotación del Deployment):

# Comando para forzar el reinicio
kubectl rollout restart deployment/payment-service -n production

# O mediante anotación en el pipeline CI/CD:
CONFIG_HASH=$(kubectl get configmap app-config -o jsonpath='{.data}' | sha256sum | cut -d' ' -f1)
kubectl patch deployment payment-service \
  -p "{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"configmap-hash\":\"$CONFIG_HASH\"}}}}}" 

Patrón Sidecar y Stakater Reloader

Stakater Reloader es un operador de Kubernetes de código abierto que observa los cambios en ConfigMaps y Secrets y ejecuta automáticamente el rolling restart de los Deployments, StatefulSets y DaemonSets asociados. Es la implementación del patrón de configuración sin reinicio en Kubernetes en su forma clásica, o más precisamente, con el reinicio mínimo posible y controlado.

Instalación de Stakater Reloader mediante Helm

helm repo add stakater https://stakater.github.io/stakater-charts
helm repo update
helm install reloader stakater/reloader \
  --namespace reloader \
  --create-namespace \
  --set reloader.watchGlobally=false

Anotación de Deployment para recarga automática

apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-service
  annotations:
    reloader.stakater.com/auto: "true"
    # O especificar explícitamente qué ConfigMap monitorear:
    configmap.reloader.stakater.com/reload: "app-config,feature-flags"
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    spec:
      containers:
        - name: payment-service
          image: payment-service:1.4.2
          envFrom:
            - configMapRef:
                name: app-config

Implementación de un Config Watcher propio en Go

Si se necesita control total, implementamos un observador del archivo de configuración directamente en la aplicación en Go. Usamos el paquete fsnotify:

package config

import (
    "encoding/json"
    "log"
    "os"
    "sync"

    "github.com/fsnotify/fsnotify"
)

type AppConfig struct {
    DatabaseHost string `json:"DATABASE_HOST"`
    CacheTTL     int    `json:"CACHE_TTL"`
    LogLevel     string `json:"LOG_LEVEL"`
}

type ConfigWatcher struct {
    mu       sync.RWMutex
    config   *AppConfig
    filePath string
    watcher  *fsnotify.Watcher
}

func NewConfigWatcher(filePath string) (*ConfigWatcher, error) {
    watcher, err := fsnotify.NewWatcher()
    if err != nil {
        return nil, err
    }

    cw := &ConfigWatcher{
        filePath: filePath,
        watcher:  watcher,
    }

    if err := cw.load(); err != nil {
        return nil, err
    }

    if err := watcher.Add(filePath); err != nil {
        return nil, err
    }

    go cw.watch()
    return cw, nil
}

func (cw *ConfigWatcher) load() error {
    data, err := os.ReadFile(cw.filePath)
    if err != nil {
        return err
    }
    cfg := &AppConfig{}
    if err := json.Unmarshal(data, cfg); err != nil {
        return err
    }
    cw.mu.Lock()
    cw.config = cfg
    cw.mu.Unlock()
    log.Printf("[ConfigWatcher] Config reloaded from %s", cw.filePath)
    return nil
}

func (cw *ConfigWatcher) watch() {
    for {
        select {
        case event, ok := <-cw.watcher.Events:
            if !ok {
                return
            }
            // Kubernetes actualiza el archivo mediante symlink-swap
            if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
                if err := cw.load(); err != nil {
                    log.Printf("[ConfigWatcher] Error reloading config: %v", err)
                }
            }
        case err, ok := <-cw.watcher.Errors:
            if !ok {
                return
            }
            log.Printf("[ConfigWatcher] Watcher error: %v", err)
        }
    }
}

func (cw *ConfigWatcher) Get() *AppConfig {
    cw.mu.RLock()
    defer cw.mu.RUnlock()
    return cw.config
}

Detalle importante: cuando Kubernetes actualiza el Volume, no modifica el archivo directamente sino el symlink en el directorio ..data. Por eso es necesario monitorear los eventos Create y no solo Write.

Config Server centralizado: arquitectura e implementación

Los ConfigMaps son adecuados para escenarios simples. Cuando el número de servicios supera los 20, las configuraciones empiezan a vivir en distintos namespaces y se necesita versionado, auditoría de cambios y actualización dinámica sin ningún tipo de reinicio: entonces se necesita un Config Server centralizado.

Cuándo se necesita un Config Server externo

  • Se necesita rastrear el historial de cambios de configuración (log de auditoría)
  • Feature flags que deben aplicarse instantáneamente sin reiniciar el servicio
  • Configuraciones dinámicas: rate limits, umbrales de circuit breaker, parámetros A/B
  • La configuración se comparte entre servicios de distintos equipos
  • Se necesita autorización granular: qué servicio puede leer qué clave

Arquitectura del Config Server

El Config Server almacena la configuración en PostgreSQL (para fiabilidad y versionado) y cachea los datos frecuentes en Redis. Los servicios se suscriben a las actualizaciones mediante long polling o Server-Sent Events. Cuando cambia la configuración, el Config Server publica un evento en Redis Pub/Sub y todos los suscriptores reciben la notificación y actualizan su caché local.

-- Esquema PostgreSQL para Config Server
CREATE TABLE config_entries (
    id          BIGSERIAL PRIMARY KEY,
    service     VARCHAR(100) NOT NULL,
    environment VARCHAR(50)  NOT NULL,
    key         VARCHAR(255) NOT NULL,
    value       TEXT         NOT NULL,
    version     INT          NOT NULL DEFAULT 1,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    created_by  VARCHAR(100) NOT NULL,
    is_deleted  BOOLEAN      NOT NULL DEFAULT FALSE
);

CREATE UNIQUE INDEX idx_config_service_env_key 
    ON config_entries(service, environment, key) 
    WHERE NOT is_deleted;

CREATE TABLE config_history (
    id         BIGSERIAL PRIMARY KEY,
    entry_id   BIGINT       NOT NULL REFERENCES config_entries(id),
    old_value  TEXT,
    new_value  TEXT         NOT NULL,
    version    INT          NOT NULL,
    changed_at TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    changed_by VARCHAR(100) NOT NULL
);

API REST del Config Server en Go

package main

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    "time"

    "github.com/go-chi/chi/v5"
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/redis/go-redis/v9"
)

type ConfigServer struct {
    db    *pgxpool.Pool
    redis *redis.Client
}

type ConfigEntry struct {
    Key     string `json:"key"`
    Value   string `json:"value"`
    Version int    `json:"version"`
}

// GET /config/{service}/{environment}
func (cs *ConfigServer) GetConfig(w http.ResponseWriter, r *http.Request) {
    service := chi.URLParam(r, "service")
    env     := chi.URLParam(r, "environment")
    ctx     := r.Context()

    cacheKey := "config:" + service + ":" + env
    cached, err := cs.redis.Get(ctx, cacheKey).Result()
    if err == nil {
        w.Header().Set("Content-Type", "application/json")
        w.Header().Set("X-Config-Source", "cache")
        w.Write([]byte(cached))
        return
    }

    rows, err := cs.db.Query(ctx,
        `SELECT key, value, version FROM config_entries
         WHERE service=$1 AND environment=$2 AND NOT is_deleted
         ORDER BY key`,
        service, env,
    )
    if err != nil {
        http.Error(w, "db error", http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    entries := map[string]ConfigEntry{}
    for rows.Next() {
        var e ConfigEntry
        rows.Scan(&e.Key, &e.Value, &e.Version)
        entries[e.Key] = e
    }

    data, _ := json.Marshal(entries)
    cs.redis.Set(ctx, cacheKey, data, 5*time.Minute)

    w.Header().Set("Content-Type", "application/json")
    w.Write(data)
}

// POST /config/{service}/{environment} — actualización con invalidación de caché
func (cs *ConfigServer) UpdateConfig(w http.ResponseWriter, r *http.Request) {
    service := chi.URLParam(r, "service")
    env     := chi.URLParam(r, "environment")
    ctx     := r.Context()

    var entry ConfigEntry
    if err := json.NewDecoder(r.Body).Decode(&entry); err != nil {
        http.Error(w, "invalid body", http.StatusBadRequest)
        return
    }

    _, err := cs.db.Exec(ctx,
        `INSERT INTO config_entries(service, environment, key, value, created_by)
         VALUES($1,$2,$3,$4,$5)
         ON CONFLICT DO UPDATE SET value=$4, version=version+1`,
        service, env, entry.Key, entry.Value, "api",
    )
    if err != nil {
        http.Error(w, "db error", http.StatusInternalServerError)
        return
    }

    // Invalidamos caché y publicamos evento
    cacheKey := "config:" + service + ":" + env
    cs.redis.Del(ctx, cacheKey)
    cs.redis.Publish(ctx, "config-updates",
        service+":"+env+":"+entry.Key)

    w.WriteHeader(http.StatusNoContent)
}

func main() {
    r := chi.NewRouter()
    // ... inicialización de db y redis ...
    cs := &ConfigServer{}
    r.Get("/config/{service}/{environment}", cs.GetConfig)
    r.Post("/config/{service}/{environment}", cs.UpdateConfig)
    log.Fatal(http.ListenAndServe(":8080", r))
}

Versionado de configuración y rollback

El versionado es la capacidad clave que no tienen los ConfigMaps nativos. En el esquema anterior, la tabla config_history almacena todo el changelog. El rollback a una versión anterior se implementa con una consulta sencilla:

-- Rollback de una clave a la versión anterior
WITH prev AS (
  SELECT old_value, version - 1 as prev_version
  FROM config_history
  WHERE entry_id = $1
  ORDER BY version DESC
  LIMIT 1
)
UPDATE config_entries
SET value = prev.old_value, version = prev.prev_version
FROM prev
WHERE id = $1;

Para ConfigMaps en Kubernetes, el versionado puede implementarse mediante GitOps: almacenar todos los manifiestos en Git y usar ArgoCD o Flux. En ese caso, el rollback de configuración es un git revert más un despliegue automático.

Configuración por entorno: Kustomize vs Helm

La gestión de configuración en Kubernetes en 2026 implica casi siempre elegir entre Kustomize y Helm para administrar configuraciones por entorno.

Estructura con Kustomize

config/
├── base/
│   ├── configmap.yaml
│   └── kustomization.yaml
├── overlays/
│   ├── dev/
│   │   ├── configmap-patch.yaml
│   │   └── kustomization.yaml
│   ├── staging/
│   │   ├── configmap-patch.yaml
│   │   └── kustomization.yaml
│   └── production/
│       ├── configmap-patch.yaml
│       └── kustomization.yaml
# overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
patchesStrategicMerge:
  - configmap-patch.yaml

# overlays/production/configmap-patch.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  DATABASE_HOST: "postgres-prod.internal"
  LOG_LEVEL: "warn"
  CACHE_TTL: "600"

Helm es adecuado cuando la configuración es compleja y se necesita un motor de plantillas. Kustomize es mejor para una estrategia simple de patches sin DSL adicional.

Integración CI/CD: actualización de configuración sin downtime

En el pipeline CI/CD, la actualización de la configuración debe ocurrir de forma atómica y segura. El enfoque general: primero actualizamos el ConfigMap, luego disparamos el rolling restart solo de los servicios que dependen de las claves modificadas.

# .github/workflows/deploy-config.yml
name: Deploy Configuration
on:
  push:
    paths:
      - 'config/**'
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup kubectl
        uses: azure/setup-kubectl@v3

      - name: Apply ConfigMaps
        run: |
          kubectl apply -k config/overlays/production

      - name: Compute config hash and trigger restart
        run: |
          CONFIG_HASH=$(kubectl get configmap app-config \
            -n production \
            -o jsonpath='{.data}' | sha256sum | cut -d' ' -f1)

          kubectl patch deployment payment-service \
            -n production \
            -p "{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"config-hash\":\"$CONFIG_HASH\"}}}}}" 

          # Esperamos el rollout exitoso
          kubectl rollout status deployment/payment-service \
            -n production \
            --timeout=5m

Al usar Stakater Reloader, el paso de reinicio forzado no es necesario: el operador realizará el rolling restart automáticamente tras aplicar el nuevo ConfigMap.

Seguridad de la configuración: secretos separados de la configuración

La regla principal: nunca almacene secretos (contraseñas, claves de API, certificados TLS) en un ConfigMap. Use Kubernetes Secrets con cifrado en reposo mediante el proveedor KMS de su nube (AWS KMS, GCP CKMS).

Para entornos de producción, el stack óptimo es:

  • HashiCorp Vault — almacenamiento centralizado de secretos, dynamic secrets, políticas de acceso granulares, log de auditoría
  • External Secrets Operator — sincroniza secretos desde Vault, AWS Secrets Manager o GCP Secret Manager hacia Kubernetes Secrets
  • Sealed Secrets — cifrado de Kubernetes Secret para almacenamiento en Git (enfoque GitOps)
# ExternalSecret — sincronización desde Vault a Kubernetes Secret
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: payment-db-credentials
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: ClusterSecretStore
  target:
    name: payment-db-secret
    creationPolicy: Owner
  data:
    - secretKey: DATABASE_PASSWORD
      remoteRef:
        key: secret/production/payment-service
        property: db_password

Para datos de configuración sensibles en el Config Server, utilice cifrado a nivel de aplicación antes de escribir en PostgreSQL: AES-256-GCM con clave proveniente de Vault.

Matriz de decisión: cuándo usar cada solución

Resumamos en forma de matriz práctica para elegir la herramienta de gestión de configuración:

  • Kubernetes ConfigMap — hasta 10 servicios, configuración estática, equipo DevOps pequeño, sin requisitos de auditoría. Fácil de mantener, nativo de Kubernetes.
  • ConfigMap + Stakater Reloader — cuando se necesita recarga automática en caliente de la configuración sin reinicios manuales. Overhead mínimo, sin dependencias de sistemas externos.
  • Config Server centralizado — 20+ servicios, se requieren log de auditoría, versionado, feature flags dinámicos y acceso granular. Requiere soporte de infraestructura (PostgreSQL + Redis).
  • HashiCorp Vault — siempre para secretos en producción. Complementa los ConfigMaps o el Config Server, no los reemplaza. Obligatorio con requisitos de cumplimiento (PCI DSS, SOC 2).
  • Spring Cloud Config / Consul — justificado como alternativa al Config Server propio si ya se usan estos ecosistemas.

Conclusión

La gestión de configuración de microservicios en Kubernetes es un camino evolutivo. Comience con los ConfigMaps nativos: son simples, fiables y están bien integrados en el ecosistema. Añada Stakater Reloader para rolling restarts automáticos ante cambios de configuración: esto resuelve el 80% de los problemas sin complejidad innecesaria.

Cuando la arquitectura crece hasta decenas de microservicios con requisitos de actualización dinámica, auditoría y versionado, construya un Config Server centralizado. Go es una excelente opción para esta tarea: alto rendimiento, manejo sencillo de la concurrencia y un rico ecosistema de bibliotecas para trabajar con PostgreSQL y Redis.

Los secretos, siempre por separado. External Secrets Operator con Vault es el estándar para entornos de producción en 2026. Separar configuración y secretos no es una opción, sino un requisito de una arquitectura segura.

Un buen sistema de gestión de configuración es invisible en producción y muy visible en el momento de un incidente: es entonces cuando valorará el log de auditoría, el rollback instantáneo y la gestión centralizada.

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í →