# Шаг 2 — итоги: health-check и таблица слотов **Дата:** 2026-08-16 **Статус:** выполнено, проверено после холодного перезапуска (50 из 50 проверок) **План:** [STEP2_IMPLEMENTATION_PLAN.md](STEP2_IMPLEMENTATION_PLAN.md) **Предыдущий шаг:** [STEP1_SUMMARY.md](STEP1_SUMMARY.md) ## Что сделано - **Демон `hcd`** (Go, `hc/`) внутри контейнера-балансировщика: проверяет живость членов пула, ведёт их состояние с гистерезисом (`rise=2`, `fall=3`), пересчитывает раскладку слотов и заливает её в OVS. Стал основным процессом контейнера. - **Пробы с уникального адреса узла** (§7.1 дизайна v1): на мосту появились internal-порты `hcif-p2` (`10.20.0.253`) и `hcif-p3` (`10.30.0.253`) — это единственные адреса, которые узел держит в ядре. Источник соединения фиксируется через `net.Dialer.LocalAddr`. - **Таблица слотов вместо группы `type=select`**: 1024 слота, номер слота даёт `multipath(symmetric_l4, basis=pool_id, modulo_n)`, раскладка слотов по членам считается алгоритмом Maglev (§5). Группа удалена. - **Fail-close**: пока живых членов нет, таблица слотов пуста и трафик на VIP отбрасывается со счётчиком. - **Дренаж** (§7.3): `lbctl drain <член>` / `enable <член>` — член исключается из раскладки, пробы при этом продолжают идти. - **Наблюдаемость**: `lbctl health` (состояние пула), `lbctl slots` (раскладка и счётчики), `/metrics` в формате Prometheus, дайджест раскладки SHA-256 (§5.3), в лог пишутся только переходы состояния. ## Результаты проверок | Проверка | Результат | |---|---| | Пробы идут | оба члена `up` через 4 с после старта, задержка 1–3 мс | | Источник проб | `tcpdump` на `p2`: SYN с `10.20.0.253`, не с VIP | | Раскладка | 1024 слота, 512/512, дайджест `2f0ca39d5f33efa6` | | Детерминизм | после `docker compose down/up` дайджест тот же | | **Минимальное возмущение** | при выводе be1 переехали 512 его слотов, у be2 — **ни одного** | | Отказ члена | `pause be1` → `down` за ~6 с, все 1024 слота у be2, трафик без ошибок | | Восстановление | `unpause` → `up` за ~4 с, дайджест вернулся к исходному | | Выживание сессии | длинная сессия через `/slow` не порвалась при выводе её члена из пула (14/14 тиков) | | Fail-close | оба члена мертвы → таблица пуста, клиент получает таймаут, счётчик drop растёт | | Идемпотентность | `make flows` → раскладка восстановлена в актуальном составе | ## Отклонения от плана 1. **Формат bundle-файла.** Планировались директивы `delete` / `add`; OVS 3.1 принимает их только с указанием типа сообщения: `flow delete` / `flow add`. Первый вариант отвергался с `Unsupported bundle message type`. 2. **Шаг перестановки Maglev приводится к нечётному.** При числе слотов 1024 (степень двойки) шаг, выбранный как `h2 % (M-1) + 1`, может оказаться чётным — тогда последовательность `(offset + j*skip) mod M` покрывает лишь половину слотов и заполнение зацикливается. Классический алгоритм рассчитан на простое M; здесь сохранено значение 1024 из §5.1 дизайна, а взаимная простота обеспечена принудительной нечётностью шага. 3. **Форматирование `lbctl health` вынесено в демон** (`/status.txt`) — иначе в образ балансировщика пришлось бы тянуть `jq` или `python3`. 4. **Проверка выживания сессии добавлена в `verify.sh`** вместе с эндпоинтом `/slow` у бэкенда — в плане она была только описана. ## Подтверждённое наблюдение о ct и таблице слотов План фиксировал предположение, что установленные сессии переживают смену раскладки за счёт conntrack. Проверка подтвердила: сессия, обслуживаемая be2, не порвалась при выводе be2 из пула — все 14 тиков пришли с того же бэкенда. Причина в том, что `ct(commit, nat)` закрепляет трансляцию за соединением при его создании, и последующие пакеты следуют существующей привязке независимо от того, какого члена выбрала таблица слотов. Практический вывод: в текущем stateful-датапасе Maglev защищает **размещение новых соединений**, а не живые сессии — их и без него защищает conntrack. Ценность таблицы слотов раскроется при переходе на stateless-датапас, где никакого ct не будет и единственной защитой от переезда сессий останется именно минимальное возмущение раскладки. Заодно это ровно то поведение, которое §7.3 дизайна описывает как «дренаж означает прекратить приём, а не мягко завершить». ## Известные ограничения Часть перешла с шага 1, часть появилась вместе с health-check: - **Датапас по-прежнему stateful** — обратную трансляцию делает `ct(nat)`. Active/Active ECMP в такой схеме не работает: прямой и обратный трафик обязаны проходить через один узел. - **Кворума нет.** Вердикт о живости принимает единственный узел; §7.2 (наблюдения в `Member_Observation`, лидер через OVSDB lock, генерации пулов и сходимость по дайджестам) появится вместе с control plane. - **Только HTTP и TCP-пробы.** UDP-проб и ICMP echo из §7.1 нет. - **Пул статичен** — состав задан в `topology.env`, менять его на лету можно только дренажом. Модели `Load_Balancer → Listener → Pool → Member` в OVSDB ещё нет. - **Веса поддержаны в алгоритме, но не в конфигурации** — у всех членов вес 1. - **`hash_algo_version` не реализована.** Дайджест раскладки считается и логируется, но сравнивать его не с кем: узел один. - **Пул без живых членов не снимает анонс VIP** — альтернатива fail-close из §7.3 не реализована, BGP на узле нет. - ICMP-ошибки, фрагменты, IPv6, UDP- и L3-листенеры — как и на шаге 1, вне рамок. ## Задел на шаг 3 1. **Stateless-датапас**: заменить `ct(nat)` на явный un-DNAT (`nw_src=member_ip, tp_src=member_port` → `src = VIP`). Таблица слотов уже на месте, `symmetric_l4` даёт одинаковый слот для обоих направлений — это и есть предпосылка, ради которой шаг 2 сделан именно так. 2. Проверить детерминизм выбора по корпусу синтетических 5-tuple через `ofproto/trace` (спайк S0 из §12 дизайна). 3. Control plane: схема OVSDB вместо `topology.env`, REST API и `lbctl` поверх неё, валидации §8.4. 4. Второй узел LB: кворум наблюдений, генерации пулов, сравнение дайджестов. 5. ICMP-транслятор в userspace для PMTUD и корректных ICMP-ошибок от имени VIP.