0%
Despliegue de aplicaciones complejas

Aplicaciones complejas

Ensamblar las piezas: un punto de entrada, cuotas y elasticidad.

15-20 min

Despliegue de aplicaciones complejas

Ya sabe escribir un Deployment, exponerlo, inyectarle su configuración y darle un volumen. Una aplicación real, en cambio, se compone de varios servicios que tienen que comunicarse entre sí, ser accesibles desde Internet bajo un único nombre de dominio, no quitarse recursos unos a otros y absorber un aumento de tráfico sin intervención humana.

A esta escala aparecen tres problemas nuevos. El primero es el punto de entrada: crear un Service de tipo LoadBalancer por cada microservicio supone tantas direcciones públicas facturadas, sin enrutamiento por ruta ni certificado compartido. El segundo es el reparto de recursos: sin ninguna restricción declarada, un servicio con una fuga de memoria puede tumbar los Pods vecinos del mismo nodo. El tercero es la elasticidad: dimensionar para el pico equivale a pagar el pico de forma permanente.

El cuarto problema no es técnico sino organizativo, y suele ser el que antes duele. Una aplicación de cinco servicios representa una treintena de manifiestos, que hay que duplicar para cada entorno cambiando tres valores. El copiar y pegar se convierte entonces en una de las principales fuentes de caídas: alguien modifica el archivo de preproducción y se olvida del de producción.

Esta lección ensambla las piezas sobre un ejemplo completo y responde después a este último punto con una introducción a Helm. El objetivo no es añadir conceptos, sino mostrar cómo encajan los que ya conoce.

La aplicación de referencia

Tres componentes en un namespace dedicado: una base de datos PostgreSQL, una API y una interfaz web. El namespace no es decorativo: contiene las cuotas, las reglas de acceso y permite eliminarlo todo de un solo gesto.

apiVersion: v1
kind: Namespace
metadata:
  name: boutique
  labels:
    environnement: production

La base de datos

Un StatefulSet con su Service headless, exactamente según el modelo de la lección anterior: serviceName: postgres, clusterIP: None, un volumeClaimTemplates de 10 GiB. Dos añadidos propios de un despliegue completo, que se insertan en el contenedor:

          # Una reserva generosa: una base de datos planificada en un nodo
          # ya cargado será lenta sin que salte ninguna alerta.
          resources:
            requests:
              cpu: "250m"
              memory: "512Mi"
            limits:
              memory: "1Gi"
          # pg_isready evita que la API reciba tráfico antes de la inicialización.
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "boutique", "-d", "boutique"]
            initialDelaySeconds: 10
            periodSeconds: 5

La API

Es el manifiesto más instructivo: reúne configuración, secreto, sondas y recursos.

apiVersion: v1
kind: ConfigMap
metadata:
  name: api-config
  namespace: boutique
data:
  APP_ENV: "production"
  LOG_LEVEL: "info"
  # Basta con el nombre del Service: el DNS interno lo resuelve dentro del namespace.
  DB_HOST: "postgres"
  DB_PORT: "5432"
  DB_NAME: "boutique"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: boutique
  labels:
    app: api
    tier: backend
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0     # la capacidad nunca baja durante el cambio de versión
      maxSurge: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
        tier: backend
    spec:
      containers:
        - name: api
          image: exemple/boutique-api:1.4.0
          ports:
            - containerPort: 8080
              name: http
          envFrom:
            - configMapRef:
                name: api-config
            - secretRef:
                name: db-credentials
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              memory: "256Mi"
          readinessProbe:
            httpGet:
              path: /ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata:
  name: api
  namespace: boutique
spec:
  type: ClusterIP
  selector:
    app: api
  ports:
    - port: 8080
      targetPort: 8080
      name: http

La interfaz web

Misma estructura: un Deployment con dos réplicas y un Service ClusterIP en el puerto 80. Solo hay un punto que requiere atención, y suele jugar malas pasadas:

          env:
            # El navegador NO está en el clúster: no resuelve
            # http://api:8080. La URL debe ser una ruta pública del Ingress.
            - name: API_BASE_URL
              value: "/api"

Solo el código que se ejecuta dentro del clúster se beneficia del DNS interno. El JavaScript del navegador, por su parte, pasa obligatoriamente por el punto de entrada público, de ahí la siguiente sección.

El Ingress

Un Ingress es una regla de enrutamiento HTTP: un nombre de dominio, rutas y Services de destino. Un único punto de entrada da servicio a toda la aplicación.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: boutique
  namespace: boutique
  annotations:
    # Reescribe /api/produits como /produits antes de reenviar la petición.
    # Esta anotación es ESPECÍFICA de ingress-nginx.
    nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - boutique.example.com
      secretName: boutique-tls     # Secret de tipo kubernetes.io/tls
  rules:
    - host: boutique.example.com
      http:
        paths:
          - path: /api(/|$)(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: api
                port:
                  number: 8080
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 80

Lo que conviene entender antes de pasarse una hora depurando: el objeto Ingress no hace nada por sí solo. Es una declaración inerte mientras un controlador de entrada no la haya leído. Sin un controlador instalado, apply tiene éxito y nada se enruta.

minikube addons enable ingress
kubectl get pods -n ingress-nginx     # ¿está funcionando el controlador?
kubectl get ingress -n boutique       # ADDRESS vacío = regla no atendida
minikube ip                           # la dirección que hay que poner en /etc/hosts

La segunda reserva, más estructural: las anotaciones son propias de cada controlador. nginx.ingress.kubernetes.io/rewrite-target no tiene ningún sentido para Traefik, HAProxy o el AWS Load Balancer Controller, que tienen su propio prefijo y su propia sintaxis. Solo la estructura rules está normalizada; la reescritura, los tiempos de espera, la limitación de tasa y la redirección a HTTPS son dialecto propio de cada controlador. Esta divergencia es la que motivó la Gateway API, llamada a sustituir a Ingress a largo plazo.

Hay tres valores de pathType: Exact (coincidencia estricta), Prefix (por segmentos de ruta, el caso habitual) e ImplementationSpecific, delegado en el controlador y necesario para los patrones con captura como el anterior.

Solicitudes y límites de recursos

Dos valores, dos funciones distintas. Confundirlos produce fallos difíciles de interpretar.

          resources:
            requests:            # RESERVA: sirve para la planificación
              cpu: "100m"        # 100 millicores = 0,1 núcleo
              memory: "128Mi"
            limits:              # TECHO: sirve para contener los desvíos
              memory: "256Mi"

requests es lo que el planificador utiliza para elegir un nodo: suma las solicitudes de los Pods ya presentes y solo coloca un nuevo Pod si la suma se mantiene por debajo de la capacidad. Un Pod sin requests se coloca en cualquier sitio, incluso en un nodo ya saturado.

limits es el techo que se aplica durante la ejecución, y la CPU y la memoria no reaccionan a él de la misma manera en absoluto:

  • Superar el límite de CPU provoca throttling: el contenedor se ralentiza. La aplicación se vuelve lenta, sin errores ni reinicios; un síntoma insidioso, que a menudo se atribuye por error a la red o a la base de datos.
  • Superar el límite de memoria provoca un OOMKilled: el contenedor se termina de inmediato. La memoria no es compresible.
kubectl describe pod api-7d4b8c9f5-xk2mp | grep -A5 "Last State"
kubectl top pods -n boutique     # consumo real, si metrics-server está instalado
kubectl top nodes

De ahí la práctica recomendada, contraintuitiva a primera vista: un límite de memoria, pero ningún límite de CPU; es la decisión adoptada en los manifiestos anteriores. El límite de memoria protege el nodo frente a un vecino con fugas, ya que una saturación de memoria provoca desalojos masivos. El límite de CPU, en cambio, deja procesador sin usar cuando una aplicación podría aprovecharlo: la solicitud ya garantiza su parte en caso de contención.

El namespace puede imponer salvaguardas, en lugar de depender de la disciplina de cada autor de manifiestos:

apiVersion: v1
kind: LimitRange
metadata:
  name: valeurs-par-defaut
  namespace: boutique
spec:
  limits:
    - type: Container
      default:                 # límites aplicados si no figuran en el Pod
        memory: "256Mi"
      defaultRequest:          # solicitudes aplicadas si no figuran en el Pod
        cpu: "100m"
        memory: "128Mi"
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: quota-boutique
  namespace: boutique
spec:
  hard:
    requests.cpu: "4"
    requests.memory: "8Gi"
    limits.memory: "16Gi"
    persistentvolumeclaims: "10"

Consecuencia que conviene prever: en cuanto un ResourceQuota afecta a un recurso de cómputo, todo Pod del namespace debe declarar el valor correspondiente, o se rechazará su creación. El LimitRange es precisamente lo que evita que esta regla rompa los despliegues existentes.

El autoescalado horizontal

El HorizontalPodAutoscaler ajusta el número de réplicas según una métrica observada.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
  namespace: boutique
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70     # 70 % de la SOLICITUD, no del límite
  behavior:
    scaleUp:                         # subir rápido para absorber un pico
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30
    scaleDown:                       # bajar despacio, o se producen oscilaciones
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 50
          periodSeconds: 60

El detalle decisivo está en el comentario: averageUtilization: 70 se refiere a la solicitud, no al límite. Con requests.cpu: 100m, el escalado se activa con un consumo medio de 70 millicores. Una solicitud mal calibrada hace, por tanto, que el autoescalado sea errático: demasiado baja, añade réplicas de forma constante; demasiado alta, nunca reacciona.

Hay dos requisitos previos estrictos, que explican la práctica totalidad de los HPA que «no funcionan»: metrics-server debe estar instalado y los contenedores objetivo deben declarar requests para la métrica elegida; sin solicitud de CPU, el porcentaje es incalculable.

minikube addons enable metrics-server
kubectl get hpa -n boutique       # TARGETS en <unknown> = faltan métricas
kubectl describe hpa api -n boutique

Tenga en cuenta, por último, que el HPA añade Pods pero no crea nodos: si el clúster está lleno, los nuevos Pods permanecen en Pending. Añadir capacidad de máquina corresponde al Cluster Autoscaler o a Karpenter, específicos de cada proveedor cloud.

Desplegar y verificar el conjunto

No es necesario orquestar el orden de creación: los bucles de reconciliación convergen, y un Pod que todavía no encuentre su dependencia se reiniciará.

kubectl apply -f k8s/ --recursive
kubectl rollout status deployment/api -n boutique
kubectl rollout status statefulset/postgres -n boutique
kubectl get all -n boutique
kubectl get events -n boutique --sort-by=.metadata.creationTimestamp

El diagnóstico de una aplicación multiservicio sigue siempre el mismo camino, de fuera hacia dentro:

kubectl describe ingress boutique -n boutique   # 1. ¿está la regla atendida?
kubectl get endpoints -n boutique               # 2. ¿apuntan los Services a Pods?
kubectl get pods -n boutique                    # 3. ¿READY 3/3, y no solo Running?
kubectl port-forward -n boutique service/api 8080:8080   # 4. saltarse el Ingress

Esta progresión aísla el fallo en cuatro comandos. Si port-forward funciona pero el Ingress no, el problema está en el enrutamiento y no en la aplicación: se ahorra una hora de lectura de logs de la aplicación.

Una introducción a Helm

Estos manifiestos funcionan. El problema llega con el segundo entorno: lo mismo, pero con dos réplicas, otro nombre de dominio y una imagen de preproducción. El copiar y pegar crea dos fuentes de verdad que acabarán divergiendo.

Helm es un gestor de paquetes para Kubernetes. Un chart agrupa manifiestos parametrizados cuyos valores se extraen a un archivo aparte: Chart.yaml describe el paquete, values.yaml contiene los valores por defecto y templates/ contiene los manifiestos.

# values.yaml
api:
  image:
    repository: exemple/boutique-api
    tag: "1.4.0"
  replicas: 3
ingress:
  host: boutique.example.com
# templates/deployment-api.yaml — extracto
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-api
  namespace: {{ .Release.Namespace }}
spec:
  replicas: {{ .Values.api.replicas }}
  template:
    spec:
      containers:
        - name: api
          image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
# Comprobar el resultado renderizado SIN enviar nada al clúster: hágalo siempre
helm template boutique ./boutique -f values-production.yaml

helm install boutique ./boutique -n boutique --create-namespace
helm upgrade boutique ./boutique -n boutique -f values-production.yaml
helm history boutique -n boutique
helm rollback boutique 1 -n boutique

Lo que aporta Helm: un único conjunto de manifiestos para varios entornos, un versionado de las entregas y una vuelta atrás a escala de toda la aplicación, mientras que kubectl rollout undo solo trata un objeto cada vez. Lo que cuesta, y conviene decirlo: la plantilla degrada la legibilidad. Un chart muy parametrizado se convierte en un ejercicio de lógica de plantillas en el que ya no se ve el YAML generado. Por eso han encontrado su público alternativas como Kustomize, que superpone parches sobre un YAML que sigue siendo legible. Adquiera el reflejo de ejecutar helm template antes de cualquier helm upgrade: verá lo que el clúster va a recibir realmente.

Conclusiones

  • El Ingress centraliza un punto de entrada HTTP para todos sus Services. No hace nada sin un controlador de entrada instalado, y sus anotaciones son propias de cada controlador: solo la estructura rules es portable.
  • requests sirve para la planificación (scheduling), limits para la ejecución. Superar el límite de CPU ralentiza de forma silenciosa (throttling); superar el límite de memoria mata el contenedor (OOMKilled). De ahí la práctica habitual: siempre un límite de memoria, rara vez un límite de CPU.
  • El HPA razona en porcentaje de la solicitud (request), no del límite. Exige metrics-server y requests declaradas, y añade Pods sin crear nodos.
  • Diagnostique de fuera hacia dentro: Ingress, después endpoints, después estado de los Pods y, por último, port-forward para saltarse el enrutamiento y aislar la capa defectuosa.
  • Helm evita la duplicación de los manifiestos entre entornos y aporta una reversión global. El precio es la legibilidad: compruebe siempre el resultado con helm template antes de aplicar.

Para ir más allá: los temas que prolongan de forma natural este curso son el RBAC, las NetworkPolicy, la observabilidad (Prometheus, Grafana) y el despliegue continuo mediante GitOps. La documentación oficial kubernetes.io/es/docs/concepts/ sigue siendo la referencia que conviene consultar en primer lugar.

Comentarios

Los comentarios funcionan con GitHub Discussions

Inicia sesión con GitHub para participar en la discusión

¡Enlace copiado!