Приложение, которое пишет в журнал миллионы строк в час, заполняет раздел /var/log за считанные дни. Сначала заканчивается свободное место, затем ядро отказывает процессам в записи, и служба незаметно останавливается. Знакомая многим картина: гигантский журнал удалили вручную, а df по-прежнему показывает занятое место. Разбор рабочих параметров logrotate, от compress и delaycompress до copytruncate, показывает: у проблемы один корень, а решений два, и у каждого своя цена.

Механика logrotate и место конфигурации приложения в подключаемом каталоге

logrotate не живёт в системе постоянным процессом: утилита запускается по расписанию через cron или таймер systemd, сверяет время последней ротации журнала с файлом состояния и выполняет то, что уже созрело. Главный файл /etc/logrotate.conf задаёт общие умолчания вроде недельного периода и числа хранимых копий, после чего строкой include /etc/logrotate.d подключает каталог отдельных правил. Каждому приложению выделяется собственный файл в этом каталоге: пакеты из репозиториев поступают именно так, и правило для самописной службы вписывается в ту же схему.

Базовый набор параметров для нагруженного приложения выглядит скромно:

/var/log/myapp/app.log {
    hourly
    rotate 168
    missingok
    notifempty
    dateext
}

Чтение построчно: hourly задаёт почасовое расписание, rotate 168 оставляет сто шестьдесят восемь копий, то есть неделю истории, missingok предписывает не считать ошибкой отсутствующий файл, notifempty запрещает ротацию пустого журнала, dateext заменяет порядковые номера штампом даты в имени копии. Подводный камень: параметр hourly сам по себе ничего не планирует. Утилита лишь сверяет часы из файла состояния в момент запуска, поэтому почасовая ротация требует почасового запуска logrotate, например записи в cron.hourly или собственного таймера systemd. Без этого сутки будут ограничены одной ротацией.

Отдельная осторожность нужна с масками: шаблон /var/log/myapp/*.log захватывает и старые копии, если их имена подходят под маску. Документация предупреждает об этой ловушке: перечисление конкретных файлов надёжнее.

Открытый дескриптор файла и причина, по которой место на диске не освобождается

Переименование файла в Linux не трогает сам файл. Имя в каталоге лишь ссылка на индексный дескриптор, а процесс, открывший журнал, держит именно дескриптор с позицией записи. Когда logrotate без специальных параметров переименовывает app.log в app.log.1 и создаёт новый app.log, приложение продолжает дописывать строки в переименованный файл: для него ничего не изменилось. Место при этом не освобождается, inode жив, пока открыта хотя бы одна ссылка на него.

Отсюда классическая иллюзия с df и du: администратор удаляет гигантский журнал, свободное место в выводе df не меняется, а du в каталоге показывает пустоту. Прекратить запись в переименованный файл может только само приложение, переоткрыв журнал по имени. У конфигурации два пути: либо сообщить приложению о смене файла и дождаться переоткрытия, либо не трогать дескриптор вообще и очищать файл на месте. Первый путь опирается на create и скрипт postrotate с сигналом, второй на copytruncate.

Цена забытого сигнала наглядна: приложение дописывает в app.log.1, свежий app.log пуст, и система сбора перестаёт видеть события сразу после первой ротации, хотя в конфигурации не хватает одной строки.

Параметр copytruncate и компромисс между простотой и риском потери строк

copytruncate меняет механику полностью. logrotate копирует содержимое журнала в новый файл, после чего обрезает оригинал до нуля прямо на месте. Дескриптор в приложении остаётся валидным, позиция записи сбрасывается, и работа продолжается без единого сигнала и без остановки службы. Такой вариант выручает, когда приложением управлять нельзя: закрытый бинарник, контейнер без перезагрузки конфигурации или сторонний продукт, чей способ переоткрытия журналов неизвестен.

Слабое место подхода задокументировано честно: между завершением копирования и обрезкой файла проходит пусть короткий, но реальный интервал. Строки, попавшие в журнал за этот интервал, исчезают безвозвратно. При миллионах строк в час даже окно длиной в долю секунды вмещает измеримый объём. Сузить окно нельзя, остаётся принять его осознанно и оценивать потери по факту.

Второй расход: пока копия снимается, на диске лежат два полных экземпляра журнала, и для гигабайтного файла это заметное время и нагрузка на ввод-вывод. Лимит размера в связке с copytruncate ограничивает и разовый расход места, и длительность окна. Рабочая конфигурация выглядит так:

/var/log/myapp/app.log {
    hourly
    rotate 72
    maxsize 500M
    copytruncate
    compress
    missingok
    notifempty
    dateext
}

Параметр create здесь не нужен: новый файл не создаётся, оригинал лишь очищается, и совмещать оба подхода нет смысла.

Сжатие копий через compress и отсрочка delaycompress на один цикл ротации

Сжатие включается словом compress: после ротации копия ужимается архиватором gzip, и выигрыш существенный: текстовые журналы с повторяющимися фрагментами сжимаются в разы. Архиватор при необходимости меняется параметром compresscmd, расширение архива задаёт compressext, дополнительные флаги архиватора принимает compressoptions.

Проблема сидит в моменте. Копия сжимается сразу после ротации, а приложение реагирует на сигнал не мгновенно, и gzip может начать читать файл, который процесс ещё держит открытым. Хвост строк, дописанных между чтением файла архиватором и заменой его архивом, пропадает. Ровно для этого существует delaycompress: параметр запрещает трогать самую свежую копию и откладывает её сжатие на один цикл. За отложенный час приложение переоткрывает журнал, и к сжатию старая копия закрыта со всеми строками.

У delaycompress есть второе применение: конвейеры постобработки забирают самую свежую копию в открытом виде, от парсеров ошибок до выгрузок в аналитику, и отложенное сжатие даёт им окно, когда новейший журнал читается без распаковки. В связке с copytruncate параметр обычно лишний: приложение никогда не дописывает в копию, и она сразу самостоятельный файл.

Полная схема с переоткрытием файла собирается так:

/var/log/myapp/app.log {
    hourly
    rotate 168
    maxsize 500M
    missingok
    notifempty
    compress
    delaycompress
    dateext
    create 0640 myapp myapp
    sharedscripts
    postrotate
        /usr/bin/systemctl reload myapp.service || true
    endscript
}

Расшифровка добавленных строк: create 0640 myapp myapp создаёт новый файл журнала с правами и владельцем, под которыми работает приложение; sharedscripts выполняет скрипт один раз на всю группу журналов, что важно при маске с несколькими файлами; postrotate выполняет команду сразу после ротации, endscript закрывает блок.

Почасовая ротация, лимит размера и арифметика дискового пространства

При плотном потоке расписание строится на сочетании периодичности и лимита. Параметр size 500M предписывает ротацию при достижении порога и игнорирует время: частота привязана только к скорости записи. Параметр maxsize 500M работает мягче: расписание остаётся главным, но между плановыми запусками утилита проверяет размер и ротирует досрочно, когда журнал перерос порог. Для почасовой схемы с всплесками нагрузки уместнее второй вариант.

Арифметика места на примере: приложение пишет 300 МБ в час, около 7,2 ГБ чистого текста за сутки. При rotate 168 хранится неделя копий. Часовая копия после сжатия весит заметно меньше исходной, возьмём для расчёта 30 МБ на архив как осторожную оценку. Неделя занимает около 5 ГБ вместо 50 ГБ сырых копий, и в этом весь смысл compress. Резерв под самый большой часовой файл обязателен: в момент сжатия на диске лежат и полная копия, и строящийся архив, а к запасу добавляют скачки трафика.

Штамп даты из dateext по умолчанию различает сутки, и при почасовой ротации копии одного дня конфликтовали бы именами. Формат штампа расширяют часами через dateformat, тогда каждая копия получает уникальное имя. Когда часовой детализации мало, журнал делят на несколько файлов средствами самого приложения, а logrotate обрабатывает их одним правилом.

Отдельно настраивают владельца. Если служба работает под собственным пользователем и каталог журналов принадлежит ей, параметр su myapp myapp предписывает утилите выполнять операции от нужного владельца. Без него создание нового файла от имени суперпользователя порой ломает приложению доступ к собственному журналу, и поиск причины отнимает вечер.

Скрипт postrotate и сигнал приложению переоткрыть файл журнала

Скрипт между postrotate и endscript выполняется после переименования и создания нового файла и закрывает дескрипторную проблему. Команда внутри зависит от приложения. Службы под управлением systemd чаще всего принимают systemctl reload myapp.service: команда отправляет модулю сигнал перезагрузки из его конфигурации, типовым является SIGHUP. У сетевых служб встречаются собственные команды переоткрытия журналов, например nginx -s reopen, которая сообщает основному процессу, что файлы сменились.

Приложение без встроенной механики переоткрытия дорабатывается на стороне разработки: обработчик SIGHUP или SIGUSR1 закрывает текущий дескриптор и открывает журнал по имени заново. Несколько строк кода снимают риски copytruncate полностью. Если доработать нельзя, выбор copytruncate становится осознанным решением.

Суффикс || true в скрипте страхует от ложных ошибок: когда служба в момент ротации остановлена, reload вернёт неудачу, и logrotate отчитается о проблеме, хотя ротация уже завершена. Диагностика пропавшего сигнала проста: app.log пуст, app.log.1 растёт, lsof показывает, что процесс держит переименованный файл. Исправление: отправить сигнал вручную или перезапустить службу, затем поправить конфигурацию ротации.

Рядом живут другие скриптовые блоки: firstaction выполняется до ротации всей группы, lastaction после завершения операций, туда удобно ставить уведомление метрик или выгрузку архивов в холодное хранилище.

Проверка конфигурации в отладочном режиме и контроль файла состояния

Внедрение начинается с сухого прогона. Команда logrotate -d /etc/logrotate.conf распечатывает предполагаемые действия по каждому журналу и не меняет ни одного файла: утилита лишь печатает план. В выводе ищут блок приложения: журнал должен быть распознан, а расписание посчитано. Команда logrotate -v включает подробный вывод, logrotate -f /etc/logrotate.d/myapp ротирует журнал немедленно, не дожидаясь расписания. Принудительный прогон показывает всю цепочку: имена копий, права файла, реакцию приложения.

Файл состояния хранит моменты последних ротаций и лежит в /var/lib/logrotate. Для экспериментов состояние переопределяют ключом -s: отдельный файл состояния превращает пробную конфигурацию в песочницу и не сбивает боевое расписание. Если записи о журнале нет, утилита считает его ни разу не ротированным, и после чистки файла состояния первая ротация затронет все журналы сразу.

Порядок внедрения настройки выглядит так:

  1. Создать файл /etc/logrotate.d/myapp с параметрами под поведение приложения;
  2. Проверить синтаксис прогоном logrotate -d /etc/logrotate.conf и найти блок приложения в выводе;
  3. Выполнить logrotate -f /etc/logrotate.d/myapp и убедиться, что копия появилась под ожидаемым именем;
  4. Сверить владельца и режим нового файла журнала с ожиданиями приложения;
  5. Проверить, что приложение продолжает запись в новый файл, а копия не растёт;
  6. Проследить расход места несколько циклов и сверить его с расчётом.

За порядком стоит принцип постепенности: каждая ступень проверяется до перехода к следующей, откат всегда очевиден, а скрытые допущения не копятся к первому сбою.

Рабочая схема ротации при миллионах строк в час сводится к трём решениям. Если приложение умеет переоткрывать журнал по сигналу, надёжной остаётся связка create с postrotate и delaycompress: копия ужимается ровно тогда, когда запись в неё прекращена. Если приложением управлять нельзя, выручает copytruncate, а риск смягчается плотностью расписания и лимитом размера. Почасовой запуск утилиты, посчитанный запас места и отладочный прогон перед внедрением превращают logrotate в предсказуемый инструмент, который годами работает молча. В администрировании скучная, молчаливая работа механизма и есть высшая форма надёжности.