From 8212699f23317a970b3e858482c8814e642e84e6 Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Sun, 16 Aug 2026 21:43:02 +0300 Subject: [PATCH] MVP Single Node with Manual Config in Docker --- .claude/CLAUDE.md | 21 + Makefile | 88 +++ README.md | 257 ++++++++ backend/Dockerfile | 19 + backend/entrypoint.sh | 21 + backend/go.mod | 3 + backend/main.go | 105 +++ docker-compose.yml | 140 ++++ docs/CHANGE_POOL_4_MEMBERS_PLAN.md | 77 +++ docs/CHANGE_POOL_4_MEMBERS_SUMMARY.md | 81 +++ docs/MANUAL_TEST_PLAN.md | 878 ++++++++++++++++++++++++++ docs/STEP1_IMPLEMENTATION_PLAN.md | 126 ++++ docs/STEP1_SUMMARY.md | 103 +++ docs/STEP2_IMPLEMENTATION_PLAN.md | 179 ++++++ docs/STEP2_SUMMARY.md | 114 ++++ hc/go.mod | 3 + hc/maglev.go | 168 +++++ hc/main.go | 505 +++++++++++++++ lb/Dockerfile | 33 + lb/apply.sh | 34 + lb/entrypoint.sh | 136 ++++ lb/hc-config.sh | 66 ++ lb/lbctl.sh | 83 +++ lb/pipeline.sh | 202 ++++++ lb/topology.env | 70 ++ scripts/host-prereq.sh | 61 ++ scripts/verify.sh | 245 +++++++ 27 files changed, 3818 insertions(+) create mode 100644 .claude/CLAUDE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100755 backend/entrypoint.sh create mode 100644 backend/go.mod create mode 100644 backend/main.go create mode 100644 docker-compose.yml create mode 100644 docs/CHANGE_POOL_4_MEMBERS_PLAN.md create mode 100644 docs/CHANGE_POOL_4_MEMBERS_SUMMARY.md create mode 100644 docs/MANUAL_TEST_PLAN.md create mode 100644 docs/STEP1_IMPLEMENTATION_PLAN.md create mode 100644 docs/STEP1_SUMMARY.md create mode 100644 docs/STEP2_IMPLEMENTATION_PLAN.md create mode 100644 docs/STEP2_SUMMARY.md create mode 100644 hc/go.mod create mode 100644 hc/maglev.go create mode 100644 hc/main.go create mode 100644 lb/Dockerfile create mode 100755 lb/apply.sh create mode 100755 lb/entrypoint.sh create mode 100755 lb/hc-config.sh create mode 100755 lb/lbctl.sh create mode 100755 lb/pipeline.sh create mode 100644 lb/topology.env create mode 100755 scripts/host-prereq.sh create mode 100755 scripts/verify.sh diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..d8b257a --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,21 @@ +# Твоя роль + +- DevOps инженер +- Разработчик Backend +- Архитектор информационных систем + +# Стиль общения + +- профессиональный, но без жаргона + +# Стиль ответов + +- максимально емкие и содержательные +- не проваливайся в лишние детали, если это явно не было запрошено + +# Создание артефактов + +- На каждое новое изменение должен быть артефакт в .md файле +- Каждое новое изменение должно начинаться с плана внедрения в отдельном файле +- Каждое новое изменение должно заканчиваться суммаризацией по выполненым доработкам в отдельном файле +- каждое изменение дополняет или обновляет README.md diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b00ea94 --- /dev/null +++ b/Makefile @@ -0,0 +1,88 @@ +SHELL := /bin/bash +DC := docker compose + +.PHONY: help prereq shim shim-down build up down restart flows verify logs ports health slots drain enable metrics conns trace shell + +help: + @echo "Стенд hpnn_v2 — шаг 1 (OVS: маршрутизация + балансировка)" + @echo + @echo " make prereq загрузить модуль openvswitch на хосте" + @echo " make shim поднять macvlan-shim (проверка стенда с самого хоста)" + @echo " make shim-down снять shim" + @echo " make up собрать образы и запустить стенд" + @echo " make down остановить стенд и удалить сети" + @echo " make flows перечитать pipeline.sh и перезалить пайплайн" + @echo " make verify прогнать проверки" + @echo " make ports порты моста br-lb" + @echo " make health состояние членов пула по данным health-проб" + @echo " make slots раскладка таблицы слотов и счётчики" + @echo " make drain M=be1 вывести член из балансировки" + @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 logs логи контейнеров" + @echo " make shell shell в контейнере lb-router" + +prereq: + sudo scripts/host-prereq.sh + +shim: + sudo scripts/host-prereq.sh --shim + +shim-down: + sudo scripts/host-prereq.sh --shim-down + +build: + $(DC) build + +up: prereq + $(DC) up -d --build + @echo "Ждём готовности балансировщика..." + @for i in $$(seq 40); do \ + $(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 health + +down: + $(DC) down + +restart: + $(DC) restart lb-router + +flows: + $(DC) exec -T lb-router /opt/lb/apply.sh + +verify: + @scripts/verify.sh + +logs: + $(DC) logs --tail=80 + +ports: + $(DC) exec -T lb-router lbctl ports + +health: + $(DC) exec -T lb-router lbctl health + +slots: + $(DC) exec -T lb-router lbctl slots + +drain: + $(DC) exec -T lb-router lbctl drain $(M) + +enable: + $(DC) exec -T lb-router lbctl enable $(M) + +metrics: + $(DC) exec -T lb-router lbctl metrics + +conns: + $(DC) exec -T lb-router lbctl conns + +trace: + $(DC) exec -T lb-router lbctl trace $(SRC) $(SPORT) + +shell: + $(DC) exec lb-router bash diff --git a/README.md b/README.md new file mode 100644 index 0000000..71c9d14 --- /dev/null +++ b/README.md @@ -0,0 +1,257 @@ +# hpnn_v2 — стенд OVS-маршрутизатора и балансировщика нагрузки + +Контейнерный прототип основы сервиса: один узел на Open vSwitch, который +одновременно выполняет две функции — **маршрутизацию** между сегментами и +**балансировку нагрузки** на четыре бэкенда. Всё форвардинг-решение принимается +в OpenFlow: сетевой стек ядра контейнера транзитный трафик не обрабатывает. +Состав пула ведёт подсистема health-check: мёртвые бэкенды выводятся из +балансировки, восстановившиеся возвращаются. + +Развивает дизайн-концепцию `../hpnn_v1/docs/design.md`. + +| Шаг | Содержание | План | Итоги | +|---|---|---|---| +| 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) | + +## Топология + +``` + клиент из 192.168.5.0/24 + │ + [ 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` | OVS, все три сети, маршрутизация и балансировка | +| `hpnn-be1`, `hpnn-be3` | web-сервис на Go в приватном сегменте 2 | +| `hpnn-be2`, `hpnn-be4` | тот же сервис в приватном сегменте 3 | + +### Адресный план + +| Назначение | Адрес | +|---|---| +| Клиентская сеть | `192.168.5.0/24` | +| Адрес узла в публичном сегменте | `192.168.5.20` | +| **VIP балансировщика** | **`192.168.5.21:80`** | +| Шлюз 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` | +| Шлюзы Docker (собственный egress бэкендов) | `10.20.0.254`, `10.30.0.254` | + +Адреса `192.168.5.20` и `192.168.5.21` существуют **только в правилах +OpenFlow** — на интерфейсах они не настроены. ARP-респондер OVS отвечает на +них MAC-адресом порта `pub0`. Исключение — адреса `.253`: это единственные +адреса, которые узел держит в ядре, потому что с них уходят health-пробы. +Единственный источник правды по адресам — [lb/topology.env](lb/topology.env). + +## Запуск + +```bash +make up # modprobe openvswitch + сборка образов + запуск +make verify # проверки +make down # остановка +``` + +Проверка с самого хоста требует macvlan-shim: интерфейс macvlan контейнера и +физический интерфейс хоста по устройству macvlan не видят друг друга. Клиенту +из 192.168.5.0/24 никакой shim не нужен. + +```bash +make shim # поднять shim 192.168.5.13 с маршрутами на .20 и .21 +make shim-down +``` + +Полный сценарий ручной проверки стенда — [docs/MANUAL_TEST_PLAN.md](docs/MANUAL_TEST_PLAN.md) +(что выполнять на клиенте, что на хосте, чего ожидать на каждом шаге и что +означает отклонение). Автоматический аналог — `make verify`. + +## Проверка с клиента + +```bash +ping 192.168.5.20 # адрес узла — отвечает ICMP-респондер OVS +ping 192.168.5.21 # VIP + +for i in $(seq 20); do curl -s http://192.168.5.21/; done +``` + +``` +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 +backend=be2 time=2026-08-16 21:35:02 MSK client=192.168.5.13:47742 served=10.30.0.2:8080 req=30 +backend=be4 time=2026-08-16 21:35:02 MSK client=192.168.5.13:47746 served=10.30.0.3:8080 req=17 +``` + +Что здесь видно: + +- **балансировка** — ответы приходят от всех четырёх бэкендов примерно поровну; +- **сохранение IP клиента** — в поле `client` стоит реальный адрес из + 192.168.5.0/24, а не адрес балансировщика: выполняется только DNAT, SNAT + нигде не делается; +- ключ хэша — `(ip_src, tcp_src)`, поэтому разные соединения одного клиента + расходятся по бэкендам, а пакеты одного соединения — нет. + +Подсистему маршрутизации видно отдельно от балансировки: + +```bash +# на клиенте: маршруты в приватные сегменты через узел +ip route add 10.20.0.0/24 via 192.168.5.20 +ip route add 10.30.0.0/24 via 192.168.5.20 +curl http://10.20.0.2:8080/ # напрямую в бэкенд, мимо VIP +``` + +> Эти маршруты нужно прописывать на **клиентской машине**, а не на хосте +> стенда: на самом хосте они перекроют connected-маршруты docker-бриджей +> `hpnn-p2`/`hpnn-p3` и оборвут бэкендам выход наружу. + +Транзит между приватными сегментами проверяется изнутри: + +```bash +docker compose exec be1 curl -s http://10.30.0.2:8080/ # be1 -> be2 через OVS +``` + +## Подсистема health-check + +Демон `hcd` внутри контейнера-балансировщика проверяет живость членов пула и +отражает их состав в датапасе. Пробы уходят **с уникального адреса узла** в +сегменте бэкенда (`10.20.0.253`, `10.30.0.253`), а не с VIP — иначе ответ +вернулся бы не тому, кто проверял. + +```bash +make health # состояние членов пула +make slots # раскладка слотов и счётчики пакетов по членам +make drain M=be1 # вывести член из балансировки (пробы продолжаются) +make enable M=be1 # вернуть член в балансировку +make metrics # метрики Prometheus +``` + +``` +пул 1: слотов 1024, проба http каждые 2s (rise=2 fall=3) +дайджест раскладки: 0a16713c5a9eb94d + +ЧЛЕН АДРЕС СОСТОЯНИЕ ADMIN В ПУЛЕ СЛОТОВ ПРОБ НЕУДАЧ МС ИСТОЧНИК ПРОБ +be1 10.20.0.2:8080 up enabled да 256 120 0 1 10.20.0.253 +be2 10.30.0.2:8080 up enabled да 256 120 0 2 10.30.0.253 +be3 10.20.0.3:8080 up enabled да 256 120 0 2 10.20.0.253 +be4 10.30.0.3:8080 up enabled да 256 120 0 2 10.30.0.253 +``` + +Проверить руками: + +```bash +docker compose pause be1 # член уходит в down за fall × interval = 6 с +curl http://192.168.5.21/ # трафик обслуживают три оставшихся члена +docker compose unpause be1 # возвращается за rise × interval = 4 с +``` + +Параметры проб (тип, интервал, таймаут, `rise`/`fall`) — в +[lb/topology.env](lb/topology.env), раздел `HC_*`. + +### Таблица слотов + +Балансировка выполняется не группой OVS, а таблицей из 1024 слотов +(таблица 11). Номер слота даёт `multipath(symmetric_l4)` от заголовков +пакета, слот указывает на члена пула. Раскладку слотов по членам считает +`hcd` по алгоритму Maglev (§5 дизайна v1) и заливает атомарным бандлом. + +Свойства, которые это даёт: + +- **детерминированность** — одинаковый состав пула даёт одинаковую раскладку + на любом узле и между перезапусками; контролируется дайджестом SHA-256; +- **минимальное возмущение** — при выбытии члена переезжают его слоты, а у + оставшихся меняются единицы: на этом стенде вывод одного из четырёх членов + сдвигает 256 его слотов и лишь 8 чужих (0,8 %); проверяется в `make verify`; +- **fail-close** — когда живых членов нет, таблица пустеет и трафик на VIP + отбрасывается со счётчиком, а не уходит на заведомо мёртвый бэкенд; +- бесплатная **per-member статистика** из счётчиков правил слотов. + +## Осмотр датапаса + +```bash +make ports # порты моста +make conns # таблица соединений ct +make trace SRC=192.168.5.13 # ofproto/trace сессии клиент -> VIP +make flows # перечитать pipeline.sh и перезалить правила +docker compose exec lb-router lbctl flows 21 # правила конкретной таблицы +docker compose exec lb-router lbctl status # состояние пула в JSON +``` + +## Как устроен пайплайн + +Все правила рендерит [lb/pipeline.sh](lb/pipeline.sh) из `topology.env` и +применяет атомарным бандлом (`ovs-ofctl --bundle replace-flows`), поэтому +обновление не создаёт окна с полузалитым пайплайном. + +| Таблица | Назначение | +|---|---| +| 0 | Классификация по входному порту; обучение MAC клиентов для обратного пути | +| 5 | ARP-респондер для адресов узла, VIP и источников проб | +| 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 | +| 20 | Маршрутизация: приоритет = длина префикса, `dec_ttl`, MAC источника | +| 21 | Adjacency: MAC next-hop и выходной порт | + +Регистры: `reg1` — номер слота, `reg2` — идентификатор члена пула. + +Нумерация таблиц не произвольна: `goto_table` разрешает переход только вперёд, +поэтому обратный путь (15/16) стоит до общей маршрутизации (20). + +### Чего нет у чисто-OpenFlow узла + +Ядро контейнера в форвардинге не участвует, поэтому всё, что обычно делает +сетевой стек, реализовано правилами: ARP-ответы (таблица 5), ответы на ping +(таблица 6), поиск MAC клиента (действие `learn` в таблице 0 вместо +ARP-резолвера). ICMP-ошибки (`TTL exceeded`, `fragmentation needed`) на шаге 1 +не генерируются — см. ограничения в [docs/STEP1_SUMMARY.md](docs/STEP1_SUMMARY.md). + +## Особенности окружения + +- **Kernel datapath OVS** namespace-aware, поэтому контейнер держит собственный + `ovs-system` — это настоящий датапас ядра, а не эмуляция. Нужны + `privileged: true`, проброс `/lib/modules` и загруженный на хосте модуль + `openvswitch` (`make prereq`, модуль прописывается в автозагрузку). +- **Публичная сеть объявлена с подсетью `192.168.55.0/24`** — это фиктивный + пул для Docker IPAM. Реальную `192.168.5.0/24` объявить нельзя: её уже + занимает macvlan-сеть соседнего стенда `router-functest`, и Docker + отказывается регистрировать пересекающийся пул. Для L2 это безразлично — + macvlan включён в `enp3s0`, а адреса стенда живут в OpenFlow. +- **Шлюзы приватных сетей смещены на `.254`**, чтобы адрес `.1` занял + OVS-роутер и не конфликтовал с host-бриджем Docker. +- **Профиль A** (§3.2 дизайна v1): у бэкендов через балансировщик + маршрутизируются только клиентские префиксы и соседний приватный сегмент, + а `default` остаётся на шлюзе Docker — собственный исходящий трафик + бэкендов идёт мимо балансировщика. + +## Структура + +``` +docker-compose.yml три сети и три контейнера +Makefile up / down / flows / verify / health / диагностика +lb/topology.env адреса, MAC, номера портов, параметры проб и слотов +lb/pipeline.sh генератор OpenFlow-правил +lb/apply.sh атомарная заливка правил и запрос слотов у hcd +lb/entrypoint.sh запуск OVS, сборка моста, порты проб, запуск hcd +lb/hc-config.sh конфигурация health-check из topology.env +lb/lbctl.sh осмотр датапаса (ports / flows / health / slots / trace) +hc/maglev.go раскладка слотов, дайджест, рендер бандла +hc/main.go пробер, состояния членов пула, применение, API +backend/main.go web-сервис: имя хоста, дата и время, адрес клиента +scripts/host-prereq.sh модуль ядра и macvlan-shim +scripts/verify.sh проверки стенда +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..de7eff5 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY main.go ./ +RUN CGO_ENABLED=0 go build -trimpath -o /out/backend . + +FROM alpine:3.20 +# iproute2 — установка маршрутов на клиентские префиксы; curl нужен для +# проверки маршрутизации между приватными сегментами; tzdata — корректная +# локальная дата в ответе. +RUN apk add --no-cache iproute2 curl tzdata +COPY --from=build /out/backend /usr/local/bin/backend +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +HEALTHCHECK --interval=15s --timeout=3s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8080/healthz >/dev/null || exit 1 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100755 index 0000000..dc95aac --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Маршруты бэкенда (профиль A дизайна hpnn_v1, §3.2): +# - default остаётся на шлюзе Docker (.254) — собственный исходящий трафик +# бэкенда идёт мимо балансировщика; +# - через балансировщик маршрутизируются только клиентские префиксы и +# соседний приватный сегмент, то есть ответы на балансируемые сессии и +# транзит между сегментами. +set -eu + +: "${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 + +echo "[backend] таблица маршрутизации:" +ip route + +exec /usr/local/bin/backend "$@" diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..31e7203 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,3 @@ +module hpnn/backend + +go 1.22 diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..ac3db3d --- /dev/null +++ b/backend/main.go @@ -0,0 +1,105 @@ +// Бэкенд стенда hpnn_v2: показывает имя хоста, дату и время, а также реальный +// адрес клиента. Последнее — ключевая проверка схемы DNAT-без-SNAT: если в +// поле client стоит адрес из клиентской подсети, а не адрес балансировщика, +// значит трансляция источника нигде не выполняется. +package main + +import ( + "fmt" + "log" + "net" + "net/http" + "os" + "strconv" + "sync/atomic" + "time" +) + +var requests atomic.Uint64 + +func localAddr(r *http.Request) string { + if a, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr); ok { + return a.String() + } + return "unknown" +} + +func main() { + listen := os.Getenv("LISTEN") + if listen == "" { + listen = ":8080" + } + host, err := os.Hostname() + if err != nil { + host = "unknown" + } + + mux := http.NewServeMux() + + // Одна строка на запрос: удобно читать вывод цикла из 20 curl. + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + n := requests.Add(1) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintf(w, "backend=%-6s time=%s client=%-21s served=%-21s req=%d\n", + host, + time.Now().Format("2006-01-02 15:04:05 MST"), + r.RemoteAddr, + localAddr(r), + n) + }) + + // Развёрнутый вид — для ручного осмотра в браузере. + mux.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) { + n := requests.Add(1) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintf(w, "Имя хоста : %s\n", host) + fmt.Fprintf(w, "Дата и время : %s\n", time.Now().Format("2006-01-02 15:04:05 MST -07:00")) + fmt.Fprintf(w, "Адрес клиента : %s\n", r.RemoteAddr) + fmt.Fprintf(w, "Адрес назначения: %s\n", localAddr(r)) + fmt.Fprintf(w, "Host-заголовок : %s\n", r.Host) + fmt.Fprintf(w, "Запрос № : %d\n", n) + }) + + // Цель health-проб балансировщика. + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintln(w, "ok") + }) + + // Длинный ответ: тело отдаётся по строке в секунду. Нужен, чтобы держать + // открытую сессию во время смены состава пула и проверять, что уже + // установленное соединение не рвётся. /slow?seconds=20 + mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) { + seconds := 10 + if v := r.URL.Query().Get("seconds"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 120 { + seconds = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + flusher, _ := w.(http.Flusher) + for i := 1; i <= seconds; i++ { + fmt.Fprintf(w, "backend=%s tick=%d/%d time=%s\n", host, i, seconds, + time.Now().Format("15:04:05")) + if flusher != nil { + flusher.Flush() + } + select { + case <-r.Context().Done(): + return + case <-time.After(time.Second): + } + } + }) + + srv := &http.Server{ + Addr: listen, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + log.Printf("backend %s слушает %s", host, listen) + if err := srv.ListenAndServe(); err != nil { + log.Fatal(err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5a6189a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,140 @@ +name: hpnn-v2 + +services: + # Контейнер 1: маршрутизатор и балансировщик нагрузки в одном лице. + # Подключён ко всем трём сетям; весь форвардинг выполняет OVS (br-lb), + # сетевой стек ядра контейнера в передаче трафика не участвует. + lb-router: + # Контекст — корень репозитория: в образ едет и демон health-check (hc/). + build: + context: . + dockerfile: lb/Dockerfile + container_name: hpnn-lb + hostname: lb-router + # privileged + /lib/modules: kernel datapath OVS в сетевом пространстве + # имён контейнера (модуль openvswitch namespace-aware). + privileged: true + volumes: + - /lib/modules:/lib/modules:ro + environment: + TZ: Europe/Moscow + networks: + pub: + ipv4_address: 192.168.55.20 + mac_address: "02:42:c0:a8:05:14" + priv2: + ipv4_address: 10.20.0.1 + mac_address: "02:42:0a:14:00:01" + priv3: + ipv4_address: 10.30.0.1 + mac_address: "02:42:0a:1e:00:01" + restart: unless-stopped + + # Контейнер 2: бэкенд в приватном сегменте 2. + be1: + build: ./backend + container_name: hpnn-be1 + hostname: be1 + 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" + depends_on: [lb-router] + restart: unless-stopped + + # Контейнер 3: копия контейнера 2 в приватном сегменте 3. + be2: + build: ./backend + container_name: hpnn-be2 + hostname: be2 + 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" + depends_on: [lb-router] + restart: unless-stopped + + # Второй бэкенд в приватном сегменте 2. Четыре члена пула вместо двух дают + # содержательную проверку раскладки слотов: при выбытии одного члена его + # слоты делятся между тремя оставшимися, а не достаются единственному. + be3: + build: ./backend + container_name: hpnn-be3 + hostname: be3 + 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" + depends_on: [lb-router] + restart: unless-stopped + + # Второй бэкенд в приватном сегменте 3. + be4: + build: ./backend + container_name: hpnn-be4 + hostname: be4 + 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" + depends_on: [lb-router] + restart: unless-stopped + +networks: + # Сеть 1 — публичный сегмент: macvlan поверх физического enp3s0, то есть + # настоящий L2 клиентской сети 192.168.5.0/24. + # + # Объявленная здесь подсеть 192.168.55.0/24 намеренно фиктивная и служит + # только Docker IPAM: адреса узла (192.168.5.20) и VIP (192.168.5.21) + # существуют исключительно в правилах OpenFlow, а с интерфейса IP снимается + # при старте. Реальная 192.168.5.0/24 здесь объявлена быть не может — её уже + # занимает macvlan-сеть соседнего стенда router-functest, и Docker + # отказывается регистрировать пересекающийся пул. + pub: + driver: macvlan + driver_opts: + parent: enp3s0 + ipam: + config: + - subnet: 192.168.55.0/24 + + # Сеть 2 — приватный сегмент бэкенда be1. Шлюз Docker смещён на .254, + # чтобы адрес .1 занял OVS-роутер и не конфликтовал с host-бриджем. + priv2: + driver: bridge + driver_opts: + com.docker.network.bridge.name: hpnn-p2 + ipam: + config: + - subnet: 10.20.0.0/24 + gateway: 10.20.0.254 + + # Сеть 3 — дополнительный приватный сегмент бэкенда be2. + priv3: + driver: bridge + driver_opts: + com.docker.network.bridge.name: hpnn-p3 + ipam: + config: + - subnet: 10.30.0.0/24 + gateway: 10.30.0.254 diff --git a/docs/CHANGE_POOL_4_MEMBERS_PLAN.md b/docs/CHANGE_POOL_4_MEMBERS_PLAN.md new file mode 100644 index 0000000..82b9697 --- /dev/null +++ b/docs/CHANGE_POOL_4_MEMBERS_PLAN.md @@ -0,0 +1,77 @@ +# План внедрения: расширение пула до четырёх бэкендов + +**Дата:** 2026-08-16 +**Основание:** стенд шага 2 ([STEP2_SUMMARY.md](STEP2_SUMMARY.md)) + +## Контекст + +Стенд работает с пулом из двух членов — по одному бэкенду в каждом приватном +сегменте. На двух членах не видны свойства, ради которых на шаге 2 введена +таблица слотов: при выбытии единственного «соседа» оставшийся член +тривиально забирает все слоты, и минимальное возмущение раскладки нечем +измерить. + +Задача: добавить по одному бэкенду в каждый приватный сегмент (всего четыре +члена пула) и оставить алгоритм балансировки `symmetric_l4`. + +## Состав изменения + +### Новые контейнеры + +| Контейнер | Сеть | Адрес | MAC | ID члена | +|---|---|---|---|---| +| `hpnn-be3` | priv2 | `10.20.0.3` | `02:42:0a:14:00:03` | 3 | +| `hpnn-be4` | priv3 | `10.30.0.3` | `02:42:0a:1e:00:03` | 4 | + +Образ и маршруты — те же, что у существующих бэкендов: через балансировщик +маршрутизируются только клиентские префиксы и соседний приватный сегмент, +`default` остаётся на шлюзе Docker (профиль A). + +### Файлы + +| Файл | Изменение | +|---|---| +| `docker-compose.yml` | сервисы `be3`, `be4` | +| `lb/topology.env` | `BE3_*`, `BE4_*` | +| `lb/pipeline.sh` | правила DNAT (таблица 12) и adjacency (таблица 21) для новых членов | +| `lb/entrypoint.sh` | статические ARP-записи для вторых бэкендов на портах-источниках проб | +| `lb/hc-config.sh` | члены пула 3 и 4 | +| `scripts/verify.sh` | проверки обобщаются с двух членов на четыре | + +Алгоритм балансировки не меняется: `multipath(symmetric_l4, …)` уже стоит в +листенере, менять нечего. + +### Что произойдёт автоматически + +Раскладка слотов пересчитается сама: демон опросит новых членов, переведёт их +в `up` и зальёт таблицу слотов на четверых. Ожидаемое распределение — по 256 +слотов на члена (1024 / 4). + +## Отдельная проверка: минимальное возмущение на четырёх членах + +На двух членах проверка была вырожденной — оставшийся член забирал все слоты, +поэтому «чужих» переехавших слотов было ровно ноль. На четырёх членах +ситуация содержательнее: слоты выбывшего распределяются между тремя +оставшимися, и порядок заполнения меняется. + +Классический Maglev гарантирует **малое**, а не нулевое возмущение. Поэтому +проверка формулируется как порог: доля слотов, сменивших владельца среди +оставшихся членов, должна быть заметно меньше доли слотов выбывшего члена +(25 %). Фактическое значение измеряется при реализации и фиксируется в итогах +вместе с порогом. + +## Верификация + +1. `make health` — четыре члена `up`, по 256 слотов, сумма 1024. +2. Балансировка с клиента: в 20 запросах встречаются все четыре бэкенда. +3. `make slots` — счётчики пакетов растут у всех четырёх. +4. Транзит между сегментами работает для новых бэкендов тоже. +5. Отказ одного члена: его слоты уходят к трём оставшимся, трафик без ошибок. +6. Минимальное возмущение: измерить долю переехавших чужих слотов. +7. `make verify` — полный автоматический прогон. + +## Артефакты + +1. Этот план. +2. `docs/CHANGE_POOL_4_MEMBERS_SUMMARY.md` — итоги и измеренные значения. +3. Обновление `README.md` и `docs/MANUAL_TEST_PLAN.md` под новый состав пула. diff --git a/docs/CHANGE_POOL_4_MEMBERS_SUMMARY.md b/docs/CHANGE_POOL_4_MEMBERS_SUMMARY.md new file mode 100644 index 0000000..bbf826c --- /dev/null +++ b/docs/CHANGE_POOL_4_MEMBERS_SUMMARY.md @@ -0,0 +1,81 @@ +# Итоги: расширение пула до четырёх бэкендов + +**Дата:** 2026-08-16 +**Статус:** выполнено, проверено (64 из 64 проверок) +**План:** [CHANGE_POOL_4_MEMBERS_PLAN.md](CHANGE_POOL_4_MEMBERS_PLAN.md) + +## Что сделано + +Добавлено по одному бэкенду в каждый приватный сегмент — пул вырос с двух +членов до четырёх. Алгоритм балансировки оставлен прежним, `symmetric_l4`. + +| Контейнер | Сеть | Адрес | MAC | ID члена | Слотов | +|---|---|---|---|---|---| +| `hpnn-be1` | priv2 | `10.20.0.2` | `02:42:0a:14:00:02` | 1 | 256 | +| `hpnn-be3` | priv2 | `10.20.0.3` | `02:42:0a:14:00:03` | 3 | 256 | +| `hpnn-be2` | priv3 | `10.30.0.2` | `02:42:0a:1e:00:02` | 2 | 256 | +| `hpnn-be4` | priv3 | `10.30.0.3` | `02:42:0a:1e:00:03` | 4 | 256 | + +Дайджест раскладки: `0a16713c5a9eb94d` (был `2f0ca39d5f33efa6` на двух +членах). + +Изменённые файлы: `docker-compose.yml`, `lb/topology.env`, `lb/pipeline.sh` +(DNAT в таблице 12 и adjacency в таблице 21 для новых членов), +`lb/entrypoint.sh` (статические ARP-записи для вторых бэкендов на портах +проб), `lb/hc-config.sh`, `scripts/verify.sh`. + +## Результаты проверок + +| Проверка | Результат | +|---|---| +| Обнаружение новых членов | оба перешли в `up` за 4 с после старта демона | +| Раскладка | ровно 256 / 256 / 256 / 256, сумма 1024 | +| Балансировка с клиента | в 24 запросах присутствуют все четыре бэкенда (7/5/6/6) | +| Транзит между сегментами | все четыре направления: be1↔be2, be3↔be4 и обратные | +| Маршруты бэкендов | у всех четырёх профиль A: default на шлюзе Docker | +| Отказ члена | `pause be1` → его 256 слотов разошлись по трём оставшимся (341/342/341), трафик без ошибок | +| Восстановление | дайджест вернулся к `0a16713c5a9eb94d` | +| Fail-close | при отказе всех четырёх таблица слотов пуста, клиент получает таймаут | + +## Главное измерение: возмущение раскладки на четырёх членах + +На двух членах проверка минимального возмущения была вырожденной — +единственный оставшийся член забирал все слоты, и «чужих» переехавших слотов +получалось ровно ноль по построению. На четырёх членах она стала +содержательной. + +Вывод be1 из пула: + +- переехали **256** слотов самого be1 — это его четверть таблицы, они обязаны + сменить владельца; +- у трёх оставшихся членов сменили владельца **8** слотов из 768 — **1,0 %** + их слотов, или 0,8 % всей таблицы. + +Это ожидаемое поведение классического Maglev: алгоритм гарантирует *малое*, а +не строго нулевое возмущение. Проверка в `verify.sh` поэтому сформулирована +как порог (не более 20 слотов), а не как равенство нулю. Предыдущая +формулировка «ни один слот не сменил владельца» была верна только для пула из +двух членов и на четырёх уже не выполняется. + +Для сравнения: группа `type=select` из шага 1 при изменении числа бакетов +переложила бы **все** соединения, а не 1 %. + +## Отклонения от плана + +Отклонений от плана нет. Уточнение по процедуре: `docker compose up -d +--build` не пересоздал контейнер балансировщика — он поднял только новые +бэкенды, и демон продолжил работать со старой конфигурацией на двух членах. +Потребовалось явное `docker compose up -d --build --force-recreate +lb-router`. Это стоит помнить при любых правках `topology.env` и +`hc-config.sh`. + +## Ограничения + +Все ограничения шага 2 сохраняются без изменений (stateful-датапас, +отсутствие кворума, статический состав пула, только HTTP/TCP-пробы). Новое: + +- члены пула по-прежнему перечислены статически в трёх местах — + `docker-compose.yml`, `lb/topology.env` и `lb/hc-config.sh`, плюс правила + DNAT и adjacency в `lb/pipeline.sh`. Добавление пятого члена — снова ручная + правка четырёх файлов. Это именно то, что снимет модель + `Load_Balancer → Listener → Pool → Member` в OVSDB на следующем шаге. diff --git a/docs/MANUAL_TEST_PLAN.md b/docs/MANUAL_TEST_PLAN.md new file mode 100644 index 0000000..7e335cd --- /dev/null +++ b/docs/MANUAL_TEST_PLAN.md @@ -0,0 +1,878 @@ +# План ручного тестирования стенда hpnn_v2 + +Пошаговая проверка обеих подсистем — маршрутизации и балансировки — и +подсистемы health-check. Рассчитан на прохождение целиком примерно за 25–30 +минут. + +Автоматический аналог большей части проверок — `make verify`. Этот документ +нужен, чтобы увидеть поведение стенда своими глазами и понять, что означает +каждый результат. + +## Обозначения + +| Метка | Где выполнять | +|---|---| +| **[К]** | на клиентской машине в подсети 192.168.5.0/24 | +| **[Х]** | на хосте стенда (192.168.5.9), из каталога `/opt/lvraid/claude/hpnn_v2` | + +Команды **[Х]** требуют root или членства в группе `docker`. + +## Подготовка + +**Шаг 0.1 [Х]** — поднять стенд: + +```bash +cd /opt/lvraid/claude/hpnn_v2 +make up +``` + +Ожидается: пять контейнеров в состоянии `healthy` и таблица состояния пула, +где все четыре члена `up` и у каждого по 256 слотов. + +**Шаг 0.2 [Х]** — убедиться, что всё запустилось: + +```bash +docker compose ps +``` + +Ожидается: + +``` +hpnn-be1 Up ... (healthy) +hpnn-be2 Up ... (healthy) +hpnn-be3 Up ... (healthy) +hpnn-be4 Up ... (healthy) +hpnn-lb Up ... (healthy) +``` + +Если `hpnn-lb` в состоянии `Restarting` — смотрите `docker compose logs +lb-router` и раздел «Диагностика» в конце документа. + +**Шаг 0.3 [К]** — проверить, что клиент в нужной подсети: + +```bash +ip -br addr | grep 192.168.5 +``` + +Адрес клиента понадобится дальше; обозначим его ``. + +> Если проверять хотите с самого хоста стенда, а не с отдельной машины, +> выполните **[Х]** `make shim` — macvlan-интерфейс контейнера и физический +> интерфейс хоста напрямую друг друга не видят, это ограничение macvlan. +> Тогда все шаги **[К]** выполняются на хосте, а `` = 192.168.5.13. + +--- + +## Блок A. Базовая доступность узла + +Проверяем, что OVS отвечает за адреса, которых нет ни на одном интерфейсе: +ARP-респондер и ICMP-респондер живут в правилах OpenFlow. + +**Шаг A.1 [К]** — доступность адреса узла: + +```bash +ping -c3 192.168.5.20 +``` + +Ожидается: три ответа. Отвечает не сетевой стек, а правило таблицы 6. + +**Шаг A.2 [К]** — доступность VIP: + +```bash +ping -c3 192.168.5.21 +``` + +Ожидается: три ответа. + +**Шаг A.3 [К]** — какой MAC отдаёт ARP-респондер: + +```bash +ip neigh flush 192.168.5.20 2>/dev/null; ping -c1 192.168.5.20 >/dev/null +ip neigh show | grep -E '192.168.5.2[01]' +``` + +Ожидается: оба адреса, `.20` и `.21`, разрешаются в **один и тот же** MAC +`02:42:c0:a8:05:14` — это MAC macvlan-порта балансировщика. + +Если ответа нет ни на один ping — переходите сразу к разделу «Диагностика», +дальнейшие блоки бессмысленны. + +--- + +## Блок B. Балансировка нагрузки + +**Шаг B.1 [К]** — один запрос на VIP: + +```bash +curl http://192.168.5.21/ +``` + +Ожидается строка вида: + +``` +backend=be1 time=2026-08-16 20:18:49 MSK client=192.168.5.13:59660 served=10.20.0.2:8080 req=1 +``` + +**Шаг B.2 [К]** — распределение по бэкендам: + +```bash +for i in $(seq 24); do curl -s http://192.168.5.21/; done | sort | uniq -c -w 12 +``` + +Ожидается: встречаются все четыре имени — `be1`…`be4` — примерно поровну, по +6 ± 3 на 24 запроса. Заметный перекос на такой выборке нормален, это +статистика, а не дефект; равномерность раскладки проверяется по слотам в +шаге B.5, а не по числу запросов. + +Что это означает: слот выбирается хэшем от `(ip_src, tcp_src)`, а порт +источника у каждого нового соединения свой. + +**Шаг B.3 [К]** — сохранение IP клиента (ключевая проверка DNAT-без-SNAT): + +```bash +curl -s http://192.168.5.21/ | grep -o 'client=[0-9.]*' +``` + +Ожидается: `client=` — реальный адрес вашей машины. + +Если бы выполнялся SNAT, здесь стоял бы адрес балансировщика. Бэкенд видит +клиента напрямую — это то, ради чего обратный трафик заворачивается через +балансировщик маршрутом. + +**Шаг B.4 [К]** — одно соединение не «размазывается» по бэкендам: + +```bash +curl -s http://192.168.5.21/info +``` + +Ожидается: развёрнутая карточка одного бэкенда. Все пакеты одной сессии идут +на один член пула — хэш считается от заголовков, одинаковых внутри сессии. + +**Шаг B.5 [Х]** — увидеть распределение со стороны датапаса: + +```bash +make slots +``` + +Ожидается: у всех четырёх членов по 256 слотов, счётчики пакетов растут у всех. + +--- + +## Блок C. Подсистема маршрутизации + +Балансировка — не единственная функция узла. Проверяем транзит. + +**Шаг C.1 [Х]** — транзит между приватными сегментами: + +```bash +docker compose exec be1 curl -s http://10.30.0.2:8080/ +``` + +Ожидается: ответ `backend=be2`, в поле `client` — `10.20.0.2`. + +Пакет прошёл из сети 2 в сеть 3 через таблицы маршрутизации OpenFlow (20 и +21), без участия ядра контейнера и без всякой трансляции. + +**Шаг C.2 [К]** — маршрутизация из публичного сегмента в приватный. +Пропишите на клиенте маршруты в приватные сегменты через узел — команды для +Linux и Windows 11 приведены в [приложении А](#приложение-а-маршруты-на-клиенте). +После этого обратитесь к бэкендам напрямую, минуя VIP: + +```bash +curl http://10.20.0.2:8080/ +curl http://10.30.0.2:8080/ +``` + +Ожидается: ответы от be1 и be2 соответственно, в обоих `client=`. +Балансировка здесь не участвует — работает только подсистема маршрутизации. + +> **Не выполняйте этот шаг на хосте стенда.** Там эти маршруты перекроют +> connected-маршруты docker-бриджей `hpnn-p2`/`hpnn-p3` и оборвут бэкендам +> выход наружу. На отдельной клиентской машине проблемы нет. + +**Шаг C.3 [К]** — узел является настоящим L3-хопом: + +```bash +ping -c1 -t 1 10.20.0.2 # Linux: потеря, пакет умирает на узле +ping -c1 -t 2 10.20.0.2 # Linux: проходит +``` + +```powershell +ping -n 1 -i 1 10.20.0.2 # Windows: потеря +ping -n 1 -i 2 10.20.0.2 # Windows: проходит +``` + +Ожидается: с TTL=1 ответа нет, с TTL=2 есть. Сообщения `TTL expired in +transit` не будет — узел ICMP-ошибки не генерирует, поэтому и `traceroute` / +`tracert` через стенд ничего осмысленного не покажет. + +**Шаг C.4 [Х]** — трафик действительно идёт через таблицы маршрутизации: + +```bash +docker compose exec lb-router lbctl flows 20 +docker compose exec lb-router lbctl flows 21 +``` + +Ожидается: у правил `nw_dst=10.20.0.0/24`, `nw_dst=10.30.0.0/24` и +`nw_dst=192.168.5.0/24` растут счётчики `n_packets`, у правила `priority=0 +actions=drop` — нет. + +**Шаг C.5 [Х]** — назначение без маршрута отбрасывается (дефолта у узла нет): + +```bash +docker compose exec be1 ip route add 10.40.0.0/24 via 10.20.0.1 +docker compose exec lb-router lbctl flows 20 | grep priority=0 +docker compose exec be1 ping -c2 -W1 10.40.0.7 +docker compose exec lb-router lbctl flows 20 | grep priority=0 +docker compose exec be1 ip route del 10.40.0.0/24 via 10.20.0.1 +``` + +Ожидается: ping без ответа, счётчик drop вырос ровно на число пакетов (2). + +**Шаг C.6 [Х]** — ядро контейнера-узла в транзите не участвует: + +```bash +docker compose exec lb-router ip route +``` + +Ожидается: всего два connected-маршрута, на `hcif-p2` и `hcif-p3`, — они +нужны только health-пробам. Маршрутов на клиентскую сеть и на бэкенды в ядре +нет вовсе, а трафик при этом ходит: вся маршрутизация живёт в OpenFlow. + +**Шаг C.7 [К]** — уберите маршруты после проверки (см. приложение А). + +**Шаг C.8 [Х]** — профиль A: собственный трафик бэкенда идёт мимо +балансировщика. В одном терминале: + +```bash +docker compose exec lb-router tcpdump -ni p2 'host 1.1.1.1' +``` + +Во втором: + +```bash +docker compose exec be1 curl -s -o /dev/null -w '%{http_code}\n' http://1.1.1.1/ +``` + +Ожидается: код `301` во втором терминале и **ни одного пакета** в tcpdump. +Через балансировщик у бэкенда маршрутизируются только клиентские префиксы, +а `default` остаётся на шлюзе Docker. + +--- + +## Блок D. Health-check: наблюдение + +**Шаг D.1 [Х]** — состояние пула: + +```bash +make health +``` + +Ожидается таблица, где у всех четырёх членов `СОСТОЯНИЕ=up`, +`ADMIN=enabled`, `В ПУЛЕ=да`, по 256 слотов, счётчик `ПРОБ` растёт при +повторных вызовах, `НЕУДАЧ` не растёт. + +Обратите внимание на колонку `ИСТОЧНИК ПРОБ`: `10.20.0.253` и `10.30.0.253` — +уникальные адреса узла в сегментах бэкендов, а не VIP. + +**Шаг D.2 [Х]** — убедиться, что пробы действительно уходят с этих адресов: + +```bash +docker compose exec lb-router timeout 6 tcpdump -ni p2 'tcp port 8080 and tcp[tcpflags] & tcp-syn != 0' +``` + +Ожидается: примерно раз в 2 секунды SYN вида +`IP 10.20.0.253.xxxxx > 10.20.0.2.8080`. + +Почему это важно: если бы пробы уходили с VIP, ответ бэкенда попал бы в +логику обратной трансляции и до пробера не дошёл. + +**Шаг D.3 [Х]** — метрики: + +```bash +make metrics +``` + +Ожидается: `hpnn_member_up{member="be1",...} 1`, аналогично для be2, +`hpnn_member_slots` по 512, счётчики `hpnn_probes_total` растут. + +--- + +## Блок E. Отказ и восстановление бэкенда + +**Шаг E.1 [К]** — запустите фоновую нагрузку, чтобы видеть поведение под +трафиком (оставьте работать до конца блока): + +```bash +while true; do curl -s --max-time 3 http://192.168.5.21/ || echo "ОШИБКА $(date +%T)"; sleep 0.5; done +``` + +**Шаг E.2 [Х]** — «уроните» первый бэкенд: + +```bash +docker compose pause be1 +``` + +**Шаг E.3 [Х]** — через 6–8 секунд посмотрите состояние: + +```bash +make health +``` + +Ожидается: `be1` в состоянии `down`, 0 слотов, в строке ниже — причина +(`context deadline exceeded`); его 256 слотов разошлись между be2, be3 и be4 +(примерно по 341). + +Порог перехода: `fall=3` неудачных пробы при интервале 2 с, то есть до 6 с. + +**Шаг E.4 [К]** — посмотрите на окно с нагрузкой. + +Ожидается: после короткого промежутка ответы приходят только от живых членов, +строк `ОШИБКА` нет либо их единицы — те запросы, что успели уйти на be1 до +обнаружения отказа. Это и есть цена интервала проб: чем он меньше, тем короче +окно, но тем выше нагрузка проб на бэкенды. + +**Шаг E.5 [Х]** — верните бэкенд: + +```bash +docker compose unpause be1 +``` + +**Шаг E.6 [Х]** — через 4–6 секунд: + +```bash +make health +``` + +Ожидается: `be1` снова `up`, слоты вернулись к 256 у каждого, и **дайджест +раскладки совпадает с тем, что был до отказа** — раскладка детерминирована. + +**Шаг E.7 [К]** — остановите фоновую нагрузку (Ctrl+C). + +--- + +## Блок F. Таблица слотов: минимальное возмущение + +Самая содержательная проверка шага 2. Убеждаемся, что вывод одного члена +не перекладывает слоты другого. + +**Шаг F.1 [Х]** — снимок раскладки до изменения: + +```bash +snap() { docker compose exec -T lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb table=11 \ + | sed -n 's/.*reg1=\(0x[0-9a-f]*\|[0-9]\+\).*set_field:\(0x[0-9a-f]*\)->reg2.*/\1 \2/p' | sort; } +snap > /tmp/slots_before.txt +wc -l < /tmp/slots_before.txt +``` + +Ожидается: `1024`. + +**Шаг F.2 [Х]** — вывести be1 из балансировки (не останавливая его): + +```bash +make drain M=be1 +make health +``` + +Ожидается: у be1 `ADMIN=drain`, `В ПУЛЕ=нет`, 0 слотов — но `СОСТОЯНИЕ` +осталось `up` и счётчик проб продолжает расти. Дренаж означает «прекратить +приём новых сессий», а не «остановить проверку». + +**Шаг F.3 [Х]** — сравнить раскладки: + +```bash +snap > /tmp/slots_after.txt +join /tmp/slots_before.txt /tmp/slots_after.txt | awk '$2!="0x1" && $2!=$3' | wc -l # чужие слоты +join /tmp/slots_before.txt /tmp/slots_after.txt | awk '$2!=$3' | wc -l # всего +``` + +Ожидается: первое число — **не больше 20** (на этом стенде измерено 8), +второе — 264. + +Смысл: 256 слотов выбывшего be1 обязаны переехать — это его четверть +таблицы. Показательно второе: у трёх оставшихся членов сменили владельца лишь +единицы слотов. Классический Maglev гарантирует малое, а не строго нулевое +возмущение, поэтому проверка сформулирована как порог, а не как равенство +нулю. + +**Шаг F.4 [Х]** — вернуть be1: + +```bash +make enable M=be1 +make health +``` + +Ожидается: по 256 слотов у каждого и прежний дайджест. + +**Шаг F.5 [Х]** — проследить путь конкретной сессии по таблицам: + +```bash +make trace SRC=192.168.5.100 SPORT=41234 +``` + +Ожидается цепочка `0 → 10 → 11 → 12`, затем после `ct(...nat(dst=...))` — +`20 → 21` и выход в порт бэкенда. В `Final flow` видно, что `nw_src` остался +клиентским, `nw_dst` заменён на адрес бэкенда, `nw_ttl` уменьшен на единицу. + +Повторите команду с теми же аргументами — номер слота (`reg1`) и член пула +(`reg2`) обязаны совпасть. Это проверка детерминизма выбора. + +--- + +## Блок G. Fail-close при полном отказе пула + +**Шаг G.1 [Х]** — «уронить» оба бэкенда: + +```bash +docker compose pause be1 be2 be3 be4 +``` + +**Шаг G.2 [Х]** — через 8 секунд: + +```bash +make health +make slots +``` + +Ожидается: оба члена `down`, 0 слотов у каждого; в `make slots` строк с +членами нет вовсе, есть только счётчик правила fail-close. + +**Шаг G.3 [К]** — обратиться на VIP: + +```bash +time curl --max-time 5 http://192.168.5.21/ +``` + +Ожидается: таймаут, а не ответ и не мгновенный отказ. Балансировщик молча +отбрасывает трафик: отдавать соединения на заведомо мёртвый бэкенд хуже, чем +не отдавать вовсе. + +**Шаг G.4 [Х]** — убедиться, что пакеты именно отброшены правилом, а не +потерялись где-то ещё: + +```bash +make slots +``` + +Ожидается: счётчик `отброшено правилом fail-close` вырос на число попыток. + +**Шаг G.5 [Х]** — восстановить пул: + +```bash +docker compose unpause be1 be2 be3 be4 +sleep 6 && make health +``` + +Ожидается: все четыре `up`, по 256 слотов, прежний дайджест. + +--- + +## Блок H. Устойчивость конфигурации + +**Шаг H.1 [Х]** — перезаливка пайплайна не теряет раскладку: + +```bash +make health | grep дайджест # запомните значение +make flows +make health | grep дайджест # значение то же +make slots # по 256 слотов у каждого +``` + +Смысл: `make flows` перезаливает все правила целиком и стирает таблицу +слотов, после чего демон восстанавливает её в **актуальном** составе пула, а +не в полном. + +**Шаг H.2 [Х]** — состояние переживает перезапуск балансировщика: + +```bash +make health | grep дайджест # запомните значение +docker compose restart lb-router +sleep 20 && make health +``` + +Ожидается: после перезапуска все четыре члена снова `up` (через `rise=2` +пробы) и +дайджест раскладки **тот же самый**. Раскладка не хранится нигде — она +вычисляется заново и совпадает, потому что алгоритм детерминирован. + +**Шаг H.3 [К]** — стенд обслуживает трафик после перезапуска: + +```bash +for i in $(seq 6); do curl -s http://192.168.5.21/; done +``` + +--- + +## Блок I. Дополнительно: выживание установленной сессии + +**Шаг I.1 [К]** — запустите длинную сессию (ответ отдаётся по строке в +секунду) и запомните, какой бэкенд её обслуживает: + +```bash +curl -N "http://192.168.5.21/slow?seconds=20" +``` + +**Шаг I.2 [Х]** — пока сессия идёт, выведите обслуживающий её член из пула +(подставьте имя из вывода на клиенте): + +```bash +make drain M=be2 +``` + +**Шаг I.3 [К]** — наблюдайте за выводом. + +Ожидается: сессия **не рвётся**, все 20 тиков приходят от того же бэкенда. +Причина — трансляция закрепляется за соединением при его создании +(`ct(commit, nat)`), и последующие пакеты следуют существующей привязке +независимо от того, что показывает таблица слотов. + +Практический вывод: дренаж прекращает приём новых сессий, но не завершает +активные. Для graceful shutdown приложение обязано само дождаться завершения +запросов после исключения из пула. + +**Шаг I.4 [Х]** — вернуть член: + +```bash +make enable M=be2 +``` + +--- + +## Итоговый чек-лист + +| # | Проверка | Результат | +|---|---|---| +| A | Узел и VIP отвечают на ping, оба адреса — один MAC | ☐ | +| B | Запросы на VIP распределяются между be1 и be2 | ☐ | +| B | Бэкенд видит реальный IP клиента | ☐ | +| C | Транзит be1 → be2 работает | ☐ | +| C | Клиент попадает в приватные сегменты через узел | ☐ | +| C | TTL уменьшается: с `-t 1` пакет не доходит, с `-t 2` доходит | ☐ | +| C | Счётчики таблиц 20 и 21 растут, drop-правило молчит | ☐ | +| C | Назначение без маршрута отбрасывается со счётчиком | ☐ | +| C | В ядре узла нет маршрутов на транзитные сети | ☐ | +| C | Egress бэкенда идёт мимо балансировщика | ☐ | +| D | Пробы уходят с адресов `.253`, не с VIP | ☐ | +| E | Отказ бэкенда обнаружен за ~6 с, трафик перешёл на живого | ☐ | +| E | После восстановления раскладка вернулась к прежнему дайджесту | ☐ | +| F | При выводе члена ни один чужой слот не переехал | ☐ | +| F | Повторный trace даёт тот же слот и член | ☐ | +| G | При пустом пуле трафик отбрасывается, счётчик растёт | ☐ | +| H | `make flows` и перезапуск не ломают раскладку | ☐ | +| I | Установленная сессия переживает дренаж | ☐ | + +--- + +## Возврат стенда в исходное состояние + +```bash +# [Х] +for m in be1 be2 be3 be4; do make enable M=$m; done # снять дренаж, если остался +docker compose unpause be1 be2 be3 be4 2>/dev/null || true +make health # все up, по 256 слотов +rm -f /tmp/slots_before.txt /tmp/slots_after.txt +``` + +Маршруты, добавленные на клиенте в блоке C, снимаются командами из +[приложения А](#приложение-а-маршруты-на-клиенте) — они разные для Linux и +Windows. + +Если меняли параметры стенда — см. +[Б.5](#б5-возврат-к-исходной-конфигурации). + +Полная остановка стенда: **[Х]** `make down`. Снять macvlan-shim, если +поднимали: `make shim-down`. + +--- + +## Приложение А. Маршруты на клиенте + +Нужны только для проверки подсистемы маршрутизации (блок C) — обращение к +бэкендам напрямую, минуя VIP. Для проверки балансировки маршруты не нужны: +VIP находится в той же подсети, что и клиент. + +Шлюз во всех командах — адрес узла `192.168.5.20`. + +### Linux + +```bash +sudo ip route add 10.20.0.0/24 via 192.168.5.20 +sudo ip route add 10.30.0.0/24 via 192.168.5.20 + +ip route get 10.20.0.2 # проверка выбора маршрута +curl http://10.20.0.2:8080/ + +sudo ip route del 10.20.0.0/24 via 192.168.5.20 +sudo ip route del 10.30.0.0/24 via 192.168.5.20 +``` + +Маршруты живут до перезагрузки. + +### Windows 11 (PowerShell) + +PowerShell нужно запустить **от имени администратора**. + +Определить индекс сетевого интерфейса: + +```powershell +Get-NetIPAddress -AddressFamily IPv4 | + Where-Object { $_.IPAddress -like '192.168.5.*' } | + Select-Object IPAddress, InterfaceIndex, InterfaceAlias + +$if = (Get-NetIPAddress -AddressFamily IPv4 | + Where-Object { $_.IPAddress -like '192.168.5.*' } | + Select-Object -First 1).InterfaceIndex +``` + +Если строк несколько (например, есть VPN-адаптер) — возьмите нужный индекс +вручную. + +Добавить маршруты: + +```powershell +New-NetRoute -DestinationPrefix 10.20.0.0/24 -NextHop 192.168.5.20 -InterfaceIndex $if -PolicyStore ActiveStore +New-NetRoute -DestinationPrefix 10.30.0.0/24 -NextHop 192.168.5.20 -InterfaceIndex $if -PolicyStore ActiveStore +``` + +`-PolicyStore ActiveStore` делает маршруты временными — до перезагрузки. Без +этого параметра `New-NetRoute` пишет их в `PersistentStore`, и они переживут +ребут; для теста это лишнее. + +Проверить и обратиться к бэкендам: + +```powershell +Get-NetRoute -DestinationPrefix 10.2*.0.0/24 | Select-Object DestinationPrefix, NextHop, InterfaceIndex +Test-NetConnection 10.20.0.2 -Port 8080 +curl.exe http://10.20.0.2:8080/ +curl.exe http://10.30.0.2:8080/ +``` + +В PowerShell `curl` — алиас на `Invoke-WebRequest`, поэтому пишите именно +`curl.exe`. + +Удалить после проверки: + +```powershell +Remove-NetRoute -DestinationPrefix 10.20.0.0/24 -Confirm:$false +Remove-NetRoute -DestinationPrefix 10.30.0.0/24 -Confirm:$false +``` + +Вариант через классический `route` (без `-p` тоже временный): + +```powershell +route add 10.20.0.0 mask 255.255.255.0 192.168.5.20 +route add 10.30.0.0 mask 255.255.255.0 192.168.5.20 +route print 10.* +route delete 10.20.0.0 +route delete 10.30.0.0 +``` + +### Нагрузочное тестирование маршрутизации + +Цель для k6 — `http://10.20.0.2:8080/` вместо `http://192.168.5.21/`. Трафик +пойдёт через таблицы маршрутизации 20 и 21, минуя листенер, таблицу слотов и +DNAT. Сравнение показателей двух путей даёт цену балансировки в чистом виде. + +--- + +## Приложение Б. Изменение параметров стенда + +### Что где лежит + +| Что меняем | Файл | Как применить | +|---|---|---| +| Тип пробы, интервал, таймаут, `rise`/`fall` | `lb/topology.env`, блок `HC_*` | пересборка образа | +| Веса членов пула | `lb/hc-config.sh`, поле `weight` | пересборка образа | +| Ключ хэша (алгоритм балансировки) | `lb/pipeline.sh`, таблица 10 | `make flows` | +| Число слотов, basis хэша | `lb/topology.env`: `SLOTS`, `POOL_ID` | пересборка образа | +| Состав пула на лету | — | `make drain` / `make enable` | + +«Пересборка образа» — это: + +```bash +docker compose up -d --build lb-router +``` + +Файлы вшиты в образ на этапе сборки, поэтому правка на хосте без пересборки +ни на что не влияет. Конфигурация демона генерируется при старте контейнера, +так что `make flows` для параметров health-check не поможет — нужен именно +перезапуск. + +### Б.1. Параметры health-check + +```bash +vi lb/topology.env +``` + +```bash +HC_PROBE=http # http | tcp +HC_HTTP_PATH=/healthz +HC_INTERVAL=2s # период опроса +HC_TIMEOUT=1s # таймаут одной пробы +HC_RISE=2 # успехов подряд для перевода в up +HC_FALL=3 # неудач подряд для перевода в down +``` + +```bash +docker compose up -d --build lb-router +sleep 15 && make health +``` + +Проверить, что новые параметры применились, — в первой строке вывода +`make health`: + +``` +пул 1: слотов 1024, проба http каждые 2s (rise=2 fall=3) +``` + +Практический смысл: время обнаружения отказа равно `HC_FALL × HC_INTERVAL` +(по умолчанию 6 с), время возврата — `HC_RISE × HC_INTERVAL` (4 с). Уменьшая +интервал, вы сокращаете окно, в котором часть запросов уходит на мёртвый +бэкенд, но увеличиваете постоянную нагрузку проб. + +Проверьте изменение по блоку E: `docker compose pause be1` и засеките, за +сколько член уйдёт в `down`. + +### Б.2. Алгоритм балансировки: ключ хэша + +Строка листенера в `lb/pipeline.sh`, таблица 10: + +``` +actions=multipath(symmetric_l4,$POOL_ID,modulo_n,$SLOTS,0,NXM_NX_REG1[]) +``` + +Первый аргумент — по каким полям пакета считается хэш. Проверено на OVS 3.1, +принимаются все значения: + +| Значение | Поведение | +|---|---| +| `symmetric_l4` | по умолчанию: адреса и порты, симметрично для обоих направлений | +| `symmetric_l3l4`, `symmetric_l3l4+udp` | вариации симметричного хэша | +| `symmetric_l3` | только адреса: все сессии между парой хостов на одном бэкенде | +| `nw_src` | **affinity по адресу источника**: весь трафик одного клиента на одном бэкенде | +| `nw_dst`, `eth_src` | экзотические варианты, для полноты | + +Третий аргумент — способ отображения хэша в номер слота: `modulo_n` (по +умолчанию), `hash_threshold`, `hrw`, `iter_hash`. Все принимаются; для +таблицы слотов осмыслен `modulo_n`, остальные рассчитаны на выбор из +небольшого числа каналов. + +Применение — без перезапуска: + +```bash +vi lb/pipeline.sh +docker compose cp lb/pipeline.sh lb-router:/opt/lb/pipeline.sh +docker compose exec lb-router /opt/lb/apply.sh +``` + +Чтобы изменение пережило пересоздание контейнера, потом соберите образ: +`docker compose up -d --build lb-router`. + +**Проверка эффекта.** С `nw_src` все запросы одного клиента должны попадать на +один бэкенд: + +```bash +for i in $(seq 8); do curl -s http://192.168.5.21/ | grep -o 'backend=[a-z0-9]*'; done | sort | uniq -c +``` + +Ожидается, что все 8 запросов уйдут на один бэкенд вместо деления между +четырьмя. Так и проверялось на стенде: `nw_src` дал 8 из 8 на один член, +возврат к `symmetric_l4` вернул деление. + +### Б.3. Веса членов пула + +В `lb/hc-config.sh` у каждого члена есть поле `weight` (по умолчанию 1): + +```json +{ "id": 1, "name": "be1", "address": "10.20.0.2", "port": 8080, "weight": 3, "source": "10.20.0.253" } +``` + +```bash +docker compose up -d --build lb-router +sleep 15 && make health +``` + +Ожидается пропорциональное деление слотов: член с весом 3 получит втрое +больше слотов, чем член с весом 1. Проверено на пуле из двух членов — +`weight=3` против `weight=1` дало 768 / 256 вместо 512 / 512. + +### Б.4. Число слотов и basis хэша + +`lb/topology.env`: + +```bash +POOL_ID=1 # basis хэша: разные пулы дают независимые раскладки +SLOTS=1024 # гранулярность весов +``` + +Значение `SLOTS` используется одновременно в правиле `multipath` и в +раскладке демона, поэтому менять его нужно только здесь и с пересборкой — +рассинхронизация этих двух мест приведёт к тому, что часть слотов окажется +недостижима. + +Гранулярность: минимальная доля, которую можно выдать члену, равна +`1 / SLOTS`. Для четырёх бэкендов 1024 слота — с большим запасом; смысл +появится при десятках членов с разными весами. + +Изменение `POOL_ID` полностью перетасует раскладку — это ожидаемо, он входит +в хэш как seed. Дайджест при этом изменится. + +### Б.5. Возврат к исходной конфигурации + +Все параметры стенда лежат в трёх файлах: `lb/topology.env`, +`lb/hc-config.sh`, `lb/pipeline.sh`. Если стенд под git — `git checkout` этих +файлов и пересборка. Исходные значения: проба `http` каждые 2 с, +`rise=2 fall=3`, `symmetric_l4`, `POOL_ID=1`, `SLOTS=1024`, веса по 1, +четыре члена по 256 слотов с дайджестом `0a16713c5a9eb94d`. + +--- + +## Диагностика + +**Контейнер `hpnn-lb` перезапускается.** + +```bash +docker compose logs lb-router --tail=50 +``` + +Частая причина — не загружен модуль ядра: `make prereq` (выполняет +`modprobe openvswitch`). + +**Ping до 192.168.5.20 не проходит.** + +- Проверьте, что клиент действительно в 192.168.5.0/24 и не отделён от хоста + маршрутизатором с фильтрацией. +- **[Х]** `make ports` — в мосту должны быть `pub0`, `p2`, `p3`, `hcif-p2`, + `hcif-p3`. +- **[Х]** `docker compose exec lb-router tcpdump -ni pub0 arp` — видно ли + ARP-запросы клиента. +- Если проверяете с самого хоста — нужен `make shim`. + +**Ping до узла проходит, а curl на VIP — таймаут.** + +```bash +make health # есть ли живые члены пула +make slots # растёт ли счётчик fail-close +``` + +Пустой пул — ожидаемое поведение fail-close, проверьте бэкенды: +`docker compose ps`, `docker compose logs be1`. + +**Оба члена `down`, хотя бэкенды работают.** + +```bash +docker compose exec lb-router ip addr show hcif-p2 +docker compose exec lb-router ip neigh show +docker compose exec lb-router curl -sv --max-time 3 http://10.20.0.2:8080/healthz +``` + +**Ответ приходит, но `client=` содержит не ваш адрес.** + +Значит трафик пришёл не напрямую, а через промежуточный NAT — проверьте, что +обращаетесь с машины из 192.168.5.0/24, а не через проброс портов. + +**Полный автоматический прогон для сравнения:** + +```bash +make verify +``` diff --git a/docs/STEP1_IMPLEMENTATION_PLAN.md b/docs/STEP1_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..2ad16a0 --- /dev/null +++ b/docs/STEP1_IMPLEMENTATION_PLAN.md @@ -0,0 +1,126 @@ +# Шаг 1 — прототип окружения hpnn_v2 (OVS: маршрутизация + балансировка) + +## Контекст + +`hpnn_v1` содержит только дизайн-концепцию (`/opt/lvraid/claude/hpnn_v1/docs/design.md`, v0.3) OVS-native балансировщика: весь датапас — OpenFlow, DNAT без SNAT (бэкенд видит реальный IP клиента), возврат трафика маршрутизацией, control plane пишется свой. Кода нет. + +`hpnn_v2` начинается с шага 1: собрать минимально достаточное контейнерное окружение, на котором можно развивать две подсистемы — **маршрутизации** и **балансировки нагрузки**. Результат шага: клиент из 192.168.5.0/24 обращается на VIP контейнера-1 и видит поочерёдно два разных бэкенда, а бэкенды видят реальный IP клиента; одновременно работает транзитная маршрутизация между приватными сегментами. Всё форвардинг-решение принимает OVS, ядро контейнера-1 транзитный трафик не обрабатывает. + +Утверждённые решения: +- публичный сегмент — Docker **macvlan** поверх `enp3s0` (схема уже проверена на этом хосте: `router-functest_lan`, 192.168.5.11); +- **kernel datapath** OVS (модуль `openvswitch.ko` на хосте есть, не загружен); +- балансировка — **`group type=select`** + `ct(nat)`; +- маршрутизация — **целиком в OpenFlow** (свой ARP-респондер, статические next-hop-привязки, ICMP-ошибки на шаге 1 не генерируются); +- адреса: узел `192.168.5.20`, VIP `192.168.5.21`. + +## Топология + +``` + клиент 192.168.5.0/24 + │ + [ enp3s0, macvlan ] сеть 1 «публичная»: 192.168.5.0/24 + │ pub0 (02:42:c0:a8:05:14) + ┌─────┴──────────────────────────────┐ + │ lb-router (privileged, OVS) │ узел .20, VIP .21 + │ br-lb: pub0, p2, p3 │ + └──┬──────────────────────────┬──────┘ + p2 │ 10.20.0.1 │ 10.30.0.1 p3 + сеть 2 │ 10.20.0.0/24 сеть 3 │ 10.30.0.0/24 + be1│ 10.20.0.2 be2│ 10.30.0.2 +``` + +- Docker-шлюзы приватных сетей смещены на `.254` (`ipam.config.gateway`), чтобы `.1` занял OVS-роутер и не конфликтовал с host-бриджем. +- Дефолт бэкендов остаётся на `.254` (выход в интернет мимо LB — профиль A из §3.2 дизайна v1). Через LB бэкенды маршрутизируют только клиентские префиксы: + `ip route add 192.168.5.0/24 via 10.20.0.1` и `ip route add 10.30.0.0/24 via 10.20.0.1` (симметрично на be2). +- MAC бэкендов и `pub0` фиксируются в compose (`mac_address:`) — на них строятся статические adjacency-правила. + +## Структура репозитория + +``` +/opt/lvraid/claude/hpnn_v2/ +├── README.md # обзор стенда, запуск, проверка +├── docs/ +│ ├── STEP1_IMPLEMENTATION_PLAN.md # копия этого плана (артефакт «план внедрения») +│ └── STEP1_SUMMARY.md # итоги по факту выполнения +├── docker-compose.yml +├── Makefile # up / down / flows / verify / logs +├── lb/ +│ ├── Dockerfile # debian:bookworm-slim + openvswitch-switch + iproute2 + tcpdump +│ ├── entrypoint.sh # запуск OVS, сборка br-lb, применение пайплайна +│ ├── topology.env # единственный источник правды: IP, MAC, порты, VIP +│ ├── pipeline.sh # генерация OpenFlow-правил из topology.env +│ └── lbctl.sh # обёртки: dump-flows / group-stats / ofproto-trace +├── backend/ +│ ├── Dockerfile # multi-stage: golang:1.23-alpine → alpine (нужен iproute2) +│ ├── entrypoint.sh # установка маршрутов на клиентские префиксы, запуск app +│ ├── go.mod +│ └── main.go # hostname, дата/время, IP клиента, локальный адрес +└── scripts/ + ├── host-prereq.sh # modprobe openvswitch; опциональный macvlan-shim для проверки с хоста + └── verify.sh # e2e-проверки (см. раздел «Верификация») +``` + +## Реализация + +### 1. Контейнер-1: подъём OVS и портов (`lb/entrypoint.sh`) + +1. `modprobe openvswitch` (хост-модуль виден через `/lib/modules:ro`), запуск `ovsdb-server` + `ovs-vswitchd`, создание `br-lb` с `fail_mode=secure`, `protocols=OpenFlow13,OpenFlow15` и без контроллера. +2. Для каждого из трёх Docker-интерфейсов: снять IP (`ip addr flush`), внести в мост (`ovs-vsctl add-port br-lb `), закрепить `ofport_request` (pub0=1, p2=2, p3=3), поднять. + IP-адреса на интерфейсах не нужны — L3 живёт в OpenFlow, IPAM-резервация Docker остаётся за контейнером и предотвращает выдачу этих адресов кому-то ещё. +3. Применение пайплайна атомарным бандлом: `ovs-ofctl --bundle -O OpenFlow15 replace-flows br-lb <(pipeline.sh)` — обновление без blackhole-окна (принцип §4 дизайна v1). + +**Риск и запасной вариант.** Подключение macvlan-устройства портом OVS — рабочая, но нечастая схема. Если `ovs-vsctl add-port` не заведётся, откат: внутри контейнера поднять linux-bridge `brpub`, включить в него macvlan и один конец veth-пары, второй конец veth отдать в `br-lb`. Логика OpenFlow при этом не меняется — правится только шаг 2 entrypoint. + +### 2. OpenFlow-пайплайн (`lb/pipeline.sh`) + +| Таблица | Назначение | +|---|---| +| **0** | Классификация: ARP → t5; ICMP echo на собственные IP → t6; IP из `pub0` → learn-адъяценции + t10; IP из `p2/p3` → t30; иначе drop | +| **3** (inline `learn` в t0) | Обучение MAC клиентов: `learn(table=21, NXM_OF_IP_DST[]=NXM_OF_IP_SRC[], load:NXM_OF_ETH_SRC[]->NXM_OF_ETH_DST[], load:->NXM_OF_ETH_SRC[], output:NXM_OF_IN_PORT[])`, `hard_timeout=300` — заменяет отсутствующий ARP-резолвер для клиентской стороны | +| **5** | ARP-респондер для `192.168.5.20`, `192.168.5.21`, `10.20.0.1`, `10.30.0.1` (op=2, swap SHA/THA, `output:IN_PORT`) | +| **6** | ICMP echo-reply для тех же адресов (swap eth/ip, `icmp_type=0`) — даёт `ping` до узла и VIP | +| **10** | Листенеры: `tcp,nw_dst=192.168.5.21,tp_dst=80 → group:100`; остальной IP → t20 (обычная маршрутизация) | +| **group 100** | `type=select, selection_method=hash, fields(ip_src,tcp_src)`; 2 бакета, в каждом `ct(commit,zone=1,nat(dst=10.X0.0.2:8080),table=20)`. `tcp_src` в ключе обязателен — иначе все запросы одного клиента лягут на один бэкенд | +| **20** | Маршрутизация: LPM через приоритет = длина префикса. `10.20.0.0/24`→p2, `10.30.0.0/24`→p3, `192.168.5.0/24`→pub0; действия `dec_ttl`, `mod_dl_src=`, `resubmit(,21)`. `priority=0` → drop (дефолта нет) | +| **21** | Adjacency: статически `nw_dst=10.20.0.2 → mod_dl_dst=, output:p2` (и симметрично be2); клиентские адреса приходят сюда из `learn` | +| **30** | Возврат из приватных сетей: TCP → `ct(table=31, zone=1, nat)` (снимает DNAT: `src` снова VIP, `dst` — клиент); прочий IP (be↔be, транзит) → t20 | +| **31** | После ct → t20 | + +Un-DNAT выполняет `ct(nat)`, отдельных правил обратной трансляции не требуется. Stateless-вариант из §2 дизайна v1 остаётся задачей следующего шага — здесь сознательно взят stateful, чтобы окружение поднялось минимальными средствами. + +### 3. Бэкенды (`backend/`) + +`main.go` — HTTP-сервер на `:8080`, отдаёт `text/plain`: hostname, дата/время с таймзоной, `RemoteAddr` (реальный IP клиента — ключевое доказательство DNAT-only), локальный адрес соединения и счётчик запросов. Плюс `/healthz` для будущих проб. Стандартная библиотека, без зависимостей. + +`entrypoint.sh` (нужен `NET_ADMIN`): ставит маршруты на клиентский префикс и на соседний приватный сегмент через `.1`, затем `exec` приложения. + +### 4. Compose + +Три сети: `pub` (macvlan, `parent: enp3s0`, `192.168.5.0/24`), `priv2` (bridge, `10.20.0.0/24`, gateway `.254`), `priv3` (bridge, `10.30.0.0/24`, gateway `.254`). +`lb-router`: `privileged: true`, `/lib/modules:ro`, все три сети, фиксированные IP и MAC. +`be1`/`be2`: по одной приватной сети каждый, `cap_add: [NET_ADMIN]`, фиксированные IP и MAC. + +## Верификация + +**Подготовка хоста:** `scripts/host-prereq.sh` — `modprobe openvswitch`; при желании проверять с самого хоста (192.168.5.9) он же создаёт macvlan-shim, т.к. macvlan не пропускает трафик хост↔контейнер напрямую. + +С клиента из 192.168.5.0/24: + +| Проверка | Команда | Ожидание | +|---|---|---| +| Доступность узла и VIP | `ping 192.168.5.20`, `ping 192.168.5.21` | отвечает OpenFlow-респондер (t6) | +| Балансировка | `for i in $(seq 20); do curl -s http://192.168.5.21/; done` | оба hostname встречаются, распределение близко к 50/50 | +| Сохранение IP клиента | тот же вывод | в поле «клиент» — реальный адрес 192.168.5.x, не адрес LB | +| Распределение по бакетам | `docker compose exec lb-router ovs-ofctl -O OpenFlow15 dump-group-stats br-lb` | счётчики обоих бакетов растут | +| Детерминизм выбора | `ovs-appctl ofproto/trace br-lb <5-tuple>` | одинаковый бакет для одного и того же кортежа | +| Маршрутизация между сегментами | `docker compose exec be1 curl -s http://10.30.0.2:8080/` | ответ be2, трафик прошёл через t20/t21 | +| Профиль A (egress мимо LB) | `docker compose exec be1 ping -c1 8.8.8.8` при `tcpdump -ni p2` на LB | пакеты на LB не появляются | +| Целостность пайплайна | `ovs-ofctl -O OpenFlow15 dump-flows br-lb`, `ovs-vsctl show` | три порта в мосту, все таблицы на месте, счётчик drop-правил t20 не растёт при нормальном трафике | + +`scripts/verify.sh` прогоняет проверки, выполнимые с хоста, и печатает сводку; клиентские шаги с curl/ping вынесены в README как ручные. + +## Артефакты (по требованию CLAUDE.md) + +1. `docs/STEP1_IMPLEMENTATION_PLAN.md` — план внедрения (копия этого документа) — **до** начала работ. +2. `README.md` — назначение стенда, схема, адресный план, запуск, проверка, устранение неполадок. +3. `docs/STEP1_SUMMARY.md` — по завершении: что сделано, отличия от плана, известные ограничения (stateful `ct` вместо stateless un-DNAT, отсутствие ICMP-ошибок и health-check, один узел LB вместо ECMP-набора) и заготовка задач шага 2. diff --git a/docs/STEP1_SUMMARY.md b/docs/STEP1_SUMMARY.md new file mode 100644 index 0000000..f13ca82 --- /dev/null +++ b/docs/STEP1_SUMMARY.md @@ -0,0 +1,103 @@ +# Шаг 1 — итоги: прототип окружения hpnn_v2 + +**Дата:** 2026-08-16 +**Статус:** выполнено, стенд поднят и проверен (30 из 30 проверок) +**План:** [STEP1_IMPLEMENTATION_PLAN.md](STEP1_IMPLEMENTATION_PLAN.md) + +## Что сделано + +Собрано контейнерное окружение из трёх сетей и трёх контейнеров, на котором +работают обе подсистемы — маршрутизация и балансировка нагрузки. Всё +форвардинг-решение принимается в OpenFlow: сетевой стек ядра контейнера +транзитный трафик не обрабатывает. + +- **Контейнер 1 (`hpnn-lb`)** — Open vSwitch с kernel datapath в собственном + netns, мост `br-lb` с тремя портами. Пайплайн из 42 правил и группы + балансировки рендерится скриптом из `topology.env` и заливается атомарным + бандлом. +- **Контейнеры 2 и 3 (`hpnn-be1`, `hpnn-be2`)** — идентичный web-сервис на Go, + каждый в своём приватном сегменте; отдаёт имя хоста, дату, время и адрес + клиента. +- **Сети** — публичный сегмент через macvlan поверх `enp3s0` (настоящий L2 + клиентской сети) и два приватных bridge-сегмента. +- **Инструментарий** — `make up/down/flows/verify`, `lbctl` для осмотра + датапаса, `scripts/host-prereq.sh` для подготовки хоста. + +## Результаты проверок + +| Проверка | Результат | +|---|---| +| Kernel datapath OVS в netns контейнера | `system@ovs-system`, поддержка recirculation, ct_state_nat, ct_zone | +| macvlan-интерфейс как порт OVS | работает, `pub0` — ofport 1 | +| `selection_method=hash, fields(ip_src,tcp_src)` | принят OVS 3.1 | +| Балансировка 20 запросов с клиента | be1/be2 ≈ 10/10, счётчики обоих бакетов растут | +| Сохранение IP клиента | бэкенд видит `192.168.5.13` — DNAT без SNAT подтверждён | +| ARP- и ICMP-респондеры OVS | `ping` до узла и до VIP проходит | +| Маршрутизация между приватными сегментами | `be1 → be2` через таблицы 20/21 | +| Профиль A (§3.2 дизайна v1) | egress бэкенда наружу работает и на порту `p2` не появляется | +| `ofproto/trace` пути клиент → VIP | DNAT, `dec_ttl`, перепись MAC, выход в порт 2 | + +## Отличия от плана + +1. **Публичная сеть объявлена с подсетью `192.168.55.0/24`, а не + `192.168.5.0/24`.** Docker отказывается регистрировать пул, пересекающийся + с уже существующей macvlan-сетью соседнего стенда `router-functest` + (`Pool overlaps with other one on this address space`). Объявленная подсеть + нужна только Docker IPAM: адреса узла и VIP живут исключительно в правилах + OpenFlow, а macvlan включён в `enp3s0` и работает на реальном L2. + +2. **Обратный путь занял таблицы 15 и 16 вместо 30 и 31.** `goto_table` в + OpenFlow разрешает переход только вперёд, поэтому таблицы обратного пути + обязаны стоять до общей маршрутизации (20). + +3. **DNAT вынесен из бакетов группы в отдельную таблицу 11.** Бакет только + кладёт номер члена пула в `reg2` и делает `resubmit`. Так вся логика + трансляции лежит в одном месте и не зависит от того, какие действия + допустимы внутри бакета. + +4. **Обучение MAC клиентов сужено** до трафика, адресованного стенду (VIP, + адрес узла, приватные сегменты). В первой редакции `learn` срабатывал на + любой IP-пакет с macvlan-порта и заносил в таблицу 21 весь + широковещательный шум сегмента — DHCP, mDNS, SSDP всех соседей по LAN. + +5. **Проверка профиля A добавлена в `verify.sh`** — в плане она была только + ручной. + +## Известные ограничения + +Осознанные упрощения шага 1, каждое — задача следующих шагов: + +- **Датапас stateful.** Обратную трансляцию выполняет `ct(nat)`, а не + stateless un-DNAT из §2 дизайна v1. Следствие: прямой и обратный трафик + сессии обязаны проходить через один узел, то есть Active/Active ECMP в такой + схеме работать не будет. +- **ICMP-ошибки не генерируются.** У чисто-OpenFlow узла нет стека, который бы + формировал `TTL exceeded` и `fragmentation needed`; PMTUD через стенд не + работает, `traceroute` не показывает узел. +- **Фрагменты IP не обрабатываются** — правила матчат L4-заголовок, которого + во втором и последующих фрагментах нет. +- **Один узел LB.** Ни BGP, ни BFD, ни ECMP-набора: FRR в стенде нет, место + под него (internal-порты с IP) на шаге 1 не занято, поскольку маршрутизация + выполняется целиком в OpenFlow. +- **Нет health-check.** Бэкенды считаются живыми всегда; отказ бэкенда не + выводит его из группы. У приложения есть `/healthz` как задел. +- **Пул и листенер статические** — заданы в `topology.env`, control plane и + OVSDB-схемы из §6 дизайна v1 нет. +- **MAC бэкендов прописаны статически.** ARP-резолвер next-hop отсутствует; + MAC зафиксированы в `docker-compose.yml`. +- **Только TCP и только IPv4.** UDP- и L3-листенеров нет. + +## Задел на шаг 2 + +1. Заменить `ct(nat)` на stateless DNAT/un-DNAT с таблицей слотов и + `multipath(symmetric_l4)` — это спайк S0 из §12 дизайна v1 и предпосылка + для Active/Active. +2. Проверить детерминизм выбора бэкенда: один и тот же 5-tuple должен давать + один и тот же слот на разных узлах и между перезапусками + (`ofproto/trace` по корпусу синтетических кортежей). +3. Подсистема health-check: пробы с уникального адреса узла, вывод члена из + пула, перерасчёт таблицы слотов. +4. Control plane: модель `Load_Balancer → Listener → Pool → Member` в OVSDB + вместо `topology.env`, агент-рендерер пайплайна. +5. ICMP-транслятор в userspace (packet-in) — для PMTUD и корректных + ICMP-ошибок от имени VIP. diff --git a/docs/STEP2_IMPLEMENTATION_PLAN.md b/docs/STEP2_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..774c874 --- /dev/null +++ b/docs/STEP2_IMPLEMENTATION_PLAN.md @@ -0,0 +1,179 @@ +# Шаг 2 — подсистема health-check и таблица слотов + +## Контекст + +Стенд шага 1 работает: OVS маршрутизирует три сегмента и балансирует TCP на +два бэкенда группой `type=select`. Но состав пула статичен — отказ бэкенда +балансировщику неизвестен, и половина соединений уходит в никуда. + +Шаг 2 добавляет подсистему проверки живости: узел сам определяет состояние +членов пула, выводит мёртвых из балансировки и возвращает восстановившихся. +Это §7 дизайн-концепции `../hpnn_v1/docs/design.md`, адаптированный к +одноузловому прототипу (кворум по кластеру и генерации пулов появятся вместе +с control plane). + +Вместе с этим по решению заказчика группа `type=select` заменяется на +**таблицу слотов с Maglev-раскладкой** (§5 дизайна). Причина: при перезаписи +бакетов группы хэш пересчитывается целиком, и размещение новых соединений +переезжает даже у нетронутых членов. Таблица слотов даёт минимальное +возмущение — переезжают только слоты выбывшего члена — и является тем самым +механизмом, на котором позже строится stateless-датапас. + +## Ключевые решения + +**Пробы уходят с уникального адреса узла в сегменте бэкенда** (§7.1 дизайна), +не с VIP. У чисто-OpenFlow узла шага 1 адресов в ядре нет вовсе, поэтому на +мосту появляются два internal-порта: `hcif-p2` (`10.20.0.253`) и `hcif-p3` +(`10.30.0.253`). Это `lbif-bk` из §3.3 — на них позже поселится FRR. + +**Пробер — демон на Go (`hcd`) внутри контейнера-балансировщика**, как +`lb-agent` на узле LB в дизайне. Становится основным процессом контейнера; +`ovsdb-server` и `ovs-vswitchd` работают демонами рядом. + +**Fail-close при полном отказе пула:** таблица слотов пустеет, остаётся только +`priority=0 actions=drop` — трафик на VIP отбрасывается со счётчиком. + +**Дренаж** (§7.3): член выводится из раскладки вручную, пробы при этом +продолжают идти и состояние остаётся `up`. + +**Нюанс, который надо зафиксировать честно.** В пайплайне остаётся `ct(nat)` +для обратного пути, а conntrack закрепляет трансляцию за соединением при его +создании. Поэтому уже установленные сессии переживают смену раскладки и без +Maglev. Реальная ценность таблицы слотов здесь — стабильность размещения +**новых** соединений и подготовка к stateless-датапасу, где никакой ct не +подстрахует. Это проверяется отдельным тестом (см. верификацию). + +## Реализация + +### 1. Датапас: слоты вместо группы (`lb/pipeline.sh`) + +Таблицы перенумеровываются так, чтобы все переходы шли вперёд (`goto_table` +назад не умеет): + +| Таблица | Было | Стало | +|---|---|---| +| 10 | листенер → `group:100` | листенер → `multipath(...)` → таблица 11 | +| 11 | DNAT по `reg2` | **таблица слотов**: `reg1=` → `reg2=` | +| 12 | — | DNAT по `reg2` (бывшая 11) | + +``` +table=10,priority=200,tcp,nw_dst=,tp_dst=80 \ + actions=multipath(symmetric_l4,,modulo_n,1024,0,NXM_NX_REG1[]),goto_table:11 + +# правила слотов заливает hcd; pipeline.sh отдаёт только fail-close основание +table=11,priority=0 actions=drop +``` + +Группа 100 удаляется (`ovs-ofctl del-groups`), `apply.sh` её больше не +создаёт. Счётчики правил таблицы 11 дают бесплатную per-member статистику. + +### 2. Порты источника проб (`lb/entrypoint.sh`) + +Для каждого приватного сегмента: + +``` +ovs-vsctl --may-exist add-port br-lb hcif-p2 \ + -- set Interface hcif-p2 type=internal ofport_request=4 mac='"02:42:0a:14:00:fd"' +ip addr replace 10.20.0.253/24 dev hcif-p2 +ip link set hcif-p2 up +ip neigh replace 10.20.0.2 lladdr 02:42:0a:14:00:02 dev hcif-p2 nud permanent +``` + +Статическая ARP-запись избавляет ядро от резолва MAC бэкенда — MAC членов и +так зафиксированы в `docker-compose.yml`. Обратное направление (бэкенд +резолвит `10.20.0.253`) закрывает ARP-респондер OVS. + +Правила для hc-портов в `pipeline.sh`: + +| Таблица | Правило | +|---|---| +| 0 | `in_port=hcif-*` → таблица 20 (без `learn`, без `ct`) | +| 5 | ARP-респондер для `10.20.0.253` и `10.30.0.253` | +| 20 | `/32` на адреса hc-портов, приоритет 32 → таблица 21, **без `dec_ttl`** | +| 21 | adjacency на hc-порты: `mod_dl_dst` + `output` | + +Ответы бэкендов на пробы приходят на `p2`/`p3`, проходят таблицу 15 (записи в +`ct` для них нет, трансляция не применяется) и уходят транзитом. + +### 3. Демон `hc/` (Go, ~350 строк) + +`hc/maglev.go` — раскладка по §5.2 дизайна: + +- ключ члена — `"address:port"`, не UUID: пересоздание члена с теми же + адресом и портом обязано давать ту же раскладку; +- вход — отсортированный по ключу список живых членов с весами + `pool_id` + как seed; +- 1024 слота; `offset = h1 % M`, `skip` приводится к **нечётному** — при + M = 1024 (степень двойки) чётный шаг не покрывает все слоты и заполнение + зациклилось бы; +- `slot_table_digest` — SHA-256 от сериализованной раскладки (§5.3), пишется + в лог при каждом применении. + +`hc/main.go` — пробер и применение: + +- конфиг — JSON, который `lb/hc-config.sh` генерирует из `topology.env`; +- тикер `interval` (2 с), пробы всех членов параллельно с таймаутом (1 с); +- тип пробы `http` (GET `/healthz`, ожидается 2xx) или `tcp` (connect); + источник фиксируется через `net.Dialer.LocalAddr` — это и есть требование + «пробы с уникального адреса узла»; +- переход в `down` после `fall` подряд неудач (3), в `up` — после `rise` + подряд успехов (2); в лог пишутся только переходы, как в §7.1; +- применение — атомарным бандлом `ovs-ofctl bundle` с директивами + `delete table=11` + `add ...`, то есть замена таблицы целиком без + промежуточного состояния; повторно та же раскладка не заливается; +- HTTP-API на `127.0.0.1:9111`: `/status` (JSON), `/metrics` (Prometheus), + `/drain?member=`, `/enable?member=`, `/reapply`. + +`/reapply` дёргает `lb/apply.sh` после `replace-flows`: тот перезаливает весь +пайплайн и стёр бы слоты, поэтому в конце сообщает демону перезалить их. + +### 4. Инструментарий и сборка + +- `lbctl health | slots | drain | enable `; `make health`, `make slots`, + `make drain M=be1`, `make enable M=be1`. +- `lbctl slots` — сводка: сколько слотов у каждого члена, счётчики пакетов, + дайджест. +- Контекст сборки балансировщика расширяется до корня (`context: .`, + `dockerfile: lb/Dockerfile`), первая стадия — `golang:1.23-alpine`. +- В бэкенд добавляется `/slow` (ответ растягивается на N секунд) — нужен для + проверки выживания установленной сессии. + +### 5. Файлы + +``` +hc/{go.mod,main.go,maglev.go} новый демон +lb/hc-config.sh генерация конфига из topology.env +lb/topology.env параметры проб, слотов, hc-портов +lb/pipeline.sh слоты, hc-порты, перенумерация таблиц +lb/entrypoint.sh internal-порты, статический neigh, запуск hcd +lb/apply.sh снятие группы, вызов /reapply +lb/lbctl.sh, Makefile health / slots / drain / enable +backend/main.go эндпоинт /slow +docker-compose.yml контекст сборки +scripts/verify.sh новые проверки +``` + +## Верификация + +Добавляется в `scripts/verify.sh`: + +| Проверка | Ожидание | +|---|---| +| Пробы доходят | `lbctl health` — оба члена `up`, счётчик `/healthz` на бэкендах растёт | +| Источник проб | на бэкенде видно обращение с `10.20.0.253`/`10.30.0.253`, не с VIP | +| Раскладка слотов | 1024 слота поделены поровну ±1, дайджест стабилен между перезапусками | +| **Минимальное возмущение** | снимок раскладки → `drain be1` → у be2 **ни один** слот не сменил владельца | +| Отказ члена | `docker compose pause be1` → `down` за `fall × interval`, его слоты перешли к be2 | +| Балансировка при отказе | все запросы на VIP обслуживает be2, ошибок нет | +| Восстановление | `unpause` → `up` за `rise × interval`, раскладка вернулась к исходному дайджесту | +| Выживание сессии | долгая сессия через `/slow` не рвётся при выводе второго члена | +| Полный отказ | оба члена недоступны → в таблице 11 только `drop`, трафик на VIP падает со счётчиком | +| Идемпотентность | `make flows` → слоты возвращаются в актуальном составе, а не в полном | + +## Артефакты (по требованию CLAUDE.md) + +1. `docs/STEP2_IMPLEMENTATION_PLAN.md` — план внедрения; черновик уже написан + под вариант с группой, привести к решению о таблице слотов. +2. `docs/STEP2_SUMMARY.md` — итоги, отклонения, ограничения. +3. `README.md` — раздел про health-check и таблицу слотов, схема с + hc-портами, новые команды; таблица пайплайна перенумерована. diff --git a/docs/STEP2_SUMMARY.md b/docs/STEP2_SUMMARY.md new file mode 100644 index 0000000..bc6de61 --- /dev/null +++ b/docs/STEP2_SUMMARY.md @@ -0,0 +1,114 @@ +# Шаг 2 — итоги: health-check и таблица слотов + +**Дата:** 2026-08-16 +**Статус:** выполнено, проверено после холодного перезапуска (50 из 50 проверок) +**План:** [STEP2_IMPLEMENTATION_PLAN.md](STEP2_IMPLEMENTATION_PLAN.md) +**Предыдущий шаг:** [STEP1_SUMMARY.md](STEP1_SUMMARY.md) + +## Что сделано + +- **Демон `hcd`** (Go, `hc/`) внутри контейнера-балансировщика: проверяет + живость членов пула, ведёт их состояние с гистерезисом (`rise=2`, + `fall=3`), пересчитывает раскладку слотов и заливает её в OVS. Стал + основным процессом контейнера. +- **Пробы с уникального адреса узла** (§7.1 дизайна v1): на мосту появились + internal-порты `hcif-p2` (`10.20.0.253`) и `hcif-p3` (`10.30.0.253`) — это + единственные адреса, которые узел держит в ядре. Источник соединения + фиксируется через `net.Dialer.LocalAddr`. +- **Таблица слотов вместо группы `type=select`**: 1024 слота, номер слота + даёт `multipath(symmetric_l4, basis=pool_id, modulo_n)`, раскладка слотов + по членам считается алгоритмом Maglev (§5). Группа удалена. +- **Fail-close**: пока живых членов нет, таблица слотов пуста и трафик на VIP + отбрасывается со счётчиком. +- **Дренаж** (§7.3): `lbctl drain <член>` / `enable <член>` — член исключается + из раскладки, пробы при этом продолжают идти. +- **Наблюдаемость**: `lbctl health` (состояние пула), `lbctl slots` + (раскладка и счётчики), `/metrics` в формате Prometheus, дайджест раскладки + SHA-256 (§5.3), в лог пишутся только переходы состояния. + +## Результаты проверок + +| Проверка | Результат | +|---|---| +| Пробы идут | оба члена `up` через 4 с после старта, задержка 1–3 мс | +| Источник проб | `tcpdump` на `p2`: SYN с `10.20.0.253`, не с VIP | +| Раскладка | 1024 слота, 512/512, дайджест `2f0ca39d5f33efa6` | +| Детерминизм | после `docker compose down/up` дайджест тот же | +| **Минимальное возмущение** | при выводе be1 переехали 512 его слотов, у be2 — **ни одного** | +| Отказ члена | `pause be1` → `down` за ~6 с, все 1024 слота у be2, трафик без ошибок | +| Восстановление | `unpause` → `up` за ~4 с, дайджест вернулся к исходному | +| Выживание сессии | длинная сессия через `/slow` не порвалась при выводе её члена из пула (14/14 тиков) | +| Fail-close | оба члена мертвы → таблица пуста, клиент получает таймаут, счётчик drop растёт | +| Идемпотентность | `make flows` → раскладка восстановлена в актуальном составе | + +## Отклонения от плана + +1. **Формат bundle-файла.** Планировались директивы `delete` / `add`; OVS 3.1 + принимает их только с указанием типа сообщения: `flow delete` / `flow add`. + Первый вариант отвергался с `Unsupported bundle message type`. + +2. **Шаг перестановки Maglev приводится к нечётному.** При числе слотов 1024 + (степень двойки) шаг, выбранный как `h2 % (M-1) + 1`, может оказаться + чётным — тогда последовательность `(offset + j*skip) mod M` покрывает лишь + половину слотов и заполнение зацикливается. Классический алгоритм + рассчитан на простое M; здесь сохранено значение 1024 из §5.1 дизайна, а + взаимная простота обеспечена принудительной нечётностью шага. + +3. **Форматирование `lbctl health` вынесено в демон** (`/status.txt`) — + иначе в образ балансировщика пришлось бы тянуть `jq` или `python3`. + +4. **Проверка выживания сессии добавлена в `verify.sh`** вместе с эндпоинтом + `/slow` у бэкенда — в плане она была только описана. + +## Подтверждённое наблюдение о ct и таблице слотов + +План фиксировал предположение, что установленные сессии переживают смену +раскладки за счёт conntrack. Проверка подтвердила: сессия, обслуживаемая be2, +не порвалась при выводе be2 из пула — все 14 тиков пришли с того же бэкенда. +Причина в том, что `ct(commit, nat)` закрепляет трансляцию за соединением при +его создании, и последующие пакеты следуют существующей привязке независимо +от того, какого члена выбрала таблица слотов. + +Практический вывод: в текущем stateful-датапасе Maglev защищает **размещение +новых соединений**, а не живые сессии — их и без него защищает conntrack. +Ценность таблицы слотов раскроется при переходе на stateless-датапас, где +никакого ct не будет и единственной защитой от переезда сессий останется +именно минимальное возмущение раскладки. Заодно это ровно то поведение, +которое §7.3 дизайна описывает как «дренаж означает прекратить приём, а не +мягко завершить». + +## Известные ограничения + +Часть перешла с шага 1, часть появилась вместе с health-check: + +- **Датапас по-прежнему stateful** — обратную трансляцию делает `ct(nat)`. + Active/Active ECMP в такой схеме не работает: прямой и обратный трафик + обязаны проходить через один узел. +- **Кворума нет.** Вердикт о живости принимает единственный узел; §7.2 + (наблюдения в `Member_Observation`, лидер через OVSDB lock, генерации пулов + и сходимость по дайджестам) появится вместе с control plane. +- **Только HTTP и TCP-пробы.** UDP-проб и ICMP echo из §7.1 нет. +- **Пул статичен** — состав задан в `topology.env`, менять его на лету можно + только дренажом. Модели `Load_Balancer → Listener → Pool → Member` в OVSDB + ещё нет. +- **Веса поддержаны в алгоритме, но не в конфигурации** — у всех членов + вес 1. +- **`hash_algo_version` не реализована.** Дайджест раскладки считается и + логируется, но сравнивать его не с кем: узел один. +- **Пул без живых членов не снимает анонс VIP** — альтернатива fail-close из + §7.3 не реализована, BGP на узле нет. +- ICMP-ошибки, фрагменты, IPv6, UDP- и L3-листенеры — как и на шаге 1, вне + рамок. + +## Задел на шаг 3 + +1. **Stateless-датапас**: заменить `ct(nat)` на явный un-DNAT + (`nw_src=member_ip, tp_src=member_port` → `src = VIP`). Таблица слотов уже + на месте, `symmetric_l4` даёт одинаковый слот для обоих направлений — это + и есть предпосылка, ради которой шаг 2 сделан именно так. +2. Проверить детерминизм выбора по корпусу синтетических 5-tuple через + `ofproto/trace` (спайк S0 из §12 дизайна). +3. Control plane: схема OVSDB вместо `topology.env`, REST API и `lbctl` + поверх неё, валидации §8.4. +4. Второй узел LB: кворум наблюдений, генерации пулов, сравнение дайджестов. +5. ICMP-транслятор в userspace для PMTUD и корректных ICMP-ошибок от имени VIP. diff --git a/hc/go.mod b/hc/go.mod new file mode 100644 index 0000000..8a9595e --- /dev/null +++ b/hc/go.mod @@ -0,0 +1,3 @@ +module hpnn/hc + +go 1.22 diff --git a/hc/maglev.go b/hc/maglev.go new file mode 100644 index 0000000..dca5fbc --- /dev/null +++ b/hc/maglev.go @@ -0,0 +1,168 @@ +package main + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "hash/fnv" + "sort" +) + +// Раскладка слотов по членам пула — §5 дизайн-концепции hpnn_v1. +// +// Свойства, ради которых взят именно Maglev-подобный алгоритм: +// - детерминированность: одинаковый вход даёт одинаковую раскладку на любом +// узле и между перезапусками; +// - равномерность в пределах ±1 слота от идеальной доли; +// - минимальное возмущение: при выбытии члена переезжают только его слоты, +// чужие остаются на месте. +// +// Ключ члена — "адрес:порт", а не идентификатор объекта: удаление и повторное +// создание члена с теми же адресом и портом обязано давать ту же раскладку +// (§5.1). + +// hashKey — детерминированный хэш ключа члена с солью seed. +func hashKey(key string, seed uint32, salt uint32) uint32 { + h := fnv.New32a() + var buf [4]byte + binary.BigEndian.PutUint32(buf[:], seed) + _, _ = h.Write(buf[:]) + binary.BigEndian.PutUint32(buf[:], salt) + _, _ = h.Write(buf[:]) + _, _ = h.Write([]byte(key)) + return h.Sum32() +} + +// BuildSlotTable возвращает срез длиной slots, где каждый элемент — ID члена +// пула, обслуживающего этот слот. Пустой список членов даёт nil: вызывающая +// сторона трактует это как fail-close. +func BuildSlotTable(poolID uint32, members []*Member, slots int) []int { + live := make([]*Member, 0, len(members)) + for _, m := range members { + if m.Active() { + live = append(live, m) + } + } + if len(live) == 0 || slots <= 0 { + return nil + } + sort.Slice(live, func(i, j int) bool { return live[i].Key() < live[j].Key() }) + + // Перестановка каждого члена: последовательность (offset + j*skip) mod M. + // Она обойдёт все M слотов, только если skip взаимно прост с M. При M — + // степени двойки (по умолчанию 1024) это означает «skip нечётный»: чётный + // шаг покрыл бы лишь половину слотов и заполнение зациклилось бы. + perm := make([][]int, len(live)) + for i, m := range live { + offset := int(hashKey(m.Key(), poolID, 1) % uint32(slots)) + skip := int(hashKey(m.Key(), poolID, 2)%uint32(slots-1)) + 1 + if skip%2 == 0 { + skip++ + } + p := make([]int, slots) + for j := 0; j < slots; j++ { + p[j] = (offset + j*skip) % slots + } + perm[i] = p + } + + // Квоты по весам: минимальный вес получает не менее одного слота. + total := 0 + for _, m := range live { + total += m.WeightOrDefault() + } + quota := make([]int, len(live)) + assigned := 0 + for i, m := range live { + quota[i] = slots * m.WeightOrDefault() / total + if quota[i] == 0 { + quota[i] = 1 + } + assigned += quota[i] + } + // Остаток раздаём по кругу, чтобы сумма квот совпала с числом слотов. + for i := 0; assigned < slots; i = (i + 1) % len(live) { + quota[i]++ + assigned++ + } + + table := make([]int, slots) + for i := range table { + table[i] = -1 + } + next := make([]int, len(live)) + filled := 0 + for filled < slots { + progress := false + for i := range live { + if quota[i] == 0 { + continue + } + for next[i] < slots { + c := perm[i][next[i]] + next[i]++ + if table[c] == -1 { + table[c] = live[i].ID + quota[i]-- + filled++ + progress = true + break + } + } + if filled == slots { + break + } + } + if !progress { + // Недостижимо при нечётном skip, но лучше выйти, чем зациклиться. + break + } + } + // Страховка: не покрытые слоты отдаём первому живому члену. + for i := range table { + if table[i] == -1 { + table[i] = live[0].ID + } + } + return table +} + +// Digest — SHA-256 от сериализованной раскладки (§5.3 дизайна). Служит для +// сравнения раскладок между узлами и между перезапусками. +func Digest(table []int) string { + h := sha256.New() + var buf [4]byte + for _, id := range table { + binary.BigEndian.PutUint32(buf[:], uint32(id)) + _, _ = h.Write(buf[:]) + } + return hex.EncodeToString(h.Sum(nil))[:16] +} + +// SlotCounts возвращает распределение слотов по членам пула. +func SlotCounts(table []int) map[int]int { + out := map[int]int{} + for _, id := range table { + out[id]++ + } + return out +} + +// FlowBundle рендерит директивы для ovs-ofctl bundle: замена таблицы слотов +// целиком одной атомарной транзакцией. Первая строка сносит прежнее +// содержимое таблицы, остальные заливают новое — датапас не проходит через +// состояние с полупустой раскладкой. +// +// Формат файла бандла: каждая строка начинается с типа сообщения ("flow") и +// команды ("add" / "delete" / "modify"). +func FlowBundle(table []int, slotTable int, nextTable int) string { + out := fmt.Sprintf("flow delete table=%d\n", slotTable) + // Основание fail-close: пока слотов нет, трафик на VIP отбрасывается. + out += fmt.Sprintf("flow add table=%d,priority=0 actions=drop\n", slotTable) + for slot, id := range table { + out += fmt.Sprintf("flow add table=%d,priority=100,ip,reg1=0x%x actions=load:0x%x->NXM_NX_REG2[],goto_table:%d\n", + slotTable, slot, id, nextTable) + } + return out +} diff --git a/hc/main.go b/hc/main.go new file mode 100644 index 0000000..2956377 --- /dev/null +++ b/hc/main.go @@ -0,0 +1,505 @@ +// hcd — подсистема health-check узла балансировщика (§7 дизайн-концепции +// hpnn_v1, адаптированная к одноузловому прототипу). +// +// Демон проверяет живость членов пула, ведёт их состояние с гистерезисом и +// отражает состав пула в датапасе: пересчитывает Maglev-раскладку слотов и +// заливает таблицу слотов OVS одной атомарной транзакцией. +// +// Пробы уходят с уникального адреса узла в сегменте бэкенда, а не с VIP — +// иначе ответ вернулся бы не тому, кто проверял (§7.1). +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "sort" + "sync" + "syscall" + "time" +) + +// --- конфигурация ------------------------------------------------------------ + +type Config struct { + Bridge string `json:"bridge"` + OFVersion string `json:"of_version"` + PoolID uint32 `json:"pool_id"` + Slots int `json:"slots"` + SlotTable int `json:"slot_table"` + DNATTable int `json:"dnat_table"` + Probe string `json:"probe"` + HTTPPath string `json:"http_path"` + Interval string `json:"interval"` + Timeout string `json:"timeout"` + Rise int `json:"rise"` + Fall int `json:"fall"` + API string `json:"api"` + ApplyScript string `json:"apply_script"` + Members []*Member `json:"members"` + + interval time.Duration + timeout time.Duration +} + +type Member struct { + ID int `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Port int `json:"port"` + Weight int `json:"weight"` + Source string `json:"source"` + + mu sync.Mutex + state string // up | down + admin string // enabled | drain + okStreak int + failStreak int + lastChange time.Time + lastErr string + lastLatency time.Duration + probes uint64 + failures uint64 + transitions uint64 +} + +func (m *Member) Key() string { return fmt.Sprintf("%s:%d", m.Address, m.Port) } + +func (m *Member) WeightOrDefault() int { + if m.Weight <= 0 { + return 1 + } + return m.Weight +} + +// Active — член участвует в раскладке слотов: жив и не выведен на дренаж. +func (m *Member) Active() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.state == "up" && m.admin == "enabled" +} + +type memberView struct { + Name string `json:"name"` + Address string `json:"address"` + Port int `json:"port"` + State string `json:"state"` + Admin string `json:"admin"` + Active bool `json:"active"` + Since string `json:"since"` + LastError string `json:"last_error,omitempty"` + LatencyMS int64 `json:"latency_ms"` + Probes uint64 `json:"probes"` + Failures uint64 `json:"failures"` + Transitions uint64 `json:"transitions"` + Slots int `json:"slots"` + Source string `json:"probe_source"` +} + +// --- пробы ------------------------------------------------------------------- + +// probe возвращает nil, если член ответил. Источник соединения жёстко +// привязан к адресу узла в сегменте этого члена. +func probe(ctx context.Context, cfg *Config, m *Member) error { + dialer := &net.Dialer{ + Timeout: cfg.timeout, + LocalAddr: &net.TCPAddr{IP: net.ParseIP(m.Source)}, + } + target := fmt.Sprintf("%s:%d", m.Address, m.Port) + + if cfg.Probe == "tcp" { + conn, err := dialer.DialContext(ctx, "tcp", target) + if err != nil { + return err + } + return conn.Close() + } + + client := &http.Client{ + Timeout: cfg.timeout, + Transport: &http.Transport{ + DialContext: dialer.DialContext, + DisableKeepAlives: true, // каждая проба — новое соединение + }, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + fmt.Sprintf("http://%s%s", target, cfg.HTTPPath), nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return fmt.Errorf("код ответа %d", resp.StatusCode) + } + return nil +} + +// observe применяет результат пробы с гистерезисом и возвращает true, если +// состояние члена изменилось. +func observe(m *Member, cfg *Config, err error, latency time.Duration) bool { + m.mu.Lock() + defer m.mu.Unlock() + + m.probes++ + m.lastLatency = latency + changed := false + + if err == nil { + m.lastErr = "" + m.failStreak = 0 + m.okStreak++ + if m.state != "up" && m.okStreak >= cfg.Rise { + m.state = "up" + m.lastChange = time.Now() + m.transitions++ + changed = true + log.Printf("член %s (%s) -> up после %d успешных проб", m.Name, m.Key(), m.okStreak) + } + } else { + m.failures++ + m.lastErr = err.Error() + m.okStreak = 0 + m.failStreak++ + if m.state != "down" && m.failStreak >= cfg.Fall { + m.state = "down" + m.lastChange = time.Now() + m.transitions++ + changed = true + log.Printf("член %s (%s) -> down после %d неудач: %v", m.Name, m.Key(), m.failStreak, err) + } + } + return changed +} + +// --- применение раскладки ---------------------------------------------------- + +type Agent struct { + cfg *Config + + mu sync.Mutex + table []int + digest string + applies uint64 + errors uint64 +} + +// apply пересчитывает раскладку и, если она изменилась, заливает таблицу +// слотов атомарным бандлом. force заставляет залить даже неизменившуюся — +// нужно после перезаливки всего пайплайна, которая стирает слоты. +func (a *Agent) apply(force bool) error { + table := BuildSlotTable(a.cfg.PoolID, a.cfg.Members, a.cfg.Slots) + digest := Digest(table) + + a.mu.Lock() + unchanged := digest == a.digest && !force + a.mu.Unlock() + if unchanged { + return nil + } + + bundle := FlowBundle(table, a.cfg.SlotTable, a.cfg.DNATTable) + path := "/var/run/openvswitch/slots.bundle" + if err := os.WriteFile(path, []byte(bundle), 0o644); err != nil { + return err + } + + // ovs-ofctl bundle выполняет delete+add как одну транзакцию: датапас не + // проходит через состояние с полупустой таблицей слотов. + out, err := exec.Command("ovs-ofctl", "-O", a.cfg.OFVersion, "bundle", a.cfg.Bridge, path).CombinedOutput() + a.mu.Lock() + defer a.mu.Unlock() + if err != nil { + a.errors++ + return fmt.Errorf("ovs-ofctl bundle: %v: %s", err, out) + } + a.table = table + a.digest = digest + a.applies++ + + counts := SlotCounts(table) + parts := make([]string, 0, len(counts)) + for _, m := range a.cfg.Members { + if n, ok := counts[m.ID]; ok { + parts = append(parts, fmt.Sprintf("%s=%d", m.Name, n)) + } + } + sort.Strings(parts) + if len(table) == 0 { + log.Printf("раскладка применена: живых членов нет, fail-close (трафик на VIP отбрасывается)") + } else { + log.Printf("раскладка применена: слоты %v, дайджест %s", parts, digest) + } + return nil +} + +func (a *Agent) slotsOf(id int) int { + a.mu.Lock() + defer a.mu.Unlock() + return SlotCounts(a.table)[id] +} + +// --- HTTP API ---------------------------------------------------------------- + +func (a *Agent) memberByName(name string) *Member { + for _, m := range a.cfg.Members { + if m.Name == name { + return m + } + } + return nil +} + +func (a *Agent) setAdmin(w http.ResponseWriter, r *http.Request, admin string) { + name := r.URL.Query().Get("member") + m := a.memberByName(name) + if m == nil { + http.Error(w, fmt.Sprintf("член %q не найден\n", name), http.StatusNotFound) + return + } + m.mu.Lock() + prev := m.admin + m.admin = admin + m.mu.Unlock() + if prev != admin { + log.Printf("член %s: admin %s -> %s", m.Name, prev, admin) + } + if err := a.apply(false); err != nil { + log.Printf("ошибка применения: %v", err) + } + fmt.Fprintf(w, "член %s: admin=%s\n", m.Name, admin) +} + +func (a *Agent) status() []memberView { + out := make([]memberView, 0, len(a.cfg.Members)) + for _, m := range a.cfg.Members { + m.mu.Lock() + v := memberView{ + Name: m.Name, Address: m.Address, Port: m.Port, + State: m.state, Admin: m.admin, + Active: m.state == "up" && m.admin == "enabled", + LastError: m.lastErr, LatencyMS: m.lastLatency.Milliseconds(), + Probes: m.probes, Failures: m.failures, Transitions: m.transitions, + Source: m.Source, + } + if !m.lastChange.IsZero() { + v.Since = m.lastChange.Format(time.RFC3339) + } + m.mu.Unlock() + v.Slots = a.slotsOf(m.ID) + out = append(out, v) + } + return out +} + +func (a *Agent) serve() { + mux := http.NewServeMux() + + mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + digest := a.digest + a.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "pool_id": a.cfg.PoolID, + "slots": a.cfg.Slots, + "slot_table_digest": digest, + "probe": a.cfg.Probe, + "interval": a.cfg.Interval, + "rise": a.cfg.Rise, + "fall": a.cfg.Fall, + "members": a.status(), + }) + }) + + // Человекочитаемый вид для lbctl health: форматирование здесь избавляет + // образ балансировщика от зависимости на jq или python. + mux.HandleFunc("/status.txt", func(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + digest := a.digest + a.mu.Unlock() + if digest == "" { + digest = "-" + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintf(w, "пул %d: слотов %d, проба %s каждые %s (rise=%d fall=%d)\n", + a.cfg.PoolID, a.cfg.Slots, a.cfg.Probe, a.cfg.Interval, a.cfg.Rise, a.cfg.Fall) + fmt.Fprintf(w, "дайджест раскладки: %s\n\n", digest) + fmt.Fprintf(w, "%-6s %-17s %-10s %-9s %-7s %7s %7s %7s %5s %s\n", + "ЧЛЕН", "АДРЕС", "СОСТОЯНИЕ", "ADMIN", "В ПУЛЕ", "СЛОТОВ", "ПРОБ", "НЕУДАЧ", "МС", "ИСТОЧНИК ПРОБ") + for _, v := range a.status() { + inPool := "нет" + if v.Active { + inPool = "да" + } + fmt.Fprintf(w, "%-6s %-17s %-10s %-9s %-7s %7d %7d %7d %5d %s\n", + v.Name, fmt.Sprintf("%s:%d", v.Address, v.Port), v.State, v.Admin, inPool, + v.Slots, v.Probes, v.Failures, v.LatencyMS, v.Source) + if v.LastError != "" { + fmt.Fprintf(w, " последняя ошибка: %s\n", v.LastError) + } + } + }) + + mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + applies, errs := a.applies, a.errors + a.mu.Unlock() + w.Header().Set("Content-Type", "text/plain; version=0.0.4") + fmt.Fprintf(w, "# HELP hpnn_member_up Член пула жив по данным проб\n# TYPE hpnn_member_up gauge\n") + for _, v := range a.status() { + up := 0 + if v.State == "up" { + up = 1 + } + fmt.Fprintf(w, "hpnn_member_up{member=%q,address=%q} %d\n", v.Name, v.Address, up) + } + fmt.Fprintf(w, "# HELP hpnn_member_active Член участвует в раскладке слотов\n# TYPE hpnn_member_active gauge\n") + for _, v := range a.status() { + act := 0 + if v.Active { + act = 1 + } + fmt.Fprintf(w, "hpnn_member_active{member=%q} %d\n", v.Name, act) + } + fmt.Fprintf(w, "# HELP hpnn_member_slots Слотов у члена пула\n# TYPE hpnn_member_slots gauge\n") + for _, v := range a.status() { + fmt.Fprintf(w, "hpnn_member_slots{member=%q} %d\n", v.Name, v.Slots) + } + fmt.Fprintf(w, "# HELP hpnn_probes_total Всего проб\n# TYPE hpnn_probes_total counter\n") + for _, v := range a.status() { + fmt.Fprintf(w, "hpnn_probes_total{member=%q} %d\n", v.Name, v.Probes) + } + fmt.Fprintf(w, "# HELP hpnn_probe_failures_total Неудачных проб\n# TYPE hpnn_probe_failures_total counter\n") + for _, v := range a.status() { + fmt.Fprintf(w, "hpnn_probe_failures_total{member=%q} %d\n", v.Name, v.Failures) + } + fmt.Fprintf(w, "# HELP hpnn_probe_latency_ms Задержка последней пробы\n# TYPE hpnn_probe_latency_ms gauge\n") + for _, v := range a.status() { + fmt.Fprintf(w, "hpnn_probe_latency_ms{member=%q} %d\n", v.Name, v.LatencyMS) + } + fmt.Fprintf(w, "# HELP hpnn_slot_table_applies_total Заливок таблицы слотов\n# TYPE hpnn_slot_table_applies_total counter\nhpnn_slot_table_applies_total %d\n", applies) + fmt.Fprintf(w, "# HELP hpnn_slot_table_errors_total Ошибок заливки\n# TYPE hpnn_slot_table_errors_total counter\nhpnn_slot_table_errors_total %d\n", errs) + }) + + mux.HandleFunc("/drain", func(w http.ResponseWriter, r *http.Request) { a.setAdmin(w, r, "drain") }) + mux.HandleFunc("/enable", func(w http.ResponseWriter, r *http.Request) { a.setAdmin(w, r, "enabled") }) + + // Вызывается из apply.sh: перезаливка всего пайплайна стирает таблицу + // слотов, поэтому её нужно восстановить в актуальном составе. + mux.HandleFunc("/reapply", func(w http.ResponseWriter, r *http.Request) { + if err := a.apply(true); err != nil { + http.Error(w, err.Error()+"\n", http.StatusInternalServerError) + return + } + a.mu.Lock() + digest := a.digest + a.mu.Unlock() + fmt.Fprintf(w, "таблица слотов перезалита, дайджест %s\n", digest) + }) + + log.Printf("API на http://%s (/status, /metrics, /drain, /enable, /reapply)", a.cfg.API) + if err := http.ListenAndServe(a.cfg.API, mux); err != nil { + log.Fatalf("API: %v", err) + } +} + +// --- запуск ------------------------------------------------------------------ + +func loadConfig(path string) (*Config, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + cfg := &Config{} + if err := json.Unmarshal(raw, cfg); err != nil { + return nil, err + } + if cfg.interval, err = time.ParseDuration(cfg.Interval); err != nil { + return nil, fmt.Errorf("interval: %w", err) + } + if cfg.timeout, err = time.ParseDuration(cfg.Timeout); err != nil { + return nil, fmt.Errorf("timeout: %w", err) + } + if len(cfg.Members) == 0 { + return nil, fmt.Errorf("пустой список членов пула") + } + for _, m := range cfg.Members { + // Стартуем с down: член войдёт в раскладку только после Rise + // успешных проб. Балансировать на непроверенный бэкенд нельзя. + m.state = "down" + m.admin = "enabled" + } + return cfg, nil +} + +func main() { + path := flag.String("config", "/var/run/openvswitch/hc.json", "путь к конфигурации") + flag.Parse() + + log.SetFlags(log.LstdFlags) + log.SetPrefix("[hcd] ") + + cfg, err := loadConfig(*path) + if err != nil { + log.Fatalf("конфигурация: %v", err) + } + log.Printf("пул %d: %d членов, %d слотов, проба %s каждые %s (rise=%d fall=%d)", + cfg.PoolID, len(cfg.Members), cfg.Slots, cfg.Probe, cfg.Interval, cfg.Rise, cfg.Fall) + + agent := &Agent{cfg: cfg} + // Стартовое состояние — пустой пул: до первых успешных проб датапас + // работает в fail-close. + if err := agent.apply(true); err != nil { + log.Printf("ошибка стартового применения: %v", err) + } + go agent.serve() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) + + ticker := time.NewTicker(cfg.interval) + defer ticker.Stop() + + for { + select { + case <-stop: + log.Printf("остановка") + return + case <-ticker.C: + var wg sync.WaitGroup + changed := make([]bool, len(cfg.Members)) + for i, m := range cfg.Members { + wg.Add(1) + go func(i int, m *Member) { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout) + defer cancel() + start := time.Now() + err := probe(ctx, cfg, m) + changed[i] = observe(m, cfg, err, time.Since(start)) + }(i, m) + } + wg.Wait() + + for _, c := range changed { + if c { + if err := agent.apply(false); err != nil { + log.Printf("ошибка применения: %v", err) + } + break + } + } + } + } +} diff --git a/lb/Dockerfile b/lb/Dockerfile new file mode 100644 index 0000000..cd8496f --- /dev/null +++ b/lb/Dockerfile @@ -0,0 +1,33 @@ +# Контекст сборки — корень репозитория: в образ едет и демон health-check. +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY hc/go.mod ./ +COPY hc/*.go ./ +RUN CGO_ENABLED=0 go build -trimpath -o /out/hcd . + +FROM debian:bookworm-slim + +# openvswitch-switch — ovsdb-server и ovs-vswitchd; kmod — modprobe openvswitch; +# curl — обращения к API демона из lbctl; остальное — диагностика стенда. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + openvswitch-switch \ + openvswitch-common \ + iproute2 \ + kmod \ + tcpdump \ + iputils-ping \ + conntrack \ + curl \ + procps \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /out/hcd /usr/local/bin/hcd +COPY lb/topology.env lb/pipeline.sh lb/apply.sh lb/entrypoint.sh lb/lbctl.sh lb/hc-config.sh /opt/lb/ +RUN chmod +x /opt/lb/*.sh && ln -s /opt/lb/lbctl.sh /usr/local/bin/lbctl + +HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \ + CMD ovs-ofctl -O OpenFlow15 dump-flows br-lb table=11 >/dev/null 2>&1 \ + && curl -fsS --max-time 3 http://127.0.0.1:9111/status >/dev/null || exit 1 + +ENTRYPOINT ["/opt/lb/entrypoint.sh"] diff --git a/lb/apply.sh b/lb/apply.sh new file mode 100755 index 0000000..deaf1f5 --- /dev/null +++ b/lb/apply.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Генерация и применение OpenFlow-пайплайна. +# Вызывается из entrypoint.sh при старте и вручную (make flows) при правках. +# +# apply.sh применить пайплайн и попросить hcd перезалить слоты +# apply.sh --no-reapply только пайплайн (демон ещё не запущен) +set -euo pipefail + +LB_DIR=/opt/lb +# shellcheck disable=SC1091 +source "$LB_DIR/topology.env" + +FLOWS=/var/run/openvswitch/flows.txt + +# Группа балансировки шага 1 больше не используется: её место заняла таблица +# слотов (таблица 11), которую ведёт демон hcd. +ovs-ofctl -O "$OF" del-groups "$BR" 2>/dev/null || true + +# Правила применяются атомарным бандлом: датапас не проходит через +# промежуточное состояние с частично залитым пайплайном. +"$LB_DIR/pipeline.sh" > "$FLOWS" +ovs-ofctl -O "$OF" --bundle replace-flows "$BR" "$FLOWS" + +echo "[lb-router] пайплайн применён: $(grep -cv '^\s*\(#\|$\)' "$FLOWS") правил" + +# replace-flows стирает и таблицу слотов, поэтому демона просят залить её +# заново — в актуальном составе пула, а не в полном. +if [ "${1:-}" != "--no-reapply" ]; then + if out=$(curl -fsS --max-time 5 "http://$HC_API/reapply" 2>&1); then + echo "[lb-router] $out" + else + echo "[lb-router] hcd недоступен ($out) — таблица слотов пуста, датапас в fail-close" >&2 + fi +fi diff --git a/lb/entrypoint.sh b/lb/entrypoint.sh new file mode 100755 index 0000000..fb5b754 --- /dev/null +++ b/lb/entrypoint.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# Подъём Open vSwitch внутри контейнера и сборка моста br-lb. +# +# Контейнер работает как чистый OpenFlow-роутер: IP-адреса на интерфейсах не +# настраиваются, сетевой стек ядра в форвардинге не участвует. Вся L3-логика +# (ARP, маршрутизация, DNAT) живёт в таблицах OpenFlow — см. pipeline.sh. +set -euo pipefail + +LB_DIR=/opt/lb +# shellcheck disable=SC1091 +source "$LB_DIR/topology.env" + +log() { echo "[lb-router] $*"; } +die() { echo "[lb-router] ОШИБКА: $*" >&2; exit 1; } + +# --- 1. Kernel datapath ------------------------------------------------------ +log "загрузка модуля openvswitch" +modprobe openvswitch 2>/dev/null || true +grep -q '^openvswitch ' /proc/modules || die \ + "модуль openvswitch не загружен. Выполните на хосте: modprobe openvswitch (см. scripts/host-prereq.sh)" + +# --- 2. Запуск ovsdb-server и ovs-vswitchd ----------------------------------- +mkdir -p /var/run/openvswitch /var/log/openvswitch /etc/openvswitch +rm -f /var/run/openvswitch/*.pid + +if [ ! -f /etc/openvswitch/conf.db ]; then + log "инициализация OVSDB" + ovsdb-tool create /etc/openvswitch/conf.db /usr/share/openvswitch/vswitch.ovsschema +fi + +log "запуск ovsdb-server" +ovsdb-server /etc/openvswitch/conf.db \ + --remote=punix:/var/run/openvswitch/db.sock \ + --remote=db:Open_vSwitch,Open_vSwitch,manager_options \ + --pidfile --detach --log-file +ovs-vsctl --no-wait init + +log "запуск ovs-vswitchd" +ovs-vswitchd --pidfile --detach --log-file + +# --- 3. Мост ----------------------------------------------------------------- +log "создание моста $BR" +ovs-vsctl --may-exist add-br "$BR" \ + -- set bridge "$BR" fail_mode=secure \ + -- set bridge "$BR" protocols=OpenFlow13,OpenFlow15 \ + -- set bridge "$BR" other-config:disable-in-band=true +ip link set dev "$BR" up + +# --- 4. Перенос интерфейсов Docker в мост ------------------------------------ +# Интерфейсы опознаются по MAC (имена eth0/eth1/eth2 Docker раздаёт в +# непредсказуемом порядке), переименовываются в осмысленные и лишаются IP: +# адрес нужен только Docker IPAM, чтобы зарезервировать его за контейнером. +find_dev_by_mac() { + local mac="$1" d + for d in /sys/class/net/*; do + [ -f "$d/address" ] || continue + [ "$(cat "$d/address")" = "$mac" ] && { basename "$d"; return 0; } + done + return 1 +} + +attach_port() { + local mac="$1" name="$2" ofport="$3" dev + dev=$(find_dev_by_mac "$mac") || die "интерфейс с MAC $mac не найден" + if [ "$dev" != "$name" ]; then + ip link set dev "$dev" down + ip addr flush dev "$dev" + ip link set dev "$dev" name "$name" + else + ip addr flush dev "$name" + fi + ip link set dev "$name" up + ovs-vsctl --may-exist add-port "$BR" "$name" \ + -- set Interface "$name" ofport_request="$ofport" + log "порт $name (MAC $mac) -> $BR, ofport $ofport" +} + +attach_port "$PUB_MAC" "$PUB_IFNAME" "$PUB_OFPORT" +attach_port "$P2_MAC" "$P2_IFNAME" "$P2_OFPORT" +attach_port "$P3_MAC" "$P3_IFNAME" "$P3_OFPORT" + +# --- 4a. Порты-источники health-проб ----------------------------------------- +# Единственные адреса, которые узел держит в ядре: с них уходят пробы (§7.1 +# дизайна v1 — источник проб должен быть уникальным адресом узла, не VIP). +# MAC бэкенда прописывается статически: ARP-резолвер узлу не нужен, MAC членов +# пула и так зафиксированы в docker-compose.yml. +hc_port() { + local name="$1" ofport="$2" mac="$3" ip="$4" net="$5" + shift 5 + ovs-vsctl --may-exist add-port "$BR" "$name" \ + -- set Interface "$name" type=internal ofport_request="$ofport" \ + -- set Interface "$name" mac="\"$mac\"" + ip link set dev "$name" address "$mac" + ip addr replace "$ip/${net##*/}" dev "$name" + ip link set dev "$name" up + # Остальные аргументы — пары «адрес MAC» членов пула в этом сегменте. + local peers="" + while [ $# -ge 2 ]; do + ip neigh replace "$1" lladdr "$2" dev "$name" nud permanent + peers="$peers $1" + shift 2 + done + log "порт $name ($ip) — источник health-проб для$peers" +} + +hc_port "$HC2_IFNAME" "$HC2_OFPORT" "$HC2_MAC" "$HC2_IP" "$P2_NET" \ + "$BE1_IP" "$BE1_MAC" "$BE3_IP" "$BE3_MAC" +hc_port "$HC3_IFNAME" "$HC3_OFPORT" "$HC3_MAC" "$HC3_IP" "$P3_NET" \ + "$BE2_IP" "$BE2_MAC" "$BE4_IP" "$BE4_MAC" + +# Ждём, пока vswitchd реально привяжет порты к датапасу. +for name in "$PUB_IFNAME" "$P2_IFNAME" "$P3_IFNAME"; do + for _ in $(seq 30); do + ofport=$(ovs-vsctl --if-exists get Interface "$name" ofport || echo -1) + [ "$ofport" -gt 0 ] 2>/dev/null && break + sleep 0.5 + done + [ "${ofport:-0}" -gt 0 ] 2>/dev/null || die "порт $name не привязался к датапасу: $(ovs-vsctl get Interface "$name" error 2>/dev/null || true)" +done + +# --- 5. Пайплайн ------------------------------------------------------------- +# Таблица слотов здесь пуста (fail-close) — её наполнит hcd после первых +# успешных проб, поэтому apply.sh на этом этапе демона ещё не дёргает. +"$LB_DIR/apply.sh" --no-reapply + +log "готово. Порты: $(ovs-vsctl list-ports "$BR" | tr '\n' ' ')" + +# --- 6. Health-check --------------------------------------------------------- +# Демон становится основным процессом контейнера; ovsdb-server и ovs-vswitchd +# работают демонами рядом. Логи проб и переходов видны в docker logs. +CONF=$("$LB_DIR/hc-config.sh") +log "конфигурация health-check: $CONF" + +tail -F --pid=$$ /var/log/openvswitch/ovs-vswitchd.log 2>/dev/null & + +exec hcd -config "$CONF" diff --git a/lb/hc-config.sh b/lb/hc-config.sh new file mode 100755 index 0000000..4303386 --- /dev/null +++ b/lb/hc-config.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Генерация конфигурации демона hcd из topology.env — единственного источника +# правды по адресам стенда. Пока пул задан статически; на следующем шаге его +# место займёт модель Load_Balancer -> Listener -> Pool -> Member в OVSDB. +set -euo pipefail + +LB_DIR=/opt/lb +# shellcheck disable=SC1091 +source "$LB_DIR/topology.env" + +OUT=${1:-/var/run/openvswitch/hc.json} + +cat > "$OUT" < +set -euo pipefail + +LB_DIR=/opt/lb +# shellcheck disable=SC1091 +source "$LB_DIR/topology.env" + +usage() { + cat < вывести член из балансировки (пробы продолжаются) + enable <член> вернуть член в балансировку + metrics метрики в формате Prometheus + conns таблица соединений ct (зона $CT_ZONE) + trace [src_port] + ofproto/trace для TCP-сессии клиент -> VIP:$VIP_PORT + reload перегенерировать и применить пайплайн +USAGE +} + +api() { curl -fsS --max-time 5 "http://$HC_API$1"; } + +case "${1:-}" in +ports) + ovs-vsctl show + ovs-ofctl -O "$OF" show "$BR" | grep -E '^\s+[0-9]+\(' + ;; +flows) + if [ -n "${2:-}" ]; then + ovs-ofctl -O "$OF" dump-flows "$BR" "table=$2" + else + ovs-ofctl -O "$OF" dump-flows "$BR" + fi + ;; +health) + api /status.txt + ;; +status) + api /status + ;; +slots) + echo "Раскладка по данным датапаса (счётчики правил дают per-member статистику):" + ovs-ofctl -O "$OF" dump-flows "$BR" table=11 \ + | sed -n 's/.*n_packets=\([0-9]*\).*set_field:\(0x[0-9a-f]*\)->reg2.*/\2 \1/p' \ + | awk '{slots[$1]++; pkts[$1]+=$2} + END {for (m in slots) printf " член id=%s: слотов %d, пакетов %d\n", m, slots[m], pkts[m]}' \ + | sort + drops=$(ovs-ofctl -O "$OF" dump-flows "$BR" table=11 | sed -n 's/.*n_packets=\([0-9]*\).*priority=0.*/\1/p') + echo " отброшено правилом fail-close: ${drops:-0}" + api /status.txt | sed -n '2p' + ;; +drain) + api "/drain?member=${2:?укажите имя члена}" + ;; +enable) + api "/enable?member=${2:?укажите имя члена}" + ;; +metrics) + api /metrics + ;; +conns) + ovs-appctl dpctl/dump-conntrack | grep -F "zone=$CT_ZONE" || echo "соединений нет" + ;; +trace) + src_ip="${2:?укажите IP клиента}" + src_port="${3:-40000}" + 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" + ;; +reload) + "$LB_DIR/apply.sh" + ;; +*) + usage + ;; +esac diff --git a/lb/pipeline.sh b/lb/pipeline.sh new file mode 100755 index 0000000..7e52a06 --- /dev/null +++ b/lb/pipeline.sh @@ -0,0 +1,202 @@ +#!/bin/bash +# Генератор OpenFlow-пайплайна br-lb. Печатает flow-файл в stdout. +# Применяется атомарным бандлом: ovs-ofctl --bundle -O OpenFlow15 replace-flows. +# +# Карта таблиц: +# 0 — классификация по in_port и типу трафика, обучение MAC клиентов +# 5 — ARP-респондер для собственных адресов узла и VIP +# 6 — ICMP echo-респондер для тех же адресов +# 10 — листенеры: хэш сессии в слот (multipath), прочий IP -> маршрутизация +# 11 — таблица слотов: слот -> член пула. Заливает и обновляет демон hcd +# 12 — применение DNAT выбранным членом пула (по reg2) +# 15 — обратный путь из приватных сегментов (снятие DNAT через ct) +# 16 — пост-ct hook (счётчики, место под будущие проверки) +# 20 — маршрутизация (LPM через приоритет = длина префикса) +# 21 — adjacency: next-hop MAC + выходной порт +# +# Регистры: reg1 — номер слота, reg2 — идентификатор члена пула. +# +# Нумерация не произвольна: goto_table в OpenFlow разрешает переход только +# вперёд, поэтому обратный путь (15/16) стоит до общей маршрутизации (20). +set -euo pipefail + +# shellcheck disable=SC1091 +source "$(dirname "$0")/topology.env" + +ip2hex() { local IFS=.; read -ra o <<<"$1"; printf '0x%02x%02x%02x%02x' "${o[0]}" "${o[1]}" "${o[2]}" "${o[3]}"; } +mac2hex() { echo "0x${1//:/}"; } + +PUB_MAC_H=$(mac2hex "$PUB_MAC") +P2_MAC_H=$(mac2hex "$P2_MAC") +P3_MAC_H=$(mac2hex "$P3_MAC") + +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") + +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[])" + +# 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" +} + +# icmp_responder +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" +} + +cat < -> reg2=<член пула>) рассчитывает +# и заливает демон hcd по Maglev-раскладке живых членов пула. Здесь только +# основание fail-close: пока пул пуст, трафик на VIP отбрасывается со +# счётчиком, а не уходит на заведомо мёртвый бэкенд. +# +# Счётчики правил этой таблицы дают бесплатную per-member статистику. +table=11,priority=0 actions=drop + +# ============================================================================= +# Таблица 12 — DNAT выбранным членом пула +# ============================================================================= +# Слот положил идентификатор члена в reg2. SNAT не выполняется: бэкенд видит +# реальный адрес клиента. +table=12,priority=100,ip,reg2=0x1 actions=ct(commit,zone=$CT_ZONE,nat(dst=$BE1_IP:$BE1_PORT),table=20) +table=12,priority=100,ip,reg2=0x2 actions=ct(commit,zone=$CT_ZONE,nat(dst=$BE2_IP:$BE2_PORT),table=20) +table=12,priority=100,ip,reg2=0x3 actions=ct(commit,zone=$CT_ZONE,nat(dst=$BE3_IP:$BE3_PORT),table=20) +table=12,priority=100,ip,reg2=0x4 actions=ct(commit,zone=$CT_ZONE,nat(dst=$BE4_IP:$BE4_PORT),table=20) +table=12,priority=0 actions=drop + +# ============================================================================= +# Таблица 15 — обратный путь из приватных сегментов +# ============================================================================= +# ct(nat) без commit снимает ранее выполненный DNAT: source возвращается к VIP. +# Для сессий, которых нет в таблице соединений (трафик be1<->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 + +# ============================================================================= +# Таблица 16 — после ct +# ============================================================================= +table=16,priority=100,ip actions=goto_table:20 +table=16,priority=0 actions=drop + +# ============================================================================= +# Таблица 20 — маршрутизация +# ============================================================================= +# Приоритет = длина префикса, что даёт longest-prefix match средствами +# OpenFlow. Дефолтного маршрута нет: неизвестное назначение отбрасывается. +# +# Собственные адреса узла (/32, приоритет 32) — ответы бэкендов на health-пробы. +# TTL для них не уменьшается: пакет адресован самому узлу, а не транзитный. +table=20,priority=32,ip,nw_dst=$HC2_IP actions=goto_table:21 +table=20,priority=32,ip,nw_dst=$HC3_IP actions=goto_table:21 +table=20,priority=24,ip,nw_dst=$P2_NET actions=dec_ttl,mod_dl_src:$P2_MAC,goto_table:21 +table=20,priority=24,ip,nw_dst=$P3_NET actions=dec_ttl,mod_dl_src:$P3_MAC,goto_table:21 +table=20,priority=24,ip,nw_dst=$PUB_NET actions=dec_ttl,mod_dl_src:$PUB_MAC,goto_table:21 +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 +# Ответы на 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. +table=21,priority=0 actions=drop +EOF diff --git a/lb/topology.env b/lb/topology.env new file mode 100644 index 0000000..07d6362 --- /dev/null +++ b/lb/topology.env @@ -0,0 +1,70 @@ +# Единственный источник правды по адресам стенда шага 1. +# Используется entrypoint.sh (сборка моста) и pipeline.sh (генерация OpenFlow). +# Значения здесь должны совпадать с docker-compose.yml. + +BR=br-lb +OF=OpenFlow15 +CT_ZONE=1 + +# --- сеть 1: публичный сегмент (macvlan поверх enp3s0, реальный L2 LAN) ------- +# IP-адреса ниже существуют ТОЛЬКО в OpenFlow-правилах: ARP-респондер отвечает +# на них MAC-адресом PUB_MAC. На интерфейсе адрес не настраивается. +PUB_IFNAME=pub0 +PUB_OFPORT=1 +PUB_MAC=02:42:c0:a8:05:14 +PUB_NET=192.168.5.0/24 +LB_PUB_IP=192.168.5.20 +VIP=192.168.5.21 +VIP_PORT=80 + +# --- сеть 2: приватный сегмент бэкенда be1 ----------------------------------- +P2_IFNAME=p2 +P2_OFPORT=2 +P2_MAC=02:42:0a:14:00:01 +P2_NET=10.20.0.0/24 +LB_P2_IP=10.20.0.1 +BE1_IP=10.20.0.2 +BE1_MAC=02:42:0a:14:00:02 +BE1_PORT=8080 +BE3_IP=10.20.0.3 +BE3_MAC=02:42:0a:14:00:03 +BE3_PORT=8080 +# Уникальный адрес узла в сегменте — источник health-проб (§7.1 дизайна v1). +# Живёт на internal-порту OVS: это единственный адрес, который узел держит в +# ядре, весь остальной форвардинг идёт мимо стека. +HC2_IFNAME=hcif-p2 +HC2_OFPORT=4 +HC2_MAC=02:42:0a:14:00:fd +HC2_IP=10.20.0.253 + +# --- сеть 3: приватный сегмент бэкенда be2 ----------------------------------- +P3_IFNAME=p3 +P3_OFPORT=3 +P3_MAC=02:42:0a:1e:00:01 +P3_NET=10.30.0.0/24 +LB_P3_IP=10.30.0.1 +BE2_IP=10.30.0.2 +BE2_MAC=02:42:0a:1e:00:02 +BE2_PORT=8080 +BE4_IP=10.30.0.3 +BE4_MAC=02:42:0a:1e:00:03 +BE4_PORT=8080 +HC3_IFNAME=hcif-p3 +HC3_OFPORT=5 +HC3_MAC=02:42:0a:1e:00:fd +HC3_IP=10.30.0.253 + +# --- пул и таблица слотов ---------------------------------------------------- +# POOL_ID служит basis хэша multipath: разные пулы дают независимые раскладки. +POOL_ID=1 +# Слотов на пул (§5.1 дизайна v1). Определяет гранулярность весов. +SLOTS=1024 + +# --- health-check ------------------------------------------------------------ +HC_PROBE=http # http | tcp +HC_HTTP_PATH=/healthz +HC_INTERVAL=2s +HC_TIMEOUT=1s +HC_RISE=2 # успехов подряд для перевода в up +HC_FALL=3 # неудач подряд для перевода в down +HC_API=127.0.0.1:9111 diff --git a/scripts/host-prereq.sh b/scripts/host-prereq.sh new file mode 100755 index 0000000..33ec17b --- /dev/null +++ b/scripts/host-prereq.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Подготовка хоста к запуску стенда. +# +# ./host-prereq.sh загрузить модуль openvswitch +# ./host-prereq.sh --shim дополнительно поднять macvlan-shim, чтобы стенд +# можно было проверять с самого хоста +# ./host-prereq.sh --shim-down снять shim +# +# Про shim: macvlan-интерфейс контейнера и физический интерфейс хоста не видят +# друг друга напрямую — это ограничение macvlan, а не стенда. Клиент из +# 192.168.5.0/24 обращается к стенду без всяких shim; shim нужен только если +# проверять хочется с самой машины 192.168.5.9. +set -euo pipefail + +PARENT=${PARENT:-enp3s0} +SHIM=${SHIM:-hpnn-shim} +SHIM_IP=${SHIM_IP:-192.168.5.13/32} +LB_IP=${LB_IP:-192.168.5.20} +VIP=${VIP:-192.168.5.21} + +log() { echo "[host-prereq] $*"; } + +shim_up() { + if ! ip link show "$SHIM" >/dev/null 2>&1; then + ip link add "$SHIM" link "$PARENT" type macvlan mode bridge + log "создан $SHIM поверх $PARENT" + fi + ip addr replace "$SHIM_IP" dev "$SHIM" + ip link set "$SHIM" up + ip route replace "$LB_IP/32" dev "$SHIM" + ip route replace "$VIP/32" dev "$SHIM" + log "shim поднят: $SHIM_IP, маршруты на $LB_IP и $VIP" +} + +shim_down() { + ip link del "$SHIM" 2>/dev/null && log "$SHIM удалён" || log "$SHIM отсутствует" +} + +case "${1:-}" in +--shim-down) + shim_down + exit 0 + ;; +esac + +if ! grep -q '^openvswitch ' /proc/modules; then + modprobe openvswitch + log "модуль openvswitch загружен" +else + log "модуль openvswitch уже загружен" +fi + +# Чтобы стенд переживал перезагрузку хоста. +if [ -d /etc/modules-load.d ] && [ ! -f /etc/modules-load.d/openvswitch.conf ]; then + echo openvswitch > /etc/modules-load.d/openvswitch.conf + log "модуль добавлен в автозагрузку (/etc/modules-load.d/openvswitch.conf)" +fi + +[ "${1:-}" = "--shim" ] && shim_up + +log "готово" diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..f4f5dac --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,245 @@ +#!/bin/bash +# Проверки стенда, выполнимые с хоста. Клиентские шаги (ping/curl из +# 192.168.5.0/24) описаны в README и выполняются вручную; если поднят +# macvlan-shim (host-prereq.sh --shim), скрипт прогоняет и их. +set -uo pipefail + +cd "$(dirname "$0")/.." +DC="docker compose" +VIP=192.168.5.21 +LB_IP=192.168.5.20 +SHIM=hpnn-shim + +pass=0 +fail=0 +ok() { echo -e " \033[32mOK\033[0m $*"; pass=$((pass + 1)); } +bad() { echo -e " \033[31mПРОВАЛ\033[0m $*"; fail=$((fail + 1)); } +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 + state=$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo "нет") + [ "$state" = "running" ] && ok "$c: $state" || bad "$c: $state" +done + +head_ "2. Мост br-lb и порты" +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 +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 + 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 +for p in hcif-p2 hcif-p3; do + echo "$ports" | grep -qw "$p" && ok "порт-источник проб $p в мосту" || bad "порт $p отсутствует" +done + +head_ "4. Маршруты бэкендов (профиль A: default мимо LB)" +for be in be1:10.20.0.1:10.30.0.0/24 be2:10.30.0.1:10.20.0.0/24 \ + be3:10.20.0.1:10.30.0.0/24 be4:10.30.0.1:10.20.0.0/24; do + name=${be%%:*}; rest=${be#*:}; gw=${rest%%:*}; peer=${rest#*:} + r=$($DC exec -T "$name" ip route 2>/dev/null) + echo "$r" | grep -q "192.168.5.0/24 via $gw" && ok "$name: клиентский префикс via $gw" || bad "$name: нет маршрута на 192.168.5.0/24" + echo "$r" | grep -q "$peer via $gw" && ok "$name: соседний сегмент via $gw" || bad "$name: нет маршрута на $peer" + echo "$r" | grep -q "^default via .*\.254" && ok "$name: default мимо LB" || bad "$name: default не на шлюзе Docker" +done + +head_ "5. Маршрутизация между приватными сегментами" +for pair in be1:10.30.0.2:be2 be3:10.30.0.3:be4 be2:10.20.0.2:be1 be4:10.20.0.3:be3; do + from=${pair%%:*}; rest=${pair#*:}; dst=${rest%%:*}; to=${rest#*:} + out=$($DC exec -T "$from" curl -s --max-time 5 "http://$dst:8080/" 2>/dev/null) + echo "$out" | grep -q "backend=$to" && ok "$from -> $to через OVS" \ + || bad "$from -> $to недоступен (ответ: ${out:-пусто})" +done + +head_ "6. Балансировка (требует macvlan-shim на хосте)" +if ip link show "$SHIM" >/dev/null 2>&1; then + ping -c1 -W2 "$LB_IP" >/dev/null 2>&1 && ok "ping $LB_IP (ARP+ICMP-респондер OVS)" || bad "ping $LB_IP не проходит" + ping -c1 -W2 "$VIP" >/dev/null 2>&1 && ok "ping $VIP (VIP)" || bad "ping $VIP не проходит" + + declare -A hits=() + client="" + for _ in $(seq 20); do + line=$(curl -s --max-time 5 "http://$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') + [ -n "$b" ] && hits[$b]=$(( ${hits[$b]:-0} + 1 )) + [ -n "$c" ] && client=$c + done + total=0; for k in "${!hits[@]}"; do total=$((total + hits[$k])); done + echo " распределение: $(for k in "${!hits[@]}"; do printf '%s=%s ' "$k" "${hits[$k]}"; done)(всего $total)" + # На 20 запросах и четырёх членах пропуск одного бэкенда возможен, но + # маловероятен; порог в три различных имени отделяет статистику от дефекта. + [ "${#hits[@]}" -ge 3 ] && ok "трафик распределён между ${#hits[@]} бэкендами из 4" \ + || bad "задействовано лишь ${#hits[@]} бэкендов" + if [ -n "$client" ]; then + case "$client" in + 192.168.5.*) ok "бэкенд видит реальный IP клиента: $client (DNAT без SNAT)" ;; + *) bad "бэкенд видит адрес $client вместо клиентского" ;; + esac + else + bad "не удалось получить адрес клиента из ответа" + fi +else + skip "shim не поднят — выполните: sudo scripts/host-prereq.sh --shim" +fi + +head_ "7. Профиль A: собственный egress бэкенда идёт мимо балансировщика" +dump=$(mktemp) +timeout 12 $DC exec -T lb-router timeout 8 tcpdump -ni p2 -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) +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" +else + ok "на порту p2 балансировщика этого трафика нет" +fi +rm -f "$dump" + +head_ "8. Health-check: состояние пула" +hc() { $DC exec -T lb-router curl -fsS --max-time 5 "http://127.0.0.1:9111$1" 2>/dev/null; } +state() { hc /status | sed -n "s/.*\"name\":\"$1\"[^}]*\"state\":\"\([a-z]*\)\".*/\1/p"; } +digest() { hc /status | sed -n 's/.*"slot_table_digest":"\([^"]*\)".*/\1/p'; } +# Раскладка из датапаса: пары «слот -> член». reg1=0 OVS печатает без 0x. +snapshot() { + $DC exec -T lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb table=11 2>/dev/null \ + | sed -n 's/.*reg1=\(0x[0-9a-f]*\|[0-9]\+\).*set_field:\(0x[0-9a-f]*\)->reg2.*/\1 \2/p' | sort +} +slots_of() { snapshot | grep -c " $1\$"; } +# wait_state <член> <состояние> <секунд> +wait_state() { + for _ in $(seq "$3"); do + [ "$(hc /status | sed -n "s/.*\"name\":\"$1\"[^}]*\"state\":\"\([a-z]*\)\".*/\1/p")" = "$2" ] && return 0 + sleep 1 + done + return 1 +} + +for m in be1 be2 be3 be4; do + [ "$(state $m)" = "up" ] && ok "член $m: up" || bad "член $m: $(state $m)" +done +probes=$(hc /status | sed -n 's/.*"probes":\([0-9]*\).*/\1/p' | head -1) +[ "${probes:-0}" -gt 0 ] && ok "пробы идут (у be1 их $probes)" || bad "проб не было" + +total_slots=$(snapshot | wc -l) +[ "$total_slots" -eq 1024 ] && ok "таблица слотов заполнена: $total_slots слотов" \ + || bad "слотов $total_slots вместо 1024" +# Идеальная доля при четырёх равновесных членах — 256 слотов; Maglev +# гарантирует отклонение в пределах ±1, порог взят с большим запасом. +uneven=0; dist="" +for id in 0x1 0x2 0x3 0x4; do + n=$(slots_of $id); dist="$dist $id=$n" + [ "$n" -lt 230 ] || [ "$n" -gt 280 ] && uneven=1 +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 \ + | 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" + +head_ "10. Минимальное возмущение раскладки при выводе члена" +before=$(mktemp); after=$(mktemp) +snapshot > "$before" +digest_full=$(digest) +$DC exec -T lb-router lbctl drain be1 >/dev/null 2>&1 +sleep 1 +snapshot > "$after" +# Слоты выбывшего члена обязаны переехать — это его 25 % таблицы. Смысл +# проверки в другом: сколько слотов сменило владельца у ОСТАВШИХСЯ членов. +# Классический Maglev гарантирует малое, а не нулевое возмущение, поэтому +# проверяется порог. Измеренное на этом стенде значение — 8 слотов (0,8 %). +own=$(join "$before" "$after" | awk '$2=="0x1"' | wc -l) +foreign=$(join "$before" "$after" | awk '$2!="0x1" && $2!=$3' | wc -l) +if [ "$foreign" -le 20 ]; then + ok "возмущение минимально: переехали $own слотов выбывшего be1 и лишь $foreign чужих (порог 20)" +else + bad "у оставшихся членов переехало $foreign слотов — возмущение выше ожидаемого" +fi +$DC exec -T lb-router lbctl enable be1 >/dev/null 2>&1 +sleep 1 +[ "$(digest)" = "$digest_full" ] && ok "после возврата члена дайджест прежний: $digest_full" \ + || bad "дайджест не восстановился: $(digest) вместо $digest_full" +rm -f "$before" "$after" + +head_ "11. Отказ и восстановление члена пула" +$DC pause be1 >/dev/null 2>&1 +if wait_state be1 down 20; then + ok "be1 переведён в down по данным проб" + [ "$(slots_of 0x1)" -eq 0 ] && ok "у be1 не осталось слотов" || bad "у be1 ещё $(slots_of 0x1) слотов" + [ "$(snapshot | wc -l)" -eq 1024 ] && ok "его слоты разошлись по трём оставшимся членам" \ + || bad "в таблице $(snapshot | wc -l) слотов вместо 1024" + nodead=1 + for _ in $(seq 8); do + line=$(curl -s --max-time 5 "http://$VIP/" 2>/dev/null) + echo "$line" | grep -qE 'backend=(be2|be3|be4)' || nodead=0 + done + [ "$nodead" -eq 1 ] && ok "весь трафик на VIP обслуживают живые члены, ошибок нет" \ + || bad "часть запросов не обслужена или ушла на мёртвый член" +else + bad "be1 не перешёл в down за 20 с" +fi +$DC unpause be1 >/dev/null 2>&1 +wait_state be1 up 20 && ok "be1 вернулся в up после восстановления" || bad "be1 не вернулся в up" + +head_ "12. Fail-close при полном отказе пула" +$DC pause be1 be2 be3 be4 >/dev/null 2>&1 +if wait_state be1 down 20 && wait_state be2 down 20 \ + && wait_state be3 down 20 && wait_state be4 down 20; then + [ "$(snapshot | wc -l)" -eq 0 ] && ok "таблица слотов пуста" || bad "в таблице остались слоты" + d0=$($DC exec -T lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb table=11 2>/dev/null \ + | sed -n 's/.*n_packets=\([0-9]*\).*priority=0.*/\1/p') + curl -s --max-time 4 "http://$VIP/" >/dev/null 2>&1 && bad "VIP ответил при пустом пуле" || ok "клиент получает таймаут" + d1=$($DC exec -T lb-router ovs-ofctl -O OpenFlow15 dump-flows br-lb table=11 2>/dev/null \ + | sed -n 's/.*n_packets=\([0-9]*\).*priority=0.*/\1/p') + [ "${d1:-0}" -gt "${d0:-0}" ] && ok "счётчик правила fail-close вырос: ${d0:-0} -> ${d1:-0}" \ + || bad "счётчик fail-close не изменился" +else + bad "члены не перешли в down" +fi +$DC unpause be1 be2 be3 be4 >/dev/null 2>&1 +wait_state be1 up 20 && wait_state be2 up 20 && wait_state be3 up 20 && wait_state be4 up 20 \ + && ok "пул восстановлен" || bad "пул не восстановился" + +head_ "13. Установленная сессия переживает смену состава пула" +if ip link show "$SHIM" >/dev/null 2>&1; then + slow=$(mktemp) + (curl -sN --max-time 22 "http://$VIP/slow?seconds=14" > "$slow" 2>&1 &) + sleep 4 + served=$(sed -n 's/backend=\([a-z0-9]*\).*/\1/p' "$slow" | head -1) + if [ -n "$served" ]; then + $DC exec -T lb-router lbctl drain "$served" >/dev/null 2>&1 + sleep 12 + $DC exec -T lb-router lbctl enable "$served" >/dev/null 2>&1 + ticks=$(grep -c '^backend=' "$slow") + [ "$ticks" -eq 14 ] && ok "сессия к $served не порвалась при его выводе из пула ($ticks/14 тиков)" \ + || bad "сессия оборвалась: $ticks из 14 тиков" + else + bad "не удалось начать длинную сессию" + fi + rm -f "$slow" +else + skip "shim не поднят" +fi + +head_ "14. Идемпотентность: перезаливка пайплайна не теряет раскладку" +d_before=$(digest) +$DC exec -T lb-router /opt/lb/apply.sh >/dev/null 2>&1 +sleep 1 +[ "$(digest)" = "$d_before" ] && [ "$(snapshot | wc -l)" -eq 1024 ] \ + && ok "после apply.sh раскладка та же: $d_before" \ + || bad "раскладка изменилась: $(digest), слотов $(snapshot | wc -l)" + +echo +echo "Итог: успешно $pass, провалено $fail" +[ "$fail" -eq 0 ]