# poc_frr_anycast POC anycast-топологии на FRR: две площадки, eBGP full-mesh, ECMP-балансировка трафика клиентов площадки B к anycast-адресу площадки A. - **Дизайн (HLD):** [hld.md](hld.md) (в т.ч. Mermaid-диаграмма топологии) - **История изменений:** [docs/](docs/) — на каждое изменение отдельный план внедрения и summary. ## Lab-окружение (Docker Compose) Топология из `hld.md` поднимается локально в Docker Compose: 5 FRR-роутеров (a1/a2/a3 — площадка A, b1/b2 — площадка B) + 6 клиентских контейнеров площадки B. Каждый линк HLD — отдельная Docker-сеть с фиксированной адресацией, 1:1 соответствующая схеме из `hld.md`. Детали архитектуры и обоснование решений: [docs/2026-09-03-anycast-lab-plan.md](docs/2026-09-03-anycast-lab-plan.md), результаты — [docs/2026-09-03-anycast-lab-summary.md](docs/2026-09-03-anycast-lab-summary.md). ### Запуск ```bash docker compose up -d # поднять топологию (13 контейнеров) ./scripts/verify.sh # проверить BGP-сессии и ECMP-маршрут до 10.200.200.1/32 ./scripts/traffic-test.sh [client] [count] # прогнать трафик к anycast, увидеть распределение по A1/A2/A3 docker compose down # разобрать окружение ``` Пример: `./scripts/traffic-test.sh c1 30` — 30 запросов с клиента `c1` (площадка B1) к anycast-адресу с варьированием src-port; ответ каждого anycast-узла (`a1`/`a2`/`a3`) содержит его hostname, что показывает распределение трафика. ### Структура ``` hld.md HLD-дизайн топологии docker-compose.yml 13 контейнеров, 8 сетей (1 на линк) frr/{a1,a2,a3,b1,b2}/ конфиги FRR (daemons, frr.conf, vtysh.conf) entrypoints/ создание dummy0 + anycast-responder (A), маршрут по умолчанию (клиенты) scripts/ verify.sh, traffic-test.sh docs/ план внедрения + summary по каждому изменению ``` ### Проверка окружения Всё это уже автоматизировано в `./scripts/verify.sh` (BGP + маршруты) и `./scripts/traffic-test.sh` (трафик), но ниже — те же проверки вручную, командами на самих узлах. #### На маршрутизаторах (`vtysh`) Выполняются как `docker compose exec -T vtysh -c ""` или интерактивно: `docker compose exec vtysh`. | Команда | Где | Что должно быть в норме | |---|---|---| | `show bgp summary` | все 5 узлов | все соседи в состоянии `Established` (число вместо `Idle`/`Active` в колонке `State/PfxRcd`) — 2 соседа на a1/a2/a3, 3 на b1/b2 | | `show ip route 10.200.200.1/32` | b1, b2 | 3 записи `* , via ethN, weight 1` — ECMP-маршрут до anycast через a1/a2/a3 | | `show bgp ipv4 unicast 10.200.200.1/32` | b1, b2 | тот же префикс в BGP-таблице, 3 валидных пути, `Local AS 65001` у всех | | `show ip bgp neighbor advertised-routes` | a1/a2/a3 (`` — сосед в сторону B) | среди анонсируемых — `10.200.200.1/32` | | `show ip bgp neighbor advertised-routes` | b1, b2 (`` — сосед в сторону A) | среди анонсируемых — `10.100.1.0/24` (b1) / `10.100.2.0/24` (b2) | | `show interface brief` | любой | все интерфейсы `up`, IP соответствуют `hld.md` | Пример: ```bash docker compose exec -T b1 vtysh -c "show bgp summary" docker compose exec -T b1 vtysh -c "show ip route 10.200.200.1/32" docker compose exec -T a1 vtysh -c "show ip bgp neighbor 10.0.1.2 advertised-routes" ``` #### На клиентах (площадка B, доверенная сеть) Выполняются как `docker compose exec -T `, где `` — один из `c1`…`c6`. | Команда | Что должно быть в норме | |---|---| | `ping -c 4 10.200.200.1` | `0% packet loss`, ответы приходят с `ttl=63` (1 транзитный хоп — B-роутер) | | `traceroute -n 10.200.200.1` | 2 хопа: локальный B-роутер (`10.100.1.1` / `10.100.2.1`), затем сразу `10.200.200.1` | | `curl -s http://10.200.200.1/` | в ответе — hostname одного из `a1`/`a2`/`a3` (мини-HTTP на dummy0, см. `entrypoints/a-entrypoint.sh`) | | серия `curl` с разным `--local-port` (см. `scripts/traffic-test.sh`) | ответы приходят от **разных** anycast-узлов — подтверждает, что ECMP реально распределяет трафик по 5-tuple, а не всегда уходит в один и тот же узел | Пример: ```bash docker compose exec -T c1 ping -c 4 10.200.200.1 docker compose exec -T c1 traceroute -n 10.200.200.1 docker compose exec -T c1 curl -s http://10.200.200.1/ ./scripts/traffic-test.sh c1 30 # готовый прогон с разбивкой по узлам ``` Если `curl`/`ping` не проходят — сначала проверить BGP (`show bgp summary` на затронутых узлах), затем ECMP-маршрут (`show ip route 10.200.200.1/32` на b1/b2). ### Важные технические нюансы - Линки между A и B заведены как `/29` (не `/30`, как в hld.md буквально) — Docker резервирует первый адрес подсети под gateway моста даже для `internal: true`-сетей, поэтому реальным peering-адресам (`.1`/`.2`, как в hld.md) нужно место рядом с ним. IP-адресация роутеров и логика BGP при этом полностью соответствуют hld.md. - ECMP-балансировка на b1/b2 требует `net.ipv4.fib_multipath_hash_policy=1` (в `docker-compose.yml`) — по умолчанию (`0`) Linux хеширует next-hop только по src/dst IP, без учёта портов, из-за чего один клиент всегда попадал бы в один и тот же anycast-узел. ## Развёртывание площадки A на ВМ (Ansible) `ansible/` — playbook для конфигурации FRR на реальных ВМ площадки A (a1/a2/a3), продолжение того же HLD, но не в Docker, а «в проде». Provisioning ВМ (создание, 3 сетевых интерфейса: 1 внешний/управляющий + 2 приватных к B1/B2) выполняется отдельным Terraform-сценарием (вне рамок этого репозитория) — приватные интерфейсы уже сконфигурированы к моменту запуска playbook; Ansible создаёт `dummy0` (anycast) и разворачивает FRR. Все специфичные для площадки A значения (ASN, anycast-префикс, router-id и BGP-соседи каждой ноды, имена интерфейсов, SSH-доступ) вынесены в единый файл переменных окружения `ansible/site-a.env` — сама роль `frr_anycast` полностью параметризована и не содержит захардкоженных адресов/ASN. `frr.conf.j2` при рендере воспроизводит уже проверенную в Docker-лабе BGP-конфигурацию (`frr/a{1,2,3}/frr.conf`), за вычетом блока `interface dummy0` — в VM-варианте этот адрес назначается через netplan (`dummy0.netplan.yml.j2`), а не через frr.conf. Роль `frr_anycast` обобщена: список BGP-соседей узла — не 2 жёстко именованных (`neighbor_b1`/`neighbor_b2`), а `bgp_neighbors` произвольной длины, каждый с флагом `prepend` (as-path prepend на исходящих анонсах). Опционально (`enable_snat: true`) роль также поднимает sNAT через `nftables` (masquerade на внешнем интерфейсе) — оба узла ниже используют эту одну и ту же роль с разными `group_vars`. Детали: [docs/2026-09-03-ansible-site-a-plan.md](docs/2026-09-03-ansible-site-a-plan.md) / [docs/2026-09-03-ansible-site-a-summary.md](docs/2026-09-03-ansible-site-a-summary.md) — исходная реализация; [docs/2026-09-09-ansible-site-cloud-plan.md](docs/2026-09-09-ansible-site-cloud-plan.md) / [docs/2026-09-09-ansible-site-cloud-summary.md](docs/2026-09-09-ansible-site-cloud-summary.md) — доработка под второе окружение (см. ниже). ### Структура ``` ansible/ ├── site-a.env / site-cloud.env источники site-специфичных значений (2 окружения) ├── run.sh общий для всех окружений (SITE_ENV_FILE + имя playbook'а) ├── inventory/{site-a,site-cloud}.yml ansible_host из lookup('env', ...) ├── group_vars/{site_a,site_cloud}.yml site-wide переменные (ASN, anycast, интерфейсы, bgp_neighbors) ├── host_vars/{a1,a2,a3,vr01..vr04}.yml per-node переменные (router-id, [bgp_neighbors]) ├── site-a.yml / site-cloud.yml playbook'и (role: frr_anycast — общая для обоих) ├── roles/frr_anycast/ задачи, handlers, шаблоны (daemons.j2, frr.conf.j2, │ dummy0.netplan.yml.j2, nftables.conf.j2) └── tests/ render-check.sh (площадка A, golden-diff), render-check-cloud.sh (cloud, grep-инварианты) ``` ### Запуск ```bash cd ansible ./run.sh site-a.yml # применить конфигурацию к a1/a2/a3 (env по умолчанию: site-a.env) ./run.sh site-a.yml --check --diff # сухой прогон ``` Если `ansible-playbook` не установлен в системе — поставить локально в venv, не трогая системный Python: `python3 -m venv venv && venv/bin/pip install ansible` (каталог `venv/` в `.gitignore`, не коммитится); `run.sh` сам подхватит `venv/bin/ansible-playbook`, если он есть. `run.sh` универсален для всех окружений в этом репозитории: инвентарь берётся по имени playbook'а (`X.yml` → `inventory/X.yml`), env-файл — из `SITE_ENV_FILE` (по умолчанию `site-a.env`). ### Проверка без живых ВМ На момент подготовки playbook ВМ площадки A ещё не подняты (Terraform-провижининг — отдельный шаг), поэтому функциональная проверка (`vtysh`, реальный apply) не выполнялась и не входит в текущую поставку. Доступна и выполнена офлайн-проверка: ```bash ansible/run.sh site-a.yml --syntax-check # синтаксис ansible/tests/render-check.sh # golden-diff: рендер frr.conf/daemons для a1/a2/a3 # сравнивается с уже работавшими в Docker-лабе frr/a{1,2,3}/ ``` После того как Terraform поднимет ВМ — реальный прогон `./run.sh site-a.yml`, затем те же `vtysh`-команды, что и в разделе «[Проверка окружения](#проверка-окружения)» выше (заменить `docker compose exec -T vtysh ...` на `ssh vtysh ...` или `ansible site_a -m command -a "vtysh -c 'show bgp summary'"`). ## Второе окружение: «Облачная площадка» (anycast + sNAT, 2 канала) Второе, независимое от площадки A окружение (та же роль `frr_anycast`, другие `group_vars`/`host_vars`): 4 узла `vr01`…`vr04`, один AS `65200`, общий anycast-адрес `172.31.255.10/32`. У каждого узла — 2 приватных интерфейса-сегмента (не point-to-point, а общий L2 с обоими пирами стороны): - **Основной канал** — сегмент к «Основная площадка» (внешняя сторона, ASN `65104`, пиры M1/M2) — анонсы **без** as-path prepend. - **Резервный канал** — сегмент к «Резервная площадка» (внешняя сторона, ASN `65103`, пиры B1/B2) — анонсы **с** as-path prepend (на всех 4 узлах, `AS_PATH_PREPEND_COUNT` из env, по умолчанию 3), чтобы при исправном основном канале трафик шёл через него. - **sNAT** (nftables, masquerade) на внешнем интерфейсе — на всех 4 узлах: трафик на anycast-адрес транслируется для выхода в интернет. Все значения — в `ansible/site-cloud.env` (ASN, anycast-префикс, IP всех 4 пиров, имена интерфейсов, per-node router-id). Т.к. пиры общие для всех 4 узлов, список `bgp_neighbors` задан один раз в `group_vars/site_cloud.yml`, а не per-node. ```bash cd ansible SITE_ENV_FILE=site-cloud.env ./run.sh site-cloud.yml # применить SITE_ENV_FILE=site-cloud.env ./run.sh site-cloud.yml --check --diff # сухой прогон ``` ### Проверка без живых ВМ ВМ этого окружения тоже не подняты — golden-файла для сравнения тоже нет (окружение новое), поэтому проверка через grep-инварианты вместо byte-diff: ```bash SITE_ENV_FILE=site-cloud.env ansible/run.sh site-cloud.yml --syntax-check ansible/tests/render-check.sh # регресс: площадка A рендерится как прежде ansible/tests/render-check-cloud.sh # 4 соседа, prepend только на резервном канале, sNAT ``` `render-check-cloud.sh` проверяет: у каждого из 4 узлов ровно 4 `neighbor ... remote-as` (2×65104 без route-map, 2×65103 с `route-map RM-PREPEND-OUT out`), `route-map` содержит `set as-path prepend 65200 65200 65200`, `nftables.conf` содержит `masquerade` на заданном внешнем интерфейсе. Детали и разбор диаграммы: [docs/2026-09-09-ansible-site-cloud-plan.md](docs/2026-09-09-ansible-site-cloud-plan.md) / [docs/2026-09-09-ansible-site-cloud-summary.md](docs/2026-09-09-ansible-site-cloud-summary.md).