Remember, one of the aims of CD is to make deployment boring, so whether its one or three applications, as long as its still boring it doesn't matter [Lewis/Fowler - 2014]
Muito se fala sobre a natureza descentralizada e tolerante à falha de microserviços quando comparada com a arquitetura monolítica tradicional baseada em libraries e frameworks, mas novas tecnologias precisam de novas formas de gestão para manter a governança que já existe de forma madura nas plataformas mais tradicionais. Alguns frameworks propostos (Serverless, ClaudiaJS etc) buscam fornecer um modelo de gestão que cobre os aspectos mais básicos de CI/CD e mesmo debug local o que é muito louvável, mas pecam quando não consideram o modelo nativo de gestão fornecido pelo Lambda.
Uma abordagem melhor seria ter um build inteligente, que identificasse o que exatamente modificou no último commit e então seletivamente fizesse o empacotamento apenas dos microserviços que foram alterados ou criados, procedendo com os testes automatizados unitários, de integração e ou funcionais que verificam o perfeito funcionamento dos serviços modificados. Neste artigo um pouco mais longo do que o usual, veremos como fazer isto, abrindo espaço ainda para a discussão sobre questões como technical debt e build sob demanda.
Git Flow
Na sua essência o GIt Flow é um modelo de organização dos branches de um repositório, separando os diversos estágios de desenvolvimento e manutenção do código. Embora existam diversos tipos de branches com diversos propósitos, neste artigo vamos focar em 2 branches principais (develop e master).

Git Flow big picture
Git as a Service
O CodeCommit é o Git como um serviço da Amazon. Seu uso não poderia ser mais simples, crie um repositório, cadastre os usuários no IAM e comece a trabalhar.

Dashboard do CodeCommit
No nosso exemplo criaremos um novo repositório para demonstrar como implementar um processo básico de DevOps incluindo build, teste, deploy e monitoramento totalmente automatizado para microserviços.
Estrutura do projeto
Fowler diz : One main reason for using services as components (rather than libraries) is that services are independently deployable. Para alcançar este modelo é necessário ser capaz de rodar o processo apenas para os microserviços afetados por uma mudança, mesmo que seja um microserviço apenas. Ao mesmo tempo em que preconizamos independência dos serviços, nada impede que a estrutura de diretórios reflita um modelo mais unificado, agrupando os microserviços de acordo com o seu propósito, exemplo:

Sugestão de organização dos microserviços
No exemplo acima de um projeto para uma empresa de petróleo, os microserviços foram agrupados de acordo com o seu propósito, enquanto mantém-se a independência entre eles. Todas as dependências de um microserviço são auto-contidas, descartando-se o modelo monolítico de bibliotecas centralizadas. Desta forma cada microserviço é independente dos demais, sendo que o client deles faz a coreografia necessárias de acordo com suas necessidades na hora de executá-los. Um benefício adicional é que os microserviços tornam-se extremamente reaproveitáveis, enquanto mantém independência uns dos outros.
Processo DevOps
Para configurar o processo DevOps vamos precisar implementar algumas convenções e configurar alguns serviços:
Arquivo de configuração
Para guiar o serviço de build vamos criar um arquivo de configuração que deverá estar presente no diretório de cada microserviço. Este arquivo será nomeado build.json e o seu conteúdo será o seguinte:
{
"ACCOUNT_ID": "123456789012",
"REGION": "region",
"LAMBDA": {
"FUNCTION_NAME": "nome-da-função",
"DESCRIPTION": "descrição",
"ROLE": "arn:aws:iam::123456789012:role/nome-da-role",
"HANDLER": "index.handler",
"TIMEOUT": "10",
"MEMORY_SIZE": "128",
"RUNTIME": "nodejs6.10",
"ENVIRONMENT":"Variables={}"
},
"API_GATEWAY":
{
"API_ID": "id-da-api-no-api-gateway",
"HTTP_METHOD": "GET",
"REQUIRE_APIKEY": true,
"APIKEYS": {
"DEV": "myapi-apikey-developers",
"GAMMA": "myapi-apikey-developers",
"PROD": "myapi-apikey-general-prod"
},
"CUSTOM_AUTHORIZER": "",
"CACHE": false,
"THROTTLE": {
"ACTIVE": true,
"RATE": "1000",
"BURST": "2000"
},
"HEADERS": [{
"NAME": "Access-Control-Allow-Headers",
"MAPPING": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,x-application,clientId'"
},
{
"NAME": "Access-Control-Allow-Origin",
"MAPPING": "'*'"
}
]
}
}
O JSON acima contém as configurações usuais que precisam ser feitas no Lambda e API Gateway para cada API. Em caso de necessidade novos parâmetros podem ser criados.
A seção "API_GATEWAY" pode ser removida em caso da função Lambda não ser exposta através de uma API Rest (exemplo, se for acionada por um evento do CloudWatch ou usada no SQS, SNS etc).
Build sob demanda com ECS
É muito comum em instalações DevOps haver um Jenkins e um servidor de build configurado para centralizar os builds num processo de CI. A abordagem proposta elimina completamente o custo fixo mensal de manutenção dos servidores, possibilita escalabilidade do serviço de build e é totalmente serverless. Seus principais componentes, são:
CodePipeline
Atua como o orquestrador do processo de CI/CD, núcleo da solução DevOps. O CodePipeline é acionado a cada vez que um novo push é feito no respositório, acionando um workflow que vai, passo-a-passo, realizar o build, os testes automatizados e o deploy dos serviços. Um workflow é constituído de stages que executam actions, sequenciais ou simultâneas.

Exemplo de um pipeline
CodeBuild
Uma action de um stage do CodePipeline pode ser desempenhada pelo CodeBuild. Isto permite criar um modelo de build sob demanda, baseado no ECS. O cenário abaixo mostra a diferença entre os dois modelos.
Servidor de orquestração (Jenkins Master)
8 vCPUs
15 GiB RAM
128GiB SSD
Servidor de build (Jenkins Slave)
8 vCPUs
15 GiB RAM
128GiB SSD
* Cabe notar que cada slave aloca memória no master (o qual não deve ter slaves): https://support.cloudbees.com/hc/en-us/articles/224505688-Hardware-Requirements-for-Jenkins-Enterprise
Usando a configuração acima, temos os seguintes custos de EC2 (em dólares):

Usando o CodePipeline e o CodeBuild o cenário muda bastante, mesmo considerando o mesmo tamanho de máquina:
CodePipeline - US$ 1.00 (um dólar) por pipeline ativo pode mês (no caso teremos dois pipelines na nossa solução)
CodeBuild - Considerando uma média diária de 4 builds, cada um levando em média 15 min para ser executado e usando a mesma configuração de máquina do exemplo anterior, teremos um custo mensal de US$ 26.40 calculados da seguinte forma:
Custo = NB * D * T * C
Onde:
NB = Número de builds diários
D = Número de dias de trabalho no mês
T = Tempo médio de cada build
C = Custo por minuto do CodeBuild
Somando-se o custo do CodePipeline, o valor total fica em US$ 28.40 mensais, com a vantagem ainda de não haverem servidores para dar manutenção e o ambiente ser escalável (múltiplos builds podem ser disparados ao mesmo tempo). Como a solução tem custo de acordo com o uso, à medida em que o projeto faz suas entregas, os custos caem de acordo com a demanda.
Criando usuário, credencial e chave SSH para o serviço de build
No IAM crie um usuário de serviço com uma ROLE que lhe dê direitos de executar as APIs do CodeBuild, CodePipeline, ECS, S3, Lambda, API Gateway e SNS.
Crie uma credencial (accesskey) e faça o upload de uma chave pública gerada com o keygen para a sua conta no IAM para acesso ao CodeCommit (SSH keys for AWS CodeCommit). A chave privada será colocada na imagem da máquina no Docker.
Criando a imagem de build no ECS
Após baixar e instalar o Docker no seu Desktop e configurar o AWS CLI, criaremos a imagem usando o seguinte dockerfile:
FROM openjdk:8-jdk MAINTAINER Emerson Lopes USER root ENV DEBIAN_FRONTEND=noninteractive # Atualizando pacotes RUN apt-get update -y && apt-get upgrade -y # Instalando pacotes específicos RUN apt-get install -y build-essential git curl python ssh expect zip RUN apt-get install git-flow RUN apt-cache policy libc6 RUN apt-get install libc6 # Instalando utilitário Pip RUN curl -O https://bootstrap.pypa.io/get-pip.py && python get-pip.py # Instalando Node.JS 6.10 RUN cd ~ && curl -sL https://deb.nodesource.com/setup_6.x -o nodesource_setup.sh && bash nodesource_setup.sh RUN apt-get install nodejs RUN apt-get install build-essential RUN apt-get update -y # Instalando AWS CLI RUN pip install awscli awsebcli # Configurando a chave-pública do SSH ADD id_rsa.pub /root/.ssh/id_rsa.pub RUN chmod 600 /root/.ssh/id_rsa.pub ADD config /root/.ssh/config RUN cd /root/.ssh/ && chmod 600 config # Configurando o AWS CLI RUN aws configure set aws_access_key_id ACCESS-KEY-DO-USUÁRIO-DE-SERVIÇO RUN aws configure set aws_secret_access_key SECRET-ACCESS-KEY RUN aws configure set default.region us-east-1 # Atualizando todas as dependências RUN apt-get update -qq RUN apt-get clean #Copiando script de build COPY build.sh /root/build.sh RUN 500 /root/build.sh
O script build.sh referenciado no Dockerfile acima tem o seguinte conteúdo:
#!/usr/bin/env bashcd $CODEBUILD_SRC_DIR
git flow init
aws s3 cp s3://myproject-build-scripts/__build-microservices.js .
aws s3 cp s3://myproject-build-scripts/package.json.js .
npm install
node __build-microservices.js $ambiente $branch
Crie a imagem e submeta a mesma para o ECS, dando um nome significativo
O script de build fica no S3 e é baixado sempre que um novo build vai ser executado. Isto permite modificar o comportamento do script sem alterar a imagem no ECS. Uma possibilidade é o script ficar no próprio CodeCommit, possivelmente num diretório oculto.
Abaixo uma versão simplficada do script de build (__build-microservices.js). Note que a execução dos testes unitários é realizada durante este processo, salvando o resultado no S3 para análise em caso de problemas.
var utf8_encode = require("./utf8_encode"); var execSync = require("child_process").execSync; var shell = require("shelljs") var path = require("path"); var fs = require("fs"); var logID = process.env.CODEBUILD_SRC_DIR.split("/")[2] const AWS = require('aws-sdk'); console.lineBuffer = "" console.out = function (data) { console.log(data) console.lineBuffer += utf8_encode(data + "\n") } console.outTitle = function (data) { console.out(" ") console.out("-------------------------------------------") console.out(data.toUpperCase()) console.out("-------------------------------------------") } console.saveLog = function (fileName) { fs.writeFileSync(fileName, console.lineBuffer) console.lineBuffer = "" } var environmentName = process.argv[2] var branchName = process.argv[3].toLowerCase(); execSync(`cd ${process.env.CODEBUILD_SRC_DIR} && \ git diff-tree --no-commit-id \ --name-only -r $CODEBUILD_SOURCE_VERSION > ./__files.codecommit`, function(error, stdout, stderr) { console.out("execSync Error:" + error + stdout + stderr); process.exit() } ); var resultArray = {} fs.readFileSync('./__files.codecommit') .toString() .split("\n") .map((line) => { if (line.length > 0) resultArray.push(line) }); if (resultArray.join() == "") { console.out ("Nenhum artefato encontrado"); console.out (gitPath); console.out (JSON.stringify(resultArray)); execSync(`aws codebuild stop-build --id ${process.env.CODEBUILD_BUILD_ID}`) console.saveLog (process.env.CODEBUILD_SRC_DIR + "/pre-build-" + logID + ".log") process.exit() } var build = {} var servicesHashTable = {} var versionsHashTable = {} for (index = 0; index < resultArray.services.length; ++index) { servicesHashTable[resultArray.services[index]] = resultArray.services[index] console.out(resultArray.services[index] + "[" + index + "]" + "=" + servicesHashTable[resultArray.services[index]]) } console.out(" ") for (line in servicesHashTable) { // verificando se o diretório atual ou algum diretório superior contém um microserviço if (line.indexOf("/") < 0) { continue; } var service = line var checkBuildJson = false while (true) { if (service.indexOf("/") < 0) { break } service = line.substr(0, line.lastIndexOf("/"))
try { var temp = require(service + "/build.json") checkBuildJson = true break } catch(err) { checkBuildJson = false } } if (!checkBuildJson) { break; } console.outTitle(service) var message = "" build = require(service + "/build.json"); console.out ("Building " + service + "...\n\n"); console.out("Updating dependencies " + build.LAMBDA.FUNCTION_NAME + "...\n") execSync(`cd ${service} && npm install`) if (build.LAMBDA.UNIT_TESTS) { console.out( "Running unit tests of " + build.LAMBDA.FUNCTION_NAME + "...\n") execSync(`cd ${service} && npm test`) } else { console.out( "Unit tests not enabled!!!\n\n") } console.out("Packaging project " + build.LAMBDA.FUNCTION_NAME + " code...\n") execSync(`cd ${service} && zip -r service.zip .`, {maxBuffer: 1024 * 100000}) console.out (" Packaging OK\n\n") console.out("Creating function " + build.LAMBDA.FUNCTION_NAME + "...\n") if (shell.exec(`aws lambda create-function \ --function-name ${build.LAMBDA.FUNCTION_NAME} \ --description '${utf8_encode(removeDiacritcs(build.LAMBDA.DESCRIPTION))}' \ --role '${build.LAMBDA.ROLE}' \ --handler '${build.LAMBDA.HANDLER}' \ --timeout ${build.LAMBDA.TIMEOUT} \ --memory-size ${build.LAMBDA.MEMORY_SIZE} \ --zip-file fileb://${service}/service.zip \ --environment '${"ENVIRONMENT" in build.LAMBDA ? build.LAMBDA.ENVIRONMENT : "Variables={}"}' \ --runtime '${build.LAMBDA.RUNTIME}'`).code !== 0) { console.out("create function configuration error") } else { console.out("create function configuration OK") } if (build.LAMBDA.VPCCONFIG == undefined) { build.LAMBDA.VPCCONFIG = {} } console.out("Updating function " + build.LAMBDA.FUNCTION_NAME + " configuration...\n") if (shell.exec(`aws lambda update-function-configuration \ --function-name ${build.LAMBDA.FUNCTION_NAME} \ --description '${utf8_encode(removeDiacritcs(build.LAMBDA.DESCRIPTION))}' \ --role '${build.LAMBDA.ROLE}' \ --handler '${build.LAMBDA.HANDLER}' \ --timeout ${build.LAMBDA.TIMEOUT} \ --memory-size ${build.LAMBDA.MEMORY_SIZE} \ --vpc-config ${JSON.stringify(build.LAMBDA.VPCCONFIG)} \ --environment '${"ENVIRONMENT" in build.LAMBDA ? build.LAMBDA.ENVIRONMENT : "Variables={}"}' \ --runtime '${build.LAMBDA.RUNTIME}'`).code !== 0) { console.out("update function configuration error") } else { console.out("update function configuration OK") } console.out("Updating function " + build.LAMBDA.FUNCTION_NAME + " code...\n") if (shell.exec(`aws lambda update-function-code \ --function-name ${build.LAMBDA.FUNCTION_NAME} \ --zip-file fileb://${service}/service.zip`).code !== 0) { console.out("update function code error") } else { console.out("update function code OK") } console.out( "Publishing version " + build.LAMBDA.FUNCTION_NAME + ' new version in ' + environmentName + '...\n') if (shell.exec(`aws lambda publish-version \ --function-name '${build.LAMBDA.FUNCTION_NAME}' > ${service}/._result.json`).code !== 0) { console.out("publishing version error") } else { console.out("publishing version OK") } console.out( "Creating alias " + build.LAMBDA.FUNCTION_NAME + ' new version in ' + environmentName + '...\n') var result = require(service + "/._result.json"); if (shell.exec(`aws lambda create-alias \ --function-name '${build.LAMBDA.FUNCTION_NAME}' \ --description '${utf8_encode(removeDiacritcs(build.LAMBDA.DESCRIPTION))}' \ --function-version "${result.Version}" \ --name '${environmentName}'`).code !== 0) { console.out("alias already exists") } else { console.out("create alias OK") } console.out( "Updating function alias version " + build.LAMBDA.FUNCTION_NAME + ' alias version in ' + environmentName + '...\n') if (shell.exec(`aws lambda update-alias \ --function-name '${build.LAMBDA.FUNCTION_NAME}' \ --name '${environmentName}' \ --function-version '${result.Version}'`).code !== 0) { console.outTitle("update alias error, parando o build") execSync(`aws codebuild stop-build --id ${process.env.CODEBUILD_BUILD_ID}`) execSync(`aws sns publish --topic-arn "arn:aws:sns:us-east-1:525324176518:BuildServices" \ --subject "DONUTS ALERT!" \ --message "DONUTS ALERT! Build ${service}, executado por ${utf8_encode(resultArray.author)}, foi quebrado...\n - update-alias falhou!\n\n\n"`); shell.exit(1) process.exit() } else { versionsHashTable[build.LAMBDA.FUNCTION_NAME] = result.Version console.out("update alias OK") } if (typeof build.LAMBDA.SOURCEARN !== "undefined" && typeof build.API_GATEWAY !== "undefined") { console.out( "Authorizing API Gateway to invoke " + build.LAMBDA.FUNCTION_NAME + " ...\n") if (shell.exec(`aws lambda remove-permission \ --function-name '${build.LAMBDA.FUNCTION_NAME}' \ --statement-id 'SID_InvokeFunction_${build.LAMBDA.FUNCTION_NAME}_${build.API_GATEWAY.REST_API_ID}' \ --qualifier ${environmentName} `).code !== 0) { console.out("permission does not exists") } else { console.out("permission already OK") } if (build.LAMBDA.SOURCEARN !== "") { if (shell.exec(`aws lambda add-permission \ --function-name '${build.LAMBDA.FUNCTION_NAME}' \ --source-arn '${build.LAMBDA.SOURCEARN}' \ --principal 'apigateway.amazonaws.com' \ --statement-id 'SID_InvokeFunction_${build.LAMBDA.FUNCTION_NAME}_${build.API_GATEWAY.REST_API_ID}' \ --qualifier ${environmentName} \ --action 'lambda:InvokeFunction'`).code !== 0) { console.out("permission already exists") } else { console.out("permission already OK") } } if (build.API_GATEWAY.REQUIRE_APIKEY) { console.out( "Setting APIKEY as required to " + build.LAMBDA.FUNCTION_NAME + " ...\n") if (shell.exec(`aws apigateway \ update-method --rest-api-id ${build.API_GATEWAY.REST_API_ID} \ --resource-id ${build.API_GATEWAY.RESOURCE_ID} \ --http-method ${build.API_GATEWAY.HTTP_METHOD} \ --patch-operations \ op="replace",path="/apiKeyRequired",value="${build.API_GATEWAY.REQUIRE_APIKEY}"`).code !== 0) { console.out("update method error") } else { console.out("update method OK") } } } console.outTitle (service + ": Build finalizado!\n\n"); exec(`aws sns publish --topic-arn "arn:aws:sns:us-east-1:123456789012:BuildServices" \ --subject "NO DONUTS FOR YOU!" \ --message "Build ${service} em ${environmentName}, executado por ${utf8_encode(resultArray.author)}, foi finalizado com sucesso!"`); }; console.out (">>>>>>>>>>>>>>>>>>>>>>>> Skynet terminated. Hasta la vista, baby"); function replaceAll(text, str1, str2, ignore) { return text.replace(new RegExp(str1.replace(/([\/\,\!\\\^\$\{\}\[\]\(\)\.\*\+\?\|\<\>\-\&])/g,"\\$&"),(ignore?"gi":"g")),(typeof(str2)=="string")?str2.replace(/\$/g,"$$$$"):str2); } function removeDiacritcs(text) { var diacritcs = ["á", "é", "í", "ó", "ú", "à", "è", "ì", "ò", "ù", "ã", "õ", "ä", "ë", "ï", "ö", "ü", "â", "ê", "î", "ô", "û", "ç", "ç"] var regular = ["a", "e", "i", "o", "u", "a", "e", "i", "o", "u", "a", "o", "a", "e", "i", "o", "u", "a", "e", "i", "o", "u", "c", "c"] for (var i=0;i< diacritcs.length;i++) { text = replaceAll(text, diacritcs[i], regular[i]) text = replaceAll(text, diacritcs[i].toUpperCase(), regular[i].toUpperCase()) } return text } |
Configurando o ECS
As imagens criadas com sucesso aparecem no ECS-> Amazon ECR -> Repositories

O próximo passo é dar as permissões adequadas para que a imagem seja acessada pelo CodeBuild:
Adicione permissões para :
arn:aws:iam::964771811575:root, arn:aws:iam::131992011433:root, arn:aws:iam::201349592320:root, arn:aws:iam::570169269855:root, arn:aws:iam::883865855280:root, arn:aws:iam::828209784933:root, arn:aws:iam::157144849617:root, arn:aws:iam::064387162992:root
Marque as opções abaixo:
A policy final deve ser semelhante a esta:
{
"Version": "2008-10-17",
"Statement": [
{
"Sid": "socialmedia-ecs-services-permission",
"Effect": "Allow",
"Principal": {
"AWS": [
"arn:aws:iam::964771811575:root",
"arn:aws:iam::131992011433:root",
"arn:aws:iam::201349592320:root",
"arn:aws:iam::570169269855:root",
"arn:aws:iam::883865855280:root",
"arn:aws:iam::828209784933:root",
"arn:aws:iam::157144849617:root",
"arn:aws:iam::064387162992:root"
]
},
"Action": [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability"
]
}
]
}
Criando os pipelines
Crie um pipeline para cada branch (develop e master) . O pipeline para develop deverá realizar o build para os ambientes DEV e GAMMA (este último com aprovação). O pipeline para a branch master deverá criar o ambiente PROD. Na configuração do action do Codebuild, você deverá ter algo como:

Este comando será executado pelo container criado a partir da imagem que criamos e submetemos para o ECS, processando o script de build, gerando todas as configurações necessárias (aliases, versions, api gateway etc).
Technical debt
O conceito de technical debt tem ganhado força em ambientes DevOps, permitindo uma avaliação mais objetiva da qualidade do trabalho dos desenvolvedores através da captura dos resultados dos testes automatizados após o build. Outra forma de medir skills e proeficiência é utilizar uma ferramenta como o SONAR, que avalia a qualidade do código escrito. Nosso exemplo acima poderia ser facilmente modificado para executar os testes e o processo de análise qualidade de código para criar um dashboard com os resultados do build. Utilizando o conceito de technical debt é possível estabelecer um formato baseado em meritocracia na hora de avaliar e promover ou melhor remunerar os profissionais. Também poderá ser uma ferramenta útil para avaliar a necessidade de treinamento ou mesmo substituição de elementos do time cujas performances deixarem a desejar.
Considerações finais
Este artigo não pretende esgotar a discussão sobre qual a melhor forma de construir microserviços, mas apenas demonstrar uma alternativa a frameworks que mudam a organização dos serviços ou implementam formas "alternativas" de separação entre ambientes, mas não necessariamente melhores do que aquela já criada pela AWS. A criação de um dashboard para acompanhamento indicando o status de cada build e o resultado dos testes unitários (não cobertos aqui, mas alvo de um futuro artigo) seriam o próximo passo, de forma que o resultado do build faça parte das estatísticas do projeto, indicando, por exemplo, erros encontrados, percentual de cobertura de código e análise de qualidade de código, por desenvolvedor.
Como um verdadeiro processo DevOps, o resultado das configurações e automações deve ser o de afastar o time de desenvolvimento do console da AWS ao mesmo tempo em que permite deploy a qualquer instante. Tudo o que é necessário fazer é submeter um commit para o Git (CodeCommit), deixando todo o restante para os scripts de automação de build, teste e deployment.
Emerson Lopes
Arquiteto de soluções na nuvem
emersonlopes@gmail.com