K3s DevOps: IaC, Secretos, SLOs, Seguridad y Recuperación ante Desastres

K3s DevOps: IaC, Secretos, SLOs, Seguridad y Recuperación ante Desastres

K3s desde la perspectiva del operador DevOps: provisionamiento con Terraform, gestión de secretos, SLO/error budgets, hardening CIS y DR con RTO/RPO reales definidos.

Por Omar Flores

La infraestructura es un producto. Tiene usuarios (tu equipo de ingeniería), requisitos de disponibilidad (tus SLAs), una postura de seguridad (tus obligaciones de cumplimiento) y un ciclo de vida (tu plan de upgrades y recuperación ante desastres). La mayoría de las guías de Kubernetes terminan cuando el workload está corriendo. Esta empieza donde ellas terminan.

Los posts anteriores de esta serie cubrieron los fundamentos de K3s y el ciclo de vida de entornos para equipos ágiles. Este post se enfoca en lo que el ingeniero DevOps realmente posee después de que el clúster está corriendo: provisionarlo de forma repetible, gestionar secretos de manera segura, definir y defender niveles de servicio, endurecer la postura de seguridad, y asegurarse de que una falla no se convierta en un desastre.

Cada sección está escrita para la persona que tiene que responder ante un postmortem de incidente, no solo para quien quiere hacer que algo funcione.


Infraestructura como Código: Provisionando K3s con Terraform

Un clúster que provisionaste a mano es un clúster que no puedes reproducir bajo presión. Cuando el VPS se quema a las 2 AM un sábado, la pregunta no es “cómo instalo K3s” — es “qué tan rápido puedo recuperar el clúster idéntico.” La respuesta es Terraform.

El patrón es un stack por capas: Terraform provisiona las máquinas y la red, un script de cloud-init instala K3s, y Ansible maneja la configuración post-instalación (descarga de kubeconfig, rotación de certificados, reglas de firewall). Cada capa es independientemente testeable y reemplazable.

Estructura del Módulo de Terraform

# main.tf — provisiona un servidor K3s de un nodo en Hetzner Cloud
terraform {
  required_providers {
    hcloud = {
      source  = "hetznercloud/hcloud"
      version = "~> 1.45"
    }
  }
  backend "s3" {
    bucket = "tu-bucket-tfstate"
    key    = "k3s/production/terraform.tfstate"
    region = "eu-central-1"
    # usa un backend real — nunca commitees el estado a git
  }
}

resource "hcloud_server" "k3s_server" {
  name        = "k3s-${var.environment}"
  server_type = var.server_type   # cx22 para staging, cx32 para producción
  image       = "ubuntu-24.04"
  location    = var.location
  ssh_keys    = [hcloud_ssh_key.deploy.id]
  user_data   = templatefile("${path.module}/cloud-init.yaml.tpl", {
    k3s_version  = var.k3s_version
    k3s_token    = random_password.k3s_token.result
    environment  = var.environment
    extra_args   = var.k3s_extra_args
  })

  lifecycle {
    prevent_destroy = var.environment == "production" ? true : false
  }
}

resource "hcloud_firewall" "k3s" {
  name = "k3s-${var.environment}"

  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "6443"
    source_ips = var.allowed_cidr_blocks   # solo tus CI runners y VPN
  }
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "80"
    source_ips = ["0.0.0.0/0", "::/0"]
  }
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "443"
    source_ips = ["0.0.0.0/0", "::/0"]
  }
}

resource "random_password" "k3s_token" {
  length  = 64
  special = false
}

output "k3s_token" {
  value     = random_password.k3s_token.result
  sensitive = true
}

output "server_ipv4" {
  value = hcloud_server.k3s_server.ipv4_address
}
# cloud-init.yaml.tpl — se ejecuta una vez en el primer arranque
#cloud-config
package_update: true
packages:
  - curl
  - jq
  - fail2ban
  - ufw

write_files:
  - path: /etc/rancher/k3s/config.yaml
    content: |
      token: "${k3s_token}"
      tls-san:
        - "${server_ip}"
      disable:
        - traefik       # lo instalamos por separado con Helm para control de versiones
      kube-apiserver-arg:
        - "audit-log-path=/var/log/k3s-audit.log"
        - "audit-log-maxage=30"
        - "audit-log-maxbackup=3"
        - "audit-log-maxsize=100"
        - "audit-policy-file=/etc/rancher/k3s/audit-policy.yaml"
      kubelet-arg:
        - "protect-kernel-defaults=true"
        - "event-qps=0"

runcmd:
  - ufw default deny incoming
  - ufw default allow outgoing
  - ufw allow 22/tcp
  - ufw allow 80/tcp
  - ufw allow 443/tcp
  - ufw allow 6443/tcp
  - ufw --force enable
  - curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION="${k3s_version}" sh -
  - systemctl enable k3s

Variables y Entornos

# variables.tf
variable "environment" {
  type        = string
  description = "staging o production"
  validation {
    condition     = contains(["staging", "production"], var.environment)
    error_message = "El entorno debe ser staging o production."
  }
}

variable "k3s_version" {
  type    = string
  default = "v1.29.3+k3s1"
  # fija la versión — nunca uses 'latest' en producción
}

variable "server_type" {
  type    = string
  default = "cx22"
}

variable "allowed_cidr_blocks" {
  type      = list(string)
  sensitive = true
}

La regla de ciclo de vida prevent_destroy en el servidor de producción fuerza a Terraform a fallar si alguien intenta destruirlo y recrearlo. Reconstruir producción requiere eliminar ese flag explícitamente — una fricción intencional que previene accidentes.


Gestión de Secretos

Los secretos en Kubernetes tienen un problema fundamental: un recurso Secret es solo un ConfigMap codificado en base64. Cualquier persona con acceso de lectura al clúster puede decodificarlo. Commitear el YAML crudo a git es equivalente a commitear contraseñas en texto plano.

Hay dos soluciones de nivel productivo. La elección correcta depende de si controlas tu propio almacén de secretos o lo delegas a un proveedor de nube.

Opción 1: Sealed Secrets (self-hosted)

Sealed Secrets encripta tu secreto con una clave pública que solo el controlador en tu clúster puede desencriptar. Puedes commitear el SealedSecret encriptado a git de forma segura.

# instalar el controlador
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets \
  --namespace kube-system \
  --set fullnameOverride=sealed-secrets-controller

# instalar CLI de kubeseal
brew install kubeseal  # o descargar el binario

# obtener la clave pública del clúster
kubeseal --fetch-cert \
  --controller-name=sealed-secrets-controller \
  --controller-namespace=kube-system \
  > pub-sealed-secrets.pem

# sellar un secreto — el output es seguro para commitear
kubectl create secret generic db-credentials \
  --from-literal=password=supersecret \
  --dry-run=client \
  -o yaml \
  | kubeseal \
    --cert pub-sealed-secrets.pem \
    --format yaml \
  > k8s/base/db-credentials-sealed.yaml

El manifiesto SealedSecret resultante tiene este aspecto:

apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: db-credentials
  namespace: production
spec:
  encryptedData:
    password: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq...
  template:
    metadata:
      name: db-credentials
      namespace: production
    type: Opaque

Cuando el controlador ve este recurso, lo desencripta y crea el Secret correspondiente en el clúster. El texto plano nunca toca git.

Rotación de claves: cuando necesitas rotar la propia clave de sellado (por ejemplo, después de un incidente de seguridad), genera un nuevo par de claves, re-encripta todos los secretos y reemplaza la clave del controlador.

# rotar la clave de sellado
kubectl -n kube-system delete secret sealed-secrets-key
# el controlador genera una nueva clave al reiniciarse
kubectl -n kube-system rollout restart deployment sealed-secrets-controller
# obtener la nueva clave pública y re-sellar todos los secretos

Opción 2: External Secrets Operator (ESO)

ESO extrae secretos de un almacén externo (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault, GCP Secret Manager) y crea objetos Secret de Kubernetes en el clúster. El almacén externo es la fuente de verdad. Ningún secreto vive jamás en git.

helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
  --namespace external-secrets \
  --create-namespace
# SecretStore — conecta ESO con AWS Secrets Manager
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: aws-secrets-manager
  namespace: production
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      auth:
        secretRef:
          accessKeyIDSecretRef:
            name: aws-credentials
            key: access-key-id
          secretAccessKeySecretRef:
            name: aws-credentials
            key: secret-access-key
---
# ExternalSecret — extrae un secreto específico de AWS y crea un Secret de K8s
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: db-credentials
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: db-credentials
    creationPolicy: Owner
  data:
    - secretKey: password
      remoteRef:
        key: production/db-credentials
        property: password

ESO re-sincroniza el secreto cada refreshInterval. Si el valor cambia en el almacén externo, el Secret de Kubernetes se actualiza automáticamente.

Cuándo elegir cuál:

  • Sealed Secrets: equipo pequeño, sin dependencia de proveedor de nube, cadencia de rotación simple
  • ESO: entorno regulado, inversión existente en Vault/Secrets Manager, rotación automática de secretos requerida

SLO, SLI y Error Budgets

Un SLO (Service Level Objective) es un compromiso. “El 99.5% de las solicitudes HTTP retornan 2xx en menos de 500ms, medido en una ventana deslizante de 30 días.” No es un objetivo que se define una vez y se olvida — es el número que tu equipo usa para decidir si desplegar un cambio riesgoso o dedicar el próximo sprint a confiabilidad.

El SLI (Service Level Indicator) es cómo lo mides. El error budget es lo que te queda para gastar antes de violar el SLO.

Definiendo SLIs en Prometheus

# PrometheusRule — define las recording rules y alerting rules para tu SLO
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: api-slo
  namespace: monitoring
  labels:
    release: kube-prometheus-stack
spec:
  groups:
    - name: slo.api.availability
      interval: 30s
      rules:
        # SLI: ratio de solicitudes exitosas sobre total
        - record: job:http_requests_total:rate5m
          expr: rate(http_requests_total[5m])

        - record: job:http_request_errors:rate5m
          expr: rate(http_requests_total{status=~"5.."}[5m])

        - record: job:http_availability:ratio5m
          expr: |
            1 - (
              sum(job:http_request_errors:rate5m)
              /
              sum(job:http_requests_total:rate5m)
            )

        # ratio de disponibilidad a 30 días (para cálculo de error budget)
        - record: job:http_availability:ratio30d
          expr: |
            1 - (
              sum_over_time(job:http_request_errors:rate5m[30d])
              /
              sum_over_time(job:http_requests_total:rate5m[30d])
            )

    - name: slo.api.latency
      interval: 30s
      rules:
        # SLI: ratio de solicitudes que completan bajo 500ms
        - record: job:http_latency_fast:ratio5m
          expr: |
            sum(rate(http_request_duration_seconds_bucket{le="0.5"}[5m]))
            /
            sum(rate(http_request_duration_seconds_count[5m]))

    - name: slo.api.alerts
      rules:
        # Alerta de burn rate multi-ventana — se activa cuando el error budget se consume demasiado rápido
        - alert: SLOErrorBudgetBurnRateHigh
          expr: |
            (
              job:http_availability:ratio5m < (1 - 14.4 * (1 - 0.995))
              and
              job:http_availability:ratio1h < (1 - 14.4 * (1 - 0.995))
            )
          for: 2m
          labels:
            severity: critical
            team: platform
          annotations:
            summary: "Error budget de API consumiéndose a 14.4x — violación de SLO en < 1 hora"
            runbook: "https://wiki.internal/runbooks/api-slo-burn"

        - alert: SLOErrorBudgetBurnRateMedium
          expr: |
            (
              job:http_availability:ratio30m < (1 - 6 * (1 - 0.995))
              and
              job:http_availability:ratio6h < (1 - 6 * (1 - 0.995))
            )
          for: 15m
          labels:
            severity: warning
            team: platform
          annotations:
            summary: "Error budget consumiéndose a 6x — investigar antes del fin de semana"

Los multiplicadores de burn rate (14.4x y 6x) provienen del Google SRE Workbook. Un burn rate de 14.4x significa que agotarás el error budget mensual completo en 50 minutos si continúa. Este es el umbral para una alerta crítica — despertar a alguien.

Política de Error Budget

El error budget no es solo una métrica. Es un framework de toma de decisiones. Documéntalo explícitamente:

## Política de Error Budget del Servicio API

SLO: 99.5% disponibilidad en 30 días
Error budget: 0.5% de solicitudes = ~3.6 horas de caída total por mes

### Cuando el budget es > 50% restante
- Desarrollo normal de features y despliegues permitidos
- Cambios de infraestructura riesgosos permitidos con revisión

### Cuando el budget es 25–50% restante
- Feature freeze en cambios que afecten el request path
- Todos los despliegues requieren dos aprobadores
- On-call aumenta a SLA de respuesta de 30 minutos

### Cuando el budget es < 25% restante
- Feature freeze total
- El foco de ingeniería se traslada exclusivamente a confiabilidad
- Sin cambios de infraestructura sin aprobación del incident commander

### Cuando el budget se agota
- Despliegues en producción suspendidos hasta que el budget se recupere
- Revisión post-incidente requerida antes de reanudar operaciones normales

Hardening de Seguridad

Una instalación por defecto de K3s es funcional pero no endurecida. La distancia entre “funciona” y “seguro” es exactamente donde ocurren las brechas. El hardening de seguridad no es una tarea única — es un conjunto de controles que implementas, testeas y mantienes.

Pod Security Standards

Kubernetes 1.25+ reemplazó PodSecurityPolicies con Pod Security Standards. Aplica el perfil restricted en namespaces de producción y baseline en todo lo demás.

# Aplicar labels para hacer cumplir Pod Security Standards por namespace
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: latest
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: latest

Lo que hace cumplir restricted:

  • Sin contenedores privilegiados
  • Sin escalación de privilegios (allowPrivilegeEscalation: false)
  • Los contenedores deben ejecutarse como no-root
  • El sistema de archivos raíz debe ser de solo lectura
  • Todas las capabilities eliminadas, solo se re-agregan específicas si se necesitan
  • El perfil seccomp debe ser RuntimeDefault o Localhost

Un deployment que pasa la validación restricted:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: production
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: api
          image: ghcr.io/tu-org/api:sha-abc123
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: tmp
              mountPath: /tmp      # tmpfs escribible para apps que lo necesiten
            - name: cache
              mountPath: /app/cache
      volumes:
        - name: tmp
          emptyDir: {}
        - name: cache
          emptyDir: {}

Network Policies

Por defecto, cada pod en un clúster de Kubernetes puede alcanzar a cualquier otro pod en todos los namespaces. Esto es incorrecto para producción. Define una lista de permisos explícita usando NetworkPolicy.

# Denegar todo ingress y egress por defecto en producción
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
---
# Permitir ingress a la API solo desde Traefik
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-ingress-from-traefik
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              app.kubernetes.io/name: traefik
      ports:
        - port: 8080
---
# Permitir que la API alcance PostgreSQL
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-api-to-postgres
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: postgres
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: api
      ports:
        - port: 5432
---
# Permitir resolución DNS para todos los pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - ports:
        - port: 53
          protocol: UDP
        - port: 53
          protocol: TCP
---
# Permitir que la API alcance servicios externos (solo HTTPS)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-api-egress-https
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
    - Egress
  egress:
    - ports:
        - port: 443

Audit Logging

La política de auditoría en el cloud-init es incompleta sin el archivo de política real. Defínelo explícitamente — es el registro que necesitarás después de una brecha.

# /etc/rancher/k3s/audit-policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  # Registrar todas las solicitudes a secretos — body completo en creación/actualización
  - level: RequestResponse
    resources:
      - group: ""
        resources: ["secrets"]
    verbs: ["create", "update", "patch", "delete"]

  # Registrar operaciones exec y port-forward — alto privilegio, alto riesgo
  - level: RequestResponse
    resources:
      - group: ""
        resources: ["pods/exec", "pods/portforward", "pods/attach"]

  # Registrar cambios de RBAC
  - level: RequestResponse
    resources:
      - group: "rbac.authorization.k8s.io"
        resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]

  # Reducir ruido de operaciones de solo lectura en recursos comunes
  - level: None
    resources:
      - group: ""
        resources: ["configmaps", "endpoints", "services"]
    verbs: ["get", "list", "watch"]

  # Por defecto: registrar metadata para todo lo demás
  - level: Metadata
    omitStages:
      - RequestReceived

Seguridad de Imágenes

Nunca uses :latest. Fija a un digest o a una imagen con tag de SHA. Usa un motor de políticas para hacerlo cumplir.

# ClusterPolicy de Kyverno — bloquea el tag latest y requiere digest en producción
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-image-digest
spec:
  validationFailureAction: Enforce
  background: false
  rules:
    - name: check-image-tag
      match:
        any:
          - resources:
              kinds: ["Pod"]
              namespaces: ["production", "staging"]
      validate:
        message: "Las imágenes de producción deben usar un digest o tag SHA, no :latest ni un tag mutable."
        pattern:
          spec:
            containers:
              - image: "*:sha-*"
helm repo add kyverno https://kyverno.github.io/kyverno/
helm install kyverno kyverno/kyverno \
  --namespace kyverno \
  --create-namespace \
  --set replicaCount=1   # una réplica para K3s; usa 3 para HA

Validación del Benchmark CIS

Ejecuta kube-bench contra el clúster para medir el cumplimiento CIS. Es la herramienta que los auditores esperan ver resultados.

kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
kubectl wait --for=condition=complete job/kube-bench --timeout=120s
kubectl logs job/kube-bench

Verificaciones clave que K3s falla por defecto y cómo corregirlas:

VerificaciónEstado por DefectoCorrección
Audit logging del API serverDeshabilitadoAgregar audit-log-path a la config de k3s
Autenticación anónimaHabilitadoAgregar --anonymous-auth=false al kubelet
Puerto de solo lectura10255 abiertoAgregar --read-only-port=0 al kubelet
Proteger kernel defaultsNo configuradoAgregar --protect-kernel-defaults=true al kubelet
Limitación de tasa de eventosNo configuradoAgregar --event-qps=0 al kubelet

Optimización de Recursos

Kubernetes te da las herramientas para describir los requisitos de recursos. La mayoría de los equipos se saltan este paso. El resultado es o bien un clúster donde los pods se hambrean mutuamente durante los picos de carga, o uno donde pagas tres veces la capacidad que realmente necesitas.

Requests vs Limits

La distinción crítica: requests determina el scheduling (dónde aterriza el pod). limits determina la aplicación (qué pasa cuando excede el umbral). Un pod con limits.cpu: 500m y requests.cpu: 100m puede hacer burst a 500m en un nodo con capacidad disponible, pero tiene garantizados 100m.

resources:
  requests:
    cpu: "100m"      # asignación garantizada para scheduling
    memory: "128Mi"  # asignación garantizada para scheduling
  limits:
    cpu: "500m"      # burst máximo — se throttlea si se excede (no se mata)
    memory: "256Mi"  # límite estricto — OOMKilled si se excede

Nunca configures limits.memory por debajo de requests.memory. Nunca omitas requests — sin ellos, el scheduler no tiene datos y coloca pods aleatoriamente.

VPA para Ajuste Automático

El Vertical Pod Autoscaler observa el uso real de recursos y recomienda (o aplica) mejores valores de requests. Úsalo en modo Off primero para obtener recomendaciones sin efectos secundarios.

kubectl apply -f https://github.com/kubernetes/autoscaler/releases/latest/download/vertical-pod-autoscaler.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: api-vpa
  namespace: production
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  updatePolicy:
    updateMode: "Off"   # Solo recomendar — no mutar pods automáticamente
  resourcePolicy:
    containerPolicies:
      - containerName: api
        minAllowed:
          cpu: "50m"
          memory: "64Mi"
        maxAllowed:
          cpu: "2000m"
          memory: "1Gi"

Después de una semana de observaciones, revisa las recomendaciones:

kubectl describe vpa api-vpa -n production
# Busca la sección "Recommendation:"
# Lower bound = mínimo seguro, Target = recomendado, Upper bound = headroom para picos

LimitRange y ResourceQuota por Namespace

Define defaults para que cualquier pod sin requests explícitos obtenga valores razonables:

apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: production
spec:
  limits:
    - type: Container
      default:
        cpu: "200m"
        memory: "256Mi"
      defaultRequest:
        cpu: "50m"
        memory: "64Mi"
      max:
        cpu: "2000m"
        memory: "2Gi"
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: production-quota
  namespace: production
spec:
  hard:
    requests.cpu: "8"
    requests.memory: "16Gi"
    limits.cpu: "16"
    limits.memory: "32Gi"
    pods: "50"
    services: "20"
    persistentvolumeclaims: "10"

Recuperación ante Desastres

La recuperación ante desastres es la práctica de definir lo que puedes sobrevivir y construir los sistemas que te permiten sobrevivirlo. Los dos números que importan son RTO (Recovery Time Objective — cuánto tiempo puedes estar caído) y RPO (Recovery Point Objective — cuántos datos puedes perder).

Define estos antes de construir el sistema DR, no después. “Ya lo resolveremos” no es un plan de DR.

Inventario de Estado

Antes de poder recuperarte, necesitas saber qué estado existe y dónde vive:

EstadoUbicaciónEstrategia de BackupRPO
Config del clúster (CRDs, RBAC, deployments)etcd / SQLiteetcdctl snapshot o backup SQLite1 hora
Base de datos de aplicaciónStatefulSet PostgreSQL o externopg_dump + WAL streaming5 minutos
Archivos subidos / object storeS3 o Longhorn PVCReplicación cross-region15 minutos
SecretosSealed Secrets en gitHistoria de gitInmediato
Imágenes de contenedoresghcr.io / Docker HubPull fresco del registryInmediato

Backup del Datastore de K3s

Para K3s de un solo nodo (SQLite), el datastore es un archivo en /var/lib/rancher/k3s/server/db/state.db. Hazle backup con un systemd timer:

#!/bin/bash
# /usr/local/bin/k3s-backup.sh
set -euo pipefail

BACKUP_DIR="/opt/k3s-backups"
DATE=$(date +%Y%m%d-%H%M%S)
BACKUP_FILE="$BACKUP_DIR/k3s-state-$DATE.db"

mkdir -p "$BACKUP_DIR"

# detener escrituras temporalmente para un snapshot consistente
systemctl stop k3s
cp /var/lib/rancher/k3s/server/db/state.db "$BACKUP_FILE"
systemctl start k3s

# comprimir
gzip "$BACKUP_FILE"

# subir a S3
aws s3 cp "$BACKUP_FILE.gz" "s3://tu-bucket-backup/k3s/$(hostname)/$DATE.db.gz"

# mantener solo los últimos 7 días localmente
find "$BACKUP_DIR" -name "*.gz" -mtime +7 -delete
# /etc/systemd/system/k3s-backup.timer
[Unit]
Description=Timer de Backup K3s SQLite

[Timer]
OnCalendar=*:0/30     # cada 30 minutos
Persistent=true

[Install]
WantedBy=timers.target

Para K3s HA con etcd embebido, usa el snapshot nativo:

# snapshot manual
k3s etcd-snapshot save --name pre-upgrade-$(date +%Y%m%d)

# listar snapshots
k3s etcd-snapshot list

# restaurar desde snapshot (el clúster debe estar detenido)
systemctl stop k3s
k3s server --cluster-reset --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/pre-upgrade-20260306.db
systemctl start k3s

Configura snapshots automáticos de etcd en la config de k3s:

# /etc/rancher/k3s/config.yaml
etcd-snapshot-schedule-cron: "*/30 * * * *"
etcd-snapshot-retention: 96      # mantener 96 snapshots = 2 días a intervalos de 30 minutos
etcd-snapshot-dir: /opt/k3s-snapshots

Runbook de Restauración del Clúster

Documenta la secuencia antes de necesitarla. El runbook es el artefacto que marca la diferencia entre una recuperación de 30 minutos y una de 4 horas.

## Runbook de Restauración del Clúster K3s

### Precondiciones
- Acceso a: bucket S3 de backup, proveedor DNS, nuevo VPS
- Secretos requeridos: K3s token (en Vault), contraseña DB (en Vault), clave privada TLS

### Paso 1: Provisionar nuevo nodo (Terraform)
```bash
cd infrastructure/terraform
terraform apply -var="environment=production" -target=hcloud_server.k3s_server
# esperar que cloud-init complete: ssh root@<new-ip> journalctl -f -u cloud-final

Paso 2: Restaurar datastore de K3s

aws s3 cp s3://tu-bucket-backup/k3s/latest.db.gz /tmp/
gunzip /tmp/latest.db.gz
systemctl stop k3s
cp /tmp/latest.db /var/lib/rancher/k3s/server/db/state.db
systemctl start k3s

Paso 3: Verificar clúster

kubectl get nodes     # debe mostrar Ready
kubectl get pods -A   # debe mostrar pods recuperándose

Paso 4: Actualizar DNS

Apunta el registro A de tu dominio a la IP del nuevo VPS. El TTL debe ser 60s en producción (configúralo antes de un incidente, no durante).

Paso 5: Verificar aplicación

Ejecutar smoke tests contra la nueva IP antes de cambiar el DNS.

RTO esperado: 25 minutos

Probando el DR

Un plan de DR que nunca has probado es un plan de DR que fallará. Ejecuta un simulacro completo de DR trimestralmente:

# simular pérdida total del clúster en staging
terraform destroy -target=hcloud_server.k3s_server -var="environment=staging"

# iniciar el cronómetro
# ejecutar el runbook desde el paso 1
# medir el RTO real vs el RTO objetivo

# documentar en el log de incidentes qué fue más lento de lo esperado
# actualizar el runbook antes del próximo trimestre

Estrategia de Upgrade

K3s sigue el upstream de Kubernetes con un retraso de 2-4 semanas. Correr más de 2 versiones menores atrás significa que te estás perdiendo parches de seguridad. El system-upgrade-controller automatiza los upgrades:

# Plan CRD — actualiza nodos servidor a una versión específica de K3s
apiVersion: upgrade.cattle.io/v1
kind: Plan
metadata:
  name: k3s-server-upgrade
  namespace: system-upgrade
spec:
  concurrency: 1        # actualizar un nodo a la vez
  cordon: true          # cordon antes de upgrade, uncordon después
  serviceAccountName: system-upgrade
  upgrade:
    image: rancher/k3s-upgrade
  channel: https://update.k3s.io/v1-release/channels/stable
  nodeSelector:
    matchExpressions:
      - key: node-role.kubernetes.io/control-plane
        operator: Exists

Antes de cualquier upgrade:

  1. Tomar un snapshot del datastore
  2. Revisar el changelog de Kubernetes para deprecaciones de API
  3. Validar versiones de Helm charts contra las nuevas versiones de API
  4. Ejecutar el upgrade en staging primero — dejar reposar 48 horas
  5. Aplicar en producción durante ventana de bajo tráfico con plan de rollback documentado

La Mentalidad de Infraestructura

Los clústeres de Kubernetes no son mascotas. El objetivo de cada práctica en este post — IaC, gestión de secretos, SLOs, hardening, DR — es hacer que el clúster sea aburrido. Un clúster aburrido es uno que provisiona de forma idéntica cada vez, falla de manera predecible, se recupera automáticamente, y nunca sorprende al ingeniero de on-call a las 3 AM.

La medida de una infraestructura madura no es el uptime. El uptime es un indicador rezagado. La medida es el tiempo de restauración después de una falla. Un equipo que puede reconstruir el clúster completo en 25 minutos a partir de un backup conocido puede tolerar fallas catastróficas. Un equipo que nunca ha probado su procedimiento de DR no puede.

El ingeniero de on-call que nunca tuvo que usar el runbook es el ingeniero que estará perdido cuando el runbook importe. Prueba tu recuperación. El simulacro es el trabajo.

Tags

#kubernetes #devops #security #monitoring #best-practices #guide #senior