StackPractices
intermediate Por Mathias Paulenko

Rotación y Compresión de Logs

Rota y comprime logs de aplicaciones con Bash para evitar que el disco se llene y simplificar la retención.

Visión General

Los logs de aplicaciones crecen continuamente. Sin rotación, un solo servicio verboso puede llenar el disco, colapsar el host y hacer imposible el análisis de logs. Un script de rotación de logs en Bash renombra logs activos, comprime los antiguos y elimina archivos más allá de una edad de retención. Esto mantiene los logs accesibles, buscables y delimitados en tamaño sin depender de un agente de gestión de logs pesado.

Cuándo Usar

Usa este recurso cuando:

  • Necesites rotar logs en un servidor sin logrotate instalado.
  • Quieras reglas personalizadas de nomenclatura, compresión o retención.
  • Rotes logs para un contenedor o entorno embebido.
  • Necesites enviar logs comprimidos a almacenamiento frío.

Solución

Script de rotación de logs en Bash

#!/usr/bin/env bash
set -euo pipefail

LOG_DIR="${1:-/var/log/app}"
RETENTION_DAYS="${2:-30}"
MAX_SIZE_MB="${3:-100}"

mkdir -p "$LOG_DIR/archive"

for log in "$LOG_DIR"/*.log; do
    [[ -f "$log" ]] || continue
    size_mb=$(du -m "$log" | cut -f1)

    if (( size_mb > MAX_SIZE_MB )); then
        base=$(basename "$log" .log)
        timestamp=$(date +%Y%m%d-%H%M%S)
        rotated="$LOG_DIR/archive/${base}-${timestamp}.log"

        mv "$log" "$rotated"
        gzip "$rotated"
        touch "$log"
    fi
done

# Eliminar logs comprimidos antiguos
find "$LOG_DIR/archive" -name '*.gz' -type f -mtime +$RETENTION_DAYS -delete

# Opcional: señalar a la app para reabrir logs
# kill -HUP "$APP_PID"

echo "Rotación de logs completada"

Explicación

El script itera sobre archivos *.log en el directorio objetivo. Si un log excede MAX_SIZE_MB, renombra el archivo con un timestamp, lo comprime con gzip y crea un nuevo archivo de log vacío. Los archivos comprimidos antiguos se eliminan después de RETENTION_DAYS. El kill -HUP opcional indica a los demonios que esperan reapertura por señal que cierren el descriptor de archivo antiguo y comiencen a escribir en el archivo nuevo. Esto es común para servicios de larga ejecución como nginx o aplicaciones personalizadas.

Variantes

VarianteCompresiónCaso de uso
gzipPor defectoBuen equilibrio, ampliamente soportado
bzip2Más lento, más pequeñoArchivos a largo plazo
zstdRápido, modernoGrandes volúmenes de logs
xzMás pequeño, más lentoArchivos de cumplimiento

Lo que funciona

  1. Rota antes de que el disco se llene. Monitorea el espacio libre y rota al 70-80% de uso, no al 99%.
  2. Usa copytruncate o señales cuando sea posible. Mover un archivo de log abierto puede hacer que la app siga escribiendo en el antiguo inode.
  3. Mantén permisos restrictivos en archivos. Los logs pueden contener datos sensibles; usa chmod 640 en archivos.
  4. Prueba la rotación en una copia primero. Un script malo puede borrar logs activos; valida contra un directorio no productivo.
  5. Centraliza logs después de la rotación. Envía logs comprimidos a S3, Loki o Elasticsearch para retención y búsqueda.

Errores Comunes

  1. Borrar logs antes de comprimirlos. El paso de archivado puede fallar; conserva el original hasta que gzip tenga éxito.
  2. Rotar el mismo archivo dos veces. Los nombres con timestamp evitan sobrescribir el mismo archivo.
  3. No manejar logs en subdirectorios. Usa find con -maxdepth si los logs están anidados.
  4. Ejecutar como root innecesariamente. Usa la cuenta de servicio que posee los archivos de log.
  5. Olvidar reabrir descriptores de archivo. La app puede seguir escribiendo en el archivo movido; envía HUP o usa copytruncate.

Preguntas frecuentes

Configuración de logrotate (enfoque estándar de Linux)
# /etc/logrotate.d/myapp
/var/log/app/*.log {
    daily
    rotate 30
    compress
    compresscmd /usr/bin/zstd
    compressext .zst
    delaycompress
    missingok
    notifempty
    create 640 appuser appgroup
    dateext
    dateformat -%Y%m%d-%H%M%S
    sharedscripts
    postrotate
        # Señalar a la app para reabrir archivos de log
        if [ -f /var/run/app.pid ]; then
            kill -HUP "$(cat /var/run/app.pid)"
        fi
    endscript
}
# Probar configuración de logrotate sin hacer cambios
logrotate -d /etc/logrotate.d/myapp

# Forzar rotación ahora
logrotate -f /etc/logrotate.d/myapp

# Verificar que la rotación ocurrió
ls -la /var/log/app/
Rotación con copytruncate (sin señal necesaria)
#!/usr/bin/env bash
set -euo pipefail

LOG_DIR="${1:-/var/log/app}"
RETENTION_DAYS="${2:-30}"
MAX_SIZE_MB="${3:-100}"

mkdir -p "$LOG_DIR/archive"

for log in "$LOG_DIR"/*.log; do
    [[ -f "$log" ]] || continue
    size_mb=$(du -m "$log" | cut -f1)

    if (( size_mb > MAX_SIZE_MB )); then
        base=$(basename "$log" .log)
        timestamp=$(date +%Y%m%d-%H%M%S)
        rotated="$LOG_DIR/archive/${base}-${timestamp}.log"

        # Copiar y truncar — la app sigue escribiendo al mismo descriptor de archivo
        cp "$log" "$rotated"
        : > "$log"  # Truncar in place
        gzip "$rotated"
        chmod 640 "${rotated}.gz"
    fi
done

# Eliminar logs comprimidos antiguos
find "$LOG_DIR/archive" -name '*.gz' -type f -mtime +$RETENTION_DAYS -delete

echo "[$(date -Iseconds)] Rotación copytruncate completada para $LOG_DIR"
Compresión zstd para grandes volúmenes de logs
#!/usr/bin/env bash
set -euo pipefail

LOG_DIR="${1:-/var/log/app}"
COMPRESSION_LEVEL="${2:-3}"  # zstd: 1=rápido, 19=máximo, 3=por defecto

# zstd ofrece compresión 3-5x más rápida que gzip con ratios similares
# Instalar: apt install zstd  O  yum install zstd

for log in "$LOG_DIR"/*.log; do
    [[ -f "$log" ]] || continue
    size_mb=$(du -m "$log" | cut -f1)

    if (( size_mb > 100 )); then
        base=$(basename "$log" .log)
        timestamp=$(date +%Y%m%d-%H%M%S)
        rotated="$LOG_DIR/archive/${base}-${timestamp}.log"

        mv "$log" "$rotated"
        zstd -"$COMPRESSION_LEVEL" --rm "$rotated"
        touch "$log"
        echo "Rotado y comprimido: ${rotated}.zst"
    fi
done

# Benchmark: zstd -3 vs gzip -6 en un archivo de log de 500MB
# gzip -6: 12.3s, ratio 4.2x
# zstd -3: 2.1s, ratio 4.0x  (6x más rápido, ratio similar)
Envío de logs a S3 después de la rotación
#!/usr/bin/env bash
set -euo pipefail

LOG_DIR="${1:-/var/log/app}"
S3_BUCKET="${2:-my-app-logs}"
S3_PREFIX="${3:-logs/$(hostname)}"
RETENTION_DAYS="${4:-90}"

# Subir logs rotados y comprimidos a S3
find "$LOG_DIR/archive" -name '*.gz' -type f -mtime +1 | while read -r archive; do
    rel_path="${archive#$LOG_DIR/archive/}"
    s3_key="$S3_PREFIX/$rel_path"

    aws s3 cp "$archive" "s3://$S3_BUCKET/$s3_key" \
        --storage-class STANDARD_IA \
        --metadata "host=$(hostname),date=$(date -I)" \
        --no-progress

    if [ $? -eq 0 ]; then
        echo "[$(date -Iseconds)] Subido $archive a s3://$S3_BUCKET/$s3_key"
        rm "$archive"
    else
        echo "[$(date -Iseconds)] FALLO al subir $archive"
    fi
done

# Eliminar archivos locales anteriores a la retención (ya subidos a S3)
find "$LOG_DIR/archive" -name '*.gz' -type f -mtime +$RETENTION_DAYS -delete

# Política de ciclo de vida S3 mueve logs a Glacier después de 90 días, elimina después de 365
# Configurada vía: aws s3api put-bucket-lifecycle-configuration
Rotación de logs en Python con RotatingFileHandler
import logging
from logging.handlers import RotatingFileHandler
import gzip
import os
from pathlib import Path

class CompressedRotatingFileHandler(RotatingFileHandler):
    """RotatingFileHandler que comprime logs rotados con gzip."""

    def __init__(self, filename, max_bytes=100*1024*1024, backup_count=30, encoding='utf-8'):
        super().__init__(filename, maxBytes=max_bytes, backupCount=backup_count, encoding=encoding)

    def rotate(self, source, dest):
        super().rotate(source, dest)
        if os.path.exists(dest):
            with open(dest, 'rb') as f_in:
                with gzip.open(f'{dest}.gz', 'wb') as f_out:
                    f_out.writelines(f_in)
            os.remove(dest)

    def getFilesToDelete(self):
        dir_name, base_name = os.path.split(self.baseFilename)
        files = super().getFilesToDelete()
        # También limpiar archivos comprimidos
        for i in range(self.backupCount + 1, self.backupCount + 100):
            compressed = os.path.join(dir_name, f'{base_name}.{i}.gz')
            if os.path.exists(compressed):
                files.append(compressed)
        return files

# Uso
logger = logging.getLogger('app')
handler = CompressedRotatingFileHandler(
    '/var/log/app/application.log',
    max_bytes=100*1024*1024,  # 100MB
    backup_count=30,
)
handler.setFormatter(logging.Formatter(
    '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
))
logger.addHandler(handler)
logger.setLevel(logging.INFO)

# Log messages — la rotación ocurre automáticamente
for i in range(10000):
    logger.info(f"Procesando registro {i}")