MVP Single Node with Manual Config in Docker

This commit is contained in:
ayurishchev committed 2026-08-16 21:43:02 +03:00
commit 8212699f23
27 files changed
+3818

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
# Твоя роль
- DevOps инженер
- Разработчик Backend
- Архитектор информационных систем
# Стиль общения
- профессиональный, но без жаргона
# Стиль ответов
- максимально емкие и содержательные
- не проваливайся в лишние детали, если это явно не было запрошено
# Создание артефактов
- На каждое новое изменение должен быть артефакт в .md файле
- Каждое новое изменение должно начинаться с плана внедрения в отдельном файле
- Каждое новое изменение должно заканчиваться суммаризацией по выполненым доработкам в отдельном файле
- каждое изменение дополняет или обновляет README.md
+88
View File
@@ -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
+257
View File
@@ -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 проверки стенда
```
+19
View File
@@ -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"]
+21
View File
@@ -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 "$@"
+3
View File
@@ -0,0 +1,3 @@
module hpnn/backend
go 1.22
+105
View File
@@ -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)
}
}
+140
View File
@@ -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
+77
View File
@@ -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` под новый состав пула.
+81
View File
@@ -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 на следующем шаге.
+878
View File
@@ -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
```
+126
View File
@@ -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.
+103
View File
@@ -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.
+179
View File
@@ -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-портами, новые команды; таблица пайплайна перенумерована.
+114
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
module hpnn/hc
go 1.22
+168
View File
@@ -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
View File
@@ -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
}
}
}
}
}
+33
View File
@@ -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
View File
@@ -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
+136
View File
@@ -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"
+66
View File
@@ -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
View File
@@ -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
View File
@@ -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
+70
View File
@@ -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
+61
View File
@@ -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 "готово"
+245
View File
@@ -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 ]