Files
clickstream-data-platform/docs/adr/0004-resource-limits.md
T
ddadminandClaude Opus 5 a9d66ed74a fix(stand): снят несуществующий бюджет памяти, нодам ClickHouse — 4 ГиБ
Зачем

Стенд упирался в память ноды ClickHouse: пробник валился на CREATE TABLE
ON CLUSTER, вместе с ним краснели make smoke и make smoke-guards. Причина не
та, что предполагал #21: дело не в заводских кэшах, а в коробке на гигабайт.
Около 550 МиБ RSS праздной ноды — страницы её собственного бинарника, и на
работу оставалось около 350 МиБ, которые пробник добирал за сессию.

Заодно выяснилось, откуда взялся предел 3,4 ГБ. Это была оценка расхода из
спеки, посчитанная по стенду-предшественнику до первой сборки v2 и превращённая
в жёсткий порог проверки. Порог стал критерием приёмки каждого этапа и дальше
блокировал бы любой рост стенда на этапах 2-9.

Что

- ADR 0004: бюджета памяти у стенда нет, есть требование к машине — около 8 ГБ,
  доступных Docker. Ресурсный довод ADR 0001 отозван, сами решения в силе.
- Нодам ClickHouse 4 ГиБ вместо гигабайта. Остальные лимиты не тронуты: ни один
  из них ни разу не сработал, а снять их скопом — то же изменение без
  свидетельств, каким они были выставлены.
- Из make smoke убрана проверка суммарного потребления. Она мерила docker stats
  вместе со страничным кэшем, то есть отвечала на вопрос «сколько файлов стенд
  потрогал», и с появлением настоящих данных краснела бы на здоровом стенде.
  Вместе с ней убрана привязанная к её сообщению проверка docs-guards.
- Взамен smoke спрашивает у Docker, не убивало ли ядро долгоживущий контейнер
  за память и не включалась ли политика перезапуска. Порога у проверки нет:
  убитый контейнер Docker поднимает сам, и без этого вопроса стенд отрапортует
  «всё хорошо» о ноде, которая умирала.
- README и раздел «Ресурсный бюджет» спеки переписаны с предела на требование
  к машине; README объясняет менти, что такое «память, доступная Docker».

Проверка

make config-test; make up; make smoke — 25 из 25; make smoke-cluster — 8 из 8;
make smoke-guards — 3 из 3, включая шаг «после восстановления стенд проходит
make smoke», который падал 31 июля.

На живом стенде с новой коробкой: max_server_memory_usage = 3,60 ГиБ, в журнале
ноды «Lowered mark cache size to 2.00 GiB because the system has limited RAM».
Семантика счётчиков Docker снята отдельными контейнерами: ручной restart
оставляет RestartCount = 0, убийство за память даёт OOMKilled = true и растущий
счётчик, убийство не за память OOMKilled не поднимает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:35:36 +03:00

13 KiB
Raw Blame History

ADR 0004. Ограничения ресурсов на стенде

Дата: 1 августа 2026 года. Статус: принято.

Решение

Общего бюджета памяти у стенда нет. Вместо предела — требование к машине: стенду нужно около 8 ГБ памяти, доступной Docker. Это не объём ноутбука, а то, что отдано докеру: в WSL2 величина задаётся файлом .wslconfig, и по умолчанию она меньше. Кому своей машины не хватает — берёт недорогой VPS. README объясняет это менти словами.

Проверка суммарного потребления памяти уходит из scripts/stand-smoke.sh. Вместе с ней уходят зависящая от её сообщения проверка в tests/docs-guards.sh и описание порога в README. Взамен make smoke спрашивает у Docker, не убивало ли ядро какой-нибудь долгоживущий контейнер за нехватку памяти и не включалась ли политика перезапуска. Это не бюджет в новой одежде: порога у проверки нет, она отвечает «да или нет».

Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Остальные mem_limit не меняются.

Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются статуса решения: KAFKA_HEAP_OPTS, shared_buffers, max_connections, AIRFLOW__CORE__PARALLELISM и девять неизменённых mem_limit. Основания, кроме отменённого, у них нет. Кто в них упрётся — меняет по первому свидетельству и этот ADR не пересматривает. Срок хранения метрик Prometheus сюда не входит: неделя истории — осознанный учебный выбор, и цена у него дисковая.

Ресурсный довод ADR 0001 отозван.

Почему

Предел 3,4 ГБ никто не выдавал. Число появилось в спеке «Боевой реализм стенда (v2)», раздел «Ресурсный бюджет», как оценка расхода, посчитанная по стенду-предшественнику — до того как v2 впервые собрали. Оттуда оно попало жёстким порогом в make smoke, make smoke стал критерием приёмки каждого этапа, а дальше решения сверялись уже с порогом, а не с исходным доводом. Так экономия памяти стала ценностью, которую никто не выбирал.

Проверка суммы меряет не то, что называет. На cgroup v2 docker stats показывает memory.current за вычетом неактивных файловых страниц, то есть вместе с активным страничным кэшем. На праздном контейнере это 1056 МБ, из которых 779 МБ файловых при 234 МБ анонимных. Сумма отвечает на вопрос «сколько файлов стенд потрогал», а не «сколько памяти ему нужно». Как только на этапе 2 появятся настоящие данные, порог будет перейден одним страничным кэшем, и проверка начнёт краснеть на здоровом стенде. Правило, красное в норме, учит не смотреть на оповещения — это уже записано в ADR 0002. Настоящую аварию она к тому же не ловила: 31 июля нода отказала запросу по памяти внутри своей коробки, сумма при этом была в норме — 3181 МиБ из 3242,5, — а красным стенд сделал пробник. Механизм оповещения работает и без неё.

Замена не возвращает бюджет через заднюю дверь. Дыра, которую она закрывает, другая: убитый за память контейнер Docker поднимает сам, через полминуты его проверка состояния снова зелёная, и make smoke докладывает, что всё хорошо — хотя нода умирала и потеряла всё, что держала в памяти. Новая проверка спрашивает у Docker факт, а не величину, и потому нечего подгонять и незачем пересматривать при росте стенда.

Гигабайт на ноду был неработоспособен, и причина не та, что предполагал issue #21. Праздная нода держит VmRSS 846 МиБ, из которых 530 МиБ — страницы её собственного бинарника: файл clickhouse весит 790 МБ, и ядро подгружает куски по мере обращения к ним. Учёта памяти в этом почти нет, MemoryTracking всего 113 МиБ, но отказ по превышению сравнивается с RSS, куда чистые страницы кода входят. Значит из гигабайта ноде оставалось около 350 МиБ рабочего места, а RSS полз вверх просто потому, что каждый новый прогон пробника трогал новый код: 531 → 774 МиБ за сессию. Рестарт возвращал число назад, и это выглядело как утечка. Заводские кэши тут ни при чём: в коробке на гигабайт ClickHouse сам понижает каждый до 512 МиБ, а на стенде без данных они и вовсе пусты.

Лимит для ClickHouse — не потолок, а вход в самонастройку. Сервер берёт 0,9 от того, что видит, под общий предел памяти и 0,5 — под размеры кэшей и пороги сброса на диск. Поэтому «поднять потолок ничего не стоит» верно для девяти сервисов, которые лимит не читают, и неверно для двух нод: от коробки зависит, где у них срабатывают предохранители. Снять лимит с нод по той же причине нельзя — без коробки каждая считает свои доли от всей машины, и память у машины кончится раньше, чем сервер решит экономить. Панель «не упёрлись ли ноды в память» из ADR 0002 тоже требует знаменателя. Сумма всех потолков после правки больше восьми гигабайт, и это не противоречие: mem_limit — потолок, а не бронь, Docker ничего не резервирует, в покое стенд занимает около 3,2 ГБ. Восемь гигабайт нужны не сумме потолков, а работе с запасом.

Остальные девять лимитов не меняются, потому что ни один из них ни разу не сработал. Снять их скопом — то же изменение без свидетельств, каким они были выставлены, только в обратную сторону. Лечение здесь не в том, чтобы стереть числа, а в том, чтобы лишить их статуса закона: число без основания меняют, когда оно мешает, и не защищают как принятое решение.

ADR 0001 обосновывал отказ от triggerer, второго Postgres и внешних сборщиков тем, что это удерживает стенд в пределе 3,4 ГБ. Предела больше нет, довод отозван; сами решения в силе по своим основаниям — у triggerer это «в каркасе нет отложенных задач», у общего Postgres — сэкономленный контейнер при сохранённой границе владения данными. Часть про внешние сборщики уже пересмотрена ADR 0002: сборщик метрик Kafka нужен ради отставания чтения. Ближайшее следствие — этап 5: ожидание дневного батча заказов решается по существу (сенсор в режиме poke, асинхронный оператор на воркере или отложенный с triggerer), а не по остатку памяти.

Что проверено

Замеры 1 августа 2026 года на закреплённых образах.

clickhouse/clickhouse-server:26.3.17.56 в контейнере с --memory 1g: max_server_memory_usage = 921,60 МиБ — посимвольно то же число, что в ошибке инцидента; в журнале Lowered mark cache size to 512.00 MiB because the system has limited RAM; cache_size_to_ram_max_ratio = 0,5; праздный сервер — VmRSS 846 МиБ при RssFile 530 МиБ и MemoryTracking 113 МиБ.

Там же max_bytes_ratio_before_external_group_by и max_bytes_ratio_before_external_sort = 0,5. Тяжёлый GROUP BY в коробке не получает отказ, а уходит на диск и досчитывается; отказ по памяти остаётся для того, что на диск не сбрасывается. Учебный сюжет «тяжёлый запрос — симптом на графике памяти» из ADR 0002 выглядит полкой и дисковым вводом-выводом, а не обрывом.

cgroup v2: docker stats показывает memory.current минус неактивные файловые страницы. На праздном контейнере — 1056 МБ при 779 МБ файловых и 234 МБ анонимных.

apache/kafka:4.3.1: kafka-server-start.sh при пустой KAFKA_HEAP_OPTS ставит -Xmx1G -Xms1G, а по размеру коробки кучу не подбирает. Поэтому переменную не убираем: без неё куча стала бы больше, а не меньше, и с нынешней коробкой на гигабайт это был бы прямой OOM-kill.

На стенде с новой коробкой в 4 ГиБ то же поведение подтвердилось: max_server_memory_usage = 3,60 ГиБ, в журнале ноды Lowered mark cache size to 2.00 GiB because the system has limited RAM и такие же строки про остальные кэши. Сервер по-прежнему считает свои доли от коробки, просто коробка другая.

Семантика счётчиков Docker, на которой держится новая проверка, снята отдельными контейнерами. Ручной docker restart оставляет RestartCount = 0 — значит документированный в README перезапуск нод проверку не роняет. Убитый ядром за память контейнер с политикой unless-stopped даёт OOMKilled = true и растущий RestartCount: смерть видна и после того, как Docker поднял контейнер заново. Убийство не за память OOMKilled не поднимает.

Значение max_server_memory_usage_to_ram_ratio = 0,9 и то, что в cgroup предел считается от коробки, а не от машины, сверены по документации ClickHouse через MCP Context7.