Files
poc-frr-anycast/README.md
T
ayurishchevandClaude Sonnet 5 72367682b5 Harden frr_anycast against unattended-upgrade-triggered route flush
Prod-инцидент: apt-daily-upgrade обновил libc6 -> postinst вызвал
рестарт зависимых юнитов -> перезапустился systemd-networkd -> он
вычистил из ядра BGP-маршруты zebra (KeepConfiguration не был задан,
networkd по умолчанию удаляет "чужие" маршруты при старте). Zebra не
знала о вычистке, трафик встал до ручного restart frr. Повторилось
10 сентября с обновлением systemd, но было замаскировано плейбуком.

Роль frr_anycast теперь защищает от этого класса сбоя безусловно:
- отключены apt-daily/apt-daily-upgrade (20auto-upgrades + маскирование
  таймеров и сервисов) - обновления пакетов только вручную;
- systemd-networkd получает drop-in KeepConfiguration=yes - устраняет
  сам механизм вычистки маршрутов при любом будущем рестарте networkd,
  не только от конкретного пакета-триггера;
- новый handler Restart systemd-networkd между Apply netplan и Restart
  frr (networkd должен переустановить владение интерфейсами до того,
  как zebra переустановит маршруты).

Шаблоны FRR/nftables не затронуты: render-check.sh 6/6,
render-check-cloud.sh 84/84, без изменений.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bc3HkMiDwooSyLBLoMKNyF
2026-09-11 13:09:17 +03:00

19 KiB
Raw Blame History

poc_frr_anycast

POC anycast-топологии на FRR: две площадки, eBGP full-mesh, ECMP-балансировка трафика клиентов площадки B к anycast-адресу площадки A.

  • Дизайн (HLD): hld.md (в т.ч. Mermaid-диаграмма топологии)
  • История изменений: 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-summary.md.

Запуск

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 <node> vtysh -c "<command>" или интерактивно: docker compose exec <node> 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 записи * <ip>, 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 <ip> advertised-routes a1/a2/a3 (<ip> — сосед в сторону B) среди анонсируемых — 10.200.200.1/32
show ip bgp neighbor <ip> advertised-routes b1, b2 (<ip> — сосед в сторону A) среди анонсируемых — 10.100.1.0/24 (b1) / 10.100.2.0/24 (b2)
show interface brief любой все интерфейсы up, IP соответствуют hld.md

Пример:

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 <client> <command>, где <client> — один из 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, а не всегда уходит в один и тот же узел

Пример:

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.

Outbound-фильтр обязателен у всех соседей. Каждому соседу назначен outbound route-map (RM-OUT или, если prepend: true, RM-OUT-PREPEND), матчащий ip prefix-list PL-ANYCAST-OUT (permit только anycast_prefix) с явным deny в конце — наружу уходит исключительно анонсируемый anycast-префикс, что бы ни попало в локальную BGP-таблицу от других соседей. Это фикс реального прод-инцидента: без фильтра FRR реэкспортировал случайно полученный 10.0.0.0/8 обратно в сторону других eBGP-соседей (см. docs/2026-09-10-bgp-outbound-filter-summary.md).

Детали: docs/2026-09-03-ansible-site-a-plan.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-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-инварианты)

Запуск

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) не выполнялась и не входит в текущую поставку. Доступна и выполнена офлайн-проверка:

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 <node> vtysh ... на ssh <node> 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-адрес транслируется для выхода в интернет.
  • DNAT (nftables, prerouting) — на всех 4 узлах трафик на anycast-адрес (ANYCAST_PREFIX) сначала перенаправляется на DNAT_TARGET_IP (реальный backend, напр. 95.163.53.117), и только потом, в postrouting, применяется sNAT-masquerade. Включается автоматически, если в env задан DNAT_TARGET_IP (не нужен отдельный булев флаг); имеет смысл только вместе с enable_snat: true, иначе nftables.conf не разворачивается — при нарушении playbook останавливается на pre-flight assert.

Все значения — в ansible/site-cloud.env (ASN, anycast-префикс, IP всех 4 пиров, имена интерфейсов, per-node router-id). Т.к. пиры общие для всех 4 узлов, список bgp_neighbors задан один раз в group_vars/site_cloud.yml, а не per-node.

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:

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, outbound route-map навешан на всех — RM-OUT-PREPEND (фильтр + set as-path prepend 65200 65200 65200) на 2×65103, RM-OUT (только фильтр) на 2×65104, nftables.conf содержит DNAT (dnat to 95.163.53.117) и masquerade на заданном внешнем интерфейсе, а также (если nft доступен на control node) валидирует синтаксис каждого nftables.conf через nft -c -f.

Детали и разбор диаграммы: docs/2026-09-09-ansible-site-cloud-plan.md / docs/2026-09-09-ansible-site-cloud-summary.md.

Защита от auto-update-триггеров и networkd route-flush

Прод-инцидент: apt-daily-upgrade обновил libc6 → postinst вызвал рестарт зависимых юнитов → перезапустился systemd-networkd → он вычистил из ядра BGP-маршруты zebra (не «свои» для него) → трафик встал до ручного systemctl restart frr. Роль frr_anycast теперь защищает от этого класса сбоя безусловно, на всех узлах обеих сред:

  • Обновления только по требованию администратора: /etc/apt/apt.conf.d/20auto-upgrades отключает периодические apt update/unattended-upgrade, дополнительно замаскированы apt-daily.timer/apt-daily-upgrade.timer и их .service. apt upgrade по-прежнему доступен вручную.
  • systemd-networkd не вычищает чужие маршруты: drop-in /etc/systemd/networkd.conf.d/99-keep-configuration.conf (KeepConfiguration=yes) — защита не от конкретного пакета-триггера, а от самого механизма, на случай любого будущего рестарта networkd.

Не влияет на рендер frr.conf/daemons/nftables.conf — render-check.sh/render-check-cloud.sh проходят без изменений. Детали: docs/2026-09-11-system-hardening-plan.md / docs/2026-09-11-system-hardening-summary.md.