В прошлой статье я рассказывал, как устроена Gotcha - self-hosted-система observability на Go: ошибки, трейсы, метрики, профилирование, аптайм-чеки. В комментариях больше всего реакции собрали не архитектурные решения, а цифры потребления с боевого стенда. Логично: материалов вида “поставьте и попробуйте” много, а ответа “сколько это будет есть на самом деле” почти нет.

Эта статья - именно такой ответ, с замерами. Дисклеймер сразу: проект мой, и сервер тоже мой. Но статья не про то, какой проект хороший, а про то, как я неделю смотрел на 900 МБ занятой памяти и не понимал, откуда они. Спойлер: главным героем оказался стоковый конфиг ClickHouse, а главной моей ошибкой - склеенные в один два разных симптома.

Стенд: минимальный VPS - 2 ядра 2.2 ГГц, 2 ГБ RAM, 20 ГБ SATA SSD. Инстанс боевой - принимает события с двух моих небольших сайтов. Стек - три контейнера: приложение на Go, PostgreSQL (пользователи, проекты, правила алертов) и ClickHouse (сама телеметрия).

Что было видно снаружи

Первый взгляд на docker stats при практически нулевом потоке событий:

  • ClickHouse: 55-99% CPU, 842-920 МБ памяти из лимита 1 ГБ;

  • приложение: 0.04% CPU;

  • машина: load average 1.15 на двух ядрах, в свопе 271 МБ;

  • диск: занято 12 ГБ из 20.

Последняя цифра и есть та, из-за которой всё началось. Сколько данных к этому моменту накопило приложение? Смотрим размеры баз в ClickHouse:

  • база приложения - 543 КБ, 16 тысяч строк;

  • база system - 579 МБ, 46.3 миллиона строк.

Полмегабайта полезных данных и полгигабайта собственной телеметрии ClickHouse. Соотношение - примерно тысяча к одному.

Куда именно ушло место

ClickHouse по умолчанию пишет о себе очень подробно. И у части этих таблиц нет TTL - то есть они не чистятся вообще никогда:

  • trace_log - 404 МБ, 26 млн строк (сюда пишет профайлер запросов, включённый по умолчанию: query_profiler_real_time_period_ns = 1000000000, то есть раз в секунду);

  • asynchronous_metric_log - 16.6 млн строк;

  • text_log - 132 МБ;

  • плюс query_log, latency_log - тоже без ограничения срока.

TTL из коробки есть только у metric_log, processors_profile_log и part_log. Остальное растёт линейно и навсегда.

Теперь посмотрим на поток вставок за 30 секунд:

  • trace_log - 227 строк в секунду;

  • asynchronous_metric_log - 157 строк в секунду;

  • text_log - 44 строки в секунду;

  • приложение - около 5 строк в секунду.

98.8% всех вставок в базу - это ClickHouse, рассказывающий сам себе о том, как он работает.

Мерж-амплификация: почему это дорого не только по диску

Дальше самое интересное. За те же 30 секунд:

  • вставлено строк: 16 222;

  • смержено строк: 11 007 643.

Это соотношение 1 : 678. На каждую вставленную строку движок перезаписал 678 уже лежащих.

Механика такая. MergeTree складывает каждую вставку в отдельный кусок данных (парт) и потом сливает куски в более крупные, чтобы чтение не деградировало. Пока таблица маленькая, это дёшево. Но когда в таблице 26 миллионов строк, а вставки идут мелкие и постоянные, каждый следующий уровень слияния тащит за собой всё больше уже записанных данных. В пределе система тратит основную часть своего I/O не на новые данные, а на перекладывание старых.

Именно это и происходило: диск был занят на 60%, SSD постоянно что-то писал, и всё это не имело никакого отношения к полезной нагрузке.

Гипотеза, которая оказалась неверной

Мне показалось, что дело закрыто: разросшийся trace_log даёт мержи, мержи грузят CPU, профайлер сэмплирует в том числе потоки мержей - замкнутый круг. Проверка простая: замерить CPU, сделать TRUNCATE TABLE system.trace_log, замерить снова.

CPU не упал. До - в среднем 69%, после - 81%.

Честная оговорка: второе окно я открыл через 60 секунд после удаления 400 МБ, а уборка партов сама грузит машину, так что часть роста могла быть последствием самого TRUNCATE. Но главное было ясно: одной таблицей это не объясняется.

Дальше я отключил все тяжёлые логи целиком и замерил ещё раз. Мержи упали практически до нуля:

  • смержено строк за 30 с: 11 007 643 → 5 727;

  • вставлено строк за 30 с: 16 222 → 35;

  • диск: освободилось 2.7 ГБ из 20.

А CPU - снова почти не изменился: 69% → 61%.

Вывод, который пришлось принять: системные логи были реальной проблемой по диску и I/O, но не были причиной загрузки процессора. Два разных симптома, которые я по невнимательности склеил в один.

Ловушка, на которой я сам себя поймал

Пока я разбирался, я успел написать, что ClickHouse “съедает половину машины”. Это неправда, и ошибка типовая: docker stats считает проценты от одного ядра, а не от машины. 60% в docker stats на двухъядерном сервере - это ~30% сервера. Проверка через ps на хосте подтвердила: 51.4% при двух ядрах.

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

Что в итоге дало результат

К отключению тяжёлых логов добавился тюнинг под маленькую машину:

  • asynchronous_metrics_update_period_s: 1 → 60 (метрики считались каждую секунду);

  • metric_log: сбор 1 с → 30 с, сброс 7.5 с → 60 с, TTL 3 дня;

  • фоновые пулы: background_schedule_pool_size со 128 до 8 (дефолт рассчитан на многоядерные серверы), background_pool_size 4, остальные по 2;

  • mark_cache_size: 5 ГиБ → 256 МиБ. Да, дефолтный размер кэша засечек в пять раз больше, чем лимит памяти всего контейнера.

Итог по машине (среднее по 15 замерам):

  • CPU контейнера (от одного ядра): ~69% → ~48%;

  • CPU на хосте: 51.4% → 45.8%;

  • память контейнера: 842-920 МБ → 256 МБ, то есть -66%;

  • память хоста занято: 1043 МБ → 779 МБ, доступно 916 МБ → 1183 МБ;

  • load average: 1.15 → 0.79;

  • диск занято: 12 ГБ → 9.2 ГБ.

Главный выигрыш - память. ClickHouse сидел на 85-90% своего cgroup-лимита и ловил бы OOM при любом всплеске трафика; стало 25% с реальным запасом. Основной вклад, судя по всему, у mark_cache_size.

PostgreSQL, для полноты картины, оказался скромным гостем: 45-47 МБ до тюнинга, 37 МБ после. Ему из универсального нужны всего две настройки - random_page_cost=1.1 и effective_io_concurrency=200, потому что дефолты PostgreSQL до сих пор рассчитаны на диски со шпинделем, а не на SSD.

Ошибка, которая уронила продакшен

Про неё стоит рассказать отдельно, потому что она обиднее всех находок.

Первую версию тюнинга я применил сразу на боевой сервер, не проверив локально. ClickHouse на старте выполняет sanity check: number_of_free_entries_in_pool_to_execute_mutation (дефолт 20) не должен превышать background_pool_size × background_merges_mutations_concurrency_ratio. Я поставил background_pool_size=4, произведение стало 8, проверка не прошла → BAD_ARGUMENTS → сервер отказался стартовать. Мониторинг лежал несколько минут.

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

Откат, к счастью, сработал штатно: .bak рядом с файлом, восстановление за 8 секунд. Если вы правите конфиги на живом сервере - делайте резервную копию до, а не после.

Отдельно: не затачивайте конфиг под минимум

Когда я принёс эти настройки в репозиторий проекта, мне справедливо задали вопрос: а на сервере с 10 ядрами и 10 ГБ памяти они не сделают хуже?

Сделают. Я проверил все одиннадцать настроек, и универсальными оказались только три:

  • random_page_cost=1.1 и effective_io_concurrency=200 - это факт про SSD, а не про размер сервера;

  • TTL на системные логи - бесконечный рост вреден на любом железе.

Остальные восемь на мощной машине именно вредят: background_pool_size=4 ограничит параллелизм мержей и приведёт к “too many parts”, урезанный mark_cache_size добавит лишних чтений с диска, shared_buffers=64MB при 10 ГБ памяти смешон, effective_cache_size=192MB заставит планировщик избегать индексных сканов, max_parallel_workers=2 отрежет параллельные планы. А отключать trace_log и text_log на большом сервере вообще не за что: накладные расходы там исчезающие, а диагностику терять жалко.

Поэтому схема получилась двухслойной: в базовом docker-compose.yml - только универсальное, а потолки под слабое железо вынесены в отдельный оверлей, который включается осознанно:

docker compose -f docker-compose.yml -f docker-compose.small.yml up -d

Правило, которое я из этого вынес: если настройка пропорциональна ресурсам, она не может лежать в дефолтном конфиге. В дефолт идёт только то, что верно и на двух ядрах, и на двадцати.

Так сколько же это стоит

Ответ на исходный вопрос, если он вам нужен в цифрах: полный self-hosted-стек observability - приложение, PostgreSQL и ClickHouse - живёт на 2 ядрах и 2 ГБ памяти, занимая после настройки около 780 МБ RAM и 9 ГБ диска. Из них подавляющая часть - ClickHouse; приложение на Go в простое стоит примерно ноль.

Ограничения, которые надо назвать честно: пропускную способность я пока не мерил, нагрузочный тест впереди. Остаток CPU (~30% двухъядерной машины в простое) объяснить исчерпывающе я так и не смог - похоже на штатный idle-расход ClickHouse, но доказательства у меня нет, и выдавать догадку за факт не буду.

И общий вывод, который к моему проекту отношения не имеет: стоковые дефолты баз данных рассчитаны не на ваш сервер. ClickHouse из коробки предполагает, что у него много ядер, много памяти и никто не считает диск. Если это не так - 2.8 ГБ диска и две трети памяти можно вернуть за один конфиг. Просто проверьте его сначала локально.

Проект, на котором всё это мерилось: github.com/OtezVikentiy/gotcha - Go, self-hosted, принимает данные от стандартных SDK Sentry и по OTLP. Как оно устроено внутри - в первой статье. Замеры воспроизводимы: конфиги в репозитории, стенд описан в начале статьи.