diff --git a/Makefile b/Makefile index b00ea94..85efe92 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,21 @@ SHELL := /bin/bash DC := docker compose +# Скрипты подготовки хоста требуют root. Под root sudo не нужен и может +# отсутствовать в системе. +SUDO := $(shell [ "$$(id -u)" = 0 ] || echo sudo) -.PHONY: help prereq shim shim-down build up down restart flows verify logs ports health slots drain enable metrics conns trace shell +.PHONY: help prereq shim shim-down build up down restart attach detach flows verify logs ports health slots drain enable metrics conns trace fdb shell help: - @echo "Стенд hpnn_v2 — шаг 1 (OVS: маршрутизация + балансировка)" + @echo "Стенд hpnn_v2 — OVS: маршрутизация, балансировка, коммутация сегментов" @echo @echo " make prereq загрузить модуль openvswitch на хосте" @echo " make shim поднять macvlan-shim (проверка стенда с самого хоста)" @echo " make shim-down снять shim" @echo " make up собрать образы и запустить стенд" @echo " make down остановить стенд и удалить сети" + @echo " make attach подключить ВМ сегментов к br-lb (после пересоздания)" + @echo " make detach снять порты сегментов с моста" @echo " make flows перечитать pipeline.sh и перезалить пайплайн" @echo " make verify прогнать проверки" @echo " make ports порты моста br-lb" @@ -20,18 +25,19 @@ help: @echo " make enable M=be1 вернуть член в балансировку" @echo " make metrics метрики Prometheus" @echo " make conns таблица соединений ct" - @echo " make trace SRC=192.168.5.7 [SPORT=40000] ofproto/trace сессии на VIP" + @echo " make trace SRC=192.168.5.7 [SPORT=40000] [SEG=pub|p2|p3] ofproto/trace" + @echo " make fdb таблица коммутации сегментов (выученные MAC)" @echo " make logs логи контейнеров" @echo " make shell shell в контейнере lb-router" prereq: - sudo scripts/host-prereq.sh + $(SUDO) scripts/host-prereq.sh shim: - sudo scripts/host-prereq.sh --shim + $(SUDO) scripts/host-prereq.sh --shim shim-down: - sudo scripts/host-prereq.sh --shim-down + $(SUDO) scripts/host-prereq.sh --shim-down build: $(DC) build @@ -43,8 +49,19 @@ up: prereq $(DC) exec -T lb-router curl -fsS --max-time 2 http://127.0.0.1:9111/status >/dev/null 2>&1 && break; \ sleep 1; \ done + @$(MAKE) --no-print-directory attach + @echo "Ждём, пока health-пробы увидят членов пула..." + @sleep 6 @$(MAKE) --no-print-directory health +# Порты ВМ сегментов создаёт гипервизор; в стенде их создаёт этот скрипт. +# Повторить нужно после пересоздания любого контейнера сегмента. +attach: + $(SUDO) scripts/attach-segment.sh + +detach: + $(SUDO) scripts/attach-segment.sh --detach + down: $(DC) down @@ -82,7 +99,10 @@ conns: $(DC) exec -T lb-router lbctl conns trace: - $(DC) exec -T lb-router lbctl trace $(SRC) $(SPORT) + $(DC) exec -T lb-router lbctl trace $(SRC) $(SPORT) $(SEG) + +fdb: + $(DC) exec -T lb-router lbctl fdb shell: $(DC) exec lb-router bash diff --git a/README.md b/README.md index 1b3fd9b..1b112d1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,16 @@ # hpnn_v2 — стенд OVS-маршрутизатора и балансировщика нагрузки Контейнерный прототип основы сервиса: один узел на Open vSwitch, который -одновременно выполняет две функции — **маршрутизацию** между сегментами и -**балансировку нагрузки** на четыре бэкенда. Всё форвардинг-решение принимается -в OpenFlow: сетевой стек ядра контейнера транзитный трафик не обрабатывает. -Состав пула ведёт подсистема health-check: мёртвые бэкенды выводятся из -балансировки, восстановившиеся возвращаются. +выполняет три функции — **маршрутизацию** между сегментами, **балансировку +нагрузки** на четыре бэкенда и **коммутацию приватных сегментов**. Всё +форвардинг-решение принимается в OpenFlow: сетевой стек ядра контейнера +транзитный трафик не обрабатывает. Состав пула ведёт подсистема health-check: +мёртвые бэкенды выводятся из балансировки, восстановившиеся возвращаются. + +Листенеры есть и в публичном сегменте, и в приватных. Приватный листенер +обслуживает клиента, стоящего **в одном сегменте с бэкендами**, сохраняя его +исходный IP и транслируя порт 80 на 8080 — при этом в ОС виртуальных машин не +настраивается ничего. Развивает дизайн-концепцию `../hpnn_v1/docs/design.md`. @@ -14,9 +19,14 @@ | 1 | Окружение, маршрутизация, балансировка | [план](docs/STEP1_IMPLEMENTATION_PLAN.md) | [итоги](docs/STEP1_SUMMARY.md) | | 2 | Health-check и таблица слотов | [план](docs/STEP2_IMPLEMENTATION_PLAN.md) | [итоги](docs/STEP2_SUMMARY.md) | | — | Расширение пула до четырёх бэкендов | [план](docs/CHANGE_POOL_4_MEMBERS_PLAN.md) | [итоги](docs/CHANGE_POOL_4_MEMBERS_SUMMARY.md) | +| 3 | Листенер в приватном сегменте, коммутация сегментов | [план](docs/STEP3_IMPLEMENTATION_PLAN.md) | [итоги](docs/STEP3_SUMMARY.md) | +| — | Разделение документации по потокам данных | [план](docs/CHANGE_SPLIT_DATAFLOW_DOCS_PLAN.md) | [итоги](docs/CHANGE_SPLIT_DATAFLOW_DOCS_SUMMARY.md) | -Диаграммы потоков данных подсистемы маршрутизации — -[docs/ROUTING_DATAFLOW.md](docs/ROUTING_DATAFLOW.md). +Потоки данных описаны отдельно для каждого вида балансировки: +[публичный трафик](docs/PUBLIC_LB_DATAFLOW.md) — клиент из клиентской сети на +`192.168.5.21:80`, целиком маршрутизируемый путь; +[приватный трафик](docs/PRIVATE_LB_DATAFLOW.md) — клиент внутри приватного +сегмента, рядом с бэкендами, на `10.20.0.100:80`. ## Топология @@ -25,23 +35,34 @@ │ [ enp3s0, macvlan ] сеть 1 — публичный сегмент │ pub0 · ofport 1 - ┌─────────────────┴─────────────────────┐ - │ hpnn-lb (Open vSwitch, br-lb) │ узел 192.168.5.20 - │ маршрутизатор + балансировщик │ VIP 192.168.5.21 - │ hcd: health-check + таблица слотов │ - └──┬──────────────────────────────┬─────┘ - p2 ·2 │ 10.20.0.1 10.30.0.1 │ p3 ·3 - hcif-p2 ·4 10.20.0.253 10.30.0.253 │ hcif-p3 ·5 (источники проб) - сеть 2 │ 10.20.0.0/24 сеть 3 │ 10.30.0.0/24 - hpnn-be1 │ 10.20.0.2 hpnn-be2 │ 10.30.0.2 - hpnn-be3 │ 10.20.0.3 hpnn-be4 │ 10.30.0.3 + ┌─────────────────┴─────────────────────────────┐ + │ hpnn-lb (Open vSwitch, br-lb) │ узел 192.168.5.20 + │ маршрутизатор + балансировщик + коммутатор │ VIP 192.168.5.21 + │ hcd: health-check + таблица слотов │ + └──┬─────────────────────────────────────┬──────┘ + сеть 2 │ 10.20.0.0/24 сеть 3 │ 10.30.0.0/24 + шлюз ·1 │ VIP 10.20.0.100:80 шлюз ·1 │ VIP 10.30.0.100:80 + │ │ + p2 ·2 ──┤ аплинк к шлюзу Docker .254 p3 ·3 ├── аплинк .254 + hcif-p2·4│ 10.20.0.253 (пробы) hcif-p3 ·5│ 10.30.0.253 + be1 ·10 │ 10.20.0.2:8080 be2 ·20│ 10.30.0.2:8080 + be3 ·11 │ 10.20.0.3:8080 be4 ·21│ 10.30.0.3:8080 + cli2 ·12 │ 10.20.0.10 (клиент) cli3 ·22│ 10.30.0.10 ``` +Каждая ВМ приватного сегмента — **отдельный порт моста**. Так балансировщик +видит и ответ бэкенда клиенту-соседу по подсети, а значит может снять с него +трансляцию. В целевой среде это выполняется само собой: ВМ подключены к +vSwitch. В стенде порты создаёт [scripts/attach-segment.sh](scripts/attach-segment.sh) +(`make attach`) — он же назначает адреса и маршруты снаружи, как это делают +гипервизор и DHCP. + | Контейнер | Роль | |---|---| -| `hpnn-lb` | OVS, все три сети, маршрутизация и балансировка | +| `hpnn-lb` | OVS: маршрутизация, балансировка, коммутация сегментов | | `hpnn-be1`, `hpnn-be3` | web-сервис на Go в приватном сегменте 2 | | `hpnn-be2`, `hpnn-be4` | тот же сервис в приватном сегменте 3 | +| `hpnn-cli2`, `hpnn-cli3` | клиенты внутри сегментов; в их ОС настроен только адрес | ### Адресный план @@ -49,7 +70,9 @@ |---|---| | Клиентская сеть | `192.168.5.0/24` | | Адрес узла в публичном сегменте | `192.168.5.20` | -| **VIP балансировщика** | **`192.168.5.21:80`** | +| **VIP публичного листенера** | **`192.168.5.21:80`** | +| **VIP приватных листенеров** | **`10.20.0.100:80`, `10.30.0.100:80`** | +| Клиенты внутри сегментов | `10.20.0.10`, `10.30.0.10` | | Шлюз OVS в сети 2 / бэкенды be1, be3 | `10.20.0.1` / `10.20.0.2:8080`, `10.20.0.3:8080` | | Шлюз OVS в сети 3 / бэкенды be2, be4 | `10.30.0.1` / `10.30.0.2:8080`, `10.30.0.3:8080` | | Источники health-проб (internal-порты OVS) | `10.20.0.253`, `10.30.0.253` | @@ -64,11 +87,15 @@ OpenFlow** — на интерфейсах они не настроены. ARP- ## Запуск ```bash -make up # modprobe openvswitch + сборка образов + запуск +make up # modprobe openvswitch + сборка образов + запуск + attach make verify # проверки make down # остановка ``` +`make up` вызывает `make attach`: порты ВМ приватных сегментов создаются +скриптом, а не Docker. Повторить `make attach` нужно после пересоздания любого +контейнера сегмента — вручную созданные veth вместе с ним исчезают. + Проверка с самого хоста требует macvlan-shim: интерфейс macvlan контейнера и физический интерфейс хоста по устройству macvlan не видят друг друга. Клиенту из 192.168.5.0/24 никакой shim не нужен. @@ -91,6 +118,21 @@ ping 192.168.5.21 # VIP for i in $(seq 20); do curl -s http://192.168.5.21/; done ``` +Из приватного сегмента — с клиента, который стоит рядом с бэкендами: + +```bash +docker compose exec cli2 curl -s http://10.20.0.100/ +docker compose exec cli2 ip route # маршрутов нет: настроен только адрес +``` + +``` +backend=be1 time=2026-08-17 15:29:23 MSK client=10.20.0.10:46064 served=10.20.0.2:8080 req=1 +``` + +`client=10.20.0.10` — исходный адрес клиента, `served=…:8080` — трансляция +порта 80 → 8080. Ответ бэкенда клиенту-соседу уходит по L2 напрямую, но +проходит через мост, где таблица 24 снимает трансляцию. + ``` backend=be1 time=2026-08-16 21:35:02 MSK client=192.168.5.13:47734 served=10.20.0.2:8080 req=24 backend=be3 time=2026-08-16 21:35:02 MSK client=192.168.5.13:47740 served=10.20.0.3:8080 req=11 @@ -187,6 +229,8 @@ docker compose unpause be1 # возвращается за rise × interval = make ports # порты моста make conns # таблица соединений ct make trace SRC=192.168.5.13 # ofproto/trace сессии клиент -> VIP +make trace SRC=10.20.0.10 SEG=p2 # то же для приватного листенера +make fdb # выученные MAC сегментов и счётчики рассылки make flows # перечитать pipeline.sh и перезалить правила docker compose exec lb-router lbctl flows 21 # правила конкретной таблицы docker compose exec lb-router lbctl status # состояние пула в JSON @@ -200,20 +244,43 @@ docker compose exec lb-router lbctl status # состояние пула в | Таблица | Назначение | |---|---| -| 0 | Классификация по входному порту; обучение MAC клиентов для обратного пути | -| 5 | ARP-респондер для адресов узла, VIP и источников проб | +| 0 | Сегмент по входному порту (`reg0`); обучение MAC (FDB) и adjacency | +| 1 | Классификация: ARP, ICMP, листенер, трафик к маршрутизатору, коммутация | +| 5 | ARP-респондер для адресов узла, VIP и источников проб; прочий ARP — в коммутацию | | 6 | ICMP echo-респондер для адресов узла и VIP | | 10 | Листенеры: `VIP:80` → `multipath` кладёт номер слота в `reg1`; прочий IP → маршрутизация | | 11 | **Таблица слотов**: `reg1` → `reg2` (член пула). Ведёт демон `hcd` | -| 12 | DNAT выбранным членом пула (`ct(commit, nat)`) | -| 15, 16 | Обратный путь: `ct(nat)` снимает DNAT, источник снова становится VIP | +| 12 | Применение члена: DNAT и выдача — коммутацией либо через маршрутизацию | +| 15, 16 | Обратный путь L3: `ct(nat)` снимает DNAT, источник снова становится VIP | | 20 | Маршрутизация: приоритет = длина префикса, `dec_ttl`, MAC источника | | 21 | Adjacency: MAC next-hop и выходной порт | +| 24 | Обратная трансляция коммутируемого трафика сегмента | +| 25 | L2-коммутация сегмента: FDB, broadcast и рассылка неизвестного unicast | -Регистры: `reg1` — номер слота, `reg2` — идентификатор члена пула. +Регистры: `reg0` — сегмент (1 — публичный, 2 и 3 — приватные), `reg1` — номер +слота, `reg2` — идентификатор члена пула. Нумерация таблиц не произвольна: `goto_table` разрешает переход только вперёд, -поэтому обратный путь (15/16) стоит до общей маршрутизации (20). +поэтому обратный путь (15/16) стоит до общей маршрутизации (20), а коммутация +(24/25) — после неё: в неё попадают и кадры, прошедшие DNAT в таблице 12. + +### Два пути внутри одного пула + +Приватный листенер и публичный делят пул, таблицу слотов и хэш. Различается +только выдача: + +- **член в сегменте клиента** — меняется лишь MAC назначения, кадр отдаётся в + коммутацию. `dec_ttl` не выполняется: клиент и бэкенд остаются L2-соседями, + и ответ приходит к клиенту с исходным TTL. Обратную трансляцию делает + таблица 24, когда бэкенд отвечает соседу напрямую; +- **член в другом сегменте либо публичный листенер** — прежний маршрутизируемый + путь через таблицы 20/21 с `dec_ttl` и обратной трансляцией в таблице 15. + +Для клиента оба пути неотличимы: ответ приходит от `VIP:80`. + +Какие приватные листенеры подняты, задаёт `PRIV_LISTENERS` в +[lb/topology.env](lb/topology.env): пусто — ни одного, `"p2"` — один сегмент, +`"p2 p3"` — оба. ### Чего нет у чисто-OpenFlow узла diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index dc95aac..48e4aa6 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -5,17 +5,24 @@ # - через балансировщик маршрутизируются только клиентские префиксы и # соседний приватный сегмент, то есть ответы на балансируемые сессии и # транзит между сегментами. +# +# С шага 3 порт сегмента создаёт гипервизор (в стенде — +# scripts/attach-segment.sh), он же назначает адрес и эти маршруты: внутри ОС +# бэкенда не настраивается ничего. Переменные ниже оставлены для запуска стенда +# в прежней схеме, с docker-сетями. set -eu -: "${LB_GW:?не задан LB_GW}" +: "${LB_GW:=}" : "${VIA_LB_ROUTES:=}" -for net in $(echo "$VIA_LB_ROUTES" | tr ',' ' '); do - ip route replace "$net" via "$LB_GW" - echo "[backend] маршрут $net via $LB_GW" -done +if [ -n "$LB_GW" ]; then + for net in $(echo "$VIA_LB_ROUTES" | tr ',' ' '); do + ip route replace "$net" via "$LB_GW" + echo "[backend] маршрут $net via $LB_GW" + done +fi echo "[backend] таблица маршрутизации:" -ip route +ip route || true exec /usr/local/bin/backend "$@" diff --git a/docker-compose.yml b/docker-compose.yml index 5a6189a..9e3d8f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,15 @@ services: mac_address: "02:42:0a:1e:00:01" restart: unless-stopped - # Контейнер 2: бэкенд в приватном сегменте 2. + # Бэкенды и клиенты приватных сегментов (шаг 3). + # + # network_mode: none — у контейнера нет ни одной docker-сети. Порт сегмента + # создаёт scripts/attach-segment.sh: veth напрямую в br-lb, адрес, MAC и + # маршруты назначаются снаружи. Так стенд повторяет целевую среду, где ВМ + # подключены к vSwitch, и балансировщик видит весь трафик сегмента — включая + # ответ бэкенда клиенту-соседу по подсети. + # + # Адреса и MAC перечислены в lb/topology.env — единственном источнике правды. be1: build: ./backend container_name: hpnn-be1 @@ -38,16 +46,10 @@ services: cap_add: [NET_ADMIN] environment: TZ: Europe/Moscow - LB_GW: 10.20.0.1 - VIA_LB_ROUTES: "192.168.5.0/24,10.30.0.0/24" - networks: - priv2: - ipv4_address: 10.20.0.2 - mac_address: "02:42:0a:14:00:02" + network_mode: none depends_on: [lb-router] restart: unless-stopped - # Контейнер 3: копия контейнера 2 в приватном сегменте 3. be2: build: ./backend container_name: hpnn-be2 @@ -55,18 +57,12 @@ services: cap_add: [NET_ADMIN] environment: TZ: Europe/Moscow - LB_GW: 10.30.0.1 - VIA_LB_ROUTES: "192.168.5.0/24,10.20.0.0/24" - networks: - priv3: - ipv4_address: 10.30.0.2 - mac_address: "02:42:0a:1e:00:02" + network_mode: none depends_on: [lb-router] restart: unless-stopped - # Второй бэкенд в приватном сегменте 2. Четыре члена пула вместо двух дают - # содержательную проверку раскладки слотов: при выбытии одного члена его - # слоты делятся между тремя оставшимися, а не достаются единственному. + # Четыре члена пула вместо двух дают содержательную проверку раскладки слотов: + # при выбытии одного члена его слоты делятся между тремя оставшимися. be3: build: ./backend container_name: hpnn-be3 @@ -74,16 +70,10 @@ services: cap_add: [NET_ADMIN] environment: TZ: Europe/Moscow - LB_GW: 10.20.0.1 - VIA_LB_ROUTES: "192.168.5.0/24,10.30.0.0/24" - networks: - priv2: - ipv4_address: 10.20.0.3 - mac_address: "02:42:0a:14:00:03" + network_mode: none depends_on: [lb-router] restart: unless-stopped - # Второй бэкенд в приватном сегменте 3. be4: build: ./backend container_name: hpnn-be4 @@ -91,12 +81,36 @@ services: cap_add: [NET_ADMIN] environment: TZ: Europe/Moscow - LB_GW: 10.30.0.1 - VIA_LB_ROUTES: "192.168.5.0/24,10.20.0.0/24" - networks: - priv3: - ipv4_address: 10.30.0.3 - mac_address: "02:42:0a:1e:00:03" + network_mode: none + depends_on: [lb-router] + restart: unless-stopped + + # Клиенты внутри приватных сегментов — те самые ВМ, что живут рядом с + # бэкендами. Образ бэкенда переиспользован ради curl и iproute2; сервис не + # запускается. В их ОС не настраивается ничего, кроме адреса. + cli2: + build: ./backend + container_name: hpnn-cli2 + hostname: cli2 + entrypoint: ["sleep", "infinity"] + healthcheck: + disable: true + environment: + TZ: Europe/Moscow + network_mode: none + depends_on: [lb-router] + restart: unless-stopped + + cli3: + build: ./backend + container_name: hpnn-cli3 + hostname: cli3 + entrypoint: ["sleep", "infinity"] + healthcheck: + disable: true + environment: + TZ: Europe/Moscow + network_mode: none depends_on: [lb-router] restart: unless-stopped @@ -118,8 +132,12 @@ networks: config: - subnet: 192.168.55.0/24 - # Сеть 2 — приватный сегмент бэкенда be1. Шлюз Docker смещён на .254, - # чтобы адрес .1 занял OVS-роутер и не конфликтовал с host-бриджем. + # Сеть 2 — приватный сегмент 10.20.0.0/24. С шага 3 к ней подключён только + # балансировщик: его порт p2 служит аплинком сегмента к шлюзу Docker (.254), + # через который бэкенды выходят наружу (профиль A). ВМ сегмента подключены + # напрямую к br-lb — см. scripts/attach-segment.sh. + # + # Шлюз Docker смещён на .254, чтобы адрес .1 занял OVS-роутер. priv2: driver: bridge driver_opts: diff --git a/docs/CHANGE_SPLIT_DATAFLOW_DOCS_PLAN.md b/docs/CHANGE_SPLIT_DATAFLOW_DOCS_PLAN.md new file mode 100644 index 0000000..8b9cd5b --- /dev/null +++ b/docs/CHANGE_SPLIT_DATAFLOW_DOCS_PLAN.md @@ -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` в репозитории не осталось. diff --git a/docs/CHANGE_SPLIT_DATAFLOW_DOCS_SUMMARY.md b/docs/CHANGE_SPLIT_DATAFLOW_DOCS_SUMMARY.md new file mode 100644 index 0000000..daa5503 --- /dev/null +++ b/docs/CHANGE_SPLIT_DATAFLOW_DOCS_SUMMARY.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` — ссылка в перечне документации. + +Ссылок на удалённый документ в репозитории не осталось, кроме упоминаний в +плане и в этих итогах — там они описывают само изменение. diff --git a/docs/MANUAL_TEST_PLAN.md b/docs/MANUAL_TEST_PLAN.md index 7e335cd..7a89881 100644 --- a/docs/MANUAL_TEST_PLAN.md +++ b/docs/MANUAL_TEST_PLAN.md @@ -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 diff --git a/docs/PRIVATE_LB_DATAFLOW.md b/docs/PRIVATE_LB_DATAFLOW.md new file mode 100644 index 0000000..e37b162 --- /dev/null +++ b/docs/PRIVATE_LB_DATAFLOW.md @@ -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
аплинк к шлюзу Docker"] + HC2["hcif-p2 · 4
10.20.0.253 в ядре"] + B1["be1 · 10"] + B3["be3 · 11"] + C2["cli2 · 12"] + end + end + + CLI2["cli2 10.20.0.10
клиент: настроен только адрес"] + BE1["be1 10.20.0.2:8080"] + BE3["be3 10.20.0.3:8080"] + GW["шлюз Docker 10.20.0.254
egress бэкендов"] + OTHER["сегмент 3 — 10.30.0.0/24
be2, be4, cli3"] + + C2 <--> CLI2 + B1 <--> BE1 + B3 <--> BE3 + P2 <--> GW + S2 -.->|"маршрутизация
таблицы 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["0 сегмент по in_port → reg0
learn FDB (eth_src → порт)
learn adjacency (для адресов своей подсети)"] + T0 --> T1{"1 классификация
по MAC назначения"} + + T1 -->|"arp"| T5["5 ARP-респондер
за VIP, шлюз, .253"] + T1 -->|"icmp echo на VIP
или шлюз"| T6["6 ICMP-респондер"] + T1 -->|"dl_dst = MAC шлюза
+ nw_dst = VIP:80"| T10 + T1 -->|"dl_dst = MAC шлюза
прочий IP"| T15["15 обратный путь L3
ct(nat) — для сессий
из другого сегмента"] + T1 -->|"прочее —
трафик между ВМ"| T24 + + T5 -->|"не наш адрес"| T25 + T10{"10 листенер
tcp VIP:80"} --> T11["11 таблица слотов
reg1 → reg2 (общая
с публичным листенером)"] + T11 -->|"пул пуст"| DROPF(["drop — fail-close"]) + T11 --> T12{"12 применение члена"} + + T12 -->|"член в сегменте клиента
mod_dl_dst + ct(commit,nat)
без dec_ttl"| T25 + T12 -->|"член в другом сегменте
ct(commit,nat)"| T20["20/21 маршрутизация
dec_ttl, adjacency"] + T20 --> OUTR(["output: порт члена
в другом сегменте"]) + + T15 --> T16["16 пост-ct"] --> T20 + T24{"24 обратная трансляция
сегмента"} + T24 -->|"nw_src = член, tp_src = 8080
ct(nat): src → VIP:80"| T25 + T24 -->|"прочее — без изменений"| T25 + T25{"25 коммутация
FDB по eth_dst"} --> OUTL(["output: порт ВМ"]) + T25 -->|"broadcast или
неизвестный MAC"| FLOOD(["рассылка по сегменту,
кроме входного порта"]) + + 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,
ct(commit, nat(dst=10.20.0.2:8080))
dec_ttl не выполняется + OF->>B: t25: FDB → порт be1 + Note over B: client=10.20.0.10 — исходный адрес клиента
served=10.20.0.2:8080 — трансляция 80 → 8080 + B->>OF: ответ src=10.20.0.2:8080 → 10.20.0.10,
dst MAC = MAC клиента (сосед по L2) + Note over OF: t1: dl_dst ≠ MAC шлюза → t24 + Note over OF: t24: 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
10.20.0.10"] -->|"dst=10.20.0.100:80"| A["t0/t1: reg0=2,
dl_dst = MAC шлюза"] + A --> B["t10 → t11 → t12
reg2 указывает на be2:
DNAT 10.30.0.2:8080"] + B --> D["t20: LPM /24 10.30.0.0
dec_ttl, src MAC = p3"] + D --> E["t21: dst MAC = be2,
output порт be2"] + E --> BE2["be2
client=10.20.0.10"] + BE2 -->|"маршрут 10.20.0.0/24
via 10.30.0.1"| F["t1: dl_dst = MAC шлюза → t15
ct(nat): src → 10.20.0.100:80"] + F --> G["t20 → t21: adjacency клиента,
выученная в t0"] --> C +``` + +Клиенту оба пути неотличимы — ответ приходит от `10.20.0.100:80`. Разница видна +только по TTL: **64** от соседа по сегменту против **63** через маршрутизацию. + +## 5. Остальной трафик сегмента + +Балансировка не мешает обычной работе сегмента: всё, что не адресовано VIP, +коммутируется без трансляции. + +```mermaid +flowchart TD + A["cli2 → be1:8080 напрямую,
минуя VIP"] --> B["t1: dl_dst = MAC be1 → t24"] + B --> C["t24: ct без записи —
изменений нет"] --> D["t25: FDB → порт be1"] + + E["be1 → be3:8080
внутри сегмента"] --> F["t1 → t24 → t25"] + F --> G["t25: FDB → порт be3"] + + H["be1 → 1.1.1.1
default via 10.20.0.254"] --> I["t1 → t24 → t25"] + I --> J["t25: FDB → аплинк p2 →
docker-бридж → NAT хоста
профиль A: мимо балансировки"] + + K["ARP между ВМ"] --> L["t5: не наш адрес → t25"] + L --> M["t25: рассылка по портам сегмента"] + + N["health-проба
10.20.0.253 → be1:8080"] --> O["t0: in_port = hcif-p2
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-ошибки и фрагменты — как и в публичном + пути, вне рамок. diff --git a/docs/PUBLIC_LB_DATAFLOW.md b/docs/PUBLIC_LB_DATAFLOW.md new file mode 100644 index 0000000..276c148 --- /dev/null +++ b/docs/PUBLIC_LB_DATAFLOW.md @@ -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["Клиент
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 из `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 секунд** — после простоя первая же новая сессия + создаёт запись заново. diff --git a/docs/ROUTING_DATAFLOW.md b/docs/ROUTING_DATAFLOW.md deleted file mode 100644 index 68ddfe5..0000000 --- a/docs/ROUTING_DATAFLOW.md +++ /dev/null @@ -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["Клиент
192.168.5.0/24"] - - 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 .21"] - P2["p2 — ofport 2
02:42:0a:14:00:01
шлюз 10.20.0.1"] - P3["p3 — ofport 3
02:42:0a:1e:00:01
шлюз 10.30.0.1"] - HC2["hcif-p2 — ofport 4
10.20.0.253 в ядре"] - HC3["hcif-p3 — ofport 5
10.30.0.253 в ядре"] - 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 — демон
health-check"] - - CL <--> PUB - P2 <--> BE1 - P2 <--> BE3 - P3 <--> BE2 - P3 <--> BE4 - HCD <--> HC2 - HCD <--> HC3 -``` - -## 2. Конвейер таблиц: путь маршрутизируемого пакета - -Красным помечены точки отбрасывания, пунктиром — ответвление в балансировку. - -```mermaid -flowchart TD - IN(["Пакет на порту моста"]) --> T0 - - T0{"Таблица 0
классификация по in_port
и типу трафика"} - - T0 -->|"arp"| T5["5 ARP-респондер
для .20/.21/10.20.0.1/
10.30.0.1/.253"] - T0 -->|"icmp echo
на адреса узла"| T6["6 ICMP echo-респондер"] - T0 -->|"in_port=1 (pub)
+ dst ∈ {VIP, узел,
10.20/24, 10.30/24}"| LEARN["learn → запись
adjacency клиента
в таблицу 21
(priority 90, 300 c)"] - T0 -->|"in_port=2,3 (priv)"| T15["15 обратный путь"] - T0 -->|"in_port=4,5 (hcif)"| T20 - T0 -->|"иначе"| DROP0(["drop"]) - - LEARN --> T10{"Таблица 10
листенеры"} - T10 -.->|"tcp dst=VIP:80"| LB["11/12 слот → член пула,
DNAT ct(commit,nat)"] - T10 -->|"прочий IP
(прямое обращение
к бэкенду)"| T20 - T10 -->|"IP на VIP
без листенера"| DROPV(["drop"]) - LB --> T20 - - T15 -->|"tcp"| CT["ct(zone=1, nat)
снятие DNAT,
если сессия известна"] --> T16["16 пост-ct"] --> T20 - T15 -->|"прочий IP"| T20 - - T20{"Таблица 20 — LPM
приоритет = длина префикса"} - T20 -->|"/32 10.20.0.253
/32 10.30.0.253
адрес узла, TTL не трогаем"| T21 - T20 -->|"/24 10.20.0.0
dec_ttl, src MAC = p2"| T21 - T20 -->|"/24 10.30.0.0
dec_ttl, src MAC = p3"| T21 - T20 -->|"/24 192.168.5.0
dec_ttl, src MAC = pub0"| T21 - T20 -->|"нет маршрута
default отсутствует"| DROP20(["drop"]) - - T21{"Таблица 21 — adjacency
next-hop MAC + порт"} - T21 -->|"prio 100: be1..be4,
hcif — статически"| OUT(["output: 2 / 3 / 4 / 5"]) - T21 -->|"prio 90: клиент,
из действия 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
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
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
10.20.0.2"] -->|"dst=10.30.0.2:8080
TTL 64"| A["p2 (2)"] - A --> B["t0: in_port=2 → t15"] - B --> C["t15: tcp → ct(zone=1,nat)
сессии в таблице нет —
пакет не меняется"] - C --> D["t16 → t20"] - D --> E["t20: /24 10.30.0.0
dec_ttl → 63
src MAC = 02:42:0a:1e:00:01"] - E --> F["t21: dst MAC =
02:42:0a:1e:00:02
output:3"] - F --> G["p3 (3)"] --> BE2["be2
10.30.0.2
client=10.20.0.2"] -``` - -Обратный путь `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
dst=192.168.5.13"| A["p2 (2)"] - A --> B["t0: in_port=2 → t15"] - B --> C["t15: ct(zone=1, nat)
un-DNAT:
src → 192.168.5.21:80"] - C --> D["t16 → t20"] - D --> E["t20: /24 192.168.5.0
dec_ttl, src MAC = pub0"] - E --> F["t21 prio 90:
MAC клиента из learn
output:1"] - F --> CL["Клиент
видит ответ от 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 → сразу t20
без 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: /32 10.20.0.253 prio 32
TTL не уменьшается — пакет
адресован самому узлу - 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.** diff --git a/docs/STEP3_IMPLEMENTATION_PLAN.md b/docs/STEP3_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..ed13b6f --- /dev/null +++ b/docs/STEP3_IMPLEMENTATION_PLAN.md @@ -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`. diff --git a/docs/STEP3_SUMMARY.md b/docs/STEP3_SUMMARY.md new file mode 100644 index 0000000..65ea0b6 --- /dev/null +++ b/docs/STEP3_SUMMARY.md @@ -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: кворум наблюдений, генерации пулов, сравнение дайджестов. diff --git a/lb/lbctl.sh b/lb/lbctl.sh index dabbb14..b08ca45 100755 --- a/lb/lbctl.sh +++ b/lb/lbctl.sh @@ -19,8 +19,11 @@ lbctl — осмотр датапаса стенда hpnn_v2 enable <член> вернуть член в балансировку metrics метрики в формате Prometheus conns таблица соединений ct (зона $CT_ZONE) - trace [src_port] - ofproto/trace для TCP-сессии клиент -> VIP:$VIP_PORT + fdb таблица коммутации сегментов: выученные MAC и порты + trace [src_port] [сегмент] + ofproto/trace для TCP-сессии клиент -> VIP. + Сегмент: pub (по умолчанию), p2, p3 — определяет порт входа + и адрес листенера reload перегенерировать и применить пайплайн USAGE } @@ -68,11 +71,30 @@ metrics) conns) ovs-appctl dpctl/dump-conntrack | grep -F "zone=$CT_ZONE" || echo "соединений нет" ;; +fdb) + echo "Выученные MAC (таблица 25, reg0 — номер сегмента):" + # priority=100 — только записи, созданные обучением; правила рассылки + # (priority 50 и 10) отбрасываются, иначе маска broadcast выглядела бы + # как выученный MAC. + ovs-ofctl -O "$OF" dump-flows "$BR" table=25 \ + | grep 'priority=100' \ + | sed -n 's/.*n_packets=\([0-9]*\).*reg0=\(0x[0-9a-f]*\).*dl_dst=\([0-9a-f:]*\).*output:\([0-9]*\).*/ сегмент \2 MAC \3 -> порт \4 (пакетов \1)/p' \ + | sort + echo "Рассылка по сегменту (неизвестный MAC и broadcast):" + ovs-ofctl -O "$OF" dump-flows "$BR" table=25 \ + | sed -n 's/.*n_packets=\([0-9]*\).*priority=\(50\|10\),reg0=\(0x[0-9a-f]*\).*/ сегмент \3 приоритет \2: пакетов \1/p' \ + | sort + ;; trace) src_ip="${2:?укажите IP клиента}" src_port="${3:-40000}" + case "${4:-pub}" in + p2) in_port=$CLI2_OFPORT; gw_mac=$P2_MAC; vip=$P2_VIP; vip_port=$PRIV_VIP_PORT ;; + p3) in_port=$CLI3_OFPORT; gw_mac=$P3_MAC; vip=$P3_VIP; vip_port=$PRIV_VIP_PORT ;; + *) in_port=$PUB_OFPORT; gw_mac=$PUB_MAC; vip=$VIP; vip_port=$VIP_PORT ;; + esac ovs-appctl ofproto/trace "$BR" \ - "in_port=$PUB_OFPORT,dl_src=00:11:22:33:44:55,dl_dst=$PUB_MAC,dl_type=0x0800,nw_src=$src_ip,nw_dst=$VIP,nw_proto=6,nw_ttl=64,tp_src=$src_port,tp_dst=$VIP_PORT,tcp_flags=syn" + "in_port=$in_port,dl_src=00:11:22:33:44:55,dl_dst=$gw_mac,dl_type=0x0800,nw_src=$src_ip,nw_dst=$vip,nw_proto=6,nw_ttl=64,tp_src=$src_port,tp_dst=$vip_port,tcp_flags=syn" ;; reload) "$LB_DIR/apply.sh" diff --git a/lb/pipeline.sh b/lb/pipeline.sh index 7e52a06..01cf2e4 100755 --- a/lb/pipeline.sh +++ b/lb/pipeline.sh @@ -3,21 +3,33 @@ # Применяется атомарным бандлом: ovs-ofctl --bundle -O OpenFlow15 replace-flows. # # Карта таблиц: -# 0 — классификация по in_port и типу трафика, обучение MAC клиентов -# 5 — ARP-респондер для собственных адресов узла и VIP +# 0 — определение сегмента (reg0) по in_port, обучение MAC и adjacency +# 1 — классификация: ARP, ICMP, листенер, L3-трафик к шлюзу, коммутация +# 5 — ARP-респондер для собственных адресов узла и VIP, затем коммутация # 6 — ICMP echo-респондер для тех же адресов # 10 — листенеры: хэш сессии в слот (multipath), прочий IP -> маршрутизация # 11 — таблица слотов: слот -> член пула. Заливает и обновляет демон hcd -# 12 — применение DNAT выбранным членом пула (по reg2) -# 15 — обратный путь из приватных сегментов (снятие DNAT через ct) +# 12 — применение выбранного члена: DNAT + выдача (L2 либо маршрутизация) +# 15 — обратный путь L3 из приватных сегментов (снятие DNAT через ct) # 16 — пост-ct hook (счётчики, место под будущие проверки) # 20 — маршрутизация (LPM через приоритет = длина префикса) # 21 — adjacency: next-hop MAC + выходной порт +# 24 — обратная трансляция коммутируемого трафика сегмента (шаг 3) +# 25 — L2-коммутация сегмента: FDB, broadcast и unknown flood (шаг 3) # -# Регистры: reg1 — номер слота, reg2 — идентификатор члена пула. +# Регистры: reg0 — сегмент (1 — публичный, 2 и 3 — приватные), reg1 — номер +# слота, reg2 — идентификатор члена пула. # # Нумерация не произвольна: goto_table в OpenFlow разрешает переход только -# вперёд, поэтому обратный путь (15/16) стоит до общей маршрутизации (20). +# вперёд, поэтому обратный путь (15/16) стоит до общей маршрутизации (20), а +# коммутация сегмента (24/25) — после неё: в неё попадают и кадры, прошедшие +# DNAT в таблице 12. +# +# Ключевое отличие шага 3: приватный сегмент коммутирует сам br-lb, каждая ВМ +# подключена отдельным портом. Поэтому ответ бэкенда клиенту-соседу по подсети +# проходит через таблицу 24, где ct снимает DNAT, — при этом ни в ОС ВМ, ни в +# адресации сегмента ничего не меняется, а внутрисегментный трафик не +# блокируется. set -euo pipefail # shellcheck disable=SC1091 @@ -34,20 +46,57 @@ LB_PUB_IP_H=$(ip2hex "$LB_PUB_IP") VIP_H=$(ip2hex "$VIP") LB_P2_IP_H=$(ip2hex "$LB_P2_IP") LB_P3_IP_H=$(ip2hex "$LB_P3_IP") +P2_VIP_H=$(ip2hex "$P2_VIP") +P3_VIP_H=$(ip2hex "$P3_VIP") HC2_MAC_H=$(mac2hex "$HC2_MAC") HC3_MAC_H=$(mac2hex "$HC3_MAC") HC2_IP_H=$(ip2hex "$HC2_IP") HC3_IP_H=$(ip2hex "$HC3_IP") -# Действие обучения: по IP-адресу отправителя создаёт в таблице 21 запись -# adjacency для обратного пути — куда и с каким MAC отправлять ответы этому -# клиенту. Заменяет ARP-резолвер, которого у чисто-OpenFlow узла нет. -LEARN="learn(table=21,priority=90,hard_timeout=300,eth_type=0x0800,NXM_OF_IP_DST[]=NXM_OF_IP_SRC[],load:NXM_OF_ETH_SRC[]->NXM_OF_ETH_DST[],load:$PUB_MAC_H->NXM_OF_ETH_SRC[],output:NXM_OF_IN_PORT[])" +# --- описание сегментов ------------------------------------------------------ +# Производные величины: номер сегмента в reg0, состав портов и члены пула. +P2_REG=0x2 +P3_REG=0x3 +P2_PORTS="$P2_OFPORT $HC2_OFPORT $BE1_OFPORT $BE3_OFPORT $CLI2_OFPORT" +P3_PORTS="$P3_OFPORT $HC3_OFPORT $BE2_OFPORT $BE4_OFPORT $CLI3_OFPORT" +P2_MEMBERS="BE1 BE3" +P3_MEMBERS="BE2 BE4" -# arp_responder +# flood_ports <список ofport> — действие рассылки по сегменту. Порт входа OVS +# исключает сам, поэтому отдельного «кроме in_port» не требуется. +flood_ports() { + local out="" p + for p in $1; do out="$out,output:$p"; done + echo "${out#,}" +} + +# Включён ли приватный листенер в сегменте. +listener_on() { + local seg="$1" s + for s in $PRIV_LISTENERS; do [ "$s" = "$seg" ] && return 0; done + return 1 +} + +# Действие обучения FDB: eth_src -> порт. Заполняет таблицу 25, то есть делает +# из br-lb обычный обучающийся коммутатор в пределах сегмента. +l2_learn() { + echo "learn(table=25,priority=100,idle_timeout=300,NXM_NX_REG0[]=$1,NXM_OF_ETH_DST[]=NXM_OF_ETH_SRC[],output:NXM_OF_IN_PORT[])" +} + +# Действие обучения adjacency: по IP-адресу отправителя создаёт в таблице 21 +# запись «куда и с каким MAC отправлять ответы этому адресу». Заменяет +# ARP-резолвер, которого у чисто-OpenFlow узла нет. Нужна маршрутизируемому +# трафику: ответ бэкенда из соседнего сегмента приходит к клиенту через L3. +l3_learn() { + echo "learn(table=21,priority=90,hard_timeout=300,eth_type=0x0800,NXM_OF_IP_DST[]=NXM_OF_IP_SRC[],load:NXM_OF_ETH_SRC[]->NXM_OF_ETH_DST[],load:$1->NXM_OF_ETH_SRC[],output:NXM_OF_IN_PORT[])" +} + +LEARN_PUB=$(l3_learn "$PUB_MAC_H") + +# arp_responder arp_responder() { - echo "table=5,priority=100,arp,arp_op=1,arp_tpa=$1 actions=move:NXM_OF_ETH_SRC[]->NXM_OF_ETH_DST[],mod_dl_src:$3,load:0x2->NXM_OF_ARP_OP[],move:NXM_NX_ARP_SHA[]->NXM_NX_ARP_THA[],move:NXM_OF_ARP_SPA[]->NXM_OF_ARP_TPA[],load:$4->NXM_NX_ARP_SHA[],load:$2->NXM_OF_ARP_SPA[],IN_PORT" + echo "table=5,priority=100,reg0=$1,arp,arp_op=1,arp_tpa=$2 actions=move:NXM_OF_ETH_SRC[]->NXM_OF_ETH_DST[],mod_dl_src:$4,load:0x2->NXM_OF_ARP_OP[],move:NXM_NX_ARP_SHA[]->NXM_NX_ARP_THA[],move:NXM_OF_ARP_SPA[]->NXM_OF_ARP_TPA[],load:$5->NXM_NX_ARP_SHA[],load:$3->NXM_OF_ARP_SPA[],IN_PORT" } # icmp_responder @@ -55,55 +104,114 @@ icmp_responder() { echo "table=6,priority=100,icmp,nw_dst=$1,icmp_type=8 actions=move:NXM_OF_ETH_SRC[]->NXM_OF_ETH_DST[],load:$3->NXM_OF_ETH_SRC[],move:NXM_OF_IP_SRC[]->NXM_OF_IP_DST[],load:$2->NXM_OF_IP_SRC[],load:0->NXM_OF_ICMP_TYPE[],IN_PORT" } +# segment_ingress <сеть> <порты> — таблица 0 для сегмента. +# Обучение FDB идёт для любого кадра, обучение adjacency — только для адресов +# самого сегмента: иначе с аплинка в таблицу 21 попал бы весь внешний мир. +segment_ingress() { + local reg="$1" gwmac_h="$2" net="$3" ports="$4" p + local l2 l3 + l2=$(l2_learn "$reg") + l3=$(l3_learn "$gwmac_h") + for p in $ports; do + echo "table=0,priority=200,in_port=$p,ip,nw_src=$net actions=load:$reg->NXM_NX_REG0[],$l2,$l3,goto_table:1" + echo "table=0,priority=190,in_port=$p actions=load:$reg->NXM_NX_REG0[],$l2,goto_table:1" + done +} + +# segment_classify <сегмент> — таблица 1 для сегмента. +segment_classify() { + local seg="$1" reg="$2" gwmac="$3" vip="$4" + echo "# --- сегмент $seg ---" + if listener_on "$seg"; then + echo "table=1,priority=130,reg0=$reg,dl_dst=$gwmac,tcp,nw_dst=$vip,tp_dst=$PRIV_VIP_PORT actions=goto_table:10" + fi + # Трафик на VIP, для которого листенера нет, отбрасывается со счётчиком — + # так видно, что адрес занят стендом, а листенер выключен. + echo "table=1,priority=125,reg0=$reg,dl_dst=$gwmac,ip,nw_dst=$vip actions=drop" + # Кадр адресован маршрутизатору сегмента: транзит, ответы балансируемых + # сессий из другого сегмента, обращения наружу через LB. + echo "table=1,priority=120,reg0=$reg,dl_dst=$gwmac,ip actions=goto_table:15" + # Всё остальное — трафик между ВМ сегмента: коммутация, при необходимости + # с обратной трансляцией. + echo "table=1,priority=100,reg0=$reg actions=goto_table:24" +} + +# segment_untranslate <члены> — таблица 24. +segment_untranslate() { + local reg="$1" m ip port + for m in $2; do + eval "ip=\$${m}_IP; port=\$${m}_PORT" + echo "table=24,priority=100,reg0=$reg,tcp,nw_src=$ip,tp_src=$port actions=ct(table=25,zone=$CT_ZONE,nat)" + done +} + cat <NXM_NX_REG0[],goto_table:1 + +# Приватные сегменты: каждая ВМ — отдельный порт, br-lb работает коммутатором. +$(segment_ingress "$P2_REG" "$P2_MAC_H" "$P2_NET" "$P2_PORTS") +$(segment_ingress "$P3_REG" "$P3_MAC_H" "$P3_NET" "$P3_PORTS") + +table=0,priority=0 actions=drop + +# ============================================================================= +# Таблица 1 — классификация # ============================================================================= # ARP обрабатывает собственный респондер (таблица 5): ядро в форвардинге не # участвует, поэтому штатного ARP-стека у узла нет. -table=0,priority=200,arp actions=goto_table:5 +table=1,priority=200,arp actions=goto_table:5 # ICMP echo-request на собственные адреса узла и на VIP — в респондер. -table=0,priority=190,icmp,nw_dst=$LB_PUB_IP,icmp_type=8 actions=goto_table:6 -table=0,priority=190,icmp,nw_dst=$VIP,icmp_type=8 actions=goto_table:6 -table=0,priority=190,icmp,nw_dst=$LB_P2_IP,icmp_type=8 actions=goto_table:6 -table=0,priority=190,icmp,nw_dst=$LB_P3_IP,icmp_type=8 actions=goto_table:6 +table=1,priority=190,icmp,nw_dst=$LB_PUB_IP,icmp_type=8 actions=goto_table:6 +table=1,priority=190,icmp,nw_dst=$VIP,icmp_type=8 actions=goto_table:6 +table=1,priority=190,icmp,nw_dst=$LB_P2_IP,icmp_type=8 actions=goto_table:6 +table=1,priority=190,icmp,nw_dst=$LB_P3_IP,icmp_type=8 actions=goto_table:6 +table=1,priority=190,icmp,nw_dst=$P2_VIP,icmp_type=8 actions=goto_table:6 +table=1,priority=190,icmp,nw_dst=$P3_VIP,icmp_type=8 actions=goto_table:6 # Вход из публичного сегмента: запоминаем MAC отправителя как adjacency для -# обратного пути (замена ARP-резолвера на клиентской стороне), затем к -# листенерам. Записи живут 300 с и обновляются каждым пакетом клиента. +# обратного пути, затем к листенерам. Записи живут 300 с и обновляются каждым +# пакетом клиента. # # Обучение включено только для трафика, адресованного стенду (VIP, адрес узла, # приватные сегменты). Иначе в таблицу 21 попадал бы весь широковещательный # шум LAN — macvlan-порт видит DHCP, mDNS и SSDP всех соседей по сегменту. -table=0,priority=110,in_port=$PUB_OFPORT,ip,nw_dst=$VIP actions=$LEARN,goto_table:10 -table=0,priority=110,in_port=$PUB_OFPORT,ip,nw_dst=$LB_PUB_IP actions=$LEARN,goto_table:10 -table=0,priority=110,in_port=$PUB_OFPORT,ip,nw_dst=$P2_NET actions=$LEARN,goto_table:10 -table=0,priority=110,in_port=$PUB_OFPORT,ip,nw_dst=$P3_NET actions=$LEARN,goto_table:10 -table=0,priority=100,in_port=$PUB_OFPORT,ip actions=drop +table=1,priority=110,reg0=0x1,ip,nw_dst=$VIP actions=$LEARN_PUB,goto_table:10 +table=1,priority=110,reg0=0x1,ip,nw_dst=$LB_PUB_IP actions=$LEARN_PUB,goto_table:10 +table=1,priority=110,reg0=0x1,ip,nw_dst=$P2_NET actions=$LEARN_PUB,goto_table:10 +table=1,priority=110,reg0=0x1,ip,nw_dst=$P3_NET actions=$LEARN_PUB,goto_table:10 +table=1,priority=100,reg0=0x1,ip actions=drop -# Вход из приватных сегментов: обратный путь балансируемых сессий и транзит. -table=0,priority=100,in_port=$P2_OFPORT,ip actions=goto_table:15 -table=0,priority=100,in_port=$P3_OFPORT,ip actions=goto_table:15 +$(segment_classify p2 "$P2_REG" "$P2_MAC" "$P2_VIP") +$(segment_classify p3 "$P3_REG" "$P3_MAC" "$P3_VIP") -# Health-пробы, порождённые самим узлом: сразу в маршрутизацию, без ct и без -# обучения — это трафик из стека узла, а не клиентская сессия. -table=0,priority=100,in_port=$HC2_OFPORT,ip actions=goto_table:20 -table=0,priority=100,in_port=$HC3_OFPORT,ip actions=goto_table:20 - -# Всё остальное (не-IP, не-ARP: broadcast, IPv6, DHCP) в прототипе не нужно. -table=0,priority=0 actions=drop +# Всё остальное (не-IP, не-ARP с публичного порта) в прототипе не нужно. +table=1,priority=0 actions=drop # ============================================================================= # Таблица 5 — ARP-респондер # ============================================================================= -$(arp_responder "$LB_PUB_IP" "$LB_PUB_IP_H" "$PUB_MAC" "$PUB_MAC_H") -$(arp_responder "$VIP" "$VIP_H" "$PUB_MAC" "$PUB_MAC_H") -$(arp_responder "$LB_P2_IP" "$LB_P2_IP_H" "$P2_MAC" "$P2_MAC_H") -$(arp_responder "$LB_P3_IP" "$LB_P3_IP_H" "$P3_MAC" "$P3_MAC_H") +$(arp_responder 0x1 "$LB_PUB_IP" "$LB_PUB_IP_H" "$PUB_MAC" "$PUB_MAC_H") +$(arp_responder 0x1 "$VIP" "$VIP_H" "$PUB_MAC" "$PUB_MAC_H") +$(arp_responder "$P2_REG" "$LB_P2_IP" "$LB_P2_IP_H" "$P2_MAC" "$P2_MAC_H") +$(arp_responder "$P3_REG" "$LB_P3_IP" "$LB_P3_IP_H" "$P3_MAC" "$P3_MAC_H") +# VIP приватных листенеров отвечают тем же MAC, что и шлюз сегмента: по нему +# таблица 1 отличает трафик к маршрутизатору от трафика между ВМ. +$(arp_responder "$P2_REG" "$P2_VIP" "$P2_VIP_H" "$P2_MAC" "$P2_MAC_H") +$(arp_responder "$P3_REG" "$P3_VIP" "$P3_VIP_H" "$P3_MAC" "$P3_MAC_H") # Адреса источника health-проб: бэкенд резолвит их, отвечая на пробу. -$(arp_responder "$HC2_IP" "$HC2_IP_H" "$HC2_MAC" "$HC2_MAC_H") -$(arp_responder "$HC3_IP" "$HC3_IP_H" "$HC3_MAC" "$HC3_MAC_H") +$(arp_responder "$P2_REG" "$HC2_IP" "$HC2_IP_H" "$HC2_MAC" "$HC2_MAC_H") +$(arp_responder "$P3_REG" "$HC3_IP" "$HC3_IP_H" "$HC3_MAC" "$HC3_MAC_H") +# Остальной ARP сегмента — обычная коммутация: ВМ резолвят друг друга сами, +# балансировщик в это не вмешивается. +table=5,priority=10,reg0=$P2_REG actions=goto_table:25 +table=5,priority=10,reg0=$P3_REG actions=goto_table:25 table=5,priority=0 actions=drop # ============================================================================= @@ -113,24 +221,38 @@ $(icmp_responder "$LB_PUB_IP" "$LB_PUB_IP_H" "$PUB_MAC_H") $(icmp_responder "$VIP" "$VIP_H" "$PUB_MAC_H") $(icmp_responder "$LB_P2_IP" "$LB_P2_IP_H" "$P2_MAC_H") $(icmp_responder "$LB_P3_IP" "$LB_P3_IP_H" "$P3_MAC_H") +$(icmp_responder "$P2_VIP" "$P2_VIP_H" "$P2_MAC_H") +$(icmp_responder "$P3_VIP" "$P3_VIP_H" "$P3_MAC_H") table=6,priority=0 actions=drop # ============================================================================= # Таблица 10 — листенеры # ============================================================================= -# Единственный листенер прототипа: TCP $VIP:$VIP_PORT. # multipath раскладывает сессию в один из $SLOTS слотов по симметричному # L4-хэшу; basis = POOL_ID, поэтому разные пулы дают независимые раскладки. # Хэш симметричен, то есть прямое и обратное направление дают один слот — # свойство, необходимое для будущего stateless-датапаса. -table=10,priority=200,tcp,nw_dst=$VIP,tp_dst=$VIP_PORT actions=multipath(symmetric_l4,$POOL_ID,modulo_n,$SLOTS,0,NXM_NX_REG1[]),goto_table:11 +# +# Публичный листенер: TCP $VIP:$VIP_PORT. +table=10,priority=200,reg0=0x1,tcp,nw_dst=$VIP,tp_dst=$VIP_PORT actions=multipath(symmetric_l4,$POOL_ID,modulo_n,$SLOTS,0,NXM_NX_REG1[]),goto_table:11 # Трафик на VIP, для которого листенера нет, — отбрасываем со счётчиком. -table=10,priority=150,ip,nw_dst=$VIP actions=drop +table=10,priority=150,reg0=0x1,ip,nw_dst=$VIP actions=drop # Прочий IP из публичного сегмента маршрутизируется обычным образом # (клиент может обратиться напрямую к бэкенду, минуя балансировку). -table=10,priority=100,ip actions=goto_table:20 +table=10,priority=100,reg0=0x1,ip actions=goto_table:20 +EOF + +# Приватные листенеры: тот же пул, та же таблица слотов, тот же хэш. Отличие +# только в адресе и в том, что выдача пойдёт коммутацией (таблица 12). +for seg in p2 p3; do + listener_on "$seg" || continue + if [ "$seg" = p2 ]; then reg=$P2_REG; vip=$P2_VIP; else reg=$P3_REG; vip=$P3_VIP; fi + echo "table=10,priority=200,reg0=$reg,tcp,nw_dst=$vip,tp_dst=$PRIV_VIP_PORT actions=multipath(symmetric_l4,$POOL_ID,modulo_n,$SLOTS,0,NXM_NX_REG1[]),goto_table:11" +done + +cat <be2, обращения -# бэкендов к клиентским префиксам), пакет проходит без изменений. +# Для сессий, которых нет в таблице соединений (транзит между сегментами, +# обращения бэкендов к клиентским префиксам), пакет проходит без изменений. table=15,priority=100,tcp actions=ct(table=16,zone=$CT_ZONE,nat) table=15,priority=50,ip actions=goto_table:20 table=15,priority=0 actions=drop @@ -189,14 +329,39 @@ table=20,priority=0 actions=drop # ============================================================================= # Таблица 21 — adjacency (next-hop MAC + выходной порт) # ============================================================================= -# Бэкенды прописаны статически: их MAC фиксирован в docker-compose.yml. -table=21,priority=100,ip,nw_dst=$BE1_IP actions=mod_dl_dst:$BE1_MAC,output:$P2_OFPORT -table=21,priority=100,ip,nw_dst=$BE2_IP actions=mod_dl_dst:$BE2_MAC,output:$P3_OFPORT -table=21,priority=100,ip,nw_dst=$BE3_IP actions=mod_dl_dst:$BE3_MAC,output:$P2_OFPORT -table=21,priority=100,ip,nw_dst=$BE4_IP actions=mod_dl_dst:$BE4_MAC,output:$P3_OFPORT +# Члены пула прописаны статически: их MAC и порт фиксированы в topology.env. +table=21,priority=100,ip,nw_dst=$BE1_IP actions=mod_dl_dst:$BE1_MAC,output:$BE1_OFPORT +table=21,priority=100,ip,nw_dst=$BE2_IP actions=mod_dl_dst:$BE2_MAC,output:$BE2_OFPORT +table=21,priority=100,ip,nw_dst=$BE3_IP actions=mod_dl_dst:$BE3_MAC,output:$BE3_OFPORT +table=21,priority=100,ip,nw_dst=$BE4_IP actions=mod_dl_dst:$BE4_MAC,output:$BE4_OFPORT # Ответы на health-пробы — в стек узла через internal-порты. table=21,priority=100,ip,nw_dst=$HC2_IP actions=mod_dl_dst:$HC2_MAC,output:$HC2_OFPORT table=21,priority=100,ip,nw_dst=$HC3_IP actions=mod_dl_dst:$HC3_MAC,output:$HC3_OFPORT -# priority=90 — записи клиентов, устанавливаемые действием learn из таблицы 0. +# priority=90 — записи клиентов, устанавливаемые действием learn из таблиц 0 и 1. table=21,priority=0 actions=drop + +# ============================================================================= +# Таблица 24 — обратная трансляция коммутируемого трафика сегмента +# ============================================================================= +# Здесь решается задача шага 3. Бэкенд отвечает клиенту-соседу напрямую по L2, +# но кадр всё равно проходит через br-lb, потому что порт ВМ — порт OVS. +# ct(nat) без commit возвращает source к VIP:80 для балансируемых сессий и +# пропускает без изменений всё остальное — обращения be<->be, ответы на +# health-пробы, прямые обращения клиента к бэкенду. +$(segment_untranslate "$P2_REG" "$P2_MEMBERS") +$(segment_untranslate "$P3_REG" "$P3_MEMBERS") +table=24,priority=0 actions=goto_table:25 + +# ============================================================================= +# Таблица 25 — L2-коммутация сегмента +# ============================================================================= +# priority=100 — записи FDB, устанавливаемые действием learn из таблицы 0. +# Ниже — рассылка по сегменту: broadcast и multicast отдельным правилом ради +# счётчика, неизвестный unicast — тем же способом. Порт входа OVS исключает +# из рассылки сам. +table=25,priority=50,reg0=$P2_REG,dl_dst=01:00:00:00:00:00/01:00:00:00:00:00 actions=$(flood_ports "$P2_PORTS") +table=25,priority=50,reg0=$P3_REG,dl_dst=01:00:00:00:00:00/01:00:00:00:00:00 actions=$(flood_ports "$P3_PORTS") +table=25,priority=10,reg0=$P2_REG actions=$(flood_ports "$P2_PORTS") +table=25,priority=10,reg0=$P3_REG actions=$(flood_ports "$P3_PORTS") +table=25,priority=0 actions=drop EOF diff --git a/lb/topology.env b/lb/topology.env index 07d6362..1efaf15 100644 --- a/lb/topology.env +++ b/lb/topology.env @@ -17,18 +17,35 @@ LB_PUB_IP=192.168.5.20 VIP=192.168.5.21 VIP_PORT=80 -# --- сеть 2: приватный сегмент бэкенда be1 ----------------------------------- +# --- сеть 2: приватный сегмент 10.20.0.0/24 ---------------------------------- +# С шага 3 сегмент коммутирует сам br-lb: каждая ВМ сегмента подключена +# отдельным портом (см. scripts/attach-segment.sh). Порт p2 остался аплинком к +# docker-бриджу: через него идёт egress бэкендов на шлюз .254 (профиль A). +# +# Адрес шлюза LB_P2_IP и VIP приватного листенера P2_VIP отвечают одним MAC +# (P2_MAC) — по нему пайплайн отличает L3-трафик к маршрутизатору от +# L2-трафика сегмента. P2_IFNAME=p2 P2_OFPORT=2 P2_MAC=02:42:0a:14:00:01 P2_NET=10.20.0.0/24 +P2_DOCKER_GW=10.20.0.254 LB_P2_IP=10.20.0.1 BE1_IP=10.20.0.2 BE1_MAC=02:42:0a:14:00:02 BE1_PORT=8080 +BE1_OFPORT=10 +BE1_ID=0x1 BE3_IP=10.20.0.3 BE3_MAC=02:42:0a:14:00:03 BE3_PORT=8080 +BE3_OFPORT=11 +BE3_ID=0x3 +# Клиент внутри сегмента — та самая ВМ, что живёт рядом с бэкендами. +# В его ОС не настраивается ничего, кроме адреса: VIP находится в его подсети. +CLI2_IP=10.20.0.10 +CLI2_MAC=02:42:0a:14:00:0a +CLI2_OFPORT=12 # Уникальный адрес узла в сегменте — источник health-проб (§7.1 дизайна v1). # Живёт на internal-порту OVS: это единственный адрес, который узел держит в # ядре, весь остальной форвардинг идёт мимо стека. @@ -37,23 +54,43 @@ HC2_OFPORT=4 HC2_MAC=02:42:0a:14:00:fd HC2_IP=10.20.0.253 -# --- сеть 3: приватный сегмент бэкенда be2 ----------------------------------- +# --- сеть 3: приватный сегмент 10.30.0.0/24 ---------------------------------- P3_IFNAME=p3 P3_OFPORT=3 P3_MAC=02:42:0a:1e:00:01 P3_NET=10.30.0.0/24 +P3_DOCKER_GW=10.30.0.254 LB_P3_IP=10.30.0.1 BE2_IP=10.30.0.2 BE2_MAC=02:42:0a:1e:00:02 BE2_PORT=8080 +BE2_OFPORT=20 +BE2_ID=0x2 BE4_IP=10.30.0.3 BE4_MAC=02:42:0a:1e:00:03 BE4_PORT=8080 +BE4_OFPORT=21 +BE4_ID=0x4 +CLI3_IP=10.30.0.10 +CLI3_MAC=02:42:0a:1e:00:0a +CLI3_OFPORT=22 HC3_IFNAME=hcif-p3 HC3_OFPORT=5 HC3_MAC=02:42:0a:1e:00:fd HC3_IP=10.30.0.253 +# --- приватные листенеры (шаг 3) --------------------------------------------- +# VIP берётся из подсети сегмента: клиенту не нужны ни маршруты, ни настройки — +# адрес находится в его собственной сети, ARP-респондер OVS отвечает за него. +# +# PRIV_LISTENERS перечисляет сегменты, где листенер поднят. Пустое значение +# выключает приватные листенеры целиком, "p2" — включает один сегмент, +# "p2 p3" — оба. Пул и таблица слотов общие с публичным листенером. +PRIV_LISTENERS="p2 p3" +PRIV_VIP_PORT=80 +P2_VIP=10.20.0.100 +P3_VIP=10.30.0.100 + # --- пул и таблица слотов ---------------------------------------------------- # POOL_ID служит basis хэша multipath: разные пулы дают независимые раскладки. POOL_ID=1 diff --git a/scripts/attach-segment.sh b/scripts/attach-segment.sh new file mode 100755 index 0000000..d4c6716 --- /dev/null +++ b/scripts/attach-segment.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# Подключение ВМ приватных сегментов к мосту br-lb (шаг 3). +# +# В целевой среде порты виртуальных машин создаёт гипервизор, и каждая ВМ уже +# висит на vSwitch. В контейнерном стенде эту роль выполняет этот скрипт: для +# каждого участника сегмента он создаёт veth-пару, один конец кладёт в сетевое +# пространство имён контейнера, другой — в netns балансировщика и добавляет в +# br-lb с детерминированным ofport. +# +# Адрес, MAC и маршруты назначаются СНАРУЖИ, как это делают гипервизор и DHCP. +# Внутри ОС ВМ ничего не настраивается — это условие шага 3. +# +# Запускать на хосте с правами root, после docker compose up: +# sudo scripts/attach-segment.sh # подключить +# sudo scripts/attach-segment.sh --status # показать порты моста +# sudo scripts/attach-segment.sh --detach # снять порты +set -euo pipefail + +cd "$(dirname "$0")/.." +# shellcheck disable=SC1091 +source lb/topology.env + +LB_CONTAINER=hpnn-lb + +log() { echo "[attach] $*"; } +die() { echo "[attach] ОШИБКА: $*" >&2; exit 1; } + +[ "$(id -u)" = 0 ] || die "нужны права root (sudo scripts/attach-segment.sh)" + +pid_of() { + local pid + pid=$(docker inspect -f '{{.State.Pid}}' "$1" 2>/dev/null) || die "контейнер $1 не найден" + [ "$pid" != "0" ] || die "контейнер $1 не запущен" + echo "$pid" +} + +# attach <контейнер> <имя порта> <префикс> <шлюз|-> [сеть via LB]... +attach() { + local cname="$1" port="$2" ofport="$3" mac="$4" ip="$5" plen="$6" gw="$7" + shift 7 + local pid pid_lb tmp + + pid=$(pid_of "$cname") + pid_lb=$(pid_of "$LB_CONTAINER") + + if nsenter -t "$pid" -n ip link show eth0 >/dev/null 2>&1; then + log "$cname уже подключён, пропуск" + return 0 + fi + + # Временное имя нужно потому, что оба конца veth рождаются в netns хоста и + # имя eth0 там почти наверняка занято. + tmp="tmp$$-${ofport}" + ip link add "$port" type veth peer name "$tmp" + ip link set "$port" netns "$pid_lb" + ip link set "$tmp" netns "$pid" + + nsenter -t "$pid" -n ip link set "$tmp" name eth0 + nsenter -t "$pid" -n ip link set eth0 address "$mac" + nsenter -t "$pid" -n ip addr replace "$ip/$plen" dev eth0 + nsenter -t "$pid" -n ip link set eth0 up + # Профиль A: default остаётся на шлюзе Docker, через LB маршрутизируются + # только клиентские префиксы и соседний сегмент. Клиентам не выдаётся ничего. + [ "$gw" = "-" ] || nsenter -t "$pid" -n ip route replace default via "$gw" + while [ $# -ge 1 ]; do + nsenter -t "$pid" -n ip route replace "$1" via "$2" + shift 2 + done + + nsenter -t "$pid_lb" -n ip link set "$port" up + docker exec "$LB_CONTAINER" ovs-vsctl --may-exist add-port "$BR" "$port" \ + -- set Interface "$port" ofport_request="$ofport" >/dev/null + + log "$cname -> порт $port (ofport $ofport), $ip/$plen, MAC $mac" +} + +detach() { + local port + for port in be1 be3 cli2 be2 be4 cli3; do + docker exec "$LB_CONTAINER" ovs-vsctl --if-exists del-port "$BR" "$port" >/dev/null 2>&1 || true + done + log "порты сегментов сняты с моста (интерфейсы исчезнут вместе с контейнерами)" +} + +status() { + docker exec "$LB_CONTAINER" ovs-ofctl -O "$OF" show "$BR" | grep -E '^\s+[0-9]+\(' +} + +case "${1:-}" in +--status) status; exit 0 ;; +--detach) detach; exit 0 ;; +esac + +p2len=${P2_NET##*/} +p3len=${P3_NET##*/} + +# Бэкенды сегмента 2: default на docker-шлюз, клиентский префикс и соседний +# сегмент — через балансировщик. +attach hpnn-be1 be1 "$BE1_OFPORT" "$BE1_MAC" "$BE1_IP" "$p2len" "$P2_DOCKER_GW" \ + 192.168.5.0/24 "$LB_P2_IP" "$P3_NET" "$LB_P2_IP" +attach hpnn-be3 be3 "$BE3_OFPORT" "$BE3_MAC" "$BE3_IP" "$p2len" "$P2_DOCKER_GW" \ + 192.168.5.0/24 "$LB_P2_IP" "$P3_NET" "$LB_P2_IP" +attach hpnn-be2 be2 "$BE2_OFPORT" "$BE2_MAC" "$BE2_IP" "$p3len" "$P3_DOCKER_GW" \ + 192.168.5.0/24 "$LB_P3_IP" "$P2_NET" "$LB_P3_IP" +attach hpnn-be4 be4 "$BE4_OFPORT" "$BE4_MAC" "$BE4_IP" "$p3len" "$P3_DOCKER_GW" \ + 192.168.5.0/24 "$LB_P3_IP" "$P2_NET" "$LB_P3_IP" + +# Клиенты: только адрес и маска. Ни маршрутов, ни дополнительных интерфейсов — +# ровно то, что требует условие шага 3. +attach hpnn-cli2 cli2 "$CLI2_OFPORT" "$CLI2_MAC" "$CLI2_IP" "$p2len" - +attach hpnn-cli3 cli3 "$CLI3_OFPORT" "$CLI3_MAC" "$CLI3_IP" "$p3len" - + +log "готово" +status diff --git a/scripts/verify.sh b/scripts/verify.sh index f4f5dac..cf67119 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -18,7 +18,7 @@ skip() { echo -e " \033[33mПРОПУСК\033[0m $*"; } head_() { echo; echo "== $*"; } head_ "1. Контейнеры" -for c in hpnn-lb hpnn-be1 hpnn-be2 hpnn-be3 hpnn-be4; do +for c in hpnn-lb hpnn-be1 hpnn-be2 hpnn-be3 hpnn-be4 hpnn-cli2 hpnn-cli3; do state=$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo "нет") [ "$state" = "running" ] && ok "$c: $state" || bad "$c: $state" done @@ -28,11 +28,17 @@ ports=$($DC exec -T lb-router ovs-vsctl list-ports br-lb 2>/dev/null | tr '\n' ' for p in pub0 p2 p3; do echo "$ports" | grep -qw "$p" && ok "порт $p в мосту" || bad "порт $p отсутствует (есть: $ports)" done +# Шаг 3: каждая ВМ сегмента — отдельный порт моста. Без этого балансировщик не +# увидит ответ бэкенда клиенту-соседу и приватный листенер работать не сможет. +for p in be1 be3 cli2 be2 be4 cli3; do + echo "$ports" | grep -qw "$p" && ok "ВМ $p подключена портом OVS" \ + || bad "порт $p отсутствует — выполните make attach" +done dp=$($DC exec -T lb-router ovs-appctl dpif/show 2>/dev/null | head -1) [ -n "$dp" ] && ok "датапас: $dp" || bad "датапас не поднят" head_ "3. Пайплайн" -for t in 0 5 6 10 11 12 15 16 20 21; do +for t in 0 1 5 6 10 11 12 15 16 20 21 24 25; do n=$($DC exec -T lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb "table=$t" 2>/dev/null | grep -c 'table=') [ "${n:-0}" -gt 0 ] && ok "таблица $t: $n правил" || bad "таблица $t пуста" done @@ -90,9 +96,12 @@ else skip "shim не поднят — выполните: sudo scripts/host-prereq.sh --shim" fi -head_ "7. Профиль A: собственный egress бэкенда идёт мимо балансировщика" +head_ "7. Профиль A: собственный egress бэкенда не маршрутизируется балансировщиком" +# С шага 3 br-lb коммутирует сегмент, поэтому egress физически через него +# проходит — но только как через коммутатор. Признак: кадр адресован MAC +# docker-шлюза, а не MAC маршрутизатора сегмента (02:42:0a:14:00:01). dump=$(mktemp) -timeout 12 $DC exec -T lb-router timeout 8 tcpdump -ni p2 -c 2 'host 1.1.1.1' >"$dump" 2>&1 & +timeout 12 $DC exec -T lb-router timeout 8 tcpdump -eni be1 -c 2 'host 1.1.1.1' >"$dump" 2>&1 & tcpdump_pid=$! sleep 2 code=$($DC exec -T be1 curl -s -o /dev/null -w '%{http_code}' --max-time 6 http://1.1.1.1/ 2>/dev/null) @@ -100,9 +109,13 @@ wait $tcpdump_pid 2>/dev/null [ "${code:-000}" != "000" ] && ok "be1 достучался наружу (HTTP $code) через шлюз Docker" \ || bad "be1 не имеет выхода наружу (HTTP ${code:-000})" if grep -q '^[0-9][0-9]:' "$dump"; then - bad "трафик egress виден на порту p2 балансировщика:"; sed 's/^/ /' "$dump" + if grep -q '> 02:42:0a:14:00:01' "$dump"; then + bad "egress адресован маршрутизатору сегмента — default ушёл на балансировщик" + else + ok "egress адресован шлюзу Docker, а не маршрутизатору сегмента" + fi else - ok "на порту p2 балансировщика этого трафика нет" + bad "трафик egress не наблюдается на порту ВМ" fi rm -f "$dump" @@ -144,7 +157,9 @@ done [ "$uneven" -eq 0 ] && ok "слоты поделены поровну:$dist" || bad "неравномерная раскладка:$dist" head_ "9. Источник health-проб — уникальный адрес узла, не VIP" -src=$($DC exec -T lb-router timeout 6 tcpdump -ni p2 -c 1 'tcp port 8080 and tcp[tcpflags] & tcp-syn != 0' 2>/dev/null \ +# Слушаем порт самой ВМ: с шага 3 проба идёт коммутацией сегмента, а не через +# аплинк p2, поэтому на аплинке её больше не видно. +src=$($DC exec -T lb-router timeout 6 tcpdump -ni be1 -c 1 'tcp port 8080 and tcp[tcpflags] & tcp-syn != 0' 2>/dev/null \ | sed -n 's/.*IP \([0-9.]*\)\.[0-9]* > .*/\1/p' | head -1) [ "$src" = "10.20.0.253" ] && ok "проба к be1 уходит с $src" || bad "проба уходит с ${src:-неизвестно}, ожидался 10.20.0.253" @@ -240,6 +255,74 @@ sleep 1 && ok "после apply.sh раскладка та же: $d_before" \ || bad "раскладка изменилась: $(digest), слотов $(snapshot | wc -l)" +head_ "15. Листенер в приватном сегменте (шаг 3)" +# Клиент cli2 живёт в одном сегменте с be1 и be3 — тот самый случай, ради +# которого сегмент переведён на порты OVS. В его ОС не настроено ничего, кроме +# адреса: VIP находится в его собственной подсети. +P2_VIP=10.20.0.100 +routes=$($DC exec -T cli2 ip route 2>/dev/null | grep -c 'via') +[ "${routes:-1}" -eq 0 ] && ok "в ОС клиента нет ни одного маршрута — только адрес" \ + || bad "у клиента появились маршруты: их быть не должно" + +$DC exec -T cli2 ping -c1 -W2 "$P2_VIP" >/dev/null 2>&1 \ + && ok "ping $P2_VIP из сегмента (ARP- и ICMP-респондеры OVS)" \ + || bad "VIP приватного листенера недоступен" + +declare -A phits=() +pclient=""; pserved=""; pown=0 +for _ in $(seq 24); do + line=$($DC exec -T cli2 curl -s --max-time 5 "http://$P2_VIP/" 2>/dev/null) || continue + b=$(echo "$line" | sed -n 's/.*backend=\([a-z0-9]*\).*/\1/p') + c=$(echo "$line" | sed -n 's/.*client=\([0-9.]*\):.*/\1/p') + sv=$(echo "$line" | sed -n 's/.*served=\([0-9.]*:[0-9]*\).*/\1/p') + [ -n "$b" ] && phits[$b]=$(( ${phits[$b]:-0} + 1 )) + [ -n "$c" ] && pclient=$c + case "$b" in be1|be3) pown=$((pown + 1)); pserved=$sv ;; esac +done +echo " распределение: $(for k in "${!phits[@]}"; do printf '%s=%s ' "$k" "${phits[$k]}"; done)" +[ "${#phits[@]}" -ge 3 ] && ok "приватный листенер балансирует между ${#phits[@]} членами из 4" \ + || bad "задействовано лишь ${#phits[@]} членов" + +# Ключевая проверка шага: бэкенд видит исходный адрес клиента. +[ "$pclient" = "10.20.0.10" ] && ok "бэкенд видит исходный IP клиента: $pclient (SNAT не выполняется)" \ + || bad "бэкенд видит $pclient вместо 10.20.0.10" + +# Тот самый случай: член пула в одном сегменте с клиентом. Ответ уходит по L2 +# напрямую, но проходит через br-lb, где таблица 24 снимает трансляцию. +[ "$pown" -gt 0 ] && ok "члены из сегмента клиента обслужили $pown запросов" \ + || bad "ни один запрос не попал на be1/be3 — коммутируемый путь не проверен" +case "$pserved" in +10.20.0.[23]:8080) ok "трансляция порта выполнена: листенер 80 -> член $pserved" ;; +"") bad "не удалось определить адрес обслуживания" ;; +*) bad "неожиданный адрес обслуживания: $pserved" ;; +esac + +# Путь остался коммутируемым: TTL ответа от VIP не уменьшен, значит клиент и +# бэкенд по-прежнему L2-соседи и балансировщик не стал для них L3-хопом. +ttls=$(mktemp) +timeout 12 $DC exec -T lb-router timeout 8 tcpdump -ni cli2 -c 4 -v \ + "tcp and src host $P2_VIP and tcp[tcpflags] & tcp-syn != 0" >"$ttls" 2>/dev/null & +tpid=$! +sleep 1 +for _ in $(seq 12); do $DC exec -T cli2 curl -s --max-time 3 -o /dev/null "http://$P2_VIP/" 2>/dev/null; done +wait $tpid 2>/dev/null +grep -q 'ttl 64' "$ttls" && ok "ответ от VIP приходит с TTL 64 — путь коммутируемый, не маршрутный" \ + || bad "TTL ответа уменьшен: путь стал маршрутизируемым" +rm -f "$ttls" + +head_ "16. Внутрисегментный трафик не блокирован" +out=$($DC exec -T cli2 curl -s --max-time 5 http://10.20.0.2:8080/ 2>/dev/null) +echo "$out" | grep -q 'backend=be1' && ok "клиент обращается к бэкенду напрямую, минуя VIP" \ + || bad "прямое обращение клиента к бэкенду не работает" +out=$($DC exec -T be1 curl -s --max-time 5 http://10.20.0.3:8080/ 2>/dev/null) +echo "$out" | grep -q 'backend=be3' && ok "be1 -> be3 внутри сегмента" \ + || bad "связность между бэкендами сегмента нарушена" +$DC exec -T be1 ping -c1 -W2 10.20.0.254 >/dev/null 2>&1 \ + && ok "бэкенд достаёт шлюз Docker через аплинк сегмента" \ + || bad "шлюз сегмента недоступен" +$DC exec -T cli3 curl -s --max-time 5 http://10.30.0.3:8080/ 2>/dev/null | grep -q 'backend=be4' \ + && ok "cli3 -> be4 во втором сегменте" || bad "связность второго сегмента нарушена" + echo echo "Итог: успешно $pass, провалено $fail" [ "$fail" -eq 0 ]