From a9d66ed74aeeaa055930be175497768047dedf3c Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 1 Aug 2026 14:35:36 +0300 Subject: [PATCH 1/3] =?UTF-8?q?fix(stand):=20=D1=81=D0=BD=D1=8F=D1=82=20?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82=D0=B2=D1=83?= =?UTF-8?q?=D1=8E=D1=89=D0=B8=D0=B9=20=D0=B1=D1=8E=D0=B4=D0=B6=D0=B5=D1=82?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D0=B8,=20=D0=BD=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=D0=BC=20ClickHouse=20=E2=80=94=204=20=D0=93=D0=B8=D0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем Стенд упирался в память ноды 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 --- README.md | 16 ++- compose.yaml | 6 +- docs/adr/0004-resource-limits.md | 140 ++++++++++++++++++++++ docs/specs/2026-07-30-stand-v2-realism.md | 21 +++- scripts/stand-smoke.sh | 58 ++++----- tests/docs-guards.sh | 16 --- 6 files changed, 203 insertions(+), 54 deletions(-) create mode 100644 docs/adr/0004-resource-limits.md diff --git a/README.md b/README.md index 87610c4..aeab7f5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,15 @@ Prometheus, Grafana и общая база Postgres для метаданных. ## Быстрый старт -Нужны Docker с Compose. Полная проверка также использует `curl`, `jq`, `awk`, +Нужны Docker с Compose и около 8 ГБ памяти, доступной Docker. Это не объём +ноутбука, а то, что отдано самому Docker: в Docker Desktop и WSL2 он живёт +внутри виртуальной машины и получает лишь часть памяти хозяина. Сколько выдано +сейчас, в байтах, покажет `docker info --format '{{.MemTotal}}'`. В WSL2 это +поднимается параметром `memory` в файле `.wslconfig` домашнего каталога +пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не +хватает, стенд одинаково хорошо живёт на недорогом VPS. + +Полная проверка также использует `curl`, `jq`, `awk`, `grep`, `sed`, `tail`, `sleep` и `timeout`. По умолчанию должны быть свободны порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090` и `29092`. Для статической проверки `make config-test` нужен `uv`. @@ -51,8 +59,10 @@ Superset, создаёт администратора и импортирует и `test_kafka`, метаданные и подключение Superset. Первый пробник создаёт таблицы на обеих нодах и читает через `Distributed` на ноде 2 строку из локальной таблицы ноды 1. Второй пишет в Kafka и читает свой маркер. В конце -проверка ждёт 20 секунд покоя, печатает общую память контейнеров и падает при -превышении 3,4 ГБ. Временный топик проверки с машины и запуски DAG удаляются; +проверка спрашивает у Docker, не убивало ли ядро какой-нибудь долгоживущий +контейнер за нехватку памяти и не включалась ли политика перезапуска: убитый +контейнер Docker поднимает сам, и проверка состояния об этом промолчит. +Временный топик проверки с машины и запуски DAG удаляются; постоянный топик пробника сохраняется, а старые записи чистит Kafka. `make smoke-cluster` запускает отдельную глубокую проверку ClickHouse: описание diff --git a/compose.yaml b/compose.yaml index f98f036..20fa0cb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -15,7 +15,11 @@ x-clickhouse-common: &clickhouse-common clickhouse-keeper: condition: service_healthy ulimits: *clickhouse-nofile - mem_limit: 1g + # Не гигабайт: около 550 МиБ от RSS праздной ноды — страницы её собственного + # бинарника, и рабочего места почти не оставалось. Лимит не бронь: в покое + # нода занимает столько же, а коробка задаёт, где сервер включает + # предохранители — свои кэши и сброс на диск (ADR 0004). + mem_limit: 4g healthcheck: test: ["CMD-SHELL", "clickhouse-client --host 127.0.0.1 --query 'SELECT 1' >/dev/null 2>&1"] interval: 5s diff --git a/docs/adr/0004-resource-limits.md b/docs/adr/0004-resource-limits.md new file mode 100644 index 0000000..bb20093 --- /dev/null +++ b/docs/adr/0004-resource-limits.md @@ -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. diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index ec621d8..48e96cc 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -338,12 +338,21 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate ### Ресурсный бюджет (#10) -Расчёт на ноутбук менти с 16 ГБ памяти; у кого 8 ГБ — берёт VDS за свой счёт. -Полный стенд в покое ≈3,4 ГБ. Топология 2×1 добавляет ≈0,6–0,8 ГБ — влезает -свободно. Топология 2×2 добавила бы ≈1,7–1,9 ГБ и упёрлась бы в дефолтный -бюджет WSL2 (~8 ГБ) — это второй довод против реплик, рядом с главным -(репликационная эксплуатация — отдельный операционный домен). Координатор — -clickhouse-keeper, а не ZooKeeper, в том числе из-за этого бюджета. +Предела расхода у стенда нет. Есть требование к машине: около 8 ГБ памяти, +доступной Docker. Решение и его основания — +[ADR 0004](../adr/0004-resource-limits.md); для менти то же самое объясняет +README. + +Прежняя оценка «полный стенд в покое ≈3,4 ГБ» была расчётом по +стенду-предшественнику, сделанным до первой сборки v2, и предела не задавала. +Проверка `make smoke`, сторожившая это число, убрана: она мерила потребление +вместе со страничным кэшем и с появлением настоящих данных начала бы краснеть +на здоровом стенде. + +Топология 2×2 остаётся отвергнутой по главному доводу — репликационная +эксплуатация есть отдельный операционный домен. Второй довод, от бюджета, снят +вместе с бюджетом. Выбор clickhouse-keeper вместо ZooKeeper держится на +резолюции #14 и ссылки на бюджет больше не требует. ## 7. Слои: карта таблиц v2 diff --git a/scripts/stand-smoke.sh b/scripts/stand-smoke.sh index b9e862c..468cf92 100755 --- a/scripts/stand-smoke.sh +++ b/scripts/stand-smoke.sh @@ -590,41 +590,43 @@ check_superset() { fi } -check_memory_budget() { - local -a ids=() +# Контейнер, убитый ядром за нехватку памяти, Docker поднимает сам, и через +# полминуты его проверка состояния снова зелёная: о смерти она не расскажет. +# Поэтому спрашиваем у Docker два факта — убивало ли контейнер ядро и включалась +# ли политика перезапуска. Ручной `docker compose restart` счётчик не трогает, +# так что документированный перезапуск нод проверку не роняет. Порога здесь +# нет: это «да или нет», а не бюджет памяти (ADR 0004). +check_containers_survived() { local container_id local service - local total_mib + local state + local hurt=0 - sleep 20 for service in "${LONG_LIVED_SERVICES[@]}"; do - container_id="$(compose ps --status running --quiet "$service" 2>/dev/null || true)" + container_id="$(compose ps --all --quiet "$service" 2>/dev/null || true)" if [[ -z "$container_id" || "$container_id" == *$'\n'* ]]; then - fail "не удалось получить работающий контейнер ${service} для измерения памяти" + fail "не удалось получить контейнер ${service} для проверки перезапусков" return fi - ids+=("$container_id") + state="$(docker inspect --format '{{.State.OOMKilled}}/{{.RestartCount}}' "$container_id" 2>/dev/null || true)" + case "$state" in + false/0) ;; + true/*) + fail "контейнер ${service} был убит из-за нехватки памяти" + hurt=1 + ;; + false/*) + fail "контейнер ${service} перезапускался, счётчик Docker — ${state#*/}" + hurt=1 + ;; + *) + fail "Docker не рассказал о состоянии контейнера ${service}" + hurt=1 + ;; + esac done - if ! total_mib="$(docker stats --no-stream --format '{{.MemUsage}}' "${ids[@]}" 2>/dev/null | awk -v expected="${#ids[@]}" ' - $1 ~ /GiB$/ {sub(/GiB$/, "", $1); total += $1 * 1024; next} - $1 ~ /MiB$/ {sub(/MiB$/, "", $1); total += $1; next} - $1 ~ /KiB$/ {sub(/KiB$/, "", $1); total += $1 / 1024; next} - $1 ~ /GB$/ {sub(/GB$/, "", $1); total += $1 * 1000 / 1.048576; next} - $1 ~ /MB$/ {sub(/MB$/, "", $1); total += $1 / 1.048576; next} - $1 ~ /kB$/ {sub(/kB$/, "", $1); total += $1 / 1048.576; next} - {invalid = 1} - END { - if (invalid || NR != expected) exit 1 - printf "%.1f", total - } - ')"; then - fail 'Docker не вернул полное измерение памяти стенда' - return - fi - if awk -v total="$total_mib" 'BEGIN {exit !(total <= 3242.5)}'; then - pass "стенд занимает ${total_mib} MiB после 20 секунд покоя, порог 3,4 ГБ не превышен" - else - fail "стенд занимает ${total_mib:-неизвестно} MiB после 20 секунд покоя, это больше 3,4 ГБ" + if [[ "$hurt" -eq 0 ]]; then + pass 'ни один долгоживущий контейнер не был убит по памяти и не перезапускался сам' fi } @@ -638,7 +640,7 @@ if check_host_dependencies; then check_grafana_datasource check_airflow check_superset - check_memory_budget + check_containers_survived else fail 'проверки контейнеров, Kafka, Airflow, Superset, Prometheus и Grafana пропущены без зависимостей машины' fi diff --git a/tests/docs-guards.sh b/tests/docs-guards.sh index 91d1aad..072879c 100755 --- a/tests/docs-guards.sh +++ b/tests/docs-guards.sh @@ -18,7 +18,6 @@ set -euo pipefail readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" readonly README="$ROOT_DIR/README.md" -readonly SMOKE="$ROOT_DIR/scripts/stand-smoke.sh" passed=0 fail() { @@ -93,25 +92,10 @@ restart_lesson_present() { ' "$README" } -# Единица памяти в отчёте проверки — русская «ГБ», а не латинская «GB» -# (контракт языка из AGENTS.md). Здесь успех — это отсутствие образца, поэтому -# код возврата grep разбирается вручную: 1 — не нашли, и это хорошо; 0 — нашли -# латинское; больше 1 — сам grep не отработал, и молчать об этом нельзя. -smoke_uses_russian_unit() { - local status=0 - grep -q '3,4 GB' "$SMOKE" || status=$? - case "$status" in - 1) return 0 ;; - 0) return 1 ;; - *) fail "не удалось проверить обозначение единицы памяти в $SMOKE" ;; - esac -} - check 'README перечисляет HTTP- и нативные порты обеих нод' ports_documented check 'README объясняет сброс томов после смены исходных учётных данных' clean_advice_present check 'список портов остаётся единым списком' ports_stay_one_list check 'README перечисляет малые проверки пробников в составе config-test' probe_checks_documented check 'README требует перезапуск ClickHouse после изменения настройки метрик' restart_lesson_present -check 'отчёт проверки использует русское обозначение ГБ' smoke_uses_russian_unit printf 'ИТОГ: пройдено %d, ошибок 0\n' "$passed" From 5a21684dc45b98bb10d54fcd51ab48f28514ef31 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 1 Aug 2026 15:18:43 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(stand):=20=D0=BF=D0=BE=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=B0=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD=D0=B0=20=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D0=B9=D1=84=D0=B0=20=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D0=BE=D1=82=D0=B7=D1=8B=D0=B2=20=D0=B4=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=20ADR=200001?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем Двухосевое ревью нашло в 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 --- README.md | 4 +- docs/adr/0001-stand-services.md | 10 +++- docs/adr/0004-resource-limits.md | 83 ++++++++++++++++++-------------- scripts/stand-smoke.sh | 27 ++++++----- 4 files changed, 75 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index aeab7f5..15fd6e7 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ Superset, создаёт администратора и импортирует и `test_kafka`, метаданные и подключение Superset. Первый пробник создаёт таблицы на обеих нодах и читает через `Distributed` на ноде 2 строку из локальной таблицы ноды 1. Второй пишет в Kafka и читает свой маркер. В конце -проверка спрашивает у Docker, не убивало ли ядро какой-нибудь долгоживущий -контейнер за нехватку памяти и не включалась ли политика перезапуска: убитый +проверка спрашивает у Docker, не убивало ли ядро что-нибудь в долгоживущих +контейнерах за нехватку памяти и не включалась ли политика перезапуска: убитый контейнер Docker поднимает сам, и проверка состояния об этом промолчит. Временный топик проверки с машины и запуски DAG удаляются; постоянный топик пробника сохраняется, а старые записи чистит Kafka. diff --git a/docs/adr/0001-stand-services.md b/docs/adr/0001-stand-services.md index 49309fc..0282380 100644 --- a/docs/adr/0001-stand-services.md +++ b/docs/adr/0001-stand-services.md @@ -1,6 +1,7 @@ # ADR 0001. Сервисы каркаса стенда -Дата: 30 июля 2026 года. Статус: принято. +Дата: 30 июля 2026 года. Статус: принято; ресурсный довод отозван +[ADR 0004](0004-resource-limits.md). ## Решение @@ -50,6 +51,13 @@ Prometheus читает встроенные точки метрик двух с компьютера. Отказ от triggerer, второго Postgres и внешних сборщиков удерживает стенд в пределе 3,4 ГБ. +Ресурсный довод предыдущего абзаца отозван +[ADR 0004](0004-resource-limits.md): предела 3,4 ГБ у стенда нет, вместо него +объявлено требование к машине. Сами решения остаются в силе по остальным +основаниям, названным выше. Отказ от внешних сборщиков вдобавок частично +пересмотрен [ADR 0002](0002-monitoring-scope.md): сборщик метрик Kafka нужен +ради отставания чтения. + Тома `clickhouse_*_data` хранят данные keeper и двух нод ClickHouse. `kafka_data`, `postgres_metadata_data`, `superset_home`, `prometheus_data` и `grafana_data` хранят состояние своих сервисов. `airflow_logs` хранит журналы, diff --git a/docs/adr/0004-resource-limits.md b/docs/adr/0004-resource-limits.md index bb20093..648ec58 100644 --- a/docs/adr/0004-resource-limits.md +++ b/docs/adr/0004-resource-limits.md @@ -13,12 +13,13 @@ Проверка суммарного потребления памяти уходит из `scripts/stand-smoke.sh`. Вместе с ней уходят зависящая от её сообщения проверка в `tests/docs-guards.sh` и описание порога в README. Взамен `make smoke` спрашивает у Docker, не убивало -ли ядро какой-нибудь долгоживущий контейнер за нехватку памяти и не включалась -ли политика перезапуска. Это не бюджет в новой одежде: порога у проверки нет, -она отвечает «да или нет». +ли ядро что-нибудь в долгоживущих контейнерах за нехватку памяти и не включалась +ли политика перезапуска. Прежний бюджет она не заменяет: числа, с которым +что-то сравнивают, у неё нет вовсе — ответ либо «убивало», либо «нет». -Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Остальные `mem_limit` не -меняются. +Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Дальше в этом тексте предел +памяти контейнера (`mem_limit`) называется коробкой: сервер живёт внутри неё и +о самой машине ничего не знает. Остальные `mem_limit` не меняются. Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`, @@ -51,35 +52,47 @@ своей коробки, сумма при этом была в норме — 3181 МиБ из 3242,5, — а красным стенд сделал пробник. Механизм оповещения работает и без неё. -Замена не возвращает бюджет через заднюю дверь. Дыра, которую она закрывает, -другая: убитый за память контейнер Docker поднимает сам, через полминуты его -проверка состояния снова зелёная, и `make smoke` докладывает, что всё хорошо — -хотя нода умирала и потеряла всё, что держала в памяти. Новая проверка -спрашивает у Docker факт, а не величину, и потому нечего подгонять и незачем -пересматривать при росте стенда. +Новая проверка — не тот же бюджет под другим именем. Она закрывает другую дыру: +убитый за память контейнер Docker поднимает сам, через полминуты его проверка +состояния снова зелёная, и `make smoke` докладывает, что всё хорошо — хотя нода +умирала и потеряла всё, что держала в памяти. Проверка спрашивает у Docker +факт, а не величину, поэтому её нечего подгонять и незачем пересматривать, +когда стенд растёт. Гигабайт на ноду был неработоспособен, и причина не та, что предполагал -issue #21. Праздная нода держит `VmRSS` 846 МиБ, из которых 530 МиБ — страницы -её собственного бинарника: файл `clickhouse` весит 790 МБ, и ядро подгружает -куски по мере обращения к ним. Учёта памяти в этом почти нет, `MemoryTracking` -всего 113 МиБ, но отказ по превышению сравнивается с RSS, куда чистые страницы -кода входят. Значит из гигабайта ноде оставалось около 350 МиБ рабочего места, -а RSS полз вверх просто потому, что каждый новый прогон пробника трогал новый -код: 531 → 774 МиБ за сессию. Рестарт возвращал число назад, и это выглядело -как утечка. Заводские кэши тут ни при чём: в коробке на гигабайт ClickHouse сам -понижает каждый до 512 МиБ, а на стенде без данных они и вовсе пусты. +issue #21. У праздной ноды около 530 МиБ RSS — это страницы её собственного +бинарника: файл `clickhouse` весит 790 МБ, ядро подгружает куски по мере +обращения к ним, а отказ по превышению памяти сравнивается с RSS, куда такие +страницы входят. Учёта сервера в них почти нет: `MemoryTracking` при этом +всего 113 МиБ. Это постоянная часть расхода: меньше неё нода не занимает +никогда. Из гигабайта на саму работу оставалось около 350 МиБ. -Лимит для ClickHouse — не потолок, а вход в самонастройку. Сервер берёт 0,9 от -того, что видит, под общий предел памяти и 0,5 — под размеры кэшей и пороги -сброса на диск. Поэтому «поднять потолок ничего не стоит» верно для девяти -сервисов, которые лимит не читают, и неверно для двух нод: от коробки зависит, -где у них срабатывают предохранители. Снять лимит с нод по той же причине -нельзя — без коробки каждая считает свои доли от всей машины, и память у -машины кончится раньше, чем сервер решит экономить. Панель «не упёрлись ли -ноды в память» из ADR 0002 тоже требует знаменателя. Сумма всех потолков после -правки больше восьми гигабайт, и это не противоречие: `mem_limit` — потолок, а -не бронь, Docker ничего не резервирует, в покое стенд занимает около 3,2 ГБ. -Восемь гигабайт нужны не сумме потолков, а работе с запасом. +Дальше нода набирает рабочую память, и вместе с постоянной частью та перестаёт +помещаться. За обычную сессию стенда замерено: страницы бинарника 523 → 531 +МиБ, то есть стоят на месте, а рабочая память 489 → 723 МиБ. Инцидент 31 июля +имел ту же форму — 531 → 774 МиБ: та же постоянная часть плюс наросшая работа. +Рестарт возвращал число назад, и это выглядело как утечка. Отчего именно растёт +рабочая память, здесь не разбирается — для решения хватает того, что одна +только постоянная часть занимала больше половины коробки. Заводские кэши тут +ни при чём: в коробке на гигабайт +ClickHouse сам понижает каждый до 512 МиБ, а на стенде без данных они и вовсе +пусты. + +Для ClickHouse коробка — не просто верхняя граница: сервер настраивает себя по +её размеру. При запуске он читает её и берёт 0,9 под общий предел собственной +памяти, а 0,5 — под размеры кэшей и под порог, после которого промежуточные +данные запроса уходят на диск. Поэтому «поднять предел ничего не стоит» верно +для девяти сервисов, которые своей коробки не читают, и неверно для двух нод: от +её размера зависит, когда сервер начинает себя ограничивать. Снять лимит с нод +по той же причине нельзя — без коробки каждая нода считает свои доли от всей +машины, и память кончится у машины раньше, чем сервер решит экономить. Панель +«не упёрлись ли ноды в память» из ADR 0002 без коробки тоже не работает: +упираться становится не во что. + +Сумма всех пределов после правки больше восьми гигабайт, и это не противоречие. +`mem_limit` — верхняя граница, а не резерв: Docker ничего не откладывает +заранее, и в покое стенд занимает около 3,2 ГБ. Восемь гигабайт нужны не сумме +пределов, а работе с запасом. Остальные девять лимитов не меняются, потому что ни один из них ни разу не сработал. Снять их скопом — то же изменение без свидетельств, каким они были @@ -111,8 +124,8 @@ has limited RAM`; `cache_size_to_ram_max_ratio` = 0,5; праздный серв `max_bytes_ratio_before_external_sort` = 0,5. Тяжёлый `GROUP BY` в коробке не получает отказ, а уходит на диск и досчитывается; отказ по памяти остаётся для того, что на диск не сбрасывается. Учебный сюжет «тяжёлый запрос — симптом на -графике памяти» из ADR 0002 выглядит полкой и дисковым вводом-выводом, а не -обрывом. +графике памяти» из ADR 0002 выглядит ровной линией у верхней границы и +дисковой нагрузкой, а не обрывом. cgroup v2: `docker stats` показывает `memory.current` минус неактивные файловые страницы. На праздном контейнере — 1056 МБ при 779 МБ файловых и 234 МБ @@ -120,8 +133,8 @@ cgroup v2: `docker stats` показывает `memory.current` минус не `apache/kafka:4.3.1`: `kafka-server-start.sh` при пустой `KAFKA_HEAP_OPTS` ставит `-Xmx1G -Xms1G`, а по размеру коробки кучу не подбирает. Поэтому -переменную не убираем: без неё куча стала бы больше, а не меньше, и с нынешней -коробкой на гигабайт это был бы прямой OOM-kill. +переменную не убираем: без неё куча стала бы больше, а не меньше, и в нынешней +коробке Kafka на гигабайт ядро убивало бы контейнер за нехватку памяти. На стенде с новой коробкой в 4 ГиБ то же поведение подтвердилось: `max_server_memory_usage` = 3,60 ГиБ, в журнале ноды `Lowered mark cache size diff --git a/scripts/stand-smoke.sh b/scripts/stand-smoke.sh index 468cf92..2e14ef1 100755 --- a/scripts/stand-smoke.sh +++ b/scripts/stand-smoke.sh @@ -592,15 +592,18 @@ check_superset() { # Контейнер, убитый ядром за нехватку памяти, Docker поднимает сам, и через # полминуты его проверка состояния снова зелёная: о смерти она не расскажет. -# Поэтому спрашиваем у Docker два факта — убивало ли контейнер ядро и включалась -# ли политика перезапуска. Ручной `docker compose restart` счётчик не трогает, -# так что документированный перезапуск нод проверку не роняет. Порога здесь -# нет: это «да или нет», а не бюджет памяти (ADR 0004). +# Поэтому спрашиваем у Docker два факта — убивало ли ядро что-нибудь в контейнере +# за память и включалась ли политика перезапуска. Ручной `docker compose restart` +# счётчик не трогает, так что документированный перезапуск нод проверку не +# роняет. Порога здесь нет: это «да или нет», а не бюджет памяти (ADR 0004). +# +# Два факта берутся одним `--format` и разбираются образцом — тем же приёмом, +# что и состояние с проверкой здоровья выше. check_containers_survived() { local container_id local service local state - local hurt=0 + local problems=0 for service in "${LONG_LIVED_SERVICES[@]}"; do container_id="$(compose ps --all --quiet "$service" 2>/dev/null || true)" @@ -611,22 +614,24 @@ check_containers_survived() { state="$(docker inspect --format '{{.State.OOMKilled}}/{{.RestartCount}}' "$container_id" 2>/dev/null || true)" case "$state" in false/0) ;; + # OOMKilled поднимается и когда ядро убило процесс внутри живого + # контейнера, поэтому говорим про процесс, а не про контейнер. true/*) - fail "контейнер ${service} был убит из-за нехватки памяти" - hurt=1 + fail "в контейнере ${service} ядро убило процесс из-за нехватки памяти" + problems=$((problems + 1)) ;; false/*) fail "контейнер ${service} перезапускался, счётчик Docker — ${state#*/}" - hurt=1 + problems=$((problems + 1)) ;; *) fail "Docker не рассказал о состоянии контейнера ${service}" - hurt=1 + problems=$((problems + 1)) ;; esac done - if [[ "$hurt" -eq 0 ]]; then - pass 'ни один долгоживущий контейнер не был убит по памяти и не перезапускался сам' + if [[ "$problems" -eq 0 ]]; then + pass 'ни в одном долгоживущем контейнере ядро не убивало процессы за память, и никто не перезапускался сам' fi } From 87785672668caa2b147b6017ac805298a454bfb5 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sat, 1 Aug 2026 15:26:22 +0300 Subject: [PATCH 3/3] =?UTF-8?q?style(stand):=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20=D1=83=D0=B6=D0=B0?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B4=D0=BE=20=D1=82=D0=BE=D0=B3=D0=BE,=20?= =?UTF-8?q?=D1=87=D0=B5=D0=B3=D0=BE=20=D0=BD=D0=B5=D1=82=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем Комментарии к правке были размером с объяснение, хотя объяснение уже лежит в ADR 0004. В compose.yaml четыре строки на одну настройку; в stand-smoke.sh одиннадцать новых строк там, где на весь файл до этого было две — шебанг и одна строка про разбор подстановок. Заодно в комментариях остались метафоры («бронь», «предохранители»), вычищенные из ADR прошлым коммитом. Что - compose.yaml: одна строка вместо четырёх — почему не гигабайт и куда идти за подробностями. - stand-smoke.sh: две строки вместо шести — зачем проверка вообще нужна. Комментарий про разбор `--format` убран целиком: он оправдывался перед читателем, а не помогал ему. - Комментарий про OOMKilled оставлен, но в одну строку: без него сообщение «убило процесс, а не контейнер» выглядит опиской. Проверка make config-test — зелено. Co-Authored-By: Claude Opus 5 --- compose.yaml | 5 +---- scripts/stand-smoke.sh | 14 +++----------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/compose.yaml b/compose.yaml index 20fa0cb..40f638d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -15,10 +15,7 @@ x-clickhouse-common: &clickhouse-common clickhouse-keeper: condition: service_healthy ulimits: *clickhouse-nofile - # Не гигабайт: около 550 МиБ от RSS праздной ноды — страницы её собственного - # бинарника, и рабочего места почти не оставалось. Лимит не бронь: в покое - # нода занимает столько же, а коробка задаёт, где сервер включает - # предохранители — свои кэши и сброс на диск (ADR 0004). + # Гигабайта не хватало: половину съедали страницы самого бинарника (ADR 0004). mem_limit: 4g healthcheck: test: ["CMD-SHELL", "clickhouse-client --host 127.0.0.1 --query 'SELECT 1' >/dev/null 2>&1"] diff --git a/scripts/stand-smoke.sh b/scripts/stand-smoke.sh index 2e14ef1..3d13b16 100755 --- a/scripts/stand-smoke.sh +++ b/scripts/stand-smoke.sh @@ -590,15 +590,8 @@ check_superset() { fi } -# Контейнер, убитый ядром за нехватку памяти, Docker поднимает сам, и через -# полминуты его проверка состояния снова зелёная: о смерти она не расскажет. -# Поэтому спрашиваем у Docker два факта — убивало ли ядро что-нибудь в контейнере -# за память и включалась ли политика перезапуска. Ручной `docker compose restart` -# счётчик не трогает, так что документированный перезапуск нод проверку не -# роняет. Порога здесь нет: это «да или нет», а не бюджет памяти (ADR 0004). -# -# Два факта берутся одним `--format` и разбираются образцом — тем же приёмом, -# что и состояние с проверкой здоровья выше. +# Убитый за память контейнер Docker поднимает сам, и проверка здоровья об этом +# промолчит. Порога здесь нет: это «да или нет», а не бюджет памяти (ADR 0004). check_containers_survived() { local container_id local service @@ -614,8 +607,7 @@ check_containers_survived() { state="$(docker inspect --format '{{.State.OOMKilled}}/{{.RestartCount}}' "$container_id" 2>/dev/null || true)" case "$state" in false/0) ;; - # OOMKilled поднимается и когда ядро убило процесс внутри живого - # контейнера, поэтому говорим про процесс, а не про контейнер. + # OOMKilled встаёт и когда убит процесс внутри живого контейнера. true/*) fail "в контейнере ${service} ядро убило процесс из-за нехватки памяти" problems=$((problems + 1))