Guía práctica · Linux · Bash · Automatización segura · Operación real

Automatización segura de limpieza en Linux

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}

1. Objetivo de la guía

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.

2. Qué problema resuelve realmente

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.

3. Estructura recomendada

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.

4. Versión base del fichero de configuración

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.

5. Riesgos que hay que controlar

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.

6. Script mejorado

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"

7. Por qué usar set -euo pipefail

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.

8. Por qué usar find con -print0

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.

9. Por qué no borrar archivos abiertos

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.

10. Dry-run obligatorio

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.

11. Exclusiones

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.

12. Control de concurrencia con flock

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.

13. Límite de borrado por ejecución

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.

14. Instalación completa

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

15. Primera ejecución

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.

16. Pasar a borrado real

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

17. Automatización con systemd timer

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

18. Integración con Zabbix

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

19. Qué no debe hacer este script

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.

20. Checklist operativo

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

21. Conclusión

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.

Volver a guías