/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 || fallbackdentro de una sustitución de comandos puede concatenar salidas inesperadas sicmdimprime 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;
lsofpara 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.