- Enquanto a EdgeDB migrava I/O de rede de Python para Rust, novos testes de HTTP fetch baseados em
reqwestpareciam travar apenas no CI ARM64, mas na verdade estavam crashando - A análise do core dump mostrou que o ponto problemático não era o novo código HTTP, e sim dentro de
getenv()dalibc; ele falhava ao tentar ler o ponteiro inválido0x220enquanto percorria o array de variáveis de ambiente - Outra thread definia
SSL_CERT_FILEeSSL_CERT_DIRno caminho doopenssl-probe, fazendosetenv()realocarenviron; ao mesmo tempo, o caminho de tratamento de erro do Python chamavagetenv(), criando uma condição de corrida - O código Rust não tinha
unsafeexplícito, mas, enquantostd::env::set_var()alterava o ambiente global, outras runtimes ou chamadas diretas àlibcnão eram sincronizadas pelo lock interno do Rust - A solução foi migrar no Linux para
rustlsem vez do backendrust-native-tls/openssldoreqwest, e tanto o Rust 2024 edition quanto a glibc também estão mudando para reduzir problemas dessa família
Crash revelado apenas no CI ARM64
- A EdgeDB estava criando uma nova funcionalidade de HTTP fetch enquanto portava uma parte significativa do código de I/O de rede de Python para Rust
- Usou
reqwestcomo biblioteca de cliente HTTP, e a funcionalidade passava localmente e nos runners de CI x86_64, mas falhava de forma intermitente em runners de CI ARM64 - No início, parecia que o runner de testes travava indefinidamente, e nos logs do CI um teste permanecia em execução sem erro até dar timeout horas depois
- A hipótese inicial foi a diferença de modelo de memória entre Intel e ARM64
- Intel tem um modelo de memória relativamente estrito, com uma ordem total de escritas de memória com a qual todos os processadores concordam
- ARM tem um modelo de memória com ordenação mais fraca, em que escritas podem aparecer em ordens diferentes para threads diferentes
Rastreando o core dump no ambiente Docker do CI
- As máquinas noturnas de CI rodavam na Amazon AWS, então era possível acessar como usuário root real fora do contêiner e ver
dmesge logs do sistema - Eles procuraram o PID que parecia travado dentro e fora do contêiner, mas o processo não existia; isso revelou que era um crash, não um deadlock
- Como contêineres Docker são namespaces de processo, o core dump foi encaminhado para o host Docker, e em
journalctlfoi confirmado o core dump do processopython3 - Ao abrir o core com
gdbpela primeira vez, o backtrace era inútil porque faltavam os arquivos.sode dentro do contêiner - Depois de copiar
/lib,/usretc. do contêiner e configurarsolib-absolute-prefixnogdb, foi confirmado que o ponto do crash eragetenv()emlibc.so.6
O ponteiro quebrado de variável de ambiente lido por getenv()
- O backtrace completo seguia o fluxo
getenv()→__dcigettext()→strerror_r()→strerror()→PyErr_SetFromErrnoWithFilenameObjects() - Não foi o novo código HTTP que crashou diretamente; o Python estava criando uma exceção baseada em errno e, passando por um caminho relacionado a gettext, chamou
getenv() - A implementação de
getenv()da GLIBC 2.17 percorre oenvirondo POSIX como uma listachar **e inspeciona ponteiros para strings de variáveis de ambiente até o ponteiroNULLfinal - Pela desmontagem e pelo estado dos registradores,
getenv()crashou ao tentar ler um byte do endereçox19 = 0x2200x220era claramente um endereço de memória inválido- Ao inspecionar o próprio
environ, a lista atual de variáveis de ambiente parecia consistente
Causa: condição de corrida entre setenv() e getenv()
setenv()não é uma função que possa ser chamada com segurança em ambientes multithread, e crashes estranhos emgetenv()dalibccausados por isso já foram redescobertos várias vezes- Ao comparar a desmontagem com o código C,
x20correspondia ao ponteiroepque percorre o arrayenviron - No momento do crash,
x20era0x248b5000, enquanto oenvironatual era0x28655750, cerca de 60 MB adiante - Ao comparar a memória ao redor do array de ambiente antigo com o
environatual, a última diferença aparecia nos itensSSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crteSSL_CERT_DIR=/etc/ssl/certs - Outra thread havia movido o
environdurante uma chamada asetenv(), egetenv()pode ter continuado lendo o array de ambiente antigo em uma situação de use-after-free
Ligação com openssl-probe e o backend TLS
- Em uma issue antiga relacionada a
rust-native-tls, foi encontrada a pista de queopenssl-probedefine as variáveis de ambienteSSL_CERT_FILEeSSL_CERT_DIRpara localizar certificados do sistema - No Linux, esse caminho é chamado ao usar o backend
opensslderust-native-tls - O código problemático do
openssl-probedefine as duas variáveis de ambiente comenv::set_var(), semunsafeexplícitoSSL_CERT_FILESSL_CERT_DIR
- Por causa dessa combinação, código Rust sem unsafe interagiu mal com o uso da
libcno mesmo processo e produziu um crash
Por que foi reproduzido no Linux ARM64
- Esse crash ocorre quando outra thread chama
getenv()exatamente no momento em quesetenv()move o array de ambiente comrealloc - Para reproduzir, várias condições precisavam coincidir
- A quantidade de variáveis de ambiente precisava ser suficiente para acionar
realloc - Uma falha de I/O não relacionada precisava ser capturada pelo
asyncio - O caminho de tratamento de erro do Python precisava chamar
getenv()exatamente no mesmo momento para obter a variável de ambienteLANGUAGE
- A quantidade de variáveis de ambiente precisava ser suficiente para acionar
- O valor inválido
0x220era próximo do tamanho antigo do ambiente em palavras de 64 bits0x220 / 8 = 68- Esse valor sobrescreveu o espaço do
NULLde término do bloco de ambiente antigo, e parecia ser um valor usado pelo malloc do sistema para indicar o tamanho de um bloco livre
- Como eram necessárias muitas precondições, foi uma sorte que o problema fosse razoavelmente reproduzível em uma única plataforma
Pistas confirmadas na desmontagem ARM64
- Houve confusão porque, na desmontagem de
getenv(), não era fácil ver ondex20mudava - O ponto-chave era o modo de endereçamento pre-indexado do AArch64
ldr x19, [x20, #8]!funciona assimx19 = *(x20 + 8)x20 = x20 + 8
- Por causa desse modo de endereçamento,
x20estava percorrendo o array de ponteiros de variáveis de ambiente mesmo sem aparecer explicitamente no lado esquerdo
Correção aplicada e mudanças em projetos relacionados
- Por fim, decidiu-se migrar no Linux para
rustlsem vez do backendrust-native-tls/openssldoreqwest - O motivo original para escolher o backend TLS nativo era evitar carregar dois engines TLS enquanto o código Python era migrado para Rust
- Depois desse problema, concluiu-se que, no curto prazo, carregar dois engines TLS era aceitável
- Uma alternativa seria executar a primeira chamada a
try_init_ssl_cert_env_vars()mantendo o GIL do Python- O Rust tem um lock interno que impede corridas entre códigos Rust que leem e escrevem o ambiente
- Mas isso não impede casos em que código de outras linguagens usa diretamente a
libc - Manter o GIL bloquearia ao menos a corrida com threads do Python
- O projeto Rust já reconheceu esse problema e planeja tornar funções setter de ambiente
unsafeno 2024 edition - O projeto glibc também adicionou recentemente uma mudança que aumenta a segurança em threads de
getenv()evitandorealloce vazando arrays de ambiente antigos
4 comentários
setenvnão é thread-safe, e C não quer corrigir issoA função
setenvestá causando problemas de novo.Eu escreveria o título como: "A insegurança de thread da stdlib de C é algo que nem o Rust, tão seguro assim, consegue remediar". :)
Entendi com certeza.
Opiniões no Hacker News
O ponto mais importante aqui é que, na próxima edição do Rust, as funções de definição de variáveis de ambiente passarão a ser unsafe.
Com sorte, o impacto deve chegar até os crates que causam esse tipo de crash; enquanto isso, foi aberta uma issue no upstream: https://github.com/alexcrichton/openssl-probe/issues/30
getenvesetenvouunsetenvnão podem ser chamados com segurança a partir de threads diferentes — não será realmente corrigido.A única solução confiável parece ser alterar essas funções para que elas necessariamente adquiram um mutex.
Infelizmente, o ecossistema não é assim: https://github.com/seanmonstar/reqwest/blob/master/Cargo.tom...
____UNSAFE_payattention__nevermindthatthisappears50timesinthisfile___.Algo parecido acontece em frameworks web: no Vue há a diretiva
v-html, e no React hádangerouslySetInnerHTML; nesse aspecto, acho que o Vue é claramente melhor.set_vareremove_varda biblioteca padrão do Rust passarão a exigir corretamente um blocounsafe {}na próxima edição, a edição 2024.A documentação também agora menciona a questão de segurança, mas ter tornado essas funções safe desde o início foi um erro — um erro que linguagens de nível mais alto também cometeram.
https://doc.rust-lang.org/stable/std/env/fn.set_var.html
A glibc tem um patch que torna
getenvseguro em mais casos nos quais o ambiente é modificado, mas C ainda permite acesso direto aenviron, então não dá para torná-lo completamente seguro em situações em que há modificações: https://github.com/bminor/glibc/commit/7a61e7f557a97ab597d6f...Isso cria um vazamento de memória de tamanho constante amortizado para cada variável de ambiente ativa, mas, segundo dizem, as próprias variáveis já têm esse tipo de vazamento, inclusive variando conforme o tamanho e incluindo valores que não são mais usados.
Parece claro que deve haver casos de uso patológicos em que, mesmo em programas corretos do ponto de vista da API, a memória cresça indefinidamente por causa disso.
Para corrigir programas que violaram as regras ao usar a API em várias threads, parece que estão introduzindo em programas que seguem a API um bug de crescimento infinito de memória; é interessante, mas incômodo. Passa uma sensação de pragmatismo acima do dogma.
unsafe.Mesmo que os mantenedores da biblioteca padrão de C sejam contra tornar
setenvseguro para múltiplas threads, no mínimo deveria ser definida uma nova API thread-safe, seja dentro do POSIX, seja primeiro como um padrão de facto que o POSIX aceite depois.Se o tempo gasto explicando por que nada pode ser feito tivesse sido usado para corrigir esse problema, já poderia existir uma substituta para o antigo
setenv, que muitos projetos de software poderiam depreciar e remover.Vendo que a glibc está fazendo mudanças para praticamente eliminar esse problema, a afirmação do mantenedor da Musl de que isso não pode ser corrigido dentro da Musl também fica pouco convincente.
extern char **environ;.Enquanto
environfor publicamente acessível, não há sequer garantia de quesetenvegetenvsejam usados, pois eles não são obrigatórios.Se fosse possível eliminar
environ, tornarsetenvegetenvthread-safe seria relativamente simples. Se não for possível eliminá-lo, é impossível; ainda assim, dá para argumentar que tornarsetenvegetenvthread-safe seria uma melhoria, mesmo que não seja uma solução completa.exec()que não recebem o ambiente como argumento ou que pesquisam oPATHpara encontrar executáveis também passariam a precisar de bloqueio.Encontrar bugs relacionados a variáveis de ambiente no Linux parece uma espécie de rito de passagem, e em outros Unix isso, curiosamente, tende a ser menos problemático
Linus e o kernel corrigem bugs do POSIX de forma pragmática, fazendo com que eles não estourem na prática; é meio engraçado que a glibc continue atrasada mesmo décadas depois de as pessoas tentarem ao menos mitigar o problema
É verdade que existem todo tipo de dores de cabeça como
TZ, mas, se tivessem oferecidogetenv_r(), sincronizado isso comsetenv()e apenas emitido avisos na etapa de compilação/linkedição ao usargetenv(), boa parte dos problemas teria desaparecidoDava até para ir além e usar uma abordagem de cópia na escrita (COW), deixando o ponteiro do ambiente como somente leitura
Em vez disso, empurraram o problema para cada aplicação, o que é um grande erro, porque quem escreve aplicações quase nunca consegue saber o que suas dependências fazem. Foi o que aconteceu comigo muito tempo atrás, e na época o fornecedor de uma biblioteca de código fechado me disse para parar de usar aquele clone de Unix de brinquedo chamado Linux
O problema não é a implementação, é a própria API.
setenv(),unsetenv(),putenv()e, especialmente,environsão inerentemente inseguros em programas multithreadgetenv_r()também não resolve tudo. Isso porque uma thread pode chamarsetenv()enquanto outra está copiando o valor antigo de uma variável de ambiente para o buffer fornecidoClaro,
getenv_r()corrige o caso em que, depois de receber algo viagetenv(), outra thread chamasetenv()e invalida aquela memória, mas não há como impedir outras chamadas que quebram a APIA libc pode mitigar parte do problema pegando um mutex dentro de
getenv()/setenv()/putenv()/unsetenv(), mas ainda não há como a libc garantir que o valor retornado porgetenv()permanecerá válido pelo tempo necessário para o código chamador usá-loTambém não há uma boa forma de tornar seguro o acesso direto a
environ. Seria possível tornarenvironthread-local, mas então cada thread poderia ficar permanentemente com uma visão diferente do ambiente, e o resultado de uma chamada agetenv_r()poderia divergir de uma verificação direta deenvironPreservar a compatibilidade retroativa aqui é realmente difícil, e só adicionar um mutex para proteger essas funções já poderia alterar a semântica de programas existentes e quebrá-los
Houve um texto antigo dizendo que
setenvé horrível: https://www.evanjones.ca/setenv-is-not-thread-safe.htmlTambém houve uma discussão, e já no primeiro comentário se dizia que isso causava problemas em Rust: https://news.ycombinator.com/item?id=38342642
Aqui, a maior parte do restante do problema parece estar no ambiente de desenvolvimento. Eles testavam usando Docker em uma máquina remota em um datacenter da Amazon, e essa máquina não conseguia reportar crashes de processos
Além disso, dentro do contêiner também não havia informações de símbolos de depuração suficientes para obter um backtrace. Se tivessem recebido um backtrace limpo na primeira falha, teria ficado claro imediatamente
Para começo de conversa, também é questionável por que usar
setenvEssa conversa me lembra o movimento 12-factor app, no qual alguns antigos colegas acreditavam muito. Um desses “factors” dizia que a configuração da aplicação deveria ser feita por variáveis de ambiente
Sempre achei isso meio tolo, porque esse modo de configuração é uma estrutura que coloca valores do tipo string em um espaço de nomes plano, como se fosse uma cesta
Também vejo os riscos de
getenv()/setenv()/environcomo um argumento forte para não usar variáveis de ambiente para configuraçãoClaro que nem sempre há uma alternativa excelente e bem suportada. Eu prefiro arquivos de configuração, e também dá para usar configurações-modelo preenchendo apenas os valores de desenvolvimento, staging e produção. Normalmente uso YAML, apesar das desvantagens e armadilhas; pode até haver formatos melhores para arquivos de configuração, mas acho YAML muito melhor do que variáveis de ambiente
No NT, variáveis de ambiente podem ser tipadas e modeladas, e também existe o Registro, um banco de dados de configuração com espaços de nomes. Embora seja verboso e estranho
Além disso, o MSVC fornece versões thread-safe de quase todas as funções da biblioteca padrão
Ouço com frequência novos desenvolvedores C/C++ lamentarem a falta de compatibilidade POSIX do MSVC, mas parece que não pensam a fundo no que isso realmente significa. Está mais para querer compatibilidade cruzada com programas em C escritos nos anos 1990
Elas atrapalham a capacidade de inferir o comportamento só pela assinatura de uma função e tornam impuras muitas funções que poderiam ter sido puras
Se existisse um recurso de linguagem para marcar uma aplicação de modo que, em qualquer processo, variáveis de ambiente não pudessem ser usadas e só pudessem ser lidas uma única vez — não uma vez por variável, mas uma única vez em lote — eu o usaria em todo lugar
getenv()em si é perfeitamente aceitável; o problema ésetenv()Em teoria, o ambiente é configurado antes que essa aplicação misteriosa seja iniciada, então não deveria haver necessidade de usar isso
Mas um espaço de nomes plano, valores string e um espaço global livre, compartilhado por todos sem saber quais bibliotecas e módulos vão entrar, não são uma boa ideia mesmo sem o problema de segurança de
setenv()A maioria dos problemas que as pessoas mencionam aqui parece vir do abuso do ambiente como um armazenamento chave-valor para estado global mutável. Não sei por que alguém iria querer fazer isso
A JVM trata o ambiente como efetivamente imutável, e é possível que empresas usuárias de Scala e Java, como a SoundCloud, tenham influenciado o movimento 12-factor app. Nunca passei por uma situação em que o ambiente mudasse ou causasse problemas de threading
Mesmo que o ambiente mude, a cópia imutável criada na inicialização da JVM permanece a mesma, e o código que interage com o ambiente pelas APIs Java comuns não vê essa modificação
O problema dos arquivos de configuração é que o parsing é por processo. É por isso que Linux/Unix é tão bagunçado. Cada ferramenta tem suas próprias convenções e mecanismos de configuração, e não há um padrão
No ecossistema Docker, dentro do contêiner você pode fazer o que quiser, mas a interface com o exterior é montar volumes e seguir o esquema complexo de configuração de cada app, ou simplesmente usar variáveis de ambiente
Hoje, a maior parte dos softwares modernos rodando em Docker é amigável o suficiente ao Docker para que seu comportamento possa ser totalmente controlado pelo ambiente, e em muitos casos isso basta
Se você usa Docker Compose ou Kubernetes, acaba tendo em um arquivo YAML a lista de variáveis de ambiente que define como o processo é iniciado, então isso também entrega, em certa medida, a estrutura desejada. Não gosto de YAML, mas funciona bem o bastante; problemas de sintaxe podem arruinar seu dia, mas as alternativas também não são isentas de problemas
Eu também uso o estilo 12-factor app, mas, depois que elas entram na aplicação, valido e armazeno as variáveis de ambiente e os dados. Depois disso, não há problema algum
É um ótimo texto que investiga um bug difícil de perceber
Tinha de tudo: bug intermitente, peculiaridade de arquitetura, algo escondido dentro de uma dependência, Rust, GIL do Python e até gettext
Relatos detalhados de resolução de problemas como esse são o material mais próximo de vivenciar a situação diretamente. Quando a dependência é que está usando aquilo, fica difícil simplesmente dizer “é só não usar X”, porque como eu saberia?
Dizem que “as máquinas de CI noturnas rodam na Amazon AWS e têm a vantagem de poder usar um usuário root real, não um contêiner”, mas ao mesmo tempo que “não há arquivos necessários fora do contêiner, e o contêiner é muito minimalista, então não dá para instalar
gdbfacilmente”As pessoas agora perderam a capacidade de compilar e depurar localmente sem nuvem e contêineres?
Para fazer coisas muito triviais, passou a ser necessária toda uma complexidade de nuvem e camadas de implantação. É como reverter 100% a revolução do PC e voltar à era desajeitada e cara da computação de mainframe
O motivo é que há dinheiro na nuvem, e a nuvem é DRM. Se você coloca o software lá, pode cobrar assinatura, não há como contornar, e dá para manter uma dependência perfeita para sempre. Muitas vezes o usuário nem consegue tirar os próprios dados
Também fica conveniente fazer análises em tempo real para otimização do produto
A arquitetura de computação vem a jusante do modelo de negócio. O mainframe morreu da primeira vez porque não havia internet e o PC era mais barato, mas também porque os fornecedores perderam muito poder de aprisionamento
Agora existe um jeito de ressuscitar um modelo muito mais lucrativo. As liberdades incômodas dos usuários desapareceram e, francamente, quando os usuários obtêm essas liberdades, muitas vezes acabam não pagando, o que também dificulta a existência de negócios de software de alta qualidade
É bem provável que não conseguissem reproduzir o crash localmente. As máquinas dos desenvolvedores eram majoritariamente x86, e nelas o crash não teria acontecido
O tratamento do crash deveria ter sido melhor, mas eles também parecem reconhecer isso, e esse não é o ponto central discutido aqui
Estado global mutável é maligno. Se você é amigo de alguém, não deixa essa pessoa usar estado global mutável
Odeio variáveis de ambiente. Esse é o “jeito Linux”, mas eu evito como a peste. Recomendo muito
A libc é horrível, e o mundo já deveria ter seguido em frente
O problema não é o Linux, nem estado ou recursos globais mutáveis, nem a libc
O problema é que, no trabalho, não dão tempo para fazer as coisas direito. Por exemplo, para pegar o problema no GDB antes que ele estoure, o chefe precisa dar tempo para depurar persistentemente e rastrear de volta o código e tudo que esse código toca
Há dinheiro demais em código meio cru. Triste, mas verdade
Havia tantos problemas desse tipo que, no fim, fiz patch em
getenv/setenv/putenvcomLD_PRELOAD