MAC каждого члена пула больше не статическая константа в topology.env, а резолвится демоном hcd через обычный ARP ядра — в реальном окружении MAC бэкенда заранее не известен (сервер ещё не подключён, NIC может замениться), топология не описывается статически, в отличие от стенда. hcif-порты (единственные адреса узла в ядре) уже были настоящими L3-интерфейсами в тех же сегментах, что и бэкенды — единственное, что мешало обычному ARP, это permanent-записи ip neigh в entrypoint.sh. Убрав их и добавив hc/neigh.go (читает ip -json neigh show, точечно заливает бандл в таблицу 21 на том же тикере, что и health-пробы), получили резолвер без нового OpenFlow-контроллера. Таблица 21 стала единственным источником MAC для обоих путей — маршрутизируемого и коммутируемого (шаг 3): таблица 12 больше не дублирует MAC инлайново, ct(commit) ведёт сразу в таблицу 21. Исправлен попутно найденный баг: после apply.sh (replace-flows) таблица 21 не восстанавливалась, поскольку syncNeighbors сравнивал MAC с памятью демона, а не с датапасом. Добавлен force-режим по аналогии с Agent.apply(), плюс регрессионная проверка в verify.sh. Проверено на живом стенде: MAC всех четырёх членов резолвлен и совпадает с реальными интерфейсами; смена MAC "железа" обнаружена и применена без вмешательства за счёт штатного старения ARP ядра. Регрессия: 94 из 94 проверок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
341 lines
24 KiB
Markdown
341 lines
24 KiB
Markdown
# hpnn_v2 — стенд OVS-маршрутизатора и балансировщика нагрузки
|
||
|
||
Контейнерный прототип основы сервиса: один узел на Open vSwitch, который
|
||
выполняет три функции — **маршрутизацию** между сегментами, **балансировку
|
||
нагрузки** на четыре бэкенда и **коммутацию приватных сегментов**. Всё
|
||
форвардинг-решение принимается в OpenFlow: сетевой стек ядра контейнера
|
||
транзитный трафик не обрабатывает. Состав пула ведёт подсистема health-check:
|
||
мёртвые бэкенды выводятся из балансировки, восстановившиеся возвращаются.
|
||
|
||
Листенеры есть и в публичном сегменте, и в приватных. Приватный листенер
|
||
обслуживает клиента, стоящего **в одном сегменте с бэкендами**, сохраняя его
|
||
исходный IP и транслируя порт 80 на 8080 — при этом в ОС виртуальных машин не
|
||
настраивается ничего.
|
||
|
||
Развивает дизайн-концепцию `../hpnn_v1/docs/design.md`.
|
||
|
||
| Шаг | Содержание | План | Итоги |
|
||
|---|---|---|---|
|
||
| 1 | Окружение, маршрутизация, балансировка | [план](docs/STEP1_IMPLEMENTATION_PLAN.md) | [итоги](docs/STEP1_SUMMARY.md) |
|
||
| 2 | Health-check и таблица слотов | [план](docs/STEP2_IMPLEMENTATION_PLAN.md) | [итоги](docs/STEP2_SUMMARY.md) |
|
||
| — | Расширение пула до четырёх бэкендов | [план](docs/CHANGE_POOL_4_MEMBERS_PLAN.md) | [итоги](docs/CHANGE_POOL_4_MEMBERS_SUMMARY.md) |
|
||
| 3 | Листенер в приватном сегменте, коммутация сегментов | [план](docs/STEP3_IMPLEMENTATION_PLAN.md) | [итоги](docs/STEP3_SUMMARY.md) |
|
||
| — | Разделение документации по потокам данных | [план](docs/CHANGE_SPLIT_DATAFLOW_DOCS_PLAN.md) | [итоги](docs/CHANGE_SPLIT_DATAFLOW_DOCS_SUMMARY.md) |
|
||
| 4 | Публичная балансировка без conntrack | — | [оценка осуществимости](docs/STEP4_PUBLIC_STATELESS_ASSESSMENT.md) |
|
||
| 5 | Динамическое изучение MAC/ARP для бэкендов | [план](docs/STEP5_IMPLEMENTATION_PLAN.md) | [итоги](docs/STEP5_SUMMARY.md) |
|
||
|
||
Как устроена раскладка слотов — [docs/MAGLEV_SLOT_TABLE.md](docs/MAGLEV_SLOT_TABLE.md)
|
||
(справка по `hc/maglev.go` для разработки). Что нужно изменить для перевода ноды
|
||
на OVS-DPDK в ПРОД — [docs/OVS_DPDK_MIGRATION.md](docs/OVS_DPDK_MIGRATION.md),
|
||
на мультитенантную схему (100+ тенантов, изоляция уровня VRF) —
|
||
[docs/MULTITENANCY.md](docs/MULTITENANCY.md), на несколько листенеров с
|
||
независимыми пулами бэкендов — [docs/MULTI_LISTENER_POOLS.md](docs/MULTI_LISTENER_POOLS.md).
|
||
|
||
Потоки данных описаны отдельно для каждого вида балансировки:
|
||
[публичный трафик](docs/PUBLIC_LB_DATAFLOW.md) — клиент из клиентской сети на
|
||
`192.168.5.21:80`, целиком маршрутизируемый путь;
|
||
[приватный трафик](docs/PRIVATE_LB_DATAFLOW.md) — клиент внутри приватного
|
||
сегмента, рядом с бэкендами, на `10.20.0.100:80`.
|
||
|
||
## Топология
|
||
|
||
```
|
||
клиент из 192.168.5.0/24
|
||
│
|
||
[ enp3s0, macvlan ] сеть 1 — публичный сегмент
|
||
│ pub0 · ofport 1
|
||
┌─────────────────┴─────────────────────────────┐
|
||
│ hpnn-lb (Open vSwitch, br-lb) │ узел 192.168.5.20
|
||
│ маршрутизатор + балансировщик + коммутатор │ VIP 192.168.5.21
|
||
│ hcd: health-check + таблица слотов │
|
||
└──┬─────────────────────────────────────┬──────┘
|
||
сеть 2 │ 10.20.0.0/24 сеть 3 │ 10.30.0.0/24
|
||
шлюз ·1 │ VIP 10.20.0.100:80 шлюз ·1 │ VIP 10.30.0.100:80
|
||
│ │
|
||
p2 ·2 ──┤ аплинк к шлюзу Docker .254 p3 ·3 ├── аплинк .254
|
||
hcif-p2·4│ 10.20.0.253 (пробы) hcif-p3 ·5│ 10.30.0.253
|
||
be1 ·10 │ 10.20.0.2:8080 be2 ·20│ 10.30.0.2:8080
|
||
be3 ·11 │ 10.20.0.3:8080 be4 ·21│ 10.30.0.3:8080
|
||
cli2 ·12 │ 10.20.0.10 (клиент) cli3 ·22│ 10.30.0.10
|
||
```
|
||
|
||
Каждая ВМ приватного сегмента — **отдельный порт моста**. Так балансировщик
|
||
видит и ответ бэкенда клиенту-соседу по подсети, а значит может снять с него
|
||
трансляцию. В целевой среде это выполняется само собой: ВМ подключены к
|
||
vSwitch. В стенде порты создаёт [scripts/attach-segment.sh](scripts/attach-segment.sh)
|
||
(`make attach`) — он же назначает адреса и маршруты снаружи, как это делают
|
||
гипервизор и DHCP.
|
||
|
||
| Контейнер | Роль |
|
||
|---|---|
|
||
| `hpnn-lb` | OVS: маршрутизация, балансировка, коммутация сегментов |
|
||
| `hpnn-be1`, `hpnn-be3` | web-сервис на Go в приватном сегменте 2 |
|
||
| `hpnn-be2`, `hpnn-be4` | тот же сервис в приватном сегменте 3 |
|
||
| `hpnn-cli2`, `hpnn-cli3` | клиенты внутри сегментов; в их ОС настроен только адрес |
|
||
|
||
### Адресный план
|
||
|
||
| Назначение | Адрес |
|
||
|---|---|
|
||
| Клиентская сеть | `192.168.5.0/24` |
|
||
| Адрес узла в публичном сегменте | `192.168.5.20` |
|
||
| **VIP публичного листенера** | **`192.168.5.21:80`** |
|
||
| **VIP приватных листенеров** | **`10.20.0.100:80`, `10.30.0.100:80`** |
|
||
| Клиенты внутри сегментов | `10.20.0.10`, `10.30.0.10` |
|
||
| Шлюз OVS в сети 2 / бэкенды be1, be3 | `10.20.0.1` / `10.20.0.2:8080`, `10.20.0.3:8080` |
|
||
| Шлюз OVS в сети 3 / бэкенды be2, be4 | `10.30.0.1` / `10.30.0.2:8080`, `10.30.0.3:8080` |
|
||
| Источники health-проб (internal-порты OVS) | `10.20.0.253`, `10.30.0.253` |
|
||
| Шлюзы Docker (собственный egress бэкендов) | `10.20.0.254`, `10.30.0.254` |
|
||
|
||
Адреса `192.168.5.20` и `192.168.5.21` существуют **только в правилах
|
||
OpenFlow** — на интерфейсах они не настроены. ARP-респондер OVS отвечает на
|
||
них MAC-адресом порта `pub0`. Исключение — адреса `.253`: это единственные
|
||
адреса, которые узел держит в ядре, потому что с них уходят health-пробы.
|
||
Единственный источник правды по адресам — [lb/topology.env](lb/topology.env).
|
||
|
||
## Запуск
|
||
|
||
```bash
|
||
make up # modprobe openvswitch + сборка образов + запуск + attach
|
||
make verify # проверки
|
||
make down # остановка
|
||
```
|
||
|
||
`make up` вызывает `make attach`: порты ВМ приватных сегментов создаются
|
||
скриптом, а не Docker. Повторить `make attach` нужно после пересоздания любого
|
||
контейнера сегмента — вручную созданные veth вместе с ним исчезают.
|
||
|
||
Проверка с самого хоста требует macvlan-shim: интерфейс macvlan контейнера и
|
||
физический интерфейс хоста по устройству macvlan не видят друг друга. Клиенту
|
||
из 192.168.5.0/24 никакой shim не нужен.
|
||
|
||
```bash
|
||
make shim # поднять shim 192.168.5.13 с маршрутами на .20 и .21
|
||
make shim-down
|
||
```
|
||
|
||
Полный сценарий ручной проверки стенда — [docs/MANUAL_TEST_PLAN.md](docs/MANUAL_TEST_PLAN.md)
|
||
(что выполнять на клиенте, что на хосте, чего ожидать на каждом шаге и что
|
||
означает отклонение). Автоматический аналог — `make verify`.
|
||
|
||
## Проверка с клиента
|
||
|
||
```bash
|
||
ping 192.168.5.20 # адрес узла — отвечает ICMP-респондер OVS
|
||
ping 192.168.5.21 # VIP
|
||
|
||
for i in $(seq 20); do curl -s http://192.168.5.21/; done
|
||
```
|
||
|
||
Из приватного сегмента — с клиента, который стоит рядом с бэкендами:
|
||
|
||
```bash
|
||
docker compose exec cli2 curl -s http://10.20.0.100/
|
||
docker compose exec cli2 ip route # маршрутов нет: настроен только адрес
|
||
```
|
||
|
||
```
|
||
backend=be1 time=2026-08-17 15:29:23 MSK client=10.20.0.10:46064 served=10.20.0.2:8080 req=1
|
||
```
|
||
|
||
`client=10.20.0.10` — исходный адрес клиента, `served=…:8080` — трансляция
|
||
порта 80 → 8080. Ответ бэкенда клиенту-соседу уходит по L2 напрямую, но
|
||
проходит через мост, где таблица 24 снимает трансляцию.
|
||
|
||
```
|
||
backend=be1 time=2026-08-16 21:35:02 MSK client=192.168.5.13:47734 served=10.20.0.2:8080 req=24
|
||
backend=be3 time=2026-08-16 21:35:02 MSK client=192.168.5.13:47740 served=10.20.0.3:8080 req=11
|
||
backend=be2 time=2026-08-16 21:35:02 MSK client=192.168.5.13:47742 served=10.30.0.2:8080 req=30
|
||
backend=be4 time=2026-08-16 21:35:02 MSK client=192.168.5.13:47746 served=10.30.0.3:8080 req=17
|
||
```
|
||
|
||
Что здесь видно:
|
||
|
||
- **балансировка** — ответы приходят от всех четырёх бэкендов примерно поровну;
|
||
- **сохранение IP клиента** — в поле `client` стоит реальный адрес из
|
||
192.168.5.0/24, а не адрес балансировщика: выполняется только DNAT, SNAT
|
||
нигде не делается;
|
||
- ключ хэша — `(ip_src, tcp_src)`, поэтому разные соединения одного клиента
|
||
расходятся по бэкендам, а пакеты одного соединения — нет.
|
||
|
||
Подсистему маршрутизации видно отдельно от балансировки:
|
||
|
||
```bash
|
||
# на клиенте: маршруты в приватные сегменты через узел
|
||
ip route add 10.20.0.0/24 via 192.168.5.20
|
||
ip route add 10.30.0.0/24 via 192.168.5.20
|
||
curl http://10.20.0.2:8080/ # напрямую в бэкенд, мимо VIP
|
||
```
|
||
|
||
> Эти маршруты нужно прописывать на **клиентской машине**, а не на хосте
|
||
> стенда: на самом хосте они перекроют connected-маршруты docker-бриджей
|
||
> `hpnn-p2`/`hpnn-p3` и оборвут бэкендам выход наружу.
|
||
|
||
Транзит между приватными сегментами проверяется изнутри:
|
||
|
||
```bash
|
||
docker compose exec be1 curl -s http://10.30.0.2:8080/ # be1 -> be2 через OVS
|
||
```
|
||
|
||
## Подсистема health-check
|
||
|
||
Демон `hcd` внутри контейнера-балансировщика проверяет живость членов пула и
|
||
отражает их состав в датапасе. Пробы уходят **с уникального адреса узла** в
|
||
сегменте бэкенда (`10.20.0.253`, `10.30.0.253`), а не с VIP — иначе ответ
|
||
вернулся бы не тому, кто проверял.
|
||
|
||
```bash
|
||
make health # состояние членов пула
|
||
make slots # раскладка слотов и счётчики пакетов по членам
|
||
make drain M=be1 # вывести член из балансировки (пробы продолжаются)
|
||
make enable M=be1 # вернуть член в балансировку
|
||
make metrics # метрики Prometheus
|
||
```
|
||
|
||
```
|
||
пул 1: слотов 1024, проба http каждые 2s (rise=2 fall=3)
|
||
дайджест раскладки: 0a16713c5a9eb94d
|
||
|
||
ЧЛЕН АДРЕС СОСТОЯНИЕ ADMIN В ПУЛЕ СЛОТОВ ПРОБ НЕУДАЧ МС ИСТОЧНИК ПРОБ
|
||
be1 10.20.0.2:8080 up enabled да 256 120 0 1 10.20.0.253
|
||
be2 10.30.0.2:8080 up enabled да 256 120 0 2 10.30.0.253
|
||
be3 10.20.0.3:8080 up enabled да 256 120 0 2 10.20.0.253
|
||
be4 10.30.0.3:8080 up enabled да 256 120 0 2 10.30.0.253
|
||
```
|
||
|
||
Проверить руками:
|
||
|
||
```bash
|
||
docker compose pause be1 # член уходит в down за fall × interval = 6 с
|
||
curl http://192.168.5.21/ # трафик обслуживают три оставшихся члена
|
||
docker compose unpause be1 # возвращается за rise × interval = 4 с
|
||
```
|
||
|
||
Параметры проб (тип, интервал, таймаут, `rise`/`fall`) — в
|
||
[lb/topology.env](lb/topology.env), раздел `HC_*`.
|
||
|
||
### Таблица слотов
|
||
|
||
Балансировка выполняется не группой OVS, а таблицей из 1024 слотов
|
||
(таблица 11). Номер слота даёт `multipath(symmetric_l4)` от заголовков
|
||
пакета, слот указывает на члена пула. Раскладку слотов по членам считает
|
||
`hcd` по алгоритму Maglev (§5 дизайна v1) и заливает атомарным бандлом.
|
||
|
||
Свойства, которые это даёт:
|
||
|
||
- **детерминированность** — одинаковый состав пула даёт одинаковую раскладку
|
||
на любом узле и между перезапусками; контролируется дайджестом SHA-256;
|
||
- **минимальное возмущение** — при выбытии члена переезжают его слоты, а у
|
||
оставшихся меняются единицы: на этом стенде вывод одного из четырёх членов
|
||
сдвигает 256 его слотов и лишь 8 чужих (0,8 %); проверяется в `make verify`;
|
||
- **fail-close** — когда живых членов нет, таблица пустеет и трафик на VIP
|
||
отбрасывается со счётчиком, а не уходит на заведомо мёртвый бэкенд;
|
||
- бесплатная **per-member статистика** из счётчиков правил слотов.
|
||
|
||
## Осмотр датапаса
|
||
|
||
```bash
|
||
make ports # порты моста
|
||
make conns # таблица соединений ct
|
||
make trace SRC=192.168.5.13 # ofproto/trace сессии клиент -> VIP
|
||
make trace SRC=10.20.0.10 SEG=p2 # то же для приватного листенера
|
||
make fdb # выученные MAC сегментов и счётчики рассылки
|
||
make neigh # MAC членов пула, резолвленный hcd через ARP ядра
|
||
make flows # перечитать pipeline.sh и перезалить правила
|
||
docker compose exec lb-router lbctl flows 21 # правила конкретной таблицы
|
||
docker compose exec lb-router lbctl status # состояние пула в JSON
|
||
```
|
||
|
||
## Как устроен пайплайн
|
||
|
||
Все правила рендерит [lb/pipeline.sh](lb/pipeline.sh) из `topology.env` и
|
||
применяет атомарным бандлом (`ovs-ofctl --bundle replace-flows`), поэтому
|
||
обновление не создаёт окна с полузалитым пайплайном.
|
||
|
||
| Таблица | Назначение |
|
||
|---|---|
|
||
| 0 | Сегмент по входному порту (`reg0`); обучение MAC (FDB) и adjacency клиентов |
|
||
| 1 | Классификация: ARP, ICMP, листенер, трафик к маршрутизатору, коммутация |
|
||
| 5 | ARP-респондер для адресов узла, VIP и источников проб; прочий ARP — в коммутацию |
|
||
| 6 | ICMP echo-респондер для адресов узла и VIP |
|
||
| 10 | Листенеры: `VIP:80` → `multipath` кладёт номер слота в `reg1`; прочий IP → маршрутизация |
|
||
| 11 | **Таблица слотов**: `reg1` → `reg2` (член пула). Ведёт демон `hcd` |
|
||
| 12 | Применение члена: `ct(commit, nat)`, дальше — таблица 21 (свой сегмент) или 20 (чужой сегмент/публичный) |
|
||
| 15, 16 | Обратный путь L3: `ct(nat)` снимает DNAT, источник снова становится VIP |
|
||
| 20 | Маршрутизация: приоритет = длина префикса, `dec_ttl`, MAC источника |
|
||
| 21 | **Adjacency**: MAC next-hop и выходной порт. Единственное место с MAC членов пула — резолвит `hcd` через ARP ядра (приоритет 100, шаг 5); MAC клиентов — `learn` (приоритет 90) |
|
||
| 24 | Обратная трансляция коммутируемого трафика сегмента |
|
||
| 25 | L2-коммутация сегмента: FDB, broadcast и рассылка неизвестного unicast |
|
||
|
||
Регистры: `reg0` — сегмент (1 — публичный, 2 и 3 — приватные), `reg1` — номер
|
||
слота, `reg2` — идентификатор члена пула.
|
||
|
||
Нумерация таблиц не произвольна: `goto_table` разрешает переход только вперёд,
|
||
поэтому обратный путь (15/16) стоит до общей маршрутизации (20), а коммутация
|
||
(24/25) — после неё: в неё попадают и кадры, прошедшие DNAT в таблице 12.
|
||
|
||
### Два пути внутри одного пула
|
||
|
||
Приватный листенер и публичный делят пул, таблицу слотов и хэш. Различается
|
||
только выдача:
|
||
|
||
- **член в сегменте клиента** — из таблицы 12 сразу в таблицу 21 (минуя 20):
|
||
MAC назначения переписывается, `dec_ttl` не выполняется — клиент и бэкенд
|
||
остаются L2-соседями, ответ приходит к клиенту с исходным TTL. Обратную
|
||
трансляцию делает таблица 24, когда бэкенд отвечает соседу напрямую;
|
||
- **член в другом сегменте либо публичный листенер** — прежний маршрутизируемый
|
||
путь через таблицы 20 → 21 с `dec_ttl` и обратной трансляцией в таблице 15.
|
||
|
||
Оба пути сходятся в одной и той же таблице 21 — MAC члена пула нужен ровно в
|
||
одном месте пайплайна, а не дублируется.
|
||
|
||
Для клиента оба пути неотличимы: ответ приходит от `VIP:80`.
|
||
|
||
Какие приватные листенеры подняты, задаёт `PRIV_LISTENERS` в
|
||
[lb/topology.env](lb/topology.env): пусто — ни одного, `"p2"` — один сегмент,
|
||
`"p2 p3"` — оба.
|
||
|
||
### Чего нет у чисто-OpenFlow узла
|
||
|
||
Ядро контейнера в форвардинге не участвует, поэтому всё, что обычно делает
|
||
сетевой стек, реализовано правилами: ARP-ответы (таблица 5), ответы на ping
|
||
(таблица 6), поиск MAC клиента (действие `learn` в таблице 0 вместо
|
||
ARP-резолвера). ICMP-ошибки (`TTL exceeded`, `fragmentation needed`) на шаге 1
|
||
не генерируются — см. ограничения в [docs/STEP1_SUMMARY.md](docs/STEP1_SUMMARY.md).
|
||
|
||
## Особенности окружения
|
||
|
||
- **Kernel datapath OVS** namespace-aware, поэтому контейнер держит собственный
|
||
`ovs-system` — это настоящий датапас ядра, а не эмуляция. Нужны
|
||
`privileged: true`, проброс `/lib/modules` и загруженный на хосте модуль
|
||
`openvswitch` (`make prereq`, модуль прописывается в автозагрузку).
|
||
- **Публичная сеть объявлена с подсетью `192.168.55.0/24`** — это фиктивный
|
||
пул для Docker IPAM. Реальную `192.168.5.0/24` объявить нельзя: её уже
|
||
занимает macvlan-сеть соседнего стенда `router-functest`, и Docker
|
||
отказывается регистрировать пересекающийся пул. Для L2 это безразлично —
|
||
macvlan включён в `enp3s0`, а адреса стенда живут в OpenFlow.
|
||
- **Шлюзы приватных сетей смещены на `.254`**, чтобы адрес `.1` занял
|
||
OVS-роутер и не конфликтовал с host-бриджем Docker.
|
||
- **Профиль A** (§3.2 дизайна v1): у бэкендов через балансировщик
|
||
маршрутизируются только клиентские префиксы и соседний приватный сегмент,
|
||
а `default` остаётся на шлюзе Docker — собственный исходящий трафик
|
||
бэкендов идёт мимо балансировщика.
|
||
|
||
## Структура
|
||
|
||
```
|
||
docker-compose.yml три сети и три контейнера
|
||
Makefile up / down / flows / verify / health / диагностика
|
||
lb/topology.env адреса, MAC, номера портов, параметры проб и слотов
|
||
lb/pipeline.sh генератор OpenFlow-правил
|
||
lb/apply.sh атомарная заливка правил и запрос слотов у hcd
|
||
lb/entrypoint.sh запуск OVS, сборка моста, порты проб, запуск hcd
|
||
lb/hc-config.sh конфигурация health-check из topology.env
|
||
lb/lbctl.sh осмотр датапаса (ports / flows / health / slots / trace)
|
||
hc/maglev.go раскладка слотов, дайджест, рендер бандла
|
||
hc/main.go пробер, состояния членов пула, применение, API
|
||
backend/main.go web-сервис: имя хоста, дата и время, адрес клиента
|
||
scripts/host-prereq.sh модуль ядра и macvlan-shim
|
||
scripts/verify.sh проверки стенда
|
||
```
|