Las dos lecciones anteriores construyeron los almacenes: un ConfigMap con la configuración de tienda-web y un Secret con las credenciales de postgres-reservas. En ambas viste una sola forma de llevarlos al contenedor, el fichero montado, y en ambas quedó aplazada la otra: la variable de entorno, que es la que aparece en la inmensa mayoría de los manifiestos reales y la única que entienden las imágenes de terceros como postgres:16 o redis:7.2-alpine. Esta lección la cubre entera y unifica las dos anteriores: cómo se declaran, las cuatro fuentes de las que pueden venir, cómo un pod puede consultar datos sobre sí mismo con la Downward API, qué pasa cuando la misma clave llega por dos caminos, por qué el shell no expande lo que crees, y sobre todo la limitación que lo condiciona todo: una variable de entorno no cambia mientras el proceso vive. Al final tendrás el catálogo completo de variables de los seis componentes de Rutas Norte en los tres entornos.

Contenido

  1. Qué es realmente una variable de entorno en un contenedor
  2. env con valor literal
  3. envFrom: volcar un ConfigMap o un Secret entero
  4. valueFrom: tomar una clave concreta
  5. La Downward API: fieldRef
  6. La Downward API: resourceFieldRef
  7. Expansión de variables con $(VAR)
  8. Interacción con command y args
  9. Precedencia y colisiones
  10. La limitación fundamental: no se refrescan
  11. El hash de la configuración en una anotación
  12. Variable de entorno frente a fichero montado
  13. El catálogo de variables de Rutas Norte

  1. Qué es realmente una variable de entorno en un contenedor

Antes de la sintaxis conviene entender el mecanismo, porque explica casi todas las sorpresas.

Cuando el kubelet arranca un contenedor, construye una lista de pares CLAVE=valor y se la pasa al runtime (containerd), que a su vez la entrega al kernel en la llamada execve() que lanza el proceso principal. A partir de ahí:

  • El proceso guarda esa lista en su propia memoria (environ).
  • Nadie desde fuera puede modificarla. No hay una llamada al sistema para cambiar el entorno de otro proceso. Ni Kubernetes, ni el kubelet, ni kubectl.
  • Los procesos hijos la heredan en el momento de crearse.
flowchart LR
    A["Manifiesto del pod<br/>env / envFrom"] --> B["kubelet<br/>resuelve ConfigMaps,<br/>Secrets y Downward API"]
    B --> C["containerd<br/>lista CLAVE=valor"]
    C --> D["execve()<br/>proceso principal"]
    D --> E["environ del proceso<br/>CONGELADO de por vida"]
    E -.->|"herencia"| F["procesos hijos"]

De aquí se deduce todo lo que viene después: por qué hay que reiniciar el pod al cambiar un ConfigMap, por qué las variables son visibles en /proc/<pid>/environ, y por qué el contenido de una variable es siempre una cadena de texto (no existen números ni booleanos en el entorno de un proceso).

Comprobémoslo:

kubectl exec -n rutas-norte-dev deploy/api-reservas -- env | sort | head -12
DB_HOST=postgres-reservas
DB_PORT=5432
HOME=/root
HOSTNAME=api-reservas-7f4b8c9d6-2xkqp
KUBERNETES_PORT=tcp://10.96.0.1:443
KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443
KUBERNETES_SERVICE_HOST=10.96.0.1
KUBERNETES_SERVICE_PORT=443
NIVEL_LOG=debug
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
REDIS_CACHE_SERVICE_HOST=10.96.184.22
TIEMPO_ESPERA_MS=5000

Hay tres orígenes mezclados en esa lista:

  1. Las que vienen de la imagen (PATH, HOME): las define el Dockerfile o la imagen base.
  2. Las que hemos declarado nosotros (DB_HOST, NIVEL_LOG).
  3. Las que inyecta Kubernetes automáticamente: HOSTNAME (el nombre del pod) y las variables de descubrimiento de Services (KUBERNETES_SERVICE_HOST, REDIS_CACHE_SERVICE_HOST...).

Esas últimas son un vestigio de los inicios de Kubernetes, heredado de Docker links. Tienen dos limitaciones serias: solo aparecen los Services que ya existían cuando arrancó el pod, y solo los del mismo namespace. Por eso nadie las usa hoy: el descubrimiento se hace por DNS, como verás en DNS Interno. Si tienes muchos Services y quieres una lista de entorno limpia, se pueden desactivar con enableServiceLinks: false en spec del pod.

  1. env con valor literal

La forma más simple. Es una lista, no un mapa, y ese es el primer detalle que confunde:

    spec:
      containers:
        - name: api
          image: registry.rutasnorte.example/api-reservas:2.5.0
          env:
            - name: DB_HOST                 # cada entrada es un objeto con name y value
              value: postgres-reservas
            - name: DB_PORT
              value: "5432"                 # OJO: comillas obligatorias
            - name: MODO_DEPURACION
              value: "false"                # tambien los booleanos

Las comillas en los valores numéricos y booleanos no son opcionales. El campo value es de tipo string en el esquema de la API. Si escribes value: 5432, el analizador de YAML produce un entero y la validación falla:

error: error validating data: ValidationError(Deployment.spec.template.spec.containers[0].env[1].value):
  invalid type for io.k8s.api.core.v1.EnvVar.value: got "number", expected "string"

El mismo problema con value: true, con value: no (YAML 1.1 lo interpreta como booleano) y con value: 08:00 (lo interpreta como sexagesimal). Regla práctica: pon comillas en todos los valores de env que no sean claramente texto.

Cuándo usar un valor literal en lugar de un ConfigMap:

Usa value literal Usa ConfigMap o Secret
El valor es el mismo en los tres entornos El valor cambia según el entorno
Es una ruta interna del contenedor (/etc/secretos/postgres/password) Es una URL, un host o un tiempo de espera
Es una constante estructural (PGDATA) Es algo que un operador querrá cambiar sin tocar el Deployment
Nunca, jamás, para una credencial Siempre para una credencial (Secret)

  1. envFrom: volcar un ConfigMap o un Secret entero

Cuando el ConfigMap tiene diez claves y las quieres todas, escribir diez bloques valueFrom es absurdo. envFrom las vuelca de golpe:

          envFrom:
            - configMapRef:
                name: api-reservas-config       # TODAS sus claves se vuelven variables
            - secretRef:
                name: postgres-reservas-credenciales

Con este ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: api-reservas-config
  namespace: rutas-norte-dev
data:
  NIVEL_LOG: debug
  TIEMPO_ESPERA_MS: "5000"
  MAX_PLAZAS_POR_RESERVA: "9"

El contenedor recibe NIVEL_LOG, TIEMPO_ESPERA_MS y MAX_PLAZAS_POR_RESERVA con esos valores. El nombre de la clave se convierte literalmente en el nombre de la variable, lo que impone una condición: las claves deben ser nombres válidos de variable de entorno (letras, dígitos y _, sin empezar por dígito).

¿Qué pasa si una clave no es válida? Kubernetes la ignora en silencio y lo registra como un evento:

kubectl describe pod api-reservas-7f4b8c9d6-2xkqp -n rutas-norte-dev | grep -A3 Events
Events:
  Type     Reason              Age   From     Message
  ----     ------              ----  -------  -------
  Warning  InvalidEnvironmentVariableNames  12s  kubelet
    Keys [nginx.conf, api-url] from the EnvFrom list in the container "api" are invalid

Este es exactamente el motivo por el que en la lección de ConfigMaps insistí en separar el ConfigMap de ficheros del ConfigMap de variables. Un nginx.conf no puede ser una variable de entorno, pero convive perfectamente en el mismo objeto y estropea el envFrom.

El campo prefix

envFrom admite un prefijo, que resuelve el problema de las colisiones entre fuentes:

          envFrom:
            - configMapRef:
                name: api-reservas-config
              prefix: APP_                      # NIVEL_LOG -> APP_NIVEL_LOG
            - secretRef:
                name: postgres-reservas-credenciales
              prefix: DB_                       # password  -> DB_password

Resultado dentro del contenedor:

kubectl exec -n rutas-norte-dev deploy/api-reservas -- env | grep -E "^(APP_|DB_)" | sort
APP_MAX_PLAZAS_POR_RESERVA=9
APP_NIVEL_LOG=debug
APP_TIEMPO_ESPERA_MS=5000
DB_database=reservas
DB_password=d3v-C4mbi4m3-2026
DB_username=rutasnorte

Fíjate en DB_password en minúsculas: el prefijo se antepone tal cual, sin cambiar el resto. Si quieres DB_PASSWORD, la clave del Secret debe llamarse PASSWORD. Es un motivo razonable para nombrar las claves de los Secrets en mayúsculas cuando sabes que se consumirán con envFrom.

optional

Por defecto, si el ConfigMap o el Secret referenciado no existe, el pod no arranca: se queda en CreateContainerConfigError.

kubectl get pods -n rutas-norte-dev
kubectl describe pod api-reservas-6c8d7f9b5-lmnop -n rutas-norte-dev | grep -A2 "Warning"
NAME                            READY   STATUS                       RESTARTS   AGE
api-reservas-6c8d7f9b5-lmnop    0/1     CreateContainerConfigError   0          34s

  Warning  Failed  5s (x4 over 33s)  kubelet
    Error: configmap "api-reservas-config" not found

Con optional: true el pod arranca sin esas variables:

          envFrom:
            - configMapRef:
                name: api-reservas-experimental
                optional: true                  # si no existe, se ignora

Úsalo con cuidado. Es apropiado para configuración verdaderamente opcional (un ConfigMap de banderas de funcionalidad que solo existe en dev), pero es peligroso para lo esencial: un fallo silencioso donde el pod arranca con la configuración a medias es mucho peor de diagnosticar que un pod que no arranca. En Rutas Norte, optional: true solo se usa para el ConfigMap de banderas experimentales.

  1. valueFrom: tomar una clave concreta

Cuando quieres una sola clave, o cuando el nombre de la variable debe ser distinto del de la clave, se usa valueFrom:

          env:
            # De un ConfigMap
            - name: LOG_LEVEL                  # nombre que espera la aplicacion
              valueFrom:
                configMapKeyRef:
                  name: api-reservas-config
                  key: NIVEL_LOG               # nombre de la clave en el ConfigMap
            # De un Secret
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-reservas-credenciales
                  key: password
            # Opcional: si falta, la variable simplemente no existe
            - name: NEW_RELIC_KEY
              valueFrom:
                secretKeyRef:
                  name: apm-credenciales
                  key: licencia
                  optional: true

Esta es la forma recomendada por defecto frente a envFrom, y por tres razones sólidas:

  1. Es explícita. Al leer el Deployment sabes exactamente qué variables recibe el contenedor. Con envFrom hay que ir a mirar el ConfigMap.
  2. Desacopla nombres. La aplicación espera LOG_LEVEL y nuestro ConfigMap se llama NIVEL_LOG porque el equipo trabaja en español. valueFrom lo traduce sin renombrar nada.
  3. Mínimo privilegio. Al pod solo llegan las claves que necesita, igual que hacíamos con items en los volúmenes.
envFrom valueFrom
Verbosidad Baja Alta
Trazabilidad al leer el YAML Mala Excelente
Renombrar claves Solo con prefix Sí, libremente
Claves no válidas Se ignoran en silencio Error explícito
Recomendación Muchas claves, nombres ya correctos Por defecto

Y una advertencia sobre secretKeyRef: aunque la referencia sea limpia, el valor acaba en el entorno del proceso. Es visible en /proc/<pid>/environ para cualquier proceso del contenedor, y muchos frameworks vuelcan el entorno completo en las páginas de error de depuración. Para credenciales de alto valor, el fichero montado de la lección anterior sigue siendo mejor opción.

  1. La Downward API: fieldRef

Hasta ahora la configuración venía de fuera. La Downward API ("API hacia abajo") permite lo contrario: que el contenedor conozca datos sobre sí mismo y sobre el pod que lo contiene, sin hablar con la API de Kubernetes ni necesitar permisos.

El caso de Rutas Norte: cuando un cliente reporta que una reserva falló a las 22:47, queremos poder buscar en las trazas qué pod y qué nodo atendieron esa petición. Con 6 réplicas de api-reservas repartidas por 3 nodos, sin esa información la investigación es imposible.

          env:
            - name: POD_NOMBRE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: POD_IP
              valueFrom:
                fieldRef:
                  fieldPath: status.podIP
            - name: NODO_NOMBRE
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
            - name: CUENTA_SERVICIO
              valueFrom:
                fieldRef:
                  fieldPath: spec.serviceAccountName
            - name: ENTORNO
              valueFrom:
                fieldRef:
                  fieldPath: metadata.labels['entorno']       # etiqueta concreta

Campos disponibles en fieldRef:

fieldPath Qué devuelve Uso típico
metadata.name Nombre del pod Trazas, identificar la réplica
metadata.namespace Namespace Componer nombres DNS, etiquetar métricas
metadata.uid UID del pod Correlación en sistemas externos
metadata.labels['clave'] Valor de una etiqueta Leer entorno sin duplicarlo
metadata.annotations['clave'] Valor de una anotación Configuración inyectada por un operador
spec.nodeName Nodo donde corre Diagnóstico de problemas de nodo
spec.serviceAccountName ServiceAccount Auditoría
status.podIP IP del pod Registrarse en un servicio de descubrimiento
status.podIPs Lista de IPs (doble pila) Redes IPv4/IPv6
status.hostIP IP del nodo Enviar métricas a un agente del nodo

Dos restricciones que hay que conocer:

  • Solo se admiten esos campos. No puedes pedir spec.containers[0].image ni un campo arbitrario del pod. Para eso hay que hablar con la API, que es lo que veremos en ServiceAccounts.
  • metadata.labels y metadata.annotations sin especificar clave solo funcionan en un volumen downwardAPI, no en env. En env hay que indicar la clave concreta entre corchetes.

Veamos el resultado y su valor real:

kubectl exec -n rutas-norte-pro deploy/api-reservas -- env | grep -E "^(POD_|NODO_|ENTORNO)"
POD_NOMBRE=api-reservas-7f4b8c9d6-qh4nc
POD_NAMESPACE=rutas-norte-pro
POD_IP=10.244.2.37
NODO_NOMBRE=rutas-norte-m02
ENTORNO=pro

Y así queda una traza de api-reservas que use esas variables:

2026-08-05T22:47:13.412Z INFO  [pod=api-reservas-7f4b8c9d6-qh4nc nodo=rutas-norte-m02 entorno=pro]
  reserva_creada id=RN-2026-084412 origen=Bilbao destino=Santander plazas=2 ms=184
2026-08-05T22:47:19.887Z ERROR [pod=api-reservas-7f4b8c9d6-qh4nc nodo=rutas-norte-m02 entorno=pro]
  reserva_fallida motivo=timeout_bd ms=5001

Con esa línea, el diagnóstico es inmediato: todos los errores vienen del mismo pod y del mismo nodo, así que el problema no es la aplicación sino ese nodo concreto (o su ruta de red hasta postgres-reservas). Sin la Downward API, esa correlación no existe.

Un detalle importante sobre ENTORNO: podríamos haberlo puesto como literal value: "pro", pero entonces tendríamos el dato duplicado (en la etiqueta y en la variable) con riesgo de que se desincronicen. Leerlo de metadata.labels['entorno'] garantiza que siempre coincide con la etiqueta real del pod. Es una aplicación directa del esquema de etiquetado del módulo 2.

La Downward API como volumen

Existe también la variante de fichero, útil cuando quieres todas las etiquetas o anotaciones:

      volumes:
        - name: info-pod
          downwardAPI:
            items:
              - path: etiquetas
                fieldRef:
                  fieldPath: metadata.labels        # sin clave: todas
              - path: anotaciones
                fieldRef:
                  fieldPath: metadata.annotations
kubectl exec -n rutas-norte-pro deploy/api-reservas -- cat /etc/pod-info/etiquetas
app="api-reservas"
app.kubernetes.io/component="backend"
app.kubernetes.io/name="api-reservas"
app.kubernetes.io/part-of="rutas-norte"
entorno="pro"
pod-template-hash="7f4b8c9d6"

Y con una ventaja: a diferencia de las variables, este fichero sí se actualiza si cambian las etiquetas del pod, con el mismo mecanismo de la lección de ConfigMaps.

  1. La Downward API: resourceFieldRef

La segunda mitad de la Downward API expone los recursos declarados del contenedor, que estudiaremos a fondo en Cuotas y Límites:

          resources:
            requests:
              cpu: 250m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 512Mi
          env:
            - name: CPU_LIMITE_MILICORES
              valueFrom:
                resourceFieldRef:
                  containerName: api           # obligatorio si hay varios contenedores
                  resource: limits.cpu
                  divisor: 1m                  # unidad de salida
            - name: MEMORIA_LIMITE_MB
              valueFrom:
                resourceFieldRef:
                  containerName: api
                  resource: limits.memory
                  divisor: 1Mi
            - name: MEMORIA_SOLICITADA_MB
              valueFrom:
                resourceFieldRef:
                  containerName: api
                  resource: requests.memory
                  divisor: 1Mi
kubectl exec -n rutas-norte-pro deploy/api-reservas -- env | grep -E "^(CPU_|MEMORIA_)"
CPU_LIMITE_MILICORES=1000
MEMORIA_LIMITE_MB=512
MEMORIA_SOLICITADA_MB=256

Recursos disponibles: limits.cpu, requests.cpu, limits.memory, requests.memory, limits.ephemeral-storage, requests.ephemeral-storage.

El divisor decide la unidad: 1m da milicores, 1 da cores enteros (redondeando hacia arriba), 1Mi da mebibytes, 1Gi gibibytes. Si lo omites, el valor por defecto es 1, lo que para memoria significa bytes y produce números incómodos como 536870912.

¿Para qué sirve esto en la práctica? Para que el proceso se dimensione a sí mismo. Es un problema real y muy común: muchos entornos de ejecución no ven los límites de cgroups y creen que disponen de toda la memoria y todas las CPU del nodo, con lo que dimensionan sus pools de hilos y sus montones de memoria a lo grande y acaban en OOMKilled.

Aplicado a api-reservas (Node.js):

          env:
            - name: MEMORIA_LIMITE_MB
              valueFrom:
                resourceFieldRef:
                  containerName: api
                  resource: limits.memory
                  divisor: 1Mi
            # Al montón de V8 se le da el 75% del limite del contenedor,
            # dejando margen para el resto del proceso y evitar el OOMKill
            - name: NODE_OPTIONS
              value: "--max-old-space-size=384"

Ese 384 es el 75 % de 512Mi. Lo ideal sería calcularlo, pero la expansión $(VAR) del apartado siguiente no hace aritmética, así que en la práctica se calcula en el entrypoint del contenedor leyendo MEMORIA_LIMITE_MB:

# entrypoint.sh de api-reservas
MAX_HEAP=$(( MEMORIA_LIMITE_MB * 75 / 100 ))
exec node --max-old-space-size=${MAX_HEAP} servidor.js

Para la JVM existe el equivalente automático (-XX:MaxRAMPercentage=75), que es la opción preferible cuando el lenguaje la ofrece.

  1. Expansión de variables con $(VAR)

Kubernetes permite referenciar unas variables desde otras usando la sintaxis $(NOMBRE):

          env:
            - name: DB_HOST
              value: postgres-reservas
            - name: DB_PORT
              value: "5432"
            - name: DB_NOMBRE
              value: reservas
            - name: DB_URL
              value: "postgresql://$(DB_HOST):$(DB_PORT)/$(DB_NOMBRE)"
kubectl exec -n rutas-norte-dev deploy/api-reservas -- printenv DB_URL
postgresql://postgres-reservas:5432/reservas

Las reglas, que hay que conocer con precisión:

Regla 1: la sintaxis es $(VAR), con paréntesis. No ${VAR} ni $VAR. Esas dos son sintaxis de shell y Kubernetes las deja intactas.

Regla 2: solo se expanden variables definidas ANTES en la misma lista env. El orden importa:

          env:
            - name: MAL
              value: "$(DEFINIDA_DESPUES)"    # no se expande: aun no existe
            - name: DEFINIDA_DESPUES
              value: "hola"
MAL=$(DEFINIDA_DESPUES)

Cuando una referencia no se puede resolver, queda literal. No hay error ni aviso, y el valor $(DEFINIDA_DESPUES) llega tal cual a la aplicación. Es una fuente de errores silenciosos.

Regla 3: no se expanden las variables que vienen de envFrom. Las de envFrom se procesan como un bloque y no participan en la expansión de las de env. Si necesitas componer con una clave de un ConfigMap, tráela primero con valueFrom:

          env:
            - name: DB_HOST                        # primero la traemos explicitamente
              valueFrom:
                configMapKeyRef:
                  name: api-reservas-config
                  key: DB_HOST
            - name: DB_URL                         # ahora si se puede referenciar
              value: "postgresql://$(DB_HOST):5432/reservas"

Regla 4: no se expanden las variables de la imagen. $(PATH) o $(HOME) no se resuelven: Kubernetes solo conoce las que él mismo define.

Regla 5: $$ escapa el dólar. Si tu contraseña contiene $( literal, escríbelo $$(.

            - name: PLANTILLA
              value: "El coste es $$(precio) euros"
El coste es $(precio) euros

  1. Interacción con command y args

Aquí está el malentendido más frecuente del tema, y merece un apartado propio.

        - name: worker
          image: registry.rutasnorte.example/worker-notificaciones:1.8.0
          env:
            - name: LOTE_MAXIMO
              value: "50"
          command: ["/app/worker"]
          args: ["--lote=$(LOTE_MAXIMO)", "--cola=notificaciones"]

Esto funciona: Kubernetes expande $(LOTE_MAXIMO) en args con las mismas reglas del apartado anterior, y el proceso recibe --lote=50.

Pero esto no funciona:

          command: ["/app/worker"]
          args: ["--lote=${LOTE_MAXIMO}"]        # sintaxis de shell
# El proceso recibe literalmente:
--lote=${LOTE_MAXIMO}

Y esto tampoco:

          command: ["sh", "-c", "echo $LOTE_MAXIMO"]
          args: ["--extra=$LOTE_MAXIMO"]         # un solo dolar

La causa de la confusión: no hay shell. Cuando escribes command: ["/app/worker"], containerd ejecuta ese binario directamente con execve(). No se invoca /bin/sh, así que nadie expande $VAR ni ${VAR}: esa expansión es un trabajo del shell, y el shell no está en la cadena.

Las tres opciones y cuándo usar cada una:

Forma Quién expande Cuándo usarla
args: ["--lote=$(VAR)"] Kubernetes, antes de arrancar Preferida. Simple y sin shell
command: ["sh","-c","/app/worker --lote=$VAR"] El shell del contenedor Si necesitas tuberías, condicionales o aritmética
El propio programa lee os.environ La aplicación Lo más limpio de todo

Si usas la segunda forma, dos avisos importantes:

Aviso 1: el shell se convierte en el PID 1 y muchos shells no reenvían las señales a sus hijos. Eso rompe la terminación ordenada con SIGTERM que estudiaste en la lección de Pods: al borrar el pod, el proceso real no recibe la señal y muere de golpe al agotarse el periodo de gracia. La solución es exec:

          command: ["sh", "-c", "exec /app/worker --lote=$LOTE_MAXIMO"]

Con exec, el shell se reemplaza por el programa, que hereda el PID 1 y recibe las señales.

Aviso 2: es una puerta de entrada a la inyección de comandos. Si una variable viene de una fuente poco fiable y la interpretas en un sh -c, un valor como ; rm -rf / se ejecuta. Con $(VAR) de Kubernetes eso no pasa: el valor se pasa como argumento, no se interpreta.

Y un recordatorio de la lección anterior: nunca pongas una credencial en args, ni siquiera expandida desde un Secret. kubectl describe pod muestra Args con el valor ya resuelto.

  1. Precedencia y colisiones

Cuando MISMA_CLAVE llega por varias vías, ¿cuál gana? El orden de resolución es estricto:

flowchart TB
    A["1. Variables de la IMAGEN (Dockerfile ENV)"] --> B["2. Variables de servicios de Kubernetes"]
    B --> C["3. envFrom, en el ORDEN de la lista<br/>(cada una pisa a la anterior)"]
    C --> D["4. env, en el ORDEN de la lista<br/>(cada una pisa a la anterior)"]
    D --> E["VALOR FINAL<br/>gana lo ultimo aplicado"]

La regla en una frase: env siempre gana sobre envFrom, y dentro de cada bloque gana la última entrada.

Ejemplo completo para ver los cuatro niveles:

apiVersion: v1
kind: ConfigMap
metadata:
  name: config-a
  namespace: rutas-norte-dev
data:
  NIVEL_LOG: "info"
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: config-b
  namespace: rutas-norte-dev
data:
  NIVEL_LOG: "warn"
---
apiVersion: v1
kind: Pod
metadata:
  name: prueba-precedencia
  namespace: rutas-norte-dev
spec:
  containers:
    - name: prueba
      image: busybox:1.36
      command: ["sh", "-c", "env | grep NIVEL_LOG; sleep 3600"]
      envFrom:
        - configMapRef:
            name: config-a          # info
        - configMapRef:
            name: config-b          # warn  <- pisa a config-a
      env:
        - name: NIVEL_LOG
          value: "debug"            # <- pisa a todo lo anterior
kubectl apply -f prueba-precedencia.yaml
kubectl logs prueba-precedencia -n rutas-norte-dev
pod/prueba-precedencia created
NIVEL_LOG=debug

Gana debug, el de env. Y si eliminamos ese bloque env, ganaría warn, el del último configMapRef.

Consejos para no sufrir con esto:

  1. Evita las colisiones en lugar de gestionarlas. Un ConfigMap por propósito y sin claves repetidas.
  2. Usa prefix cuando volques varias fuentes con envFrom.
  3. Ante la duda, comprueba el valor efectivo, que es la única verdad:
kubectl exec -n rutas-norte-dev deploy/api-reservas -- printenv NIVEL_LOG
  1. Cuidado con las variables de la imagen. Una imagen base puede definir NODE_ENV=production en su Dockerfile, y si no la sobrescribes explícitamente estará ahí sin que aparezca en ningún manifiesto tuyo. kubectl exec ... -- env la revela.

  1. La limitación fundamental: no se refrescan

Volvemos al mecanismo del apartado 1. Las variables de entorno se fijan en execve() y nadie puede cambiarlas después. La consecuencia es tajante:

Si cambias un ConfigMap o un Secret, los pods que ya corren no verán el cambio nunca, por muchas horas que esperes.

Demostración:

# El valor actual
kubectl exec -n rutas-norte-dev deploy/api-reservas -- printenv NIVEL_LOG

# Cambiamos el ConfigMap
kubectl patch configmap api-reservas-config -n rutas-norte-dev \
  --type merge -p '{"data":{"NIVEL_LOG":"trace"}}'

# Comprobamos que el objeto SI ha cambiado
kubectl get cm api-reservas-config -n rutas-norte-dev -o jsonpath='{.data.NIVEL_LOG}'; echo

# Y ahora, cinco minutos despues, dentro del pod
sleep 300
kubectl exec -n rutas-norte-dev deploy/api-reservas -- printenv NIVEL_LOG
debug
configmap/api-reservas-config patched
trace
debug

El objeto vale trace y el proceso sigue viendo debug. Y seguirá así hasta el fin de los tiempos.

Comparativa con lo que sí se refresca:

Mecanismo ¿Se refresca al cambiar la fuente?
ConfigMap montado como volumen , en 1-2 minutos
Secret montado como volumen , en 1-2 minutos
Volumen con subPath No
Volumen de un objeto inmutable No
Downward API en volumen (etiquetas)
Variable de entorno (cualquier origen) Nunca

Las tres salidas posibles:

  1. Reiniciar los pods a mano. Funciona y es lo que hicimos al rotar credenciales:
kubectl rollout restart deploy/api-reservas -n rutas-norte-dev

El problema es que hay que acordarse. Si alguien cambia el ConfigMap en una pull request y olvida el reinicio, la plataforma queda en un estado en el que el manifiesto dice una cosa y los pods hacen otra. Ese desfase silencioso es peligroso: se descubre semanas después, cuando un pod se reinicia por otro motivo y de repente cambia de comportamiento sin que nadie haya tocado nada.

  1. Usar fichero montado para lo que deba cambiar en caliente.

  2. Automatizar el reinicio con un hash, que es la solución elegante y el apartado siguiente.

  1. El hash de la configuración en una anotación

La técnica es sencilla y se ha convertido en un patrón estándar: guardar un resumen (hash) del contenido del ConfigMap y del Secret en una anotación del template del pod.

La clave es que Kubernetes dispara un despliegue nuevo cuando cambia cualquier cosa dentro de spec.template, incluidas sus anotaciones. Como el hash depende del contenido de la configuración, cambiar la configuración cambia el hash, cambiar el hash cambia el template, y cambiar el template dispara el despliegue progresivo.

flowchart LR
    A["Cambias el ConfigMap"] --> B["Recalculas el hash<br/>sha256 del contenido"]
    B --> C["Cambia la anotacion<br/>del spec.template"]
    C --> D["El Deployment detecta<br/>que el template cambio"]
    D --> E["RollingUpdate automatico<br/>sin corte de servicio"]

En el manifiesto:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
spec:
  replicas: 4
  selector:
    matchLabels:
      app: api-reservas
      entorno: pro
  template:
    metadata:
      labels:
        app: api-reservas
        app.kubernetes.io/name: api-reservas
        app.kubernetes.io/part-of: rutas-norte
        entorno: pro
      annotations:
        # Estos valores los calcula el pipeline antes de aplicar
        rutasnorte.example/config-hash: "a3f5b81c9d2e4770"
        rutasnorte.example/secret-hash: "7e21c40ab6f39185"
    spec:
      containers:
        - name: api
          image: registry.rutasnorte.example/api-reservas:2.5.0
          envFrom:
            - configMapRef:
                name: api-reservas-config
            - secretRef:
                name: postgres-reservas-credenciales

Cómo se calcula el hash en el pipeline de despliegue:

#!/usr/bin/env bash
set -euo pipefail
NS=rutas-norte-pro

# Hash del contenido del ConfigMap (solo el campo data, no metadatos volatiles)
CONFIG_HASH=$(kubectl get cm api-reservas-config -n "$NS" -o jsonpath='{.data}' \
  | sha256sum | cut -c1-16)

SECRET_HASH=$(kubectl get secret postgres-reservas-credenciales -n "$NS" -o jsonpath='{.data}' \
  | sha256sum | cut -c1-16)

kubectl patch deployment api-reservas -n "$NS" -p "$(cat <<EOF
{"spec":{"template":{"metadata":{"annotations":{
  "rutasnorte.example/config-hash":"${CONFIG_HASH}",
  "rutasnorte.example/secret-hash":"${SECRET_HASH}"
}}}}}
EOF
)"

kubectl rollout status deployment/api-reservas -n "$NS" --timeout=5m
deployment.apps/api-reservas patched
Waiting for deployment "api-reservas" rollout to finish: 2 out of 4 new replicas have been updated...
deployment "api-reservas" successfully rolled out

Es importante hacer el hash solo de .data: si lo hicieras del objeto completo, el resourceVersion cambiaría en cada actualización aunque el contenido fuese idéntico, y tendrías despliegues espurios.

Cuatro ventajas de este patrón:

  1. Cambiar la configuración despliega solo. No hay que acordarse de nada.
  2. Queda historial. Aparece en kubectl rollout history y se puede deshacer con rollout undo, igual que un cambio de código.
  3. No hay desfase. El pod que corre siempre corresponde al ConfigMap vigente.
  4. Es auditable. La anotación dice a qué versión exacta de la configuración corresponde cada revisión.

Vale la pena saber que las herramientas del ecosistema hacen esto por ti:

Herramienta Cómo lo resuelve
Kustomize configMapGenerator añade un sufijo de hash al nombre del ConfigMap; el Deployment lo referencia y cambia solo
Helm La anotación checksum/config con include, que es el patrón canónico de sus plantillas
Reloader (Stakater) Un controlador que vigila ConfigMaps y Secrets y hace rollout restart automáticamente al cambiar
Argo CD Detecta la deriva entre Git y el clúster y reconcilia

Para Rutas Norte, mientras trabajamos con YAML plano, el script de arriba en el pipeline es suficiente. En el módulo 10 lo sustituiremos por Kustomize.

  1. Variable de entorno frente a fichero montado

Ya tienes todos los elementos para decidir. Esta es la tabla de decisión:

Criterio Variable de entorno Fichero montado
Valor corto y simple Excesivo
Fichero de configuración completo Imposible en la práctica
Contenido binario No (binaryData)
Debe refrescarse sin reiniciar No puede
Compatible con imágenes de terceros Casi siempre Solo si admiten *_FILE
Visible en /proc/<pid>/environ (riesgo) No
Puede filtrarse en un volcado de error (riesgo) Poco probable
Se hereda a procesos hijos (a veces indeseable) No
Visible en kubectl describe pod La referencia, no el valor La referencia
Aparece en docker inspect / crictl inspect Sí, con el valor No
Coste de arranque Nulo Un montón que preparar
Se puede componer con $(VAR) No
Límite de tamaño práctico Unos pocos KB 1 MiB

Reglas de decisión para Rutas Norte:

  1. Configuración no sensible y estable durante la vida del pod → variable de entorno. NIVEL_LOG, TIEMPO_ESPERA_MS, REDIS_HOST. Es lo más simple y lo entiende todo el mundo.
  2. Ficheros de configuración → volumen. El nginx.conf de tienda-web, el reglas-tarifas.json de api-reservas.
  3. Credenciales de alto valor → volumen, con *_FILE si la imagen lo admite. La contraseña de postgres-reservas para api-reservas.
  4. Credenciales exigidas por imágenes de terceros → variable con secretKeyRef, sin alternativa. POSTGRES_PASSWORD en la imagen oficial de PostgreSQL.
  5. Configuración que debe cambiarse en caliente → volumen. El MODO_MANTENIMIENTO de nginx.
  6. Identidad del pod → Downward API en variables. Es información inmutable durante la vida del pod, así que la limitación no molesta.

  1. El catálogo de variables de Rutas Norte

Cerramos con el inventario completo, que es también el estado de la plataforma al terminar esta lección. Marco en cada caso el origen: L literal, CM ConfigMap, S Secret, DA Downward API.

tienda-web (nginx sirviendo la SPA)

Variable Origen dev pre pro
NGINX_ENTRYPOINT_QUIET_LOGS L 1 1 1
POD_NOMBRE DA metadata.name ídem ídem

tienda-web casi no usa variables: su configuración es el nginx.conf del ConfigMap montado, porque nginx lee un fichero, no el entorno. Es el ejemplo perfecto de la regla 2.

api-reservas (API REST en Node.js)

Variable Origen dev pre pro
NODE_ENV L development production production
PUERTO L 8080 8080 8080
LOG_LEVEL CM NIVEL_LOG debug info warn
DB_HOST CM postgres-reservas ídem ídem
DB_PORT CM 5432 5432 5432
DB_NOMBRE CM reservas reservas reservas
DB_USUARIO S username (secreto) (secreto) (secreto)
DB_PASSWORD_FILE L /etc/secretos/postgres/password ídem ídem
DB_POOL_MAX CM 5 10 25
REDIS_HOST CM redis-cache ídem ídem
REDIS_TTL_SEGUNDOS CM 30 120 300
MAX_PLAZAS_POR_RESERVA CM 9 9 9
TIEMPO_ESPERA_MS CM 5000 3000 2000
POD_NOMBRE DA metadata.name ídem ídem
NODO_NOMBRE DA spec.nodeName ídem ídem
ENTORNO DA metadata.labels['entorno'] ídem ídem
MEMORIA_LIMITE_MB DA limits.memory / 1Mi ídem ídem

Observa DB_POOL_MAX: crece con el entorno porque en producción hay más réplicas y más tráfico en puentes y vacaciones, pero no puede crecer sin límite, porque max_connections de PostgreSQL es finito y réplicas × DB_POOL_MAX no debe superarlo. Con 4 réplicas y DB_POOL_MAX: 25 son 100 conexiones. Es exactamente el tipo de cálculo que hay que documentar en el YAML con un comentario.

postgres-reservas

Variable Origen Valor
POSTGRES_USER S username (secreto)
POSTGRES_PASSWORD S password (secreto)
POSTGRES_DB S database (secreto)
PGDATA L /var/lib/postgresql/data/pgdata

Aquí no hay elección: la imagen oficial de PostgreSQL lee esas variables. Es la regla 4. El PGDATA en un subdirectorio es una precaución imprescindible cuando llegue el volumen persistente del módulo 5: el punto de montaje suele contener un lost+found que impide inicializar la base de datos.

redis-cache

Variable Origen dev pre pro
REDIS_MAXMEMORY CM 64mb 256mb 1gb
REDIS_MAXMEMORY_POLICY CM allkeys-lru ídem ídem

allkeys-lru es una decisión de negocio: redis-cache es la caché de disponibilidad de plazas y es prescindible, así que preferimos que descarte las claves menos usadas antes que rechazar escrituras.

worker-notificaciones

Variable Origen dev pre pro
LOG_LEVEL CM debug info warn
DB_HOST CM postgres-reservas ídem ídem
DB_PASSWORD_FILE L /etc/secretos/postgres/password ídem ídem
SMTP_HOST CM mailhog smtp-pre.rutasnorte.example smtp.rutasnorte.example
SMTP_PUERTO CM 1025 587 587
SMTP_USUARIO S (secreto) (secreto) (secreto)
SMTP_PASSWORD S (secreto) (secreto) (secreto)
REMITENTE CM [email protected] no-reply@pre... [email protected]
LOTE_MAXIMO CM 10 50 200
INTERVALO_SONDEO_S CM 30 15 5
POD_NOMBRE DA metadata.name ídem ídem

En dev el SMTP apunta a mailhog, un capturador de correo local: en desarrollo no se envían correos reales a clientes. Es una decisión de protección de datos, no de comodidad, y es exactamente el tipo de cosa que la separación de configuración hace posible.

informes-ocupacion (llegará en el módulo 6)

Variable Origen Valor previsto
DB_HOST CM postgres-reservas
FECHA_INFORME L $(date -d yesterday) desde el CronJob
DESTINO_S3 CM s3://informes-rutasnorte/<entorno>/

El Deployment completo de api-reservas

Reunimos todo lo aprendido en un único manifiesto, que es el estado real del componente al cerrar esta lección:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
  labels:
    app: api-reservas
    app.kubernetes.io/name: api-reservas
    app.kubernetes.io/component: backend
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
spec:
  replicas: 4
  selector:
    matchLabels:
      app: api-reservas
      entorno: pro
  template:
    metadata:
      labels:
        app: api-reservas
        app.kubernetes.io/name: api-reservas
        app.kubernetes.io/component: backend
        app.kubernetes.io/part-of: rutas-norte
        entorno: pro
      annotations:
        rutasnorte.example/config-hash: "a3f5b81c9d2e4770"
        rutasnorte.example/secret-hash: "7e21c40ab6f39185"
    spec:
      imagePullSecrets:
        - name: registry-rutasnorte
      containers:
        - name: api
          image: registry.rutasnorte.example/api-reservas:2.5.0
          ports:
            - name: http
              containerPort: 8080

          env:
            # --- Literales: iguales en los tres entornos ---
            - name: NODE_ENV
              value: "production"
            - name: PUERTO
              value: "8080"
            - name: DB_PASSWORD_FILE
              value: "/etc/secretos/postgres/password"

            # --- Del ConfigMap, con renombrado ---
            - name: LOG_LEVEL
              valueFrom:
                configMapKeyRef:
                  name: api-reservas-config
                  key: NIVEL_LOG
            - name: DB_HOST
              valueFrom:
                configMapKeyRef:
                  name: api-reservas-config
                  key: DB_HOST
            - name: REDIS_TTL_SEGUNDOS
              valueFrom:
                configMapKeyRef:
                  name: api-reservas-config
                  key: REDIS_TTL_SEGUNDOS

            # --- Del Secret: solo el usuario; la clave va por fichero ---
            - name: DB_USUARIO
              valueFrom:
                secretKeyRef:
                  name: postgres-reservas-credenciales
                  key: username

            # --- Downward API: identidad para las trazas ---
            - name: POD_NOMBRE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: NODO_NOMBRE
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
            - name: ENTORNO
              valueFrom:
                fieldRef:
                  fieldPath: metadata.labels['entorno']
            - name: MEMORIA_LIMITE_MB
              valueFrom:
                resourceFieldRef:
                  containerName: api
                  resource: limits.memory
                  divisor: 1Mi

            # --- Compuesta: DB_HOST ya esta definida arriba ---
            - name: DB_URL_BASE
              value: "postgresql://$(DB_HOST):5432/reservas"

          volumeMounts:
            - name: credenciales-bd
              mountPath: /etc/secretos/postgres
              readOnly: true

          resources:
            requests:
              cpu: 250m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 512Mi

      volumes:
        - name: credenciales-bd
          secret:
            secretName: postgres-reservas-credenciales
            defaultMode: 0400
            items:
              - key: password
                path: password

Este manifiesto se puede publicar sin filtrar nada. Contiene referencias, no valores. Es exactamente el listón que nos pusimos al empezar el módulo.

Errores Comunes y Consejos

Error Síntoma Solución
Valor numérico sin comillas invalid type ... got "number" value: "5432"
value: no sin comillas Llega false Comillas siempre
ConfigMap con claves no válidas y envFrom Variables que faltan, evento InvalidEnvironmentVariableNames Separa el ConfigMap de ficheros del de variables
ConfigMap o Secret inexistente CreateContainerConfigError Créalo, o optional: true si de verdad es opcional
optional: true en lo esencial Arranque silencioso con configuración incompleta Resérvalo para lo verdaderamente opcional
${VAR} o $VAR en args Llega el texto literal Usa $(VAR), o sh -c
$(VAR) referenciando algo definido después Llega el texto literal, sin aviso Define primero, referencia después
$(VAR) sobre una clave de envFrom No se expande Tráela con valueFrom primero
sh -c sin exec El SIGTERM no llega al proceso real command: ["sh","-c","exec ..."]
Credencial en args Visible en describe pod Nunca. Usa secretKeyRef o un volumen
Esperar que un cambio de ConfigMap llegue solo "He cambiado el ConfigMap y no pasa nada" Las variables no se refrescan. rollout restart o hash
Colisión entre envFrom y la imagen Un valor inesperado kubectl exec -- printenv VAR
resourceFieldRef sin divisor Números en bytes divisor: 1Mi
metadata.labels sin clave en env Error de validación En env hay que poner metadata.labels['clave']

Consejos:

  1. valueFrom por defecto, envFrom solo cuando sean muchas y ya se llamen bien. La trazabilidad del manifiesto vale más que ahorrar líneas.
  2. Añade la Downward API a todo componente desde el primer día. El coste es de seis líneas y el día del incidente vale su peso en oro.
  3. Documenta cada variable con un comentario en el manifiesto: qué hace, qué rango es válido, quién la consume.
  4. Ordena env por bloques —literales, ConfigMap, Secret, Downward API, compuestas— como en el manifiesto final del apartado 13. Se lee mucho mejor.
  5. Ante cualquier duda, kubectl exec -- env. El manifiesto es la intención; el entorno del proceso es la realidad.

Ejercicios

Ejercicio 1: Las cuatro fuentes en un solo pod

Crea un pod de diagnóstico llamado inspector-config en rutas-norte-dev que reciba variables por los cuatro caminos y las imprima:

  1. Un literal COMPONENTE=inspector.
  2. Todas las claves del ConfigMap api-reservas-config con el prefijo CFG_.
  3. Únicamente la clave password del Secret postgres-reservas-credenciales, con el nombre CLAVE_BD.
  4. El nombre del pod, el nodo y la etiqueta entorno por Downward API.
  5. Una variable RESUMEN compuesta con $(VAR) que contenga <componente>@<nodo>:<entorno>.

Aplica el manifiesto, muestra la salida y explica por qué el punto 5 solo funciona si las variables están en el orden correcto.

Ejercicio 2: Demostrar que las variables no se refrescan

  1. Despliega api-reservas con LOG_LEVEL proveniente del ConfigMap.
  2. Comprueba el valor efectivo dentro del pod.
  3. Cambia el ConfigMap a trace y espera tres minutos. Comprueba de nuevo el valor en el pod y el valor del objeto.
  4. Monta además el mismo ConfigMap como volumen en /etc/config y repite el experimento. ¿Qué diferencia observas?
  5. Implementa la técnica del hash: calcula el sha256 del .data del ConfigMap, ponlo en una anotación del template y demuestra que al cambiar el ConfigMap y recalcular el hash se dispara un despliegue.

Ejercicio 3: Depurar un args que no expande

Un compañero ha desplegado worker-notificaciones con este fragmento y se queja de que el trabajador procesa lotes de tamaño ${LOTE_MAXIMO} en lugar de 200:

        - name: worker
          image: busybox:1.36
          env:
            - name: LOTE_MAXIMO
              valueFrom:
                configMapKeyRef:
                  name: worker-config
                  key: LOTE_MAXIMO
          command: ["sh", "-c", "echo Procesando lote de ${LOTE_MAXIMO}; sleep 3600"]
          args: ["--cola=$LOTE_MAXIMO"]
  1. Explica qué está mal en args y qué está bien en command, y por qué son casos distintos.
  2. Corrige el manifiesto usando la sintaxis de Kubernetes.
  3. Explica por qué este command con sh -c es problemático para la terminación ordenada del módulo 2 y corrígelo.
  4. Reescribe el fragmento sin usar shell en absoluto.
  5. Si LOTE_MAXIMO viniera de un envFrom en lugar de un valueFrom, ¿funcionaría la expansión $(LOTE_MAXIMO) en args? Justifícalo.

Soluciones

Solución 1

apiVersion: v1
kind: Pod
metadata:
  name: inspector-config
  namespace: rutas-norte-dev
  labels:
    app: inspector-config
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  restartPolicy: Never
  containers:
    - name: inspector
      image: busybox:1.36
      command: ["sh", "-c", "env | sort; echo '---'; echo \"RESUMEN=$RESUMEN\""]
      envFrom:
        - configMapRef:
            name: api-reservas-config
          prefix: CFG_
      env:
        # El orden importa para el punto 5
        - name: COMPONENTE
          value: "inspector"
        - name: CLAVE_BD
          valueFrom:
            secretKeyRef:
              name: postgres-reservas-credenciales
              key: password
        - name: POD_NOMBRE
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        - name: NODO_NOMBRE
          valueFrom:
            fieldRef:
              fieldPath: spec.nodeName
        - name: ENTORNO
          valueFrom:
            fieldRef:
              fieldPath: metadata.labels['entorno']
        # Compuesta: todas sus referencias estan definidas ARRIBA
        - name: RESUMEN
          value: "$(COMPONENTE)@$(NODO_NOMBRE):$(ENTORNO)"
kubectl apply -f inspector-config.yaml
kubectl logs inspector-config -n rutas-norte-dev
pod/inspector-config created

CFG_DB_HOST=postgres-reservas
CFG_MAX_PLAZAS_POR_RESERVA=9
CFG_NIVEL_LOG=debug
CFG_REDIS_TTL_SEGUNDOS=30
CFG_TIEMPO_ESPERA_MS=5000
CLAVE_BD=d3v-C4mbi4m3-2026
COMPONENTE=inspector
ENTORNO=dev
HOSTNAME=inspector-config
NODO_NOMBRE=rutas-norte
POD_NOMBRE=inspector-config
RESUMEN=inspector@rutas-norte:dev
---
RESUMEN=inspector@rutas-norte:dev

El punto 5 funciona porque COMPONENTE, NODO_NOMBRE y ENTORNO están definidas antes que RESUMEN en la lista env. Kubernetes resuelve la lista en orden y solo puede sustituir lo que ya ha procesado. Si RESUMEN estuviera en primera posición, el valor sería literalmente $(COMPONENTE)@$(NODO_NOMBRE):$(ENTORNO), sin ningún error ni aviso.

Y observa el efecto colateral que sirve de advertencia: CLAVE_BD con la contraseña aparece en el log del pod, porque el comando hace env. Cualquiera con kubectl logs la ve, y en producción ese log iría al sistema centralizado del módulo 7. Nunca vuelques el entorno en un contenedor que consuma secretos.

Solución 2

kubectl exec -n rutas-norte-dev deploy/api-reservas -- printenv LOG_LEVEL
kubectl patch cm api-reservas-config -n rutas-norte-dev --type merge -p '{"data":{"NIVEL_LOG":"trace"}}'
sleep 180
kubectl get cm api-reservas-config -n rutas-norte-dev -o jsonpath='{.data.NIVEL_LOG}'; echo
kubectl exec -n rutas-norte-dev deploy/api-reservas -- printenv LOG_LEVEL
debug
configmap/api-reservas-config patched
trace
debug

El objeto vale trace, el proceso sigue en debug. Con el volumen añadido:

          volumeMounts:
            - name: config
              mountPath: /etc/config
              readOnly: true
      volumes:
        - name: config
          configMap:
            name: api-reservas-config
kubectl exec -n rutas-norte-dev deploy/api-reservas -- cat /etc/config/NIVEL_LOG; echo
kubectl exec -n rutas-norte-dev deploy/api-reservas -- printenv LOG_LEVEL
trace
debug

El mismo ConfigMap, el mismo pod, el mismo instante, dos valores distintos. El fichero se refrescó y la variable no. Esa es la diferencia en una sola pantalla.

El hash:

NS=rutas-norte-dev
H=$(kubectl get cm api-reservas-config -n $NS -o jsonpath='{.data}' | sha256sum | cut -c1-16)
echo "hash: $H"
kubectl patch deployment api-reservas -n $NS \
  -p "{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"rutasnorte.example/config-hash\":\"$H\"}}}}}"
kubectl rollout status deployment/api-reservas -n $NS
kubectl exec -n $NS deploy/api-reservas -- printenv LOG_LEVEL
hash: c81f4a9e2b7d0356
deployment.apps/api-reservas patched
Waiting for deployment "api-reservas" rollout to finish: 1 out of 3 new replicas have been updated...
deployment "api-reservas" successfully rolled out
trace

Ahora sí. Y con historial:

kubectl rollout history deployment/api-reservas -n rutas-norte-dev
REVISION  CHANGE-CAUSE
1         despliegue inicial
2         <none>
3         cambio de configuracion: NIVEL_LOG=trace

Solución 3

  1. args está mal, command está bien. En args se ha escrito $LOTE_MAXIMO con la sintaxis del shell; Kubernetes solo entiende $(VAR), así que deja el texto tal cual y el programa recibe --cola=$LOTE_MAXIMO. En command, en cambio, la expansión ${LOTE_MAXIMO} sí funciona, pero no la hace Kubernetes: la hace el sh -c que se está ejecutando. Son dos expansiones distintas hechas por dos actores distintos, y confundirlas es el origen del error.

Además hay un segundo problema oculto: cuando se define command con sh -c "...", el contenido de args se añade como argumentos posicionales del shell ($0, $1...), no del programa. Ese --cola=... no llega a ningún sitio útil.

  1. Corregido con sintaxis de Kubernetes:
        - name: worker
          image: busybox:1.36
          env:
            - name: LOTE_MAXIMO
              valueFrom:
                configMapKeyRef:
                  name: worker-config
                  key: LOTE_MAXIMO
          command: ["/app/worker"]
          args:
            - "--lote=$(LOTE_MAXIMO)"
            - "--cola=notificaciones"
# El proceso recibe:
/app/worker --lote=200 --cola=notificaciones
  1. El problema del sh -c sin exec: el shell es el PID 1 del contenedor y /app/worker es su hijo. Cuando Kubernetes borra el pod envía SIGTERM al PID 1, es decir, al shell. La mayoría de shells no reenvían señales a sus hijos, así que el trabajador no se entera y sigue procesando correos hasta que expira el terminationGracePeriodSeconds (30 s por defecto) y llega el SIGKILL, que lo mata en seco. Si estaba enviando un correo de confirmación, se pierde.
          command: ["sh", "-c", "exec /app/worker --lote=$LOTE_MAXIMO"]

Con exec, el shell se reemplaza a sí mismo por el trabajador, que pasa a ser el PID 1 y recibe el SIGTERM directamente.

  1. Sin shell en absoluto, que es lo correcto:
        - name: worker
          image: registry.rutasnorte.example/worker-notificaciones:1.8.0
          env:
            - name: LOTE_MAXIMO
              valueFrom:
                configMapKeyRef:
                  name: worker-config
                  key: LOTE_MAXIMO
          command: ["/app/worker"]
          args: ["--lote=$(LOTE_MAXIMO)", "--cola=notificaciones"]

Sin shell no hay problema de señales, no hay riesgo de inyección de comandos, y la expansión la hace Kubernetes antes de arrancar nada. Lo más limpio de todo sería que /app/worker leyera directamente LOTE_MAXIMO de su entorno y prescindir de args.

  1. No, no funcionaría. La expansión $(VAR) solo alcanza a las variables definidas en la lista env anterior a su uso. Las que vienen por envFrom se resuelven como un bloque separado y no participan en la expansión ni de env ni de args. --cola=$(LOTE_MAXIMO) llegaría literal. La solución es traerla explícitamente con valueFrom antes de usarla, aunque también venga en el envFrom.

Conclusión

Con esta lección cierras el bloque de configuración del módulo. Entiendes el mecanismo real —la lista CLAVE=valor que el kubelet entrega a execve() y que queda congelada en el proceso— y de ahí se deduce todo lo demás. Dominas las cuatro fuentes: el literal con sus comillas obligatorias, envFrom con prefix y optional y su trampa de las claves no válidas que se ignoran en silencio, valueFrom con configMapKeyRef y secretKeyRef como opción recomendada por explícita y por permitir renombrar, y la Downward API en sus dos formas, fieldRef para la identidad del pod y resourceFieldRef para que el proceso se dimensione a sí mismo y no acabe en OOMKilled.

Sabes que api-reservas registra ahora en cada traza qué pod y qué nodo la atendieron, lo que convierte un incidente inabordable en un diagnóstico de dos minutos. Conoces las cinco reglas de la expansión $(VAR) —paréntesis, orden, nada de envFrom, nada de la imagen, $$ para escapar— y por qué en command/args no hay shell salvo que lo pidas, con las consecuencias que eso arrastra: ${VAR} no se expande, y si usas sh -c sin exec rompes la terminación ordenada del módulo 2. Tienes clara la precedencia (env gana a envFrom, y dentro de cada bloque gana el último) y la forma de comprobar la verdad, que siempre es kubectl exec -- printenv.

Y sobre todo tienes interiorizada la limitación que gobierna las decisiones de diseño: las variables de entorno no se refrescan nunca, con la solución del hash de la configuración en una anotación del template que convierte un cambio de ConfigMap en un despliegue progresivo con historial y rollback. Con la tabla de decisión "variable frente a fichero" ya no dudas dónde poner cada cosa, y el catálogo completo de los seis componentes de Rutas Norte por entorno es el mapa de la plataforma tal como está ahora: una sola imagen por componente, cero credenciales en Git y toda la variabilidad concentrada en k8s/entornos/.

Quedan dos deudas del módulo 2, y las dos son de recursos. Ningún namespace tiene cuota, así que un despliegue equivocado en rutas-norte-dev —una réplica de más, un bucle de reinicio, un pod que pide 16 GiB— puede consumir la capacidad del clúster y dejar sin sitio a rutas-norte-pro. Y los requests y limits que llevamos escribiendo desde el módulo 2 están puestos a ojo, sin ningún criterio. La lección siguiente, Cuotas y Límites de Recursos, lo aborda: verás quién usa las requests (el planificador) y quién los limits (el kubelet a través de cgroups), la diferencia crucial entre la CPU, que se estrangula, y la memoria, que mata el proceso con OOMKilled, qué pasa cuando la suma de límites supera la capacidad del clúster, y pondrás una ResourceQuota a cada uno de los tres entornos de Rutas Norte para que dev no pueda volver a asustar a pro.

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