Собственный сервис месяцами пишет журнал в один и тот же файл, и однажды утром мониторинг сообщает, что раздел /var заполнен почти целиком. Архив, который мог бы выручить при разборе инцидента, вместо этого сам становится проблемой: его не открыть в редакторе, а за ночь он прибавляет гигабайт. Штатный logrotate закрывает задачу целиком, но его настройки рассчитаны на пакеты вроде apt и системные журналы, а самодельное приложение ведёт себя иначе: держит файл открытым сутками, не знает про сигналы и пишет без остановки. Правило ротации под такой сервис собирается за один вечер, если заранее понимать три вещи: где утилита хранит память о прошлых запусках, чем create с postrotate отличается от copytruncate и почему сжатие иногда обязано опаздывать на сутки. Дальше всё это разобрано на живом примере.

Как logrotate устроен внутри и где он помнит прошлые ротации

logrotate не служба и не постоянно живущий процесс: утилита запускается по расписанию, за секунды делает работу и завершается. На классических выпусках Debian и Ubuntu расписание держит скрипт /etc/cron.daily/logrotate, на системах с systemd ту же роль играет таймер logrotate.timer, который срабатывает раз в сутки. На свежих выпусках Ubuntu скрипт остаётся подстраховкой и молча уступает таймеру. Свежая стабильная версия утилиты имеет номер 3.22.0 и вышла в июне 2024 года, но за годы развития синтаксис правил почти не менялся, конфиг из старых руководств работает на новых выпусках без правок.

logrotate --version
systemctl status logrotate.timer
head -15 /etc/cron.daily/logrotate

Память о прошлых ротациях лежит в файле состояния /var/lib/logrotate/status, на старых системах он называется /var/lib/logrotate.status. По датам из него утилита решает, какие журналы пора переносить в архив. Потеря файла оборачивается сюрпризом: утилита считает, что ротаций не было, и за ночь перекладывает все журналы сервера разом, поэтому состояние бэкапят вместе с каталогом /etc.

Общий конфиг живёт в /etc/logrotate.conf, отдельные правила складываются в каталог /etc/logrotate.d, куда главный файл ходит через include. Своё правило принято класть туда же отдельным файлом, но тут прячется подводный камень: файлы с расширениями .rpmnew, .dpkg-dist, .old, .swp из встроенного списка игнорируются молча. Список настраивается директивой tabooext и защищает каталог от временных файлов редакторов, а случайно сохранённая с таким расширением копия правила однажды съест час на поиск причины.

ls /etc/logrotate.d
head -5 /var/lib/logrotate/status

Каркас правила для своего сервиса с сигналом приложению о новом файле

Пусть есть самописный сервис myapp, который пишет журнал в /var/log/myapp/app.log и умеет переоткрывать файл по сигналу HUP. Полное правило помещается в файл /etc/logrotate.d/myapp:

/var/log/myapp/app.log {
    daily
    rotate 14
    maxsize 100M
    missingok
    notifempty
    compress
    delaycompress
    dateext
    dateformat -%Y%m%d
    create 0640 myapp myapp
    sharedscripts
    postrotate
        /bin/systemctl kill -s HUP --kill-who=main myapp.service
    endscript
}

Чтение сверху вниз: daily просит ротацию раз в сутки, rotate 14 оставляет на диске не больше четырнадцати архивов, maxsize 100M разрешает перенести журнал раньше срока, если тот дорос до ста мегабайт. missingok не превращает ночной запуск в аварию, когда файла нет, notifempty не создаёт архив из пустышки. Пара compress с delaycompress сжимает все архивы, кроме самого свежего. dateext ставит в имя дату вместо номера, create 0640 myapp myapp создаёт новый файл с нужными правами, а блок postrotate отправляет службе сигнал переоткрыть журнал. sharedscripts гарантирует один сигнал на всё правило, даже когда шаблон пути раскроется в несколько файлов.

Помимо postrotate есть ещё три крючка. prerotate выполняется до ротации, его ненулевой код возврата отменяет перенос файла: так строятся условия вроде "не трогать журнал, пока идёт копирование". firstaction и lastaction срабатывают один раз до и после работы правила.

Copytruncate против create с postrotate и где теряются строки

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

Честное решение: create вместе с сигналом в postrotate. Служба, получив HUP или USR1, переоткрывает путь и продолжает работу в новом файле: nginx умеет это по USR1, sshd перечитывает конфигурацию по HUP. Для собственного сервиса достаточно отправить сигнал главному процессу по pid-файлу:

kill -HUP $(cat /run/myapp/myapp.pid)

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

Правило для приложения, которое нельзя заставить переоткрывать файл, выглядит так:

/var/log/myapp/app.log {
    daily
    rotate 30
    copytruncate
    compress
    missingok
    notifempty
}

Ротация по расписанию и по размеру с расчётом места под архив

Расписание задаётся одной строкой, но семантика у каждой частоты своя. daily переносит журнал, если с прошлой ротации прошли сутки. weekly работает тоньше: файл переносится в заданный день недели либо когда от прошлой ротации отделилось не меньше семи дней. hourly требует отдельной оговорки: штатный запуск случается раз в сутки, поэтому почасовая ротация заработает только после собственного таймера или строки в cron, иначе реальная частота останется суточной. Документация предупреждает и другое: один журнал не переносится дважды за сутки, исключения размерный критерий при частых запусках и ключ принуждения.

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

Арифметика диска проверяется до аварии, на салфетке. Пусть сервис пишет 300 мегабайт в сутки, gzip сжимает текстовый журнал примерно в восемь раз, rotate 14 держит две недели глубины. Пик: свежий несжатый архив 300 мегабайт, тринадцать упакованных по 40 и растущий текущий файл, итого около полутора гигабайт. Директива maxage 60 страхует по возрасту и удаляет архивы старше двух месяцев, даже когда счётчик rotate позволил бы держать их дольше.

/var/log/myapp/app.log {
    weekly
    rotate 8
    minsize 10M
    maxage 60
    compress
    su myapp myapp
}

Сжатие архивов с задержкой на один цикл и даты в именах файлов

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

    compress
    compresscmd /usr/bin/zstd
    compressoptions -3
    compressext .zst
    delaycompress

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

dateext меняет схему имён: вместо app.log.1 появляется app.log-20261008. Дата в имени удобна глазами и для выгрузки в хранилище, но если ротация случается чаще раза в сутки, имена совпадают и перенос блокируется. Формат правится директивой dateformat по правилам strftime, разрешены %Y, %m, %d, %H, %M, %s и другие; у почасовой ротации часы в имени стоят по умолчанию, в своей схеме их добавляют руками:

    dateext
    dateformat -%Y%m%d-%H%M

Права на файл журнала и директива su для непривилегированных сервисов

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

Права важны и сами по себе: журналы содержат токены и адреса, режим 0640 с владельцем-приложением закрывает их от чужих глаз, а 0666 на многопользовательском сервере превращает журнал в открытую книгу. Для отдельного каталога с архивами olddir уводит старьё в сторону, а createolddir создаёт каталог заранее. Ограничение честное: каталог обязан жить на той же файловой системе, что и журнал, иначе перенос превращается в копирование.

И отдельно про shred: директива затирает удаляемые архивы перед удалением, что осмысленно для чувствительных журналов. На гигабайтных файлах она заметно удлиняет ночь, поэтому включать её стоит осознанно.

Проверка правила вхолостую и принудительная ротация без ожидания ночи

Дожидаться ночного запуска, чтобы понять, работает ли правило, не обязательно. Ключ --debug включает сухой режим: утилита рассказывает, какие файлы рассматривает, что собирается делать и почему решила пропустить журнал, но ничего не меняет и не трогает файл состояния, честно предупреждая об этом при каждом запуске.

logrotate -d /etc/logrotate.d/myapp
logrotate -d /etc/logrotate.conf

Типичный фрагмент сухого прогона с принуждением выглядит так:

considering log /var/log/myapp/app.log
  log needs rotating
dateext suffix '-20261009'
renaming /var/log/myapp/app.log to /var/log/myapp/app.log-20261009
creating new /var/log/myapp/app.log mode = 0640 uid = 1001 gid = 1001
running postrotate script

Запуск по общему конфигу обязателен: он показывает, как правило вписалось в глобальные настройки. Если соседний пакет уже описывает тот же файл, отладка ругнётся ошибкой "duplicate log entry", и один из шаблонов придётся сузить. Порядок конфигов имеет значение: прочитанные позже настройки перекрывают ранние, и правило с широким шаблоном способно перехватить чужой журнал.

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

logrotate -f /etc/logrotate.d/myapp
logrotate -f -s /tmp/test.state /etc/logrotate.d/myapp
logrotate -v /etc/logrotate.conf

Внедрение правила на живом сервере сводится к короткой последовательности шагов:

  1. положить файл в /etc/logrotate.d под именем, не совпадающим с пакетными, и вычитать его от первой строки до endscript;
  2. прогнать сухой режим по своему файлу и по общему конфигу, добиваясь чистого вывода без предупреждений;
  3. принудительно ротировать журнал и убедиться, что приложение переоткрыло файл и продолжает писать;
  4. проверить права нового файла, а также имя и размер свежего архива;
  5. добавить в мониторинг два порога: занятое место в каталоге журналов и свежесть последней записи;
  6. зафиксировать решение в документации сервера, чтобы следующий инженер не изобретал правило заново.

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