Automatização de deploys e testes
Como construímos uma pipeline de camadas de testes para a garantia de deploys automatizados com segurança
Na Legiti, trabalhamos com uma arquitetura baseada em microsserviços. Ao mesmo tempo em que isso nos traz serviços mais enxutos, com responsabilidades mais definidas, significa também que temos uma quantidade alta de serviços em relação ao tamanho do nosso time — mais especificamente, temos mais serviços do que pessoas no time. Além disso, nem todos os nossos serviços rodam nos mesmos tipos de ambiente: possuímos serviços rodando em Lambdas da AWS, serviços rodando em clusters Kubernetes, e alguns serviços rodando diretamente dentro de máquinas EC2 (AWS). Por fim, a garantia de que um serviço de produção está realizando suas tarefas da maneira adequada envolve diferentes tipos de testes e verificações, para cada um dos serviços.
Como então manter essa arquitetura de microsserviços sem fazer com que cada um de nós precise saber executar diversos conjuntos diferentes de comandos para deploy e, ainda, saber exatamente como se deve verificar se o serviço está devidamente atualizado e funcionando como especificado?
Estava claro desde o início que muitas partes desse processo eram automatizáveis — até porque, nossa infraestrutura sempre foi definida por código. Se o processo de deploy consiste de uma sequência de comandos, essa sequência poderia virar um script. Se a sequência vira um script, é muito fácil adicionar uma etapa para execução do mesmo dentro de um sistema de integração contínua (aqui na Legiti, utilizamos o CircleCI). Isso resolveria a primeira parte do nosso problema — realizar deploys automatizados, sem fazer com que todo nosso time de desenvolvimento saiba como realizar cada um desses comandos, para cada tipo de serviço que possuímos.
Mas sobra a segunda parte: como fazer essa automatização mantendo a segurança de que nossos deploys estão correndo bem, e com a facilidade e segurança de que conseguiremos fazer uma reversão, caso seja necessário?
Nós já tínhamos testes unitários, mas sabemos que eles não garantem 100% do funcionamento do serviço (não é nem o escopo deles, já que o objetivo dos testes unitários é exatamente testar os diversos pedaços do serviço separadamente). A primeira evolução que tivemos nessa direção, foi a implementação de testes de integração. Como a maioria dos nossos serviços atualmente envolvem contato com um banco de dados, foi difícil no início entendermos como conseguiríamos testar 100% do fluxo em uma suíte de testes a ser rodada tanto nos nossos computadores pessoais quanto em sistemas de CI (integração contínua), sem que tivessem contato com nossos bancos de dados (nem no ambiente de produção, nem no de staging). Se fizéssemos um mock do contato com o banco de dados, não estaríamos validando o serviço por completo.
Previamente, já havíamos desenvolvido uma imagem de Docker que continha uma réplica do nosso banco de dados, baseado nas definições em SQLAlchemy das nossas tabelas e já populado com data snapshots anonimizados, a fim de utilizarmos em nosso ambiente local de desenvolvimento. Assim, conseguiríamos utilizar esse contêiner também para os testes de integração, subindo uma instância dele para que fosse utilizada pelo serviço, tanto para a execução desses testes de integração localmente, quanto em nosso serviço de CI.
Essa já era uma garantia significativa de teste do serviço. Entretanto, como verificar após cada deploy que ele realmente havia funcionado e que realmente o serviço estava atuando e respondendo como esperado?
Evoluímos finalmente para testes fim a fim (end-to-end). Nesses testes, geramos requisições aos serviços reais, e verificamos que tanto sua resposta quanto consequências de sua execução (como inserções de dados em nosso banco) ocorreram corretamente. Como possuímos um ambiente de staging, que replica toda nossa infraestrutura de produção, podemos nos aproveitar dele para executar o deploy e os testes end-to-end primeiro no ambiente de staging, e só então, caso tudo corra bem, testar no ambiente de produção.
Entretanto, se estamos fazendo um teste depois do deploy do serviço, o ideal seria conseguirmos tomar uma ação também automatizada no caso de tais testes falharem. Dessa forma, implementamos um sistema de rollback nos nossos pipelines de CI. Isso é, caso um serviço que acabou de ter seu deploy realizado não esteja funcionando da maneira esperada, a pipeline sabe ativar passos responsáveis por restaurar o serviço a uma versão anterior, que estava funcionando corretamente.
Ok, mas como exatamente tudo isso se conecta para fazer os deploys e testes de maneira automatizada?
O ponto central do nosso fluxo é a utilização de Git e um serviço de CI. Com essa ferramentas, executamos o seguinte fluxo:
- A primeira etapa é fazer com que todas as branches que são criadas no repositório funcionem como gatilho para a execução de testes unitários e de integração (além de uma etapa de linting para garantir que nosso código esteja seguindo todas as boas práticas de estilo de código).
- Nenhum merge às nossas branches protegidas (develop e master, a serem explicadas em seguida) pode ocorrer sem que tal verificação seja feita com sucesso.
- A única branch para a qual branches de desenvolvimento podem realizar merges, é a branch que chamamos de develop.
- Quando um novo commit é incluído nessa branch (as branches que realizam merges nela, o fazem por meio de merge squash, virando apenas um commit), é dado então um gatilho no workflow de deploy em staging.
- Após tal deploy, os testes fim a fim no ambiente de staging são automaticamente executados.
- Caso haja algum erro, a etapa de rollback é prontamente ativada, restaurando a versão prévia do serviço; e a branch fica bloqueada para que não possa realizar merge em nossa branch principal (mantemos o nome master para essa branch).
- Caso o teste seja bem sucedido, a branch está pronta para realizar um merge à master.
- Após tal merge à master, a mesma sequência de etapas ocorre, mas agora em produção: deploy, teste fim a fim, e rollback caso necessário.
A última etapa que ainda pretendemos automatizar desse processo é um merge automatizado na master, caso a develop tenha o deploy e testes em staging bem sucedidos.
Com a utilização do CircleCI, podemos também nos aproveitar de seu sistema de Orbs para desenvolver algumas orbs internas. Com essas orbs, conseguimos reutilizar código de nossos pipelines dentre diversos serviços, garantindo dessa forma não somente uma maior facilidade de manutenção de tal código, já que ele passa a existir em um lugar centralizado, mas também uma maior padronização de nossos pipelines através de nossos serviços.
Com um sistema de diferentes camadas de testes sendo utilizado para prover a garantia necessária num sistema de deploys automatizados, conseguimos aumentar significativamente a facilidade com a qual conseguimos realizar deploys de nossos serviços, nos deixando mais livres para focar no que realmente deveríamos estar focando. Agora, nosso processo de deploy se resume basicamente a um clique no botão de merge do GitHub. A validação do deploy é feita automaticamente, e, em caso de qualquer problema, o serviço volta automaticamente para o estado anterior no qual estava. Sem mais tempo perdido fazendo curls e consultas manuais ao banco de dados após um deploy, na tentativa de entender se ele ocorreu e se foi bem sucedido!