
🌐 También en: English · Deutsch · Français
Hoy he mudado un Nextcloud de producción desde una acogedora instalación nativa en bare metal a un reluciente stack de K3s/contenedores. Sobre el papel: sacar una copia de seguridad, restaurarla, cambiar el DNS, listo. En la práctica: una serie de pequeñas minas, cada una invisible hasta que la pisas. Aquí va el diario de batalla. 🪖
Paso 1: palear los datos 🚚
El trabajo pesado fue un rsync de ~360 GB de datos de usuarios más un volcado SQL de ~330 MB desde la máquina de copias de seguridad al nuevo nodo. Como ya había presincronizado antes ese mismo día, la última pasada solo tuvo que enviar el delta: al final, todo se redujo a una copia incremental rápida. Lección más vieja que el mundo: siembra primero, y la sincronización del cambio será minúscula.
Paso 2: la carrera de obstáculos de las versiones mayores 🎮
La copia de seguridad era Nextcloud 32.x. El stack de destino ejecuta 34.x. Nextcloud tiene una regla de hierro: no puedes saltarte una versión mayor. Así que toca ir a pie, paso a paso:
32.0.14 → 33.0.9 → 34.0.4 (occ upgrade at every step)El giro de los contenedores: el código vive en un volumen persistente, y Nextcloud se niega a ejecutar código más antiguo sobre una versión de datos más nueva («downgrade not supported»). Restaurar una base de datos v32 en un pod que aún tenía código v34 = rechazo inmediato. La solución es deliciosamente chapucera: fijar la imagen a la versión correspondiente, retocar el version.php del volumen de código para que el entrypoint vuelva a colocar el código correcto, y dejar que cada occ upgrade termine (su readiness probe bloquea hasta que la migración de la base de datos acaba; se requiere paciencia).
Las minas invisibles 💣
1. El socket antivirus maldito
Tras la restauración, la app Archivos soltaba un escueto «Internal Server Error» justo después de iniciar sesión, y el log seguía vacío, porque el fallo ocurría por debajo del propio logger de Nextcloud. Causa: la configuración restaurada había reactivado files_antivirus en modo socket, apuntando a un socket del demonio de ClamAV que existe en la vieja máquina nativa, pero no en el contenedor. Solución: desactivar la app (este stack hace escaneos por lotes cada noche). Diagnosticado solo a base de darse cuenta, no por ninguna línea de log. Traicionero.
2. La cárcel de Let’s Encrypt 🔒
El DNS aún no apuntaba a la nueva máquina, así que cert-manager llevaba días fallando al emitir certificados. Cuando por fin cambié el DNS, los certificados seguían sin emitirse. ¿Por qué? Tras suficientes fallos, cert-manager aparca el Certificate en un back-off exponencial guardado en el estado del objeto: próximo intento en ~24 horas. ¿Reiniciar el controlador? Ignorado. ¿Borrar el secret? Ignorado. Lo que de verdad funciona: borrar el objeto Certificate y dejar que el ingress-shim lo vuelva a crear desde cero, sin historial de fallos → emisión instantánea.
Trampa extra: la comprobación HTTP-01 de Let’s Encrypt prefiere IPv6. Si solo mueves el registro A y te olvidas del AAAA, la validación sigue llegando al host antiguo y fallando mientras todo «parece» cambiado. Mueve los dos. Siempre los dos.
El plato fuerte: la casilla que mentía ☑️
Esta fue la que más me hizo rascarme la cabeza. Collabora (Nextcloud Office) se negaba a abrir documentos: «No se ha podido cargar Nextcloud Office.» Mientras tanto, la prueba de conectividad de Office del panel de administración estaba tranquilizadoramente en verde: «Collabora Online server is reachable.» En el lado del servidor todo cuadraba: discovery 200, capabilities 200, certificados válidos, URL de WOPI correcta, cabeceras WebSocket presentes, idéntico a un host de referencia que funcionaba bien.
La diferencia resultó ser un único ajuste heredado del servidor antiguo: «Desactivar la verificación de certificados (inseguro)» estaba marcado (disable_certificate_verification=yes en richdocuments). En la máquina vieja tenía sentido: certificados autofirmados. Pero ahora, con certificados de Let’s Encrypt válidos, ese modo «inseguro» rompe la sesión de documento WOPI en richdocuments 11.x, dejando la prueba de conectividad de administración perfectamente en verde, porque esa prueba usa otra ruta de código distinta del verdadero handshake del documento.
¿Por qué creo que era el culpable? Porque la solución fue exactamente esa: desmarcar la casilla (borrar la clave), reintentar, y los documentos se abrieron al instante. Mi teoría: la ruta insegura sin verificación y el intercambio de tokens WOPI dejan de entenderse en cuanto entra en juego un TLS de verdad, y el fallo solo aflora en la sesión del editor: el clásico «el health check está en verde pero la funcionalidad está muerta» que mantiene humildes a los sysadmins. Otras dos claves heredadas de producción (public_wopi_url, doc_format) se fueron con ella, para igualar la configuración mínima del host de referencia.
La pista falsa: «¿por qué hay tantas apps desactivadas?» 🎣
Tras la actualización, un montón de apps aparecían como desactivadas (panel, comentarios, almacenamiento externo, …) y me preparé para un maratón de reconciliación. Luego comparé el conjunto actual de apps con el estado del servidor antiguo (extraído del volcado de la base de datos previo a la actualización). Giro de guion: también estaban desactivadas en la máquina vieja. La nueva instancia ya coincidía con producción. La única baja real fue metadata, que simplemente no tiene ninguna versión compatible con Nextcloud 34. A veces el diff que da miedo es solo… la verdad.
Nativo vs. contenedores: la parte honesta ⚖️
La nueva instancia se nota un pelín más lenta que la antigua nativa. La RAM y la CPU se aburren (margen de sobra), y los tiempos de respuesta del servidor están entre 50 y 210 ms: perfectamente sanos. La diferencia es de arquitectura: los contenedores añaden saltos (proxy del ingress, red overlay, PHP-FPM↔nginx por la red de pods, base de datos a través de la frontera pod/host). Por petición son milisegundos; sumados a lo largo de las muchas peticiones que lanza una página de Nextcloud, se convierten en «algo menos ágil». Es el impuesto que pagas por reproducibilidad, aislamiento y actualizaciones sin dolor, y para una organización pequeña es un trato que merece la pena.
La gran limpieza de la base de datos 🗃️
Restaurar una base de datos de un servidor antiguo arrastra toda su historia. El índice del filecache cargaba con las rutas de archivo del servidor antiguo bajo IDs de almacenamiento obsoletos, además de un paquete de OnlyOffice eliminado hacía mucho cuyas 276k filas de índice habían sobrevivido a la app. Resultado neto: una tabla oc_filecache con 1.053.373 filas, de las que ~93 % eran peso muerto, y una base de datos de 1 GB.
La limpieza, hecha con cuidado, con copia de seguridad y valores de control (los archivos reales de los usuarios y el número de usuarios no deben cambiar): eliminar las 7 tablas huérfanas de OnlyOffice, borrar los dos almacenamientos obsoletos (la antigua ruta nativa y un mapeo de contenedor anterior) tras comprobar que no contenían ni un solo archivo real —solo datos de apps regenerables— y luego OPTIMIZE para devolver al sistema de archivos las páginas liberadas. Resultado:
oc_filecache rows: 1,053,373 → 72,580
database size: 1 GB → 282 MB (-72%)
real user files: 56,183 → 56,183 (unchanged ✔)
users: 151 → 151 (unchanged ✔)Y en el disco: 27 GB de miniaturas de vista previa huérfanas, cuyo índice acababa de borrarse. Las vistas previas son una caché pura y regenerable: borrar el árbol huérfano recupera el espacio y Nextcloud reconstruye las miniaturas bajo demanda. (La lección de un ensayo anterior: nunca reindexes vistas previas huérfanas; eso solo vuelve a meter ~500k filas muertas en la tabla que acabas de limpiar. Bórralas.)
Gremlin extra: los gráficos de red vacíos 📉
node_exporter informaba alegremente a Grafana de CPU, RAM y disco, pero todos los paneles de Network Traffic seguían obstinadamente en blanco. La métrica node_network_receive_bytes_total simplemente no estaba. La pista estaba en node_scrape_collector_success{collector="netdev"} 0 y en esta línea de log:
netdev: "couldn't get netstats: socket: address family not supported by protocol"Las versiones modernas de node_exporter leen las estadísticas de las tarjetas de red a través de un socket netlink, y el bastionado de systemd del servicio tenía RestrictAddressFamilies=AF_INET AF_INET6, que prohíbe sin hacer ruido AF_NETLINK. El collector falla en silencio, la métrica nunca aparece y el gráfico sigue vacío. Añade AF_NETLINK a la lista de permitidos, reload, restart, y las curvas de tráfico cobran vida. (Déjà vu extra: exactamente la misma omisión de AF_NETLINK ya había mordido antes al bastionado de MariaDB en este proyecto. El sandboxing da seguridad y quita netlink.)
Ver pensar a la máquina 📊

Dos tareas de limpieza relacionadas remataron el trabajo. Primero borré los 27 GB de miniaturas de vista previa huérfanas (su índice ya había desaparecido con la limpieza de la base de datos de arriba): las vistas previas son una caché pura, así que ningún daño. Después, en lugar de hacer pagar a cada uno de los primeros probadores el impuesto de «generando esta miniatura, espera», lancé una regeneración completa con occ preview:generate-all. Ese trabajo ha estado triturando toda la noche, masticando ~35.000 imágenes, PDF y vídeos: el panel de arriba lo pilla in fraganti, y el escáner de abajo es esa misma ejecución reconstruyendo vistas previas archivo por archivo.

Lo que me lleva a una pequeña confesión: nunca había tenido un panel como este. Ver en tiempo real la CPU, la carga, la memoria y —ahora que el gremlin de netlink está arreglado— el caudal de red resulta extrañamente satisfactorio. Tengo verdadera curiosidad por ver cómo será en el día a día: ¿veré picos bonitos y limpios cuando alguien suba de golpe una sesión de fotos entera, o un equipo se descargue una galería completa? Aún no lo sé, y esa es la mitad de la gracia. Pregúntame otra vez dentro de una semana, cuando haya mirado estos gráficos el tiempo suficiente como para tener opiniones sobre percentiles que ni sabía que tenía. 😄
Epílogo: probes que de verdad reinician un pod atascado 🩺
Un día después, volví para arreglar algo que me tenía inquieto. Los pods tenían readiness probes —Kubernetes sabía cuándo no enviar tráfico—, pero su comprobación de liveness era el desdentado kill -0 1, que solo se entera cuando el PID 1 ya ha muerto (y para entonces el contenedor ya no existe de todos modos). Un proceso que sigue en marcha pero atascado —FPM bloqueado, sin aceptar ya nada en su socket— se quedaría ahí, roto, hasta que un humano se diera cuenta. En un pod de una sola réplica, ese es precisamente el caso que quieres que se gestione automáticamente.
La solución es una configuración de tres probes como es debido en cada contenedor: una startupProbe que retiene liveness y readiness hasta que la app escucha de verdad (hasta ~10 minutos de margen, para que un occ upgrade lento o una ráfaga de vistas previas al arrancar nunca provoquen un bucle de reinicios), una readinessProbe que regula el tráfico y una livenessProbe ajustada —una conexión tcpSocket real, no una comprobación de PID— que solo reinicia el contenedor cuando de verdad deja de responder. La puerta de arranque es la pieza que sostiene todo: sin ella, una liveness probe lo bastante estricta como para detectar un atasco también ejecutaría a un contenedor que simplemente arranca despacio. El blog funciona sobre el mismo stack, así que recibió exactamente el mismo tratamiento, por coherencia.
Epílogo: afinando sobre el hardware real 🎛️
Cuando se asentó el polvo, empezó la parte divertida: observar los nuevos paneles de Grafana y afinar según lo que mostraban de verdad, no según lo que yo había supuesto. Resultó que la máquina tenía mucho más margen del que creía —8 núcleos, 23 GB de RAM, ~16 GB libres en reposo—, así que dejé de ser tacaño. Collabora pasó de 4 a 6 procesos hijo preforkeados, y sus límites de 3 a 6 GB y de 2 a 4 núcleos, lo que suaviza el hipo ocasional del arranque en frío la primera vez que alguien abre un documento tras un rato de inactividad. De paso llegaron algunos retoques más: sistemas de archivos raíz de solo lectura en los sidecars, un nivel de log más sensato (fatal-only oculta justo los avisos que quieres ver) y enseñarle a node_exporter a dejar de informar de tres docenas de interfaces virtuales de contenedores, para que el gráfico de red muestre la única tarjeta de red que importa. Todo vive en el mismo repo de Ansible: reproducible, versionado y aburrido en el mejor sentido posible.
Ponerse el sombrero negro 🕵️
Antes de darlo por terminado, hice lo que debería hacer todo admin paranoico: atacar mi propia instancia desde fuera. Nada de escaneos de puertos —aparte de los puertos web y un puerto SSH solo con clave, el cortafuegos descarta todo en silencio, así que hay poco que encontrar—, solo las peticiones que un oportunista real lanza contra un Nextcloud recién montado, buscando un punto débil. Versión corta: no lo había.
Todas las rutas sensibles devolvieron un 403 seco: config/, data/, .htaccess, .git/, 3rdparty/, todo el lote. WebDAV y remote.php exigen autenticación (401) antes de decir nada. La cabecera Server es un lacónico nginx sin versión, sin X-Powered-By, sin huella de PHP que cotejar con una lista de CVE. El TLS es 1.3 con un certificado válido de Let’s Encrypt; 1.0 y 1.1 se rechazan de plano. El conjunto completo de cabeceras de seguridad está en su sitio: HSTS con preload, una CSP basada en nonce, nosniff, políticas de frame y referrer, una permissions policy bien cerrada y cookies __Host-. La propia comprobación de integridad del código de Nextcloud informa de cero archivos modificados.
La parte que más me importa: la limitación contra fuerza bruta está activa y —como la cadena de proxies inversos reenvía correctamente la IP real del cliente— frena al atacante, no a mi propio ingress. Ese es el fallo que neutraliza sin hacer ruido el rate limiting en muchas instalaciones detrás de un proxy, así que merece la pena comprobarlo en lugar de suponerlo. Nada de esto es exótico; son sobre todo los valores por defecto aburridos y correctos, más unos cuantos toques deliberados. Y esa es la gracia: lo más desalentador que puede encontrar un atacante es un muro sin juntas. 🧱
Día tres: atando cabos sueltos 🧹
El maratón de vistas previas de arriba terminó por fin al amanecer del tercer día, tras bastante más de un día masticando toda la biblioteca. Como ya nada escribía en el índice de archivos, pudo ejecutarse la limpieza pendiente, y una buena comprobación de extremo a extremo después sacó a la luz dos gremlins que se escondían a plena vista.
Las tablas «vacías» que no lo estaban
Mis propias notas decían que las tablas sobrantes de apps desaparecidas hacía tiempo (Talk, Polls, Collectives, Maps) estaban vacías y listas para eliminar. Antes de eliminar nada, conté las filas de todos modos. No estaban vacías: un par de docenas de páginas de wiki, algunas encuestas con votos, varias docenas de salas de chat. Ahora mismo nadie puede verlas, porque las apps no están instaladas, pero si reinstalas una app, recupera sus tablas antiguas al momento. Así que se quedan. Lo que sí se fue: los últimos restos de OnlyOffice en disco (archivados primero, luego borrados), más un OPTIMIZE completo que redujo la fragmentación de 59 MB a 2 MB en 17 segundos. Lección: fíate del recuento de filas, no de tu propio resumen de ayer.
Push en vez de poll: notify_push 📡
Los clientes de sincronización de Nextcloud suelen preguntar al servidor cada ~30 segundos: «¿hay algo nuevo?». La app notify_push (Client Push) le da la vuelta: un pequeño demonio en Rust mantiene abierto un WebSocket y el servidor anuncia los cambios en el momento en que ocurren. (No tiene nada que ver con las notificaciones del móvil; esas pasan por el proxy push de Nextcloud y funcionaron todo el tiempo.) En una máquina nativa son tres piezas: la app, un servicio de systemd y un location /push/ en el servidor web. En K3s son las mismas tres piezas con otra ropa: la app, su propio Deployment y una ruta de Ingress.
La parte de Ansible la había montado el día anterior, desactivada. Al revisarla antes de darle al interruptor encontré cuatro bugs que habrían producido el peor resultado posible —una ejecución del playbook en verde y una funcionalidad muerta—, porque cada paso estaba configurado para tolerar fallos. occ se ejecutaba como root (Nextcloud se niega). El binario se elegía con un glob, y alfabéticamente aarch64 va antes que x86_64. El contenedor se ejecutaba como root con todas las capabilities eliminadas, lo que suena seguro hasta que te das cuenta de que root sin CAP_DAC_OVERRIDE no puede leer un archivo 0640 propiedad de www-data, como config.php. Y el ingress pasaba /push/… sin cambios, mientras que el demonio espera /ws en su raíz.
Incluso con los cuatro arreglados, la primera autoprueba devolvió un 404, renderizado por Nextcloud. La ruta existía; nginx simplemente nunca la elegía. Las rutas de Nextcloud son una regex comodín, location ~ "^/", y en nginx una regex le gana a un prefijo simple. Así que la ruta de push también tuvo que convertirse en regex, y aun así seguía perdiendo, porque el controlador de ingress emite las locations regex en orden alfabético del nombre del Ingress, y nginx se queda con la primera coincidencia. notify-push-routes va detrás de nextcloud-routes; renombrada a nextcloud-push-routes («p» antes que «r»), gana. No es el arreglo del que más orgulloso estoy, pero está documentado. Las seis autopruebas se pusieron en verde, y la primera sesión del navegador pasó a WebSocket (HTTP 101) y recibió sus primeros eventos de archivos. Veredicto sincero: para un puñado de clientes de escritorio es un detalle agradable —los cambios llegan en segundos en vez de hasta medio minuto—, no algo que cambie las reglas del juego.
Gremlin n.º 1: el bastionado que tumbó la base de datos
Mientras se ejecutaba un playbook, Nextcloud respondió brevemente con 500. MariaDB se había detenido, limpiamente, sin caída. El journal contaba la historia: dos segundos antes, el rol de bastionado del sistema había eliminado el paquete rsync por estar «sin uso en este host». En el host del blog era cierto, allí se había comprobado. En el host de Nextcloud, el paquete MariaDB-server requiere rsync: sus herramientas de clúster Galera incluyen un script de transferencia de estado basado en rsync, incluso en un único nodo que nunca se unirá a un clúster. Así que dnf eliminó obedientemente MariaDB junto con él, lo que detiene el servicio; un rol posterior reinstaló ambos y otro volvió a arrancar la base de datos. Efecto neto: de dos a tres minutos de caída en cada ejecución del playbook, invisibles salvo que miraras justo en el momento adecuado. La solución es una sola condición: eliminar rsync solo si rpm -e --test rsync dice que nada depende de él. «Comprobado en el host A» no es «cierto en el host B».
Gremlin n.º 2: las alertas que nunca podían dispararse
Las reglas de alerta de Grafana —disco, memoria, CPU, nodo caído— parecían perfectamente correctas en la interfaz. En el log, todas y cada una fallaban en cada evaluación con condition must not be empty: el código de aprovisionamiento nunca le dijo a Grafana qué consulta decide la alerta. Y como las reglas trataban los errores de evaluación como «OK», nunca se ponía nada en rojo. Un campo que faltaba, "condition": "B", y ahora se evalúan correctamente. Una alerta que no puede dispararse se ve exactamente igual que un sistema sano: el tipo de verde más peligroso que existe. (Las alertas básicas por correo vía Monit estuvieron activas todo el tiempo, así que la máquina no volaba completamente a ciegas.)
Gremlin n.º 3: el bloqueo por escaneo de puertos que no existía
Una versión anterior de este mismo artículo afirmaba que un escaneo de puertos «te gana un castigo instantáneo de fail2ban». No era así. fail2ban solo reacciona a líneas de log, y el cortafuegos registra los paquetes descartados a un ritmo deliberadamente minúsculo, así que un escaneo simplemente desaparecía en la política de descarte por defecto. Inofensivo, pero tampoco se bloqueaba a nadie. El cortafuegos incluso tenía un par de sets etiquetados como «port-scanner blocklist», y nada relacionado con escaneos los llenó jamás. La solución vive ahora en el kernel: la última regla antes del descarte final cuenta los intentos de conexión por origen, y solo ve paquetes dirigidos a puertos cerrados, así que el tráfico normal nunca cuenta. Un origen que sigue llamando a la puerta queda descartado en todos los puertos durante un rato. Sin logs, sin demonio, sin parseo.
Al desplegarlo apareció una trampa más. El servicio nftables estándar recarga con flush ruleset, y en el host de Nextcloud kube-proxy, flannel y Calico también guardan sus reglas en nftables. Así que cada cambio en el cortafuegos se llevaba por delante también la red propia de Kubernetes, durante hasta minuto y medio, hasta que se resincronizaba. Ahora el conjunto de reglas solo reemplaza su propia tabla, en un único paso atómico.
Días cuatro a seis: leer los logs como un detective 🔎
Cuando se asentó el polvo, el trabajo pasó de «hacer que funcione» a «no quitarle el ojo de encima». Durante un par de días la rutina fue sencilla: leer nextcloud.log, los logs de los contenedores y el log del ingress, y ante cada línea que pareciera rara, preguntar por qué hasta tener una respuesta. Una condición previa lo hizo posible: el nivel de log se había subido de 4 (solo fatal) a 2 (avisos). Con el nivel 4, nada de lo que sigue habría aparecido. Un log silencioso no siempre es un servidor sano. A veces es solo un servidor silenciado.
Gremlin n.º 4: la jail que vigilaba un archivo muerto
fail2ban estaba en marcha, las jails de nginx estaban «activas» y la lista de bloqueos estaba vacía. Durante días. ¿Semana tranquila o jail rota? El controlador de ingress escribe su log de acceso en un archivo cuyo nombre termina en un contador (…/0.log, 1.log, …), y cada reinicio del contenedor empieza uno nuevo. fail2ban expande el glob de logpath solo cuando arranca la jail. Tras un reinicio, fail2ban arranca antes que el contenedor del ingress, se engancha al archivo antiguo y luego sigue fielmente un log en el que ya no escribe nadie, en ambos hosts. La solución es un pequeño temporizador de systemd que detecta cuándo cambia el conjunto de archivos de log del ingress (y una vez tras cada arranque) y recarga solo las jails de nginx. Una herramienta de seguridad que informa de que «todo está tranquilo» porque mira el archivo equivocado es peor que no tener ninguna, porque crees que estás protegido.
Gremlin n.º 5: el ping-pong de propietarios
En cada ejecución del playbook, el status.php de Nextcloud daba error durante un momento y el directorio de datos no pasaba su propia comprobación de escritura, para luego curarse solo más adelante en la misma ejecución. Los culpables eran dos roles que no se ponían de acuerdo. El rol de SELinux creaba los directorios hostPath como root:root, y el rol de despliegue los cambiaba a 33:33 (www-data). Cada ejecución hacía bascular al propietario de un lado a otro, y hasta que el siguiente rol lo devolvía a su sitio, Nextcloud se encontraba con un directorio de datos en el que no tenía permiso para escribir. Ahora ambos roles fijan el mismo propietario y modo, y el ping-pong se acabó. Dos roles que hacen cada uno algo «correcto» pueden sumar igualmente un bug.
Gremlin n.º 6: las vistas previas de Office y el muro de 1 MB
Los archivos pequeños de Word y Excel tenían bonitas miniaturas en la lista de archivos; los más grandes, no. Ningún error en la interfaz, solo el icono genérico. El log del ingress tenía la respuesta: 413 Request Entity Too Large en cada llamada convert-to a Collabora. Nextcloud genera las vistas previas de Office subiendo el documento a Collabora, y el límite por defecto de nginx para el cuerpo de la petición es de 1 MB. La ruta de Nextcloud tenía un límite generoso, pero la de Collabora nunca recibió uno. Una anotación (client-max-body-size: 2g) lo arregló, y las vistas previas afectadas se pueden regenerar sin más. Los pocos errores convert-to que quedan son documentos que Collabora de verdad no puede abrir, lo cual es un problema del archivo y no de la configuración.
Gremlin n.º 7: la foto de 108 megapíxeles
Algunas fotos se quedaban sin miniatura, y la app recibía una y otra vez un 404 para sus vistas previas. Todas venían del mismo móvil, con 108 megapíxeles y 12.032 píxeles de ancho. Decodificar una imagen de ese tamaño requiere unos 430 MB de RAM, y el preview_max_memory de Nextcloud está por defecto en 256 MB. Por encima de eso, Nextcloud ni lo intenta. Se salta la vista previa en silencio. Lo subí a 512 (el límite propio de PHP está muy por encima) y las fotos por fin tuvieron sus miniaturas.
Ya puestos, las fotos HEIC del iPhone también recibieron vistas previas. Es una línea en enabledPreviewProviders, con trampa: definir esa clave reemplaza la lista por defecto integrada en lugar de ampliarla, así que hay que repetir los valores por defecto (PNG, JPEG, GIF, texto, OpenDocument…), o acabarás con vistas previas HEIC y ninguna de JPEG. Lo que queda ahora sin vista previa son datos, no configuración: unas cuantas docenas de restos de 400 bytes de una subida fallida de hace años, algunos archivos vacíos y un puñado de PNG de más de 50 MB.
Gremlin n.º 8: la configuración que nunca llegó
Al verificar el arreglo anterior tras una ejecución del playbook apareció algo peor. Los nuevos valores estaban en el ConfigMap de Kubernetes, pero el custom.config.php dentro del pod en ejecución tenía un día. El archivo está montado con subPath, y un montaje subPath se copia una vez al arrancar el contenedor y nunca se actualiza. Kubernetes actualiza en caliente los volúmenes ConfigMap normales, pero no los de subPath. Así que cada cambio de configuración en el repo solo surtía efecto en el siguiente reinicio accidental del pod. (Mientras tanto, el arreglo de las vistas previas solo funcionó porque también se había aplicado vía occ.)
La solución estándar es una anotación checksum/config en la plantilla del pod: un SHA-256 de las plantillas de configuración renderizadas. Cambia la configuración y cambia el hash, lo que hace que Kubernetes vuelva a desplegar el pod. Si no tocas la configuración, no pasa nada. El blog recibió el mismo tratamiento para su configuración de nginx y PHP. Lección: «el playbook ha salido en verde» y «el ajuste está activo» son dos afirmaciones distintas, así que comprueba el archivo dentro del contenedor.
Así es un log sano 🩺
Después de todo esto, la revisión diaria se ha vuelto aburrida, y ese es el objetivo. Un día típico tiene ahora un puñado de líneas en nextcloud.log: una vista previa que no se pudo abrir, un notice de PHP. El resto es ruido que conviene saberse de memoria, para que no te mande de cacería cada mañana:
- Collabora «Broken pipe» / «Connection reset», unas 60 por hora, incluso a las 3 de la madrugada: el otro extremo cerró la conexión antes de que Collabora terminara de escribir. Inofensivo.
- nginx «access forbidden» en
/data/.ncdatay/.env: lo primero es la propia comprobación de seguridad de Nextcloud, que verifica que el directorio de datos no sea accesible desde la web; lo segundo es un bot. Ambos bloqueados a propósito. - «config differs from the latest version of this image» en cada arranque de pod: el rol gestiona esos archivos por sí mismo (Redis con contraseña, por ejemplo), así que se supone que deben ser distintos.
- notify_push «Self test failed» justo después de un reinicio: la comprobación TLS falla una vez con un error de certificado (lo más probable es que el ingress siga sirviendo su certificado por defecto unos segundos antes de que se cargue el real). Un minuto después, la autoprueba está 6/6 en verde.
La gracia de esa lista no son las entradas en sí. Una vez que sabes cómo es lo inofensivo, la única línea que no lo es salta a la vista al instante. Ese es todo el truco de la monitorización de logs. No es magia, es conocer la línea base.
Veredicto ✅
Está en producción. Certificados válidos, la edición de Office funciona, datos intactos y los usuarios ni se han enterado. La migración en sí fue el 20 % fácil; el otro 80 % fue una gincana en busca de ajustes y comportamientos que solo fallan exactamente en las nuevas condiciones. Como siempre: el servidor nunca fue el problema; lo eran las suposiciones. 😄
El día tres añadió una nota al pie. Los bugs más retorcidos no fueron los que se caían, sino los que seguían en verde: una casilla, un playbook tolerante a fallos, una regla de alerta que no podía dispararse. Si esta migración me ha enseñado un hábito, es comprobar la cosa en sí, no lo que informa sobre ella. 🔍
Una cosa más: este no fue mi único rodeo con Nextcloud. También llevo como voluntario una segunda instancia de producción —más pequeña, con menos datos y menos usuarios registrados—, y todo lo que aprendí aquí resultó ser realmente útil. Así que es la siguiente: la migraré al mismo stack de K3s en las próximas dos semanas, con la esperanza de pisar muchas menos de estas minas la segunda vez. 🤞
El stack completo de Ansible que hay detrás de todo esto —los roles de WordPress y Nextcloud, el ingress, cert-manager, la monitorización y cada truco de bastionado mencionado arriba— está en GitHub, sin secretos ni detalles específicos de los hosts: github.com/aptupgrademe/www_k3s. Esta migración es el hito v3.0.0, así que puedes explorar exactamente el estado descrito en este artículo en lugar de lo que HEAD sea hoy por casualidad.




