Al final de la lección anterior quedaron tres preguntas abiertas. ¿Cómo hacer que la migración de esquema se ejecute automáticamente antes de que arranque api-reservas, en lugar de como un Job aparte? ¿Cómo evitar que api-reservas entre en CrashLoopBackOff cuando arranca antes que postgres-reservas? ¿Cómo exportar métricas de PostgreSQL sin tocar la imagen oficial?

Las tres se responden igual: poniendo más de un contenedor en el mismo pod.

Desde la lección 02-01 sabemos que un pod es un grupo de contenedores que comparten espacio de nombres de red —y por tanto localhost—, volúmenes y ciclo de vida. Hasta ahora no habíamos aprovechado esa capacidad: todos los pods de Rutas Norte tienen un solo contenedor. En esta lección la usaremos con criterio, que es la parte difícil: la mayoría de pods multi-contenedor mal diseñados nacen de meter dos aplicaciones juntas porque "van relacionadas", y eso siempre acaba mal.

Contenido

  1. Cuándo un pod necesita varios contenedores y cuándo eso es un error
  2. Init containers: preparación secuencial antes del arranque
  3. Casos de uso de init containers en Rutas Norte
  4. Diagnóstico de init containers fallidos
  5. Sidecars nativos: init containers con restartPolicy: Always
  6. Los tres patrones clásicos: sidecar, embajador y adaptador
  7. Patrón sidecar: exportador de métricas junto a postgres-reservas
  8. Patrón embajador: la conexión con la pasarela de pagos
  9. Patrón adaptador: normalizar el log de worker-notificaciones
  10. Orden de arranque y terminación, y el coste real

  1. Cuándo un pod necesita varios contenedores y cuándo eso es un error

La regla de oro, formulada con precisión:

Dos procesos van en el mismo pod cuando están tan acoplados que no tiene sentido escalarlos, actualizarlos ni ejecutarlos por separado, y uno de ellos existe únicamente para servir al otro.

La consecuencia inmediata de compartir pod es que comparten destino: se programan en el mismo nodo, escalan juntos, se reinician juntos y se actualizan juntos. Si api-reservas necesita 5 réplicas y worker-notificaciones necesita 2, ponerlos en el mismo pod obliga a tener 5 de cada uno. Eso es un error de diseño, no una optimización.

Situación ¿Mismo pod? Por qué
api-reservas + exportador de sus métricas El exportador no sirve para nada sin su aplicación; escalan a la vez
api-reservas + worker-notificaciones No Escalan distinto, se despliegan distinto, son dos aplicaciones
worker-notificaciones + adaptador de logs El adaptador procesa el fichero local de ese proceso concreto
tienda-web + postgres-reservas No Ciclos de vida y necesidades de almacenamiento radicalmente distintos
Proceso principal + proxy hacia un servicio externo El proxy es infraestructura local del proceso
Recolector de logs de todo el nodo No Eso es un DaemonSet (06-02)

Tres preguntas de control antes de añadir un contenedor a un pod existente:

  1. ¿Necesitan escalar juntos siempre? Si la respuesta es no, son dos cargas de trabajo distintas.
  2. ¿El segundo sirve exclusivamente al primero? Si otros pods también lo necesitan, es un Service aparte.
  3. ¿Necesitan compartir localhost o un sistema de ficheros? Si no, no hay motivo técnico para juntarlos.

Kubernetes ofrece dos categorías de contenedor auxiliar:

  • initContainers: se ejecutan antes, en orden, uno tras otro, y deben terminar con éxito.
  • Contenedores auxiliares que acompañan al principal durante toda la vida del pod: los sidecars.

  1. Init containers: preparación secuencial antes del arranque

Un init container es un contenedor que se ejecuta hasta completarse antes de que arranque ningún contenedor principal. Si hay varios, se ejecutan en el orden en que aparecen en el manifiesto, cada uno esperando a que el anterior termine con éxito.

graph LR
  A[Pod programado] --> I1[initContainer 1<br/>esperar-postgres]
  I1 -->|exit 0| I2[initContainer 2<br/>migrar-esquema]
  I2 -->|exit 0| C[containers<br/>api arranca]
  I1 -->|exit != 0| R1[reinicio del initContainer]
  R1 --> I1

Propiedades que los distinguen de los contenedores normales:

Propiedad Init container Contenedor principal
Momento de ejecución Antes de todos los principales Después de todos los init
Ejecución Secuencial, uno tras otro Todos en paralelo
Debe terminar Sí, con código 0 No; terminar es una anomalía
Si falla Se reintenta según restartPolicy del pod Se reinicia según restartPolicy
Sondas No admite readinessProbe ni startupProbe
Imagen Suele ser distinta y con más herramientas La de la aplicación

Esa última fila es más útil de lo que parece: el init container puede usar una imagen con psql, curl o git que la imagen de producción no tiene, sin engordar la imagen final ni ampliar su superficie de ataque.

Sintaxis básica:

spec:
  template:
    spec:
      initContainers:
        - name: primero
          image: busybox:1.36
          command: ["sh", "-c", "echo preparando; sleep 2"]
        - name: segundo
          image: busybox:1.36
          command: ["sh", "-c", "echo listo"]
      containers:
        - name: api
          image: registry.rutasnorte.example/api-reservas:2.5.0

Los init containers también consumen recursos y participan en el cálculo de lo que el pod pide al scheduler. La fórmula efectiva es:

petición del pod = max( mayor petición entre los initContainers ,
                        suma de peticiones de los containers )

Es decir, un init container que pide 2 GiB obliga al scheduler a encontrar un nodo con 2 GiB libres, aunque la aplicación solo necesite 256 MiB. Conviene mantenerlos ligeros.

  1. Casos de uso de init containers en Rutas Norte

Caso 1: esperar a que postgres-reservas acepte conexiones

Cuando se levanta un entorno desde cero, api-reservas puede arrancar antes de que la base de datos esté lista, fallar al conectar, salir y entrar en CrashLoopBackOff. Acaba recuperándose, pero el arranque tarda minutos y los eventos se llenan de ruido que enmascara problemas reales.

      initContainers:
        - name: esperar-postgres
          image: postgres:16.4
          command:
            - /bin/sh
            - -c
            - |
              echo "Esperando a postgres-reservas..."
              until pg_isready -h postgres-reservas -p 5432 -U "${PGUSER}" -t 3; do
                echo "  todavía no responde; reintento en 2 s"
                sleep 2
              done
              echo "postgres-reservas acepta conexiones"
          env:
            - name: PGUSER
              valueFrom:
                secretKeyRef:
                  name: postgres-reservas-credenciales
                  key: usuario
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 100m
              memory: 128Mi

pg_isready es una utilidad de la propia imagen de PostgreSQL que devuelve 0 si el servidor acepta conexiones. El bucle no tiene límite de intentos a propósito: el pod se quedará en Init:0/1 indefinidamente, lo que en kubectl get pods es una señal clarísima de "la base de datos no está disponible", mucho mejor que un CrashLoopBackOff cuya causa hay que ir a buscar en los logs.

Una nota de diseño honesta: esto no sustituye a que la aplicación gestione la reconexión. Si postgres-reservas se cae dos horas después, el init container ya no está para ayudar. Es una comodidad para el arranque, no una garantía de resiliencia.

Caso 2: ejecutar la migración de esquema

Aquí retomamos la pregunta que dejó abierta la lección 06-03. La migración puede ir en un init container en lugar de en un Job separado:

      initContainers:
        - name: esperar-postgres
          # ... como arriba ...
        - name: migrar-esquema
          image: registry.rutasnorte.example/api-reservas-migraciones:2.5.0
          command: ["/app/migrar", "--hasta=2.5.0"]
          env:
            - name: PGHOST
              value: postgres-reservas
            - name: PGUSER
              valueFrom:
                secretKeyRef:
                  name: postgres-reservas-credenciales
                  key: usuario
            - name: PGPASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-reservas-credenciales
                  key: password
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi

Ventaja evidente: la migración va acoplada al despliegue. Es imposible desplegar el código 2.5.0 sin haber migrado, porque el contenedor de la aplicación no arranca si el init container no termina bien.

Pero hay una trampa importante que hay que conocer:

Con 4 réplicas de api-reservas, el init container de migración se ejecuta 4 veces, potencialmente en paralelo durante una actualización.

Consecuencias y mitigaciones:

  • La migración debe ser idempotente y estar protegida por un lock. IF NOT EXISTS no basta: dos ALTER TABLE simultáneos pueden bloquearse mutuamente. Una herramienta de migraciones seria (Flyway, Liquibase, golang-migrate) toma un lock de aplicación en la base de datos y las demás instancias esperan.
  • Si la migración tarda minutos, cada réplica paga esa espera, y el despliegue se alarga.
Enfoque Ventaja Inconveniente
Job separado (06-03) Se ejecuta una vez; control explícito Hay que acordarse de lanzarlo antes; se puede olvidar
initContainer en el Deployment Imposible desplegar sin migrar Se ejecuta por réplica; exige lock e idempotencia

Recomendación para Rutas Norte: Job separado para migraciones grandes o destructivas (índices sobre millones de filas, cambios de tipo), initContainer para migraciones pequeñas e idempotentes del día a día.

Caso 3: preparar ficheros en un emptyDir compartido

tienda-web sirve HTML estático desde nginx, y la plantilla del pie de página cambia según el entorno. En vez de construir tres imágenes, un init container genera el fichero en un volumen compartido:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: tienda-web
  namespace: rutas-norte-pro
  labels:
    app: tienda-web
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
spec:
  replicas: 3
  selector:
    matchLabels:
      app: tienda-web
      entorno: pro
  template:
    metadata:
      labels:
        app: tienda-web
        app.kubernetes.io/part-of: rutas-norte
        entorno: pro
    spec:
      automountServiceAccountToken: false
      initContainers:
        - name: preparar-contenido
          image: busybox:1.36
          command:
            - sh
            - -c
            - |
              set -eu
              cp /plantillas/index.html /publico/index.html
              sed -i "s|__ENTORNO__|${ENTORNO}|g" /publico/index.html
              sed -i "s|__NODO__|${NODO}|g"       /publico/index.html
              echo "Contenido preparado para el entorno ${ENTORNO}"
          env:
            - name: ENTORNO
              value: "pro"
            - name: NODO
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
          resources:
            requests:
              cpu: 20m
              memory: 32Mi
            limits:
              cpu: 100m
              memory: 64Mi
          volumeMounts:
            - name: plantillas
              mountPath: /plantillas
              readOnly: true
            - name: publico
              mountPath: /publico
      containers:
        - name: nginx
          image: nginx:1.27.2-alpine
          ports:
            - name: http
              containerPort: 80
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 300m
              memory: 128Mi
          volumeMounts:
            - name: publico
              mountPath: /usr/share/nginx/html
              readOnly: true
      volumes:
        - name: plantillas
          configMap:
            name: tienda-web-plantillas
        - name: publico
          emptyDir: {}

El mecanismo es exactamente el emptyDir de 05-01: un volumen vacío creado con el pod, compartido por todos sus contenedores. El init container escribe, nginx lee en solo lectura. Cuando el pod muere, el emptyDir desaparece, y no importa: se regenera en el arranque siguiente.

  1. Diagnóstico de init containers fallidos

Los init containers tienen sus propios estados en la columna STATUS, y saber leerlos ahorra mucho tiempo.

kubectl get pods -n rutas-norte-pro -l app=api-reservas
STATUS Significado
Init:0/2 Ejecutando el primer init container de dos; ninguno completado
Init:1/2 El primero terminó bien; ejecutando el segundo
Init:Error Un init container salió con código distinto de 0 y restartPolicy: Never
Init:CrashLoopBackOff Un init container falla repetidamente con restartPolicy: Always
PodInitializing Todos los init terminaron; arrancando los principales
Running Todo en marcha

Un caso real: api-reservas atascada porque la base de datos no responde.

NAME                            READY   STATUS     RESTARTS   AGE
api-reservas-6c8f9d4b7-jm2xq    0/1     Init:0/2   0          4m

Init:0/2 sostenido cuatro minutos: el primer init container (esperar-postgres) sigue en su bucle.

kubectl logs -n rutas-norte-pro api-reservas-6c8f9d4b7-jm2xq -c esperar-postgres --tail=4
Esperando a postgres-reservas...
  todavía no responde; reintento en 2 s
  todavía no responde; reintento en 2 s
  todavía no responde; reintento en 2 s

La clave está en -c <nombre>. Sin ese flag, kubectl logs intenta leer el contenedor principal, que aún no existe, y responde con un error confuso.

Otro caso: la migración falla.

NAME                            READY   STATUS       RESTARTS      AGE
api-reservas-7f4d2b9c8-k3n5p    0/1     Init:Error   3 (52s ago)   3m
kubectl logs -n rutas-norte-pro api-reservas-7f4d2b9c8-k3n5p -c migrar-esquema --previous
Aplicando migración 2.5.0...
ERROR: column "canal_venta" of relation "reservas" already exists (SQLSTATE 42701)
migración fallida

Diagnóstico: la migración no es idempotente. Faltaba el IF NOT EXISTS.

Comandos útiles para inspeccionar init containers:

# Nombres de los init containers de un pod
kubectl get pod <pod> -n <ns> \
  -o jsonpath='{range .spec.initContainers[*]}{.name}{"\n"}{end}'

# Estado detallado de cada uno
kubectl get pod <pod> -n <ns> -o jsonpath='{.status.initContainerStatuses}' | python3 -m json.tool

# Vista completa, con la sección "Init Containers" separada
kubectl describe pod <pod> -n <ns>

En la salida de describe, los init containers aparecen en un bloque Init Containers: antes del bloque Containers:, cada uno con su estado, su código de salida y su razón de terminación.

  1. Sidecars nativos: init containers con restartPolicy: Always

Durante años, un sidecar era simplemente "otro contenedor más en la lista containers". Funcionaba, pero tenía dos defectos graves que Kubernetes 1.29 resolvió y que están estables desde 1.33.

Los dos problemas del sidecar clásico

Problema 1: no hay orden de arranque. Todos los contenedores de containers arrancan en paralelo. Si el sidecar es un proxy por el que la aplicación tiene que salir a la red, y la aplicación arranca antes que el proxy, las primeras peticiones fallan.

Problema 2: los Jobs no terminaban nunca. Un Job cuyo pod tiene un sidecar era un problema sin solución limpia. El contenedor principal termina con éxito, pero el sidecar sigue vivo, así que el pod nunca alcanza Succeeded y el Job se queda colgado indefinidamente. La única salida eran apaños feos: ficheros centinela en un emptyDir, o que el contenedor principal matara al sidecar por la API.

La solución: restartPolicy: Always en un init container

      initContainers:
        - name: exportador-metricas
          image: prometheuscommunity/postgres-exporter:v0.16.0
          restartPolicy: Always      # <- esto lo convierte en sidecar nativo
          ports:
            - name: metricas
              containerPort: 9187

Ese único campo cambia por completo la semántica del contenedor:

Init container normal Sidecar nativo (restartPolicy: Always) Contenedor de containers
Momento de arranque En orden, antes que todo En orden, antes de los principales En paralelo con los demás
¿Bloquea al siguiente? Sí, hasta terminar No: basta con que esté iniciado No aplica
Duración Termina Vive todo el pod Vive todo el pod
Si sale Se pasa al siguiente Se reinicia Se reinicia
Al terminar el pod Ya no está Se para después de los principales Se para en paralelo
¿Bloquea la finalización de un Job? No No

Las cuatro consecuencias prácticas, en orden de importancia:

  1. Arranca antes que los contenedores principales, garantizando que el proxy o el exportador estén listos cuando la aplicación empieza a trabajar.
  2. Sigue vivo durante toda la vida del pod y se reinicia si muere, cosa que un init container normal no hace.
  3. Se termina después de los contenedores principales, así que un sidecar de logs captura los últimos mensajes del cierre.
  4. No impide que un Job termine. Cuando los contenedores de containers acaban, el kubelet para los sidecars y el pod alcanza Succeeded. Esto desbloquea todo el trabajo por lotes con sidecars: un CronJob con proxy, con exportador de métricas o con adaptador de logs simplemente funciona.

Una precisión sobre el orden: el pod no espera a que el sidecar termine (nunca lo hará), sino a que esté iniciado —y a que pase su startupProbe si la tiene—. A diferencia de los init containers normales, los sidecars sí admiten sondas, tema de la lección 07-01.

Regla de decisión sencilla: si el contenedor auxiliar debe estar listo antes que la aplicación, o si el pod es de un Job, usa sidecar nativo. En los demás casos, un contenedor normal en containers sigue siendo perfectamente válido.

  1. Los tres patrones clásicos: sidecar, embajador y adaptador

Los tres nombres vienen del artículo fundacional de Brendan Burns y Dave Oppenheimer sobre patrones de diseño para sistemas distribuidos. Se distinguen por hacia dónde va el flujo y qué transforman.

Patrón Qué hace Dirección del flujo Ejemplo en Rutas Norte
Sidecar Añade una capacidad que la aplicación no tiene, sin modificarla Lateral: observa o complementa Exportador de métricas de postgres-reservas
Embajador (ambassador) Intermedia la salida hacia un servicio externo Aplicación → exterior Proxy hacia pagos.proveedorexterno.example
Adaptador (adapter) Normaliza la salida de la aplicación a un formato estándar Aplicación → exterior, transformando Convertir el log propietario de worker-notificaciones a JSON

Otra forma de memorizarlo:

  • El sidecar añade algo.
  • El embajador simplifica lo que la aplicación ve del mundo exterior.
  • El adaptador simplifica lo que el mundo exterior ve de la aplicación.

Los tres se apoyan en los mismos dos mecanismos, que ya conoces de 02-01:

  • localhost compartido: todos los contenedores del pod comparten el espacio de nombres de red, así que se hablan por 127.0.0.1 sin pasar por la red del clúster, sin DNS y sin latencia apreciable. Corolario: dos contenedores del mismo pod no pueden usar el mismo puerto.
  • Volúmenes compartidos: un emptyDir montado en ambos permite pasar ficheros. Es el canal del patrón adaptador.
graph TB
  subgraph POD[Pod]
    direction LR
    APP[Contenedor principal]
    SC[Sidecar<br/>añade capacidad]
    EM[Embajador<br/>proxy de salida]
    AD[Adaptador<br/>normaliza formato]
    APP <-->|localhost| SC
    APP -->|localhost:8080| EM
    APP -->|emptyDir| AD
  end
  EM -->|TLS + reintentos| EXT[pagos.proveedorexterno.example]
  SC -->|:9187/metrics| PROM[Prometheus 07-03]
  AD -->|stdout JSON| LOGS[Recolector 06-02]

  1. Patrón sidecar: exportador de métricas junto a postgres-reservas

La imagen postgres:16.4 no expone métricas en formato Prometheus. Modificarla sería un error: perderíamos las actualizaciones oficiales y tendríamos que mantener una imagen propia. La solución es un sidecar que se conecta a PostgreSQL por localhost, ejecuta consultas de estado y publica el resultado en /metrics.

Añadimos el sidecar al StatefulSet que construimos en 06-01:

# k8s/base/postgres-reservas-statefulset.yaml (fragmento)
spec:
  template:
    spec:
      serviceAccountName: postgres-reservas
      automountServiceAccountToken: false
      initContainers:
        - name: exportador-metricas
          image: prometheuscommunity/postgres-exporter:v0.16.0
          restartPolicy: Always          # sidecar nativo
          ports:
            - name: metricas
              containerPort: 9187
          env:
            # localhost: el sidecar comparte la red del contenedor de PostgreSQL
            - name: DATA_SOURCE_URI
              value: "localhost:5432/reservas?sslmode=disable"
            - name: DATA_SOURCE_USER
              valueFrom:
                secretKeyRef:
                  name: postgres-reservas-credenciales
                  key: usuario
            - name: DATA_SOURCE_PASS
              valueFrom:
                secretKeyRef:
                  name: postgres-reservas-credenciales
                  key: password
          resources:
            requests:
              cpu: 20m
              memory: 48Mi
            limits:
              cpu: 100m
              memory: 96Mi
          securityContext:
            runAsNonRoot: true
            runAsUser: 65534
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]
      containers:
        - name: postgres
          image: postgres:16.4
          # ... resto igual que en 06-01 ...

Puntos que explican el patrón:

  • DATA_SOURCE_URI: localhost:5432: no hay nombre de servicio ni DNS. El sidecar habla con PostgreSQL a través del bucle local del pod. Es la ventaja fundamental: latencia mínima, sin exponer el puerto 5432 a la red del clúster para esto, y sin necesidad de NetworkPolicy adicional, porque el tráfico ni siquiera sale del pod.
  • Sidecar nativo: arranca antes que PostgreSQL. En principio no es imprescindible aquí, pero garantiza que no perdemos las métricas de los primeros segundos de vida, que son justo las del arranque y la recuperación del WAL.
  • Recursos modestos y separados: 20m de CPU y 48 MiB. Se suman a los de PostgreSQL en el cálculo del scheduler.
  • Endurecimiento propio: el sidecar corre como nobody y con sistema de ficheros de solo lectura. No necesita nada más, y así un fallo en el exportador no compromete al contenedor de la base de datos.

Nota importante sobre QoS: en 03-05 establecimos que postgres-reservas es de clase Guaranteed. Para que el pod siga siéndolo, todos sus contenedores —sidecar incluido— deben tener requests iguales a limits. Con los valores de arriba (20m/100m, 48Mi/96Mi) el pod pasaría a Burstable. Si queremos conservar Guaranteed, hay que igualarlos:

          resources:
            requests:
              cpu: 100m
              memory: 96Mi
            limits:
              cpu: 100m
              memory: 96Mi

Es una consecuencia poco intuitiva de añadir sidecars que conviene tener presente.

El Service que expone las métricas:

apiVersion: v1
kind: Service
metadata:
  name: postgres-reservas-metricas
  namespace: rutas-norte-pro
  labels:
    app: postgres-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
spec:
  selector:
    app: postgres-reservas
    entorno: pro
  ports:
    - name: metricas
      port: 9187
      targetPort: metricas

Comprobación:

kubectl exec -n rutas-norte-pro postgres-reservas-0 -c exportador-metricas -- \
  wget -qO- localhost:9187/metrics | grep -E '^pg_up|^pg_stat_database_numbackends' | head -3
pg_up 1
pg_stat_database_numbackends{datname="reservas"} 14

pg_up 1 confirma que el exportador puede conectar; numbackends da las conexiones activas. Estas métricas son las que Prometheus recogerá en la lección 07-03; aquí solo hemos montado la fuente.

  1. Patrón embajador: la conexión con la pasarela de pagos

api-reservas cobra a través de pagos.proveedorexterno.example, un servicio externo con las complicaciones habituales: TLS mutuo con certificado de cliente, reintentos con retroceso, cortacircuitos cuando el proveedor va lento, límite de peticiones por segundo y un endpoint de pruebas distinto en cada entorno.

Meter toda esa lógica en api-reservas significa escribirla en Node.js, mantenerla, probarla y volver a hacerla si mañana aparece un segundo proveedor. El embajador la saca del código: un proxy local que escucha en localhost y se ocupa de todo.

# k8s/base/api-reservas-deployment.yaml (fragmento)
spec:
  template:
    spec:
      serviceAccountName: api-reservas
      automountServiceAccountToken: false
      initContainers:
        - name: embajador-pagos
          image: envoyproxy/envoy:v1.31.3
          restartPolicy: Always        # sidecar nativo: debe estar listo antes que la API
          args: ["-c", "/etc/envoy/envoy.yaml", "--log-level", "warn"]
          ports:
            - name: pagos-local
              containerPort: 8081
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 200m
              memory: 128Mi
          securityContext:
            runAsNonRoot: true
            runAsUser: 65534
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: config-embajador
              mountPath: /etc/envoy
              readOnly: true
            - name: certificados-pagos
              mountPath: /etc/certificados
              readOnly: true
      containers:
        - name: api
          image: registry.rutasnorte.example/api-reservas:2.5.0
          env:
            # La aplicación habla HTTP plano contra localhost. Nada más.
            - name: PASARELA_PAGOS_URL
              value: "http://127.0.0.1:8081"
          ports:
            - name: http
              containerPort: 3000
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 512Mi
      volumes:
        - name: config-embajador
          configMap:
            name: embajador-pagos-config
        - name: certificados-pagos
          secret:
            secretName: pagos-certificado-cliente
            defaultMode: 0400

Y la configuración del proxy, resumida a lo esencial:

# k8s/base/embajador-pagos-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: embajador-pagos-config
  namespace: rutas-norte-pro
data:
  envoy.yaml: |
    static_resources:
      listeners:
        - name: pagos_local
          address:
            socket_address: { address: 127.0.0.1, port_value: 8081 }
          filter_chains:
            - filters:
                - name: envoy.filters.network.http_connection_manager
                  typed_config:
                    "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                    stat_prefix: pagos
                    route_config:
                      virtual_hosts:
                        - name: pagos
                          domains: ["*"]
                          routes:
                            - match: { prefix: "/" }
                              route:
                                cluster: pasarela_externa
                                timeout: 8s
                                retry_policy:
                                  retry_on: "5xx,connect-failure,reset"
                                  num_retries: 3
                    http_filters:
                      - name: envoy.filters.http.router
                        typed_config:
                          "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
      clusters:
        - name: pasarela_externa
          connect_timeout: 3s
          type: LOGICAL_DNS
          circuit_breakers:
            thresholds:
              - max_connections: 50
                max_pending_requests: 20
          load_assignment:
            cluster_name: pasarela_externa
            endpoints:
              - lb_endpoints:
                  - endpoint:
                      address:
                        socket_address:
                          address: pagos.proveedorexterno.example
                          port_value: 443
          transport_socket:
            name: envoy.transport_sockets.tls
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
              sni: pagos.proveedorexterno.example
              common_tls_context:
                tls_certificates:
                  - certificate_chain: { filename: /etc/certificados/tls.crt }
                    private_key: { filename: /etc/certificados/tls.key }

Lo que ha ganado Rutas Norte:

Responsabilidad Antes: en api-reservas Ahora: en el embajador
TLS mutuo con certificado de cliente Código Node.js + gestión de ficheros Configuración declarativa
Reintentos ante 5xx y cortes Librería y lógica propia retry_policy
Cortacircuito Librería y lógica propia circuit_breakers
Tiempos de espera Constantes repartidas por el código timeout en un sitio
Endpoint distinto por entorno Variable de entorno y condicionales Un ConfigMap por entorno
Rotación del certificado Redespliegue de la aplicación Cambio del Secret

Y sobre todo: api-reservas hace un POST HTTP plano a http://127.0.0.1:8081/cobros y ya está. En las pruebas locales de desarrollo, ese endpoint puede ser un simulador; la aplicación no nota la diferencia.

La NetworkPolicy de 04-06 que autoriza la salida a la pasarela sigue aplicándose al pod, no al contenedor, así que no cambia: el pod entero necesita permiso de salida al puerto 443 externo.

Una aclaración necesaria: si esta idea se aplica a todo el tráfico de todos los pods, con un plano de control que distribuye la configuración, ya no se llama embajador sino malla de servicios (Istio, Linkerd). Ese es territorio de la lección 08-04; aquí resolvemos un caso concreto sin adoptar una plataforma entera.

  1. Patrón adaptador: normalizar el log de worker-notificaciones

worker-notificaciones es un componente heredado que escribe en un fichero, con un formato propio y con trazas multilínea cuando hay una excepción:

2026-08-05 03:14:22 | ENVIO_OK | reserva=4471 | [email protected] | ms=312
2026-08-05 03:14:25 | ENVIO_ERR | reserva=4472 | [email protected] | causa=SMTP timeout
  at smtp.enviar (smtp.js:88)
  at cola.procesar (cola.js:41)

El recolector de logs del DaemonSet de 06-02 lee la salida estándar de los contenedores, no ficheros arbitrarios, y aunque los leyera, ese formato no es consultable: no hay campos, y una excepción se parte en tres entradas sin relación.

El adaptador resuelve las dos cosas: lee el fichero desde un volumen compartido, une las líneas de continuación y emite JSON estructurado por su propia salida estándar, donde el recolector sí lo recoge.

# k8s/base/worker-notificaciones-deployment.yaml (fragmento)
spec:
  template:
    spec:
      automountServiceAccountToken: false
      initContainers:
        - name: adaptador-logs
          image: fluent/fluent-bit:3.1.9
          restartPolicy: Always      # sidecar nativo: se para DESPUÉS del worker
          resources:
            requests:
              cpu: 30m
              memory: 48Mi
            limits:
              cpu: 100m
              memory: 96Mi
          volumeMounts:
            - name: logs-worker
              mountPath: /logs
              readOnly: true
            - name: config-adaptador
              mountPath: /fluent-bit/etc
              readOnly: true
      containers:
        - name: worker
          image: registry.rutasnorte.example/worker-notificaciones:1.9.3
          env:
            - name: LOG_FICHERO
              value: /logs/notificaciones.log
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi
          volumeMounts:
            - name: logs-worker
              mountPath: /logs
      volumes:
        - name: logs-worker
          emptyDir:
            sizeLimit: 256Mi     # sin límite, un log desbocado llena el disco del nodo
        - name: config-adaptador
          configMap:
            name: adaptador-logs-config
# k8s/base/adaptador-logs-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: adaptador-logs-config
  namespace: rutas-norte-pro
data:
  fluent-bit.conf: |
    [SERVICE]
        Flush        3
        Log_Level    error
        Parsers_File parsers.conf

    [INPUT]
        Name              tail
        Path              /logs/notificaciones.log
        Tag               notificaciones
        Parser            worker_rutasnorte
        Multiline.parser  worker_traza
        Refresh_Interval  5

    [FILTER]
        Name    record_modifier
        Match   notificaciones
        Record  componente worker-notificaciones
        Record  entorno    pro

    [OUTPUT]
        Name   stdout
        Match  notificaciones
        Format json_lines

  parsers.conf: |
    [PARSER]
        Name        worker_rutasnorte
        Format      regex
        Regex       ^(?<time>[\d-]+ [\d:]+) \| (?<nivel>\w+) \| reserva=(?<reserva>\d+) \| destino=(?<destino>[^ ]+) \|(?<resto>.*)$
        Time_Key    time
        Time_Format %Y-%m-%d %H:%M:%S

    [MULTILINE_PARSER]
        Name          worker_traza
        Type          regex
        Flush_Timeout 1000
        Rule          "start_state"  "^\d{4}-\d{2}-\d{2} "  "cont"
        Rule          "cont"         "^  at "               "cont"

Resultado en la salida estándar del adaptador, que es lo que el recolector del nodo se lleva:

kubectl logs -n rutas-norte-pro deployment/worker-notificaciones -c adaptador-logs --tail=1
{"date":1754363665.0,"nivel":"ENVIO_ERR","reserva":"4472","destino":"[email protected]","resto":" causa=SMTP timeout\n  at smtp.enviar (smtp.js:88)\n  at cola.procesar (cola.js:41)","componente":"worker-notificaciones","entorno":"pro"}

La excepción viaja entera en un solo registro, con sus campos separados y etiquetada con el componente y el entorno. En 07-05 esto permitirá consultas del tipo "todos los ENVIO_ERR de la reserva 4472".

Aquí el sidecar nativo aporta algo concreto e importante: como se termina después del contenedor principal, captura los últimos mensajes que worker-notificaciones escribe durante su cierre ordenado con SIGTERM (02-01). Con un contenedor normal en containers, ambos reciben SIGTERM a la vez y esas últimas líneas —a menudo las que explican por qué se paró— se pierden.

El sizeLimit: 256Mi del emptyDir no es opcional: un emptyDir sin límite escribe en el disco del nodo hasta llenarlo, y entonces el nodo entra en disk-pressure y desaloja pods ajenos.

  1. Orden de arranque y terminación, y el coste real

La secuencia completa

Con init containers y sidecars nativos, el ciclo de vida de un pod queda así:

sequenceDiagram
    participant K as kubelet
    participant I as initContainers normales
    participant S as sidecars (restartPolicy Always)
    participant C as containers principales
    K->>I: arranca en orden; espera a que cada uno termine con éxito
    I-->>K: exit 0
    K->>S: arranca en orden; espera a que estén iniciados
    S-->>K: iniciado (y startupProbe superada si la hay)
    K->>C: arranca todos en paralelo
    Note over C: vida útil del pod
    K->>C: SIGTERM a los principales
    C-->>K: terminados (o vencido el periodo de gracia)
    K->>S: SIGTERM a los sidecars, en orden inverso
    S-->>K: terminados

Puntos a retener:

  1. Los init containers normales se ejecutan secuencialmente y hasta terminar.
  2. Los sidecars nativos arrancan en el orden declarado, y basta con que estén iniciados.
  3. Los contenedores principales arrancan todos a la vez, sin garantía de orden entre ellos.
  4. En la terminación, primero los principales; después los sidecars, en orden inverso.
  5. El terminationGracePeriodSeconds es del pod, no de cada contenedor: es el presupuesto total para todo el cierre.

El coste real

Cada sidecar es un contenedor más por cada pod, y esa multiplicación es fácil de subestimar. Números de Rutas Norte en producción:

Componente Réplicas Sidecar CPU sidecar Memoria sidecar Total CPU Total memoria
api-reservas 6 Embajador de pagos 50m 64Mi 300m 384Mi
worker-notificaciones 3 Adaptador de logs 30m 48Mi 90m 144Mi
postgres-reservas 1 Exportador de métricas 100m 96Mi 100m 96Mi
Total 0,49 CPU 624Mi

Medio núcleo y 600 MiB solo en contenedores auxiliares. En un pico de puente festivo, con api-reservas autoescalada a 20 réplicas (09-01), el embajador solo ya son 1 CPU y 1,25 GiB.

Y hay costes menos visibles:

  • Cada sidecar es una imagen que hay que mantener, escanear y actualizar (08-05). Tres sidecars son tres cadenas de suministro más.
  • Cada sidecar puede fallar y, con restartPolicy: Always, entrar en bucle de reinicio arrastrando al pod entero.
  • Cada sidecar suma tiempo al arranque, y los nativos lo suman de forma secuencial antes de la aplicación.
  • La depuración se complica: todo kubectl logs y kubectl exec necesita ya el flag -c.

Preguntas de control antes de añadir un sidecar:

  1. ¿Puede hacerlo un DaemonSet, uno por nodo en lugar de uno por pod? Para logs y métricas de nodo, casi siempre sí.
  2. ¿Puede hacerlo la propia aplicación con una librería? A veces cinco líneas de código sustituyen a un contenedor de 60 MiB.
  3. ¿Justifica el sidecar su coste multiplicado por el número máximo de réplicas? Calcula el peor caso, no el habitual.

Errores Comunes y Consejos

Olvidar -c <contenedor> en kubectl logs y kubectl exec. Con varios contenedores, kubectl exige saber cuál. El error a container name must be specified es inequívoco, pero cuando hay init containers el mensaje puede despistar porque el contenedor principal aún no existe.

Poner un init container en bucle infinito sin visibilidad. Un until ... done sin echo deja el pod en Init:0/1 sin ningún log que explique la espera. Imprime siempre algo en cada iteración.

Init containers pesados. El scheduler reserva el máximo entre los init containers, así que uno que pida 2 GiB obliga a encontrar un nodo con 2 GiB libres aunque la aplicación necesite 256 MiB. Mantenlos pequeños.

Migraciones en initContainer sin lock. Con N réplicas, la migración se ejecuta N veces, potencialmente en paralelo. Sin idempotencia y sin lock de aplicación, el resultado es una base de datos a medio migrar. Usa una herramienta de migraciones que tome lock, o un Job separado (06-03).

Dos contenedores del mismo pod escuchando en el mismo puerto. Comparten el espacio de red, así que el segundo falla con address already in use. Lleva un registro de qué puerto usa cada contenedor auxiliar (3000 la API, 8081 el embajador, 9187 el exportador...).

Sidecar clásico en un pod de Job. Es la trampa histórica: el Job no termina nunca. La solución en 1.29+ es un sidecar nativo con restartPolicy: Always como init container.

emptyDir sin sizeLimit para logs. Un log que crece sin control llena el disco del nodo y provoca disk-pressure, con desalojo de pods que no tenían nada que ver. Pon siempre sizeLimit.

Romper la clase QoS al añadir un sidecar. Un pod es Guaranteed solo si todos sus contenedores tienen requests == limits. Añadir un sidecar con valores distintos degrada el pod a Burstable y cambia su prioridad de desalojo (03-05).

Consejo: nombra los contenedores por su función, no por su tecnología. embajador-pagos es mucho más útil en una alerta a las tres de la mañana que envoy.

Consejo: kubectl describe pod es la mejor vista. Muestra Init Containers: y Containers: en bloques separados, con el estado, el código de salida y la razón de cada uno. Es más rápido que encadenar jsonpath.

Consejo: kubectl logs --all-containers=true vuelca todo el pod de golpe, útil para reconstruir la secuencia de un arranque problemático.

Ejercicios

Ejercicio 1: init container que espera a una dependencia

En rutas-norte-dev, crea un Deployment api-demo con una réplica de nginx:1.27.2-alpine y un init container que espere a que exista y responda un Service llamado dependencia-demo. Aplica el Deployment antes de crear la dependencia y observa el estado del pod. Después crea la dependencia (un Deployment y un Service con nginx) y comprueba que api-demo arranca.

Ejercicio 2: patrón adaptador con emptyDir compartido

En rutas-norte-dev, crea un Deployment worker-demo con:

  • Un contenedor principal worker que cada 5 segundos escriba una línea en formato propietario en /logs/salida.log (por ejemplo 2026-08-05 10:00:00 | ENVIO_OK | reserva=4471).
  • Un sidecar nativo adaptador (init container con restartPolicy: Always) que siga ese fichero y emita cada línea por su salida estándar con el prefijo [adaptado].
  • Un emptyDir con sizeLimit: 64Mi compartido.

Verifica que el sidecar arranca antes que el principal y que emite las líneas.

Ejercicio 3: sidecar nativo en un Job

Crea en rutas-norte-dev un Job informe-con-sidecar cuyo pod tenga un contenedor principal que tarde 15 segundos y termine, y un sidecar nativo que escriba algo cada 3 segundos indefinidamente. Comprueba que el Job llega a Complete. Después razona qué habría pasado si el sidecar estuviera declarado en containers en lugar de como init container con restartPolicy: Always.

Soluciones

Solución 1

# /tmp/api-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-demo
  namespace: rutas-norte-dev
  labels:
    app: api-demo
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api-demo
      entorno: dev
  template:
    metadata:
      labels:
        app: api-demo
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      automountServiceAccountToken: false
      initContainers:
        - name: esperar-dependencia
          image: busybox:1.36
          command:
            - sh
            - -c
            - |
              echo "Esperando a dependencia-demo:80..."
              until wget -q -T 2 -O /dev/null http://dependencia-demo:80 2>/dev/null; do
                echo "  aún no responde; reintento en 3 s"
                sleep 3
              done
              echo "dependencia-demo disponible"
          resources:
            requests:
              cpu: 10m
              memory: 16Mi
            limits:
              cpu: 50m
              memory: 32Mi
      containers:
        - name: nginx
          image: nginx:1.27.2-alpine
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 200m
              memory: 128Mi
kubectl apply -f /tmp/api-demo.yaml
kubectl get pods -n rutas-norte-dev -l app=api-demo
NAME                        READY   STATUS     RESTARTS   AGE
api-demo-5b7c9d6f4-x8k2m    0/1     Init:0/1   0          35s

Init:0/1: el init container está corriendo y ninguno ha completado.

kubectl logs -n rutas-norte-dev -l app=api-demo -c esperar-dependencia --tail=3
Esperando a dependencia-demo:80...
  aún no responde; reintento en 3 s
  aún no responde; reintento en 3 s

Ahora la dependencia:

kubectl create deployment dependencia-demo -n rutas-norte-dev --image=nginx:1.27.2-alpine
kubectl label deployment dependencia-demo -n rutas-norte-dev app=dependencia-demo entorno=dev --overwrite
kubectl expose deployment dependencia-demo -n rutas-norte-dev --port=80

kubectl wait --for=condition=ready pod -l app=api-demo -n rutas-norte-dev --timeout=120s
kubectl get pods -n rutas-norte-dev -l app=api-demo
NAME                        READY   STATUS    RESTARTS   AGE
api-demo-5b7c9d6f4-x8k2m    1/1     Running   0          2m10s
kubectl logs -n rutas-norte-dev -l app=api-demo -c esperar-dependencia --tail=1
dependencia-demo disponible

El pod pasó de Init:0/1 a PodInitializing y luego a Running sin ningún reinicio. Ese RESTARTS: 0 es la mejora frente a dejar que la aplicación entre en CrashLoopBackOff mientras espera.

Solución 2

# /tmp/worker-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: worker-demo
  namespace: rutas-norte-dev
  labels:
    app: worker-demo
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app: worker-demo
      entorno: dev
  template:
    metadata:
      labels:
        app: worker-demo
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      automountServiceAccountToken: false
      initContainers:
        - name: adaptador
          image: busybox:1.36
          restartPolicy: Always          # sidecar nativo
          command:
            - sh
            - -c
            - |
              echo "[adaptador] arrancado antes que el worker"
              touch /logs/salida.log
              tail -F /logs/salida.log | while read -r LINEA; do
                echo "[adaptado] $LINEA"
              done
          resources:
            requests:
              cpu: 10m
              memory: 16Mi
            limits:
              cpu: 50m
              memory: 32Mi
          volumeMounts:
            - name: logs
              mountPath: /logs
      containers:
        - name: worker
          image: busybox:1.36
          command:
            - sh
            - -c
            - |
              N=4471
              while true; do
                echo "$(date '+%Y-%m-%d %H:%M:%S') | ENVIO_OK | reserva=$N" >> /logs/salida.log
                N=$(( N + 1 ))
                sleep 5
              done
          resources:
            requests:
              cpu: 20m
              memory: 32Mi
            limits:
              cpu: 100m
              memory: 64Mi
          volumeMounts:
            - name: logs
              mountPath: /logs
      volumes:
        - name: logs
          emptyDir:
            sizeLimit: 64Mi
kubectl apply -f /tmp/worker-demo.yaml
kubectl wait --for=condition=ready pod -l app=worker-demo -n rutas-norte-dev --timeout=120s

POD=$(kubectl get pod -n rutas-norte-dev -l app=worker-demo -o jsonpath='{.items[0].metadata.name}')
kubectl logs -n rutas-norte-dev "$POD" -c adaptador --tail=4
[adaptador] arrancado antes que el worker
[adaptado] 2026-08-05 19:14:02 | ENVIO_OK | reserva=4471
[adaptado] 2026-08-05 19:14:07 | ENVIO_OK | reserva=4472
[adaptado] 2026-08-05 19:14:12 | ENVIO_OK | reserva=4473

La primera línea confirma el orden: el sidecar imprimió su mensaje de arranque antes de que el worker escribiera nada, porque los sidecars nativos arrancan antes que los contenedores de containers. El contenedor principal, en cambio, no imprime nada por su salida estándar:

kubectl logs -n rutas-norte-dev "$POD" -c worker --tail=3
(sin salida)

Todo su registro va al fichero, y solo llega al recolector del nodo gracias al adaptador. Ese es exactamente el propósito del patrón.

Solución 3

# /tmp/informe-con-sidecar.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: informe-con-sidecar
  namespace: rutas-norte-dev
  labels:
    app: informes-ocupacion
    entorno: dev
spec:
  backoffLimit: 1
  ttlSecondsAfterFinished: 3600
  template:
    metadata:
      labels:
        app: informes-ocupacion
        entorno: dev
    spec:
      restartPolicy: Never
      automountServiceAccountToken: false
      initContainers:
        - name: metricas-lote
          image: busybox:1.36
          restartPolicy: Always      # sidecar nativo: NO impide que el Job termine
          command:
            - sh
            - -c
            - 'while true; do echo "[metricas] latido $(date +%H:%M:%S)"; sleep 3; done'
          resources:
            requests:
              cpu: 10m
              memory: 16Mi
            limits:
              cpu: 50m
              memory: 32Mi
      containers:
        - name: generador
          image: busybox:1.36
          command:
            - sh
            - -c
            - 'echo "generando informe de ocupación..."; sleep 15; echo "informe generado"'
          resources:
            requests:
              cpu: 20m
              memory: 32Mi
            limits:
              cpu: 100m
              memory: 64Mi
kubectl apply -f /tmp/informe-con-sidecar.yaml
kubectl wait --for=condition=complete job/informe-con-sidecar -n rutas-norte-dev --timeout=120s
kubectl get job informe-con-sidecar -n rutas-norte-dev
NAME                  STATUS     COMPLETIONS   DURATION   AGE
informe-con-sidecar   Complete   1/1           19s        25s

El Job llega a Complete en 19 segundos pese a que el sidecar seguía imprimiendo latidos indefinidamente.

POD=$(kubectl get pod -n rutas-norte-dev -l job-name=informe-con-sidecar -o jsonpath='{.items[0].metadata.name}')
kubectl logs -n rutas-norte-dev "$POD" -c metricas-lote --tail=3
kubectl get pod "$POD" -n rutas-norte-dev
[metricas] latido 19:22:14
[metricas] latido 19:22:17
[metricas] latido 19:22:20

NAME                        READY   STATUS      RESTARTS   AGE
informe-con-sidecar-4kx2p   0/2     Completed   0          45s

El pod está Completed con sus dos contenedores parados.

Qué habría pasado con el sidecar en containers: el contenedor generador habría terminado con éxito a los 15 segundos, pero metricas-lote habría seguido vivo. Un pod solo alcanza la fase Succeeded cuando todos sus contenedores han terminado, así que el pod se habría quedado indefinidamente en Running con 1/2 contenedores listos, y el Job en 0/1 completions para siempre. Solo activeDeadlineSeconds lo habría cortado, y con estado Failed.

Ese era exactamente el problema histórico que los sidecars nativos resolvieron.

# Limpieza
kubectl delete -f /tmp/informe-con-sidecar.yaml
kubectl delete -f /tmp/worker-demo.yaml
kubectl delete -f /tmp/api-demo.yaml
kubectl delete deployment,service dependencia-demo -n rutas-norte-dev

Conclusión

Un pod con varios contenedores es una herramienta potente y fácil de usar mal. La regla que la gobierna es que los procesos compartan pod solo cuando estén tan acoplados que no tenga sentido escalarlos ni desplegarlos por separado, y cuando uno exista para servir al otro.

Los init containers se ejecutan en orden, hasta completarse, antes de que arranque ningún contenedor principal, y en Rutas Norte nos sirven para esperar a postgres-reservas, aplicar migraciones pequeñas e idempotentes, y preparar contenido en un emptyDir compartido. Sus estados —Init:0/2, Init:Error, Init:CrashLoopBackOff— son diagnósticos por sí mismos, y kubectl logs -c <nombre> es el comando que hay que interiorizar.

Los sidecars nativos de Kubernetes 1.29+ son init containers con restartPolicy: Always: arrancan antes que los contenedores principales, viven durante todo el pod, se reinician si mueren, se paran después de los principales y —lo que zanjó un problema de años— no impiden que un Job termine.

Los tres patrones clásicos se distinguen por la dirección del flujo: el sidecar añade una capacidad (el exportador de métricas de postgres-reservas que Prometheus consumirá en 07-03); el embajador intermedia la salida hacia el exterior (el proxy que se ocupa de TLS mutuo, reintentos y cortacircuito contra pagos.proveedorexterno.example); el adaptador normaliza lo que la aplicación produce (el conversor a JSON del log propietario de worker-notificaciones). Los tres se apoyan en localhost y en volúmenes compartidos, y los tres cuestan recursos multiplicados por el número de pods, algo que hay que calcular en el peor caso de escalado.

Hasta aquí hemos decidido qué se ejecuta y cómo se compone cada pod. La pregunta que no hemos tocado es dónde: hasta ahora el kube-scheduler ha colocado nuestros pods donde ha querido, y eso ha bastado. Pero postgres-reservas debería estar en un nodo con disco SSD, las réplicas de api-reservas no deberían compartir nodo —si ese nodo cae, se cae la API entera—, y las cargas de análisis no deberían competir con la venta de billetes. Todo eso se controla con afinidad, taints y tolerations, y es el tema de la siguiente lección: Planificación.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados