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
+30 -28
View File
@@ -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