Cerrábamos la lección anterior con una conclusión: la inversión en App Engine no se acumula fuera de App Engine, pero la inversión en contenedores sirve en todas partes. Kubernetes es el estándar de facto para orquestar contenedores, y Google Kubernetes Engine es la implementación gestionada de Google —significativamente, la casa donde nació Kubernetes, derivado del sistema interno Borg.

Kubernetes tiene fama de complejo, y en parte es merecida. Pero esa complejidad responde a un problema real: cuando tienes decenas de contenedores repartidos en varias máquinas, alguien tiene que decidir dónde se ejecuta cada uno, reiniciar los que fallan, repartir el tráfico, actualizar sin cortar el servicio y crecer cuando llega un pico. Si no lo hace Kubernetes, lo haces tú a mano.

En esta lección aprenderás Kubernetes desde cero con lo justo y suficiente, crearás el clúster alpinashop-cluster, publicarás la imagen del catálogo Flask en Artifact Registry y desplegarás alpinashop-web con manifiestos completos, escalado automático, configuración, secretos y actualizaciones sin corte con su rollback.

Contenido

  1. Kubernetes en diez minutos: los objetos que importan
  2. Nodos y plano de control
  3. Qué añade GKE
  4. Autopilot frente a Standard
  5. Crear alpinashop-cluster y conectar kubectl
  6. Construir y publicar la imagen en Artifact Registry
  7. El Deployment de alpinashop-web
  8. El Service: exponer la aplicación
  9. Escalado: manual, HPA y autoescalado del clúster
  10. ConfigMaps y Secrets
  11. Actualizaciones sin corte y rollback
  12. Observabilidad básica del clúster

  1. Kubernetes en diez minutos: los objetos que importan

Kubernetes es un sistema declarativo: tú describes el estado deseado en ficheros YAML y el sistema trabaja continuamente para que la realidad coincida con esa descripción. Si pides tres réplicas y una muere, Kubernetes crea otra. No das órdenes; declaras objetivos.

Los objetos imprescindibles:

Objeto Qué es Analogía
Contenedor Un proceso empaquetado con todas sus dependencias La aplicación y su entorno, en una caja
Pod La unidad mínima que Kubernetes despliega: uno o varios contenedores que comparten red y almacenamiento Un "servidor lógico" desechable
ReplicaSet Garantiza que existan N pods iguales El que cuenta y repone
Deployment Gestiona ReplicaSets y orquesta las actualizaciones Lo que tú escribes de verdad
Service Un nombre y una IP estables que reparten tráfico entre pods El balanceador interno
Ingress Enrutamiento HTTP(S) desde fuera hacia varios Services El proxy inverso
Namespace Partición lógica del clúster Carpeta con permisos y cuotas
ConfigMap Configuración no sensible Fichero de configuración
Secret Datos sensibles (con matices, apartado 10) Sobre cerrado, no blindado
Node Una máquina (VM) que ejecuta pods El servidor físico

Tres ideas que hay que interiorizar antes de escribir un YAML:

  • Los pods son efímeros y desechables. Nacen, mueren y se recrean con otro nombre y otra IP. Nunca te conectes a un pod por su IP: para eso está el Service. Es la misma lección de las instancias del MIG en 02-01, llevada al extremo.
  • Casi nunca creas pods directamente. Creas un Deployment, que crea un ReplicaSet, que crea los pods. Cada capa añade una garantía.
  • Todo se identifica por labels. Un Service no conoce a sus pods por nombre: selecciona los que llevan una etiqueta determinada. Si el selector no coincide con las etiquetas de los pods, el Service existe pero no envía tráfico a nadie. Es el error número uno de los principiantes.
graph TD
    subgraph "Plano de control (gestionado por Google)"
        API[API Server]
        SCHED[Scheduler]
        CM[Controller Manager]
        ETCD[(etcd)]
    end

    subgraph "Nodos (VMs de Compute Engine)"
        subgraph "Nodo 1"
            P1[Pod alpinashop-web]
            P2[Pod alpinashop-web]
        end
        subgraph "Nodo 2"
            P3[Pod alpinashop-web]
        end
    end

    DEP[Deployment<br/>alpinashop-web] --> RS[ReplicaSet]
    RS --> P1
    RS --> P2
    RS --> P3

    SVC[Service LoadBalancer<br/>alpinashop-web] --> P1
    SVC --> P2
    SVC --> P3

    KUBECTL[kubectl] --> API
    API --> SCHED
    API --> CM
    API --> ETCD
    INTERNET((Internet)) --> SVC

  1. Nodos y plano de control

Un clúster de Kubernetes tiene dos mitades:

El plano de control es el cerebro. Contiene el API Server (la única puerta de entrada: kubectl y todo lo demás hablan con él), el Scheduler (decide en qué nodo va cada pod), los Controller Managers (los bucles que comparan estado real con estado deseado y actúan) y etcd (la base de datos que guarda todo el estado del clúster).

Los nodos son las máquinas que ejecutan los pods. En GKE son VM de Compute Engine —las mismas de la lección 02-01—, cada una con el kubelet (el agente que habla con el plano de control y arranca contenedores), un runtime de contenedores y kube-proxy (que implementa la red de los Services).

Montar y mantener un plano de control por tu cuenta es un trabajo considerable: alta disponibilidad de etcd, certificados, actualizaciones coordinadas, copias de seguridad. Esa es exactamente la parte que GKE te quita.

  1. Qué añade GKE

Sobre Kubernetes vainilla, GKE aporta:

  • Plano de control gestionado y con SLA. Google lo despliega, lo replica, lo parchea y lo actualiza. En modo regional, replicado en varias zonas.
  • Actualizaciones automáticas de plano de control y nodos, con canales de versión (rapid, regular, stable) para elegir cuánta novedad quieres.
  • Reparación automática de nodos: un nodo que deja de responder se recrea.
  • Autoescalado de nodos: si no caben más pods, se añaden nodos; si sobran, se retiran.
  • Integración con la red de Google: los Services de tipo LoadBalancer crean balanceadores nativos de Google Cloud, y los pods reciben IP de la VPC (03-01).
  • Integración con IAM y Workload Identity: los pods se autentican ante las APIs de Google Cloud con una identidad propia, sin claves.
  • Cloud Logging y Cloud Monitoring integrados desde el primer momento.
  • Autopilot: un modo donde ni siquiera ves los nodos.

  1. Autopilot frente a Standard

Es la primera decisión al crear un clúster, y condiciona el día a día.

Autopilot Standard
¿Quién gestiona los nodos? Google, por completo Tú: tamaño, número, imagen, pools
¿Ves las VM? No Sí, en Compute Engine
Facturación Por CPU, memoria y disco solicitados por tus pods Por las VM de los nodos, se usen o no
Escalado de nodos Automático e invisible Autoescalador configurable por pool
Seguridad Endurecido por defecto (sin privilegios, sin acceso al host) Configurable, más permisivo
DaemonSets y acceso al host Limitado Permitido
GPU y hardware especial Soportado con restricciones Control total
Sobrecarga operativa Mínima Media-alta
Cuándo elegirlo Caso general, equipos pequeños, aplicaciones estándar Necesidades específicas de hardware, agentes con privilegios, ajuste fino de costes a gran escala

La diferencia de facturación es la clave para entenderlo. En Standard pagas los nodos: si tienes tres VM e2-standard-4 y tus pods usan el 15 %, pagas el 100 %. En Autopilot pagas lo que tus pods solicitan: si un pod pide 250 mCPU y 512 MiB, eso es lo que se factura. Esto tiene dos consecuencias directas: los requests de tus manifiestos dejan de ser una recomendación y pasan a ser tu factura, y no hay incentivo para "rellenar" nodos.

AlpinaShop elige Autopilot. Marta es la única responsable de infraestructura de una empresa de 40 personas; no tiene tiempo para dimensionar pools de nodos ni para ajustar el binpacking. La aplicación es un contenedor web estándar sin requisitos especiales. Autopilot elimina toda una categoría de trabajo a cambio de un sobrecoste por unidad que, a esta escala, es irrelevante frente a las horas de Marta.

  1. Crear alpinashop-cluster y conectar kubectl

gcloud services enable container.googleapis.com artifactregistry.googleapis.com

gcloud container clusters create-auto alpinashop-cluster \
  --project=alpinashop-prod \
  --region=europe-west1 \
  --release-channel=regular \
  --labels=entorno=prod,equipo=plataforma,centro-coste=tienda,aplicacion=catalogo

Notas sobre el comando:

  • create-auto crea un clúster Autopilot. Para Standard sería create con toda la configuración de pools de nodos.
  • --region (no --zone): los clústeres Autopilot son siempre regionales, con el plano de control replicado entre zonas. Es la elección correcta para producción y coherente con el razonamiento de 01-05.
  • --release-channel=regular: versiones estables con actualizaciones automáticas. stable es más conservador y rapid da acceso anticipado a versiones nuevas.

La creación tarda entre 5 y 10 minutos. Después hay que decirle a kubectl con qué clúster hablar:

# Instalar el plugin de autenticacion si no esta (Cloud Shell ya lo trae)
gcloud components install gke-gcloud-auth-plugin

# Obtener credenciales: escribe la configuracion en ~/.kube/config
gcloud container clusters get-credentials alpinashop-cluster \
  --region=europe-west1 --project=alpinashop-prod

# Comprobar la conexion
kubectl cluster-info
kubectl get nodes
kubectl get namespaces

En Autopilot, kubectl get nodes puede devolver una lista vacía al principio: los nodos aparecen cuando hay pods que ejecutar. Es desconcertante la primera vez y es exactamente el comportamiento esperado.

Creamos un namespace propio, en lugar de trabajar en default:

kubectl create namespace tienda
kubectl config set-context --current --namespace=tienda

Los namespaces permiten separar entornos y equipos dentro de un clúster, con cuotas de recursos y permisos RBAC propios. Usar default para todo es un mal hábito que se paga cuando el clúster crece.

  1. Construir y publicar la imagen en Artifact Registry

Kubernetes ejecuta imágenes de contenedor, así que hay que empaquetar el catálogo Flask. Artifact Registry es el registro de artefactos de Google Cloud —sucesor de Container Registry, que está retirado; si encuentras documentación con gcr.io, está desactualizada.

gcloud artifacts repositories create alpinashop \
  --repository-format=docker \
  --location=europe-west1 \
  --description="Imagenes de contenedor de AlpinaShop"

# Configurar Docker para autenticarse contra este registro
gcloud auth configure-docker europe-west1-docker.pkg.dev

El Dockerfile del catálogo:

# Imagen base slim: mas pequeña que la completa, con menos superficie de ataque
FROM python:3.12-slim

# Evita que Python escriba .pyc y fuerza salida sin buffer,
# imprescindible para que los logs lleguen a Cloud Logging en tiempo real.
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# Copiar primero SOLO requirements.txt: si no cambia, Docker reutiliza
# la capa de dependencias en las siguientes construcciones. Es la
# optimizacion de cache mas rentable de un Dockerfile.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Ahora el codigo, que cambia en cada commit
COPY . .

# Usuario sin privilegios: Autopilot RECHAZA contenedores que
# intenten ejecutarse como root.
RUN useradd --create-home --uid 1000 alpina && chown -R alpina:alpina /app
USER 1000

EXPOSE 8080

# 2 workers y 4 hilos: la app espera mucho a la base de datos,
# asi que los hilos aprovechan bien ese tiempo muerto.
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", \
     "--threads", "4", "--timeout", "60", "main:app"]

Construcción y publicación. Hay dos caminos:

# Opcion A: construir en local con Docker
IMAGEN="europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:v1"
docker build -t "$IMAGEN" .
docker push "$IMAGEN"

# Opcion B: construir en la nube con Cloud Build (no requiere Docker local)
gcloud builds submit --tag "$IMAGEN" .

La opción B es especialmente cómoda desde Cloud Shell y es el germen del pipeline de integración continua que montaremos en 06-01.

Verifica lo publicado:

gcloud artifacts docker images list \
  europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop

# Analisis de vulnerabilidades (si esta activado en el repositorio)
gcloud artifacts docker images scan "$IMAGEN"

Dos buenas prácticas de etiquetado: nunca uses :latest en producción —no sabrás qué versión está corriendo ni podrás hacer rollback— y etiqueta con el hash del commit (catalogo:a3f9c1d), que hace trazable qué código está en cada pod. Es la misma idea de nombres inmutables que aplicamos a los objetos de Storage en 02-02 y a las plantillas de instancia en 02-01.

  1. El Deployment de alpinashop-web

Ahora el manifiesto principal. Cada bloque está comentado:

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: alpinashop-web
  namespace: tienda
  labels:
    app: alpinashop-web
    entorno: prod
spec:
  # Numero de pods deseado. El HPA (apartado 9) lo sobrescribira despues.
  replicas: 3

  # El Deployment gobierna los pods que coinciden con este selector.
  # DEBE coincidir con las labels de la plantilla de abajo.
  selector:
    matchLabels:
      app: alpinashop-web

  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # hasta 1 pod extra durante la actualizacion
      maxUnavailable: 0    # nunca bajar del numero deseado: cero corte

  template:
    metadata:
      labels:
        app: alpinashop-web    # etiqueta que usara el Service
        entorno: prod
    spec:
      # Cuenta de servicio de Kubernetes vinculada por Workload Identity
      # a una cuenta de servicio de Google Cloud (apartado 10).
      serviceAccountName: sa-catalogo

      containers:
        - name: catalogo
          image: europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:v1

          ports:
            - name: http
              containerPort: 8080

          # requests: lo que el planificador reserva. En Autopilot, LO QUE PAGAS.
          # limits: el techo. Si se supera la memoria, el contenedor muere (OOMKilled).
          resources:
            requests:
              cpu: "250m"          # 0,25 de una vCPU
              memory: "512Mi"
              ephemeral-storage: "1Gi"
            limits:
              cpu: "500m"
              memory: "512Mi"      # igual que requests: evita sorpresas de memoria

          env:
            - name: BUCKET_CATALOGO
              value: "alpinashop-catalogo"
            - name: ENTORNO
              valueFrom:
                configMapKeyRef:
                  name: config-catalogo
                  key: entorno
            - name: INSTANCIA_SQL
              valueFrom:
                configMapKeyRef:
                  name: config-catalogo
                  key: instancia_sql
            - name: DB_USER
              valueFrom:
                configMapKeyRef:
                  name: config-catalogo
                  key: db_user
            - name: DB_PASS
              valueFrom:
                secretKeyRef:
                  name: secreto-catalogo
                  key: db_pass

          # ¿Esta vivo el contenedor? Si falla, Kubernetes lo REINICIA.
          livenessProbe:
            httpGet:
              path: /salud
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 20
            failureThreshold: 3

          # ¿Esta listo para recibir trafico? Si falla, se le RETIRA el trafico
          # pero NO se reinicia. Es la sonda que evita servir errores durante
          # el arranque o una sobrecarga puntual.
          readinessProbe:
            httpGet:
              path: /salud
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 2

          # Endurecimiento exigido por Autopilot
          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]

      # Reparte los pods entre zonas: si cae europe-west1-b, la tienda sigue.
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: alpinashop-web

Los puntos que más se equivocan:

  • selector.matchLabels debe coincidir con template.metadata.labels. Si no, el Deployment no reconoce a sus propios pods.
  • requests frente a limits. requests es lo que el planificador reserva y, en Autopilot, lo que facturas. limits es el techo: superar el límite de memoria mata el contenedor con OOMKilled. Igualar memoria de request y limit evita sorpresas.
  • Liveness y readiness no son lo mismo. La primera reinicia; la segunda solo retira tráfico. Una liveness mal configurada —por ejemplo, apuntando a una ruta que consulta la base de datos— provoca reinicios en cadena cuando la base de datos va lenta, empeorando el problema. Mantén /salud ligera y sin dependencias externas.
  • maxUnavailable: 0 garantiza que la capacidad nunca baja durante un despliegue.

Aplicar y comprobar:

kubectl apply -f deployment.yaml

kubectl get deployments
kubectl get pods -o wide          # muestra en qué nodo y zona cae cada pod
kubectl describe pod <nombre-del-pod>
kubectl logs -f deployment/alpinashop-web

  1. El Service: exponer la aplicación

Los pods tienen IP efímeras y cambiantes. Un Service ofrece un nombre DNS y una IP virtual estables que reparten tráfico entre los pods que coincidan con su selector.

Tipo de Service Alcance Uso
ClusterIP (por defecto) Solo dentro del clúster Comunicación entre servicios internos
NodePort Puerto en cada nodo Raro de usar directamente
LoadBalancer IP pública externa Exponer un servicio a internet
ExternalName Alias DNS a un host externo Integración con servicios de fuera
# service.yaml
apiVersion: v1
kind: Service
metadata:
  name: alpinashop-web
  namespace: tienda
  annotations:
    # Balanceador nativo por IP de pod: el trafico va directo al pod,
    # sin salto adicional por el nodo. Menos latencia y mejor health checking.
    cloud.google.com/neg: '{"ingress": true}'
spec:
  type: LoadBalancer
  selector:
    app: alpinashop-web      # DEBE coincidir con las labels de los pods
  ports:
    - name: http
      protocol: TCP
      port: 80               # puerto expuesto al exterior
      targetPort: 8080       # puerto del contenedor
kubectl apply -f service.yaml

# La IP externa tarda 1-2 minutos en aparecer
kubectl get service alpinashop-web --watch

IP=$(kubectl get service alpinashop-web -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
curl -s "http://$IP/"

Un Service de tipo LoadBalancer en GKE crea automáticamente un balanceador de red de Google Cloud. Es la forma más rápida de exponer algo, pero para una tienda real querrás HTTPS, un dominio propio, enrutamiento por ruta y protección frente a ataques. Eso se consigue con un Ingress o un Gateway delante de un balanceador HTTP(S) global, junto con Cloud Armor y certificados gestionados: contenido de las lecciones 03-02, 03-05 y 03-07. Aquí paramos deliberadamente en el LoadBalancer de tipo L4.

Dentro del clúster, cualquier pod puede llamar a este servicio por su nombre DNS:

http://alpinashop-web.tienda.svc.cluster.local
http://alpinashop-web            # forma corta, dentro del mismo namespace

  1. Escalado: manual, HPA y autoescalado del clúster

Kubernetes escala en dos niveles independientes: número de pods y número de nodos.

Escalado manual de pods:

kubectl scale deployment alpinashop-web --replicas=5
kubectl get pods -w

HorizontalPodAutoscaler (HPA): ajusta las réplicas automáticamente según una métrica.

# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: alpinashop-web
  namespace: tienda
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: alpinashop-web

  minReplicas: 3
  maxReplicas: 20

  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60    # % sobre el REQUEST de CPU, no sobre el limit
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 75

  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30   # reacciona rapido a los picos
      policies:
        - type: Percent
          value: 100                   # como maximo, duplicar cada 30 s
          periodSeconds: 30
    scaleDown:
      stabilizationWindowSeconds: 300  # baja despacio: evita oscilaciones
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60            # retirar como mucho 1 pod por minuto
kubectl apply -f hpa.yaml
kubectl get hpa alpinashop-web --watch
kubectl describe hpa alpinashop-web    # muestra por qué escaló o no

El bloque behavior es lo que separa un HPA que funciona de uno que oscila. La asimetría es deliberada: subir rápido y bajar despacio. Un pico de tráfico requiere capacidad inmediata; retirar capacidad con prisa provoca flapping, con pods creándose y destruyéndose sin parar. La ventana de 300 segundos a la baja es una recomendación muy razonable como punto de partida.

Cuidado con un detalle: el porcentaje del HPA se calcula sobre el request, no sobre el límite. Con requests.cpu: 250m y objetivo del 60 %, el HPA escala cuando el uso medio supera 150 mCPU por pod. Un request mal puesto descoloca todo el autoescalado.

Autoescalado del clúster. En Autopilot es automático e invisible: si los pods nuevos no caben, Google añade capacidad; si sobra, la retira. No hay nada que configurar, que es precisamente su propuesta de valor. En Standard hay que configurarlo por pool:

gcloud container clusters update alpinashop-cluster-std \
  --enable-autoscaling --min-nodes=1 --max-nodes=10 \
  --node-pool=pool-principal --region=europe-west1

Prueba de carga rápida para ver el HPA en acción:

kubectl run generador-carga --rm -it --image=busybox:1.36 --restart=Never -- \
  /bin/sh -c "while true; do wget -q -O- http://alpinashop-web.tienda/; done"

En otra terminal, kubectl get hpa --watch mostrará subir el uso de CPU y, tras unos segundos, aumentar el número de réplicas.

  1. ConfigMaps y Secrets

ConfigMap para configuración no sensible:

# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: config-catalogo
  namespace: tienda
data:
  entorno: "produccion"
  instancia_sql: "alpinashop-prod:europe-west1:alpinashop-pedidos"
  db_user: "app_catalogo"
  db_name: "tienda"
  productos_por_pagina: "24"
kubectl apply -f configmap.yaml
kubectl get configmap config-catalogo -o yaml

Secret para datos sensibles:

kubectl create secret generic secreto-catalogo \
  --from-literal=db_pass='<contraseña>' \
  --namespace=tienda

Y aquí viene la advertencia más importante de este apartado: los Secrets de Kubernetes están codificados en base64, no cifrados. Cualquiera con permiso para leer secrets en ese namespace los ve en claro:

kubectl get secret secreto-catalogo -o jsonpath='{.data.db_pass}' | base64 -d

Base64 no es cifrado, es una codificación. Un Secret de Kubernetes protege frente a que la contraseña aparezca en un kubectl describe o en un manifiesto del repositorio, y nada más.

Lo correcto en producción es Secret Manager (lección 03-06), integrado con GKE mediante el complemento Secret Manager CSI driver, que monta los secretos como ficheros obtenidos en tiempo de ejecución, con rotación centralizada y auditoría. La regla práctica: los Secrets de Kubernetes valen para desarrollo y para datos de bajo impacto; las credenciales reales de producción viven en Secret Manager.

Workload Identity merece un párrafo propio porque resuelve el problema de raíz. Vincula una cuenta de servicio de Kubernetes con una de Google Cloud, de modo que los pods obtienen credenciales de Google Cloud automáticamente, sin ficheros de clave:

PROYECTO=alpinashop-prod

# 1. Cuenta de servicio de Google Cloud
gcloud iam service-accounts create sa-catalogo-gke \
  --display-name="Catalogo en GKE"

gcloud projects add-iam-policy-binding $PROYECTO \
  --member="serviceAccount:sa-catalogo-gke@$PROYECTO.iam.gserviceaccount.com" \
  --role="roles/cloudsql.client"

gcloud storage buckets add-iam-policy-binding gs://alpinashop-catalogo \
  --member="serviceAccount:sa-catalogo-gke@$PROYECTO.iam.gserviceaccount.com" \
  --role="roles/storage.objectUser"

# 2. Cuenta de servicio de Kubernetes
kubectl create serviceaccount sa-catalogo --namespace=tienda

# 3. Vincular ambas
gcloud iam service-accounts add-iam-policy-binding \
  "sa-catalogo-gke@$PROYECTO.iam.gserviceaccount.com" \
  --role="roles/iam.workloadIdentityUser" \
  --member="serviceAccount:$PROYECTO.svc.id.goog[tienda/sa-catalogo]"

kubectl annotate serviceaccount sa-catalogo --namespace=tienda \
  iam.gke.io/gcp-service-account="sa-catalogo-gke@$PROYECTO.iam.gserviceaccount.com"

Con esto, el mismo storage.Client() y el mismo conector de Cloud SQL de las lecciones anteriores funcionan dentro del pod sin ninguna credencial. Es la continuación natural de lo que ya viste en Compute Engine y App Engine: la identidad la da la plataforma, no un fichero.

  1. Actualizaciones sin corte y rollback

Con strategy: RollingUpdate y maxUnavailable: 0, actualizar la imagen sustituye los pods de uno en uno sin bajar la capacidad:

# Publicar la version nueva
gcloud builds submit \
  --tag europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:v2 .

# Actualizar el Deployment
kubectl set image deployment/alpinashop-web \
  catalogo=europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:v2

# Seguir el progreso
kubectl rollout status deployment/alpinashop-web

# Historial de revisiones
kubectl rollout history deployment/alpinashop-web

El proceso, paso a paso: se crea un pod con v2, se espera a que su readinessProbe dé el visto bueno, se retira un pod de v1, y se repite. Como maxUnavailable: 0, siempre hay al menos tres pods listos. Aquí se ve por qué la readinessProbe es imprescindible: sin ella, Kubernetes daría por buena la nueva versión en cuanto el contenedor arranca, antes de que gunicorn esté aceptando peticiones, y algunos clientes recibirían errores.

Si la versión nueva falla:

# Volver a la revision anterior
kubectl rollout undo deployment/alpinashop-web

# Volver a una revision concreta
kubectl rollout undo deployment/alpinashop-web --to-revision=3

# Pausar una actualizacion en curso (canary manual)
kubectl rollout pause deployment/alpinashop-web
kubectl rollout resume deployment/alpinashop-web

Para proteger el servicio también durante operaciones de mantenimiento del clúster —actualizaciones de nodos, reescalados—, define un PodDisruptionBudget:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: alpinashop-web
  namespace: tienda
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: alpinashop-web

Esto le dice a Kubernetes que nunca deje menos de 2 pods disponibles cuando él decide mover pods. Sin un PDB, una actualización de nodos podría vaciar un nodo entero y dejar la tienda con menos capacidad de la necesaria en el peor momento.

  1. Observabilidad básica del clúster

GKE envía métricas y logs a Cloud Monitoring y Cloud Logging sin configuración adicional. Los comandos de diagnóstico de cada día:

# Estado general
kubectl get all -n tienda
kubectl get events -n tienda --sort-by=.metadata.creationTimestamp

# Consumo real de pods y nodos
kubectl top pods -n tienda
kubectl top nodes

# Logs
kubectl logs -f deployment/alpinashop-web
kubectl logs deployment/alpinashop-web --previous   # logs del contenedor que murió

# Diagnostico de un pod que no arranca
kubectl describe pod <pod>          # la seccion Events dice casi siempre por qué

# Abrir una shell dentro del contenedor
kubectl exec -it <pod> -- /bin/sh

# Probar el servicio sin exponerlo
kubectl port-forward service/alpinashop-web 8080:80

Los estados de pod que verás con más frecuencia y qué significan:

Estado Causa habitual
Pending No hay recursos para planificarlo; en Autopilot, se está añadiendo capacidad
ImagePullBackOff La imagen no existe o falta permiso sobre Artifact Registry
CrashLoopBackOff El contenedor arranca y muere en bucle. Mira kubectl logs --previous
OOMKilled Superó limits.memory. Sube el límite o arregla la fuga
Running pero no listo La readinessProbe falla; revisa la ruta y el puerto

En Cloud Monitoring hay paneles predefinidos de GKE con uso por clúster, namespace y carga de trabajo, y se pueden definir alertas sobre reinicios de contenedor, pods no disponibles o latencia. El tratamiento completo está en 06-04 y 06-06.

Errores Comunes y Consejos

  • Selector del Service que no coincide con las labels de los pods. El Service existe, no da error y no envía tráfico a nadie. Verifica con kubectl get endpoints alpinashop-web.
  • No definir requests y limits. En Autopilot se aplican valores por defecto que probablemente no son los tuyos; en Standard, un pod sin límites puede ahogar a sus vecinos.
  • Igualar la liveness probe a una ruta que consulta la base de datos. Si la base va lenta, Kubernetes reinicia todos los pods y empeora el incidente.
  • Usar la etiqueta :latest. No sabes qué versión corre ni puedes hacer rollback fiable.
  • Guardar credenciales reales en Secrets de Kubernetes. Base64 no es cifrado. Usa Secret Manager.
  • Ejecutar como root. Autopilot lo rechaza, y en Standard es un riesgo innecesario.
  • Escalar a la baja demasiado rápido en el HPA. Provoca oscilaciones. Usa una ventana de estabilización amplia.
  • Trabajar siempre en el namespace default. Separa por entorno y equipo desde el principio.
  • Olvidar el PodDisruptionBudget. El mantenimiento del clúster puede dejarte sin capacidad.
  • Consejo: kubectl describe antes que ningún otro comando cuando algo no funciona; la sección Events suele dar la respuesta.
  • Consejo: etiqueta las imágenes con el hash del commit. Trazabilidad completa entre código y pod.
  • Consejo: usa kubectl apply -f sobre ficheros versionados en Git, nunca kubectl edit en producción. Lo que no está en Git, no existe.
  • Consejo: kubectl port-forward te deja probar un servicio interno sin exponerlo a internet.

Ejercicios

Ejercicio 1: clúster, imagen y primer despliegue

  1. Crea un clúster Autopilot alpinashop-cluster-ej en europe-west1 y conecta kubectl.
  2. Crea un repositorio de Artifact Registry en europe-west1 y publica una imagen de una aplicación Flask mínima que muestre el nombre del pod (variable HOSTNAME) y responda en /salud.
  3. Escribe el Deployment con 2 réplicas, requests y limits razonables, sondas de liveness y readiness, y securityContext sin root.
  4. Escribe el Service de tipo LoadBalancer y comprueba con curl que el reparto entre pods funciona.
  5. Comprueba con kubectl get endpoints que el Service tiene pods asociados.

Ejercicio 2: escalado y actualización sin corte

  1. Escala manualmente a 4 réplicas y comprueba en qué zonas caen los pods.
  2. Crea un HPA de 2 a 8 réplicas con objetivo de CPU al 60 % y comportamiento asimétrico.
  3. Genera carga y observa el escalado.
  4. Publica una v2 de la imagen con un cambio visible y actualiza el Deployment sin corte, comprobando con curl en bucle que no hay ningún error.
  5. Haz rollback a la versión anterior y verifica el historial de revisiones.

Ejercicio 3: configuración, secretos y diagnóstico

  1. Crea un ConfigMap con el entorno y el número de productos por página, y un Secret con una contraseña ficticia.
  2. Inyecta ambos en el Deployment como variables de entorno y verifica su valor dentro del pod.
  3. Demuestra que el Secret no está cifrado.
  4. Provoca deliberadamente un CrashLoopBackOff (por ejemplo, con un comando de arranque erróneo) y diagnostícalo paso a paso.
  5. Explica qué usarías en producción en lugar de un Secret de Kubernetes y por qué.

Soluciones

Solución 1

gcloud container clusters create-auto alpinashop-cluster-ej \
  --region=europe-west1 --release-channel=regular

gcloud container clusters get-credentials alpinashop-cluster-ej --region=europe-west1
kubectl create namespace tienda
kubectl config set-context --current --namespace=tienda

gcloud artifacts repositories create alpinashop \
  --repository-format=docker --location=europe-west1
# main.py
import os
from flask import Flask

app = Flask(__name__)


@app.route("/")
def inicio():
    return f"<h1>AlpinaShop</h1><p>Pod: {os.environ.get('HOSTNAME', 'local')}</p>"


@app.route("/salud")
def salud():
    return "ok", 200
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --create-home --uid 1000 alpina && chown -R alpina:alpina /app
USER 1000
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "main:app"]
IMG="europe-west1-docker.pkg.dev/$(gcloud config get-value project)/alpinashop/catalogo:v1"
gcloud builds submit --tag "$IMG" .
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: alpinashop-web
  namespace: tienda
spec:
  replicas: 2
  selector:
    matchLabels:
      app: alpinashop-web
  template:
    metadata:
      labels:
        app: alpinashop-web
    spec:
      containers:
        - name: catalogo
          image: europe-west1-docker.pkg.dev/PROYECTO/alpinashop/catalogo:v1
          ports:
            - containerPort: 8080
          resources:
            requests: { cpu: "250m", memory: "512Mi" }
            limits:   { cpu: "500m", memory: "512Mi" }
          livenessProbe:
            httpGet: { path: /salud, port: 8080 }
            initialDelaySeconds: 15
            periodSeconds: 20
          readinessProbe:
            httpGet: { path: /salud, port: 8080 }
            initialDelaySeconds: 5
            periodSeconds: 5
          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            allowPrivilegeEscalation: false
            capabilities: { drop: ["ALL"] }
kubectl apply -f deployment.yaml -f service.yaml

IP=$(kubectl get svc alpinashop-web -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
for i in $(seq 1 10); do curl -s "http://$IP/" | grep -o "Pod: [a-z0-9-]*"; done

# 5. Comprobar que el Service tiene endpoints
kubectl get endpoints alpinashop-web

Si kubectl get endpoints devuelve <none>, el selector del Service no coincide con las labels de los pods, o ningún pod está pasando la readinessProbe. Es la primera comprobación ante un Service que no responde.

Solución 2

# 1. Escalado manual y reparto por zonas
kubectl scale deployment alpinashop-web --replicas=4
kubectl get pods -o custom-columns=\
NOMBRE:.metadata.name,NODO:.spec.nodeName,ESTADO:.status.phase
# 2. hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: alpinashop-web
  namespace: tienda
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: alpinashop-web
  minReplicas: 2
  maxReplicas: 8
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 60 }
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30
    scaleDown:
      stabilizationWindowSeconds: 300
kubectl apply -f hpa.yaml

# 3. Generar carga y observar
kubectl run carga --rm -it --image=busybox:1.36 --restart=Never -- \
  /bin/sh -c "while true; do wget -q -O- http://alpinashop-web.tienda/ >/dev/null; done"
# en otra terminal:
kubectl get hpa alpinashop-web --watch

# 4. Actualizacion sin corte, comprobando que no hay errores
gcloud builds submit --tag "${IMG%:v1}:v2" .

while true; do
  curl -s -o /dev/null -w "%{http_code} " "http://$IP/"
  sleep 0.3
done &

kubectl set image deployment/alpinashop-web catalogo="${IMG%:v1}:v2"
kubectl rollout status deployment/alpinashop-web

# 5. Rollback
kubectl rollout undo deployment/alpinashop-web
kubectl rollout history deployment/alpinashop-web

Durante la actualización, el bucle de curl debe mostrar únicamente códigos 200. Si aparecieran 502 o 503, la causa casi siempre es una readinessProbe mal configurada o maxUnavailable mayor que cero.

Solución 3

# 1. ConfigMap y Secret
kubectl create configmap config-catalogo \
  --from-literal=entorno=produccion \
  --from-literal=productos_por_pagina=24

kubectl create secret generic secreto-catalogo \
  --from-literal=db_pass='ClaveDePrueba123'
# 2. Inyeccion en el Deployment
          env:
            - name: ENTORNO
              valueFrom:
                configMapKeyRef: { name: config-catalogo, key: entorno }
            - name: PRODUCTOS_POR_PAGINA
              valueFrom:
                configMapKeyRef: { name: config-catalogo, key: productos_por_pagina }
            - name: DB_PASS
              valueFrom:
                secretKeyRef: { name: secreto-catalogo, key: db_pass }
kubectl apply -f deployment.yaml
kubectl exec -it deployment/alpinashop-web -- env | grep -E "ENTORNO|PRODUCTOS|DB_PASS"

# 3. El Secret no esta cifrado
kubectl get secret secreto-catalogo -o jsonpath='{.data.db_pass}' | base64 -d; echo

# 4. Provocar y diagnosticar un CrashLoopBackOff
kubectl set image deployment/alpinashop-web catalogo=python:3.12-slim
kubectl get pods
kubectl describe pod <pod>              # Events: Back-off restarting failed container
kubectl logs <pod> --previous           # salida del contenedor que murió
kubectl rollout undo deployment/alpinashop-web

El diagnóstico correcto sigue siempre este orden: kubectl get pods para ver el estado, kubectl describe pod para leer los Events (que indican si el problema es de imagen, de recursos o de sondas) y kubectl logs --previous para ver qué imprimió el contenedor justo antes de morir. En este caso, la imagen python:3.12-slim no tiene comando de arranque útil y termina de inmediato, con Kubernetes reintentando con retroceso exponencial.

  1. En producción usaría Secret Manager con el complemento CSI de GKE, o Workload Identity para eliminar directamente la credencial. Razones: los Secrets de Kubernetes solo están codificados en base64 y son legibles por cualquiera con permisos de lectura en el namespace; no tienen rotación automática; no dejan traza de auditoría de quién accedió; y si se versionan en Git, la credencial queda en el historial para siempre. Secret Manager cifra en reposo, controla el acceso con IAM granular, registra cada acceso y permite rotar sin volver a desplegar. Se estudia en 03-06.

Limpieza:

kubectl delete namespace tienda
gcloud container clusters delete alpinashop-cluster-ej --region=europe-west1 --quiet

Un clúster olvidado es de los recursos más caros que puedes dejar encendidos. Bórralo al terminar los ejercicios.

Conclusión

Has recorrido Kubernetes desde los conceptos hasta una aplicación real en producción. Sabes que es un sistema declarativo donde describes el estado deseado y el sistema converge hacia él, y conoces los objetos que importan: el pod como unidad efímera, el ReplicaSet que cuenta y repone, el Deployment que orquesta actualizaciones, el Service que da una identidad estable a un conjunto cambiante de pods, y los namespaces que particionan el clúster. Has entendido la separación entre plano de control y nodos, y por qué que Google gestione el primero es el núcleo del valor de GKE, junto con la reparación automática, el autoescalado de nodos, las actualizaciones por canales y la integración con la red y con IAM.

Has comparado Autopilot y Standard, y has visto que la diferencia esencial es qué se factura: los nodos en Standard, lo que solicitan tus pods en Autopilot. AlpinaShop eligió Autopilot porque Marta es una sola persona y la aplicación no tiene requisitos especiales de hardware. Has creado alpinashop-cluster regional, has conectado kubectl, has escrito un Dockerfile con caché de dependencias y usuario sin privilegios, y has publicado la imagen en Artifact Registry con etiquetas versionadas y nunca :latest. Has escrito el Deployment completo de alpinashop-web con requests y limits, sondas de liveness y readiness bien diferenciadas, contexto de seguridad y reparto entre zonas; y el Service de tipo LoadBalancer, sabiendo que HTTPS, dominio propio y enrutamiento avanzado llegarán en el módulo 3.

Has escalado a mano, has configurado un HorizontalPodAutoscaler con comportamiento asimétrico —rápido al subir, lento al bajar— y has comprobado que el porcentaje se calcula sobre el request. Has gestionado configuración con ConfigMaps y has descubierto que los Secrets de Kubernetes solo están codificados en base64, con Secret Manager y Workload Identity como respuesta correcta. Y has hecho una actualización sin un solo error 500 y su rollback en un comando, protegido además con un PodDisruptionBudget.

AlpinaShop ya tiene su cómputo resuelto en cuatro niveles distintos y sus datos transaccionales en Cloud SQL. Pero no todo encaja en una base de datos relacional: el carrito de la compra que cambia en cada clic, las sesiones de usuario, la telemetría de qué productos mira la gente. En 02-06, Bases de datos NoSQL: Firestore, Bigtable y Spanner, veremos por qué existen otros modelos de datos —documental, columnar ancho, relacional distribuido—, entenderemos consistencia y el teorema CAP sin dogmatismo, guardaremos el carrito de AlpinaShop en Firestore y su telemetría de clics en Bigtable, situaremos Spanner, Memorystore y BigQuery en un mapa comparativo, y cerraremos con un árbol de decisión y el reparto razonado de qué dato de AlpinaShop vive en cada sitio.

Curso de Google Cloud Platform (GCP)

Módulo 1: Introducción a Google Cloud Platform

Módulo 2: Servicios principales de GCP

Módulo 3: Redes y seguridad

Módulo 4: Datos y análisis

Módulo 5: Aprendizaje automático e IA

Módulo 6: DevOps y monitoreo

Módulo 7: Temas avanzados de GCP

Módulo 8: Proyecto final

© Copyright 2026. Todos los derechos reservados