Recuperação eficiente de imagens em escala usando o Amazon S3 Versioning · trivago techblog

tech no trivago Blocked Unblock Seguir Seguindo 2 de setembro de 2018

Como o trivago gerencia imagens de milhões de hotéis

Se você estiver usando o Amazon Web Services, há uma possibilidade maior de estar familiarizado com o Amazon S3. O Amazon S3 (Simple Storage Service) é um serviço amplamente utilizado, no qual podemos armazenar (teoricamente, quantidade ilimitada de) nossos dados com alta disponibilidade (99,99%) [1]. É por isso que nós, a equipe de Conteúdo Visual do trivago, usamos o Amazon S3 para armazenar as imagens que você vê em nosso site e muitas outras ferramentas.

O desafio

Nós, a equipe de Conteúdo Visual, nos deparamos recentemente com um cenário relacionado à recuperação de imagens excluídas, o que nos fez perceber que devemos ter um método de backup confiável para nossas imagens. Quando estávamos fazendo a pesquisa para encontrar uma solução melhor, percebemos que não seria econômico manter cópias de nossos arquivos como backups regulares, já que temos centenas de milhões de imagens e isso nos custaria o mesmo valor que Estamos pagando pelo balde S3, onde armazenamos os dados originais (ao vivo).

Existem dois outros serviços oferecidos pela Amazon: S3 IA (S3 Infrequent Access) e Amazon Glacier . Como o nome sugere, o S3 IA é usado quando os arquivos de backup não precisam ser acessados com frequência, o que adicionará custos adicionais ao recuperar os arquivos. O Amazon Glacier é um método de arquivamento que introduz um atraso adicional junto com o custo adicional para a recuperação de dados [2].

Além disso, geralmente não substituímos / sobregravamos arquivos em nosso bucket do S3, mas fazemos o upload e (em alguns casos) os excluímos. Com esses requisitos, precisávamos de uma maneira reativa de fazer o backup, em vez de usar um método em massa, em que apenas fazeríamos o backup dos arquivos alterados. Em seguida, teremos um mecanismo para reverter caso nossos arquivos sejam excluídos (por acidente ou devido a qualquer outro problema técnico). Como a maioria dos provedores de soluções de terceiros tem uma abordagem de backup em massa, continuamos procurando um método eficiente, porém econômico.

Em seguida, ficamos sabendo que o próprio Amazon S3 fornece a solução mais conveniente que atende perfeitamente aos nossos requisitos: Versão do S3.

NOTA: Podemos nos dar ao luxo de gastar algum tempo na decisão dos critérios de filtragem para a restauração do arquivo, pois, mesmo que os arquivos sejam excluídos do S3, eles ainda estarão disponíveis nos caches CDN por alguns dias.

Versão do Amazon S3

A abordagem da Amazon não é complexa, mas muito eficaz do ponto de vista de armazenamento e, em última instância, de custo. Se habilitarmos o controle de versão para um bucket do S3, que é tão simples quanto colocar uma marca de seleção na página de opções do bucket, a Amazon criará uma nova versão de cada arquivo apenas quando eles forem alterados. O que significa que, quando substituímos um arquivo por um novo, ele carregará o arquivo com uma nova versão, mantendo o arquivo original. No caso de excluirmos um arquivo, um marcador chamado Excluir marcador será adicionado ao arquivo enquanto o arquivo original permanecer intacto. No entanto, no console da Web do S3, temos a opção de mostrar apenas as versões mais recentes dos arquivos, o que nos poupa de muita confusão.

Para facilitar a compreensão, imagine o histórico de versões de um único arquivo como uma pilha, onde as versões mais recentes (incluindo os marcadores de exclusão) são empurradas de cima para baixo.

No entanto, se quisermos excluir um arquivo (ou uma versão) permanentemente, devemos excluir o arquivo fornecendo a versão específica que queremos excluir. Se você quiser saber mais sobre o S3 Versioning, confira a documentação oficial .

API de versão do Amazon S3

A API do Amazon S3 fornece dois endpoints úteis para o controle de versão:

  1. versões de objeto de lista
  2. excluir objetos

Listando as versões do objeto

Com a primeira chamada de API, obteremos todas as versões de todos os arquivos no determinado intervalo e não haverá nenhum método para especificar nenhum filtro, exceto o prefixo da chave (caminho do arquivo). Como todas as respostas são paginadas, devemos usar NextKeyMarker e NextVersionIdMarker de cada resposta e usá-las para KeyMarker e VersionIdMarker respectivamente para a próxima solicitação, até recebermos uma resposta com IsTruncated como False . Devido a essa dependência, esse processo de recuperação parece seqüencial.

Na primeira iteração do nosso script, seriam necessárias mais de 60 horas para listar todos os 100 milhões de arquivos que temos em nosso bucket do S3. Precisamos pensar em maneiras de reduzir esse tempo de execução.

Limpar e restaurar operações

Cada uma das entradas nas respostas que obtemos das versões list-object terá duas listas: Versions e DeleteMarkers , que contêm versões de objeto e excluem marcadores separadamente. Com essas listas, podemos

  • Restaure os arquivos excluídos excluindo os Marcadores de Exclusão em que o IsLatest é True .
  • Exclua versões mais antigas de um arquivo excluindo os objetos em que IsLatest é False .
  • Use o campo LastModified na resposta se quisermos restaurar nosso bucket para um determinado ponto no tempo.

Nossa abordagem

Além da abordagem tradicional, fizemos várias modificações e aprimoramentos para obter melhores resultados.

Fazendo solicitações de list-object-versions em paralelo

Como explicamos anteriormente, o processo de recuperação de versões de objetos parece seqüencial. Mas se soubermos como os arquivos e diretórios são organizados no bucket, podemos executar solicitações independentes paralelas para prefixos diferentes que retornarão as versões de objeto para os arquivos dos quais a chave (caminho de arquivo) inicia com o prefix . Dessa forma, podemos paralelizar as chamadas da API list-object-versions .

Por exemplo, se temos 8 diretórios em nosso bucket S3 (nomeados dir_1… dir 8), podemos executar 8 threads em paralelo realizando chamadas de API list-object-versions iterativamente, cada uma com dir x como o prefix .

Armazenando os resultados

Um ponto importante a ser lembrado é o custo da Amazon por solicitação . Por isso, devemos tentar otimizar o uso das chamadas da API. No momento da redação, o preço deles é o seguinte:

PUT, COPY, POST, or LIST Requests $0.005 per 1,000 requests

Por exemplo, se tivermos 100 milhões de objetos (com uma versão) em nosso bucket, precisaremos fazer 100.000 solicitações para obter todas as versões de objetos se cada resposta contiver 1.000 entradas. Então, nós pagamos 0,005 x (100.000 / 1000) = $ 0,5 para listar todo o nosso balde. Se tivermos muitas versões de um arquivo, esse custo aumentará drasticamente.

Então, imagine que tenhamos escrito um bom script para listar todas as versões, filtrar os objetos e DeleteMarkers com as nossas condições e excluí-los. Quando estamos executando esse script na metade, ele sai por algum motivo e agora temos que executar o script novamente. Mesmo se pudermos suportar o tempo que levamos para correr, devemos ter em mente que isso nos custará também. Portanto, deve haver uma maneira de evitar a solicitação da lista inteira nesse cenário.

E, além disso, incorporar os critérios de filtro em nosso próprio script é complexo e, em seguida, diminuirá o processo de recuperação. E se a filtragem precisar de feedback de alguma outra parte (como proprietário de produto ou consumidor), não poderemos executar a exclusão on-the-fly.

Devido a essas restrições, pensamos em armazenar essas respostas que recebemos da chamada da API list-object-versions em duas tabelas de banco de dados MySQL: objects e delete_markers . A razão pela qual escolhemos o MySQL é que queríamos permitir que este script fosse executado em nossas máquinas locais, onde o SQLite não suporta gravações paralelas [3] e o uso de um banco de dados na nuvem teria adicionado mais complexidade.

Depois de obtermos uma resposta, formulamos duas consultas com várias linhas para as Versions e DeleteMarkers nessa resposta e as executamos nas duas tabelas respectivas. Dessa forma, poderíamos reduzir também o número de consultas de inserção feitas no banco de dados. Assim, cada thread que é responsável por um prefixo fará a chamada da API, receberá a resposta, formulará as duas consultas e depois as inserirá nas tabelas, até receber o IsTruncated como False .

Mesmo se o processo de recuperação parar (por algum motivo), agora podemos reiniciar a recuperação recuperando as chaves e versões iniciais, pois temos a última chave recuperada e a versão para cada prefixo.

Fazendo solicitações de delete-objects em paralelo

Depois de refinar as versões que precisam ser restauradas (ou seja, os marcadores de exclusão a serem excluídos), podemos executar várias solicitações de API de exclusão em paralelo. Podemos ter um número configurável de threads e cada thread lerá uma parte dos registros do banco de dados, formulará uma solicitação de exclusão e a enviará para a API até que o número de registros lidos do banco de dados seja 0.

Por exemplo, se tivermos 8 encadeamentos e quisermos gerar solicitações excluídas com 1000 versões de objetos para serem excluídas (dado o fato de que o banco de dados agora possui apenas versões a serem excluídas), cada encadeamento lerá blocos (de 1000s) um após cada 8 blocos. Isso significa que o encadeamento 1 lerá as linhas de 0 a 999, 8.000 a 8.999, 16.000 a 16.999 e assim por diante, enquanto outros encadeamentos seguem o mesmo padrão.

O motivo para usar esse tipo de padrão é que inicialmente não sabemos o número de linhas no banco de dados e a execução de uma count(*) também levará alguns minutos quando tivermos milhões de registros.

Abordagens descartadas

Estabelecemos o método descrito acima depois de descartar várias outras abordagens. No entanto, gostaríamos de compartilhar os aprendizados de nossas tentativas fracassadas.

Chamadas sequenciais para versões de objeto de lista

Chamar o endpoint list-object-versions seqüencialmente para todo o bucket é muito ineficiente. No primeiro estágio, levamos cerca de 40 minutos para recuperar um milhão de objetos. Depois que usamos vários segmentos em que cada thread manipulou seu próprio prefix , conseguimos recuperar um milhão de objetos (e gravá-los no banco de dados) em menos de cinco minutos.

Filtragem na memória

Como mencionado anteriormente, nos deparamos com vários casos em que o script parou devido a alguns erros e tivemos que reiniciá-lo para ler a lista inteira de versões desde o início. Não apenas para evitar a reiteração, mas também para ativar a filtragem offline, decidimos evitar a filtragem na memória e usar um banco de dados. No entanto, não usamos o SQLite, pois ele não suporta gravações paralelas. Nós escolhemos o MySQL não apenas porque ele suporta gravações paralelas, mas também não queremos complicar nossa solução introduzindo bancos de dados na memória.

Padrão produtor-consumidor

Uma das melhores maneiras de paralelizar esse tipo de processo é usar o padrão produtor-consumidor, onde os produtores farão a recuperação dos dados e os consumidores executarão a tarefa. Primeiro, implementamos o produtor-consumidor para as fases de recuperação e exclusão, mas tornou-se complexo para lidar com os casos de borda.

Por exemplo, o padrão típico produtor-consumidor é executado teoricamente infinitamente, mas no nosso caso sabemos quando os produtores estão prontos e não há uma maneira simples de comunicar isso aos consumidores. Passar um simples sinalizador “concluído” para todos os consumidores e verificar cada iteração não funcionaria, porque temos vários produtores e temos que esperar que todos eles terminem. Devido a esses tipos de casos de borda, há uma grande chance de que encadeamentos do consumidor entrem em deadlocks e / ou inanição. Portanto, evitamos usar o padrão produtor-consumidor.

Resultados finais

Testamos nossa solução final (em Python3 usando Boto3 ) em um dos nossos maiores buckets S3 com cerca de 100 milhões de objetos e os resultados foram promissores. Escolhemos o Python não apenas porque usamos o Python para escrever a maior parte do nosso código no Visual Content, mas também devido à portabilidade e flexibilidade que precisávamos para esse script.

Conseguimos recuperar um milhão de objetos em menos de 5 minutos, o que significa que serão necessárias apenas ~ 8 horas para listar as versões do objeto, o que levaria de 60 a 70 horas se o fizéssemos de maneira completamente sequencial sem usar prefixos. De acordo com nossos cálculos, podemos enviar um milhão de solicitações de exclusão em menos de três minutos, o que significa que levará apenas cinco horas para realizar a restauração em todo o nosso depósito se excluirmos todos os 100 milhões de nossos arquivos. A soma da duração é menor que um dia e nos permite ter tempo suficiente para fazer a filtragem e recuperar dentro do período de cache da CDN.

Como escrevemos as entradas em um banco de dados, também nos preocupamos com a utilização do espaço em disco e usamos 100 MB por 1 milhão de linhas, onde ficamos por volta de 10 GB para armazenar todas as entradas em nosso bucket (com 100 milhões de objetos ).

O backup de um grande depósito do S3 nos custará mais do que estamos pagando pelos dados originais. Uma das melhores abordagens que encontramos é ativar o controle de versão para o bucket do S3. No caso de uma exclusão em massa de objetos, podemos escrever um script para restaurar os objetos. Implementamos nossa solução com o objetivo de recuperar todo o bucket do S3 com milhões de arquivos.

No entanto, pode haver outras abordagens que poderiam dar melhores resultados. Sinta-se à vontade para nos informar como podemos melhorar ou o que poderíamos ter feito. Se você gosta de trabalhar em projetos semelhantes conosco, talvez até queira se candidatar para as nossas vagas abertas .

Felicidades!

Referências

[1] Consulte “Perguntas frequentes gerais do S3” ? “Qual é a confiabilidade do Amazon S3?” Seção nas Perguntas frequentes

[2] Classes de Armazenamento do Amazon S3

[3] FAQ do SQLite : É possível que vários aplicativos ou várias instâncias do mesmo aplicativo acessem um único arquivo de banco de dados ao mesmo tempo?