La lección anterior sacó la configuración de tienda-web de la imagen y la puso en un ConfigMap, pero terminaba con una advertencia: un ConfigMap no protege nada. Cualquiera que pueda listar ConfigMaps en rutas-norte-pro lee su contenido íntegro en texto plano. Y la deuda que arrastramos desde el módulo 2 es precisamente de las que no admiten eso: la contraseña de postgres-reservas está escrita en claro en un manifiesto versionado en Git, y esa base de datos guarda el nombre, el DNI, el teléfono y el correo de cada cliente de Rutas Norte. Esta lección la salda con el objeto Secret, pero sobre todo te enseña a no fiarte de él más de lo que merece: verás por qué base64 no es cifrado, por qué el cifrado en reposo de etcd no viene activado por defecto, quién puede leer un secreto sin que te enteres, y qué usa el ecosistema cuando el Secret nativo se queda corto.

Advertencia importante. Esta lección explica los mecanismos que Kubernetes ofrece para gestionar credenciales y te da un punto de partida sólido, pero no sustituye a un diseño de seguridad profesional. postgres-reservas almacena datos personales de clientes reales (nombre, DNI, teléfono, correo), lo que en la Unión Europea entra de lleno en el ámbito del RGPD y, en España, de la LOPDGDD. El diseño concreto de la gestión de credenciales, el cifrado en reposo, la política de rotación, los registros de auditoría y las medidas de seguridad aplicables debe revisarlo y aprobarlo un profesional de seguridad o el responsable de cumplimiento normativo de tu organización. Trata todo lo que sigue como conocimiento técnico necesario, no como una recomendación de cumplimiento.

Contenido

  1. La deuda del módulo 2 y por qué duele
  2. Qué es un Secret y en qué se diferencia de un ConfigMap
  3. Base64 no es cifrado: la demostración
  4. Los tipos de Secret
  5. Crear Secrets: imperativo y declarativo con stringData
  6. Consumir un Secret como volumen
  7. El secreto postgres-reservas-credenciales en Rutas Norte
  8. imagePullSecrets para el registro privado
  9. Cifrado en reposo en etcd
  10. Quién puede leer un secreto
  11. Rotación de credenciales
  12. Qué no se debe hacer nunca
  13. Las soluciones reales del ecosistema

  1. La deuda del módulo 2 y por qué duele

Este es el manifiesto que tenemos hoy en el repositorio de Rutas Norte:

# k8s/base/postgres-reservas/deployment.yaml   <-- ESTO ESTA MAL
    spec:
      containers:
        - name: postgres
          image: postgres:16.4
          env:
            - name: POSTGRES_PASSWORD
              value: "R3s3rv4s2026!"          # en claro, en Git, para siempre
            - name: POSTGRES_USER
              value: "rutasnorte"
            - name: POSTGRES_DB
              value: "reservas"

El problema no es solo estético. Enumeremos el daño real:

  1. Git no olvida. Aunque borres la línea hoy y hagas commit, la contraseña sigue en el historial. Sacarla de ahí exige reescribir el historial del repositorio (git filter-repo) y forzar el push a todos los clones. En la práctica, una credencial que ha estado en Git se considera comprometida y hay que rotarla, no borrarla.
  2. Se propaga sin control. Cada desarrollador que clonó el repositorio tiene la contraseña de producción en su portátil. También la tienen el CI, el sistema de copias del repositorio, el buscador de código interno y cualquier análisis estático que guarde caché.
  3. No hay trazabilidad. No existe forma de saber quién ha leído esa contraseña ni cuándo.
  4. Rotarla es un despliegue de código. Cambiar la contraseña exige un commit, una revisión, un merge y un despliegue. Eso empuja a no rotarla nunca.
  5. Bots automatizados la encontrarán. Si el repositorio llega a ser público un solo minuto, hay rastreadores que localizan credenciales en segundos.

El objetivo de esta lección es que ese manifiesto quede así:

          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-reservas-credenciales
                  key: password

El manifiesto se puede publicar sin filtrar nada. Ese es el listón de los doce factores que veíamos en la lección anterior.

  1. Qué es un Secret y en qué se diferencia de un ConfigMap

Un Secret es, estructuralmente, casi idéntico a un ConfigMap: metadata más datos, sin spec ni status, con namespace, y con el mismo límite de 1 MiB. La diferencia está en el tratamiento que Kubernetes le da y en las expectativas que crea.

Aspecto ConfigMap Secret
apiVersion v1 v1
Campo de datos data (texto), binaryData data (base64), stringData (solo escritura)
Codificación de data Texto plano UTF-8 Base64 obligatorio
Campo type No existe Sí, y determina validaciones
Al montarse como volumen Escrito en disco del nodo Escrito en tmpfs (memoria RAM)
Aparece en kubectl describe El contenido completo Solo el tamaño en bytes de cada clave
Cifrado en reposo en etcd No Solo si se configura (no por defecto)
Puede ser inmutable Sí Sí
Límite de tamaño 1 MiB 1 MiB
Puede llevar imagePullSecrets No Sí
Se puede restringir con RBAC Sí Sí, y es imprescindible

Las tres diferencias que de verdad importan:

Primera: stringData. Es un campo solo de escritura. Escribes en él texto plano y el apiserver lo codifica en base64 y lo mueve a data antes de guardarlo. Es lo que hace tolerable escribir Secrets a mano.

Segunda: el montaje en tmpfs. Cuando un Secret se monta como volumen, el kubelet crea un sistema de ficheros en memoria RAM, no en el disco del nodo. Consecuencia práctica: si el nodo se apaga bruscamente, la credencial no queda escrita en ningún disco recuperable. Es una protección real, aunque limitada.

Tercera: describe no muestra el contenido.

kubectl describe secret postgres-reservas-credenciales -n rutas-norte-dev
Name:         postgres-reservas-credenciales
Namespace:    rutas-norte-dev
Labels:       app=postgres-reservas
              app.kubernetes.io/part-of=rutas-norte
              entorno=dev
Annotations:  <none>

Type:  Opaque

Data
====
password:  13 bytes
username:  10 bytes
database:  8 bytes

Solo los tamaños. Esto evita el accidente clásico de mostrar la contraseña en una sesión compartida o dejarla en el log de una consola. Pero es un detalle de presentación, no un control de seguridad, como demuestra el apartado siguiente.

  1. Base64 no es cifrado: la demostración

Esta es la idea más importante de la lección y hay que interiorizarla haciéndola.

Creamos el Secret:

kubectl create secret generic postgres-reservas-credenciales \
  --from-literal=username=rutasnorte \
  --from-literal=password='R3s3rv4s2026!' \
  --from-literal=database=reservas \
  -n rutas-norte-dev
secret/postgres-reservas-credenciales created

Lo miramos en YAML:

kubectl get secret postgres-reservas-credenciales -n rutas-norte-dev -o yaml
apiVersion: v1
data:
  database: cmVzZXJ2YXM=
  password: UjNzM3J2NHMyMDI2IQ==
  username: cnV0YXNub3J0ZQ==
kind: Secret
metadata:
  creationTimestamp: "2026-08-05T11:02:47Z"
  name: postgres-reservas-credenciales
  namespace: rutas-norte-dev
  resourceVersion: "191455"
  uid: 8a3f7d21-5c6b-4e09-b1d7-4f2a9c8e3105
type: Opaque

A primera vista parece cifrado. No lo es. Base64 es una codificación reversible sin clave, diseñada en los años 80 para transportar binarios por canales de texto. Cualquiera puede deshacerla:

kubectl get secret postgres-reservas-credenciales -n rutas-norte-dev \
  -o jsonpath='{.data.password}' | base64 -d; echo
R3s3rv4s2026!

Tres segundos y un comando. Ni contraseña, ni clave, ni permisos especiales más allá de poder leer el Secret.

Podemos sacar todo el contenido de golpe:

kubectl get secret postgres-reservas-credenciales -n rutas-norte-dev \
  -o go-template='{{range $k, $v := .data}}{{$k}}={{$v | base64decode}}{{"\n"}}{{end}}'
database=reservas
password=R3s3rv4s2026!
username=rutasnorte

O, desde Kubernetes 1.30, con el visor incorporado:

kubectl get secret postgres-reservas-credenciales -n rutas-norte-dev -o jsonpath='{.data}' \
  | jq -r 'to_entries[] | "\(.key)=\(.value|@base64d)"'

Qué implica esto exactamente:

Creencia falsa Realidad
"Está codificado, es seguro" Base64 se deshace sin clave, en un comando
"Como está en un Secret, puedo subirlo a Git" No. Es equivalente a subirlo en claro
"Solo los administradores lo ven" Lo ve quien tenga permiso get secrets en el namespace
"En etcd está cifrado" Por defecto no (apartado 9)
"Los logs no lo muestran" Un kubectl get secret -o yaml en un pipeline sí

Entonces, ¿para qué sirve base64? Para dos cosas legítimas y ninguna de ellas es seguridad:

  1. Permitir contenido binario. Un certificado en formato DER o una clave privada con bytes no imprimibles no cabe en un campo de texto YAML. Base64 lo hace posible.
  2. Evitar problemas de escapado. Una contraseña con comillas, saltos de línea o $ rompería el YAML. Codificada, no.

La regla que debes memorizar: un Secret de Kubernetes no protege el dato; lo que hace es darle un lugar donde aplicar controles (RBAC, cifrado en reposo, auditoría, montaje en memoria) y separarlo del manifiesto de la aplicación. La protección la aportan esos controles, no el objeto.

  1. Los tipos de Secret

El campo type no es decorativo: determina qué claves exige Kubernetes y qué componentes saben usar el Secret.

type Claves obligatorias Para qué sirve Comando de creación
Opaque Ninguna Uso general: contraseñas, tokens de API, claves de cifrado kubectl create secret generic
kubernetes.io/dockerconfigjson .dockerconfigjson Credenciales de un registro privado de imágenes kubectl create secret docker-registry
kubernetes.io/tls tls.crt, tls.key Certificado y clave privada para TLS (Ingress) kubectl create secret tls
kubernetes.io/basic-auth username, password Autenticación básica HTTP generic con --type
kubernetes.io/ssh-auth ssh-privatekey Clave SSH (clonar repositorios privados) generic con --type
kubernetes.io/service-account-token token, ca.crt, namespace Token permanente de una ServiceAccount Manifiesto con anotación
bootstrap.kubernetes.io/token token-id, token-secret Unir nodos nuevos al clúster (kubeadm) Manifiesto

Notas de uso en Rutas Norte:

  • Opaque es el que usaremos para postgres-reservas-credenciales, para la clave de la pasarela de pago y para el token del proveedor de correo de worker-notificaciones. Es el tipo por defecto si no pones nada.
  • kubernetes.io/dockerconfigjson será el que permita descargar imágenes de registry.rutasnorte.example (apartado 8).
  • kubernetes.io/tls será el que sostenga el certificado de www.rutasnorte.example. Lo verás en TLS y Certificados, donde cert-manager lo generará y renovará solo.
  • kubernetes.io/service-account-token: cuidado con este. Antes de Kubernetes 1.24, cada ServiceAccount tenía asociado un Secret de este tipo con un token sin caducidad. Ya no se crean automáticamente y crearlos a mano es una mala idea salvo casos muy concretos: un token eterno es un token que nunca se puede revocar de forma limpia. El mecanismo actual lo verás en ServiceAccounts.

El tipo también se valida. Esto falla:

kubectl create secret generic mal-tipo --type=kubernetes.io/basic-auth \
  --from-literal=usuario=rutasnorte -n rutas-norte-dev
The Secret "mal-tipo" is invalid: data[username]: Required value

El tipo basic-auth exige exactamente la clave username, no usuario. Esa validación es útil: evita que un Ingress o un controlador reciba un Secret con la forma equivocada.

  1. Crear Secrets: imperativo y declarativo con stringData

5.1. kubectl create secret generic

kubectl create secret generic postgres-reservas-credenciales \
  --from-literal=username=rutasnorte \
  --from-literal=password='R3s3rv4s2026!' \
  -n rutas-norte-dev

Cuidado con el historial del shell. Ese comando queda escrito en ~/.bash_history con la contraseña dentro. Dos formas de evitarlo:

# Opcion A: un espacio delante (con HISTCONTROL=ignorespace)
 kubectl create secret generic ... --from-literal=password='R3s3rv4s2026!'

# Opcion B (mejor): desde fichero, y borrar el fichero despues
printf '%s' 'R3s3rv4s2026!' > /tmp/pw
kubectl create secret generic postgres-reservas-credenciales \
  --from-file=password=/tmp/pw -n rutas-norte-dev
shred -u /tmp/pw

El printf '%s' en lugar de echo es deliberado: echo añade un salto de línea final que acabaría dentro de la contraseña. Es un error clásico y desconcertante: la contraseña "parece correcta" pero PostgreSQL la rechaza. Si usas echo, pon -n.

5.2. kubectl create secret docker-registry

kubectl create secret docker-registry registry-rutasnorte \
  --docker-server=registry.rutasnorte.example \
  --docker-username=despliegues \
  --docker-password='<token-de-despliegue>' \
  [email protected] \
  -n rutas-norte-pro

5.3. kubectl create secret tls

kubectl create secret tls tienda-web-tls \
  --cert=certs/www.rutasnorte.example.crt \
  --key=certs/www.rutasnorte.example.key \
  -n rutas-norte-pro

5.4. Manifiesto con stringData

Si escribes el manifiesto a mano, usa siempre stringData, nunca data:

apiVersion: v1
kind: Secret
metadata:
  name: postgres-reservas-credenciales
  namespace: rutas-norte-dev
  labels:
    app: postgres-reservas
    app.kubernetes.io/name: postgres-reservas
    app.kubernetes.io/component: base-de-datos
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
type: Opaque
stringData:                         # texto plano; el apiserver lo codifica al guardarlo
  username: rutasnorte
  password: "R3s3rv4s2026!"
  database: reservas

Ventajas de stringData frente a data:

  • Se escribe y se lee sin codificar ni descodificar nada.
  • Elimina el error de codificar con echo sin -n, que mete un \n en el valor.
  • Un diff en una pull request es legible... lo cual, ojo, también es el motivo por el que este fichero no puede ir a Git tal cual.

Si una clave aparece en stringData y en data, gana stringData.

Y aquí llega la pregunta obvia: si este fichero no puede ir a Git, ¿dónde vive? Tres respuestas legítimas, en orden de madurez:

  1. Solo en el clúster. El fichero se aplica una vez desde un puesto seguro y se destruye. Simple, pero se pierde la trazabilidad y hay que documentar aparte qué claves existen.
  2. En Git, cifrado. Sealed Secrets o SOPS permiten versionar un fichero que solo el clúster puede descifrar (apartado 13).
  3. Fuera de Kubernetes. Un gestor de secretos externo (Vault, AWS Secrets Manager) es la fuente de verdad y un operador lo sincroniza (apartado 13).

Mientras tanto, lo mínimo imprescindible en el repositorio de Rutas Norte:

# .gitignore
k8s/**/secret-*.yaml
k8s/**/*-secret.yaml
*.key
*.pem

Y un secret-postgres.yaml.ejemplo con valores ficticios, versionado, para que el equipo sepa qué claves hacen falta.

  1. Consumir un Secret como volumen

El mecanismo es idéntico al de los ConfigMaps de la lección anterior, cambiando configMap por secret y name por secretName:

    spec:
      containers:
        - name: api
          image: registry.rutasnorte.example/api-reservas:2.5.0
          volumeMounts:
            - name: credenciales-bd
              mountPath: /etc/secretos/postgres    # ruta propia, no un directorio del sistema
              readOnly: true
      volumes:
        - name: credenciales-bd
          secret:
            secretName: postgres-reservas-credenciales
            defaultMode: 0400                      # solo el propietario lee
            items:                                 # opcional: solo estas claves
              - key: password
                path: password

Dentro del contenedor:

kubectl exec -n rutas-norte-dev deploy/api-reservas -- ls -la /etc/secretos/postgres
kubectl exec -n rutas-norte-dev deploy/api-reservas -- mount | grep secretos
total 0
drwxrwxrwt 3 root root  100 Aug  5 11:31 .
drwxr-xr-x 3 root root 4096 Aug  5 11:31 ..
lrwxrwxrwx 1 root root   15 Aug  5 11:31 password -> ..data/password

tmpfs on /etc/secretos/postgres type tmpfs (ro,relatime,size=...)

Dos cosas a destacar:

  • type tmpfs: el volumen está en memoria RAM. Nunca toca el disco del nodo. Al morir el pod, desaparece.
  • La misma cadena de enlaces a ..data que en los ConfigMaps, con la misma consecuencia: si el Secret cambia, el fichero montado se actualiza (con el mismo retardo del kubelet, y con la misma excepción de subPath, que no propaga).

Esa capacidad de refresco es la razón principal para preferir el volumen frente a la variable de entorno cuando se trata de credenciales. Una aplicación bien escrita puede releer el fichero y adoptar una contraseña rotada sin reiniciarse. Volveremos a ello en el apartado 11.

El consumo como variable de entorno (secretKeyRef, envFrom con secretRef) es igual de común y a menudo más práctico, sobre todo con imágenes de terceros como postgres:16, que esperan POSTGRES_PASSWORD en el entorno. Lo verás con todo el detalle en Variables de Entorno. Adelanto la comparación, porque condiciona el diseño:

Volumen Variable de entorno
Se refresca al rotar Sí No: exige recrear el pod
Visible en kubectl describe pod No La referencia sí, el valor no
Visible en /proc/<pid>/environ No Sí, para cualquier proceso del contenedor
Puede filtrarse en un volcado de error Poco probable Sí: muchos frameworks vuelcan el entorno
Compatible con imágenes de terceros A veces (*_FILE) Casi siempre

Muchas imágenes serias admiten la variante _FILE: POSTGRES_PASSWORD_FILE=/etc/secretos/postgres/password. Cuando exista, prefiérela: combina la compatibilidad de la variable con la seguridad del fichero.

  1. El secreto postgres-reservas-credenciales en Rutas Norte

Vamos a saldar la deuda de verdad. Diseño completo:

flowchart LR
    S["Secret<br/>postgres-reservas-credenciales<br/>type: Opaque"]
    S -->|"env: POSTGRES_PASSWORD"| PG["postgres-reservas<br/>(crea el usuario al inicializarse)"]
    S -->|"volumen /etc/secretos/postgres"| API["api-reservas<br/>(se conecta a la BD)"]
    S -->|"volumen /etc/secretos/postgres"| W["worker-notificaciones<br/>(lee reservas pendientes)"]
    S2["Secret<br/>smtp-notificaciones"] -->|"env: SMTP_PASSWORD"| W
    RBAC["RBAC (08-01)<br/>solo estas SA leen el Secret"] -.controla.-> S

El Secret, en k8s/entornos/dev/secret-postgres.yaml (fuera de Git):

apiVersion: v1
kind: Secret
metadata:
  name: postgres-reservas-credenciales
  namespace: rutas-norte-dev
  labels:
    app: postgres-reservas
    app.kubernetes.io/name: postgres-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
type: Opaque
stringData:
  username: rutasnorte
  password: "d3v-C4mbi4m3-2026"
  database: reservas
  # URL completa: comoda para la app, pero duplica la contrasenya.
  # Si la app puede componerla, mejor no tenerla aqui.
  url: "postgresql://rutasnorte:d3v-C4mbi4m3-2026@postgres-reservas:5432/reservas"

El Deployment de postgres-reservas, ya sin contraseñas en claro:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres-reservas
  namespace: rutas-norte-dev
  labels:
    app: postgres-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres-reservas
      entorno: dev
  strategy:
    type: Recreate                     # una BD no admite dos pods sobre el mismo disco
  template:
    metadata:
      labels:
        app: postgres-reservas
        app.kubernetes.io/name: postgres-reservas
        app.kubernetes.io/component: base-de-datos
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      containers:
        - name: postgres
          image: postgres:16.4
          ports:
            - name: postgres
              containerPort: 5432
          env:
            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef:
                  name: postgres-reservas-credenciales
                  key: username
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-reservas-credenciales
                  key: password
            - name: POSTGRES_DB
              valueFrom:
                secretKeyRef:
                  name: postgres-reservas-credenciales
                  key: database
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata

Y api-reservas, que la consume como fichero:

        - name: api
          image: registry.rutasnorte.example/api-reservas:2.5.0
          env:
            - name: DB_HOST
              value: postgres-reservas          # el Service del modulo 2
            - name: DB_PORT
              value: "5432"
            - name: DB_PASSWORD_FILE
              value: /etc/secretos/postgres/password
          volumeMounts:
            - name: credenciales-bd
              mountPath: /etc/secretos/postgres
              readOnly: true
      volumes:
        - name: credenciales-bd
          secret:
            secretName: postgres-reservas-credenciales
            defaultMode: 0400
            items:
              - key: password
                path: password
              - key: username
                path: username

Fíjate en el detalle de diseño: api-reservas recibe solo username y password gracias a items. No recibe la clave url, que contiene la contraseña duplicada, ni ninguna otra clave que pueda añadirse al Secret en el futuro. Es el principio de mínimo privilegio aplicado al contenido de un objeto.

Verificación de que la deuda está saldada:

grep -r "R3s3rv4s2026" k8s/ ; echo "salida: $?"
kubectl exec -n rutas-norte-dev deploy/api-reservas -- cat /etc/secretos/postgres/password; echo
salida: 1

d3v-C4mbi4m3-2026

grep no encuentra nada en el repositorio y la aplicación tiene su credencial. Ese es el objetivo.

  1. imagePullSecrets para el registro privado

Las imágenes de Rutas Norte viven en registry.rutasnorte.example, que es privado. Sin credenciales, el pod falla así:

kubectl describe pod api-reservas-7d9f8c6b4-x2mkl -n rutas-norte-pro | tail -6
Events:
  Type     Reason     Age                From     Message
  ----     ------     ----               ----     -------
  Normal   Pulling    45s                kubelet  Pulling image "registry.rutasnorte.example/api-reservas:2.5.0"
  Warning  Failed     43s                kubelet  Failed to pull image: rpc error: code = Unknown
             desc = failed to resolve reference: unexpected status from HEAD request: 401 Unauthorized
  Warning  Failed     43s                kubelet  Error: ErrImagePull
  Normal   BackOff    18s (x3 over 42s)  kubelet  Back-off pulling image
  Warning  Failed     18s                kubelet  Error: ImagePullBackOff

ImagePullBackOff con un 401 es siempre lo mismo: faltan credenciales de registro.

Se crea el Secret del tipo adecuado:

kubectl create secret docker-registry registry-rutasnorte \
  --docker-server=registry.rutasnorte.example \
  --docker-username=despliegues \
  --docker-password='<token-de-despliegue-de-solo-lectura>' \
  -n rutas-norte-pro
secret/registry-rutasnorte created

Lo que guarda dentro es un fichero .dockerconfigjson:

kubectl get secret registry-rutasnorte -n rutas-norte-pro \
  -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq .
{
  "auths": {
    "registry.rutasnorte.example": {
      "username": "despliegues",
      "password": "<token-de-despliegue-de-solo-lectura>",
      "auth": "ZGVzcGxpZWd1ZXM6PHRva2VuLi4uPg=="
    }
  }
}

Otra demostración de que base64 no protege nada: el campo auth es simplemente usuario:contraseña codificado.

Y se referencia en el pod:

    spec:
      imagePullSecrets:
        - name: registry-rutasnorte      # a nivel de POD, no de contenedor
      containers:
        - name: api
          image: registry.rutasnorte.example/api-reservas:2.5.0

Tres detalles importantes:

  1. imagePullSecrets va en spec del pod, no dentro de containers. Aplica a todas las imágenes del pod, incluidos los init containers.
  2. Se pueden listar varios, uno por registro. El kubelet elige el que casa con el servidor de la imagen.
  3. Hay que repetirlo en cada pod del namespace, lo cual es tedioso y fácil de olvidar. La solución elegante es declararlo en la ServiceAccount: todos los pods que la usen lo heredarán sin escribirlo. Lo verás en ServiceAccounts.

Buena práctica de seguridad: el token del registro debe ser de solo lectura de imágenes (pull), nunca uno con permiso de subida. Si el clúster se ve comprometido, un token de pull limita el daño a poder descargar imágenes; uno de push permitiría sustituirlas por versiones maliciosas. La lección de Seguridad de Imágenes profundiza en esto.

  1. Cifrado en reposo en etcd

Llegamos al punto que más gente desconoce.

Por defecto, los Secrets se guardan en etcd sin cifrar. Codificados en base64, sí; cifrados, no. Quien tenga acceso al disco de un nodo del plano de control, a una copia de seguridad de etcd o a un volcado del sistema de ficheros, tiene todas las credenciales del clúster.

Demostración (en un clúster donde tengas acceso al plano de control):

sudo ETCDCTL_API=3 etcdctl \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  get /registry/secrets/rutas-norte-dev/postgres-reservas-credenciales | hexdump -C | head -8
00000000  2f 72 65 67 69 73 74 72  79 2f 73 65 63 72 65 74  |/registry/secret|
00000010  73 2f 72 75 74 61 73 2d  6e 6f 72 74 65 2d 64 65  |s/rutas-norte-de|
00000020  76 2f 70 6f 73 74 67 72  65 73 0a 6b 38 73 00 0a  |v/postgres.k8s..|
...
000000a0  64 33 76 2d 43 34 6d 62  69 34 6d 33 2d 32 30 32  |d3v-C4mbi4m3-202|
000000b0  36 12 0a 72 75 74 61 73  6e 6f 72 74 65           |6..rutasnorte|

La contraseña se lee directamente en el volcado. Ni siquiera hace falta descodificar base64: etcd guarda el objeto serializado con los valores ya en binario.

La solución es una EncryptionConfiguration en el apiserver:

# /etc/kubernetes/enc/encryption-config.yaml (solo en los nodos del plano de control)
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets                        # que objetos se cifran
      - configmaps                     # opcional, tiene coste
    providers:
      - aescbc:                        # el PRIMERO se usa para ESCRIBIR
          keys:
            - name: clave-2026-08
              secret: <32 bytes aleatorios en base64>
      - identity: {}                   # sin cifrar: necesario para LEER lo antiguo

Y se activa arrancando el apiserver con --encryption-provider-config=/etc/kubernetes/enc/encryption-config.yaml.

Los proveedores disponibles:

Proveedor Cifrado Velocidad Fortaleza Comentario
identity Ninguno — Nula El comportamiento por defecto
secretbox XSalsa20 + Poly1305 Muy rápida Fuerte Buena opción si no hay KMS
aescbc AES-CBC 32 bytes Rápida Aceptable Vulnerable a padding oracle en teoría
aesgcm AES-GCM Muy rápida Fuerte Exige rotar la clave con frecuencia
kms v2 Delegado en un KMS externo Rápida La mejor La clave nunca está en el disco del nodo

Cuatro reglas críticas:

  1. El orden importa. El primer proveedor de la lista es el que cifra al escribir. Todos los de la lista se prueban al leer. Por eso identity debe ir al final durante la migración: permite leer los Secrets que ya existían sin cifrar.
  2. Activarlo no cifra lo que ya existe. Solo se cifra lo que se escribe a partir de ese momento. Hay que forzar una reescritura de todo:
kubectl get secrets --all-namespaces -o json | kubectl replace -f -
  1. La clave del fichero es la joya de la corona. Si usas aescbc o secretbox, la clave está en texto plano en el disco del nodo del plano de control. Quien tenga ese fichero y una copia de etcd lo tiene todo. Por eso kms v2 es la única opción realmente sólida en producción: la clave de cifrado de datos se envuelve con una clave que vive en un HSM o en el KMS de la nube y nunca se escribe en el nodo.
  2. En Kubernetes gestionado (EKS, AKS, GKE) esto lo activas con una casilla, delegando en el KMS del proveedor. Es una de las razones de peso para usar un clúster gestionado, como verás en 10-06.

Para Rutas Norte, que guarda DNI y datos de contacto de clientes, el cifrado en reposo con KMS no es opcional: es una medida técnica de las que el RGPD espera ver documentada. Y de nuevo: la decisión concreta debe validarla el responsable de cumplimiento.

  1. Quién puede leer un secreto

Un Secret no tiene ninguna protección propia. Todo depende de RBAC, que se estudia a fondo en Control de Acceso Basado en Roles. Aquí basta con entender por qué es inseparable.

Quién puede leer postgres-reservas-credenciales hoy:

Quién Cómo Cómo se restringe
Cualquiera con get secrets en el namespace kubectl get secret -o yaml RBAC: no conceder ese verbo
Cualquiera con list secrets en el namespace kubectl get secrets -o yaml (¡lista los contenidos!) RBAC: list es tan peligroso como get
Cualquiera que pueda crear un pod en el namespace Monta el Secret en un pod suyo y lo lee RBAC sobre create pods
Cualquiera con exec en un pod que lo monte kubectl exec ... -- cat /etc/secretos/... RBAC sobre pods/exec
El kubelet de los nodos donde corren esos pods Necesita el Secret para montarlo NodeRestriction limita a los de sus pods
Quien tenga acceso a etcd o a sus copias Volcado directo Cifrado en reposo + seguridad del nodo
Un administrador del clúster Todo Auditoría

Las dos entradas que sorprenden y hay que subrayar:

list filtra los contenidos. Mucha gente concede list secrets pensando que solo permite ver los nombres. No es así: kubectl get secrets -o yaml con permiso de list devuelve los objetos completos, con sus datos. En RBAC, list sobre secretos es equivalente a get.

Poder crear pods equivale a poder leer todos los secretos del namespace. Quien pueda desplegar un pod puede montar cualquier Secret del namespace y volcarlo. No hay forma de evitarlo con RBAC sobre secretos: hay que restringir la creación de pods. Es la razón principal por la que el namespace es la unidad real de aislamiento de credenciales y por la que Rutas Norte separa dev, pre y pro: nadie con acceso de desarrollo debe poder crear pods en rutas-norte-pro.

Un buen ejercicio de auditoría, que anticipa el módulo 8:

kubectl auth can-i get secrets -n rutas-norte-pro
kubectl auth can-i list secrets -n rutas-norte-pro --as=system:serviceaccount:rutas-norte-pro:default
yes
no

  1. Rotación de credenciales

Rotar una credencial es cambiarla de forma periódica y, obligatoriamente, cuando se sospecha que se ha filtrado. Y aquí aparece un problema que Kubernetes no resuelve solo.

El problema: un pod que arrancó ayer tiene la contraseña vieja. Si la cambias en el Secret:

  • Si la consume como volumen: el fichero se actualiza en un par de minutos. Pero la aplicación solo se enterará si relee el fichero. La mayoría lee al arrancar y guarda la conexión abierta.
  • Si la consume como variable de entorno: no se actualiza nunca. Las variables de entorno de un proceso se fijan al ejecutarlo y no hay forma de cambiarlas desde fuera.

La secuencia correcta de rotación, con doble credencial válida, que es lo que evita el corte de servicio:

sequenceDiagram
    participant OP as Operador
    participant PG as postgres-reservas
    participant K8S as Secret
    participant API as pods de api-reservas

    OP->>PG: 1. Crear la contrasenya NUEVA (la vieja sigue valida)
    OP->>K8S: 2. Actualizar el Secret con la nueva
    Note over API: 3. Los pods viejos siguen con la vieja: SIGUEN FUNCIONANDO
    OP->>API: 4. kubectl rollout restart (progresivo, sin corte)
    Note over API: 5. Los pods nuevos arrancan con la nueva
    OP->>PG: 6. Revocar la contrasenya VIEJA
    OP->>PG: 7. Verificar en los logs que nadie la usa

En comandos:

# 1. En PostgreSQL, crear la nueva credencial sin quitar la vieja
kubectl exec -n rutas-norte-pro deploy/postgres-reservas -- \
  psql -U postgres -c "ALTER USER rutasnorte PASSWORD 'nueva-2026-08';"

# 2. Actualizar el Secret
kubectl create secret generic postgres-reservas-credenciales \
  --from-literal=username=rutasnorte \
  --from-literal=password='nueva-2026-08' \
  --from-literal=database=reservas \
  -n rutas-norte-pro --dry-run=client -o yaml | kubectl apply -f -

# 3. Reiniciar de forma progresiva los consumidores
kubectl rollout restart deploy/api-reservas -n rutas-norte-pro
kubectl rollout restart deploy/worker-notificaciones -n rutas-norte-pro
kubectl rollout status deploy/api-reservas -n rutas-norte-pro
secret/postgres-reservas-credenciales configured
deployment.apps/api-reservas restarted
deployment.apps/worker-notificaciones restarted
deployment "api-reservas" successfully rolled out

Un aviso sobre ALTER USER: en PostgreSQL un usuario tiene una sola contraseña, así que el paso 1 la sustituye y los pods viejos se romperán en cuanto abran una conexión nueva. En un sistema con rotación seria se usan dos usuarios (rutasnorte_a y rutasnorte_b) y se alterna entre ellos, que es lo que hacen los motores de secretos dinámicos de Vault. Diséñalo antes de necesitarlo.

Puntos que conviene fijar:

  • Automatiza la detección del cambio. La técnica del hash del Secret en una anotación del template, que verás en 03-03, hace que actualizar el Secret dispare el despliegue solo, sin rollout restart manual.
  • Rota siempre tras una exposición. Si la contraseña estuvo en Git, rotarla es obligatorio aunque hayas reescrito el historial.
  • Vigila los pods rezagados. Un CronJob o un pod que no forma parte de un Deployment no se reinicia con rollout restart. Hazte una lista de consumidores antes de rotar.

  1. Qué no se debe hacer nunca

Práctica Por qué es grave
Subir el Secret a Git, aunque esté en base64 Base64 no es cifrado. Equivale a subirlo en claro y queda en el historial para siempre
Poner una credencial en una anotación Las anotaciones se copian a objetos derivados, salen en describe y en los logs de los controladores
Pasarla en args o command Aparece en kubectl describe pod, en ps aux dentro del contenedor y en los logs del kubelet
Hornearla en la imagen Queda en una capa del registro; docker history la muestra aunque la borres en una capa posterior
Escribirla en un ConfigMap Sin describe protegido, sin tmpfs, sin cifrado en reposo, y RBAC de ConfigMaps suele ser laxo
Registrarla en un log Los logs se centralizan, se copian y se conservan meses (07-05)
Reutilizar la misma credencial en los tres entornos Un incidente en dev compromete pro
Usar el Secret default o compartir uno entre componentes Impide revocar el acceso de un solo componente
Conceder list secrets "porque solo lista nombres" list devuelve los contenidos completos
Crear tokens de ServiceAccount permanentes Un token sin caducidad no se puede revocar limpiamente
Depender solo de .gitignore Un git add -f o un fichero con otro nombre lo saltan. Añade un pre-commit hook que detecte credenciales

Sobre el punto de args, una demostración de lo expuesto que queda:

# NUNCA HAGAS ESTO
        - name: worker
          image: registry.rutasnorte.example/worker-notificaciones:1.8.0
          args: ["--smtp-password=Env10-2026!"]
kubectl describe pod worker-notificaciones-6f7d9-abcde -n rutas-norte-pro | grep -A2 Args
    Args:
      --smtp-password=Env10-2026!

Cualquiera con permiso de lectura sobre pods —un permiso que se concede a mucha gente, incluidos los sistemas de monitorización— acaba de leer la contraseña del correo.

  1. Las soluciones reales del ecosistema

El Secret nativo resuelve el dónde vive la credencial dentro del clúster. No resuelve cómo llega ahí de forma segura y auditable, ni cómo se versiona, ni cómo se rota sola. Para eso existen estas cuatro familias. No vamos a hacer un tutorial de ninguna —cada una da para un curso—, pero debes saber cuándo mirar hacia cada lado.

Solución Idea central Cuándo elegirla
Sealed Secrets (Bitnami) Un controlador en el clúster tiene una clave privada; tú cifras con la pública y subes el fichero cifrado a Git. Solo ese clúster puede descifrarlo Equipos pequeños, GitOps puro, sin infraestructura externa
SOPS (+ age/KMS) Cifra solo los valores del YAML, dejando las claves legibles. Se integra con Flux, Helm y Kustomize Cuando quieres diffs legibles y ya usas GitOps
External Secrets Operator Un CRD ExternalSecret declara de dónde sacar la credencial; el operador la lee de Vault/AWS/Azure/GCP y mantiene sincronizado un Secret nativo El estándar de hecho en empresas con un gestor de secretos ya montado
HashiCorp Vault Gestor de secretos completo: credenciales dinámicas de vida corta, motor de PKI, auditoría detallada, políticas propias Organizaciones grandes, requisitos de auditoría fuertes
KMS de las nubes (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager) El gestor de la nube es la fuente de verdad; se accede con identidad federada, sin credenciales estáticas Ya estás en esa nube

Un ejemplo de cómo se ve un ExternalSecret, solo para que reconozcas el patrón cuando te lo encuentres:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: postgres-reservas-credenciales
  namespace: rutas-norte-pro
spec:
  refreshInterval: 1h                     # resincroniza cada hora: rotacion automatica
  secretStoreRef:
    name: vault-rutasnorte
    kind: SecretStore
  target:
    name: postgres-reservas-credenciales  # el Secret NATIVO que se creara
    creationPolicy: Owner
  data:
    - secretKey: password                 # clave en el Secret de Kubernetes
      remoteRef:
        key: rutasnorte/pro/postgres      # ruta en Vault
        property: password

Lo elegante del patrón: este fichero sí puede ir a Git. No contiene la credencial, solo dice dónde encontrarla. Y refreshInterval: 1h significa que si alguien rota la contraseña en Vault, el Secret del clúster se actualiza solo en menos de una hora.

Dónde encaja cada cosa en la madurez de una plataforma como Rutas Norte:

  1. Hoy (donde estamos): Secrets nativos creados a mano, fuera de Git, con .gitignore y un fichero de ejemplo. Aceptable en dev.
  2. Siguiente paso: cifrado en reposo con KMS en el clúster y RBAC estricto sobre secretos en rutas-norte-pro.
  3. Madurez: External Secrets Operator apuntando al gestor de secretos corporativo, con rotación automática y auditoría de cada lectura.

Errores Comunes y Consejos

Error Síntoma Solución
Creer que base64 protege Se sube el Secret a Git Es codificación, no cifrado. Nunca a Git sin cifrar
echo sin -n al codificar La app rechaza la contraseña "correcta" Usa printf '%s' o echo -n, o mejor stringData
Tipo incorrecto de Secret data[username]: Required value Revisa la tabla de tipos del apartado 4
Secret en otro namespace Pod en ContainerCreating, FailedMount Los Secrets no cruzan namespaces
Esperar refresco con variables de entorno La rotación no llega a los pods Las variables son inmutables: rollout restart
subPath con un Secret La rotación no se propaga nunca Igual que con ConfigMaps: evita subPath
defaultMode: 0400 con usuario no root Permission denied Usa 0440 con fsGroup, o 0444
Olvidar imagePullSecrets ImagePullBackOff con 401 Añádelo al pod o, mejor, a la ServiceAccount
imagePullSecrets dentro de containers Error de validación del YAML Va en spec del pod
Conceder list secrets Fuga silenciosa de todas las credenciales list devuelve contenidos. Trátalo como get
No activar cifrado en reposo Credenciales legibles en las copias de etcd EncryptionConfiguration con kms v2
Rotar sin doble credencial Corte de servicio durante la rotación Nueva credencial válida antes de revocar la vieja
Contraseña en args o en un log Visible en describe pod y en el sistema de logs Volumen o variable de entorno, nunca argumento

Consejos finales:

  1. Trata todo Secret como si fuera a filtrarse. Diseña asumiendo que alguien lo leerá: credenciales distintas por entorno y por componente, con el mínimo privilegio en la base de datos.
  2. Instala un detector de credenciales en el pre-commit. gitleaks o detect-secrets cuestan diez minutos de configuración y evitan el 90 % de los accidentes.
  3. Ten un inventario de secretos. Qué existe, quién lo consume, cuándo se rotó por última vez, quién es el responsable.
  4. Documenta las claves, nunca los valores. Un secret-postgres.yaml.ejemplo con password: CAMBIAME versionado es útil y seguro.
  5. Y lo dicho al principio: que el diseño lo revise un profesional de seguridad o el responsable de cumplimiento. Con datos personales de clientes, esto no es negociable.

Ejercicios

Ejercicio 1: Demostrar que base64 no es cifrado

  1. Crea un Secret pasarela-pago en rutas-norte-dev con las claves api_key (valor sk_test_4eC39HqLyjWDarjtT1zdp7dc) y entorno (valor sandbox).
  2. Muestra el objeto en YAML y comprueba que no se lee el valor.
  3. Descodifica api_key con un solo comando encadenado.
  4. Comprueba qué muestra kubectl describe de ese Secret y explica por qué eso no es una medida de seguridad.
  5. Responde: si un compañero tiene el verbo list sobre secretos en ese namespace pero no get, ¿puede leer la clave? Demuéstralo con kubectl auth can-i.

Ejercicio 2: Saldar la deuda de postgres-reservas

  1. Escribe el manifiesto del Secret postgres-reservas-credenciales para rutas-norte-dev usando stringData, con username, password y database, y con el esquema de etiquetas de Rutas Norte.
  2. Modifica el Deployment de postgres-reservas para que tome las tres variables del Secret.
  3. Modifica api-reservas para que monte solo username y password como ficheros en /etc/secretos/postgres con permisos de solo lectura.
  4. Verifica que el volumen está en tmpfs.
  5. Demuestra con grep que en k8s/ no queda ninguna contraseña en claro, y añade las reglas necesarias al .gitignore.

Ejercicio 3: Rotación sin corte de servicio

api-reservas corre con 4 réplicas en rutas-norte-pro y hay que rotar la contraseña de la base de datos porque un antiguo miembro del equipo la conocía.

  1. Escribe la secuencia completa de pasos, indicando en cuál de ellos hay riesgo de corte y cómo lo evitas.
  2. Ejecuta la actualización del Secret con un solo comando que funcione tanto si el Secret existe como si no.
  3. Haz que los pods adopten la credencial nueva sin quedarte sin servicio en ningún momento.
  4. Verifica que los 4 pods nuevos están arriba y que ninguno conserva la credencial anterior.
  5. Identifica qué consumidores del Secret no se reinician con kubectl rollout restart y qué harías con ellos.

Soluciones

Solución 1

kubectl create secret generic pasarela-pago \
  --from-literal=api_key=sk_test_4eC39HqLyjWDarjtT1zdp7dc \
  --from-literal=entorno=sandbox \
  -n rutas-norte-dev

kubectl get secret pasarela-pago -n rutas-norte-dev -o yaml
secret/pasarela-pago created

apiVersion: v1
data:
  api_key: c2tfdGVzdF80ZUMzOUhxTHlqV0Rhcmp0VDF6ZHA3ZGM=
  entorno: c2FuZGJveA==
kind: Secret
metadata:
  name: pasarela-pago
  namespace: rutas-norte-dev
type: Opaque

La descodificación:

kubectl get secret pasarela-pago -n rutas-norte-dev \
  -o jsonpath='{.data.api_key}' | base64 -d; echo
sk_test_4eC39HqLyjWDarjtT1zdp7dc
kubectl describe secret pasarela-pago -n rutas-norte-dev
Name:         pasarela-pago
Namespace:    rutas-norte-dev
Type:         Opaque

Data
====
api_key:  32 bytes
entorno:  7 bytes

describe solo muestra el tamaño. No es una medida de seguridad, sino de higiene visual: evita mostrar la credencial por accidente en una pantalla compartida o en la salida de un script. Quien tiene permiso para hacer describe tiene el verbo get, y con get -o yaml obtiene el valor. La protección real la dan RBAC y el cifrado en reposo, no el formato de salida.

Sobre list:

kubectl auth can-i list secrets -n rutas-norte-dev [email protected]
kubectl get secrets -n rutas-norte-dev -o yaml [email protected] | grep api_key
yes
  api_key: c2tfdGVzdF80ZUMzOUhxTHlqV0Rhcmp0VDF6ZHA3ZGM=

Sí puede leerla. list devuelve los objetos completos, no solo sus nombres. Es un error de concesión de permisos muy extendido.

Solución 2

# k8s/entornos/dev/secret-postgres.yaml  (NO se versiona en Git)
apiVersion: v1
kind: Secret
metadata:
  name: postgres-reservas-credenciales
  namespace: rutas-norte-dev
  labels:
    app: postgres-reservas
    app.kubernetes.io/name: postgres-reservas
    app.kubernetes.io/component: base-de-datos
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
type: Opaque
stringData:
  username: rutasnorte
  password: "d3v-C4mbi4m3-2026"
  database: reservas

El Deployment de postgres-reservas es el del apartado 7. El fragmento de api-reservas:

          volumeMounts:
            - name: credenciales-bd
              mountPath: /etc/secretos/postgres
              readOnly: true
      volumes:
        - name: credenciales-bd
          secret:
            secretName: postgres-reservas-credenciales
            defaultMode: 0400
            items:
              - key: username
                path: username
              - key: password
                path: password

Verificación del tmpfs y de que solo hay dos ficheros:

kubectl exec -n rutas-norte-dev deploy/api-reservas -- sh -c \
  'ls /etc/secretos/postgres && mount | grep secretos'
password
username
tmpfs on /etc/secretos/postgres type tmpfs (ro,relatime)

Dos ficheros, no tres: database no se ha proyectado porque no está en items. Y el volumen es tmpfs, es decir, RAM.

grep -rniE "password.*[:=].*[a-z0-9]{8}" k8s/ --include=*.yaml | grep -v secret-

Sin resultados. El .gitignore:

# Secretos: nunca en el repositorio
k8s/**/secret-*.yaml
k8s/**/*-secret.yaml
!k8s/**/*.yaml.ejemplo
*.key
*.pem
*.p12

La línea !k8s/**/*.yaml.ejemplo permite versionar las plantillas con valores ficticios, que son las que documentan qué claves hacen falta.

Solución 3

La secuencia, con el riesgo señalado:

  1. Crear la contraseña nueva en PostgreSQL. Riesgo alto si el motor solo admite una contraseña por usuario: los pods vivos se romperán en la siguiente conexión. Se evita creando un usuario nuevo (rutasnorte_b) en lugar de cambiar el del actual, dejando ambos válidos.
  2. Actualizar el Secret. Sin riesgo: los pods vivos ya tienen la credencial cargada en memoria.
  3. rollout restart. Sin riesgo si maxUnavailable está bien puesto: el módulo 2 nos garantiza sustitución progresiva.
  4. Revocar la credencial vieja. Riesgo si queda algún consumidor sin reiniciar; por eso va después de verificar.
kubectl create secret generic postgres-reservas-credenciales \
  --from-literal=username=rutasnorte_b \
  --from-literal=password='R0t4d4-2026-08!' \
  --from-literal=database=reservas \
  -n rutas-norte-pro --dry-run=client -o yaml | kubectl apply -f -
secret/postgres-reservas-credenciales configured

El truco --dry-run=client -o yaml | kubectl apply -f - es el idiomático: genera el objeto y lo aplica, funcionando tanto para crear como para actualizar, cosa que kubectl create solo no hace (fallaría con AlreadyExists).

kubectl rollout restart deploy/api-reservas -n rutas-norte-pro
kubectl rollout status deploy/api-reservas -n rutas-norte-pro
kubectl get pods -l app=api-reservas -n rutas-norte-pro
deployment.apps/api-reservas restarted
Waiting for deployment "api-reservas" rollout to finish: 2 of 4 updated replicas are available...
deployment "api-reservas" successfully rolled out

NAME                            READY   STATUS    RESTARTS   AGE
api-reservas-7f4b8c9d6-2xkqp    1/1     Running   0          48s
api-reservas-7f4b8c9d6-8mvtr    1/1     Running   0          62s
api-reservas-7f4b8c9d6-j9wzl    1/1     Running   0          35s
api-reservas-7f4b8c9d6-qh4nc    1/1     Running   0          21s

Los cuatro pods son nuevos (mismo pod-template-hash, AGE menor de un minuto y RESTARTS 0). Verificación de la credencial efectiva:

kubectl exec -n rutas-norte-pro deploy/api-reservas -- cat /etc/secretos/postgres/username; echo
rutasnorte_b

Consumidores que no se reinician con rollout restart de los Deployments:

  • CronJobs como informes-ocupacion (módulo 6): los pods se crean en cada ejecución, así que tomarán la credencial nueva solos; pero si hay una ejecución en curso durante la rotación, fallará. Hay que mirar kubectl get jobs antes de revocar.
  • Pods sueltos creados a mano para depurar. Se localizan con kubectl get pods -n rutas-norte-pro -o json | jq '.items[] | select(.metadata.ownerReferences == null) | .metadata.name'.
  • StatefulSets (módulo 6): rollout restart funciona, pero es ordinal y más lento; hay que esperar a que termine.
  • Clientes fuera del clúster: herramientas de informes, copias de seguridad, cuadros de mando. No los ve Kubernetes y son los que provocan el incidente al revocar. Por eso el inventario de consumidores del apartado 11 no es burocracia.

Conclusión

Has saldado la deuda más grave del módulo 2. La contraseña de postgres-reservas ya no está en ningún manifiesto de Git: vive en un Secret, api-reservas la recibe como fichero montado en memoria con solo las claves que necesita, y postgres-reservas la toma de secretKeyRef para inicializarse. Un grep sobre k8s/ no devuelve nada.

Pero lo importante de esta lección no es el objeto, sino el criterio. Sabes que un Secret se diferencia de un ConfigMap en stringData, en el montaje en tmpfs, en que describe oculta el contenido y en que existe un campo type que valida la forma. Y sabes, porque lo has demostrado con un comando, que base64 no es cifrado: la protección real la aportan RBAC, el cifrado en reposo y el aislamiento por namespaces, no el objeto en sí. Conoces los siete tipos de Secret y para qué sirve cada uno, las tres formas de crearlos, y por qué stringData es siempre preferible a data al escribir a mano.

Sabes que el cifrado en reposo en etcd no viene activado, has visto la contraseña legible en un volcado de etcd, conoces los cinco proveedores de EncryptionConfiguration y por qué kms v2 es el único realmente sólido, y que activarlo no cifra retroactivamente lo existente. Tienes claro quién puede leer un secreto —con las dos sorpresas: list filtra contenidos y poder crear pods equivale a poder leerlo todo—, cómo se rota una credencial sin cortar el servicio y por qué las variables de entorno no se refrescan nunca. Tienes la lista de lo que jamás debe hacerse y un mapa del ecosistema real —Sealed Secrets, SOPS, External Secrets Operator, Vault, los KMS de las nubes— para cuando el Secret nativo se quede corto. Y no olvides la advertencia del inicio: con los datos personales que guarda Rutas Norte, el diseño definitivo debe validarlo un profesional de seguridad o el responsable de cumplimiento.

Las dos lecciones han dejado un cabo suelto común. Hemos visto los objetos que guardan la configuración y los secretos, y una de las formas de llevarlos al contenedor: el fichero montado. Falta la otra, la que aparece en el 80 % de los manifiestos del mundo real y que ambas lecciones han mencionado sin desarrollar. La siguiente, Variables de Entorno, la cubre entera: env, envFrom con prefix, valueFrom con configMapKeyRef y secretKeyRef, el flag optional, la Downward API para que api-reservas registre en sus trazas qué pod y qué nodo atendió cada petición, la expansión $(VAR) y sus reglas, las colisiones cuando la misma clave llega por varias vías, y la técnica del hash de la configuración en una anotación que resuelve de una vez el problema de que las variables de entorno no se refresquen nunca.

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