--- title: "Диск забит на 100%: как Loki съел 24 ГБ логов, а df -i почти ввёл в заблуждение" date: 2026-06-24 draft: false description: "Расследование заполненного диска на Debian 12: от алерта Zabbix через df, du и docker system df до найденного виновника - логов Loki без лимита ротации. Чек-лист подозреваемых и настройка лимита логов Docker." tags: ["debian 12", "docker", "zabbix", "мониторинг", "диагностика", "логи", "xfs", "linux"] categories: ["Системное администрирование"] series: ["Linux: эксплуатация и обслуживание"] series_order: 1 --- # Диск забит на 100%: как Loki съел 24 ГБ логов, а df -i почти ввёл в заблуждение Zabbix присылает алерт: на проде закончилось место в корне файловой системы. Сервер - Debian 12, что на нём крутится и почему диск кончился - неизвестно, в тикете только хостнейм и факт. Эта статья - не теория про docker logs и не абстрактный гайд по очистке диска. Это реальное расследование: от первого `df -h` до настроенного лимита логов, со всеми тупиками по дороге, включая два моих собственных, а не системы. Один из них почти увёл диагностику в сторону несуществующей проблемы с инодами. Если у вас диск забился по другой причине - в конце статьи отдельный чек-лист по всем проверенным подозреваемым. Он работает как самостоятельный справочник, независимо от того, что нашлось в этом конкретном случае. ## Старт: два предела сразу "Место кончилось" в Linux бывает по двум разным причинам - не хватило байтов или не хватило инодов. Алерт Zabbix не уточняет, какая именно, так что смотрим сразу на обе. Занятость по байтам на всех точках монтирования: ```bash 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 можно забить и десятком огромных файлов, и миллионом мелких, и метод диагностики для этих случаев разный: ```bash 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` по верхним каталогам: ```bash 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` не покидал текущую файловую систему: ```bash 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` не смог прочитать - без скрытия ошибок: ```bash sudo du -hx --max-depth=1 / 2>&1 1>/dev/null | grep -i denied ``` Вывод пустой - но это ничего не доказывает: команда уже шла через sudo, то есть не воспроизводила условия первого, проблемного запуска. Сама проверка получилась нерелевантной собственной ошибке, которую должна была вскрыть. Пересчитываем с правами root, без слепых зон: ```bash 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` на уровень глубже: ```bash 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, и базы данных, и системные служебные данные. Раскрываем ещё на уровень: ```bash 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 сразу: ```bash 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 пишет по умолчанию без лимита, в эту статистику не попадают вообще. Проверяем напрямую, сколько весят лог-файлы контейнеров: ```bash du -h /var/lib/docker/containers/*/*-json.log 2>/dev/null | sort -rh ``` ``` 24G .../containers//-json.log 257M .../containers//-json.log ``` Оба контейнера работают по 4 месяца без перезапуска - времени накопить логи без ротации хватало обоим. Но разница в 93 раза говорит не просто об "отсутствии ротации" - что-то заставляет именно Loki писать аномально много. ## Почему именно Loki Сначала смотрим характер записей - последние строки лога: ```bash CID=$(docker ps -qf "name=loki") tail -n 20 /var/lib/docker/containers/$CID/$CID-json.log ``` Все видимые строки - `level=info`, без единой ошибки. Но паттерн заметный: лог крутится по последовательным индексным таблицам компактора - `index_20407` → `index_20406` → `index_20405`, каждая обрабатывается за пару миллисекунд. Это не разовая фоновая задача, это что-то, что молотит постоянно и очень быстро. Ищем настоящие ошибки, с ограничением по времени, чтобы не зависнуть на полном проходе по 24G: ```bash 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 в реальном времени: ```bash timeout 10 docker logs -f --tail 0 $CID | wc -l ``` Второй подход - без живого слежения, через временные метки в самом файле: ```bash 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 по байтам: ```bash tail -c 500M /var/lib/docker/containers/$CID/$CID-json.log | grep -c 'level=error' ``` ``` 0 ``` Ноль ошибок в недавней истории. Гипотеза "контейнер сломан и спамит ошибками" закрыта - дело не в этом. ## Главная причина: лимит логов никто не настраивал Раз дело не в ошибках, причина - в объёме обычного info-логирования без всякого предела. Смотрим, ограничен ли размер логов хоть на каком-то уровне. Глобальная настройка драйвера логов: ```bash cat /etc/docker/daemon.json ``` ```json { "registry-mirrors": ["https://mirror.example.com"] } ``` Настройка конкретного контейнера: ```bash docker inspect --format '{{json .HostConfig.LogConfig}}' $CID ``` ```json {"Type":"json-file","Config":{}} ``` Оба пустые. Используется дефолт Docker - драйвер `json-file` без `max-size`/`max-file`. Компактор болтлив на уровне info, лимита нет, контейнер живёт 4 месяца - 24G неизбежны. Причина подтверждена документально, не догадкой. ## Остальные подозреваемые - для полноты Виновник найден, но по уговору формата добиваем весь список систематически - это даёт читателю с другой причиной полную картину. | Подозреваемый | Команда | Результат | |---|---|---| | journald | `journalctl --disk-usage` | 245.9M, чисто | | Старые ядра | `dpkg -l 'linux-image-*' \| grep ^ii` | 3 версии, но на отдельном `/boot`, не влияет на `/` | | Кэш apt | `du -sh /var/cache/apt/archives` | 118M, чисто | | Удалённые, но открытые файлы | `lsof +L1` | пустой вывод, suspect закрыт окончательно | Последний пункт стоит выделить отдельно: пустой `lsof +L1` - это не "цифры совпали", а прямое подтверждение, что ни один процесс не держит открытым файл с нулевым числом ссылок. Гипотеза про "призрачный файл" была отвергнута на двух независимых основаниях - арифметикой `du`/`df` раньше и прямым инструментом сейчас. ## Лечение: освобождаем место Truncate файла "на горячую" безопасен для драйвера `json-file` - Docker продолжит дописывать в тот же inode, ничего не упадёт: ```bash truncate -s 0 /var/lib/docker/containers/$CID/$CID-json.log ``` Проверяем результат: ```bash df -h / ``` ``` Filesystem Size Used Avail Use% Mounted on /dev/mapper/vg01-root 29G 5.1G 24G 18% / ``` Освободилось 23.9G - почти точное совпадение с размером усечённого файла. Это не просто "стало легче", а математическое подтверждение, что причина определена верно. ## Настройка лимита - и неожиданный нюанс Чтобы это не повторилось ни с Loki, ни с любым другим контейнером, добавляем лимит в `daemon.json`: ```bash 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 ``` Проверяем, применилось ли: ```bash docker inspect --format '{{json .HostConfig.LogConfig}}' $CID ``` ```json {"Type":"json-file","Config":{}} ``` Всё ещё пусто. **Глобальный `log-opts` действует только для новых контейнеров**, созданных после изменения - не для существующих. `LogConfig` фиксируется в момент создания контейнера и не обновляется при `restart docker`, только при пересоздании. Чтобы пересоздать контейнер без потери параметров, сначала смотрим, как он был запущен - через лейблы compose-проекта: ```bash docker inspect --format '{{json .Config.Labels}}' $CID ``` В выводе - `com.docker.compose.project.working_dir` и `com.docker.compose.service: loki`. Контейнер управляется через compose, путь известен: ```bash cd /home/deploy/docker docker compose up -d --force-recreate loki ``` ``` ✔ Container loki Started ``` Проверяем лимит на пересозданном контейнере: ```bash docker inspect --format '{{json .HostConfig.LogConfig}}' loki ``` ```json {"Type":"json-file","Config":{"max-file":"5","max-size":"20m"}} ``` Применилось. Тот же compose-проект поднимает и `grafana` - у неё на момент инцидента всего 257M логов, но без лимита она повторила бы путь Loki за более долгий срок. Раз инструмент уже в руках, закрываем сразу, не оставляя половину работы: ```bash docker compose up -d --force-recreate grafana ``` ## Побочный квест: ложная тревога с инодами Возвращаемся к инодовой детали, отложенной в начале. Сверяем `df -i` сейчас с замером в начале расследования: ```bash df -i / ``` ``` Filesystem Inodes IUsed IFree IUse% Mounted on /dev/mapper/vg01-root 15224832 66741 15158091 1% / ``` | Замер | Inodes всего | IUsed | IFree | IUse% | |---|---|---|---|---| | В начале | 67424 | 66740 | 684 | 99% | | Сейчас | 15224832 | 66741 | 15158091 | 1% | `IUsed` почти не изменился, а общее число инодов выросло в 225 раз. В ext4 количество инодов фиксируется в суперблоке при `mkfs` и не меняется без явного `resize2fs` - а размер тома не менялся вообще. Гипотеза "том создан с искусственно малым лимитом инодов" построена на ext4-логике, и она оказалась неверной - я строил её, не проверив тип файловой системы. Смотрим напрямую в суперблок: ```bash 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. Если суперблок не найден - файловая система другая. Подтверждаем тип ФС напрямую: ```bash findmnt -no FSTYPE / ``` ``` xfs ``` Подтверждено: XFS. У XFS иноды выделяются динамически, а не фиксируются при создании. Столбец "Inodes" в `df -i` для XFS - это не константа, а оценка: текущие занятые иноды плюс расчёт, сколько ещё теоретически можно выделить исходя из свободного места на диске. При почти 100% занятости диска эта оценка падала почти до нуля, создавая видимость жёсткого лимита. После truncate освободилось 24G - и оценка свободных инодов резко выросла, потому что физически появилось место для их размещения. Никакой аномалии не было - был артефакт интерпретации `df -i` на XFS при заполненном диске. Прячущая деталь: `IUsed` остался почти неизменным (66740 → 66741), и именно это надо было проверить раньше, чем строить гипотезу про лимит. ## Итог Финальная сверка по диску: ```bash 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` без -x | `du -hx --max-depth=1 /` | Без `-x` команда пересечёт точки монтирования и даст мусорные числа | | `du` без root | `sudo du -hx ...` | Без прав молча обнуляет недоступные каталоги | | Docker логи контейнеров | `du -h /var/lib/docker/containers/*/*-json.log` | `docker system df` логи контейнеров не считает вообще | | Лимит логов Docker | `docker inspect --format '{{json .HostConfig.LogConfig}}' ` | Пустой `Config{}` значит лимита нет; глобальный `daemon.json` не действует на уже созданные контейнеры | | journald | `journalctl --disk-usage` | Может разрастись без `SystemMaxUse` в `journald.conf` | | Старые ядра | `dpkg -l 'linux-image-*'` | Чистить через `apt autoremove --purge`, обычно живут в `/boot` отдельным томом | | Кэш apt | `du -sh /var/cache/apt/archives` | `apt clean` освобождает мгновенно | | Удалённые, но открытые файлы | `lsof +L1` | Процесс держит дескриптор - место не освободится без перезапуска процесса | ## Стек Debian 12, XFS, Docker, Loki 2.9.2, Grafana, Docker Compose 2.29.7, Zabbix. ## Что дальше Следующая статья серии - тот же сценарий для RHEL-подобных систем: dnf-кэш вместо apt, другие пути логов, и проверка, какие из подозреваемых из этого списка вообще не применимы на RHEL-like.