1 pontos por GN⁺ 2024-09-26 | 1 comentários | Compartilhar no WhatsApp
  • A Wafris está trocando, no cliente middleware Rails v2, o armazenamento Redis controlado pelo usuário do v1 por um armazenamento baseado em SQLite, com o objetivo de reduzir ao mesmo tempo a dificuldade de implantação e a latência na avaliação das requisições
  • A escolha original pelo Redis foi influenciada pela inércia do ecossistema Rails, com Heroku e Sidekiq, mas na operação real o problema de fazer o usuário assumir também o papel de administrador de Redis ficou grande demais
  • O caminho de leitura, que compara todas as requisições HTTP de entrada com as regras, é o principal gargalo, enquanto as escritas para relatórios podem ser tratadas mais lentamente ou separadas em lotes e de forma assíncrona
  • Em testes locais num MacBook Air M2 com um conjunto de dados de faixas de 1,2 milhão de entradas, no pior caso de consulta de faixa de IP, o SQLite mostrou desempenho cerca de 3 vezes melhor que o Redis local, sem incluir latência de rede
  • O v2 usa uma estrutura de sincronização em que verifica novas regras com base em um intervalo de tempo ou número de requisições e então baixa um novo banco SQLite completo, o que aumentou em cerca de 3 vezes as instalações bem-sucedidas

Atrito de implantação revelado no v1 baseado em Redis

  • A Wafris é uma empresa open source de firewall para aplicações web e fornece um cliente middleware que se conecta a aplicações Rails
  • O cliente v1 exigia um armazenamento Redis controlado pelo usuário implantado junto com a aplicação
  • A escolha inicial pelo Redis foi influenciada pelo ambiente em que era fácil conectar Redis no Heroku, pela conveniência do acesso remoto e por casos de sucesso como o Sidekiq
  • Os ambientes reais dos usuários eram mais variados, e muitos tiveram dificuldade para depurar problemas de implantação e configuração do Redis
  • No RailsWorld 2023 também havia um clima negativo em relação à suposição de que um servidor Redis ao lado de uma aplicação Rails fosse uma necessidade óbvia

O ponto central do problema de velocidade é a latência de rede

  • O Redis é rápido em comparação com RDBMS tradicionais, mas, por ser um banco separado, exige gerenciamento de conexão, memória e processo
  • Em ambientes de nuvem, a latência de rede afeta diretamente o desempenho do processamento das requisições
    • A Wafris precisa comparar todas as requisições HTTP que chegam à aplicação com as regras armazenadas
    • Mesmo deixando o cliente v1 o mais rápido possível, se a rede onde a aplicação estava implantada fosse lenta, toda a resposta poderia ficar lenta
  • As apps Rails também nem sempre eram implantadas como um único “majestic monolith”
    • Apps implantadas em várias zonas
    • Apps divididas em vários servidores com responsabilidades sobrepostas
    • Apps em que só uma parte era Rails e o restante era implantado com outras linguagens ou frameworks
  • Nesses ambientes operacionais, o atrito de usar Redis aumentava ainda mais

Separando leitura e escrita no processamento de requisições da Wafris

  • A Wafris é instalada como middleware Rails e, após configurar regras como “bloquear o IP 1.2.3.4”, compara as requisições recebidas com essas regras
  • O fluxo simplificado de processamento tem duas etapas
    1. Comparar a requisição HTTP com as regras e, se houver correspondência, responder 403; caso contrário, 200
    2. Reportar o resultado do processamento, como bloqueio, permissão ou passagem
  • A leitura de regras, que é a primeira etapa, é muito mais importante que a escrita de relatórios
    • As requisições precisam ser processadas de forma sequencial
    • Se a filtragem não funcionar, requisições maliciosas podem passar
    • A avaliação da requisição afeta o desempenho do site percebido pelo usuário
    • A escrita de relatórios pode ser tratada mais devagar, em lote ou de forma assíncrona

Por que escolheram o SQLite

  • O principal gargalo da Wafris era I/O de rede, e a frase da documentação do SQLite — “SQLite does not compete with client/server databases. SQLite competes with fopen()” — influenciou a decisão
  • Partindo da hipótese de que só eliminar a ida e volta pela rede já poderia torná-lo mais rápido que a estrutura baseada em Redis, a equipe comparou SQLite e Redis em benchmark
  • As referências foram as seguintes

Escopo do benchmark e limitações intencionais

  • O benchmark não era uma comparação geral de desempenho entre bancos de dados, mas um teste deliberadamente enviesado para o hot path da Wafris e sua pior consulta
  • A pior consulta era uma busca numa estrutura de dados “lexical decimal” que mapeia faixas de IP e categorias
    • Um exemplo simples é um mapeamento de IP → país, que verifica se um endereço IP está dentro do intervalo entre dois endereços e retorna o país
    • Essa estrutura chega a centenas de milhares de linhas, e no IPv6 cada item é grande
  • As consultas por faixa eram pré-calculadas e depois gravadas nos dois armazenamentos
  • Em casos patológicos, cada requisição HTTP de entrada precisava comparar o IP da requisição com as seguintes faixas
    1. Faixas personalizadas de permissão
    2. Faixas personalizadas de bloqueio
    3. Faixas de GeoIP
    4. Faixas de reputação de IP
  • Esse tipo de consulta era importante o suficiente para que a equipe testasse apenas ele, sem portar outras consultas ou funcionalidades

Método de teste e resultados

  • O teste foi feito localmente em um MacBook Air M2, usando Redis instalado via Homebrew e um banco SQLite local
  • O protocolo foi o seguinte
    1. Usar um conjunto existente de 1,2 milhão de itens de faixas
    2. Executar vários conjuntos de IPs no SQLite e no Redis na mesma ordem
    3. Rodar o teste 5 vezes em cada múltiplo e usar a média
  • No caso de uso específico da Wafris, o SQLite mostrou desempenho cerca de 3 vezes melhor que o Redis local
  • Esse resultado é um número de antes de considerar a latência de rede
  • O teste refletia intencionalmente uma configuração simples e imperfeições do uso real, então é difícil generalizá-lo como comparação universal entre bancos de dados

Diferenças operacionais que não aparecem no gráfico

  • Mesmo que o desempenho do SQLite no benchmark tivesse sido muito pior que o do Redis, a equipe entendeu que, em ambiente real, ele ainda poderia ser mais rápido por causa da latência de rede até um Redis no mesmo datacenter ou região
  • Mesmo que o servidor Redis fosse montado de forma robusta com cluster ou sharding, continuariam existindo limitações como largura de banda de rede, número de conexões e latência entre regiões
  • Como o SQLite existe localmente em cada instância de computação, nesse caso de uso da Wafris o custo de escalabilidade horizontal praticamente desaparece
  • O onboarding também ficou mais simples com SQLite
    • O usuário pode simplesmente adicionar a gem à aplicação web e executá-la sem nem saber que SQLite está sendo usado
  • Ainda há muito espaço para otimização no Redis, mas a Wafris teve dificuldade até para convencer os usuários a mudar configurações básicas como política de eviction de cache de forma consistente

Mudanças estruturais necessárias após a migração para SQLite

  • O fluxo de atualização no v1 baseado em Redis era simples
    1. O usuário atualiza as regras no Wafris Hub
    2. O Wafris Hub atualiza as regras no armazenamento Redis do usuário
  • Com SQLite, o Wafris Hub não conseguia “empurrar” diretamente o banco SQLite para o servidor web
  • Alguns provedores de SQLite as a service permitem algo parecido, mas não servia para a Wafris por questões de custo, desempenho e segurança
    • Cada usuário teria que implantar isso individualmente
    • Seria necessário abrir portas
    • Seria preciso permitir conexões de entrada
  • O fluxo de atualização do v2 baseado em SQLite passou a ser o seguinte
    1. O usuário atualiza as regras no Wafris Hub
    2. O cliente verifica regras atualizadas em intervalos definidos por tempo ou número de requisições
    3. Se as regras mudaram, o cliente baixa um banco SQLite totalmente novo
  • Essa estrutura reduziu bastante a responsabilidade do usuário na instalação e configuração, e as instalações bem-sucedidas do cliente v2 aumentaram em cerca de 3 vezes

A estrutura do SQLite em implantações distribuídas

  • Em ambientes de nuvem como AWS, Heroku e Fly, quando uma app Rails escala automaticamente, as instâncias de computação aumentam, mas muitas vezes o banco de dados não cresce junto
  • Se o tráfego sobe de 100 req/s para 10.000 req/s, dynos, machines ou instâncias EC2 podem subir, mas o gargalo do banco de dados pode continuar igual
  • A Wafris observou que a principal causa de falhas em apps Rails muitas vezes não era DDoS real, mas credential stuffing ou tráfego ruim de bots
    • Esse tráfego provoca autoscaling
    • Depois disso, as conexões com o banco podem se esgotar e a aplicação cair
  • Ao sincronizar o banco SQLite em cada instância de computação, todas as chamadas da nova instância podem permanecer locais

O caminho de escrita foi removido do cliente

  • Os testes anteriores não consideravam operações de escrita, e a equipe também não considera que o SQLite sirva para todos os papéis
  • A Wafris passou a usar SQLite em um papel centrado em leitura e redesenhou separadamente o caminho de escrita
  • O caminho de relatório do v2 mudou para o seguinte
    • Reportar por conexão assíncrona ao Wafris Hub
    • Enviar os dados de relatório em lote
    • Remover completamente a escrita em banco do cliente
  • Essa estrutura pode não servir para a maioria dos outros serviços, mas atende ao objetivo da Wafris de oferecer implantação simples e um cliente rápido
  • A arquitetura v2 baseada em SQLite já ajudou vários sites a resistirem a ataques e permanecerem online, além de reduzir o trabalho de suporte da Wafris e o incômodo para os usuários

1 comentários

 
GN⁺ 2024-09-26
Opiniões no Hacker News
  • O modelo em que cada servidor de aplicação tem uma cópia do arquivo de banco de dados SQLite e a substitui por completo periodicamente é realmente interessante.
    Aqui ele é usado para regras de firewall de aplicação web, mas parece que também se encaixaria bem em configurações de feature flags. Feature flags podem ser verificadas dezenas de vezes por requisição e muitas vezes exigem consultas como “o usuário pertence ao grupo A e o IP está no país B”, algo que o SQLite local consegue lidar bem. Em geral, é aceitável que atualizações de feature flags levem alguns segundos, ou até mais, para se propagar.

    • Esse modelo também é usado em CDNs. Um arquivo de configuração global contendo certificados de todos os clientes, regras de roteamento HTTP etc. é atualizado como uma estrutura B-tree em um único arquivo, e então esse “bundle” é distribuído com frequência para todos os pontos de borda.
      Dito isso, ainda não vi SQLite ser usado para esse fim; normalmente usam bancos de dados no estilo DBM, como LMDB ou Kyoto Cabinet.
    • No Airbnb, esse modelo também foi usado para distribuir traduções, feature flags, configurações, índices de busca etc. Em vez de SQLite, usavam o Sparkey, um formato de arquivo chave-valor criado pelo Spotify.
      No início, uma tarefa Cron em cada servidor buscava os dados daquele serviço; depois da migração para Kubernetes, usaram algo parecido com DaemonSet e marcação de hosts/taints para baixar vários conjuntos de dados em cada host, garantindo então que os serviços que usavam esses dados só fossem executados naquele host. Em Ruby, isso era chamado de “hammerspace”: https://github.com/airbnb/hammerspace
    • O problema de consultar valores de feature flags várias vezes por requisição é resolvido buscando os valores das flags uma única vez e armazenando-os no objeto da requisição.
      Assim, uma mesma requisição não paga mais de uma vez o custo de uma consulta cara, e também não há risco de os valores ficarem inconsistentes porque uma flag foi atualizada durante o processamento da requisição.
    • Depois de lidar por muito tempo com feature flags durante migrações longas, fiquei convencido de que o bloco básico realmente útil é “algo como SQLite, mas com um contador de versão global que aumenta a cada alteração, e com a capacidade de buscar ou aplicar apenas as mudanças entre números de versão”.
      Em cima disso, dá para colocar diversos mecanismos de distribuição, como uma rede de gossip ou uma rede explícita de cache/distribuição em árvore. Qualquer pessoa que tenha uma versão mais recente pode fornecer a atualização.
      A versão atual do DB pode ser incluída no artefato da aplicação, mas depois que o app estiver em execução ela pode ser atualizada de forma eficiente. Para feature flags ou configurações live, é possível fazer fallback para um estado recente em vez de valores padrão no nível do código.
      Qualquer cliente pode enviar o contador global como um ETAG e receber apenas as mudanças; reconectar após uma breve queda de rede também se torna simples e barato.
      Se for possível manter alguns minutos de histórico, dá para anexar o contador a chamadas entre microsserviços usando algo como o cabeçalho W3C Baggage, permitindo que vários serviços avaliem flags/configurações/dados com base no mesmo número de versão. Mesmo sem funcionalidade de avaliação baseada em tempo, registrar o número de geração nos logs ajuda muito na depuração posterior.
    • SQLite para distribuição é bacana. A ideia parece ter sido inspirada em alguma medida pelo projeto Datasette, e talvez ainda dê para fazer algo nessa linha mais adiante em reporting e exploração de dados.
  • Além da latência de rede, no Redis vi a latência de leitura/escrita tender a ser quase linear em relação ao número de chaves consultadas, e os gráficos do artigo parecem mostrar algo parecido.
    Em uma aplicação monolítica, quando usamos Postgres e Redis juntos para finalidades diferentes, isso funcionou muito bem, mas era fácil demais empurrar um novo recurso para um cluster Redis compartilhado. Como o Redis é single-threaded, um único recurso sem cuidado que faça leitura em massa de mais de 100 mil chaves pode deixar outros recursos mais lentos também.
    Por isso, sugeri a diretriz de que o Redis é especialmente bom quando várias funcionalidades aleatórias o usam para ler e escrever uma chave por vez, ou um número pequeno e fixo de chaves, como locks ou rate limiting em endpoints populares.
    Neste caso, o Redis brilharia em uma consulta simples de chave única (endereço IP), mas não parece se adequar bem a leituras mais complexas, como expressar consultas por intervalo. Eu não entendo a fundo por que o SQLite se comporta tão bem aqui em comparação com um Redis local, então isso foi inesperado e interessante.

    • Muitas vezes o Redis acaba sendo uma armadilha porque as pessoas entendem mal seus pontos fortes e fracos.
      O melhor é encarar o Redis como um cache com operações primitivas mais ricas. Quando usado de forma adequada, ele é rápido e confiável.
      Mas, quando você começa a colocar no Redis coisas que não se encaixam bem no RDBMS principal, logo aparecem filas de trabalho, vários tipos de locks etc.; no fim, é só questão de tempo até o desempenho cair de um penhasco ou tudo desmoronar por algum outro motivo. O processo de recuperação costuma ser bagunçado e tende a terminar com alguma perda de dados aceita.
      Esse fluxo acontece aos poucos e com facilidade, então é preciso disciplina para evitá-lo. O desempenho do SQLite não vem apenas de evitar o overhead de rede; também pesa o fato de muita gente subestimar o custo de serialização/desserialização. Mesmo que o protocolo do Redis seja bem minimalista, os custos se acumulam, enquanto no SQLite muitas operações acabam se reduzindo a cópias de memória dentro do próprio processo.
  • Algumas semanas atrás, em um hackathon interno da Neon, criei um pequeno servidor Node.js que transformava RESP, o protocolo de rede do Redis, em consultas Postgres.
    Foi um projeto de hacking muito divertido: https://github.com/btholt/redis-to-postgres

  • Parece um caso de uso específico em que SQLite no lado do servidor se encaixa muito bem, já que o banco de dados é somente leitura e não há necessidade de replicação.
    Outra alternativa seria carregar arquivos estáticos na memória, mas neste caso parece haver dados demais para mantê-los permanentemente em memória, então o SQLite se torna uma boa alternativa.

    • Espero que isso tenha ficado claro no artigo, mas esta é uma ótima solução para um caso de uso específico, não um substituto 1:1 para Redis ou Postgres.
    • Se você começar carregando arquivos estáticos na memória e for acrescentando apenas as funcionalidades necessárias para funcionar em casos de uso gerais, no fim acaba chegando a um banco de dados relacional como o SQLite.
      A diferença é que o SQLite é extremamente robusto, tem bom desempenho e é excepcionalmente bem testado.
  • Fiquei curioso com o trecho que dizia que havia um clima de “sentir cheiro de sangue” em relação ao Redis na RailsWorld 2023
    Na minha carreira, só trabalhei em uma app Rails em produção, e essa app usava Redis, então não conheço bem as tendências atuais. Fico curioso se o ecossistema está virando as costas para o Redis do ponto de vista de negócios, se é por causa da mudança de licença, ou se é uma situação mais próxima de YAGNI
    Na época, ele era usado principalmente com Rescue para agendar tarefas assíncronas como geração de índices e transcodificação, e naquele momento parecia uma ferramenta bem boa

    • É um pouco mais próximo de YAGNI. O principal motivo pelo qual a comunidade Rails passou a usar muito Redis foram as tarefas assíncronas, e a ferramenta que a maioria adotou foi o Sidekiq
      Na pesquisa da comunidade Rails de 2024, o Redis ainda aparece como o armazenamento de dados mais usado em apps
      Só que, embora muitas apps usem Redis, na prática elas costumam usá-lo apenas para coisas como Sidekiq, e não para recursos como placares em tempo real ou funcionalidades de banco de dados vetorial, então o padrão real de uso fica um pouco nebuloso
    • Vejo isso puramente como uma questão de simplicidade
      Hoje, em uma configuração Rails com algum tráfego, é comum ter servidores de front-end, um banco SQL, um armazenamento chave-valor (Redis ou Memcached) e um armazenamento de cache apontando para esse armazenamento chave-valor. Mas os padrões de uso comuns de chave-valor, como manter cotas de API ou rate limiting, são bem diferentes dos padrões de uso de cache, o que dá trabalho
      Como o desempenho de disco ficou suficientemente rápido e o desempenho de SQL também melhorou, há um movimento para remover o armazenamento chave-valor e dividir os usos tradicionais de chave-valor para o banco SQL e o backend de cache para o disco. Discos NVMe novos são quase tão rápidos quanto RAM, mas muito mais baratos, então dá para armazenar muito mais em cache
    • No passado, o Redis era útil como uma memória compartilhada muito inteligente, com recursos embutidos como expiração. Com clustering, também era possível compartilhar entre várias máquinas
      Mas, na era de Kubernetes e Redis-as-a-service, essa “memória compartilhada” acaba ficando em outra VM, em outro rack. Nesse ponto, você já abriu mão da eficiência de recursos, então acho melhor simplesmente ler e escrever arquivos no S3
    • SSDs hoje em dia são rápidos, então mais gente deveria usar RocksDB. Dá para rodar um armazenamento chave-valor muito maior em cima de SSD
  • Sobre o trecho “o Redis é rápido em comparação com RDBMS tradicionais, mas ainda é um banco de dados em que é preciso gerenciar conexões, memória, processos etc., o que torna a stack mais frágil”, acho que, quando se começa a lidar com transações além do nível de brinquedo, todos os bancos de dados, relacionais ou não relacionais, exigem mais ou menos o mesmo nível de administração e manutenção
    A parte de “rápido” também é um pouco interessante. Se você não precisa se preocupar com joins, inserções e consultas de linhas também são bem rápidas

    • O motivo de o SQLite ser excepcionalmente rápido em ambiente de servidor é que ele não precisa de chamadas de rede ao consultar ou buscar dados
      Quando se entende que ele lê diretamente do disco local, discussões sobre joins ou transações perdem muito do sentido. Isso, por si só, já é um caminho várias ordens de grandeza mais rápido
    • O SQLite tem uma operação VACUUM que parece uma espécie de garbage collection
      Toda vez que leio a documentação sobre quando executar VACUUM, fico confuso. Da última vez que implantei uma aplicação baseada em SQLite, resolvi simplesmente manter um contador e executar VACUUM depois de muitas operações de escrita
    • Não concordo com a afirmação de que “todos os bancos de dados exigem mais ou menos o mesmo nível de administração e manutenção”
      Se é um banco de dados que exige operar um processo de servidor independente, inevitavelmente haverá diferenças no esforço de administração e manutenção. Levando ao extremo, é difícil acreditar que usar SQLite e Oracle realmente não tenha diferença
  • Talvez você se interesse pelo Redka, uma reimplementação do Redis em Go usando SQLite: https://github.com/nalgeon/redka

    • Achei interessante o suficiente para considerar uma migração, mas hesitei ao ver a parte “segundo os benchmarks, o Redka é várias vezes mais lento que o Redis”
      É um projeto legal, sem dúvida, mas, nesse nível, há pouco incentivo para migrar
  • Quero confirmar se entendi corretamente a forma de uso do Redis
    Na v1, o WAF e o Redis ficavam no mesmo servidor, e, quando o cliente definia uma nova regra no painel de administração, essa regra entrava no Redis no mesmo servidor do painel de administração e, graças ao mecanismo interno de sincronização do Redis, as regras eram atualizadas nos Redis locais ao lado dos WAFs ao redor do mundo? Depois, quando uma nova requisição chegava a algum WAF, o WAF validava a requisição/IP usando as regras atualizadas no Redis? Quero confirmar se esse era o fluxo
    Na v2, o cluster Redis foi removido, todos os servidores WAF passaram a ter um DB SQLite, foi criado um mecanismo separado de sincronização para distribuir novas regras do painel de administração para cada servidor WAF+SQLite, e, quando uma nova requisição chega, o WAF faz a validação rapidamente usando as regras atualizadas no SQLite? Fico curioso se essa é a estrutura

  • A melhor frase é: “O SQLite não compete com bancos de dados cliente/servidor. O SQLite compete com fopen()