Привет! Меня зовут Сергей Кляус, и я как разработчик виртуальной сети сопровождаю создателей приложений, размещённых в Yandex.Cloud. При этом диагностические возможности самого облака ограничены: мы не видим метрики пользовательских виртуальных машин, например количество TCP retransmissions, а записывать и анализировать огромные дампы всего сетевого трафика с помощью tcpdump дорого и трудно из-за ограничений безопасности.
К счастью, динамическая трассировка позволяет получить лучшее от двух миров: исполнять произвольный код в момент увеличения метрики, а в самом коде печатать тело пакета. Например, недавно мы диагностировали проблемы с TCP-соединениями у одного из наших managed-сервисов, и оказалось, что теряются на самом деле UDP-пакеты. Гипотеза требовала уточнения, хотя корреляция между ростом метрики и сбоем была поначалу очевидна. В современном Linux динамическая трассировка реализована через eBPF и утилиту BPFTrace, но постепенно мы накопили набор типовых сценариев их использования и сделали обёртку над BPFTrace. Она называется skbtrace и доступна на GitHub. Про неё я и расскажу под катом.

eBPF и BPFTrace


Технология eBPF в последнее время активно развивается в ядре Linux. Она берёт свое начало от Berkeley Packet Filter — по сути, небольшой виртуальной машины внутри ядра, которая получала инструкции от tcpdump, интерпретировала их перед обработкой ядром пакета, что позволяло фильтровать их чрезвычайно эффективно. Таким образом, в userspace-программу передавались для анализа только отфильтрованные пакеты, а уже она выполняла полный анализ и печатала все распознанные сетевые заголовки и поля в них. Например, поле флагов TCP, в котором злополучный RST (reset) означает аварийное завершение соединения.

Конечно, современный eBPF сильно ушёл вперёд — за это отвечает буква «e» в названии, Extended: JIT-компилятор превращает инструкции BPF-машины в нативные инструкции процессора (например, x86), а за безопасность исполняемого кода отвечает сложный Verifier. Но суть осталась прежней: можно привязаться к определенному месту в ядре через kprobe или XDP-хуки в драйверах сетевой карты, выполнить небольшой код и делать это крайне эффективно.

Однако BPF долгое время страдал из-за сложных инструментов: требовалось писать много кода на языке C и собирать через LLVM с бэкендом BPF, хотя часть boilerplate и управляющих сигналов можно было спрятать за Python/BCC. Наконец, в 2019 году появился BPFTrace, в качестве интерфейса которого используется скриптовый язык, похожий на DTrace (что не удивительно: активное участие в создании обоих принимал Брендан Грегг). BPFTrace гораздо приятнее использовать для задач observability. Достаточно сравнить объём кода на BCC и BPFTrace для трассировки TCP-соединений, чтобы понять: BCC ещё более монструозен, чем BPFTrace, пускай и более гибок.

Сегодня для работы с eBPF появилось множество библиотек, а на их базе вышло немало инструментов для мониторинга сети. Например, на прошедшем в 2021 году eBPF summit разработчики из Alibaba показали, как с помощью небольшого демона поверх eBPF отлавливать сами TCP Retransmissions. Когда мы начинали работу над skbtrace в 2019 году, эти библиотеки ещё не были развиты, а BPFTrace уже существовал и позволял с помощью небольшого скрипта «проинструментировать» виртуальную сеть.

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

Пример
#include <linux/skbuff.h>
#include <linux/netdevice.h>
#include </home/sklyaus/xiphdr.h>

k:tcp_gso_segment {
    $orig_skb = (sk_buff*) arg0;

    printf("TCP SEGMENT len %d, features %x dev %s\n", $orig_skb->len, arg1,
            $orig_skb->dev->name);
    printf("ORIG SKB len %d hroom %d nhoff %d thoff %d\n", $orig_skb->len,
            $orig_skb->data - $orig_skb->head, $orig_skb->network_header,
            $orig_skb->inner_network_header);

    $skbsi = (skb_shared_info*) ($orig_skb->head + $orig_skb->end);
    printf("SKBSI: nr_frags %d gso size %d\n",
                    $skbsi->nr_frags, $skbsi->gso_size);

    /* printf("%s", kstack(4)); */
}
    
kretprobe:tcp_gso_segment {
    $skb = (sk_buff*) retval;
    $idx = 0;

    unroll(2) {
        if ($skb) {
            $addr = $skb->head + $skb->mac_header + 14 /* ETH */;
            $out_iph = (xiphdr*) $addr;

            $tot_len = ($out_iph->tot_len >> 8) |
                        (($out_iph->tot_len & 0xff) << 8);

            printf("SEG %d len %d hroom %d nhoff %d thoff %d\n", $idx, $skb->len,
                    $skb->data - $skb->head, $skb->network_header, $skb->inner_network_header);
            printf("OUTER: ihl/ver %x proto %x tot len %6d\n",
                    $out_iph->ihl_version, $out_iph->protocol, $tot_len);

            $skb = $skb->next;
        };
        $idx = $idx + 1;
    };
    if ($skb->next) {
        printf("... more skbs\n");
    }
}

Если же надо было добавить вывод какого-нибудь заголовка или поменять режим инкапсуляции пакетов, то скрипт требовал переработки. Так появилась обёртка yc-bpf-trace — написанный на Python генератор BPFTrace-скриптов, который позволяет всего одним вызовом командной строки описать выводимые заголовки, используемые пробы и способ агрегации данных. А под флагом избавления от легаси и интеграции с новыми внутренними сервисами, используемыми поддержкой Yandex.Cloud, мы переписали эту утилиту на Go и выложили под названием skbtrace.

Сейчас она поддерживает самый простой сценарий сетевого оверлея: когда пакет пересекает ядро Linux из физического сетевого интерфейса в виртуальный — принадлежащий контейнеру или виртуальной машине, как в нашем случае. К сожалению, она сейчас не поддерживает сценарий «сетевой интерфейс – приложение», но мы думаем, что можно будет трассировать и его, разметив соответствующие пробы и расширив skbtrace.

Ниже я покажу, как добавление ещё одной пробы помогает найти ошибку в конфигурации фаервола, а поле truesize у socket buffer — узнать, что лимит памяти на чтение у сокета SO_RCVBUF следует устанавливать со значительным запасом (25-кратным в случае нашего Cloud DNS). Кстати, truesize можно подсмотреть, только если залезть в ядерные структуры данных, что как раз и позволяет BPFTrace.

Ну и честно признаюсь: мы не перенесли некоторые вещи либо из-за их специфики (как в случае со структурами данных и пробами нашего форка TF VRouter), или потому, что немного стыдно публиковать unroll для поиска TCP MSS-опции, реализованный функцией из пяти уровней вложенности.

Примеры использования skbtrace


Куда летят пакеты, в какие CloudGate?



CloudGate обрабатывает ваш трафик

CloudGate, о которых уже писал PurplePowder, — окно во внешний мир для виртуальной сети. Это отдельные виртуальные машины, к тому же зарезервированные в виде ECMP-группы. При этом пользовательская виртуальная машина может отправить трафик в любую из них. Но недавно мы нашли неприятную ошибку, из-за которой в редких случаях случался сильный дисбаланс в таком выборе и один из CloudGate получал семикратную нагрузку.

Чтобы подтвердить гипотезу со стороны виртуальной сети, нам было достаточно всего лишь построить агрегацию: посчитать количество пакетов, отправленных в каждый из CloudGate. При этом ключом был бы IP-адрес CloudGate, то есть Destination IP Address во внешнем IP-заголовке. Вот так это выглядело в лабе:

$ skbtrace aggregate -6 -P xmit -i eth1 -k outer-dst \
     -F 'inner-src == 2a02:6b8:c0e:205:0:417a:0:3da' 2s

Смотреть скринкаст

Отслеживание потерянных пакетов


Потерянный или dropped-пакет — регулярное явление в виртуальной сети. Запрещает группа безопасности? Дроп. Погасил виртуальную машину, но продолжил лить на неё нагрузку? Дроп. Куда интереснее ситуация, когда машину подняли обратно, а потери пакетов продолжились. Последней ситуации мы хотим избежать и регулярно проверяем, не всплывает ли она. Для этого снимаем заголовки дропнутых пакетов и убеждаемся, что виртуальная машина с таким IP-адресом и правда не запущена.

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

Но в примере ниже мы посмотрим в Linux Netfilter. BPFTrace и, соответственно, skbtrace умеют выводить kernel stack, и по функции nf_hook_slow видно, что причина дропов — пакетный фильтр:

$ skbtrace dump -6 -P free -F 'dst == 2a02:6b8:c0e:205:0:417a:0:3da' -K -o ipv6

Attaching 2 probes...
01:55:29.118794463 - kprobe:kfree_skb
IPV6: priority_version 66 payload_len 40
IPV6: nexthdr 6 hop_limit 51 src 2a02:6b8:bf00:300:9cbe:4ff:fea9:b3de dst 2a02:6b8:c0e:205:0:417a:0:3da

        kfree_skb+1
        nf_hook_slow+192
        br_nf_forward_ip+1101
        nf_hook_slow+95
        __br_forward+361
        ...

kfree_skb — это освобождение socket buffer. Учитывая, что в большинстве своём диагностика iptables — нетривиальное занятие, гораздо приятнее начинать её с более простого инструмента, подтверждающего, что проблема действительно в фаерволе.

Таргеты сетевого балансировщика, отвечающие за сотни миллисекунд


Среди пользователей облака есть те, кто довольно чувствителен к высоким задержкам. В целом, проверки healthchecks позволяют отсекать совсем уж неработоспособные таргеты, но что если задержки возникают на единичных запросах? Такие запросы можно отловить по времени handshake. По сути это время между отправкой первого SYN пакета и последующего (первого в соединении) ACK-пакета, при этом у них совпадает five tuple.

Время между событиями, идентифицируемыми по ключу, можно ловить с помощью команды skbtrace timeit from ... to ... (в данном случае есть более удобный алиас: skbtrace timeit handshake --ingress). А вот аномальные пакеты лучше ловить через подкоманду outliers. aggregate -f hist используется без дополнительных подкоманд и выводит гистограмму, по которой видно: один five tuple handshake занял больше 256 мс (по умолчанию skbtrace считает все времена в микросекунднах, то есть 256K = 256 000 мкс = 256 мс):

$ skbtrace timeit tcp handshake -i tapa7kkobaif-1 --ingress

Attaching 5 probes...
12:21:54
@:
[512, 1K)              1 |@@@@@@@@@@@@@@@@@@@@@@@@@@                          |
[1K, 2K)               0 |                                                    |
[2K, 4K)               0 |                                                    |
[4K, 8K)               0 |                                                    |
[8K, 16K)              0 |                                                    |
[16K, 32K)             0 |                                                    |
[32K, 64K)             2 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[64K, 128K)            0 |                                                    |
[128K, 256K)           0 |                                                    |
[256K, 512K)           1 |@@@@@@@@@@@@@@@@@@@@@@@@@@                          |

$ skbtrace timeit tcp handshake -i tapa7kkobaif-1 --ingress outliers -o ip -o tcp

Attaching 4 probes...
TIME: 152467 us
12:22:19.670245756 - kprobe:dev_queue_xmit
IP: ihl/ver 45 tot_len 52 frag_off 0 (- DF) check 4643
IP: id 23291 ttl 62 protocol 6 saddr 100.64.117.131 daddr 192.168.1.26
TCP: source 45972 dest 80 check 3a5e
TCP: seq 1558770843 ack_seq 693522456 doff 8 win 229
TCP: flags -A---

В этом примере задержка добавлена с помощью tc qdisc change dev eth1 root netem delay 1ms 100ms distribution paretonormal. В целом, используя эту же подкоманду, но для --egress, можно диагностировать клиента — и искать сервисы, которые отвечают слишком медленно.

Охота на ретрансмиты


Наверное, каждый сетевой инженер сталкивался с этой неприятной чёрной полоской в выводе Wireshark и розоватыми буквами: TCP Retransmission. Но tcpdump ещё надо снять, проанализировать, а нам интересно состояние нашего виртуального роутера в момент ретрансмита. В общем случае ретрансмит — это повторный ACK-пакет с таким же sequence number, который мы уже видели в рамках этого соединения, идентифицируемого по five tuple. То есть надо немного изменить поведение команды timeit, чтобы она реагировала на повторный пакет с таким же ключом, но не замеряла время, а выходила, а при выходе мы могли бы запустить дополнительные диагностические скрипты.

В этом примере мы будем иногда дропать первый SYN-пакет в TCP-соединении с помощью iptables на стороне виртуальный машины и отслеживать повторную посылку, которая выполняется через секунду:

$ skbtrace timeit tcp retransmit -i tapa7kkobaif-1 --ingress -o ip -o tcp

Attaching 3 probes...
DUPLICATE EVENT TIME: 998231 us
10:56:25.222838145 - kprobe:dev_queue_xmit
IP: ihl/ver 45 tot_len 60 frag_off 0 (- DF) check be7e
IP: id 58039 ttl 62 protocol 6 saddr 100.64.117.131 daddr 192.168.1.26
TCP: source 45570 dest 80 check 5449
TCP: seq 850649414 ack_seq 0 doff 10 win 29200
TCP: flags S----

Обычно в случае виртуальной сети чуть больший интерес представляют такие ретрансмиты перед нашим виртуальным роутером. Это хороший повод начать искать потери.

DNS и UdpInErrors


BPFTrace (и skbtrace) можно использовать не только для анализа сетевых пакетов, но и для получения представления других структур в ядре. Например, наш новый DNS удалось стабилизировать только при значениях Receive Buffer в ~100 МБ. Иначе возникали потери UdpInErrors из-за нехватки места в буфере чтения. Дело не в том, что сервис медленно читает данные или у него такая колоссальная нагрузка. Просто ядро Linux переиспользует сетевые буферы большего размера, чем нужно, иногда даже размером в несколько килобайт для стобайтного пакета. Это видно, если посмотреть значение truesize: накладные расходы для 113-байтного пакета составляет почти 700%:

$ skbtrace dump -P recv -F 'dport == 53' -p udp -o layout
Attaching 2 probes...
09:24:58.304536041 - kprobe:netif_receive_skb_internal
LAYOUT: hroom 78 hlen 113 mac hoff 64 net hoff 78 trans hoff 98
LAYOUT: len 113 data_len 0 troom -129 truesize 896

Таким образом, даже в нашей лаборатории у DNS-сервиса пришлось бы выставлять SO_RCVBUF с 8-кратным запасом, а, как я уже говорил, в PROD-инсталляции потребовался 25-кратный запас. Если вы наблюдаете что-то похожее на UdpInErrors, возможно, вам тоже стоит проверить накладные расходы на socket buffers.

Вместо ложки дёгтя


Конечно BPFTrace — не серебряная пуля, и нам пришлось столкнуться с многими его ограничениями, а в skbtrace предусмотреть «подпорки» для них. Например, он некорректно взаимодействует с битовыми полями в структурах. Да, мы до сих пор не используем BTF и потому работаем с заголовочными файлами.

Мы не рискнули применять постоянно запущенные мониторы и оформлять skbtrace в виде демона, опасаясь проблем со стабильностью. Но эти страхи не оправдались. Более того, коллеги из команды CloudGate довольно успешно реализовали такой демон. Но запускать одновременно демон и BPFTrace нельзя — по крайней мере, не разрешено в нашей версии ядра.

Cам по себе skbtrace, конечно, примитивен по сравнению с tcpdump, который умеет корректно нарезать пакеты и определять заголовки там, где skbtrace нуждается в подсказках. Зато он чрезвычайно быстр: можно сказать «до свидания» гигабайтным pcap-файлам.

Утилита выложена на GitHub. Приносите свои сценарии в комментарии, а предложения в Issues и Pull Requests.

Комментарии (0)