/tmp es uno de esos directorios que casi nunca miramos hasta que el disco empieza a llenarse.

Entonces aparece la tentación:

rm -rf /tmp/*

Y esa es precisamente la clase de solución que puede convertir un problema de espacio en disco en un problema de producción.

En una máquina Linux que ejecuta builds, workers, servicios, herramientas de desarrollo y procesos de larga duración, /tmp puede contener una mezcla de:

  • archivos realmente desechables;
  • cachés abandonadas;
  • directorios de builds viejos;
  • sockets y pipes activos;
  • directorios privados creados por systemd;
  • locks;
  • datos temporales que todavía está usando un proceso;
  • árboles enormes donde solo un archivo fue modificado recientemente.

La pregunta correcta no es “¿cómo vacío /tmp?”, sino:

¿Cómo descubro qué podría borrar con suficiente evidencia, lo reviso primero y reduzco la probabilidad de eliminar algo que todavía está vivo?

Este artículo resume un caso real de construcción, prueba y endurecimiento de un script de limpieza. Los nombres de máquinas, usuarios, proyectos, jobs, repositorios e identificadores fueron eliminados o sustituidos. Las cifras y rutas del ejemplo también fueron modificadas deliberadamente para no describir una infraestructura concreta.

El punto de partida: primero medir

Antes de borrar nada, conviene entender el problema:

df -h /
du -sh /tmp
sudo du -xhd1 /tmp 2>/dev/null | sort -h

En un caso representativo —con números alterados— el escenario podría verse así:

filesystem raíz:  ~60 GB
uso:               ~88 %
/tmp:              ~3.4 GB

Dentro de /tmp aparecen, por ejemplo:

/tmp/build-job-a        820 MB
/tmp/build-job-b        760 MB
/tmp/media-cache-old    390 MB
/tmp/test-runner        600 MB

Eso todavía no significa que podamos borrarlos.

El tamaño solo responde qué ocupa espacio. No responde qué está muerto.

Regla 1: el script debe ser dry-run por defecto

La decisión más importante fue hacer que ejecutar el script sin argumentos no borre nada.

./cleanup-tmp.sh

solo descubre candidatos.

Para borrar de verdad hay que decirlo explícitamente:

./cleanup-tmp.sh --apply

La diferencia parece pequeña, pero cambia completamente el modelo de seguridad. Puedes ejecutar el descubrimiento, leer la lista, revisar tamaños y nombres, y solo entonces decidir si quieres aplicar la limpieza.

Un buen script destructivo debería requerir más intención para destruir que para inspeccionar.

Regla 2: inspeccionar solo hijos directos de /tmp

En lugar de recorrer /tmp y borrar archivos sueltos por toda la jerarquía, el script trata cada hijo directo como una unidad:

find -P /tmp -mindepth 1 -maxdepth 1 -print0

Por ejemplo:

/tmp/build-job-a
/tmp/editor-cache-x
/tmp/session-123

Si un directorio completo es seguro, se elimina completo. Si hay dudas sobre cualquier contenido de ese árbol, se conserva completo.

Esto produce una política mucho más fácil de razonar:

/tmp/candidato
    ├── viejo.dat
    ├── cache/
    │   └── viejo.bin
    └── actividad-reciente.log

Aunque casi todo sea viejo, si actividad-reciente.log fue modificado recientemente, todo /tmp/candidato se conserva.

Regla 3: “viejo” debe significar viejo en todo el árbol

Un error común sería mirar solamente el mtime del directorio raíz:

find /tmp -maxdepth 1 -mtime +2

Pero el mtime de un directorio no necesariamente refleja modificaciones al contenido de archivos ya existentes dentro de él.

Por eso usamos una comprobación recursiva:

has_recent_content() {
  local path="$1"
  local minutes=$((MIN_AGE_DAYS * 24 * 60))
  local recent

  if ! recent="$(find -P "$path" -mmin "-${minutes}" -print -quit 2>/dev/null)"; then
    return 2
  fi

  [[ -n "$recent" ]]
}

La función tiene tres resultados conceptuales:

0 → hay algo reciente
1 → todo lo inspeccionado es suficientemente viejo
2 → no pude inspeccionar el árbol de forma confiable

El tercer estado es importante.

Si find recibe un Permission denied, el enfoque conservador no es asumir que “probablemente está viejo”. Es:

no puedo demostrar que es seguro
        ↓
no lo borro

En limpieza destructiva, los errores de observabilidad deberían tender a fail closed.

Regla 4: excluir nombres del sistema y nodos especiales

Hay entradas de /tmp que no deberíamos tratar como basura genérica aunque parezcan antiguas.

Una lista razonable de exclusiones incluye patrones como:

.X11-unix
.ICE-unix
.XIM-unix
.font-unix
.Test-unix
systemd-private-*
snap-private-tmp
snap.*
ssh-*
tmux-*

Además, sockets, FIFOs y otros nodos especiales deben saltarse:

if [[ ! -f "$path" && ! -d "$path" && ! -L "$path" ]]; then
  echo "SKIP special: $path"
  continue
fi

La lista de exclusiones no pretende conocer cada programa de Linux. Es una segunda barrera después de la política de recencia y archivos abiertos.

Regla 5: preguntar al kernel indirectamente quién tiene archivos abiertos

Que algo sea viejo tampoco demuestra que esté inactivo.

Un proceso podría mantener abierto un archivo que no ha sido modificado durante días.

Para eso usamos lsof:

is_open_or_busy() {
  local path="$1"

  if [[ -d "$path" ]]; then
    lsof +D "$path" >/dev/null 2>&1 && return 0
  else
    lsof -- "$path" >/dev/null 2>&1 && return 0
  fi

  return 1
}

Para un directorio, lsof +D revisa recursivamente el árbol.

Tiene un coste: puede ser lento sobre árboles grandes. En nuestra prueba, el dry-run completo tardó bastante más de lo que esperábamos precisamente por estas inspecciones recursivas.

Pero para un script conservador ejecutado ocasionalmente, ese coste puede ser una buena transacción:

más lento
vs.
menos probable borrar datos en uso

Para la versión endurecida tomamos otra decisión: --apply no funciona si lsof no está disponible.

if [[ "$MODE" == "apply" && $have_lsof -eq 0 ]]; then
  echo "ERROR: --apply requires lsof" >&2
  exit 1
fi

El dry-run puede seguir siendo útil sin borrar nada, pero el modo destructivo exige la comprobación adicional.

Regla 6: revalidar justo antes de rm

Hay una carrera inevitable entre:

inspeccionar
   ↓
decidir
   ↓
borrar

Un directorio puede estar viejo y cerrado cuando lo inspeccionamos, y empezar a usarse unos milisegundos después.

No podemos eliminar completamente esa carrera con un simple script Bash. Pero sí podemos estrechar la ventana repitiendo las comprobaciones inmediatamente antes de borrar:

if has_recent_content "$path"; then
  echo "SKIP became recent: $path"
  continue
fi

if is_open_or_busy "$path"; then
  echo "SKIP became busy: $path"
  continue
fi

rm -rf --one-file-system -- "$path"

--one-file-system añade otra defensa: evita que rm cruce a otro filesystem que pudiera estar montado debajo del árbol candidato.

No convierte la operación en atómica, pero reduce la superficie de daño.

Un bug interesante: du produjo dos números

La primera versión calculaba bytes así:

size_bytes="$(du -sb -- "$path" 2>/dev/null | awk '{print $1}' || echo 0)"

Parecía razonable: si du falla, usamos cero.

El problema es que un programa puede producir salida válida y aun así terminar con código distinto de cero.

Por ejemplo, du puede alcanzar a calcular parte del árbol, imprimir:

49319

pero también encontrar una entrada inaccesible y salir con error. Entonces se ejecuta echo 0.

La variable queda con dos líneas:

49319
0

Más adelante:

((candidate_bytes += size_bytes))

falla porque eso ya no es un entero válido.

La corrección fue separar “capturar lo que haya” de “validar que sea un entero”:

size_bytes="$(
  du -sb -- "$path" 2>/dev/null |
    awk 'NR == 1 { print $1; exit }' || true
)"

[[ "$size_bytes" =~ ^[0-9]+$ ]] || size_bytes=0

Esta es una lección general para scripts shell:

cmd || fallback dentro de una sustitución de comandos puede concatenar salidas inesperadas si cmd imprime algo antes de fallar.

Valida el dato que vas a usar, no solo el exit code del productor.

Otro bug: set -e convirtió un archivo con permisos raros en abortar toda la limpieza

Con set -e, una primera implementación hacía simplemente:

rm -rf --one-file-system -- "$path"

En uno de los candidatos había archivos pertenecientes a otro usuario o servicio. rm recibió Permission denied y devolvió un status no cero.

Por set -e, el script completo terminó en ese punto.

Pero la intención del limpiador era “borra lo que sea seguro y posible; reporta lo que no”. Un candidato problemático no debería impedir limpiar los demás.

La solución fue convertir el borrado en una operación explícitamente manejada:

if rm -rf --one-file-system -- "$path"; then
  echo "REMOVED: $path"
  ((removed_count += 1))
else
  echo "WARN delete failed: $path" >&2
  ((delete_failures += 1))
fi

Esto ilustra una idea importante sobre set -euo pipefail: el modo estricto no sabe cuál es tu política de negocio.

Un fallo de rm puede significar:

fatal para todo el programa

o:

fallo local de un ítem; registrar y continuar

Eso lo tiene que expresar el código.

No ejecutar como root por accidente

Un script de limpieza es mucho más peligroso cuando tiene permisos para borrar prácticamente cualquier cosa.

La versión pública endurecida rechaza --apply como root, salvo opt-in explícito:

if [[ "$MODE" == "apply" && $EUID -eq 0 && "${ALLOW_ROOT:-0}" != "1" ]]; then
  echo "ERROR: refusing --apply as root" >&2
  exit 1
fi

No significa que root sea siempre incorrecto. Significa que elevar privilegios debe ser una decisión visible.

La misma filosofía se aplica al target: el script espera /tmp. Si quieres apuntarlo a otro directorio, debe requerir un override explícito.

La salida debe ayudar a revisar

Un dry-run útil debería diferenciar por qué una entrada no se toca:

SKIP protected:   /tmp/systemd-private-...
SKIP recent:      /tmp/test-runner
SKIP busy/open:   /tmp/session-live
SKIP special:     /tmp/app.sock
SKIP scan error:  /tmp/restricted-tree
CANDIDATE 780M    /tmp/build-job-a

Y terminar con un resumen:

Summary
  candidates: 900+
  estimated candidate bytes: ~2 GiB
  skipped by safety checks: 100+
  removed: 0
  delete failures: 0

Nothing was deleted.

En --apply, el mismo resumen permite distinguir candidatos, elementos realmente eliminados y fallos.

El patrón completo de seguridad

El limpiador terminó siguiendo esta secuencia:

1. enumerar solo hijos directos de /tmp
2. saltar nombres protegidos
3. saltar sockets/FIFOs/nodos especiales
4. buscar cualquier contenido reciente recursivamente
5. si no puedo inspeccionar, conservar
6. revisar archivos abiertos con lsof
7. calcular tamaño solo para reporting
8. mostrar candidato
9. en --apply, repetir recencia y lsof
10. rm --one-file-system
11. manejar el fallo por candidato y continuar

Ese orden es más importante que cualquier comando individual.

Qué no debemos publicar cuando compartimos el script

Después de construir una herramienta así es normal querer convertirla en un snippet o repositorio público. La versión pública de este script está disponible en frarteaga/scripts-configs, un repositorio de scripts y configuraciones pequeños y revisables. El script puede ser genérico y aun así los logs de desarrollo filtrar infraestructura.

Antes de publicar revisa tanto código como ejemplos buscando:

hostnames
usuarios reales
IPs públicas o privadas
nombres de repositorios internos
nombres de runners
rutas con nombres de proyectos
IDs de cloud
URLs de servicios
tokens o fragmentos de credenciales
correos personales en metadata de commits

Por ejemplo, en documentación pública es mejor transformar:

/tmp/<proyecto>-issue-481
runner-prod-east-02
user@real-host
10.20.30.40

en algo como:

/tmp/build-job-a
runner-example-01
user@example-host
192.0.2.10

El bloque 192.0.2.0/24 está reservado para documentación, por lo que evita señalar accidentalmente un host real.

También conviene revisar la metadata Git, no solo los archivos. Puedes tener un README perfectamente limpio y aun así publicar tu correo en el author/committer de cada commit.

Qué aprendimos

Limpiar /tmp de forma responsable terminó siendo menos un problema de rm y más un problema de evidencia y política de fallo.

Las decisiones que más valor aportaron fueron:

  • dry-run por defecto;
  • antigüedad aplicada al árbol completo, no solo al directorio raíz;
  • lsof para detectar uso activo;
  • fail-closed cuando la inspección no es completa;
  • segunda comprobación inmediatamente antes de borrar;
  • --one-file-system;
  • no abortar toda la limpieza por un único Permission denied;
  • no usar root salvo decisión explícita;
  • tratar logs y metadata Git como posibles fuentes de información sensible.

El resultado es más lento que rm -rf /tmp/*.

Ese es el punto.

Cuando una operación es destructiva, hacerla ligeramente incómoda, observable y conservadora suele ser una característica, no un defecto.