Files
hpnn-proto/docs/MANUAL_TEST_PLAN.md
T

1102 lines
48 KiB
Markdown
Raw Normal View History

# План ручного тестирования стенда hpnn_v2
Пошаговая проверка обеих подсистем — маршрутизации и балансировки — и
подсистемы health-check. Рассчитан на прохождение целиком примерно за 25–30
минут.
Автоматический аналог большей части проверок — `make verify`. Этот документ
нужен, чтобы увидеть поведение стенда своими глазами и понять, что означает
каждый результат.
Что происходит с пакетом на каждом шаге — в документах по потокам данных:
[публичная балансировка](PUBLIC_LB_DATAFLOW.md) для блоков A–C и E–I,
[приватная балансировка](PRIVATE_LB_DATAFLOW.md) для блока J.
## Обозначения
| Метка | Где выполнять |
|---|---|
| **[К]** | на клиентской машине в подсети 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
```
Ожидается: семь контейнеров и таблица состояния пула, где все четыре члена
`up` и у каждого по 256 слотов. `make up` сам вызывает `make attach` —
подключение ВМ приватных сегментов к мосту.
**Шаг 0.2 [Х]** — убедиться, что всё запустилось:
```bash
docker compose ps
```
Ожидается:
```
hpnn-be1 Up ... (healthy)
hpnn-be2 Up ... (healthy)
hpnn-be3 Up ... (healthy)
hpnn-be4 Up ... (healthy)
hpnn-cli2 Up ...
hpnn-cli3 Up ...
hpnn-lb Up ... (healthy)
```
Проверьте, что ВМ сегментов подключены к мосту:
```bash
make ports
```
Ожидается 11 портов: `pub0`, `p2`, `p3`, `hcif-p2`, `hcif-p3`, `be1`, `be3`,
`cli2`, `be2`, `be4`, `cli3`. Если портов ВМ нет — `make attach`.
Если `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
```
---
## Блок J. Листенер в приватном сегменте
Главное шага 3: клиент стоит в одном сегменте с бэкендами, и его исходный адрес
на бэкенде сохраняется. Все шаги выполняются на хосте — клиентом служит
контейнер `cli2`.
**Шаг J.1 [Х]** — убедиться, что в ОС клиента ничего не настроено:
```bash
docker compose exec cli2 ip route
```
Ожидается ровно одна строка — connected-маршрут `10.20.0.0/24 dev eth0`. Ни
default, ни маршрута на VIP: адрес листенера находится в подсети клиента.
**Шаг J.2 [Х]** — доступность VIP сегмента:
```bash
docker compose exec cli2 ping -c2 10.20.0.100
```
**Шаг J.3 [Х]** — запрос к сервису через балансировщик:
```bash
docker compose exec cli2 curl -s http://10.20.0.100/
```
Ожидается строка вида:
```
backend=be1 time=... client=10.20.0.10:46064 served=10.20.0.2:8080 req=1
```
Здесь два ключевых поля: `client` — исходный адрес клиента (SNAT не
выполняется), `served` — адрес и порт бэкенда, то есть листенер принял на 80 и
оттранслировал на 8080.
**Шаг J.4 [Х]** — распределение по пулу:
```bash
docker compose exec cli2 sh -c 'for i in $(seq 24); do curl -s http://10.20.0.100/; done' \
| sort | uniq -c -w 12
```
Ожидается: встречаются все четыре бэкенда. `be1` и `be3` — соседи клиента по
сегменту (коммутируемый путь), `be2` и `be4` — из другого сегмента
(маршрутизируемый).
**Шаг J.5 [Х]** — увидеть разницу путей по TTL. В одном терминале:
```bash
docker compose exec lb-router tcpdump -ni cli2 -v \
'tcp and src host 10.20.0.100 and tcp[tcpflags] & tcp-syn != 0'
```
Во втором — десяток запросов из шага J.3.
Ожидается: у ответов **ttl 64** и **ttl 63**. Первые пришли от `be1`/`be3` —
для них балансировщик не был L3-хопом, он лишь подменил MAC и оттранслировал
адрес. Вторые — от `be2`/`be4` через маршрутизацию, с уменьшением TTL.
**Шаг J.6 [Х]** — путь пакета по таблицам:
```bash
make trace SRC=10.20.0.10 SPORT=40000 SEG=p2
```
Ожидается цепочка `0 → 1 → 10 → 11 → 12`, затем после `ct(...nat(dst=...))` —
`Resuming from table 25` и вывод в порт члена. В `Final flow` видно, что
`nw_src` остался клиентским, а `nw_ttl` **не уменьшен**.
**Шаг J.7 [Х]** — внутрисегментный трафик не блокирован:
```bash
docker compose exec cli2 curl -s http://10.20.0.2:8080/ # клиент к бэкенду напрямую
docker compose exec be1 curl -s http://10.20.0.3:8080/ # бэкенд к бэкенду
docker compose exec be1 ping -c2 10.20.0.254 # шлюз Docker
```
Ожидается: все три работают. Балансировщик коммутирует этот трафик, не
транслируя его.
**Шаг J.8 [Х]** — таблица коммутации:
```bash
make fdb
```
Ожидается: по строке на каждую ВМ сегмента с её MAC и портом, счётчики растут.
**Шаг J.9 [Х]** — выключить листенер в одном сегменте:
```bash
sed -i 's/^PRIV_LISTENERS=.*/PRIV_LISTENERS="p2"/' lb/topology.env
docker compose cp lb/topology.env lb-router:/opt/lb/topology.env
docker compose exec lb-router /opt/lb/apply.sh
docker compose exec cli3 curl -s --max-time 4 http://10.30.0.100/ # таймаут
docker compose exec cli3 curl -s http://10.30.0.2:8080/ # связность цела
docker compose exec cli2 curl -s http://10.20.0.100/ # второй сегмент работает
```
Вернуть обратно:
```bash
sed -i 's/^PRIV_LISTENERS=.*/PRIV_LISTENERS="p2 p3"/' lb/topology.env
docker compose cp lb/topology.env lb-router:/opt/lb/topology.env
docker compose exec lb-router /opt/lb/apply.sh
```
---
## Блок K. Динамическое изучение MAC/ARP (шаг 5)
MAC каждого члена пула больше не статическая константа — его резолвит демон
`hcd` через обычный ARP ядра. Проверяем, что это действительно так, а не
осталось прежним поведением под новым названием.
**Шаг K.1 [Х]** — MAC членов пула резолвлен и совпадает с реальным:
```bash
make neigh
docker compose exec be1 ip link show eth0 | grep link/ether
```
Ожидается: `lbctl neigh` показывает у `be1` состояние «да» (резолвлен) и MAC,
совпадающий с выводом `ip link show` внутри контейнера `be1`.
**Шаг K.2 [Х]** — MAC действительно используется в датапасе:
```bash
docker compose exec lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb table=21 \
| grep priority=100
```
Ожидается: по правилу `priority=100,ip,nw_dst=<адрес члена>` на каждого
живого члена, `actions` содержат тот же MAC, что и в `make neigh`.
**Шаг K.3 [Х]** — обнаружение смены MAC «железа» без перезапуска стенда:
```bash
docker compose exec be1 ip link set eth0 down
docker compose exec be1 ip link set eth0 address 02:42:0a:14:00:99
docker compose exec be1 ip link set eth0 up
docker compose exec be1 ip route replace 192.168.5.0/24 via 10.20.0.1
docker compose exec be1 ip route replace 10.30.0.0/24 via 10.20.0.1
docker compose exec be1 ip route replace default via 10.20.0.254
```
Ожидается: не сразу, а в течение примерно двух минут (обычное старение ARP
ядра — `hcd` опрашивает быстро, но отражает только то, что уже узнало ядро)
`make neigh` покажет у `be1` новый MAC `02:42:0a:14:00:99`, и трафик к нему
продолжит доставляться:
```bash
watch -n5 'docker compose exec -T lb-router lbctl neigh'
# в отдельном терминале, после смены MAC в neigh:
docker compose exec cli2 curl -s http://10.20.0.2:8080/
```
Вернуть `be1` в исходное состояние (родной MAC пропишет заново
`scripts/attach-segment.sh`):
```bash
docker compose restart be1
sleep 2 && sudo scripts/attach-segment.sh
sleep 6 && make neigh
```
**Шаг K.4 [Х]** — заливка таблицы 21 переживает перезаливку пайплайна:
```bash
make health | grep -A1 дайджест # или просто make neigh — запомнить MAC
make flows
make neigh # те же MAC на месте, без ручного вмешательства
```
Смысл: `make flows` (`apply.sh`) стирает весь пайплайн вместе с таблицей 21;
`/reapply` у `hcd` обязан восстановить и таблицу слотов, и таблицу adjacency —
без этого второй `make neigh` показал бы пустые записи до следующего
естественного изменения MAC (которого может не случиться никогда).
---
## Итоговый чек-лист
| # | Проверка | Результат |
|---|---|---|
| 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 | Установленная сессия переживает дренаж | ☐ |
| J | В ОС клиента сегмента нет ни одного маршрута | ☐ |
| J | Бэкенд видит исходный IP клиента-соседа | ☐ |
| J | Листенер 80 транслирует на порт бэкенда 8080 | ☐ |
| J | Ответ от соседа по сегменту приходит с неуменьшенным TTL | ☐ |
| J | Трафик между ВМ сегмента не блокирован | ☐ |
| J | `PRIV_LISTENERS` выключает листенер, не трогая связность | ☐ |
| K | MAC членов пула резолвлен и совпадает с реальным MAC интерфейса | ☐ |
| K | Таблица 21 содержит правило приоритета 100 с этим MAC | ☐ |
| K | Смена MAC «железа» обнаружена без перезапуска стенда | ☐ |
| K | Таблица 21 переживает `make flows` | ☐ |
---
## Возврат стенда в исходное состояние
```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` |
| Приватные листенеры (какие сегменты) | `lb/topology.env`, `PRIV_LISTENERS` | `make flows` после копирования файла |
| Адрес приватного VIP | `lb/topology.env`, `P2_VIP` / `P3_VIP` | то же |
«Пересборка образа» — это:
```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`.
---
## Диагностика
**Клиент сегмента не получает ответ от приватного VIP, или ответ приходит не
от VIP.**
Первая гипотеза — ВМ не подключены к мосту: `make ports` должен показывать
порты `be1`, `be3`, `cli2`, `be2`, `be4`, `cli3`. Вручную созданные veth
исчезают вместе с контейнером, поэтому после `docker compose restart be1` или
пересоздания любого контейнера сегмента нужен `make attach`.
Если порты на месте — смотрите `make fdb` (выучен ли MAC клиента) и счётчики
таблицы 24 (`docker compose exec lb-router lbctl flows 24`): именно она снимает
трансляцию с ответа бэкенда соседу.
**Контейнер `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
```