Перейти к основному содержимому

Диск забит на 100%: как Loki съел 24 ГБ логов, а df -i почти ввёл в заблуждение

Олег Казанин
Автор
Олег Казанин
Строю полезную инфраструктуру на Open Source стеке. Документирую грабли, чтобы вы на них не наступали.
Linux: эксплуатация и обслуживание - Эта статья — часть серии.
Часть 1: Ты уже здесь

Диск забит на 100%: как Loki съел 24 ГБ логов, а df -i почти ввёл в заблуждение
#

Zabbix присылает алерт: на проде закончилось место в корне файловой системы. Сервер - Debian 12, что на нём крутится и почему диск кончился - неизвестно, в тикете только хостнейм и факт.

Эта статья - не теория про docker logs и не абстрактный гайд по очистке диска. Это реальное расследование: от первого df -h до настроенного лимита логов, со всеми тупиками по дороге, включая два моих собственных, а не системы. Один из них почти увёл диагностику в сторону несуществующей проблемы с инодами.

Если у вас диск забился по другой причине - в конце статьи отдельный чек-лист по всем проверенным подозреваемым. Он работает как самостоятельный справочник, независимо от того, что нашлось в этом конкретном случае.

Старт: два предела сразу
#

“Место кончилось” в Linux бывает по двум разным причинам - не хватило байтов или не хватило инодов. Алерт Zabbix не уточняет, какая именно, так что смотрим сразу на обе.

Занятость по байтам на всех точках монтирования:

df -h
Filesystem             Size  Used Avail Use% Mounted on
/dev/mapper/vg01-root   29G   29G   16K 100% /
/dev/sda2              944M  147M  732M  17% /boot
/dev/mapper/vg02-opt   500G   22G  478G   5% /opt

Корень (vg01-root) забит под потолок - 29G из 29G, 16K свободно. /opt - отдельный том на 500G, занят на 5%, чист. Разделение на тома здесь сыграло на руку: не разведи их администратор раньше, искать причину было бы сложнее.

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

df -i
Filesystem               Inodes  IUsed     IFree IUse% Mounted on
/dev/mapper/vg01-root     67424  66740       684   99% /
/dev/mapper/vg02-opt  262141952 486209 261655743    1% /opt

Деталь, которая зацепила взгляд: всего 67424 инода на 29-гигабайтном томе - по привычке принял это за фиксированное значение, заданное при создании файловой системы, как это работает в ext4. При 99% занятости с таким скромным лимitom легко вылететь по инодам даже без аномального количества файлов. Отложил мысль на потом - и, как выяснится в финале статьи, отложил с неверной гипотезой в основе.

Первая ошибка: du без -x
#

Чтобы понять, что внутри корня весит больше всего, логично пройтись du по верхним каталогам:

du -h --max-depth=1 / 2>/dev/null | sort -rh
23G     /
19G     /opt
2.2G    /usr
1.1G    /home
765M    /var
147M    /boot

Результат бессмысленный, и вот почему: без флага -x du пересекает точки монтирования. /opt и /boot - отдельные файловые системы, но du зашёл туда и насчитал их объём как часть /. Корень - это vg01-root, а в выводе сидят чужие 19G и 147M с других дисков.

Повторяем с -x, чтобы du не покидал текущую файловую систему:

du -hx --max-depth=1 / 2>/dev/null | sort -rh
4.0G    /
2.2G    /usr
1.1G    /home
765M    /var

Сумма едва дотягивает до 4G, а df -h показывает 29G использовано. Разрыв в 25G - и тут я слишком быстро потянулся к экзотической гипотезе: удалённый, но открытый файл, который процесс держит дескриптором, а в дереве каталогов его уже нет.

Слепая зона: du без root
#

Гипотеза была преждевременной. Команда выполнялась без sudo, а 2>/dev/null заглушил не только мусор, а заодно все “Permission denied”. Если du не смог зайти в каталог с ограниченными правами, он молча посчитал его как 0 байт - и 4.0G превращаются в недостоверное число, а не в доказательство призрачного файла.

Сначала проверяем явно, что именно du не смог прочитать - без скрытия ошибок:

sudo du -hx --max-depth=1 / 2>&1 1>/dev/null | grep -i denied

Вывод пустой - но это ничего не доказывает: команда уже шла через sudo, то есть не воспроизводила условия первого, проблемного запуска. Сама проверка получилась нерелевантной собственной ошибке, которую должна была вскрыть.

Пересчитываем с правами root, без слепых зон:

sudo du -hx --max-depth=1 / 2>/dev/null | sort -rh
29G     /
26G     /var
2.2G    /usr
1.1G    /home

26G + 2.2G + 1.1G с мелочью почти точно сходится с 29G из df -h. Гипотеза про удалённый-но-открытый файл не подтвердилась - фиксирую как часть истории, а не прячу. Разрыв был не призраком, а банальной слепой зоной из-за прав доступа в моей же команде.

Золотое правило: диагностику диска без sudo лучше не начинать. Любая Permission denied молча обнулит реальный объём каталога, и вы будете гоняться за несуществующей проблемой.

/var/lib: Docker номер один
#

Раскрываем /var на уровень глубже:

du -hx --max-depth=1 /var 2>/dev/null | sort -rh
26G     /var/lib
242M    /var/log
203M    /var/cache
728K    /var/backups

Весь вес - в /var/lib, остальное несущественно. /var/lib - общий контейнер для данных приложений, туда попадает и Docker, и базы данных, и системные служебные данные. Раскрываем ещё на уровень:

du -hx --max-depth=1 /var/lib 2>/dev/null | sort -rh
25G     /var/lib/docker
283M    /var/lib/apt
18M     /var/lib/dpkg
17M     /var/lib/plocate

/var/lib/docker - 25G из 26G /var/lib. Остальное суммарно тянет на 350M. Виновник почти найден.

docker system df врёт - не считает логи
#

У Docker есть встроенная команда, дающая разбиение по образам, контейнерам, volumes и build cache сразу:

docker system df -v
Images space usage:
REPOSITORY        TAG       SIZE
grafana/grafana   latest    485MB
grafana/loki      2.9.2     74.6MB
hello-world       latest    13.3kB

Containers space usage:
CONTAINER     IMAGE                    CREATED         STATUS        SIZE
loki          grafana/loki:2.9.2       4 months ago    Up 4 months   0B
grafana       grafana/grafana:latest   4 months ago    Up 4 months   0B

Local Volumes space usage: (нет volumes)
Build cache usage: 0B

Суммарно - около 560MB. А /var/lib/docker весит 25G. Разница больше 24G, и docker system df её просто не видит.

Это известная засада: docker system df не считает логи контейнеров. Команда показывает только образы, writable-слои, volumes и build cache - JSON-логи, которые Docker пишет по умолчанию без лимита, в эту статистику не попадают вообще.

Проверяем напрямую, сколько весят лог-файлы контейнеров:

du -h /var/lib/docker/containers/*/*-json.log 2>/dev/null | sort -rh
24G     .../containers/<loki>/<loki>-json.log
257M    .../containers/<grafana>/<grafana>-json.log

Оба контейнера работают по 4 месяца без перезапуска - времени накопить логи без ротации хватало обоим. Но разница в 93 раза говорит не просто об “отсутствии ротации” - что-то заставляет именно Loki писать аномально много.

Почему именно Loki
#

Сначала смотрим характер записей - последние строки лога:

CID=$(docker ps -qf "name=loki")
tail -n 20 /var/lib/docker/containers/$CID/$CID-json.log

Все видимые строки - level=info, без единой ошибки. Но паттерн заметный: лог крутится по последовательным индексным таблицам компактора - index_20407index_20406index_20405, каждая обрабатывается за пару миллисекунд. Это не разовая фоновая задача, это что-то, что молотит постоянно и очень быстро.

Ищем настоящие ошибки, с ограничением по времени, чтобы не зависнуть на полном проходе по 24G:

timeout 15 grep -m 1 'level=error' /var/lib/docker/containers/$CID/$CID-json.log

Нашлась одна:

level=error ts=2026-01-26T12:36:53Z msg="error asking ring for who should run the compactor, will check again" err="at least 1 healthy replica required, could only find 0"

Классика для однонодового Loki: ring-based compactor рассчитан на кластер, а здесь единственный инстанс, и lifecycler не успел зарегистрироваться в ring при старте. Но дата - 26 января, почти 5 месяцев назад. Похоже на разовый сбой при старте, а не на текущую причину - нужно проверить, повторяется ли она сейчас.

Дальше - оценка темпа записи. Первая попытка была неудачной, признаю сразу: timeout после 10 секунд убил всю цепочку процессов сигналом, и wc -l не успел вывести даже ноль - результат недостоверен, метод сломан, аргумент в любую сторону строить на нём нельзя.

Первая попытка - через docker logs в реальном времени:

timeout 10 docker logs -f --tail 0 $CID | wc -l

Второй подход - без живого слежения, через временные метки в самом файле:

tail -n 2000 /var/lib/docker/containers/$CID/$CID-json.log | head -n 1
tail -n 1 /var/lib/docker/containers/$CID/$CID-json.log

Разница между 2000-й-с-конца записью и последней - 7.48 секунды. 2000 строк за 7.48с - это около 267 строк в секунду. При среднем объёме 24G за 4 месяца (~200MB в день, ~2.3KB/сек, то есть 10-15 строк/сек) пойманное значение в 20+ раз выше среднего - это всплеск компактора, а не стабильный фон. Экстраполировать всплеск на все 4 месяца было бы нечестно по отношению к методу.

Проверяем ошибки за недавний период без скана всех 24G - берём последние 500MB через seek по байтам:

tail -c 500M /var/lib/docker/containers/$CID/$CID-json.log | grep -c 'level=error'
0

Ноль ошибок в недавней истории. Гипотеза “контейнер сломан и спамит ошибками” закрыта - дело не в этом.

Главная причина: лимит логов никто не настраивал
#

Раз дело не в ошибках, причина - в объёме обычного info-логирования без всякого предела. Смотрим, ограничен ли размер логов хоть на каком-то уровне.

Глобальная настройка драйвера логов:

cat /etc/docker/daemon.json
{
  "registry-mirrors": ["https://mirror.example.com"]
}

Настройка конкретного контейнера:

docker inspect --format '{{json .HostConfig.LogConfig}}' $CID
{"Type":"json-file","Config":{}}

Оба пустые. Используется дефолт Docker - драйвер json-file без max-size/max-file. Компактор болтлив на уровне info, лимита нет, контейнер живёт 4 месяца - 24G неизбежны. Причина подтверждена документально, не догадкой.

Остальные подозреваемые - для полноты
#

Виновник найден, но по уговору формата добиваем весь список систематически - это даёт читателю с другой причиной полную картину.

ПодозреваемыйКомандаРезультат
journaldjournalctl --disk-usage245.9M, чисто
Старые ядраdpkg -l 'linux-image-*' | grep ^ii3 версии, но на отдельном /boot, не влияет на /
Кэш aptdu -sh /var/cache/apt/archives118M, чисто
Удалённые, но открытые файлыlsof +L1пустой вывод, suspect закрыт окончательно

Последний пункт стоит выделить отдельно: пустой lsof +L1 - это не “цифры совпали”, а прямое подтверждение, что ни один процесс не держит открытым файл с нулевым числом ссылок. Гипотеза про “призрачный файл” была отвергнута на двух независимых основаниях - арифметикой du/df раньше и прямым инструментом сейчас.

Лечение: освобождаем место
#

Truncate файла “на горячую” безопасен для драйвера json-file - Docker продолжит дописывать в тот же inode, ничего не упадёт:

truncate -s 0 /var/lib/docker/containers/$CID/$CID-json.log

Проверяем результат:

df -h /
Filesystem             Size  Used Avail Use% Mounted on
/dev/mapper/vg01-root   29G  5.1G   24G  18% /

Освободилось 23.9G - почти точное совпадение с размером усечённого файла. Это не просто “стало легче”, а математическое подтверждение, что причина определена верно.

Настройка лимита - и неожиданный нюанс
#

Чтобы это не повторилось ни с Loki, ни с любым другим контейнером, добавляем лимит в daemon.json:

cat > /etc/docker/daemon.json << 'EOF'
{
  "registry-mirrors": ["https://mirror.example.com"],
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "20m",
    "max-file": "5"
  }
}
EOF
systemctl restart docker

Проверяем, применилось ли:

docker inspect --format '{{json .HostConfig.LogConfig}}' $CID
{"Type":"json-file","Config":{}}

Всё ещё пусто. Глобальный log-opts действует только для новых контейнеров, созданных после изменения - не для существующих. LogConfig фиксируется в момент создания контейнера и не обновляется при restart docker, только при пересоздании.

Чтобы пересоздать контейнер без потери параметров, сначала смотрим, как он был запущен - через лейблы compose-проекта:

docker inspect --format '{{json .Config.Labels}}' $CID

В выводе - com.docker.compose.project.working_dir и com.docker.compose.service: loki. Контейнер управляется через compose, путь известен:

cd /home/deploy/docker
docker compose up -d --force-recreate loki
✔ Container loki  Started

Проверяем лимит на пересозданном контейнере:

docker inspect --format '{{json .HostConfig.LogConfig}}' loki
{"Type":"json-file","Config":{"max-file":"5","max-size":"20m"}}

Применилось. Тот же compose-проект поднимает и grafana - у неё на момент инцидента всего 257M логов, но без лимита она повторила бы путь Loki за более долгий срок. Раз инструмент уже в руках, закрываем сразу, не оставляя половину работы:

docker compose up -d --force-recreate grafana

Побочный квест: ложная тревога с инодами
#

Возвращаемся к инодовой детали, отложенной в начале. Сверяем df -i сейчас с замером в начале расследования:

df -i /
Filesystem              Inodes IUsed    IFree IUse% Mounted on
/dev/mapper/vg01-root 15224832 66741 15158091    1% /
ЗамерInodes всегоIUsedIFreeIUse%
В начале674246674068499%
Сейчас1522483266741151580911%

IUsed почти не изменился, а общее число инодов выросло в 225 раз. В ext4 количество инодов фиксируется в суперблоке при mkfs и не меняется без явного resize2fs - а размер тома не менялся вообще. Гипотеза “том создан с искусственно малым лимитом инодов” построена на ext4-логике, и она оказалась неверной - я строил её, не проверив тип файловой системы.

Смотрим напрямую в суперблок:

tune2fs -l /dev/mapper/vg01-root | grep -i inode
tune2fs: Bad magic number in super-block while trying to open /dev/mapper/vg01-root

Это не ошибка диагностики - это разоблачение неверной гипотезы. tune2fs понимает только ext2/ext3/ext4. Если суперблок не найден - файловая система другая.

Подтверждаем тип ФС напрямую:

findmnt -no FSTYPE /
xfs

Подтверждено: XFS. У XFS иноды выделяются динамически, а не фиксируются при создании. Столбец “Inodes” в df -i для XFS - это не константа, а оценка: текущие занятые иноды плюс расчёт, сколько ещё теоретически можно выделить исходя из свободного места на диске. При почти 100% занятости диска эта оценка падала почти до нуля, создавая видимость жёсткого лимита. После truncate освободилось 24G - и оценка свободных инодов резко выросла, потому что физически появилось место для их размещения.

Никакой аномалии не было - был артефакт интерпретации df -i на XFS при заполненном диске. Прячущая деталь: IUsed остался почти неизменным (66740 → 66741), и именно это надо было проверить раньше, чем строить гипотезу про лимит.

Итог
#

Финальная сверка по диску:

df -h /
df -i /
Filesystem             Size  Used Avail Use% Mounted on
/dev/mapper/vg01-root   29G  4.9G   25G  17% /

Filesystem              Inodes IUsed    IFree IUse% Mounted on
/dev/mapper/vg01-root 15224832 66741 15158091    1% /

Было 29G из 29G, 100%. Стало 4.9G из 29G, 17%. Оба контейнера теперь ограничены лимитом логов - повторения с тем же сценарием не будет.

Чек-лист: куда смотреть при заполненном диске
#

Этот раздел - справочный.

ПодозреваемыйКоманда проверкиНа что обратить внимание
Байты vs инодыdf -h, df -iКакой именно лимит исчерпан; на XFS число инодов в df -i - оценка, а не константа
du без -xdu -hx --max-depth=1 /Без -x команда пересечёт точки монтирования и даст мусорные числа
du без rootsudo du -hx ...Без прав молча обнуляет недоступные каталоги
Docker логи контейнеровdu -h /var/lib/docker/containers/*/*-json.logdocker system df логи контейнеров не считает вообще
Лимит логов Dockerdocker inspect --format '{{json .HostConfig.LogConfig}}' <id>Пустой Config{} значит лимита нет; глобальный daemon.json не действует на уже созданные контейнеры
journaldjournalctl --disk-usageМожет разрастись без SystemMaxUse в journald.conf
Старые ядраdpkg -l 'linux-image-*'Чистить через apt autoremove --purge, обычно живут в /boot отдельным томом
Кэш aptdu -sh /var/cache/apt/archivesapt clean освобождает мгновенно
Удалённые, но открытые файлыlsof +L1Процесс держит дескриптор - место не освободится без перезапуска процесса

Стек
#

Debian 12, XFS, Docker, Loki 2.9.2, Grafana, Docker Compose 2.29.7, Zabbix.

Что дальше
#

Следующая статья серии - тот же сценарий для RHEL-подобных систем: dnf-кэш вместо apt, другие пути логов, и проверка, какие из подозреваемых из этого списка вообще не применимы на RHEL-like.

Linux: эксплуатация и обслуживание - Эта статья — часть серии.
Часть 1: Ты уже здесь

Статьи по теме