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>
This commit is contained in:
2026-08-01 14:35:36 +03:00
co-authored by Claude Opus 5
parent d0c9e8ff43
commit a9d66ed74a
6 changed files with 203 additions and 54 deletions
+140
View File
@@ -0,0 +1,140 @@
# 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.