Инженеры Cloudflare раскрыли тайну «фантомных» сбоев, которая годами мучила разработчиков на Go.
При анализе сбоев в распределённой инфраструктуре инженеры Cloudflare столкнулись с редчайшей ошибкой, которая проявлялась только на серверах с архитектурой arm64. Масштаб системы — десятки миллионов HTTP-запросов в секунду, сотни дата-центров — позволил заметить её раньше всех. Оказалось, что причина заключалась не в оборудовании и не в коде приложений, а в самом компиляторе Go: из-за некорректной генерации инструкций возникало состояние гонки (race condition) данных на уровне машинного кода.
Всё началось с непредсказуемых паник в сервисе Cloudflare, отвечающем за настройку ядра Linux для маршрутизации трафика в продуктах Magic Transit и Magic WAN. Мониторинг фиксировал спорадические аварии с сообщением о неполном «раскручивании» стека вызовов. Первоначально инженеры предположили, что это случайное повреждение памяти — процесс управления был малонагруженным и не критичным, поэтому расследование отложили. Но вскоре количество падений начало расти, достигнув 30 в день. Причём они не совпадали ни с обновлениями, ни с изменениями инфраструктуры.
Анализ показал, что все сбои происходили в момент «разматывания» стека при работе сборщика мусора и касались функции (*unwinder).next. Иногда это выливалось в ошибку доступа к памяти, иногда — в принудительное завершение процесса из-за неконсистентного состояния стека. Один из инженеров заметил, что обращения шли к структуре m — внутреннему элементу планировщика Go, отвечающему за связь между горутинами и ядрами процессора. Так как Go использует M:N планирование, где множество горутин распределяются по ограниченному числу потоков, это указывало на возможное несоответствие между активным контекстом и стеком вызовов.
Проблема оказалась не в приложении, а в самом сгенерированном коде. Почти все трассировки указывали на библиотеку Netlink, используемую для взаимодействия с ядром Linux. Ошибки возникали в момент асинхронного прерывания горутины, выполняющей метод Receive. С переходом Go на асинхронное вытеснение (начиная с версии 1.14) планировщик может принудительно останавливать длительные функции, посылая сигнал SIGURG. Однако в данном случае прерывание происходило в середине эпилога функции — между двумя инструкциями ADD, которые поочерёдно корректировали указатель стека. Если остановка происходила ровно между ними, стек оказывался в некорректном состоянии, и при последующем «разматывании» сборщик мусора обращался к неверному адресу, вызывая сегфолт.
Чтобы подтвердить гипотезу, специалисты Cloudflare создали минимальный пример на чистом Go, где функция искусственно заставляла компилятор разбить корректировку указателя стека на две операции. При включённой сборке мусора и непрерывном вызове этой функции приложение стабильно падало через полторы минуты. Это доказало, что речь идёт не об ошибке библиотеки, а о дефекте компилятора Go, который допускал возможность прерывания в «окне» между двумя машинными инструкциями.
Ошибка оказалась буквально одноинструкционной гонкой — прерывание могло произойти в узком временном промежутке между двумя ADD, что делало сбой крайне редким, но воспроизводимым. Проблема затрагивала все версии Go до 1.23.12, 1.24.6 и 1.25.0, где поведение было исправлено. После обновления компилятор перестал делить корректировку указателя стека на несколько команд: теперь смещение вычисляется во временном регистре и применяется одной неделимой операцией. Это исключило возможность прерывания в середине изменения стека и полностью устранило сбой.
Инженеры Cloudflare отметили, что подобные случаи — редкая возможность буквально «поймать» ошибку в компиляторе на продакшене. Без гигантского масштаба инфраструктуры компания, вероятно, никогда бы не столкнулась с такой ситуацией. Этот инцидент стал примером того, как даже в зрелых языках и архитектурах может скрываться уязвимость, проявляющаяся лишь в сочетании высокой нагрузки и точного стечения обстоятельств.