System Design: Web Crawlers sem Downtime
Olá!
Esse texto faz parte de uma série sobre System Design. Você encontra outros textos relacionados nos textos abaixo:
Desafio 0: separando banco de dados
Desafio 1: integração de arquivos
Desafio 2: processamento idempotente de arquivos
Desafio 3: síncrono para assíncrono
Desafio 4: métricas em tempo real
Desafio 5: integração com eventos via webhooks
Desafio 6: exposição de serviços
Desafio 7: hashes de senhas
Desafio 8: migração de dados para nuvem
Desafio 9: tokens opacos de acesso
Desafio 10: sistemas de delivery cell-based
Vamos à definição do problema em questão:
Como você projetaria um sistema de web crawling que precisa coletar informações constantemente de milhares de sites, em tempo real, sem interrupções no serviço (ou seja, sem downtime). O sistema precisa ser capaz de lidar com um grande volume de dados e variações de carga, mantendo a disponibilidade e a consistência das informações.
Dado que já é tradição desse tipo de texto, vou começar com a solução mais simples possível e a partir daí, vamos complicando as coisas.
A título de curiosidade, web crawler também é chamado de web spider, indexador ou bot de busca.
Solução Ingênua
Um crawler pode ser usado para inúmeros fins, para tentar deixar menos abstrato, minha sugestão aqui é imaginar que estamos tentando projetar um sistema que seja usado para inteligência de mercado de varejo. Ou seja, vamos buscar em inúmeros sites por informações públicas de produtos, como preço de venda e informações similares.
O jeito mais simples de se fazer isso é o jeito mais “ingênuo” possível, no qual tudo roda em um lugar só. Nesse exemplo, temos um script python que usa Beautiful Soup e consulta as URLs a serem processadas de uma tabela chamada URLs e escreve em uma tabela Content após o término do processamento.
Esse processo funciona dessa forma? Sim. No entanto, essa arquitetura não escala e pode ter algum downtime caso o banco de dados fique indisponível.
Além disso, como podemos expor as funcionalidades desse crawler para uso? Em linhas gerais, nem uma camada adequada de input esse desenho possui.
Então, vamos expor uma forma de os usuários conseguirem interagir com esse web crawler adequadamente. Adicionando uma camada de API para que o input fique mais flexível, temos:
Lembrando que o desafio é fazer uma arquitetura sem downtime. Para não soar repetitivo nessa série de textos, a solução está em uma API multi-instância + API Gateway atuando como Load Balancer, assunto que já abordei aqui.
Com isso, temos:
Me soa um pequeno overengineering adicionar Kubernetes apenas para escalar essa camada de API. Dado a natureza desse processo, é possível seguir um caminho mais simples. E se ao invés de algo mais robusto para processar somente o input, tivessemos algo que respondesse sob demanda e funcionasse (em termos de escala), tão bem quanto?
Uma sugestão aqui nessa etapa é usar serverless. Lembre-se de considerar o cold start, que é uma limitação comum desse tipo de serviço de nuvem. Vale destacar ainda que eu vejo essa etapa como um processo que não precisa ser long running (ao contrário de alguns que veremos mais adiante). Portanto, usando esse método, temos algo escalável e relativamente barato, sem downtime também.
Um outro detalhe: o input do usuário. Assumindo a natureza recursiva de um crawler, o input sugerido seria somente a base_URL de um determinado domínio + informações de scheduling (uma cron expression já resolve isso facilmente). Segue um exemplo:
[
{
"url": "https://example.com",
"scheduler": "0 0 * * *",
"_comment": "Executa todos os dias à meia-noite (00:00), iniciando o script diariamente."
},
{
"url": "https://meusite.com.br/produtos",
"scheduler": "30 2 * * *",
"_comment": "Executa todos os dias às 2h30 da manhã"
},
{
"url": "https://outrosite.com.br/lista",
"scheduler": "0 6 * * *",
"_comment": "Executa todos os dias às 6h da manhã"
},
{
"url": "https://dados.publicos.gov/diario",
"scheduler": "0 * * * *",
"_comment": "Executa a tarefa de hora em hora, sempre no minuto zero (por exemplo, 13:00, 14:00, 15:00, etc.), todos os dias, todos os meses e todos os dias da semana."
}
]
Com isso, diretamente na lambda, conseguimos resolver de uma forma fácil o problema de scheduling. Se for necessário abstrair a cron expression para um usuário final, um front-end poderia cuidar disso sem dificuldade. Contudo, para que esse artigo não se torne uma documentação em texto de como fazer um sistema inteiro de uma startup, continuemos.
Após o input em lambda function, para manter a consistência de manter o ecossistema inteiro com 100% de uptime, um caminho que podemos seguir para resolver essa camada da inserção de dados é desacoplar o input do restante do processamento, para que consigamos escalar independentemente o restante do fluxo.
O jeito mais simples que eu consigo pensar de fazer isso é adicionar uma fila após a lambda e entre a próxima etapa de processamento.
Ou seja, a lambda lê o input do usuário e enfilera cada domínio + informações de cron em uma fila (no desenho abaixo, chamado de URL queue). Após essa etapa, um worker lê cada URL independentemente e grava o conteúdo em um banco apropriado.
Ainda assim não está bom. Pelos seguintes motivos: o crawler está fazendo coisas demais sozinho e o banco de dados ainda são pontos únicos de falha (SPOFs).
Adicionando mais algumas camadas de indireção, podemos separar um pouco melhor as responsabilidades e utilizando algumas técnicas um pouco mais sofisticadas, resolver eventuais duplicidades que possam ocorrer na distribuição de URLs.
Aqui vale uma explicação específica: imagine que o usuário subiu duas URLs diferentes, mas, que durante o processamento e extração dos links relacionados, surgiram em dois sites diferentes o mesmo apontamento de URL. De que forma podemos garantir que não gastaremos processamento desnecessário passando pela mesma URL 2x sem necessidade?
A solução está em uma estrutura de dados chamada Bloom Filter. Aqui tem um texto meu sobre se você quiser se aprofundar. Mas, resumidamente, é uma estrutura de dados probabilística e bastante eficiente que consegue dizer com certeza que um determinado elemento não está contido na lista (que é a feature que queremos para esse problema).
Dado que o nosso consumidor da fila do usuário (estou chamando ele de URL Filter) pode ter problemas em verificar uma URL isoladamente e queremos que ele escale de uma forma adequada, usar o bloom filter isoladamente no código da instância não funcionaria. Precisamos então, de um bloom filter distribuído. Já existem inúmeras ferramentas que fazem isso. A grosso modo, é algo implementado em cima de um Redis. A verificação é simples: sempre verifica no bloom filter se a URL já foi processada, se não foi, continua o processamento. Se já foi, o fluxo é interrompido.
Considerando que precisamos acessar cada URL e sub_URL para ir fazendo a travessia dos links, seria legal adicionar alguma camada de configuração também, como parametrização de rate limit, quantidade de threads e similares. Como o intuito é zero downtime, essa camada pode ser facilmente ignorada caso o worker suba e ela esteja fora do ar. Nesse cenário, o worker sobe com parâmetros default.
Até o momento, temos (com o fim do fluxo omitido intencionalmente):
A parte de parametrização pode muito bem dividir recurso usando o mesmo redis do bloom filter, desde que tenha algum tipo de failover e alta disponibilidade. Por outro lado, para evitar mais riscos de downtime, optei por separar o banco de configuração e o bloom filter em instâncias separadas.
Um outro ponto que vale mencionar é que o bloom filter não é perfeito e possui falsos positivos, minha sugestão para lidar com esse problema é replicar as regras de unicidade ao longo do processo (mais detalhes mais adiante).
Dada a natureza de ser um processamento relativamente rápido a extração de links, é possível utilizar lambdas aqui também, ao meu ver. Lembrando que o filtro de conteúdo mal-formado, inválido ou respeito ao robots.txt dos sites ocorre aqui.
Caso ocorra algum tipo de impacto na performance, a sugestão aqui seria remover as lambdas dessa camada para usar pods k8s comuns. Mantendo o Redis, obviamente.
Expandindo a solução
Eu pensei bastante no que vem depois da extração de links únicos com o bloom filter. Considerando que precisamos manter o fluxo funcionando sem downtime e bancos de dados podem cair, qual o modo de isolar os domínios e as URLs de uma forma que não dependa de um DB?
Bom, é simples: arquivos.
O fluxo agora se torna o seguinte: após cada extração de links, os links de cada URL são agrupados em um arquivo para cada domínio. Onde um consumidor (chamada de raw extractor) lida com o conteúdo bruto de cada página html e usa o mesmo pattern de filas + DLQ para desacoplar a escrita no banco de dados dos dados brutos.
Pensando no nosso exemplo inicial (inteligência de mercado de produtos), os dados brutos podem vir a calhar para alguma outra análise futura. Por isso, optei por armazenar ao invés de descartar.
Com cada worker terminando o processamento de cada domínio com sucesso, basta remover o arquivo da hospedagem e jogar o conteúdo na fila de inserção.
Nessa etapa, dado que as páginas podem ter um processamento maior do que uma simples extração de links, lambdas talvez possam ser um problema. Por isso optei por usar consumidores comuns ao invés de functions aqui.
Com isso, temos:
A partir daqui, um pattern se repete. Basta mandar via fila para extratores específicos, que também escrevem no banco de dados via filas. Eu chamei o último step de product info extractor. Porém, poderia ser especializado para qualquer outra info, bastaria manter o padrão arquitetural que funcionaria de forma independente do resto.
Além disso, lembra do problema dos falsos positivos do bloom filter? Seria interessante tanto o raw extractor quanto o product info extractor terem controle de unicidade, garantindo que ao longo do tempo, os dados cresçam de forma saudável e sem muitas redundâncias. Uma outra técnica que poderíamos utilizar para manter esses bancos de dados com os tamanhos controlados é aplicar expurgos de tempos em tempos e mandar esses dados para um datalake.
Dado que também estamos falando de downtime, é importante ter uma camada de APM para monitoramento e alertas, de uma forma que seja possível detectar com antecedência comportamentos anômalos e uma melhor atuação do time de engenharia na manutenção da disponibilidade desse ecossistema.
Na versão final, temos a seguinte arquitetura:
Cada componente lidando com individualmente com eventuais indisponibilidades dos seus componentes adjacentes e um fluxo linear e relativamente fácil de entender.
Um detalhe que vale mencionar: atingir 100% de uptime de forma realista envolveria técnicas como multi-cloud e georeplication. Essa parte ficaria fora do escopo do texto, que é voltado para system design.
O único downside geral que eu vejo é custo. Afinal, manter toda a infra-estrutura mencionada provavelmente consumiria uma quantidade considerável de dinheiro, certo?
Esse é o principal trade-off que gostaria de deixar explícito aqui: software bem-feito custa caro. Porém, software sem funcionar é ainda mais caro.
Caso você encontre algum erro ou incongruência na arquitetura, me mande uma mensagem, adoraria conversar mais sobre :)
Espero que o texto seja útil para você de alguma forma.
Até!
Você gostou do conteúdo e gostaria de fazer mentoria comigo? Clique aqui e descubra como.