Empecemos por lo que ninguna lección de Kubernetes suele decir en el primer párrafo: para Tramontana S.L., Kubernetes es desproporcionado. Una aplicación, una base de datos, tres personas y quinientas reservas al mes no justifican un orquestador de contenedores distribuido. Montarlo aquí multiplicaría la complejidad operativa por cinco a cambio de resolver problemas que Tramontana no tiene.

Esta lección lo va a decir con números en el apartado 2, y aun así la vas a hacer entera. Por tres razones que sí se sostienen:

  1. Es la tecnología dominante del sector. Te la vas a encontrar en tu próximo trabajo, en la mayoría de las ofertas de empleo de administración de sistemas y en prácticamente cualquier equipo de plataforma.
  2. Entenderla te hace mejor administrador aunque no la uses. Kubernetes es una destilación de décadas de buenas prácticas —salud, despliegues progresivos, límites de recursos, configuración separada del código— y esas ideas se aplican igual con systemd.
  3. Saber cuándo NO usarla es parte de saber usarla. Y eso solo se puede argumentar habiéndola montado.

Así que la construyes en srv-tramontana-pruebas, con k3s, y despliegas Tramontana Reservas de verdad. Al final vuelves a la pregunta con conocimiento de causa.

Contenido

  1. Objetivo, requisitos previos y advertencia
  2. Qué problema resuelve Kubernetes y cuándo NO usarlo
  3. Arquitectura: plano de control y nodos de trabajo
  4. Distribuciones: kubeadm, k3s, microk8s y los gestionados
  5. Instalación de k3s con un servidor y un agente
  6. kubectl y el kubeconfig
  7. Los objetos, en orden de dependencia
  8. Manifiestos completos para Tramontana Reservas
  9. Despliegue progresivo y vuelta atrás
  10. Seguridad: securityContext, NetworkPolicy, RBAC y PSS
  11. Diagnóstico: leer eventos y los cuatro fallos habituales
  12. Automatización con Ansible
  13. La complejidad operativa real

Objetivo, requisitos previos y advertencia

Objetivo. Levantar un clúster funcional de dos nodos, desplegar Tramontana Reservas con alta disponibilidad interna, exposición TLS, configuración y secretos separados, sondas de salud y límites de recursos; hacer un despliegue sin interrupción y una vuelta atrás; y saber diagnosticar los fallos habituales.

Requisitos previos: contenedores y Docker (07-05), especialmente namespaces, cgroups, capabilities y Dockerfile multietapa; el entorno de virtualización de 07-04; el proxy inverso de 08-01; y la noción de sondas de salud de 07-07.

Advertencia operativa, y va en serio: todo esto se hace en srv-tramontana-pruebas. No se instala Kubernetes en srv-tramontana. k3s modifica las reglas de iptables/nftables, gestiona sus propias interfaces de red y arranca un runtime de contenedores propio; en un servidor de producción con ufw, nftables, AppArmor y un servicio en marcha, la interferencia es real y difícil de deshacer.

$ ssh [email protected] 'hostnamectl; free -m | head -2; nproc'
 Static hostname: srv-tramontana-pruebas
  Operating System: Ubuntu 24.04.1 LTS
            Kernel: Linux 6.8.0-41-generic
               total        used        free
Mem:            3891         412        3102
2

Y una segunda VM para el nodo de trabajo, creada con cloud-init como en 07-04:

$ virt-install --name k8s-nodo2 --memory 2048 --vcpus 2 --disk size=20 \
    --cloud-init user-data=cloud-init-nodo2.yaml \
    --os-variant ubuntu24.04 --import --noautoconsole
$ virsh domifaddr k8s-nodo2
 vnet2  52:54:00:a1:b2:c4  ipv4  192.168.122.105/24

Qué problema resuelve Kubernetes y cuándo NO usarlo

El problema real

Kubernetes nació para gestionar muchos contenedores en muchas máquinas de forma declarativa. Los problemas que resuelve son concretos:

Problema Sin Kubernetes Con Kubernetes
¿En qué máquina arranco este contenedor? Lo decide una persona El planificador, según recursos
Un contenedor muere Alguien lo reinicia Se reinicia solo
Una máquina muere Alguien recoloca sus cargas Se recolocan solas
Desplegar sin cortar servicio Script de drenaje (07-07) Nativo: RollingUpdate
¿Dónde está el servicio X? IP fija o DNS manual DNS interno automático
Escalar de 3 a 30 réplicas Aprovisionar y configurar kubectl scale, segundos
Configuración y secretos Ficheros por máquina ConfigMap y Secret, versionados
Estado deseado del sistema Documentación y confianza El clúster reconcilia solo

Esa última fila es la idea central. Kubernetes es un bucle de reconciliación: tú declaras «quiero tres réplicas de esta imagen con estos límites», y unos controladores comparan continuamente el estado real con el declarado y actúan para acercarlos. No das órdenes; describes un objetivo. Es el mismo principio de idempotencia de Ansible en 07-06, pero continuo en lugar de puntual.

Cuándo NO usarlo, y el caso de Tramontana

Señal ¿Kubernetes?
1-3 servicios, una o dos máquinas No
Un equipo sin persona dedicada a plataforma No
Carga estable y predecible No
Aplicación monolítica con estado en disco local No, o con mucho trabajo
Decenas de servicios y equipos Sí
Escalado frecuente e impredecible Sí
Despliegues varias veces al día Sí
Multi-inquilino con aislamiento Sí
Ya usáis un proveedor con Kubernetes gestionado Probablemente sí

El análisis para Tramontana, con números:

Concepto Situación actual Con Kubernetes
Servicios que orquestar 1 aplicación + 1 base de datos Los mismos
Máquinas necesarias 1 (2 con la réplica) 3 mínimo para plano de control con quórum
Piezas nuevas que mantener — etcd, CNI, Ingress, certificados internos, CRD
Actualizaciones al año apt upgrade 3-4 versiones menores, cada una con notas de ruptura
Formación necesaria Ya la tienes 3-6 meses hasta ser productivo
Tiempo de recuperación en un fallo 50 min (medido, 07-06) Depende de si el fallo es del clúster
Beneficio en disponibilidad ~99,8 % con la opción B de 07-07 El mismo, con más complejidad

La conclusión es inequívoca: Kubernetes no aportaría a Tramontana nada que no aporte la opción B de 07-07 —dos nodos de aplicación con un balanceador—, y añadiría un plano de control entero que mantener. La recomendación profesional sigue siendo la de 07-07.

Y el corolario que conviene interiorizar: Kubernetes no es la evolución natural de un sistema bien administrado. Es una herramienta para un problema de escala concreto. Un sistema de tres máquinas bien mantenido con systemd, Ansible y un balanceador es una arquitectura excelente, no una etapa inmadura.

Arquitectura: plano de control y nodos de trabajo

graph TB
    subgraph CP["PLANO DE CONTROL (nodo servidor)"]
        API["kube-apiserver<br/>única puerta de entrada<br/>valida, autentica, persiste"]
        ETCD[("etcd<br/>base de datos clave-valor<br/>TODO el estado vive aquí")]
        SCH["kube-scheduler<br/>decide EN QUÉ NODO<br/>va cada Pod"]
        CM["kube-controller-manager<br/>bucles de reconciliación<br/>real vs. deseado"]
        API <--> ETCD
        SCH --> API
        CM --> API
    end
    subgraph N1["NODO DE TRABAJO 1"]
        K1["kubelet<br/>agente: arranca y vigila<br/>los Pods de este nodo"]
        P1["kube-proxy<br/>reglas de red<br/>para los Services"]
        R1["containerd<br/>runtime de contenedores"]
        K1 --> R1
    end
    subgraph N2["NODO DE TRABAJO 2"]
        K2["kubelet"]
        P2["kube-proxy"]
        R2["containerd"]
        K2 --> R2
    end
    K1 -.->|"«¿qué me toca<br/>ejecutar?»"| API
    K2 -.-> API
    P1 -.-> API
    P2 -.-> API
    USER["kubectl apply -f app.yaml"] --> API

El plano de control, las cuatro piezas que hay que conocer:

Componente Qué hace Si falla
kube-apiserver Única puerta de entrada. Autentica, autoriza, valida y escribe en etcd. Todo pasa por aquí No se puede cambiar nada; lo que ya corre sigue corriendo
etcd Base de datos clave-valor con todo el estado del clúster El clúster es irrecuperable sin copia de seguridad
kube-scheduler Elige nodo para cada Pod nuevo según recursos, afinidades y restricciones Los Pods nuevos se quedan en Pending
kube-controller-manager Bucles que reconcilian estado real y deseado (réplicas, nodos, endpoints) Nada se autorrepara

Los nodos de trabajo:

Componente Qué hace
kubelet Agente en cada nodo: pregunta al apiserver qué le toca, arranca contenedores, ejecuta las sondas e informa del estado
kube-proxy Programa las reglas de red (iptables o IPVS) que hacen funcionar los Services
runtime (containerd) Ejecuta los contenedores. Es lo de 07-05, sin Docker por medio

Dos observaciones que ordenan el modelo mental:

Todo pasa por el apiserver. No hay comunicación directa entre componentes: el scheduler no habla con el kubelet, escribe en el apiserver que ese Pod va a ese nodo, y el kubelet lo lee. Esa arquitectura de bus central es lo que hace que el sistema sea extensible y auditable.

Si el plano de control cae, la carga sigue funcionando. Los kubelets siguen ejecutando lo que ya tenían y reiniciando lo que se caiga. Lo que se pierde es la capacidad de cambiar cosas y de reaccionar a fallos de nodos completos. Es una propiedad de diseño valiosa y sorprende a mucha gente.

Distribuciones: kubeadm, k3s, microk8s y los gestionados

kubeadm k3s microk8s minikube EKS/GKE/AKS
Quién lo mantiene El proyecto SUSE (Rancher) Canonical El proyecto El proveedor
Instalación Varios pasos Un comando Un snap Un comando Consola web
Tamaño del binario ~1 GB en piezas ~70 MB, uno solo ~200 MB Variable —
RAM mínima realista 2 GB por nodo 512 MB 1 GB 2 GB —
Almacén de estado etcd SQLite por defecto, etcd opcional dqlite etcd Gestionado
Multinodo real Sí Sí Sí No (es local) Sí
Certificado de conformidad Sí Sí Sí Sí Sí
Producción Sí Sí, con matices Sí No Sí
Trae Ingress y balanceador No Sí: Traefik + ServiceLB Con addons Con addons Sí
Coste del plano de control El hardware El hardware El hardware — 70-100 €/mes

La elección es k3s, por cuatro motivos concretos:

  1. Cabe en la máquina de pruebas. Con 3,8 GB y 2 vCPU, kubeadm va justo; k3s deja sitio para desplegar algo encima.
  2. Un binario y un comando, sin dejar de ser Kubernetes certificado: los manifiestos son idénticos y lo aprendido se transfiere sin cambios.
  3. Trae lo imprescindible montado: Traefik como Ingress, ServiceLB para Services de tipo LoadBalancer, local-path como StorageClass y CoreDNS. Con kubeadm habría que instalar y configurar cada pieza, lo que es instructivo y aquí es ruido.
  4. Es producción real, no un juguete: se usa en despliegues en el borde, en fábricas y en dispositivos.

Su compromiso: SQLite en lugar de etcd por defecto, lo que significa un solo nodo de plano de control. Para aprender y para muchos casos reales, sobra; para alta disponibilidad del plano de control hay que pasar a etcd empotrado con tres nodos.

Sobre los servicios gestionados (EKS, GKE, AKS): el proveedor mantiene el plano de control —actualizaciones, certificados, etcd, copias— y tú solo pones nodos de trabajo. Cuesta unos 70-100 € al mes por clúster, y para una empresa pequeña que realmente necesite Kubernetes suele ser la opción sensata: la mayor parte de la complejidad operativa del apartado 13 desaparece de tu tejado.

Instalación de k3s con un servidor y un agente

# ===== NODO SERVIDOR: srv-tramontana-pruebas (192.168.122.104) =====
$ ssh [email protected]

# Descargar y REVISAR el script antes de ejecutarlo. Canalizar una URL
# directamente a sh es exactamente lo que 05-03 desaconseja.
$ curl -sfL https://get.k3s.io -o /tmp/k3s-install.sh
$ sha256sum /tmp/k3s-install.sh
$ less /tmp/k3s-install.sh

$ INSTALL_K3S_VERSION="v1.30.4+k3s1" \
  INSTALL_K3S_EXEC="server \
      --write-kubeconfig-mode 0644 \
      --disable traefik=false \
      --node-label rol=servidor \
      --tls-san 192.168.122.104" \
  sh /tmp/k3s-install.sh
[INFO]  Using v1.30.4+k3s1 as release
[INFO]  systemd: Starting k3s

$ sudo systemctl status k3s --no-pager | head -4
● k3s.service - Lightweight Kubernetes
     Active: active (running) since Tue 2026-08-18 15:02:11 CEST

Fijar la versión con INSTALL_K3S_VERSION no es opcional. Sin ella se instala la última, y una máquina reconstruida seis meses después tendría otra versión distinta: exactamente el problema que Ansible vino a resolver en 07-06. Es la misma política de pinning de 05-03.

# El token que autentica a los nodos que se unan. Es un secreto real:
# con el, cualquiera puede unir un nodo al clúster.
$ sudo cat /var/lib/rancher/k3s/server/node-token
K10a3f19c8d::server:8f2b1c4d5e6a7b8c9d0e1f2a3b4c5d6e
# ===== NODO AGENTE: k8s-nodo2 (192.168.122.105) =====
$ ssh [email protected]
$ curl -sfL https://get.k3s.io -o /tmp/k3s-install.sh
$ INSTALL_K3S_VERSION="v1.30.4+k3s1" \
  K3S_URL="https://192.168.122.104:6443" \
  K3S_TOKEN="K10a3f19c8d::server:8f2b1c4d5e6a7b8c9d0e1f2a3b4c5d6e" \
  INSTALL_K3S_EXEC="agent --node-label rol=trabajo" \
  sh /tmp/k3s-install.sh
# ===== Verificacion, desde el servidor =====
$ sudo k3s kubectl get nodes -o wide
NAME                     STATUS   ROLES                  AGE   VERSION        INTERNAL-IP
srv-tramontana-pruebas   Ready    control-plane,master   4m    v1.30.4+k3s1   192.168.122.104
k8s-nodo2                Ready    <none>                 1m    v1.30.4+k3s1   192.168.122.105

$ sudo k3s kubectl get pods -A
NAMESPACE     NAME                                     READY   STATUS      RESTARTS
kube-system   coredns-6799fbcd5-x8k2p                  1/1     Running     0
kube-system   local-path-provisioner-6c86858495-mn4qt  1/1     Running     0
kube-system   metrics-server-54fd9b65b-7jc9d           1/1     Running     0
kube-system   svclb-traefik-4a1b2c3d-p9k4m             2/2     Running     0
kube-system   traefik-7d764994d8-lm2xw                 1/1     Running     0

Recursos que consume el clúster vacío, que es un dato honesto que conviene tener:

$ sudo k3s kubectl top nodes
NAME                     CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
srv-tramontana-pruebas   142m         7%     812Mi           21%
k8s-nodo2                38m          1%     284Mi           14%

812 MiB de RAM y un 7 % de CPU sin desplegar nada. Con kubeadm serían 1,5-2 GB. Ese es el precio de entrada de la orquestación, y es parte del argumento del apartado 2.

kubectl y el kubeconfig

# Desde tu portatil, sin depender de sudo en el servidor
$ sudo apt install kubernetes-client   # o descargar el binario oficial
$ mkdir -p ~/.kube
$ scp [email protected]:/etc/rancher/k3s/k3s.yaml ~/.kube/config-pruebas
$ sed -i 's/127.0.0.1/192.168.122.104/' ~/.kube/config-pruebas
$ chmod 0600 ~/.kube/config-pruebas
$ export KUBECONFIG=~/.kube/config-pruebas

El kubeconfig es un YAML con tres listas —clusters, users y contexts— y un contexto activo. Contiene credenciales de administrador del clúster, así que va con permisos 600 y nunca a un repositorio:

# ~/.kube/config-pruebas (fragmento)
clusters:
- cluster:
    certificate-authority-data: LS0tLS1CRUdJTiBD...   # la CA del clúster
    server: https://192.168.122.104:6443
  name: default
users:
- name: default
  user:
    client-certificate-data: LS0tLS1CRUdJTiBD...      # tu certificado
    client-key-data: LS0tLS1CRUdJTiBS...              # TU CLAVE PRIVADA
contexts:
- context: {cluster: default, user: default, namespace: tramontana}
  name: pruebas
current-context: pruebas

Los comandos que se usan el 95 % del tiempo:

Comando Para qué
kubectl get <tipo> Listar. -o wide añade columnas; -o yaml da el objeto completo
kubectl describe <tipo> <nombre> Detalle y eventos: el primer comando ante un problema
kubectl logs <pod> Registros. -f sigue; --previous muestra los del contenedor anterior
kubectl exec -it <pod> -- sh Shell dentro del contenedor
kubectl apply -f <fichero> Aplicar un manifiesto de forma declarativa
kubectl delete -f <fichero> Eliminar lo que declara
kubectl get events --sort-by=.lastTimestamp Qué ha pasado, en orden
kubectl -n <namespace> Trabajar en otro namespace
$ kubectl get nodes
NAME                     STATUS   ROLES                  AGE   VERSION
srv-tramontana-pruebas   Ready    control-plane,master   12m   v1.30.4+k3s1
k8s-nodo2                Ready    <none>                 9m    v1.30.4+k3s1

# Autocompletado y un alias: se usa docenas de veces al dia
$ echo 'source <(kubectl completion bash)' >> ~/.bashrc
$ echo 'alias k=kubectl; complete -o default -F __start_kubectl k' >> ~/.bashrc

--previous merece una nota: cuando un contenedor se reinicia en bucle, kubectl logs muestra los del intento actual, que suele estar vacío porque acaba de arrancar. Los que explican el fallo son los del intento anterior, y solo se ven con --previous. Es el primer truco útil de diagnóstico en Kubernetes.

Los objetos, en orden de dependencia

Pod: la unidad, y por qué casi nunca se crea a mano

Un Pod es uno o varios contenedores que comparten espacio de red (misma IP, mismo localhost), almacenamiento y ciclo de vida. Es la unidad mínima que Kubernetes planifica.

$ kubectl run prueba --image=nginx:1.27-alpine --restart=Never
$ kubectl get pod prueba -o wide
NAME     READY   STATUS    RESTARTS   AGE   IP           NODE
prueba   1/1     Running   0          8s    10.42.1.14   k8s-nodo2

$ kubectl delete pod prueba
$ kubectl get pods
No resources found.

Ha desaparecido y no vuelve. Ahí está la razón de no crear Pods directamente: un Pod es efímero y nadie lo vigila. Si el nodo muere, el Pod muere con él. Lo que se crea es un objeto de nivel superior que garantice que existan N Pods, pase lo que pase.

ReplicaSet y Deployment

Un ReplicaSet mantiene N réplicas idénticas. Tampoco se crea a mano: lo gestiona un Deployment, que además sabe hacer despliegues progresivos y vueltas atrás.

Deployment  →  gestiona ReplicaSets (uno por versión)  →  crean Pods

Al cambiar la imagen, el Deployment crea un ReplicaSet nuevo y va reduciendo el viejo mientras aumenta el nuevo. Los antiguos se conservan con cero réplicas, que es lo que permite kubectl rollout undo.

Estrategia Comportamiento Cuándo
RollingUpdate (por defecto) Sustituye poco a poco. Conviven ambas versiones Lo habitual
Recreate Mata todo y arranca lo nuevo. Hay corte Cuando dos versiones no pueden coexistir (migración de esquema incompatible)
strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1          # cuántos Pods DE MÁS puede haber temporalmente
    maxUnavailable: 0    # cuántos de MENOS. 0 = capacidad íntegra siempre

maxUnavailable: 0 con maxSurge: 1 es la configuración de despliegue sin interrupción: primero arranca uno nuevo y solo cuando está listo se retira uno viejo. Es exactamente el drenaje de conexiones de 07-07, automatizado.

Service y el DNS interno

Los Pods tienen IP y cambian constantemente. Un Service da un nombre y una IP virtual estables a un conjunto de Pods, seleccionados por etiquetas.

Tipo Alcance Uso
ClusterIP (por defecto) Solo dentro del clúster Comunicación entre servicios
NodePort Un puerto alto (30000-32767) en todos los nodos Pruebas; poco elegante
LoadBalancer Pide un balanceador externo En la nube; en k3s lo sirve ServiceLB
ExternalName Alias DNS a un nombre externo Referenciar la BD de fuera del clúster

Cómo funciona el DNS interno, que es una de las cosas más elegantes de Kubernetes: CoreDNS crea automáticamente un registro por Service con el patrón

<servicio>.<namespace>.svc.cluster.local
$ kubectl run dns-test --rm -it --image=busybox:1.36 --restart=Never -- \
      nslookup tramontana.tramontana.svc.cluster.local
Server:    10.43.0.10
Address:   10.43.0.10:53
Name:      tramontana.tramontana.svc.cluster.local
Address:   10.43.112.88

Desde un Pod del mismo namespace basta con tramontana; desde otro, tramontana.tramontana. Nunca se codifica una IP: se usa el nombre, y el clúster se encarga.

Y lo que ocurre por debajo: kube-proxy programa reglas en cada nodo que reescriben el destino hacia una de las IP de Pod detrás del Service. No hay un proceso balanceador; es el propio kernel.

Ingress y su relación con el Nginx de 08-01

Un Service de tipo LoadBalancer por aplicación significa una IP pública por aplicación. Un Ingress hace de proxy inverso HTTP compartido: enruta por nombre de dominio y por ruta hacia distintos Services, y termina el TLS.

Un Ingress es solo una declaración; hace falta un controlador que la implemente. k3s trae Traefik.

Y aquí está la conexión con 08-01, que es la pregunta natural: un Ingress hace exactamente lo que hiciste con Nginx —terminar TLS, enrutar por dominio, añadir cabeceras, limitar la tasa— con dos diferencias:

Nginx de 08-01 Ingress de Kubernetes
Configuración Fichero en /etc/nginx/ Objeto declarativo en el clúster
Destinos IP y puerto fijos en upstream Services, que siguen a los Pods
Al desplegar nginx -s reload Nada: los endpoints se actualizan solos
Certificados certbot en el disco Secret, o cert-manager automático
Quién lo aplica systemd El controlador (Traefik, ingress-nginx)

De hecho, el controlador más usado es ingress-nginx, que es Nginx por dentro generando su configuración a partir de objetos del clúster. Lo que aprendiste en 08-01 se aplica literalmente; solo cambia quién escribe el fichero.

ConfigMap y Secret, y el aviso serio

ConfigMap Secret
Para qué Configuración no sensible Contraseñas, claves, certificados
Almacenamiento Texto plano en etcd base64 en etcd
Tamaño máximo 1 MiB 1 MiB
Se puede montar como Variables de entorno o ficheros Igual

Un Secret de Kubernetes es base64, NO cifrado. Base64 es una codificación, no criptografía: se deshace con un comando y sin ninguna clave.

$ kubectl create secret generic prueba --from-literal=password='SuperSecreta123'
$ kubectl get secret prueba -o jsonpath='{.data.password}' | base64 -d; echo
SuperSecreta123

Ahí está, en claro, con un comando y sin credencial adicional. Las consecuencias prácticas, enlazando directamente con 06-05:

  • Nunca subas un Secret a Git. Un fichero YAML con data: en base64 es un fichero con la contraseña.
  • Por defecto se guarda sin cifrar en etcd. Quien lea el fichero de etcd o su copia de seguridad tiene todos los secretos del clúster.
  • RBAC es lo que realmente los protege: solo quien tenga permiso de lectura sobre Secrets los ve.

Las tres soluciones reales, en orden creciente de rigor:

Solución Cómo funciona Valoración
Cifrado en reposo de etcd EncryptionConfiguration en el apiserver Mínimo indispensable en producción
Sealed Secrets Se cifran con la clave pública del clúster; el YAML cifrado sí puede ir a Git Muy práctico
Almacén externo (Vault, secretos del proveedor) Los Pods los piden en arranque; nunca están en etcd Lo más robusto

Es la misma conclusión de 06-05 en otro envoltorio: los secretos no viven junto al código, y pass con systemd-creds resolvía exactamente este problema fuera de Kubernetes.

PersistentVolumeClaim, StorageClass y Namespace

Un contenedor es efímero: lo que escribe en su sistema de ficheros desaparece al reiniciarse. Un PersistentVolumeClaim (PVC) es una petición de almacenamiento persistente; una StorageClass define cómo se aprovisiona.

$ kubectl get storageclass
NAME                   PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE
local-path (default)   rancher.io/local-path   Delete          WaitForFirstConsumer

Aviso sobre local-path: crea un directorio en el disco del nodo. Es rápido y tiene una limitación grave: el Pod queda atado a ese nodo. Si el nodo muere, los datos no están en ninguna otra parte. Para almacenamiento realmente distribuido hacen falta Longhorn, Ceph o el almacenamiento del proveedor de nube.

Un Namespace es una división lógica del clúster: separa nombres, permite cuotas de recursos y es la unidad natural de RBAC y NetworkPolicy.

Manifiestos completos para Tramontana Reservas

# ~/tramontana-k8s/00-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: tramontana
  labels:
    # Pod Security Standards (apartado 10): rechaza Pods privilegiados
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
---
# Cuota: impide que un error de configuracion consuma el clúster entero
apiVersion: v1
kind: ResourceQuota
metadata:
  name: cuota-tramontana
  namespace: tramontana
spec:
  hard:
    requests.cpu: "2"
    requests.memory: 2Gi
    limits.cpu: "4"
    limits.memory: 4Gi
    persistentvolumeclaims: "4"
# ~/tramontana-k8s/10-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: tramontana-config
  namespace: tramontana
data:
  # Es el /etc/tramontana/app.conf de siempre, como objeto del clúster
  db_host: "10.0.2.15"
  db_port: "6432"              # PgBouncer (08-02)
  db_name: "tramontana"
  max_conexiones: "80"
  timeout_consulta: "30"
  log_nivel: "info"
  escucha: "0.0.0.0"           # dentro del Pod, no del host
  puerto: "8080"
---
apiVersion: v1
kind: Secret
metadata:
  name: tramontana-secretos
  namespace: tramontana
type: Opaque
stringData:
  # stringData permite escribir en claro y Kubernetes codifica solo.
  # ESTE FICHERO NO VA A GIT. En produccion, Sealed Secrets o Vault.
  db_password: "PLACEHOLDER_SE_INYECTA_DESDE_PASS"
# En la practica, el Secret se crea sin fichero intermedio:
$ kubectl -n tramontana create secret generic tramontana-secretos \
      --from-literal=db_password="$(pass tramontana/db)" \
      --dry-run=client -o yaml | kubectl apply -f -

Ese --dry-run=client -o yaml | kubectl apply -f - es el patrón idiomático para crear o actualizar un Secret sin que quede en el historial ni en disco.

# ~/tramontana-k8s/20-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: tramontana
  namespace: tramontana
  labels:
    app: tramontana
spec:
  replicas: 2
  revisionHistoryLimit: 5        # cuántos ReplicaSets viejos conservar
  selector:
    matchLabels:
      app: tramontana

  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1                # uno de más durante el despliegue
      maxUnavailable: 0          # NUNCA menos capacidad de la declarada

  template:
    metadata:
      labels:
        app: tramontana
        version: "3.2.1"
    spec:
      # Repartir las réplicas entre nodos distintos: si un nodo cae,
      # no se van las dos a la vez.
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: tramontana

      # --- Seguridad a nivel de Pod (apartado 10) ---
      securityContext:
        runAsNonRoot: true
        runAsUser: 997             # svc-tramontana, el uid de siempre
        runAsGroup: 1002           # grupo tramontana
        fsGroup: 1002
        seccompProfile:
          type: RuntimeDefault

      containers:
        - name: tramontana
          image: registro.tramontana.example/tramontana:3.2.1
          # NUNCA ':latest'. Una etiqueta móvil hace que dos Pods de la
          # misma "versión" ejecuten código distinto, y que una vuelta
          # atrás no vuelva a ninguna parte.
          imagePullPolicy: IfNotPresent

          ports:
            - name: http
              containerPort: 8080

          # --- Configuracion desde el ConfigMap ---
          envFrom:
            - configMapRef:
                name: tramontana-config
          env:
            - name: TRAMONTANA_DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: tramontana-secretos
                  key: db_password

          # --- Recursos: la relacion con los cgroups de 07-05 ---
          # requests: lo que el PLANIFICADOR reserva para elegir nodo.
          # limits:   el tope real, impuesto por cgroups v2.
          #   - Superar el limite de MEMORIA -> el kernel MATA el
          #     contenedor (OOMKilled): es un limite duro.
          #   - Superar el de CPU -> se ESTRANGULA (throttling), no se
          #     mata: el proceso simplemente va más lento.
          resources:
            requests:
              cpu: 100m            # 0,1 núcleo
              memory: 256Mi
            limits:
              cpu: 500m
              memory: 512Mi

          # --- Las tres sondas, y cada una responde a algo distinto ---

          # startupProbe: "¿ha terminado de arrancar?" Mientras falla,
          # las otras dos NO se ejecutan. Es lo que permite arranques
          # lentos sin relajar el liveness. 30 x 5 s = 150 s de margen.
          startupProbe:
            httpGet: {path: /salud, port: http}
            periodSeconds: 5
            failureThreshold: 30

          # readinessProbe: "¿puede atender peticiones AHORA?"
          # Si falla, el Pod SALE del Service (deja de recibir tráfico)
          # pero NO se reinicia. Es la puerta del balanceador.
          readinessProbe:
            httpGet: {path: /salud, port: http}
            periodSeconds: 5
            timeoutSeconds: 2
            successThreshold: 1
            failureThreshold: 3

          # livenessProbe: "¿está vivo o colgado?" Si falla, el kubelet
          # MATA Y REINICIA el contenedor. Por eso es la más peligrosa:
          # mal calibrada, provoca reinicios en cascada bajo carga.
          # Umbrales MÁS LAXOS que readiness, siempre.
          livenessProbe:
            httpGet: {path: /salud, port: http}
            periodSeconds: 15
            timeoutSeconds: 3
            failureThreshold: 5

          # --- Seguridad a nivel de contenedor ---
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]

          # Con readOnlyRootFilesystem hay que dar sitio escribible
          # explícito para lo que de verdad lo necesite.
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: cache
              mountPath: /var/cache/tramontana

          # Cierre ordenado: da tiempo a terminar las peticiones en
          # curso antes de que llegue el SIGTERM.
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 5"]

      terminationGracePeriodSeconds: 30
      volumes:
        - name: tmp
          emptyDir: {sizeLimit: 64Mi}
        - name: cache
          emptyDir: {sizeLimit: 256Mi}
# ~/tramontana-k8s/30-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: tramontana
  namespace: tramontana
spec:
  type: ClusterIP
  selector:
    app: tramontana            # las etiquetas del Pod, no del Deployment
  ports:
    - name: http
      port: 80                 # puerto del Service
      targetPort: http         # puerto con nombre del contenedor
---
# La base de datos vive FUERA del clúster (srv-tramontana). Este objeto
# le da un nombre interno, de modo que la aplicación use siempre
# "postgresql" y no una IP codificada.
apiVersion: v1
kind: Service
metadata:
  name: postgresql
  namespace: tramontana
spec:
  type: ExternalName
  externalName: srv-tramontana.interno
# ~/tramontana-k8s/40-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: tramontana
  namespace: tramontana
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: websecure
    traefik.ingress.kubernetes.io/router.tls: "true"
    # Las cabeceras de seguridad de 08-01, como anotaciones
    traefik.ingress.kubernetes.io/router.middlewares: tramontana-seguridad@kubernetescrd
spec:
  ingressClassName: traefik
  tls:
    - hosts: [reservas.tramontana.example]
      secretName: tramontana-tls      # Secret de tipo kubernetes.io/tls
  rules:
    - host: reservas.tramontana.example
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: tramontana
                port: {name: http}
---
# Middleware de Traefik: el equivalente a los add_header de 08-01
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: seguridad
  namespace: tramontana
spec:
  headers:
    stsSeconds: 63072000
    stsIncludeSubdomains: true
    contentTypeNosniff: true
    frameDeny: true
    referrerPolicy: "strict-origin-when-cross-origin"
# El certificado de 06-05, como Secret
$ sudo kubectl -n tramontana create secret tls tramontana-tls \
      --cert=/etc/letsencrypt/live/reservas.tramontana.example/fullchain.pem \
      --key=/etc/letsencrypt/live/reservas.tramontana.example/privkey.pem

# Desplegar todo, en orden
$ kubectl apply -f ~/tramontana-k8s/
namespace/tramontana created
resourcequota/cuota-tramontana created
configmap/tramontana-config created
deployment.apps/tramontana created
service/tramontana created
ingress.networking.k8s.io/tramontana created

$ kubectl -n tramontana get pods -o wide
NAME                          READY   STATUS    RESTARTS   AGE   NODE
tramontana-7d8f9c4b5-k2m4p    1/1     Running   0          48s   srv-tramontana-pruebas
tramontana-7d8f9c4b5-x9n7q    1/1     Running   0          48s   k8s-nodo2

$ kubectl -n tramontana get endpoints tramontana
NAME         ENDPOINTS                           AGE
tramontana   10.42.0.18:8080,10.42.1.22:8080     51s

Las réplicas han caído en nodos distintos gracias a topologySpreadConstraints, y el Service ya tiene sus dos endpoints.

Las tres sondas merecen un resumen en tabla, porque confundirlas es el error más caro de este apartado:

Sonda Pregunta Si falla Riesgo si está mal
startupProbe ¿Ha terminado de arrancar? Reinicia tras agotar el margen Sin ella, liveness mata arranques lentos
readinessProbe ¿Puede atender ahora? Sale del Service, no se reinicia Sin ella, llega tráfico a un Pod que no está listo
livenessProbe ¿Está colgado? Mata y reinicia Demasiado agresiva: reinicios en cascada bajo carga

Y el error clásico, que aparece en producciones reales con consecuencias graves: poner el mismo failureThreshold y periodSeconds en readiness y liveness. Bajo carga, la aplicación responde algo más lenta, ambas fallan, y el kubelet reinicia Pods que solo estaban ocupados — lo que reduce la capacidad, aumenta la carga de los restantes y provoca una caída en cascada. Liveness siempre más laxa que readiness.

Despliegue progresivo y vuelta atrás

# 1. Desplegar la version 3.3.0. --record esta obsoleto; se usa una
#    anotacion para que quede constancia del motivo.
$ kubectl -n tramontana set image deployment/tramontana \
      tramontana=registro.tramontana.example/tramontana:3.3.0
$ kubectl -n tramontana annotate deployment/tramontana \
      kubernetes.io/change-cause="Version 3.3.0: informes optimizados"

# 2. Seguirlo en directo
$ kubectl -n tramontana rollout status deployment/tramontana
Waiting for deployment "tramontana" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "tramontana" rollout to finish: 1 old replicas are pending termination...
deployment "tramontana" successfully rolled out

# 3. Durante el proceso conviven ambos ReplicaSets
$ kubectl -n tramontana get rs
NAME                    DESIRED   CURRENT   READY   AGE
tramontana-7d8f9c4b5    0         0         0       18m    # 3.2.1
tramontana-9f2a1c8e7    2         2         2       42s    # 3.3.0

Cero peticiones perdidas, y el mecanismo es el que ya conoces de 07-07: maxUnavailable: 0 garantiza que nunca hay menos capacidad de la declarada, y la readinessProbe garantiza que un Pod no recibe tráfico hasta que responde. El drenaje que en 07-07 hacías con socat contra el socket de HAProxy, aquí es una propiedad del sistema.

# 4. La version nueva falla en produccion: vuelta atras
$ kubectl -n tramontana rollout history deployment/tramontana
REVISION  CHANGE-CAUSE
1         Version 3.2.1 inicial
2         Version 3.3.0: informes optimizados

$ kubectl -n tramontana rollout undo deployment/tramontana
deployment.apps/tramontana rolled back

$ kubectl -n tramontana rollout status deployment/tramontana
deployment "tramontana" successfully rolled out

La vuelta atrás tardó once segundos, contra los minutos del desplegar.sh de 04-07 con su ln -sfn. Es el argumento más sólido a favor de Kubernetes: la reversibilidad es una propiedad del sistema, no un script que hay que acordarse de escribir bien.

Con la salvedad que hay que decir siempre: rollout undo revierte el código, no los datos. Si la versión 3.3.0 ejecutó una migración de esquema, volver a la 3.2.1 deja la aplicación vieja contra una base de datos nueva. Es el mismo problema que en 04-07, y la solución es la misma: migraciones compatibles hacia atrás. Kubernetes no lo resuelve.

# 5. Escalar, que es trivial y por eso llama la atencion
$ kubectl -n tramontana scale deployment/tramontana --replicas=4
$ kubectl -n tramontana get pods --no-headers | wc -l
4

Seguridad: securityContext, NetworkPolicy, RBAC y PSS

securityContext

Los valores por defecto de Kubernetes son permisivos: sin securityContext, un contenedor corre como root con un conjunto amplio de capabilities. Todo lo de 07-05 se aplica aquí de forma declarativa:

Directiva Qué impide Equivalente en 07-05
runAsNonRoot: true Que el contenedor corra como root USER en el Dockerfile
runAsUser: 997 — --user
allowPrivilegeEscalation: false Ganar privilegios vía SUID --security-opt no-new-privileges
readOnlyRootFilesystem: true Escribir en el sistema de ficheros --read-only
capabilities.drop: ["ALL"] Todas las capacidades del kernel --cap-drop=ALL
seccompProfile: RuntimeDefault Llamadas al sistema peligrosas --security-opt seccomp

readOnlyRootFilesystem: true es la que más ataques frustra en la práctica: la mayoría de los exploits necesitan escribir algo en disco para persistir o para descargar la siguiente etapa.

# Verificar que se aplica de verdad
$ kubectl -n tramontana exec deploy/tramontana -- id
uid=997 gid=1002 groups=1002
$ kubectl -n tramontana exec deploy/tramontana -- touch /prueba
touch: /prueba: Read-only file system
command terminated with exit code 1

NetworkPolicy

Por defecto, todos los Pods del clúster pueden hablar con todos. Es una red plana, y en un clúster con varias aplicaciones eso significa que un Pod comprometido llega a cualquier otro.

# ~/tramontana-k8s/50-networkpolicy.yaml
# 1. Denegar TODO por defecto en el namespace (lista blanca, 06-03)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: denegar-todo
  namespace: tramontana
spec:
  podSelector: {}                # todos los Pods del namespace
  policyTypes: [Ingress, Egress]
---
# 2. Permitir solo lo necesario
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: permitir-tramontana
  namespace: tramontana
spec:
  podSelector:
    matchLabels: {app: tramontana}
  policyTypes: [Ingress, Egress]
  ingress:
    # Solo desde el Ingress (Traefik vive en kube-system)
    - from:
        - namespaceSelector:
            matchLabels: {kubernetes.io/metadata.name: kube-system}
      ports: [{protocol: TCP, port: 8080}]
  egress:
    # DNS: sin esto, NADA funciona y el fallo desconcierta
    - to:
        - namespaceSelector:
            matchLabels: {kubernetes.io/metadata.name: kube-system}
      ports:
        - {protocol: UDP, port: 53}
        - {protocol: TCP, port: 53}
    # PostgreSQL, fuera del clúster
    - to:
        - ipBlock: {cidr: 10.0.2.15/32}
      ports: [{protocol: TCP, port: 6432}]

Olvidar la regla de DNS es el error número uno con NetworkPolicy. Se aplica la política, todo deja de funcionar, y el síntoma —«no se puede resolver el nombre»— no apunta al cortafuegos. Con policyTypes: [Egress], el tráfico a CoreDNS también queda bloqueado.

Aviso importante para k3s: el CNI por defecto (Flannel) no implementa NetworkPolicy, así que estos objetos se aceptan y no hacen nada. Hay que instalar k3s con --flannel-backend=none y desplegar Calico, o usar --disable-network-policy=false según la versión. Comprobarlo es imprescindible: creer que tienes segmentación cuando no la tienes es peor que no tenerla.

RBAC, brevemente

El control de acceso basado en roles usa cuatro objetos:

Objeto Alcance Qué define
Role Un namespace Qué verbos sobre qué recursos
ClusterRole Todo el clúster Igual, global
RoleBinding Un namespace Quién tiene ese Role
ClusterRoleBinding Todo el clúster Quién tiene ese ClusterRole
# Luis: solo lectura y logs en su namespace. No puede ver Secrets.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: tramontana
  name: lector
rules:
  - apiGroups: ["", "apps"]
    resources: ["pods", "pods/log", "deployments", "services", "configmaps"]
    verbs: ["get", "list", "watch"]
  # 'secrets' NO aparece: leerlos es leer las contraseñas (base64)
# Comprobar permisos, propios o de otro
$ kubectl auth can-i get secrets -n tramontana --as=luis
no
$ kubectl auth can-i get pods -n tramontana --as=luis
yes

kubectl auth can-i es la herramienta de verificación de RBAC, y es la forma de comprobar que lo prohibido está prohibido, igual que hiciste con los roles de PostgreSQL en 08-02.

Pod Security Standards

Sustituyen a las PodSecurityPolicy, retiradas en la 1.25. Son tres perfiles que se aplican por namespace con etiquetas:

Perfil Qué permite
privileged Todo. Sin restricciones
baseline Bloquea lo más peligroso: privilegiado, hostNetwork, hostPath
restricted Exige runAsNonRoot, drop ALL, seccomp, sin escalada

El enforce: restricted del namespace hace que el apiserver rechace cualquier Pod que no cumpla. Es una red de seguridad que no depende de recordar poner el securityContext:

$ kubectl -n tramontana run malo --image=nginx --privileged
Error from server (Forbidden): pods "malo" is forbidden: violates PodSecurity
"restricted:latest": privileged (container "malo" must not set
securityContext.privileged=true), allowPrivilegeEscalation != false,
unrestricted capabilities, runAsNonRoot != true, seccompProfile

Diagnóstico: leer eventos y los cuatro fallos habituales

El primer comando ante cualquier problema es kubectl describe, y lo que importa está al final, en Events.

$ kubectl -n tramontana describe pod tramontana-7d8f9c4b5-k2m4p | tail -12
Events:
  Type     Reason     Age                From     Message
  ----     ------     ----               ----     -------
  Normal   Scheduled  2m                 default-scheduler  Successfully assigned...
  Normal   Pulling    2m                 kubelet  Pulling image "...:3.2.1"
  Normal   Pulled     1m                 kubelet  Successfully pulled image
  Normal   Created    1m                 kubelet  Created container tramontana
  Normal   Started    1m                 kubelet  Started container tramontana
  Warning  Unhealthy  30s (x3 over 40s)  kubelet  Readiness probe failed:
             Get "http://10.42.0.18:8080/salud": dial tcp: connect: connection refused

Esa última línea explica el problema entero: la aplicación no escucha todavía en el 8080.

Los cuatro fallos que verás de verdad

1. CrashLoopBackOff — el contenedor arranca, muere, y Kubernetes espera cada vez más antes de reintentar (10 s, 20 s, 40 s... hasta 5 min).

$ kubectl -n tramontana get pods
NAME                         READY   STATUS             RESTARTS      AGE
tramontana-9f2a1c8e7-p4k9m   0/1     CrashLoopBackOff   5 (48s ago)   4m

# La clave: --previous. Sin ella ves los logs del intento ACTUAL,
# que acaba de arrancar y está vacío.
$ kubectl -n tramontana logs tramontana-9f2a1c8e7-p4k9m --previous
FATAL: no se pudo conectar a la base de datos: password authentication
failed for user "svc_tramontana"

# Confirmar la causa
$ kubectl -n tramontana get secret tramontana-secretos -o jsonpath='{.data.db_password}' \
      | base64 -d | head -c8; echo '...'
PLACEHO...

Ahí está: el Secret tiene el marcador de posición del manifiesto, no la contraseña real.

Causa de CrashLoopBackOff Cómo se confirma
Error de configuración o credenciales logs --previous
Falta una dependencia (BD inaccesible) logs --previous + exec a otro Pod
El comando del contenedor termina describe: Exit Code: 0
livenessProbe demasiado agresiva describe: eventos Unhealthy antes de cada reinicio
OOMKilled describe: Reason: OOMKilled

2. ImagePullBackOff / ErrImagePull

$ kubectl -n tramontana describe pod tramontana-x | grep -A3 Events
  Warning  Failed  30s  kubelet  Failed to pull image
    "registro.tramontana.example/tramontana:3.3.1": failed to resolve reference:
    unexpected status: 401 Unauthorized
Mensaje Causa Solución
401 Unauthorized Falta credencial del registro imagePullSecrets
not found / manifest unknown La etiqueta no existe (errata) Verificar con crane ls o skopeo
no such host DNS del nodo no resuelve el registro Comprobar en el nodo, no en el Pod
connection refused Registro caído o cortafuegos Comprobar desde el nodo

3. Pending — el Pod existe y ningún nodo lo acepta.

$ kubectl -n tramontana describe pod tramontana-y | tail -4
  Warning  FailedScheduling  20s  default-scheduler
    0/2 nodes are available: 1 Insufficient cpu, 1 Insufficient memory.
    preemption: 0/2 nodes are available: 2 No preemption victims found.

# Cuánto hay realmente comprometido en cada nodo
$ kubectl describe node k8s-nodo2 | grep -A6 'Allocated resources'
Allocated resources:
  Resource   Requests      Limits
  cpu        1750m (87%)   3200m (160%)
  memory     1894Mi (94%)  2560Mi (128%)

El planificador reserva según requests, no según el uso real. Un nodo con el 20 % de CPU usada puede rechazar Pods si sus requests suman el 95 %. Es la confusión más habitual con Pending, y por eso poner requests generosas «por si acaso» desperdicia el clúster entero.

Otras causas de Pending: un PVC que no se puede aprovisionar, nodeSelector que no coincide con ningún nodo, o taints sin la toleration correspondiente.

4. OOMKilled — el kernel mató el contenedor por superar el límite de memoria.

$ kubectl -n tramontana describe pod tramontana-z | grep -A4 'Last State'
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137            # 128 + 9 (SIGKILL)

Es el mismo mecanismo de cgroups de 07-05. Exit Code: 137 es la firma inconfundible. Y la decisión no es automática: hay que averiguar si el límite es demasiado bajo o si hay una fuga de memoria en la aplicación — subir el límite sin mirar solo retrasa el problema.

Caja de herramientas de diagnóstico

# Eventos del namespace, en orden cronologico
$ kubectl -n tramontana get events --sort-by=.lastTimestamp | tail -10

# Consumo real (necesita metrics-server, que k3s trae)
$ kubectl -n tramontana top pods
NAME                         CPU(cores)   MEMORY(bytes)
tramontana-7d8f9c4b5-k2m4p   12m          184Mi

# Depurar la red desde dentro del clúster, sin tocar los Pods reales
$ kubectl -n tramontana run depurar --rm -it --image=nicolaka/netshoot \
      --restart=Never -- bash
depurar:~# nslookup tramontana
depurar:~# curl -sv http://tramontana/salud
depurar:~# nc -zv 10.0.2.15 6432

# Reenviar un puerto al portatil, sin exponer nada
$ kubectl -n tramontana port-forward svc/tramontana 8080:80
$ curl -s localhost:8080/salud

port-forward es la herramienta más usada del día a día: permite alcanzar un servicio interno desde tu portátil sin crear un Ingress ni un NodePort.

Automatización con Ansible

Hay una tentación evidente aquí y conviene evitarla: Ansible no debe sustituir a kubectl apply. Los manifiestos son ya declarativos e idempotentes; envolverlos en Ansible añade una capa sin ganar nada. El reparto correcto:

Capa Herramienta Por qué
Preparar el sistema operativo del nodo Ansible Es configuración de máquina
Instalar y configurar k3s Ansible Es un servicio de systemd
Unir nodos al clúster Ansible Necesita el token, que es un secreto
Desplegar aplicaciones kubectl apply o Helm Ya es declarativo
Mantener el clúster sincronizado con Git Argo CD o Flux (GitOps) Es el patrón del sector
# ~/tramontana-infra/roles/k3s/defaults/main.yml
---
k3s_version: "v1.30.4+k3s1"
k3s_url: "https://192.168.122.104:6443"
k3s_datastore: sqlite
k3s_deshabilitar: []
k3s_extra_args_servidor: "--write-kubeconfig-mode 0644 --tls-san {{ ansible_default_ipv4.address }}"
# ~/tramontana-infra/roles/k3s/tasks/main.yml
---
- name: Guardia de seguridad — NUNCA en produccion
  ansible.builtin.assert:
    that: inventory_hostname != 'srv-tramontana'
    fail_msg: >-
      Este rol NO debe ejecutarse en srv-tramontana. k3s reescribe las
      reglas de nftables y arranca su propio runtime de contenedores;
      en el servidor de produccion interferiria con ufw, AppArmor y el
      servicio tramontana.

- name: Requisitos del sistema
  ansible.builtin.apt:
    name: [curl, iptables, apparmor-utils]
    state: present

- name: Descargar el instalador de k3s
  ansible.builtin.get_url:
    url: https://get.k3s.io
    dest: /usr/local/src/k3s-install.sh
    mode: '0755'

- name: Instalar k3s en el nodo SERVIDOR
  ansible.builtin.command:
    cmd: /usr/local/src/k3s-install.sh
    creates: /usr/local/bin/k3s        # idempotencia (07-06)
  environment:
    INSTALL_K3S_VERSION: "{{ k3s_version }}"
    INSTALL_K3S_EXEC: "server {{ k3s_extra_args_servidor }}"
  when: "'k3s_servidor' in group_names"

- name: Leer el token del nodo servidor
  ansible.builtin.slurp:
    src: /var/lib/rancher/k3s/server/node-token
  register: k3s_token_raw
  when: "'k3s_servidor' in group_names"

- name: Compartir el token con los agentes
  ansible.builtin.set_fact:
    k3s_token: "{{ hostvars[groups['k3s_servidor'][0]]['k3s_token_raw']['content'] | b64decode | trim }}"
  no_log: true

- name: Instalar k3s en los nodos AGENTE
  ansible.builtin.command:
    cmd: /usr/local/src/k3s-install.sh
    creates: /usr/local/bin/k3s
  environment:
    INSTALL_K3S_VERSION: "{{ k3s_version }}"
    K3S_URL: "{{ k3s_url }}"
    K3S_TOKEN: "{{ k3s_token }}"
    INSTALL_K3S_EXEC: "agent"
  no_log: true
  when: "'k3s_agente' in group_names"

- name: Esperar a que el nodo este Ready
  ansible.builtin.command: "k3s kubectl get node {{ ansible_hostname }} -o json"
  register: nodo
  until: >-
    (nodo.stdout | from_json).status.conditions
    | selectattr('type','eq','Ready') | map(attribute='status') | first == 'True'
  retries: 30
  delay: 10
  changed_when: false
  delegate_to: "{{ groups['k3s_servidor'][0] }}"

# --- Copia de seguridad del clúster, que es lo que de verdad importa ---
- name: Instalar el script de copia de etcd/SQLite
  ansible.builtin.copy:
    src: respaldo_k3s.sh
    dest: /usr/local/bin/respaldo_k3s.sh
    mode: '0750'
  when: "'k3s_servidor' in group_names"

- name: Timer diario de copia del estado del clúster
  ansible.builtin.copy:
    src: "{{ item }}"
    dest: "/etc/systemd/system/{{ item }}"
    mode: '0644'
  loop: [respaldo-k3s.service, respaldo-k3s.timer]
  notify: Recargar systemd
  when: "'k3s_servidor' in group_names"

Ese assert inicial es la aplicación literal de la advertencia del apartado 1, convertida en código que no se puede ignorar por descuido. Y la copia del estado del clúster no es opcional: sin etcd o su SQLite, el clúster no existe.

# roles/k3s/files/respaldo_k3s.sh (nucleo)
$ k3s etcd-snapshot save --name diario           # con etcd
# o, con SQLite:
$ sqlite3 /var/lib/rancher/k3s/server/db/state.db ".backup '/srv/backups/k3s.db'"
$ restic backup /srv/backups/k3s.db --tag k3s

La complejidad operativa real

Aquí está la parte que las presentaciones de Kubernetes omiten, y la que responde de verdad a la pregunta del apartado 2.

1. Las actualizaciones son constantes. Kubernetes publica tres versiones menores al año y cada una recibe soporte unos catorce meses. Eso obliga a actualizar el clúster al menos dos veces al año, indefinidamente. Cada actualización implica leer las notas de la versión buscando cambios de ruptura, actualizar el plano de control y luego los nodos, y verificar que las cargas siguen funcionando.

2. Las versiones de API se retiran, y rompen manifiestos que funcionaban.

Recurso API antigua API actual Retirada en
Ingress extensions/v1beta1 networking.k8s.io/v1 1.22
CronJob batch/v1beta1 batch/v1 1.25
PodSecurityPolicy policy/v1beta1 Eliminado: usar PSS 1.25
HorizontalPodAutoscaler autoscaling/v2beta2 autoscaling/v2 1.26

Un manifiesto escrito hace dos años puede fallar tras una actualización. Existen herramientas (pluto, kubent) para detectarlo antes, y usarlas es obligatorio.

3. etcd es delicado. Es una base de datos distribuida con quórum: necesita 3 o 5 nodos (número impar), es sensible a la latencia de disco, y requiere compactación y desfragmentación periódicas. Un etcd corrupto sin copia es un clúster perdido. Y aplica todo lo del quórum de 07-07.

4. Los certificados internos caducan. Kubernetes usa TLS mutuo entre todos sus componentes, con una PKI propia y certificados que caducan al año. kubeadm los renueva al actualizar, pero un clúster que lleva catorce meses sin tocarse deja de funcionar de golpe, con errores de autenticación desconcertantes.

$ sudo kubeadm certs check-expiration | head -5
CERTIFICATE                EXPIRES                  RESIDUAL TIME
apiserver                  Aug 18, 2027 13:02 UTC   364d
etcd-server                Aug 18, 2027 13:02 UTC   364d

5. El ecosistema se mueve muy deprisa. Docker Shim retirado en la 1.24, PSP sustituidas por PSS, Ingress evolucionando hacia Gateway API. Lo aprendido caduca más rápido que en cualquier otra área del sistema.

6. Diagnosticar exige entender más capas. Un fallo puede estar en la aplicación, el contenedor, el Pod, el Service, el CNI, el Ingress, el DNS, el planificador o el kubelet. Todo lo del Módulo 7 sigue siendo necesario; Kubernetes añade capas, no las sustituye.

El coste anual estimado, en horas, que es la cifra que hay que llevar a una decisión:

Actividad Horas/año
Dos actualizaciones de versión menor 16-32
Mantenimiento de etcd y sus copias 8-12
Actualizar manifiestos por APIs retiradas 4-8
Diagnóstico de incidentes específicos del clúster 20-40
Formación continua 20-40
Total 70-130 h/año

Entre dos y cuatro semanas de trabajo al año, solo para mantener el clúster vivo, sin contar el despliegue de aplicaciones. Con un servicio gestionado, la mitad. Con systemd y Ansible en tres máquinas, prácticamente cero.

Ese número es la respuesta final a la pregunta del apartado 2, y también explica por qué Kubernetes gestionado tiene tanto éxito: para la mayoría de las empresas, 100 € al mes son más baratos que 100 horas al año.

Errores Comunes y Consejos

  • Usar :latest en la imagen. Dos Pods de la misma «versión» pueden ejecutar código distinto, y rollout undo no vuelve a ninguna parte. Etiquetas inmutables, siempre.
  • Crear Pods sueltos en lugar de Deployments. Nadie los vigila: si mueren, no vuelven.
  • livenessProbe igual o más estricta que readinessProbe. Bajo carga se reinician Pods sanos y el servicio cae en cascada. Liveness siempre más laxa.
  • No poner startupProbe en aplicaciones de arranque lento. La liveness mata el contenedor antes de que termine de arrancar, en bucle.
  • Creer que un Secret está cifrado. Es base64. kubectl get secret -o jsonpath | base64 -d y ahí está.
  • Subir Secrets a Git. El YAML con data: es el fichero con la contraseña. Sealed Secrets o un almacén externo.
  • Olvidar la regla de DNS en una NetworkPolicy de Egress. Todo deja de funcionar y el síntoma no apunta al cortafuegos.
  • Aplicar NetworkPolicy con un CNI que no las implementa. Flannel en k3s las acepta y las ignora: crees que tienes segmentación y no la tienes.
  • requests iguales a limits y muy generosas. El planificador reserva por requests: desperdicias el clúster y provocas Pending con nodos vacíos.
  • Subir el límite de memoria al ver un OOMKilled sin investigar. Puede ser una fuga, y solo estás retrasando el problema.
  • kubectl logs sin --previous en un CrashLoopBackOff. Ves los logs del intento actual, que está vacío.
  • No hacer copia de etcd o del SQLite. Sin él, el clúster no se puede reconstruir.
  • Ignorar la caducidad de los certificados internos. Un clúster sin tocar catorce meses deja de funcionar de golpe.
  • Instalar Kubernetes en un servidor de producción existente. Reescribe reglas de red y arranca su propio runtime. Máquina dedicada.
  • Montar Kubernetes para tres servicios. Es el error de arquitectura de esta lección entera: 70-130 h/año para resolver problemas que no tienes.
  • Consejo de método. Ante cualquier fallo: kubectl describe y lee los Events de abajo arriba. Resuelve la mayoría de los problemas antes de tocar nada.

Ejercicios

Ejercicio 1

Tras desplegar la versión 3.3.0, los Pods entran en CrashLoopBackOff y el servicio queda caído. Diagnostica el problema con método, restablece el servicio y explica qué habría evitado el incidente.

Ejercicio 2

Compara, para el caso de Tramontana, la arquitectura de Kubernetes de esta lección con la opción B recomendada en 07-07, con criterios objetivos, y emite una recomendación.

Ejercicio 3

Escribe un manifiesto de CronJob que ejecute el respaldo de la base de datos dentro del clúster, con las mismas garantías que tramontana-respaldo.timer de 05-05, y explica qué se gana y qué se pierde respecto al timer de systemd.

Soluciones

Solución 1

Paso 1: la foto general, antes de tocar nada.

$ kubectl -n tramontana get pods
NAME                          READY   STATUS             RESTARTS      AGE
tramontana-7d8f9c4b5-k2m4p    1/1     Running            0             3h    # 3.2.1
tramontana-9f2a1c8e7-p4k9m    0/1     CrashLoopBackOff   4 (52s ago)   3m    # 3.3.0

Primer dato importante y tranquilizador: gracias a maxUnavailable: 0, el Pod de la versión 3.2.1 sigue en marcha y atendiendo tráfico. El despliegue está bloqueado, no caído. Eso quita presión y permite diagnosticar sin prisa.

$ kubectl -n tramontana rollout status deployment/tramontana --timeout=10s
error: timed out waiting for the condition

$ kubectl -n tramontana get endpoints tramontana
NAME         ENDPOINTS          AGE
tramontana   10.42.0.18:8080    3h

Un solo endpoint: el Pod nuevo nunca ha llegado a Ready, así que el Service nunca le ha enviado tráfico. La readinessProbe ha hecho exactamente su trabajo.

Paso 2: los logs del intento anterior.

$ kubectl -n tramontana logs tramontana-9f2a1c8e7-p4k9m --previous
[2026-08-18T16:02:11Z] INFO  Tramontana Reservas 3.3.0 iniciando
[2026-08-18T16:02:11Z] INFO  leyendo configuracion del entorno
[2026-08-18T16:02:11Z] ERROR variable TRAMONTANA_CACHE_URL no definida
[2026-08-18T16:02:11Z] FATAL configuracion incompleta; abortando

Causa raíz en cuatro líneas. La versión 3.3.0 introduce una variable de configuración nueva —TRAMONTANA_CACHE_URL, para el caché de informes— que no está en el ConfigMap. La aplicación falla rápido y en claro, que es el comportamiento correcto.

Paso 3: confirmar y descartar otras causas.

$ kubectl -n tramontana describe pod tramontana-9f2a1c8e7-p4k9m | \
      grep -A6 'Last State'
    Last State:     Terminated
      Reason:       Error
      Exit Code:    1              # NO es 137: no es OOMKilled
      Started:      Tue, 18 Aug 2026 16:02:11 +0200
      Finished:     Tue, 18 Aug 2026 16:02:11 +0200

$ kubectl -n tramontana get configmap tramontana-config -o jsonpath='{.data}' | \
      jq 'keys'
["db_host","db_name","db_port","escucha","log_nivel","max_conexiones",
 "puerto","timeout_consulta"]

Exit Code: 1 con arranque y parada en el mismo segundo descarta OOMKilled (137), sondas mal calibradas (habría llegado a Running un rato) y problemas de red (no llegó a conectar con nada). Y el ConfigMap confirma la ausencia de la clave.

Paso 4: decidir. Y la decisión correcta es volver atrás primero.

$ kubectl -n tramontana rollout undo deployment/tramontana
deployment.apps/tramontana rolled back
$ kubectl -n tramontana rollout status deployment/tramontana
deployment "tramontana" successfully rolled out

Por qué volver atrás antes de arreglar, aunque el arreglo parezca trivial: el Deployment está en un estado inconsistente, con un ReplicaSet nuevo intentando arrancar en bucle cada pocos segundos. Cada reintento genera eventos, consume recursos y ensucia el diagnóstico. Dejar el sistema en un estado conocido y estable antes de corregir es la disciplina que evita empeorar las cosas — y es la misma lógica del árbol de decisión del runbook de recuperación de 07-06.

Paso 5: el arreglo, probado antes de aplicarlo.

$ kubectl -n tramontana patch configmap tramontana-config \
      --type merge -p '{"data":{"cache_url":"redis://cache.tramontana.svc:6379"}}'
# 20-deployment.yaml — el mapeo explicito de la variable
          env:
            - name: TRAMONTANA_CACHE_URL
              valueFrom:
                configMapKeyRef:
                  name: tramontana-config
                  key: cache_url
# Verificar en un Pod desechable ANTES de tocar el Deployment
$ kubectl -n tramontana run verificar --rm -it --restart=Never \
      --image=registro.tramontana.example/tramontana:3.3.0 \
      --overrides='{"spec":{"containers":[{"name":"verificar",
        "image":"registro.tramontana.example/tramontana:3.3.0",
        "envFrom":[{"configMapRef":{"name":"tramontana-config"}}],
        "env":[{"name":"TRAMONTANA_CACHE_URL","valueFrom":
          {"configMapKeyRef":{"name":"tramontana-config","key":"cache_url"}}}]}]}}' \
      -- /usr/bin/tramontana --check-config
configuracion valida
# Ahora si
$ kubectl apply -f ~/tramontana-k8s/20-deployment.yaml
$ kubectl -n tramontana rollout status deployment/tramontana
deployment "tramontana" successfully rolled out
$ kubectl -n tramontana get pods
NAME                          READY   STATUS    RESTARTS   AGE
tramontana-3c7e9a2b1-h4j8k    1/1     Running   0          42s
tramontana-3c7e9a2b1-w2n5r    1/1     Running   0          28s

Un detalle que hay que conocer: cambiar un ConfigMap no reinicia los Pods que lo usan como variables de entorno. Si hubiera hecho falta:

$ kubectl -n tramontana rollout restart deployment/tramontana

Qué habría evitado el incidente, que es la pregunta que de verdad importa:

Medida Cómo lo habría evitado
Probar el manifiesto en un namespace de pruebas El fallo habría ocurrido donde no importa
Validar la configuración en el arranque de la imagen Un --check-config en la construcción falla antes de desplegar
Notas de versión con los cambios de configuración Luis debe documentar toda variable nueva
Valor por defecto en la aplicación Una variable nueva con valor razonable no rompe nada
CI que aplique los manifiestos a un clúster efímero El fallo se detecta en la revisión del código
Alerta al primer CrashLoopBackOff No lo evita, pero lo detecta en un minuto

Y las tres lecciones de método:

  1. maxUnavailable: 0 convirtió una caída en un despliegue bloqueado. Es la línea de configuración con mejor relación beneficio/coste de todo el manifiesto, y merece estar en cualquier Deployment de producción.
  2. La readinessProbe impidió que el Service enviara tráfico a un Pod roto. Sin ella, la mitad de las peticiones habrían fallado durante tres minutos.
  3. --previous fue lo que dio la respuesta. Sin esa opción, kubectl logs devolvía un contenedor recién arrancado y vacío, y el diagnóstico habría tardado mucho más.

Solución 2

Comparación con criterios objetivos, sobre el caso real de Tramontana: una aplicación monolítica, una base de datos, ~500 reservas al mes, dos personas técnicas.

Criterio Kubernetes (k3s) Opción B de 07-07 (2 nodos + HAProxy)
Máquinas necesarias 3 (plano de control con quórum) o 2 con SPOF 3 (2 app + 1 balanceador)
RAM base consumida 812 MiB solo el clúster ~120 MiB (HAProxy + keepalived)
Despliegue sin interrupción Nativo, RollingUpdate Drenaje manual con socat, script
Vuelta atrás rollout undo, 11 s ln -sfn + restart, ~2 min
Autorreparación de un Pod Automática Restart=on-failure de systemd
Autorreparación ante caída de nodo Automática, recoloca HAProxy lo saca; no recoloca
Escalar a 4 réplicas Un comando, segundos Aprovisionar máquina + Ansible, ~1 h
Curva de aprendizaje 3-6 meses Ya la tienes
Mantenimiento anual 70-130 h ~15 h
Piezas nuevas que dominar etcd, CNI, Ingress, RBAC, PSS, CRD Ninguna
Diagnóstico de un fallo Más capas que descartar Las de siempre
Reconstrucción completa medida Sin medir; el clúster añade pasos 50 min, medido
Disponibilidad alcanzable ~99,8-99,9 % ~99,8 %
Coste anual estimado 3 VM + 100 h de trabajo 3 VM + 15 h
Transferible a otro trabajo Mucho Moderado

Los cuatro puntos donde Kubernetes gana de verdad, sin exagerar:

  1. Despliegue y vuelta atrás. Once segundos frente a dos minutos, y sin script propio que mantener. Es una diferencia real.
  2. Recolocación ante la caída de un nodo. HAProxy saca el nodo caído del reparto pero no recupera la capacidad; Kubernetes arranca las réplicas que faltan en el nodo superviviente.
  3. Escalado. Un comando frente a una hora de trabajo. Solo importa si el escalado es frecuente, y en Tramontana no lo es.
  4. Empleabilidad. Es un argumento personal legítimo pero no es un argumento de arquitectura para la empresa, y hay que separarlos honestamente.

Los cinco donde pierde:

  1. 812 MiB de RAM antes de desplegar nada, en máquinas de 3,8 GB. Es más del 20 % del servidor dedicado a la orquestación.
  2. 70-130 horas al año de mantenimiento indefinido, frente a unas 15.
  3. Tres a seis meses hasta poder resolver incidentes con soltura. Durante ese periodo, la fiabilidad baja.
  4. Más modos de fallo. Es el mismo argumento de 07-07: un sistema con más piezas tiene más formas de romperse, y Kubernetes añade muchas.
  5. La base de datos sigue fuera. El problema difícil de 07-07 —el estado— no lo resuelve Kubernetes. PostgreSQL seguiría en srv-tramontana, con su promoción manual y su RPO.

El análisis que cierra la cuestión, y es el mismo razonamiento que en 07-07: ambas opciones alcanzan aproximadamente la misma disponibilidad, en torno al 99,8 %, porque la mayoría de las interrupciones de Tramontana no vienen del fallo de una máquina sino de despliegues y mantenimiento, y ambas los resuelven. Kubernetes lo hace de forma más elegante y automática; la opción B lo hace con herramientas que el equipo ya domina.

Cuando dos opciones dan el mismo resultado, gana la más simple. Y la diferencia de mantenimiento —entre 55 y 115 horas al año— equivale a dos o tres semanas de trabajo que no se dedicarían a mejorar el producto.

Recomendación: mantener la opción B de 07-07 para producción.

Y mantener el clúster de k3s en srv-tramontana-pruebas como laboratorio de aprendizaje, con coste marginal cero, porque la máquina ya existe. Es donde practicar sin riesgo y donde estar preparado para el escenario que sí cambiaría la recomendación.

Los tres supuestos que la cambiarían, escritos por adelantado para poder reconocerlos:

Cambio Por qué cambiaría la decisión
Tramontana se divide en 5+ servicios La orquestación empieza a pagar sola
Se contrata Kubernetes gestionado en la nube El 60 % del mantenimiento desaparece
Se incorpora alguien con experiencia real La curva de aprendizaje deja de ser un coste

Y una advertencia final que conviene dejar por escrito: la decisión correcta hoy puede no serlo en dos años, y eso no significa que hoy esté mal tomada. Fijar una revisión anual es más profesional que elegir la arquitectura que quizá haga falta algún día.

Solución 3

# ~/tramontana-k8s/60-cronjob-respaldo.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: respaldo-basedatos
  namespace: tramontana
spec:
  # 02:30, igual que tramontana-respaldo.timer (05-05).
  # OJO: el horario se interpreta en la ZONA HORARIA DEL CLÚSTER, que
  # por defecto es UTC. timeZone (estable desde 1.27) lo resuelve; sin
  # ella, la copia se ejecutaria a las 04:30 en verano.
  schedule: "30 2 * * *"
  timeZone: "Europe/Madrid"

  # El equivalente al flock de 04-07: si la copia de ayer sigue
  # corriendo, NO se lanza otra encima.
  concurrencyPolicy: Forbid

  # Si el clúster estuvo caído a las 02:30, ¿se ejecuta al volver?
  # 3600 = solo si han pasado menos de 1 h. Es el equivalente a
  # Persistent=true del timer, pero ACOTADO: sin este campo, un Job
  # muy atrasado se lanzaria en un momento inoportuno.
  startingDeadlineSeconds: 3600

  successfulJobsHistoryLimit: 7    # conservar 7 ejecuciones correctas
  failedJobsHistoryLimit: 14       # y 14 fallidas: son las que se miran

  jobTemplate:
    spec:
      # Reintentos ante fallo, con espera exponencial
      backoffLimit: 2
      # Tope absoluto: una copia colgada no bloquea la siguiente
      activeDeadlineSeconds: 3600
      # Limpieza automatica del Job 3 dias despues
      ttlSecondsAfterFinished: 259200

      template:
        spec:
          restartPolicy: OnFailure

          securityContext:
            runAsNonRoot: true
            runAsUser: 997
            runAsGroup: 1002
            fsGroup: 1002
            seccompProfile: {type: RuntimeDefault}

          containers:
            - name: respaldo
              image: registro.tramontana.example/respaldo:1.4.0
              command: ["/usr/local/bin/respaldo_tramontana.sh"]

              env:
                - name: PGHOST
                  valueFrom:
                    configMapKeyRef: {name: tramontana-config, key: db_host}
                - name: PGDATABASE
                  valueFrom:
                    configMapKeyRef: {name: tramontana-config, key: db_name}
                - name: PGPASSWORD
                  valueFrom:
                    secretKeyRef: {name: tramontana-secretos, key: db_password}
                - name: RESTIC_REPOSITORY
                  valueFrom:
                    secretKeyRef: {name: restic-secretos, key: repositorio}
                - name: RESTIC_PASSWORD
                  valueFrom:
                    secretKeyRef: {name: restic-secretos, key: password}

              resources:
                requests: {cpu: 100m, memory: 256Mi}
                # La copia comprime: necesita mas holgura que la app
                limits:   {cpu: "1",  memory: 1Gi}

              securityContext:
                allowPrivilegeEscalation: false
                readOnlyRootFilesystem: true
                capabilities: {drop: ["ALL"]}

              volumeMounts:
                - {name: trabajo, mountPath: /tmp}
                - {name: cache-restic, mountPath: /var/cache/restic}

          volumes:
            - name: trabajo
              emptyDir: {sizeLimit: 4Gi}
            # El cache de restic acelera mucho las copias sucesivas:
            # persistirlo entre ejecuciones es una optimizacion real.
            - name: cache-restic
              persistentVolumeClaim: {claimName: cache-restic}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: cache-restic
  namespace: tramontana
spec:
  accessModes: [ReadWriteOnce]
  resources: {requests: {storage: 2Gi}}
# Probar SIN esperar a las 02:30 (equivalente a systemctl start)
$ kubectl -n tramontana create job --from=cronjob/respaldo-basedatos prueba-manual
$ kubectl -n tramontana logs job/prueba-manual -f
[2026-08-18 17:12:03] iniciando copia de PostgreSQL
[2026-08-18 17:12:41] copia base verificada: 1,7 GiB
[2026-08-18 17:14:12] restic: snapshot a3f19c8d guardado
[2026-08-18 17:14:20] respaldo completado

$ kubectl -n tramontana get cronjob
NAME                 SCHEDULE     TIMEZONE        SUSPEND   ACTIVE   LAST SCHEDULE
respaldo-basedatos   30 2 * * *   Europe/Madrid   False     0        8h

Qué se gana y qué se pierde, que es la parte central del ejercicio:

CronJob de Kubernetes tramontana-respaldo.timer (systemd)
Se ejecuta si un nodo cae Sí, en otro nodo No: muere con la máquina
Zona horaria timeZone (1.27+); UTC por defecto Zona del sistema, natural
Exclusión mutua concurrencyPolicy: Forbid flock (04-07)
Ejecución atrasada startingDeadlineSeconds Persistent=true
Historial de ejecuciones Objetos Job consultables journalctl -u
Reintentos backoffLimit, nativo Restart= + RestartSec
Aislamiento de recursos requests/limits CPUQuota, MemoryMax
Secretos Secret base64 systemd-creds cifrado (06-05)
Notificación de fallo Requiere monitorización externa OnFailure=, nativo
Acceso al disco del host Complicado, y con razón Directo
Depuración kubectl logs job/... journalctl, o ejecutar el script
Piezas necesarias Clúster + registro + imagen Un script y un fichero .timer

Lo que se gana de verdad son dos cosas, y solo dos:

  1. Resistencia al fallo del nodo. Es el argumento fuerte: tramontana-respaldo.timer vive en srv-tramontana y si esa máquina está apagada a las 02:30, no hay copia esa noche y nadie se entera hasta que comprobar_copia.sh lo detecta a las 08:00. El CronJob se ejecuta en cualquier nodo disponible.
  2. Aislamiento y límites reales, con la misma facilidad. Aunque systemd también los da con MemoryMax, aquí vienen de serie.

Lo que se pierde, y pesa:

  1. Los secretos empeoran. systemd-creds de 06-05 los guarda cifrados con la TPM o una clave de host; un Secret de Kubernetes es base64 en etcd. Es un retroceso objetivo, salvo que se añada cifrado en reposo o Vault.
  2. La notificación de fallo deja de ser nativa. OnFailure= en systemd envía la alerta directamente; con un CronJob hace falta monitorizar kube_job_status_failed desde Prometheus (08-06). Una pieza más.
  3. Acceder al disco del host se complica, y con razón: montar /srv/tramontana/backups en un Pod exige un hostPath, que las Pod Security Standards restricted prohíben. Habría que reescribir el flujo para que la copia vaya directa a restic sin pasar por disco local, lo que es más limpio pero es trabajo.
  4. Se necesita construir y publicar una imagen con el script, pg_dump, restic y sus dependencias — y mantenerla actualizada. El timer usa lo que ya hay instalado.

Recomendación coherente con la solución 2:

Mantener tramontana-respaldo.timer en systemd. El único beneficio real —resistencia al fallo del nodo— se puede conseguir sin Kubernetes: ejecutando el timer también en el segundo nodo de aplicación con un bloqueo compartido, o lanzándolo desde una máquina distinta de la que respalda, que además es mejor diseño porque la copia no depende de que el servidor respaldado esté vivo.

Y hay un argumento adicional: la copia de seguridad debe seguir funcionando cuando el clúster falla. Un mecanismo de respaldo que depende de la infraestructura que respalda es exactamente la clase de acoplamiento que hay que evitar. Es el mismo principio por el que el runbook de 05-08 vive fuera del servidor y la base de datos de AIDE fuera de la máquina.

Conclusión

Has montado un clúster de Kubernetes de dos nodos, has desplegado Tramontana Reservas encima con manifiestos completos, has hecho un despliegue progresivo sin perder una petición y una vuelta atrás en once segundos. Y sabes lo que hay debajo: el apiserver como única puerta de entrada, etcd guardando todo el estado, el planificador eligiendo nodo según requests, los controladores reconciliando sin descanso lo real con lo declarado, y el kubelet ejecutando en cada nodo lo que le toca. Sabes que si el plano de control cae, la carga sigue funcionando — y por qué.

Entiendes los objetos en su orden de dependencia y por qué existe cada uno. Que un Pod suelto no lo vigila nadie. Que el Deployment gestiona ReplicaSets y que ahí vive rollout undo. Que un Service da nombre e IP estables a un blanco móvil, y que el DNS interno lo hace transparente. Que un Ingress es el Nginx de 08-01 escrito como objeto declarativo — de hecho, el controlador más usado es Nginx por dentro. Y sabes que un Secret es base64, no cifrado, lo cual enlaza directamente con pass y systemd-creds de 06-05 y explica por qué existen Sealed Secrets y Vault.

Dominas las tres sondas y la diferencia que las separa, que es lo que más incidentes causa en producciones reales: readiness saca del reparto, liveness mata y reinicia, startup da margen al arranque — y liveness siempre más laxa que readiness, o los reinicios en cascada bajo carga son cuestión de tiempo. Sabes que requests es lo que reserva el planificador y limits lo que impone el cgroup, con la asimetría clave: superar la memoria mata el contenedor, superar la CPU solo lo estrangula. Y sabes leer un CrashLoopBackOff con --previous, un Pending mirando las requests comprometidas del nodo, y un Exit Code: 137 como la firma del OOM killer.

Pero lo que más vale de esta lección es la conclusión con la que empezaba: para Tramontana, Kubernetes es desproporcionado, y ahora puedes defenderlo con números —812 MiB de RAM antes de desplegar nada, 70 a 130 horas de mantenimiento al año, tres a seis meses de curva de aprendizaje— en lugar de con una intuición. Que la respuesta profesional a «¿deberíamos usar Kubernetes?» pueda ser «no, y esto es por qué» es exactamente el mismo criterio con el que respondiste a Marta sobre la alta disponibilidad en 07-07. Cuando dos opciones dan el mismo resultado, gana la más simple. Y el clúster de pruebas se queda, porque un laboratorio donde practicar sin riesgo vale mucho más que la decisión de no usarlo en producción.

En 08-06 se cierra el curso. Vas a llevar todo lo construido en ocho módulos a una checklist de puesta en producción de unas treinta filas, cada una con su evidencia comprobable — y ahí se cierra formalmente la deuda del cifrado en tránsito que 08-01 resolvió. Montarás por fin la monitorización con Prometheus y Grafana que quedó aplazada en 05-07, con los cuatro golden signals, PromQL de lo imprescindible y métricas propias: la edad de la última copia, los días hasta la caducidad del certificado, el retraso de la réplica. Configurarás alertas que sean accionables, que es lo contrario de lo que hace casi todo el mundo. Escribirás el procedimiento de operación diaria, el cuaderno de guardia y la revisión post-incidente sin culpables. Convertirás el 99,8 % propuesto en 07-07 en un presupuesto de error que decide si se despliega o se estabiliza. Y cerrarás las tres deudas que siguen abiertas desde el Módulo 5: la copia externa que no es append-only, el authorized_keys2 que nadie ha explicado, y el objetivo de disponibilidad que nunca se formalizó.

Curso de Linux: De Principiante a Administrador de Sistemas

Módulo 1: Introducción a Linux

Módulo 2: Comandos Básicos de Linux

Módulo 3: Habilidades Avanzadas en la Línea de Comandos

Módulo 4: Scripting en Shell

Módulo 5: Administración del Sistema

Módulo 6: Redes y Seguridad

Módulo 7: Temas Avanzados

Módulo 8: Proyectos Prácticos

© Copyright 2026. Todos los derechos reservados