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

  1. La filosofía sin plantillas
  2. Kustomize dentro de kubectl y como binario propio
  3. El modelo base + superposiciones
  4. kustomization.yaml campo a campo
  5. Parches: fusión estratégica y JSON Patch
  6. Generadores de ConfigMaps y Secrets
  7. Componentes: trozos opcionales reutilizables
  8. Flujo de trabajo: kustomize, diff y apply
  9. Migración completa de Rutas Norte
  10. Helm frente a Kustomize
  11. Combinar Helm y Kustomize
  12. Errores comunes y consejos
  13. Ejercicios
  14. Conclusión

  1. 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

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.

  1. 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/pro

Ventaja 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.

  1. 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

  1. kustomization.yaml campo a campo

resources

Ficheros, directorios con su propio kustomization.yaml, o URL remotas:

resources:
  - deployment.yaml
  - ../../base
  - github.com/kubernetes-sigs/kustomize/examples/multibases?ref=v5.4.3

Sobre 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-norte

commonLabels (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 immutable

La ú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 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

replicas:
  - { name: informes-ocupacion-lanzador, count: 1 }

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.

  1. 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: 30

Los 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.

  1. 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

kubectl kustomize k8s/entornos/pro | grep -A3 'kind: ConfigMap'
kind: ConfigMap
metadata:
  name: api-reservas-config-9t2hmf6b4d
  namespace: rutas-norte-pro

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

generatorOptions:
  disableNameSuffixHash: true
  labels: { generado-por: kustomize }

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: secretGenerator no cifra nada. Base64 no es cifrado (03-02). Si pones la contraseña de postgres-reservas en un literals o 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.

  1. 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.

  1. 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: 2Gi

Esa 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=300s

Poda 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

  1. 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 ficheros

Y 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.

diff k8s/dev/api-reservas-deployment.yaml k8s/pro/api-reservas-deployment.yaml
<   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.

kubectl diff -k k8s/entornos/pro

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

  1. 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 : rollback, uninstall No: poda manual y engorrosa
Hooks y ordenación No (lo cubren las ondas de Argo CD, 10-05)
Condicionales y bucles : 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
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.

  1. 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/monitorizacion

Un 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/pre

Solució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-dev

Explicació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 con kubectl apply -f y 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, replicas y labels resuelven la mayoría de diferencias sin escribir un parche. Y labels debe llevar includeSelectors: false: commonLabels toca los selectores inmutables y rompe despliegues en marcha.
  • Para lo demás hay patches, con fusión estratégica o JSON Patch, y un target dirigible por tipo, nombre, etiqueta o anotación. patchesStrategicMerge y patchesJson6902 está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

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