Gestionnaire de rotation de fichiers journal en Python

Présentation

Cet outil permet de gérer automatiquement la rotation des ficheirs journal afin de limiter leur taille et leur nombre. Inspiré du mécanisme de gestion des journaux utilisé par le NameNode de YARN, il garantit que le fichier principal contient toujours les entrées les plus récentes, tandis que les anciens journaux sont répartis dans des fichiers numérotés de manière décroissante par ancienneté.

Caractéristiques principales

  • Compatible avec les flux d'entrée standard (pipe) sous Linux
  • Contrôle de la taille maximale de chaque fichier journal
  • Limitation du nombre total de fichiers conservés
  • Maintien de l'ordre chronologiuqe des fichiers avec suffixe numérique
  • Faible consommation mémoire
  • Dépendances minimales : uniquement Python 2.7+

Code source

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import argparse
import sys
import os

ERR_OPEN = 301
ERR_WRITE = 302
ERR_RENAME = 303
ERR_DELETE = 304
ERR_PATH = 305

UNIT_MULTIPLIERS = {
    'B': 1,
    'K': 1024,
    'M': 1024 ** 2,
    'G': 1024 ** 3
}


def parse_size_limit(raw_value):
    last_char = raw_value[-1].upper()
    if last_char.isdigit():
        return int(raw_value)
    coefficient = int(raw_value[:-1])
    multiplier = UNIT_MULTIPLIERS.get(last_char, 1)
    return coefficient * multiplier


class CircularLogRotator(object):

    def __init__(self, max_count, size_cap, base_path):
        self.max_files = int(max_count)
        self.byte_limit = parse_size_limit(size_cap)
        self.base_path = base_path
        self.dir_path = os.path.realpath(os.path.dirname(base_path))
        self.current_handle = None
        self.bytes_written = 0
        self._initialize_stream()

    def _build_path(self, index=None):
        if index is None:
            return self.base_path
        return "{base}.{idx}".format(base=self.base_path, idx=index)

    def _initialize_stream(self):
        try:
            if os.path.exists(self.base_path):
                with open(self.base_path, 'r') as reader:
                    self.bytes_written = len(reader.read())
            else:
                self.bytes_written = 0
            self.current_handle = open(self.base_path, 'ab+')
            self.current_handle.write('')
        except IOError:
            sys.exit(ERR_OPEN)

    def _find_available_slot(self):
        for slot in range(1, self.max_files):
            candidate = self._build_path(slot)
            if not os.path.exists(candidate):
                return slot
        return self.max_files - 1

    def _shift_files(self, target_slot):
        try:
            if target_slot == self.max_files - 1:
                oldest = self._build_path(target_slot)
                if os.path.exists(oldest):
                    os.remove(oldest)
        except OSError:
            sys.exit(ERR_DELETE)

        try:
            if target_slot is not None:
                for pos in range(target_slot, 1, -1):
                    src = self._build_path(pos - 1)
                    dst = self._build_path(pos)
                    os.rename(src, dst)
            os.rename(self.base_path, self._build_path(1))
        except OSError:
            sys.exit(ERR_RENAME)

    def _perform_rotation(self):
        slot = self._find_available_slot()
        self._shift_files(slot)
        self.current_handle = open(self.base_path, 'ab+')
        self.bytes_written = 0

    def ingest(self, data=''):
        payload_size = len(data)
        try:
            self.current_handle.write(data)
            self.bytes_written += payload_size
            if self.bytes_written >= self.byte_limit:
                self.current_handle.flush()
                self.current_handle.close()
                self._perform_rotation()
        except IOError:
            sys.exit(ERR_WRITE)


def main():
    parser = argparse.ArgumentParser(
        description='Outil de rotation des journaux en mode pipe.'
    )
    parser.add_argument(
        '-n', '--max-files',
        dest='max_count',
        type=int,
        default=2,
        help='Nombre maximal de fichiers journal conservés (defaut: %(default)s)'
    )
    parser.add_argument(
        'base_path',
        type=str,
        help='Chemin complet vers le fichier journal principal'
    )
    parser.add_argument(
        'size_cap',
        type=str,
        help='Taille maximale par fichier (ex: 100M, 1G, 500K)'
    )
    args = parser.parse_args()

    rotator = CircularLogRotator(args.max_count, args.size_cap, args.base_path)

    for entry in sys.stdin:
        rotator.ingest(entry)


if __name__ == '__main__':
    main()

Prérequis

Python version 2.7 ou supérieure.

Utilisation

chmod +x /chemin/vers/rotatelogs.py
votre_commande | ./rotatelogs.py -n 5 /var/log/mon_application.log 100M

Exemple de test

#!/bin/bash
set -e

ITERATIONS=20
DEBUT=$(date +"%s")

rm -f ./journal_test.log*

for i in $(seq 1 $ITERATIONS); do
  echo "Ligne de test numero $i"
done | python ./rotatelogs.py -n 3 ./journal_test.log 500M

FIN=$(date +"%s")
TEMPS=$(echo "(${FIN}-${DEBUT})/${ITERATIONS}" | bc -l)
echo "Temps moyen par iteration : ${TEMPS}s"

Résultats observés

Commande Temps écoulé
Extraction de 30 000 lignes (head) 13s
Traitement complet (6,1 Go) 136s
Extraction de 30 000 lignes (tail) 66s
Apache rotatelogs -n 3 500M 102s
Cet outil -n 5 100M 60s
Cet outil -n 3 500M 56s

Structure des fichiers générés

-rw-r--r--  1 root root 243M  journal.log
-rw-r--r--  1 root root 501M  journal.log.1
-rw-r--r--  1 root root 501M  journal.log.2

Le fichier principal journal.log contient les données les plus récentes. Les fichiers numérotés contiennnet les anciens journaux, le suffixe le plus élevé correspondant aux entrées les plus anciennes.

Références

Documentation officielle d'Apache : rotatelogs - Programme de rotation des journaux Apache

Étiquettes: Python log-rotation shell-pipe file-management apache-rotatelogs

Publié le 7 juin à 06h15