Код, который встраивается на лету: от .so-файлов до eBPF

Как разделяемые библиотеки (.so) подгружаются в программу во время запуска и почему eBPF позволяет безопасно засунуть свой код прямо в ядро Linux, не перекомпилируя его.

В Linux есть два способа добавить код в уже работающую систему. Первый происходит постоянно и незаметно — когда любая программа подтягивает разделяемые библиотеки. Второй сложнее и глубже — он позволяет ядру выполнить чужой код прямо внутри себя. Речь про .so-файлы и eBPF.

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

Часть первая: .so-файлы

Что это вообще такое

Расширение .so — от shared object, разделяемый объект. Это скомпилированный кусок кода, который программа подтягивает не при сборке, а уже во время работы. Аналог в мире Windows — файлы .dll.

Смысл простой. Возьмём функцию printf. Её код нужен сотням программ. Можно вшить копию в каждую — тогда каждый исполняемый файл потолстеет, а в памяти будут болтаться десятки одинаковых копий одного и того же. А можно вынести printf в отдельный файл libc.so, и пусть все программы обращаются к нему. Один файл на диске, одна копия в памяти, а пользуются все.

        БЕЗ РАЗДЕЛЯЕМЫХ БИБЛИОТЕК
   [программа A: свой printf]
   [программа B: свой printf]   ← три копии одного кода
   [программа C: свой printf]

        С РАЗДЕЛЯЕМОЙ БИБЛИОТЕКОЙ
   [программа A] ─┐
   [программа B] ─┼──► libc.so (один printf на всех)
   [программа C] ─┘

Как программа находит нужную библиотеку

Тут начинается самое интересное. Когда ты запускаешь программу, она ещё не знает, где физически лежит libc.so. В исполняемом файле записано только имя нужной библиотеки — что-то вроде «мне требуется libssl.so.3». А найти файл и подгрузить его в память должен кто-то другой.

Этим занимается динамический компоновщик — отдельная программа ld.so. Ядро запускает её раньше твоего кода. Компоновщик читает список зависимостей, ищет каждую библиотеку по заранее заданным путям, грузит в память и связывает вызовы из твоей программы с реальными адресами функций.

Порядок поиска примерно такой:

  • сначала пути из переменной LD_LIBRARY_PATH, если она задана;
  • потом кэш /etc/ld.so.cache, который собирается командой ldconfig;
  • в конце — стандартные каталоги вроде /lib и /usr/lib.

Посмотреть, какие библиотеки нужны конкретной программе, можно командой ldd:

$ ldd /bin/ls
    linux-vdso.so.1
    libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
    /lib64/ld-linux-x86-64.so.2

Вот эти стрелки => и показывают, куда компоновщик разрешил каждое имя. Если напротив библиотеки написано not found — программа не запустится, и ошибка будет ровно про это.

Зачем это знать на практике

Пока всё работает, про .so-файлы можно не вспоминать. Проблемы начинаются, когда что-то ломается, и почти всегда это один из двух случаев.

Первый — библиотеку не нашли. Свежесобранный бинарник запускается, а в ответ error while loading shared libraries. Значит, компоновщик прошёлся по всем своим путям и нужного файла не встретил. Лечится либо установкой недостающего пакета, либо добавлением пути через ldconfig.

Второй, коварнее, — нашли не ту версию. Программу собрали под одну версию библиотеки, а в системе стоит другая, несовместимая. Отсюда цифры в именах: libssl.so.3 и libssl.so.1.1 — это разные, непохожие друг на друга библиотеки, а не просто обновление. Ломается это обычно после обновления системы или при переносе бинарника между разными дистрибутивами.

Именно поэтому статически слинкованные программы (когда весь код вшит внутрь и никакие .so не нужны) так удобно раскатывать. Один файл, никаких внешних зависимостей, работает везде одинаково. Плата за это — размер и то, что ради обновления той же libc придётся пересобирать всё заново.

Часть вторая: eBPF

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

Проблема, которую он решает

Ядро Linux — это святая святых. Оно управляет памятью, сетью, файловой системой, процессами. Влезть туда со своим кодом всегда было тяжело и опасно. Есть два традиционных пути, и оба так себе.

Можно написать модуль ядра. Но твой код будет исполняться с полными правами ядра, и одна ошибка с указателем роняет всю машину. Отлаживать такое — удовольствие ниже среднего.

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

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

В чём идея eBPF

eBPF (extended Berkeley Packet Filter) переворачивает подход. Вместо того чтобы менять само ядро, ты пишешь маленькую программу и просишь ядро выполнить её в определённый момент — например, каждый раз, когда какой-то процесс открывает файл.

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

   твой код ──► ВЕРИФИКАТОР ──► компиляция ──► ядро выполняет
                    │
                    ▼
            не прошёл проверку — отказ,
            код в ядро не попадает

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

К чему можно прицепиться

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

  • вызовы к ядру (те самые системные вызовы — открытие файла, запуск процесса);
  • сетевые события, вплоть до самого раннего момента, когда пакет только пришёл на сетевую карту;
  • специальные точки трассировки, заранее расставленные по коду ядра;
  • почти любая функция ядра или даже пользовательской программы.

Когда наступает нужное событие, ядро приостанавливается, запускает твою eBPF-программу, та отрабатывает и возвращает управление. Результаты она складывает в особые структуры — карты (maps), которые видны и из ядра, и из обычной программы в пользовательском пространстве. Через эти карты ты и забираешь собранные данные наружу.

Где это реально применяют

eBPF звучит абстрактно, пока не увидишь, во что он вырос. А вырос он в целый пласт инструментов, которыми пользуются каждый день, часто не подозревая об этом.

Наблюдаемость — самое очевидное. Инструменты вроде bcc и bpftrace позволяют на живой машине, без остановки и перезагрузки, посмотреть буквально что угодно: какие процессы жрут процессор, кто открывает какие файлы, сколько времени уходит на обращения к диску. Раньше за такой глубиной пришлось бы лезть в отладчик или пересобирать ядро.

Сеть и безопасность — второе большое направление. Cilium строит на eBPF всю сетевую связность в Kubernetes: маршрутизацию, балансировку, политики доступа между подами. Всё это крутится в ядре, а не в медленных обвязках поверх него. А проекты вроде Falco следят за системными вызовами и поднимают тревогу, если процесс начинает вести себя подозрительно — скажем, контейнер вдруг полез читать /etc/shadow.

Общий знаменатель у всех этих штук один: увидеть или отфильтровать что-то на уровне ядра, на полной скорости и без риска уронить машину. То, что раньше требовало модуля ядра и крепких нервов, теперь пишется отдельной небольшой программой, которую ядро само проверит перед запуском.

Одна и та же идея на разной глубине

Если отступить на шаг, .so-файлы и eBPF рифмуются сильнее, чем кажется. И то и другое — способ добавить код в готовую систему на ходу, не пересобирая её.

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

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

← Back to Blog