Epidemiologia de core dumps: corrigindo um bug de 18 anos
Usando análise em nível populacional para depurar falhas difíceis na nossa infraestrutura de dados.
Os modelos e agentes da OpenAI dependem cada vez mais de uma infraestrutura de dados escalável para buscar dados relevantes na inferência: quando os modelos estão pensando sobre sua pergunta. Alguns desses serviços são escritos em C++, cujo controle de baixo nível do sistema nos permite maximizar o desempenho e minimizar o uso de memória. Esses ganhos de eficiência são importantes conforme escalamos, mas a falta de segurança de memória do C++ significa que bugs podem causar falhas ao gravar em endereços de memória incorretos ou inexistentes.
Há alguns meses, observamos falhas vindas de dentro do serviço Rockset, uma parte sob medida da nossa infraestrutura de dados do ChatGPT, essencial para muitos plugins de dados e para buscas em conversas. Em cada falha, uma função C++ normal parecia terminar e retornar para um endereço inválido, fazendo o kernel parar o programa porque o ponteiro de instrução já não apontava para código. Às vezes, o espaço do endereço de retorno no frame da pilha era NULL. Às vezes, o próprio registrador de CPU do ponteiro da pilha parecia estar deslocado em 8 bytes, como se %rsp tivesse sido decrementado no meio da execução normal. Nos dois casos, a falha acontecia no retorno.
Esses não são modos de falha normais para código de aplicação. Uma gravação perdida que atinja apenas um endereço de retorno salvo é possível, mas extremamente improvável. Um bug que desalinha %rsp em 8 sem envolver assembly inline, setcontext ou longjmp (nenhum deles usado por nós) é ainda mais estranho, pois o código compilado só ajusta esse registrador diretamente no prólogo e no epílogo da função. Toda hipótese em que nós (ou o ChatGPT) conseguimos pensar tinha fortes evidências contra ela; o bug parecia impossível.
O que presumíamos ser um só problema acabou se revelando dois bugs sem relação, descobertos por coincidência ao mesmo tempo. Primeiro, corrupção silenciosa de hardware em um host do Azure, onde a CPU simplesmente não fazia contas corretamente. Segundo, uma condição de corrida de 18 anos na GNU libunwind, um bug despercebido em uma biblioteca open source amplamente usada.
Este post conta como identificamos e corrigimos falhas aparentemente inexplicáveis pensando como epidemiologistas e criando um conjunto de dados de alta qualidade sobre toda a população de crashes.
Primeiro, vamos nos aprofundar no Rockset. Ele é um sistema de dados cloud-native para busca e análises em tempo real que usamos em muitos casos internos na OpenAI, como conectores de sincronização (a Rockset foi adquirida pela OpenAI em 2024). Atualizações por streaming mantêm um índice atualizado da base de conhecimento de um workspace para que o ChatGPT busque informações relevantes ao responder perguntas ou executar ações.
A camada de execução do Rockset é escrita em C++. A linguagem C++ oferece acesso de baixo nível à CPU, o que é bom para desempenho e eficiência, mas permite que bugs da aplicação levem a acessos inválidos à memória e segfaults. Para rastreá-los, usamos o tratador de sinais fatais da folly para registrar uma stack trace quando ocorre uma falha e enviamos os core dumps correspondentes (um snapshot do estado do programa no momento da falha) ao Azure Blob Storage para análise posterior. Todas as folhas de processamento de consultas do Rockset são replicadas, o que minimiza o impacto de uma falha para o cliente. Ainda assim, cada segfault corresponde a um bug que precisa ser corrigido para atender às nossas metas de confiabilidade e qualidade.
Nossa abordagem inicial foi tratar esses cores como um problema convencional de depuração: inspecionar alguns core dumps de perto, formular hipóteses e descartá-las uma a uma.
A maioria das falhas ocorria em um método chamado DocumentTree::updateDocument. Nessas falhas, parecia que updateDocument havia chamado alguma função X desconhecida, a pilha se corrompera enquanto X estava ativa, e então X retornara para um endereço que não era código executável. Em alguns casos, o frame recém-removido de X parecia válido, exceto pelo endereço de retorno salvo, que era NULL. Em outros, o próprio ponteiro da pilha parecia errado, mas o próximo frame válido ainda parecia ser updateDocument.
Não sabíamos quando a pilha estava sendo corrompida, o que deixava um espaço de busca enorme. updateDocument é um método grande e muito inline, então o número de candidatos a X era esmagador.
Era um bug no nosso código C++? Um problema de compilador ou linkage? Um problema em uma de nossas bibliotecas de runtime? Um bug do kernel Linux em torno da entrega de sinais ou troca de contexto? Algo ainda mais raro? Se fosse uma gravação perdida, por que nosso ambiente de staging com ASAN não a detectou?
Tentamos usar nossos logs de aplicação para identificar todas as ocorrências do problema, mas bugs de corrupção de pilha são difíceis de classificar só pelos logs porque as stack traces registradas estão corrompidas ou ausentes. Não conseguimos construir uma consulta de logs que não tivesse falsos positivos e falsos negativos. Inspecionamos mais cores manualmente e encontramos exemplos adicionais, mas o processo era trabalhoso demais para produzir um conjunto de dados confiável.
Nesse estágio da investigação, descartamos (incorretamente) um bug de hardware, pois víamos falhas em várias regiões e tipos de hardware; por isso, ainda buscávamos causas apenas de software. Por alguns dias, mergulhamos em uma única falha com %rsp desalinhado, reconstruindo o histórico pré-falha a partir do conteúdo da pilha e dos registradores. Isso trouxe algumas pistas possíveis, mas, como não abandonamos a conclusão inicial de que todos os bugs tinham a mesma causa, continuamos travados.
Antes de chegar ao ponto de virada da investigação, é importante explicar que tipo de informação extraíamos dos arquivos core.
O Rockset é compilado com -fno-omit-frame-pointer, então o frame ativo da pilha é sempre acessível por %rbp, e os chamadores formam uma lista encadeada de ponteiros de frame.
No Linux x86_64, a ABI AMD64 System V também reserva 128 bytes abaixo de %rsp como red zone. Essa região fica disponível ao código em userspace e, de forma importante, o kernel promete não sobrescrevê-la ao entregar um sinal, como parte do contrato da ABI.
A red zone foi central na depuração de uma falha pós-retorno, pois preserva algumas informações de antes do retorno. Quando um SIGSEGV é disparado, o tratador de sinais fatais da folly roda na pilha da thread que falhou. Frames de pilha que não estão mais ativos (porque sua função retornou) serão sobrescritos pelo tratador de sinais, exceto pelos últimos 128 bytes. Por isso podemos dizer coisas como “o frame recém-removido de X parecia válido, exceto por um endereço de retorno NULL”. A red zone preserva parte dos frames inativos, ou às vezes só o fim de um frame inativo.
Encontramos uma falha de pilha desalinhada em que todas as funções envolvidas eram muito pequenas. Isso nos permitiu ver que %rsp se desalinhara durante a execução de uma função relativamente simples e que outras chamadas haviam tido sucesso depois. O programa só falhou quando a função ativa finalmente tentou retornar. Nenhum desses caminhos de código usava exceções, assembly inline, setcontext ou longjmp; portanto, se o ponteiro da pilha realmente mudou como o core sugeria, nenhum bug plausível em código userspace explicava o problema.
Isso nos levou a olhar para o kernel.
O Rockset usa sinais de forma mais agressiva que a maioria dos programas. A execução de consultas é dividida em muitas tarefas leves que trocam dados. Isso é importante para lidar com cargas de alto QPS com eficiência, mas complica a contabilização de CPU por consulta, já que o trabalho de muitas consultas é multiplexado no mesmo pool de threads.
Nossa solução é algo que chamamos de coarse_thread_cputime_clock, que aproxima clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) com custo baixo o suficiente para amostrar em cada limite de tarefa. A API timer_create pode agendar a entrega periódica de sinais com base em várias noções de passagem do tempo, incluindo o acúmulo de tempo de CPU. Agendamos um sinal (SIGUSR2) para ser entregue a cada poucos milissegundos de tempo de CPU; então o tratador atualiza um valor local da thread. Embora muitas tarefas não vejam o relógio grosso avançar durante sua execução, somar todos os deltas produz uma estimativa não enviesada do tempo real de CPU de uma consulta.
Como entregamos sinais com tanta frequência, parecia plausível haver um bug raro do kernel em troca de contexto ou entrega de sinais. Passamos tempo lendo relatórios de bugs, código-fonte do kernel e patches de kernel específicos do Azure. Tentamos testes de estresse. Não conseguimos encontrar nada que parecesse relacionado.
Nesse ponto, decidimos dar um passo atrás e tentar outra abordagem.
Há duas grandes formas de depurar um problema como este.
Uma é agir como uma espécie de médico: focar em um paciente, fazer muitos exames e tentar diagnosticar um caso único a partir de evidências detalhadas.
A outra é agir mais como epidemiologista: olhar para toda a população e perguntar se há padrões que um caso isolado não revela. O bug começou em uma versão específica? Ele se correlaciona com um SKU de hardware (a CPU e o modelo de servidor específicos), uma região ou uma versão do kernel? Há vários clusters distintos escondidos dentro do que parece uma só síndrome?
Estávamos operando sobretudo no modo médico. A mudança-chave foi decidir que precisávamos reunir dados populacionais de alta qualidade.
Nossas tentativas anteriores de encontrar automaticamente todas as instâncias do problema falharam porque tentávamos usar buscas textuais nos logs. Os próprios core dumps têm muito mais informação, mas examiná-los manualmente não escalava. Decidimos investir esforço para criar um pipeline que analisasse os core dumps automaticamente.
Pedimos ao ChatGPT que escrevesse um script que baixava um prefixo de cada arquivo core, extraía os registradores, filtrava falsos positivos conhecidos usando os logs e rotulava a falha como return-to-null, pilha desalinhada ou outra. Depois rodamos esse script em paralelo sobre todos os core dumps de produção do Rockset do ano anterior.
Esse foi o ponto de virada.
Assim que tivemos um conjunto de dados limpo, as correlações apareceram de imediato. O que vínhamos tratando como um bug estranho era, na verdade, duas populações de falhas separadas.
Os cores return-to-null estavam espalhados por muitos clusters e regiões geográficas. Sua frequência havia aumentado recentemente, mas não havia uma data de início nítida nem uma fronteira clara de infraestrutura.
As falhas de pilha desalinhada pareciam completamente diferentes. Todas vinham de uma região, tinham uma data de início clara e nunca aconteciam em nodes que estavam rodando há muito tempo. Embora envolvessem várias VMs do Azure (máquinas virtuais hospedadas na nuvem), o padrão parecia ser uma máquina física com hardware defeituoso causando problemas para qualquer VM que caísse nela.
Foi quando percebemos que havíamos confundido mentalmente dois bugs. Como estávamos misturando contraexemplos dos dois bugs, não conseguíamos encontrar uma explicação única e coerente.
Com uma lista limpa de nodes Kubernetes e timestamps, conseguimos rastrear as falhas de pilha desalinhada até um único host físico, fácil de colocar em denylist.
Não conseguimos reproduzir a corrupção de registradores nesse host em ambiente controlado, mesmo após várias semanas de testes de estresse. Depois que o host problemático foi retirado de serviço, porém, as falhas de pilha desalinhada desapareceram.
Remover o host ruim não é uma solução permanente, no sentido de que não impede uma nova ocorrência do mesmo problema. Podemos, porém, mudar o software para que, se um problema semelhante voltar, ele seja facilmente detectado e tratado. Melhoramos nosso tratador de sinais fatais para incluir o estado dos registradores, de modo que possamos detectar recorrência apenas pelos logs (sem precisar de core dump). Mudamos o plano de controle para que VMs sejam geralmente reutilizadas em vez de recicladas, o que facilita muito detectar nodes ruins no nosso nível da pilha de infraestrutura. Também atualizamos nossos runbooks (e os modelos mentais da equipe) para incluir essa possibilidade.
Com as falhas do host ruim separadas, ficou muito mais fácil raciocinar sobre os cores return-to-null restantes. Antes, havíamos descartado desenrolamento de exceções porque achávamos ter contraexemplos: falhas em caminhos de código onde exceções definitivamente não eram usadas. Mas esses contraexemplos eram todos do cluster de corrupção de hardware.
Ao revisitar os cores restantes com isso em mente, descobrimos que a conclusão era exatamente inversa: todas as falhas ocorriam durante o desenrolamento de exceções.
Quando C++ lança uma exceção, o runtime precisa descobrir qual bloco catch deve recebê-la e quais destrutores ou tratadores de limpeza devem rodar no caminho. O compilador emite esses metadados, mas a correspondência real acontece dinamicamente em runtime.
O desenrolamento de exceções não é feito pela função que invoca throw, mas por funções auxiliares chamadas pelo código compilado resultante. Essas rotinas de runtime examinam a pilha, buscam metadados sobre as funções encontradas nela, procuram dinamicamente tratadores de limpeza e blocos catch, e então transferem o controle para um desses locais. Transferir o controle inclui desenrolar todos os frames de pilha intermediários (inclusive os das funções auxiliares).
Operacionalmente, isso se parece muito mais com um longjmp ou uma troca de fibra do que com uma chamada e retorno normais. Registradores salvos pelo callee precisam ser restaurados, assim como os registradores de frame da pilha %rbp e %rsp.
Nosso binário faz link com duas bibliotecas que contêm implementações das funções que desenrolam exceções C++: libgcc e GNU libunwind. As definições da GNU libunwind foram as escolhidas pelo linker dinâmico. Isso nos surpreendeu; esperávamos que a implementação da libgcc vencesse por causa das regras de versionamento de símbolos, mas inspecionar binários em execução mostrou que não era o caso.
Nesse ponto, nossa hipótese de trabalho mudou, ao relaxarmos outra suposição feita quando achávamos que havia apenas um bug.
Talvez não estivéssemos vendo uma função comum retornar para NULL. Talvez estivéssemos vendo uma transferência de unwinding — na prática, uma restauração de registradores no estilo setcontext — em que o ponteiro de instrução de destino se tornara NULL antes da transferência de controle. Em outras palavras, dados incorretos vindos da biblioteca de unwinding, não um espaço de endereço de retorno incorreto na pilha.
Isso estreitou drasticamente o problema. Ou a GNU libunwind estava calculando o estado de destino errado, ou calculava o estado certo e algo o corrompia antes de ele ser aplicado.
Lemos o código-fonte da GNU libunwind e vimos que ela sintetiza um ucontext_t na pilha, preenche o estado desejado dos registradores para o frame do tratador de limpeza e entrega um ponteiro para essa struct a uma rotina interna em assembly: _Ux86_64_setcontext.
Nesse ponto, tínhamos todas as peças.
O ucontext_t sintetizado vive em um dos frames de pilha que são desenrolados por _Ux86_64_setcontext durante a execução dessa função. Será que _Ux86_64_setcontext lia a struct depois de alterar %rsp, quando a struct já não fazia parte da pilha ativa? Isso a tornaria vulnerável a ser sobrescrita pela entrega de um sinal, como nosso frequente SIGUSR2.
A resposta era sim.
Estas são as últimas seis instruções de _Ux86_64_setcontext na versão da GNU libunwind que usávamos, compostas principalmente por instruções mov que carregam da memória para um registrador de destino:
(%rdi aponta para o ucontext_t alocado na pilha, e as macros UC_MCONTEXT_* apenas se expandem para o offset fixo onde um registrador específico é armazenado.)
A primeira instrução é o início da janela de corrida. Ela atualiza %rsp para apontar para o novo fundo da pilha ativa. Assim que isso acontece, a struct apontada por %rdi já não faz parte da pilha ativa (nem da red zone) e deixa de estar fora do alcance do kernel.
Normalmente isso não causa problemas, mas, se um sinal chegar exatamente no momento certo (errado?), o kernel construirá o frame de sinal em %rsp-128. Isso pode sobrescrever a memória apontada por %rdi.
Se isso acontecer antes que a próxima instrução leia UC_MCONTEXT_GREGS_RIP(%rdi), o ponteiro de instrução restaurado pode ser corrompido. Nas nossas falhas, ele se tornou NULL.
Esse é o bug.
Esse assembly também explica uma das observações que nos confundiu: por que a função X tinha um NULL no espaço do endereço de retorno do frame de pilha anterior.
setcontext foi escrito para restaurar todos os registradores, incluindo %rdi, então não pode usar esse registrador para ler UC_MCONTEXT_GREGS_RIP(%rdi) no momento final da transferência de controle. Em vez disso, ele lê o valor antes, salva-o na pilha, restaura mais alguns registradores e então usa retq para ler o valor salvo e transferir o controle.
O que nos cores parecia “uma função retornou para NULL” era, na verdade, “o unwinder sintetizou um endereço de retorno de destino na pilha, mas esse destino foi corrompido antes de a transferência terminar”. Havíamos presumido que a corrupção do espaço de endereço de retorno teria de ocorrer in-place, porque não conhecíamos lugares em que dados (corruptíveis) fossem escritos de propósito nesse espaço.
O que faz esse bug parecer absurdo é o quão estreita é essa janela de corrida. Nesse tipo de condição de corrida, o evento externo (o sinal) precisa acontecer entre duas etapas executadas por outra thread. Quanto mais próximas essas etapas estiverem, menor a chance de a condição de corrida acontecer.
Neste caso, a janela vulnerável tem literalmente a largura de uma instrução! Um sinal precisa ser entregue depois que %rsp foi alterado, mas antes que a próxima instrução carregue %rip. Várias instruções simples como essa podem rodar por ciclo em uma CPU moderna superescalar e out-of-order, então a janela de corrida é de cerca de cem picossegundos.
Quando encontramos essa corrida, nossa primeira reação foi pensar que ela seria rara demais para explicar a taxa de falhas observada. Víamos mais de uma dúzia de falhas return-to-null por dia na frota. Será que uma corrida de uma instrução durante a limpeza de exceções poderia mesmo explicar isso?
Recorremos à estimativa de Fermat. Se a janela vulnerável está na ordem de segundos e SIGUSR2 chega a cada segundos de tempo de CPU, então cada tratador de limpeza de exceção ou bloco catch tem cerca de de probabilidade de perder a corrida.
O Rockset usa exceções como parte de seu mecanismo interno de backpressure na ingestão. Um único host sobrecarregado pode lançar da ordem de exceções por segundo. Isso implica um tempo médio entre falhas de segundos para um host usando backpressure, ou uma falha a cada poucas horas. Na escala da frota, isso é mais que suficiente para explicar a frequência de falhas observada.
O bug da GNU libunwind é antigo — tem mais de 18 anos, presente na primeira versão x86_64 com suporte a desenrolamento de exceções C++.
Então por que ele apareceu agora?
A taxa de falhas é aproximadamente proporcional ao número de exceções lançadas e ao número de sinais entregues. Ela também depende de quanta pilha o tratador de sinais consome.
O Rockset é incomum nos três eixos. Lançamos exceções em altas taxas como parte do controle normal de sobrecarga; entregamos SIGUSR2 com frequência incomum por causa de coarse_thread_cputime_clock; e, no início deste ano, fizemos o tratador de SIGUSR2 usar mais pilha ao adicionar uma chamada a timer_getoverrun, para contabilizar sinais mesclados.
Essa última mudança parece ter sido importante. Se o tratador usar pouca pilha, talvez não alcance nem sobrescreva a memória obsoleta de ucontext_t. Antes dessa mudança, não observávamos essas falhas. Depois da mudança, a taxa permaneceu baixa até aumentarmos a carga em alguns casos de uso que estressavam o mecanismo de backpressure.
Em outras palavras, o bug da libunwind sempre esteve lá, mas o produto entre nossa taxa de exceções, taxa de sinais e uso de pilha do tratador só recentemente cruzou o limiar em que se tornou visível operacionalmente.
Esse mecanismo também explica a coincidência de que tanto o bug de hardware quanto o da libunwind causavam falhas principalmente dentro de DocumentTree::updateDocument. As falhas da libunwind eram fortemente enviesadas para esse método, porque ele está sempre ativo quando lançamos uma exceção para aplicar backpressure na ingestão. Ele também foi muito selecionado nas falhas de desalinhamento de %rsp, porque o node de hardware ruim era de um SKU que usamos para ingestão em massa, que passa a maior parte do tempo de CPU nesse método.
Nossa mitigação imediata foi trocar a GNU libunwind pelo unwinder da libgcc. Essa já era uma boa troca por si só: a implementação da libgcc se beneficiou de muito trabalho para reduzir contenção de locks, algo importante ao escalar para VMs grandes.
Também enviamos upstream um reprodutor autocontido e uma correção(abre em uma nova janela) para a GNU libunwind, e verificamos que os outros unwinders não têm problema semelhante.
Essa jornada de depuração nos ensinou muito sobre detalhes específicos de linking dinâmico, metadados de desenrolamento DWARF, entrega de sinais no Linux, ABI System V e o maquinário de exceções do C++. Mas a principal lição foi mais simples que tudo isso.
O passo mais importante não foi ler assembly com esperteza nem conhecer profundamente os detalhes. Foi criar um conjunto de dados de alta qualidade. Sem esse conjunto de dados, misturávamos dois fenômenos distintos em uma só história e tentávamos raciocinar para sair da confusão. Quando tivemos dados populacionais precisos e completos, a estrutura do problema ficou óbvia: uma população de falhas pertencia a um host defeituoso, e a outra a uma corrida na libunwind. Quando os dados melhoraram, a depuração ficou mais fácil.
Para sistemas de infraestrutura como o Rockset, isso importa muito. Essa investigação reforçou nosso compromisso com instrumentação profunda, investigações automatizadas e melhorias contínuas em nossas ferramentas operacionais. Confiabilidade não é só corrigir bugs depois que acontecem; é construir os dados, fluxos de trabalho e habilidades que transformam problemas impossíveis em problemas diagnosticáveis e solucionáveis.
Autores
By Nathan Bronson, Member of Technical Staff


