Módulo 3: Configuration Secrets Health And Autoscaling
4. Manos a la obra: `ConfigMap` y `Secret` para `andes-cargo-status-api`
Descripción
Esta es la lección donde andes-cargo-status-api recibe, por primera vez en esta guía, la configuración que le faltaba desde el Módulo 2: un ConfigMap con la dirección donde viviría el endpoint de LocalStack, el Secret con las credenciales dummy, ambos montados en el Deployment vía envFrom, y verificados sin reconstruir la imagen ni una sola vez. También vas a confirmar algo honesto: el error de /shipments/<id> no desaparece — cambia de forma, porque ningún módulo de esta guía instala LocalStack dentro del clúster (la capa de datos queda fuera de su alcance $0). Todo lo que sigue corrió de verdad contra andes-cargo-cluster.
Conexión con el módulo
Esta lección junta las dos piezas conceptuales de las lecciones 2 y 3 en el Deployment real que dejó el Módulo 2. Es la primera vez que ves, con evidencia real, la promesa central de un ConfigMap/Secret: cambiar la configuración de un servicio sin tocar su imagen.
Antes de empezar: confirma el estado del Módulo 2
kubectl get all -n andes-cargo
Qué esperar (si tu laboratorio sigue en el mismo estado que dejó el Módulo 2; nombres de Pod, IPs y AGE son tus valores variables):
NAME READY STATUS RESTARTS AGE
pod/andes-cargo-status-api-56856576d4-2slnk 1/1 Running 0 19m
pod/andes-cargo-status-api-56856576d4-7ztk9 1/1 Running 0 22m
pod/andes-cargo-status-api-56856576d4-vjc9k 1/1 Running 0 22m
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/status-api-service ClusterIP 10.96.78.1 <none> 80/TCP 21m
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/andes-cargo-status-api 3/3 3 3 22m
NAME DESIRED CURRENT READY AGE
replicaset.apps/andes-cargo-status-api-56856576d4 3 3 3 22m
Si tu clúster no muestra esto, vuelve al Módulo 2, lección 8, antes de seguir.
Paso 1 — El ConfigMap real: andes-cargo-status-api-config
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: andes-cargo-status-api-config
namespace: andes-cargo
data:
DYNAMODB_ENDPOINT_URL: "http://localstack.localstack.svc.cluster.local:4566"
AWS_REGION: "us-east-1"
SHIPMENTS_TABLE_NAME: "Shipments"
Tres claves, cada una correspondiente a una de las tres variables no sensibles que app.py lee (Módulo 1, lección 3, ya adelantó el contrato completo). Fíjate en el valor de DYNAMODB_ENDPOINT_URL: no es solo el nombre DNS (localstack.localstack.svc.cluster.local:4566), es la URL completa con esquema (http://...) — porque así es como boto3 espera el parámetro endpoint_url en app.py (línea 58, Módulo 6 de aws-serverless-and-containers-guide). El nombre DNS en sí sigue el mismo patrón <service>.<namespace>.svc.cluster.local que ya conoces del Módulo 2, lección 6 — apuntando a un Service localstack en un namespace localstack que no existe, y no va a existir en ningún módulo de esta guía: instalar LocalStack (o cablear una cuenta AWS real) queda fuera del alcance $0 de este laboratorio de Kubernetes. Este ConfigMap deja escrita la dirección correcta para el día en que alguien sí cableara esa capa de datos — no una promesa de que este laboratorio la vaya a instalar.
kubectl apply -f configmap.yaml
Qué esperar:
configmap/andes-cargo-status-api-config created
Paso 2 — El Secret real: andes-cargo-status-api-secrets
# secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: andes-cargo-status-api-secrets
namespace: andes-cargo
type: Opaque
stringData:
AWS_ACCESS_KEY_ID: "test"
AWS_SECRET_ACCESS_KEY: "test"
Fíjate en stringData, no data: es un atajo que Kubernetes ofrece para declarar un Secret en YAML con valores en texto plano legible —más cómodo de escribir y de revisar en una revisión de código que calcular base64 a mano—, que el propio kube-apiserver codifica automáticamente al guardar el objeto. El resultado final, una vez aplicado, es idéntico a si hubieras calculado el base64 tú mismo con data.
kubectl apply -f secret.yaml
Qué esperar:
secret/andes-cargo-status-api-secrets created
Confirma que Kubernetes hizo la conversión automática:
kubectl get secret andes-cargo-status-api-secrets -n andes-cargo -o yaml
Qué esperar (literal — creationTimestamp/resourceVersion/uid son tus valores variables; data es literal, porque test en base64 siempre produce el mismo resultado, como ya confirmaste en la lección 3):
apiVersion: v1
data:
AWS_ACCESS_KEY_ID: dGVzdA==
AWS_SECRET_ACCESS_KEY: dGVzdA==
kind: Secret
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"v1","kind":"Secret","metadata":{"annotations":{},"name":"andes-cargo-status-api-secrets","namespace":"andes-cargo"},"stringData":{"AWS_ACCESS_KEY_ID":"test","AWS_SECRET_ACCESS_KEY":"test"},"type":"Opaque"}
creationTimestamp: "2026-08-14T19:55:40Z"
name: andes-cargo-status-api-secrets
namespace: andes-cargo
resourceVersion: "4069"
uid: d5ca03dc-c99b-44ca-9981-6b6f91a97617
type: Opaque
Escribiste stringData: AWS_ACCESS_KEY_ID: "test", y Kubernetes guardó data: AWS_ACCESS_KEY_ID: dGVzdA== — la misma conversión de la lección 3, hecha automáticamente por el kube-apiserver en el momento de aplicar el manifiesto.
Paso 3 — Monta ambos en el Deployment, vía envFrom
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: andes-cargo-status-api
namespace: andes-cargo
labels:
app: andes-cargo-status-api
spec:
replicas: 3
selector:
matchLabels:
app: andes-cargo-status-api
template:
metadata:
labels:
app: andes-cargo-status-api
spec:
containers:
- name: andes-cargo-status-api
image: andes-cargo-status-api:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: andes-cargo-status-api-config
- secretRef:
name: andes-cargo-status-api-secrets
El único campo nuevo frente al deployment.yaml del Módulo 2 es envFrom, con dos referencias — una a cada objeto que acabas de crear. Cuando el kubelet arranque un contenedor con este Deployment, va a leer todas las claves de andes-cargo-status-api-config y de andes-cargo-status-api-secrets, e inyectarlas como variables de entorno, sin que tengas que listar cada una por separado con env.
kubectl apply -f deployment.yaml
Qué esperar:
deployment.apps/andes-cargo-status-api configured
Como template.spec cambió, Kubernetes dispara un RollingUpdate de verdad —el mismo mecanismo que vas a estudiar a fondo en el Módulo 5 de esta guía (estrategias de despliegue)—: crea Pods nuevos con la plantilla actualizada, y solo después retira los Pods viejos.
kubectl rollout status deployment/andes-cargo-status-api -n andes-cargo --timeout=60s
Qué esperar (literal, ejecutado):
Waiting for deployment "andes-cargo-status-api" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "andes-cargo-status-api" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "andes-cargo-status-api" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "andes-cargo-status-api" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "andes-cargo-status-api" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "andes-cargo-status-api" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "andes-cargo-status-api" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "andes-cargo-status-api" rollout to finish: 1 old replicas are pending termination...
deployment "andes-cargo-status-api" successfully rolled out
Fíjate en algo importante que responde el "Errores comunes" de la lección 2: los Pods viejos (sin envFrom) nunca actualizaron sus variables de entorno en caliente — Kubernetes los reemplazó por Pods nuevos, uno a la vez, cada uno arrancando con la configuración correcta desde el primer segundo de vida. Ese es el mecanismo real detrás de "un cambio de ConfigMap/Secret requiere recrear los Pods".
Paso 4 — Verifica: las variables de entorno, sin ningún rebuild de imagen
La imagen andes-cargo-status-api:latest es exactamente la misma que cargó el Módulo 1 — no la reconstruiste, no volviste a correr docker build. Confirma que, aun así, las variables de entorno están ahí:
kubectl get pods -n andes-cargo -l app=andes-cargo-status-api -o wide
Qué esperar (nombres de Pod, IPs y AGE son tus valores variables — el prefijo del Deployment y el patrón de dos nodos son literales):
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
andes-cargo-status-api-585bd9599d-27btt 1/1 Running 0 21s 10.244.1.5 andes-cargo-cluster-worker2 <none> <none>
andes-cargo-status-api-585bd9599d-d6bsj 1/1 Running 0 20s 10.244.1.6 andes-cargo-cluster-worker2 <none> <none>
andes-cargo-status-api-585bd9599d-x4lk9 1/1 Running 0 20s 10.244.2.7 andes-cargo-cluster-worker <none> <none>
El sufijo hash del
ReplicaSetcambió (585bd9599den vez de56856576d4del Módulo 2) — exactamente lo esperado: al cambiartemplate.spec(agregandoenvFrom), Kubernetes calculó un hash nuevo, y con él, unReplicaSetnuevo. Usa el tuyo propio en los siguientes comandos.
Usa kubectl exec para confirmar, desde dentro de un Pod real, que las cinco variables llegaron correctas:
kubectl exec andes-cargo-status-api-585bd9599d-27btt -n andes-cargo -- printenv | grep -E "DYNAMODB|AWS_|SHIPMENTS" | sort
Qué esperar (literal — sustituye el nombre de Pod por el tuyo):
AWS_ACCESS_KEY_ID=test
AWS_REGION=us-east-1
AWS_SECRET_ACCESS_KEY=test
DYNAMODB_ENDPOINT_URL=http://localstack.localstack.svc.cluster.local:4566
SHIPMENTS_TABLE_NAME=Shipments
Cinco variables, ninguna declarada a mano en el Deployment, ninguna horneada en la imagen — las tres del ConfigMap y las dos del Secret, inyectadas por Kubernetes al arrancar el contenedor. Esta es la prueba directa de la promesa de las lecciones 2 y 3: cambiar configuración sin tocar el Dockerfile.
Paso 5 — El límite honesto de este punto de la guía, otra vez: /shipments/<id>
Con la configuración montada, prueba de nuevo el endpoint que el Módulo 2 dejó fallando:
kubectl port-forward svc/status-api-service -n andes-cargo 8080:80
En otra terminal:
curl -i -s http://localhost:8080/health
Qué esperar (sin cambios frente al Módulo 2 — /health nunca dependió de ninguna variable de entorno):
HTTP/1.1 200 OK
Server: Werkzeug/3.1.8 Python/3.13.15
Date: Fri, 14 Aug 2026 19:56:25 GMT
Content-Type: application/json
Content-Length: 51
Connection: close
{"service":"andes-cargo-status-api","status":"ok"}
Ahora el endpoint que sí depende de la configuración nueva:
curl -i -s http://localhost:8080/shipments/4471
Qué esperar (literal, ejecutado — sigue sin ser 200, y sigue siendo el comportamiento correcto en este punto de la guía):
HTTP/1.1 500 INTERNAL SERVER ERROR
Server: Werkzeug/3.1.8 Python/3.13.15
Date: Fri, 14 Aug 2026 19:56:50 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 265
Connection: close
<!doctype html>
<html lang=en>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
Sigue en 500 — pero fíjate en algo importante: la solicitud tardó unos 25 segundos en responder esta vez (19:56:25 a 19:56:50), no fue instantánea como el NoCredentialsError del Módulo 2. Ese detalle ya adelanta que la causa cambió. Revisa los logs para confirmarlo:
kubectl logs andes-cargo-status-api-585bd9599d-27btt -n andes-cargo --tail=40
Qué esperar (literal — el nombre exacto del Pod es tu valor variable, el traceback es idéntico si tu Pod atendió la solicitud):
urllib3.exceptions.NameResolutionError: AWSHTTPConnection(host='localstack.localstack.svc.cluster.local', port=4566): Failed to resolve 'localstack.localstack.svc.cluster.local' ([Errno -2] Name or service not known)
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/usr/local/lib/python3.13/site-packages/flask/app.py", line 1511, in wsgi_app
response = self.full_dispatch_request()
File "/app/app.py", line 27, in get_shipment
response = dynamodb.get_item(
TableName=TABLE_NAME,
Key={"shipmentId": {"S": shipment_id}},
)
File "/usr/local/lib/python3.13/site-packages/botocore/client.py", line 569, in _api_call
return self._make_api_call(operation_name, kwargs)
File "/usr/local/lib/python3.13/site-packages/botocore/httpsession.py", line 493, in send
raise EndpointConnectionError(endpoint_url=request.url, error=e)
botocore.exceptions.EndpointConnectionError: Could not connect to the endpoint URL: "http://localstack.localstack.svc.cluster.local/"
127.0.0.1 - - [14/Aug/2026 19:56:50] "GET /shipments/4471 HTTP/1.1" 500 -
La causa cambió, tal como esta guía predijo en la lección 1: ya no es NoCredentialsError (el error del Módulo 2, que ocurría antes de intentar cualquier conexión de red, al firmar la solicitud). Ahora boto3 sí tiene credenciales —test/test, montadas por el Secret— y sí intenta conectarse a DYNAMODB_ENDPOINT_URL, pero CoreDNS (el servidor de nombres interno del clúster, Módulo 1, lección 5) no puede resolver localstack.localstack.svc.cluster.local porque no existe ningún namespace localstack ni ningún Service con ese nombre, y ningún módulo de esta guía lo va a crear — nada corre ahí. NameResolutionError primero, envuelto en EndpointConnectionError después de que botocore agota sus reintentos automáticos (la razón de los ~25 segundos de espera antes del 500).
QUÉ CAMBIÓ ENTRE EL MÓDULO 2 Y ESTA LECCIÓN (honestidad del M3)
Módulo 2, lección 7 Sin ConfigMap/Secret NoCredentialsError
(falla al FIRMAR, instantáneo)
Módulo 3, lección 4 Con ConfigMap/Secret NameResolutionError /
(esta lección) EndpointConnectionError
(falla al CONECTAR, ~25s de reintentos)
Lo que falta, y por qué se queda faltando:
- LocalStack corriendo dentro del propio clúster ──▶ fuera del alcance $0 de esta guía
- `/health` (sin dependencias externas) ──▶ ya responde 200, y sigue siendo la señal que usamos
Detén el port-forward con Ctrl+C antes de seguir.
Errores comunes
Esperar que /shipments/<id> funcione ahora que hay ConfigMap/Secret, y pasar tiempo "depurando" un problema que no es tal (de expectativa, ya anticipado en la lección 1). Qué pasa: alguien ve el 500 de esta lección y empieza a revisar configmap.yaml/secret.yaml buscando un error de tipeo, sin darse cuenta de que el comportamiento es exactamente el esperado. Cómo detectarlo: si comparas el valor de DYNAMODB_ENDPOINT_URL letra por letra más de una vez, buscando un error que no existe. Cómo corregirlo: la sección "El límite honesto" de esta lección explica la causa exacta —no hay ningún Service llamado localstack en el clúster, y ningún módulo de esta guía lo va a crear— y no es un error tuyo: la capa de datos queda fuera del alcance $0 de este laboratorio por diseño.
No notar el cambio de tiempo de respuesta (~25 segundos) y no investigar la causa (de observación). Qué pasa: alguien ve el mismo código 500 que en el Módulo 2 y asume que es exactamente el mismo error, sin fijarse en cuánto tardó la respuesta esta vez. Por qué pasa: ambos casos terminan en 500, y a simple vista un curl que tarda 25 segundos se ve igual que uno instantáneo si no cronometras. Cómo detectarlo: compara el timestamp de la solicitud (Date en la cabecera de respuesta) contra cuándo la lanzaste. Cómo corregirlo: un 500 instantáneo casi siempre indica un fallo que ocurre antes de cualquier intento de red (como NoCredentialsError); un 500 que tarda varios segundos casi siempre indica reintentos de red agotándose —la pista que esta lección usó para diagnosticar el cambio de causa sin adivinar, solo leyendo kubectl logs.
Olvidar que el ReplicaSet cambió de hash, y buscar los Pods viejos que ya no existen (de flujo, el mismo patrón del Módulo 2). Qué pasa: alguien copia un nombre de Pod de un comando anterior de esta misma lección (o de una sesión anterior) y kubectl exec/kubectl logs fallan con NotFound. Cómo detectarlo: el mensaje de error menciona explícitamente que el Pod no existe. Cómo corregirlo: cada vez que cambies template.spec de un Deployment —como esta lección hizo al agregar envFrom—, Kubernetes crea un ReplicaSet nuevo con Pods de nombres nuevos. Corre kubectl get pods -n andes-cargo -l app=andes-cargo-status-api para confirmar los nombres actuales antes de cualquier comando que dependa de un nombre específico.
Ejercicios
Ejercicio 1 — Reconstruye el flujo completo de memoria. Sin volver a la lección, enumera los cinco pasos, en orden, desde crear el ConfigMap hasta confirmar el nuevo error de /shipments/<id>.
Ver solución
- Crear
configmap.yamlconDYNAMODB_ENDPOINT_URL/AWS_REGION/SHIPMENTS_TABLE_NAME, y aplicarlo. - Crear
secret.yamlconstringDataparaAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, aplicarlo, y confirmar la conversión automática adataen base64. - Agregar
envFrom(con ambas referencias) adeployment.yaml, aplicarlo, y esperar elRollingUpdateconkubectl rollout status. - Verificar con
kubectl exec ... -- printenvque las cinco variables llegaron a un Pod real, sin ningún rebuild de imagen. - Confirmar con
curl/kubectl logsque/healthsigue en200y que/shipments/<id>cambió deNoCredentialsErroraNameResolutionError/EndpointConnectionError.
Ejercicio 2 — Explica por qué el ReplicaSet cambió de hash. Un colega pregunta por qué, después de solo agregar envFrom a deployment.yaml, los nombres de todos los Pods cambiaron por completo. Explícaselo en dos o tres frases.
Ver solución
Una respuesta razonable: "El hash del ReplicaSet se calcula a partir del contenido completo de template.spec —incluido envFrom, que es parte de esa plantilla—. Al cambiar ese campo, Kubernetes calculó un hash nuevo, creó un ReplicaSet nuevo con ese hash, y migró los Pods del ReplicaSet viejo al nuevo con un RollingUpdate — el mismo mecanismo que ya viste en el Módulo 2 cuando escalaste el número de réplicas, solo que ahí el hash no cambió porque replicas no forma parte de template.spec."
Ejercicio 3 — Predice qué pasaría si solo agregaras el Secret, sin el ConfigMap. Si esta lección hubiera montado únicamente andes-cargo-status-api-secrets (con AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY), pero no andes-cargo-status-api-config, ¿qué error esperarías ver al pedir /shipments/4471? Justifica tu respuesta con lo que sabes de app.py.
Ver solución
Sin DYNAMODB_ENDPOINT_URL en el entorno, os.environ.get("DYNAMODB_ENDPOINT_URL") devolvería None (no hay valor por defecto declarado para esa variable en app.py, a diferencia de SHIPMENTS_TABLE_NAME/AWS_REGION, que sí tienen default). Con endpoint_url=None, boto3 usaría su comportamiento por defecto: intentar conectarse al endpoint real de AWS (dynamodb.us-east-1.amazonaws.com), no a LocalStack. Con las credenciales dummy test/test, esa solicitud fallaría de una forma distinta a las dos vistas hasta ahora —probablemente un error de autenticación real de AWS, o un timeout de red si tu laboratorio no tiene salida a internet configurada de esa forma— demostrando que las dos piezas (ConfigMap y Secret) son necesarias juntas, ninguna sustituye a la otra.
Resumen y siguiente paso
Esta lección montó, por primera vez en esta guía, configuración real en andes-cargo-status-api: el ConfigMap andes-cargo-status-api-config (endpoint, región, nombre de tabla) y el Secret andes-cargo-status-api-secrets (credenciales dummy), ambos vía envFrom, confirmados con kubectl exec ... -- printenv sin ningún rebuild de imagen. /health siguió respondiendo 200 sin cambios. /shipments/4471 siguió sin responder 200 —pero el error cambió de forma, de NoCredentialsError (Módulo 2, falla instantánea al firmar) a NameResolutionError/EndpointConnectionError (esta lección, falla tras ~25 segundos de reintentos al conectar) — la prueba de que la configuración llegó, aunque LocalStack nunca llegue a existir dentro del clúster: la capa de datos queda fuera del alcance $0 de esta guía, y /health sigue siendo la señal que valida el resto del camino.
Antes de avanzar deberías poder: explicar la diferencia entre stringData y data en un manifiesto de Secret; verificar variables de entorno dentro de un Pod sin reconstruir su imagen; y diagnosticar, leyendo un traceback, si un fallo de /shipments/<id> ocurre por falta de credenciales o por falta de un servicio al cual conectarse.
Siguiente lección: liveness, readiness y startup probes. Con la configuración resuelta, el módulo avanza a la segunda pregunta de la lección 1: ¿cómo sabe Kubernetes si un Pod que corre está realmente listo para atender tráfico?
Recursos
- Kubernetes — Define Environment Variables for a Container — referencia oficial de
envFrom, el mecanismo central de esta lección. - Kubernetes — Secrets: stringData — documentación oficial del campo
stringDatausado ensecret.yaml. - Boto3 — EndpointConnectionError — referencia oficial del comportamiento de reintentos de
botocore, la causa de los ~25 segundos de espera de esta lección. aws-serverless-and-containers-guide(NIEVA), Módulo 6, lección 5 — elapp.pyoriginal, sin ningún cambio, cuyas variables de entorno esta lección finalmente completa.