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:
@@ -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.
|
||||||
|
|||||||
@@ -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` хранит журналы,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user