Saltar a contenido

Incidente: Gitea inestable — reinicios nocturnos, CI por el túnel y DNS muerto (2026-10-17/18)

Síntoma reportado "En varias ocasiones Gitea presenta problemas de DNS y rendimiento": 502 de Gitea, pipelines fallidos por timeouts (Trivy, Cosign) y git pull rechazado
Servicios afectados Gitea (web, API, registry), el pipeline de Gitea Actions y, de rebote, la carga del NAS (Immich, Plex)
Causas Cuatro independientes que se sumaban (A–D, abajo). La principal es A
Estado Resuelto y verificado (2026-10-18)

Cómo se investigó

Todo en solo lectura primero, sobre:

  • los logs de 594 jobs de Gitea Actions y la tabla action_run_job de gitea.db;
  • docker events, /etc/resolv.conf, nmcli, tcpdump, /proc/diskstats y conntrack.

Cada cambio se aplicó recién después, con respaldo y verificación.


Causa A — Un cron reiniciaba Gitea cada minuto, todas las noches

Hallazgo. La app de BigBear "Docker Cron Restart Notifier" corre en un contenedor llamado, de forma engañosa, ZimaOS (imagen deduard/tools:restart-notifier-latest). Tenía:

CRON_SCHEDULE=* 3 * * *
TZ=UTC
RESTART_CONTAINERS=immich-postgres,immich-redis,immich-server,duckdns,plex,metube,gitea,nginx-proxy-manager,memos,vaulwarden

* 3 * * * no significa "a las 3". Significa cada minuto durante la hora 03 UTC, o sea de 22:00 a 22:59 en Colombia, todas las noches. En cada corrida apagaba y volvía a prender Gitea, Immich (postgres, redis y server), Plex, MeTube y Memos. Su log tenía 2.254 corridas desde el 1 de agosto, unas 29 por noche.

Impacto:

  • Gitea caído o recién arrancado durante una hora cada noche: 502 en la web y la API, git pull fallido y pipelines cortados a la mitad.
  • Jobs fallidos que caen justo en esa franja: 362 (03:51 UTC), 375 (03:45) y 598 (03:11, el 502 de la autoprueba del portal SCM).
  • Reiniciar Immich y Plex cada minuto sobre el disco mecánico (causa D) generaba picos de carga. Mientras se investigaba, la tienda respondió con timeouts intermitentes en esa misma franja.
  • duckdns, nginx-proxy-manager y vaulwarden no existen con esos nombres: el proxy se llama nginxproxymanager. Esas entradas nunca hicieron nada.

Fix (aplicado por el usuario en la UI de ZimaOS):

CRON_SCHEDULE=0 8 * * *          # una vez, 08:00 UTC = 03:00 Colombia
RESTART_CONTAINERS=immich-postgres,immich-redis,immich-server,plex,metube,memos

Gitea salió de la lista: reiniciarlo corta cualquier pipeline en curso y no aporta nada.

Efecto secundario al recrear la app: Gitea quedó detenido

El notifier viejo murió entre su stop gitea y su start gitea. Gitea quedó Exited (128), y la política unless-stopped no levanta un contenedor detenido a propósito. Se recuperó con docker start gitea. Si se vuelve a editar esa app, revisá docker ps -a después.

Verificación: el contenedor quedó con la configuración nueva. Gitea se monitoreó 5 minutos dentro de la misma hora 03 UTC, sin reinicios ni errores HTTP.


Causa B — El CI llegaba a Gitea por internet (túnel de Cloudflare)

Hallazgo. Desde los contenedores de los jobs, gitea.cruzcloud.net resolvía a las IPs públicas de Cloudflare. Todo el tráfico del CI hacía NAS → internet → túnel → NAS: actions/checkout, docker push, Cosign, Trivy y las llamadas a la API.

Ruta Latencia por pedido Subida
Túnel de Cloudflare (antes) 0,3–2,4 s ~3,7 MB/s
LAN vía Nginx Proxy Manager (después) 44–66 ms sin cuello de botella

Encaja con los errores de Cosign context deadline exceeded al pedir https://gitea.cruzcloud.net/v2/ (jobs 425 y 502).

La ruta local ya existía. Nginx Proxy Manager (:443 en 192.168.68.61) tiene un certificado Let's Encrypt válido para gitea.cruzcloud.net. Así que no cambian las URLs, los nombres de imagen ni los certificados.

Fix:

  1. Runner (/DATA/AppData/k3d-lab/gitea-runner/data/config.yaml):

    container:
      options: "--add-host=gitea.cruzcloud.net:192.168.68.61 -v /DATA/ci-cache/trivy:/root/.cache/trivy"
    

    Además, el contenedor del runner se recreó con --add-host gitea.cruzcloud.net:192.168.68.61, para que su propio polling y el envío de logs también vayan por la LAN.

  2. Host (/etc/hosts del NAS): 192.168.68.61 gitea.cruzcloud.net. Los docker push de los workflows los ejecuta el daemon de Docker del host, a través del socket, no el contenedor del job.

Gotcha: el runner ignoraba su config.yaml

El runner corría act_runner daemon sin --config, porque no tenía la variable CONFIG_FILE. Todo lo que había en config.yaml (etiquetas, network: "bridge") nunca se aplicó.

Al activarlo se dejaron a propósito:

  • labels: [], para seguir usando las etiquetas de .runner, que son las mismas de siempre;
  • network: "", una red por job, que es el comportamiento real que tuvo siempre.

Así activar el archivo no cambió nada más. El contenedor ahora se crea con -e CONFIG_FILE=/data/config.yaml.

Verificación:

  • Un contenedor con las mismas opciones que un job obtuvo HTTPS 401 en 66 ms (TLS OK) y git info/refs 200 en 44 ms.
  • tcpdump durante un docker pull del host: 10 conexiones a NPM (172.17.0.7:443, tras el DNAT) y 0 a Cloudflare.
  • El runner declaró las mismas etiquetas que antes (ubuntu-latest, ubuntu-24.04, ubuntu-22.04).

Rollback (si hiciera falta):

# (gitea-runner-old se borró el 2026-10-18 tras la prueba real exitosa.)
# Volver al config previo: restaurar config.yaml.bak-2026-10-17 y docker restart gitea-runner.
# config: config.yaml.bak-2026-10-17 / .bak-2026-10-18 ; host: /etc/hosts.bak-2026-10-17

Causa C — El DNS primario del NAS (y de toda la red) estaba muerto

Hallazgo. El DHCP del router (Deco) entregaba como DNS primario 192.168.68.61, que es el propio NAS, y 1.1.1.1 como secundario. En el NAS no hay ningún servidor DNS: el puerto 53 rechaza toda consulta (connection refused). Todos los equipos de la red intentaban primero un DNS inexistente y recién después usaban el de respaldo.

Hoy el rechazo es rápido y no se nota. Si algún día esas consultas quedan sin respuesta en vez de rechazadas, cada resolución tardaría ~5 s.

Fix:

  • NAS: nmcli con mod bond0 ipv4.ignore-auto-dns yes ipv4.dns "1.1.1.1 8.8.8.8" y nmcli device reapply bond0, sin bajar la interfaz. Respaldo en /root/bond0.nmconnection.bak-2026-10-17 del host.
  • Router (Deco → Servidor DHCP): recomendado DNS primario 1.1.1.1, secundario 8.8.8.8. Lo cambia el usuario desde la app del Deco, porque desde el NAS no se puede verificar.

Verificación: /etc/resolv.conf del NAS quedó solo con 1.1.1.1 / 8.8.8.8, y los contenedores nuevos los heredan. Los contenedores que ya existían toman el cambio cuando se recrean, y mientras tanto no hay impacto.

Descartado: el DNS interno de Docker

En 594 logs de jobs hubo una sola falla de DNS: el job 657, al bajar la base de Trivy el 2026-10-17. El DNS interno de Docker respondió 120 de 120 consultas en reposo, y la tabla conntrack estaba al 0,5 % sin descartes. El "problema de DNS" percibido era, en realidad, la causa A.


Causa D — Todo sobre un disco mecánico

En sdb, un HDD de 372 GB con btrfs y 71 % de uso, viven:

  • el data-root de Docker;
  • /DATA/AppData (symlink a sdb): Gitea con su sqlite, el runner y k3d.

Sin ningún job corriendo, el disco ya estaba ocupado ~24 % del tiempo. Eso explica los timeouts de Trivy durante el análisis de capas: 4 de los 14 jobs fallidos desde septiembre (362, 367, 389, 527).

Mitigación aplicada: cache persistente de Trivy en el SSD (/DATA/ci-cache/trivy, ext4 en sdc), montado en cada job por el runner. Guarda la base de vulnerabilidades y el cache de análisis de capas.

Escaneo de prueba Tiempo
En frío (primera vez) 139 s
Con cache 9 s (sin volver a bajar la base)

Mantenimiento del cache de Trivy

El cache ocupa ~1,5 GB y crece con el análisis de capas. Si hace falta, se limpia con trivy clean --scan-cache. Es seguro mientras el runner tenga capacity: 1. Si se sube la capacidad, dos jobs usarían la misma base a la vez y se bloquearían.

Pendiente (cambio grande, a planificar aparte): mover Docker, Gitea y el runner al SSD, que tiene ~89 GB libres. Requiere respaldo y una ventana de mantenimiento.



Segunda ronda — la prueba real del pipeline falló dos veces (2026-10-18)

El primer pipeline después de los cambios (commit c1bd5eb) falló dos veces antes de pasar. Las dos fallas dejaron dos lecciones.

Falla 1 — Al recrear el runner se perdió su cache de actions

git clone https://github.com/actions/upload-artifact terminó con connection reset by peer contra GitHub. Hasta entonces el runner tenía las actions (checkout, upload-artifact, …) en /root/.cache/act, dentro de la capa del contenedor. Al recrearlo, ese cache se perdió y cada job tuvo que volver a clonarlas desde GitHub, justo con el host cargado.

Fix: /DATA/ci-cache/act en el SSD, montado en el contenedor del runner (-v /DATA/ci-cache/act:/root/.cache/act). Se precargó con el cache del runner viejo: 7 actions, 455 MB. Recrear el runner ya no vuelve a depender de GitHub.

Falla 2 — "Instalar Semgrep": pip reportó ResolutionImpossible

No era un conflicto de dependencias: el mismo semgrep==1.173.0 instaló bien el día anterior. Pip devuelve ese error cuando no logra leer los índices de pypi. El log mostraba Read timed out (read timeout=15) contra pypi.org.

Mediciones (verificado):

Prueba Resultado
Ancho de banda a internet 488 Mbit/s
Pérdida de paquetes (router y 1.1.1.1) 0 %
DNS ~20 ms
MTU del camino 1480, con PMTUD funcionando; una red con MTU 1400 no cambió nada
CDNs (pypi/Fastly, npm, GitHub) Cortes intermitentes de ~15 s después del handshake TLS. Viene del router o del ISP, no del NAS
Instalar Semgrep sobre la capa del contenedor (HDD) 155 s
Lo mismo con /tmp en RAM 29 s

El cuello de botella era el disco mecánico: instalar un venv son miles de archivos chicos. Los cortes de los CDNs eran el disparador.

Fix (opciones del runner, sin tocar los workflows):

runner:
  envs:
    PIP_DEFAULT_TIMEOUT: "60"   # antes 15 s
    PIP_RETRIES: "10"           # antes 5
container:
  options: >-
    --add-host=gitea.cruzcloud.net:192.168.68.61
    -v /DATA/ci-cache/trivy:/root/.cache/trivy
    -v /DATA/ci-cache/pip:/root/.cache/pip
    --tmpfs /tmp:rw,exec,size=3g
  • /tmp en RAM: ahí instalan los workflows Trivy, el venv de Semgrep, Cosign, Syft y Gitleaks, más evidencias chicas: ~1–1,5 GB por job. El tmpfs solo ocupa lo que se usa y tiene un tope de 3 GB, sobre 16 GB de RAM. Necesita exec, porque un tmpfs sin exec no deja ejecutar los binarios (Permission denied).
  • Cache de pip en el SSD, precargado con Semgrep.

Resultado

Ejecución de "Construir y publicar Docs Portal" Duración
2026-09-26 a 2026-10-17 (5 corridas exitosas) 389–825 s
2026-10-18, con todos los cambios 183 s

En el log de esa corrida: Tmpfs:map[/tmp:rw,exec,size=3g], 0 descargas de la base de Trivy y 0 reintentos de pip.

Resumen de lo persistente del CI (SSD, /DATA/ci-cache/)

Directorio Montado en Contenido
act/ contenedor del runner actions clonadas (checkout, upload-artifact, …)
trivy/ cada job base de vulnerabilidades + cache de análisis de capas
pip/ cada job paquetes de pip (Semgrep y sus dependencias)

El config.yaml del runner no está versionado

Vive solo en /DATA/AppData/k3d-lab/gitea-runner/data/config.yaml, con respaldos .bak-2026-10-17, .bak-2026-10-18, -18b y -18c. El comando para recrear el contenedor está en este playbook (causa B, más el -v del cache de actions).

Limpieza menor

Se borraron 6 redes huérfanas de jobs viejos (GITEA-ACTIONS-TASK-*-network), solo las que no tenían contenedores conectados.

Lecciones

  1. Leer las expresiones cron campo por campo. * 3 * * * es "cada minuto de la hora 3", no "a las 3". Y conviene fijarse en qué zona horaria (TZ) se evalúan.
  2. Un nombre de contenedor no garantiza qué es. El contenedor ZimaOS no era el sistema operativo, sino una app de terceros con acceso al socket de Docker. Para saber quién puede reiniciar qué, listá los contenedores que montan /var/run/docker.sock.
  3. "Es el DNS" se verifica, no se asume. El síntoma parecía de red, pero la evidencia (1 falla de DNS en 594 jobs) apuntaba a otro lado.
  4. Un archivo de configuración presente no significa que esté en uso. El runner tuvo config.yaml durante meses sin leerlo nunca.