Эпидемиология дампа памяти: исправление ошибки возрастом 18 лет
Использование анализа на уровне популяции для отладки сложных сбоев в нашей инфраструктуре данных.
Автор: Натан Бронсон (Nathan Bronson), сотрудник технического персонала
Модели и агенты OpenAI все больше полагаются на масштабируемую инфраструктуру данных для поиска релевантных данных во время инференса — когда модели обдумывают ваш вопрос. Некоторые из этих сервисов написаны на C++, низкоуровневое управление системой в котором позволяет нам максимизировать производительность и минимизировать использование памяти. Эти преимущества эффективности очень важны по мере нашего масштабирования, но отсутствие безопасности памяти в C++ означает, что ошибки могут приводить к сбоям из-за записи в некорректные или несуществующие адреса памяти.
Несколько месяцев назад мы зафиксировали несколько сбоев внутри сервиса Rockset — специализированной части нашей инфраструктуры данных ChatGPT, которая играет ключевую роль во многих плагинах данных и поиске по беседам. В каждом из таких сбоев нормальная функция на C++ вроде бы завершала работу, а затем возвращалась по фиктивному адресу, из-за чего ядро останавливало программу, поскольку указатель команд больше не указывал на код. Иногда слот адреса возврата в кадре стека был равен NULL. Иногда регистр процессора указатель стека сам по себе казался смещенным на 8 байт, как будто %rsp каким-то образом уменьшился посередине нормального выполнения. В обоих случаях сбой происходил при возврате.
Это нетипичные режимы сбоя для кода приложения. Случайная запись, которая попадает только в сохраненный адрес возврата, возможна, но крайне маловероятна. Ошибка, которая дезориентирует %rsp на 8 байт без использования встроенного ассемблера, setcontext или longjmp (ничего из этого мы не используем), еще более странна, поскольку скомпилированный код напрямую изменяет этот регистр только в прологе и эпилоге функции. Каждая гипотеза, которую мы (или ChatGPT) могли придумать, имела веские опровержения, поэтому ошибка казалась невозможной.
То, что мы считали одной проблемой, в итоге оказалось двумя не связанными друг с другом ошибками, случайно обнаруженными одновременно. Первая — скрытое повреждение оборудования на одном хосте Azure, где ЦП просто неправильно выполнял математические операции. Вторая — 18-летняя ситуация гонки в GNU libunwind, незамеченная ошибка в широко используемой библиотеке с открытым исходным кодом.
Эта статья — рассказ о том, как мы выявили и исправили казалось бы необъяснимые сбои, начав мыслить как эпидемиологи и создав высококачественный набор данных по всей совокупности сбоев.
Первая попытка отладки: тщательное изучение нескольких дампов памяти
Сначала давайте подробнее рассмотрим Rockset. Это облачная система данных для поиска и аналитики в реальном времени, которую мы используем для множества внутренних задач в OpenAI, таких как коннекторы синхронизации (Rockset была приобретена OpenAI в 2024 году). Потоковые обновления используются для поддержания актуального индекса базы знаний рабочего пространства, чтобы ChatGPT мог искать релевантную информацию при ответе на вопросы или выполнении действий.
Уровень выполнения Rockset написан на C++. Язык C++ обеспечивает низкоуровневый доступ к ЦП, что хорошо для производительности и эффективности, но это означает, что ошибки в приложениях могут приводить к недопустимым обращениям к памяти и сегфолтам. Чтобы помочь в их отслеживании, мы используем обработчик фатальных сигналов folly для логирования трассировки стека при возникновении сбоя, а также выгружаем соответствующие дампы памяти (снимок состояния программы в момент сбоя) в хранилище BLOB-объектов Azure для последующего анализа. Все листья обработки запросов Rockset реплицируются, что минимизирует влияние сбоя на клиента. Тем не менее, каждый сегфолт соответствует ошибке, которую необходимо исправить для достижения наших целей по надежности и качеству.
Наш первоначальный подход заключался в том, чтобы относиться к этим дампам как к стандартной проблеме отладки: очень внимательно изучить несколько дампов памяти, сформулировать гипотезы и отбраковывать их одну за другой.
Большинство сбоев происходило в методе под названием DocumentTree::updateDocument. Во время этих сбоев казалось, что updateDocument вызвала какую-то неизвестную функцию X, стек был поврежден во время работы X, после чего X вернулась по адресу, который не являлся исполняемым кодом. В некоторых случаях только что вытолкнутый кадр X выглядел валидным, за исключением того, что его сохраненный адрес возврата был равен NULL. В других случаях сам указатель стека выглядел неверным, но следующий валидный кадр все равно казался updateDocument.
Мы не знали, когда именно повреждается стек, что оставляло огромное пространство для поиска. updateDocument — это большой метод, который подвергается активному инлайнингу, поэтому количество кандидатов на роль X было огромным.
Была ли это ошибка в нашем коде на C++? Проблема компилятора или компоновки? Проблема в одной из наших библиотек времени выполнения? Ошибка ядра Linux, связанная с доставкой сигналов или переключением контекста? Что-то еще более редкое? Если это была случайная запись, почему ее не поймала наша промежуточная среда ASAN?
Мы попытались использовать наши логи уровня приложения для выявления всех случаев проблемы, но ошибки повреждения стека трудно классифицировать только по логам, поскольку сами записанные трассировки стека оказываются поврежденными или отсутствуют. Нам не удалось составить поисковый запрос для логов, который не выдавал бы как ложноположительные, так и ложноотрицательные результаты. Мы вручную изучили дополнительные дампы и нашли несколько дополнительных примеров, но этот процесс оказался слишком трудоемким, чтобы предоставить нам надежный набор данных.
На этом этапе расследования мы (ошибочно) исключили аппаратную ошибку, поскольку видели сбои в разных регионах и на разных типах оборудования, поэтому мы все еще искали причины, связанные исключительно с программным обеспечением. В течение нескольких дней мы очень глубоко погружались в один сбой с дезориентированным %rsp, восстанавливая предысторию сбоя с помощью содержимого стека и регистров. Это дало некоторые возможные зацепки, но поскольку мы не отказались от наших первоначальных выводов о том, что у всех ошибок была одна и та же причина, это не помогло нам сдвинуться с мертвой точки.
Подсказки из стека
Прежде чем перейти к переломному моменту нашего расследования, важно объяснить, какую именно информацию мы извлекали из файлов дампов.
Rockset компилируется с -fno-omit-frame-pointer, поэтому активный кадр стека всегда доступен через %rbp, а вызывающие функции образуют связанный список указателей кадров.
В Linux x86_64 системный ABI AMD64 System V также резервирует 128 байт ниже %rsp в качестве красной зоны (red zone). Эта область доступна для кода пространства пользователя, и, что важно, в рамках контракта ABI ядро обещает не затирать ее при доставке сигнала.
Красная зона играла центральную роль в отладке сбоя после возврата, поскольку она сохраняет некоторую информацию с момента до возврата. Когда срабатывает SIGSEGV, обработчик фатальных сигналов folly запускается в стеке аварийного потока. Кадры стека, которые больше не активны (поскольку их функция вернула управление), будут затерты обработчиком сигналов, за исключением последних 128 байт. Именно поэтому мы можем говорить такие вещи, как «только что вытолкнутый кадр стека функции X выглядел валидным, за исключением нулевого адреса возврата». Красная зона сохраняет некоторые из неактивных кадров или иногда просто хвост одного неактивного кадра.
Мы обнаружили один сбой из-за смещения стека, в котором все задействованные функции были очень маленькими. Это позволило нам увидеть, что %rsp сместился во время выполнения относительно простой функции, и что после этого успешно выполнилось еще несколько вызовов. Программа аварийно завершала работу только тогда, когда активная функция наконец пыталась сделать возврат. Ни один из этих путей кода не использовал исключения, встроенный ассемблер, setcontext или longjmp, поэтому, если указатель стека действительно изменился так, как предполагал дамп, ни одна правдоподобная ошибка в коде пространства пользователя не объясняла проблему.
Это подтолкнуло нас в сторону ядра.
Rockset использует сигналы более агрессивно, чем большинство программ. Выполнение запросов разбивается на множество легких задач, которые обмениваются данными. Это важно для эффективной обработки рабочих нагрузок с высоким QPS, но делает учет процессорного времени на один запрос неудобным, поскольку работа множества запросов мультиплексируется на один и тот же пул потоков.
Наше решение — это то, что мы называем coarse_thread_cputime_clock, которое аппроксимирует clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) достаточно дешево, чтобы выполнять выборку на каждой границе задачи. API timer_create может использоваться для планирования периодической доставки сигналов на основе нескольких представлений о прохождении времени, включая накопление процессорного времени. Мы планируем доставку сигнала (SIGUSR2) каждые несколько миллисекунд процессорного времени, после чего обработчик сигналов обновляет локальное значение потока. Несмотря на то, что многие задачи не видят продвижения грубых часов во время своего выполнения, суммирование всех дельт дает несмещенную оценку фактического процессорного времени для запроса.
Поскольку мы доставляем сигналы так часто, редкая ошибка ядра, связанная с переключением контекста или доставкой сигналов, казалась правдоподобной. Мы потратили время на чтение отчетов об ошибках, исходного кода ядра и патчей ядра для конкретной среды Azure. Мы пробовали стресс-тесты. Нам не удалось найти ничего, что казалось бы связанным с проблемой.
В этот момент мы решили сделать шаг назад и попробовать другой подход.
Врач или эпидемиолог?
Есть два основных способа отладки подобной проблемы.
Один из них — действовать вроде врача: сосредоточиться на одном пациенте, провести множество тестов и попытаться диагностировать конкретный случай по детальным свидетельствам.
Другой — действовать больше как эпидемиолог: рассмотреть всю популяцию в целом и задать вопрос, существуют ли паттерны, которые не может выявить отдельный случай. Возникла ли ошибка с определенного релиза? Коррелирует ли она с определенным артикулом оборудования (конкретным ЦП и моделью сервера), одним регионом или одной версией ядра? Есть ли несколько различных кластеров, скрывающихся внутри того, что выглядит как один синдром?
Мы в основном находились в режиме врача. Ключевым сдвигом стало решение о том, что нам нужно собрать высококачественные данные о популяции.
Очистка данных
Наши предыдущие попытки автоматически найти все экземпляры проблемы потерпели неудачу, потому что мы пытались использовать текстовый поиск по логам. Сами дампы памяти содержат гораздо больше информации, но их ручной просмотр не масштабировался. Мы решили инвестировать усилия в создание конвейера, который мог бы автоматически анализировать дампы памяти.
Мы попросили ChatGPT написать скрипт, который скачивал префикс каждого файла дампа, извлекал регистры, фильтровал известные ложноположительные результаты с использованием логов и автоматически маркировал сбой как return-to-null, misaligned-stack или прочее. Затем мы запустили этот скрипт параллельно для каждого продакшн-дампа Rockset за предыдущий год.
Это был поворотный момент.
Как только у нас появился чистый набор данных, корреляции проявились немедленно. То, к чему мы относились как к одной странной ошибке, на самом деле оказалось двумя отдельными популяциями сбоев.
Дампы return-to-null были распределены по многим кластерам и географическим регионам. Их частота в последнее время возросла, но не было четкой даты начала и четкой границы инфраструктуры.
Сбои со смещенным стеком выглядели совершенно иначе. Они все происходили из одного региона, имели четкую дату начала и никогда не случались на узлах, которые долго работали. Несмотря на то, что в них было задействовано несколько виртуальных машин Azure (виртуальных машин, размещенных в облаке), паттерн выглядел так, будто одна физическая машина с неисправным оборудованием создает проблемы для любой виртуальной машины, которой случалось на ней запуститься.
Именно в тот момент мы поняли, что мысленно смешивали две разные ошибки. Поскольку мы объединяли контрпримеры от обеих багов, нам не удавалось найти единое логичное объяснение.
Ошибка № 1: проблемный хост
Имея на руках четкий список узлов Kubernetes и временных меток, мы смогли проследить падения из-за смещенного стека до конкретного физического хоста, который оказалось легко внести в черный список.
Нам не удалось воспроизвести повреждение регистров на этом хосте в контролируемой среде даже после нескольких недель стресс-тестирования. Тем не менее, как только проблемный хост был выведен из эксплуатации, сбои со смещением стека прекратились.
Удаление плохого хоста не является перманентным решением в том смысле, что оно не предотвращает повторное появление той же проблемы в будущем. Однако мы можем изменить программное обеспечение так, чтобы при возникновении подобной проблемы ее можно было легко обнаружить и обработать. Мы усовершенствовали обработчик фатальных сигналов, добавив в него состояние регистров, чтобы отслеживать рецидивы исключительно по логам (без необходимости создания дампа памяти). Мы изменили плоскость управления так, чтобы виртуальные машины обычно переиспользовались, а не пересоздавались, что значительно упрощает обнаружение неисправных узлов на нашем уровне инфраструктурного стека. Мы также обновили наши инструкции (и ментальные модели команды), включив в них этот сценарий.
После того как сбои на проблемном хосте были выделены в отдельную категорию, оставшиеся дампы падений с возвратом в NULL стали гораздо понятнее. Ранее мы исключили раскрутку исключений (exception unwinding), потому что считали, что у нас есть контрпримеры: падения на путях кода, где исключения точно не использовались. Но все эти контрпримеры относились к кластеру аппаратных повреждений.
Когда мы снова вернулись к оставшимся дампам с учетом этого факта, выяснилось, что наш первоначальный вывод был прямо противоположным: все падения происходили именно во время раскрутки исключений.
Обработка исключений — это динамическая передача управления
Когда в C++ выбрасывается исключение, среда выполнения должна определить, какой блок catch должен его перехватить и какие деструкторы или обработчики очистки должны выполниться в процессе. Компилятор генерирует эти метаданные, но фактическое сопоставление происходит динамически во время выполнения программы.
Раскрутка исключений на самом деле выполняется не той функцией, которая вызывает throw, а вспомогательными функциями, вызываемыми скомпилированным кодом. Эти рутины среды выполнения исследуют стек, извлекают метаданные о найденных в стеке функциях, динамически ищут обработчики очистки и блоки catch, а затем передают управление в одно из этих мест. Передача управления включает в себя раскрутку всех промежуточных кадров стека (включая кадры самих вспомогательных функций).
С точки зрения функционирования, это гораздо ближе к longjmp или переключению файберов, чем к обычному вызову и возврату. Должны быть восстановлены сохраняемые вызываемой функцией регистры (callee-save), а также регистры кадров стека %rbp и %rsp.
Наш бинарный файл компонуется с двумя библиотеками, содержащими реализации функций, которые выполняют раскрутку исключений C++: libgcc и GNU libunwind. Динамический компоновщик отдал предпочтение определениям из GNU libunwind. Это нас удивило; мы ожидали победы реализации из libgcc из-за правил версионирования символов, однако проверка работающих бинарных файлов показала, что это не так.
Отказ от последнего предположения
В этот момент наша рабочая гипотеза изменилась, поскольку мы пересмотрели еще одно предположение, сделанное тогда, когда мы думали, что баг всего один.
Возможно, мы наблюдали не обычный возврат функции в NULL. Возможно, мы имели дело с передачей управления при раскрутке — по сути, восстановлением регистров в стиле setcontext, — когда целевой указатель инструкций стал нулевым до того, как управление было передано. Иными словами, проблема заключалась в некорректных данных от библиотеки раскрутки, а не в неверном слоте адреса возврата в стеке.
Это значительно сузило круг поиска проблемы. Либо GNU libunwind вычисляла неверное состояние назначения, либо она вычисляла правильное состояние, но что-то повреждало его до того, как оно успевало примениться.
Мы изучили исходный код GNU libunwind и обнаружили, что она синтезирует структуру ucontext_t в стеке, заполняет желаемое состояние регистров для кадра обработчика очистки, а затем передает указатель на эту структуру внутренней ассемблерной рутине: _Ux86_64_setcontext.
К этому моменту у нас сложились все элементы пазла.
Синтезированная структура ucontext_t находится в одном из кадров стека, которые раскручиваются функцией _Ux86_64_setcontext во время ее выполнения. Неужели _Ux86_64_setcontext считывала данные из структуры после того, как изменила %rsp, в результате чего структура уже не являлась частью активного стека? Это делало бы ее уязвимой к затиранию из-за доставки сигнала, такого как наш частый SIGUSR2.
Ошибка № 2: ошибка в libunwind
Ответ оказался утвердительным.
Вот последние шесть инструкций функции _Ux86_64_setcontext в той версии GNU libunwind, которую мы использовали. Они состоят в основном из инструкций mov, которые загружают данные из памяти в целевой регистр:
Plain Text
174: mov UC_MCONTEXT_GREGS_RSP(%rdi),%rsp275:376: /* push the return address on the stack */477: mov UC_MCONTEXT_GREGS_RIP(%rdi),%rcx578: push %rcx679:780: mov UC_MCONTEXT_GREGS_RCX(%rdi),%rcx881: mov UC_MCONTEXT_GREGS_RDI(%rdi),%rdi982: retq(%rdi указывает на выделенную в стеке структуру ucontext_t, а макросы UC_MCONTEXT_* просто раскрываются в фиксированное смещение, по которому сохраняется конкретный регистр.)
Первая инструкция знаменует собой начало окна гонки. Она обновляет %rsp так, чтобы оно указывало на новое дно активного стека. Как только это происходит, структура, на которую ссылается %rdi, перестает быть частью активного стека (или красной зоны — red zone), и ядро больше не считает ее запретной для записи зоной.
Обычно это не вызывает проблем, но если сигнал поступает в абсолютно правильный (или неправильный?) момент, ядро сформирует фрейм сигнала по адресу %rsp-128. Это может перезаписать память, на которую указывает %rdi.
Если это произойдет до того, как следующая инструкция прочитает UC_MCONTEXT_GREGS_RIP(%rdi), восстановленный указатель инструкций окажется поврежден. В наших случаях сбоя он становился нулевым (NULL).
В этом и заключается ошибка.
Почему дампы маскировались под обычные некорректные возвраты
Этот ассемблерный код также объясняет одно из наблюдений, которое нас озадачивало: почему у функции X в слоте адреса возврата предыдущего кадра стека содержался нуль.
setcontext была написана для восстановления всех регистров, включая %rdi, поэтому она не может использовать этот регистр для чтения UC_MCONTEXT_GREGS_RIP(%rdi) в самый последний момент передачи управления. Вместо этого она считывает значение заранее, сохраняет его в стек, восстанавливает еще несколько регистров, а затем использует retq, чтобы прочитать сохраненное значение и передать управление.
То, что в дампах выглядело как «функция вернулась в NULL», на самом деле означало следующее: «раскрутчик синтезировал целевой адрес возврата в стеке, но этот целевой адрес был поврежден до завершения передачи управления». Мы предполагали, что повреждение слота адреса возврата должно происходить на месте, поскольку нам не были известны случаи намеренной записи (потенциально повреждаемых) данных в слот адреса возврата.
Окно гонки длиною в одну инструкцию
Что делает эту ошибку абсурдной, так это узость окна гонки. При таком типе состояния гонки внешнее событие (сигнал) должно произойти между двумя шагами, выполняемыми другим потоком. Чем ближе эти шаги друг к другу, тем ниже вероятность возникновения гонки.
В данном случае уязвимое окно имеет ширину буквально в одну инструкцию! Сигнал должен быть доставлен после того, как изменился %rsp, но до того, как следующая инструкция загрузит %rip. Несколько подобных простых инструкций могут выполняться за один такт на современном суперскалярном процессоре с внеочередным исполнением, поэтому окно гонки составляет примерно сто пикосекунд.
Когда мы обнаружили эту гонку, нашей первой реакцией было то, что она должна быть слишком редкой, чтобы объяснить наблюдаемую частоту сбоев. Мы фиксировали более десятка падений с возвратом в NULL в день по всему парку машин. Могла ли гонка длиною в одну инструкцию при очистке исключений действительно послужить причиной этого?
Мы обратились к Ферми-оценкам. Если уязвимое окно составляет порядка 10−1010^{-10} секунд, а SIGUSR2 поступает каждые 10−210^{-2} секунды процессорного времени, то каждый обработчик очистки исключений или блок catch имеет вероятность проиграть в гонке примерно 10−810^{-8}.
Rockset использует исключения как часть своего внутреннего механизма обратного давления (backpressure) при приеме данных. Один перегруженный хост может генерировать порядка 10410^{4} исключений в секунду. Это означает, что средняя наработка на отказ хоста, использующего обратное давление, составляет 10410^{4} секунд, или один сбой каждые несколько часов. В масштабах всего парка серверов этого более чем достаточно для объяснения наблюдаемой частоты падений.
Почему ошибка в libunwind проявилась именно сейчас?
Ошибка в GNU libunwind очень старая — ей более 18 лет, она присутствовала еще в самой первой версии x86_64, поддерживающей раскрутку исключений C++.
Так почему же она проявилась только сейчас?
Частота сбоев примерно пропорциональна количеству выбрасываемых исключений и доставляемых сигналов. Она также зависит от того, сколько стека потребляет обработчик сигналов.
Rockset необычна по всем трем параметрам. Мы генерируем исключения с высокой частотой в рамках обычного контроля перегрузок; мы доставляем SIGUSR2 необычно часто из-за coarse_thread_cputime_clock;, а в начале этого года мы сделали так, чтобы обработчик SIGUSR2 использовал больше стека, добавив вызов timer_getoverrun, чтобы иметь возможность учитывать объединенные сигналы.
Это последнее изменение, судя по всему, имело решающее значение. Если обработчик использует достаточно мало стека, он может не дойти до устаревшей памяти ucontext_t и не перезаписать ее. До этого изменения мы вообще не наблюдали таких сбоев. После изменения частота оставалась низкой до тех пор, когла мы не увеличили нагрузку для некоторых сценариев использования, нагружающих механизм обратного давления.
Иными словами, ошибка в libunwind существовала всегда, но результирующий показатель частоты исключений, сигналов и использования стека обработчиков лишь недавно перешагнул порог, при котором проблема стала заметна на уровне эксплуатации.
Этот механизм также объясняет то совпадение, что и аппаратная ошибка, и ошибка в libunwind приводили к сбоям преимущественно внутри DocumentTree::updateDocument. Аварийные завершения из libunwind были сильно смещены в сторону этого метода, поскольку он всегда активен в тот момент, когда мы генерируем исключение для применения противодавления приема данных (ingest backpressure). Кроме того, этот метод часто фигурировал в сбоях из-за невыравнивания в %rsp, так как дефектный аппаратный узел относился к артикулу (SKU), который мы используем для массового приема данных, и бо́льшая часть процессорного времени этого узла расходуется именно в данном методе.
Нашей немедленной мерой по устранению проблемы стал переход с GNU libunwind на механизм раскрутки стека (unwinder) из libgcc. Сам по себе этот шаг оказался весьма выгодным: реализация в libgcc выиграла от масштабной работы по снижению состязательности за блокировки (lock contention), что имеет критическое значение при масштабировании до крупных ВМ.
Мы также передали в основную ветку GNU libunwind самодостаточный воспроизводимый пример и исправление, а также убедились, что в других средствах раскрутки стека подобных проблем нет.
Сила диагностики на уровне популяции сбоев
Это путешествие по отладке многому научило нас в отношении специфических деталей динамической линковки, метаданных раскрутки DWARF, доставки сигналов в Linux, ABI System V и механизма исключений C++. Но главный урок оказался гораздо проще всего этого.
Самым важным шагом было вовсе не умное чтение ассемблерного кода или глубокое знание деталей. Им стало создание высококачественного набора данных. При отсутствии такого набора мы смешивали два совершенно разных явления в одну историю и пытались логически распутать возникшую путаницу. Как только у нас появились точные и полные данные по всей популяции, структура проблемы стала очевидной: одна группа сбоев относилась к неисправному хосту, а другая — к состоянию гонки в libunwind. Когда данные стали лучше, отладка стала проще.
Для таких инфраструктурных систем, как Rockset, это имеет огромное значение. Данное расследование укрепило нашу приверженность принципам глубокого инструментирования, автоматизированного анализа и постоянного совершенствования эксплуатационного инструментария. Надежность заключается не только в исправлении ошибок после их возникновения — речь идет о создании данных, рабочих процессов и навыков, которые превращают неразрешимые проблемы в диагностируемые и решаемые.
Авторы
Автор: Нейтан Бронсон (Nathan Bronson), сотрудник технического персонала
Полный текст статьи читайте на OpenAI
