MVP Single Node with Manual Config in Docker

This commit is contained in:
ayurishchev committed 2026-08-16 21:43:02 +03:00
commit 8212699f23
27 files changed
+3818

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
# План внедрения: расширение пула до четырёх бэкендов
**Дата:** 2026-08-16
**Основание:** стенд шага 2 ([STEP2_SUMMARY.md](STEP2_SUMMARY.md))
## Контекст
Стенд работает с пулом из двух членов — по одному бэкенду в каждом приватном
сегменте. На двух членах не видны свойства, ради которых на шаге 2 введена
таблица слотов: при выбытии единственного «соседа» оставшийся член
тривиально забирает все слоты, и минимальное возмущение раскладки нечем
измерить.
Задача: добавить по одному бэкенду в каждый приватный сегмент (всего четыре
члена пула) и оставить алгоритм балансировки `symmetric_l4`.
## Состав изменения
### Новые контейнеры
| Контейнер | Сеть | Адрес | MAC | ID члена |
|---|---|---|---|---|
| `hpnn-be3` | priv2 | `10.20.0.3` | `02:42:0a:14:00:03` | 3 |
| `hpnn-be4` | priv3 | `10.30.0.3` | `02:42:0a:1e:00:03` | 4 |
Образ и маршруты — те же, что у существующих бэкендов: через балансировщик
маршрутизируются только клиентские префиксы и соседний приватный сегмент,
`default` остаётся на шлюзе Docker (профиль A).
### Файлы
| Файл | Изменение |
|---|---|
| `docker-compose.yml` | сервисы `be3`, `be4` |
| `lb/topology.env` | `BE3_*`, `BE4_*` |
| `lb/pipeline.sh` | правила DNAT (таблица 12) и adjacency (таблица 21) для новых членов |
| `lb/entrypoint.sh` | статические ARP-записи для вторых бэкендов на портах-источниках проб |
| `lb/hc-config.sh` | члены пула 3 и 4 |
| `scripts/verify.sh` | проверки обобщаются с двух членов на четыре |
Алгоритм балансировки не меняется: `multipath(symmetric_l4, …)` уже стоит в
листенере, менять нечего.
### Что произойдёт автоматически
Раскладка слотов пересчитается сама: демон опросит новых членов, переведёт их
в `up` и зальёт таблицу слотов на четверых. Ожидаемое распределение — по 256
слотов на члена (1024 / 4).
## Отдельная проверка: минимальное возмущение на четырёх членах
На двух членах проверка была вырожденной — оставшийся член забирал все слоты,
поэтому «чужих» переехавших слотов было ровно ноль. На четырёх членах
ситуация содержательнее: слоты выбывшего распределяются между тремя
оставшимися, и порядок заполнения меняется.
Классический Maglev гарантирует **малое**, а не нулевое возмущение. Поэтому
проверка формулируется как порог: доля слотов, сменивших владельца среди
оставшихся членов, должна быть заметно меньше доли слотов выбывшего члена
(25 %). Фактическое значение измеряется при реализации и фиксируется в итогах
вместе с порогом.
## Верификация
1. `make health` — четыре члена `up`, по 256 слотов, сумма 1024.
2. Балансировка с клиента: в 20 запросах встречаются все четыре бэкенда.
3. `make slots` — счётчики пакетов растут у всех четырёх.
4. Транзит между сегментами работает для новых бэкендов тоже.
5. Отказ одного члена: его слоты уходят к трём оставшимся, трафик без ошибок.
6. Минимальное возмущение: измерить долю переехавших чужих слотов.
7. `make verify` — полный автоматический прогон.
## Артефакты
1. Этот план.
2. `docs/CHANGE_POOL_4_MEMBERS_SUMMARY.md` — итоги и измеренные значения.
3. Обновление `README.md` и `docs/MANUAL_TEST_PLAN.md` под новый состав пула.
+81
View File
@@ -0,0 +1,81 @@
# Итоги: расширение пула до четырёх бэкендов
**Дата:** 2026-08-16
**Статус:** выполнено, проверено (64 из 64 проверок)
**План:** [CHANGE_POOL_4_MEMBERS_PLAN.md](CHANGE_POOL_4_MEMBERS_PLAN.md)
## Что сделано
Добавлено по одному бэкенду в каждый приватный сегмент — пул вырос с двух
членов до четырёх. Алгоритм балансировки оставлен прежним, `symmetric_l4`.
| Контейнер | Сеть | Адрес | MAC | ID члена | Слотов |
|---|---|---|---|---|---|
| `hpnn-be1` | priv2 | `10.20.0.2` | `02:42:0a:14:00:02` | 1 | 256 |
| `hpnn-be3` | priv2 | `10.20.0.3` | `02:42:0a:14:00:03` | 3 | 256 |
| `hpnn-be2` | priv3 | `10.30.0.2` | `02:42:0a:1e:00:02` | 2 | 256 |
| `hpnn-be4` | priv3 | `10.30.0.3` | `02:42:0a:1e:00:03` | 4 | 256 |
Дайджест раскладки: `0a16713c5a9eb94d` (был `2f0ca39d5f33efa6` на двух
членах).
Изменённые файлы: `docker-compose.yml`, `lb/topology.env`, `lb/pipeline.sh`
(DNAT в таблице 12 и adjacency в таблице 21 для новых членов),
`lb/entrypoint.sh` (статические ARP-записи для вторых бэкендов на портах
проб), `lb/hc-config.sh`, `scripts/verify.sh`.
## Результаты проверок
| Проверка | Результат |
|---|---|
| Обнаружение новых членов | оба перешли в `up` за 4 с после старта демона |
| Раскладка | ровно 256 / 256 / 256 / 256, сумма 1024 |
| Балансировка с клиента | в 24 запросах присутствуют все четыре бэкенда (7/5/6/6) |
| Транзит между сегментами | все четыре направления: be1↔be2, be3↔be4 и обратные |
| Маршруты бэкендов | у всех четырёх профиль A: default на шлюзе Docker |
| Отказ члена | `pause be1` → его 256 слотов разошлись по трём оставшимся (341/342/341), трафик без ошибок |
| Восстановление | дайджест вернулся к `0a16713c5a9eb94d` |
| Fail-close | при отказе всех четырёх таблица слотов пуста, клиент получает таймаут |
## Главное измерение: возмущение раскладки на четырёх членах
На двух членах проверка минимального возмущения была вырожденной —
единственный оставшийся член забирал все слоты, и «чужих» переехавших слотов
получалось ровно ноль по построению. На четырёх членах она стала
содержательной.
Вывод be1 из пула:
- переехали **256** слотов самого be1 — это его четверть таблицы, они обязаны
сменить владельца;
- у трёх оставшихся членов сменили владельца **8** слотов из 768 — **1,0 %**
их слотов, или 0,8 % всей таблицы.
Это ожидаемое поведение классического Maglev: алгоритм гарантирует *малое*, а
не строго нулевое возмущение. Проверка в `verify.sh` поэтому сформулирована
как порог (не более 20 слотов), а не как равенство нулю. Предыдущая
формулировка «ни один слот не сменил владельца» была верна только для пула из
двух членов и на четырёх уже не выполняется.
Для сравнения: группа `type=select` из шага 1 при изменении числа бакетов
переложила бы **все** соединения, а не 1 %.
## Отклонения от плана
Отклонений от плана нет. Уточнение по процедуре: `docker compose up -d
--build` не пересоздал контейнер балансировщика — он поднял только новые
бэкенды, и демон продолжил работать со старой конфигурацией на двух членах.
Потребовалось явное `docker compose up -d --build --force-recreate
lb-router`. Это стоит помнить при любых правках `topology.env` и
`hc-config.sh`.
## Ограничения
Все ограничения шага 2 сохраняются без изменений (stateful-датапас,
отсутствие кворума, статический состав пула, только HTTP/TCP-пробы). Новое:
- члены пула по-прежнему перечислены статически в трёх местах —
`docker-compose.yml`, `lb/topology.env` и `lb/hc-config.sh`, плюс правила
DNAT и adjacency в `lb/pipeline.sh`. Добавление пятого члена — снова ручная
правка четырёх файлов. Это именно то, что снимет модель
`Load_Balancer → Listener → Pool → Member` в OVSDB на следующем шаге.
+878
View File
@@ -0,0 +1,878 @@
# План ручного тестирования стенда hpnn_v2
Пошаговая проверка обеих подсистем — маршрутизации и балансировки — и
подсистемы health-check. Рассчитан на прохождение целиком примерно за 25–30
минут.
Автоматический аналог большей части проверок — `make verify`. Этот документ
нужен, чтобы увидеть поведение стенда своими глазами и понять, что означает
каждый результат.
## Обозначения
| Метка | Где выполнять |
|---|---|
| **[К]** | на клиентской машине в подсети 192.168.5.0/24 |
| **[Х]** | на хосте стенда (192.168.5.9), из каталога `/opt/lvraid/claude/hpnn_v2` |
Команды **[Х]** требуют root или членства в группе `docker`.
## Подготовка
**Шаг 0.1 [Х]** — поднять стенд:
```bash
cd /opt/lvraid/claude/hpnn_v2
make up
```
Ожидается: пять контейнеров в состоянии `healthy` и таблица состояния пула,
где все четыре члена `up` и у каждого по 256 слотов.
**Шаг 0.2 [Х]** — убедиться, что всё запустилось:
```bash
docker compose ps
```
Ожидается:
```
hpnn-be1 Up ... (healthy)
hpnn-be2 Up ... (healthy)
hpnn-be3 Up ... (healthy)
hpnn-be4 Up ... (healthy)
hpnn-lb Up ... (healthy)
```
Если `hpnn-lb` в состоянии `Restarting` — смотрите `docker compose logs
lb-router` и раздел «Диагностика» в конце документа.
**Шаг 0.3 [К]** — проверить, что клиент в нужной подсети:
```bash
ip -br addr | grep 192.168.5
```
Адрес клиента понадобится дальше; обозначим его `<CLIENT_IP>`.
> Если проверять хотите с самого хоста стенда, а не с отдельной машины,
> выполните **[Х]** `make shim` — macvlan-интерфейс контейнера и физический
> интерфейс хоста напрямую друг друга не видят, это ограничение macvlan.
> Тогда все шаги **[К]** выполняются на хосте, а `<CLIENT_IP>` = 192.168.5.13.
---
## Блок A. Базовая доступность узла
Проверяем, что OVS отвечает за адреса, которых нет ни на одном интерфейсе:
ARP-респондер и ICMP-респондер живут в правилах OpenFlow.
**Шаг A.1 [К]** — доступность адреса узла:
```bash
ping -c3 192.168.5.20
```
Ожидается: три ответа. Отвечает не сетевой стек, а правило таблицы 6.
**Шаг A.2 [К]** — доступность VIP:
```bash
ping -c3 192.168.5.21
```
Ожидается: три ответа.
**Шаг A.3 [К]** — какой MAC отдаёт ARP-респондер:
```bash
ip neigh flush 192.168.5.20 2>/dev/null; ping -c1 192.168.5.20 >/dev/null
ip neigh show | grep -E '192.168.5.2[01]'
```
Ожидается: оба адреса, `.20` и `.21`, разрешаются в **один и тот же** MAC
`02:42:c0:a8:05:14` — это MAC macvlan-порта балансировщика.
Если ответа нет ни на один ping — переходите сразу к разделу «Диагностика»,
дальнейшие блоки бессмысленны.
---
## Блок B. Балансировка нагрузки
**Шаг B.1 [К]** — один запрос на VIP:
```bash
curl http://192.168.5.21/
```
Ожидается строка вида:
```
backend=be1 time=2026-08-16 20:18:49 MSK client=192.168.5.13:59660 served=10.20.0.2:8080 req=1
```
**Шаг B.2 [К]** — распределение по бэкендам:
```bash
for i in $(seq 24); do curl -s http://192.168.5.21/; done | sort | uniq -c -w 12
```
Ожидается: встречаются все четыре имени — `be1`…`be4` — примерно поровну, по
6 ± 3 на 24 запроса. Заметный перекос на такой выборке нормален, это
статистика, а не дефект; равномерность раскладки проверяется по слотам в
шаге B.5, а не по числу запросов.
Что это означает: слот выбирается хэшем от `(ip_src, tcp_src)`, а порт
источника у каждого нового соединения свой.
**Шаг B.3 [К]** — сохранение IP клиента (ключевая проверка DNAT-без-SNAT):
```bash
curl -s http://192.168.5.21/ | grep -o 'client=[0-9.]*'
```
Ожидается: `client=<CLIENT_IP>` — реальный адрес вашей машины.
Если бы выполнялся SNAT, здесь стоял бы адрес балансировщика. Бэкенд видит
клиента напрямую — это то, ради чего обратный трафик заворачивается через
балансировщик маршрутом.
**Шаг B.4 [К]** — одно соединение не «размазывается» по бэкендам:
```bash
curl -s http://192.168.5.21/info
```
Ожидается: развёрнутая карточка одного бэкенда. Все пакеты одной сессии идут
на один член пула — хэш считается от заголовков, одинаковых внутри сессии.
**Шаг B.5 [Х]** — увидеть распределение со стороны датапаса:
```bash
make slots
```
Ожидается: у всех четырёх членов по 256 слотов, счётчики пакетов растут у всех.
---
## Блок C. Подсистема маршрутизации
Балансировка — не единственная функция узла. Проверяем транзит.
**Шаг C.1 [Х]** — транзит между приватными сегментами:
```bash
docker compose exec be1 curl -s http://10.30.0.2:8080/
```
Ожидается: ответ `backend=be2`, в поле `client` — `10.20.0.2`.
Пакет прошёл из сети 2 в сеть 3 через таблицы маршрутизации OpenFlow (20 и
21), без участия ядра контейнера и без всякой трансляции.
**Шаг C.2 [К]** — маршрутизация из публичного сегмента в приватный.
Пропишите на клиенте маршруты в приватные сегменты через узел — команды для
Linux и Windows 11 приведены в [приложении А](#приложение-а-маршруты-на-клиенте).
После этого обратитесь к бэкендам напрямую, минуя VIP:
```bash
curl http://10.20.0.2:8080/
curl http://10.30.0.2:8080/
```
Ожидается: ответы от be1 и be2 соответственно, в обоих `client=<CLIENT_IP>`.
Балансировка здесь не участвует — работает только подсистема маршрутизации.
> **Не выполняйте этот шаг на хосте стенда.** Там эти маршруты перекроют
> connected-маршруты docker-бриджей `hpnn-p2`/`hpnn-p3` и оборвут бэкендам
> выход наружу. На отдельной клиентской машине проблемы нет.
**Шаг C.3 [К]** — узел является настоящим L3-хопом:
```bash
ping -c1 -t 1 10.20.0.2 # Linux: потеря, пакет умирает на узле
ping -c1 -t 2 10.20.0.2 # Linux: проходит
```
```powershell
ping -n 1 -i 1 10.20.0.2 # Windows: потеря
ping -n 1 -i 2 10.20.0.2 # Windows: проходит
```
Ожидается: с TTL=1 ответа нет, с TTL=2 есть. Сообщения `TTL expired in
transit` не будет — узел ICMP-ошибки не генерирует, поэтому и `traceroute` /
`tracert` через стенд ничего осмысленного не покажет.
**Шаг C.4 [Х]** — трафик действительно идёт через таблицы маршрутизации:
```bash
docker compose exec lb-router lbctl flows 20
docker compose exec lb-router lbctl flows 21
```
Ожидается: у правил `nw_dst=10.20.0.0/24`, `nw_dst=10.30.0.0/24` и
`nw_dst=192.168.5.0/24` растут счётчики `n_packets`, у правила `priority=0
actions=drop` — нет.
**Шаг C.5 [Х]** — назначение без маршрута отбрасывается (дефолта у узла нет):
```bash
docker compose exec be1 ip route add 10.40.0.0/24 via 10.20.0.1
docker compose exec lb-router lbctl flows 20 | grep priority=0
docker compose exec be1 ping -c2 -W1 10.40.0.7
docker compose exec lb-router lbctl flows 20 | grep priority=0
docker compose exec be1 ip route del 10.40.0.0/24 via 10.20.0.1
```
Ожидается: ping без ответа, счётчик drop вырос ровно на число пакетов (2).
**Шаг C.6 [Х]** — ядро контейнера-узла в транзите не участвует:
```bash
docker compose exec lb-router ip route
```
Ожидается: всего два connected-маршрута, на `hcif-p2` и `hcif-p3`, — они
нужны только health-пробам. Маршрутов на клиентскую сеть и на бэкенды в ядре
нет вовсе, а трафик при этом ходит: вся маршрутизация живёт в OpenFlow.
**Шаг C.7 [К]** — уберите маршруты после проверки (см. приложение А).
**Шаг C.8 [Х]** — профиль A: собственный трафик бэкенда идёт мимо
балансировщика. В одном терминале:
```bash
docker compose exec lb-router tcpdump -ni p2 'host 1.1.1.1'
```
Во втором:
```bash
docker compose exec be1 curl -s -o /dev/null -w '%{http_code}\n' http://1.1.1.1/
```
Ожидается: код `301` во втором терминале и **ни одного пакета** в tcpdump.
Через балансировщик у бэкенда маршрутизируются только клиентские префиксы,
а `default` остаётся на шлюзе Docker.
---
## Блок D. Health-check: наблюдение
**Шаг D.1 [Х]** — состояние пула:
```bash
make health
```
Ожидается таблица, где у всех четырёх членов `СОСТОЯНИЕ=up`,
`ADMIN=enabled`, `В ПУЛЕ=да`, по 256 слотов, счётчик `ПРОБ` растёт при
повторных вызовах, `НЕУДАЧ` не растёт.
Обратите внимание на колонку `ИСТОЧНИК ПРОБ`: `10.20.0.253` и `10.30.0.253` —
уникальные адреса узла в сегментах бэкендов, а не VIP.
**Шаг D.2 [Х]** — убедиться, что пробы действительно уходят с этих адресов:
```bash
docker compose exec lb-router timeout 6 tcpdump -ni p2 'tcp port 8080 and tcp[tcpflags] & tcp-syn != 0'
```
Ожидается: примерно раз в 2 секунды SYN вида
`IP 10.20.0.253.xxxxx > 10.20.0.2.8080`.
Почему это важно: если бы пробы уходили с VIP, ответ бэкенда попал бы в
логику обратной трансляции и до пробера не дошёл.
**Шаг D.3 [Х]** — метрики:
```bash
make metrics
```
Ожидается: `hpnn_member_up{member="be1",...} 1`, аналогично для be2,
`hpnn_member_slots` по 512, счётчики `hpnn_probes_total` растут.
---
## Блок E. Отказ и восстановление бэкенда
**Шаг E.1 [К]** — запустите фоновую нагрузку, чтобы видеть поведение под
трафиком (оставьте работать до конца блока):
```bash
while true; do curl -s --max-time 3 http://192.168.5.21/ || echo "ОШИБКА $(date +%T)"; sleep 0.5; done
```
**Шаг E.2 [Х]** — «уроните» первый бэкенд:
```bash
docker compose pause be1
```
**Шаг E.3 [Х]** — через 6–8 секунд посмотрите состояние:
```bash
make health
```
Ожидается: `be1` в состоянии `down`, 0 слотов, в строке ниже — причина
(`context deadline exceeded`); его 256 слотов разошлись между be2, be3 и be4
(примерно по 341).
Порог перехода: `fall=3` неудачных пробы при интервале 2 с, то есть до 6 с.
**Шаг E.4 [К]** — посмотрите на окно с нагрузкой.
Ожидается: после короткого промежутка ответы приходят только от живых членов,
строк `ОШИБКА` нет либо их единицы — те запросы, что успели уйти на be1 до
обнаружения отказа. Это и есть цена интервала проб: чем он меньше, тем короче
окно, но тем выше нагрузка проб на бэкенды.
**Шаг E.5 [Х]** — верните бэкенд:
```bash
docker compose unpause be1
```
**Шаг E.6 [Х]** — через 4–6 секунд:
```bash
make health
```
Ожидается: `be1` снова `up`, слоты вернулись к 256 у каждого, и **дайджест
раскладки совпадает с тем, что был до отказа** — раскладка детерминирована.
**Шаг E.7 [К]** — остановите фоновую нагрузку (Ctrl+C).
---
## Блок F. Таблица слотов: минимальное возмущение
Самая содержательная проверка шага 2. Убеждаемся, что вывод одного члена
не перекладывает слоты другого.
**Шаг F.1 [Х]** — снимок раскладки до изменения:
```bash
snap() { docker compose exec -T lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb table=11 \
| sed -n 's/.*reg1=\(0x[0-9a-f]*\|[0-9]\+\).*set_field:\(0x[0-9a-f]*\)->reg2.*/\1 \2/p' | sort; }
snap > /tmp/slots_before.txt
wc -l < /tmp/slots_before.txt
```
Ожидается: `1024`.
**Шаг F.2 [Х]** — вывести be1 из балансировки (не останавливая его):
```bash
make drain M=be1
make health
```
Ожидается: у be1 `ADMIN=drain`, `В ПУЛЕ=нет`, 0 слотов — но `СОСТОЯНИЕ`
осталось `up` и счётчик проб продолжает расти. Дренаж означает «прекратить
приём новых сессий», а не «остановить проверку».
**Шаг F.3 [Х]** — сравнить раскладки:
```bash
snap > /tmp/slots_after.txt
join /tmp/slots_before.txt /tmp/slots_after.txt | awk '$2!="0x1" && $2!=$3' | wc -l # чужие слоты
join /tmp/slots_before.txt /tmp/slots_after.txt | awk '$2!=$3' | wc -l # всего
```
Ожидается: первое число — **не больше 20** (на этом стенде измерено 8),
второе — 264.
Смысл: 256 слотов выбывшего be1 обязаны переехать — это его четверть
таблицы. Показательно второе: у трёх оставшихся членов сменили владельца лишь
единицы слотов. Классический Maglev гарантирует малое, а не строго нулевое
возмущение, поэтому проверка сформулирована как порог, а не как равенство
нулю.
**Шаг F.4 [Х]** — вернуть be1:
```bash
make enable M=be1
make health
```
Ожидается: по 256 слотов у каждого и прежний дайджест.
**Шаг F.5 [Х]** — проследить путь конкретной сессии по таблицам:
```bash
make trace SRC=192.168.5.100 SPORT=41234
```
Ожидается цепочка `0 → 10 → 11 → 12`, затем после `ct(...nat(dst=...))` —
`20 → 21` и выход в порт бэкенда. В `Final flow` видно, что `nw_src` остался
клиентским, `nw_dst` заменён на адрес бэкенда, `nw_ttl` уменьшен на единицу.
Повторите команду с теми же аргументами — номер слота (`reg1`) и член пула
(`reg2`) обязаны совпасть. Это проверка детерминизма выбора.
---
## Блок G. Fail-close при полном отказе пула
**Шаг G.1 [Х]** — «уронить» оба бэкенда:
```bash
docker compose pause be1 be2 be3 be4
```
**Шаг G.2 [Х]** — через 8 секунд:
```bash
make health
make slots
```
Ожидается: оба члена `down`, 0 слотов у каждого; в `make slots` строк с
членами нет вовсе, есть только счётчик правила fail-close.
**Шаг G.3 [К]** — обратиться на VIP:
```bash
time curl --max-time 5 http://192.168.5.21/
```
Ожидается: таймаут, а не ответ и не мгновенный отказ. Балансировщик молча
отбрасывает трафик: отдавать соединения на заведомо мёртвый бэкенд хуже, чем
не отдавать вовсе.
**Шаг G.4 [Х]** — убедиться, что пакеты именно отброшены правилом, а не
потерялись где-то ещё:
```bash
make slots
```
Ожидается: счётчик `отброшено правилом fail-close` вырос на число попыток.
**Шаг G.5 [Х]** — восстановить пул:
```bash
docker compose unpause be1 be2 be3 be4
sleep 6 && make health
```
Ожидается: все четыре `up`, по 256 слотов, прежний дайджест.
---
## Блок H. Устойчивость конфигурации
**Шаг H.1 [Х]** — перезаливка пайплайна не теряет раскладку:
```bash
make health | grep дайджест # запомните значение
make flows
make health | grep дайджест # значение то же
make slots # по 256 слотов у каждого
```
Смысл: `make flows` перезаливает все правила целиком и стирает таблицу
слотов, после чего демон восстанавливает её в **актуальном** составе пула, а
не в полном.
**Шаг H.2 [Х]** — состояние переживает перезапуск балансировщика:
```bash
make health | grep дайджест # запомните значение
docker compose restart lb-router
sleep 20 && make health
```
Ожидается: после перезапуска все четыре члена снова `up` (через `rise=2`
пробы) и
дайджест раскладки **тот же самый**. Раскладка не хранится нигде — она
вычисляется заново и совпадает, потому что алгоритм детерминирован.
**Шаг H.3 [К]** — стенд обслуживает трафик после перезапуска:
```bash
for i in $(seq 6); do curl -s http://192.168.5.21/; done
```
---
## Блок I. Дополнительно: выживание установленной сессии
**Шаг I.1 [К]** — запустите длинную сессию (ответ отдаётся по строке в
секунду) и запомните, какой бэкенд её обслуживает:
```bash
curl -N "http://192.168.5.21/slow?seconds=20"
```
**Шаг I.2 [Х]** — пока сессия идёт, выведите обслуживающий её член из пула
(подставьте имя из вывода на клиенте):
```bash
make drain M=be2
```
**Шаг I.3 [К]** — наблюдайте за выводом.
Ожидается: сессия **не рвётся**, все 20 тиков приходят от того же бэкенда.
Причина — трансляция закрепляется за соединением при его создании
(`ct(commit, nat)`), и последующие пакеты следуют существующей привязке
независимо от того, что показывает таблица слотов.
Практический вывод: дренаж прекращает приём новых сессий, но не завершает
активные. Для graceful shutdown приложение обязано само дождаться завершения
запросов после исключения из пула.
**Шаг I.4 [Х]** — вернуть член:
```bash
make enable M=be2
```
---
## Итоговый чек-лист
| # | Проверка | Результат |
|---|---|---|
| A | Узел и VIP отвечают на ping, оба адреса — один MAC | ☐ |
| B | Запросы на VIP распределяются между be1 и be2 | ☐ |
| B | Бэкенд видит реальный IP клиента | ☐ |
| C | Транзит be1 → be2 работает | ☐ |
| C | Клиент попадает в приватные сегменты через узел | ☐ |
| C | TTL уменьшается: с `-t 1` пакет не доходит, с `-t 2` доходит | ☐ |
| C | Счётчики таблиц 20 и 21 растут, drop-правило молчит | ☐ |
| C | Назначение без маршрута отбрасывается со счётчиком | ☐ |
| C | В ядре узла нет маршрутов на транзитные сети | ☐ |
| C | Egress бэкенда идёт мимо балансировщика | ☐ |
| D | Пробы уходят с адресов `.253`, не с VIP | ☐ |
| E | Отказ бэкенда обнаружен за ~6 с, трафик перешёл на живого | ☐ |
| E | После восстановления раскладка вернулась к прежнему дайджесту | ☐ |
| F | При выводе члена ни один чужой слот не переехал | ☐ |
| F | Повторный trace даёт тот же слот и член | ☐ |
| G | При пустом пуле трафик отбрасывается, счётчик растёт | ☐ |
| H | `make flows` и перезапуск не ломают раскладку | ☐ |
| I | Установленная сессия переживает дренаж | ☐ |
---
## Возврат стенда в исходное состояние
```bash
# [Х]
for m in be1 be2 be3 be4; do make enable M=$m; done # снять дренаж, если остался
docker compose unpause be1 be2 be3 be4 2>/dev/null || true
make health # все up, по 256 слотов
rm -f /tmp/slots_before.txt /tmp/slots_after.txt
```
Маршруты, добавленные на клиенте в блоке C, снимаются командами из
[приложения А](#приложение-а-маршруты-на-клиенте) — они разные для Linux и
Windows.
Если меняли параметры стенда — см.
[Б.5](#б5-возврат-к-исходной-конфигурации).
Полная остановка стенда: **[Х]** `make down`. Снять macvlan-shim, если
поднимали: `make shim-down`.
---
## Приложение А. Маршруты на клиенте
Нужны только для проверки подсистемы маршрутизации (блок C) — обращение к
бэкендам напрямую, минуя VIP. Для проверки балансировки маршруты не нужны:
VIP находится в той же подсети, что и клиент.
Шлюз во всех командах — адрес узла `192.168.5.20`.
### Linux
```bash
sudo ip route add 10.20.0.0/24 via 192.168.5.20
sudo ip route add 10.30.0.0/24 via 192.168.5.20
ip route get 10.20.0.2 # проверка выбора маршрута
curl http://10.20.0.2:8080/
sudo ip route del 10.20.0.0/24 via 192.168.5.20
sudo ip route del 10.30.0.0/24 via 192.168.5.20
```
Маршруты живут до перезагрузки.
### Windows 11 (PowerShell)
PowerShell нужно запустить **от имени администратора**.
Определить индекс сетевого интерфейса:
```powershell
Get-NetIPAddress -AddressFamily IPv4 |
Where-Object { $_.IPAddress -like '192.168.5.*' } |
Select-Object IPAddress, InterfaceIndex, InterfaceAlias
$if = (Get-NetIPAddress -AddressFamily IPv4 |
Where-Object { $_.IPAddress -like '192.168.5.*' } |
Select-Object -First 1).InterfaceIndex
```
Если строк несколько (например, есть VPN-адаптер) — возьмите нужный индекс
вручную.
Добавить маршруты:
```powershell
New-NetRoute -DestinationPrefix 10.20.0.0/24 -NextHop 192.168.5.20 -InterfaceIndex $if -PolicyStore ActiveStore
New-NetRoute -DestinationPrefix 10.30.0.0/24 -NextHop 192.168.5.20 -InterfaceIndex $if -PolicyStore ActiveStore
```
`-PolicyStore ActiveStore` делает маршруты временными — до перезагрузки. Без
этого параметра `New-NetRoute` пишет их в `PersistentStore`, и они переживут
ребут; для теста это лишнее.
Проверить и обратиться к бэкендам:
```powershell
Get-NetRoute -DestinationPrefix 10.2*.0.0/24 | Select-Object DestinationPrefix, NextHop, InterfaceIndex
Test-NetConnection 10.20.0.2 -Port 8080
curl.exe http://10.20.0.2:8080/
curl.exe http://10.30.0.2:8080/
```
В PowerShell `curl` — алиас на `Invoke-WebRequest`, поэтому пишите именно
`curl.exe`.
Удалить после проверки:
```powershell
Remove-NetRoute -DestinationPrefix 10.20.0.0/24 -Confirm:$false
Remove-NetRoute -DestinationPrefix 10.30.0.0/24 -Confirm:$false
```
Вариант через классический `route` (без `-p` тоже временный):
```powershell
route add 10.20.0.0 mask 255.255.255.0 192.168.5.20
route add 10.30.0.0 mask 255.255.255.0 192.168.5.20
route print 10.*
route delete 10.20.0.0
route delete 10.30.0.0
```
### Нагрузочное тестирование маршрутизации
Цель для k6 — `http://10.20.0.2:8080/` вместо `http://192.168.5.21/`. Трафик
пойдёт через таблицы маршрутизации 20 и 21, минуя листенер, таблицу слотов и
DNAT. Сравнение показателей двух путей даёт цену балансировки в чистом виде.
---
## Приложение Б. Изменение параметров стенда
### Что где лежит
| Что меняем | Файл | Как применить |
|---|---|---|
| Тип пробы, интервал, таймаут, `rise`/`fall` | `lb/topology.env`, блок `HC_*` | пересборка образа |
| Веса членов пула | `lb/hc-config.sh`, поле `weight` | пересборка образа |
| Ключ хэша (алгоритм балансировки) | `lb/pipeline.sh`, таблица 10 | `make flows` |
| Число слотов, basis хэша | `lb/topology.env`: `SLOTS`, `POOL_ID` | пересборка образа |
| Состав пула на лету | — | `make drain` / `make enable` |
«Пересборка образа» — это:
```bash
docker compose up -d --build lb-router
```
Файлы вшиты в образ на этапе сборки, поэтому правка на хосте без пересборки
ни на что не влияет. Конфигурация демона генерируется при старте контейнера,
так что `make flows` для параметров health-check не поможет — нужен именно
перезапуск.
### Б.1. Параметры health-check
```bash
vi lb/topology.env
```
```bash
HC_PROBE=http # http | tcp
HC_HTTP_PATH=/healthz
HC_INTERVAL=2s # период опроса
HC_TIMEOUT=1s # таймаут одной пробы
HC_RISE=2 # успехов подряд для перевода в up
HC_FALL=3 # неудач подряд для перевода в down
```
```bash
docker compose up -d --build lb-router
sleep 15 && make health
```
Проверить, что новые параметры применились, — в первой строке вывода
`make health`:
```
пул 1: слотов 1024, проба http каждые 2s (rise=2 fall=3)
```
Практический смысл: время обнаружения отказа равно `HC_FALL × HC_INTERVAL`
(по умолчанию 6 с), время возврата — `HC_RISE × HC_INTERVAL` (4 с). Уменьшая
интервал, вы сокращаете окно, в котором часть запросов уходит на мёртвый
бэкенд, но увеличиваете постоянную нагрузку проб.
Проверьте изменение по блоку E: `docker compose pause be1` и засеките, за
сколько член уйдёт в `down`.
### Б.2. Алгоритм балансировки: ключ хэша
Строка листенера в `lb/pipeline.sh`, таблица 10:
```
actions=multipath(symmetric_l4,$POOL_ID,modulo_n,$SLOTS,0,NXM_NX_REG1[])
```
Первый аргумент — по каким полям пакета считается хэш. Проверено на OVS 3.1,
принимаются все значения:
| Значение | Поведение |
|---|---|
| `symmetric_l4` | по умолчанию: адреса и порты, симметрично для обоих направлений |
| `symmetric_l3l4`, `symmetric_l3l4+udp` | вариации симметричного хэша |
| `symmetric_l3` | только адреса: все сессии между парой хостов на одном бэкенде |
| `nw_src` | **affinity по адресу источника**: весь трафик одного клиента на одном бэкенде |
| `nw_dst`, `eth_src` | экзотические варианты, для полноты |
Третий аргумент — способ отображения хэша в номер слота: `modulo_n` (по
умолчанию), `hash_threshold`, `hrw`, `iter_hash`. Все принимаются; для
таблицы слотов осмыслен `modulo_n`, остальные рассчитаны на выбор из
небольшого числа каналов.
Применение — без перезапуска:
```bash
vi lb/pipeline.sh
docker compose cp lb/pipeline.sh lb-router:/opt/lb/pipeline.sh
docker compose exec lb-router /opt/lb/apply.sh
```
Чтобы изменение пережило пересоздание контейнера, потом соберите образ:
`docker compose up -d --build lb-router`.
**Проверка эффекта.** С `nw_src` все запросы одного клиента должны попадать на
один бэкенд:
```bash
for i in $(seq 8); do curl -s http://192.168.5.21/ | grep -o 'backend=[a-z0-9]*'; done | sort | uniq -c
```
Ожидается, что все 8 запросов уйдут на один бэкенд вместо деления между
четырьмя. Так и проверялось на стенде: `nw_src` дал 8 из 8 на один член,
возврат к `symmetric_l4` вернул деление.
### Б.3. Веса членов пула
В `lb/hc-config.sh` у каждого члена есть поле `weight` (по умолчанию 1):
```json
{ "id": 1, "name": "be1", "address": "10.20.0.2", "port": 8080, "weight": 3, "source": "10.20.0.253" }
```
```bash
docker compose up -d --build lb-router
sleep 15 && make health
```
Ожидается пропорциональное деление слотов: член с весом 3 получит втрое
больше слотов, чем член с весом 1. Проверено на пуле из двух членов —
`weight=3` против `weight=1` дало 768 / 256 вместо 512 / 512.
### Б.4. Число слотов и basis хэша
`lb/topology.env`:
```bash
POOL_ID=1 # basis хэша: разные пулы дают независимые раскладки
SLOTS=1024 # гранулярность весов
```
Значение `SLOTS` используется одновременно в правиле `multipath` и в
раскладке демона, поэтому менять его нужно только здесь и с пересборкой —
рассинхронизация этих двух мест приведёт к тому, что часть слотов окажется
недостижима.
Гранулярность: минимальная доля, которую можно выдать члену, равна
`1 / SLOTS`. Для четырёх бэкендов 1024 слота — с большим запасом; смысл
появится при десятках членов с разными весами.
Изменение `POOL_ID` полностью перетасует раскладку — это ожидаемо, он входит
в хэш как seed. Дайджест при этом изменится.
### Б.5. Возврат к исходной конфигурации
Все параметры стенда лежат в трёх файлах: `lb/topology.env`,
`lb/hc-config.sh`, `lb/pipeline.sh`. Если стенд под git — `git checkout` этих
файлов и пересборка. Исходные значения: проба `http` каждые 2 с,
`rise=2 fall=3`, `symmetric_l4`, `POOL_ID=1`, `SLOTS=1024`, веса по 1,
четыре члена по 256 слотов с дайджестом `0a16713c5a9eb94d`.
---
## Диагностика
**Контейнер `hpnn-lb` перезапускается.**
```bash
docker compose logs lb-router --tail=50
```
Частая причина — не загружен модуль ядра: `make prereq` (выполняет
`modprobe openvswitch`).
**Ping до 192.168.5.20 не проходит.**
- Проверьте, что клиент действительно в 192.168.5.0/24 и не отделён от хоста
маршрутизатором с фильтрацией.
- **[Х]** `make ports` — в мосту должны быть `pub0`, `p2`, `p3`, `hcif-p2`,
`hcif-p3`.
- **[Х]** `docker compose exec lb-router tcpdump -ni pub0 arp` — видно ли
ARP-запросы клиента.
- Если проверяете с самого хоста — нужен `make shim`.
**Ping до узла проходит, а curl на VIP — таймаут.**
```bash
make health # есть ли живые члены пула
make slots # растёт ли счётчик fail-close
```
Пустой пул — ожидаемое поведение fail-close, проверьте бэкенды:
`docker compose ps`, `docker compose logs be1`.
**Оба члена `down`, хотя бэкенды работают.**
```bash
docker compose exec lb-router ip addr show hcif-p2
docker compose exec lb-router ip neigh show
docker compose exec lb-router curl -sv --max-time 3 http://10.20.0.2:8080/healthz
```
**Ответ приходит, но `client=` содержит не ваш адрес.**
Значит трафик пришёл не напрямую, а через промежуточный NAT — проверьте, что
обращаетесь с машины из 192.168.5.0/24, а не через проброс портов.
**Полный автоматический прогон для сравнения:**
```bash
make verify
```
+126
View File
@@ -0,0 +1,126 @@
# Шаг 1 — прототип окружения hpnn_v2 (OVS: маршрутизация + балансировка)
## Контекст
`hpnn_v1` содержит только дизайн-концепцию (`/opt/lvraid/claude/hpnn_v1/docs/design.md`, v0.3) OVS-native балансировщика: весь датапас — OpenFlow, DNAT без SNAT (бэкенд видит реальный IP клиента), возврат трафика маршрутизацией, control plane пишется свой. Кода нет.
`hpnn_v2` начинается с шага 1: собрать минимально достаточное контейнерное окружение, на котором можно развивать две подсистемы — **маршрутизации** и **балансировки нагрузки**. Результат шага: клиент из 192.168.5.0/24 обращается на VIP контейнера-1 и видит поочерёдно два разных бэкенда, а бэкенды видят реальный IP клиента; одновременно работает транзитная маршрутизация между приватными сегментами. Всё форвардинг-решение принимает OVS, ядро контейнера-1 транзитный трафик не обрабатывает.
Утверждённые решения:
- публичный сегмент — Docker **macvlan** поверх `enp3s0` (схема уже проверена на этом хосте: `router-functest_lan`, 192.168.5.11);
- **kernel datapath** OVS (модуль `openvswitch.ko` на хосте есть, не загружен);
- балансировка — **`group type=select`** + `ct(nat)`;
- маршрутизация — **целиком в OpenFlow** (свой ARP-респондер, статические next-hop-привязки, ICMP-ошибки на шаге 1 не генерируются);
- адреса: узел `192.168.5.20`, VIP `192.168.5.21`.
## Топология
```
клиент 192.168.5.0/24
│
[ enp3s0, macvlan ] сеть 1 «публичная»: 192.168.5.0/24
│ pub0 (02:42:c0:a8:05:14)
┌─────┴──────────────────────────────┐
│ lb-router (privileged, OVS) │ узел .20, VIP .21
│ br-lb: pub0, p2, p3 │
└──┬──────────────────────────┬──────┘
p2 │ 10.20.0.1 │ 10.30.0.1 p3
сеть 2 │ 10.20.0.0/24 сеть 3 │ 10.30.0.0/24
be1│ 10.20.0.2 be2│ 10.30.0.2
```
- Docker-шлюзы приватных сетей смещены на `.254` (`ipam.config.gateway`), чтобы `.1` занял OVS-роутер и не конфликтовал с host-бриджем.
- Дефолт бэкендов остаётся на `.254` (выход в интернет мимо LB — профиль A из §3.2 дизайна v1). Через LB бэкенды маршрутизируют только клиентские префиксы:
`ip route add 192.168.5.0/24 via 10.20.0.1` и `ip route add 10.30.0.0/24 via 10.20.0.1` (симметрично на be2).
- MAC бэкендов и `pub0` фиксируются в compose (`mac_address:`) — на них строятся статические adjacency-правила.
## Структура репозитория
```
/opt/lvraid/claude/hpnn_v2/
├── README.md # обзор стенда, запуск, проверка
├── docs/
│ ├── STEP1_IMPLEMENTATION_PLAN.md # копия этого плана (артефакт «план внедрения»)
│ └── STEP1_SUMMARY.md # итоги по факту выполнения
├── docker-compose.yml
├── Makefile # up / down / flows / verify / logs
├── lb/
│ ├── Dockerfile # debian:bookworm-slim + openvswitch-switch + iproute2 + tcpdump
│ ├── entrypoint.sh # запуск OVS, сборка br-lb, применение пайплайна
│ ├── topology.env # единственный источник правды: IP, MAC, порты, VIP
│ ├── pipeline.sh # генерация OpenFlow-правил из topology.env
│ └── lbctl.sh # обёртки: dump-flows / group-stats / ofproto-trace
├── backend/
│ ├── Dockerfile # multi-stage: golang:1.23-alpine → alpine (нужен iproute2)
│ ├── entrypoint.sh # установка маршрутов на клиентские префиксы, запуск app
│ ├── go.mod
│ └── main.go # hostname, дата/время, IP клиента, локальный адрес
└── scripts/
├── host-prereq.sh # modprobe openvswitch; опциональный macvlan-shim для проверки с хоста
└── verify.sh # e2e-проверки (см. раздел «Верификация»)
```
## Реализация
### 1. Контейнер-1: подъём OVS и портов (`lb/entrypoint.sh`)
1. `modprobe openvswitch` (хост-модуль виден через `/lib/modules:ro`), запуск `ovsdb-server` + `ovs-vswitchd`, создание `br-lb` с `fail_mode=secure`, `protocols=OpenFlow13,OpenFlow15` и без контроллера.
2. Для каждого из трёх Docker-интерфейсов: снять IP (`ip addr flush`), внести в мост (`ovs-vsctl add-port br-lb <if>`), закрепить `ofport_request` (pub0=1, p2=2, p3=3), поднять.
IP-адреса на интерфейсах не нужны — L3 живёт в OpenFlow, IPAM-резервация Docker остаётся за контейнером и предотвращает выдачу этих адресов кому-то ещё.
3. Применение пайплайна атомарным бандлом: `ovs-ofctl --bundle -O OpenFlow15 replace-flows br-lb <(pipeline.sh)` — обновление без blackhole-окна (принцип §4 дизайна v1).
**Риск и запасной вариант.** Подключение macvlan-устройства портом OVS — рабочая, но нечастая схема. Если `ovs-vsctl add-port` не заведётся, откат: внутри контейнера поднять linux-bridge `brpub`, включить в него macvlan и один конец veth-пары, второй конец veth отдать в `br-lb`. Логика OpenFlow при этом не меняется — правится только шаг 2 entrypoint.
### 2. OpenFlow-пайплайн (`lb/pipeline.sh`)
| Таблица | Назначение |
|---|---|
| **0** | Классификация: ARP → t5; ICMP echo на собственные IP → t6; IP из `pub0` → learn-адъяценции + t10; IP из `p2/p3` → t30; иначе drop |
| **3** (inline `learn` в t0) | Обучение MAC клиентов: `learn(table=21, NXM_OF_IP_DST[]=NXM_OF_IP_SRC[], load:NXM_OF_ETH_SRC[]->NXM_OF_ETH_DST[], load:<pub0 mac>->NXM_OF_ETH_SRC[], output:NXM_OF_IN_PORT[])`, `hard_timeout=300` — заменяет отсутствующий ARP-резолвер для клиентской стороны |
| **5** | ARP-респондер для `192.168.5.20`, `192.168.5.21`, `10.20.0.1`, `10.30.0.1` (op=2, swap SHA/THA, `output:IN_PORT`) |
| **6** | ICMP echo-reply для тех же адресов (swap eth/ip, `icmp_type=0`) — даёт `ping` до узла и VIP |
| **10** | Листенеры: `tcp,nw_dst=192.168.5.21,tp_dst=80 → group:100`; остальной IP → t20 (обычная маршрутизация) |
| **group 100** | `type=select, selection_method=hash, fields(ip_src,tcp_src)`; 2 бакета, в каждом `ct(commit,zone=1,nat(dst=10.X0.0.2:8080),table=20)`. `tcp_src` в ключе обязателен — иначе все запросы одного клиента лягут на один бэкенд |
| **20** | Маршрутизация: LPM через приоритет = длина префикса. `10.20.0.0/24`→p2, `10.30.0.0/24`→p3, `192.168.5.0/24`→pub0; действия `dec_ttl`, `mod_dl_src=<MAC порта>`, `resubmit(,21)`. `priority=0` → drop (дефолта нет) |
| **21** | Adjacency: статически `nw_dst=10.20.0.2 → mod_dl_dst=<be1 mac>, output:p2` (и симметрично be2); клиентские адреса приходят сюда из `learn` |
| **30** | Возврат из приватных сетей: TCP → `ct(table=31, zone=1, nat)` (снимает DNAT: `src` снова VIP, `dst` — клиент); прочий IP (be↔be, транзит) → t20 |
| **31** | После ct → t20 |
Un-DNAT выполняет `ct(nat)`, отдельных правил обратной трансляции не требуется. Stateless-вариант из §2 дизайна v1 остаётся задачей следующего шага — здесь сознательно взят stateful, чтобы окружение поднялось минимальными средствами.
### 3. Бэкенды (`backend/`)
`main.go` — HTTP-сервер на `:8080`, отдаёт `text/plain`: hostname, дата/время с таймзоной, `RemoteAddr` (реальный IP клиента — ключевое доказательство DNAT-only), локальный адрес соединения и счётчик запросов. Плюс `/healthz` для будущих проб. Стандартная библиотека, без зависимостей.
`entrypoint.sh` (нужен `NET_ADMIN`): ставит маршруты на клиентский префикс и на соседний приватный сегмент через `.1`, затем `exec` приложения.
### 4. Compose
Три сети: `pub` (macvlan, `parent: enp3s0`, `192.168.5.0/24`), `priv2` (bridge, `10.20.0.0/24`, gateway `.254`), `priv3` (bridge, `10.30.0.0/24`, gateway `.254`).
`lb-router`: `privileged: true`, `/lib/modules:ro`, все три сети, фиксированные IP и MAC.
`be1`/`be2`: по одной приватной сети каждый, `cap_add: [NET_ADMIN]`, фиксированные IP и MAC.
## Верификация
**Подготовка хоста:** `scripts/host-prereq.sh` — `modprobe openvswitch`; при желании проверять с самого хоста (192.168.5.9) он же создаёт macvlan-shim, т.к. macvlan не пропускает трафик хост↔контейнер напрямую.
С клиента из 192.168.5.0/24:
| Проверка | Команда | Ожидание |
|---|---|---|
| Доступность узла и VIP | `ping 192.168.5.20`, `ping 192.168.5.21` | отвечает OpenFlow-респондер (t6) |
| Балансировка | `for i in $(seq 20); do curl -s http://192.168.5.21/; done` | оба hostname встречаются, распределение близко к 50/50 |
| Сохранение IP клиента | тот же вывод | в поле «клиент» — реальный адрес 192.168.5.x, не адрес LB |
| Распределение по бакетам | `docker compose exec lb-router ovs-ofctl -O OpenFlow15 dump-group-stats br-lb` | счётчики обоих бакетов растут |
| Детерминизм выбора | `ovs-appctl ofproto/trace br-lb <5-tuple>` | одинаковый бакет для одного и того же кортежа |
| Маршрутизация между сегментами | `docker compose exec be1 curl -s http://10.30.0.2:8080/` | ответ be2, трафик прошёл через t20/t21 |
| Профиль A (egress мимо LB) | `docker compose exec be1 ping -c1 8.8.8.8` при `tcpdump -ni p2` на LB | пакеты на LB не появляются |
| Целостность пайплайна | `ovs-ofctl -O OpenFlow15 dump-flows br-lb`, `ovs-vsctl show` | три порта в мосту, все таблицы на месте, счётчик drop-правил t20 не растёт при нормальном трафике |
`scripts/verify.sh` прогоняет проверки, выполнимые с хоста, и печатает сводку; клиентские шаги с curl/ping вынесены в README как ручные.
## Артефакты (по требованию CLAUDE.md)
1. `docs/STEP1_IMPLEMENTATION_PLAN.md` — план внедрения (копия этого документа) — **до** начала работ.
2. `README.md` — назначение стенда, схема, адресный план, запуск, проверка, устранение неполадок.
3. `docs/STEP1_SUMMARY.md` — по завершении: что сделано, отличия от плана, известные ограничения (stateful `ct` вместо stateless un-DNAT, отсутствие ICMP-ошибок и health-check, один узел LB вместо ECMP-набора) и заготовка задач шага 2.
+103
View File
@@ -0,0 +1,103 @@
# Шаг 1 — итоги: прототип окружения hpnn_v2
**Дата:** 2026-08-16
**Статус:** выполнено, стенд поднят и проверен (30 из 30 проверок)
**План:** [STEP1_IMPLEMENTATION_PLAN.md](STEP1_IMPLEMENTATION_PLAN.md)
## Что сделано
Собрано контейнерное окружение из трёх сетей и трёх контейнеров, на котором
работают обе подсистемы — маршрутизация и балансировка нагрузки. Всё
форвардинг-решение принимается в OpenFlow: сетевой стек ядра контейнера
транзитный трафик не обрабатывает.
- **Контейнер 1 (`hpnn-lb`)** — Open vSwitch с kernel datapath в собственном
netns, мост `br-lb` с тремя портами. Пайплайн из 42 правил и группы
балансировки рендерится скриптом из `topology.env` и заливается атомарным
бандлом.
- **Контейнеры 2 и 3 (`hpnn-be1`, `hpnn-be2`)** — идентичный web-сервис на Go,
каждый в своём приватном сегменте; отдаёт имя хоста, дату, время и адрес
клиента.
- **Сети** — публичный сегмент через macvlan поверх `enp3s0` (настоящий L2
клиентской сети) и два приватных bridge-сегмента.
- **Инструментарий** — `make up/down/flows/verify`, `lbctl` для осмотра
датапаса, `scripts/host-prereq.sh` для подготовки хоста.
## Результаты проверок
| Проверка | Результат |
|---|---|
| Kernel datapath OVS в netns контейнера | `system@ovs-system`, поддержка recirculation, ct_state_nat, ct_zone |
| macvlan-интерфейс как порт OVS | работает, `pub0` — ofport 1 |
| `selection_method=hash, fields(ip_src,tcp_src)` | принят OVS 3.1 |
| Балансировка 20 запросов с клиента | be1/be2 ≈ 10/10, счётчики обоих бакетов растут |
| Сохранение IP клиента | бэкенд видит `192.168.5.13` — DNAT без SNAT подтверждён |
| ARP- и ICMP-респондеры OVS | `ping` до узла и до VIP проходит |
| Маршрутизация между приватными сегментами | `be1 → be2` через таблицы 20/21 |
| Профиль A (§3.2 дизайна v1) | egress бэкенда наружу работает и на порту `p2` не появляется |
| `ofproto/trace` пути клиент → VIP | DNAT, `dec_ttl`, перепись MAC, выход в порт 2 |
## Отличия от плана
1. **Публичная сеть объявлена с подсетью `192.168.55.0/24`, а не
`192.168.5.0/24`.** Docker отказывается регистрировать пул, пересекающийся
с уже существующей macvlan-сетью соседнего стенда `router-functest`
(`Pool overlaps with other one on this address space`). Объявленная подсеть
нужна только Docker IPAM: адреса узла и VIP живут исключительно в правилах
OpenFlow, а macvlan включён в `enp3s0` и работает на реальном L2.
2. **Обратный путь занял таблицы 15 и 16 вместо 30 и 31.** `goto_table` в
OpenFlow разрешает переход только вперёд, поэтому таблицы обратного пути
обязаны стоять до общей маршрутизации (20).
3. **DNAT вынесен из бакетов группы в отдельную таблицу 11.** Бакет только
кладёт номер члена пула в `reg2` и делает `resubmit`. Так вся логика
трансляции лежит в одном месте и не зависит от того, какие действия
допустимы внутри бакета.
4. **Обучение MAC клиентов сужено** до трафика, адресованного стенду (VIP,
адрес узла, приватные сегменты). В первой редакции `learn` срабатывал на
любой IP-пакет с macvlan-порта и заносил в таблицу 21 весь
широковещательный шум сегмента — DHCP, mDNS, SSDP всех соседей по LAN.
5. **Проверка профиля A добавлена в `verify.sh`** — в плане она была только
ручной.
## Известные ограничения
Осознанные упрощения шага 1, каждое — задача следующих шагов:
- **Датапас stateful.** Обратную трансляцию выполняет `ct(nat)`, а не
stateless un-DNAT из §2 дизайна v1. Следствие: прямой и обратный трафик
сессии обязаны проходить через один узел, то есть Active/Active ECMP в такой
схеме работать не будет.
- **ICMP-ошибки не генерируются.** У чисто-OpenFlow узла нет стека, который бы
формировал `TTL exceeded` и `fragmentation needed`; PMTUD через стенд не
работает, `traceroute` не показывает узел.
- **Фрагменты IP не обрабатываются** — правила матчат L4-заголовок, которого
во втором и последующих фрагментах нет.
- **Один узел LB.** Ни BGP, ни BFD, ни ECMP-набора: FRR в стенде нет, место
под него (internal-порты с IP) на шаге 1 не занято, поскольку маршрутизация
выполняется целиком в OpenFlow.
- **Нет health-check.** Бэкенды считаются живыми всегда; отказ бэкенда не
выводит его из группы. У приложения есть `/healthz` как задел.
- **Пул и листенер статические** — заданы в `topology.env`, control plane и
OVSDB-схемы из §6 дизайна v1 нет.
- **MAC бэкендов прописаны статически.** ARP-резолвер next-hop отсутствует;
MAC зафиксированы в `docker-compose.yml`.
- **Только TCP и только IPv4.** UDP- и L3-листенеров нет.
## Задел на шаг 2
1. Заменить `ct(nat)` на stateless DNAT/un-DNAT с таблицей слотов и
`multipath(symmetric_l4)` — это спайк S0 из §12 дизайна v1 и предпосылка
для Active/Active.
2. Проверить детерминизм выбора бэкенда: один и тот же 5-tuple должен давать
один и тот же слот на разных узлах и между перезапусками
(`ofproto/trace` по корпусу синтетических кортежей).
3. Подсистема health-check: пробы с уникального адреса узла, вывод члена из
пула, перерасчёт таблицы слотов.
4. Control plane: модель `Load_Balancer → Listener → Pool → Member` в OVSDB
вместо `topology.env`, агент-рендерер пайплайна.
5. ICMP-транслятор в userspace (packet-in) — для PMTUD и корректных
ICMP-ошибок от имени VIP.
+179
View File
@@ -0,0 +1,179 @@
# Шаг 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=<slot>` → `reg2=<member>` |
| 12 | — | DNAT по `reg2` (бывшая 11) |
```
table=10,priority=200,tcp,nw_dst=<VIP>,tp_dst=80 \
actions=multipath(symmetric_l4,<pool_id>,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 <m> | enable <m>`; `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-портами, новые команды; таблица пайплайна перенумерована.
+114
View File
@@ -0,0 +1,114 @@
# Шаг 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.