Код, который встраивается на лету: от .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: одни и те же принципы всплывают на разных уровнях, стоит копнуть чуть глубже.