# Потоки данных: балансировка публичного трафика
**Дата:** 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`, [STEP5_SUMMARY.md](STEP5_SUMMARY.md).
Публичная балансировка целиком **маршрутизируемая**: клиент и бэкенды лежат в
разных сегментах, узел выступает L3-хопом в обе стороны, TTL уменьшается на
единицу на каждом направлении. Сетевой стек ядра контейнера в передаче трафика
не участвует — все решения принимает OpenFlow.
## 1. Участники
```mermaid
flowchart LR
CL["Клиент
192.168.5.13"]
subgraph BR["br-lb — OpenFlow datapath"]
direction TB
PUB["pub0 · ofport 1
macvlan @ enp3s0
02:42:c0:a8:05:14
адрес узла 192.168.5.20
VIP 192.168.5.21:80"]
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["0 сегмент по in_port
reg0 = 1"]
T0 --> T1{"1 классификация"}
T1 -->|"arp"| T5["5 ARP-респондер
за 192.168.5.20 и .21"]
T1 -->|"icmp echo на
адрес узла или VIP"| T6["6 ICMP-респондер"]
T1 -->|"ip на VIP, адрес узла
или приватные сети
+ learn adjacency"| T10
T1 -->|"прочий IP"| DROP1(["drop"])
T10{"10 листенер
tcp VIP:80"}
T10 -->|"multipath(symmetric_l4)
→ reg1 = слот"| T11["11 таблица слотов
reg1 → reg2
ведёт hcd"]
T10 -->|"IP на VIP без
листенера"| DROPV(["drop"])
T10 -->|"прочий IP: клиент
обратился к бэкенду
напрямую"| T20
T11 -->|"пул пуст"| DROPF(["drop — fail-close"])
T11 --> T12["12 DNAT
ct(commit, nat(dst=member:8080))"]
T12 --> T20
T20{"20 маршрутизация
LPM по приоритету"}
T20 -->|"dec_ttl,
mod_dl_src = MAC сегмента"| T21["21 adjacency
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: адресован стенду →
learn: adjacency клиента в t21
Note over OF: t10: multipath(symmetric_l4, basis=pool_id)
→ reg1 = номер слота
Note over OF: t11: слот → reg2 = член пула
Note over OF: t12: ct(commit, nat(dst=10.20.0.2:8080))
src не меняется
Note over OF: t20: LPM /24 10.20.0.0 → dec_ttl 63,
src MAC = MAC сегмента
Note over OF: t21: dst MAC = MAC be1, output порт be1
OF->>B: пакет
Note over B: client=192.168.5.13 — исходный адрес,
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
через шлюз сегмента 10.20.0.1"| A["порт be1 · 10"]
A --> B["t1: dl_dst = MAC шлюза
сегмента → t15"]
B --> C["t15: ct(zone=1, nat)
un-DNAT: src → 192.168.5.21:80"]
C --> D["t16 → t20: LPM /24 192.168.5.0
dec_ttl, src MAC = pub0"]
D --> E["t21 prio 90: MAC клиента
из действия learn"]
E --> CL["Клиент видит ответ
от 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
клиентский префикс — через узел"]
A --> C["10.30.0.0/24 via 10.20.0.1
соседний сегмент — через узел"]
A --> D["default via 10.20.0.254
шлюз Docker — мимо узла"]
B --> E["Ответы на балансируемые сессии
возвращаются на узел и
проходят обратную трансляцию"]
D --> F["Собственный egress бэкенда
(обновления, DNS, телеметрия)
узел не маршрутизирует"]
```
Проверить: `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 резолвит `hcd` через ARP ядра (шаг 5), не `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 neigh # MAC членов, резолвленный hcd через ARP
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 секунд** — после простоя первая же новая сессия
создаёт запись заново.