# Шаг 2 — подсистема health-check и таблица слотов ## Контекст Стенд шага 1 работает: OVS маршрутизирует три сегмента и балансирует TCP на два бэкенда группой `type=select`. Но состав пула статичен — отказ бэкенда балансировщику неизвестен, и половина соединений уходит в никуда. Шаг 2 добавляет подсистему проверки живости: узел сам определяет состояние членов пула, выводит мёртвых из балансировки и возвращает восстановившихся. Это §7 дизайн-концепции `../hpnn_v1/docs/design.md`, адаптированный к одноузловому прототипу (кворум по кластеру и генерации пулов появятся вместе с control plane). Вместе с этим по решению заказчика группа `type=select` заменяется на **таблицу слотов с Maglev-раскладкой** (§5 дизайна). Причина: при перезаписи бакетов группы хэш пересчитывается целиком, и размещение новых соединений переезжает даже у нетронутых членов. Таблица слотов даёт минимальное возмущение — переезжают только слоты выбывшего члена — и является тем самым механизмом, на котором позже строится stateless-датапас. ## Ключевые решения **Пробы уходят с уникального адреса узла в сегменте бэкенда** (§7.1 дизайна), не с VIP. У чисто-OpenFlow узла шага 1 адресов в ядре нет вовсе, поэтому на мосту появляются два internal-порта: `hcif-p2` (`10.20.0.253`) и `hcif-p3` (`10.30.0.253`). Это `lbif-bk` из §3.3 — на них позже поселится FRR. **Пробер — демон на Go (`hcd`) внутри контейнера-балансировщика**, как `lb-agent` на узле LB в дизайне. Становится основным процессом контейнера; `ovsdb-server` и `ovs-vswitchd` работают демонами рядом. **Fail-close при полном отказе пула:** таблица слотов пустеет, остаётся только `priority=0 actions=drop` — трафик на VIP отбрасывается со счётчиком. **Дренаж** (§7.3): член выводится из раскладки вручную, пробы при этом продолжают идти и состояние остаётся `up`. **Нюанс, который надо зафиксировать честно.** В пайплайне остаётся `ct(nat)` для обратного пути, а conntrack закрепляет трансляцию за соединением при его создании. Поэтому уже установленные сессии переживают смену раскладки и без Maglev. Реальная ценность таблицы слотов здесь — стабильность размещения **новых** соединений и подготовка к stateless-датапасу, где никакой ct не подстрахует. Это проверяется отдельным тестом (см. верификацию). ## Реализация ### 1. Датапас: слоты вместо группы (`lb/pipeline.sh`) Таблицы перенумеровываются так, чтобы все переходы шли вперёд (`goto_table` назад не умеет): | Таблица | Было | Стало | |---|---|---| | 10 | листенер → `group:100` | листенер → `multipath(...)` → таблица 11 | | 11 | DNAT по `reg2` | **таблица слотов**: `reg1=` → `reg2=` | | 12 | — | DNAT по `reg2` (бывшая 11) | ``` table=10,priority=200,tcp,nw_dst=,tp_dst=80 \ actions=multipath(symmetric_l4,,modulo_n,1024,0,NXM_NX_REG1[]),goto_table:11 # правила слотов заливает hcd; pipeline.sh отдаёт только fail-close основание table=11,priority=0 actions=drop ``` Группа 100 удаляется (`ovs-ofctl del-groups`), `apply.sh` её больше не создаёт. Счётчики правил таблицы 11 дают бесплатную per-member статистику. ### 2. Порты источника проб (`lb/entrypoint.sh`) Для каждого приватного сегмента: ``` ovs-vsctl --may-exist add-port br-lb hcif-p2 \ -- set Interface hcif-p2 type=internal ofport_request=4 mac='"02:42:0a:14:00:fd"' ip addr replace 10.20.0.253/24 dev hcif-p2 ip link set hcif-p2 up ip neigh replace 10.20.0.2 lladdr 02:42:0a:14:00:02 dev hcif-p2 nud permanent ``` Статическая ARP-запись избавляет ядро от резолва MAC бэкенда — MAC членов и так зафиксированы в `docker-compose.yml`. Обратное направление (бэкенд резолвит `10.20.0.253`) закрывает ARP-респондер OVS. Правила для hc-портов в `pipeline.sh`: | Таблица | Правило | |---|---| | 0 | `in_port=hcif-*` → таблица 20 (без `learn`, без `ct`) | | 5 | ARP-респондер для `10.20.0.253` и `10.30.0.253` | | 20 | `/32` на адреса hc-портов, приоритет 32 → таблица 21, **без `dec_ttl`** | | 21 | adjacency на hc-порты: `mod_dl_dst` + `output` | Ответы бэкендов на пробы приходят на `p2`/`p3`, проходят таблицу 15 (записи в `ct` для них нет, трансляция не применяется) и уходят транзитом. ### 3. Демон `hc/` (Go, ~350 строк) `hc/maglev.go` — раскладка по §5.2 дизайна: - ключ члена — `"address:port"`, не UUID: пересоздание члена с теми же адресом и портом обязано давать ту же раскладку; - вход — отсортированный по ключу список живых членов с весами + `pool_id` как seed; - 1024 слота; `offset = h1 % M`, `skip` приводится к **нечётному** — при M = 1024 (степень двойки) чётный шаг не покрывает все слоты и заполнение зациклилось бы; - `slot_table_digest` — SHA-256 от сериализованной раскладки (§5.3), пишется в лог при каждом применении. `hc/main.go` — пробер и применение: - конфиг — JSON, который `lb/hc-config.sh` генерирует из `topology.env`; - тикер `interval` (2 с), пробы всех членов параллельно с таймаутом (1 с); - тип пробы `http` (GET `/healthz`, ожидается 2xx) или `tcp` (connect); источник фиксируется через `net.Dialer.LocalAddr` — это и есть требование «пробы с уникального адреса узла»; - переход в `down` после `fall` подряд неудач (3), в `up` — после `rise` подряд успехов (2); в лог пишутся только переходы, как в §7.1; - применение — атомарным бандлом `ovs-ofctl bundle` с директивами `delete table=11` + `add ...`, то есть замена таблицы целиком без промежуточного состояния; повторно та же раскладка не заливается; - HTTP-API на `127.0.0.1:9111`: `/status` (JSON), `/metrics` (Prometheus), `/drain?member=`, `/enable?member=`, `/reapply`. `/reapply` дёргает `lb/apply.sh` после `replace-flows`: тот перезаливает весь пайплайн и стёр бы слоты, поэтому в конце сообщает демону перезалить их. ### 4. Инструментарий и сборка - `lbctl health | slots | drain | enable `; `make health`, `make slots`, `make drain M=be1`, `make enable M=be1`. - `lbctl slots` — сводка: сколько слотов у каждого члена, счётчики пакетов, дайджест. - Контекст сборки балансировщика расширяется до корня (`context: .`, `dockerfile: lb/Dockerfile`), первая стадия — `golang:1.23-alpine`. - В бэкенд добавляется `/slow` (ответ растягивается на N секунд) — нужен для проверки выживания установленной сессии. ### 5. Файлы ``` hc/{go.mod,main.go,maglev.go} новый демон lb/hc-config.sh генерация конфига из topology.env lb/topology.env параметры проб, слотов, hc-портов lb/pipeline.sh слоты, hc-порты, перенумерация таблиц lb/entrypoint.sh internal-порты, статический neigh, запуск hcd lb/apply.sh снятие группы, вызов /reapply lb/lbctl.sh, Makefile health / slots / drain / enable backend/main.go эндпоинт /slow docker-compose.yml контекст сборки scripts/verify.sh новые проверки ``` ## Верификация Добавляется в `scripts/verify.sh`: | Проверка | Ожидание | |---|---| | Пробы доходят | `lbctl health` — оба члена `up`, счётчик `/healthz` на бэкендах растёт | | Источник проб | на бэкенде видно обращение с `10.20.0.253`/`10.30.0.253`, не с VIP | | Раскладка слотов | 1024 слота поделены поровну ±1, дайджест стабилен между перезапусками | | **Минимальное возмущение** | снимок раскладки → `drain be1` → у be2 **ни один** слот не сменил владельца | | Отказ члена | `docker compose pause be1` → `down` за `fall × interval`, его слоты перешли к be2 | | Балансировка при отказе | все запросы на VIP обслуживает be2, ошибок нет | | Восстановление | `unpause` → `up` за `rise × interval`, раскладка вернулась к исходному дайджесту | | Выживание сессии | долгая сессия через `/slow` не рвётся при выводе второго члена | | Полный отказ | оба члена недоступны → в таблице 11 только `drop`, трафик на VIP падает со счётчиком | | Идемпотентность | `make flows` → слоты возвращаются в актуальном составе, а не в полном | ## Артефакты (по требованию CLAUDE.md) 1. `docs/STEP2_IMPLEMENTATION_PLAN.md` — план внедрения; черновик уже написан под вариант с группой, привести к решению о таблице слотов. 2. `docs/STEP2_SUMMARY.md` — итоги, отклонения, ограничения. 3. `README.md` — раздел про health-check и таблицу слотов, схема с hc-портами, новые команды; таблица пайплайна перенумерована.