From fa389f119b4f749b6f73f9493e81998635ec5f9b Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Wed, 19 Aug 2026 12:33:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A8=D0=B0=D0=B3=205:=20=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=BE=D0=B5=20=D0=B8?= =?UTF-8?q?=D0=B7=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20MAC/ARP=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 6 +- README.md | 21 ++-- docs/MANUAL_TEST_PLAN.md | 76 +++++++++++ docs/PRIVATE_LB_DATAFLOW.md | 29 +++-- docs/PUBLIC_LB_DATAFLOW.md | 7 +- docs/STEP1_SUMMARY.md | 5 +- docs/STEP4_PUBLIC_STATELESS_ASSESSMENT.md | 9 ++ docs/STEP5_IMPLEMENTATION_PLAN.md | 147 ++++++++++++++++++++++ docs/STEP5_SUMMARY.md | 127 +++++++++++++++++++ hc/main.go | 50 +++++++- hc/neigh.go | 134 ++++++++++++++++++++ lb/entrypoint.sh | 25 ++-- lb/hc-config.sh | 21 +++- lb/lbctl.sh | 7 ++ lb/pipeline.sh | 33 +++-- lb/topology.env | 6 + scripts/verify.sh | 36 +++++- 17 files changed, 678 insertions(+), 61 deletions(-) create mode 100644 docs/STEP5_IMPLEMENTATION_PLAN.md create mode 100644 docs/STEP5_SUMMARY.md create mode 100644 hc/neigh.go diff --git a/Makefile b/Makefile index 85efe92..cbeab09 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index aa05dab..c1dab86 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/docs/MANUAL_TEST_PLAN.md b/docs/MANUAL_TEST_PLAN.md index 7a89881..734dc82 100644 --- a/docs/MANUAL_TEST_PLAN.md +++ b/docs/MANUAL_TEST_PLAN.md @@ -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` | ☐ | --- diff --git a/docs/PRIVATE_LB_DATAFLOW.md b/docs/PRIVATE_LB_DATAFLOW.md index e37b162..50be84b 100644 --- a/docs/PRIVATE_LB_DATAFLOW.md +++ b/docs/PRIVATE_LB_DATAFLOW.md @@ -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{"10 листенер
tcp VIP:80"} --> T11["11 таблица слотов
reg1 → reg2 (общая
с публичным листенером)"] T11 -->|"пул пуст"| DROPF(["drop — fail-close"]) - T11 --> T12{"12 применение члена"} + T11 --> T12{"12 применение члена
ct(commit,nat)"} - T12 -->|"член в сегменте клиента
mod_dl_dst + ct(commit,nat)
без dec_ttl"| T25 - T12 -->|"член в другом сегменте
ct(commit,nat)"| T20["20/21 маршрутизация
dec_ttl, adjacency"] - T20 --> OUTR(["output: порт члена
в другом сегменте"]) + T12 -->|"член в сегменте клиента
без dec_ttl, минуя таблицу 20"| T21 + T12 -->|"член в другом сегменте"| T20["20 маршрутизация
dec_ttl"] + T20 --> T21["21 adjacency
MAC члена + порт —
резолвит hcd через ARP
ядра (шаг 5), единое
место для обоих путей"] + T21 --> OUTR(["output: порт члена"]) T15 --> T16["16 пост-ct"] --> T20 T24{"24 обратная трансляция
сегмента"} @@ -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,
ct(commit, nat(dst=10.20.0.2:8080))
dec_ttl не выполняется - OF->>B: t25: FDB → порт be1 + Note over OF: t12: ct(commit, nat(dst=10.20.0.2:8080))
→ t21 напрямую, минуя t20 + Note over OF: t21: mod_dl_dst = MAC be1 (резолвлен hcd
через ARP ядра, шаг 5), output порт be1
dec_ttl не выполнялся + OF->>B: пакет в порт be1 Note over B: client=10.20.0.10 — исходный адрес клиента
served=10.20.0.2:8080 — трансляция 80 → 8080 B->>OF: ответ src=10.20.0.2:8080 → 10.20.0.10,
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/ diff --git a/docs/PUBLIC_LB_DATAFLOW.md b/docs/PUBLIC_LB_DATAFLOW.md index 276c148..8eac7d1 100644 --- a/docs/PUBLIC_LB_DATAFLOW.md +++ b/docs/PUBLIC_LB_DATAFLOW.md @@ -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 diff --git a/docs/STEP1_SUMMARY.md b/docs/STEP1_SUMMARY.md index f13ca82..2bb1598 100644 --- a/docs/STEP1_SUMMARY.md +++ b/docs/STEP1_SUMMARY.md @@ -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 diff --git a/docs/STEP4_PUBLIC_STATELESS_ASSESSMENT.md b/docs/STEP4_PUBLIC_STATELESS_ASSESSMENT.md index ba4456a..b682329 100644 --- a/docs/STEP4_PUBLIC_STATELESS_ASSESSMENT.md +++ b/docs/STEP4_PUBLIC_STATELESS_ASSESSMENT.md @@ -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. Итоговая сводка условий и рисков | # | Пункт | Тип | Статус | diff --git a/docs/STEP5_IMPLEMENTATION_PLAN.md b/docs/STEP5_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..08c42e5 --- /dev/null +++ b/docs/STEP5_IMPLEMENTATION_PLAN.md @@ -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 to `; +- MAC резолвлен при состоянии `REACHABLE`/`STALE`/`DELAY`/`PROBE`/`PERMANENT`; +- при изменении MAC — точечный бандл `ovs-ofctl bundle`: `flow delete + table=21,ip,nw_dst=` + `flow add table=21,priority=100,ip,nw_dst= + actions=mod_dl_dst:,output:`; +- 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 пути** — требует правки ожидаемых + трасс в документации. diff --git a/docs/STEP5_SUMMARY.md b/docs/STEP5_SUMMARY.md new file mode 100644 index 0000000..6ca013c --- /dev/null +++ b/docs/STEP5_SUMMARY.md @@ -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`. diff --git a/hc/main.go b/hc/main.go index 2956377..a581ec4 100644 --- a/hc/main.go +++ b/hc/main.go @@ -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 { diff --git a/hc/neigh.go b/hc/neigh.go new file mode 100644 index 0000000..f0f3491 --- /dev/null +++ b/hc/neigh.go @@ -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 +} diff --git a/lb/entrypoint.sh b/lb/entrypoint.sh index fb5b754..ec423c7 100755 --- a/lb/entrypoint.sh +++ b/lb/entrypoint.sh @@ -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 diff --git a/lb/hc-config.sh b/lb/hc-config.sh index 4303386..74b9e5b 100755 --- a/lb/hc-config.sh +++ b/lb/hc-config.sh @@ -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" < "$OUT" < "$OUT" < "$OUT" < "$OUT" < [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}" diff --git a/lb/pipeline.sh b/lb/pipeline.sh index 01cf2e4..359b378 100755 --- a/lb/pipeline.sh +++ b/lb/pipeline.sh @@ -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 # ============================================================================= diff --git a/lb/topology.env b/lb/topology.env index 1efaf15..46dda9b 100644 --- a/lb/topology.env +++ b/lb/topology.env @@ -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 diff --git a/scripts/verify.sh b/scripts/verify.sh index cf67119..1645b16 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -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 ]