Каждый, кто отвечал за продакшен-серверы, знает этот вечер: выходит патч безопасности для ядра, а перезагружать машину нельзя. База данных держит тысячи клиентских соединений, окно обслуживания согласовано на месяц вперёд. Обычное обновление превращается в многоходовку согласований и переносов. Живые патчи появились ровно из этой боли: критичное исправление заезжает в работающее ядро, сервисы продолжают отвечать, соединения не рвутся.

Механизм livepatch вошёл в основную ветку ядра Linux в версии 4.0 в 2015 году и собрал в себе наработки двух конкурировавших проектов: kGraft от SUSE с переключением по задачам и kpatch от Red Hat с проверкой стека вызовов. Гибрид получился практичным. Патчи распространяются обычными модулями ядра, включаются и выключаются записью в sysfs, а ядро остаётся работоспособным на каждом этапе перехода.

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

Откуда в ядре Linux взялся механизм живых патчей

В ядре и до livepatch были инструменты для перенаправления исполнения кода. Kprobes умеет ставить точку останова почти на любую инструкцию и передавать управление своему обработчику. Ftrace вызывает обработчик в заранее подготовленном месте у входа в функцию, куда компилятор вставляет переход при сборке с опцией -pg. Живым патчам нужен был третий вариант: подмена должна срабатывать в самом начале функции, до того как изменятся регистры с параметрами и стек. По этой причине livepatch построен поверх динамического ftrace и уживается с обоими соседями. Kprobe, попавший на вход функции, регистрируется как обработчик ftrace, а патч подключает собственный обработчик, который ведёт вызов на новую версию кода.

Единицей работы служит функция. Патч-модуль несёт её новые версии плюс метаданные трёх уровней. struct klp_func описывает пару из имени оригинала и адреса замены, struct klp_object группирует функции одного объекта, где объектом выступает либо vmlinux, либо загружаемый модуль, а struct klp_patch собирает объекты воедино и применяет весь набор согласованно. Адреса оригиналов разыскиваются по kallsyms прямо при загрузке, одинаково названные функции различаются позицией символа внутри объекта. Если патч затрагивает модуль, которого в системе ещё нет, ничего не ломается: его функции пропатчатся автоматически, как только модуль появится.

Перенаправление вызова на новую версию функции через ftrace

На x86_64 каждая собранная функция начинается с вызова fentry, который в обычной жизни простаивает в виде короткого nop. Динамический ftrace умеет на лету перезаписывать эту точку на вызов обработчика и обратно. При включении патча livepatch регистрирует для каждой затронутой функции свой универсальный обработчик и создаёт структуру klp_ops со списком func_stack. Обработчик регистрируется однократно: если функцию уже патчит другой модуль, новый патч лишь добавляет запись в список. Какую версию функции исполнять, обработчик решает по состоянию конкретной задачи, поэтому во время перехода старый и новый код живут одновременно, и это ядро всей технологии.

Управление при этом почти тривиально. Состояние перехода видно в файле /sys/kernel/livepatch/livepatch_sample/transition, выгрузка делается записью нуля в соседний файл enabled, а следы вторжения ищутся в счётчике порчи ядра:

cat /sys/kernel/livepatch/livepatch_sample/transition
echo 0 > /sys/kernel/livepatch/livepatch_sample/enabled
cat /proc/sys/kernel/tainted

Нюанс в том, что включение выполняется вызовом klp_enable_patch из module_init при загрузке модуля, а не отдельной командой, зато ошибки видны по коду возврата.

Модель консистентности и переключение задач на новый код

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

При включении патча система входит в переходное состояние, отмеченное единицей в transition. Задачи переключаются по одной, когда для них это безопасно, и обычно переход завершается за считанные секунды. Спящая задача проверяется чтением стека: если ни одной затронутой функции на стеке нет, задача переключается, и, как правило, с первого захода переключается большинство задач в системе. Чтение стека обязано быть надёжным, за это отвечает конфигурация HAVE_RELIABLE_STACKTRACE. Задачи, уснувшие внутри пропатченной функции, дёргают сигналами: связка SIGSTOP и SIGCONT заставляет их выйти в пространство пользователя и вернуться уже с новым состоянием. Процессорозависимые задачи переключаются на ближайшем прерывании, а холостые задачи idle проходят через точку цикла простоя с вызовом klp_update_patch_state. Для потоков ядра универсальной точки нет, поэтому архитектуры без надёжного обхода стека полноценно не поддерживаются.

Кто тормозит переход, видно в файле /proc/номер_процесса/patch_state: ноль значит задача на старой версии, единица значит на новой, минус единица значит, что перехода нет. Каждые 15 секунд блокирующим задачам уходит фальшивый сигнал, будящий их без реальной доставки. Застрявший переход откатывается записью противоположного значения в enabled. Совсем упрямый случай гасится атрибутом force, сбрасывающим флаг TIF_PATCH_PENDING у всех задач сразу. С force обращаются осторожно: выгрузка модуля после принудительного перехода блокируется навсегда, ведь никто не поручится, что какая-то задача не спит в старом коде, поэтому после force планируют перезагрузку. Одновременно в системе допускается только один переход.

Сборка патч-модуля через kpatch-build и переход на klp-build

Вручную собирать патч-модуль можно, но муторно. Инструмент kpatch-build автоматизирует весь путь: он собирает ядро дважды, с исходным диффом и без него, следит, какие объектные файлы пересобрались, перекладывает изменённые объекты с флагами -ffunction-sections и -fdata-sections, после чего утилита create-diff-object сравнивает пары объектов на предмет применимости и выдаёт секции .kpatch.funcs и .kpatch.dynrelas. Из них линкуется готовый модуль. Требования скромные: gcc не старше 4.8 и ядро начиная с 4.0.

Классическая демонстрация из поставки инструмента меняет вывод /proc/meminfo, заменяя строку VmallocChunk её заглавной версией. Рабочий цикл отдельного патча умещается в шесть шагов:

  1. инженер готовит исходный дифф против дерева ядра той версии, что стоит на серверах;
  2. kpatch-build собирает ядро дважды и вычисляет изменённые объекты;
  3. из ELF-разницы формируется модуль вида livepatch-meminfo-string.ko;
  4. модуль загружается командой kpatch load, адреса функций ищутся по kallsyms;
  5. ядро входит в переходное состояние, задачи переключаются порциями;
  6. после обнуления transition патч работает до выгрузки kpatch unload или замены накопительным патчем.

Дистрибутивы надстроили над этой базой сервисы: в RHEL патчи идут через kpatch, у SUSE есть SLE Live Patching, в Ubuntu работает служба livepatch из подписки Pro. Свежая перемена: с ядра 6.19 проект kpatch переведён в режим сопровождения, а его сборщик заменяет штатный klp-build в дереве ядра, в каталоге scripts/livepatch. Для x86_64 поддержка уже есть, arm64 в разработке, следом ожидается ppc64le, s390 и loongarch. Сборка патчей становится частью самого ядра, с меньшим числом прослоек между диффом и модулем.

Теневые переменные и колбэки для данных, которые нельзя менять напрямую

Патчить статически выделенные данные сборщик отказывается и честно возвращает ошибку. Обход известный: теневые переменные, появившиеся в ядре 4.14. Функции klp_shadow_alloc, klp_shadow_get и klp_shadow_free цепляют к живому объекту дополнительные данные по числовому ключу, словно у структуры появилось поле, о котором старый код не подозревает. Старые функции работают как работали, новые читают и пишут теневую копию, а при выгрузке патча память освобождается тем же набором вызовов.

Для подготовки окружения служат колбэки патчей из ядра 4.15: pre_patch, post_patch, pre_unpatch и post_unpatch. В них делают то, что не выразить заменой функции: переводят устройство в нужный режим, готовят и разбирают теневые данные, синхронизируют состояние между этапами перехода. Отдельная тонкость связана с inline-функциями: правка статической inline-подпрограммы тянет за собой перекомпиляцию вызывающих, и kpatch-build отрапортует об изменённых функциях, которых патч вроде не касался. Это не ошибка сборщика, а свойство компилятора, поэтому список изменённых функций читают внимательно, а не пролистывают.

Копить патчи слоями плохая практика. Каждый отдельный модуль добавляет свою запись в func_stack, и выбор нужной версии усложняется. Накопительный патч с флагом .replace заменяет весь предыдущий набор целиком: после перехода старые записи вычищаются, лишний обработчик ftrace снимается, и функция несёт двойную нагрузку только в течение перехода. Слияние нескольких диффов в один делает утилита combinediff.

Накладные расходы живых патчей и их сокращение в продакшене

Цена подмены складывается из пары лишних переходов на каждый вызов: точка fentry ведёт в обработчик ftrace, тот направляет вызов в новую версию функции. Разовые вызовы дорожают на считаные наносекунды, и на обычных службах разница неразличима. Проявляется она на горячих путях, где функцию дёргают миллионы раз в секунду: планировщик, обработка сетевых пакетов, системные вызовы ввода-вывода. Там даже наносекунды складываются в измеримые проценты.

Практика сокращения расходов выглядит так. Первое: патчить ровно те функции, что изменились, не таща попутные правки из-за инлайнов. Второе: одна накопительная заплатка вместо стопки мелких, чтобы обработчику не приходилось продираться через многослойный func_stack. Третье: планирование перехода на период низкой нагрузки, потому что во время перехода часть вызовов идёт через двойную логику выбора версии. Четвёртое: контроль соседей по точке входа, ведь kretprobe, успевший занять обработчик ftrace, не пустит патч, и наоборот, первым ресурс получает тот, кто пришёл раньше. Пятое: снять kprobe с пропатченных функций, ведь после перенаправления его вызовы молча игнорируются.

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

Ограничения механизма и типичные ошибки эксплуатации

Живые патчи лечат код, но не всё на свете. Функции с пометкой __init не патчатся: после загрузки ядра их код уже выгружен, и kpatch-build откажется собирать такой патч. Недостижим vDSO, работающий в пространстве пользователя, куда ftrace не дотягивается. Не патчатся функции из библиотечных архивов lib.a без вызова fentry, например часть строковых подпрограмм. Не тронуть и функции с пометкой notrace, иначе обработчик зациклится сам на себе. Конфликт с kretprobe уже описан, а kprobe в оригинальной функции после перенаправления перестаёт срабатывать, и отладчик начинает дезинформировать инженера, если тот об этом забыл.

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

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