Files
clickstream-data-platform/docs/adr/0004-resource-limits.md
T
ddadminandClaude Opus 5 5a21684dc4 fix(stand): поправлены находки ревью — причина дрейфа памяти и отзыв довода ADR 0001
Зачем

Двухосевое ревью нашло в PR фактическую ошибку и одну процессную дыру. Ошибка
того же класса, что уже снималась по ходу разбора: в ADR было записано, будто
RSS ноды полз вверх из-за страниц её бинарника. Замер на живой ноде это
опроверг.

Что

- ADR 0004: причина дрейфа переписана по замеру. За обычную сессию страницы
  бинарника 523 -> 531 МиБ, то есть стоят на месте, а рабочая память
  489 -> 723 МиБ. Бинарник объясняет постоянную часть расхода, а не рост;
  отчего растёт рабочая память, для этого решения знать не нужно. Вывод не
  меняется: одна только постоянная часть занимала больше половины гигабайтной
  коробки.
- ADR 0001: ресурсный довод отозван прямо в файле — и строкой статуса, и
  абзацем после самого довода. Обе оси ревью нашли это независимо друг от
  друга: строка «удерживает стенд в пределе 3,4 ГБ» читалась как действующая,
  хотя предела уже нет.
- stand-smoke.sh: OOMKilled поднимается и тогда, когда ядро убило процесс
  внутри живого контейнера, поэтому сообщение говорит про процесс, а не про
  контейнер. Флаг hurt переименован в problems и считает находки — как passed
  и failed по соседству.
- Формулировки ADR 0004 упрощены: «коробка» объясняется при первом упоминании,
  а метафоры «вход в самонастройку», «предохранители», «бронь», «полка» и
  «бюджет в новой одежде» заменены обычными словами. Правило AGENTS.md —
  сложную мысль пояснять при первом упоминании.

Проверка

make config-test — зелено. make smoke — 25 из 25, проверка выживания отработала
с новым сообщением.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:18:43 +03:00

14 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) называется коробкой: сервер живёт внутри неё и о самой машине ничего не знает. Остальные 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. У праздной ноды около 530 МиБ RSS — это страницы её собственного бинарника: файл clickhouse весит 790 МБ, ядро подгружает куски по мере обращения к ним, а отказ по превышению памяти сравнивается с RSS, куда такие страницы входят. Учёта сервера в них почти нет: MemoryTracking при этом всего 113 МиБ. Это постоянная часть расхода: меньше неё нода не занимает никогда. Из гигабайта на саму работу оставалось около 350 МиБ.

Дальше нода набирает рабочую память, и вместе с постоянной частью та перестаёт помещаться. За обычную сессию стенда замерено: страницы бинарника 523 → 531 МиБ, то есть стоят на месте, а рабочая память 489 → 723 МиБ. Инцидент 31 июля имел ту же форму — 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, а по размеру коробки кучу не подбирает. Поэтому переменную не убираем: без неё куча стала бы больше, а не меньше, и в нынешней коробке Kafka на гигабайт ядро убивало бы контейнер за нехватку памяти.

На стенде с новой коробкой в 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.