Files
hpnn-proto/docs/PUBLIC_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

11 KiB
Raw Permalink Blame History

Потоки данных: балансировка публичного трафика

Дата: 2026-08-18 (обновлено после шага 5 — динамический MAC/ARP) Область: путь пакета от клиента из 192.168.5.0/24 на публичный листенер 192.168.5.21:80 и обратно. Смежный документ: PRIVATE_LB_DATAFLOW.md — балансировка внутри приватного сегмента. Источник: lb/pipeline.sh, lb/topology.env, STEP5_SUMMARY.md.

Публичная балансировка целиком маршрутизируемая: клиент и бэкенды лежат в разных сегментах, узел выступает L3-хопом в обе стороны, TTL уменьшается на единицу на каждом направлении. Сетевой стек ядра контейнера в передаче трафика не участвует — все решения принимает OpenFlow.

1. Участники

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. Конвейер таблиц публичного пути

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 → бэкенд

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).

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 на бэкенде

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 резолвит 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. Осмотр

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 секунд — после простоя первая же новая сессия создаёт запись заново.