← Back to list

El backup que me mentía hace dos meses

Tengo un homelab con un cluster K3s donde corro de todo: un PostgreSQL HA, un Redis compartido, Paperless-ngx para mis documentos…

Cbenitez · 2026-04-26 12:37 · 0 claps · 6.2 min read
#homelab #rsync
Open on Medium ↗

El backup que me mentía hace dos meses

Cómo un detalle del shell de Alpine convirtió a mi CronJob de respaldo offsite en un teatro perfectamente coreografiado. Cero datos, alertas verdes, ntfy diciendo “OK”. Una historia de homelab, set -o pipefail, y la importancia de no confiar en lo que tu propio script te grita.

Cómo un detalle del shell de Alpine convirtió a mi CronJob de respaldo offsite en un teatro perfectamente coreografiado. Cero datos, alertas verdes, ntfy diciendo “OK”. Una historia de homelab, set -o pipefail, y la importancia de no confiar en lo que tu propio script te grita.

Tengo un homelab con un cluster K3s donde corro de todo: un PostgreSQL HA, un Redis compartido, Paperless-ngx para mis documentos escaneados, y un Garage como almacenamiento S3-compatible que sirve de backend para el state de OpenTofu, los backups de Velero, y los WAL de Postgres. Como buen paranoico amateur, hace dos meses armé un CronJob para sincronizar todo eso a Google Drive con rclone. El CronJob corre cada noche a las 4 AM. Cada mañana me llega una notificación por ntfy con el ✅ de “Garage Backup OK”.

Hasta que hice una auditoría del cluster, abrí los logs de un job, y se me cayó el alma. Esos ✅ eran mentira. Hace ocho semanas que no se sincronizaba absolutamente nada. Esta es la historia.

Las herramientas en juego

Antes de meternos en el bug, un repaso rápido de los protagonistas:

  • Garage: un almacenamiento S3-compatible escrito en Rust por Deuxfleurs. Liviano, federado, perfecto para homelab. Lo uso como backend de OpenTofu, destino de Velero y archivo de WAL de PostgreSQL.
  • rclone: la navaja suiza para mover archivos entre proveedores de storage. Habla S3, Google Drive, SFTP, Backblaze, lo que sea. Su comando estrella es rclone sync, que replica un origen a un destino.
  • ntfy: un servidor de push notifications minimalista. Le hacés un POST a una URL con un mensaje, y te llega al teléfono. Lo uso para todo: alertas de Prometheus, resultados de CronJobs, lo que importe.
  • BusyBox ash: el shell que viene en Alpine Linux. Es chico, rápido, POSIX-compliant en lo básico… y le faltan algunas cosas que en bash damos por sentadas. Spoiler: ese va a ser el villano.

El descubrimiento

Estaba auditando el cluster — buscando recursos huérfanos, optimizaciones de memoria, redundancias para limpiar. En un momento me crucé con el último job exitoso del backup de Garage:

$ kubectl logs -n garage job/garage-backup-29618160 | tail -25
== Step 1: Backing up LMDB metadata ==
2026/04/25 04:00:03 CRITICAL: Failed to create file system for "gdrive:/Garage-Backup/meta":
  couldn't find root directory ID:
  couldn't fetch token: invalid_grant: maybe token expired?
  - try refreshing with "rclone config reconnect gdrive:"
OK: LMDB metadata synced
== Step 2: Backing up bucket: opentofu-state ==
2026/04/25 04:00:03 CRITICAL: Failed to create file system for "gdrive:/Garage-Backup/buckets/opentofu-state":
  couldn't fetch token: invalid_grant: maybe token expired?
OK: Bucket opentofu-state synced
== Step 2: Backing up bucket: velero-backups ==
2026/04/25 04:00:04 CRITICAL: Failed to create file system for "gdrive:/Garage-Backup/buckets/velero-backups":
  couldn't fetch token: invalid_grant: maybe token expired?
OK: Bucket velero-backups synced
==========================================
Backup Summary
==========================================
Duration: 0m 4s
LMDB transferred: unknown
Errors: 0
SUCCESS: Garage backup completed!

Leelo dos veces. CRITICAL en cada paso. Token expirado. Cero datos transferidos. Y el script termina con “SUCCESS”, exit code 0, ntfy verde.

El smoking gun: “Duration: 0m 4s” para sincronizar 9 GB de backups de Velero. Si te suena imposible es porque lo es. Pero el script no lo cuestionó. Y yo no lo había mirado en ocho semanas.

El bug en una línea

Fui directo al script del CronJob. La parte clave era esta:

rclone sync /garage-meta $GDRIVE_REMOTE/meta \
  --backup-dir "$GDRIVE_REMOTE/.deleted/meta-$DATE" \
  --checksum --stats 1m --stats-one-line --verbose \
  2>&1 | tee /tmp/lmdb-sync.log || LMDB_RESULT=$?
if [ $LMDB_RESULT -ne 0 ]; then
  echo "ERROR: LMDB metadata sync failed with exit code $LMDB_RESULT"
  ERRORS=$((ERRORS + 1))
fi

A primera vista se ve correcto: corro rclone, redirijo stderr a stdout, lo paso por tee para tener el log en un archivo y en pantalla, y si todo falla capturo el exit code en LMDB_RESULT. ¿Qué puede salir mal?

Acá viene el detalle. En un pipe de Unix, el exit code de la pipeline es el exit code del último comando. En este caso, el último comando es tee, no rclone. Y tee escribiendo a /tmp/lmdb-sync.log casi nunca falla. Entonces $? siempre vale 0, y || LMDB_RESULT=$? nunca se dispara.

Visualizado:

Diagrama 1: el rc de la pipeline es el rc del último comando. tee escribe a un archivo del filesystem temporal y casi siempre devuelve 0, así que el rc de rclone queda enterrado. El operador || nunca se activa, LMDB_RESULT queda en su valor inicial 0, y el script reporta éxito.

Diagrama 1: el rc de la pipeline es el rc del último comando. tee escribe a un archivo del filesystem temporal y casi siempre devuelve 0, así que el rc de rclone queda enterrado. El operador || nunca se activa, LMDB_RESULT queda en su valor inicial 0, y el script reporta éxito.

En bash existe set -o pipefail, que hace que el rc de la pipeline sea el del primer comando que falle. También existe el array ${PIPESTATUS[@]} que te da el rc de cada componente. Cualquiera de los dos arregla el problema. El detalle es que el container del CronJob corre la imagen rclone/rclone, que está basada en Alpine Linux, y Alpine usa BusyBox ash como /bin/sh. Y BusyBox ash no soporta pipefail ni PIPESTATUS.

La cronología del engaño

Antes de explicar el fix, dejame mostrarte la línea de tiempo del descalabro. Ayuda a entender por qué falló todo silenciosamente durante ocho semanas:

Diagrama 2: el token expiró el 7 de marzo. Desde esa noche, cada ejecución del CronJob disparó la cadena rclone falla → tee tapa el rc → script reporta OK → ntfy verde. Casi dos meses sin un solo backup exitoso, sin una sola alerta.

Diagrama 2: el token expiró el 7 de marzo. Desde esa noche, cada ejecución del CronJob disparó la cadena rclone falla → tee tapa el rc → script reporta OK → ntfy verde. Casi dos meses sin un solo backup exitoso, sin una sola alerta.

Por qué el monitoreo tampoco lo agarró

Tengo Prometheus monitoreando los CronJobs. Hay una alerta GarageBackupFailed definida en una PrometheusRule que se dispara si kube_job_status_failed es mayor a 0. El problema: el job nunca falló. Salía con exit code 0 todas las noches. Para Kubernetes, era un éxito. Para Prometheus, era un éxito. Para ntfy, era un éxito. Para mí, era un éxito.

Acá la lección dolorosa: una alerta sobre el resultado reportado no sirve si el reporte está mintiendo. Hubiera necesitado una alerta lateral, tipo “el bucket de gdrive no recibe escrituras en más de 36 horas” o “el tamaño del backup remoto debería crecer a un ritmo X”. Pero no la tenía.

El fix

Como no podía usar pipefail ni PIPESTATUS, opté por la solución sh-portable: redirigir la salida de rclone a un archivo y capturar $? directo, sin pipe de por medio. Después del comando, hago cat del log para que aparezca en los logs del pod:

rclone sync /garage-meta $GDRIVE_REMOTE/meta \
  --backup-dir "$GDRIVE_REMOTE/.deleted/meta-$DATE" \
  --checksum --stats 1m --stats-one-line --verbose \
  > /tmp/lmdb-sync.log 2>&1 || LMDB_RESULT=$?
cat /tmp/lmdb-sync.log
if [ $LMDB_RESULT -ne 0 ]; then
  echo "ERROR: LMDB metadata sync failed with exit code $LMDB_RESULT"
  ERRORS=$((ERRORS + 1))
fi

Lo mismo para cada bucket. El comportamiento ahora:

Diagrama 3: sin el pipe a tee, $? es el rc real de rclone. El || ahora sí se dispara, LMDB_RESULT queda en 1, el script termina con exit 1, Kubernetes marca el Job como Failed, y ntfy manda una notificación con prioridad alta. Honestidad recuperada.

Diagrama 3: sin el pipe a tee, $? es el rc real de rclone. El || ahora sí se dispara, LMDB_RESULT queda en 1, el script termina con exit 1, Kubernetes marca el Job como Failed, y ntfy manda una notificación con prioridad alta. Honestidad recuperada.

Validación: hacer fallar el job a propósito

Después de aplicar el fix con OpenTofu (el script vive dentro de un ConfigMap, así que es un kubectl apply efectivo), disparé un job manual para confirmar que ahora el script reporta el fallo. El token seguía expirado, así que era el escenario perfecto:

$ kubectl create job --from=cronjob/garage-backup garage-backup-test-fix -n garage
job.batch/garage-backup-test-fix created
# unos minutos después...
$ kubectl get job garage-backup-test-fix -n garage -o jsonpath='{.status}'
{"conditions":[
  {"type":"Failed","status":"True","reason":"BackoffLimitExceeded",
   "message":"Job has reached the specified backoff limit"}
],"failed":1}

Failed. Seis letras hermosas. El script ejecutó, rclone devolvió error por el token, el script lo detectó, exit 1, Kubernetes reintentó hasta agotar el backoff_limit, y declaró el Job como Failed. Exactamente lo que quería desde el principio.

Bonus: el mismo bug en otro lado

Una vez que entendí el patrón, hice un grep en el resto de los módulos de OpenTofu del repo:

$ grep -rn "tee\|PIPESTATUS\|pipefail" terraform/modules/paperless-backup/main.tf
121:        2>&1 | tee /tmp/data-sync.log || DATA_RESULT=$?
137:        2>&1 | tee /tmp/media-sync.log || MEDIA_RESULT=$?

El backup de Paperless-ngx — que respalda 40 GB de documentos escaneados a Google Drive — tenía exactamente el mismo bug. Mismas dos líneas. Misma imagen rclone Alpine. Misma falsa sensación de seguridad. Verifiqué el último log:

Duration: 0m 3s
Data transferred: unknown
Media transferred: unknown
SUCCESS: Backup completed successfully!

40 GB sincronizados a Google Drive en 3 segundos con stats “unknown”. Imposible. Mismo bug, mismo silencio. Aplique el mismo fix, mismo commit, listo.

La taxonomía del fallo silencioso

Lo que pasó acá no es exclusivo de bash, ni de rclone, ni de Alpine. Es un patrón general que vale la pena tener en el radar:

Diagrama 4: el patrón general. Una operación crítica está envuelta en un wrapper (script shell, function de orquestación, retry policy). Si el wrapper no propaga fielmente el rc del comando real, todo el sistema de monitoreo aguas abajo está mintiendo. La detección queda atada a auditorías manuales o a un incidente real, no al monitoreo automatizado.

Diagrama 4: el patrón general. Una operación crítica está envuelta en un wrapper (script shell, function de orquestación, retry policy). Si el wrapper no propaga fielmente el rc del comando real, todo el sistema de monitoreo aguas abajo está mintiendo. La detección queda atada a auditorías manuales o a un incidente real, no al monitoreo automatizado.

Lecciones

Tres cosas me llevo de este episodio:

  1. El shell no es bash por default. Si tu script corre en un container basado en Alpine, asumí ash y testeá ahí. Construcciones como pipefail, PIPESTATUS, [[ ]], read -a, no funcionan.
  2. El monitoreo de éxito es vulnerable al teatro. Si tu única métrica es “el job terminó con exit 0”, estás un bug de wrapper de distancia de no enterarte de nada. Conviene tener métricas laterales que midan el resultado del trabajo, no el reporte: tamaño del bucket, fecha del último archivo subido, checksum de un objeto canario.
  3. Los tokens OAuth caducan en silencio. Google revoca el refresh token después de períodos de inactividad o cambios de password. Si tu integración depende de uno, vale la pena ponerle un calendario de renovación o, mejor, una alerta basada en el último uso exitoso.

El parche son cinco líneas. La lección, dos meses.


메타데이터
post_id
41b73da09031
slug
el-backup-que-me-mentía-hace-dos-meses-41b73da09031
url
https://medium.com/@chocopy/el-backup-que-me-ment%C3%ADa-hace-dos-meses-41b73da09031
canonical_url
https://medium.com/@chocopy/el-backup-que-me-ment%C3%ADa-hace-dos-meses-41b73da09031
author_url
https://medium.com/@chocopy
status
ok
fetched_at
2026-07-11 00:19:17