АйТи Фреш
Главная / Статьи / Серверы и хостинг
Серверы и хостинг

Файл удалён, а место не вернулось: как найти процесс, который держит удалённый лог

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~31 мин чтения
Файл удалён, а место не вернулось: как найти процесс, который держит удалённый лог
Иллюстрация к статье «Файл удалён, а место не вернулось: как найти процесс, который держит удалённый лог».

Ночью прилетает алерт: `/var` занят на 100 %. Дежурный удаляет самый большой лог, но свободного места не прибавляется. Повторная команда сообщает, что файла уже нет, `du` видит считаные гигабайты, а приложение падает с `No space left on device`. Я разбирал такие инциденты не раз: обычно процесс продолжает держать открытый дескриптор на файл, у которого уже нет имени в каталоге. Ниже покажу механику без мистики, точную диагностику через `lsof` и `/proc`, безопасный порядок освобождения места и профилактику, которая не даст сценарию повториться.

Почему rm убирает имя, но не всегда освобождает блоки

Начну с механики, потому что без неё расхождение показаний выглядит поломкой файловой системы. Имя в каталоге и содержимое обычного файла в Linux — не одно и то же. Каталожная запись связывает имя с инодом, а инод описывает объект и его данные. Удаляя файл, rm в итоге использует системный вызов семейства unlink: запись каталога исчезает, а счётчик жёстких ссылок уменьшается. Само по себе это ещё не означает, что занятые блоки уже можно отдать другому файлу.

В unlink(2) правило сформулировано однозначно: если удалено последнее имя, но файл остаётся открытым хотя бы у одного процесса, объект живёт до закрытия последнего относящегося к нему файлового дескриптора. Точнее говорить не о двух пользовательских «счётчиках», а о двух необходимых условиях освобождения: на файл не должно остаться жёстких ссылок и ссылок из открытых файловых описаний либо иных удерживающих объектов ядра. Поэтому nginx, rsyslog, коллектор событий или самописный демон может продолжать писать в безымянный инод, который уже нельзя открыть по прежнему пути.

Отсюда расходятся df и du. Утилита du обходит доступное дерево каталогов и суммирует найденные объекты; удалённого имени в дереве уже нет. Утилита df получает статистику выделенных и свободных блоков самой файловой системы, поэтому продолжает учитывать удерживаемые данные. Для сравнения я запускаю du с правами root и с ключом -x, чтобы ошибки доступа и переходы на вложенные файловые системы не исказили картину.

df -h /var
# Filesystem            Size  Used Avail Use% Mounted on
# /dev/mapper/vg0-var    59G   59G     0 100% /var

sudo du -xsh /var
# 6.2G    /var

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

Есть и важная терминологическая ловушка. Сообщение No space left on device соответствует ошибке ENOSPC, но она возникает не только при исчерпании блоков: могут закончиться иноды, квота или свободное место в нижележащем thin pool. Поэтому расхождение df и du — это симптом. Диагноз подтверждает список открытых файлов с нулевым числом ссылок либо просмотр дескрипторов в procfs.

Увидели заметное расхождение `df` и `du` — остановите дальнейшее удаление и сохраните диагностику. Само расхождение ещё не доказывает причину, но удаление новых файлов способно увеличить ущерб, не дав ни одного свободного блока.

Разбор из практики: один инод на 34,9 GiB и шесть дескрипторов

Условный клиент — юридическая фирма «Право и партнёры», 25 сотрудников, у нас на абонентском обслуживании. В ЦОД работает виртуальная машина Debian 12 с 4 vCPU и 8 GiB оперативной памяти; под /var выделен отдельный LVM-том номинальным объёмом 60 GB. На сервере стоят пакетная ветка nginx 1.22.1 и PHP-FPM 8.2, обслуживающие клиентский портал, а агент Vector читает access-лог и отправляет события в аналитику. В 03:40 портал начал отвечать кодом 502. Дежурный увидел заполненный /var и удалил access-лог размером 37 480 202 240 байт — это примерно 34,9 GiB. Место не появилось; затем он удалил пару архивов, перезапустил PHP-FPM и связался со мной.

Диагностика заняла около восьми минут. df показывал 100 % и нулевое доступное место, а sudo du -xsh /var — 6,2 GiB. Поскольку /var здесь является точкой монтирования, я ограничил поиск этой файловой системой. Если проблемный путь не является точкой монтирования, сначала нужно получить её через findmnt, а уже затем передать найденный путь в lsof.

findmnt -no TARGET --target /var/log/nginx
sudo lsof -nP +aL1 /var
# COMMAND   PID     USER   FD   TYPE DEVICE     SIZE/OFF NLINK  NODE NAME
# nginx    1186     root    5w   REG  253,2  37480202240     0 26214 /var/log/nginx/access.log (deleted)
# nginx    1187 www-data    5w   REG  253,2  37480202240     0 26214 /var/log/nginx/access.log (deleted)
# nginx    1188 www-data    5w   REG  253,2  37480202240     0 26214 /var/log/nginx/access.log (deleted)
# nginx    1189 www-data    5w   REG  253,2  37480202240     0 26214 /var/log/nginx/access.log (deleted)
# nginx    1190 www-data    5w   REG  253,2  37480202240     0 26214 /var/log/nginx/access.log (deleted)
# vector   2043   vector   27r   REG  253,2  37480202240     0 26214 /var/log/nginx/access.log (deleted)

Ключевые поля здесь — NLINK и NODE. Нулевой NLINK означает, что жёстких ссылок на инод больше нет. Одинаковый NODE 26214 и одно устройство 253,2 показывают, что шесть строк относятся к одному объекту, а не к шести отдельным файлам по 34,9 GiB. Суммировать SIZE/OFF построчно нельзя: получится шестикратное завышение. Более того, SIZE/OFF сообщает логический размер; для разрежённого файла он может быть намного больше реально выделенного места.

Сначала я попросил nginx штатно переоткрыть журналы: послал USR1 master-процессу по PID из /run/nginx.pid. Официальная документация nginx именно этот сигнал связывает с переоткрытием логов; то же действие выражает управляющая команда nginx -s reopen. Master- и worker-процессы закрыли старые ссылки и открыли новый файл по настроенному пути, но свободное место не изменилось. Старый инод всё ещё удерживал Vector на чтение.

sudo kill -USR1 "$(cat /run/nginx.pid)"
sudo lsof -nP +aL1 /var
sudo systemctl restart vector.service
df -h /var

После перезапуска vector.service последний дескриптор закрылся, примерно 34,9 GiB вернулись файловой системе, а заполнение /var упало до 12 %. Важен порядок: повторный запуск lsof после каждого штатного действия показывает, остался ли читатель. Фраза «сигнал не помог» была бы неверной — nginx свою часть выполнил, просто он не был единственным держателем.

Корневая причина оказалась прозаичной. Правило logrotate запускало ротацию раз в неделю, хранило четыре архива и не ограничивало размер активного файла. После всплеска автоматических обращений портал стал писать по 4–5 GiB событий в сутки, поэтому лог рос быстрее расписания. Триггер Zabbix на 90 % заполнения отключили несколькими месяцами ранее из-за частых уведомлений. В итоге возврат места занял около 40 секунд после постановки диагноза, а ещё примерно полчаса ушло на ротацию и мониторинг, которых не хватало изначально.

Держатель часто не один. Кроме основного сервиса старый инод могут удерживать коллектор логов, агент мониторинга или забытый `tail -F`; проверяйте список после каждого закрытия дескрипторов.
Файл удалён, а место не вернулось: как найти процесс, который держит удалённый лог — схема
Схема к статье. Открыть схему в полном размере

Как найти держателя через lsof и не ошибиться в полях

Основной инструмент — lsof с опцией +L. Согласно руководству, +L включает колонку числа ссылок, а число после опции оставляет объекты со счётчиком меньше указанного значения. Поэтому +L1 выбирает открытые файлы с нулевым числом ссылок. Комбинация +aL1 логически связывает этот критерий с указанной файловой системой. Передавать следует её точку монтирования, а не произвольный каталог внутри неё.

# По всей системе
sudo lsof -nP +L1

# По файловой системе, смонтированной в /var
sudo lsof -nP +aL1 /var

# Быстрый список уникальных DEVICE:NODE из обычного табличного вывода
sudo lsof -nP +aL1 /var | \
  awk 'NR > 1 && !seen[$6 ":" $9]++ {print $7, $1, $2, $6 ":" $9}' | \
  sort -nr

# Строки конкретного инода; при +L поле NODE здесь девятое
sudo lsof -nP +aL1 /var | awk 'NR == 1 || $9 == 26214'

Ключ -n запрещает преобразование сетевых адресов в имена, а -P — номеров портов в названия сервисов. Для регулярных файлов второй ключ не меняет смысл результата, но связка -nP делает системный обход предсказуемее, если в выдачу попадут сокеты. Полный системный поиск может быть тяжёлым на хосте с тысячами процессов; ограничение конкретной файловой системой обычно уменьшает шум. Запускаю команду с повышенными правами: обычный пользователь видит не все чужие процессы, а дополнительные ограничения procfs, LSM или контейнеризации могут сузить обзор даже для диагностического окружения.

В табличном выводе легко промахнуться на одну колонку. Когда +L добавляет NLINK, устройство находится в шестом поле, размер — в седьмом, число ссылок — в восьмом, инод NODE — в девятом, а имя начинается с десятого. Такой разбор годится для быстрой проверки знакомого формата. Для долговременной автоматизации я предпочитаю procfs или структурированный field output lsof -F, потому что пробелы в имени и различия платформ делают разбор красивой таблицы хрупким.

Версии здесь тоже важны, потому что в исходных памятках часто встречаются выдуманные номера. На дату проверки актуальный опубликованный upstream-релиз lsof — 4.99.5, а не 4.99.7. В Debian 12 пакет имеет версию 4.95.0-1, в Ubuntu 24.04 — 4.95.0-1build3. Для нашей задачи обновление не требуется: синтаксис +L1 и +aL1 присутствует в этих пакетах. Утверждение о появлении JSON-вывода в несуществующем релизе я убрал; для машинного чтения у lsof давно есть формат полей -F, но это не JSON.

Путь с суффиксом (deleted) в Linux полезен как историческая подсказка, но это не путь к существующей каталожной записи. Команда по старому имени ничего не найдёт. Доступ к самому объекту сохраняется через дескриптор процесса, например /proc/1187/fd/5, пока процесс жив и проверка прав разрешает переход. Именно поэтому нельзя перезапускать подозреваемый сервис до того, как вы решили, нужно ли спасать содержимое и сохранили диагностику.

В примере с колонкой `NLINK` инод находится в девятом поле, а восьмое поле содержит число ссылок. Ошибка `$8 == 26214` никогда не найдёт нужный `NODE`; корректная быстрая проверка использует `$9 == 26214`.
Порядок действий: Как найти держателя через lsof и не ошибиться в полях — схема
Порядок действий: Как найти держателя через lsof и не ошибиться в полях. Открыть схему в полном размере

Диагностика без lsof: точный обход /proc с дедупликацией

Если lsof не установлен, я не начинаю установку пакетов на уже заполненном томе. В Linux открытые дескрипторы процесса представлены записями /proc/PID/fd/N. Это специальные procfs-ссылки: readlink обычно показывает прежнее имя и пометку (deleted), а stat -L получает параметры самого открытого объекта. Доступ к разыменованию проверяется ядром в режиме PTRACE_MODE_READ_FSCREDS, поэтому такой обход нужно выполнять от root и учитывать ограничения namespace, hidepid и политик безопасности.

Ниже рабочий вариант скрипта для GNU/Linux. Он дедуплицирует объекты по паре «номер устройства:инод», отдельно показывает логический размер и оценку выделенных блоков, а в режиме --bytes печатает только сумму выделенных байтов. Для обычного лога на ext4 эта сумма ближе к тому, сколько освободится после закрытия, чем простое сложение логических размеров. На файловых системах с компрессией, дедупликацией или разделяемыми extents это всё равно оценка, а не бухгалтерская гарантия.

#!/usr/bin/env bash
# /usr/local/sbin/deleted-open-scan
set -u
shopt -s nullglob
declare -A seen
total=0

for fd in /proc/[0-9]*/fd/*; do
    link=$(readlink -- "$fd" 2>/dev/null) || continue
    [[ $link == *" (deleted)" ]] || continue

    values=$(stat -L -c '%d:%i %s %b %B' -- "$fd" 2>/dev/null) || continue
    read -r key logical blocks block_size <<< "$values"
    [[ ${seen[$key]+present} ]] && continue
    seen[$key]=1

    allocated=$((blocks * block_size))
    total=$((total + allocated))
    pid=${fd#/proc/}
    pid=${pid%%/*}
    comm=$(tr -d '\n' < "/proc/$pid/comm" 2>/dev/null)

    if [[ ${1-} != --bytes ]]; then
        printf '%9d MiB allocated  %9d MiB logical  pid=%-7s %-16s %s\n' \
            "$((allocated / 1048576))" "$((logical / 1048576))" \
            "$pid" "$comm" "${link% (deleted)}"
    fi
done

if [[ ${1-} == --bytes ]]; then
    printf '%s\n' "$total"
else
    printf 'ИТОГО выделено: %d MiB\n' "$((total / 1048576))"
fi

В этом варианте нет ошибки исходного черновика: имя процесса не разрывает строку, потому что завершающий перевод строки из /proc/PID/comm удаляется. Форматы GNU stat тоже выбраны осознанно: %d — номер устройства, %i — инод, %s — логический размер, %b — число выделенных блоков, %B — размер блока, используемого для %b. Человекочитаемый вывод округляется вниз до целых MiB, а режим --bytes возвращает целое число байтов для мониторинга.

Скрипт показывает одного произвольного держателя на уникальный инод. Чтобы увидеть все процессы, относящиеся к объекту, после обнаружения ключа возвращаюсь к lsof либо просматриваю ссылки нужного PID. Между чтением ссылки и вызовом stat процесс может закрыть fd; конструкция || continue корректно пропускает эту нормальную гонку. Если отчёт пуст, это не отменяет проверку инодов, снимков, квот и скрытых точками монтирования файлов.

Отдельно проверяю удалённые исполняемые файлы и отображённые библиотеки после обновления пакетов. Они могут отображаться с типами DEL, txt или mem; объём обычно скромнее гигантского access-лога, но старый процесс действительно продолжает использовать прежний объект до перезапуска. Утилита needrestart, если она установлена в дистрибутиве, помогает перечислить процессы со старыми библиотеками, однако сама по себе не заменяет планирование безопасного рестарта.

sudo lsof -nP | awk '$4 ~ /^(DEL|txt|mem)/ && /\(deleted\)/'
На CoW-файловых системах и при разделяемых extents даже сумма `%b × %B` не обязана совпасть с будущим приростом `Avail`: блоки могут оставаться нужны снимку или другому extent. Для обычного удалённого лога на ext4 это хорошая оперативная оценка.

Как освободить место: от штатного reopen до truncate

Я иду от обратимого действия к более грубому. Сначала прошу именно сервис-писатель переоткрыть лог штатным способом. Для nginx подходят nginx -s reopen и сигнал USR1 master-процессу. Для rsyslog сигнал HUP используется, в частности, чтобы закрыть файловые выходы после ротации; способ отправки лучше брать из пакетного правила logrotate конкретного дистрибутива. Для journald применяются собственные операции ротации и очистки, а не произвольное удаление активного файла.

# nginx: один из двух штатных способов
sudo nginx -s reopen
# либо
sudo kill -USR1 "$(cat /run/nginx.pid)"

# rsyslog: сигнал основному процессу через systemd
sudo systemctl kill --kill-whom=main --signal=HUP rsyslog.service

# journald: сначала архивировать активные журналы, затем чистить архив
sudo journalctl --rotate --vacuum-size=1G

У journalctl параметры можно объединить в одном вызове: активные journal-файлы сначала становятся архивными, после чего --vacuum-size=1G удаляет самые старые архивы, пока их общий размер не опустится ниже заданного предела. Активные файлы vacuum не удаляет. Это штатная уборка журнала, но она не универсальная команда для любого удалённого лога и не должна подменять поиск чужого держателя.

Если штатного reopen нет, а перезапуск сейчас дороже потери содержимого уже удалённого append-only лога, можно обрезать объект через /proc/PID/fd/N. До этого я копирую нужные данные на другой том, проверяю, что fd относится к ожидаемому устройству и иноду, и читаю /proc/PID/fdinfo/N. Поле flags выводится восьмеричным числом. Бит O_APPEND имеет восьмеричное значение 02000; проверять лучше битовой операцией, а не поиском подстроки.

cat /proc/1187/fdinfo/5
# pos:    37480202240
# flags:  0102001

flags=$(awk '/^flags:/ {print $2}' /proc/1187/fdinfo/5)
if (( (8#$flags & 8#2000) != 0 )); then
    echo 'O_APPEND установлен'
else
    echo 'O_APPEND не установлен'
fi

# Необязательное спасение содержимого на другом томе
sudo cp /proc/1187/fd/5 /mnt/rescue/access.log

# Безвозвратно обнуляет содержимое выбранного объекта
sudo truncate -s 0 /proc/1187/fd/5

Почему O_APPEND важен: перед каждой записью ядро переносит смещение в текущий конец файла и выполняет запись как единый шаг. Если append-флага нет, открытое файловое описание может сохранить позицию 37 480 202 240. После обрезки следующая обычная запись по старой позиции создаст большую дыру; логический размер снова станет около 34,9 GiB, хотя реально выделенных блоков будет мало. Это не возвращает старые данные, а лишь объясняет странную пару показаний: большой размер в ls -l и небольшой объём в du.

Даже при O_APPEND слово «безопасно» относится только к риску старого смещения, а не к содержимому. truncate -s 0 уничтожает данные без возможности отмены и подходит лишь для понятного однонаправленного лога, который вы сознательно готовы потерять. Для базы данных, файла очереди, индекса, сокета, mmap-объекта или неизвестного fd этот приём запрещён. Если есть сомнение, короткий управляемый перезапуск службы надёжнее.

После каждого действия я снова запускаю lsof и df. Если процесс можно остановить в согласованное окно, обычный systemctl restart закрывает его дескрипторы предсказуемо. SIGKILL всем строкам из выдачи и перезагрузка сервера в качестве первого шага плохи не потому, что никогда не освобождают место, а потому что лишают нас улик и могут оборвать незавершённую работу приложений. Перезагрузка остаётся аварийным выходом, когда сервисами уже нельзя управлять и простой согласован.

Наличие `O_APPEND` не делает обрезку безвредной: она всё равно навсегда уничтожает содержимое лога. Этот флаг лишь гарантирует, что последующая append-запись пойдёт в новый конец, а не по сохранённому далёкому смещению.
Памятка: Как освободить место: от штатного reopen до truncate — схема
Памятка: Как освободить место: от штатного reopen до truncate. Открыть схему в полном размере

Если lsof пуст: иноды, резерв ext4, точки монтирования и снимки

Расхождение df и du — симптом, поэтому после пустого +L1 я прохожу дифференциальную диагностику. Сначала смотрю и блоки, и иноды. Сто процентов IUse% при наличии свободных гигабайтов означает не большой скрытый лог, а исчерпание инодов из-за множества мелких объектов: сессий, очередей, кеша или временных файлов. Удаление одного крупного файла тогда может вообще не вернуть возможность создавать новые имена.

df -h /var
df -i /var
findmnt -no SOURCE,FSTYPE,TARGET --target /var

Следующая проверка относится только к ext2, ext3 и ext4. tune2fs сообщает, что обычно при создании файловой системы резервируется 5 % блоков для привилегированных процессов: это помогает системным демонам продолжить работу и уменьшает фрагментацию, когда обычным пользователям запись уже запрещена. Резерв не «пропал» и не является универсально лишним. На выделенном томе данных администратор иногда снижает долю до 1 %, но только после оценки назначения тома, темпа роста и аварийного запаса; на корневой файловой системе механически повторять это решение не стоит.

sudo tune2fs -l /dev/mapper/vg0-var | \
  grep -E 'Block count|Reserved block count|Block size'

# Пример осознанного изменения для отдельного ext4-тома данных
sudo tune2fs -m 1 /dev/mapper/vg0-var

Ещё одна причина — данные, скрытые вложенной точкой монтирования. Допустим, приложение сначала писало в каталог /var/log, а позднее поверх него смонтировали другой том. Обычный обход видит новое содержимое, тогда как старые файлы остаются в родительской файловой системе. Для просмотра я создаю временную точку и делаю обычный bind родительского монтирования /var: в отличие от recursive bind, такой вид не подтягивает вложенное монтирование /var/log. Делать это нужно в окно обслуживания и предварительно убедиться, что выбран правильный родитель.

sudo mkdir -p /mnt/under-var
sudo mount --bind /var /mnt/under-var
sudo du -xsh /mnt/under-var/log
sudo find /mnt/under-var/log -xdev -type f -size +100M -ls
sudo umount /mnt/under-var

Исходный совет всегда привязывать / и смотреть /mnt/var неверен для случая, когда /var уже отдельная файловая система: он покажет скрытый каталог на корневом томе, а не слой под /var/log. Привязывать нужно ту файловую систему, внутри которой находится перекрытая точка. Перед удалением найденных данных я сверяю findmnt, открытые файлы и резервную копию: скрытый слой может содержать не мусор, а единственный экземпляр журнала.

На ZFS и Btrfs удалённые данные могут оставаться выделенными из-за снимков — это штатная семантика copy-on-write. В OpenZFS свойство usedbysnapshots показывает объём, который освободился бы после удаления всех снимков набора данных; складывать used отдельных снимков нельзя, потому что блоки могут разделяться. У LVM thin снимки и клоны удерживают блоки thin pool, однако заполнение пула и заполнение файловой системы — разные уровни, поэтому сравниваю и df, и lvs. Снимки не удаляю автоматически: сначала выясняю их назначение, зависимости репликации и срок хранения.

zfs list -o name,used,referenced,usedbysnapshots
zfs list -t snapshot -o name,used,referenced,written -r pool/dataset
sudo lvs -a -o lv_name,lv_attr,data_percent,metadata_percent,pool_lv
Не применяйте `tune2fs` к ZFS, XFS или Btrfs и не уменьшайте резерв корневого ext4-тома по шаблону. Сначала подтвердите тип файловой системы и назначение резерва.

Профилактика: logrotate, journald и мониторинг без лишних прав

Найти держателя — половина работы. Для обычных файловых логов я начинаю с logrotate и проверяю не только расписание, но и частоту запуска самой утилиты. Директива maxsize 1G разрешает ротацию раньше временного интервала, если файл уже превысил 1 GiB; однако проверка происходит лишь при очередном запуске logrotate. Если таймер стартует раз в сутки, активный лог способен многократно перерасти предел между проверками.

/var/log/nginx/*.log {
    daily
    maxsize 1G
    rotate 14
    missingok
    notifempty
    compress
    delaycompress
    create 0640 www-data adm
    sharedscripts
    postrotate
        if [ -s /run/nginx.pid ]; then
            kill -USR1 "$(cat /run/nginx.pid)"
        fi
    endscript
}

Права в create и путь к PID нельзя копировать вслепую: они должны совпадать с пользователем worker-процесса, группой чтения логов и директивой pid на конкретном сервере. Перед внедрением я проверяю конфигурацию отладочным запуском, который не выполняет ротацию, затем принудительный прогон делаю только на тестовом файле или в согласованное окно. Директива copytruncate нужна лишь программе, которую невозможно попросить закрыть старый лог; руководство logrotate предупреждает о коротком промежутке между копированием и обрезкой, в котором записи могут потеряться. Для аудита я её не использую.

sudo logrotate --debug /etc/logrotate.conf

На горячем сервере меняю период системного таймера. У OnCalendar может быть несколько значений, поэтому пустая строка сначала сбрасывает поставляемое пакетом расписание, а следующая задаёт почасовое. После drop-in перезапускаю timer и проверяю фактическое расписание. Одной директивы hourly внутри правила logrotate недостаточно, если сам процесс запускается реже.

# /etc/systemd/system/logrotate.timer.d/hourly.conf
[Timer]
OnCalendar=
OnCalendar=hourly
sudo systemctl daemon-reload
sudo systemctl restart logrotate.timer
systemctl list-timers logrotate.timer

Для persistent journal настройки относятся к секции [Journal] и каталогу /var/log/journal. По документации systemd значения SystemMaxUse и RuntimeMaxUse по умолчанию рассчитываются как 10 % соответствующей файловой системы, но ограничиваются сверху 4 GiB; SystemKeepFree и RuntimeKeepFree рассчитываются как 15 % и тоже ограничиваются 4 GiB. Это не обещание, что vacuum мгновенно уложит весь журнал в лимит: удаляются только архивные файлы, а при почти полном диске эффективное поведение зависит от уже занятого места. Для небольшого /var я задаю пределы явно и после изменения перезапускаю journald в согласованное окно.

# /etc/systemd/journald.conf.d/limits.conf
[Journal]
SystemMaxUse=2G
SystemMaxFileSize=200M

Мониторинг через procfs нельзя бездумно выполнять непосредственно от пользователя zabbix: он не увидит многие чужие fd. Я запускаю приведённый выше scanner отдельным root-owned systemd timer раз в пять минут, атомарно кладу одно число в /run, а агент читает только этот файл. Так Zabbix не получает sudo и произвольный доступ к /proc. Файлы unit создаются с владельцем root и обычными правами конфигурации.

# /etc/systemd/system/deleted-open-scan.service
[Unit]
Description=Measure allocated blocks held by deleted open files

[Service]
Type=oneshot
ExecStart=/bin/sh -c '/usr/local/sbin/deleted-open-scan --bytes > /run/deleted-open-bytes.tmp && chmod 0644 /run/deleted-open-bytes.tmp && mv -f /run/deleted-open-bytes.tmp /run/deleted-open-bytes'
# /etc/systemd/system/deleted-open-scan.timer
[Unit]
Description=Run deleted-open file scan every five minutes

[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s

[Install]
WantedBy=timers.target
# /etc/zabbix/zabbix_agent2.d/deleted-open.conf
UserParameter=vfs.fs.deleted_open_bytes,cat /run/deleted-open-bytes
sudo systemctl daemon-reload
sudo systemctl enable --now deleted-open-scan.timer
sudo systemctl restart zabbix-agent2.service

Для предупреждения я обычно беру небольшую долю тома, а аварийный порог согласую с темпом роста и временем реакции: фиксированные 5 GiB одинаково плохо подходят 20-гигабайтному /var и терабайтному архиву. Отдельно оставляю обычный контроль свободного места и прогноз заполнения. Несколько сотен мегабайт старых библиотек после обновления не требуют ночной аварии, но должны попасть в плановый список перезапусков.

Не давайте пользователю Zabbix широкий sudo ради чтения чужих дескрипторов. Root-owned timer и атомарно обновляемый файл с одним числом дают нужную метрику без лишнего расширения привилегий.
Цифры и версии: Профилактика: logrotate, journald и мониторинг без лишних прав — схема
Цифры и версии: Профилактика: logrotate, journald и мониторинг без лишних прав. Открыть схему в полном размере

Контейнеры, NFS и процессы баз данных

В Docker с overlayfs симптом сохраняется, если приложение пишет обычный файл внутри writable layer: удалившая файл программа остаётся хостовым процессом с хостовым PID, поэтому на хосте её дескриптор виден через lsof и /proc. Сначала определяю файловую систему, содержащую каталог Docker, и передаю в +aL1 именно точку её монтирования. Это не следует путать со стандартными stdout/stderr-логами контейнера, которыми управляет logging driver Docker: для них путь и процедура зависят от выбранного драйвера.

docker container inspect --format '{{.State.Pid}} {{.Name}}' container_name
cat /proc/HOST_PID/cgroup
findmnt -no TARGET --target /var/lib/docker

docker container inspect официально поддерживает --format с Go-шаблоном; поле состояния помогает получить PID процесса контейнера на хосте. Строка /proc/HOST_PID/cgroup даёт дополнительную привязку к cgroup, но её вид зависит от cgroup v1 или v2, systemd и runtime, поэтому я не парсю её одним универсальным регулярным выражением. У дочернего процесса PID будет отличаться от PID 1 контейнера; сопоставление нужно подтверждать namespace и данными runtime, а не одной похожей строкой.

На NFS действует другой механизм. Linux NFS client при удалении ещё открытого файла обычно выполняет silly rename: оставляет скрытое имя вида .nfs..., чтобы объект жил до закрытия. У него сохраняется каталожная запись и ненулевой NLINK, поэтому фильтр +L1 может не показать проблему. Ищу такие файлы на клиенте, но не удаляю повторно: правильное действие — найти и корректно завершить либо переоткрыть держащий процесс. Для параллельной append-записи на NFS есть дополнительные ограничения, потому что серверный протокол не даёт локальной атомарности O_APPEND, и ядро клиента вынуждено её имитировать.

sudo find /mnt/share -name '.nfs*' -type f -size +100M -ls
sudo lsof -nP /mnt/share/path/.nfs1234567890abcdef

С процессами СУБД правило ещё строже. Если в списке виден postgres, mysqld или другой database engine с удалённым файлом, я не делаю вывод по одному прежнему имени и никогда не применяю truncate через чужой fd. Это может быть временное рабочее пространство запроса, сегмент данных, журнал или объект, который движок удалил по своему протоколу, но продолжает использовать. Сначала определяю запрос и назначение файла штатными средствами конкретной базы, затем выбираю отмену запроса, checkpoint, перезапуск или ожидание по документации и регламенту.

Наконец, самый будничный держатель — интерактивная утилита. tail -F специально повторяет попытки открыть файл по имени и одновременно может продолжать читать старый дескриптор; забытая сессия tmux или screen способна удерживать удалённый лог часами. Но имя команды — только начало проверки: я сверяю PID, родительский процесс, пользователя и fd, чтобы не закрыть чужую диагностическую сессию без предупреждения.

ps -o pid,ppid,user,lstart,cmd -p 2043
readlink /proc/2043/fd/27
cat /proc/2043/fdinfo/27

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

На боевой СУБД неизвестный удалённый fd нельзя считать «просто логом». Обрезка объекта, который движок продолжает использовать, способна сорвать запрос или повредить рабочие данные.

Частые вопросы

Почему повторное удаление сообщает, что файла нет, хотя место всё ещё занято?

Первое удаление уже убрало каталожное имя. Инод и его блоки остаются, пока существует открытый дескриптор или другой удерживающий объект ядра. Подтвердите это командой `sudo lsof -nP +aL1 /var`, где `/var` — именно точка монтирования проблемной файловой системы.

Можно ли сохранить удалённый, но ещё открытый лог?

Да, пока процесс и fd существуют, обычный файл часто доступен через `/proc/PID/fd/N`. Скопируйте его на другой том командой `sudo cp /proc/1187/fd/5 /mnt/rescue/access.log`. Копия растущего лога не является атомарным снимком, а перезапуск держателя до копирования уничтожит эту возможность.

Нужно ли сразу перезапускать сервис?

Нет. Сначала используйте документированный reopen или сигнал ротации и затем снова проверьте держателей. Для nginx это `nginx -s reopen` или `USR1` master-процессу. Если fd не закрылся либо у сервиса нет такого механизма, управляемый перезапуск обычно безопаснее обрезки через procfs.

Почему нельзя просто сложить SIZE/OFF из вывода lsof?

Один инод может появиться в нескольких строках у разных PID и FD, поэтому сумма многократно учтёт одни блоки. Кроме того, `SIZE/OFF` показывает логический размер, который у sparse-файла больше выделенного места. Дедуплицируйте по `DEVICE:NODE`, а выделение оценивайте через `%b × %B` команды GNU `stat`.

Чем опасен truncate через /proc/PID/fd/N?

Он безвозвратно стирает содержимое объекта. Без `O_APPEND` следующая запись может пойти по сохранённому старому смещению и создать огромную дыру; с `O_APPEND` устраняется только этот риск, но данные всё равно потеряны. Для СУБД, очередей, индексов и неизвестных fd такую операцию применять нельзя.

Что проверять, если +L1 ничего не показал?

Проверьте `df -i`, квоты, реальный тип и точку монтирования через `findmnt`, резерв блоков ext4, файлы под вложенными точками монтирования, снимки ZFS/Btrfs и заполнение LVM thin pool. На NFS ищите `.nfs*`: silly rename оставляет каталожное имя, поэтому `NLINK` не равен нулю.

Как следить за проблемой из Zabbix, если агент не видит чужой procfs?

Не выдавайте агенту широкий sudo. Запускайте сканер от root отдельным systemd timer, записывайте числовой результат в доступный для чтения файл в `/run`, а через `UserParameter` возвращайте только содержимое этого файла. Порог связывайте с объёмом конкретного тома и допустимым временем реакции.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи