Llegamos al problema que abrió este módulo. El directorio k8s/ de Rutas Norte tiene más de 120 ficheros YAML. tienda-web-dev.yaml, tienda-web-pre.yaml y tienda-web-pro.yaml son idénticos salvo en el namespace, el número de réplicas, la etiqueta de la imagen y dos límites de recursos. Multiplica eso por seis componentes, cada uno con su Deployment, Service, HPA, ConfigMap, Ingress y NetworkPolicy, y tienes el desastre actual: cada cambio hay que replicarlo tres veces, y nadie está seguro de qué versión corre en producción.

Helm es el gestor de paquetes de Kubernetes, y es la primera de las dos respuestas a ese problema (la otra, Kustomize, es la lección siguiente). Ya lo hemos usado dos veces en el curso sin explicarlo: para instalar cert-manager en 04-05 y kube-prometheus-stack en 07-03. En esta lección vamos a entender qué estaba pasando realmente en esas órdenes, y después construiremos el chart de Rutas Norte desde cero.

Contenido

  1. El problema de Rutas Norte y cómo lo aborda Helm
  2. Conceptos: chart, release, repositorio y valores
  3. Consumir charts de terceros con criterio
  4. Estructura de un chart
  5. Plantillas: sintaxis, funciones y control de flujo
  6. Crear el chart de Rutas Norte
  7. Valores por entorno
  8. El ciclo de vida de una release
  9. Depuración: template, lint, dry-run y diff
  10. Dependencias y subcharts
  11. Hooks
  12. Empaquetar y publicar
  13. Qué hace mal Helm
  14. Errores comunes y consejos
  15. Ejercicios
  16. Conclusión

  1. El problema de Rutas Norte y cómo lo aborda Helm

El diferencial real entre dev y pro para api-reservas es este:

Campo dev pre pro
metadata.namespace rutas-norte-dev rutas-norte-pre rutas-norte-pro
spec.replicas 1 2 4
image :dev-abc123 :rc-2.4.0 :2.4.0@sha256:...
resources.limits.memory 256Mi 512Mi 1Gi
HPA maxReplicas 3 5 20

Cinco valores. Y por cinco valores mantenemos 114 líneas duplicadas por componente.

Helm resuelve esto con una idea sencilla: convierte los manifiestos en plantillas y saca los valores variables a un fichero aparte.

flowchart LR
    T["templates/<br/>deployment.yaml"] --> R{{"helm<br/>render"}}
    V1["values-dev.yaml"] --> R
    V2["values-pre.yaml"] --> R
    V3["values-pro.yaml"] --> R
    R --> M1["Manifiestos dev"]
    R --> M2["Manifiestos pre"]
    R --> M3["Manifiestos pro"]
    M1 --> K[(Clúster)]
    M2 --> K
    M3 --> K

Pero Helm hace algo más que sustituir variables, y esto es lo que lo diferencia de un simple sed: gestiona el ciclo de vida completo de la instalación. Sabe qué objetos instaló, en qué versión, y puede desinstalarlos todos o volver atrás con una orden.

  1. Conceptos: chart, release, repositorio y valores

  • Chart: el paquete. Un directorio (o .tgz) con las plantillas, los valores por defecto y los metadatos. Es el equivalente a un .deb. No está instalado: es material inerte.
  • Release: una instalación concreta de un chart, con nombre. El mismo chart puede instalarse varias veces, y cada release tiene su historial independiente.
  • Repositorio: un servidor HTTP (o un registro OCI) con charts empaquetados y un index.yaml que los cataloga.
  • Valores: la configuración. Se resuelven por capas, y lo de más abajo gana: values.yaml del chart → cada -f fichero.yaml en orden (el último gana) → --set de la línea de órdenes.

Helm es un gestor de paquetes con estado

Esta es la característica más malinterpretada. Cuando ejecutas helm install, Helm renderiza las plantillas y las aplica, pero además guarda una copia de todo en un Secret dentro del namespace de la release.

kubectl get secrets -n rutas-norte-dev -l owner=helm
NAME                                TYPE                 DATA   AGE
sh.helm.release.v1.rutas-norte.v1   helm.sh/release.v1   1      3d
sh.helm.release.v1.rutas-norte.v2   helm.sh/release.v1   1      2d
sh.helm.release.v1.rutas-norte.v3   helm.sh/release.v1   1      4h

Cada revisión es un Secret con el chart completo, los valores usados y los manifiestos renderizados, comprimidos y en base64.

Consecuencia Explicación
helm rollback es posible Helm sabe exactamente qué había antes
helm uninstall borra todo lo instalado Sabe qué objetos son suyos
Si alguien hace kubectl edit, Helm no se entera El estado guardado y el real divergen
Si borras esos Secrets, Helm "pierde" la release Aunque los objetos sigan en el clúster
El estado vive en el clúster, no en Git La crítica principal a Helm, que resuelve GitOps (10-05)

Helm 2 tenía un componente en el servidor llamado Tiller, con permisos de administrador, que fue un problema de seguridad notorio. Helm 3 lo eliminó: hoy helm es solo un binario en tu máquina que habla con la API usando tu kubeconfig y tu RBAC (08-01).

  1. Consumir charts de terceros con criterio

Volvamos sobre lo que hicimos en 04-05 y 07-03, ahora entendiendo cada paso.

helm repo add jetstack https://charts.jetstack.io
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts

# Descargar los índices. SIN ESTO, helm trabaja con una caché vieja.
helm repo update

# Ver TODAS las versiones disponibles
helm search repo jetstack/cert-manager --versions | head -4
NAME                    CHART VERSION   APP VERSION   DESCRIPTION
jetstack/cert-manager   v1.16.1         v1.16.1       A Helm chart for cert-manager
jetstack/cert-manager   v1.15.3         v1.15.3       A Helm chart for cert-manager

Fíjate en las dos columnas de versión. CHART VERSION es la del empaquetado (cambia cuando el autor modifica las plantillas); APP VERSION es la del software instalado. Avanzan de forma independiente, y --version fija la del chart.

El paso que casi nadie da y que separa a un profesional de alguien que copia órdenes de un blog: leer los valores disponibles antes de instalar.

helm show values jetstack/cert-manager --version v1.16.1 > /tmp/defectos.yaml
wc -l /tmp/defectos.yaml        # 1204 líneas de opciones documentadas

Ahí está todo lo configurable: réplicas, recursos, tolerations, PDB, integración con Prometheus, crds.enabled. Si instalas sin mirarlo, aceptas a ciegas los defectos del autor. Complementan helm show chart, helm show readme y helm show all.

cert-manager, ahora con criterio

En 04-05 ejecutamos algo parecido a helm install cert-manager jetstack/cert-manager --namespace cert-manager --create-namespace --version v1.16.1 --set crds.enabled=true. Ahora sabemos leerlo: cert-manager es el nombre de la release (muchos objetos lo heredan); --create-namespace es necesario porque Helm no crea namespaces si no se lo pides; y --version es imprescindible, porque sin él instalas la última versión de ese momento y reinstalar seis meses después instala otra distinta. Es el mismo principio que las etiquetas inmutables de imagen (08-05).

La versión profesional usa un fichero de valores versionado en el repositorio:

# plataforma/cert-manager/valores-pro.yaml
crds:
  enabled: true
  keep: true          # no borrar las CRDs al desinstalar: se llevarían
                      # por delante todos los Certificate del clúster
replicaCount: 2       # alta disponibilidad (09-05)
podDisruptionBudget:
  enabled: true
  minAvailable: 1
resources:
  requests: { cpu: 10m, memory: 32Mi }
  limits:   { memory: 128Mi }
prometheus:
  servicemonitor:
    enabled: true     # que kube-prometheus-stack lo recoja (07-03)
topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: ScheduleAnyway
    labelSelector:
      matchLabels: { app.kubernetes.io/instance: cert-manager }
helm upgrade --install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --version v1.16.1 -f plataforma/cert-manager/valores-pro.yaml \
  --atomic --timeout 5m

Y lo mismo para Prometheus, donde hay un valor que merece atención especial:

# plataforma/kube-prometheus-stack/valores-pro.yaml
prometheus:
  prometheusSpec:
    retention: 30d
    # Que descubra ServiceMonitors de TODOS los namespaces. Sin esto,
    # los ServiceMonitor de rutas-norte-pro se ignoran EN SILENCIO.
    serviceMonitorSelectorNilUsesHelmValues: false
    storageSpec:
      volumeClaimTemplate:
        spec:
          storageClassName: rutas-norte-ssd
          resources: { requests: { storage: 100Gi } }
grafana:
  ingress:
    enabled: true
    ingressClassName: nginx
    hosts: ["grafana.rutasnorte.example"]

Lista de verificación antes de instalar un chart ajeno

Comprobación Cómo
¿Quién lo mantiene? helm show chart y el repositorio de origen
¿Qué valores acepto por defecto? helm show values > fichero.yaml y leerlo
¿Qué va a crear exactamente? helm template ... | less
¿Pide permisos de cluster-admin? Buscar ClusterRole en la salida de helm template
¿Instala CRDs? ¿Qué pasa al desinstalar? Buscar el directorio crds/ en el chart
¿Qué imágenes descarga y de dónde? helm template ... | grep image:
¿He fijado la versión y guardado los valores en Git? --version y -f, siempre

  1. Estructura de un chart

helm create chart-rutas-norte
chart-rutas-norte/
├── Chart.yaml           # metadatos del chart
├── values.yaml          # valores por defecto
├── charts/              # subcharts descargados
├── crds/                # CRDs, instaladas antes que todo lo demás
├── .helmignore          # qué excluir al empaquetar
└── templates/
    ├── NOTES.txt        # mensaje que se imprime tras instalar
    ├── _helpers.tpl     # fragmentos reutilizables (no genera manifiestos)
    ├── deployment.yaml  # ... y el resto de plantillas
    └── tests/
# Chart.yaml
apiVersion: v2                    # v2 = Helm 3. No uses v1.
name: chart-rutas-norte
description: Plataforma de venta de billetes de Rutas Norte S.L.
version: 1.4.0                    # versión del CHART, semántica
appVersion: "2.4.0"               # versión de la APLICACIÓN que despliega
type: application
kubeVersion: ">=1.28.0-0"         # si el clúster no cumple, install falla claro
maintainers:
  - { name: Equipo de plataforma, email: [email protected] }
dependencies:
  - name: redis
    version: "20.1.0"
    repository: "https://charts.bitnami.com/bitnami"
    condition: redis.enabled

La distinción entre version y appVersion confunde a todo el mundo. Regla: si cambias una plantilla, sube version. Si cambias la versión del software desplegado, sube appVersion (y también version, porque el chart ha cambiado).

Sobre los directorios especiales:

  • templates/: todo pasa por el motor de plantillas. Los ficheros que empiezan por _ (como _helpers.tpl) se renderizan pero no generan manifiestos: sirven para definir fragmentos. NOTES.txt se imprime en pantalla tras instalar.
  • charts/: aquí aterrizan los subcharts con helm dependency update.
  • crds/: YAML plano, sin plantillas. Helm las instala antes que el resto y nunca las actualiza ni las borra. Es una limitación deliberada y una fuente constante de fricción (apartado 13).
  • .helmignore: igual que .dockerignore, qué no se incluye al empaquetar.

  1. Plantillas: sintaxis, funciones y control de flujo

Helm usa el motor de plantillas de Go con las funciones de la biblioteca Sprig. Todo va entre {{ }}.

Los objetos integrados

Objeto Contenido Ejemplo
.Values Los valores resueltos .Values.replicaCount
.Release Datos de la release .Release.Name, .Release.Namespace, .Release.Revision, .Release.IsUpgrade
.Chart Contenido de Chart.yaml .Chart.Name, .Chart.Version, .Chart.AppVersion
.Capabilities Qué sabe hacer el clúster .Capabilities.APIVersions.Has "..."
.Files Ficheros del chart fuera de templates/ .Files.Get "config/app.conf"

Funciones y tuberías

La tubería | pasa el resultado de la izquierda como último argumento de la función de la derecha.

# 'quote' añade comillas. Imprescindible con números que deben ser texto.
version: {{ .Chart.AppVersion | quote }}        # -> "2.4.0"

# 'default' aporta un valor si el de la izquierda está vacío
replicas: {{ .Values.replicaCount | default 1 }}

# 'trunc 63' porque las etiquetas de Kubernetes tienen ese límite
name: {{ .Release.Name | trunc 63 | trimSuffix "-" }}

# 'required' ABORTA con tu mensaje si el valor falta. Mucho mejor
# que desplegar algo roto.
image: {{ required "Debes indicar image.repository" .Values.image.repository }}

# 'toYaml' convierte una estructura de values.yaml en YAML;
# 'nindent N' añade salto de línea + N espacios a cada línea
resources:
  {{- toYaml .Values.resources | nindent 2 }}

# 'sha256sum' calcula un hash: lo usaremos para el reinicio automático
checksum/config: {{ .Values.config | toYaml | sha256sum }}

indent frente a nindent causa más errores de sintaxis YAML que ninguna otra cosa: indent N añade N espacios a cada línea; nindent N hace lo mismo más un salto de línea al principio. Regla práctica: usa siempre {{- ... | nindent N }}, es el patrón que funciona.

Los guiones {{- y -}} eliminan el espacio en blanco anterior y posterior. Sin ellos, cada {{ if }} deja una línea en blanco en la salida y el YAML resultante es ilegible aunque sea válido.

Control de flujo

{{- if .Values.ingress.enabled }}
# ...
{{- else if eq .Values.entorno "pre" }}
# ...
{{- end }}

Se consideran falso: false, 0, la cadena vacía, nil, y las listas o mapas vacíos. Los operadores (eq, ne, lt, gt, and, or, not, empty) van delante, en notación prefija: {{- if and .Values.hpa.enabled (gt (int .Values.hpa.maxReplicas) 1) }}.

# 'with' comprueba que el valor no esté vacío Y cambia el '.' a ese valor
{{- with .Values.nodeSelector }}
nodeSelector:
  {{- toYaml . | nindent 2 }}
{{- end }}

Es el patrón idiomático para campos opcionales. Trampa: dentro de un with, el . ya no es la raíz; para el contexto global se usa $ (por ejemplo, {{ $.Release.Name }}).

# 'range' sobre una lista, o sobre un mapa capturando clave y valor
env:
{{- range $clave, $valor := .Values.env }}
  - name: {{ $clave }}
    value: {{ $valor | quote }}
{{- end }}

define e include: fragmentos reutilizables

Aquí está la clave para no repetir las etiquetas de Rutas Norte en veinte plantillas.

{{/* templates/_helpers.tpl */}}

{{- define "rutas-norte.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "rutas-norte.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}

{{/* Etiquetas comunes de TODOS los objetos, con las convenciones de 02-07 */}}
{{- define "rutas-norte.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 }}
{{ include "rutas-norte.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: rutas-norte
entorno: {{ .Values.entorno | quote }}
{{- end }}

{{/* Etiquetas de SELECTOR. Son INMUTABLES en un Deployment (02-03):
     si cambian, el upgrade falla. Por eso van aparte y contienen
     lo MÍNIMO imprescindible, y no se tocan nunca más. */}}
{{- define "rutas-norte.selectorLabels" -}}
app.kubernetes.io/name: {{ include "rutas-norte.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

Se usan así: {{- include "rutas-norte.labels" . | nindent 4 }}.

include frente a template: template inserta el resultado pero no se puede encadenar con tuberías; include sí. Como casi siempre necesitas | nindent, usa include siempre.

  1. Crear el chart de Rutas Norte

values.yaml (defectos)

entorno: dev                                # dev | pre | pro
imageRegistry: registry.rutasnorte.example

apiReservas:
  enabled: true
  replicaCount: 1
  image:
    repository: api-reservas
    tag: ""            # vacío -> se usa .Chart.AppVersion
    digest: ""         # si se indica, tiene prioridad (08-05)
    pullPolicy: IfNotPresent
  service: { type: ClusterIP, port: 80, targetPort: 8080 }
  resources:
    requests: { cpu: 100m, memory: 128Mi }
    limits:   { memory: 256Mi }

  # Configuración no sensible -> acaba en un ConfigMap (03-01)
  config:
    LOG_LEVEL: info
    RESERVA_TTL_MINUTOS: "15"
    PASARELA_URL: https://pagos.proveedorexterno.example/v2

  # Secretos: SOLO el nombre de un Secret que ya existe.
  # NUNCA se ponen valores sensibles en values.yaml (03-02).
  existingSecret: api-reservas-credenciales

  hpa: { enabled: false, minReplicas: 1, maxReplicas: 3, targetCPUUtilizationPercentage: 70 }
  probes:
    liveness:  { path: /salud/vivo,  initialDelaySeconds: 15, periodSeconds: 20 }
    readiness: { path: /salud/listo, initialDelaySeconds: 5,  periodSeconds: 5 }
  nodeSelector: {}
  tolerations: []
  topologySpreadConstraints: []

# Contexto de seguridad común (08-02)
podSecurityContext:
  runAsNonRoot: true
  runAsUser: 10001
  fsGroup: 10001
  seccompProfile: { type: RuntimeDefault }
securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities: { drop: ["ALL"] }

El ConfigMap

{{- if .Values.apiReservas.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "rutas-norte.fullname" . }}-api-config
  labels:
    {{- include "rutas-norte.labels" . | nindent 4 }}
data:
  {{- range $clave, $valor := .Values.apiReservas.config }}
  {{ $clave }}: {{ $valor | quote }}
  {{- end }}
{{- end }}

Sencillo pero potente: añadir una variable nueva en values.yaml no toca la plantilla.

El Deployment

{{- if .Values.apiReservas.enabled }}
{{- $c := .Values.apiReservas }}            {{/* variable local: ahorra el camino largo */}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "rutas-norte.fullname" . }}-api
  labels:
    {{- include "rutas-norte.labels" . | nindent 4 }}
    app: api-reservas
spec:
  {{- if not $c.hpa.enabled }}
  # IMPORTANTE: si el HPA está activo (09-01) NO declaramos replicas.
  # Si lo hiciéramos, cada 'helm upgrade' devolvería el Deployment al
  # valor del chart, deshaciendo el escalado automático.
  replicas: {{ $c.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "rutas-norte.selectorLabels" . | nindent 6 }}
      app: api-reservas
  strategy:
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
  template:
    metadata:
      labels:
        {{- include "rutas-norte.labels" . | nindent 8 }}
        app: api-reservas
      annotations:
        # Hash de la configuración: si el ConfigMap cambia, cambia este
        # hash, cambia la plantilla del pod y el Deployment hace rollout
        # solo. Es el mecanismo de 03-03, ahora calculado automáticamente.
        checksum/config: {{ include (print $.Template.BasePath "/api-reservas/configmap.yaml") . | sha256sum }}
        prometheus.io/scrape: "true"
        prometheus.io/port: "8080"
    spec:
      serviceAccountName: {{ include "rutas-norte.serviceAccountName" . }}
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: api
          image: {{ printf "%s/%s" .Values.imageRegistry (include "rutas-norte.imagenApi" .) }}
          imagePullPolicy: {{ $c.image.pullPolicy }}
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
          ports:
            - { name: http, containerPort: {{ $c.service.targetPort }} }
          envFrom:
            - configMapRef: { name: {{ include "rutas-norte.fullname" . }}-api-config }
            {{- if $c.existingSecret }}
            - secretRef: { name: {{ $c.existingSecret }} }
            {{- end }}
          livenessProbe:
            httpGet: { path: {{ $c.probes.liveness.path }}, port: http }
            initialDelaySeconds: {{ $c.probes.liveness.initialDelaySeconds }}
          readinessProbe:
            httpGet: { path: {{ $c.probes.readiness.path }}, port: http }
            initialDelaySeconds: {{ $c.probes.readiness.initialDelaySeconds }}
          resources:
            {{- toYaml $c.resources | nindent 12 }}
          volumeMounts:
            # readOnlyRootFilesystem exige volúmenes para lo escribible
            - { name: tmp, mountPath: /tmp }
      volumes:
        - { name: tmp, emptyDir: {} }
      {{- with $c.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with $c.topologySpreadConstraints }}
      topologySpreadConstraints:
        {{- toYaml . | nindent 8 }}
      {{- end }}
{{- end }}

Cuatro puntos merecen atención:

  1. replicas condicionado al HPA resuelve, dentro de Helm, media parte del conflicto anunciado en 09-01. La otra media (que Argo CD no marque deriva por ese campo) la veremos en 10-05.
  2. checksum/config renderiza la plantilla del ConfigMap y le calcula el hash: automatiza exactamente lo que en 03-03 hacíamos a mano.
  3. with en los campos opcionales evita que salga nodeSelector: {} o, peor, nodeSelector: null.
  4. El Service y el HPA siguen el mismo patrón: {{- if and .Values.apiReservas.enabled .Values.apiReservas.hpa.enabled }} envuelve el HPA, cuyo scaleTargetRef.name usa el mismo include "rutas-norte.fullname" para no desincronizarse nunca del Deployment.

Y NOTES.txt cierra la instalación con instrucciones contextuales:

La plataforma Rutas Norte se ha desplegado en el entorno {{ .Values.entorno }}.
Release: {{ .Release.Name }} | Namespace: {{ .Release.Namespace }} | Revisión: {{ .Release.Revision }}

  kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/part-of=rutas-norte
{{- if eq .Values.entorno "pro" }}

*** ENTORNO DE PRODUCCIÓN *** Verifica el rollout antes de cerrar la ventana:
  kubectl rollout status deploy/{{ include "rutas-norte.fullname" . }}-api -n {{ .Release.Namespace }}
{{- end }}

  1. Valores por entorno

Aquí es donde desaparecen los 120 ficheros.

# entornos/values-dev.yaml
entorno: dev
apiReservas:
  replicaCount: 1
  image: { tag: dev-abc123f, pullPolicy: Always }
  resources:
    requests: { cpu: 50m, memory: 64Mi }
    limits:   { memory: 256Mi }
  config:
    LOG_LEVEL: debug
    PASARELA_URL: https://pagos-sandbox.proveedorexterno.example/v2
  hpa: { enabled: false }
# entornos/values-pro.yaml
entorno: pro
apiReservas:
  replicaCount: 4
  image:
    tag: "2.4.0"
    # Digest inmutable: es lo que realmente se despliega (08-05)
    digest: "sha256:9c1e4a7b3d2f8e6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a"
  resources:
    requests: { cpu: 250m, memory: 512Mi }
    limits:   { memory: 1Gi }
  config:
    LOG_LEVEL: warn
    PASARELA_URL: https://pagos.proveedorexterno.example/v2
  hpa: { enabled: true, minReplicas: 4, maxReplicas: 20, targetCPUUtilizationPercentage: 60 }
  podDisruptionBudget: { enabled: true, minAvailable: 2 }
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: topology.kubernetes.io/zone
      whenUnsatisfiable: DoNotSchedule
      labelSelector:
        matchLabels: { app: api-reservas }

values-pre.yaml queda en medio: 2 réplicas, imagen rc-2.4.0, límite de 512Mi, HPA de 2 a 5 y la pasarela de pruebas. Tres ficheros de valores de 30 líneas han sustituido a 120 manifiestos duplicados.

--set frente a -f

Aspecto -f fichero.yaml --set clave=valor
Versionable en Git y revisable en una PR No
Reproducible Solo si alguien recuerda la orden exacta
Estructuras complejas Natural Sintaxis con corchetes, dolorosa
Tipos Explícitos Se infieren, con sorpresas (--set-string para forzar texto)

Regla del equipo de Rutas Norte: -f para todo lo que define un entorno; --set solo para la etiqueta de imagen que inyecta la canalización ci-rutasnorte, que es lo único que legítimamente cambia en cada despliegue. Existe además --set-file clave=./fichero para volcar el contenido de un fichero como valor.

  1. El ciclo de vida de una release

# LA ORDEN QUE DEBES USAR SIEMPRE: instala si no existe, actualiza si sí.
# Idempotente, y por tanto apta para automatización.
helm upgrade --install rutas-norte ./chart-rutas-norte \
  -n rutas-norte-pro --create-namespace \
  -f entornos/values-pro.yaml \
  --atomic --timeout 10m

helm install a secas falla si la release existe; helm upgrade a secas falla si no existe. upgrade --install cubre ambos casos.

Bandera Qué hace Por qué importa
--wait Espera a que los pods estén Ready y los Services tengan endpoints Sin esto, upgrade devuelve éxito en cuanto la API acepta los objetos, aunque los pods estén en CrashLoopBackOff
--timeout 10m Cuánto espera (por defecto 5m) Un StatefulSet grande o una migración lenta necesitan más
--atomic Implica --wait, y si falla o expira hace rollback automático La red de seguridad de producción: o funciona, o el clúster queda como estaba
--cleanup-on-fail Borra los objetos nuevos de un upgrade fallido Evita dejar basura
--force Borra y recrea los objetos en vez de parchearlos Peligroso: provoca corte de servicio
--reuse-values Reutiliza los anteriores y solo aplica los nuevos Cómodo y traicionero: oculta qué está realmente aplicado

En producción, --atomic --timeout no son opcionales. Sin ellos, un despliegue con la imagen mal escrita deja rutas-norte-pro con pods en ImagePullBackOff y Helm diciendo "deployed".

Consultar el estado

helm list -n rutas-norte-pro        # releases del namespace
helm list -A --all                  # todas, incluidas las fallidas
helm history rutas-norte -n rutas-norte-pro
REVISION  UPDATED                    STATUS      CHART                    APP VERSION  DESCRIPTION
5         2026-08-01 16:45 CEST      superseded  chart-rutas-norte-1.4.0  2.4.0        Upgrade complete
6         2026-08-04 10:11 CEST      failed      chart-rutas-norte-1.4.0  2.4.1        Upgrade failed
7         2026-08-05 09:14 CEST      deployed    chart-rutas-norte-1.4.0  2.4.0        Rollback to 5

Esa tabla cuenta una historia completa: la revisión 6 falló y la 7 fue un rollback a la 5.

helm get values rutas-norte -n rutas-norte-pro         # los valores que TÚ diste
helm get values rutas-norte -n rutas-norte-pro --all   # incluyendo los defectos
helm get manifest rutas-norte -n rutas-norte-pro       # lo que está aplicado AHORA
helm get manifest rutas-norte -n rutas-norte-pro --revision 5

helm get manifest es la orden que responde a "¿qué hay realmente desplegado en producción?", la pregunta que nadie sabía contestar al principio del módulo.

Rollback y desinstalación

helm rollback rutas-norte -n rutas-norte-pro           # a la anterior
helm rollback rutas-norte 5 -n rutas-norte-pro --wait  # a una concreta

El rollback crea una revisión nueva (la 8 sería una copia de la 5): el historial nunca retrocede, solo avanza.

Limitación crítica: el rollback afecta a los objetos de Kubernetes, no a los datos. Si la revisión 6 ejecutó una migración que añadió una columna a postgres-reservas, el rollback devuelve el código pero no deshace la migración. Las migraciones de esquema tienen que ser compatibles hacia atrás.

helm uninstall rutas-norte -n rutas-norte-dev                  # borra objetos e historial
helm uninstall rutas-norte -n rutas-norte-dev --keep-history   # conserva el historial
helm list -n rutas-norte-dev --uninstalled                     # verlas
helm rollback rutas-norte 7 -n rutas-norte-dev                 # y resucitarla

--keep-history es la opción prudente cuando desinstalas algo en un entorno importante y no estás del todo seguro. Ojo con los PVC: Helm no borra los creados por los volumeClaimTemplates de un StatefulSet (06-01), así que postgres-reservas conserva sus datos. Es bueno (protege contra el borrado accidental) y confuso (reinstalar reutiliza los datos viejos).

  1. Depuración: template, lint, dry-run y diff

# Renderizar en local, sin tocar el clúster ni necesitar conexión
helm template rutas-norte ./chart-rutas-norte -f entornos/values-pro.yaml

# Solo una plantilla
helm template rutas-norte ./chart-rutas-norte -f entornos/values-pro.yaml \
  -s templates/api-reservas/deployment.yaml

# Comparar dos entornos: responde con exactitud a "¿en qué se diferencian?"
diff <(helm template rn ./chart-rutas-norte -f entornos/values-pre.yaml) \
     <(helm template rn ./chart-rutas-norte -f entornos/values-pro.yaml)

# Encadenar con la validación real del servidor (01-06): esquema + webhooks
# de admisión (Kyverno, de 08-03), sin aplicar nada
helm template rutas-norte ./chart-rutas-norte -f entornos/values-pro.yaml | \
  kubectl apply --dry-run=server -f - -n rutas-norte-pro

A diferencia de helm template, la variante --dry-run --debug sí contacta con el clúster, así que .Capabilities es real, valida contra la API y muestra los valores calculados. Es el paso previo obligatorio a cualquier despliegue en producción.

helm lint comprueba la estructura, que Chart.yaml esté completo y que las plantillas rendericen. Ponlo en ci-rutasnorte como comprobación obligatoria de cualquier cambio al chart.

helm diff: la red de seguridad

Con diferencia, la herramienta más valiosa de este apartado. Es un plugin, no viene incluido:

helm plugin install https://github.com/databus23/helm-diff
helm diff upgrade rutas-norte ./chart-rutas-norte -n rutas-norte-pro -f entornos/values-pro.yaml
rutas-norte-pro, rutas-norte-api, Deployment (apps) has changed:
        metadata:
          annotations:
-           checksum/config: 8f2a91bc4d7e...
+           checksum/config: c3e7d92a1b8f...
          containers:
          - name: api
-           image: registry.rutasnorte.example/api-reservas:2.4.0
+           image: registry.rutasnorte.example/api-reservas:2.4.1
            resources:
              limits:
-               memory: 1Gi
+               memory: 2Gi

Compara lo que hay en el clúster con lo que vas a aplicar, línea a línea. Es lo que impide desplegar un cambio de recursos que no querías, o darte cuenta de que actualizar un chart de terceros cambia treinta cosas inesperadas.

Norma para producción: nadie ejecuta helm upgrade contra rutas-norte-pro sin haber pegado antes la salida de helm diff en la petición de cambio. Es el equivalente de kubectl diff (01-06) para Helm.

Cuando la plantilla no compila, las causas más frecuentes son, por orden: indent donde tocaba nindent, un valor nil que no existe en values.yaml, una cadena con : sin comillas, y un {{- if }} sin su {{- end }}. Aísla la plantilla sospechosa con -s y usa --debug.

  1. Dependencias y subcharts

Rutas Norte necesita redis-cache. En vez de mantener su StatefulSet, podemos usar un chart existente, declarado en Chart.yaml (apartado 4) con condition: redis.enabled, tags para activar grupos y alias para renombrarlo.

helm dependency update ./chart-rutas-norte   # resuelve, descarga y REESCRIBE Chart.lock
helm dependency build  ./chart-rutas-norte   # descarga EXACTAMENTE lo que dice Chart.lock

Chart.lock va a Git; charts/*.tgz no. Es exactamente la lógica de package-lock.json. En la canalización, siempre build; en tu máquina cuando quieres subir de versión, update.

Pasar valores a un subchart

Los valores del subchart se anidan bajo su nombre (o su alias), y global es visible desde el chart padre y desde todos los subcharts:

global:
  imageRegistry: registry.rutasnorte.example
  storageClass: rutas-norte-ssd

redis:                     # <- nombre del subchart
  enabled: true
  architecture: replication
  auth: { enabled: true, existingSecret: redis-cache-credenciales }
  master:
    persistence: { storageClass: rutas-norte-ssd, size: 8Gi }
  replica: { replicaCount: 2 }

La mayoría de charts serios (Bitnami, entre otros) respetan global.imageRegistry y global.storageClass.

La trampa de sobreescribir valores de un subchart

Tres reglas que hay que interiorizar:

Regla 1: el chart padre gana, pero solo si acierta con la ruta. Si el subchart espera master.persistence.size y tú escribes redis.persistence.size, Helm no avisa: puedes escribir redis.tamañoDelDisco: 500Gi y no pasa absolutamente nada. Verifica siempre con helm template -s charts/redis/templates/... que el cambio surtió efecto.

Regla 2: -f sobreescribe, no fusiona listas. Si values.yaml tiene extraFlags: ["--appendonly yes", "--maxmemory 512mb"] y values-pro.yaml pone extraFlags: ["--maxmemory 2gb"], el resultado es solo ["--maxmemory 2gb"]. Se perdió --appendonly yes. Los mapas se fusionan clave a clave; las listas se sustituyen enteras. Fuente inagotable de sorpresas.

Regla 3: un subchart no puede leer los valores del padre salvo a través de global.

  1. Hooks

Un hook es un objeto de Kubernetes que Helm crea en un momento concreto del ciclo de vida, fuera del flujo normal: pre-install, post-install, pre-upgrade, post-upgrade, pre-rollback, post-rollback, pre-delete, post-delete y test.

El caso real: migración del esquema de la base de datos

api-reservas 2.4.0 necesita una columna nueva en postgres-reservas. Si desplegamos el código antes de migrar, los pods fallan. La solución es un pre-upgrade que bloquea el despliegue hasta que la migración termine.

apiVersion: batch/v1
kind: Job
metadata:
  # El nombre incluye la revisión: cada upgrade crea un Job distinto
  name: {{ include "rutas-norte.fullname" . }}-migracion-{{ .Release.Revision }}
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    # Los pesos menores van primero: permite encadenar hooks
    "helm.sh/hook-weight": "-5"
    # before-hook-creation: borra el anterior antes de crear el nuevo
    "helm.sh/hook-delete-policy": before-hook-creation
spec:
  backoffLimit: 2
  activeDeadlineSeconds: 600
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrador
          image: {{ .Values.imageRegistry }}/api-reservas-migraciones:{{ .Chart.AppVersion }}
          command: ["/app/migrar", "--hasta", "{{ .Chart.AppVersion }}"]
          env:
            - name: BD_PASSWORD
              valueFrom:
                secretKeyRef: { name: {{ .Values.apiReservas.existingSecret }}, key: bd-password }
sequenceDiagram
    participant U as Operador
    participant H as Helm
    participant K as Clúster
    U->>H: helm upgrade --install --atomic
    H->>K: crea el Job pre-upgrade (migración)
    alt Migración correcta
        K-->>H: Job Complete
        H->>K: aplica Deployment, Service, HPA, ConfigMap
        H->>K: espera a que los pods estén Ready (--wait)
        H-->>U: release desplegada (revisión N+1)
    else Migración fallida
        K-->>H: Job Failed
        H->>H: aborta: NO toca los objetos de la aplicación
        H-->>U: error; el clúster sigue en la revisión N
    end

Lo importante: si la migración falla, los pods de api-reservas ni se enteran. Siguen sirviendo la versión anterior. Sin el hook, habríamos desplegado código nuevo contra un esquema viejo.

Políticas de borrado

before-hook-creation (por defecto) borra el recurso anterior justo antes de crear el nuevo; hook-succeeded lo borra si termina bien; hook-failed lo borra si falla. Se combinan con comas.

Consejo firme: no uses hook-failed. Cuando una migración falle a las tres de la mañana, lo único que querrás es kubectl logs del Job que falló. Si la política lo borró, te quedas sin nada.

Hooks de prueba

apiVersion: v1
kind: Pod
metadata:
  name: "{{ include "rutas-norte.fullname" . }}-prueba-api"
  annotations:
    "helm.sh/hook": test
    "helm.sh/hook-delete-policy": before-hook-creation
spec:
  restartPolicy: Never
  containers:
    - name: curl
      image: curlimages/curl:8.10.1
      args: ["-sS", "--fail", "--max-time", "10",
             "http://{{ include "rutas-norte.fullname" . }}-api/salud/listo"]
helm test rutas-norte -n rutas-norte-pre --logs

Es una prueba de humo estupenda para ejecutar automáticamente tras desplegar en rutas-norte-pre.

  1. Empaquetar y publicar

helm package ./chart-rutas-norte              # -> chart-rutas-norte-1.4.0.tgz

# Firmarlo con GPG, complementando la firma de imágenes de 08-05
helm package ./chart-rutas-norte --sign --key '[email protected]'
helm verify chart-rutas-norte-1.4.0.tgz

Desde Helm 3.8, un registro de contenedores puede alojar charts directamente. Rutas Norte ya tiene registry.rutasnorte.example; no necesita otro servidor.

helm registry login registry.rutasnorte.example -u ci-rutasnorte
helm push chart-rutas-norte-1.4.0.tgz oci://registry.rutasnorte.example/charts

# Consumirlo: fíjate en que NO hace falta 'helm repo add'
helm upgrade --install rutas-norte \
  oci://registry.rutasnorte.example/charts/chart-rutas-norte \
  --version 1.4.0 -n rutas-norte-pro -f entornos/values-pro.yaml --atomic

Ventajas del OCI frente al repositorio HTTP clásico (helm repo index sobre un directorio servido estáticamente): mismas credenciales y mismo control de acceso que las imágenes, sin index.yaml que mantener, versionado por digest, y el mismo escaneo de seguridad que ya tienes montado (08-06).

  1. Qué hace mal Helm

Helm resuelve un problema real, pero tiene defectos honestos que conviene conocer.

1. Es un motor de texto, no entiende YAML. No manipula estructuras: concatena cadenas y al final intenta interpretar el resultado. De ahí vienen nindent, los {{-, y que un espacio de más rompa el despliegue. Además, las plantillas no son YAML válido: no puedes abrirlas y que el editor valide el esquema de Kubernetes, ni aplicarlas con kubectl apply -f. Pierdes todo el instrumental que existe alrededor del YAML.

2. Las plantillas complejas se vuelven ilegibles. Charts de terceros grandes tienen plantillas de 300 líneas con seis niveles de condicionales. Depurarlas es doloroso.

3. Las CRDs son un problema. Helm instala lo que hay en crds/ pero nunca lo actualiza ni lo borra (razón declarada: borrar una CRD elimina todos sus recursos, lo que sería catastrófico). Consecuencia: actualizar cert-manager de 1.15 a 1.16 puede requerir kubectl apply -f cert-manager.crds.yaml a mano antes del helm upgrade.

4. El estado vive en el clúster, no en Git. Si alguien hace kubectl scale a mano, Helm no lo detecta hasta el siguiente upgrade, y entonces lo pisa sin avisar. No hay reconciliación continua: solo actúa cuando alguien ejecuta una orden. Esa es exactamente la carencia que resuelve GitOps (10-05).

5. Depender de charts ajenos es depender de decisiones ajenas. Si necesitas cambiar algo que el autor no parametrizó, tus opciones son abrir una petición al proyecto, bifurcar el chart o post-procesar la salida. Ninguna es cómoda.

Por qué mucha gente prefiere Kustomize

Crítica a Helm Respuesta de Kustomize
Las plantillas no son YAML válido Los manifiestos base son YAML normal, aplicable con kubectl
nindent, {{-, errores de espaciado No hay motor de plantillas
Estado en el clúster Sin estado: solo genera YAML
Hay que aprender Go templates y Sprig Solo un fichero, kustomization.yaml
Herramienta externa Viene dentro de kubectl

Y donde Helm sigue ganando claramente: el ecosistema (miles de charts listos), el empaquetado y la distribución (un .tgz versionado y publicable), la gestión del ciclo de vida (rollback, history, uninstall que sabe qué borrar), los hooks y la lógica condicional real con bucles.

La conclusión práctica del sector, y la que adoptaremos en Rutas Norte: Helm para consumir software de terceros, Kustomize para las aplicaciones propias. cert-manager y Prometheus por Helm; tienda-web, api-reservas y compañía por Kustomize.

Errores Comunes y Consejos

1. No fijar --version. Instalas cert-manager hoy y obtienes 1.16.1; reinstalas en marzo y obtienes 1.19, con cambios incompatibles. Fija la versión siempre y anótala en el repositorio.

2. Poner secretos en values.yaml. El fichero va a Git. Usa existingSecret apuntando a un Secret que gestiona otro sistema (Sealed Secrets, SOPS, External Secrets Operator: 03-02 y 10-05).

3. Confundir indent con nindent. El error de sintaxis más frecuente. Usa {{- toYaml . | nindent N }} como patrón por defecto.

4. Declarar replicas fijas con un HPA activo. Cada helm upgrade devuelve el Deployment a las réplicas del chart, cancelando el escalado automático hasta que el HPA reacciona. Condiciona el campo, como en el apartado 6.

5. helm upgrade sin --atomic en producción. Un despliegue fallido deja el clúster a medias y la release en failed. Con --atomic, vuelve solo al estado anterior.

6. Abusar de --reuse-values. Es cómodo hasta que nadie sabe qué valores están realmente aplicados. Usa siempre -f fichero.yaml completo y versionado.

7. Escribir valores para un subchart con la ruta equivocada. Helm los ignora sin decir nada. Comprueba con helm template -s que el cambio llegó.

8. Creer que las listas se fusionan. No: se sustituyen enteras. Si redefines extraFlags en values-pro.yaml, pierdes los del values.yaml base.

9. Cambiar las etiquetas de selector entre versiones del chart. spec.selector de un Deployment es inmutable (02-03). Si tu helper selectorLabels cambia, el upgrade falla con "field is immutable" y hay que borrar y recrear el objeto, con corte de servicio. Mantén selectorLabels mínimo y estable para siempre.

10. Actualizar un chart de terceros sin helm diff. El salto de una versión menor puede cambiar treinta campos. Míralos antes.

11. hook-delete-policy: hook-failed en las migraciones. Te borra justo el Job cuyos logs necesitas.

12. Olvidar helm dependency update tras editar Chart.yaml. El despliegue usa el charts/ viejo y no entiendes por qué no cambia nada.

13. Desinstalar sin --keep-history en un entorno importante. Sin historial no hay rollback. Y recuerda que los PVC sobreviven, lo que puede sorprenderte al reinstalar.

Ejercicios

Ejercicio 1: plantillar worker-notificaciones

Añade al chart el componente worker-notificaciones: un Deployment sin Service (no recibe tráfico), con enabled condicional, recursos y réplicas parametrizados, y las etiquetas comunes vía include. Debe tener un ScaledObject de KEDA (09-04) solo si worker.keda.enabled es cierto, y en ese caso el Deployment no debe declarar replicas. Escribe también los tres bloques de valores por entorno.

Ejercicio 2: subchart, valores globales y la trampa de las listas

Añade redis como dependencia con condition, configúralo para que use la StorageClass rutas-norte-ssd mediante un valor global, y demuestra con helm template que el valor llegó. Después, define master.extraFlags en values.yaml con dos banderas y sobreescríbelo en values-pro.yaml con una sola; comprueba qué sale y explica el resultado.

Ejercicio 3: hook de migración con verificación previa

Escribe dos hooks encadenados por peso: uno pre-upgrade con peso -10 que verifique que postgres-reservas acepta conexiones, y otro con peso -5 que ejecute la migración. Si el primero falla, el segundo no debe ejecutarse y el despliegue debe abortar sin tocar la aplicación. Explica cómo verificarías todo esto sin romper rutas-norte-pro.

Soluciones

Solución 1

# templates/worker/deployment.yaml
{{- if .Values.worker.enabled }}
{{- $w := .Values.worker }}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "rutas-norte.fullname" . }}-worker
  labels: {{- include "rutas-norte.labels" . | nindent 4 }}
spec:
  {{- if not $w.keda.enabled }}
  replicas: {{ $w.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "rutas-norte.selectorLabels" . | nindent 6 }}
      app: worker-notificaciones
  template:
    metadata:
      labels:
        {{- include "rutas-norte.labels" . | nindent 8 }}
        app: worker-notificaciones
    spec:
      securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: worker
          image: {{ .Values.imageRegistry }}/worker-notificaciones:{{ $w.image.tag | default .Chart.AppVersion }}
          envFrom: [{ secretRef: { name: {{ $w.existingSecret }} } }]
          resources: {{- toYaml $w.resources | nindent 12 }}
{{- end }}
# templates/worker/scaledobject.yaml
{{- if and .Values.worker.enabled .Values.worker.keda.enabled }}
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: {{ include "rutas-norte.fullname" . }}-worker
spec:
  scaleTargetRef: { name: {{ include "rutas-norte.fullname" . }}-worker }
  minReplicaCount: {{ .Values.worker.keda.minReplicas }}
  maxReplicaCount: {{ .Values.worker.keda.maxReplicas }}
  triggers:
    - type: redis
      metadata:
        address: {{ .Values.worker.keda.redisAddress }}
        listName: cola-correos
        listLength: {{ .Values.worker.keda.listLength | quote }}
{{- end }}
# values.yaml (defectos)
worker:
  enabled: true
  replicaCount: 1
  existingSecret: worker-credenciales
  image: { tag: "" }
  resources: { requests: {cpu: 50m, memory: 64Mi}, limits: {memory: 256Mi} }
  keda: { enabled: false, minReplicas: 0, maxReplicas: 10,
          listLength: 20, redisAddress: "redis-cache:6379" }
# dev: worker.keda.enabled: false
# pre: worker.keda: { enabled: true, minReplicas: 0, maxReplicas: 5 }
# pro: worker.keda: { enabled: true, minReplicas: 1, maxReplicas: 30 }
#      worker.resources.requests: { cpu: 200m, memory: 256Mi }

Solución 2

# Chart.yaml
dependencies:
  - { name: redis, version: "20.1.0",
      repository: "https://charts.bitnami.com/bitnami", condition: redis.enabled }
# values.yaml
global: { storageClass: rutas-norte-ssd }
redis:
  enabled: true
  master: { extraFlags: ["--appendonly yes", "--maxmemory 512mb"] }
---
# values-pro.yaml
redis:
  master: { extraFlags: ["--maxmemory 4gb"] }
helm dependency update ./chart-rutas-norte
helm template rn ./chart-rutas-norte -f entornos/values-pro.yaml \
  -s charts/redis/templates/master/statefulset.yaml | grep -E 'storageClassName|maxmemory|appendonly'
        storageClassName: rutas-norte-ssd
        - --maxmemory 4gb

Explicación: global.storageClass llegó porque el chart de Bitnami lo respeta explícitamente. --appendonly yes desapareció: las listas no se fusionan, se sustituyen enteras. Para conservar ambas hay que repetir las dos en values-pro.yaml.

Solución 3

# templates/hooks/00-verificar-bd.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "rutas-norte.fullname" . }}-verif-bd-{{ .Release.Revision }}
  annotations:
    "helm.sh/hook": pre-upgrade
    "helm.sh/hook-weight": "-10"
    "helm.sh/hook-delete-policy": before-hook-creation
spec:
  backoffLimit: 3
  activeDeadlineSeconds: 120
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: verificar
          image: postgres:16.4
          command: ["sh","-c","pg_isready -h postgres-reservas -p 5432 -t 10"]

El segundo hook es el Job de migración del apartado 11, con "helm.sh/hook-weight": "-5".

Cómo funciona: Helm ordena los hooks del mismo evento por peso ascendente y espera a que cada uno termine antes de lanzar el siguiente. Si el de peso -10 falla, el de -5 nunca se crea y el upgrade aborta sin tocar Deployments ni Services.

Verificación sin romper producción:

# 1. Ver que se generan y en qué orden
helm template rn ./chart-rutas-norte -f entornos/values-pro.yaml | grep -B2 hook-weight

# 2. Probar el flujo completo en pre
helm upgrade --install rutas-norte ./chart-rutas-norte \
  -n rutas-norte-pre -f entornos/values-pre.yaml --atomic --timeout 10m

# 3. Provocar el fallo a propósito: apuntar a un host inexistente
helm upgrade rutas-norte ./chart-rutas-norte -n rutas-norte-pre \
  -f entornos/values-pre.yaml --set bd.host=no-existe --atomic
kubectl get jobs -n rutas-norte-pre     # solo debe existir verif-bd, NO migracion
helm history rutas-norte -n rutas-norte-pre   # la revisión aparece como failed
kubectl get deploy -n rutas-norte-pre -o wide # la imagen NO ha cambiado

Conclusión

Helm ha convertido los 120 ficheros de Rutas Norte en un chart con veinte plantillas y tres ficheros de valores. Lo esencial:

  • Un chart es el paquete, una release es su instalación con nombre, y Helm guarda estado en el clúster en forma de Secrets de release: eso es lo que hace posibles history, rollback y uninstall.
  • Consumir charts de terceros con criterio significa fijar --version, leer helm show values antes de instalar, y guardar tus valores en un fichero versionado. Es lo que ahora sabemos que estábamos haciendo con cert-manager y kube-prometheus-stack.
  • Las plantillas combinan .Values, .Release, .Chart y .Capabilities con funciones (default, quote, toYaml, nindent, required, sha256sum) y control de flujo (if, with, range); define/include en _helpers.tpl centralizan las etiquetas del proyecto.
  • El ciclo de vida profesional es helm upgrade --install ... --atomic --timeout, con -f en vez de --set, y con helm diff como paso obligatorio antes de tocar rutas-norte-pro.
  • Los hooks ordenan las migraciones de base de datos respecto al despliegue, con el enorme beneficio de que un fallo en la migración no llega a tocar la aplicación.
  • Y Helm tiene defectos reales: es un motor de texto, las plantillas dejan de ser YAML válido, las CRDs no se actualizan, y el estado vive en el clúster y no en Git.

Ese último punto abre las dos lecciones que siguen. La siguiente, Kustomize, ataca la crítica de las plantillas desde el extremo opuesto: sin motor de plantillas, sin estado, con manifiestos que siguen siendo YAML válido y que se personalizan mediante superposiciones. Veremos su modelo de base y overlays, sus generadores de ConfigMaps con hash automático —que resuelve limpiamente el reinicio ante cambios de configuración de 03-03—, y migraremos el k8s/ de Rutas Norte completo. Al final compararemos las dos herramientas de forma honesta y veremos cómo combinarlas.

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