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
- Qué es realmente una variable de entorno en un contenedor
envcon valor literalenvFrom: volcar un ConfigMap o un Secret enterovalueFrom: tomar una clave concreta- La Downward API:
fieldRef - La Downward API:
resourceFieldRef - Expansión de variables con
$(VAR) - Interacción con
commandyargs - Precedencia y colisiones
- La limitación fundamental: no se refrescan
- El hash de la configuración en una anotación
- Variable de entorno frente a fichero montado
- El catálogo de variables de Rutas Norte
- 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:
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=5000Hay tres orígenes mezclados en esa lista:
- Las que vienen de la imagen (
PATH,HOME): las define elDockerfileo la imagen base. - Las que hemos declarado nosotros (
DB_HOST,NIVEL_LOG). - 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.
env con valor literal
env con valor literalLa 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 booleanosLas 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) |
envFrom: volcar un ConfigMap o un Secret entero
envFrom: volcar un ConfigMap o un Secret enteroCuando 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-credencialesCon 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:
Events:
Type Reason Age From Message
---- ------ ---- ------- -------
Warning InvalidEnvironmentVariableNames 12s kubelet
Keys [nginx.conf, api-url] from the EnvFrom list in the container "api" are invalidEste 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_passwordResultado dentro del contenedor:
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=rutasnorteFí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 foundCon optional: true el pod arranca sin esas variables:
Ú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.
valueFrom: tomar una clave concreta
valueFrom: tomar una clave concretaCuando 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: trueEsta es la forma recomendada por defecto frente a envFrom, y por tres razones sólidas:
- Es explícita. Al leer el Deployment sabes exactamente qué variables recibe el contenedor. Con
envFromhay que ir a mirar el ConfigMap. - Desacopla nombres. La aplicación espera
LOG_LEVELy nuestro ConfigMap se llamaNIVEL_LOGporque el equipo trabaja en español.valueFromlo traduce sin renombrar nada. - Mínimo privilegio. Al pod solo llegan las claves que necesita, igual que hacíamos con
itemsen 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.
- La Downward API:
fieldRef
fieldRefHasta 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 concretaCampos 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].imageni un campo arbitrario del pod. Para eso hay que hablar con la API, que es lo que veremos en ServiceAccounts. metadata.labelsymetadata.annotationssin especificar clave solo funcionan en un volumendownwardAPI, no enenv. Enenvhay que indicar la clave concreta entre corchetes.
Veamos el resultado y su valor real:
POD_NOMBRE=api-reservas-7f4b8c9d6-qh4nc
POD_NAMESPACE=rutas-norte-pro
POD_IP=10.244.2.37
NODO_NOMBRE=rutas-norte-m02
ENTORNO=proY 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=5001Con 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.annotationsapp="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.
- La Downward API:
resourceFieldRef
resourceFieldRefLa 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: 1MiRecursos 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.jsPara la JVM existe el equivalente automático (-XX:MaxRAMPercentage=75), que es la opción preferible cuando el lenguaje la ofrece.
- Expansión de variables con
$(VAR)
$(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)"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"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 $$(.
- Interacción con
command y args
command y argsAquí 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:
Y esto tampoco:
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:
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.
- 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 anteriorGana debug, el de env. Y si eliminamos ese bloque env, ganaría warn, el del último configMapRef.
Consejos para no sufrir con esto:
- Evita las colisiones en lugar de gestionarlas. Un ConfigMap por propósito y sin claves repetidas.
- Usa
prefixcuando volques varias fuentes conenvFrom. - Ante la duda, comprueba el valor efectivo, que es la única verdad:
- Cuidado con las variables de la imagen. Una imagen base puede definir
NODE_ENV=productionen suDockerfile, y si no la sobrescribes explícitamente estará ahí sin que aparezca en ningún manifiesto tuyo.kubectl exec ... -- envla revela.
- 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_LOGEl 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 | Sí, en 1-2 minutos |
| Secret montado como volumen | Sí, en 1-2 minutos |
Volumen con subPath |
No |
| Volumen de un objeto inmutable | No |
| Downward API en volumen (etiquetas) | Sí |
| Variable de entorno (cualquier origen) | Nunca |
Las tres salidas posibles:
- Reiniciar los pods a mano. Funciona y es lo que hicimos al rotar credenciales:
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.
-
Usar fichero montado para lo que deba cambiar en caliente.
-
Automatizar el reinicio con un hash, que es la solución elegante y el apartado siguiente.
- 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-credencialesCó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=5mdeployment.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 outEs 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:
- Cambiar la configuración despliega solo. No hay que acordarse de nada.
- Queda historial. Aparece en
kubectl rollout historyy se puede deshacer conrollout undo, igual que un cambio de código. - No hay desfase. El pod que corre siempre corresponde al ConfigMap vigente.
- 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.
- 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 | Sí | Excesivo |
| Fichero de configuración completo | Imposible en la práctica | Sí |
| Contenido binario | No | Sí (binaryData) |
| Debe refrescarse sin reiniciar | No puede | Sí |
| Compatible con imágenes de terceros | Casi siempre | Solo si admiten *_FILE |
Visible en /proc/<pid>/environ |
Sí (riesgo) | No |
| Puede filtrarse en un volcado de error | Sí (riesgo) | Poco probable |
| Se hereda a procesos hijos | Sí (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) |
Sí | No |
| Límite de tamaño práctico | Unos pocos KB | 1 MiB |
Reglas de decisión para Rutas Norte:
- 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. - Ficheros de configuración → volumen. El
nginx.confdetienda-web, elreglas-tarifas.jsondeapi-reservas. - Credenciales de alto valor → volumen, con
*_FILEsi la imagen lo admite. La contraseña depostgres-reservasparaapi-reservas. - Credenciales exigidas por imágenes de terceros → variable con
secretKeyRef, sin alternativa.POSTGRES_PASSWORDen la imagen oficial de PostgreSQL. - Configuración que debe cambiarse en caliente → volumen. El
MODO_MANTENIMIENTOde nginx. - Identidad del pod → Downward API en variables. Es información inmutable durante la vida del pod, así que la limitación no molesta.
- 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: passwordEste 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:
valueFrompor defecto,envFromsolo cuando sean muchas y ya se llamen bien. La trazabilidad del manifiesto vale más que ahorrar líneas.- 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.
- Documenta cada variable con un comentario en el manifiesto: qué hace, qué rango es válido, quién la consume.
- Ordena
envpor bloques —literales, ConfigMap, Secret, Downward API, compuestas— como en el manifiesto final del apartado 13. Se lee mucho mejor. - 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:
- Un literal
COMPONENTE=inspector. - Todas las claves del ConfigMap
api-reservas-configcon el prefijoCFG_. - Únicamente la clave
passworddel Secretpostgres-reservas-credenciales, con el nombreCLAVE_BD. - El nombre del pod, el nodo y la etiqueta
entornopor Downward API. - Una variable
RESUMENcompuesta 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
- Despliega
api-reservasconLOG_LEVELproveniente del ConfigMap. - Comprueba el valor efectivo dentro del pod.
- Cambia el ConfigMap a
tracey espera tres minutos. Comprueba de nuevo el valor en el pod y el valor del objeto. - Monta además el mismo ConfigMap como volumen en
/etc/configy repite el experimento. ¿Qué diferencia observas? - Implementa la técnica del hash: calcula el
sha256del.datadel ConfigMap, ponlo en una anotación deltemplatey 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"]- Explica qué está mal en
argsy qué está bien encommand, y por qué son casos distintos. - Corrige el manifiesto usando la sintaxis de Kubernetes.
- Explica por qué este
commandconsh -ces problemático para la terminación ordenada del módulo 2 y corrígelo. - Reescribe el fragmento sin usar shell en absoluto.
- Si
LOTE_MAXIMOviniera de unenvFromen lugar de unvalueFrom, ¿funcionaría la expansión$(LOTE_MAXIMO)enargs? 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)"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:devEl 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_LEVELEl 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-configkubectl 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_LEVELEl 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_LEVELhash: 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
traceAhora sí. Y con historial:
Solución 3
argsestá mal,commandestá bien. Enargsse ha escrito$LOTE_MAXIMOcon la sintaxis del shell; Kubernetes solo entiende$(VAR), así que deja el texto tal cual y el programa recibe--cola=$LOTE_MAXIMO. Encommand, en cambio, la expansión${LOTE_MAXIMO}sí funciona, pero no la hace Kubernetes: la hace elsh -cque 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.
- 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 problema del
sh -csinexec: el shell es el PID 1 del contenedor y/app/workeres su hijo. Cuando Kubernetes borra el pod envíaSIGTERMal 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 elterminationGracePeriodSeconds(30 s por defecto) y llega elSIGKILL, que lo mata en seco. Si estaba enviando un correo de confirmación, se pierde.
Con exec, el shell se reemplaza a sí mismo por el trabajador, que pasa a ser el PID 1 y recibe el SIGTERM directamente.
- 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.
- No, no funcionaría. La expansión
$(VAR)solo alcanza a las variables definidas en la listaenvanterior a su uso. Las que vienen porenvFromse resuelven como un bloque separado y no participan en la expansión ni deenvni deargs.--cola=$(LOTE_MAXIMO)llegaría literal. La solución es traerla explícitamente convalueFromantes de usarla, aunque también venga en elenvFrom.
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
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
