Деплой метрик перечитывает конфигурацию, а не только кладёт её на диск (#3467) #3475

Closed
bot-backend wants to merge 2 commits from fix/metrics-deploy-reloads-prometheus into main
2 changed files with 311 additions and 1 deletions

View file

@ -287,6 +287,43 @@ jobs:
# обязан быть безопасен даже при полном доступе к дашбордам.
bash scripts/setup-metrics-grafana-role.sh
# ── Prometheus: проверить конфигурацию ДО ЛЮБОГО применения ─────
# Тот же порядок, что у Caddy ниже: проверка — здесь, применение —
# только после неё. Стоит ВЫШЕ `up -d` намеренно: `up -d`
# пересоздаёт контейнер при смене образа, и тогда битый конфиг
# положил бы Prometheus раньше, чем деплой успел бы покраснеть.
# Сама перезагрузка — в конце, после приёмки: дёргать ручку
# раньше, чем процесс отвечает, нечем.
#
# ЗАЧЕМ ЭТО ВООБЩЕ. Правила подключены КАТАЛОГОМ, поэтому новый
# текст контейнер видит сразу — но Prometheus читает правила только
# при старте либо по явной перезагрузке, а деплой не делал ни того,
# ни другого. 12.09: контейнер работал с 27.08, на диске (и внутри
# контейнера) лежали правила из #3464, а /api/v1/rules отдавал
# прежние. Зелёный деплой, новый файл на диске, старое поведение —
# отказ без единого отрицательного признака. Значит и все прежние
# правки ops/metrics/prometheus/** вступали в силу лишь тогда, когда
# контейнер пересоздавался по какой-то посторонней причине.
#
# Проверяем ОДНОРАЗОВЫМ контейнером того же образа по файлам С
# ДИСКА, а не `docker exec` в работающий: prometheus.yml подключён
# бинд-маунтом ОДНОГО файла, и работающий контейнер после `git
# reset` читает СТАРЫЙ инод — exec проверял бы не тот текст,
# который применится.
#
# Нужны ОБЕ команды: при пустом (или не смонтированном) каталоге
# правил `check config` печатает «0 rule files found» и проходит, а
# `check rules` по явному шаблону в этом случае падает — проверено
# на стенде prom/prometheus:v3.1.0.
PROM_IMAGE=$(grep -oE 'prom/prometheus:[^ ]+' docker-compose.metrics.yml | head -1)
if ! docker run --rm -v "$PWD/ops/metrics/prometheus:/etc/prometheus:ro" \
--entrypoint sh "$PROM_IMAGE" -c \
'promtool check config /etc/prometheus/prometheus.yml && promtool check rules /etc/prometheus/rules/*.yml'; then
echo "ОШИБКА: конфиг или правила Prometheus не проходят проверку — ничего не применяем."
echo "Работающий Prometheus не тронут и остаётся на прежней конфигурации. Чинить и повторять."
exit 1
fi
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml pull --quiet
COMPOSE_PROFILES="$PROFILES" \
@ -317,6 +354,34 @@ jobs:
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
fi
# ── Бинд-маунт ОДНОГО файла держит инод ────────────────────────
# Та же ловушка, что у Alertmanager выше и у Alloy в джобах агентов:
# `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а
# работающий контейнер продолжает читать прежний, уже удалённый.
# `up -d` этого не видит (сравнивается описание сервиса, не
# содержимое маунта), перезагрузка по API — тоже (перечитывается тот
# же открытый инод). Лечит только пересоздание контейнера.
#
# Список обязан совпадать с одиночными файловыми маунтами
# docker-compose.metrics.yml; сторожит соответствие
# backend/tests/ops/test_3467_prometheus_reload.py. Производные
# файлы (alertmanager.yml, alertmanager_targets.gen.yml) сюда не
# входят: их рендерит сам деплой и уже разбирается с инодом.
for entry in \
"prometheus gendesign-prometheus ops/metrics/prometheus/prometheus.yml /etc/prometheus/prometheus.yml" \
"loki gendesign-loki ops/metrics/loki/loki-config.yml /etc/loki/loki-config.yml" \
"alert-ack gendesign-alert-ack ops/metrics/alert-ack/app.py /app/app.py"; do
set -- $entry
docker ps -q -f "name=^$2\$" | grep -q . || continue
if [ "$(stat -c %i "$3")" = "$(docker exec "$2" stat -c %i "$4")" ]; then
continue
fi
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml \
up -d --force-recreate "$1"
echo "$1: $3 переписан новым инодом — контейнер пересоздан."
done
# ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
@ -337,13 +402,45 @@ jobs:
# ── Приёмка ────────────────────────────────────────────────────
for i in $(seq 1 30); do
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null \
&& docker exec gendesign-grafana wget -q --spider http://localhost:3000/api/health 2>/dev/null; then
break
fi
sleep 3
done
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
# ── Применение: перечитать конфигурацию работающими сервисами ───
# Конфиг проверен выше, контейнеры отвечают — только теперь.
#
# Prometheus перечитывает и prometheus.yml, и правила по POST
# /-/reload (флаг --web.enable-lifecycle стоит в
# docker-compose.metrics.yml). Без флага ручка отвечает 405 и шаг
# краснеет — это и нужно: молча оставаться на старых правилах
# деплой больше не должен.
docker exec gendesign-prometheus wget -q -O- --post-data='' \
http://localhost:9090/-/reload
echo "Prometheus: конфигурация перечитана, правила вступили в силу."
# Grafana. Дашборды провайдер пересканирует сам раз в 30 с
# (updateIntervalSeconds в provisioning/dashboards/dashboards.yml —
# проверено на стенде grafana:11.5.1: новый файл появился без
# рестарта), а ДАТАСОРСЫ применяются только при старте: правка
# provisioning/datasources лежала бы на диске без эффекта — ровно
# тот же разрыв, что и у правил Prometheus. Штатная ручка
# перезагрузки дешевле рестарта и не роняет открытые дашборды.
#
# Пароль берётся из окружения САМОГО контейнера: в аргументы команд
# на хосте и в лог деплоя он так не попадает.
if docker exec gendesign-grafana sh -c \
'wget -q -O- --post-data="" --header="Authorization: Basic $(printf "%s:%s" "${GF_SECURITY_ADMIN_USER:-admin}" "${GF_SECURITY_ADMIN_PASSWORD}" | base64 -w0)" http://localhost:3000/api/admin/provisioning/datasources/reload'; then
echo
echo "Grafana: датасорсы перечитаны."
else
echo "ОШИБКА: Grafana не перечитала датасорсы — правка provisioning/datasources осталась бы без эффекта."
exit 1
fi
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
agent-apps:
runs-on: ubuntu-latest

View file

@ -0,0 +1,213 @@
"""Деплой метрик обязан не только положить конфигурацию на диск, но и применить её.
ЧТО СЛУЧИЛОСЬ НА ПРОДЕ 12.09 (#3467). #3464 поправил два правила в
`ops/metrics/prometheus/rules/infra.yml`, «Deploy Metrics / server» на этом
коммите success, новый текст лежал и на диске инфраструктурного хоста, и
ВНУТРИ контейнера:
docker exec gendesign-prometheus grep -n container_memory_working_set_bytes \
/etc/prometheus/rules/infra.yml строка 139, новая формулировка
а API отдавал прежнее правило:
GET /api/v1/rules ContainerNearMemoryLimit:
container_spec_memory_limit_bytes{name!=""} > 0 and ...
ПОЧЕМУ. Правила подключены КАТАЛОГОМ, поэтому новый текст контейнер видит сразу,
но Prometheus читает правила только при старте или по явной перезагрузке.
Контейнер не перезапускался с 2026-08-27T18:12:24Z (16 суток), а деплой
перезагружал один Caddy. То есть ЛЮБАЯ правка ops/metrics/prometheus/** с
момента появления стека доезжала до диска и не вступала в силу до случайного
пересоздания контейнера при зелёном деплое и без единого отрицательного
признака. Тот же класс, что #3448: объявлено ≠ исполняется.
ЧТО ПРОВЕРЕНО НА СТЕНДЕ, а не взято из документации (prom/prometheus:v3.1.0,
те же файлы, что на проде):
правило переписано на диске, контейнер не тронут API отдаёт СТАРОЕ
promtool check config && check rules rc=0, затем POST /-/reload rc=0
после reload API отдаёт НОВОЕ
битое правило проверка rc=1 (reload не делается)
пустой каталог правил `check config` ПРОХОДИТ («0 rule files found»),
падает только `check rules` потому в деплое стоят
обе команды, а не одна
Тест сторожит связку целиком: правки правил приезжают этим воркфлоу (триггер),
перезагрузка в нём есть, она стоит ПОСЛЕ проверки конфигурации, а провалившаяся
проверка обрывает деплой и не доходит до перезагрузки. Отдельно проверяется, что
у каждого одиночного файлового маунта есть путь доезда: такой маунт держит инод,
и перезагрузка по API его не лечит (см. test_3xxx_alertmanager_inode.py).
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
COMPOSE = REPO_ROOT / "docker-compose.metrics.yml"
# Перезагрузка Prometheus: POST на ручку lifecycle из работающего контейнера.
#
# Хвост строки смотрим НАРОЧНО: `|| true` после команды глушит её отказ, и шаг
# остаётся зелёным с неперечитанной конфигурацией — то есть ровно тот разрыв,
# который тут и чинится, только теперь ещё и с «зелёной сводкой». Ревьюер
# #3475 этой мутацией прошёл гейт насквозь.
RELOAD = re.compile(
r"docker exec gendesign-prometheus wget[^\n]*--post-data[^\n]*"
r"http://localhost:9090/-/reload(?![^\n]*\|\|)"
)
# Проверка конфигурации: обе команды promtool по файлам с диска.
CHECK = re.compile(
r"promtool check config \S*prometheus\.yml"
r" && promtool check rules \S*rules/\*\.yml"
)
# Ветка «проверка не прошла» — от `if ! ... promtool ...` до её `fi`.
CHECK_FAILED_BRANCH = re.compile(r"if ! docker run[^\n]*promtool[^\n]*; then(.+?)\n\s*fi\n", re.S)
# Одиночный файловый маунт в серверном стеке: ./ops/metrics/... : /путь/в/контейнере.
# Суффикс прав (`:ro`) НЕ требуется: инод маунт держит одинаково что на чтение,
# что на запись, а требование `:ro` делало гейт слепым к маунту без него.
MOUNT = re.compile(r"-\s+\./(ops/metrics/[^\s:]+):(/[^\s:]+)")
def _server_script() -> str:
"""Скрипт серверной джобы с ЗАКЛЕЕННЫМИ переносами строк.
Команды в воркфлоу разбиты `\\` по ширине, и без склейки любой поиск
«команда целиком» ловил бы только первую строку.
"""
text = WORKFLOW.read_text(encoding="utf-8")
start = text.find("Поднять серверный стек")
end = text.find("# ═══ АГЕНТЫ")
assert start != -1 and end != -1, (
"в deploy-metrics.yml не найдены границы серверной джобы: шаг «Поднять "
"серверный стек» или заголовок «АГЕНТЫ» переименовали — гейт смотрел бы "
"не туда, поправь якоря здесь"
)
return re.sub(r"\s*\\\n\s*", " ", text[start:end])
def test_rules_are_delivered_by_this_workflow() -> None:
"""Правки правил приезжают на хост именно этим деплоем.
Без этого остальные проверки сторожили бы механизм, который никто не
запускает: «объявлено» и «исполняется» разошлись бы уже на триггере.
"""
text = WORKFLOW.read_text(encoding="utf-8")
start, end = text.find("paths:"), text.find("workflow_dispatch")
assert start != -1 and end != -1, "в deploy-metrics.yml не найден блок триггеров paths:"
assert '"ops/metrics/**"' in text[start:end], (
"деплой не запускается на правку ops/metrics/** — правила меняются мимо него"
)
def test_deploy_reloads_prometheus() -> None:
"""Перезагрузка Prometheus в деплое есть.
Её отсутствие и есть #3467: конфигурация на диске новая, работающий процесс
на старой, деплой зелёный.
"""
assert RELOAD.search(_server_script()), (
"в серверной джобе нет POST /-/reload для Prometheus — правки правил "
"лягут на диск и не вступят в силу, как это и было с #3464"
)
def test_reload_comes_after_config_check() -> None:
"""Перезагрузка — только после проверки конфигурации, как у Caddy.
Порядок здесь несущий: перезагрузка битого конфига оставляет Prometheus на
прежнем то есть ровно тот же тихий отказ, который чинится.
"""
script = _server_script()
check = CHECK.search(script)
reload_ = RELOAD.search(script)
assert check, "в деплое нет проверки promtool (check config + check rules)"
assert reload_, "в деплое нет перезагрузки Prometheus"
assert check.start() < reload_.start(), (
"перезагрузка стоит РАНЬШЕ проверки конфигурации — битый конфиг молча "
"оставит Prometheus на старой конфигурации при зелёном деплое"
)
def test_config_check_precedes_any_application() -> None:
"""Проверка стоит ВЫШЕ `up -d`, а не между подъёмом и перезагрузкой.
`up -d` пересоздаёт контейнер при смене образа: проверка ниже него не
успевает спасти битый конфиг уже применён стартом нового контейнера, и
Prometheus не поднимется вовсе.
"""
script = _server_script()
check = CHECK.search(script)
assert check, "в деплое нет проверки promtool (check config + check rules)"
assert check.start() < script.index("up -d --remove-orphans"), (
"проверка конфигурации стоит после `up -d` — контейнер успеет "
"пересоздаться с битым конфигом раньше, чем деплой покраснеет"
)
def test_failed_check_stops_deploy_without_reload() -> None:
"""Провалившаяся проверка обрывает деплой и НЕ перезагружает Prometheus."""
branch = CHECK_FAILED_BRANCH.search(_server_script())
assert branch, "проверка promtool не закрыта веткой обработки отказа"
body = branch.group(1)
assert "exit 1" in body, "при провале проверки деплой продолжается — отказ выглядел бы успехом"
assert "/-/reload" not in body, "в ветке отказа делается перезагрузка"
def test_lifecycle_flag_makes_reload_possible() -> None:
"""Ручка /-/reload работает только с --web.enable-lifecycle.
Флаг и перезагрузка одно целое: снимут флаг, и POST начнёт отвечать 405.
"""
assert "--web.enable-lifecycle" in COMPOSE.read_text(encoding="utf-8"), (
"у Prometheus снят --web.enable-lifecycle — перезагрузка из деплоя невозможна"
)
def test_every_single_file_mount_has_a_way_to_arrive() -> None:
"""У каждого одиночного файлового маунта есть путь доезда до контейнера.
Бинд-маунт ОДНОГО файла держит инод: `git reset --hard` пишет новый файл, а
контейнер продолжает читать прежний `up -d` этого не видит, перезагрузка
по API тоже (перечитывается тот же инод). Лечит только пересоздание, и
деплой обязан сверять инод по каждому такому файлу.
Список берётся ИЗ compose, а не из воркфлоу: добавят новый файловый маунт
тест покраснеет, пока для него не появится сверка. Производные файлы
(alertmanager.yml, alertmanager_targets.gen.yml) в git не лежат и потому
сюда не попадают: их рендерит сам деплой и сам разбирается с инодом.
"""
script = _server_script()
mounts = [
(host, container)
for host, container in MOUNT.findall(COMPOSE.read_text(encoding="utf-8"))
if (REPO_ROOT / host).is_file()
]
assert len(mounts) == 3, (
f"ожидались три одиночных файловых маунта (prometheus.yml, loki-config.yml, "
f"alert-ack/app.py), найдено: {mounts}"
)
assert "stat -c %i" in script and '--force-recreate "$1"' in script, (
"в деплое нет сверки инода с пересозданием контейнера"
)
for host, container in mounts:
assert re.search(rf'"[^"\n]*{re.escape(host)} {re.escape(container)}[^"\n]*"', script), (
f"{host} не сверяется с {container}: правка этого файла ляжет на диск, "
f"а контейнер останется на старом иноде — молча"
)
def test_grafana_datasources_are_reloaded() -> None:
"""Датасорсы Grafana применяются ТОЛЬКО при старте — их надо перечитать явно.
Замер на стенде (grafana/grafana:11.5.1, provisioning из этого репозитория):
новый дашборд появился сам за 30 с (updateIntervalSeconds), а изменённый url
датасорса не применился и через 75 с; POST на
/api/admin/provisioning/datasources/reload применил его сразу.
"""
assert "/api/admin/provisioning/datasources/reload" in _server_script(), (
"деплой не перечитывает датасорсы Grafana — правка "
"provisioning/datasources останется без эффекта при зелёном деплое"
)