Uma boa arquitetura pode inviabilizar um bom projeto na nuvem. Pode parecer um paradoxo, mas quando uma arquitetura não considera a volumetria e os custos associados dos serviços, os exemplos abundam. Uma das causas deste fenômeno é continuar pensando da mesma forma na hora de resolver problemas, novos ou não.
Um exemplo prático
Imagine um serviço de envio de emails em massa, enviando cerca de 7 Milhões de emails/dia. A infra estrutura na nuvem é baseada em servidores EC2, com auto-scaling tanto para as máquinas de processamento dos emails quanto o cluster responsável pelo processamento do retorno e emissão de relatórios gerenciais.
O processo de envio tem início com o recebimento do arquivo via upload com a lista de emails a serem enviados (arquivo csv). O arquivo deve lido e os dados inseridos no banco de dados ao mesmo tempo. Este processo é custoso para o banco de dados, ocorrendo degradação à medida em que a base cresce.
A arquitetura da solução é demonstrada abaixo:
O problema mais simples de resolver é a eliminação completa do cluster usado para gerenciar os uploads. Isto requer uma mudança na aplicação que consiste na chamada de um serviço Lambda, um bucket no S3 e um evento no CloudWatch para processar o arquivo uma vez recebido.
A função Lambda abaixo demonstra como fazer a assinatura do arquivo. Note que estamos assinando o path com o nome do arquivo, não o arquivo em si. O arquivo será submetido pela aplicação diretamente para o S3 usando a assinatura devolvida pela função Lambda, sem custos de envio.
/** * Gera a assinatura de um arquivo para upload para o s3 */ var crypto = require('crypto'), awsKey = "ACCESS KEY", secret = "ACCESS KEY SECRET", maxSizeFile = 100000000, // 100MB, minSizeFile = 1; // 1MB, exports.handler = (event, context, callback) => { var body = event["body-json"], expiration = new Date(new Date() .getTime() + 1000 * 60 * 5) .toISOString(), bucket = `myfiles-media-bucket-${event.context.stage.toLowerCase()}`; if (!body.size || !body.type) { return callback(400, "[BadRequest] Missing Parameters"); } if (body.size > maxSizeFile) { return callback(400, "[BadRequest] Very large file"); } var key = `media/${body.prefix}/temp/` + (new Date()) .getTime() + '.' + body.type; var acl = 'public-read'; var conditionsObjects = { bucket, key, acl } if (body.params) { for (let key in body.params) { body.params[key] = encodeURI(body.params[key]); conditionsObjects[key] = body.params[key]; } } var conditionsArray = [ ["starts-with", "$Content-Type", ""], ["content-length-range", minSizeFile, maxSizeFile] ]; for (let key in conditionsObjects) { conditionsArray.push({ [key]: conditionsObjects[key] }) } var policy = { "expiration": expiration, "conditions": conditionsArray }, policyB64 = new Buffer(JSON.stringify(policy), 'utf8').toString('base64'), signature = crypto.createHmac('sha1', secret) .update(policyB64) .digest('base64'); callback(null, { bucket, awsKey, "policy": policyB64, signature, key, acl, "params": body.params }); }
O custo total do upload passa agora para US$ 0.00001667. Lembrando que o upload em si não possui custo, todo custo do processo é a chamada da função para assinar a URL, que ainda possui a vantagem de suportar múltiplos processos e alta concorrência de usuários. Com isto eliminamos a necessidade de servidores. Do lado da aplicação é necessário mudar a forma como o arquivo é submetido. O processo é descrito abaixo:
O processo pode ser resumido como consumir uma API e fazer um post com a URL devolvida.
Processando o upload
A segunda parte é um pouco mais complexa, o que fazer para ter capacidade de processamento pesado, mas sem ter servidores? Primeiro vamos revisar algumas tecnologias disponibilizadas pela AWS, começando pelo S3.
Eventos no S3
Uma vez recebido o arquivo é necessário processar seu conteúdo e iniciar o envio dos emails. Isto requer que o processo de processamento seja iniciado pelo S3 tão logo o arquivo tenha sido recebido. Isto é obtido pelo recurso do S3 chamado "Events":
Nas propriedades do bucket para onde será feito o upload temos a definição da função Lambda que será executada quando um arquivo for submetido:
É possível disparar o evento de forma condicional, indicando que o mesmo deve iniciar apenas para certos arquivos ou arquivos iniciados com certos padrões ou que tenham certas extensões. A função Lambda não processará diretamente o arquivo em questão, mas será usado para chamar um processo mais robusto que será capaz de realizar a tarefa em questão.
Lambda e ECS
Lambda é conveniente para muitas questões, mas quando trata-se de processamento que pode levar dezenas de minutos ou mesmo horas, encontramos limitações como o tempo máximo de execução (300 segundos) ou a quantidade de memória RAM que pode ser definida para uma função (3GB). O que precisamos é de uma função simples, que inicie um processo capaz de lidar com a demanda de processamento. Para o processamento intenso utilizaremos o ECS - Fargate com uma pequena ajuda do SQS (Simple Queue Service). Zero servidores, todo o poder computacional necessário sob demanda.
A arquitetura proposta tem o seguinte fluxo:
Arquitetura proposta para processamento dos arquivos e envio dos emails, substituindo servidores EC2 com Auto-Scale
No exemplo acima embora existam dois buckets, trata-se do mesmo, mas usado em dois momentos distintos.
Esta arquitetura possui diversas vantagens, incluindo a escalabilidade e o baixo custo. Outras vantagens incluem baixo custo operacional, escalabilidade, custos sob demanda (pague apenas pelo o quê você usar)
Função Lambda
A função abaixo coloca numa fila do SQS uma mensagem com os parâmetros a serem processados pelo nosso script que rodará no ECS. A função também cria a instância do ECS e finaliza logo após.
var async = require('async'); var aws = require('aws-sdk'); var sqs = new aws.SQS({apiVersion: '2012-11-05', region: 'us-east-1'}); var ecs = new aws.ECS({apiVersion: '2014-11-13', region: 'us-east-1'}); exports.handler = function(event, context, callback) { const stage = event.context.stage; let eventSQS = { 'bucket': event.Records[0].s3.bucket.name, 'key': event.Records[0].s3.object.key, 'params' : event, 'stage' : stage.toLowerCase() } async.waterfall([ /** * Armazenando os parametros no SQS */ function (next) { var params = { MessageGroupId: MY_QUEUE_GROUP, MessageBody: JSON.stringify(eventSQS), QueueUrl: YOUR_QUEUE_URL }; sqs.sendMessage(params, function (err, data) { if (err) { console.warn('Error while sending message: ' + err); } else { console.info('Message sent, ID: ' + data.MessageId); } next(err); }); }, /** * Ativando a ECS */ function (next) { var params = { cluster: YOUR_CLUSTER, taskDefinition: YOUR_TASK, launchType: "FARGATE", networkConfiguration: { awsvpcConfiguration: { assignPublicIp: "ENABLED", securityGroups: [YOUR_SECURITY_GROUP], subnets: [YOUR_SUBNET1, YOUR_SUBNET2] } }, count: 1 }; ecs.runTask (params, function (err, data) { if (err) {console.warn ('error:', "Erro ao iniciar a tarefa:" + err); } else {console.info ('Task' + YOUR_TASK + 'started:' + JSON.stringify (data.tasks))} next(err); }); } ], function (err) { if (err) { console.log(err); callback(err); } else { callback(null, 'Success'); } } ); };
Script no ECS que processará o envio
O shell script abaixo (Posix compatible) usa o AWS CLI para ler a fila do SQS, baixar e executar do S3 o script NodeJS de processamento de emails. Usando um modelo semelhante é possível manter a imagem no ECS na forma mais simples
#!/bin/sh die() { BASE=$(basename -- "$0") echo "$BASE: error: $@" >&2 exit 1 } set_aws_profile_param() { ## Which AWS profile to use from configuration file (~/.aws/config) ? ## If empty, uses the [Default]. AWS_PROFILE_PARAM="" test -n "$AWS_PROFILE" && AWS_PROFILE_PARAM="--profile $AWS_PROFILE" } get_queue_url() { test -n "$1" || die "missing QUEUE_NAME param to get_queue_url()" ## Get Qeueu URL from AWS (as JSON reposonse) __tmp=$(aws $AWS_PROFILE_PARAM \ --output=json \ sqs get-queue-url --queue-name "$1") \ || die "failed to get queue URL for '$1'" ## return queue URL as a string) echo "$__tmp" | jq -r '.QueueUrl' \ || die "failed to extract queue URL from JSON for '$1'" } usage() { BASE=$(basename -- "$0") echo "SQS Example Usage: $BASE Will get a job from the SQS queue, printing the filename, value and receipt handle. " exit 1 } # A fila deve ser do tipo FIFO para que a solução processe corretamente as solicitações em ordem cronológica QUEUE_NAME=${QUEUE_NAME:-SUA_FILA_NO_SQS.fifo} set_aws_profile_param QUEUE_URL=$(get_queue_url "$QUEUE_NAME") || exit 1 JSON=$(aws $AWS_PROFILE_PARAM \ --output=json \ sqs receive-message \ --queue-url "$QUEUE_URL") \ || die "failed to receive-message from SQS queue '$QUEUE_NAME'" [ -z "$JSON" ] && continue # Number of messages NUM=$(echo "$JSON" | jq '.Messages[] | length') \ || die "failed to get number of messages from JSON: $JSON" ##test -n $NUM && die "no pending messages" ## Quit if no messages ##test "$NUM" -eq 0 && die "no pending messages" ## We expect exactly one message echo "NUM: $NUM"; test "$NUM" -ne 0 \ ## || die "got too many messages from SQS: $JSON" ## Extract Receipt Handle from JSON RECEIPT=$(echo "$JSON" | jq -r '.Messages[] | .ReceiptHandle') \ || die "failed to extract ReceiptHandle from JSON: $JSON" ## The Extract the body of the message - which is itself a JSON file. BODY=$(echo "$JSON" | jq -r '.Messages[] | .Body') \ || die "failed to extract message body from JSON: $JSON" FILENAME=$(echo "$BODY" | jq -r '.filename') \ || die "failed to extract job's filename from JSON: $JSON" VALUE=$(echo "$BODY" | jq -r '.value') \ || die "failed to extract job's value from JSON: $JSON" echo "FILENAME: $FILENAME VALUE: $VALUE BODY: $BODY RECEIPT-HANDLE: $RECEIPT" ## S3 BUCKET=$(echo "$BODY" | jq -r '.bucket') \ || die "failed to extract bucket from JSON: $JSON" KEY=$(echo "$BODY" | jq -r '.key') \ || die "failed to extract key from JSON: $JSON" echo "Copying ${KEY} from S3 bucket ${BUCKET}..." mkdir scripts cd scripts aws s3 cp s3://NOME-DO-BUCKET-COM-SCRIPTS/${KEY} . --region us-east-1 --recursive ##Resolvendo dependências do script NodeJS npm install PARAMS=$(echo "$BODY" | jq -r '.params') \ || die "failed to extract key from JSON: $JSON" node index.js params="$PARAMS" cd .. rm -fr scripts ##Após processamento, remover item da fila aws sqs delete-message \ --queue-url ${QUEUE_URL} \ --region us-east-1 \ --receipt-handle "${RECEIPT}" echo "Removido da fila";
Queries com Athena
Agora que temos um modelo baseado no docker para a execução do serviço, precisamos diminuir a dependência do banco de dados para o envio. O AWS Athena é capaz de executar queries (SQL) contra conteúdo disponibilizado nos buckets (JSON, CSV etc), incluindo conteúdo compactado (zip). Isto é uma enorme facilidade que dispensa o uso de banco de dados relacional em muitas situações. No nosso caso serve perfeitamente e é o que iremos utilizar no script Node que será executado pelo container e que é disparado pelo shell script acima.
A tabela será criada a partir de arquivo CSV com a seguinte estrutura:
CREATE EXTERNAL TABLE IF NOT EXISTS AthenaUsersDatabase.athena_users_table ( `nome` string, `email` string, `estado` string, `cidade` string ) ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe' WITH SERDEPROPERTIES ( 'serialization.format' = ',', 'field.delim' = ',' ) LOCATION 's3://marketing-datalake/usuarios/' TBLPROPERTIES ('has_encrypted_data'='false');
Uma vez que o arquivo seja submetido para o S3, o seu conteúdo poderá ser recuperado através de uma query SQL comum:
Agora que temos nossa estrutura pronta, o script que rodará no ECS pode acessar os dados submetidos pelo upload. Um exemplo simples de query pode ser visto a seguir:
var clientConfig = { bucketUri: 's3://marketing-datalake/usuarios' } var awsConfig = { region: 'us-east-1', } var athena = require("athena-client") var client = athena.createClient(clientConfig, awsConfig) client.execute('SELECT * FROM athenausersdatabase.athena_users_table;;').toPromise() .then(function(data) { // aqui entra o código para envio do email pelo SES console.log(data) }) .catch(function(err) { console.error(err) })
Comparando custos
Uma comparação direta dos custos de banco de dados permite avaliar a diferença de custos das diferentes abordagens.
Abordagem usando banco de dados relacional | Abordagem usando S3 e Athena |
![]() | ![]() |
![]() | ![]() |
Comparativo
Considerações finais
O objetivo deste artigo não é eliminar o banco de dados da arquitetura, mas diminuir o seu custo, removendo a dependência dele para a execução de tarefas que podem ser desempenhadas de forma mais eficiente em termos de escalabilidade e custos através de serviços como o S3/Athena ou mesmo o ElastiCache (Redis). Existem limites práticos no caso do Athena como a capacidade de executar 20 queries concorrentes (embora este limite possa ser aumentado requisitando uma mudança ao suporte da AWS). Em cada caso considerar em primeiro lugar a volumetria é o primeiro passo na direção certa para manter o orçamento sob controle.