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>
This commit is contained in:
2026-08-01 15:18:43 +03:00
co-authored by Claude Opus 5
parent a9d66ed74a
commit 5a21684dc4
4 changed files with 75 additions and 49 deletions
+2 -2
View File
@@ -59,8 +59,8 @@ Superset, создаёт администратора и импортирует
и `test_kafka`, метаданные и подключение Superset. Первый пробник создаёт и `test_kafka`, метаданные и подключение Superset. Первый пробник создаёт
таблицы на обеих нодах и читает через `Distributed` на ноде 2 строку из таблицы на обеих нодах и читает через `Distributed` на ноде 2 строку из
локальной таблицы ноды 1. Второй пишет в Kafka и читает свой маркер. В конце локальной таблицы ноды 1. Второй пишет в Kafka и читает свой маркер. В конце
проверка спрашивает у Docker, не убивало ли ядро какой-нибудь долгоживущий проверка спрашивает у Docker, не убивало ли ядро что-нибудь в долгоживущих
контейнер за нехватку памяти и не включалась ли политика перезапуска: убитый контейнерах за нехватку памяти и не включалась ли политика перезапуска: убитый
контейнер Docker поднимает сам, и проверка состояния об этом промолчит. контейнер Docker поднимает сам, и проверка состояния об этом промолчит.
Временный топик проверки с машины и запуски DAG удаляются; Временный топик проверки с машины и запуски DAG удаляются;
постоянный топик пробника сохраняется, а старые записи чистит Kafka. постоянный топик пробника сохраняется, а старые записи чистит Kafka.
+9 -1
View File
@@ -1,6 +1,7 @@
# ADR 0001. Сервисы каркаса стенда # ADR 0001. Сервисы каркаса стенда
Дата: 30 июля 2026 года. Статус: принято. Дата: 30 июля 2026 года. Статус: принято; ресурсный довод отозван
[ADR 0004](0004-resource-limits.md).
## Решение ## Решение
@@ -50,6 +51,13 @@ Prometheus читает встроенные точки метрик двух с
компьютера. Отказ от triggerer, второго Postgres и внешних сборщиков удерживает компьютера. Отказ от triggerer, второго Postgres и внешних сборщиков удерживает
стенд в пределе 3,4 ГБ. стенд в пределе 3,4 ГБ.
Ресурсный довод предыдущего абзаца отозван
[ADR 0004](0004-resource-limits.md): предела 3,4 ГБ у стенда нет, вместо него
объявлено требование к машине. Сами решения остаются в силе по остальным
основаниям, названным выше. Отказ от внешних сборщиков вдобавок частично
пересмотрен [ADR 0002](0002-monitoring-scope.md): сборщик метрик Kafka нужен
ради отставания чтения.
Тома `clickhouse_*_data` хранят данные keeper и двух нод ClickHouse. Тома `clickhouse_*_data` хранят данные keeper и двух нод ClickHouse.
`kafka_data`, `postgres_metadata_data`, `superset_home`, `prometheus_data` и `kafka_data`, `postgres_metadata_data`, `superset_home`, `prometheus_data` и
`grafana_data` хранят состояние своих сервисов. `airflow_logs` хранит журналы, `grafana_data` хранят состояние своих сервисов. `airflow_logs` хранит журналы,
+48 -35
View File
@@ -13,12 +13,13 @@
Проверка суммарного потребления памяти уходит из `scripts/stand-smoke.sh`. Проверка суммарного потребления памяти уходит из `scripts/stand-smoke.sh`.
Вместе с ней уходят зависящая от её сообщения проверка в `tests/docs-guards.sh` Вместе с ней уходят зависящая от её сообщения проверка в `tests/docs-guards.sh`
и описание порога в README. Взамен `make smoke` спрашивает у Docker, не убивало и описание порога в README. Взамен `make smoke` спрашивает у Docker, не убивало
ли ядро какой-нибудь долгоживущий контейнер за нехватку памяти и не включалась ли ядро что-нибудь в долгоживущих контейнерах за нехватку памяти и не включалась
ли политика перезапуска. Это не бюджет в новой одежде: порога у проверки нет, ли политика перезапуска. Прежний бюджет она не заменяет: числа, с которым
она отвечает «да или нет». что-то сравнивают, у неё нет вовсе — ответ либо «убивало», либо «нет».
Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Остальные `mem_limit` не Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Дальше в этом тексте предел
меняются. памяти контейнера (`mem_limit`) называется коробкой: сервер живёт внутри неё и
о самой машине ничего не знает. Остальные `mem_limit` не меняются.
Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются
статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`, статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`,
@@ -51,35 +52,47 @@
своей коробки, сумма при этом была в норме — 3181 МиБ из 3242,5, — а красным своей коробки, сумма при этом была в норме — 3181 МиБ из 3242,5, — а красным
стенд сделал пробник. Механизм оповещения работает и без неё. стенд сделал пробник. Механизм оповещения работает и без неё.
Замена не возвращает бюджет через заднюю дверь. Дыра, которую она закрывает, Новая проверка — не тот же бюджет под другим именем. Она закрывает другую дыру:
другая: убитый за память контейнер Docker поднимает сам, через полминуты его убитый за память контейнер Docker поднимает сам, через полминуты его проверка
проверка состояния снова зелёная, и `make smoke` докладывает, что всё хорошо — состояния снова зелёная, и `make smoke` докладывает, что всё хорошо — хотя нода
хотя нода умирала и потеряла всё, что держала в памяти. Новая проверка умирала и потеряла всё, что держала в памяти. Проверка спрашивает у Docker
спрашивает у Docker факт, а не величину, и потому нечего подгонять и незачем факт, а не величину, поэтому её нечего подгонять и незачем пересматривать,
пересматривать при росте стенда. когда стенд растёт.
Гигабайт на ноду был неработоспособен, и причина не та, что предполагал Гигабайт на ноду был неработоспособен, и причина не та, что предполагал
issue #21. Праздная нода держит `VmRSS` 846 МиБ, из которых 530 МиБ — страницы issue #21. У праздной ноды около 530 МиБ RSS — это страницы её собственного
её собственного бинарника: файл `clickhouse` весит 790 МБ, и ядро подгружает бинарника: файл `clickhouse` весит 790 МБ, ядро подгружает куски по мере
куски по мере обращения к ним. Учёта памяти в этом почти нет, `MemoryTracking` обращения к ним, а отказ по превышению памяти сравнивается с RSS, куда такие
всего 113 МиБ, но отказ по превышению сравнивается с RSS, куда чистые страницы страницы входят. Учёта сервера в них почти нет: `MemoryTracking` при этом
кода входят. Значит из гигабайта ноде оставалось около 350 МиБ рабочего места, всего 113 МиБ. Это постоянная часть расхода: меньше неё нода не занимает
а RSS полз вверх просто потому, что каждый новый прогон пробника трогал новый никогда. Из гигабайта на саму работу оставалось около 350 МиБ.
код: 531 → 774 МиБ за сессию. Рестарт возвращал число назад, и это выглядело
как утечка. Заводские кэши тут ни при чём: в коробке на гигабайт ClickHouse сам
понижает каждый до 512 МиБ, а на стенде без данных они и вовсе пусты.
Лимит для ClickHouse — не потолок, а вход в самонастройку. Сервер берёт 0,9 от Дальше нода набирает рабочую память, и вместе с постоянной частью та перестаёт
того, что видит, под общий предел памяти и 0,5 — под размеры кэшей и пороги помещаться. За обычную сессию стенда замерено: страницы бинарника 523 → 531
сброса на диск. Поэтому «поднять потолок ничего не стоит» верно для девяти МиБ, то есть стоят на месте, а рабочая память 489 → 723 МиБ. Инцидент 31 июля
сервисов, которые лимит не читают, и неверно для двух нод: от коробки зависит, имел ту же форму — 531 → 774 МиБ: та же постоянная часть плюс наросшая работа.
где у них срабатывают предохранители. Снять лимит с нод по той же причине Рестарт возвращал число назад, и это выглядело как утечка. Отчего именно растёт
нельзя — без коробки каждая считает свои доли от всей машины, и память у рабочая память, здесь не разбирается — для решения хватает того, что одна
машины кончится раньше, чем сервер решит экономить. Панель «не упёрлись ли только постоянная часть занимала больше половины коробки. Заводские кэши тут
ноды в память» из ADR 0002 тоже требует знаменателя. Сумма всех потолков после ни при чём: в коробке на гигабайт
правки больше восьми гигабайт, и это не противоречие: `mem_limit` — потолок, а ClickHouse сам понижает каждый до 512 МиБ, а на стенде без данных они и вовсе
не бронь, Docker ничего не резервирует, в покое стенд занимает около 3,2 ГБ. пусты.
Восемь гигабайт нужны не сумме потолков, а работе с запасом.
Для 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` в коробке не `max_bytes_ratio_before_external_sort` = 0,5. Тяжёлый `GROUP BY` в коробке не
получает отказ, а уходит на диск и досчитывается; отказ по памяти остаётся для получает отказ, а уходит на диск и досчитывается; отказ по памяти остаётся для
того, что на диск не сбрасывается. Учебный сюжет «тяжёлый запрос — симптом на того, что на диск не сбрасывается. Учебный сюжет «тяжёлый запрос — симптом на
графике памяти» из ADR 0002 выглядит полкой и дисковым вводом-выводом, а не графике памяти» из ADR 0002 выглядит ровной линией у верхней границы и
обрывом. дисковой нагрузкой, а не обрывом.
cgroup v2: `docker stats` показывает `memory.current` минус неактивные файловые cgroup v2: `docker stats` показывает `memory.current` минус неактивные файловые
страницы. На праздном контейнере — 1056 МБ при 779 МБ файловых и 234 МБ страницы. На праздном контейнере — 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` `apache/kafka:4.3.1`: `kafka-server-start.sh` при пустой `KAFKA_HEAP_OPTS`
ставит `-Xmx1G -Xms1G`, а по размеру коробки кучу не подбирает. Поэтому ставит `-Xmx1G -Xms1G`, а по размеру коробки кучу не подбирает. Поэтому
переменную не убираем: без неё куча стала бы больше, а не меньше, и с нынешней переменную не убираем: без неё куча стала бы больше, а не меньше, и в нынешней
коробкой на гигабайт это был бы прямой OOM-kill. коробке Kafka на гигабайт ядро убивало бы контейнер за нехватку памяти.
На стенде с новой коробкой в 4 ГиБ то же поведение подтвердилось: На стенде с новой коробкой в 4 ГиБ то же поведение подтвердилось:
`max_server_memory_usage` = 3,60 ГиБ, в журнале ноды `Lowered mark cache size `max_server_memory_usage` = 3,60 ГиБ, в журнале ноды `Lowered mark cache size
+16 -11
View File
@@ -592,15 +592,18 @@ check_superset() {
# Контейнер, убитый ядром за нехватку памяти, Docker поднимает сам, и через # Контейнер, убитый ядром за нехватку памяти, Docker поднимает сам, и через
# полминуты его проверка состояния снова зелёная: о смерти она не расскажет. # полминуты его проверка состояния снова зелёная: о смерти она не расскажет.
# Поэтому спрашиваем у Docker два факта — убивало ли контейнер ядро и включалась # Поэтому спрашиваем у Docker два факта — убивало ли ядро что-нибудь в контейнере
# ли политика перезапуска. Ручной `docker compose restart` счётчик не трогает, # за память и включалась ли политика перезапуска. Ручной `docker compose restart`
# так что документированный перезапуск нод проверку не роняет. Порога здесь # счётчик не трогает, так что документированный перезапуск нод проверку не
# нет: это «да или нет», а не бюджет памяти (ADR 0004). # роняет. Порога здесь нет: это «да или нет», а не бюджет памяти (ADR 0004).
#
# Два факта берутся одним `--format` и разбираются образцом — тем же приёмом,
# что и состояние с проверкой здоровья выше.
check_containers_survived() { check_containers_survived() {
local container_id local container_id
local service local service
local state local state
local hurt=0 local problems=0
for service in "${LONG_LIVED_SERVICES[@]}"; do for service in "${LONG_LIVED_SERVICES[@]}"; do
container_id="$(compose ps --all --quiet "$service" 2>/dev/null || true)" 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)" state="$(docker inspect --format '{{.State.OOMKilled}}/{{.RestartCount}}' "$container_id" 2>/dev/null || true)"
case "$state" in case "$state" in
false/0) ;; false/0) ;;
# OOMKilled поднимается и когда ядро убило процесс внутри живого
# контейнера, поэтому говорим про процесс, а не про контейнер.
true/*) true/*)
fail "контейнер ${service} был убит из-за нехватки памяти" fail "в контейнере ${service} ядро убило процесс из-за нехватки памяти"
hurt=1 problems=$((problems + 1))
;; ;;
false/*) false/*)
fail "контейнер ${service} перезапускался, счётчик Docker — ${state#*/}" fail "контейнер ${service} перезапускался, счётчик Docker — ${state#*/}"
hurt=1 problems=$((problems + 1))
;; ;;
*) *)
fail "Docker не рассказал о состоянии контейнера ${service}" fail "Docker не рассказал о состоянии контейнера ${service}"
hurt=1 problems=$((problems + 1))
;; ;;
esac esac
done done
if [[ "$hurt" -eq 0 ]]; then if [[ "$problems" -eq 0 ]]; then
pass 'ни один долгоживущий контейнер не был убит по памяти и не перезапускался сам' pass 'ни в одном долгоживущем контейнере ядро не убивало процессы за память, и никто не перезапускался сам'
fi fi
} }