Почти все статьи про EVPN-VXLAN заканчиваются одинаково: автор показывает вывод show evpn database, в нём видны MAC-адреса соседнего листа, и дальше идёт фраза вроде «фабрика работает». Я собрал такую фабрику на свободных образах Junos, получил ровно этот вывод — и не смог передать между хостами ни одного пакета.

Разбор занял вечер и оказался полезнее, чем сама сборка. Ниже — как поднять фабрику дома на обычном десктопе под Windows, почему show evpn database ничего не доказывает, и как мерить отказы так, чтобы числам можно было верить. Всё воспроизводится: конфиги и скрипты в репозитории, ссылка в конце.

Что понадобится и чего не понадобится

Juniper раздаёт виртуальные образы Junos бесплатно — без лицензии и без ограничения по времени, условие одно: непродакшен и низкий трафик. Образов несколько, и выбрать правильный оказалось первым нетривиальным шагом.

Очевидный кандидат — vJunos-switch, виртуальный EX. Он не подошёл, и причина не в ресурсах. В требованиях прямым текстом:

vJunos-switch is not supported on EVE-NG or any other deployments that launch vJunos from within a VM due to the constraints of deeply nested virtualization.

Архитектура у него унаследована от vMX: внутри виртуалки уже вложены VCP и VFP. Третьего слоя вложенности он не тянет. А у меня хост — Windows, то есть containerlab живёт в WSL2, а WSL2 — это виртуальная машина. Мимо. Там же, в требованиях, указан только Intel VT-x, а у меня Ryzen.

Подошёл cJunosEvolved — контейнерный Junos OS Evolved, эмулирующий транспортные PTX: PTX10001-36MR на чипсете Express 4 (флавор BT) или PTX10002-36QDD на Express 5 (BX). В его требованиях написано ровно то, чего не хватало:

any x86 processor (Intel or AMD) with VT-x capability

и отдельно — что containerlab является поддерживаемым способом развёртывания. Цена вопроса: 8 ГБ памяти и 4 ядра на узел против 5 ГБ у vJunos-switch, плюс 20 ГБ диска на контейнер в рантайме с ростом до 40.

Отдельная мелочь, которая экономит полчаса: через поиск по сайту эти продукты не находятся. Juniper сам об этом предупреждает и даёт прямые ссылки. Для cJunosEvolved это support.juniper.net/support/downloads/?p=cjunos-evolved. Файл cJunosEvolved-26.2R1.7-EVO.tar.gz весит 1822 МБ и после docker load разворачивается в образ на 4.55 ГБ.

Железо у меня такое: Ryzen 5 5600X (6 ядер / 12 потоков), 32 ГБ, Windows 11.

Хост под Windows: что проверить до начала

cJunosEvolved — это KVM-виртуалка внутри контейнера, значит внутри WSL2 обязан быть /dev/kvm. Проверяется до всего остального:

ls -l /dev/kvm
kvm-ok
# INFO: /dev/kvm exists
# KVM acceleration can be used

У меня он оказался на месте, и флаг svm прокинут в гостя — то есть вложенная виртуализация на AMD в WSL2 работает. Этого факта я не нашёл ни в одной инструкции: все они написаны под bare-metal Linux на Intel.

Дистрибутив я поставил отдельный и сразу на другой диск, чтобы не забивать системный:

wsl --install -d Ubuntu-24.04 --no-launch --location F:\WSL\clab --name clab

В .wslconfig подняты лимиты — по умолчанию WSL2 берёт половину памяти хоста, на два узла по 8 ГБ этого не хватит:

[wsl2]
memory=26GB
processors=12
nestedVirtualization=true
vmIdleTimeout=-1

Про vmIdleTimeout=-1 скажу отдельно, потому что без него я потерял первый собранный стенд. WSL гасит дистрибутив, когда в нём не остаётся ни одного процесса, и уносит с собой докер вместе со всеми контейнерами. Выглядит это так: containerlab отчитался об успешном развёртывании, вы выходите из шелла, возвращаетесь — а docker ps пуст, и ничего в логах. Лечится этой строкой плюс любым живым процессом внутри.

Докер нужен не ниже 28.0.0, и это не придирка. В документации Juniper отдельное предупреждение: на более старых версиях интерфейсы подключаются к контейнеру не в том порядке, и трафик между узлами просто не идёт. Отказ, разумеется, немой. Я ставил из официального репозитория, приехал 29.9.0.

Сборка фабрики

Топология стенда
Топология стенда

Топология простейшая: два листа спина к спине, по хосту за каждым. Андерлей — eBGP по прямому линку, оверлей — eBGP между loopback’ами с family evpn signaling, поверх MAC-VRF с VNI 10010.

name: evpn2
topology:
  kinds:
    juniper_cjunosevolved:
      image: cjunosevolved:26.2R1.7-EVO
  nodes:
    leaf1:
      kind: juniper_cjunosevolved
      startup-config: leaf1.conf
    leaf2:
      kind: juniper_cjunosevolved
      startup-config: leaf2.conf
    h1: { kind: linux, image: alpine:3.20 }
    h2: { kind: linux, image: alpine:3.20 }
  links:
    - endpoints: ["leaf1:eth4", "leaf2:eth4"]
    - endpoints: ["leaf1:eth5", "h1:eth1"]
    - endpoints: ["leaf2:eth5", "h2:eth1"]

Здесь первая ловушка для тех, кто привык к Junos. В топологии имена линуксовые, в конфиге — джуниперовские. eth0 — менеджмент, eth1–eth3 зарезервированы и трогать их нельзя, а eth4 и дальше соответствуют et-0/0/0 и дальше. То есть линк описывается как leaf1:eth4, а адрес на него вешается на et-0/0/0.

Конфигурация листа, сокращённо:

interfaces {
    et-0/0/0 { unit 0 { family inet { address 10.255.0.0/31; } } }
    et-0/0/1 {
        flexible-vlan-tagging;
        encapsulation extended-vlan-bridge;
        unit 10 { vlan-id 10; }
    }
    lo0 { unit 0 { family inet { address 10.0.0.1/32; } } }
}
routing-instances {
    MACVRF1 {
        instance-type mac-vrf;
        service-type vlan-based;
        protocols { evpn { encapsulation vxlan; } }
        vtep-source-interface lo0.0;
        route-distinguisher 10.0.0.1:10;
        vrf-target target:65000:10;
        vlans {
            v10 {
                vlan-id 10;
                interface et-0/0/1.10;
                vxlan { vni 10010; }
            }
        }
    }
}

Две мелочи, на которых я споткнулся при вводе конфигурации. Первая: интерфейс привязывается не к инстансу, а к VLAN внутри него, иначе получите EVPN: Interface et-0/0/1.10 must be added in a bridge-domain/vlan. Вторая: как только трогаешь стансу system, commit требует root-authentication, которого в образе нет. Предупреждение про отсутствующую лицензию BGP можно игнорировать, Juniper об этом пишет прямо.

Сколько это грузится

Отдельный пункт, потому что ожидания тут ломаются об реальность:

Событие

Время

containerlab deploy вернул управление

6 секунд

Загрузка ВМ внутри контейнера (из лога)

220 секунд

CLI реально ответил

814 секунд

containerlab через шесть секунд рисует красивую таблицу со статусом running — и это правда, контейнер запущен. Но до момента, когда с устройством можно разговаривать, проходит почти четырнадцать минут. В документации containerlab написано «около 5 минут»; у меня втрое больше, скорее всего из-за переподписки CPU. Любой скрипт, который после deploy сразу лезет настраивать, развалится.

И ещё: docker exec <node> cli -c "show version" не работает — отдаёт приглашение и молчит. Потому что внутри контейнера крутится виртуалка, а cli в контейнере лишь обёртка над её консолью. Работает обычный SSH на адрес, который выдал containerlab, с учёткой admin / admin@123. Многострочные конфиги прекрасно заходят туда heredoc’ом.

Фабрика собралась. Трафика нет

Теперь то, ради чего всё это писалось.

Контрол-плейн: обе сессии Establ, удалённый MAC через vtep, база EVPN заполнена
Контрол-плейн: обе сессии Establ, удалённый MAC через vtep, база EVPN заполнена

Всё, что принято показывать как доказательство работоспособности фабрики.

BGP поднялся с обеих сторон:

Peer                AS      InPkt   OutPkt  Last Up/Dwn  State
10.0.0.2         65002          7        6         1:28  Establ
  bgp.evpn.0: 1/1/1/0
10.255.0.1       65002          7        5         1:39  Establ
  inet.0: 1/1/1/0

Маршруты EVPN разошлись в обе стороны — и type-2 с MAC-адресами хостов, и type-3 для BUM-трафика. В базе EVPN на втором листе честно лежит MAC хоста из-за первого, с правильным IP:

Instance: MACVRF1
VLAN  DomainId  MAC address        Active source   IP address
     10010      aa:c1:ab:7e:89:07  10.0.0.1        10.10.10.1

MAC-таблица знает удалённый адрес и указывает на туннель:

   Vlan    MAC                 MAC flags   Logical interface    Active source
   v10     aa:c1:ab:27:09:e8   DR          vtep-54.32770        10.0.0.2
   v10     aa:c1:ab:7e:89:07   D           et-0/0/1.10

Туннельные эндпоинты на месте, VNI 10010 присутствует, loopback’и пингуются за 2 миллисекунды. По всем признакам, которые обычно приводят в статьях как доказательство, фабрика работает.

А ping между хостами даёт сто процентов потерь.

Причём таблица соседей на хосте при этом выглядит здоровой — MAC второго хоста разрешён, запись в состоянии REACHABLE. То есть обманывает не только вывод с коммутаторов: и на самом хосте всё в порядке, просто трафик не ходит. (В другом прогоне того же стенда ARP не разрешался и запись висела в INCOMPLETE — от чего зависит, я не выяснил, и выдавать догадку за объяснение не буду.)

Сто процентов потерь при живом ARP
Сто процентов потерь при живом ARP

И при этом между хостами не проходит ничего.

Разбор

Отказ немой: ни ошибки, ни записи в логе, ни единицы в счётчиках drops. Поэтому единственный способ — идти по пути пакета с tcpdump и смотреть, где он исчезает.

Три точки захвата
Три точки захвата

Шаг первый, отправитель. Слушаем андерлейный интерфейс со стороны первого листа:

13:40:40.119661 IP 10.0.0.1.56839 > 10.0.0.2.4789: VXLAN, flags [I] (0x08), vni 10010
IP 10.10.10.1 > 10.10.10.2: ICMP echo request, id 184, seq 1, length 64
13:40:40.626062 IP 10.0.0.1.56839 > 10.0.0.2.4789: VXLAN, flags [I] (0x08), vni 10010
IP 10.10.10.1 > 10.10.10.2: ICMP echo request, id 184, seq 2, length 64
4 packets captured

Инкапсуляция работает. Эхо-запрос завёрнут в VXLAN с правильным VNI и отправлен правильному соседу.

Шаг второй, получатель. Тот же линк со стороны второго листа:

13:40:40.119663 IP 10.0.0.1.56839 > 10.0.0.2.4789: VXLAN, flags [I] (0x08), vni 10010
IP 10.10.10.1 > 10.10.10.2: ICMP echo request, id 184, seq 1, length 64
4 packets captured

Те же пакеты, с точностью до микросекунд. Сеть между узлами ни при чём.

Шаг третий, хост за получателем. Ноль.

listening on eth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
0 packets captured
0 packets received by filter
0 packets dropped by kernel
Три захвата подряд: уходит, приходит, не доходит
Три захвата подряд: уходит, приходит, не доходит

То есть пакет приходит на оконечную точку туннеля и умирает в ней. Симметрично в обе стороны. Конфигурация при этом валидная, контрол-плейн идеальный, счётчики чистые.

Разгадка нашлась в списке ограничений cJunosEvolved — одной фразой среди прочих:

For cJunosEvolved to function correctly as a VXLAN Tunnel End Point (VTEP), you must configure tunnel termination using the set forwarding-options tunnel termination command. Otherwise, the traffic in the tunnel drops on the egress VTEP.

Лечение — одна строка на каждый узел, применяется на живую:

set forwarding-options tunnel-termination

Сразу после commit пинг идёт: 0% потерь, 1.1 мс.

Починка в одну строку на каждом узле и сразу живой пинг
Починка в одну строку на каждом узле и сразу живой пинг

Да, это особенность конкретного виртуального образа, а не Junos вообще. Но поучительна здесь не сама строка, а форма отказа. Весь контрол-плейн рапортовал успех. Все команды, которые принято приводить как доказательство работоспособности фабрики, показывали именно то, что должны показывать. Если бы я верил выводу show evpn database, я бы ещё долго искал проблему в BGP и в политиках импорта.

Как мерить отказы

Теперь, когда фабрика передаёт трафик, можно заняться тем, ради чего виртуальные лабы вообще поднимают, — ломать её и смотреть на часы.

Первый вопрос — чем мерить. Ответ «пингом» не годится: ping шлёт пакет раз в секунду, то есть провал короче секунды он не увидит в принципе, а более длинный округлит до целых секунд. Для сетевых отказов это всё равно что мерить спринт настенным календарём.

Я написал простую мерилку: UDP-поток с порядковым номером в каждом пакете, на приёмнике считается разрыв в последовательности и, главное, разрыв во времени между соседними пришедшими пакетами. При 1000 pps это даёт разрешение около миллисекунды.

Тут надо сразу оговорить потолок. В ограничениях cJunosEvolved написано: максимум 2000 pps и 3–5 Мбит/с суммарно по всем интерфейсам. Значит 1000 pps — это половина паспортной способности симулятора, выше лезть бессмысленно: будешь мерить эмулятор, а не сеть.

И мерилку тоже пришлось чинить дважды, о чём честно скажу, потому что обе ошибки типовые.

Первая: отправитель врал о своей скорости. Наивная пауза между пакетами через time.sleep() при интервале в миллисекунду даёт не 1000 пакетов в секунду, а около 740 — накладные расходы самого системного вызова съедают заметную часть бюджета. Лечится так: спим, только пока до следующей отправки есть запас, а последние полторы миллисекунды дожидаемся в цикле. И обязательно печатаем достигнутую скорость: инструмент, который врёт о собственной скорости, хуже отсутствия инструмента.

Вторая: приёмник умирал во время измеряемого простоя. Он завершался по таймауту тишины — и честно завершался ровно тогда, когда начинался отказ, который и надо было измерить. Правильно — работать фиксированное окно по часам, заведомо длиннее планируемого простоя.

Первые числа

Базовая линия. Фабрика в покое, 1000 pps, 10 секунд:

sent 10001 packets in 10.00s = 1000 pps (requested 1000)
received 10001, lost 0, loss 0.0%

Потерь нет. Худший разрыв между соседними пакетами, если отбросить артефакт на самом конце потока, — около 7 миллисекунд. Это и есть разрешающая способность стенда: провал короче десятка миллисекунд достоверно не различается.

Отказ линка, попытка первая. Гасим андерлейный интерфейс штатно, через deactivate и commit:

простой 26132.9 мс, потеряно 25911 пакетов

Число мусорное, и вот почему. Команда на выключение ушла на 8-й секунде потока. Трафик встал на 10.7-й. commit вернул управление на 15.2-й. commit на этой платформе занимает около семи секунд — и что из этого считать моментом отказа, непонятно.

Отсюда методический вывод, который я считаю главным в этой части: инструмент управления нельзя использовать как источник времени события. Если замер привязан к commit, вы измеряете латентность commit, а не сходимость сети.

Отказ линка, попытка вторая. Гасим линк мгновенно, со стороны Linux, не трогая Junos вообще:

линк погашен на 10.171 с, поднят на 20.324 с
последний пакет до разрыва  seq 10004  = 10.004 с
первый пакет после разрыва  seq 20158  = 20.158 с
простой 10109 мс, потеряно 10153 пакета

Простой совпал с тем временем, на которое линк был погашен: 10.11 секунды против 10.15 административного простоя. То есть трафик возобновился сразу, как только линк вернулся, — в пределах погрешности стенда.

Что из этого следует

Результат выглядит скучно ровно до того момента, пока не задашь вопрос: а почему восстановление мгновенное?

Потому что восстанавливать было нечего. За десять секунд отсутствия линка контрол-плейн не заметил ничего. Дефолтный hold-time BGP — девяносто секунд, сессия спокойно пережила обрыв, маршруты никто не отзывал, VTEP никто не перепрограммировал. Линк вернулся — пакеты пошли.

А теперь обратная сторона той же медали, и это то, ради чего стоит поднимать такие стенды. Будь отказ не временным, чёрная дыра длилась бы не десять секунд, а до истечения hold-time. И всё это время show bgp summary показывал бы честный Establ, а трафик уходил бы в никуда.

Девяносто секунд по умолчанию — это не «настройка по умолчанию». Это мина, и обезвреживают её не тюнингом таймеров ради тюнинга, а пониманием, какой именно вид отказа вы собираетесь переживать: пропадание линка, падение соседа или тихую смерть форвардинга при живой сессии. Это три разные истории с разным временем реакции, и дефолт закрывает из них ровно одну.

Честные границы этого стенда

Виртуальный образ — не железо, и выдавать его миллисекунды за характеристики PTX было бы враньём. В ограничениях cJunosEvolved прямо сказано, что не поддерживаются «QoS и протоколы с агрессивными (sub-second) таймерами, включая BFD». Для BT-флавора туда же — MC-LAG, VRRP и IPv6 в андерлее и оверлее.

Поэтому всё, что здесь измерено, — это механика и порядок величин: что отказ бывает разных видов, что разница между ними принципиальная, и откуда берётся девяностосекундная дыра. Абсолютные числа на вашем железе будут другими. Но вопрос, который стоит задать своей сети, от этого не меняется.

Что дальше

Главный замер — отказ не линка, а узла — на топологии из двух листьев сделать нельзя: убивая второй лист, убиваешь и получателя. Нужен резервный путь: либо второй линк и ECMP в андерлее, либо хост, подключённый к обоим листьям через ESI-LAG. Это следующая часть, и там же — сравнение видов отказа в одной таблице.

Топологию, конфиги листьев, скрипты сборки и мерилку выложу отдельным репозиторием — ссылку оставлю в комментариях к статье.

Если соберётесь повторять — закладывайте по 8 ГБ памяти на узел, около получаса на первую загрузку и то самое tunnel-termination сразу, чтобы не повторять мой вечер.