MVP Single Node with Manual Config in Docker
This commit is contained in:
commit
8212699f23
27 files changed
+3818
No files matched your search
@@ -0,0 +1,21 @@
|
||||
# Твоя роль
|
||||
|
||||
- DevOps инженер
|
||||
- Разработчик Backend
|
||||
- Архитектор информационных систем
|
||||
|
||||
# Стиль общения
|
||||
|
||||
- профессиональный, но без жаргона
|
||||
|
||||
# Стиль ответов
|
||||
|
||||
- максимально емкие и содержательные
|
||||
- не проваливайся в лишние детали, если это явно не было запрошено
|
||||
|
||||
# Создание артефактов
|
||||
|
||||
- На каждое новое изменение должен быть артефакт в .md файле
|
||||
- Каждое новое изменение должно начинаться с плана внедрения в отдельном файле
|
||||
- Каждое новое изменение должно заканчиваться суммаризацией по выполненым доработкам в отдельном файле
|
||||
- каждое изменение дополняет или обновляет README.md
|
||||
@@ -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
|
||||
@@ -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 проверки стенда
|
||||
```
|
||||
@@ -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"]
|
||||
Executable
+21
@@ -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 "$@"
|
||||
@@ -0,0 +1,3 @@
|
||||
module hpnn/backend
|
||||
|
||||
go 1.22
|
||||
+105
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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` под новый состав пула.
|
||||
@@ -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 на следующем шаге.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Адрес клиента понадобится дальше; обозначим его `<CLIENT_IP>`.
|
||||
|
||||
> Если проверять хотите с самого хоста стенда, а не с отдельной машины,
|
||||
> выполните **[Х]** `make shim` — macvlan-интерфейс контейнера и физический
|
||||
> интерфейс хоста напрямую друг друга не видят, это ограничение macvlan.
|
||||
> Тогда все шаги **[К]** выполняются на хосте, а `<CLIENT_IP>` = 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=<CLIENT_IP>` — реальный адрес вашей машины.
|
||||
|
||||
Если бы выполнялся 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=<CLIENT_IP>`.
|
||||
Балансировка здесь не участвует — работает только подсистема маршрутизации.
|
||||
|
||||
> **Не выполняйте этот шаг на хосте стенда.** Там эти маршруты перекроют
|
||||
> 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
|
||||
```
|
||||
@@ -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 <if>`), закрепить `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:<pub0 mac>->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=<MAC порта>`, `resubmit(,21)`. `priority=0` → drop (дефолта нет) |
|
||||
| **21** | Adjacency: статически `nw_dst=10.20.0.2 → mod_dl_dst=<be1 mac>, 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.
|
||||
@@ -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.
|
||||
@@ -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=<slot>` → `reg2=<member>` |
|
||||
| 12 | — | DNAT по `reg2` (бывшая 11) |
|
||||
|
||||
```
|
||||
table=10,priority=200,tcp,nw_dst=<VIP>,tp_dst=80 \
|
||||
actions=multipath(symmetric_l4,<pool_id>,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 <m> | enable <m>`; `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-портами, новые команды; таблица пайплайна перенумерована.
|
||||
@@ -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.
|
||||
+168
@@ -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
|
||||
}
|
||||
+505
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
Executable
+34
@@ -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
|
||||
Executable
+136
@@ -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"
|
||||
Executable
+66
@@ -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" <<EOF
|
||||
{
|
||||
"bridge": "$BR",
|
||||
"of_version": "$OF",
|
||||
"pool_id": $POOL_ID,
|
||||
"slots": $SLOTS,
|
||||
"slot_table": 11,
|
||||
"dnat_table": 12,
|
||||
"probe": "$HC_PROBE",
|
||||
"http_path": "$HC_HTTP_PATH",
|
||||
"interval": "$HC_INTERVAL",
|
||||
"timeout": "$HC_TIMEOUT",
|
||||
"rise": $HC_RISE,
|
||||
"fall": $HC_FALL,
|
||||
"api": "$HC_API",
|
||||
"apply_script": "$LB_DIR/apply.sh",
|
||||
"members": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "be1",
|
||||
"address": "$BE1_IP",
|
||||
"port": $BE1_PORT,
|
||||
"weight": 1,
|
||||
"source": "$HC2_IP"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "be2",
|
||||
"address": "$BE2_IP",
|
||||
"port": $BE2_PORT,
|
||||
"weight": 1,
|
||||
"source": "$HC3_IP"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "be3",
|
||||
"address": "$BE3_IP",
|
||||
"port": $BE3_PORT,
|
||||
"weight": 1,
|
||||
"source": "$HC2_IP"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "be4",
|
||||
"address": "$BE4_IP",
|
||||
"port": $BE4_PORT,
|
||||
"weight": 1,
|
||||
"source": "$HC3_IP"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "$OUT"
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# Инструмент осмотра датапаса. Запускается внутри контейнера lb-router:
|
||||
# docker compose exec lb-router lbctl <команда>
|
||||
set -euo pipefail
|
||||
|
||||
LB_DIR=/opt/lb
|
||||
# shellcheck disable=SC1091
|
||||
source "$LB_DIR/topology.env"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
lbctl — осмотр датапаса стенда hpnn_v2
|
||||
|
||||
ports порты моста и их ofport
|
||||
flows [табл] правила (все или конкретной таблицы), со счётчиками
|
||||
health состояние членов пула по данным health-проб
|
||||
slots раскладка таблицы слотов и счётчики пакетов
|
||||
drain <член> вывести член из балансировки (пробы продолжаются)
|
||||
enable <член> вернуть член в балансировку
|
||||
metrics метрики в формате Prometheus
|
||||
conns таблица соединений ct (зона $CT_ZONE)
|
||||
trace <src_ip> [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
|
||||
Executable
+202
@@ -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 <ip> <ip_hex> <mac> <mac_hex>
|
||||
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 <ip> <ip_hex> <mac_hex>
|
||||
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 <<EOF
|
||||
# =============================================================================
|
||||
# Таблица 0 — классификация
|
||||
# =============================================================================
|
||||
# ARP обрабатывает собственный респондер (таблица 5): ядро в форвардинге не
|
||||
# участвует, поэтому штатного ARP-стека у узла нет.
|
||||
table=0,priority=200,arp actions=goto_table:5
|
||||
|
||||
# ICMP echo-request на собственные адреса узла и на VIP — в респондер.
|
||||
table=0,priority=190,icmp,nw_dst=$LB_PUB_IP,icmp_type=8 actions=goto_table:6
|
||||
table=0,priority=190,icmp,nw_dst=$VIP,icmp_type=8 actions=goto_table:6
|
||||
table=0,priority=190,icmp,nw_dst=$LB_P2_IP,icmp_type=8 actions=goto_table:6
|
||||
table=0,priority=190,icmp,nw_dst=$LB_P3_IP,icmp_type=8 actions=goto_table:6
|
||||
|
||||
# Вход из публичного сегмента: запоминаем MAC отправителя как adjacency для
|
||||
# обратного пути (замена ARP-резолвера на клиентской стороне), затем к
|
||||
# листенерам. Записи живут 300 с и обновляются каждым пакетом клиента.
|
||||
#
|
||||
# Обучение включено только для трафика, адресованного стенду (VIP, адрес узла,
|
||||
# приватные сегменты). Иначе в таблицу 21 попадал бы весь широковещательный
|
||||
# шум LAN — macvlan-порт видит DHCP, mDNS и SSDP всех соседей по сегменту.
|
||||
table=0,priority=110,in_port=$PUB_OFPORT,ip,nw_dst=$VIP actions=$LEARN,goto_table:10
|
||||
table=0,priority=110,in_port=$PUB_OFPORT,ip,nw_dst=$LB_PUB_IP actions=$LEARN,goto_table:10
|
||||
table=0,priority=110,in_port=$PUB_OFPORT,ip,nw_dst=$P2_NET actions=$LEARN,goto_table:10
|
||||
table=0,priority=110,in_port=$PUB_OFPORT,ip,nw_dst=$P3_NET actions=$LEARN,goto_table:10
|
||||
table=0,priority=100,in_port=$PUB_OFPORT,ip actions=drop
|
||||
|
||||
# Вход из приватных сегментов: обратный путь балансируемых сессий и транзит.
|
||||
table=0,priority=100,in_port=$P2_OFPORT,ip actions=goto_table:15
|
||||
table=0,priority=100,in_port=$P3_OFPORT,ip actions=goto_table:15
|
||||
|
||||
# Health-пробы, порождённые самим узлом: сразу в маршрутизацию, без ct и без
|
||||
# обучения — это трафик из стека узла, а не клиентская сессия.
|
||||
table=0,priority=100,in_port=$HC2_OFPORT,ip actions=goto_table:20
|
||||
table=0,priority=100,in_port=$HC3_OFPORT,ip actions=goto_table:20
|
||||
|
||||
# Всё остальное (не-IP, не-ARP: broadcast, IPv6, DHCP) в прототипе не нужно.
|
||||
table=0,priority=0 actions=drop
|
||||
|
||||
# =============================================================================
|
||||
# Таблица 5 — ARP-респондер
|
||||
# =============================================================================
|
||||
$(arp_responder "$LB_PUB_IP" "$LB_PUB_IP_H" "$PUB_MAC" "$PUB_MAC_H")
|
||||
$(arp_responder "$VIP" "$VIP_H" "$PUB_MAC" "$PUB_MAC_H")
|
||||
$(arp_responder "$LB_P2_IP" "$LB_P2_IP_H" "$P2_MAC" "$P2_MAC_H")
|
||||
$(arp_responder "$LB_P3_IP" "$LB_P3_IP_H" "$P3_MAC" "$P3_MAC_H")
|
||||
# Адреса источника health-проб: бэкенд резолвит их, отвечая на пробу.
|
||||
$(arp_responder "$HC2_IP" "$HC2_IP_H" "$HC2_MAC" "$HC2_MAC_H")
|
||||
$(arp_responder "$HC3_IP" "$HC3_IP_H" "$HC3_MAC" "$HC3_MAC_H")
|
||||
table=5,priority=0 actions=drop
|
||||
|
||||
# =============================================================================
|
||||
# Таблица 6 — ICMP echo-респондер
|
||||
# =============================================================================
|
||||
$(icmp_responder "$LB_PUB_IP" "$LB_PUB_IP_H" "$PUB_MAC_H")
|
||||
$(icmp_responder "$VIP" "$VIP_H" "$PUB_MAC_H")
|
||||
$(icmp_responder "$LB_P2_IP" "$LB_P2_IP_H" "$P2_MAC_H")
|
||||
$(icmp_responder "$LB_P3_IP" "$LB_P3_IP_H" "$P3_MAC_H")
|
||||
table=6,priority=0 actions=drop
|
||||
|
||||
# =============================================================================
|
||||
# Таблица 10 — листенеры
|
||||
# =============================================================================
|
||||
# Единственный листенер прототипа: TCP $VIP:$VIP_PORT.
|
||||
# multipath раскладывает сессию в один из $SLOTS слотов по симметричному
|
||||
# L4-хэшу; basis = POOL_ID, поэтому разные пулы дают независимые раскладки.
|
||||
# Хэш симметричен, то есть прямое и обратное направление дают один слот —
|
||||
# свойство, необходимое для будущего stateless-датапаса.
|
||||
table=10,priority=200,tcp,nw_dst=$VIP,tp_dst=$VIP_PORT actions=multipath(symmetric_l4,$POOL_ID,modulo_n,$SLOTS,0,NXM_NX_REG1[]),goto_table:11
|
||||
|
||||
# Трафик на VIP, для которого листенера нет, — отбрасываем со счётчиком.
|
||||
table=10,priority=150,ip,nw_dst=$VIP actions=drop
|
||||
|
||||
# Прочий IP из публичного сегмента маршрутизируется обычным образом
|
||||
# (клиент может обратиться напрямую к бэкенду, минуя балансировку).
|
||||
table=10,priority=100,ip actions=goto_table:20
|
||||
table=10,priority=0 actions=drop
|
||||
|
||||
# =============================================================================
|
||||
# Таблица 11 — таблица слотов
|
||||
# =============================================================================
|
||||
# Содержимое ($SLOTS правил вида reg1=<слот> -> 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
|
||||
@@ -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
|
||||
Executable
+61
@@ -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 "готово"
|
||||
Executable
+245
@@ -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 ]
|
||||
Reference in new issue
Block a user