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.
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
RuntimeDefaultoLocalhost
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ón | Estado por Defecto | Corrección |
|---|---|---|
| Audit logging del API server | Deshabilitado | Agregar audit-log-path a la config de k3s |
| Autenticación anónima | Habilitado | Agregar --anonymous-auth=false al kubelet |
| Puerto de solo lectura | 10255 abierto | Agregar --read-only-port=0 al kubelet |
| Proteger kernel defaults | No configurado | Agregar --protect-kernel-defaults=true al kubelet |
| Limitación de tasa de eventos | No configurado | Agregar --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:
| Estado | Ubicación | Estrategia de Backup | RPO |
|---|---|---|---|
| Config del clúster (CRDs, RBAC, deployments) | etcd / SQLite | etcdctl snapshot o backup SQLite | 1 hora |
| Base de datos de aplicación | StatefulSet PostgreSQL o externo | pg_dump + WAL streaming | 5 minutos |
| Archivos subidos / object store | S3 o Longhorn PVC | Replicación cross-region | 15 minutos |
| Secretos | Sealed Secrets en git | Historia de git | Inmediato |
| Imágenes de contenedores | ghcr.io / Docker Hub | Pull fresco del registry | Inmediato |
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:
- Tomar un snapshot del datastore
- Revisar el changelog de Kubernetes para deprecaciones de API
- Validar versiones de Helm charts contra las nuevas versiones de API
- Ejecutar el upgrade en staging primero — dejar reposar 48 horas
- 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
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Arquitectura de software: Más allá del código
Una guía completa sobre arquitectura de software explicada en lenguaje humano: patrones, organización, estructura y cómo construir sistemas que escalen con tu negocio.
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.