Сервер с ядром возрастом в семь или десять лет никого уже не удивляет: крупные компании годами поддерживают старые парки машин, а сервисы на них иногда ведут себя загадочно. Современные утилиты динамической трассировки на таких узлах просто не собираются, их рантайм рассчитан на свежие ядра. SystemTap существует с 2005 года и остаётся проверенным способом заглянуть в живое ядро без перезагрузки и без правки кода. В статье разбирается, как он устроен изнутри, какие задачи решает на практике и как встроить его в повседневную работу инженера. Пробы ставятся точечно, не требуют ни перезапуска сервиса, ни отдельной сборки, и в этом их главное преимущество перед разбором логов постфактум.
Отличия от инструментов на eBPF и работа со старыми ядрами
Динамическая трассировка на свежих ядрах выросла вокруг eBPF: виртуальная машина с проверкой безопасности внутри ядра и утилиты bpftrace и BCC поверх неё. Экосистема сильная, но требует нового ядра. Программы на eBPF подключили к kprobes в ядре 4.1 в 2015 году, трейспоинты приняли в 4.7 в 2016 году, формат BTF появился в 4.18 в 2018 году, а удобные кольцевые буферы дождались версии 5.8 в 2020 году. Комфортной платформой для bpftrace считается ядро не младше ветки 4.x, часть возможностей требует 5.x. На узлах с ядром 2.6.32, как в RHEL 6, или 3.10, как в RHEL 7, готовые eBPF утилиты либо не стартуют, либо работают с урезанным набором функций.
SystemTap в этих условиях чувствует себя дома. Его рантайм опирается на kprobes, uprobes и трейспоинты, которые существуют с ветки 2.6, поэтому один и тот же скрипт работает и на десятилетнем ядре, и на свежем. При этом проект умеет выпускать и программы eBPF: с версии 4.0 доступен режим stap --runtime=bpf. Проект вырос из идей DTrace в Solaris, но обзавёлся собственным языком и библиотекой готовых тапсетов, которые прячут различия версий ядра. Для инженера всё выглядит одинаково: один язык скриптов на весь парк машин, от старых виртуальных узлов до новейшего железа. Соседи по цеху тоже полезны: perf считает события на счётчиках процессора без всяких скриптов, ftrace строит графики вызовов встроенными средствами ядра. Их стоит держать в арсенале, но как только вопрос формулируется словами, SystemTap вне конкуренции.
Путь скрипта от исходного текста до модуля ядра
Схема работы прозрачна. Транслятор stap читает скрипт, проверяет типы и безопасность обращений, разворачивает пробы из библиотек тапсетов и выпускает файл на C. Дальше в дело вступает системный компилятор, который собирает модуль ядра. Загружает его утилита staprun, а связанный с ней процесс stapio передаёт вывод на терминал или в файл. Скомпилированные модули попадают в кэш в каталоге пользователя, и повторный запуск того же скрипта на том же ядре начинается мгновенно. Имена проб выстроены по уровням: syscall покрывает системные вызовы, vfs работает с файловой системой, tcp следит за сетью, timer за таймерами, и такое имя сразу подсказывает, где искать нужную точку.
Для символических проб вроде kernel.function("vfs_read") нужны отладочные символы ядра: пакеты kernel-devel и kernel-debuginfo в семействе Red Hat, пакеты dbgsym в Debian и Ubuntu. Свежие версии SystemTap умеют подтягивать символы по протоколу debuginfod, так что ручная установка тяжёлых пакетов становится необязательной. Без символов тоже можно жить: пробы syscall, timer и статические маркеры отладочной информации не требуют. Подобрать точку перехвата помогают команды stap -l, перечисляющая пробы по маске, и stap -L, которая дополнительно показывает локальные переменные. Для ядер без установленной отладочной информации есть парный набор проб nd_syscall: он покрывает системные вызовы описанными вручную сигнатурами и работает там, где символы недоступны. Границы между наборами прозрачны: счётчики и фильтры пишутся одинаково.
Первый скрипт с подсчётом системных вызовов
Разминка для входа в язык: посчитать, какие процессы чаще всего открывают файлы. Весь скрипт помещается в десяток строк:
global opens
probe syscall.open {
opens[execname()] <<< 1
}
probe timer.s(10) {
foreach (name in opens- limit 5)
printf("%-20s %6d\n", name, @count(opens[name]))
delete opens
}
Запуск выглядит так: sudo stap opens.stp. Каждые десять секунд на экране появляется пятёрка самых активных процессов, а прерывание по Ctrl-C выгружает модуль аккуратно и без последствий. Оператор <<< накапливает значения в агрегате, функции @count, @sum, @avg и @max вытаскивают из него статистику, а @hist_log строит логарифмическую гистограмму одной строкой. Для замеров задержек в пробах с суффиксом .return служит @entry: выражение @entry(gettimeofday_us()) фиксирует время на входе, и разница с текущим значением даёт длительность системного вызова:
global lat
probe syscall.read.return {
lat <<< gettimeofday_us() - @entry(gettimeofday_us())
}
probe end {
print(@hist_log(lat))
}
Агрегаты растут вместе с числом ключей: если счётчиков становятся миллионы, транслятор упрётся в лимит MAXENTRIES и честно сообщит о переполнении, поэтому ключевые пространства заранее сужают фильтрами. Разовый прогон с флагом -k сохраняет временный каталог с сгенерированным кодом на C, и любопытный читатель может изучить, во что превратился его скрипт.
Задачи для диска и сети в ежедневной практике
Первый вопрос дежурного инженера: кто грузит диск? Отвечает проба vfs.write.return:
global bytes
probe vfs.write.return {
if ($return > 0)
bytes[execname()] <<< $return
}
probe timer.s(5) {
printf("=== %s", ctime(gettimeofday_s()))
foreach (name in bytes- limit 10)
printf("%-20s %8d KB\n", name, @sum(bytes[name]) / 1024)
delete bytes
}
В сети работает проба tcp.sendmsg со счётчиком переданных байтов, а трейспоинты уровня netdev дают картину пакетов на интерфейсе. Начало записи грязных страниц отмечает проба vm.writeback_dirty, а моменты ухода процесса с процессора и возврата дают scheduler.cpu_off и scheduler.cpu_on, и на их основе строят распределение ожидания в очереди планировщика. Когда готового тапсета нет, в дело идут трейспоинты ядра:
global iops
probe kernel.trace("block:block_rq_issue") {
iops[execname()] <<< 1
}
probe timer.s(10) {
foreach (name in iops- limit 8)
printf("%-20s %6d\n", name, @count(iops[name]))
delete iops
}
Поля трейспоинта доступны как переменные со знаком доллара, а их список печатает всё та же stap -L. Для файловых расследований пригодится фильтр по имени пути в пробе syscall.open с функцией isinstr, отбирающей только нужный каталог. Отдельный класс задач: выяснить, что за процесс держит открытый дескриптор и читает файл, который должен остаться нетронутым, или кто плодит тысячи короткоживущих потоков.
Нагрузку на процессор показывает выборка. Проба timer.profile срабатывает на тактах планировщика, и счётчик по имени процесса за полминуты складывается в честный профиль:
global busy
probe timer.profile {
busy[execname()] <<< 1
}
probe timer.s(30) {
foreach (name in busy- limit 10)
printf("%-20s %6d\n", name, @count(busy[name]))
delete busy
}
Такой профиль отвечает, чей код чаще живёт на процессоре. Для деталей к счётчику добавляют backtrace() и печать стека функцией print_stack(). Накладные расходы появляются только в момент срабатывания пробы, поэтому выборку допустимо держать включённой всё время инцидента.
Пробы пользовательских процессов и статические маркеры
Ядро не единственная мишень. Уровень uprobes ставит пробы на функции обычных программ: строка process("/opt/app/server").function("handle_request").return покажет время обработки каждого запроса. Для этого в бинарнике должны остаться символы, поэтому от тотального вырезания таблиц символов в релизе стоит отказаться. Второй путь закладывают заранее: пакет systemtap-sdt-devel добавляет в C макросы семейства DTRACE_PROBE, которые компилируются в тонкую метку в коде, почти не влияющую на скорость. Скрипт ловит метку пробой process("/opt/app/server").mark("db_query"). Такую телеметрию используют PostgreSQL и другие зрелые проекты, и в собственных сервисах приём стоит перенять: маркеры дают стабильные точки наблюдения независимо от версии сборки. Аргументы маркера приходят в скрипт как $arg1, $arg2 и далее до $arg9, поэтому метку можно сразу наполнять смыслом: идентификатор запроса, длительность, имя таблицы. Жизненный цикл процесса тоже под наблюдением: пробы process.begin и process.end отмечают старт и завершение, а process.thread.begin фиксирует каждый новый поток.
Сборка модулей вне сервера и встроенные предохранители
Компилятор и заголовки ядра на боевом узле держать не обязательно. Транслятор умеет останавливаться после сборки: команда stap -p4 -m diag -r 3.10.0-1160.el7.x86_64 diag.stp выпускает готовый модуль diag.ko. Его копируют на сервер вместе с пакетом systemtap-runtime и запускают уже там: staprun diag.ko. Для целого парка удобнее выделенные машины сборки: опция --use-server отправляет компиляцию на отдельный сервер, а модуль возвращается готовым к загрузке. Подпись модулей поддерживается на уровне проекта, поэтому машины со строгой проверкой подписей остаются доступны для диагностики. Версии транслятора и рантайма держат согласованными: модуль, собранный одним выпуском, загружается рантаймом того же выпуска без сюрпризов.
Предохранители встроены в сам рантайм. Скрипт по умолчанию не получает произвольного доступа к памяти ядра: каждое обращение проверяет транслятор. Полный контроль уровня C открывает гуру режим -g со вставками %{ ... %}, но его применяют только тогда, когда обычные средства не справляются. На высокочастотных пробах работает сторож: если проба срабатывает слишком часто и задерживает систему, часть срабатываний пропускается, о чём stap сообщает в выводе. Полезные параметры запуска: -x привязывает скрипт к идентификатору процесса, -c запускает команду под наблюдением, -T 30 завершает прогон по таймауту, -o пишет в файл, а -F уводит вывод в фоновый режим. Отдельный режим --unprivileged разрешает запуск заранее собранных модулей обычным пользователям с жёсткими ограничениями на доступ к переменным ядра, что подходит для дежурных без прав суперпользователя.
Частые ошибки первого запуска и порядок внедрения
Первый запуск редко проходит без сюрпризов. Сообщение semantic error: no probes match означает, что пробы с таким именем в этом ядре нет или не хватает отладочных символов. Ошибка про недоступные переменные указывает на неудачный выбор выражений: их набор виден в выводе stap -L. Зависание на тяжёлой пробе лечится агрегатами вместо печати в момент срабатывания. Диагностику трансляции упрощает флаг -v, показывающий каждый этап, а этапы можно обрывать: -p2 останавливается после разворачивания проб, -p4 выпускает готовый модуль. Ошибку с отсутствующими символами видно сразу по подсказке с именем пакета, и решается она установкой отладочных символов именно для версии ядра работающего узла.
Внедрение укладывается в короткую последовательность:
- Установить пакеты systemtap и systemtap-runtime и держать их версии одинаковыми на всём парке машин;
- Проверить набор доступных проб командой stap -l и наличие отладочных символов ядра;
- Сложить скрипты в репозиторий с комментариями и параметрами, чтобы любой инженер воспроизвёл прогон;
- Для критичных узлов собрать модули заранее на идентичной машине и хранить вместе с копией ядра;
- Запускать на сервере готовый модуль через staprun и выгружать его сразу после снятия показаний.
SystemTap не претендует на замену новых инструментов там, где свежие ядра доступны. Его сила в другом: на узлах, где версия ядра измеряется годами, он остаётся главным способом ответить на вопрос, кто, что и когда делает в ядре прямо сейчас. Пара десятков строк скрипта экономит часы гаданий по косвенным признакам, кэш модулей превращает повторные проверки в мгновенные, а встроенные предохранители позволяют не бояться за стабильность боевого узла. Соберите набор проверенных скриптов заранее, до первого загадочного сбоя в ночную смену, прогоните их на тестовой машине того же поколения и держите под рукой вместе с копией ядра. Дежурный инженер скажет вам спасибо, а график резких ночных инцидентов станет заметно спокойнее.