Диск освободили, а ClickHouse продолжает его забивать: сломанный логгер и json-лог Docker без ротации
Короткий ответ: если под ClickHouse закончилось место и после освобождения диск продолжает заполняться — ищите не базу, а логгер. В версиях до 26.7 файловый логгер Poco после первой ошибки записи не восстанавливается, и каждое следующее сообщение вылетает в stderr с полной трассой стека: на стенде 1 443 463 байта за 20 секунд, и ещё столько же за следующие 20 — уже при свободном диске. Петлю замыкает Docker, который подхватывает stderr драйвером json-file с дефолтным max-size равным -1 (без ограничения) и пишет его на тот же раздел. Лечится без рестарта базы: SIGHUP уничтожает объект со сломанным потоком, логгер пересоздаёт его сам и оживает.
Началось всё с заполненного раздела под логами ClickHouse в Docker. Место освободили — поток в журнал не прекратился. Это и есть интересная часть: отказ пережил собственное устранение.
Чтобы не гадать, я собрал стенд: 32-мегабайтный ext4 на loop-устройстве, смонтированный в контейнер как /var/log/clickhouse-server, и две версии ClickHouse — 23.8.16.16 и 26.8.23.6. Все цифры ниже сняты с него, их может воспроизвести любой на публичных образах.
Коротко (TL;DR)
- Причина:
Poco::FileChannel::log()проверяет необходимость ротации до блокаtryи вне его. Проверка читает размер файла через поток, поток после первой неудачной записи в состоянииfail,LogFileImpl::sizeImpl()бросаетFileException. Обработчик ENOSPC стоит наcatch (const WriteFileException &)— он поймает только самый первый сбой, дальше тип исключения уже не тот. - Почему поток идёт в stderr: авторы ClickHouse предусмотрели рекурсию и намеренно вывели аварийный путь в stderr, минуя логгер. Петлю замыкает Docker: драйвер
json-fileвозвращает stderr на тот же диск без ротации. - Где починено: PR #93127, влит 2 июля 2026, впервые в
v26.7.1.1315-stable. Добавлен_str.clear()в две функции записи — логгер снова восстанавливается сам. Поток в stderr при заполненном диске остался. - Что не починено: обработчик аварии по-прежнему смотрит только на
ENOSPC— при исчерпанной квоте или отобранных правах он не делает ничего, аsizeImpl()флаги ошибки не сбрасывает. Issue #68438 открыт с 16 августа 2024. - Лечение без рестарта:
docker kill --signal=HUP <контейнер>. Сигнал привязан кcloseLogs(), тот делаетdelete _pFile, и следующая запись создаёт поток заново. На стенде после сигнала лог вырос на 54 631 Б, а stderr — на 0 Б. - Грабли 1:
rmбольшого лога, пока его держит открытым живой процесс, вернул 0 байт.truncate -s 0того же файла вернул 157 287 424 байта сразу. - Грабли 2: балласт, созданный
truncate -s 100M, занимает 0 байт и при удалении возвращает 0 байт. Настоящий резерв делает толькоfallocate. - Грабли 3: обработчик ENOSPC в Poco срабатывает один раз и, когда архивов ротации нет, усекает живой лог сервера до нуля. На стенде
clickhouse-server.logушёл с 192 326 байт в 0 — вместе с диагностикой.
Почему освобождение диска не остановило поток в лог
Потому что логгер к этому моменту уже сломан, а сломанное состояние живёт в памяти процесса и само не лечится. Освобождение места возвращает возможность писать на диск — но не возвращает в рабочее состояние поток std::ofstream внутри логгера.
Сначала я грешил на внешнюю ротацию: решил, что logrotate переименовал файл, ClickHouse продолжает писать в старый инод, а место не возвращается из-за открытого дескриптора. Версия красивая и неверная — в контейнере не было никакого logrotate, а дескриптор вёл на файл с тем же именем.
Проверка на стенде заняла три фазы. Беру контейнер ClickHouse 23.8.16.16 с логами на 32-мегабайтном loop-разделе, забиваю раздел до нуля свободных байт, гоняю запросы и считаю приращение stderr. Потом освобождаю место и повторяю замер, не трогая контейнер. Потом перезапускаю контейнер и повторяю ещё раз.
| Фаза (ClickHouse 23.8.16.16) | Прирост stderr за 20 с | Прирост clickhouse-server.log |
|---|---|---|
| Раздел заполнен | 1 443 463 Б | 0 Б |
| Место освобождено, процесс не тронут | 1 508 715 Б | 0 Б |
После docker restart (2 с) | 0 Б | 57 319 Б |
Вторая строка — весь смысл статьи. Место есть, записи в лог нет, поток в stderr продолжается на той же скорости. Логгер не пробует снова: для него файл сломан навсегда.
Где именно ломается логгер
Ломается в Poco::FileChannel::log() — в проверке ротации, которая стоит вне защищённого блока. Вот ключевой фрагмент из base/poco/Foundation/src/FileChannel.cpp (нумерация по master ClickHouse):
| |
Блок try обёрнут вокруг одной строки — самой записи. А mustRotate() в условии if на строке 112 вызывается раньше и ничем не защищён. Чтобы решить, пора ли ротировать, RotateBySizeStrategy::mustRotate() узнаёт текущий размер файла, а узнаёт он его через тот же поток:
| |
Пока поток исправен, это безобидно. Но первая же неудачная запись выставляет на нём флаг fail — и со второго сообщения sizeImpl() начинает бросать исключение, которое летит через mustRotate() наружу из FileChannel::log(), не доходя до строки 131. Запись больше даже не пытается произойти. Защита, написанная ровно под эту ситуацию, осталась за три строки ниже точки отказа.
Здесь я ошибся во второй раз. В обсуждении issue #68438 исключение локализовано в FileStreamBuf::writeToDevice, и я взял это на веру. Трасса со стенда показала другое — кадры 1 и 2 говорят сами за себя:
Poco::Exception. Code: 1000, e.code() = 0, File access error:
/var/log/clickhouse-server/clickhouse-server.log, Stack trace:
0. Poco::FileException::FileException(String const&, int) @ 0x0000000015c4bd29
1. Poco::RotateBySizeStrategy::mustRotate(Poco::LogFile*) @ 0x0000000015c7a93b
2. Poco::FileChannel::log(Poco::Message c...
_pFile->write() в трассе нет вообще — до записи дело не доходит. Путь идёт через проверку ротации.
А дальше выяснилось кое-что интереснее простого «в обсуждении ошиблись». Я снял трассу на 26.8.23.6 — и она другая:
Cannot log message in OwnAsyncSplitChannel channel: Poco::Exception. Code: 1000,
e.code() = 28, File access error: no space left on device:
/var/log/clickhouse-server/clickhouse-server.log, Stack trace:
0. Poco::FileImpl::handleLastErrorImpl(String const&) @ 0x0000000024cbbfb0
1. ? @ 0x0000000024cbcfc6
2. ? @ 0x0000000024cc90f5
3. Poco::FileStreamBuf::seekoff(long long, std::ios_base::seekdir, unsigned int)
4. Poco::RotateBySizeStrategy::mustRotate(Poco::LogFile*)
5. Poco::CombinedRotateStrategy::mustRotate(Poco::LogFile*)
6. Poco::FileChannel::log(Poco::Message const&)
7. DB::OwnFormattingChannel::logExtended(DB::ExtendedLogMessage const&)
8. DB::OwnRunnableForChannel::run()
Трассы различает ровно та правка, которая появилась в 26.7. На 23.8 флаг fail с потока никто не снимает, поэтому sizeImpl() вылетает на собственной первой строке — if (!_str.good()) — и глубже не идёт: в трассе FileException рождается прямо под mustRotate. На 26.8 _str.clear() флаг уже сбросил, проверка good() проходит, функция добирается до tellp(), а tellp() через seekoff сбрасывает буфер и упирается в настоящий write(2).
Поэтому кадром 0 стоит handleLastErrorImpl — та самая таблица, которая назначает тип исключения по errno. Кадры 1 и 2 в официальной сборке заинлайнены, и именно там живёт writeToDevice, на который указывает обсуждение. Обе локализации верны, просто на разной глубине: writeToDevice — место, где ошибка возникает, mustRotate — место, из которого она уходит незащищённой.
Побочно это объясняет и расхождение в кодах: на 23.8 e.code() = 0, потому что sizeImpl() бросает FileException(_path) без errno вообще; на 26.8 e.code() = 28 — это ENOSPC, доехавший через handleLastErrorImpl. По коду в чужом логе можно с ходу сказать, на какой стороне правки версия.
Что делает обработчик ENOSPC: срабатывает один раз и стирает диагностику
Обработчик срабатывает ровно однажды — на первом сбое записи — и в этот единственный раз обнуляет живой лог сервера. Дальше он структурно недостижим. Я сначала решил, что он не срабатывает вообще, и был неправ: усечённый до нуля лог на стенде — его работа.
Разбирается это по шагам. Первое сообщение после заполнения диска застаёт поток ещё в рабочем состоянии, поэтому проверка ротации проходит, и выполнение доходит до строки 131 — настоящей записи. Вот тут writeImpl() ловит неудачный сброс буфера, выставляет на потоке флаг fail и бросает WriteFileException — тот самый тип, который обработчик ждёт. errno равен ENOSPC, вызывается PurgeOneFileStrategy().purge().
Со второго сообщения поток уже в состоянии fail, проверка ротации перестаёт его пропускать, и наружу летит FileException — другой тип. Обработчик его не видит, и больше не увидит никогда.
Что делает purge(), когда архивов ротации на диске ещё нет, — весь код целиком:
| |
Удалять нечего — значит setSize(0) по самому clickhouse-server.log. Контролируемый замер на стенде:
| Шаг | Размер clickhouse-server.log |
|---|---|
| После рестарта при свободном диске | 192 326 Б |
| Сразу после заполнения раздела | 192 326 Б |
| После первых сообщений в сломанный логгер | 0 Б |
Логика Poco понятна: место важнее истории. Практический вывод неприятный — в момент, когда нужны последние строки перед отказом, их уже нет, и стёр их сам обработчик аварии. При разборе ENOSPC под ClickHouse в Docker смотреть надо на stderr контейнера (docker logs, json-файл на хосте), а не на clickhouse-server.log: он с большой вероятностью пустой, и пустой не потому, что писать было нечего.
Почему обработчик не поймает ничего после первого раза — видно в таблице, которая назначает тип исключения по errno. Это FileImpl::handleLastErrorImpl() в base/poco/Foundation/src/File_UNIX.cpp, и WriteFileException в ней не встречается ни разу:
| |
Кончилось место — FileException. Кончилась квота — FileException. Отобрали права на файл — FileAccessDeniedException. Перемонтировали в read-only — FileReadOnlyException. WriteFileException рождается только внутри функций записи LogFileImpl, а путь через проверку ротации до них не доходит.
Авторы Poco про ограничения обработчика написали прямо над ним, тремя строками подряд:
| |
Вторая строка — про всё, что не нехватка места. Случай с правами я проверил отдельно: поставил на лог-файл иммутабельный флаг chattr +i при живом контейнере, диск при этом был свободен. Запись упёрлась в EPERM, и в stderr полетело то же самое — 1 334 038 байт за 15 секунд, около 86 КБ/с. Отличается только текст: e.code() = 1, Access to file denied: insufficient permissions. Выходит, дело не в самом ENOSPC: заполненный диск — лишь самый частый способ получить неудачную запись, а дальше механизм один на любую причину. Разница в том, что при ENOSPC обработчик хотя бы пытается освободить место, а при правах или квоте не делает и этого.
Почему поток идёт в stderr, а не в лог-файл
Потому что так задумано, и это важная деталь: авторы ClickHouse предусмотрели рекурсию и специально вывели аварийный путь мимо логгера. В src/Loggers/OwnSplitChannel.cpp над обработчиком стоит комментарий, который не оставляет места для догадок:
«Also note, that we cannot log the exception here, since this will lead to recursion, using regular
tryLogCurrentException(). but let’s log it into the stderr at least.»
И ниже — сам обработчик:
| |
getCurrentExceptionMessage(true) — это сообщение с полной трассой стека. Ни ограничения частоты, ни дедупликации: сколько сообщений пришло в логгер, столько полных трасс уйдёт в stderr. На стенде один такой выхлоп весил 1033 байта полезной нагрузки.
Инженерное решение здесь правильное: когда логгер сломан, писать в него нельзя, аварийный канал должен быть независимым. Stderr таким и был — ровно до того момента, как сервис переехал в контейнер.
Что добавляет Docker
Docker превращает аварийный клапан обратно в источник проблемы: драйвер логов по умолчанию json-file, он забирает stderr контейнера и пишет его на диск хоста, и по умолчанию не ограничен ничем. Документация Docker про опцию max-size говорит прямо:
«The maximum size of the log before it is rolled. <…> Defaults to -1 (unlimited).»
И про max-file:
«Only effective when
max-sizeis also set. <…> Defaults to 1.»
То есть из коробки один файл неограниченного размера. Диагностический канал, задуманный как «хоть куда-нибудь напишем», оказывается подключённым к тому же разделу, нехватка места на котором его и запустила.
Объём Docker при этом ещё и увеличивает. Трасса стека выходит в stderr как полтора десятка коротких строк, а json-file оборачивает каждую строку в отдельный JSON-объект с временной меткой RFC3339Nano. Замер со стенда:
| Что считал | Значение |
|---|---|
| Полезная нагрузка stderr | 1 323 681 Б |
| Размер json-файла на диске | 2 686 052 Б |
| Накладные расходы оболочки | 2,03× |
| Событий (трасс) | 1281 |
| JSON-записей | 19 219 |
| Строк json на одно событие | 15 |
Ротация лечится одной парой опций, и это я тоже измерил. Та же нагрузка, тот же интервал:
| Настройка драйвера | Итоговый объём |
|---|---|
По умолчанию (max-size не задан) | 2 721 541 Б за 12 с (≈221 КБ/с) |
--log-opt max-size=256k --log-opt max-file=2 | 480 329 Б, дальше не растёт |
Главная засада в том, что проверять надо не daemon.json. Документация Docker:
«Changing the default logging driver or logging driver options in the daemon configuration only affects containers that are created after the configuration is changed.»
Опции логирования впекаются в контейнер при создании. Поправили daemon.json, перезапустили демона — работающие контейнеры остались со старыми настройками. Единственная честная проверка:
| |
На моём стенде контейнеры показали {"max-file":"3","max-size":"10m"}, унаследованные от конфигурации демона на момент их создания, — и именно это значение, а не содержимое файла конфигурации, определяет поведение.
Что изменилось в 26.7 и что осталось
В 26.7 логгер научился восстанавливаться после заполнения диска, но поток в stderr пока диск полон — остался. Правка PR #93127 («fix LogFile recovery after disk full error», влит 2 июля 2026, 9 коммитов, 2 файла, +159/−3) добавляет одну строку в две функции записи base/poco/Foundation/src/LogFile_STD.cpp:
| |
Флаги ошибки сбрасываются, поток снова годен к употреблению, следующая попытка записи проходит. Границу версий я проверял не по словам в обсуждении, а счётом вхождений _str.clear() по ветвям релизов: в 23.3, 23.8, 25.3, 26.3 и 26.6 — ноль, в 26.7 и 26.8 — два. Первый релиз с правкой — v26.7.1.1315-stable.
Теперь что правка даёт и чего не даёт. Тот же трёхфазный замер на 26.8.23.6:
| Фаза | 23.8.16.16 | 26.8.23.6 |
|---|---|---|
| Раздел заполнен, прирост stderr | 1 443 463 Б / 20 с | 1 297 676 Б / 20 с |
| Место освобождено, прирост stderr | 1 508 715 Б / 20 с | 0 Б |
Место освобождено, прирост clickhouse-server.log | 0 Б | 123 655 Б |
| Восстановление без вмешательства | нет (нужен SIGHUP или рестарт) | да |
В обсуждении issue #68438 сказано, что на исправленных версиях заполненный диск обрабатывается молча. Мой замер это не подтверждает: 1,3 МБ stderr за 20 секунд, пока места нет. Правка возвращает восстановление, а не тишину. Для аварии это огромная разница — но если раздел под логами полон и останется полным, новая версия будет его добивать так же старательно, как старая.
Текст ошибки между версиями тоже разошёлся, и по нему удобно опознавать версию в чужом логе:
| Версия | Префикс | Код | Текст |
|---|---|---|---|
| 23.8.16.16 | Cannot add message to the log: | e.code() = 0 | File access error |
| 26.8.23.6 | Cannot log message in OwnAsyncSplitChannel channel: | e.code() = 28 | no space left on device |
Незакрытым остаётся второй пункт того же issue: sizeImpl() флаги ошибки не сбрасывает, а обработчик аварии реагирует только на ENOSPC — при исчерпанной квоте или отобранных правах он не освобождает ничего, о чём авторы Poco и предупредили вторым NOTE. Issue #68438 открыт с 16 августа 2024 и на момент написания не закрыт.
Как выйти из петли на старой версии без рестарта: SIGHUP
Достаточно послать серверу SIGHUP — логгер восстанавливается полностью, процесс остаётся жив. Я ожидал, что сигнал не поможет (отсюда и совет «рестартовать»), и замер меня переубедил.
Механизм прозрачен и целиком читается по исходникам. В src/Daemon/BaseDaemon.cpp сигнал привязан к обработчику закрытия логов:
| |
Обработчик доходит до Loggers::closeLogs() в src/Loggers/Loggers.cpp, а тот просто закрывает файлы:
| |
FileChannel::close() делает delete _pFile; _pFile = 0; — то есть уничтожает объект, внутри которого сидит отравленный std::ofstream. А первая же строчка FileChannel::log() вызывает unsafeOpen(), который на _pFile == 0 создаёт файл заново: if (!_pFile) _pFile = newLogFile();. Новый объект — новый поток, флага fail на нём нет.
Поэтому SIGHUP — не «переоткрытие файла ради logrotate», а ровно то лекарство, которое нужно: он выбрасывает структуру с застрявшим битом. Рестарт делает то же самое, только вместе со всей базой. Правка в 26.7 делает то же самое аккуратнее — сбрасывает флаг на месте, не пересоздавая объект.
Замер на 23.8.16.16, та же петля, место уже освобождено:
| Шаг | Прирост clickhouse-server.log | Прирост stderr |
|---|---|---|
| Место освобождено, сигнала не было | 0 Б | 483 808 Б |
Сразу после kill -HUP | 23 132 Б | 4 203 Б |
| Контрольный раунд запросов после того | 54 631 Б | 0 Б |
Третья строка — доказательство, что восстановление устойчивое, а не разовое: лог растёт, в stderr не уходит ничего. Контейнер при этом ни разу не перезапускался.
Таблицу я снимал через kill -HUP по хостовому PID, но в бою удобнее штатная команда Docker, и её я проверил отдельным прогоном — лог вырос на 48 531 байт, stderr на 0, контейнер остался в статусе running:
| |
Порядок при тушении пожара от этого меняется: сначала освободить место, потом SIGHUP, и только если не помогло — рестарт. Для реплики под нагрузкой разница существенная.
Почему rm не вернул место, а truncate -s 0 вернул
Потому что rm снимает имя, а не данные: пока хоть один процесс держит открытый дескриптор, блоки остаются занятыми. Это POSIX, и каждый разбор заполненного диска я начинаю с того, что спотыкаюсь об это заново.
Стенд простой: отдельная файловая система, процесс пишет файл на 150 МиБ, оставляет дескриптор открытым и засыпает. Дальше я измеряю df --output=used --block-size=1 до и после.
| Действие при открытом дескрипторе | Освободилось |
|---|---|
rm big.log | 0 Б |
| закрытие дескриптора (процесс убит) | 157 287 424 Б |
truncate -s 0 big2.log | 157 287 424 Б, сразу |
Удалённый, но открытый файл видно через lsof — со стенда дословно:
python3 fd=3w size=157286400 /tmp/.../big.log (deleted)
Отсюда правило при тушении пожара: по логу живого сервиса — truncate -s 0, не rm. truncate возвращает место немедленно и не ломает дескриптор, процесс продолжает писать в тот же файл с нулевого смещения. rm даст иллюзию работы и ноль байт результата, а на старых версиях ClickHouse ещё и не выведет логгер из сломанного состояния.
Почему балласт из truncate не спасает
Потому что truncate -s 100M создаёт разреженный файл: видимый размер есть, занятых блоков нет, и удалять нечего. Это вторая ловушка, в которую я залез на стенде собственными руками, пока проверял идею «положить файл-заглушку, чтобы при следующем ENOSPC было что удалить».
| Файл | ls -l | du | du --apparent-size | Занял на ФС | Вернул при rm |
|---|---|---|---|---|---|
truncate -s 100M | 104 857 600 | 0 | 100M | 0 Б | 0 Б |
fallocate -l 100M | 104 857 600 | 101M | 100M | 104 858 624 Б | 104 858 624 Б |
По ls -l файлы неотличимы — именно поэтому ловушка и работает. Отличает их du против du --apparent-size: расхождение означает дыры. Балласт имеет смысл только от fallocate, и проверять его надо через du, а не глазами по списку файлов.
Как освободить место в ClickHouse, не сломав таблицы
Удалять партиции через DROP PARTITION и ждать; DETACH места не возвращает совсем. Разница в документации ClickHouse описана прямо. Про DETACH PARTITION:
«Moves all data for the specified partition to the
detacheddirectory. The server forgets about the detached data partition as if it does not exist.»
Сервер забывает — файловая система нет. Данные лежат в detached/ и занимают те же байты. Про DROP PARTITION:
«This query tags the partition as inactive and deletes data completely, approximately in 10 minutes.»
Десять минут — не фигура речи. Неактивные куски живут по таймеру old_parts_lifetime, и в src/Storages/MergeTree/MergeTreeSettings.cpp стоит DECLARE(Seconds, old_parts_lifetime, 8 * 60, ...) — 480 секунд, с объяснением выбора:
«Experimentally, a value of 480 seconds was chosen for
old_parts_lifetime, during which a new part is guaranteed to be written to disk.»
Так что после DROP PARTITION место возвращается не сразу, и в этом интервале легко решить, что запрос не сработал, и начать удалять что-нибудь ещё.
Второй сюрприз — DROP PARTITION может просто отказаться работать. Ограничения заданы в src/Core/ServerSettings.cpp:
| |
Пятьдесят миллиардов байт, примерно 46,6 ГиБ. Таблица больше — партицию удалить нельзя. Хорошая новость в том, что рестарт не нужен, и документация это подчёркивает:
«This setting does not require a restart of the ClickHouse server to apply. Another way to disable the restriction is to create the
<clickhouse-path>/flags/force_drop_tablefile.»
Создание файла-флага — путь для случая, когда до SQL-интерфейса не добраться. Что как раз и бывает при ENOSPC.
Про сами логи сервера: дефолты в programs/server/config.xml — <size>1000M</size> и <count>10</count>, то есть ClickHouse сам допускает рост своих логов до 11 ГБ (текущий файл плюс десять архивов). Если /var/log/clickhouse-server живёт на разделе с данными, это первая вещь, которую надо посчитать. И проверять оба лога — clickhouse-server.log и clickhouse-server.err.log.
Пошагово: ENOSPC под ClickHouse в Docker
dfиduпо разделу. Если крупнейший файл — json-лог контейнера в/var/lib/docker/containers/<id>/<id>-json.log, диагноз уже почти поставлен.lsof +L1илиlsof | grep deleted— проверить, не держит ли кто удалённые файлы. Если держит,rmуже не поможет, нуженtruncateили рестарт держателя.- Освободить место: по логам живых процессов —
truncate -s 0, неrm. - Проверить, вернулось ли место и остановился ли рост. Это разные вопросы.
docker logs --since 1m <контейнер> | wc -cдважды с паузой — если растёт, логгер в сломанном состоянии. - Если растёт — послать
docker kill --signal=HUP <контейнер>. На версиях до 26.7 это и есть способ вернуть логгер в строй, не перезапуская базу. Рестарт помогает тоже, но он дороже и нужен только если сигнал не подействовал. - Посмотреть в stderr контейнера, что было причиной:
Cannot add message to the logилиCannot log message in OwnAsyncSplitChannel— это следствие, искать надо строки до первого такого сообщения.clickhouse-server.logможет оказаться пустым из-заPurgeOneFileStrategy. - Проверить фактические опции логирования:
docker inspect <контейнер> --format '{{json .HostConfig.LogConfig}}'. Неdaemon.json— он на уже созданные контейнеры не влияет. - Задать ограничение и пересоздать контейнер:
--log-opt max-size=256m --log-opt max-file=3. Без пересоздания настройка не применится. - Разнести разделы: логи ClickHouse и данные ClickHouse не должны делить файловую систему с
/var/lib/docker. Иначе любой разговорчивый логгер становится угрозой для базы. - Если нужен аварийный резерв — только
fallocate, и проверить черезdu, что блоки действительно заняты.
Грабли
- Место вернулось, авария продолжается. Проверять надо не свободные байты, а скорость роста. Это два независимых наблюдения, и первое ничего не говорит о втором.
rmвместоtruncate -s 0по логу живого процесса возвращает ноль байт. Дескриптор держит блоки.- Балласт из
truncateзанимает ноль байт и при удалении отдаёт ноль байт. Толькоfallocate, только с проверкой черезdu. daemon.jsonне влияет на существующие контейнеры. Смотретьdocker inspect, менять — пересозданием.DETACH PARTITIONне освобождает место. Данные уезжают вdetached/и занимают те же байты.DROP PARTITIONотдаёт место через ~480 секунд. Не сразу, и это не повод удалять что-то ещё.DROP PARTITIONмолча запрещён на таблицах больше 50 000 000 000 байт. Обходится настройкой без рестарта или файломflags/force_drop_table.clickhouse-server.logобнуляет сам сервер в момент ENOSPC — это обработчик аварии, а не чья-то ошибка. Диагностику искать в stderr контейнера.- Обработчик ENOSPC в Poco срабатывает только на первом сбое. Дальше поток в состоянии
fail, исключение летит из проверки ротации с другим типом, и обработчик недостижим. - Рестарт базы для этого не нужен.
SIGHUPснимает петлю полностью: сигнал уничтожает объект с отравленным потоком, следующая запись создаёт его заново. - Новая версия не означает тишину. 26.8 при полном диске пишет в stderr с той же интенсивностью, что 23.8. Исправлено только восстановление после освобождения места.
Итог
Отказ, который переживает собственное устранение, почти всегда означает состояние, застрявшее в памяти процесса. Здесь это флаг ошибки на потоке std::ofstream внутри логгера Poco: один бит, из-за которого на версиях до 26.7 ClickHouse перестаёт писать в свой лог навсегда и переключается на stderr с полными трассами стека — на моём стенде порядка 1,4 МБ за 20 секунд, неограниченно долго. Пока обновиться нельзя, петля снимается одним сигналом: SIGHUP выбрасывает объект с застрявшим флагом, и база при этом продолжает работать. Обновление до 26.7 или новее закрывает эту половину проблемы совсем — логгер восстанавливается сам. Вторая половина — архитектурная и обновлением не лечится: пока json-file работает без max-size, аварийный канал сервиса подключён к тому же диску, который он и добивает. Одна пара опций драйвера и отдельный раздел под логи стоят дешевле, чем ночной рестарт продакшена.
Первоисточники:
base/poco/Foundation/src/FileChannel.cpp— проверка ротации внеtry, обработчик ENOSPCbase/poco/Foundation/src/LogFile_STD.cpp—_str.clear()и необработанныйsizeImpl()base/poco/Foundation/src/File_UNIX.cpp— отображениеerrnoв типы исключенийbase/poco/Foundation/src/PurgeStrategy.cpp—PurgeOneFileStrategy::purge()иsetSize(0)на живом файлеsrc/Loggers/OwnSplitChannel.cpp— аварийный вывод в stderr и комментарий про рекурсиюsrc/Daemon/BaseDaemon.cpp— привязкаSIGHUPкcloseLogsSignalHandlersrc/Loggers/Loggers.cpp—Loggers::closeLogs()- PR #93127 — «fix LogFile recovery after disk full error»
- Issue #68438 — «bug(s) in log writing failure handling»
- Docker: json-file logging driver — дефолты
max-sizeиmax-file - ClickHouse: ALTER TABLE … PARTITION —
DETACHпротивDROP src/Storages/MergeTree/MergeTreeSettings.cpp—old_parts_lifetimeи обоснование 480 секундsrc/Core/ServerSettings.cpp—max_partition_size_to_dropиflags/force_drop_table