[Перевод] Отслеживание пути пакета с помощью точек трассировки Linux, perf и eBPF

soj3siemp60ajusq3pw8pibuky0.jpeg

Я давно искал какой-нибудь инструмент для низкоуровневой отладки сети Linux. Linux позволяет создавать сложные сети, запускаемые прямо на хосте, используя комбинацию из виртуальных интерфейсов и сетевого пространства имен. Когда что-то идет не так решение возникших проблем утомительно. Если это проблема маршрутизации L3, mtr (Matt’s traceroute) имеет неплохие шансы принести пользу. Однако, если проблема на более низком уровне, обычно все заканчивается тем, что я вручную проверяю каждый интерфейс / мост / пространство имен сети / iptables и пару раз запускаю tcpdump в попытках понять что происходит. Если вы не знакомы с настройками сети, то при решении проблем в ней, вас ждет запутанный лабиринт.

Что мне нужно, так это инструмент который скажет мне: «Эй, я видел там твой пакет: он прошел этим путем, через этот интерфейс в этом пространстве имен сети».

В общем-то, мне нужен mtr для L2.

Не существует? Сделаем свой!

В конце этой статьи мы получим простой и легкий в использовании трассировщик пакетов низкого уровня. Если вы пропингуете локальный контейнер Docker, он выдаст что-то вроде:

# ping -4 172.17.0.2
[  4026531957]          docker0 request #17146.001 172.17.0.1 -> 172.17.0.2
[  4026531957]      vetha373ab6 request #17146.001 172.17.0.1 -> 172.17.0.2
[  4026532258]             eth0 request #17146.001 172.17.0.1 -> 172.17.0.2
[  4026532258]             eth0   reply #17146.001 172.17.0.2 -> 172.17.0.1
[  4026531957]      vetha373ab6   reply #17146.001 172.17.0.2 -> 172.17.0.1
[  4026531957]          docker0   reply #17146.001 172.17.0.2 -> 172.17.0.1


Трассировка к спасению

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

В терминах Linux это значит сместиться на точку зрения ядра, где пространство имен сети это лишь ярлыки, а не «контейнеры». Пакеты и интерфейсы внутри ядра являются простыми наблюдаемыми объектами.

В этой статье я сконцентрируюсь на двух инструментах трассировки. perf и eBPF.


Введение в perf и eBPF

perf это основной инструмент для любого анализа производительности в Linux. Он разработан в той же ветке исходников что и ядро Linux и должен быть специально собран для ядра, которое вы планируете использовать для отслеживания. Имеется возможность трассировки как ядра, так и пользовательских программ. Есть возможность работы путем выборки или с использованием точек трассировки. Просто думайте о perf как о надмножестве strace с меньшими заморочками. Мы будем использовать его только немного, если же вы хотите узнать больше о perf, я настоятельно рекомендую вам посетить блог Брэндана Грегга (Brendan Gregg).

eBPF это относительно недавнее дополнение к ядру Linux. Как и говорится в названии, это расширенная (extended) версия BPF bytecode, известный как «Berkeley Packet Filter», используемый для… фильтрации пакетов в семействе BSD. Ну вы поняли. В Linux это так же может использоваться для безопасного запуска платформенно-независимого кода внутри работающего ядра, обеспечивая этим некоторую степень безопасности. Например, доступ к памяти выдается ДО того, как программа может запуститься и тогда должно быть возможно подтвердить, что программа завершится за ограниченный промежуток времени. Если ядро не может подтвердить это, то, даже если все безопасно и всегда завершается, запуск будет отклонен.

Такие программы могут быть использованы как сетевой классификатор для QOS, очень низкого уровня сети и фильтрации части eXpress Data Plane (XDP), как агент трассировки и во многих других областях. Зонды трассировки можно применить к любой функции, чьи символы экспортируются в /proc/kallsyms или любую точку трассировки. В этой статье я сфокусируюсь на агентах трассировки прикрепленных к точкам трассировки.

В качестве примера подобных зондов, применяемых к функции ядра, или более мягкого введения, я приглашаю вас прочитать мой предыдущий пост по поводу eBPF.


Настройка Lab

Для этой статьи нам нужен perf и несколько других инструментов по работе с eBPF. Я не большой фанат ручной сборки, так что использую здесь bcc. Это мощный и гибкий инструмент для написания зондов ядра на ограниченном Си и использования их в пользовательской среде Python. Тяжеловесно в продакшне, но прекрасно для разработки!

Я повторю здесь инструкцию по установке для Ubuntu 17.04 (Zesty), на котором и работает ОС на моем ноутбуке. Инструкция для «perf» не должна сильно отличаться для других дистрибутивов, а специальную инструкцию по установке bcc можно найти на GitHub.


Примечание: для применения eBPF к точкам трассировки необходимо иметь версию ядра Linux не ниже 4.7.

Установка perf:

# Grab 'perf'
sudo apt install linux-tools-generic

# Test it
perf

Если вы видите сообщение об ошибке, это возможно означает, что ядро было недавно обновлено, но перезагрузку еще не совершали.

Установка bcc:

# Install dependencies
sudo apt install bison build-essential cmake flex git libedit-dev python zlib1g-dev libelf-dev libllvm4.0 llvm-dev libclang-dev luajit luajit-5.1-dev

# Grab the sources
git clone https://github.com/iovisor/bcc.git

# Build and install
mkdir bcc/build
cd bcc/build
cmake .. -DCMAKE_INSTALL_PREFIX=/usr
make
sudo make install


Поиск хороших точек трассировки или «ручная трассировка пути пакета с perf»

Есть несколько способов найти хорошие точки трассировки. В предыдущей версии этой статьи, я начал с кода veth драйверов, и пошел далее по цепочке, чтобы найти функцию для трассировки. Несмотря на приемлемые результаты, я не смог отследить все пакеты. Общие пути, по которым проходят все пакеты, находятся в неэкспортируемых (встроенных или статических) методах. Тогда же я понял, что у Linux есть точки трассировки, и решил переписать старую статью и связанный с ней код, используя точки трассировки. Это было довольно раздражающе, но и гораздо интереснее (для меня).

Хватит разговоров обо мне, возвращаемся к работе.

Цель — проследить путь, пройденный пакетом. В зависимости от пересекаемых интерфейсов пересекаемые точки трассировки могут отличаться (спойлер: так и есть).

Чтобы найти подходящие точки трассировки, я использовал пинг двух внутренних и двух внешних целей от трассировки perf.


  1. localhost с IP 127.0.0.1
  2. Невинный контейнер Docker с IP 172.17.0.2
  3. Мой телефон через USB привязку с IP 192.168.42.129
  4. Мой телефон через WiFi с IP 192.168.43.1

perf trace это подкоманда perf, которая создает вывод схожий с strace (с ГОРАЗДО меньшей головной болью). Мы можем легко настроить его, чтобы скрыть сами системные вызовы и вместо этого печатать события категории «net». Например, отслеживание ping до контейнера Docker с IP 172.17.0.2 будет выглядеть так:

sudo perf trace --no-syscalls --event 'net:*' ping 172.17.0.2 -c1 > /dev/null
     0.000 net:net_dev_queue:dev=docker0 skbaddr=0xffff96d481988700 len=98)
     0.008 net:net_dev_start_xmit:dev=docker0 queue_mapping=0 skbaddr=0xffff96d481988700 vlan_tagged=0 vlan_proto=0x0000 vlan_tci=0x0000 protocol=0x0800 ip_summed=0 len=98 data_len=0 network_offset=14 transport_offset_valid=1 transport_offset=34 tx_flags=0 gso_size=0 gso_segs=0 gso_type=0)
     0.014 net:net_dev_queue:dev=veth79215ff skbaddr=0xffff96d481988700 len=98)
     0.016 net:net_dev_start_xmit:dev=veth79215ff queue_mapping=0 skbaddr=0xffff96d481988700 vlan_tagged=0 vlan_proto=0x0000 vlan_tci=0x0000 protocol=0x0800 ip_summed=0 len=98 data_len=0 network_offset=14 transport_offset_valid=1 transport_offset=34 tx_flags=0 gso_size=0 gso_segs=0 gso_type=0)
     0.020 net:netif_rx:dev=eth0 skbaddr=0xffff96d481988700 len=84)
     0.022 net:net_dev_xmit:dev=veth79215ff skbaddr=0xffff96d481988700 len=98 rc=0)
     0.024 net:net_dev_xmit:dev=docker0 skbaddr=0xffff96d481988700 len=98 rc=0)
     0.027 net:netif_receive_skb:dev=eth0 skbaddr=0xffff96d481988700 len=84)
     0.044 net:net_dev_queue:dev=eth0 skbaddr=0xffff96d481988b00 len=98)
     0.046 net:net_dev_start_xmit:dev=eth0 queue_mapping=0 skbaddr=0xffff96d481988b00 vlan_tagged=0 vlan_proto=0x0000 vlan_tci=0x0000 protocol=0x0800 ip_summed=0 len=98 data_len=0 network_offset=14 transport_offset_valid=1 transport_offset=34 tx_flags=0 gso_size=0 gso_segs=0 gso_type=0)
     0.048 net:netif_rx:dev=veth79215ff skbaddr=0xffff96d481988b00 len=84)
     0.050 net:net_dev_xmit:dev=eth0 skbaddr=0xffff96d481988b00 len=98 rc=0)
     0.053 net:netif_receive_skb:dev=veth79215ff skbaddr=0xffff96d481988b00 len=84)
     0.060 net:netif_receive_skb_entry:dev=docker0 napi_id=0x3 queue_mapping=0 skbaddr=0xffff96d481988b00 vlan_tagged=0 vlan_proto=0x0000 vlan_tci=0x0000 protocol=0x0800 ip_summed=2 hash=0x00000000 l4_hash=0 len=84 data_len=0 truesize=768 mac_header_valid=1 mac_header=-14 nr_frags=0 gso_size=0 gso_type=0)
     0.061 net:netif_receive_skb:dev=docker0 skbaddr=0xffff96d481988b00 len=84)

Если сохранять только имена событий и skbaddr, то будет выглядеть читабельнее.

net_dev_queue           dev=docker0     skbaddr=0xffff96d481988700
net_dev_start_xmit      dev=docker0     skbaddr=0xffff96d481988700
net_dev_queue           dev=veth79215ff skbaddr=0xffff96d481988700
net_dev_start_xmit      dev=veth79215ff skbaddr=0xffff96d481988700
netif_rx                dev=eth0        skbaddr=0xffff96d481988700
net_dev_xmit            dev=veth79215ff skbaddr=0xffff96d481988700
net_dev_xmit            dev=docker0     skbaddr=0xffff96d481988700
netif_receive_skb       dev=eth0        skbaddr=0xffff96d481988700

net_dev_queue           dev=eth0        skbaddr=0xffff96d481988b00
net_dev_start_xmit      dev=eth0        skbaddr=0xffff96d481988b00
netif_rx                dev=veth79215ff skbaddr=0xffff96d481988b00
net_dev_xmit            dev=eth0        skbaddr=0xffff96d481988b00
netif_receive_skb       dev=veth79215ff skbaddr=0xffff96d481988b00
netif_receive_skb_entry dev=docker0     skbaddr=0xffff96d481988b00
netif_receive_skb       dev=docker0     skbaddr=0xffff96d481988b00

Есть несколько вещей, которые надо сказать сейчас. Самое очевидное — skbaddr изменяется в середине, но в остальном остается неизменным. Это происходит, когда пакет эхо-ответа генерируется как ответ на эхо-запрос (пинг). Оставшееся время сетевой пакет перемещается между интерфейсами, к счастью без копирования. Копирование так дорого…

Другой интересный момент — мы ясно видим, что пакет проходит через мост docker0, затем через veth на стороне хоста, в моем случае это veth79215ff, и наконец, через контейнерную сторону veth, притворяющуюся eth0. Вы еще не видим пространство имен сети, но уже имеем неплохой обзор.

Наконец, после просмотра пакета на eth0, мы попадаем в точки трассировки в обратном порядке. Это не отклик, а завершение передачи.

Повторив подобный процесс на 4 целевых сценариях, мы можем выбрать подходящие точки трассировки для отслеживания пути пакета. Я выбрал четыре следующих:


  • net_dev_queue
  • netif_receive_skb_entry
  • netif_rx
  • napi_gro_receive_entry

Выбор этих точек даст мне возможность упорядочить события без дублирования, тем самым сэкономив немного времени на дедупликации.

Мы можем легко дважды проверить этот выбор, например:

sudo perf trace --no-syscalls           \
  --event 'net:net_dev_queue'           \
  --event 'net:netif_receive_skb_entry' \
  --event 'net:netif_rx'                \
  --event 'net:napi_gro_receive_entry'  \
  ping 172.17.0.2 -c1 > /dev/null
     0.000 net:net_dev_queue:dev=docker0 skbaddr=0xffff8e847720a900 len=98)
     0.010 net:net_dev_queue:dev=veth7781d5c skbaddr=0xffff8e847720a900 len=98)
     0.014 net:netif_rx:dev=eth0 skbaddr=0xffff8e847720a900 len=84)
     0.034 net:net_dev_queue:dev=eth0 skbaddr=0xffff8e849cb8cd00 len=98)
     0.036 net:netif_rx:dev=veth7781d5c skbaddr=0xffff8e849cb8cd00 len=84)
     0.045 net:netif_receive_skb_entry:dev=docker0 napi_id=0x1 queue_mapping=0 skbaddr=0xffff8e849cb8cd00 vlan_tagged=0 vlan_proto=0x0000 vlan_tci=0x0000 protocol=0x0800 ip_summed=2 hash=0x00000000 l4_hash=0 len=84 data_len=0 truesize=768 mac_header_valid=1 mac_header=-14 nr_frags=0 gso_size=0 gso_type=0)

Миссия выполнена!

Если вы хотите пойти дальше и изучить список доступных сетевых точек трассировки, вы можете использовать perf list:

sudo perf list 'net:*'

Это должно вернуть список точек трассировки, называющийся net: netif_rx. Часть перед двоеточием это категория события («net»). После двоеточия это имя события в этой категории.


Пишем заказной трассировщик с помощью eBPF / bcc

Этого будет более чем достаточно для большинства ситуаций. Если вы читаете эту статью, чтобы узнать как отслеживать путь пакета в системе Linux, вы уже получили всё что необходимо. Но, если вы хотите копнуть глубже, запустить свой фильтр, отслеживать больше данных, вроде пространств имен сети пересекаемых пакетами, или начальный и конечный IP, прошу потерпите меня ещё.

Начиная с Linux Kernel 4.7, приложения eBPF могут быть привязаны к точкам трассировки ядра. До этого единственной альтернативой для создания этого трассировщика было бы прикрепление зондов к экспортируемым символам ядра. Несмотря на то, что это могло сработать, было и несколько недостатков.


  1. Внутренний API ядра нестабилен, в отличие от точек трассировки.
  2. В соображениях производительности, большинство внутренних сетевых функций встроены или статичны. Ничто из этого нельзя исследовать.
  3. Это просто утомительно, искать все возможные вызовы от сайтов для этих функций, а некоторые из необходимых данных могут и вовсе быть недоступны на этом этапе.

В более ранней версии этой статьи я пытался использовать kprobes, что легче, но результаты были неполноценными.

Будем честны, доступ к данным через точки трассировки намного утомительнее, чем с аналогом kprobe. И хотя я старался сделать эту статью как можно более простой для чтения, возможно вам придется заглянуть в более старые посты. [«Как переделать любой системный вызов в событий: Вступление в зонды ядра eBPF»] (/2016/03/30/turn-any-syscall-into-event-introducing-ebpf-kernel-probes/).

Предупреждения позади, начнем с простого «hello world» и установим низкоуровневый plumbing на место. В этом примере мы будем строить событие каждый раз, когда срабатывает одна из четырех выбранных точек трассировки (net_dev_queue, netif_receive_skb_entry, netif_rx и napi_gro_receive_entry). Чтобы не усложнять задачу, мы пошлем программное сообщение из 16 символов, в основном это имя программы.

#include 
#include 

// Event structure
struct route_evt_t {
        char comm[TASK_COMM_LEN];
};
BPF_PERF_OUTPUT(route_evt);

static inline int do_trace(void* ctx, struct sk_buff* skb)
{
    // Built event for userland
    struct route_evt_t evt = {};
    bpf_get_current_comm(evt.comm, TASK_COMM_LEN);

    // Send event to userland
    route_evt.perf_submit(ctx, &evt, sizeof(evt));

    return 0;
}

/**
  * Attach to Kernel Tracepoints
  */

TRACEPOINT_PROBE(net, netif_rx) {
    return do_trace(args, (struct sk_buff*)args->skbaddr);
}

TRACEPOINT_PROBE(net, net_dev_queue) {
    return do_trace(args, (struct sk_buff*)args->skbaddr);
}

TRACEPOINT_PROBE(net, napi_gro_receive_entry) {
    return do_trace(args, (struct sk_buff*)args->skbaddr);
}

TRACEPOINT_PROBE(net, netif_receive_skb_entry) {
    return do_trace(args, (struct sk_buff*)args->skbaddr);
}

Этот фрагмент прикрепляется к четырем точкам трассировки типа «net», загружает поле skbaddr и отправляет их в общую секцию, где пока что загрузит только имя программы. Если вы задумались, откуда приходят эти args→skbaddr, то знайте, их структура генерируется с помощью bcc, вне зависимости от выбранных точек трассировки в TRACEPOINT_PROBE. Поскольку эта структура генерируется на лету, нет простого способа увидеть ее определение, НО можно сделать и поумнее. Мы посмотрим прямо в источник данных, в ядро. К счастью, существует вход /sys/kernel/debug/tracing/events для каждой точки трассировки. Например, для net: netif_rx просто пишем «cat» /sys/kernel/debug/tracing/events/net/netif_rx/format и получаем подобный вывод:

name: netif_rx
ID: 1183
format:
    field:unsigned short common_type;         offset:0; size:2; signed:0;
    field:unsigned char common_flags;         offset:2; size:1; signed:0;
    field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
    field:int common_pid;                     offset:4; size:4; signed:1;

    field:void * skbaddr;         offset:8;  size:8; signed:0;
    field:unsigned int len;       offset:16; size:4; signed:0;
    field:__data_loc char[] name; offset:20; size:4; signed:1;

print fmt: "dev=%s skbaddr=%p len=%u", __get_str(name), REC->skbaddr, REC->len

Вы можете заметить строку «print fmt» в конце записи. Это в точности то, что использовал трассировщик perf для генерации вывода.

С использованием plumbing и хорошим пониманием сути, мы можем переделать это в скрипт Python для отображения каждого события отосланного со стороны зонда eBPF:

#!/usr/bin/env python
# coding: utf-8

from socket import inet_ntop
from bcc import BPF
import ctypes as ct

bpf_text = ''''''

TASK_COMM_LEN = 16 # linux/sched.h

class RouteEvt(ct.Structure):
    _fields_ = [
        ("comm",    ct.c_char * TASK_COMM_LEN),
    ]

def event_printer(cpu, data, size):
    # Decode event
    event = ct.cast(data, ct.POINTER(RouteEvt)).contents

    # Print event
    print "Just got a packet from %s" % (event.comm)

if __name__ == "__main__":
    b = BPF(text=bpf_text)
    b["route_evt"].open_perf_buffer(event_printer)

    while True:
        b.kprobe_poll()

Можете протестировать сейчас. Вам нужны root права.


Примечание: Сортировки на этом этапе не будет. Даже с низким фоновым использованием сети ваша консоль будет быстро захламляться.
 $> sudo python ./tracepkt.py
...
Just got a packet from ping6
Just got a packet from ping6
Just got a packet from ping
Just got a packet from irq/46-iwlwifi
...

В данном случае, можно заметить, что я использовал ping и ping6, а драйвер WiFi только что получил несколько пакетов. Значит, это был эхо-ответ.

Давайте добавим немного полезной информации / фильтров.

В этой статье я не концентрируюсь на производительности. Так лучше покажут себя сильные и слабые стороны eBPF. Чтобы ускорить процесс, можно использовать размер пакета для эвристики, предполагая отсутствие странных вариантов IP. Использование программ из примера без изменений может замедлить ваш сетевой трафик.


Примечание: чтобы излишне не удлинять статью, я сфокусируюсь на C/eBPF. Я оставлю ссылку на полный исходный код в конце.


Добавление информации о сетевом интерфейсе.

Для начала, вы можете спокойно удалять поля «comm», loading и заголовок sched.h. В нашем случае от этого не будет никакой пользы.

Теперь можно включить в проект net/inet_sock.h, так как все необходимые объявления сделаны, и добавить char ifname[IFNAMSIZ]; в структуру событий.

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

// Get device pointer, we'll need it to get the name and network namespace
struct net_device *dev;
bpf_probe_read(&dev, sizeof(skb->dev), ((char*)skb) + offsetof(typeof(*skb), dev));

// Load interface name
bpf_probe_read(&evt.ifname, IFNAMSIZ, dev->name);

Можете проверить — должно работать. И не забудьте добавить соответствующую часть на сторону Python:)

Окей, так как это работает? Чтобы загрузить имя интерфейса, нам необходимо структура интерфейса устройства. Я начну с последнего, как более простого для понимания, хотя предыдущее на деле просто сложнее. Используем bpf_probe_read для чтения длины IFNAMSIZ из dev→name и скопируем его в evt.ifname. Первая строка следует точно той же логике. Загружаем значение указателя skb→ dev в dev. К несчастью, я не смог найти другого способа загрузить поле адреса без этого маленького offsetof / typeof трюка.

Напоминаю, что цель eBPF обеспечить безопасное написание скриптов ядра. То есть запрещены случайные доступы к памяти. Любые обращения к памяти должны быть подтверждены. Кроме случаев, когда требуемая память находится в стеке, тогда вам нужно средство доступа для чтения bpf_probe_read. Такой способ делает код громоздким для чтения \ написания, но улучшает безопасность. bpf_probe_read это вроде безопасной версии memcpy, он определен в bpf_trace.c внутри ядра. А вот и занимательные моменты:


  1. Это похоже на memcpy. Будьте внимательны к возможному падению производительности из-за множества копий.
  2. В случае ошибки, программа сначала вернет буфер инициализированный в 0, а затем выдаст ошибку. Однако это не прервет и не остановит программу.

В оставшейся статье я буду использовать следующий макрос, чтобы поддерживать всё удобочитаемым:

#define member_read(destination, source_struct, source_member)                 \
  do{                                                                          \
    bpf_probe_read(                                                            \
      destination,                                                             \
      sizeof(source_struct->source_member),                                    \
      ((char*)source_struct) + offsetof(typeof(*source_struct), source_member) \
    );                                                                         \
  } while(0)

Что позволяет нам писать:

member_read(&dev, skb, dev);

Так-то лучше!


Добавим ID в пространство имен сети.

Это, вероятно, наиболее ценная информация. Само по себе это уже веская причина для всех этих усилий, но к сожалению и самая трудная для загрузки.

Идентификатор пространства имен может быть загружен из двух мест:


  1. Структура сокета «sk»
  2. Структура устройства «dev»

Изначально я использовал сокетную структуру, так раньше работал с ней при написании solisten.py. К несчастью, я не вполне уверен почему, но пространство имен идентификатора становится недоступно для чтения, как только пересекает границу пространства имен. В значении поля все нули, что ясно свидетельствует об отсутствии доступа к памяти (вспомните как ведет себя bpf_probe_read в случае ошибок) и полностью ломает процесс.

К счастью, вариант через устройство все еще работает. Думайте об этом, как о вопросе к пакету, на каком он интерфейсе, и к интерфейсу, в чьему пространству имен он принадлежит.

struct net* net;

// Get netns id. Equivalent to: evt.netns = dev->nd_net.net->ns.inum
possible_net_t *skc_net = &dev->nd_net;
member_read(&net, skc_net, net);
struct ns_common* ns = member_address(net, ns);
member_read(&evt.netns, ns, inum);
 Здесь используется следующий дополнительный макрос, опять же для улучшения читабельности. 
#define member_address(source_struct, source_member) \
({                                                   \
  void* __ret;                                       \
  __ret = (void*) (((char*)source_struct) + offsetof(typeof(*source_struct), source_member)); \
  __ret;                                             \
})

Как дополнительный эффект, это дает возможность упростить макрос member_read. Оставлю это как самостоятельное задание для читателя.

Соединяем это вместе и… вуаля!

$> sudo python ./tracepkt.py
[  4026531957]          docker0
[  4026531957]      vetha373ab6
[  4026532258]             eth0
[  4026532258]             eth0
[  4026531957]      vetha373ab6
[  4026531957]          docker0

Это то, что вы должны увидеть при отправке запроса в контейнер Docker. Пакет проходит через локальный мост docker0, а затем перемещается к паре veth, пересекая границу пространства имен сети, и возвращает ответ по тому же пути в обратную сторону.

Это было неприятно!


Идем дальше: трассировать только запрашиваемые ответы и эхо-ответы пакетов.

В дополнение, мы также загрузим IP из пакетов, хотя заголовок IP необходимо прочитать в любом случае. Я буду придерживаться IPv4, но эта же логика подходит и для IPv6.

Плохая новость в том, что не будет ничего простого. Помните, мы работаем с ядром в плане сетевого взаимодействия. Некоторые пакеты еще не открыты. Это означает, что часть смещения заголовков еще не инициализированы. Необходимо вычислить всё, начиная с заголовка MAC адреса, и заголовка IP, и заканчивая заголовком ICMP.

Начнем понемногу, с загрузки адреса заголовка MAC и вывода адреса заголовка IP. Сам по себе заголовок MAC загружать нет необходимости, вместо этого предположим, что он в длину 14 байтов.

// Compute MAC header address
char* head;
u16 mac_header;

member_read(&head,       skb, head);
member_read(&mac_header, skb, mac_header);

// Compute IP Header address
#define MAC_HEADER_SIZE 14;
char* ip_header_address = head + mac_header + MAC_HEADER_SIZE;

В основном, это означает что заголовок IP начинается с skb→head + skb→mac_header + MAC_HEADER_SIZE;.

Теперь возможно расшифровать версию IP по первым четырем битам заголовка, то есть по первой половине первого байта, и убедится, что это IPv4.

// Load IP protocol version
u8 ip_version;
bpf_probe_read(&ip_version, sizeof(u8), ip_header_address);
ip_version = ip_version >> 4 & 0xf;

// Filter IPv4 packets
if (ip_version != 4) {
    return 0;
}

Теперь: загрузим весь заголовок IP, возьмем сам IP, чтобы сделать информацию Python полезнее. Убедимся, что следующий заголовок это ICMP, и вычислим смещение заголовка ICMP.

Да, вот это всё:

// Load IP Header
struct iphdr iphdr;
bpf_probe_read(&iphdr, sizeof(iphdr), ip_header_address);

// Load protocol and address
u8 icmp_offset_from_ip_header = iphdr.ihl * 4;
evt.saddr[0] = iphdr.saddr;
evt.daddr[0] = iphdr.daddr;

// Filter ICMP packets
if (iphdr.protocol != IPPROTO_ICMP) {
    return 0;
}

Наконец можно загрузить заголовок ICMP и удостовериться, что это эхо-запрос ответа, и загрузить из него id и seq:

 // Compute ICMP header address and load ICMP header
char* icmp_header_address = ip_header_address + icmp_offset_from_ip_header;
struct icmphdr icmphdr;
bpf_probe_read(&icmphdr, sizeof(icmphdr), icmp_header_address);

// Filter ICMP echo request and echo reply
if (icmphdr.type != ICMP_ECHO && icmphdr.type != ICMP_ECHOREPLY) {
    return 0;
}

// Get ICMP info
evt.icmptype = icmphdr.type;
evt.icmpid   = icmphdr.un.echo.id;
evt.icmpseq  = icmphdr.un.echo.sequence;

// Fix endian
evt.icmpid  = be16_to_cpu(evt.icmpid);
evt.icmpseq = be16_to_cpu(evt.icmpseq);

Вот и всё, ребята!

Если хотите сортировать ICMP по определенному экземпляру ping, можно предположить, что evt.icmpid это PID ping, по крайней мере используя ping Linux.


Время для шоу!

Имея обычный Python для обработки события, мы можем протестировать его в нескольких сценариях. Запустите программу с правами root, создайте некоторый «ping» в другом терминале и наблюдайте:

# ping -4 localhost
[  4026531957]               lo request #20212.001 127.0.0.1 -> 127.0.0.1
[  4026531957]               lo request #20212.001 127.0.0.1 -> 127.0.0.1
[  4026531957]               lo   reply #20212.001 127.0.0.1 -> 127.0.0.1
[  4026531957]               lo   reply #20212.001 127.0.0.1 -> 127.0.0.1

Эхо-запрос ICMP отправляется процессом 20212 (ICMP id в ping Linux) с интерфейса loopback, доставляется на тот же самый интерфейс, где эхо-ответ генерируется и отправляется назад. Интерфейс loopback является как передающим, так и принимающим интерфейсом.

А что насчет моего WiFi шлюза?

# ping -4 192.168.43.1
[  4026531957]           wlp2s0 request #20710.001 192.168.43.191 -> 192.168.43.1
[  4026531957]           wlp2s0   reply #20710.001 192.168.43.1 -> 192.168.43.191

В этом случае и эхо-запрос, и эхо-ответ проходят через интерфейс WiFi. Легкотня.

Немного не относящееся к делу: помните, когда мы печатали только «comm» процесса, владеющего пакетом? В этом случае эхо-запрос будет принадлежать процессу ping, в то время как ответ будет принадлежать драйверу WiFi, так как драйвер его и генерирует, если мы говорим о Linux.

И напоследок, мое любимое — пинг контейнера Docker. Это моё НЕлюбимое из-за Docker, и любимое, потому что лучше прочих показывает силу eBPF. Это позволило создать почти «рентгеновский» инструмент для пинга.

# ping -4 172.17.0.2
[  4026531957]          docker0 request #17146.001 172.17.0.1 -> 172.17.0.2
[  4026531957]      vetha373ab6 request #17146.001 172.17.0.1 -> 172.17.0.2
[  4026532258]             eth0 request #17146.001 172.17.0.1 -> 172.17.0.2
[  4026532258]             eth0   reply #17146.001 172.17.0.2 -> 172.17.0.1
[  4026531957]      vetha373ab6   reply #17146.001 172.17.0.2 -> 172.17.0.1
[  4026531957]          docker0   reply #17146.001 172.17.0.2 -> 172.17.0.1

Теперь с некоторыми рисунками это выглядит так:

      Host netns           | Container netns
+---------------------------+-----------------+
| docker0 ---> veth0e65931 ---> eth0          |
+---------------------------+-----------------+


Заключение

eBPF / bcc позволяет нам написать новый набор инструментов для глубокой диагностики, трассировки и отслеживания проблем в местах, ранее недоступных, без исправления ядра. Точки трассировки также весьма удобны, так как они дают хорошую подсказку о точках интереса, устраняя необходимость утомительного чтения кода ядра и могут быть помещены в части кода, которые в противном случае были бы недоступны для kprobes, например встроенные или статические функции.

Можно пойти дальше и добавить поддержку IPv6. Это довольно легко сделать, и я оставлю это в качестве упражнения для читателя. В идеале я хотел бы также измерить влияние на производительность, но этот пост уже и так очень и очень длинный. Было бы интересно улучшить этот инструмент, с помощью трассировки маршрутизации, iptables и ARP-пакеты. Все это превратило бы этот инструмент в идеальный «рентгеновский» трассировщик пакетов для таких людей, как я, кто когда-то боролся с нетривиальными сетевыми настройками Linux.

Как и обещал, полный код (с поддержкой IPv6) вы можете посмотреть на Github: https://github.com/yadutaf/tracepkt

Наконец, я хотел бы поблагодарить @fcabestre за помощь, за помощь в спасении рабочего черновика этой статьи из неисправного жесткого диска, @bluxte за его терпеливую корректуру и людей из bcc, которые сделали этот пост технически возможным.


Примечания


Я заключил «контейнеры» в кавычки, поскольку с технической точки зрения сетевые пространства имен являются одним из многих составляющих блоков контейнеров Linux.

© Habrahabr.ru