La lección anterior terminó con una lista honesta de los defectos de Helm: las plantillas dejan de ser YAML válido, nindent y los espacios en blanco son una fuente constante de errores, y hace falta aprender el motor de plantillas de Go antes de poder tocar nada. Kustomize nace precisamente de ese descontento y parte de una premisa opuesta: no usar plantillas en absoluto.
En Kustomize, tus manifiestos siguen siendo YAML de Kubernetes perfectamente válido. Puedes abrirlos en el editor y que te valide el esquema, aplicarlos con kubectl apply -f, y leerlos sin descifrar nada. Lo que cambia entre entornos no se expresa con variables, sino con superposiciones: pequeños ficheros que declaran las diferencias respecto a una base común.
En esta lección migraremos el k8s/ de Rutas Norte de 120 ficheros duplicados a una base más tres superposiciones, resolveremos de forma limpia el problema del reinicio ante cambios de configuración que en 03-03 arreglábamos con una anotación a mano, y terminaremos comparando Helm y Kustomize sin favoritismos.
Contenido
- La filosofía sin plantillas
- Kustomize dentro de kubectl y como binario propio
- El modelo base + superposiciones
kustomization.yamlcampo a campo- Parches: fusión estratégica y JSON Patch
- Generadores de ConfigMaps y Secrets
- Componentes: trozos opcionales reutilizables
- Flujo de trabajo: kustomize, diff y apply
- Migración completa de Rutas Norte
- Helm frente a Kustomize
- Combinar Helm y Kustomize
- Errores comunes y consejos
- Ejercicios
- Conclusión
- La filosofía sin plantillas
Compara los dos enfoques para el mismo objetivo: que api-reservas tenga 1 réplica en desarrollo y 4 en producción.
Con Helm, el manifiesto deja de ser YAML de Kubernetes: replicas: {{ .Values.apiReservas.replicaCount }}, image: {{ .Values.imageRegistry }}/api-reservas:{{ .Values.apiReservas.image.tag }}. Con Kustomize, la base es YAML normal y corriente (replicas: 1, image: registry.rutasnorte.example/api-reservas:2.4.0), aplicable con kubectl apply -f, y la superposición declara las diferencias:
# k8s/entornos/pro/kustomization.yaml
resources:
- ../../base
replicas:
- { name: api-reservas, count: 4 }
images:
- name: registry.rutasnorte.example/api-reservas
newTag: "2.4.0"
digest: sha256:9c1e4a7b3d2f8e6a...| Helm | Kustomize | |
|---|---|---|
| Qué es un manifiesto | Una plantilla que produce YAML | YAML válido desde el principio |
| Cómo se personaliza | Sustituyendo variables antes de renderizar | Transformando el YAML ya renderizado |
| Qué hay que aprender | Go templates + Sprig + estructura de charts | Un fichero: kustomization.yaml |
| Se puede aplicar sin la herramienta | No | Sí, la base sí |
| El editor valida el esquema | No | Sí |
Kustomize funciona como una canalización de transformaciones: lee los manifiestos base, aplica una serie de operaciones declaradas (cambiar el namespace, añadir etiquetas, sustituir la imagen, aplicar parches) y emite el YAML resultante.
flowchart LR
B["k8s/base/<br/>YAML válido"] --> T1[namespace] --> T2[labels] --> T3[images]
T3 --> T4[replicas] --> T5[patches] --> T6[generators] --> O["YAML final<br/>para el clúster"]
OV["overlay pro/<br/>kustomization.yaml"] -.declara.-> T1 & T3 & T5 & T6
style B fill:#e8f4ff
style O fill:#e8ffe8
Esa naturaleza de "transformar YAML existente" tiene una consecuencia elegante: Kustomize entiende los tipos de Kubernetes. Cuando fusiona dos listas de contenedores sabe que la clave de correlación es name; cuando cambia una imagen sabe dónde están los campos image en un Deployment, un StatefulSet, un CronJob o un DaemonSet. Helm, que solo concatena texto, no sabe nada de eso.
- Kustomize dentro de kubectl y como binario propio
kubectl kustomize k8s/entornos/pro # ver el resultado sin aplicar
kubectl apply -k k8s/entornos/pro # aplicar
kubectl diff -k k8s/entornos/pro # comparar con el clúster
kubectl delete -k k8s/entornos/proVentaja enorme: no hay nada que instalar. Cualquiera con kubectl puede desplegar. Desventaja: la versión integrada va por detrás de la independiente y no se puede actualizar sin actualizar kubectl.
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
kustomize build k8s/entornos/pro | kubectl apply -f -| Aspecto | kubectl -k |
kustomize |
|---|---|---|
| Instalación | Ya lo tienes | Descarga aparte |
| Versión y funciones nuevas | Con retraso de meses | Inmediatas |
Generadores externos y helmCharts |
Limitado | Completo |
Recomendación para Rutas Norte: kubectl -k para el uso interactivo diario; el binario con versión fijada en la canalización ci-rutasnorte, para que todo el mundo genere exactamente el mismo YAML.
- El modelo base + superposiciones
k8s/
├── base/
│ ├── kustomization.yaml
│ ├── tienda-web/ (kustomization + deployment + service + hpa + ingress)
│ ├── api-reservas/ (+ servicemonitor + config/)
│ ├── postgres-reservas/ (recurso del operador, 06-07)
│ ├── redis-cache/
│ ├── worker-notificaciones/ (+ scaledobject de KEDA, 09-04)
│ └── informes-ocupacion/ (cronjob)
├── componentes/
│ ├── politicas-red/ (NetworkPolicies, 04-06)
│ ├── alta-disponibilidad/ (PDB + topología, 09-05)
│ └── observabilidad/ (ServiceMonitors + reglas, 07-03)
└── entornos/
├── dev/ (kustomization.yaml + config.env + recursos-patch.yaml)
├── pre/ (idem)
└── pro/ (idem + ingress-patch.yaml)Tres conceptos:
- Base: los manifiestos comunes. Debe ser desplegable por sí sola y contiene los valores más conservadores.
- Superposición (overlay): un directorio que referencia una base y declara las diferencias. Una por entorno.
- Componente: un trozo reutilizable que las superposiciones activan opcionalmente.
# k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: # Kustomize es recursivo: cada uno tiene el suyo
- tienda-web
- api-reservas
- postgres-reservas
- redis-cache
- worker-notificaciones
- informes-ocupacion
labels:
- includeSelectors: false # ver el apartado 4 sobre por qué
pairs:
app.kubernetes.io/part-of: rutas-norte
app.kubernetes.io/managed-by: kustomize# k8s/base/api-reservas/deployment.yaml
# YAML de Kubernetes puro. Valores conservadores, propios de desarrollo.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-reservas
labels: { app: api-reservas }
spec:
replicas: 1
selector:
matchLabels: { app: api-reservas }
strategy:
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
template:
metadata:
labels: { app: api-reservas }
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
spec:
serviceAccountName: api-reservas
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: api
image: registry.rutasnorte.example/api-reservas:2.4.0
ports:
- { name: http, containerPort: 8080 }
envFrom:
- configMapRef: { name: api-reservas-config }
- secretRef: { name: api-reservas-credenciales }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
livenessProbe:
httpGet: { path: /salud/vivo, port: http }
initialDelaySeconds: 15
readinessProbe:
httpGet: { path: /salud/listo, port: http }
initialDelaySeconds: 5
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 256Mi }
volumeMounts: [{ name: tmp, mountPath: /tmp }]
volumes: [{ name: tmp, emptyDir: {} }]Fíjate en que este fichero es directamente aplicable con kubectl apply -f. Eso no lo puedes hacer con una plantilla de Helm: es la ventaja principal del enfoque.
Y la superposición de desarrollo son once líneas:
# k8s/entornos/dev/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: rutas-norte-dev
resources: [../../base]
labels:
- includeSelectors: false
pairs: { entorno: dev }
images:
- { name: registry.rutasnorte.example/api-reservas, newTag: dev-abc123f }
- { name: registry.rutasnorte.example/tienda-web, newTag: dev-abc123f }
configMapGenerator:
- name: api-reservas-config
behavior: merge
envs: [config.env]
patches:
- path: recursos-patch.yaml
kustomization.yaml campo a campo
kustomization.yaml campo a camporesources
Ficheros, directorios con su propio kustomization.yaml, o URL remotas:
resources:
- deployment.yaml
- ../../base
- github.com/kubernetes-sigs/kustomize/examples/multibases?ref=v5.4.3Sobre los recursos remotos: fija siempre ?ref= a una etiqueta o commit. Sin eso apuntas a la rama principal y tu despliegue cambia cuando alguien ajeno hace un commit. Es el mismo principio que --version en Helm y las etiquetas inmutables de imagen (08-05).
namespace, namePrefix y nameSuffix
namespace: rutas-norte-pro establece metadata.namespace en todos los objetos generados y actualiza las referencias cruzadas (el namespace de un subjects de RoleBinding, por ejemplo). Un solo campo elimina la principal duplicación entre entornos. Los objetos de ámbito de clúster (ClusterRole, StorageClass, CRD) no se ven afectados: Kustomize lo sabe.
namePrefix: rn- y nameSuffix: -pro renombran los objetos, y —muy importante— Kustomize actualiza las referencias: el scaleTargetRef del HPA, el serviceName del StatefulSet, el name del ConfigMap en envFrom, el backend.service.name del Ingress. Rutas Norte no los usa, porque ya separa entornos por namespace y añadir -pro al nombre de todo complica las órdenes de diagnóstico; son útiles cuando despliegas dos instancias en el mismo namespace.
labels y por qué commonLabels está desaconsejado
Este apartado merece atención especial porque es una trampa real que rompe despliegues.
# FORMA MODERNA Y CORRECTA
labels:
- includeSelectors: false # <-- LA CLAVE
pairs:
entorno: pro
app.kubernetes.io/part-of: rutas-nortecommonLabels (la forma antigua) añade las etiquetas a metadata.labels y también a spec.selector.matchLabels del Deployment, a spec.template.metadata.labels del pod y a spec.selector del Service. Y ahí está el problema: spec.selector de un Deployment es inmutable (02-03, 02-07). Si la base ya está desplegada y añades una etiqueta, el siguiente apply falla:
The Deployment "api-reservas" is invalid: spec.selector: Invalid value:
v1.LabelSelector{...}: field is immutableLa única salida es borrar el Deployment y recrearlo, con corte de servicio. En rutas-norte-pro, a las once de la mañana.
Hay un segundo problema, más sutil: si añade entorno: pro al selector del Service, ese Service dejará de encontrar los pods que ya existían sin esa etiqueta. Tráfico a ninguna parte, sin ningún error visible.
| Campo | Toca los selectores | Cuándo usarlo |
|---|---|---|
labels con includeSelectors: false |
No | Por defecto, siempre |
labels con includeSelectors: true |
Sí | Solo en un despliegue nuevo desde cero |
commonLabels |
Sí (equivale a true) |
Desaconsejado; existe por compatibilidad |
commonAnnotations |
N/A (no son selectores) | Sin riesgo |
Regla de Rutas Norte: las etiquetas de selector se definen una vez en la base (app: api-reservas) y no se tocan nunca. Todo lo demás se añade con includeSelectors: false.
images
images:
- { name: registry.rutasnorte.example/api-reservas, newTag: "2.4.0" }
# Con digest presente, es el digest lo que manda (08-05)
- name: registry.rutasnorte.example/tienda-web
newTag: "3.1.2"
digest: sha256:9c1e4a7b3d2f8e6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a
# Cambiar el registro entero (réplica en otra región)
- name: registry.rutasnorte.example/worker-notificaciones
newName: registry-eu.rutasnorte.example/worker-notificaciones
newTag: "1.8.4"Kustomize busca el campo image en cualquier tipo que lo tenga, incluidos los initContainers. No hay que decirle dónde mirar. Esto es lo que la canalización ci-rutasnorte modifica en cada despliegue:
cd k8s/entornos/pre
kustomize edit set image registry.rutasnorte.example/api-reservas=registry.rutasnorte.example/api-reservas:rc-${GIT_SHA}kustomize edit modifica el kustomization.yaml en el disco. Combinado con un commit automático, es la pieza que conecta la construcción de la imagen con GitOps (10-05).
replicas
Ahorra escribir un parche para algo tan común. Pero atención, retomando 09-01: si api-reservas tiene un HPA, declarar replicas aquí es contraproducente. Cada kubectl apply -k devolverá el Deployment a 4 réplicas aunque el HPA lo tenga en 15 por la carga del puente de mayo, con un bajón de capacidad hasta que el HPA reaccione.
Para componentes con HPA, la solución correcta es no declarar replicas en ninguna parte (ni en la base ni en la superposición) y dejar que minReplicas del HPA gobierne. Kubernetes no exige el campo: por defecto vale 1 y el HPA lo sube inmediatamente. La pieza que falta —que Argo CD no marque deriva cuando el HPA cambie ese campo— la resolveremos en 10-05 con ignoreDifferences.
- Parches: fusión estratégica y JSON Patch
Los campos anteriores cubren lo habitual. Para todo lo demás, hay parches.
Fusión estratégica (strategic merge)
Escribes un YAML parcial con la misma estructura del objeto. Kustomize lo fusiona entendiendo la semántica de los tipos de Kubernetes.
# k8s/entornos/pro/recursos-patch.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-reservas # nombre y tipo identifican qué objeto parchear
spec:
template:
spec:
containers:
# 'name' es la clave de correlación de la lista de contenedores:
# Kustomize fusiona con el contenedor 'api', NO sustituye la lista.
- name: api
resources:
requests: { cpu: 250m, memory: 512Mi }
limits: { memory: 1Gi }
env:
- { name: LOG_LEVEL, value: warn }Lo que hace especial a la fusión estratégica es que Kustomize conoce el esquema: sabe que containers se correlaciona por name, ports por containerPort, volumeMounts por mountPath. Un parche que solo menciona el contenedor api deja intactos los sidecars.
Se puede escribir en línea con patch: |- dentro del kustomization.yaml, útil para cambios pequeños. Y hay dos directivas especiales: $patch: replace sustituye una lista entera en vez de fusionarla, y $patch: delete elimina el elemento que coincida.
JSON Patch (RFC 6902)
Cuando la fusión estratégica no llega —porque hay que operar sobre índices de lista, o sobre un CRD que Kustomize no conoce— se usa JSON Patch: una lista de operaciones explícitas.
patches:
- target: { kind: Deployment, name: api-reservas }
patch: |-
# '-' significa "añadir al final de la lista"
- op: add
path: /spec/template/spec/containers/0/env/-
value: { name: REGION, value: eu-oeste }
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: 2Gi
- op: remove
path: /spec/template/spec/containers/0/livenessProbe/initialDelaySeconds
# '/' y '~' en una clave se escapan como ~1 y ~0
- op: add
path: /spec/template/metadata/annotations/rutasnorte.example~1revision
value: "7"Detalles que hay que conocer: las rutas empiezan en la raíz del objeto y los índices de lista son numéricos (/spec/template/spec/containers/0/); replace falla si la ruta no existe, mientras que add la crea o la sustituye, así que ante la duda usa add.
El campo target: a quién se aplica el parche
Aquí está la potencia real del sistema. Un parche puede dirigirse a muchos objetos a la vez.
patches:
- target: { kind: Deployment, name: api-reservas }
path: recursos-patch.yaml
# A TODOS los Deployments (el nombre admite expresión regular)
- target: { kind: Deployment, name: ".*" }
patch: |-
- op: add
path: /spec/template/metadata/annotations/rutasnorte.example~1revisado
value: "2026-08-05"
# Por etiqueta
- target: { labelSelector: "app.kubernetes.io/part-of=rutas-norte", kind: Deployment }
patch: |-
- op: add
path: /spec/template/spec/priorityClassName
value: rutas-norte-alta
# Por grupo y versión de API: necesario con CRDs
- target: { group: keda.sh, version: v1alpha1, kind: ScaledObject, name: worker-notificaciones }
patch: |-
- op: replace
path: /spec/maxReplicaCount
value: 30Los selectores disponibles en target son kind, name (exacto o regex), namespace, group, version, labelSelector y annotationSelector.
patchesStrategicMerge y patchesJson6902 están obsoletos
Verás mucho código antiguo con esos dos campos. El campo unificado patches reemplaza a ambos: detecta automáticamente si el contenido es una fusión estratégica o un JSON Patch, y admite target con selectores en los dos casos, que los antiguos no permitían.
| Campo obsoleto | Sustituto | Ventaja del nuevo |
|---|---|---|
patchesStrategicMerge |
patches con path |
Admite target con selectores |
patchesJson6902 |
patches con target |
Sintaxis unificada, parche en línea |
commonLabels |
labels con includeSelectors |
Control sobre los selectores inmutables |
bases |
resources |
Un solo concepto |
vars |
replacements |
Más potente y predecible |
kustomize edit fix migra los campos obsoletos automáticamente.
- Generadores de ConfigMaps y Secrets
Esta es la funcionalidad más elegante de Kustomize, y resuelve limpiamente un problema que en 03-03 solucionábamos a mano.
configMapGenerator:
# A partir de literales
- name: api-reservas-config
literals: [LOG_LEVEL=info, RESERVA_TTL_MINUTOS=15, CACHE_HOST=redis-cache]
# A partir de ficheros: cada fichero es una clave con su contenido
- name: api-reservas-plantillas
files:
- config/app.propiedades
- plantilla-correo=config/correo-reserva.html # renombrar la clave
# A partir de un fichero de variables: cada línea, una clave
- name: api-reservas-entorno
envs: [config/produccion.env]El sufijo de hash: la joya de la corona
Kustomize añade un hash del contenido al nombre. Y —esto es lo importante— actualiza automáticamente todas las referencias, así que el Deployment queda con configMapRef: { name: api-reservas-config-9t2hmf6b4d }.
Piensa en lo que implica. Cuando cambias LOG_LEVEL de info a warn: cambia el contenido → cambia el hash → cambia la referencia en el Deployment → cambia la plantilla del pod → el Deployment hace un rollout automáticamente.
flowchart LR
A["Cambias<br/>config.env"] --> B["Hash nuevo:<br/>...-c7d4k9m2t8"] --> C["Cambia la referencia<br/>en el Deployment"]
C --> D["Cambia la plantilla<br/>del pod"] --> E["Rollout automático<br/>con la config nueva"]
style E fill:#e8ffe8
Esto resuelve, de forma nativa y sin trucos, el problema de 03-03. Allí explicamos que un ConfigMap actualizado no reinicia los pods y que el apaño era añadir a mano una anotación con el hash. Con Kustomize no hay apaño: es el comportamiento por defecto. Y comparado con Helm (10-03), donde había que escribir checksum/config: {{ include ... | sha256sum }} en cada plantilla, aquí no escribes nada.
Ventaja adicional: el rollback funciona de verdad. Como cada versión de la configuración es un objeto distinto con nombre distinto, un kubectl rollout undo devuelve el pod a la referencia anterior, y el ConfigMap viejo sigue existiendo mientras algún ReplicaSet lo referencie. Con un ConfigMap de nombre fijo, el rollback devolvería el código viejo pero con la configuración nueva: lo peor de ambos mundos.
Desactivar el hash
También por generador, con options: { disableNameSuffixHash: true } dentro de una entrada concreta.
¿Cuándo desactivarlo? Solo cuando algo externo referencia el ConfigMap por un nombre fijo que Kustomize no puede actualizar: un CRD de un operador que Kustomize no sabe interpretar, un pod que relee el ConfigMap en caliente a propósito, o uno compartido entre aplicaciones gestionadas por sistemas distintos. Consejo firme: no lo desactives sin una razón concreta. El hash es la mejor característica de Kustomize.
behavior: extender un generador de la base
Si la base define api-reservas-config con LOG_LEVEL=info, RESERVA_TTL_MINUTOS=15 y CACHE_HOST=redis-cache, y la superposición de producción declara:
configMapGenerator:
- name: api-reservas-config
behavior: merge # <-- fusiona con el de la base
envs: [config.env] # LOG_LEVEL=warn, PASARELA_URL=...El resultado combina las tres claves heredadas con LOG_LEVEL sobreescrito y PASARELA_URL añadida.
behavior |
Efecto |
|---|---|
| (sin especificar) | Crea uno nuevo; falla si ya existe con ese nombre |
merge |
Fusiona con el de la base, sobreescribiendo las claves coincidentes |
replace |
Sustituye completamente el de la base |
secretGenerator
Misma mecánica, con codificación base64 automática y soporte de type: kubernetes.io/tls para certificados desde ficheros.
Advertencia crítica:
secretGeneratorno cifra nada. Base64 no es cifrado (03-02). Si pones la contraseña depostgres-reservasen unliteralso en un fichero del repositorio, esa contraseña está en claro en Git para siempre, incluso si luego la borras.
La forma correcta en Rutas Norte, retomando lo anunciado en 03-02, es referenciar un Secret cifrado con SOPS o Sealed Secrets, o mejor aún un recurso del External Secrets Operator que va a buscar el valor a Vault:
# k8s/entornos/pro/external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata: { name: api-reservas-credenciales }
spec:
refreshInterval: 1h
secretStoreRef: { name: vault-rutasnorte, kind: ClusterSecretStore }
target: { name: api-reservas-credenciales, creationPolicy: Owner }
data:
- secretKey: BD_PASSWORD
remoteRef: { key: rutas-norte/pro/postgres, property: password }
- secretKey: PASARELA_TOKEN
remoteRef: { key: rutas-norte/pro/pagos, property: token }Ese fichero es inocuo: solo dice dónde está el secreto, no cuál es. Volveremos a ello en 10-05.
- Componentes: trozos opcionales reutilizables
Un componente es como una superposición, pero pensado para ser incluido por varias. Resuelve el caso "esto lo quiero en pre y en pro, pero no en dev". Rutas Norte tiene tres candidatos claros: las NetworkPolicies (04-06), la alta disponibilidad (09-05) y la observabilidad (07-03). En desarrollo estorban; en los otros dos entornos son obligatorios.
# k8s/componentes/politicas-red/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component # <-- Component, NO Kustomization
resources: [netpol.yaml]# k8s/componentes/politicas-red/netpol.yaml (fragmento)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: postgres-solo-api }
spec:
podSelector:
matchLabels: { app: postgres-reservas }
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: { app: api-reservas }
ports: [{ protocol: TCP, port: 5432 }]Un componente puede tener también parches, no solo recursos:
# k8s/componentes/alta-disponibilidad/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
resources: [pdb.yaml]
patches:
- target: { kind: Deployment, labelSelector: "app.kubernetes.io/part-of=rutas-norte" }
path: topologia-patch.yaml# topologia-patch.yaml — el 'target' manda, así que el nombre se ignora
apiVersion: apps/v1
kind: Deployment
metadata: { name: NO-IMPORTA }
spec:
template:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app.kubernetes.io/part-of: rutas-norte }Y las superposiciones lo activan: dev no lleva ninguno, pre lleva politicas-red y observabilidad, y pro añade además alta-disponibilidad.
| Base | Superposición | Componente | |
|---|---|---|---|
kind / apiVersion |
Kustomization / v1beta1 |
Kustomization / v1beta1 |
Component / v1alpha1 |
| Se usa desde | resources |
Se aplica directamente | components |
| Cuántas veces | Una por superposición | Una | Varias superposiciones |
| Propósito | Lo común | Un entorno concreto | Una capacidad opcional |
Los componentes se aplican en el orden en que aparecen, después de los resources. Si dos parchean el mismo campo, gana el último.
- Flujo de trabajo: kustomize, diff y apply
# 1. VER el resultado antes de nada. Siempre el primer paso.
kubectl kustomize k8s/entornos/pro > /tmp/pro-generado.yaml
# 2. Validar contra el servidor (esquema real + webhooks de admisión)
kubectl kustomize k8s/entornos/pro | kubectl apply --dry-run=server -f -
# 3. COMPARAR con lo que hay en el clúster. El paso decisivo.
kubectl diff -k k8s/entornos/pro--- LIVE
+++ MERGED
envFrom:
- configMapRef:
- name: api-reservas-config-9t2hmf6b4d
+ name: api-reservas-config-c7d4k9m2t8
resources:
limits:
- memory: 1Gi
+ memory: 2GiEsa salida cuenta exactamente lo que va a pasar: cambia el hash de la configuración (habrá rollout) y sube el límite de memoria. Es el equivalente a helm diff de la lección anterior, pero integrado en kubectl, sin plugins.
# 4. Aplicar y verificar
kubectl apply -k k8s/entornos/pro
kubectl rollout status deploy/api-reservas -n rutas-norte-pro --timeout=300sPoda y validación en la canalización
Un problema real: si borras un manifiesto del repositorio, kubectl apply -k no borra el objeto del clúster. Se queda huérfano. Existe --prune con --applyset y --prune-allowlist, pero es engorroso y hay que enumerar los tipos. Es otro de los problemas que GitOps (10-05) resuelve de forma nativa: Argo CD y Flux saben qué objetos les pertenecen y los podan solos.
#!/usr/bin/env bash
# ci/validar-manifiestos.sh — gratis y sin clúster, ideal para cada PR
set -euo pipefail
for entorno in dev pre pro; do
kustomize build "k8s/entornos/${entorno}" > "/tmp/${entorno}.yaml" # ¿YAML válido?
kubeconform -strict -summary "/tmp/${entorno}.yaml" # ¿esquema?
kyverno apply politicas/ --resource "/tmp/${entorno}.yaml" # ¿políticas (08-03)?
done
- Migración completa de Rutas Norte
El antes y el después
ANTES DESPUÉS
k8s/ k8s/
├── dev/ 38 ficheros ├── base/ 26 ficheros
├── pre/ 38 ficheros ├── componentes/ 7 ficheros
└── pro/ 38 ficheros └── entornos/ 10 ficheros
= 114 ficheros = 43 ficherosY lo importante no es el número de ficheros, sino que cada línea de configuración existe una sola vez.
El proceso, paso a paso
Paso 1: elegir la base. El entorno más simple, normalmente dev. Copiar sus manifiestos a k8s/base/, quitándoles el namespace (lo pondrá la superposición) con yq -i 'del(.metadata.namespace)' k8s/base/**/*.yaml.
Paso 2: escribir los kustomization.yaml de la base (apartado 3).
Paso 3: calcular el diferencial real de cada entorno.
< namespace: rutas-norte-dev > namespace: rutas-norte-pro
< replicas: 1 > replicas: 4
< image: ...api-reservas:dev-abc > image: ...api-reservas:2.4.0
< requests: {cpu: 50m, mem: 64Mi} > requests: {cpu: 250m, mem: 512Mi}Cuatro diferencias. Tres se resuelven con campos de kustomization.yaml (namespace, replicas, images) y solo una necesita parche (resources).
Paso 4: escribir las superposiciones.
# k8s/entornos/pro/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: rutas-norte-pro
resources:
- ../../base
- external-secret.yaml
components:
- ../../componentes/politicas-red
- ../../componentes/observabilidad
- ../../componentes/alta-disponibilidad
labels:
- includeSelectors: false
pairs: { entorno: pro }
commonAnnotations:
rutasnorte.example/equipo: plataforma
rutasnorte.example/criticidad: alta
images:
- name: registry.rutasnorte.example/api-reservas
newTag: "2.4.0"
digest: sha256:9c1e4a7b3d2f8e6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a
- { name: registry.rutasnorte.example/tienda-web, newTag: "3.1.2" }
# tienda-web y api-reservas NO llevan 'replicas': los gobierna el HPA (09-01)
replicas:
- { name: informes-ocupacion-lanzador, count: 1 }
configMapGenerator:
- name: api-reservas-config
behavior: merge
envs: [config.env]
patches:
- path: recursos-patch.yaml
- path: ingress-patch.yaml # certificado real de Let's Encrypt (04-05)
- target: { kind: HorizontalPodAutoscaler, name: api-reservas }
patch: |-
- { op: replace, path: /spec/minReplicas, value: 4 }
- { op: replace, path: /spec/maxReplicas, value: 20 }Paso 5: verificar que la migración no cambia nada. Este es el paso que da confianza.
Un diff vacío significa que puedes hacer kubectl apply -k y no pasará absolutamente nada. La migración es una operación sin riesgo.
Paso 6: borrar los directorios viejos con git rm -r k8s/dev k8s/pre k8s/pro y actualizar la canalización.
El resultado en cifras
| Métrica | Antes | Después |
|---|---|---|
| Ficheros YAML / líneas totales | 114 / ~4 800 | 43 / ~1 700 |
Sitios donde cambiar el límite de memoria de api-reservas |
3 | 1 |
| Riesgo de que un entorno se quede desincronizado | Alto | Nulo por construcción |
| Reinicio al cambiar la configuración | Manual (anotación) | Automático (hash) |
| Poder responder "¿qué hay en pro?" | No | kubectl kustomize k8s/entornos/pro |
- Helm frente a Kustomize
| Criterio | Helm | Kustomize |
|---|---|---|
| Curva de aprendizaje | Alta: Go templates, Sprig, nindent |
Baja: un fichero declarativo |
| Legibilidad de los fuentes | Baja: {{- if }}, {{ toYaml | nindent }} |
Alta: los manifiestos son YAML válido |
| Legibilidad de la personalización | Alta: values.yaml es plano y explícito |
Media: hay que seguir la cadena de parches |
| Instalación | Binario aparte | Integrado en kubectl |
| Distribución a terceros | Excelente: .tgz versionado, OCI |
Pobre: se comparte un repositorio Git |
| Ecosistema | Enorme | Escaso: apenas hay bases publicadas |
| Estado en el clúster | Sí: Secrets de release | No: solo genera YAML |
| Rollback y limpieza integrados | Sí: rollback, uninstall |
No: poda manual y engorrosa |
| Hooks y ordenación | Sí | No (lo cubren las ondas de Argo CD, 10-05) |
| Condicionales y bucles | Sí: lógica arbitraria | No: components es lo más parecido |
| Rollout al cambiar la configuración | Manual (checksum/config) |
Automático (hash en el nombre) |
| Validación con herramientas estándar | No hasta renderizar | Sí |
| Cambios que el autor no previó | Difícil: hay que bifurcar el chart | Fácil: un parche llega a cualquier campo |
La regla práctica del sector
flowchart TB
Q{"¿De quién es<br/>este software?"}
Q -->|"De terceros:<br/>cert-manager, Prometheus,<br/>ingress-nginx, KEDA"| H["**Helm**<br/>consumir el chart oficial<br/>con un values.yaml versionado"]
Q -->|"Nuestro:<br/>tienda-web, api-reservas,<br/>worker-notificaciones"| K["**Kustomize**<br/>base + superposiciones"]
H --> G["Ambos versionados en Git<br/>y desplegados por GitOps (10-05)"]
K --> G
style H fill:#fff4e8
style K fill:#e8f4ff
Helm para consumir, Kustomize para producir. Es la decisión de Rutas Norte y la de la mayoría de equipos maduros: nadie quiere mantener a mano los 60 objetos de kube-prometheus-stack, y nadie quiere convertir su propio Deployment en una plantilla ilegible. La excepción: si distribuyes tu software a clientes que lo instalan en sus clústeres, necesitas un paquete versionado y configurable, y ahí Helm no tiene rival.
- Combinar Helm y Kustomize
Hay dos formas de usar las dos herramientas juntas, y sirven para lo mismo: modificar un chart de terceros en un campo que su autor no parametrizó.
Forma A: helm template y kustomizar la salida
helm template monitorizacion prometheus-community/kube-prometheus-stack \
--version 65.1.1 --namespace monitorizacion \
-f plataforma/valores-pro.yaml --include-crds \
> plataforma/generado/kube-prometheus-stack.yaml# plataforma/generado/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: monitorizacion
resources: [kube-prometheus-stack.yaml]
patches:
# Lo que el chart NO permite configurar, lo parcheamos aquí
- target: { kind: Deployment, name: monitorizacion-grafana }
patch: |-
- op: add
path: /spec/template/spec/containers/0/env/-
value: { name: GF_FEATURE_TOGGLES_ENABLE, value: "traceToMetrics" }| Ventaja | Inconveniente |
|---|---|
| El YAML generado va a Git: sabes exactamente qué se despliega | Hay que regenerar a mano al actualizar el chart |
| Puedes parchear cualquier campo | El fichero generado es enorme y los diffs son ruidosos |
| Revisable en una petición de cambio, reproducible bit a bit | Pierdes helm rollback y helm history |
Forma B: el generador helmCharts
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: monitorizacion
helmCharts:
- name: kube-prometheus-stack
repo: https://prometheus-community.github.io/helm-charts
version: 65.1.1
releaseName: monitorizacion
valuesFile: valores-pro.yaml
includeCRDs: true
patches:
- target: { kind: Deployment, name: monitorizacion-grafana }
patch: |-
- op: add
path: /spec/template/spec/containers/0/env/-
value: { name: GF_FEATURE_TOGGLES_ENABLE, value: "traceToMetrics" }# Requiere permiso explícito porque ejecuta un binario externo
kustomize build --enable-helm plataforma/monitorizacionUn solo fichero declara todo y actualizar es cambiar el número de versión, pero necesita --enable-helm y tener helm instalado, el resultado no está en Git, y el soporte es desigual (kubectl -k no lo admite bien; Argo CD y Flux requieren configuración extra).
Recomendación para Rutas Norte: la forma A. Tener el YAML generado en Git es exactamente lo que hace que GitOps funcione bien: cualquiera puede leer el repositorio y saber qué hay desplegado.
Errores Comunes y Consejos
1. Usar commonLabels (o labels con includeSelectors: true) sobre algo ya desplegado. Modifica spec.selector, que es inmutable, y el apply falla. La única salida es borrar y recrear, con corte de servicio. Usa siempre includeSelectors: false.
2. Declarar replicas en un componente con HPA. Cada apply deshace el escalado automático hasta que el HPA reacciona. Para lo que tenga HPA, no declares replicas en ningún sitio.
3. Secretos en secretGenerator con literals. Base64 no es cifrado. Si escribes la contraseña de postgres-reservas en un fichero del repositorio, está en claro en el historial de Git para siempre. Usa SOPS, Sealed Secrets o External Secrets Operator.
4. Desactivar disableNameSuffixHash sin necesidad. Pierdes el rollout automático al cambiar la configuración, que es la mejor característica de Kustomize.
5. Recursos remotos sin ?ref=. Tu despliegue cambia cuando un desconocido hace un commit. Fija siempre etiqueta o commit.
6. Poner demasiado en la base. Si la base contiene cosas que la mitad de los entornos tienen que parchear para quitar, está mal diseñada. La base es el mínimo común; lo opcional va en componentes.
7. Cadenas de superposiciones muy profundas. Base → común → región → entorno → cliente es técnicamente posible y humanamente impracticable: nadie sabe de dónde sale un valor. Dos niveles, tres como mucho.
8. Parche que no se aplica y nadie se entera. Si el target no casa con nada, Kustomize no siempre avisa. Verifica siempre con kubectl kustomize que el cambio aparece en la salida.
9. replace en un JSON Patch sobre una ruta que no existe. Falla con "missing value". Usa add, que crea o sustituye.
10. Olvidar escapar / en las claves de un JSON Patch. rutasnorte.example/revision se escribe rutasnorte.example~1revision.
11. Aplicar sin kubectl diff -k antes. Es gratis, tarda dos segundos y te enseña exactamente qué va a cambiar. En producción debería ser obligatorio.
12. Suponer que las listas se fusionan siempre. En la fusión estratégica, las listas con clave de correlación conocida (containers por name) se fusionan; las que no la tienen (args, command) se sustituyen enteras.
13. Editar objetos a mano con kubectl edit y olvidarlo. Kustomize no reconcilia: solo actúa cuando alguien ejecuta apply. El siguiente despliegue pisará el cambio manual sin avisar. Este es exactamente el problema que resuelve GitOps.
Ejercicios
Ejercicio 1: superposición de preproducción completa
Partiendo de la base descrita en la lección, escribe k8s/entornos/pre/kustomization.yaml que: use el namespace rutas-norte-pre, etiquete todo con entorno: pre sin tocar los selectores, fije las imágenes a rc-2.4.0, active los componentes de políticas de red y observabilidad, fusione un ConfigMap con LOG_LEVEL=info y una URL de pasarela de pruebas, y aplique un parche que suba los recursos a la mitad de los de producción. Verifica el resultado sin aplicar nada.
Ejercicio 2: parche JSON dirigido por etiqueta
Escribe un parche que añada a todos los Deployments etiquetados con app.kubernetes.io/part-of: rutas-norte un initContainer que espere a que postgres-reservas esté accesible antes de arrancar (06-04), sin modificar ningún fichero de la base. Explica por qué necesitas JSON Patch y no fusión estratégica.
Ejercicio 3: demostrar el rollout automático por hash
Con la superposición de dev desplegada, demuestra en tres pasos que cambiar una línea de config.env provoca un rollout automático: captura el nombre del ConfigMap y la generación del Deployment antes, cambia el valor, aplica, y compara. Explica por qué esto no ocurriría con un ConfigMap de nombre fijo.
Soluciones
Solución 1
# k8s/entornos/pre/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: rutas-norte-pre
resources: [../../base]
components:
- ../../componentes/politicas-red
- ../../componentes/observabilidad
labels:
- includeSelectors: false # CLAVE: no tocar los selectores inmutables
pairs: { entorno: pre }
images:
- { name: registry.rutasnorte.example/api-reservas, newTag: rc-2.4.0 }
- { name: registry.rutasnorte.example/tienda-web, newTag: rc-3.1.2 }
configMapGenerator:
- name: api-reservas-config
behavior: merge
envs: [config.env]
patches:
- path: recursos-patch.yaml# k8s/entornos/pre/config.env
LOG_LEVEL=info
PASARELA_URL=https://pagos-sandbox.proveedorexterno.example/v2# k8s/entornos/pre/recursos-patch.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: api-reservas }
spec:
template:
spec:
containers:
- name: api
resources:
requests: { cpu: 125m, memory: 256Mi }
limits: { memory: 512Mi }kubectl kustomize k8s/entornos/pre | grep -E 'namespace:|entorno:|image:|memory:'
kubectl kustomize k8s/entornos/pre | kubectl apply --dry-run=server -f -
kubectl diff -k k8s/entornos/preSolución 2
patches:
- target:
kind: Deployment
labelSelector: "app.kubernetes.io/part-of=rutas-norte"
patch: |-
- op: add
path: /spec/template/spec/initContainers
value: []
- op: add
path: /spec/template/spec/initContainers/-
value:
name: esperar-bd
image: postgres:16.4
command: ["sh","-c","until pg_isready -h postgres-reservas -p 5432; do sleep 2; done"]
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 10001
capabilities: { drop: ["ALL"] }
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { memory: 64Mi }Nota: la primera operación vaciaría una lista initContainers ya existente. Si algunos Deployments ya tienen init containers que hay que conservar, la solución robusta es separar en dos parches con target distintos.
Por qué JSON Patch: la fusión estratégica exige un fichero por objeto con su metadata.name exacto, así que no puede dirigirse a "todos los que casen con una etiqueta". JSON Patch combinado con labelSelector en target alcanza N objetos con una sola declaración, y /- permite añadir al final sin conocer cuántos elementos había.
Solución 3
# --- ANTES ---
kubectl get deploy api-reservas -n rutas-norte-dev \
-o jsonpath='{.metadata.generation}{"\n"}{.spec.template.spec.containers[0].envFrom[0].configMapRef.name}{"\n"}'
# 4
# api-reservas-config-9t2hmf6b4d
# --- CAMBIO ---
sed -i 's/LOG_LEVEL=debug/LOG_LEVEL=info/' k8s/entornos/dev/config.env
kubectl diff -k k8s/entornos/dev # se ve el cambio de nombre del ConfigMap
kubectl apply -k k8s/entornos/dev
# --- DESPUÉS --- -> 5 y api-reservas-config-c7d4k9m2t8
kubectl rollout status deploy/api-reservas -n rutas-norte-devExplicación: el hash forma parte del nombre del ConfigMap, y ese nombre aparece dentro de spec.template del Deployment. Al cambiar la plantilla del pod, el controlador crea un ReplicaSet nuevo y ejecuta el despliegue gradual (02-03).
Por qué no ocurriría con nombre fijo: spec.template seguiría siendo byte a byte idéntico, metadata.generation no cambiaría, y no habría ReplicaSet nuevo. Los pods seguirían con las variables viejas hasta que alguien ejecutara kubectl rollout restart a mano. Ese es justamente el apaño de la anotación checksum/config de 03-03, que aquí no hace falta.
Conclusión
Kustomize ha reducido el k8s/ de Rutas Norte de 114 ficheros duplicados a 43, con cada valor definido una sola vez. Lo esencial:
- Sin plantillas: los manifiestos de
k8s/base/siguen siendo YAML de Kubernetes válido, aplicable conkubectl apply -fy validable por el editor. Lo que cambia entre entornos se declara como transformaciones en la superposición. - Viene en kubectl (
apply -k,diff -k,kubectl kustomize), aunque el binario propio va más al día y es el que conviene fijar en la canalización. - El modelo base + superposiciones + componentes cubre los tres ejes reales: lo común, lo propio de cada entorno, y las capacidades opcionales que solo quieren algunos entornos.
namespace,images,replicasylabelsresuelven la mayoría de diferencias sin escribir un parche. Ylabelsdebe llevarincludeSelectors: false:commonLabelstoca los selectores inmutables y rompe despliegues en marcha.- Para lo demás hay
patches, con fusión estratégica o JSON Patch, y untargetdirigible por tipo, nombre, etiqueta o anotación.patchesStrategicMergeypatchesJson6902están obsoletos. - Los generadores con su sufijo de hash son la joya: cambiar una línea de configuración cambia el nombre del ConfigMap, cambia la plantilla del pod y provoca un rollout automático. Resuelve de forma nativa lo que en 03-03 arreglábamos a mano, y además hace que el rollback devuelva código y configuración a la vez.
- Y en la comparación honesta con Helm, la regla es clara: Helm para consumir software de terceros, Kustomize para las aplicaciones propias, con dos formas de combinarlos cuando hace falta parchear un chart ajeno.
Pero queda un problema que ni Helm ni Kustomize resuelven, y que ha aparecido en las dos lecciones. Ambas herramientas solo actúan cuando alguien ejecuta una orden. Si alguien hace kubectl edit en producción, ninguna se entera. Si el portátil de quien despliega se estropea, nadie sabe desplegar. Si la canalización ci-rutasnorte necesita credenciales de administrador del clúster para ejecutar kubectl apply, tenemos un problema de seguridad serio que contradice el RBAC mínimo que definimos en 08-01.
En la siguiente lección, GitOps con Argo CD y Flux, damos el paso final: un agente que vive dentro del clúster, tira de Git continuamente y reconcilia la realidad con lo declarado. Veremos el recurso Application de Argo CD, los ApplicationSet para desplegar los tres entornos desde una sola definición, el equivalente en Flux, las tres soluciones al problema de los secretos en un repositorio, y por fin cerraremos el conflicto entre el HPA y el campo replicas que dejamos anunciado en 09-01.
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
