
🌐 También en: English · Deutsch · Français
Tengo una migración por delante a la que llevo un tiempo dándole vueltas. Una instancia de Nextcloud que administro sigue funcionando como una instalación nativa de toda la vida: paquetes, servidor web, PHP-FPM, todo gestionado a mano en una sola máquina. Se muda al stack de K3s que he ido construyendo en Ansible.
La razón honesta de la mudanza es que la instalación nativa ha envejecido. Sigue funcionando, pero ha acumulado una década de pequeñas decisiones que nadie apuntó, y cada actualización es un acontecimiento en lugar de una rutina. Lo que espero conseguir con K3s es un tipo de actualización más aburrida: la versión de Nextcloud y la de cada contenedor fijadas en el código de Ansible, de modo que una actualización se convierta en una etiqueta cambiada en un archivo que puedo revisar, desplegar dos veces exactamente igual y revertir si sale mal. La aplicación y los datos acaban en sitios realmente separados en lugar de entremezclados en un único árbol de directorios. Y Collabora pasa a ser un contenedor propio, que habla con Nextcloud a través de una interfaz definida en vez de estar cableado en la misma instalación: una cosa menos que se rompe porque dos aplicaciones comparten el mismo entorno de ejecución.
Nada de esto es una suposición, y esa es la principal razón por la que estoy dispuesto a hacerlo. Este blog lleva ya un tiempo funcionando sobre el mismo stack de K3s, y actualizarlo ha resultado ser exactamente tan aburrido como esperaba: cambiar una etiqueta de contenedor, volver a ejecutar el playbook, listo. Como el playbook es idempotente, volver a ejecutarlo no tiene ningún misterio: es la misma operación tanto si el servidor está recién instalado como a medio configurar o ya correcto, y eso elimina la mayoría de los motivos para dudar antes de lanzarlo. Todo lo demás que este blog ha ido acumulando por el camino —el bastionado, la monitorización, el ingress y la gestión de certificados, el ajuste de rendimiento, las pequeñas correcciones que me costaron una tarde cada una— es código que simplemente puedo apuntar hacia el lado de Nextcloud. Eso es, en el fondo, esta migración: coger lo que me enseñó el proyecto en el que había poco en juego e invertirlo en el que de verdad importa.
Antes de hacerle esto a una instancia con usuarios reales, lo ejecuté todo de principio a fin en un servidor desechable: restaurar los datos, restaurar la base de datos, recorrer el camino de versiones, romper cosas, averiguar por qué. Treinta y seis archivos y mil cien líneas de cambios después, el código de Ansible es notablemente mejor que cuando empecé.
Todo está en GitHub: playbooks, roles, bastionado, verificación. La parte de Nextcloud en particular ha recibido mucha atención últimamente.
Pero de lo que realmente quiero escribir no es de las correcciones. Es de la forma de los problemas.
🎭 Todo falló haciéndose pasar por otra cosa
Ni uno solo de estos problemas se anunció. Todos y cada uno llegaron disfrazados de otro problema más plausible, normalmente uno que me habría mandado a buscar en un sitio completamente equivocado.
La instalación de un paquete falló con un error de permisos que no tenía nada que ver con los permisos. chmod: Operation not permitted, como root, con todas las capabilities, en un sistema de archivos montado con normalidad. Comprobé todo lo que señalaba el mensaje, y todo estaba bien. La causa real era una directiva de bastionado de systemd en el demonio SSH —RestrictSUIDSGID=yes— cuyo filtro seccomp heredan todos los procesos que descienden de sshd. Es decir, cada sesión SSH y cada comando que Ansible ejecuta a través de una.
Esa es la lección que merece la pena guardar: el bastionado aplicado a sshd no es bastionado a nivel de servicio. sshd es una fábrica de procesos para sesiones interactivas, así que todo lo que restrinjas ahí lo restringes para cada persona y cada herramienta que entre por esa puerta. Durante un rato llegué a sospechar en serio que el proveedor de hosting había bloqueado algo.
Una ejecución del playbook se «colgó» en el paso del cortafuegos. Ningún error, ningún timeout, solo silencio durante minutos. El rol cambia SSH a un puerto no estándar y activa nftables, sobre la misma conexión que está reconfigurando. El nuevo conjunto de reglas acepta las conexiones establecidas, lo que suena completo, salvo que cuando nftables arranca por primera vez, conntrack no tiene registro de una conexión anterior a él. Así que la sesión no coincidía con ninguna regla y sus paquetes simplemente se descartaban. No se rechazaban: se descartaban. Para Ansible, eso es idéntico a una tarea que tarda mucho.
Un sitio era totalmente inaccesible, y el navegador no decía nada útil. Este controlador de ingress responde a un nombre de host cuyo certificado TLS no está listo rechazando el handshake sin más: ningún certificado, ni siquiera uno autofirmado. Desde fuera, eso es indistinguible de que el servidor esté caído. La causa real estaba tres capas más allá: una lista de imágenes permitidas bloqueaba el pod de challenge de cert-manager, así que el certificado no podía emitirse nunca.
Y mi favorito: una protección contra fuerza bruta que no protegía nada. Las jails de fail2ban que vigilan los inicios de sesión web leen el log del ingress a través de una ruta que contiene el ID del pod. fail2ban resuelve esa ruta una sola vez, al arrancar. Cada vez que se recrea el pod del ingress —una actualización de imagen, un helm upgrade, un reinicio—, las jails siguen funcionando contra una ruta que ya no existe:
Status for the jail: nginx-k3s-nc-login
|- Filter
| |- Currently failed: 0
| `- File list:Una lista de archivos vacía. Activado, en marcha y estructuralmente incapaz de bloquear a nadie. La comprobación de monitorización que existía solo preguntaba si el proceso de fail2ban estaba vivo, y vaya si lo estaba.
🩹 Lo que salió de todo esto
La mayoría de las correcciones no tienen nada de especial una vez que sabes cuál era el problema, que es lo que suele pasar:
- El cambio de SSH y cortafuegos se hace ahora con un único reinicio en un servidor nuevo, de modo que ambos arrancan juntos desde un arranque limpio en lugar de modificarse bajo una conexión activa.
- Cualquier nombre de host que aún no tenga un certificado real recibe un sustituto autofirmado de corta duración, así que una advertencia del navegador sustituye a un socket muerto. Una advertencia te dice que el servidor está vivo y que el problema es el certificado; un socket muerto no te dice nada.
- Dos nuevas comprobaciones de monitorización: una para certificados a punto de caducar y otra que verifica que las jails de fail2ban vigilan de verdad un archivo en lugar de limitarse a estar en marcha.
- Y la mitad aburrida: un remitente de correo montado a partir de la mitad equivocada de una dirección (lo que tumbaba Grafana por completo al arrancar), un rol de verificación que abortaba todo el play con un error del parser justo cuando algo estaba roto, y cabeceras de seguridad definidas dos veces con dos valores distintos.
⬆️ Algo que conviene saber antes de planificar una ventana de mantenimiento
Nextcloud se niega a saltarse versiones mayores. Llevar una instancia restaurada de la 32 a la 34 significa parar en la 33 entre medias, con un paso de actualización en cada parada.
Dos detalles en los que la documentación no insiste: el contenedor ejecuta su propia actualización al arrancar y no acepta tráfico hasta que termina, así que lanzar la actualización a mano solo compite con la que ya está en curso: cambia la etiqueta de la imagen y espera. Y revisa la lista de apps después de cada versión mayor, no una sola vez al final. Algunas apps se desactivan temporalmente y vuelven; otras son realmente incompatibles y se quedan desactivadas. Una de las mías se elimina por completo en lugar de actualizarse, porque está instalada mediante descarga directa y no a través de la tienda de apps.
🧱 Lo que decidí no cambiar
Durante un tiempo me tentó hacer la actualización de AlmaLinux 9 a 10 en la misma ventana. Servidor nuevo, borrón y cuenta nueva, ya puestos, ¿por qué no empezar directamente con la versión mayor actual?
Me convencí a mí mismo de no hacerlo, y creo que el razonamiento es generalizable. Esta migración ya cambia muchas cosas a la vez: el modelo de despliegue (de bare metal a K3s), la versión de Nextcloud, la forma de ejecutar Collabora y la máquina que está debajo de todo eso. Añadir un salto de versión mayor del sistema operativo significa añadir una segunda variable totalmente independiente. Si dos semanas después algo se comporta de forma rara —una anomalía de rendimiento, un problema de permisos sutil, algo en SELinux—, no tendría forma de saber cuál de los dos cambios lo ha provocado. Depurar funciona manteniendo cosas constantes, y una migración es precisamente el momento en el que te quedan menos constantes.
Además, no hay ningún reloj que apriete. AlmaLinux 9 tiene soporte hasta 2032. No es una fecha límite en torno a la que tenga que planificar nada.
Pero la razón más interesante es la que solo se hizo visible al construir este stack. En un entorno de contenedores, el sistema operativo del host pesa mucho menos que antes. Todo lo que de verdad determina cómo se comporta el servicio —la versión de Nextcloud, PHP, el servidor web, Collabora— vive en imágenes de contenedor cuyas versiones fijo en el código de Ansible. El host aporta un kernel, systemd, K3s y el bastionado que los rodea. Las decisiones interesantes han subido una capa, y lo que queda debajo se ha vuelto relativamente intercambiable.
Lo cual es una conclusión un poco curiosa: una de las ventajas de esta migración es que hace que la siguiente migración importe menos. La actualización del sistema operativo pasa a ser un ejercicio aparte y aburrido que puedo hacer a mi manera, una tarde tranquila, sin nada más en marcha, que es exactamente lo que debería ser una actualización del sistema operativo.
🔒 Lo único que el ensayo no pudo resolver
Hay un problema que no pude sortear en absoluto en el servidor de pruebas: conseguir certificados válidos de Let’s Encrypt.
La razón es más estructural que técnica. El challenge HTTP-01 funciona así: Let’s Encrypt resuelve el dominio y descarga un token por HTTP desde la dirección a la que apunte el registro A público. Y esa sigue siendo la del servidor antiguo, y lo será hasta el momento en que cambie el DNS. Simulé el DNS en local con una entrada en /etc/hosts que apuntaba a la máquina nueva, lo que basta para convencer a mi propio navegador pero no sirve absolutamente de nada para Let’s Encrypt, que hace su propia consulta contra el DNS público. Así que el servidor nuevo no puede tener un certificado válido para un nombre de host del que todavía no es oficialmente responsable.
Hay una solución como es debido: el challenge DNS-01 demuestra el control del dominio publicando un registro TXT en _acme-challenge.<domain> en lugar de servir un archivo. Le da igual adónde apunte el registro A, así que los certificados podrían emitirse por adelantado y estar listos el primer día. Necesita un proveedor de DNS con una API que el cliente ACME pueda manejar, y montar eso queda fuera de lo que quiero abordar en esta migración.
Así que acepto el hueco: durante unos minutos después del cambio de DNS, el servidor nuevo estará accesible sin un certificado válido todavía. Esta es exactamente la situación para la que se creó el certificado sustituto del que hablaba antes: en lugar de un handshake rechazado que parece una caída, los visitantes ven una advertencia del navegador, que al menos dice el servidor está ahí y el certificado aún no está listo. No es elegante. Pero un hueco conocido, acotado y visible es mejor que uno invisible, que es más o menos el tema de todo lo anterior.
📅 Lo que viene ahora
La migración real será en los próximos días.
El plan para el día en sí es deliberadamente anodino:
- Uno o dos días antes: bajar el TTL del DNS, para que luego el cambio se propague en minutos en lugar de horas.
- Servidor antiguo en modo mantenimiento. A partir de aquí nada cambia bajo mis pies, y ningún cliente puede escribir en la instancia que estoy a punto de copiar.
- Última copia de seguridad del servidor antiguo —datos y base de datos— antes de tocar nada.
- Última sincronización con el servidor nuevo, para cerrar el hueco abierto desde la copia del ensayo.
- Cambiar el DNS: A y AAAA. Olvidarse del registro IPv6 es una forma excelente de dejar a la mitad de tus usuarios en la máquina vieja mientras desde tu propio portátil todo parece perfecto.
- Esperar a los certificados, que solo pueden emitirse ahora (ver más arriba).
- Probar: inicio de sesión, sincronización de escritorio en ambos sentidos, recursos compartidos intactos, Collabora abriendo un documento, correo saliente.
- Dejar el servidor antiguo exactamente donde está, en modo mantenimiento, como vía de vuelta atrás.
Ese orden importa más de lo que parece. Como el servidor antiguo pasa a modo mantenimiento en lugar de apagarse o borrarse, sigue ahí completo e intacto: datos, base de datos, configuración, todo exactamente como estaba en el momento en que lo detuve. Si el nuevo stack resulta ser un desastre por algún motivo que no he previsto, la vuelta atrás consiste en apuntar de nuevo los registros DNS a la máquina antigua y quitar el modo mantenimiento. Sin restauración, sin pérdida de datos, nada que volver a montar, porque nunca se desmontó nada. El servidor antiguo no se da de baja el día de la migración; es mi vía de vuelta atrás, y lo seguirá siendo hasta que el nuevo haya demostrado lo que vale durante un tiempo.
Lo único que de verdad no puedo predecir es la propagación del DNS. El TTL indica a los resolvers cuánto tiempo pueden guardar en caché un registro, y bajarlo uno o dos días antes acorta bastante la cola, pero es un límite superior, no una promesa. Muchos resolvers lo redondean al alza, algunos lo ignoran y unos cuantos clientes se aferran a una dirección hasta que algo se reinicia. Así que en la práctica hay una ventana en la que algunos usuarios llegan al servidor nuevo y otros siguen aterrizando en el antiguo. Eso es incómodo precisamente para un servicio de sincronización de archivos, porque las escrituras podrían acabar en cualquiera de los dos lados. Es la razón principal por la que la instancia antigua se queda en modo mantenimiento en lugar de simplemente seguir funcionando: un usuario que todavía no ha cambiado ve una página de mantenimiento, lo cual es molesto pero inofensivo, en lugar de subir con éxito un archivo a un servidor que ya no mira nadie.
Me gustaría decir que gracias al ensayo todo irá como la seda, y espero que así sea. Pero, siendo sincero, un ensayo en un servidor desechable con datos copiados no es lo mismo que la instancia en producción con usuarios reales, recursos compartidos reales y clientes de sincronización reales que tienen su propia opinión sobre lo que ha cambiado mientras no miraban. Algo pasará. Siempre pasa algo.
Así que contaré aquí cómo ha ido después: qué salió mal de verdad, qué no supo predecir el ensayo y qué tuve que cambiar. Y sea lo que sea, irá directamente de vuelta al código de Ansible en el repositorio, porque un playbook que solo funciona en el servidor en el que lo probaste no está terminado.
Si el próximo artículo es corto y aburrido, será buena señal.
El patrón de fondo
Mirando atrás, hay una cosa que lo conecta todo.
Ninguno de estos problemas era un error. Eran ausencias: una jail sin archivo, un certificado que nunca se emitió, una conexión que dejó de responder en lugar de rechazar, un filtro que bloqueaba una llamada al sistema que a nadie se le ocurrió buscar. Cada uno de ellos producía una salida indistinguible de que todo iba bien.
Eso es lo que te da de verdad un ensayo. No la seguridad de que funcionará, eso no se puede tener. Solo una oportunidad barata de toparte con los problemas silenciosos antes de que cuesten algo.




