BitPage

Диск освободили, а ClickHouse продолжает его забивать: сломанный логгер и json-лог Docker без ротации

Автор:  ·   · 21 мин чтения

Короткий ответ: если под 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)

Почему освобождение диска не остановило поток в лог

Потому что логгер к этому моменту уже сломан, а сломанное состояние живёт в памяти процесса и само не лечится. Освобождение места возвращает возможность писать на диск — но не возвращает в рабочее состояние поток 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
void FileChannel::log(const Message& msg)
{
	FastMutex::ScopedLock lock(_mutex);
	unsafeOpen();

	if (_pRotateStrategy && _pArchiveStrategy && _pRotateStrategy->mustRotate(_pFile))   // :112
	{
		try { _pFile = _pArchiveStrategy->archive(_pFile, _streamCompress); purge(); }
		catch (...) { _pFile = newLogFile(); }
		_pRotateStrategy->mustRotate(_pFile);                                            // :126
	}

	try
	{
		_pFile->write(msg.getText(), _flush);                                            // :131
	}
	catch (const WriteFileException & e)                                                 // :133
	{
		if (errno == ENOSPC)
			PurgeOneFileStrategy().purge(_path);
	}
}

Блок try обёрнут вокруг одной строки — самой записи. А mustRotate() в условии if на строке 112 вызывается раньше и ничем не защищён. Чтобы решить, пора ли ротировать, RotateBySizeStrategy::mustRotate() узнаёт текущий размер файла, а узнаёт он его через тот же поток:

1
2
3
4
5
6
UInt64 LogFileImpl::sizeImpl() const
{
	if (!_str.good())
		throw FileException(_path);
	return (UInt64) _str.tellp();
}

Пока поток исправен, это безобидно. Но первая же неудачная запись выставляет на нём флаг 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(), когда архивов ротации на диске ещё нет, — весь код целиком:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void PurgeOneFileStrategy::purge(const std::string& path)
{
	std::vector<File> files;
	list(path, files);

	if (files.empty())
	{
		File(path).setSize(0);
		return;
	}
	...
}

Удалять нечего — значит 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 в ней не встречается ни разу:

1
2
3
4
5
case EPERM:   throw FileAccessDeniedException("insufficient permissions", path, errno);
case EACCES:  throw FileAccessDeniedException(path, errno);
case EROFS:   throw FileReadOnlyException(path, errno);
case ENOSPC:  throw FileException("no space left on device", path, errno);
case EDQUOT:  throw FileException("disk quota exceeded", path, errno);

Кончилось место — FileException. Кончилась квота — FileException. Отобрали права на файл — FileAccessDeniedException. Перемонтировали в read-only — FileReadOnlyException. WriteFileException рождается только внутри функций записи LogFileImpl, а путь через проверку ротации до них не доходит.

Авторы Poco про ограничения обработчика написали прямо над ним, тремя строками подряд:

1
2
3
// NOTE: error reason is not preserved in WriteFileException, we need to check errno manually.
// NOTE: other reasons like quota exceeded are not handled.
// NOTE: current log message will be lost.

Вторая строка — про всё, что не нехватка места. Случай с правами я проверил отдельно: поставил на лог-файл иммутабельный флаг 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.»

И ниже — сам обработчик:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
catch (...)
{
	const std::string & exception_message = getCurrentExceptionMessage(true);
	const std::string & message = msg.getText();

	/// NOTE: errors are ignored, since nothing can be done.
	writeRetry(STDERR_FILENO, "Cannot add message to the log: ");
	writeRetry(STDERR_FILENO, message.data(), message.size());
	writeRetry(STDERR_FILENO, "\n");
	writeRetry(STDERR_FILENO, exception_message.data(), exception_message.size());
	writeRetry(STDERR_FILENO, "\n");
}

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-size is also set. <…> Defaults to 1.»

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

Объём Docker при этом ещё и увеличивает. Трасса стека выходит в stderr как полтора десятка коротких строк, а json-file оборачивает каждую строку в отдельный JSON-объект с временной меткой RFC3339Nano. Замер со стенда:

Что считалЗначение
Полезная нагрузка stderr1 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=2480 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, перезапустили демона — работающие контейнеры остались со старыми настройками. Единственная честная проверка:

1
docker inspect <контейнер> --format '{{json .HostConfig.LogConfig}}'

На моём стенде контейнеры показали {"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:

1
2
3
4
5
	if (!_str.good())
	{
		_str.clear();  // Clear error flags to allow recovery after disk full
		throw WriteFileException(_path);
	}

Флаги ошибки сбрасываются, поток снова годен к употреблению, следующая попытка записи проходит. Границу версий я проверял не по словам в обсуждении, а счётом вхождений _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.1626.8.23.6
Раздел заполнен, прирост stderr1 443 463 Б / 20 с1 297 676 Б / 20 с
Место освобождено, прирост stderr1 508 715 Б / 20 с0 Б
Место освобождено, прирост clickhouse-server.log0 Б123 655 Б
Восстановление без вмешательстванет (нужен SIGHUP или рестарт)да

В обсуждении issue #68438 сказано, что на исправленных версиях заполненный диск обрабатывается молча. Мой замер это не подтверждает: 1,3 МБ stderr за 20 секунд, пока места нет. Правка возвращает восстановление, а не тишину. Для аварии это огромная разница — но если раздел под логами полон и останется полным, новая версия будет его добивать так же старательно, как старая.

Текст ошибки между версиями тоже разошёлся, и по нему удобно опознавать версию в чужом логе:

ВерсияПрефиксКодТекст
23.8.16.16Cannot add message to the log:e.code() = 0File access error
26.8.23.6Cannot log message in OwnAsyncSplitChannel channel:e.code() = 28no space left on device

Незакрытым остаётся второй пункт того же issue: sizeImpl() флаги ошибки не сбрасывает, а обработчик аварии реагирует только на ENOSPC — при исчерпанной квоте или отобранных правах он не освобождает ничего, о чём авторы Poco и предупредили вторым NOTE. Issue #68438 открыт с 16 августа 2024 и на момент написания не закрыт.

Как выйти из петли на старой версии без рестарта: SIGHUP

Достаточно послать серверу SIGHUP — логгер восстанавливается полностью, процесс остаётся жив. Я ожидал, что сигнал не поможет (отсюда и совет «рестартовать»), и замер меня переубедил.

Механизм прозрачен и целиком читается по исходникам. В src/Daemon/BaseDaemon.cpp сигнал привязан к обработчику закрытия логов:

1
HandledSignals::instance().addSignalHandler({SIGHUP}, closeLogsSignalHandler, true);

Обработчик доходит до Loggers::closeLogs() в src/Loggers/Loggers.cpp, а тот просто закрывает файлы:

1
2
3
4
5
6
7
8
void Loggers::closeLogs(Poco::Logger & logger)
{
    if (log_file)
        log_file->close();
    if (error_log_file)
        error_log_file->close();
    ...
}

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 -HUP23 132 Б4 203 Б
Контрольный раунд запросов после того54 631 Б0 Б

Третья строка — доказательство, что восстановление устойчивое, а не разовое: лог растёт, в stderr не уходит ничего. Контейнер при этом ни разу не перезапускался.

Таблицу я снимал через kill -HUP по хостовому PID, но в бою удобнее штатная команда Docker, и её я проверил отдельным прогоном — лог вырос на 48 531 байт, stderr на 0, контейнер остался в статусе running:

1
docker kill --signal=HUP <контейнер>

Порядок при тушении пожара от этого меняется: сначала освободить место, потом SIGHUP, и только если не помогло — рестарт. Для реплики под нагрузкой разница существенная.

Почему rm не вернул место, а truncate -s 0 вернул

Потому что rm снимает имя, а не данные: пока хоть один процесс держит открытый дескриптор, блоки остаются занятыми. Это POSIX, и каждый разбор заполненного диска я начинаю с того, что спотыкаюсь об это заново.

Стенд простой: отдельная файловая система, процесс пишет файл на 150 МиБ, оставляет дескриптор открытым и засыпает. Дальше я измеряю df --output=used --block-size=1 до и после.

Действие при открытом дескриптореОсвободилось
rm big.log0 Б
закрытие дескриптора (процесс убит)157 287 424 Б
truncate -s 0 big2.log157 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 -ldudu --apparent-sizeЗанял на ФСВернул при rm
truncate -s 100M104 857 6000100M0 Б0 Б
fallocate -l 100M104 857 600101M100M104 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 detached directory. 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:

1
2
DECLARE(UInt64, max_partition_size_to_drop, 50000000000lu, ...)
DECLARE(UInt64, max_table_size_to_drop, 50000000000lu, ...)

Пятьдесят миллиардов байт, примерно 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_table file.»

Создание файла-флага — путь для случая, когда до 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

  1. df и du по разделу. Если крупнейший файл — json-лог контейнера в /var/lib/docker/containers/<id>/<id>-json.log, диагноз уже почти поставлен.
  2. lsof +L1 или lsof | grep deleted — проверить, не держит ли кто удалённые файлы. Если держит, rm уже не поможет, нужен truncate или рестарт держателя.
  3. Освободить место: по логам живых процессов — truncate -s 0, не rm.
  4. Проверить, вернулось ли место и остановился ли рост. Это разные вопросы. docker logs --since 1m <контейнер> | wc -c дважды с паузой — если растёт, логгер в сломанном состоянии.
  5. Если растёт — послать docker kill --signal=HUP <контейнер>. На версиях до 26.7 это и есть способ вернуть логгер в строй, не перезапуская базу. Рестарт помогает тоже, но он дороже и нужен только если сигнал не подействовал.
  6. Посмотреть в stderr контейнера, что было причиной: Cannot add message to the log или Cannot log message in OwnAsyncSplitChannel — это следствие, искать надо строки до первого такого сообщения. clickhouse-server.log может оказаться пустым из-за PurgeOneFileStrategy.
  7. Проверить фактические опции логирования: docker inspect <контейнер> --format '{{json .HostConfig.LogConfig}}'. Не daemon.json — он на уже созданные контейнеры не влияет.
  8. Задать ограничение и пересоздать контейнер: --log-opt max-size=256m --log-opt max-file=3. Без пересоздания настройка не применится.
  9. Разнести разделы: логи ClickHouse и данные ClickHouse не должны делить файловую систему с /var/lib/docker. Иначе любой разговорчивый логгер становится угрозой для базы.
  10. Если нужен аварийный резерв — только fallocate, и проверить через du, что блоки действительно заняты.

Грабли

Итог

Отказ, который переживает собственное устранение, почти всегда означает состояние, застрявшее в памяти процесса. Здесь это флаг ошибки на потоке std::ofstream внутри логгера Poco: один бит, из-за которого на версиях до 26.7 ClickHouse перестаёт писать в свой лог навсегда и переключается на stderr с полными трассами стека — на моём стенде порядка 1,4 МБ за 20 секунд, неограниченно долго. Пока обновиться нельзя, петля снимается одним сигналом: SIGHUP выбрасывает объект с застрявшим флагом, и база при этом продолжает работать. Обновление до 26.7 или новее закрывает эту половину проблемы совсем — логгер восстанавливается сам. Вторая половина — архитектурная и обновлением не лечится: пока json-file работает без max-size, аварийный канал сервиса подключён к тому же диску, который он и добивает. Одна пара опций драйвера и отдельный раздел под логи стоят дешевле, чем ночной рестарт продакшена.

Первоисточники:

#ClickHouse #Docker #логирование #ENOSPC #post-mortem

<< Previous Post

|

Next Post >>