Перейти до основного вмісту
OpenAI

30 червня 2026 р.

Інженерія

Епідеміологія core dump: виправлення 18-річного бага

Використання аналізу на рівні популяції для налагодження складних збоїв у нашій інфраструктурі даних.

Завантаження…

Моделі й агенти OpenAI дедалі більше покладаються на масштабовану інфраструктуру даних, щоб шукати релевантні дані під час інференсу — коли моделі обмірковують ваше запитання. Деякі з цих сервісів написані на C++, низькорівневий контроль якого над системою дає нам змогу максимізувати продуктивність і мінімізувати використання пам’яті. Ці переваги ефективності важливі під час масштабування, але відсутність безпечної роботи з пам’яттю в C++ означає, що баги можуть спричиняти збої через запис за неправильними або неіснуючими адресами пам’яті.

Кілька місяців тому ми помітили збої всередині сервісу Rockset — спеціально створеної частини нашої інфраструктури даних ChatGPT, важливої для багатьох плагінів даних і пошуку в розмовах. У кожному з цих збоїв звичайна функція C++ начебто завершувалася, а потім поверталася на фіктивну адресу, через що ядро зупиняло програму, бо вказівник інструкції більше не вказував на код. Іноді слот адреси повернення у стековому фреймі був NULL. Іноді сам регістр CPU з указівником стека здавався зміщеним на 8 байтів, ніби %rsp якимось чином було декрементовано посеред нормального виконання. В обох випадках збій ставався під час повернення.

Для прикладного коду це нетипові режими відмови. Випадковий запис, що потрапляє лише в збережену адресу повернення, можливий, але вкрай малоймовірний. Баг, який зміщує %rsp на 8 без inline assembly, setcontext або longjmp (нічого з цього ми не використовуємо), ще дивніший, бо скомпільований код напряму змінює цей регістр лише в прологу й епілогу функції. Проти кожної гіпотези, яку ми (або ChatGPT) могли придумати, були вагомі докази, тож баг здавався неможливим.

Те, що ми вважали однією проблемою, зрештою виявилося двома не пов’язаними між собою багами, випадково виявленими одночасно. Перший — тихі апаратні спотворення на одному з хостів Azure, де CPU просто неправильно виконував обчислення. Другий — 18-річний race condition у GNU libunwind, непомічений баг у широко використовуваній open source бібліотеці.

Цей допис — історія про те, як ми виявили й виправили, здавалося б, незрозумілі збої, мислячи як епідеміологи та створивши якісний набір даних про всю популяцію збоїв.

Перша спроба налагодження: уважний аналіз кількох core dump

Спершу докладніше про Rockset. Це cloud-native система даних для пошуку й аналітики в реальному часі, яку ми використовуємо для багатьох внутрішніх сценаріїв в OpenAI, наприклад sync connectors (Rockset була придбана OpenAI у 2024 році). Потокові оновлення підтримують актуальний індекс бази знань робочого простору, щоб ChatGPT міг шукати релевантну інформацію, відповідаючи на запитання або виконуючи дії.

Рівень виконання Rockset написаний на C++. Мова C++ надає низькорівневий доступ до CPU, що добре для продуктивності й ефективності, але означає, що баги застосунку можуть призводити до недійсних звернень до пам’яті та segfault. Щоб відстежувати такі проблеми, ми використовуємо обробник фатальних сигналів folly, який записує трасування стека під час збою, і завантажуємо відповідні core dump (знімок стану програми в момент збою) до Azure Blob Storage для подальшого аналізу. Усі листові вузли обробки запитів Rockset репліковані, що мінімізує вплив збою на клієнта. Однак кожен segfault відповідає багу, який потрібно виправити, щоб досягати наших цілей щодо надійності та якості.

Наш початковий підхід полягав у тому, щоб розглядати ці core dump як звичайну задачу налагодження: дуже уважно переглянути кілька дампів, сформулювати гіпотези й відкидати їх одну за одною.

Більшість збоїв відбувалися в методі під назвою DocumentTree::updateDocument. У цих збоях здавалося, що updateDocument викликав невідому функцію X, стек було пошкоджено, поки X була активною, а потім X повернулася на адресу, яка не була виконуваним кодом. У деяких випадках щойно знятий зі стека фрейм X виглядав коректним, окрім того, що його збережена адреса повернення була NULL. В інших випадках сам указівник стека виглядав неправильним, але наступним коректним фреймом усе одно здавався updateDocument.

Ми не знали, коли саме пошкоджується стек, тож простір пошуку був величезним. updateDocument — великий метод із великою кількістю інлайнінгу, тому кількість кандидатів на X була приголомшливою.

Це був баг у нашому C++ коді? Проблема компілятора або компонування? Проблема в одній із наших runtime-бібліотек? Баг ядра Linux у доставці сигналів або перемиканні контексту? Щось іще рідкісніше? Якщо це був випадковий запис, чому його не впіймало наше staging-середовище з ASAN?

Ми спробували використати журнали на рівні застосунку, щоб знайти всі випадки проблеми, але баги з пошкодженням стека важко класифікувати лише за логами, бо самі записані трасування стека пошкоджені або відсутні. Нам не вдалося побудувати запит до логів, який не мав би одночасно хибнопозитивних і хибнонегативних результатів. Ми вручну переглянули більше core dump і знайшли додаткові приклади, але цей процес був надто трудомістким, щоб дати нам надійний набір даних.

На цьому етапі розслідування ми відкинули апаратний баг (хоча це було помилкою), бо бачили збої в кількох регіонах і на кількох типах обладнання, тож усе ще шукали суто програмні причини. Кілька днів ми дуже глибоко досліджували один збій із неправильно вирівняним %rsp, відтворюючи передісторію збою за вмістом стека й регістрів. Це дало кілька можливих підказок, але оскільки ми не відмовилися від початкового висновку, що всі баги мають одну причину, це не допомогло нам зрушити з місця.

Підказки зі стека

Перш ніж перейти до переломного моменту нашого розслідування, важливо пояснити, яку інформацію ми витягували з core-файлів.

Rockset компілюється з -fno-omit-frame-pointer, тому активний стековий фрейм завжди доступний через %rbp, а викликачі утворюють зв’язаний список указівників фреймів.

У Linux x86_64 ABI AMD64 System V також резервує 128 байтів нижче %rsp як red zone. Ця ділянка доступна коду userspace, і, що важливо, ядро в межах контракту ABI обіцяє не псувати її під час доставки сигналу.

Red zone була центральною для нашого налагодження збою після повернення, бо зберігає частину інформації, що існувала до повернення. Коли спрацьовує SIGSEGV, обробник фатальних сигналів folly запускається на стеку потоку, що впав. Стекові фрейми, які вже не активні (бо їхня функція повернулася), будуть перезаписані обробником сигналу, окрім останніх 128 байтів. Саме тому ми можемо казати щось на кшталт: «Щойно знятий зі стека фрейм X виглядав коректним, окрім NULL-адреси повернення». Red zone зберігає частину неактивних фреймів або іноді лише хвіст одного неактивного фрейму.

Діаграма стека, що показує пошкоджені стекові фрейми, які можуть перезаписувати адреси повернення й спричиняти збої.

Ми знайшли один збій із неправильно вирівняним стеком, у якому всі задіяні функції були дуже малими. Це дало нам змогу побачити, що %rsp став невирівняним під час виконання відносно простої функції, а після цього ще кілька викликів завершилися успішно. Програма впала лише тоді, коли активна функція нарешті спробувала повернутися. Жоден із цих шляхів коду не використовував винятки, inline assembly, setcontext або longjmp, тож якщо вказівник стека справді змінився так, як натякав core dump, жоден правдоподібний баг у userspace-коді цього не пояснював.

Це підштовхнуло нас до ядра.

Rockset використовує сигнали агресивніше, ніж більшість програм. Виконання запитів розбивається на багато легких задач, які обмінюються даними. Це важливо для ефективної обробки навантажень із високим QPS, але ускладнює облік CPU для окремого запиту, бо робота багатьох запитів мультиплексується на один пул потоків.

Наше рішення — те, що ми називаємо coarse_thread_cputime_clock: воно достатньо дешево апроксимує clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...), щоб робити вибірку на межі кожної задачі. API timer_create можна використовувати, щоб планувати періодичну доставку сигналу за кількома уявленнями про плин часу, зокрема накопиченням CPU-часу. Ми плануємо доставку сигналу (SIGUSR2) кожні кілька мілісекунд CPU-часу, після чого обробник сигналу оновлює thread-local значення. Хоча багато задач не бачать, як грубий годинник просувається під час їхнього виконання, сума всіх дельт дає незміщену оцінку фактичного CPU-часу запиту.

Оскільки ми доставляємо сигнали так часто, рідкісний баг ядра в перемиканні контексту або доставці сигналів здавався правдоподібним. Ми витратили час на читання звітів про баги, вихідного коду ядра та специфічних для Azure патчів ядра. Ми пробували стрес-тести. Нам не вдалося знайти нічого, що здавалося б пов’язаним.

Тоді ми вирішили відступити на крок і спробувати інший підхід.

Лікар чи епідеміолог?

Є два широкі способи налагоджувати таку проблему.

Перший — діяти як лікар: зосередитися на одному пацієнті, провести багато тестів і спробувати діагностувати один випадок за детальними доказами.

Другий — діяти радше як епідеміолог: подивитися на всю популяцію й запитати, чи є закономірності, яких один випадок не покаже. Чи почався баг із певного релізу? Чи корелює він з одним апаратним SKU (конкретною моделлю CPU й сервера), одним регіоном або однією версією ядра? Чи не ховаються кілька окремих кластерів усередині того, що виглядає як один синдром?

Ми здебільшого працювали в режимі лікаря. Ключовою зміною стало рішення про те, що нам потрібно зібрати якісні дані про всю популяцію.

Очищення даних

Наші попередні спроби автоматично знайти всі екземпляри проблеми провалилися, бо ми намагалися шукати текст у логах. Самі core dump містять значно більше інформації, але їхній ручний перегляд не масштабувався. Ми вирішили докласти зусиль і побудувати пайплайн, який міг би автоматично аналізувати core dump.

Ми попросили ChatGPT написати скрипт, який завантажував префікс кожного core-файлу, витягував регістри, відфільтровував відомі хибнопозитивні випадки за логами й автоматично позначав збій як return-to-null, misaligned-stack або other. Потім ми паралельно запустили цей скрипт на всіх production core dump Rockset за попередній рік.

Це стало переломним моментом.

Щойно ми отримали чистий набір даних, кореляції з’явилися миттєво. Те, що ми вважали одним дивним багом, насправді було двома окремими популяціями збоїв.

Core dump типу return-to-null були розкидані по багатьох кластерах і географічних регіонах. Їхня частота останнім часом зросла, але не було ні чіткої дати початку, ні ясної інфраструктурної межі.

Збої misaligned-stack виглядали зовсім інакше. Усі вони походили з одного регіону, мали чітку дату початку й ніколи не траплялися на вузлах, які працювали довго. Хоча вони охоплювали кілька VM Azure (віртуальних машин у хмарі), патерн виглядав так, ніби одна фізична машина з несправним обладнанням створювала проблеми для будь-якої VM, яка випадково на неї потрапляла.

Точкова діаграма частоти збоїв за кластерами в часі: більшість збоїв зосереджені в кластерах 2, 3 і 6, зі сплеском у кластері 1 наприкінці періоду.

Тоді ми зрозуміли, що підсвідомо зливали два баги в один. Оскільки ми змішували контрприклади з обох багів, нам не вдавалося знайти єдине узгоджене пояснення.

Баг №1: проблемний хост

Маючи чистий список вузлів Kubernetes і часових міток, ми змогли простежити збої з невирівняним стеком до одного фізичного хоста, який було легко додати до denylist.

Ми не змогли відтворити пошкодження регістрів на цьому хості в контрольованому середовищі навіть після кількох тижнів стрес-тестування. Однак щойно проблемний хост вивели з експлуатації, збої з невирівняним стеком зникли.

Видалення проблемного хоста не є постійним рішенням у тому сенсі, що воно не запобігає новому виникненню тієї самої проблеми. Проте ми можемо змінити програмне забезпечення так, щоб у разі повторення схожої проблеми її було легко виявити й обробити. Ми вдосконалили наш обробник фатальних сигналів, додавши стан регістрів, щоб виявляти повторення лише за логами (без потреби в core dump). Ми змінили control plane так, щоб VM зазвичай повторно використовувалися, а не перероблялися, що значно спрощує виявлення поганого вузла на нашому рівні інфраструктурного стека. Ми також оновили наші runbook-и (і ментальні моделі команди), щоб включити цю можливість.

Після відокремлення збоїв проблемного хоста про решту core dump return-to-null стало набагато легше міркувати. Раніше ми відкинули розмотування винятків, бо вважали, що маємо контрприклади: збої в шляхах коду, де винятки точно не використовувалися. Але всі ці контрприклади належали до кластера апаратної корупції.

Коли ми знову переглянули решту core dump з огляду на це, то побачили, що висновок був прямо протилежним: усі збої ставалися під час розмотування винятків.

Обробка винятків — це динамічне передавання керування

Коли C++ кидає виняток, runtime має визначити, який catch-блок має його отримати і які деструктори чи обробники очищення треба виконати дорогою. Компілятор генерує ці метадані, але фактичне зіставлення відбувається динамічно під час виконання.

Розмотування винятку фактично виконує не функція, що викликає throw, а допоміжні функції, викликані згенерованим скомпільованим кодом. Ці runtime-процедури аналізують стек, отримують метадані про знайдені на стеку функції, динамічно шукають обробники очищення й catch-блоки, а потім передають керування в одне з цих місць. Передавання керування включає розмотування всіх проміжних стекових фреймів (зокрема фреймів допоміжних функцій).

Операційно це значно ближче до longjmp або перемикання fiber, ніж до звичайного виклику й повернення. Потрібно відновити callee-save регістри, а також регістри стекового фрейму %rbp і %rsp.

Наш бінарник лінкується з двома бібліотеками, що містять реалізації функцій для розмотування винятків C++: libgcc і GNU libunwind. Динамічний компонувальник обрав саме визначення GNU libunwind. Це нас здивувало: ми очікували, що реалізація libgcc переможе через правила версіонування символів; однак аналіз запущених бінарників показав, що це не так.

Скасування останнього припущення

На цьому етапі наша робоча гіпотеза змінилася, коли ми послабили ще одне припущення, зроблене тоді, коли думали, що баг лише один.

Можливо, ми бачили не звичайне повернення функції в NULL. Можливо, ми бачили передавання під час unwind — фактично відновлення регістрів у стилі setcontext, де цільовий указівник інструкції став NULL до передавання керування. Іншими словами, неправильні дані з unwind-бібліотеки, а не неправильний слот адреси повернення на стеку.

Це різко звузило проблему. Або 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, що завантажують із пам’яті в цільовий регістр:

Простий текст

1
74: mov UC_MCONTEXT_GREGS_RSP(%rdi),%rsp
2
75:
3
76: /* push the return address on the stack */
4
77: mov UC_MCONTEXT_GREGS_RIP(%rdi),%rcx
5
78: push %rcx
6
79:
7
80: mov UC_MCONTEXT_GREGS_RCX(%rdi),%rcx
8
81: mov UC_MCONTEXT_GREGS_RDI(%rdi),%rdi
9
82: retq

(%rdi вказує на виділений на стеку ucontext_t, а макроси UC_MCONTEXT_* просто розгортаються у фіксоване зміщення, за яким зберігається певний регістр.)

Перша інструкція — це початок вікна гонки. Вона оновлює %rsp, щоб він указував на новий низ активного стека. Щойно це стається, структура, на яку вказує %rdi, уже не є частиною активного стека (або red zone), і ядро більше не зобов’язане її не чіпати.

Зазвичай це не спричиняє проблем, але якщо сигнал надходить у точно правильний (неправильний?) момент, ядро побудує сигнальний фрейм за адресою %rsp-128. Це може перезаписати пам’ять, на яку вказує %rdi.

Якщо це станеться до того, як наступна інструкція прочитає UC_MCONTEXT_GREGS_RIP(%rdi), відновлений указівник інструкції може бути пошкоджений. У наших збоях він ставав NULL.

Ось у чому був баг.

Чому core dump маскувалися під звичайні bad return

Цей асемблер також пояснює одне зі спостережень, яке нас збивало з пантелику: чому у функції X був NULL у слоті адреси повернення попереднього стекового фрейму.

setcontext написано так, щоб відновлювати всі регістри, включно з %rdi, тому він не може використати цей регістр, щоб в останній момент перед передаванням керування прочитати UC_MCONTEXT_GREGS_RIP(%rdi). Натомість він читає значення раніше, зберігає його на стеку, відновлює ще кілька регістрів, а потім використовує retq, щоб прочитати збережене значення й передати керування.

Те, що в core dump виглядало як «функція повернулася до NULL», насправді було «unwinder синтезував цільову адресу повернення на стеку, але цю ціль було пошкоджено до завершення передавання». Ми припускали, що пошкодження слота адреси повернення має ставатися на місці, бо не знали місць, де (вразливі до пошкодження) дані навмисно записуються у слот адреси повернення.

Вікно гонки завширшки в одну інструкцію

Цей баг здається абсурдним через те, наскільки вузьке це вікно гонки. У такій гонці потоків зовнішня подія (сигнал) має статися між двома кроками, які виконує інший потік. Що ближчі ці кроки один до одного, то менш імовірно, що race condition станеться.

У цьому випадку вразливе вікно буквально завширшки в одну інструкцію! Сигнал має бути доставлений після зміни %rsp, але до того, як наступна інструкція завантажить %rip. Кілька таких простих інструкцій можуть виконуватися за цикл на сучасному суперскалярному CPU з позачерговим виконанням, тож вікно гонки становить приблизно сотню пікосекунд.

Коли ми знайшли цю гонку, першою реакцією було: «вона надто рідкісна, щоб пояснити спостережувану частоту збоїв». Ми бачили понад десяток збоїв return-to-null на день на всіх машинах. Чи могла гонка завширшки в одну інструкцію під час очищення винятку справді це пояснити?

Ми звернулися до оцінки Фермі. Якщо вразливе вікно має порядок 101010^{-10} секунди, а SIGUSR2 надходить кожні 10210^{-2} секунди CPU-часу, то кожен обробник очищення винятку або catch-блок має приблизно 10810^{-8} імовірності програти гонку.

Rockset використовує винятки як частину внутрішнього механізму backpressure під час ingest. Один перевантажений хост може кидати близько 10410^{4} винятків на секунду. Звідси випливає, що середній час між відмовами хоста, який використовує backpressure, становить 10410^{4} секунд, тобто один збій кожні кілька годин. У масштабі всіх машин цього більш ніж достатньо, щоб пояснити спостережувану частоту збоїв.

Чому баг libunwind проявився саме зараз?

Баг GNU libunwind старий — йому понад 18 років, він був уже в першій версії x86_64, що підтримувала розмотування винятків C++.

То чому він проявився зараз?

Частота збоїв приблизно пропорційна кількості кинутих винятків і доставлених сигналів. Вона також залежить від того, скільки стека споживає обробник сигналу.

Rockset незвичайний за всіма трьома осями. Ми кидаємо винятки з високою частотою як частину нормального контролю перевантаження; незвично часто доставляємо SIGUSR2 через coarse_thread_cputime_clock; а раніше цього року змусили обробник SIGUSR2 використовувати більше стека, додавши виклик timer_getoverrun, щоб враховувати об’єднані сигнали.

Схоже, остання зміна була важливою. Якщо обробник використовує достатньо мало стека, він може не дістатися до застарілої пам’яті ucontext_t і не перезаписати її. До цієї зміни ми взагалі не спостерігали таких збоїв. Після зміни частота залишалася низькою, доки ми не наростили навантаження для деяких сценаріїв, що напружували механізм backpressure.

Іншими словами, баг libunwind був завжди, але добуток нашої частоти винятків, частоти сигналів і використання стека обробником лише нещодавно перетнув поріг, за яким став операційно помітним.

Цей механізм також пояснює збіг, що і апаратний баг, і баг libunwind переважно спричиняли збої всередині DocumentTree::updateDocument. Збої від libunwind були сильно зміщені в бік цього методу, бо він завжди активний у момент, коли ми кидаємо виняток, щоб застосувати ingest backpressure. Він також часто потрапляв у вибірку збоїв із невирівняним %rsp, бо несправний апаратний вузол належав до SKU, який ми використовуємо для масового ingest і який проводить більшість CPU-часу в цьому методі.

Нашим негайним пом’якшенням був перехід із GNU libunwind на unwinder з libgcc. Це саме по собі було вдалим компромісом: реалізація libgcc отримала багато поліпшень для зменшення конкуренції за блокування, що важливо під час масштабування до великих VM.

Ми також upstream-нули самодостатній відтворювач і виправлення(відкривається у новому вікні) до GNU libunwind та перевірили, що інші unwinder не мають подібної проблеми.

Сила діагностики на рівні популяції

Ця історія налагодження багато чого навчила нас про конкретні деталі динамічного компонування, unwind-метадані DWARF, доставку сигналів у Linux, ABI System V і механізм винятків C++. Але головний урок був простішим за все це.

Найважливішим кроком було не хитре читання асемблера й не глибоке знання деталей. Це було створення якісного набору даних. Без цього набору даних ми змішували два різні явища в одну історію й намагалися розплутати плутанину самими міркуваннями. Коли ми отримали точні й повні дані про популяцію, структура проблеми стала очевидною: одна популяція збоїв належала поганому хосту, а інша — гонці в libunwind. Коли дані стали кращими, налагодження стало легшим.

Для інфраструктурних систем на кшталт Rockset це дуже важливо. Це розслідування зміцнило нашу відданість глибокій інструментації, автоматизованим розслідуванням і постійному поліпшенню операційних інструментів. Надійність — це не лише виправлення багів після їх появи; це створення даних, робочих процесів і навичок, які перетворюють неможливі проблеми на діагностовані й розв’язувані.

Автори

By Nathan Bronson, Member of Technical Staff