В прошлой статье я рассказывал, как устроена 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_size4, остальные по 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. Как оно устроено внутри - в первой статье. Замеры воспроизводимы: конфиги в репозитории, стенд описан в начале статьи.