Зачем Двухосевое ревью нашло в 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>
154 lines
14 KiB
Markdown
154 lines
14 KiB
Markdown
# 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.
|