Operación

Procedimientos para el día a día. Todos los comandos kubectl dan por hecho un kubeconfig que apunta a la VIP (https://192.0.2.250:6443).

Estado general

scripts/check_cluster.sh hace una revisión de solo lectura: nodos, miembros de etcd, VIP, keepalived, pods con problemas, volúmenes de Longhorn y Velero.

./scripts/check_cluster.sh

Para las actualizaciones del sistema de los nodos, el portal de actualizaciones (scripts/mantenimiento.sh). Para la seguridad, scripts/seguridad.py (ver seguridad).

Comprobaciones a mano:

kubectl get nodes -o wide
kubectl get pods -A | grep -vE 'Running|Completed'
kubectl -n longhorn-system get volumes.longhorn.io        # robustness: healthy
sudo k3s etcd-snapshot list                               # en un master
journalctl -u k3s --since '1 hour ago' | grep -iE 'leader|elect|timeout' | tail

Señales de que etcd sufre:

Mantenimiento de un nodo (drain y reboot)

kubectl drain <nodo> --ignore-daemonsets --delete-emptydir-data
# … mantenimiento / reboot …
kubectl uncordon <nodo>

Antes de hacer drain:

Si el drain se queda esperando por el PDB de instance-manager de Longhorn, suele ser un ticket snapshot-controller-* huérfano en un volumeattachments.longhorn.io: apunta a un snapshot que ya no existe (pasó tras la caída del 27/10/2026). Se quita del spec.attachmentTickets del volumen, solo si el snapshot no aparece en kubectl -n longhorn-system get snapshots.longhorn.io. No borra datos. Ver incidente del 28/10.

# Tickets de snapshot y sus volúmenes
kubectl -n longhorn-system get volumeattachments.longhorn.io -o json \
  | jq -r '.items[]|.metadata.name as $v|(.spec.attachmentTickets//{})|keys[]|select(startswith("snapshot-controller-"))|"\($v) \(.)"'
# Para cada uno cuyo snapshot (el nombre sin "snapshot-controller-") no exista:
kubectl -n longhorn-system patch volumeattachments.longhorn.io <volumen> --type json \
  -p '[{"op":"remove","path":"/spec/attachmentTickets/<ticket>"}]'

Añadir un worker

Ver nodes/README.md. Después:

kubectl label node <nodo> node-role.kubernetes.io/worker=worker

Convertir un worker en master

  1. Copia antes /var/lib/rancher/k3s/storage/: el desinstalador de agent lo borra sin avisar.
  2. Haz drain del nodo y ejecuta k3s-agent-uninstall.sh.
  3. Instala el server con la misma versión que el clúster:
    curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.34.6+k3s1 K3S_TOKEN=<token> sh -s - server \
      --server https://192.0.2.250:6443 --disable=servicelb \
      --node-ip=<ip> --flannel-iface=<interfaz-cableada>
    
  4. Añade el nodo al backend de haproxy en los masters e instala keepalived con su prioridad (nodes/masters/).
  5. Comprueba que ip -d link show flannel.1 | grep local muestra la IP real del nodo.

Con 2 miembros de etcd, el quórum son los 2. Si el tercero no termina de unirse, se pierde la API. Para recuperarla: k3s server --cluster-reset en el master sano, borrar db/ en el nodo que falló y repetir el join.

Actualizar k3s

⚠️ No uses curl -sfL https://get.k3s.io | sh -s - server sin flags para actualizar. El script de instalación reescribe /etc/systemd/system/k3s.service con los flags que le pases. Sin ellos, el master pierde --node-ip, --flannel-iface, --disable=servicelb, --server… (ver nodes/README.md).

El clúster tiene 3 masters con etcd embebido: no es un k3s single (SQLite) desde la migración del 2026-07-03. La actualización se hace por turnos (rolling): un nodo cada vez, mientras los otros dos masters mantienen el quórum de etcd.

Reglas

Desde el portal (recomendado)

Botón «6 · Actualizar k3s» de Semaphore (ansible/README.md), o ./scripts/mantenimiento.sh k3s -e version=v1.34.12+k3s1 (con --check antes). Hace este mismo procedimiento: masters primero de uno en uno (snapshot de etcd, espera a Ready con la versión nueva y [+]etcd ok) y después los workers, que no se tocan si algún master va por detrás. nodo-mantenimiento k3s valida la versión (formato, nunca hacia atrás, como mucho una rama más, binario de GitHub con su sha256). Para un cambio de rama hace drain y uncordon, pero la revisión de compatibilidades sigue siendo manual.

A mano, en un nodo: scripts/k3s_parche.sh (solo parches; lanzarlo con systemd-run y la ruta completa del script).

Procedimiento por nodo (sustituyendo solo el binario)

Conserva el unit de systemd y todos los flags.

V=v1.34.12+k3s1                      # versión de destino
ARCH=amd64; BIN=k3s                  # en la Raspberry: ARCH=arm64; BIN=k3s-arm64
U="https://github.com/k3s-io/k3s/releases/download/${V/+/%2B}"

# 1. Descargar y verificar
curl -sfLo /tmp/$BIN "$U/$BIN"
curl -sfL "$U/sha256sum-$ARCH.txt" | grep " $BIN\$" | sed "s| $BIN\$| /tmp/$BIN|" | sha256sum -c -

# 2. Solo en masters: snapshot de etcd antes de tocar nada
sudo k3s etcd-snapshot save --name pre-$(echo $V | tr + -)

# 3. Sustituir el binario y reiniciar
sudo install -m 755 /tmp/$BIN /usr/local/bin/k3s
sudo systemctl restart k3s           # en workers: k3s-agent

Comprobaciones antes de pasar al siguiente nodo:

kubectl get nodes -o wide                                  # el nodo, Ready y con la versión nueva
kubectl get --raw '/readyz?verbose' | grep -E 'etcd|failed'  # [+]etcd ok
./scripts/check_cluster.sh

Si el SSH va mal: lanzar la actualización desacoplada con systemd-run

Si la conexión SSH se corta (no solo va lenta), el servidor envía SIGHUP a la sesión y mata el script a medias. Para que termine pase lo que pase, se lanza como servicio temporal de systemd:

sudo -v    # meter la contraseña antes, para que no la pida a mitad
sudo systemd-run --unit=k3s-upgrade --collect bash -c '
set -e; V=v1.34.12+k3s1; BIN=k3s; ARCH=amd64        # Raspberry: BIN=k3s-arm64; ARCH=arm64
U="https://github.com/k3s-io/k3s/releases/download/${V/+/%2B}"
curl -sfLo /tmp/$BIN "$U/$BIN"
curl -sfL "$U/sha256sum-$ARCH.txt" | grep " $BIN$" | sed "s| $BIN$| /tmp/$BIN|" | sha256sum -c -
# solo en masters: k3s etcd-snapshot save --name pre-$(echo $V | tr + -)
cp -a /usr/local/bin/k3s /usr/local/bin/k3s.anterior.bak
install -m 755 /tmp/$BIN /usr/local/bin/k3s
systemctl restart k3s-agent    # en masters: k3s
echo "OK: actualizado a $V"'

journalctl -u k3s-upgrade -f    # ver el progreso (también después de reconectar)

El resultado se comprueba también sin SSH, por la API: kubectl get nodes -o wide o la tarjeta Nodos de Homepage.

Esto protege el script, no el clúster: con la red cableada perdiendo paquetes, no actualices masters (el quórum de etcd depende de los otros dos). Mide antes: ping -c 60 -i 0.5 192.0.2.1 desde un nodo cableado debe dar 0 % de pérdida.

En Homepage (http://192.0.2.242), la tarjeta Nodos muestra la versión de cada nodo y Control-plane el número de versiones distintas (2 mientras dura la actualización y 1 al terminar).

Después de actualizar un master, revisa que local-path no ha vuelto a quedar como StorageClass por defecto.

Marcha atrás de un parche: reinstalar el binario anterior igual que en el paso 3. Si etcd se corrompiera: k3s server --cluster-reset --cluster-reset-restore-path=<snapshot pre-…> (ver más abajo).

Actualizaciones del sistema de los nodos

Con el portal de actualizaciones: el panel de Grafana Actualizaciones de los nodos muestra qué falta, y ./scripts/mantenimiento.sh actualizar lo instala nodo a nodo, con drain y reinicio si hace falta.

Grafana: volumen persistente

Grafana guarda paneles, usuarios, carpetas y alertas creados a mano en /var/lib/grafana (grafana.db). Hasta el 2026-10-26 era un emptyDir, que se pierde cada vez que el pod cambia de nodo. scripts/grafana_persistente.sh lo pasa a un PVC de Longhorn sin perder nada:

  1. Copia grafana.db a /root/.config/grafana-copia/ (solo root) y comprueba que su sha256 coincide con la del pod; si la red corta la copia, reintenta. Los plugins no se copian: Grafana los vuelve a instalar al arrancar.
  2. helm upgrade --reuse-values con monitoring/grafana-persistente.yaml (y el colector textfile de node-exporter).
  3. Aparta el grafana.db vacío del volumen nuevo, sube la copia en trozos de 48 KB (por la entrada estándar de kubectl exec llega cortada), comprueba su huella y arranca Grafana.

Aplicado el 2026-10-26: PVC prometheus-grafana en Longhorn, base de datos restaurada con huella idéntica y la copia en /root/.config/grafana-copia/.

Los datasources de Prometheus y Alertmanager los crea el chart y no dependen de esto.

Grafana: «database is locked»

El 2026-10-26 (con el volumen recién creado y 3 réplicas en master-2, worker-2 y master-3), un corte de red con la réplica de master-3 dejó las escrituras colgadas unos segundos y Grafana empezó a fallar con database is locked (SQLite es muy sensible a eso). Se recuperó sola cuando Longhorn siguió con las otras réplicas. Para que no dependa de la red, scripts/grafana_local.sh deja el volumen con 2 réplicas y una siempre en el nodo de Grafana (dataLocality: best-effort), quitando la que sobre (la de master-3 si se puede, nunca la local). Grafana tiene que correr en un nodo cableado (fijado en worker-2): en el master-1 (WiFi) la réplica local no llega a reconstruirse (ver incidentes). Es un cambio en el volumen de Longhorn, no en Helm: un helm upgrade no lo deshace, pero recrear el PVC (otro grafana_persistente.sh) lo devuelve a 3 réplicas sin localidad.

Exportar el estado del clúster a este repo

python3 scripts/export_cluster.py    # regenera cluster/ (sin secretos)
git diff --stat                      # revisa los cambios
git add -A && git commit -m "Exportación del estado del clúster $(date +%F)"

El script falla si detecta algo que parezca una clave privada o un token. Revisa siempre el diff antes de hacer push.

Cambiar la interfaz VRRP de keepalived

Hace falta cuando un master cambia de red física, por ejemplo al pasar de WiFi a cable. (Desde el 2026-10-27 los 3 masters anuncian por cable; master-1 se pasó con scripts/master1_a_cable.sh, que además cambia su IP y k3s.) Si interface apunta a una interfaz caída, keepalived entra en FAULT y ese master no puede tomar la VIP.

Requisito previo: la red cableada tiene que estar estable. Si un BACKUP deja de oír los anuncios del MASTER durante más de unos 3 s, se queda también con la VIP (split-brain). Con cortes periódicos en la red, la VIP iría saltando de un nodo a otro. Comprobación desde master-1:

ping -D -O -c 90 192.0.2.139   # master-2: 0 % de pérdida y ningún "no answer yet"
ping -D -O -c 90 192.0.2.129   # master-3: lo mismo, antes de tocarlo

Cuando se escribió, master-1 salía por WiFi, así que esto medía el camino WiFi + cable: servía para detectar los cortes, pero no era una medida solo del cable. Para medir solo el cable, desde master-2:

ping -D -O -c 90 192.0.2.1     # router

Valores para cada nodo:

Nodo Interfaz antigua (WiFi, caída) Interfaz nueva (cable)
master-1 wlp3s0 enp4s0f0 (con master1_a_cable.sh)
master-2 wlp2s0 enp1s0
master-3 wlo1 enp2s0

✅ Hecho el 2026-10-27 en los dos nodos. scripts/keepalived_cable.sh automatiza estos pasos y se para si algo no está bien (sudo bash keepalived_cable.sh enp1s0 en master-2, enp2s0 en master-3). A mano:

Se hace de un nodo en uno, primero master-2 y después master-3. En cada nodo, con sudo:

NUEVA=enp1s0     # enp2s0 en master-3

# 1. ¿Llegan por cable los anuncios VRRP de master-1 (192.0.2.112, prio 150)?
#    Tienen que salir ~10 paquetes, uno por segundo. Si no sale nada, PARAR: el BACKUP no oiría al MASTER.
sudo timeout 12 tcpdump -ni "$NUEVA" -c 10 vrrp

# 2. Copia de seguridad y cambio
sudo cp -a /etc/keepalived/keepalived.conf /etc/keepalived/keepalived.conf.bak-$(date +%F)
sudo sed -i -E "s/^(\s*interface\s+)\S+/\1$NUEVA/" /etc/keepalived/keepalived.conf
grep -n interface /etc/keepalived/keepalived.conf   # REVISAR: una sola línea y con la interfaz nueva.
                                                    # El sed cambia TODAS las líneas "interface"; si hubiera
                                                    # más de un vrrp_instance, restaurar la copia y editar a mano.

# 3. Validar la sintaxis y aplicar.
#    keepalived -t devuelve código 5 por el aviso "Truncating auth_pass to 8 characters"
#    (la contraseña VRRP actual es más larga; se recorta igual en los 3 nodos). Ese aviso se ignora;
#    cualquier otro mensaje (p. ej. "Non-existent interface") detiene el procedimiento.
out=$(sudo keepalived -t -f /etc/keepalived/keepalived.conf 2>&1); echo "$out"
if echo "$out" | grep -v 'Truncating auth_pass' | grep -q .; then
  echo "REVISAR la configuración: NO se reinicia"
else
  sudo systemctl restart keepalived
fi

# 4. Comprobar: tiene que decir "Entering BACKUP STATE", nunca MASTER ni FAULT
journalctl -u keepalived -n 20 --no-pager | grep -i state
ip -br addr show dummy0     # NO debe aparecer 192.0.2.250 en este nodo

Después, desde master-1:

ip -br addr show dummy0          # aquí SÍ: 192.0.2.250/32
./scripts/check_cluster.sh       # keepalived: .112 MASTER, .139 y .129 BACKUP

Marcha atrás, si el nodo se pone en MASTER teniendo master-1 la VIP (split-brain):

sudo cp -a /etc/keepalived/keepalived.conf.bak-<fecha> /etc/keepalived/keepalived.conf
sudo systemctl restart keepalived

Prueba de relevo (opcional, en un momento tranquilo): en master-1, sudo systemctl stop keepalived. En unos 3 s la VIP tiene que pasar a master-2 (ip -br addr show dummy0 en master-2) y kubectl get nodes tiene que seguir respondiendo. Después, sudo systemctl start keepalived en master-1: por prioridad, recupera la VIP.

Recuperación del control-plane

Situación Acción
Cae 1 master Nada urgente: etcd sigue con quórum (2 de 3). Arreglar el nodo y arrancarlo.
Caen 2 masters Sin quórum: la API no responde. Arrancar al menos uno más. Si no es posible, k3s server --cluster-reset en el superviviente (vuelve a 1 miembro) y volver a unir los otros con db/ borrado.
Pérdida total de etcd k3s server --cluster-reset --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/<snapshot> en un master y volver a unir el resto.