Files
hpnn-proto/docs/PRIVATE_LB_DATAFLOW.md
ayurishchevandClaude Opus 5 fa389f119b Шаг 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>
2026-08-19 12:33:48 +03:00

241 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Потоки данных: балансировка приватного трафика
**Дата:** 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), [STEP5_SUMMARY.md](STEP5_SUMMARY.md).
Отличие от публичного случая: клиент может стоять **в одном сегменте с
бэкендами**. Тогда ответ бэкенда уходит клиенту по L2 напрямую, минуя
маршрутизацию, и снять с него трансляцию можно только там, где этот кадр виден.
Поэтому в приватном сегменте узел работает **коммутатором**: каждая ВМ
подключена отдельным портом моста.
Предпосылка всей схемы: **порт ВМ — порт OVS**. В целевой среде это выполняется
по построению (ВМ подключены к vSwitch), в стенде порты создаёт
`scripts/attach-segment.sh` (`make attach`). При этом в ОС виртуальных машин не
настраивается ничего, адресация заказчика не меняется, а трафик внутри сегмента
не блокируется.
## 1. Участники сегмента
```mermaid
flowchart LR
subgraph BR["br-lb — OpenFlow datapath"]
direction TB
subgraph S2["сегмент 2 — 10.20.0.0/24 · reg0 = 2"]
P2["p2 · 2<br/>аплинк к шлюзу Docker"]
HC2["hcif-p2 · 4<br/>10.20.0.253 <b>в ядре</b>"]
B1["be1 · 10"]
B3["be3 · 11"]
C2["cli2 · 12"]
end
end
CLI2["cli2 10.20.0.10<br/><i>клиент: настроен только адрес</i>"]
BE1["be1 10.20.0.2:8080"]
BE3["be3 10.20.0.3:8080"]
GW["шлюз Docker 10.20.0.254<br/><i>egress бэкендов</i>"]
OTHER["сегмент 3 — 10.30.0.0/24<br/>be2, be4, cli3"]
C2 <--> CLI2
B1 <--> BE1
B3 <--> BE3
P2 <--> GW
S2 -.->|"маршрутизация<br/>таблицы 20/21"| OTHER
```
Шлюз сегмента `10.20.0.1` и VIP листенера `10.20.0.100` отвечают **одним MAC** —
`02:42:0a:14:00:01`. По нему пайплайн отличает трафик к маршрутизатору от
трафика между ВМ. Сегмент 3 устроен симметрично.
Какие сегменты имеют листенер, задаёт `PRIV_LISTENERS` в `topology.env`: пусто —
ни одного, `"p2"` — один, `"p2 p3"` — оба.
## 2. Конвейер таблиц приватного пути
```mermaid
flowchart TD
IN(["Кадр с порта ВМ"]) --> T0["<b>0</b> сегмент по in_port → reg0<br/><b>learn</b> FDB (eth_src → порт)<br/><b>learn</b> adjacency (для адресов своей подсети)"]
T0 --> T1{"<b>1</b> классификация<br/>по MAC назначения"}
T1 -->|"arp"| T5["<b>5</b> ARP-респондер<br/>за VIP, шлюз, .253"]
T1 -->|"icmp echo на VIP<br/>или шлюз"| T6["<b>6</b> ICMP-респондер"]
T1 -->|"dl_dst = MAC шлюза<br/>+ nw_dst = VIP:80"| T10
T1 -->|"dl_dst = MAC шлюза<br/>прочий IP"| T15["<b>15</b> обратный путь L3<br/>ct(nat) — для сессий<br/>из другого сегмента"]
T1 -->|"прочее —<br/>трафик между ВМ"| T24
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> применение члена<br/>ct(commit,nat)"}
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/>сегмента"}
T24 -->|"nw_src = член, tp_src = 8080<br/>ct(nat): src → VIP:80"| T25
T24 -->|"прочее — без изменений"| T25
T25{"<b>25</b> коммутация<br/>FDB по eth_dst"} --> OUTL(["output: порт ВМ"])
T25 -->|"broadcast или<br/>неизвестный MAC"| FLOOD(["рассылка по сегменту,<br/>кроме входного порта"])
classDef d fill:#7f1d1d,stroke:#ef4444,color:#fff
class DROPF d
```
Таблица 21 — единственное место, где применяется MAC следующего перехода, для
обоих путей сразу: и для члена в сегменте клиента (сюда ведёт таблица 12
напрямую, без `dec_ttl`), и для члена в другом сегменте (сюда приходят из
таблицы 20, уже после `dec_ttl`). MAC члена не дублируется между таблицами.
## 3. Основной поток: клиент и член пула в одном сегменте
Тот самый случай, ради которого сегмент переведён на порты OVS.
```mermaid
sequenceDiagram
autonumber
participant C as cli2 10.20.0.10
participant OF as br-lb
participant B as be1 10.20.0.2
C->>OF: ARP who-has 10.20.0.100
Note over OF: t1 → t5: респондер за VIP
OF-->>C: is-at 02:42:0a:14:00:01 (MAC шлюза сегмента)
C->>OF: SYN 10.20.0.10 → 10.20.0.100:80, TTL 64
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: 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
Note over OF: <b>t24</b>: ct(nat) → src = 10.20.0.100:80
OF->>C: t25: FDB → порт cli2
Note over C: ответ от 10.20.0.100:80, TTL 64
```
Обратный кадр не адресован балансировщику вовсе — бэкенд отправляет его соседу
напрямую. Через мост он проходит только потому, что порт ВМ — порт OVS. Именно
здесь, в таблице 24, снимается трансляция.
Для ВМ ничего не изменилось: TTL не уменьшен, значит клиент и бэкенд по-прежнему
L2-соседи, а балансировщик не стал для них маршрутным хопом.
## 4. Второй поток: член пула в другом сегменте
Тот же листенер, но слот выбрал члена из соседнего сегмента — работает обычная
маршрутизация.
```mermaid
flowchart LR
C["cli2<br/>10.20.0.10"] -->|"dst=10.20.0.100:80"| A["t0/t1: reg0=2,<br/>dl_dst = MAC шлюза"]
A --> B["t10 → t11 → t12<br/>reg2 указывает на be2:<br/>DNAT 10.30.0.2:8080"]
B --> D["t20: LPM /24 10.30.0.0<br/><b>dec_ttl</b>, src MAC = p3"]
D --> E["t21: dst MAC = be2,<br/>output порт be2"]
E --> BE2["be2<br/><i>client=10.20.0.10</i>"]
BE2 -->|"маршрут 10.20.0.0/24<br/>via 10.30.0.1"| F["t1: dl_dst = MAC шлюза → t15<br/>ct(nat): src → 10.20.0.100:80"]
F --> G["t20 → t21: adjacency клиента,<br/>выученная в t0"] --> C
```
Клиенту оба пути неотличимы — ответ приходит от `10.20.0.100:80`. Разница видна
только по TTL: **64** от соседа по сегменту против **63** через маршрутизацию.
## 5. Остальной трафик сегмента
Балансировка не мешает обычной работе сегмента: всё, что не адресовано VIP,
коммутируется без трансляции.
```mermaid
flowchart TD
A["cli2 → be1:8080 напрямую,<br/>минуя VIP"] --> B["t1: dl_dst = MAC be1 → t24"]
B --> C["t24: ct без записи —<br/>изменений нет"] --> D["t25: FDB → порт be1"]
E["be1 → be3:8080<br/>внутри сегмента"] --> F["t1 → t24 → t25"]
F --> G["t25: FDB → порт be3"]
H["be1 → 1.1.1.1<br/>default via 10.20.0.254"] --> I["t1 → t24 → t25"]
I --> J["t25: FDB → аплинк p2 →<br/>docker-бридж → NAT хоста<br/><i>профиль A: мимо балансировки</i>"]
K["ARP между ВМ"] --> L["t5: не наш адрес → t25"]
L --> M["t25: рассылка по портам сегмента"]
N["health-проба<br/>10.20.0.253 → be1:8080"] --> O["t0: in_port = hcif-p2<br/>t1 → t24 → t25 → порт be1"]
```
Про пробы: они уходят с уникального адреса узла `.253`, а не с VIP. Если бы
источником был VIP, ответ бэкенда попал бы в обратную трансляцию и до пробера не
дошёл. Правило таблицы 24 ответ на пробу видит, но `ct` записи для него не
находит и пропускает без изменений.
## 6. Таблица решений приватного пути
| Таблица | Матч | Действие | Комментарий |
|---|---|---|---|
| 0 | `in_port` = порт сегмента | `reg0` + `learn` FDB | мост становится обучающимся коммутатором |
| 0 | то же, `ip`, `nw_src` из подсети сегмента | + `learn` adjacency | нужно для возврата ответов из другого сегмента; с аплинка не обучаем, иначе в t21 попал бы внешний мир |
| 1 | `dl_dst` = MAC шлюза, `nw_dst` = VIP, `tp_dst` = 80 | → t10 | листенер сегмента |
| 1 | `dl_dst` = MAC шлюза, `ip`, `nw_dst` = VIP | drop | адрес занят, листенер выключен — видно по счётчику |
| 1 | `dl_dst` = MAC шлюза, `ip` | → t15 | транзит и обратный путь L3 |
| 1 | прочее | → t24 | трафик между ВМ |
| 5 | `arp_tpa` = VIP, шлюз или `.253` | ответ `IN_PORT` | MAC-ом порта сегмента |
| 5 | прочий ARP | → t25 | ВМ резолвят друг друга сами |
| 10 | `tcp`, `nw_dst` = VIP сегмента | `multipath` → t11 | тот же `pool_id` и хэш, что у публичного |
| 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 с |
| 25 | broadcast или неизвестный MAC | рассылка по сегменту | порт входа OVS исключает сам |
## 7. Где поток обрывается
| Точка | Причина | Как увидеть |
|---|---|---|
| t0 prio 0 | кадр с порта, не отнесённого к сегменту — ВМ не подключена | `make ports`, затем `make attach` |
| t1 prio 125 | обращение на VIP при выключенном листенере | `lbctl flows 1` |
| t11 prio 0 | пул без живых членов — fail-close | `make slots` |
| t20 prio 0 | назначение без маршрута | `lbctl flows 20 \| grep priority=0` |
| t21 prio 0 | adjacency клиента неизвестна — запись `learn` истекла | `lbctl flows 21` |
| t25 prio 0 | кадр без сегмента в `reg0` | `make fdb` |
## 8. Осмотр
```bash
make trace SRC=10.20.0.10 SPORT=40000 SEG=p2 # путь сессии по таблицам
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/
```
В `Final flow` трассировки коммутируемого пути `nw_src` остаётся клиентским,
`nw_ttl` не уменьшен, а вывод происходит из таблицы 25 в порт члена.
## 9. Ограничения приватного пути
- **Порты ВМ должны быть портами OVS.** Если между клиентом и бэкендом окажется
коммутатор, который SDN не контролирует, ответ бэкенда мост не увидит, и кейс
не решается ничем, кроме SNAT — то есть с потерей IP клиента.
- **Весь внутрисегментный трафик проходит через узел.** Пропускная способность
узла становится пропускной способностью сегмента.
- **Обратная трансляция опирается на `ct`.** Stateless-вариант (`nw_src=member,
tp_src=8080 → VIP:80`) потребует аккуратного исключения трафика, который
бэкенд сам инициирует с порта 8080.
- **FDB без старения короче 300 с и без ограничения на число MAC** в сегменте.
- **Неизвестный unicast и unicast-ARP рассылаются флудом** — обычное поведение
обучающегося коммутатора.
- **В стенде порты ВМ исчезают при пересоздании контейнера** — нужен
`make attach`. В целевой среде порты создаёт гипервизор.
- **Только TCP и только IPv4**; ICMP-ошибки и фрагменты — как и в публичном
пути, вне рамок.