
🌐 También en: English · Deutsch · Français
Cada lunes, un pequeño script me envía un informe de versiones: qué versiones fijadas están desfasadas, qué imágenes se han reconstruido con la misma etiqueta y qué paquetes esperan en los hosts. Esta vez la lista era larga, e incluía el salto de Nextcloud 34 a 35 en las dos instancias. Al final de la tarde todo estaba al día, ningún servicio roto y la comprobación de deriva mostraba changed=0.
Este artículo recorre las etapas en orden. Cada una trae su propia lección, y una de ellas fue un error en mi propia herramienta.
📬 El informe informa, no actúa
El script se ejecuta en un servidor de mi homelab y solo lee. Compara las versiones fijadas en versions.yml con Docker Hub y GitHub, comprueba si una imagen se ha reconstruido con la misma etiqueta, compara la versión de PHP de los pods con php.net y cuenta los paquetes pendientes por host. No cambia nada: las actualizaciones las aplico yo, de forma consciente, un host tras otro.
Esta vez el informe decía: Nextcloud 35.0.2 disponible, Trivy 0.76.1 disponible, la imagen de WordPress «reconstruida» y seis paquetes del kernel en los tres hosts.
Cómo funciona el informe: el script es un programa en Python que se ejecuta en HP3, un servidor de mi homelab que también recoge cada noche las copias de seguridad de los tres servidores, no en los propios servidores. Una entrada de cron lo arranca cada lunes a las 07:17, y envía el correo con su propia cuenta. En cinco pasos:
- Lee
versions.ymlde la copia local del repositorio, donde cada versión fijada está en un único sitio. - Consulta Docker Hub para cada imagen y GitHub para la última versión de cada binario, y lo compara con la versión fijada.
- Se conecta por SSH a cada servidor, lista con
crictl imageslas imágenes que hay de verdad y las compara con la compilación actual de la misma etiqueta. - Lee la versión de PHP dentro de los pods en ejecución y la compara con php.net.
- Cuenta los paquetes pendientes por host con
dnf check-update.
Todos los resultados llegan en un solo correo, junto con los comandos de las actualizaciones en el orden correcto. Ejecutarlos me toca a mí.
📦 Para reproducirlo: weekly-update-check.zip (script, envoltorio para cron y un breve README, 4 KB). Los hosts son ejemplos al principio de check.py; las versiones fijadas salen de versions.yml de mi repositorio público.
Lo que queda: un informe que solo informa puede permitirse ser exhaustivo. La decisión de qué se aplica y cuándo sigue siendo humana.
☁️ Nextcloud 34 → 35: primero comprobar, luego saltar
En Nextcloud, una actualización mayor nunca es automática. No se pueden saltar versiones, y las aplicaciones de la tienda necesitan una versión compatible con la nueva versión mayor. Antes del salto comprobé:
- ¿Existe la imagen?
nextcloud:35.0.2-fpmestaba disponible en Docker Hub. - ¿Encajan las aplicaciones? La API de la tienda lista las aplicaciones disponibles por versión de la plataforma. Las nueve aplicaciones de la tienda de cada instancia, entre ellas Collabora, Deck, notify_push y Groupfolders, tenían versión para la 35.
- ¿Se cumplen los requisitos? Nextcloud 35 exige al menos MariaDB 10.11 y deja de admitir PHP 8.2. La base de datos ya estaba en 10.11, y PHP viene dentro de la imagen del contenedor.
- ¿Hay vuelta atrás? Además de la copia nocturna, hice un volcado nuevo de la base de datos justo antes de actualizar.
Primero fijé la nueva versión solo para una instancia, en sus variables de host. La segunda instancia y el valor global siguieron en 34.0.5 hasta que la primera funcionó sin problemas. Solo entonces pasó 35.0.2 a versions.yml y se eliminaron las dos fijaciones por host.
Lo que queda: una actualización mayor no empieza con la actualización, sino con tres preguntas: ¿está todo disponible, encaja todo y puedo volver atrás?
⚙️ Qué pasa realmente durante la actualización
No es Ansible quien ejecuta la actualización, sino la propia imagen de Nextcloud. Al arrancar, su script de entrada compara la versión instalada con la de la imagen. Si la de la imagen es más nueva, copia el código nuevo y ejecuta occ upgrade, que migra la base de datos. Mientras tanto, Nextcloud está en modo mantenimiento.
El playbook se encarga de dos cosas:
- Recreate en lugar de rolling update: con una imagen nueva, el pod antiguo se detiene por completo antes de que arranque el nuevo. Si no, el código antiguo y el nuevo trabajarían a la vez en el mismo directorio.
- Una sonda de arranque paciente: PHP-FPM solo arranca después de la actualización. La startupProbe concede hasta diez minutos, para que Kubernetes no confunda una migración larga de la base de datos con un fallo.
Tardó 40 y 47 segundos. En el registro, sin embargo, había líneas que asustan al principio: «Disabled incompatible app: richdocuments», y lo mismo para cada aplicación de la tienda. Es lo esperado: en una actualización mayor, Nextcloud desactiva todas las aplicaciones cuya versión instalada no corresponde a la nueva versión mayor. La ejecución siguiente de nextcloud-update.yml descarga las versiones adecuadas, las vuelve a activar y reinicia el pod, para que OPcache deje de ejecutar bytecode antiguo.
Después, todas las aplicaciones estaban de vuelta, la mayoría en una versión nueva (Collabora 11 → 12, Groupfolders 22 → 23). Como novedad llegó la aplicación integrada sharing, para el uso compartido unificado de Nextcloud 35.
Lo que queda: una actualización mayor tiene dos pasos que van juntos: el núcleo y luego las aplicaciones. Quien se detiene tras el primero tiene un Nextcloud sin Office.
🔍 Qué significa «terminado» para mí
«La página carga» no me basta como prueba de aceptación. Después de cada instancia comprobé:
- el registro de Nextcloud: ningún error desde la actualización, solo tres avisos conocidos sobre aplicaciones desactivadas a propósito
- los registros del servidor web: ninguna respuesta 5xx
- la autoprueba de notify_push
- en el host: ningún rechazo de SELinux, MariaDB y Redis en sus propios dominios, ninguna unidad fallida y todos los objetivos de Prometheus accesibles
- y al final la comprobación de deriva: una ejecución del playbook en modo check que ya no debe cambiar nada
Las dos instancias dieron changed=0. Solo entonces considero terminada una actualización: el servidor es exactamente lo que describe el código.
Lo que queda: «funciona» es una sensación; changed=0 es un resultado.
🧪 La comprobación de deriva en detalle
La comprobación de deriva es un segundo script, completamente distinto: scripts/check-drift.sh en el repositorio público. No se ejecuta según un horario, sino a mano, en el equipo desde el que lanzo Ansible: siempre justo después de una ejecución real de un playbook y después de cada actualización.
La idea: Ansible describe cómo debe ser un servidor. Si se vuelve a ejecutar el mismo playbook en modo check, ya no debe quedar nada por hacer. El script hace exactamente eso:
- Llama a
ansible-playbook … --check --diffpara un host. En modo check, Ansible no cambia nada; solo informa de lo que haría. - Toma la salida en JSON y filtra las tareas que darían «changed», con su diff. En los objetos de Kubernetes oculta los campos de gestión del servidor, para que solo queden las diferencias reales.
- El código de salida da el resultado:
0= sin deriva,1= deriva,2= la propia ejecución de comprobación ha fallado.
Así se veía hoy después de la actualización:
$ scripts/check-drift.sh nextcloud-k3s.yml <host>
<host>: changed=0 failed=0
NO DRIFTY así se ve un hallazgo: un caso anterior en el que una tarea daba «changed» en cada ejecución aunque no había cambiado nada:
### would change: rkhunter | Build / refresh file-properties database
<host>: changed=1 failed=0
DRIFT: 1 task(s) would changeUn hallazgo justo después de una ejecución real significa siempre una de dos cosas: alguien ha cambiado algo a mano en el servidor, o una tarea del código no es idempotente. En ambos casos conviene saberlo antes de que la siguiente ejecución lo «corrija» sin avisar.
Lo que queda: la comprobación de deriva es la inspección final frente al plano. No demuestra que todo funcione, pero sí que el servidor coincide exactamente con el código.
⏳ Dos semanas de espera para Trivy
Trivy, mi escáner nocturno de imágenes, tenía una versión nueva. No la instalé. En marzo de 2026 se comprometió la cadena de publicación de Trivy, que distribuyó una versión manipulada. Desde entonces sigo una regla: Trivy solo en versiones publicadas hace al menos dos semanas, siempre con una suma SHA-256 fija tomada del fichero de sumas de verificación de la versión.
La versión 0.76.1 salió el 21 de octubre, así que está permitida a partir del 4 de noviembre. Comprobé la suma de antemano: el archivo descargado coincide con el fichero oficial. El commit espera un día más.
Lo que queda: en las herramientas de seguridad, «nuevo» no significa automáticamente «más seguro». Un plazo de espera cuesta poco, y en marzo habría ayudado.
🧩 La falsa alarma: índice nuevo, misma imagen
El informe marcó wordpress:7.1.4-php8.5-fpm como «reconstruida en origen». El playbook de actualización la descargó y no reinició ningún pod. El registro de containerd mostraba el motivo: solo se leyeron unos 21 KB y el identificador de la imagen siguió siendo el mismo.
La explicación: mi script comparaba el digest del índice. Una etiqueta en Docker Hub apunta a un índice que enumera las imágenes de todas las plataformas. Si cambia la imagen de otra arquitectura, o solo una atestación, el índice recibe un digest nuevo, pero la imagen amd64 que ejecuto sigue idéntica, byte a byte.
La corrección fue pequeña: el script ahora resuelve el índice, toma la entrada linux/amd64 y compara su digest de configuración con el identificador de la imagen en el host. Eso es exactamente lo que ejecuta el contenedor. Desde entonces: «none – every pinned tag is current».
Lo que queda: un digest nuevo no significa una imagen nueva. Hay que comparar lo que realmente se ejecuta.
🐧 Por qué Ansible nunca instala mis kernels
Quedaban los paquetes del kernel. Mi playbook de paquetes instala todas las actualizaciones, salvo el kernel, y es a propósito. El servicio SSH está endurecido con systemd, entre otras cosas con ProtectKernelModules=yes. Por eso cada sesión SSH, también la de Ansible, se ejecuta en su propio espacio de nombres de montaje, donde /usr/lib/modules aparece vacío y de solo lectura. Instalar un kernel por SSH falla ahí siempre.
Así que los kernels siguen otro camino:
- dnf-automatic instala de noche solo las actualizaciones de seguridad, como servicio de systemd, fuera de cualquier sesión SSH. El kernel nuevo era una de esas actualizaciones.
- A las 03:30 el host se reinicia si
needs-restarting -rlo pide. - A las 04:00 AIDE actualiza su base de referencia, para que el kernel nuevo no sea una alarma al día siguiente.
Esta vez no quise esperar a la noche. En lugar de instalar el paquete por SSH, arranqué a mano dnf-automatic-install.service en cada host: el mismo camino que de noche, solo que antes. Después, un reinicio, un host tras otro. Cada uno volvió en 40 a 50 segundos, con el kernel nuevo, sin rechazos de SELinux y con todos los pods en marcha.
Adelantarlo tuvo una consecuencia: la comprobación diaria de AIDE se ejecuta a medianoche, antes de la actualización de las 04:00. Sin hacer nada, habría notificado el kernel nuevo como un cambio. Por eso, después, cada host necesita la actualización de AIDE y rkhunter.
Lo que queda: el endurecimiento tiene efectos sobre la propia automatización. En lugar de debilitarlo, se le da a la actualización del kernel un camino propio y adecuado.
🎯 Las lecciones
- Los informes informan, las personas deciden: comprobar de forma automática, aplicar de forma consciente.
- Tres preguntas antes de una actualización mayor: ¿está todo, encaja todo y puedo volver atrás?
- Núcleo y aplicaciones son una sola actualización: Nextcloud solo está listo cuando las aplicaciones vuelven a funcionar.
- Comparar lo que realmente se ejecuta: no el índice, sino la imagen de la propia plataforma.
- Terminado significa
changed=0: la comprobación de deriva cierra cada día de actualizaciones.





