Шаг 5: динамическое изучение MAC/ARP для бэкендов

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>
This commit is contained in:
ayurishchevandClaude Opus 5 committed 2026-08-19 12:33:48 +03:00
1 parent f34d2edefa
commit fa389f119b
17 files changed
+678 -61

No files matched your search

+5 -1
View File
@@ -4,7 +4,7 @@ DC := docker compose
# отсутствовать в системе.
SUDO := $(shell [ "$$(id -u)" = 0 ] || echo sudo)
.PHONY: help prereq shim shim-down build up down restart attach detach flows verify logs ports health slots drain enable metrics conns trace fdb shell
.PHONY: help prereq shim shim-down build up down restart attach detach flows verify logs ports health slots drain enable metrics conns trace fdb neigh shell
help:
@echo "Стенд hpnn_v2 — OVS: маршрутизация, балансировка, коммутация сегментов"
@@ -27,6 +27,7 @@ help:
@echo " make conns таблица соединений ct"
@echo " make trace SRC=192.168.5.7 [SPORT=40000] [SEG=pub|p2|p3] ofproto/trace"
@echo " make fdb таблица коммутации сегментов (выученные MAC)"
@echo " make neigh MAC членов пула, резолвленный через ARP ядра"
@echo " make logs логи контейнеров"
@echo " make shell shell в контейнере lb-router"
@@ -104,5 +105,8 @@ trace:
fdb:
$(DC) exec -T lb-router lbctl fdb
neigh:
$(DC) exec -T lb-router lbctl neigh
shell:
$(DC) exec lb-router bash
+13 -8
View File
@@ -22,6 +22,7 @@
| 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` для разработки). Что нужно изменить для перевода ноды
@@ -239,6 +240,7 @@ 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
@@ -252,16 +254,16 @@ docker compose exec lb-router lbctl status # состояние пула в
| Таблица | Назначение |
|---|---|
| 0 | Сегмент по входному порту (`reg0`); обучение MAC (FDB) и adjacency |
| 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 | Применение члена: DNAT и выдача — коммутацией либо через маршрутизацию |
| 12 | Применение члена: `ct(commit, nat)`, дальше — таблица 21 (свой сегмент) или 20 (чужой сегмент/публичный) |
| 15, 16 | Обратный путь L3: `ct(nat)` снимает DNAT, источник снова становится VIP |
| 20 | Маршрутизация: приоритет = длина префикса, `dec_ttl`, MAC источника |
| 21 | Adjacency: MAC next-hop и выходной порт |
| 21 | **Adjacency**: MAC next-hop и выходной порт. Единственное место с MAC членов пула — резолвит `hcd` через ARP ядра (приоритет 100, шаг 5); MAC клиентов — `learn` (приоритет 90) |
| 24 | Обратная трансляция коммутируемого трафика сегмента |
| 25 | L2-коммутация сегмента: FDB, broadcast и рассылка неизвестного unicast |
@@ -277,12 +279,15 @@ docker compose exec lb-router lbctl status # состояние пула в
Приватный листенер и публичный делят пул, таблицу слотов и хэш. Различается
только выдача:
- **член в сегменте клиента** — меняется лишь MAC назначения, кадр отдаётся в
коммутацию. `dec_ttl` не выполняется: клиент и бэкенд остаются L2-соседями,
и ответ приходит к клиенту с исходным TTL. Обратную трансляцию делает
таблица 24, когда бэкенд отвечает соседу напрямую;
- **член в сегменте клиента** — из таблицы 12 сразу в таблицу 21 (минуя 20):
MAC назначения переписывается, `dec_ttl` не выполняется — клиент и бэкенд
остаются L2-соседями, ответ приходит к клиенту с исходным TTL. Обратную
трансляцию делает таблица 24, когда бэкенд отвечает соседу напрямую;
- **член в другом сегменте либо публичный листенер** — прежний маршрутизируемый
путь через таблицы 20/21 с `dec_ttl` и обратной трансляцией в таблице 15.
путь через таблицы 20 → 21 с `dec_ttl` и обратной трансляцией в таблице 15.
Оба пути сходятся в одной и той же таблице 21 — MAC члена пула нужен ровно в
одном месте пайплайна, а не дублируется.
Для клиента оба пути неотличимы: ответ приходит от `VIP:80`.
+76
View File
@@ -666,6 +666,78 @@ 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 (которого может не случиться никогда).
---
## Итоговый чек-лист
| # | Проверка | Результат |
@@ -694,6 +766,10 @@ docker compose exec lb-router /opt/lb/apply.sh
| J | Ответ от соседа по сегменту приходит с неуменьшенным TTL | ☐ |
| J | Трафик между ВМ сегмента не блокирован | ☐ |
| J | `PRIV_LISTENERS` выключает листенер, не трогая связность | ☐ |
| K | MAC членов пула резолвлен и совпадает с реальным MAC интерфейса | ☐ |
| K | Таблица 21 содержит правило приоритета 100 с этим MAC | ☐ |
| K | Смена MAC «железа» обнаружена без перезапуска стенда | ☐ |
| K | Таблица 21 переживает `make flows` | ☐ |
---
+19 -10
View File
@@ -1,11 +1,11 @@
# Потоки данных: балансировка приватного трафика
**Дата:** 2026-08-17
**Дата:** 2026-08-18 (обновлено после шага 5 — динамический MAC/ARP)
**Область:** путь пакета от клиента внутри приватного сегмента на приватный
листенер (`10.20.0.100:80` или `10.30.0.100:80`) и обратно.
**Смежный документ:** [PUBLIC_LB_DATAFLOW.md](PUBLIC_LB_DATAFLOW.md) —
балансировка публичного трафика.
**Источник:** `lb/pipeline.sh`, `lb/topology.env`, [STEP3_SUMMARY.md](STEP3_SUMMARY.md).
**Источник:** `lb/pipeline.sh`, `lb/topology.env`, [STEP3_SUMMARY.md](STEP3_SUMMARY.md), [STEP5_SUMMARY.md](STEP5_SUMMARY.md).
Отличие от публичного случая: клиент может стоять **в одном сегменте с
бэкендами**. Тогда ответ бэкенда уходит клиенту по L2 напрямую, минуя
@@ -70,11 +70,12 @@ flowchart TD
T5 -->|"не наш адрес"| T25
T10{"<b>10</b> листенер<br/>tcp VIP:80"} --> T11["<b>11</b> таблица слотов<br/>reg1 → reg2 (общая<br/>с публичным листенером)"]
T11 -->|"пул пуст"| DROPF(["drop — fail-close"])
T11 --> T12{"<b>12</b> применение члена"}
T11 --> T12{"<b>12</b> применение члена<br/>ct(commit,nat)"}
T12 -->|"<b>член в сегменте клиента</b><br/>mod_dl_dst + ct(commit,nat)<br/><b>без dec_ttl</b>"| T25
T12 -->|"член в другом сегменте<br/>ct(commit,nat)"| T20["<b>20/21</b> маршрутизация<br/>dec_ttl, adjacency"]
T20 --> OUTR(["output: порт члена<br/>в другом сегменте"])
T12 -->|"<b>член в сегменте клиента</b><br/><b>без dec_ttl</b>, минуя таблицу 20"| T21
T12 -->|"член в другом сегменте"| T20["<b>20</b> маршрутизация<br/>dec_ttl"]
T20 --> T21["<b>21</b> adjacency<br/>MAC члена + порт —<br/>резолвит hcd через ARP<br/>ядра (шаг 5), единое<br/>место для обоих путей"]
T21 --> OUTR(["output: порт члена"])
T15 --> T16["<b>16</b> пост-ct"] --> T20
T24{"<b>24</b> обратная трансляция<br/>сегмента"}
@@ -87,6 +88,11 @@ flowchart TD
class DROPF d
```
Таблица 21 — единственное место, где применяется MAC следующего перехода, для
обоих путей сразу: и для члена в сегменте клиента (сюда ведёт таблица 12
напрямую, без `dec_ttl`), и для члена в другом сегменте (сюда приходят из
таблицы 20, уже после `dec_ttl`). MAC члена не дублируется между таблицами.
## 3. Основной поток: клиент и член пула в одном сегменте
Тот самый случай, ради которого сегмент переведён на порты OVS.
@@ -105,8 +111,9 @@ sequenceDiagram
Note over OF: t0: reg0=2, обучение FDB и adjacency
Note over OF: t1: dl_dst = MAC шлюза, nw_dst = VIP → листенер
Note over OF: t10 → t11: слот → член пула
Note over OF: t12: mod_dl_dst = MAC be1,<br/>ct(commit, nat(dst=10.20.0.2:8080))<br/><b>dec_ttl не выполняется</b>
OF->>B: t25: FDB → порт be1
Note over OF: t12: ct(commit, nat(dst=10.20.0.2:8080))<br/>→ t21 напрямую, минуя t20
Note over OF: t21: mod_dl_dst = MAC be1 (резолвлен hcd<br/>через ARP ядра, шаг 5), output порт be1<br/><b>dec_ttl не выполнялся</b>
OF->>B: пакет в порт be1
Note over B: client=10.20.0.10 — исходный адрес клиента<br/>served=10.20.0.2:8080 — трансляция 80 → 8080
B->>OF: ответ src=10.20.0.2:8080 → 10.20.0.10,<br/>dst MAC = MAC клиента (сосед по L2)
Note over OF: t1: dl_dst ≠ MAC шлюза → t24
@@ -181,8 +188,9 @@ flowchart TD
| 5 | `arp_tpa` = VIP, шлюз или `.253` | ответ `IN_PORT` | MAC-ом порта сегмента |
| 5 | прочий ARP | → t25 | ВМ резолвят друг друга сами |
| 10 | `tcp`, `nw_dst` = VIP сегмента | `multipath` → t11 | тот же `pool_id` и хэш, что у публичного |
| 12 | `reg0` = сегмент члена | `mod_dl_dst` + `ct(commit,nat)` → t25 | **без `dec_ttl`** |
| 12 | `reg0` = сегмент члена | `ct(commit,nat)` → t21 напрямую | **без `dec_ttl`**, минуя t20 |
| 12 | прочее | `ct(commit,nat)` → t20 | член из другого сегмента |
| 21 | `nw_dst` = член пула | prio 100, `mod_dl_dst` + `output` | MAC резолвит `hcd` через ARP ядра (шаг 5) — единое место для обоих путей |
| 24 | `nw_src` = член, `tp_src` = 8080 | `ct(nat)` → t25 | снимает DNAT с ответа соседу |
| 24 | prio 0 | → t25 | обычный трафик проходит нетронутым |
| 25 | `eth_dst` из FDB | `output` | записи `learn`, `idle_timeout` 300 с |
@@ -203,7 +211,8 @@ flowchart TD
```bash
make trace SRC=10.20.0.10 SPORT=40000 SEG=p2 # путь сессии по таблицам
make fdb # выученные MAC и счётчики рассылки
make fdb # выученные MAC клиентов и счётчики рассылки
make neigh # MAC членов пула, резолвленный hcd через ARP
make ports # подключены ли ВМ сегментов
docker compose exec lb-router lbctl flows 24 # счётчики обратной трансляции
docker compose exec cli2 curl -s http://10.20.0.100/
+4 -3
View File
@@ -1,11 +1,11 @@
# Потоки данных: балансировка публичного трафика
**Дата:** 2026-08-17
**Дата:** 2026-08-18 (обновлено после шага 5 — динамический MAC/ARP)
**Область:** путь пакета от клиента из `192.168.5.0/24` на публичный листенер
`192.168.5.21:80` и обратно.
**Смежный документ:** [PRIVATE_LB_DATAFLOW.md](PRIVATE_LB_DATAFLOW.md) —
балансировка внутри приватного сегмента.
**Источник:** `lb/pipeline.sh`, `lb/topology.env`.
**Источник:** `lb/pipeline.sh`, `lb/topology.env`, [STEP5_SUMMARY.md](STEP5_SUMMARY.md).
Публичная балансировка целиком **маршрутизируемая**: клиент и бэкенды лежат в
разных сегментах, узел выступает L3-хопом в обе стороны, TTL уменьшается на
@@ -156,7 +156,7 @@ flowchart TD
| 12 | `reg2` = член | `ct(commit, nat(dst=member:8080))` → t20 | без SNAT |
| 15 | `tcp` с порта сегмента, адресован шлюзу | `ct(nat)` → t16 | обратная трансляция |
| 20 | `/24` префиксы | `dec_ttl` + `mod_dl_src` | LPM через приоритет правила |
| 21 | `nw_dst` = член пула | prio 100, `mod_dl_dst` + `output` | статические MAC из `topology.env` |
| 21 | `nw_dst` = член пула | prio 100, `mod_dl_dst` + `output` | MAC резолвит `hcd` через ARP ядра (шаг 5), не `topology.env` |
| 21 | `nw_dst` = клиент | prio 90, из `learn` | `hard_timeout` 300 с |
## 7. Где поток обрывается
@@ -173,6 +173,7 @@ flowchart TD
```bash
make trace SRC=192.168.5.13 SPORT=41234 # путь сессии по таблицам
make neigh # MAC членов, резолвленный hcd через ARP
make slots # раскладка и per-member счётчики
make conns # записи ct
docker compose exec lb-router lbctl flows 21
+3 -2
View File
@@ -83,8 +83,9 @@
выводит его из группы. У приложения есть `/healthz` как задел.
- **Пул и листенер статические** — заданы в `topology.env`, control plane и
OVSDB-схемы из §6 дизайна v1 нет.
- **MAC бэкендов прописаны статически.** ARP-резолвер next-hop отсутствует;
MAC зафиксированы в `docker-compose.yml`.
- ~~MAC бэкендов прописаны статически. ARP-резолвер next-hop отсутствует~~ —
снято на шаге 5: `hcd` резолвит MAC членов пула через обычный ARP ядра, см.
[STEP5_SUMMARY.md](STEP5_SUMMARY.md).
- **Только TCP и только IPv4.** UDP- и L3-листенеров нет.
## Задел на шаг 2
@@ -189,6 +189,15 @@ packet-in/packet-out по своей neigh-таблице (§4.3, §8.2), но
интеграция с тем, как EVPN-фабрика публикует MAC-привязки, чтобы control plane
HPNN получал их не вручную, а из фабрики.
> **Обновление (шаг 5, [STEP5_SUMMARY.md](STEP5_SUMMARY.md)):** первая часть
> этого пробела закрыта — `hcd` резолвит MAC членов пула динамически через
> обычный ARP ядра, статических `BE*_MAC` в таблице 21 больше нет. Это не
> решает вопрос EVPN-фабрики целиком: `hcd` резолвит MAC в пределах L2-домена,
> видимого узлу напрямую (те же hcif-порты, что и для health-проб), а не через
> интеграцию с EVPN Type-2. Если фабрика доставляет сегмент до узла как один
> L2-домен (что и предполагает текущая архитектура), тот же ARP-путь работает
> без изменений; отдельная интеграция с control plane EVPN остаётся вне рамок.
## 9. Итоговая сводка условий и рисков
| # | Пункт | Тип | Статус |
+147
View File
@@ -0,0 +1,147 @@
# Шаг 5 — план внедрения: динамическое изучение MAC/ARP для бэкендов
**Дата:** 2026-08-18
**Предыдущий шаг:** [STEP3_SUMMARY.md](STEP3_SUMMARY.md) (реализован).
**Не путать с шагом 4** ([STEP4_PUBLIC_STATELESS_ASSESSMENT.md](STEP4_PUBLIC_STATELESS_ASSESSMENT.md))
— это только оценка перехода публичного пути на схему без conntrack, ещё не
реализована; данный шаг с ней не пересекается.
**Итоги:** [STEP5_SUMMARY.md](STEP5_SUMMARY.md)
## Задача
Сейчас MAC каждого члена пула — статическая константа (`BE1_MAC`…`BE4_MAC` в
`lb/topology.env`), продублированная в четырёх независимых местах: правило
`mod_dl_dst` в таблице 21 (routed-путь), инлайновый `mod_dl_dst` в таблице 12
(same-segment/коммутируемый путь шага 3), permanent-запись `ip neigh` в ядре
узла (`lb/entrypoint.sh`, для health-проб) и реальный MAC интерфейса ВМ
(`scripts/attach-segment.sh`). Все четыре обязаны совпадать вручную.
В реальном окружении MAC бэкенда заранее не известен: топология сети, в
отличие от стенда, не описывается статически. Уже задокументированный пробел
(`STEP1_SUMMARY.md`, известные ограничения: «MAC бэкендов прописаны статически.
ARP-резолвер next-hop отсутствует»), отдельно всплывший в оценке шага 4 (раздел
8, доставка до физического сервера в EVPN-фабрике).
Нужен компонент, изучающий MAC членов пула динамически, без предварительного
описания в конфигурации.
**Объём:** изучаются MAC только для адресов, уже известных из конфигурации
(member IP объявлен в `hc-config.sh`); обнаружение новых, необъявленных хостов
— вне рамок (отдельная задача control plane, уже отложена в `MULTITENANCY.md`).
Логика живёт в демоне `hcd` — расширение существующего единственного
control-plane процесса на ноде.
## Ключевая находка
Internal-порты `hcif-p2`/`hcif-p3` — единственные адреса узла, живущие в ядре
Linux, — уже настоящие L3-интерфейсы **в том же сегменте**, что и бэкенды.
Ядро уже способно резолвить их MAC обычным ARP; единственное препятствие —
статические `ip neigh ... nud permanent` записи в `lb/entrypoint.sh:99`,
которые ARP для этих адресов отключают навсегда. Подтверждено на живом стенде:
`ip -json neigh show` внутри `lb-router` сейчас отдаёт четыре записи с
`"state":["PERMANENT"]` — ровно то, что предстоит заменить на реальный ARP.
Дополнительно: `segment_ingress()`/`l3_learn()` в `lb/pipeline.sh` уже заливает
действием `learn` записи приоритета 90 в таблицу 21 для любого IP-трафика с
портов сегмента, включая ответы бэкендов на health-пробы — на живом стенде эти
записи присутствуют в `dump-flows table=21`, но перекрыты статическими
правилами приоритета 100. Задача не строит резолвер с нуля, а: (1) снимает
блокировку ARP ядра, (2) делает `hcd` явным и наблюдаемым источником MAC вместо
неявного поведения `learn`, (3) убирает дублирование MAC между таблицами 12 и
21.
## Разделение ответственности
| Кто | Адрес | Механизм |
|---|---|---|
| Клиенты | заранее неизвестен, произвольный | как сейчас — `learn` (таблица 21, приоритет 90), без изменений |
| Члены пула | известен из конфигурации, MAC — нет | новое: `hcd` резолвит через ядро (ARP на hcif-портах), заливает таблицу 21 приоритетом 100 |
## Изменения по файлам
### `lb/entrypoint.sh`
В `hc_port()` убрать `ip neigh replace ... nud permanent` для адресов членов
пула (строка 99, вызовы 106-109). MAC самого internal-порта (строка 93)
остаётся статичным — собственный адрес узла, не next-hop.
### `hc/neigh.go` (новый файл)
Работает на тикере health-проб (`cfg.interval`), без отдельной горутины:
- по `Source` члена и новому полю `iface` выполняет
`ip -json neigh show dev <iface> to <member_ip>`;
- MAC резолвлен при состоянии `REACHABLE`/`STALE`/`DELAY`/`PROBE`/`PERMANENT`;
- при изменении MAC — точечный бандл `ovs-ofctl bundle`: `flow delete
table=21,ip,nw_dst=<ip>` + `flow add table=21,priority=100,ip,nw_dst=<ip>
actions=mod_dl_dst:<mac>,output:<port>`;
- API: поле в `/status`, команда `lbctl neigh`.
### `lb/pipeline.sh`
- Убрать статические `table=21,priority=100,ip,nw_dst=$BE{1..4}_IP
actions=mod_dl_dst:$BE{n}_MAC,output:...` (строки 333-336). Записи для
`HC2_IP`/`HC3_IP` остаются статичными.
- Таблица 12, ветка same-segment (приоритет 200): убрать инлайновый
`mod_dl_dst:$mmac` (строка 284), `ct(commit,...)` направить в `table=21`
вместо `table=25`. Таблица 21 не декрементирует TTL — свойство «TTL не
уменьшается для соседей по сегменту» сохраняется, дублирование MAC между
таблицами уходит.
### `lb/topology.env`, `lb/hc-config.sh`
`BE1_MAC`…`BE4_MAC` перестают быть обязательными. Добавляется поле `iface` на
член пула.
### `lb/lbctl.sh`
Команда `neigh`: «член / адрес / MAC / состояние / возраст».
### `scripts/verify.sh`, `docs/MANUAL_TEST_PLAN.md`
- Таблица 21 заполняется динамически в течение нескольких секунд после старта.
- `lbctl neigh` показывает MAC, совпадающий с реальным MAC интерфейса ВМ.
- Смена MAC у `be1` обнаруживается `hcd` без разрыва доставки.
- Регрессия: все существующие проверки (оба листенера, транзит, health-check,
fail-close, дренаж, идемпотентность `apply.sh`) без изменений в поведении.
- `lbctl trace` same-segment пути — обновить ожидаемую цепочку (`12 → 21`
вместо `12 → 25`).
### Документация
`docs/PUBLIC_LB_DATAFLOW.md`, `docs/PRIVATE_LB_DATAFLOW.md` — таблица 21:
«статические MAC» → «резолвятся `hcd` через ядро». Снять пункт «MAC бэкендов
прописаны статически» из ограничений `STEP1_SUMMARY.md`. `README.md`.
## Порядок работ
1. `docs/STEP5_IMPLEMENTATION_PLAN.md` (этот документ).
2. ~~Спайк `ip -json neigh`~~ — выполнено, подтверждено на живом стенде.
3. `lb/entrypoint.sh` — снять статические `ip neigh permanent`; проверить ARP
вручную.
4. `hc/neigh.go` + правки `hc/main.go`.
5. `lb/pipeline.sh` — таблица 21 как единственный источник MAC.
6. `lb/hc-config.sh`, `lb/topology.env`.
7. `lb/lbctl.sh` — команда `neigh`.
8. Ручная проверка, затем `scripts/verify.sh` и `MANUAL_TEST_PLAN.md`.
9. `docs/STEP5_SUMMARY.md`, README, диаграммы потоков.
## Критерии приёмки
- `lbctl neigh` показывает реальный MAC каждого члена, совпадающий с
`ip link show eth0` внутри соответствующего контейнера;
- в `topology.env` нет обязательных `BE*_MAC`;
- смена MAC у бэкенда обнаруживается и трафик продолжает доставляться без
ручного вмешательства;
- все существующие проверки (`make verify`) проходят без изменений в
наблюдаемом поведении.
## Риски
- **Окно на холодном старте** таблицы 21 для члена, пока `hcd` не сделал
первый резолв — по порядку величины совпадает с существующим fail-close
таблицы слотов, новой категории риска не вводит.
- **Обнаружение новых, необъявленных хостов вне объёма** — осознанно отложено
к control plane (`MULTITENANCY.md`).
- **Изменение цепочки таблиц same-segment пути** — требует правки ожидаемых
трасс в документации.
+127
View File
@@ -0,0 +1,127 @@
# Шаг 5 — итоги: динамическое изучение MAC/ARP для бэкендов
**Дата:** 2026-08-18
**Статус:** выполнено, проверено после холодного перезапуска (94 из 94 проверок)
**План:** [STEP5_IMPLEMENTATION_PLAN.md](STEP5_IMPLEMENTATION_PLAN.md)
**Предыдущий шаг:** [STEP3_SUMMARY.md](STEP3_SUMMARY.md)
## Что сделано
MAC каждого члена пула больше не статическая константа из `topology.env` — он
резолвится динамически демоном `hcd` через обычный ARP ядра.
- **`lb/entrypoint.sh`** — убраны permanent-записи `ip neigh` для адресов
членов пула. `hcif-p2`/`hcif-p3` — настоящие L3-интерфейсы ядра в тех же
сегментах, что и бэкенды, поэтому ядро само резолвит их MAC обычным ARP:
первая health-проба порождает ARP-запрос через `br-lb`, реальный бэкенд
отвечает, ядро запоминает.
- **`hc/neigh.go`** (новый) — на том же тикере, что и health-пробы, читает
`ip -json neigh show` для каждого члена по его интерфейсу (`hcif-p2`/
`hcif-p3`, новое поле `iface` в конфиге) и при изменении MAC атомарно
заливает точечный бандл `ovs-ofctl bundle` в таблицу 21 — не трогая записи
остальных членов.
- **Таблица 21 стала единственным источником MAC** для обоих путей —
маршрутизируемого и коммутируемого (шаг 3). Таблица 12 (same-segment DNAT)
больше не пишет MAC инлайново: `ct(commit,...)` теперь ведёт напрямую в
таблицу 21 вместо таблицы 25, минуя таблицу 20 (TTL по-прежнему не
уменьшается для соседей по сегменту).
- **`lbctl neigh` / `make neigh`** — таблица «член / адрес / интерфейс / MAC /
резолвлен / с момента», плюс сырые правила таблицы 21.
- **`BE1_MAC`…`BE4_MAC` в `topology.env`** остались только как «заводской» MAC,
который `attach-segment.sh` назначает интерфейсу ВМ (эмуляция того, что
реально прошито в NIC) — в OpenFlow эти переменные больше не попадают.
## Результаты проверок
| Проверка | Результат |
|---|---|
| `lbctl neigh` показывает реальный MAC каждого члена | совпадает с `ip link show eth0` внутри контейнера |
| Таблица 21, приоритет 100 | правило на каждого члена, `actions` содержат резолвленный MAC |
| Регрессия (все шаги 1–3) | 86 существующих проверок без изменений в поведении |
| **Смена MAC «железа»** | `ip link set eth0 address ...` у be1 → обнаружено `hcd`, таблица 21 обновлена, доставка восстановлена без вмешательства |
| Идемпотентность `apply.sh` | таблица 21 полностью восстанавливается после `replace-flows` (см. отклонения) |
## Ключевая находка (уже была на момент планирования)
`segment_ingress()`/`l3_learn()` и раньше заливала действием `learn` записи
приоритета 90 в таблицу 21 для любого IP-трафика с портов сегмента, включая
ответы бэкендов на health-пробы — но они были перекрыты статическими
правилами приоритета 100. Шаг 5 не строил резолвер с нуля: снял то, что
блокировало естественный ARP ядра (`nud permanent`), и сделал `hcd` явным,
наблюдаемым источником приоритета 100 вместо неявного поведения `learn`.
Разделение осталось ровно таким, как задумано: `learn` (приоритет 90)
по-прежнему единственный механизм для клиентов — их адрес заранее не известен
и не enumerable; `hcd` (приоритет 100) — для членов пула, чей адрес известен
из конфигурации.
## Отклонение от плана: баг с `force`
Первая реализация не заливала таблицу 21 заново после `apply.sh`
(`replace-flows` стирает весь пайплайн). Причина: `syncNeighbors()` сравнивал
резолвленный MAC с MAC **в памяти демона** — после `replace-flows` датапас
пуст, но в памяти MAC не изменился, поэтому условие «MAC изменился» не
срабатывало и точечный бандл не переливался. Обнаружено тестом идемпотентности
(`verify.sh`, блок 14 → новая проверка блока 17) — именно то, для чего этот
тест и существует.
Исправление зеркально `force` у `Agent.apply()`: `syncNeighbors(force bool)`,
`/reapply` теперь вызывает `a.syncNeighbors(true)` вслед за `a.apply(true)`.
Это тот же класс проблемы, что уже был явно предусмотрен для таблицы слотов
(`force заставляет залить даже неизменившуюся`, `hc/main.go`), просто
не был перенесён на новый подкомпонент при первой реализации.
## Наблюдение: реальная скорость обнаружения смены MAC
Проверка на живом стенде: смена MAC интерфейса `be1` обнаружена ядром и
отражена `hcd` в датапасе примерно через **2 минуты**, а не за один-два цикла
тикера (2 с), как можно было бы предположить из интервала опроса `hcd`.
Причина — `hcd` опрашивает **быстро** (каждые 2 с), но может отразить только
то, что уже узнало **ядро**, а стандартное старение Linux ARP (`REACHABLE` →
`STALE` → `DELAY` → `PROBE` → `FAILED`/переразрешение) по умолчанию рассчитано
на десятки секунд на первом же переходе (`base_reachable_time` ≈ 30 с) и суммарно
может занимать порядка минуты и больше. Частота опроса `hcd` не сокращает
это время — она лишь гарантирует, что новый MAC попадёт в датапас на первом же
тике **после** того, как его узнало ядро. Это честная характеристика решения,
а не дефект: тот же порядок величины, что и в реальных сетях с обычным ARP.
## Отклонения от плана
1. **`nud permanent` для members убраны полностью**, без опционального
ручного `mac`-переопределения в JSON — как и намечалось («не делать этого
на первой итерации»). Подтверждено достаточным: cold-start окно закрывается
первой же успешной пробой.
2. **Обнаружен и исправлен баг с `force`**, не предусмотренный явно в плане
(план предполагал его как риск в общих чертах — «зависимость от `ip -json
neigh`», но не called out force-реапплай конкретно). Добавлена
регрессионная проверка (`verify.sh`, блок 17) именно на этот случай.
3. **Проверка TTL в блоке 15 (`verify.sh`) сделана устойчивее**: захват `-c 4`
на 12 запросах оказался статистически хрупким — при 4 членах пула 12
запросов иногда не попадали ни разу на соседа по сегменту, тест ложно
падал. Увеличено до `-c 16` на 32 запросах; на двух прогонах подряд
стабильно зелёный. Не связано с шагом 5 по существу (тест существовал с
шага 3), но исправлено в этой же сессии, раз затронут `verify.sh`.
## Известные ограничения
- **Обнаружение новых, необъявленных хостов вне объёма** — резолвится MAC
только для адресов, уже присутствующих в `hc-config.sh`. Обнаружение самих
адресов — отдельная, более крупная задача control plane (`MULTITENANCY.md`).
- **Скорость обнаружения смены MAC ограничена стандартным старением ARP ядра**
(см. наблюдение выше) — величина порядка минуты, не секунд.
- **Окно на холодном старте**: пока `hcd` не сделал первый резолв, трафик к
члену зависит от того, успел ли сработать `learn` (приоритет 90); в худшем
случае — `priority=0 drop`. По порядку величины совпадает с fail-close
таблицы слотов, новой категории риска не вводит.
- **`ip -json neigh`** — версия `iproute2` в образе (`6.1.0`) поддерживает флаг;
при смене базового образа стоит переподтвердить.
## Задел на следующие шаги
1. Тот же приём (демон резолвит адрес через ядро и заливает точечным бандлом)
применим к обнаружению самих хостов, если понадобится снять ограничение
«только уже известные адреса» — потребует ARP-сканирования сегмента и
решения, как новый хост попадает в конфигурацию пула.
2. Control plane (`Load_Balancer → Listener → Pool → Member` в OVSDB,
`MULTITENANCY.md`) естественно принимает поле `iface` как часть модели
`Member`, а не специфику `hc-config.sh`.
+46 -4
View File
@@ -35,6 +35,7 @@ type Config struct {
Slots int `json:"slots"`
SlotTable int `json:"slot_table"`
DNATTable int `json:"dnat_table"`
AdjTable int `json:"adj_table"` // таблица 21 — next-hop MAC членов (шаг 5)
Probe string `json:"probe"`
HTTPPath string `json:"http_path"`
Interval string `json:"interval"`
@@ -56,6 +57,8 @@ type Member struct {
Port int `json:"port"`
Weight int `json:"weight"`
Source string `json:"source"`
Iface string `json:"iface"` // hcif-порт, на котором резолвится MAC (шаг 5)
OFPort int `json:"ofport"` // выходной порт члена в OpenFlow
mu sync.Mutex
state string // up | down
@@ -68,6 +71,8 @@ type Member struct {
probes uint64
failures uint64
transitions uint64
mac string // резолвлен hcd через neigh-таблицу ядра, пусто = не резолвлен
macSince time.Time
}
func (m *Member) Key() string { return fmt.Sprintf("%s:%d", m.Address, m.Port) }
@@ -101,6 +106,9 @@ type memberView struct {
Transitions uint64 `json:"transitions"`
Slots int `json:"slots"`
Source string `json:"probe_source"`
Iface string `json:"iface"`
MAC string `json:"mac"`
MACSince string `json:"mac_since,omitempty"`
}
// --- пробы -------------------------------------------------------------------
@@ -290,11 +298,14 @@ func (a *Agent) status() []memberView {
Active: m.state == "up" && m.admin == "enabled",
LastError: m.lastErr, LatencyMS: m.lastLatency.Milliseconds(),
Probes: m.probes, Failures: m.failures, Transitions: m.transitions,
Source: m.Source,
Source: m.Source, Iface: m.Iface, MAC: m.mac,
}
if !m.lastChange.IsZero() {
v.Since = m.lastChange.Format(time.RFC3339)
}
if !m.macSince.IsZero() {
v.MACSince = m.macSince.Format(time.RFC3339)
}
m.mu.Unlock()
v.Slots = a.slotsOf(m.ID)
out = append(out, v)
@@ -351,6 +362,28 @@ func (a *Agent) serve() {
}
})
// Резолв MAC членов пула (шаг 5): человекочитаемая таблица для lbctl neigh.
mux.HandleFunc("/neigh.txt", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "%-6s %-17s %-10s %-17s %-8s %s\n",
"ЧЛЕН", "АДРЕС", "ИНТЕРФЕЙС", "MAC", "РЕЗОЛВЛЕН", "С МОМЕНТА")
for _, v := range a.status() {
mac := v.MAC
resolved := "нет"
if mac == "" {
mac = "-"
} else {
resolved = "да"
}
since := v.MACSince
if since == "" {
since = "-"
}
fmt.Fprintf(w, "%-6s %-17s %-10s %-17s %-8s %s\n",
v.Name, v.Address, v.Iface, mac, resolved, since)
}
})
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
applies, errs := a.applies, a.errors
@@ -395,20 +428,24 @@ func (a *Agent) serve() {
mux.HandleFunc("/drain", func(w http.ResponseWriter, r *http.Request) { a.setAdmin(w, r, "drain") })
mux.HandleFunc("/enable", func(w http.ResponseWriter, r *http.Request) { a.setAdmin(w, r, "enabled") })
// Вызывается из apply.sh: перезаливка всего пайплайна стирает таблицу
// слотов, поэтому её нужно восстановить в актуальном составе.
// Вызывается из apply.sh: перезаливка всего пайплайна (replace-flows)
// стирает и таблицу слотов, и правила adjacency (таблица 21) — обе нужно
// восстановить в актуальном составе. force=true у обеих: MAC/раскладка в
// памяти демона могли не измениться, но датапас сброшен и нуждается в
// повторной заливке уже известных значений.
mux.HandleFunc("/reapply", func(w http.ResponseWriter, r *http.Request) {
if err := a.apply(true); err != nil {
http.Error(w, err.Error()+"\n", http.StatusInternalServerError)
return
}
a.syncNeighbors(true)
a.mu.Lock()
digest := a.digest
a.mu.Unlock()
fmt.Fprintf(w, "таблица слотов перезалита, дайджест %s\n", digest)
})
log.Printf("API на http://%s (/status, /metrics, /drain, /enable, /reapply)", a.cfg.API)
log.Printf("API на http://%s (/status, /neigh.txt, /metrics, /drain, /enable, /reapply)", a.cfg.API)
if err := http.ListenAndServe(a.cfg.API, mux); err != nil {
log.Fatalf("API: %v", err)
}
@@ -492,6 +529,11 @@ func main() {
}
wg.Wait()
// Резолв next-hop MAC членов пула — на том же тикере, что и пробы:
// именно проба вызывает первый исходящий пакет с hcif-порта и тем
// самым запускает ARP ядра для ещё не резолвленных адресов.
agent.syncNeighbors(false)
for _, c := range changed {
if c {
if err := agent.apply(false); err != nil {
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"time"
)
// Резолв MAC членов пула через neigh-таблицу ядра — шаг 5.
//
// Адреса членов известны из конфигурации заранее, их MAC — нет (в реальном
// окружении сервер ещё не подключён, NIC может замениться). hcif-порты —
// настоящие L3-интерфейсы ядра в том же сегменте, что и члены пула, поэтому
// ядро само резолвит их MAC обычным ARP (entrypoint.sh больше не подсовывает
// статические permanent-записи). hcd лишь читает то, что уже узнало ядро, и
// отражает это в единственном месте датапаса, где применяется MAC следующего
// перехода, — в таблице 21 (см. pipeline.sh).
//
// Для клиентов (адрес заранее не известен и не enumerable) резолв остаётся
// пассивным действием learn в самом OpenFlow — здесь не участвует.
// neighEntry — одна запись `ip -json neigh show`.
type neighEntry struct {
Dst string `json:"dst"`
Dev string `json:"dev"`
Lladdr string `json:"lladdr"`
State []string `json:"state"`
}
// resolvedStates — состояния ARP, при которых MAC считается годным к
// использованию. FAILED и INCOMPLETE (или отсутствие записи) означают, что
// адрес пока не резолвлен.
var resolvedStates = map[string]bool{
"REACHABLE": true,
"STALE": true,
"DELAY": true,
"PROBE": true,
"PERMANENT": true,
}
// resolveMAC возвращает MAC адреса ip на интерфейсе iface, если ядро его уже
// резолвило. Пустая строка — адрес пока не резолвлен либо недостижим.
func resolveMAC(iface, ip string) (string, error) {
out, err := exec.Command("ip", "-json", "neigh", "show", "dev", iface, "to", ip).Output()
if err != nil {
return "", err
}
var entries []neighEntry
if err := json.Unmarshal(out, &entries); err != nil {
return "", fmt.Errorf("разбор ip -json neigh: %w", err)
}
for _, e := range entries {
if e.Lladdr == "" {
continue
}
for _, s := range e.State {
if resolvedStates[s] {
return e.Lladdr, nil
}
}
}
return "", nil
}
// syncNeighbors проходит по всем членам, резолвит их MAC через ядро и для
// каждого изменения атомарно обновляет его правило в таблице adjacency —
// точечным бандлом, не трогая записи остальных членов. Вызывается на том же
// тикере, что и health-пробы.
//
// force заливает правило заново, даже если MAC не изменился с прошлого раза:
// нужно после apply.sh (replace-flows стирает весь пайплайн, включая уже
// залитые сюда правила, а resolveMAC() в следующий раз вернёт тот же MAC и
// без force заливка не повторится). Тот же приём, что у Agent.apply(force).
func (a *Agent) syncNeighbors(force bool) {
for _, m := range a.cfg.Members {
mac, err := resolveMAC(m.Iface, m.Address)
if err != nil {
log.Printf("neigh: %s (%s@%s): %v", m.Name, m.Address, m.Iface, err)
continue
}
m.mu.Lock()
changed := force || mac != m.mac
if mac != m.mac {
m.macSince = time.Now()
}
m.mac = mac
m.mu.Unlock()
if !changed {
continue
}
if mac == "" {
log.Printf("neigh: %s (%s) MAC утерян — снимаю запись adjacency", m.Name, m.Address)
if err := a.applyNeigh(m); err != nil {
log.Printf("neigh: %s: ошибка снятия записи: %v", m.Name, err)
}
continue
}
log.Printf("neigh: %s (%s@%s) -> %s", m.Name, m.Address, m.Iface, mac)
if err := a.applyNeigh(m); err != nil {
log.Printf("neigh: %s: ошибка применения: %v", m.Name, err)
}
}
}
// applyNeigh заливает точечный бандл для ОДНОГО члена: снимает прежнее
// правило adjacency (если было) и, если MAC резолвлен, ставит новое. Формат
// файла бандла — тот же, что и для таблицы слотов (FlowBundle в maglev.go).
func (a *Agent) applyNeigh(m *Member) error {
m.mu.Lock()
mac := m.mac
m.mu.Unlock()
table := a.cfg.AdjTable
out := fmt.Sprintf("flow delete table=%d,ip,nw_dst=%s\n", table, m.Address)
if mac != "" {
out += fmt.Sprintf("flow add table=%d,priority=100,ip,nw_dst=%s actions=mod_dl_dst:%s,output:%d\n",
table, m.Address, mac, m.OFPort)
}
path := fmt.Sprintf("/var/run/openvswitch/neigh-%s.bundle", m.Name)
if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
return err
}
res, err := exec.Command("ovs-ofctl", "-O", a.cfg.OFVersion, "bundle", a.cfg.Bridge, path).CombinedOutput()
if err != nil {
return fmt.Errorf("ovs-ofctl bundle: %v: %s", err, res)
}
return nil
}
+10 -15
View File
@@ -82,31 +82,26 @@ attach_port "$P3_MAC" "$P3_IFNAME" "$P3_OFPORT"
# --- 4a. Порты-источники health-проб -----------------------------------------
# Единственные адреса, которые узел держит в ядре: с них уходят пробы (§7.1
# дизайна v1 — источник проб должен быть уникальным адресом узла, не VIP).
# MAC бэкенда прописывается статически: ARP-резолвер узлу не нужен, MAC членов
# пула и так зафиксированы в docker-compose.yml.
#
# MAC членов пула здесь больше НЕ прописывается статически (шаг 5): hcif-порты
# — настоящие L3-интерфейсы ядра в том же сегменте, что и бэкенды, и ядро само
# резолвит их MAC обычным ARP. Первая проба после старта чуть медленнее (кадр
# ARP-запроса уходит через br-lb и возвращается ответом реального бэкенда), но
# дальше запись живёт в neigh-таблице ядра обычным старением ARP — именно её
# читает hcd (hc/neigh.go) и заливает в таблицу 21 OpenFlow.
hc_port() {
local name="$1" ofport="$2" mac="$3" ip="$4" net="$5"
shift 5
ovs-vsctl --may-exist add-port "$BR" "$name" \
-- set Interface "$name" type=internal ofport_request="$ofport" \
-- set Interface "$name" mac="\"$mac\""
ip link set dev "$name" address "$mac"
ip addr replace "$ip/${net##*/}" dev "$name"
ip link set dev "$name" up
# Остальные аргументы — пары «адрес MAC» членов пула в этом сегменте.
local peers=""
while [ $# -ge 2 ]; do
ip neigh replace "$1" lladdr "$2" dev "$name" nud permanent
peers="$peers $1"
shift 2
done
log "порт $name ($ip) — источник health-проб для$peers"
log "порт $name ($ip) — источник health-проб"
}
hc_port "$HC2_IFNAME" "$HC2_OFPORT" "$HC2_MAC" "$HC2_IP" "$P2_NET" \
"$BE1_IP" "$BE1_MAC" "$BE3_IP" "$BE3_MAC"
hc_port "$HC3_IFNAME" "$HC3_OFPORT" "$HC3_MAC" "$HC3_IP" "$P3_NET" \
"$BE2_IP" "$BE2_MAC" "$BE4_IP" "$BE4_MAC"
hc_port "$HC2_IFNAME" "$HC2_OFPORT" "$HC2_MAC" "$HC2_IP" "$P2_NET"
hc_port "$HC3_IFNAME" "$HC3_OFPORT" "$HC3_MAC" "$HC3_IP" "$P3_NET"
# Ждём, пока vswitchd реально привяжет порты к датапасу.
for name in "$PUB_IFNAME" "$P2_IFNAME" "$P3_IFNAME"; do
+17 -4
View File
@@ -2,6 +2,10 @@
# Генерация конфигурации демона hcd из topology.env — единственного источника
# правды по адресам стенда. Пока пул задан статически; на следующем шаге его
# место займёт модель Load_Balancer -> Listener -> Pool -> Member в OVSDB.
#
# MAC членов пула здесь не фигурирует (шаг 5): hcd резолвит его сам через
# neigh-таблицу ядра (hc/neigh.go) на интерфейсе "iface" и заливает в таблицу
# "adj_table" по указанному "ofport". См. docs/STEP5_SUMMARY.md.
set -euo pipefail
LB_DIR=/opt/lb
@@ -18,6 +22,7 @@ cat > "$OUT" <<EOF
"slots": $SLOTS,
"slot_table": 11,
"dnat_table": 12,
"adj_table": 21,
"probe": "$HC_PROBE",
"http_path": "$HC_HTTP_PATH",
"interval": "$HC_INTERVAL",
@@ -33,7 +38,9 @@ cat > "$OUT" <<EOF
"address": "$BE1_IP",
"port": $BE1_PORT,
"weight": 1,
"source": "$HC2_IP"
"source": "$HC2_IP",
"iface": "$HC2_IFNAME",
"ofport": $BE1_OFPORT
},
{
"id": 2,
@@ -41,7 +48,9 @@ cat > "$OUT" <<EOF
"address": "$BE2_IP",
"port": $BE2_PORT,
"weight": 1,
"source": "$HC3_IP"
"source": "$HC3_IP",
"iface": "$HC3_IFNAME",
"ofport": $BE2_OFPORT
},
{
"id": 3,
@@ -49,7 +58,9 @@ cat > "$OUT" <<EOF
"address": "$BE3_IP",
"port": $BE3_PORT,
"weight": 1,
"source": "$HC2_IP"
"source": "$HC2_IP",
"iface": "$HC2_IFNAME",
"ofport": $BE3_OFPORT
},
{
"id": 4,
@@ -57,7 +68,9 @@ cat > "$OUT" <<EOF
"address": "$BE4_IP",
"port": $BE4_PORT,
"weight": 1,
"source": "$HC3_IP"
"source": "$HC3_IP",
"iface": "$HC3_IFNAME",
"ofport": $BE4_OFPORT
}
]
}
+7
View File
@@ -20,6 +20,7 @@ lbctl — осмотр датапаса стенда hpnn_v2
metrics метрики в формате Prometheus
conns таблица соединений ct (зона $CT_ZONE)
fdb таблица коммутации сегментов: выученные MAC и порты
neigh MAC членов пула, резолвленный hcd через ARP ядра (таблица 21)
trace <src_ip> [src_port] [сегмент]
ofproto/trace для TCP-сессии клиент -> VIP.
Сегмент: pub (по умолчанию), p2, p3 — определяет порт входа
@@ -85,6 +86,12 @@ fdb)
| sed -n 's/.*n_packets=\([0-9]*\).*priority=\(50\|10\),reg0=\(0x[0-9a-f]*\).*/ сегмент \3 приоритет \2: пакетов \1/p' \
| sort
;;
neigh)
api /neigh.txt
echo
echo "Правила таблицы 21 (priority=100 — заливает hcd, priority=90 — learn):"
ovs-ofctl -O "$OF" dump-flows "$BR" table=21 | grep -E 'priority=(100|90),ip' | sort
;;
trace)
src_ip="${2:?укажите IP клиента}"
src_port="${3:-40000}"
+21 -12
View File
@@ -272,16 +272,18 @@ table=11,priority=0 actions=drop
# Слот положил идентификатор члена в reg2. SNAT не выполняется ни в одном из
# путей: бэкенд видит реальный адрес клиента.
#
# Член в том же сегменте, что и клиент (приоритет 200) — выдача коммутацией:
# меняется только MAC назначения, TTL не уменьшается, маршрутизации нет. Для
# ВМ клиент и бэкенд остаются L2-соседями, какими и были.
# Член в том же сегменте, что и клиент (приоритет 200) — TTL не уменьшается,
# маршрутизации нет. MAC назначения здесь больше не пишется (шаг 5): следующий
# переход — таблица 21, единственное место, где живёт актуальный MAC члена
# (резолвит hcd через ARP, см. hc/neigh.go). Для ВМ клиент и бэкенд остаются
# L2-соседями, какими и были — таблица 21 сама не декрементирует TTL.
EOF
for seg_reg in "$P2_REG:$P2_MEMBERS" "$P3_REG:$P3_MEMBERS"; do
reg="${seg_reg%%:*}"
for m in ${seg_reg#*:}; do
eval "mip=\$${m}_IP; mport=\$${m}_PORT; mmac=\$${m}_MAC; mid=\$${m}_ID"
echo "table=12,priority=200,reg0=$reg,ip,reg2=$mid actions=mod_dl_dst:$mmac,ct(commit,zone=$CT_ZONE,nat(dst=$mip:$mport),table=25)"
eval "mip=\$${m}_IP; mport=\$${m}_PORT; mid=\$${m}_ID"
echo "table=12,priority=200,reg0=$reg,ip,reg2=$mid actions=ct(commit,zone=$CT_ZONE,nat(dst=$mip:$mport),table=21)"
done
done
@@ -329,15 +331,22 @@ table=20,priority=0 actions=drop
# =============================================================================
# Таблица 21 — adjacency (next-hop MAC + выходной порт)
# =============================================================================
# Члены пула прописаны статически: их MAC и порт фиксированы в topology.env.
table=21,priority=100,ip,nw_dst=$BE1_IP actions=mod_dl_dst:$BE1_MAC,output:$BE1_OFPORT
table=21,priority=100,ip,nw_dst=$BE2_IP actions=mod_dl_dst:$BE2_MAC,output:$BE2_OFPORT
table=21,priority=100,ip,nw_dst=$BE3_IP actions=mod_dl_dst:$BE3_MAC,output:$BE3_OFPORT
table=21,priority=100,ip,nw_dst=$BE4_IP actions=mod_dl_dst:$BE4_MAC,output:$BE4_OFPORT
# Ответы на health-пробы — в стек узла через internal-порты.
# Единственное место, где применяется MAC следующего перехода — и для
# маршрутизируемого пути (после таблицы 20), и для коммутируемого (напрямую
# из таблицы 12, без decrement TTL). Три источника записей, по приоритету:
#
# priority=100 члены пула — заливает и обновляет hcd (hc/neigh.go),
# резолвя MAC настоящим ARP через hcif-порты. Пусто до
# первого резолва после старта — fail-close ниже, окно то же,
# что у таблицы слотов.
# priority=90 клиенты (публичные и внутрисегментные) — действие learn из
# таблиц 0 и 1: адрес заранее не известен, изучается пассивно.
# priority=0 неизвестный next-hop — drop.
#
# Ответы на health-пробы — в стек узла через internal-порты; это собственные
# адреса узла, а не next-hop, поэтому остаются статичными.
table=21,priority=100,ip,nw_dst=$HC2_IP actions=mod_dl_dst:$HC2_MAC,output:$HC2_OFPORT
table=21,priority=100,ip,nw_dst=$HC3_IP actions=mod_dl_dst:$HC3_MAC,output:$HC3_OFPORT
# priority=90 — записи клиентов, устанавливаемые действием learn из таблиц 0 и 1.
table=21,priority=0 actions=drop
# =============================================================================
+6
View File
@@ -25,6 +25,12 @@ VIP_PORT=80
# Адрес шлюза LB_P2_IP и VIP приватного листенера P2_VIP отвечают одним MAC
# (P2_MAC) — по нему пайплайн отличает L3-трафик к маршрутизатору от
# L2-трафика сегмента.
#
# BE*_MAC (шаг 5): это уже не источник правды для OpenFlow, а «заводской» MAC,
# который attach-segment.sh назначает интерфейсу ВМ — эмуляция того, что в
# реальности прошито в NIC сервера. В таблицу 21 этот MAC не подставляется:
# его заново резолвит hcd через обычный ARP на hcif-порту (hc/neigh.go) и
# заливает в датапас сам. pipeline.sh эти переменные больше не читает.
P2_IFNAME=p2
P2_OFPORT=2
P2_MAC=02:42:0a:14:00:01
+34 -2
View File
@@ -299,12 +299,16 @@ esac
# Путь остался коммутируемым: TTL ответа от VIP не уменьшен, значит клиент и
# бэкенд по-прежнему L2-соседи и балансировщик не стал для них L3-хопом.
# Захватываем с запасом: пул из 4 членов, capture -c должен гарантированно
# застать хотя бы один ответ от соседа по сегменту (be1/be3), а не только от
# кросс-сегментных (be2/be4) — иначе проверка становится статистически
# хрупкой на малой выборке.
ttls=$(mktemp)
timeout 12 $DC exec -T lb-router timeout 8 tcpdump -ni cli2 -c 4 -v \
timeout 20 $DC exec -T lb-router timeout 15 tcpdump -ni cli2 -c 16 -v \
"tcp and src host $P2_VIP and tcp[tcpflags] & tcp-syn != 0" >"$ttls" 2>/dev/null &
tpid=$!
sleep 1
for _ in $(seq 12); do $DC exec -T cli2 curl -s --max-time 3 -o /dev/null "http://$P2_VIP/" 2>/dev/null; done
for _ in $(seq 32); do $DC exec -T cli2 curl -s --max-time 3 -o /dev/null "http://$P2_VIP/" 2>/dev/null; done
wait $tpid 2>/dev/null
grep -q 'ttl 64' "$ttls" && ok "ответ от VIP приходит с TTL 64 — путь коммутируемый, не маршрутный" \
|| bad "TTL ответа уменьшен: путь стал маршрутизируемым"
@@ -323,6 +327,34 @@ $DC exec -T be1 ping -c1 -W2 10.20.0.254 >/dev/null 2>&1 \
$DC exec -T cli3 curl -s --max-time 5 http://10.30.0.3:8080/ 2>/dev/null | grep -q 'backend=be4' \
&& ok "cli3 -> be4 во втором сегменте" || bad "связность второго сегмента нарушена"
head_ "17. Динамическое изучение MAC/ARP (шаг 5)"
# MAC членов пула больше не статичен: hcd резолвит его через обычный ARP на
# hcif-портах (ядро) и сам заливает таблицу 21. Проверяем, что резолвленный
# MAC совпадает с реальным MAC интерфейса ВМ, а не с константой topology.env.
neigh=$($DC exec -T lb-router lbctl neigh 2>/dev/null)
for be in be1:10.20.0.2 be2:10.30.0.2 be3:10.20.0.3 be4:10.30.0.3; do
name=${be%%:*}; ip=${be#*:}
real_mac=$($DC exec -T "$name" ip -o link show eth0 2>/dev/null | sed -n 's/.*link\/ether \([0-9a-f:]*\).*/\1/p')
resolved=$(echo "$neigh" | awk -v n="$name" '$1==n{print $4}')
if [ -n "$real_mac" ] && [ "$resolved" = "$real_mac" ]; then
ok "$name: hcd резолвил реальный MAC $real_mac (не из topology.env)"
else
bad "$name: резолвлен $resolved, реальный MAC интерфейса $real_mac"
fi
done
# Таблица 21 — единственное место с MAC следующего перехода; правило члена
# должно быть приоритета 100 (заливка hcd), а не 90 (пассивный learn клиента).
# Проверка заодно закрывает регрессию force-реапплая: apply.sh уже вызывался
# в блоке 14 и стирал весь пайплайн вместе с таблицей 21 — если бы /reapply
# не восстанавливал её принудительно, этих правил здесь бы уже не было.
t21=$($DC exec -T lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb table=21 2>/dev/null)
for ip in 10.20.0.2 10.30.0.2 10.20.0.3 10.30.0.3; do
echo "$t21" | grep -q "priority=100,ip,nw_dst=$ip " \
&& ok "таблица 21: приоритет-100 правило для $ip пережило apply.sh" \
|| bad "таблица 21: нет приоритет-100 правила для $ip"
done
echo
echo "Итог: успешно $pass, провалено $fail"
[ "$fail" -eq 0 ]