Files
hpnn-proto/docs/PRIVATE_LB_DATAFLOW.md
T
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

16 KiB
Raw Blame History

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

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

Отличие от публичного случая: клиент может стоять в одном сегменте с бэкендами. Тогда ответ бэкенда уходит клиенту по L2 напрямую, минуя маршрутизацию, и снять с него трансляцию можно только там, где этот кадр виден. Поэтому в приватном сегменте узел работает коммутатором: каждая ВМ подключена отдельным портом моста.

Предпосылка всей схемы: порт ВМ — порт OVS. В целевой среде это выполняется по построению (ВМ подключены к vSwitch), в стенде порты создаёт scripts/attach-segment.sh (make attach). При этом в ОС виртуальных машин не настраивается ничего, адресация заказчика не меняется, а трафик внутри сегмента не блокируется.

1. Участники сегмента

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

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.

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. Второй поток: член пула в другом сегменте

Тот же листенер, но слот выбрал члена из соседнего сегмента — работает обычная маршрутизация.

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, коммутируется без трансляции.

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. Осмотр

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-ошибки и фрагменты — как и в публичном пути, вне рамок.