← Заметки

pathosd: чего стоило анонсировать адрес, только пока сервис жив

· bgp, bird, frr, anycast, go, openwrt

Некоторые сервисы в моей сети работают в нескольких местах сразу — DNS-резолвер на каждом хабе, S3-шлюз больше чем на одной ноде хранилища. Клиенты должны видеть один стабильный адрес, а маршрутизация — отправлять их на ближайший экземпляр, который действительно работает. В маршрутизируемой сети это anycast: каждый экземпляр анонсирует один и тот же адрес по BGP. Загвоздка в слове работает — маршрут понятия не имеет, жив ли сервис за ним.

pathosd закрывает этот разрыв. Это проверка здоровья и источник BGP-маршрутов в одном процессе на Go: он проверяет сервис локально (HTTP, DNS, ping, TCP, UDP, gRPC) и анонсирует адрес, только пока проверка проходит, — или оставляет его с пониженным приоритетом, чтобы предпочитались другие. keepalived/VRRP решает похожую задачу, но в одном L2-сегменте с общим MAC, а не поперёк маршрутизируемой mesh-сети из туннелей.

Что он умеет, рассказано в README. Эта заметка — о том, как он к этому пришёл, и в основном о том, что не заработало с первого раза.

Адреса, которые никуда не уходили

Март 2026. У хабов появились общие anycast-адреса — первым был DNS-резолвер. Каждый хаб вешал их на dummy-интерфейс, а FRR анонсировал в mesh-сеть. Просто — и нормально, пока сервис на одном из хабов не останавливался: маршрут оставался, хаб продолжал притягивать трафик, а трафик уходил в никуда.

Решение очевидно на бумаге — анонсировать, только пока сервис здоров, — и мне не хотелось делить его между демоном маршрутизации и скриптом проверки, которые могут разойтись во мнениях. Поэтому pathosd встраивает GoBGP и делает и то, и другое. Если он умирает, сессии падают и маршруты уходят вместе с ними; адреса стартуют отозванными и появляются только после нескольких удачных проверок подряд.

FRR на той же коробке

Апрель. Естественная схема — pathosd говорит с FRR на том же роутере. Она так и не устоялась: сначала сессия через loopback-адреса, потом через отдельные /32 на dummy-интерфейсе — и к концу того же дня я от неё отказался.

Тогда я перестал с этим бороться: pathosd на каждом хабе пирился с FRR на других хабах, а те принимали от него только префиксы VIP. Это было стабильно. А адрес DNS, который действительно важен, на время вернулся к обычному статическому анонсу. Одну мелочь из того периода я оставил: у pathosd на каждом хабе свой номер AS, так что по AS-пути сразу видно, с какого хаба пришёл анонс.

Один день с BIRD

11 августа. Когда mesh-сеть переехала с FRR на BIRD, я захотел вернуть локальную сессию — VIP должен анонсировать тот хаб, который его реально обслуживает. На это ушёл почти весь день:

  1. Обе стороны ждут друг друга. pathosd звонил BIRD на 179, а BIRD звонил pathosd на его собственный порт; никто не слушал там, куда звонил другой, и сессия висела в Active.
  2. GoBGP отказался звонить сам себе. Когда pathosd был активной стороной и на обоих концах один и тот же loopback-адрес, срабатывала защита GoBGP от подключения к самому себе. pathosd стал пассивной стороной, BIRD звонит ему.
  3. «Invalid next hop». pathosd анонсировал VIP с next hop 127.0.0.1. Теперь он анонсирует LAN-адрес роутера.
  4. «Invalid remote address». Для прямого BGP-соседа BIRD ищет его в подключённой сети, а loopback ею не является. multihop пропускает эту проверку; в итоге сессия переехала на второй адрес на LAN-бридже.
  5. «Unreachable». Даже с настоящим next hop BIRD не станет маршрутизировать на собственный адрес и помечает маршрут недостижимым. Я пробовал просто экспортировать VIP как connected-маршрут — откатил, вернул обратно, — но так теряется весь смысл: ни контроля по здоровью, ни возможности уступить.

Прижилось решение в несколько строк фильтра импорта BIRD:

import filter {
    if net ~ [ 192.0.2.53/32 ] then {   # только наши VIP
        bgp_next_hop = net.ip;          # next hop = сам VIP
        preference = 250;               # выше connected-маршрута (240)
        accept;
    }
    reject;
};

Локально BIRD по-прежнему считает маршрут недостижимым, но с preference выше connected-маршрута выбирает его и экспортирует соседям по mesh — а больше от него ничего и не нужно.

После этого адрес DNS вернулся под pathosd и теперь уступает, а не исчезает: когда локальная проверка не проходит, маршрут остаётся с удлинённым AS-путём, так что mesh предпочитает другой хаб, но этот у неё остаётся, если остальные ещё хуже. Удлинение проходит через BIRD без изменений — ни iBGP, ни community не нужны.

На той же неделе pathosd научился HTTP-проверкам через forward proxy, чтобы общий адрес прокси анонсировался, только пока прокси реально проксирует. А чтобы видеть, что происходит внутри, я включил встроенный API GoBGP для CLI gobgp — на localhost по TCP, потому что CLI не умеет в unix-сокет.

Нода хранилища, отвечавшая не оттуда

Конец апреля и снова октябрь. S3-шлюз работает на обычных нодах хранилища под Debian, поэтому у pathosd появился Debian-пакет, а ноды анонсируют адрес шлюза роутеру хоумлаба с проверкой по TLS.

Сюрприз пришёл позже. Клиент в том же сегменте LAN, что и нода, отправлял запрос на адрес шлюза через роутер, как и положено, — а нода отвечала прямо по сегменту, с адреса шлюза. Клиент видел ответ от адреса, с которым на этом линке не разговаривал, и выбрасывал его. Лечится это на ноде, а не в pathosd: ответы с адреса шлюза уходят через роутер по policy routing.

И одна уборка: pathosd только анонсирует, маршруты ему не нужны, поэтому роутер теперь ничего не экспортирует таким пирам, которые только объявляют.