La prueba de reinicio del NAS (2026-09-26) mostro que /etc/sysctl.conf persiste pero ZimaOS no lo aplica al arrancar (systemd-sysctl solo lee /etc/sysctl.d/*.conf, sin symlink 99-sysctl.conf). Se documenta la causa, el fix con /etc/sysctl.d/99-disable-ipv6.conf, como aplicarlo en caliente con un contenedor privilegiado de un solo uso, y se cierran los pendientes ya resueltos (commit de entrypoint.sh, limpieza de etiquetas). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
376 lines
17 KiB
Markdown
376 lines
17 KiB
Markdown
# 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](../arquitectura/sesion-tmux-persistente.md)) |
|
|
| **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.
|
|
|
|
!!! info "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](../arquitectura/sesion-tmux-persistente.md)
|
|
**(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.
|
|
|
|
!!! warning "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`:
|
|
|
|
```diff
|
|
-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) ===
|
|
```
|
|
|
|
!!! warning "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)**:
|
|
|
|
1. El resolver devolvía primero la dirección IPv6 de `api.anthropic.com`.
|
|
2. La ruta IPv6 del NAS no sale a internet: el intento fallaba al
|
|
instante con `Network unreachable`.
|
|
3. 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:
|
|
|
|
```bash
|
|
# 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.conf` **sí** sobrevivió al reinicio con las dos líneas
|
|
(el overlay de `/etc` persiste).
|
|
- Pero `sysctl net.ipv6.conf.all.disable_ipv6` volvió a dar **`0`**: 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:
|
|
|
|
```bash
|
|
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`).
|
|
|
|
!!! warning "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í.
|
|
|
|
!!! note "Pendiente de validar"
|
|
Falta confirmar que el drop-in de `/etc/sysctl.d/` se aplique solo en
|
|
el **próximo** reinicio. Tampoco se sabe si una actualización de
|
|
ZimaOS pisa `upper_etc`.
|
|
|
|
---
|
|
|
|
## 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.**
|
|
|
|
1. `/login` en **una** sesión. Las credenciales viven en
|
|
`/root/.claude/.credentials.json`, que es el volumen compartido por
|
|
todas las sesiones del contenedor.
|
|
2. `/remote-control` → **1. Enable Remote Control** en **cada** sesión ya
|
|
abierta. Un proceso `claude` que 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:
|
|
|
|
```bash
|
|
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.
|
|
|
|
!!! tip "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:
|
|
|
|
```bash
|
|
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
|
|
|
|
- [x] ~~Commitear el fix de `entrypoint.sh`.~~ Mergeado a `main` del repo
|
|
`scripts` el 2026-09-26 (`510cc41`). Antes se verificó que
|
|
`entrypoint.sh` y `healthcheck.sh` del repo fueran idénticos (md5) a
|
|
los de la imagen `fix-real` en producción.
|
|
- [x] ~~Validar que IPv6 siga deshabilitado después de un reinicio
|
|
real del NAS.~~ Probado el 2026-09-26 y **falló**: `/etc/sysctl.conf`
|
|
no se aplica al arrancar en ZimaOS. Se movió a
|
|
`/etc/sysctl.d/99-disable-ipv6.conf` (ver la causa c).
|
|
- [ ] Confirmar en el próximo reinicio que el drop-in de
|
|
`/etc/sysctl.d/` deja IPv6 en `1` sin intervención.
|
|
- [x] ~~Limpiar las etiquetas de imagen viejas de `tmux-host`.~~ Hecho el
|
|
2026-09-26: quedan solo `2026-09-26-fix-real` (vigente) y
|
|
`2026-09-20-uv` (rollback).
|