Automatizar una limpieza en Linux parece una tarea sencilla. De hecho, se puede resolver de forma peligrosa con una sola línea:
find /var/log -type f -mtime +30 -delete
El problema es que esa línea no entiende contexto. No sabe si el archivo está en uso, si pertenece a un servicio crítico, si está dentro de una ruta que no debería tocarse, si el patrón es correcto, si alguien se equivocó configurando el directorio objetivo o si el borrado debería haberse probado antes en simulación.
Y ahí está la diferencia entre un comando rápido y una automatización mínimamente seria. En sistemas reales, borrar archivos no es una tarea inocente. Un script de limpieza mal planteado puede eliminar evidencias de una incidencia, romper auditorías, dejar servicios sin logs, borrar ficheros que aún están abiertos o provocar más problemas de los que pretendía solucionar.
La base de esta guía parte de una estructura sencilla pero bien orientada: un directorio de herramienta, un fichero de configuración, un script principal, una carpeta de logs y un README. Esa estructura inicial ya separa configuración, ejecución y trazabilidad, que es justo lo que diferencia un script mantenible de un apaño rápido. El ejemplo original define una estructura cleanup-tool/ con cleanup.sh, config.conf, logs/ y README.md, además de variables como TARGET_DIR, DAYS, EXCLUDE_PATHS, DRY_RUN y LOG_FILE. :contentReference[oaicite:0]{index=0}
El objetivo no es simplemente borrar archivos antiguos. El objetivo es diseñar una pequeña herramienta de limpieza segura, trazable y controlada, pensada para entornos reales.
La herramienta debe permitir:
- Definir el directorio objetivo desde configuración. - Definir antigüedad mínima de archivos. - Excluir rutas críticas. - Ejecutar primero en modo simulación. - Registrar todo lo que se haría o se borra. - Evitar borrar archivos abiertos por procesos. - Evitar ejecuciones concurrentes. - Validar parámetros peligrosos. - Integrarse con cron o systemd timer. - Poder revisarse y auditarse después.
La idea es que el script pueda usarse en escenarios como limpieza controlada de logs antiguos, temporales operativos, ficheros generados por aplicaciones o directorios de trabajo que tienden a crecer con el tiempo.
Muchos servidores no fallan por una causa sofisticada. Fallan porque algo sencillo se dejó crecer durante demasiado tiempo. Logs sin rotación, temporales acumulados, volcados antiguos, backups duplicados, trazas de aplicaciones, ficheros de debug o exportaciones que alguien dejó “un momento” y acabaron viviendo años.
Cuando el disco se llena, el comportamiento del sistema se vuelve impredecible:
- Servicios que no pueden escribir. - Bases de datos que empiezan a fallar. - Aplicaciones que dejan de procesar. - Logs que dejan de registrar errores. - Colas que se bloquean. - Procesos que parecen vivos pero no avanzan. - Arranques fallidos tras reinicios.
Una limpieza automatizada ayuda, pero solo si se diseña con cuidado. Si no, puede convertirse en otro punto de fallo.
Vamos a usar esta estructura:
cleanup-tool/ ├── cleanup.sh ├── config.conf ├── logs/ └── README.md
Esta estructura es sencilla, pero suficiente para empezar bien. El script no lleva valores críticos hardcodeados. La configuración vive en su propio fichero. Los logs se guardan en una carpeta separada. Y el README documenta el uso básico.
En operación real esto importa mucho. Un script que solo entiende quien lo creó es deuda técnica. Un script con estructura, configuración y documentación puede ser revisado, modificado y operado por otra persona.
La configuración original es correcta como punto de partida: define directorio objetivo, antigüedad, exclusiones, modo simulación y fichero de log. :contentReference[oaicite:1]{index=1}
TARGET_DIR="/var/log" DAYS=30 EXCLUDE_PATHS="/var/log/journal /var/log/secure" DRY_RUN=true LOG_FILE="./logs/cleanup.log"
Pero para una versión más segura podemos ampliarla:
TARGET_DIR="/var/log" DAYS=30 EXCLUDE_PATHS="/var/log/journal /var/log/secure /var/log/audit" DRY_RUN=true LOG_FILE="./logs/cleanup.log" LOCK_FILE="/tmp/cleanup-tool.lock" MAX_DELETE_PER_RUN=500 MIN_DAYS_ALLOWED=7 REQUIRE_ABSOLUTE_PATH=true
Con esto añadimos controles importantes:
LOCK_FILE: Evita ejecuciones simultáneas. MAX_DELETE_PER_RUN: Limita el número de borrados por ejecución. MIN_DAYS_ALLOWED: Evita configuraciones demasiado agresivas. REQUIRE_ABSOLUTE_PATH: Obliga a usar rutas absolutas y reduce errores peligrosos.
Antes de escribir el script final conviene pensar cómo puede fallar.
Riesgo 1: TARGET_DIR mal configurado. Riesgo 2: DAYS demasiado bajo. Riesgo 3: Borrado de logs necesarios para auditoría. Riesgo 4: Borrado de archivos abiertos por procesos. Riesgo 5: Ejecución simultánea desde cron. Riesgo 6: Falta de logs. Riesgo 7: Exclusiones mal interpretadas. Riesgo 8: No probar antes en modo simulación. Riesgo 9: Ejecutar como root sin controles. Riesgo 10: No saber qué se borró después de una incidencia.
Un buen script de mantenimiento no se diseña pensando solo en el caso correcto. Se diseña pensando también en el error humano.
Esta sería una versión más robusta del script.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="${SCRIPT_DIR}/config.conf"
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "ERROR: No existe config.conf"
exit 1
fi
source "$CONFIG_FILE"
mkdir -p "$(dirname "$LOG_FILE")"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "ERROR: Ya hay una ejecución en curso" | tee -a "$LOG_FILE"
exit 1
fi
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
fail() {
log "ERROR: $*"
exit 1
}
[[ -n "${TARGET_DIR:-}" ]] || fail "TARGET_DIR vacío"
[[ -d "$TARGET_DIR" ]] || fail "TARGET_DIR no existe: $TARGET_DIR"
if [[ "${REQUIRE_ABSOLUTE_PATH:-true}" == "true" ]]; then
[[ "$TARGET_DIR" = /* ]] || fail "TARGET_DIR debe ser ruta absoluta"
fi
[[ "$TARGET_DIR" != "/" ]] || fail "TARGET_DIR no puede ser /"
[[ "$DAYS" =~ ^[0-9]+$ ]] || fail "DAYS debe ser numérico"
[[ "$DAYS" -ge "${MIN_DAYS_ALLOWED:-7}" ]] || fail "DAYS demasiado bajo"
[[ "$DRY_RUN" == "true" || "$DRY_RUN" == "false" ]] || fail "DRY_RUN debe ser true o false"
log "Inicio limpieza"
log "TARGET_DIR=$TARGET_DIR"
log "DAYS=$DAYS"
log "DRY_RUN=$DRY_RUN"
log "EXCLUDE_PATHS=$EXCLUDE_PATHS"
count=0
while IFS= read -r -d '' file; do
skip=false
for path in $EXCLUDE_PATHS; do
if [[ "$file" == "$path"* ]]; then
skip=true
log "EXCLUIDO: $file"
break
fi
done
[[ "$skip" == "true" ]] && continue
if lsof "$file" >/dev/null 2>&1; then
log "EN USO: $file"
continue
fi
if [[ "$DRY_RUN" == "true" ]]; then
log "SIMULACIÓN: se borraría $file"
else
rm -f -- "$file"
log "BORRADO: $file"
fi
count=$((count + 1))
if [[ "$count" -ge "${MAX_DELETE_PER_RUN:-500}" ]]; then
log "Límite MAX_DELETE_PER_RUN alcanzado: $count"
break
fi
done < <(find "$TARGET_DIR" -type f -mtime +"$DAYS" -print0)
log "Fin limpieza. Archivos procesados: $count"
Esta línea parece pequeña, pero cambia bastante el comportamiento del script:
set -euo pipefail
Hace que el script falle si un comando devuelve error, si se usa una variable no definida o si falla una parte de un pipeline. No convierte Bash en un lenguaje perfecto, pero evita muchos errores silenciosos.
En scripts de mantenimiento es importante fallar pronto. Es mejor detener una limpieza por una variable mal definida que seguir ejecutando con valores vacíos.
El ejemplo original usaba un bucle con while read file. Eso puede funcionar, pero puede fallar con nombres que contienen espacios, saltos de línea o caracteres raros.
Por eso usamos:
find "$TARGET_DIR" -type f -mtime +"$DAYS" -print0
y leemos con:
while IFS= read -r -d '' file; do
Esto hace el script más robusto frente a nombres de archivo problemáticos.
El script base ya tenía una validación importante: comprobar si el archivo estaba en uso con lsof antes de borrarlo. :contentReference[oaicite:2]{index=2}
if lsof "$file" >/dev/null 2>&1; then
echo "EN USO: $file" >> $LOG_FILE
continue
fi
Esto es una buena práctica. En Linux puedes borrar un fichero que está abierto por un proceso. El nombre desaparece del filesystem, pero el espacio puede no liberarse hasta que el proceso cierre el descriptor. Eso genera situaciones confusas: “he borrado el log pero el disco sigue lleno”.
Para verlo:
lsof | grep deleted
Si hay muchos ficheros borrados pero aún abiertos, puede que necesites reiniciar el servicio que mantiene esos descriptores.
El modo simulación no es un extra. Es una medida de seguridad.
La configuración original ya contemplaba DRY_RUN=true para simular y DRY_RUN=false para borrado real. :contentReference[oaicite:3]{index=3}
La primera ejecución en cualquier servidor debería ser siempre:
DRY_RUN=true
Y después revisar:
cat ./logs/cleanup.log grep "SIMULACIÓN" ./logs/cleanup.log grep "EXCLUIDO" ./logs/cleanup.log grep "EN USO" ./logs/cleanup.log
Solo cuando el resultado tenga sentido debería pasarse a borrado real.
Las exclusiones son críticas. No todos los ficheros antiguos deben borrarse.
EXCLUDE_PATHS="/var/log/journal /var/log/secure /var/log/audit"
Conviene excluir rutas como:
/var/log/journal /var/log/audit /var/log/secure /var/log/auth.log /var/log/mysql /var/log/postgresql /var/log/nginx /var/log/apache2
No porque nunca deban limpiarse, sino porque normalmente requieren una política propia: logrotate, retención regulatoria, compresión, envío a SIEM o almacenamiento externo.
Si el script se ejecuta desde cron y una ejecución tarda más de lo esperado, podría solaparse con la siguiente. Eso puede generar problemas, especialmente si hay borrados, logs o recursos compartidos.
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "ERROR: Ya hay una ejecución en curso"
exit 1
fi
Con esto evitamos ejecuciones simultáneas.
Otro control útil es limitar cuántos archivos puede borrar una ejecución.
MAX_DELETE_PER_RUN=500
Esto evita que una mala configuración provoque una limpieza masiva inesperada. Si de pronto el script encuentra miles de ficheros candidatos, probablemente conviene revisarlo antes de dejar que borre todo.
Creación de estructura:
sudo mkdir -p /opt/cleanup-tool/logs sudo chown -R root:root /opt/cleanup-tool sudo chmod 750 /opt/cleanup-tool sudo chmod 750 /opt/cleanup-tool/logs
Crear configuración:
sudo tee /opt/cleanup-tool/config.conf > /dev/null <<'CONFIG' TARGET_DIR="/var/log" DAYS=30 EXCLUDE_PATHS="/var/log/journal /var/log/secure /var/log/audit" DRY_RUN=true LOG_FILE="/opt/cleanup-tool/logs/cleanup.log" LOCK_FILE="/tmp/cleanup-tool.lock" MAX_DELETE_PER_RUN=500 MIN_DAYS_ALLOWED=7 REQUIRE_ABSOLUTE_PATH=true CONFIG
Crear script:
sudo tee /opt/cleanup-tool/cleanup.sh > /dev/null <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="${SCRIPT_DIR}/config.conf"
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "ERROR: No existe config.conf"
exit 1
fi
source "$CONFIG_FILE"
mkdir -p "$(dirname "$LOG_FILE")"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "ERROR: Ya hay una ejecución en curso" | tee -a "$LOG_FILE"
exit 1
fi
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
fail() {
log "ERROR: $*"
exit 1
}
[[ -n "${TARGET_DIR:-}" ]] || fail "TARGET_DIR vacío"
[[ -d "$TARGET_DIR" ]] || fail "TARGET_DIR no existe: $TARGET_DIR"
if [[ "${REQUIRE_ABSOLUTE_PATH:-true}" == "true" ]]; then
[[ "$TARGET_DIR" = /* ]] || fail "TARGET_DIR debe ser ruta absoluta"
fi
[[ "$TARGET_DIR" != "/" ]] || fail "TARGET_DIR no puede ser /"
[[ "$DAYS" =~ ^[0-9]+$ ]] || fail "DAYS debe ser numérico"
[[ "$DAYS" -ge "${MIN_DAYS_ALLOWED:-7}" ]] || fail "DAYS demasiado bajo"
[[ "$DRY_RUN" == "true" || "$DRY_RUN" == "false" ]] || fail "DRY_RUN debe ser true o false"
log "Inicio limpieza"
log "TARGET_DIR=$TARGET_DIR"
log "DAYS=$DAYS"
log "DRY_RUN=$DRY_RUN"
log "EXCLUDE_PATHS=$EXCLUDE_PATHS"
count=0
while IFS= read -r -d '' file; do
skip=false
for path in $EXCLUDE_PATHS; do
if [[ "$file" == "$path"* ]]; then
skip=true
log "EXCLUIDO: $file"
break
fi
done
[[ "$skip" == "true" ]] && continue
if lsof "$file" >/dev/null 2>&1; then
log "EN USO: $file"
continue
fi
if [[ "$DRY_RUN" == "true" ]]; then
log "SIMULACIÓN: se borraría $file"
else
rm -f -- "$file"
log "BORRADO: $file"
fi
count=$((count + 1))
if [[ "$count" -ge "${MAX_DELETE_PER_RUN:-500}" ]]; then
log "Límite MAX_DELETE_PER_RUN alcanzado: $count"
break
fi
done < <(find "$TARGET_DIR" -type f -mtime +"$DAYS" -print0)
log "Fin limpieza. Archivos procesados: $count"
SCRIPT
sudo chmod 750 /opt/cleanup-tool/cleanup.sh
La primera ejecución debe hacerse siempre en simulación:
cd /opt/cleanup-tool sudo ./cleanup.sh sudo tail -100 logs/cleanup.log
Hay que revisar especialmente:
- Qué ficheros propone borrar. - Si excluye correctamente rutas críticas. - Si detecta ficheros en uso. - Si el número de candidatos es razonable. - Si TARGET_DIR es correcto.
Solo después de validar la simulación:
sudo sed -i 's/DRY_RUN=true/DRY_RUN=false/' /opt/cleanup-tool/config.conf sudo ./cleanup.sh sudo tail -100 /opt/cleanup-tool/logs/cleanup.log
Y después conviene volver a simulación si no va a quedar automatizado todavía:
sudo sed -i 's/DRY_RUN=false/DRY_RUN=true/' /opt/cleanup-tool/config.conf
Para producción prefiero systemd timer antes que cron cuando quiero trazabilidad clara en journal, control de servicios y ejecución más ordenada.
Servicio:
sudo tee /etc/systemd/system/cleanup-tool.service > /dev/null <<'SERVICE' [Unit] Description=Cleanup Tool - limpieza segura de archivos antiguos [Service] Type=oneshot WorkingDirectory=/opt/cleanup-tool ExecStart=/opt/cleanup-tool/cleanup.sh User=root Group=root SERVICE
Timer:
sudo tee /etc/systemd/system/cleanup-tool.timer > /dev/null <<'TIMER' [Unit] Description=Ejecuta cleanup-tool diariamente [Timer] OnCalendar=*-*-* 03:30:00 Persistent=true [Install] WantedBy=timers.target TIMER
Activación:
sudo systemctl daemon-reload sudo systemctl enable --now cleanup-tool.timer systemctl list-timers | grep cleanup-tool
Ejecución manual:
sudo systemctl start cleanup-tool.service sudo systemctl status cleanup-tool.service journalctl -u cleanup-tool.service -n 100 --no-pager
Una automatización sin monitorización puede fallar durante semanas sin que nadie se entere. Como mínimo conviene monitorizar:
- Última ejecución. - Código de salida. - Tamaño del log. - Número de errores. - Número de archivos borrados. - Si sigue en DRY_RUN cuando debería borrar.
Una comprobación simple:
grep -c "ERROR" /opt/cleanup-tool/logs/cleanup.log grep -c "BORRADO" /opt/cleanup-tool/logs/cleanup.log grep -c "EN USO" /opt/cleanup-tool/logs/cleanup.log
Y para systemd:
systemctl is-active cleanup-tool.timer systemctl status cleanup-tool.service
Es igual de importante definir límites.
Este script NO debería: - Limpiar bases de datos. - Borrar backups sin política específica. - Tocar logs de auditoría sin aprobación. - Sustituir logrotate. - Borrar archivos de aplicaciones críticas sin validación. - Ejecutarse en /. - Ejecutarse con DAYS demasiado bajo. - Activarse en producción sin simulación previa.
La limpieza segura no sustituye a una política de retención. La complementa.
Configuración revisada TARGET_DIR correcto Exclusiones definidas DRY_RUN ejecutado Log revisado Archivos en uso detectados Límite de borrado definido Concurrencia protegida con flock systemd timer validado Monitorización definida Rollback operativo claro Documentación actualizada
Automatizar una limpieza no va de borrar archivos antiguos. Va de reducir riesgo operativo.
Un script como este tiene valor porque obliga a pensar antes de actuar: qué se va a borrar, qué no se debe tocar, cómo se registra, cómo se prueba, cómo se evita una ejecución duplicada y cómo se detecta si algo sale mal.
La diferencia entre un comando peligroso y una herramienta operable está en esos detalles.
Y en sistemas reales, esos detalles son los que evitan que una tarea de mantenimiento acabe convertida en una incidencia.