Добавлена локальная локальная балансировка

This commit is contained in:
ayurishchev committed 2026-08-17 16:59:16 +03:00
1 parent afd4f057be
commit d80a6c44b0
17 files changed
+1628 -369

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
# План: разделение документа потоков данных
**Дата:** 2026-08-17
**Итоги:** [CHANGE_SPLIT_DATAFLOW_DOCS_SUMMARY.md](CHANGE_SPLIT_DATAFLOW_DOCS_SUMMARY.md)
## Задача
`docs/ROUTING_DATAFLOW.md` после шага 3 описывает сразу два разных сценария
балансировки — публичный и приватный — в одном наборе диаграмм. Читателю,
которому нужен конкретный сценарий, приходится вычитывать его из общего
конвейера и мысленно отбрасывать чужие ветки.
Разделить на два самостоятельных документа: по одному на каждый вид
балансировки.
## Что делаем
1. **`docs/PUBLIC_LB_DATAFLOW.md`** — балансировка публичного трафика: клиент из
`192.168.5.0/24`, VIP `192.168.5.21:80`, маршрутизируемый путь до члена пула
и обратно. Сюда же — ARP- и ICMP-респондеры публичного сегмента, обучение
adjacency клиента, обратный путь через `ct`, профиль A как условие возврата
ответа.
2. **`docs/PRIVATE_LB_DATAFLOW.md`** — балансировка приватного трафика: клиент
внутри сегмента, VIP `10.20.0.100:80` / `10.30.0.100:80`, два пути выдачи
(коммутируемый для соседа по сегменту и маршрутизируемый для члена из
другого сегмента), обратная трансляция в таблице 24, коммутация в таблице 25.
3. **Удалить `docs/ROUTING_DATAFLOW.md`** — его содержимое целиком переходит в
два новых документа.
4. Обновить ссылки: `README.md`, `docs/STEP3_IMPLEMENTATION_PLAN.md`.
## Принципы разделения
- Каждый документ самодостаточен: своя топология портов, своя карта таблиц,
свои точки обрыва потока и свои ограничения. Дублирование общих сведений
допустимо — читатель не должен ходить между файлами.
- В карте таблиц каждого документа перечислены только таблицы, участвующие в
его сценарии, с пометкой о назначении именно в этом пути.
- Общие подсистемы (health-check, транзит между сегментами, egress бэкендов)
описываются в том документе, где они влияют на путь: health-check — кратко в
обоих, транзит и коммутация сегмента — в приватном.
- Диаграммы mermaid сохраняются, но каждая перерисовывается под свой сценарий
без чужих ветвей.
## Критерии приёмки
- Ни в одном из двух документов нет диаграммы, смешивающей публичный и
приватный путь.
- В README есть ссылки на оба документа с пояснением, какой для чего.
- Ссылок на удалённый `ROUTING_DATAFLOW.md` в репозитории не осталось.
@@ -0,0 +1,46 @@
# Итоги: разделение документа потоков данных
**Дата:** 2026-08-17
**Статус:** выполнено
**План:** [CHANGE_SPLIT_DATAFLOW_DOCS_PLAN.md](CHANGE_SPLIT_DATAFLOW_DOCS_PLAN.md)
## Что сделано
`docs/ROUTING_DATAFLOW.md` удалён, вместо него — два самостоятельных документа,
по одному на каждый вид балансировки:
| Документ | Сценарий | Характер пути |
|---|---|---|
| [PUBLIC_LB_DATAFLOW.md](PUBLIC_LB_DATAFLOW.md) | клиент из `192.168.5.0/24` → `192.168.5.21:80` | целиком маршрутизируемый, `dec_ttl` в обе стороны |
| [PRIVATE_LB_DATAFLOW.md](PRIVATE_LB_DATAFLOW.md) | клиент внутри сегмента → `10.20.0.100:80` | коммутируемый для соседа по сегменту, маршрутизируемый для члена из другого |
Каждый документ самодостаточен: своя схема участников, свой конвейер таблиц,
свои потоки, таблица решений, точки обрыва, команды осмотра и ограничения.
Читатель, которому нужен один сценарий, не встречает ветвей другого.
## Как поделено содержимое
- **Публичный документ**: ARP- и ICMP-респондеры публичного сегмента, обучение
adjacency клиента, листенер `VIP:80`, DNAT, маршрутизация 20/21, обратный путь
через таблицу 15, профиль A как условие возврата ответа.
- **Приватный документ**: предпосылка «порт ВМ — порт OVS», сегментация и
обучение FDB в таблице 0, классификация по MAC назначения в таблице 1, два
пути выдачи в таблице 12, обратная трансляция в таблице 24, коммутация в
таблице 25, а также остальной трафик сегмента — `be↔be`, прямые обращения
клиента, egress, ARP и health-пробы.
- **Общее** (таблица слотов, `pool_id`, fail-close, health-check) упомянуто в
обоих ровно в той мере, в какой участвует в пути: пул и хэш у листенеров
общие, и это сказано в каждом документе.
Каждая диаграмма перерисована под свой сценарий: в публичном конвейере нет
таблиц 24 и 25, в приватном — ветви `reg0=1`.
## Изменённые файлы
- добавлены `docs/PUBLIC_LB_DATAFLOW.md`, `docs/PRIVATE_LB_DATAFLOW.md`;
- удалён `docs/ROUTING_DATAFLOW.md`;
- `README.md` — вместо одной ссылки две, с пояснением, какая для чего;
- `docs/STEP3_IMPLEMENTATION_PLAN.md` — ссылка в перечне документации.
Ссылок на удалённый документ в репозитории не осталось, кроме упоминаний в
плане и в этих итогах — там они описывают само изменение.
+150 -2
View File
@@ -8,6 +8,10 @@
нужен, чтобы увидеть поведение стенда своими глазами и понять, что означает
каждый результат.
Что происходит с пакетом на каждом шаге — в документах по потокам данных:
[публичная балансировка](PUBLIC_LB_DATAFLOW.md) для блоков A–C и E–I,
[приватная балансировка](PRIVATE_LB_DATAFLOW.md) для блока J.
## Обозначения
| Метка | Где выполнять |
@@ -26,8 +30,9 @@ cd /opt/lvraid/claude/hpnn_v2
make up
```
Ожидается: пять контейнеров в состоянии `healthy` и таблица состояния пула,
где все четыре члена `up` и у каждого по 256 слотов.
Ожидается: семь контейнеров и таблица состояния пула, где все четыре члена
`up` и у каждого по 256 слотов. `make up` сам вызывает `make attach` —
подключение ВМ приватных сегментов к мосту.
**Шаг 0.2 [Х]** — убедиться, что всё запустилось:
@@ -42,9 +47,20 @@ hpnn-be1 Up ... (healthy)
hpnn-be2 Up ... (healthy)
hpnn-be3 Up ... (healthy)
hpnn-be4 Up ... (healthy)
hpnn-cli2 Up ...
hpnn-cli3 Up ...
hpnn-lb Up ... (healthy)
```
Проверьте, что ВМ сегментов подключены к мосту:
```bash
make ports
```
Ожидается 11 портов: `pub0`, `p2`, `p3`, `hcif-p2`, `hcif-p3`, `be1`, `be3`,
`cli2`, `be2`, `be4`, `cli3`. Если портов ВМ нет — `make attach`.
Если `hpnn-lb` в состоянии `Restarting` — смотрите `docker compose logs
lb-router` и раздел «Диагностика» в конце документа.
@@ -538,6 +554,118 @@ make enable M=be2
---
## Блок J. Листенер в приватном сегменте
Главное шага 3: клиент стоит в одном сегменте с бэкендами, и его исходный адрес
на бэкенде сохраняется. Все шаги выполняются на хосте — клиентом служит
контейнер `cli2`.
**Шаг J.1 [Х]** — убедиться, что в ОС клиента ничего не настроено:
```bash
docker compose exec cli2 ip route
```
Ожидается ровно одна строка — connected-маршрут `10.20.0.0/24 dev eth0`. Ни
default, ни маршрута на VIP: адрес листенера находится в подсети клиента.
**Шаг J.2 [Х]** — доступность VIP сегмента:
```bash
docker compose exec cli2 ping -c2 10.20.0.100
```
**Шаг J.3 [Х]** — запрос к сервису через балансировщик:
```bash
docker compose exec cli2 curl -s http://10.20.0.100/
```
Ожидается строка вида:
```
backend=be1 time=... client=10.20.0.10:46064 served=10.20.0.2:8080 req=1
```
Здесь два ключевых поля: `client` — исходный адрес клиента (SNAT не
выполняется), `served` — адрес и порт бэкенда, то есть листенер принял на 80 и
оттранслировал на 8080.
**Шаг J.4 [Х]** — распределение по пулу:
```bash
docker compose exec cli2 sh -c 'for i in $(seq 24); do curl -s http://10.20.0.100/; done' \
| sort | uniq -c -w 12
```
Ожидается: встречаются все четыре бэкенда. `be1` и `be3` — соседи клиента по
сегменту (коммутируемый путь), `be2` и `be4` — из другого сегмента
(маршрутизируемый).
**Шаг J.5 [Х]** — увидеть разницу путей по TTL. В одном терминале:
```bash
docker compose exec lb-router tcpdump -ni cli2 -v \
'tcp and src host 10.20.0.100 and tcp[tcpflags] & tcp-syn != 0'
```
Во втором — десяток запросов из шага J.3.
Ожидается: у ответов **ttl 64** и **ttl 63**. Первые пришли от `be1`/`be3` —
для них балансировщик не был L3-хопом, он лишь подменил MAC и оттранслировал
адрес. Вторые — от `be2`/`be4` через маршрутизацию, с уменьшением TTL.
**Шаг J.6 [Х]** — путь пакета по таблицам:
```bash
make trace SRC=10.20.0.10 SPORT=40000 SEG=p2
```
Ожидается цепочка `0 → 1 → 10 → 11 → 12`, затем после `ct(...nat(dst=...))` —
`Resuming from table 25` и вывод в порт члена. В `Final flow` видно, что
`nw_src` остался клиентским, а `nw_ttl` **не уменьшен**.
**Шаг J.7 [Х]** — внутрисегментный трафик не блокирован:
```bash
docker compose exec cli2 curl -s http://10.20.0.2:8080/ # клиент к бэкенду напрямую
docker compose exec be1 curl -s http://10.20.0.3:8080/ # бэкенд к бэкенду
docker compose exec be1 ping -c2 10.20.0.254 # шлюз Docker
```
Ожидается: все три работают. Балансировщик коммутирует этот трафик, не
транслируя его.
**Шаг J.8 [Х]** — таблица коммутации:
```bash
make fdb
```
Ожидается: по строке на каждую ВМ сегмента с её MAC и портом, счётчики растут.
**Шаг J.9 [Х]** — выключить листенер в одном сегменте:
```bash
sed -i 's/^PRIV_LISTENERS=.*/PRIV_LISTENERS="p2"/' lb/topology.env
docker compose cp lb/topology.env lb-router:/opt/lb/topology.env
docker compose exec lb-router /opt/lb/apply.sh
docker compose exec cli3 curl -s --max-time 4 http://10.30.0.100/ # таймаут
docker compose exec cli3 curl -s http://10.30.0.2:8080/ # связность цела
docker compose exec cli2 curl -s http://10.20.0.100/ # второй сегмент работает
```
Вернуть обратно:
```bash
sed -i 's/^PRIV_LISTENERS=.*/PRIV_LISTENERS="p2 p3"/' lb/topology.env
docker compose cp lb/topology.env lb-router:/opt/lb/topology.env
docker compose exec lb-router /opt/lb/apply.sh
```
---
## Итоговый чек-лист
| # | Проверка | Результат |
@@ -560,6 +688,12 @@ make enable M=be2
| G | При пустом пуле трафик отбрасывается, счётчик растёт | ☐ |
| H | `make flows` и перезапуск не ломают раскладку | ☐ |
| I | Установленная сессия переживает дренаж | ☐ |
| J | В ОС клиента сегмента нет ни одного маршрута | ☐ |
| J | Бэкенд видит исходный IP клиента-соседа | ☐ |
| J | Листенер 80 транслирует на порт бэкенда 8080 | ☐ |
| J | Ответ от соседа по сегменту приходит с неуменьшенным TTL | ☐ |
| J | Трафик между ВМ сегмента не блокирован | ☐ |
| J | `PRIV_LISTENERS` выключает листенер, не трогая связность | ☐ |
---
@@ -686,6 +820,8 @@ DNAT. Сравнение показателей двух путей даёт ц
| Ключ хэша (алгоритм балансировки) | `lb/pipeline.sh`, таблица 10 | `make flows` |
| Число слотов, basis хэша | `lb/topology.env`: `SLOTS`, `POOL_ID` | пересборка образа |
| Состав пула на лету | — | `make drain` / `make enable` |
| Приватные листенеры (какие сегменты) | `lb/topology.env`, `PRIV_LISTENERS` | `make flows` после копирования файла |
| Адрес приватного VIP | `lb/topology.env`, `P2_VIP` / `P3_VIP` | то же |
«Пересборка образа» — это:
@@ -829,6 +965,18 @@ SLOTS=1024 # гранулярность весов
## Диагностика
**Клиент сегмента не получает ответ от приватного VIP, или ответ приходит не
от VIP.**
Первая гипотеза — ВМ не подключены к мосту: `make ports` должен показывать
порты `be1`, `be3`, `cli2`, `be2`, `be4`, `cli3`. Вручную созданные veth
исчезают вместе с контейнером, поэтому после `docker compose restart be1` или
пересоздания любого контейнера сегмента нужен `make attach`.
Если порты на месте — смотрите `make fdb` (выучен ли MAC клиента) и счётчики
таблицы 24 (`docker compose exec lb-router lbctl flows 24`): именно она снимает
трансляцию с ответа бэкенда соседу.
**Контейнер `hpnn-lb` перезапускается.**
```bash
+231
View File
@@ -0,0 +1,231 @@
# Потоки данных: балансировка приватного трафика
**Дата:** 2026-08-17
**Область:** путь пакета от клиента внутри приватного сегмента на приватный
листенер (`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).
Отличие от публичного случая: клиент может стоять **в одном сегменте с
бэкендами**. Тогда ответ бэкенда уходит клиенту по 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> применение члена"}
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/>в другом сегменте"])
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
```
## 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: 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 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` = сегмент члена | `mod_dl_dst` + `ct(commit,nat)` → t25 | **без `dec_ttl`** |
| 12 | прочее | `ct(commit,nat)` → t20 | член из другого сегмента |
| 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 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-ошибки и фрагменты — как и в публичном
пути, вне рамок.
+193
View File
@@ -0,0 +1,193 @@
# Потоки данных: балансировка публичного трафика
**Дата:** 2026-08-17
**Область:** путь пакета от клиента из `192.168.5.0/24` на публичный листенер
`192.168.5.21:80` и обратно.
**Смежный документ:** [PRIVATE_LB_DATAFLOW.md](PRIVATE_LB_DATAFLOW.md) —
балансировка внутри приватного сегмента.
**Источник:** `lb/pipeline.sh`, `lb/topology.env`.
Публичная балансировка целиком **маршрутизируемая**: клиент и бэкенды лежат в
разных сегментах, узел выступает L3-хопом в обе стороны, TTL уменьшается на
единицу на каждом направлении. Сетевой стек ядра контейнера в передаче трафика
не участвует — все решения принимает OpenFlow.
## 1. Участники
```mermaid
flowchart LR
CL["Клиент<br/>192.168.5.13"]
subgraph BR["br-lb — OpenFlow datapath"]
direction TB
PUB["<b>pub0 · ofport 1</b><br/>macvlan @ enp3s0<br/>02:42:c0:a8:05:14<br/><i>адрес узла 192.168.5.20</i><br/><i>VIP 192.168.5.21:80</i>"]
B1["be1 · 10"]
B3["be3 · 11"]
B2["be2 · 20"]
B4["be4 · 21"]
end
BE1["be1 10.20.0.2:8080"]
BE3["be3 10.20.0.3:8080"]
BE2["be2 10.30.0.2:8080"]
BE4["be4 10.30.0.3:8080"]
CL <-->|"L2 клиентской сети"| PUB
B1 <--> BE1
B3 <--> BE3
B2 <--> BE2
B4 <--> BE4
```
Адреса `192.168.5.20` и `192.168.5.21` существуют только в правилах OpenFlow —
на интерфейсе они не настроены, за них отвечает ARP-респондер MAC-адресом порта
`pub0`.
## 2. Конвейер таблиц публичного пути
```mermaid
flowchart TD
IN(["Кадр с pub0"]) --> T0["<b>0</b> сегмент по in_port<br/>reg0 = 1"]
T0 --> T1{"<b>1</b> классификация"}
T1 -->|"arp"| T5["<b>5</b> ARP-респондер<br/>за 192.168.5.20 и .21"]
T1 -->|"icmp echo на<br/>адрес узла или VIP"| T6["<b>6</b> ICMP-респондер"]
T1 -->|"ip на VIP, адрес узла<br/>или приватные сети<br/>+ <b>learn</b> adjacency"| T10
T1 -->|"прочий IP"| DROP1(["drop"])
T10{"<b>10</b> листенер<br/>tcp VIP:80"}
T10 -->|"multipath(symmetric_l4)<br/>→ reg1 = слот"| T11["<b>11</b> таблица слотов<br/>reg1 → reg2<br/><i>ведёт hcd</i>"]
T10 -->|"IP на VIP без<br/>листенера"| DROPV(["drop"])
T10 -->|"прочий IP: клиент<br/>обратился к бэкенду<br/>напрямую"| T20
T11 -->|"пул пуст"| DROPF(["drop — fail-close"])
T11 --> T12["<b>12</b> DNAT<br/>ct(commit, nat(dst=member:8080))"]
T12 --> T20
T20{"<b>20</b> маршрутизация<br/>LPM по приоритету"}
T20 -->|"dec_ttl,<br/>mod_dl_src = MAC сегмента"| T21["<b>21</b> adjacency<br/>MAC члена + его порт"]
T20 -->|"нет маршрута"| DROP20(["drop"])
T21 --> OUT(["output: порт члена пула"])
classDef d fill:#7f1d1d,stroke:#ef4444,color:#fff
class DROP1,DROPV,DROPF,DROP20 d
```
Обратный путь идёт по другим таблицам — см. раздел 4.
## 3. Прямой путь: клиент → VIP → бэкенд
```mermaid
sequenceDiagram
autonumber
participant C as Клиент 192.168.5.13
participant P as pub0 · 1
participant OF as Таблицы OpenFlow
participant B as be1 10.20.0.2
C->>P: ARP who-has 192.168.5.21
P->>OF: t1 → t5 ARP-респондер
OF-->>C: is-at 02:42:c0:a8:05:14
C->>P: SYN 192.168.5.13 → 192.168.5.21:80, TTL 64
Note over OF: t0: reg0=1
Note over OF: t1: адресован стенду →<br/><b>learn</b>: adjacency клиента в t21
Note over OF: t10: multipath(symmetric_l4, basis=pool_id)<br/>→ reg1 = номер слота
Note over OF: t11: слот → reg2 = член пула
Note over OF: t12: ct(commit, nat(dst=10.20.0.2:8080))<br/><b>src не меняется</b>
Note over OF: t20: LPM /24 10.20.0.0 → dec_ttl 63,<br/>src MAC = MAC сегмента
Note over OF: t21: dst MAC = MAC be1, output порт be1
OF->>B: пакет
Note over B: client=192.168.5.13 — исходный адрес,<br/>served=10.20.0.2:8080 — после DNAT
```
Ключевое: **SNAT не выполняется**. Транслируется только адрес и порт
назначения, поэтому бэкенд видит реальный адрес клиента.
## 4. Обратный путь: бэкенд → клиент
Ответ обязан пройти через узел — иначе клиент получит пакет от `10.20.0.2:8080`
вместо `192.168.5.21:80` и разорвёт соединение. Это обеспечивается маршрутом на
бэкенде (профиль A, см. раздел 5).
```mermaid
flowchart LR
BE["be1 10.20.0.2:8080"] -->|"src=10.20.0.2 dst=192.168.5.13<br/>через шлюз сегмента 10.20.0.1"| A["порт be1 · 10"]
A --> B["t1: dl_dst = MAC шлюза<br/>сегмента → t15"]
B --> C["<b>t15</b>: ct(zone=1, nat)<br/><b>un-DNAT</b>: src → 192.168.5.21:80"]
C --> D["t16 → t20: LPM /24 192.168.5.0<br/>dec_ttl, src MAC = pub0"]
D --> E["t21 prio 90: MAC клиента<br/>из действия learn"]
E --> CL["Клиент видит ответ<br/>от 192.168.5.21:80"]
```
Трансляция снимается conntrack'ом: `ct(commit, nat)` на прямом пути закрепил
привязку за соединением, `ct(nat)` без commit на обратном её применяет в
обратную сторону. Пакеты, для которых записи нет (транзит между сегментами,
собственные обращения бэкендов), проходят без изменений.
## 5. Условие возврата: профиль A на бэкенде
```mermaid
flowchart TD
A["Таблица маршрутизации бэкенда"] --> B["192.168.5.0/24 via 10.20.0.1<br/><i>клиентский префикс — через узел</i>"]
A --> C["10.30.0.0/24 via 10.20.0.1<br/><i>соседний сегмент — через узел</i>"]
A --> D["default via 10.20.0.254<br/><i>шлюз Docker — мимо узла</i>"]
B --> E["Ответы на балансируемые сессии<br/>возвращаются на узел и<br/>проходят обратную трансляцию"]
D --> F["Собственный egress бэкенда<br/>(обновления, DNS, телеметрия)<br/>узел не маршрутизирует"]
```
Проверить: `docker compose exec be1 ip route`. Признак, что профиль A цел, —
кадр к внешнему адресу адресован MAC-у шлюза Docker, а не MAC-у маршрутизатора
сегмента (`02:42:0a:14:00:01`).
## 6. Таблица решений публичного пути
| Таблица | Матч | Действие | Комментарий |
|---|---|---|---|
| 0 | `in_port=1` | `reg0 = 1` | публичный сегмент; коммутации в нём нет |
| 1 | `arp` | → t5 | штатного ARP-стека у узла нет |
| 1 | `ip`, dst ∈ {VIP, `.20`, `10.20/24`, `10.30/24`} | `learn` + → t10 | обучение сужено, чтобы в t21 не попадал широковещательный шум LAN |
| 1 | прочий IP с `pub0` | drop | |
| 5 | `arp_tpa` = `.20` или `.21` | ответ `IN_PORT` | отвечает MAC-ом `pub0` |
| 10 | `tcp`, `nw_dst=VIP`, `tp_dst=80` | `multipath` → t11 | симметричный L4-хэш, basis = `pool_id` |
| 10 | `ip`, `nw_dst=VIP` (нет листенера) | drop | видно по счётчику |
| 10 | прочий IP | → t20 | прямое обращение клиента к бэкенду |
| 11 | `reg1` = слот | `reg2` = член | 1024 правила, заливает `hcd` |
| 11 | prio 0 | drop | fail-close: живых членов нет |
| 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 90, из `learn` | `hard_timeout` 300 с |
## 7. Где поток обрывается
| Точка | Причина | Как увидеть |
|---|---|---|
| t1 prio 100 | IP из публичного сегмента адресован не стенду | `lbctl flows 1` |
| t10 prio 150 | обращение на VIP, для которого нет листенера | `lbctl flows 10` |
| t11 prio 0 | пул без живых членов — fail-close | `make slots` |
| t20 prio 0 | назначение без маршрута (дефолта у узла нет) | `lbctl flows 20 \| grep priority=0` |
| t21 prio 0 | MAC клиента неизвестен — запись `learn` истекла | `lbctl flows 21` |
## 8. Осмотр
```bash
make trace SRC=192.168.5.13 SPORT=41234 # путь сессии по таблицам
make slots # раскладка и per-member счётчики
make conns # записи ct
docker compose exec lb-router lbctl flows 21
```
## 9. Ограничения публичного пути
- **Обратный трафик обязан идти через узел.** Без маршрута на клиентский
префикс (профиль A) ответ уйдёт мимо и соединение не соберётся.
- **Датапас stateful**: обратную трансляцию делает `ct`. Прямой и обратный
трафик сессии обязаны проходить через один узел — Active/Active ECMP в такой
схеме не работает.
- **ICMP-ошибки не генерируются**: ни `TTL exceeded`, ни `fragmentation
needed`. PMTUD через стенд не работает, `traceroute` узел не покажет.
- **Фрагменты IP не обрабатываются** — правила матчат L4-заголовок.
- **Только TCP и только IPv4**; UDP- и L3-листенеров нет.
- **MAC клиента живёт 300 секунд** — после простоя первая же новая сессия
создаёт запись заново.
-227
View File
@@ -1,227 +0,0 @@
# Потоки данных подсистемы L3-маршрутизации
**Дата:** 2026-08-17
**Область:** транзитная маршрутизация узла `hpnn-lb` — всё, что проходит через
таблицы 20 (LPM) и 21 (adjacency). Балансировка (таблицы 10–12) показана только
как точка ответвления и подробно описана в [STEP2_SUMMARY.md](STEP2_SUMMARY.md).
**Источник:** `lb/pipeline.sh`, `lb/topology.env` (состояние на 4 члена пула).
Ключевое свойство: сетевой стек ядра контейнера в транзите не участвует. В ядре
живут только два адреса — `10.20.0.253` и `10.30.0.253` на internal-портах для
health-проб. Все решения о пересылке принимает OpenFlow.
## 1. Топология портов моста `br-lb`
```mermaid
flowchart LR
CL["Клиент<br/>192.168.5.0/24"]
subgraph BR["br-lb — OpenFlow datapath"]
direction TB
PUB["pub0 — ofport 1<br/>macvlan @ enp3s0<br/>02:42:c0:a8:05:14<br/><i>шлюз 192.168.5.20, VIP .21</i>"]
P2["p2 — ofport 2<br/>02:42:0a:14:00:01<br/><i>шлюз 10.20.0.1</i>"]
P3["p3 — ofport 3<br/>02:42:0a:1e:00:01<br/><i>шлюз 10.30.0.1</i>"]
HC2["hcif-p2 — ofport 4<br/>10.20.0.253 <b>в ядре</b>"]
HC3["hcif-p3 — ofport 5<br/>10.30.0.253 <b>в ядре</b>"]
end
BE1["be1 10.20.0.2"]
BE3["be3 10.20.0.3"]
BE2["be2 10.30.0.2"]
BE4["be4 10.30.0.3"]
HCD["hcd — демон<br/>health-check"]
CL <--> PUB
P2 <--> BE1
P2 <--> BE3
P3 <--> BE2
P3 <--> BE4
HCD <--> HC2
HCD <--> HC3
```
## 2. Конвейер таблиц: путь маршрутизируемого пакета
Красным помечены точки отбрасывания, пунктиром — ответвление в балансировку.
```mermaid
flowchart TD
IN(["Пакет на порту моста"]) --> T0
T0{"<b>Таблица 0</b><br/>классификация по in_port<br/>и типу трафика"}
T0 -->|"arp"| T5["<b>5</b> ARP-респондер<br/>для .20/.21/10.20.0.1/<br/>10.30.0.1/.253"]
T0 -->|"icmp echo<br/>на адреса узла"| T6["<b>6</b> ICMP echo-респондер"]
T0 -->|"in_port=1 (pub)<br/>+ dst ∈ {VIP, узел,<br/>10.20/24, 10.30/24}"| LEARN["<b>learn</b> → запись<br/>adjacency клиента<br/>в таблицу 21<br/>(priority 90, 300 c)"]
T0 -->|"in_port=2,3 (priv)"| T15["<b>15</b> обратный путь"]
T0 -->|"in_port=4,5 (hcif)"| T20
T0 -->|"иначе"| DROP0(["drop"])
LEARN --> T10{"<b>Таблица 10</b><br/>листенеры"}
T10 -.->|"tcp dst=VIP:80"| LB["<b>11/12</b> слот → член пула,<br/>DNAT ct(commit,nat)"]
T10 -->|"прочий IP<br/>(прямое обращение<br/>к бэкенду)"| T20
T10 -->|"IP на VIP<br/>без листенера"| DROPV(["drop"])
LB --> T20
T15 -->|"tcp"| CT["ct(zone=1, nat)<br/>снятие DNAT,<br/>если сессия известна"] --> T16["<b>16</b> пост-ct"] --> T20
T15 -->|"прочий IP"| T20
T20{"<b>Таблица 20</b> — LPM<br/>приоритет = длина префикса"}
T20 -->|"/32 10.20.0.253<br/>/32 10.30.0.253<br/><i>адрес узла, TTL не трогаем</i>"| T21
T20 -->|"/24 10.20.0.0<br/>dec_ttl, src MAC = p2"| T21
T20 -->|"/24 10.30.0.0<br/>dec_ttl, src MAC = p3"| T21
T20 -->|"/24 192.168.5.0<br/>dec_ttl, src MAC = pub0"| T21
T20 -->|"нет маршрута<br/><i>default отсутствует</i>"| DROP20(["drop"])
T21{"<b>Таблица 21</b> — adjacency<br/>next-hop MAC + порт"}
T21 -->|"prio 100: be1..be4,<br/>hcif — статически"| OUT(["output: 2 / 3 / 4 / 5"])
T21 -->|"prio 90: клиент,<br/>из действия learn"| OUTC(["output: 1"])
T21 -->|"MAC неизвестен"| DROP21(["drop"])
classDef d fill:#7f1d1d,stroke:#ef4444,color:#fff
class DROP0,DROPV,DROP20,DROP21 d
```
Почему нумерация именно такая: `goto_table` в OpenFlow разрешает переход только
вперёд, поэтому обратный путь (15/16) обязан стоять **до** общей маршрутизации
(20), а не после неё.
## 3. Четыре потока маршрутизации
### R1. Клиент → бэкенд напрямую (публичный → приватный сегмент)
Балансировка не участвует: клиент обращается на `10.20.0.2:8080`, имея маршрут
через `192.168.5.20`.
```mermaid
sequenceDiagram
autonumber
participant C as Клиент 192.168.5.13
participant P as pub0 (1)
participant OF as Таблицы OpenFlow
participant Q as p2 (2)
participant B as be1 10.20.0.2
C->>P: ARP who-has 192.168.5.20
P->>OF: таблица 5 — ARP-респондер
OF-->>C: is-at 02:42:c0:a8:05:14
C->>P: IP src=192.168.5.13 dst=10.20.0.2, TTL=64
P->>OF: t0 learn (adj клиента в t21) → t10
Note over OF: t10: не VIP → goto 20
Note over OF: t20: LPM /24 10.20.0.0<br/>dec_ttl → 63, src MAC = p2
Note over OF: t21: dst MAC = 02:42:0a:14:00:02, порт 2
OF->>Q: пересылка
Q->>B: пакет, src остался клиентским
B->>Q: ответ src=10.20.0.2 dst=192.168.5.13
Q->>OF: t0 → t15 → ct(nat) сессии нет → t16 → t20
Note over OF: t20: LPM /24 192.168.5.0<br/>dec_ttl, src MAC = pub0
Note over OF: t21: prio 90 — запись, созданная learn
OF->>P: порт 1
P->>C: ответ
```
### R2. Транзит между приватными сегментами (be1 → be2)
Единственный поток, где узел работает как чистый маршрутизатор в обе стороны.
```mermaid
flowchart LR
BE1["be1<br/>10.20.0.2"] -->|"dst=10.30.0.2:8080<br/>TTL 64"| A["p2 (2)"]
A --> B["t0: in_port=2 → t15"]
B --> C["t15: tcp → ct(zone=1,nat)<br/>сессии в таблице нет —<br/>пакет не меняется"]
C --> D["t16 → t20"]
D --> E["t20: /24 10.30.0.0<br/>dec_ttl → 63<br/>src MAC = 02:42:0a:1e:00:01"]
E --> F["t21: dst MAC =<br/>02:42:0a:1e:00:02<br/>output:3"]
F --> G["p3 (3)"] --> BE2["be2<br/>10.30.0.2<br/><i>client=10.20.0.2</i>"]
```
Обратный путь `be2 → be1` симметричен: `t0 → t15 → t16 → t20 (/24 10.20.0.0)
→ t21 → p2`. Никакой трансляции ни в одном направлении.
### R3. Обратный трафик балансируемой сессии (бэкенд → клиент)
Тот же путь, что и R1-ответ, но с обязательным проходом через `ct`: именно там
`src` возвращается с адреса бэкенда на VIP.
```mermaid
flowchart LR
BE["be1 10.20.0.2:8080"] -->|"src=10.20.0.2<br/>dst=192.168.5.13"| A["p2 (2)"]
A --> B["t0: in_port=2 → t15"]
B --> C["t15: ct(zone=1, nat)<br/><b>un-DNAT</b>:<br/>src → 192.168.5.21:80"]
C --> D["t16 → t20"]
D --> E["t20: /24 192.168.5.0<br/>dec_ttl, src MAC = pub0"]
E --> F["t21 prio 90:<br/>MAC клиента из learn<br/>output:1"]
F --> CL["Клиент<br/>видит ответ от VIP"]
```
Почему обратный трафик вообще приходит на узел: у бэкендов профиль A — маршрут
на клиентский префикс `192.168.5.0/24` направлен на шлюз сегмента
(`10.20.0.1` / `10.30.0.1`), а `default` остаётся на шлюзе Docker. Собственный
исходящий трафик бэкенда через балансировщик не идёт.
### R4. Health-пробы — единственный трафик, порождённый узлом
```mermaid
sequenceDiagram
autonumber
participant H as hcd (ядро контейнера)
participant I as hcif-p2 (4)
participant OF as OpenFlow
participant Q as p2 (2)
participant B as be1
Note over H: Dialer.LocalAddr = 10.20.0.253
H->>I: SYN 10.20.0.253 → 10.20.0.2:8080
I->>OF: t0: in_port=4 → <b>сразу t20</b><br/>без learn и без ct
Note over OF: t20: /24 10.20.0.0, t21: output:2
OF->>Q: →
Q->>B: проба GET /healthz
B->>Q: SYN-ACK 10.20.0.2 → 10.20.0.253
Q->>OF: t0 → t15 → t16 → t20
Note over OF: t20: <b>/32 10.20.0.253</b> prio 32<br/>TTL не уменьшается — пакет<br/>адресован самому узлу
Note over OF: t21: output:4
OF->>I: →
I->>H: ответ, член up
```
Пробы идут с `.253`, а не с VIP, намеренно: иначе ответ бэкенда попал бы в
логику обратной трансляции (`t15`) и до пробера не дошёл.
## 4. Таблица решений
| Таблица | Матч | Действие | Комментарий |
|---|---|---|---|
| 0 | `in_port=1`, dst ∈ {VIP, .20, 10.20/24, 10.30/24} | `learn` → t10 | обучение сужено, чтобы в t21 не попадал broadcast-шум LAN |
| 0 | `in_port=1`, прочий IP | drop | |
| 0 | `in_port=2,3` | → t15 | обратный путь и транзит |
| 0 | `in_port=4,5` | → t20 | трафик стека узла, минуя ct и learn |
| 10 | `ip`, не VIP | → t20 | точка входа маршрутизации из публичного сегмента |
| 15 | `tcp` | `ct(zone=1, nat)` → t16 | un-DNAT для балансируемых сессий, no-op для транзита |
| 20 | `/32` адреса `.253` | prio 32, → t21 | TTL не уменьшается |
| 20 | `/24` три префикса | prio 24, `dec_ttl` + `mod_dl_src` | LPM через приоритет |
| 20 | остальное | prio 0, drop | **дефолтного маршрута нет** |
| 21 | `nw_dst` = be1..be4, hcif | prio 100, `mod_dl_dst` + `output` | статические MAC из `docker-compose.yml` |
| 21 | `nw_dst` = клиент | prio 90, из `learn` | hard_timeout 300 с |
## 5. Где поток обрывается
| Точка | Причина | Как увидеть |
|---|---|---|
| t0 prio 0 | не-IP и не-ARP: IPv6, DHCP, broadcast | `lbctl flows 0` |
| t0 prio 100 (pub) | IP из публичного сегмента не стенду | `lbctl flows 0` |
| t10 prio 150 | IP на VIP без листенера | `lbctl flows 10` |
| t20 prio 0 | назначение без маршрута | `lbctl flows 20 \| grep priority=0` |
| t21 prio 0 | next-hop MAC неизвестен (запись `learn` истекла) | `lbctl flows 21` |
## 6. Чего в этих потоках нет
- **ICMP-ошибок узел не генерирует** — ни `TTL exceeded`, ни `fragmentation
needed`. Пакет с TTL=1 просто исчезает, `traceroute` через стенд узел не
покажет, PMTUD не работает.
- **Фрагменты IP не обрабатываются**: правила матчат L4-заголовок, которого во
втором и последующих фрагментах нет.
- **ARP-резолвера next-hop нет.** MAC бэкендов статичны, MAC клиентов узнаются
действием `learn` — то есть только для тех, кто первым обратился к стенду.
- **Дефолтного маршрута нет** по построению: узел маршрутизирует только три
известных префикса.
- **Только IPv4.**
+149
View File
@@ -0,0 +1,149 @@
# Шаг 3 — план внедрения: листенер в приватном сегменте
**Дата:** 2026-08-17
**Предыдущий шаг:** [STEP2_SUMMARY.md](STEP2_SUMMARY.md)
**Итоги:** [STEP3_SUMMARY.md](STEP3_SUMMARY.md)
## Задача
Поддержать листенер балансировщика **в приватном сегменте** — в одном сегменте
или во всех сразу — при условиях:
1. листенер работает как публичный: принимает на порту 80 и транслирует на порт
бэкенда 8080;
2. бэкенд может стоять в одном сегменте с клиентом, отправившим запрос;
3. в ОС клиента и бэкенда не вносится ничего — ни адресов, ни маршрутов, ни
интерфейсов;
4. адресное пространство подсети задаёт заказчик, дробить её нельзя;
5. трафик внутри сегмента не блокируется;
6. единственный уровень влияния — OVS как data plane SDN и сама нода
балансировщика.
Ключевое требование: на бэкенд приходит пакет с исходным IP клиента.
## Почему прежняя схема не годится
Клиент и бэкенд — L2-соседи в одной подсети. Ответ бэкенда уходит клиенту
напрямую, минуя балансировщик: снять DNAT негде, клиент получает пакет от
`10.20.0.2:8080` вместо `10.20.0.100:80` и рвёт соединение.
Условия отсекают все обходные пути:
| Вариант | Почему не подходит |
|---|---|
| SNAT | уничтожает сохранение IP клиента |
| DSR (VIP на `lo` бэкенда) | нарушает условие 3, не транслирует порт (условие 1) |
| Изоляция портов сегмента (private VLAN) | нарушает условие 5, неприменимо к ВМ |
| Дробление подсети маршрутами `/25` на бэкенде | нарушает условия 3 и 4 |
Остаётся единственная предпосылка, совпадающая с условием 6: **порты клиента и
бэкенда — порты OVS**. Тогда кадр «бэкенд → клиент-сосед» проходит через
OpenFlow-таблицы даже при прямом L2-обмене, и обратную трансляцию есть где
выполнить.
В целевой среде это выполняется по построению: ВМ подключены к vSwitch. Текущий
стенд этому не соответствует — приватный сегмент коммутирует docker-бридж
`hpnn-p2`, а `br-lb` подключён к нему одним портом. Шаг 3 приводит стенд к
целевой модели.
## Решение
`br-lb` становится **коммутатором приватного сегмента** и выполняет трансляцию
на пути кадра. Для внутрисегментного трафика нет ни маршрутизации, ни
`dec_ttl`: с точки зрения ВМ клиент и бэкенд остаются L2-соседями.
**Прямой путь.** Клиент резолвит VIP через ARP-респондер OVS и шлёт кадр на MAC
шлюза сегмента. Таблица листенера кладёт сессию в слот, таблица DNAT
переписывает `VIP:80 → member:8080`, `src` не трогает, заменяет dst MAC на MAC
члена и отдаёт кадр в коммутацию.
**Обратный путь.** Бэкенд отвечает соседу напрямую: `src=member:8080`,
`dst=клиент`. Кадр приходит на порт OVS, где `ct(nat)` снимает трансляцию —
`src` возвращается к `VIP:80` — и кадр коммутируется в порт клиента.
**Прочий трафик сегмента** (`be↔be`, клиент к бэкенду напрямую, egress к
docker-шлюзу, ARP) коммутируется обычным образом, без трансляции.
## Топология после изменения
Каждая ВМ сегмента — отдельный порт `br-lb`. Порты `p2`/`p3` остаются как
аплинки сегмента к docker-шлюзу: через них идёт egress бэкендов (профиль A).
| Сегмент | Порт | ofport | Адрес |
|---|---|---|---|
| priv2 `10.20.0.0/24` | `p2` (аплинк) | 2 | — |
| | `hcif-p2` | 4 | `10.20.0.253` |
| | `be1` | 10 | `10.20.0.2` |
| | `be3` | 11 | `10.20.0.3` |
| | `cli2` | 12 | `10.20.0.10` |
| priv3 `10.30.0.0/24` | `p3` (аплинк) | 3 | — |
| | `hcif-p3` | 5 | `10.30.0.253` |
| | `be2` | 20 | `10.30.0.2` |
| | `be4` | 21 | `10.30.0.3` |
| | `cli3` | 22 | `10.30.0.10` |
| pub `192.168.5.0/24` | `pub0` | 1 | маршрутизируемый, без изменений |
Шлюз сегмента `10.20.0.1` и VIP `10.20.0.100` отвечают одним MAC — `$P2_MAC`.
Это и есть признак, по которому пайплайн отличает L3-трафик (адресован
маршрутизатору) от L2-трафика сегмента.
## Карта таблиц
Новые — 1, 24, 25; остальные сохраняют назначение шага 2.
| Таблица | Назначение |
|---|---|
| 0 | определение сегмента по `in_port` (`reg0`), обучение MAC и adjacency |
| 1 | классификация: ARP, ICMP, листенер, L3-трафик к шлюзу, коммутация |
| 5 | ARP-респондер (адреса узла и VIP), затем коммутация |
| 6 | ICMP echo-респондер |
| 10 | листенеры: публичный и приватные |
| 11 | таблица слотов (заливает `hcd`, не меняется) |
| 12 | применение члена пула: DNAT + L2-выдача либо DNAT + маршрутизация |
| 15/16 | обратный путь L3 (публичный и межсегментный) |
| 20/21 | маршрутизация и adjacency |
| **24** | обратная трансляция коммутируемого трафика сегмента |
| **25** | L2-коммутация сегмента: FDB, broadcast и unknown flood |
Регистры: `reg0` — номер сегмента, `reg1` — слот, `reg2` — член пула.
## Порядок работ
1. `lb/topology.env` — порты и адреса участников сегментов, VIP приватных
листенеров, переключатель `PRIV_LISTENERS`.
2. `scripts/attach-segment.sh` — veth из netns контейнера в `br-lb`, адрес, MAC
и маршруты назначаются снаружи (эмуляция гипервизора и DHCP).
`docker-compose.yml`: участники сегментов переводятся на `network_mode: none`;
добавляются клиенты `cli2` и `cli3`.
3. `lb/pipeline.sh` — таблицы 0/1/5/25: сегментация, обучение, коммутация.
Проверка связности сегмента до всякой балансировки.
4. Листенер: таблицы 10/12 и обратная трансляция в таблице 24.
5. `lb/lbctl.sh` — `trace` по приватному листенеру, `fdb`.
6. `scripts/verify.sh` — новая секция и регрессия существующих проверок.
7. Документация: `docs/STEP3_SUMMARY.md`, `README.md`,
`docs/PRIVATE_LB_DATAFLOW.md`, `docs/MANUAL_TEST_PLAN.md`.
## Критерии приёмки
- `curl http://10.20.0.100/` из `cli2` обслуживается всеми четырьмя членами;
- во всех ответах `client=10.20.0.10` — исходный IP клиента;
- `served=10.20.0.2:8080` — трансляция порта 80 → 8080 выполняется;
- TTL ответа от VIP равен TTL прямого ответа соседа по сегменту — путь остался
коммутируемым;
- внутрисегментная связность не пострадала: `cli2 → be1:8080` напрямую,
`be1 → be3:8080`, egress бэкенда мимо балансировщика;
- `PRIV_LISTENERS="p2"` выключает листенер в priv3, не затрагивая связность;
- все проверки шага 2 проходят.
## Риски
- **`br-lb` получает функцию L2-коммутатора** — обучение, flood, изоляция
сегментов. Новый класс правил и главный источник ошибок шага, поэтому этап 3
проверяется до подключения балансировки.
- **Хрупкость стенда:** veth, добавленные вручную, теряются при пересоздании
контейнера. `make attach` восстанавливает; в реальной среде порты создаёт
гипервизор.
- **Обратная трансляция опирается на `ct`**, как и прежде. Stateless-вариант —
задел следующего шага.
- **Нагрузка сегмента ложится на балансировщик**: весь внутрисегментный трафик
проходит через `br-lb`.
+137
View File
@@ -0,0 +1,137 @@
# Шаг 3 — итоги: листенер в приватном сегменте
**Дата:** 2026-08-17
**Статус:** выполнено, проверено после холодного перезапуска (86 из 86 проверок)
**План:** [STEP3_IMPLEMENTATION_PLAN.md](STEP3_IMPLEMENTATION_PLAN.md)
**Предыдущий шаг:** [STEP2_SUMMARY.md](STEP2_SUMMARY.md)
## Что сделано
Балансировщик получил листенеры в приватных сегментах — `10.20.0.100:80` и
`10.30.0.100:80` — и научился обслуживать клиента, который стоит **в одном
сегменте с бэкендами**. Исходный IP клиента сохраняется, порт транслируется
80 → 8080, в ОС виртуальных машин не настраивается ничего.
- **`br-lb` стал коммутатором приватного сегмента.** Каждая ВМ подключена
отдельным портом (`scripts/attach-segment.sh`), порты `p2`/`p3` остались
аплинками сегмента к шлюзу Docker. Появились таблицы 0 (сегментация и
обучение), 25 (FDB, broadcast и unknown flood) и 1 (классификация).
- **Обратная трансляция коммутируемого трафика — таблица 24.** Ответ бэкенда
клиенту-соседу уходит по L2 напрямую, но проходит через мост, где `ct(nat)`
возвращает источник к `VIP:80`.
- **Выдача на члена своего сегмента — без маршрутизации.** Таблица 12 при
совпадении сегмента клиента и члена меняет только MAC назначения и отдаёт кадр
в коммутацию: `dec_ttl` не выполняется, клиент и бэкенд остаются L2-соседями.
- **Пул общий с публичным листенером** — та же таблица слотов, тот же `pool_id`,
тот же дайджест `0a16713c5a9eb94d`. Демон `hcd` не изменён ни строкой.
- **Переключатель `PRIV_LISTENERS`** в `topology.env`: пусто — приватных
листенеров нет, `"p2"` — один сегмент, `"p2 p3"` — оба.
- **Клиенты сегментов** `cli2` (`10.20.0.10`) и `cli3` (`10.30.0.10`) — ВМ без
единого маршрута в таблице маршрутизации.
## Почему именно так
Клиент и бэкенд — L2-соседи, поэтому ответ бэкенда уходит клиенту напрямую.
Снять DNAT можно только там, где этот кадр виден. Остальные пути отпадали по
условиям задачи:
| Вариант | Почему отвергнут |
|---|---|
| SNAT | уничтожает сохранение IP клиента |
| DSR, VIP на `lo` бэкенда | требует настройки ОС ВМ, не транслирует порт |
| Изоляция портов сегмента | блокирует внутрисегментный трафик, неприменима к ВМ |
| Дробление подсети маршрутами `/25` | вмешательство в ОС ВМ и в адресацию заказчика |
Осталась единственная предпосылка, совпадающая с зоной влияния SDN: **порты ВМ
— порты OVS**. В целевой среде это выполняется по построению; стенд приведён к
той же модели.
## Результаты проверок
| Проверка | Результат |
|---|---|
| ВМ сегментов подключены портами OVS | 6 портов: be1, be3, cli2, be2, be4, cli3 |
| `ping 10.20.0.100` из `cli2` | отвечают ARP- и ICMP-респондеры OVS |
| Балансировка из приватного клиента | 24 запроса, задействованы все четыре члена |
| **IP клиента на бэкенде** | `client=10.20.0.10` во всех ответах |
| **Трансляция порта** | `served=10.20.0.2:8080` при листенере на 80 |
| Член в сегменте клиента | обслуживает запросы, ответ транслируется таблицей 24 |
| **TTL ответа от VIP** | 64 для членов своего сегмента, 63 — для членов другого |
| В ОС клиента нет маршрутов | `ip route` содержит только connected-запись |
| Внутрисегментная связность | `cli2 → be1` напрямую, `be1 → be3`, шлюз Docker — всё работает |
| Отказ члена под нагрузкой из `cli2` | слоты разошлись 341/342/341, 2 ошибки в окне обнаружения |
| Профиль A | egress бэкенда адресован шлюзу Docker, не маршрутизатору сегмента |
| `PRIV_LISTENERS="p2"` | листенер в priv3 выключен (счётчик drop растёт), связность цела |
| Регрессия шагов 1–2 | публичный листенер, транзит, health-check, fail-close, дренаж — без изменений |
## Главное измерение: TTL как признак пути
Ответы от `be1`/`be3` (сегмент клиента) приходят с **TTL 64**, от `be2`/`be4`
(другой сегмент) — с **TTL 63**. Это прямое доказательство того, что для соседей
по сегменту балансировщик не стал L3-хопом: он подменил MAC назначения,
оттранслировал адрес и отдал кадр коммутацией. С точки зрения ВМ ничего не
изменилось — она общается с соседом по подсети, как и раньше.
Для членов из другого сегмента работает прежний маршрутизируемый путь с
`dec_ttl`. Оба пути живут на одном пуле и одной таблице слотов, и клиент их не
различает.
## Отклонения от плана
1. **`load:0->NXM_OF_IN_PORT[]` не понадобился.** План предполагал hairpin —
выдачу в порт входа — и сброс `in_port`, чтобы OVS не подавил вывод. После
перевода каждой ВМ на собственный порт hairpin исчез: клиент и бэкенд входят
и выходят через разные порты. Риск снят самой архитектурой.
2. **Отдельная сеть для egress не потребовалась.** План предусматривал служебную
docker-сеть под default-маршрут. Оказалось достаточно оставить `p2`/`p3`
аплинками сегмента: бэкенд адресует кадр MAC-у шлюза Docker, мост его
коммутирует, и профиль A сохраняется в прежнем виде.
3. **Появилась таблица 1.** В плане классификация оставалась в таблице 0. Но
таблица 0 занята определением сегмента и обучением (два действия `learn` на
каждый порт), поэтому классификация вынесена отдельно — иначе правила
пришлось бы дублировать на каждый порт сегмента.
4. **`make` больше не требует `sudo`.** Под root его в системе может не быть;
цели подготовки хоста теперь подставляют `sudo` только при запуске не от root.
5. **Проверки 7 и 9 в `verify.sh` переформулированы.** Обе слушали аплинк `p2`,
через который health-пробы и egress больше не проходят: пробы идут
коммутацией на порт ВМ, а признаком профиля A стал MAC назначения (шлюз
Docker, а не маршрутизатор сегмента), а не отсутствие трафика на `p2`.
## Известные ограничения
- **Предпосылка архитектуры:** порты ВМ должны быть портами OVS. Если между
клиентом и бэкендом окажется коммутатор, который SDN не контролирует, кейс не
решается ничем, кроме SNAT.
- **Хрупкость стенда:** veth, созданные `attach-segment.sh`, исчезают при
пересоздании контейнера — нужен `make attach`. В целевой среде порты создаёт
гипервизор.
- **Весь внутрисегментный трафик проходит через `br-lb`** — пропускная
способность узла становится пропускной способностью сегмента. Замер k6 не
выполнен: в системе нет клиента k6; цель для сравнения —
`http://10.20.0.100/` против `http://192.168.5.21/`.
- **Обратная трансляция опирается на `ct`** — как и прежде. Stateless-вариант
(`nw_src=member, tp_src=8080 → VIP:80`) остаётся заделом: в сегменте он
потребует аккуратного исключения трафика, который бэкенд сам инициирует с
порта 8080.
- **Unicast ARP внутри сегмента рассылается флудом**, если MAC ещё не выучен —
обычное поведение обучающегося коммутатора, но без таймера старения записей
короче 300 с.
- **Приватный листенер только TCP и только IPv4**, как и публичный. UDP- и
L3-листенеров нет.
- Кворум, control plane в OVSDB, ICMP-ошибки и фрагменты — по-прежнему вне рамок.
## Задел на шаг 4
1. **Stateless-датапас** для обоих путей: явный un-DNAT вместо `ct(nat)`.
На коммутируемом пути это снимет последнюю зависимость от состояния.
2. **Control plane:** модель `Load_Balancer → Listener → Pool → Member` в OVSDB
вместо `topology.env`. Сейчас листенер, сегмент и состав пула описаны в трёх
местах, а добавление ВМ требует правки `topology.env`, `hc-config.sh` и
запуска `attach-segment.sh`.
3. **Старение FDB и защита от переполнения** — сейчас записи живут 300 с без
ограничения на число MAC в сегменте.
4. Второй узел LB: кворум наблюдений, генерации пулов, сравнение дайджестов.