Incidente: cadena de 4 fallos en tmux-host (2026-09-25/26)¶
| Ventana del incidente | Noche del 2026-09-25 al 26 (hora local); en health.log, de 2026-09-26 04:11Z a ~06:20Z |
| Servicio afectado | Contenedor tmux-host: las sesiones lab y cloud-practice de Claude Code y su Remote Control desde claude.ai (ver Sesión tmux persistente) |
| Impacto | Las sesiones remotas no aparecían en claude.ai. Mientras se intentaba arreglar, se perdió el login y la confianza del folder, el supervisor del healthcheck quedó muerto y claude no podía conectarse a Anthropic |
| Disparador | Se eliminó el contenedor tmux-host a mano creyendo que era la causa |
Contexto / disparador¶
El síntoma inicial era uno solo: "no toma las sesiones remotas". Las
sesiones de Claude Code dentro de tmux-host no aparecían en claude.ai.
Parecía un problema del contenedor, así que se eliminó tmux-host para
recrearlo desde cero.
En realidad ese síntoma tenía una causa que no tenía nada que ver con el contenedor: el token OAuth estaba vencido (causa d, más abajo). Al recrear el contenedor, la mitad de la noche se fue en resolver tres fallos reales que aparecieron en el camino (causas a, b y c). Cada uno se encontró recién después de resolver el anterior. Recién al final apareció la causa original.
Fuentes de la evidencia
Este playbook se escribió desde dentro del tmux-host ya recuperado.
Los datos marcados (verificado) se confirmaron contra el estado
real del sistema al escribirlo: docker inspect, docker images,
health.log, sysctl, curl, git diff y los panes de tmux. Los
marcados (reportado) vienen del relato de la sesión de
recuperación. Esa sesión se hizo desde fuera del contenedor y no dejó
transcript ni historial de shell que se pueda revisar.
Causa a — Contenedor recreado sin los volúmenes de autenticación¶
Síntoma. Al recrear tmux-host, claude volvía a pedir login y el
diálogo de confianza del folder. --continue no encontraba ninguna
conversación previa y arrancaba una en blanco.
Diagnóstico. El docker run que se usó para recrear el contenedor
se armó de memoria. Tenía solo 3 de los 5 volúmenes documentados en
Sesión tmux persistente
(reportado). Faltaban justamente los dos de autenticación y estado
de Claude Code:
| Volumen faltante | Qué se perdía |
|---|---|
/DATA/AppData/k3d-lab-v3/claude-code-home → /root/.claude |
Credenciales OAuth, historial de conversaciones (por eso --continue no encontraba nada), plugins y memoria |
/DATA/AppData/k3d-lab-v3/claude-code-home.json → /root/.claude.json |
Confianza del folder y el flag de Remote Control |
Sin esos mounts, /root/.claude* vive en el filesystem efímero del
contenedor, así que cada recreación arrancaba "de fábrica". El contenedor
se reconstruyó varias veces con el mismo comando incompleto antes de
comparar contra el doc de arquitectura (reportado). health.log
muestra las sesiones recreándose desde cero a las 04:11Z, 04:40Z, 04:49Z,
05:03Z, 05:18Z, 06:08Z y 06:10Z (verificado).
Fix. Recrear el contenedor con el docker run completo de la
sección Contenedor: tmux-host del doc de arquitectura. Los 5 volúmenes
van juntos, sin excepciones.
Validación en vivo (verificado). docker inspect tmux-host sobre el
contenedor final (creado 2026-09-26T06:10:55Z, imagen
tmux-host:2026-09-26-fix-real) lista los 5 mounts:
/var/run/docker.sock -> /var/run/docker.sock
/DATA/AppData/k3d-lab-v3/claude-code-home -> /root/.claude
/DATA/AppData/k3d-lab-v3/claude-code-home.json -> /root/.claude.json
/DATA -> /DATA
/media/HDD-Storage -> /media/HDD-Storage
La sesión lab retomó la conversación anterior con --continue, sin
pedir de nuevo la confianza del folder.
Lección
Antes de improvisar un docker run de memoria, leer siempre el doc
de arquitectura que ya existe. El comando completo y el motivo de
cada volumen ya estaban documentados en sesion-tmux-persistente.md.
El tiempo perdido reconstruyendo con un comando incompleto salió de no
abrirlo primero.
Causa b — set -e heredado mataba al supervisor del healthcheck¶
Síntoma. healthcheck.sh corre en background y no tenía quién lo
relanzara si moría. Para cubrir eso se agregó a entrypoint.sh un
supervisor: un loop que lo relanza tras 10 s. Aun así, cuando
healthcheck.sh terminaba con error, no se relanzaba y el contenedor
seguía vivo sin healthcheck.
Diagnóstico. entrypoint.sh tenía set -eu desde antes. El
supervisor corre como función en un subshell (supervise_healthcheck &)
y hereda el -e. Cuando healthcheck.sh devolvía un código distinto de
cero, set -e mataba al propio subshell del supervisor en esa misma
línea, antes de llegar al echo WARN y al sleep 10 que lo relanzaban.
Es decir, el supervisor moría justo en el único caso para el que existía.
Paso en falso: el fix que nunca llegó al disco. La primera
"corrección" (set -eu → set -u) nunca se guardó en el archivo, y la
imagen se reconstruyó dos veces con el contenido viejo (reportado).
La evidencia sigue en el host (verificado): las etiquetas
tmux-host:2026-09-25-supervised y tmux-host:2026-09-25-supervised-v2
apuntan a la misma imagen, ad7f6eff9537. Docker reutilizó todas las
capas porque el archivo no había cambiado. Recién después de confirmar
con cat entrypoint.sh que el cambio no estaba, se guardó de verdad y se
construyó tmux-host:2026-09-26-fix-real (7e4c94bf0bc8, 05:03Z).
Fix. En scripts/tmux-host/entrypoint.sh:
-set -eu
+set -u
-/usr/local/bin/healthcheck.sh &
+supervise_healthcheck() {
+ while true; do
+ /usr/local/bin/healthcheck.sh
+ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) WARN: healthcheck.sh salio inesperadamente (exit=$?) -- relanzando en 10s" \
+ >> /DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log 2>/dev/null || true
+ sleep 10
+ done
+}
+supervise_healthcheck &
exec tail -f /dev/null
Validación en vivo (verificado). health.log registra el supervisor
actuando en un caso real. healthcheck.sh murió por SIGKILL, se registró
el aviso y se relanzó solo 10 segundos después:
2026-09-26T05:04:17Z WARN: healthcheck.sh salio inesperadamente (exit=137) -- relanzando en 10s
[2026-09-26T05:04:27Z] === healthcheck inicio (intervalo=1800s) ===
Lección
Verificar el archivo en disco (cat, git diff) ANTES de
reconstruir la imagen. No hay que asumir que el editor guardó el
cambio. Dos etiquetas de imagen con el mismo ID indican que el build
no tuvo cambios.
Causa c — IPv6 roto en el NAS: timeout de conexión a Anthropic¶
Síntoma. Con el contenedor ya bien montado, claude fallaba al
arrancar con Unable to connect to Anthropic services a los ~10 s.
Diagnóstico con curl -v https://api.anthropic.com/ (reportado):
- El resolver devolvía primero la dirección IPv6 de
api.anthropic.com. - La ruta IPv6 del NAS no sale a internet: el intento fallaba al
instante con
Network unreachable. - El fallback automático a IPv4 terminaba funcionando, pero la conexión completa tardaba ~14 s. Eso es más que el timeout interno de Claude Code (~10 s), así que el CLI se rendía antes de que el fallback llegara a conectar.
Primer intento: parche parcial.
NODE_OPTIONS=--dns-result-order=ipv4first le indica a Node que prefiera
IPv4 al resolver. Ayudó, pero no resultó confiable en todos los casos
(reportado), y además solo corregía el proceso de Node, no la red del
host. Se descartó y no quedó en la imagen: el contenedor final no tiene
NODE_OPTIONS en su entorno (verificado).
Fix de raíz. Deshabilitar IPv6 a nivel de sistema operativo del NAS, en caliente y persistente:
# En caliente
sysctl -w net.ipv6.conf.all.disable_ipv6=1
sysctl -w net.ipv6.conf.default.disable_ipv6=1
# Persistente: /etc/sysctl.d/99-disable-ipv6.conf del host
# (NO /etc/sysctl.conf -- ver "Validación tras reinicio real" abajo)
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
Esto se tiene que hacer en el host, no dentro del contenedor.
tmux-host corre con --network host, así que comparte el network
namespace del NAS. Docker no permite fijar sysctls de red
(--sysctl net.*) en un contenedor con --network host, porque
cambiarían la red de todo el host.
Validación en vivo (verificado, 2026-09-26 ~06:40Z):
$ sysctl net.ipv6.conf.all.disable_ipv6 net.ipv6.conf.default.disable_ipv6
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
$ getent ahosts api.anthropic.com
160.79.104.10 STREAM api.anthropic.com # solo IPv4, sin AAAA
$ curl -w 'connect=%{time_connect}s total=%{time_total}s' https://api.anthropic.com/
ip=160.79.104.10 connect=0.035s total=0.65s # antes: ~14 s
/etc/sysctl.conf en el host contiene las dos líneas (escrito
2026-09-26 05:55:53Z). En ZimaOS, / es un squashfs de solo lectura,
pero /etc es un overlay con capa de escritura en
/mnt/overlay/upper_etc (ext4 en /dev/sdc7), así que el archivo
persiste.
Validación tras reinicio real: /etc/sysctl.conf no alcanzaba (2026-09-26)¶
Se reinició el NAS a propósito (~17:13Z) para probar la persistencia. Resultado (verificado):
/etc/sysctl.confsí sobrevivió al reinicio con las dos líneas (el overlay de/etcpersiste).- Pero
sysctl net.ipv6.conf.all.disable_ipv6volvió a dar0: el archivo existía y nunca se aplicó al arrancar.
Causa. En ZimaOS, systemd-sysctl solo carga /etc/sysctl.d/*.conf
(y /usr/lib/sysctl.d/, /lib/sysctl.d/). Otras distros traen un
symlink /etc/sysctl.d/99-sysctl.conf -> ../sysctl.conf que hace que
/etc/sysctl.conf también se lea al arrancar. ZimaOS no lo trae, así
que escribir en /etc/sysctl.conf funciona solo en caliente (con
sysctl -p) y no sobrevive un reinicio. Nada avisa del problema: el
archivo está, el contenido es correcto y el valor igual vuelve a 0.
Por qué no se rompió enseguida. Tras el reinicio el host no tenía
ninguna IPv6 global utilizable, así que getaddrinfo no devolvía la AAAA
de api.anthropic.com y todo salía por IPv4. Pero curl -6 seguía
fallando (Could not connect to server). Si el router vuelve a entregar
una IPv6 global por SLAAC, vuelve el timeout. Funcionaba por casualidad,
no porque estuviera corregido.
Fix. Mover la configuración a un drop-in que ZimaOS sí lee:
/etc/sysctl.d/99-disable-ipv6.conf (mismas dos líneas).
Para aplicarlo en caliente sin reiniciar: dentro de tmux-host,
/proc/sys es de solo lectura (sysctl -w → Read-only file system).
Hace falta un contenedor privilegiado de un solo uso en la red del host,
o sudo sysctl -p directo en el host:
docker run --rm --privileged --network host \
-v /etc/sysctl.d:/etc/sysctl.d:ro \
alpine sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
Validado en vivo (verificado, 2026-09-26 ~19:15Z):
disable_ipv6 = 1 en all y default, ninguna dirección IPv6 global en
el host y curl a api.anthropic.com por IPv4 (connect=0.035s).
Lección
"El archivo de configuración persistió" no es lo mismo que "la configuración se aplica al arrancar". La única validación real de algo persistente es reiniciar y medir el valor efectivo, no revisar que el archivo siga ahí.
Confirmado con un segundo reinicio real (2026-09-26 ~19:37Z)
Tras reiniciar el NAS de nuevo, sin correr ningún sysctl -p a mano:
disable_ipv6 = 1 en all y default, y curl a
api.anthropic.com por IPv4 (connect=0.035s). El drop-in de
/etc/sysctl.d/ sí se aplica al arrancar. El único IPv6 que queda
es la ULA de virbr0 (red interna de libvirt, NO-CARRIER,
tentative), que no sale a internet y no afecta.
Sigue sin verificarse si una actualización de ZimaOS pisa
upper_etc. Después de cada update del SO, revisar
sysctl net.ipv6.conf.all.disable_ipv6.
Causa d — Token OAuth vencido: Remote Control desconectado sin recuperación automática¶
Síntoma. Con todo lo anterior resuelto, las sesiones remotas seguían sin aparecer en claude.ai. Este era el síntoma original de la noche.
Diagnóstico (verificado). Ambos procesos claude --remote-control
--continue estaban corriendo (ps), y el healthcheck había recreado
las dos sesiones sin problema (health.log, 06:10:58Z). Así que el
auto-arranque funcionaba. El contenido de los panes de tmux mostró la
causa:
- Sesión
lab:Login expired · Please run /login - Sesión
cloud-practice:Remote Control disconnected — OAuth token unavailable — run /login to restore Remote Control
El token OAuth de Claude Code había vencido. No había ningún problema de red, de contenedor ni de volúmenes. Esta causa no tenía relación con ninguna de las tres anteriores. Eliminar el contenedor nunca podría haberla resuelto.
Fix.
/loginen una sesión. Las credenciales viven en/root/.claude/.credentials.json, que es el volumen compartido por todas las sesiones del contenedor./remote-control→ 1. Enable Remote Control en cada sesión ya abierta. Un procesoclaudeque ya estaba corriendo no se reconecta solo al renovarse el token. Hay que reactivarlo en cada uno. Para una sesión a la que no estás adjunto:
tmux send-keys -t cloud-practice '/remote-control' Enter
# aparece el menú con "1. Enable Remote Control" ya seleccionado
tmux send-keys -t cloud-practice Enter
Validación en vivo (verificado). El pane de cloud-practice pasó a
mostrar /remote-control is active · Continue here, on your phone, or at
https://claude.ai/code/session_…, y en lab se activó con
/remote-control después del /login. Las dos sesiones volvieron a
aparecer en claude.ai.
Diferencia clave con las otras causas
El healthcheck sí recrea sola una sesión tmux caída, y --continue
retoma la conversación. Un token vencido no se recupera solo: hace
falta que una persona se reautentique (/login) y reactive Remote
Control (/remote-control) en cada sesión abierta. Recrear el
contenedor no lo arregla. Con los volúmenes bien montados, el
contenedor nuevo lee el mismo token vencido.
Cronología resumida¶
Horas en UTC, según health.log, docker images y los timestamps de
archivos cuando existen.
| Hora (UTC) | Qué pasó | ¿Resolvió el síntoma original? |
|---|---|---|
| antes de 04:11 | Remote Control se desconecta porque vence el token OAuth (causa d, todavía sin identificar) | — |
| ~04:10 | Se elimina tmux-host creyendo que es la causa |
No |
| 04:11 → 05:18 | Varias recreaciones con un docker run de memoria, sin los mounts de auth: se pide login y confianza de nuevo y --continue arranca vacío (causa a) |
No, y además empeoró |
| 04:39 | Se agrega el supervisor del healthcheck; builds supervised y supervised-v2, misma imagen ad7f6eff9537 (el fix de set -e no estaba guardado) |
No |
| 05:03 | Se confirma con cat, se guarda de verdad y se construye fix-real (7e4c94bf0bc8) con set -u (causa b) |
No |
| 05:04 | El supervisor relanza healthcheck.sh tras un exit=137: primera prueba real |
No |
| hasta ~05:55 | claude falla con Unable to connect to Anthropic services; con curl -v se ve IPv6 unreachable y ~14 s de fallback; se prueba NODE_OPTIONS (parcial) |
No |
| 05:55 | Se deshabilita IPv6 en el host, en caliente y en /etc/sysctl.conf (causa c) |
No |
| 06:10 | Contenedor final: fix-real + los 5 volúmenes, recreado según el doc de arquitectura (causa a cerrada) |
No: Remote Control sigue desconectado |
| ~06:20 | Los panes muestran Login expired / OAuth token unavailable → /login + /remote-control en cada sesión (causa d) |
Sí |
Camino corto para la próxima vez. Si "no aparecen las sesiones remotas", antes de tocar el contenedor mirar el pane de cada sesión:
for s in $(tmux ls -F '#S'); do echo "== $s"; tmux capture-pane -p -t "$s" | grep -v '^\s*$' | tail -5; done
Si aparece Login expired u OAuth token unavailable, la solución es
/login + /remote-control. No hace falta nada más.
Lección general¶
Un solo síntoma ("no toma las sesiones remotas") puede tener varias causas independientes superpuestas. En este caso, tres de ellas las provocó el propio intento de arreglo. Que la primera causa real encontrada se haya resuelto no significa que era la causa del síntoma.
Lo que destrabó el incidente fue seguir verificando con evidencia
concreta después de cada fix, en vez de asumir que "ya quedó": el
contenido de los panes, health.log, docker inspect, docker images
(IDs repetidos) y curl -v. Cada una de las cuatro causas se confirmó
con un dato observable, no por deducción.
Pendientes¶
Todos cerrados el 2026-09-26:
- Hecho: commitear el fix de
entrypoint.sh. Mergeado amaindel reposcripts(510cc41). Antes se verificó queentrypoint.shyhealthcheck.shdel repo fueran idénticos (md5) a los de la imagenfix-realen producción. - Hecho: validar IPv6 tras un reinicio real. El primer reinicio
falló:
/etc/sysctl.confno se aplica al arrancar en ZimaOS. Se movió a/etc/sysctl.d/99-disable-ipv6.conf, y un segundo reinicio confirmó que ahora sí se aplica solo (ver la causa c). - Hecho: limpiar las etiquetas de imagen viejas de
tmux-host. Quedan solo2026-09-26-fix-real(vigente) y2026-09-20-uv(rollback).
Queda una sola vigilancia abierta, sin fecha: después de cada actualización
de ZimaOS, revisar que /etc/sysctl.d/99-disable-ipv6.conf siga existiendo
y que disable_ipv6 siga en 1.