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_jobdegitea.db; docker events,/etc/resolv.conf,nmcli,tcpdump,/proc/diskstatsy 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 pullfallido 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-manageryvaulwardenno existen con esos nombres: el proxy se llamanginxproxymanager. 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:
-
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. -
Host (
/etc/hostsdel NAS):192.168.68.61 gitea.cruzcloud.net. Losdocker pushde 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/refs200 en 44 ms. tcpdumpdurante undocker pulldel 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"ynmcli device reapply bond0, sin bajar la interfaz. Respaldo en/root/bond0.nmconnection.bak-2026-10-17del host. - Router (Deco → Servidor DHCP): recomendado DNS primario
1.1.1.1, secundario8.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-rootde Docker; /DATA/AppData(symlink asdb): 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
/tmpen 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. Necesitaexec, porque un tmpfs sinexecno 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¶
- 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. - Un nombre de contenedor no garantiza qué es. El contenedor
ZimaOSno 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. - "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.
- Un archivo de configuración presente no significa que esté en uso.
El runner tuvo
config.yamldurante meses sin leerlo nunca.