Começando a fazer integração contínua efetiva

Marcio Frayze

Integração contínua é uma prática que no papel é bastante simples, mas sua execução exige uma série de comprometimentos que podem ser difíceis de implantar. Muitas vezes o que vejo são times dizendo que fazem integração contínua, mas que na realidade trabalham com algo muito longe disso. Definir e entender o que é e quais os desafios que vamos enfrentar ao seguir este caminho é muito importante.

Escrevi um artigo explicando rapidamente o que é integração contínua algum tempo atrás. Aqui apresento uma continuação, indicando como começar de forma efetiva.

Integração contínua é uma prática, não uma ferramenta

Vejo muitas pessoas acreditando que por estarem utilizando determinadas ferramentas (como Jenkins, GoCD, Gitlab-CI, CircleCI, entre várias outras) estão automaticamente praticando integração contínua. Embora estas ferramentas sejam altamente recomendadas, na realidade, não são fundamentais. A única ferramenta realmente indispensável é um sistema de controle de versão de código, como o git. Demais programas servem como apoio às práticas, mas podem ser descartados.

Alguns times conseguem criar pipelines altamente complexas, dominam cada comando do git, containers, kubernetes e toda a API do jenkins, são capazes de configurar e realizar deploys automatizados de sistemas distribuídos em um cluster de servidores utilizando tecnologias de ponta… mas ainda assim não estão praticando integração contínua. Enquanto um outro time, ou até mesmo um projeto com uma única pessoa desenvolvedora, é capaz de praticar integração contínua efetivamente apenas com o git e uma pipeline bem simples.

Claro que uma coisa não exclui a outra. É possível (e recomendado) utilizar tecnologias avançadas em projetos que pretendem seguir integração continua. Mas tecnologias e práticas são independentes. E as práticas devem preceder as tecnologias, que estão presentes apenas para auxiliar, automatizar e facilitar a realização das práticas.

Afinal, de onde surgiu a Integração Contínua?

Definição

O primeiro livro a citar o termo continous integration foi o Object-Oriented
Analysis and Design with Applications
, escrito por Grady Booch et al., cuja primeira edição foi lançada em 1993. Em 2007 foi publicada a terceira e mais recente edição.

Mas foi a comunidade da Programação Extrema quem realmente consagrou o uso deste termo, em especial a partir do livro Extreme Programming Explained, de Kent Beck.

Segundo Kent Beck, integração contínua seria a prática de integrar e testar o código frequentemente, ou seja, não mais tardar do que algumas horas. No livro Extreme Programming Explained ele esclarece que desenvolvimento de softwares não é uma tarefa de dividir e conquistar, mas sim de dividir, conquistar e integrar. Esta última etapa é imprescindível e muitas vezes pode levar até mais tempo que a fase de desenvolvimento.

Ainda neste mesmo livro o autor explica que existem duas formas de se praticar integração contínua: ela pode ser assíncrona ou síncrona.

Integração assíncrona

Nesta abordagem, cada membro do time pode integrar suas modificações a qualquer momento e continuar seu trabalho. Alguns instantes após o push, um servidor de build irá detectar que ocorreram mudanças, executará os testes automatizados e realizará o processo de deploy da aplicação (podendo este ser um ambiente de testes ou staging, por exemplo). Tudo isso sem intervenção humana.

Integração assíncrona

Caso ocorra algum problema durante a compilação do projeto, execução dos testes automatizados ou deploy da aplicação, o time é notificado de alguma forma. Pode ser através do envio de um e-mail, mensagem em um sistema de chat, um monitor/tv em local visível para todo time ou qualquer outra espécie de notificação. Pode ser até uma lâmpada diferente que se acende no ambiente. Com um raspberry pi e um pouco de tempo livre com certeza alguém do time vai conseguir encontrar alguma forma bacana de notificar todos os envolvidos que algo está errado!

Integração síncrona

Integrar assincronamente já traz uma serie de vantagens e pode ser um bom começo (especialmente em times que ainda não implementam muitos testes automatizados), mas a forma mais indicada por Kent Beck é a integração síncrona.

Neste modelo, a programadora (de preferência de forma pareada) integra seu código pelo menos uma vez a cada poucas horas, aguarda a execução da build automatizada, execução dos testes automatizados e deploy em pelo menos um ambiente de testes ou staging. A dupla então certifica-se que todos os testes de regressão estão passando e só então podem prosseguir para próxima tarefa e continuar o desenvolvimento do software.

Integração síncrona

Para isso é importante que consiga executar todo o processo de forma automatizada e em menos de 10 minutos. Este intervalo, onde a dupla fica supostamente ociosa, é um ótimo momento para reflexão sobre o que foi feito nas últimas horas, além de um merecido descanso mental.

Cuidado com sistemas de controle de versão distribuídos

Git e mercurial, os dois principais sistemas de controle de versão distribuídos atualmente, são excelentes ferramentas. Mas algumas de suas funcionalidades podem atrapalhar a prática de integração contínua.

Trabalhar com códigos versionados de forma distribuída significa, por definição, que o time não está fazendo integração contínua.

Aquela pessoa do time que acha que consegue resolver tudo sozinha

Imagine uma equipe que versiona seus códigos no git e uma das pessoas desenvolvedoras resolve realizar commits apenas localmente ou em um repositório que não seja o mesmo que as demais estão utilizando, optando por fazer o push para o branch master do servidor compartilhado apenas quando ela considerar que a funcionalidade em desenvolvimento está "pronta" (podendo se passar dias ou mesmo semanas até que isso ocorra).

Você tentando fazer o merge depois…

Este é um cenário que encontramos frequentemente e o resultado costuma ser sempre o mesmo: commits com muitos conflitos, necessidade de grande retrabalho para realizar os merges, testes de regressão falhando devido a efeitos colaterais inesperados, dificuldades para alcançar uma build estável para publicar em produção e muita dor de cabeça durante e depois das implantações.

Se você se identifica com o cenário descrito acima, convencer seu time a praticar integração contínua de forma efetiva pode ajudar a minimizar estes problemas.

A forma mais simples de evitar que isso ocorra é adotando um workflow de trabalho centralizado. Neste modelo evitamos ao máximo o uso de branches e repositórios alternativos. Todos do time se comprometem a integrar seu código pelo menos algumas vezes por dia em um repositório centralizado. Isso não significa que branches temporários (com duração de algumas horas) precisam ser banidos, mas devem ser evitamos ao máximo e utilizados em casos muito pontuais e pelo menor tempo possível.

Mas e as feature branches?

Martin Fowler afirma neste artigo que:

"Quando você isola as feature branches, há um risco de um conflito desagradável crescendo sem que você perceba. Então o isolamento é uma ilusão, e será quebrado dolorosamente mais cedo ou mais tarde." — Martin Fowler

Neste mesmo artigo, Fowler cita uma fala muito pertinente do Daniel Bodart, em que diz:

"Feature Branching é a arquitetura modular de um homem pobre, que ao vez de construir sistemas com a capacidade de trocar facilmente recursos em tempo de execução/deploy se acoplam ao mecanismo de merging manual fornecido pelo sistema de controle de versão." — Daniel Bodart

Bodart deixa claro que a necessidade de se criar branches pode ser um sinal que o time está na realidade com problemas arquiteturais em sua aplicação. E ao invés de criar branches deveriam entender a causa desta necessidade e corrigi-la na raiz.

Palestra do Dave Farley. Imagem retirada deste post.

A melhor forma para usar branches é seguir a dica do Dave Farley: Don’t branch, don’t branch, don’t branch! (não crie branches, não crie branches, não crie branches!). Ou como ele indica neste artigo, resista ao máxima a tentação de criar branches, assim como realizar otimização prematura ou incluir toggles.

Para entender melhor alguns outros problemas das feature branches, consulte este artigo em que falo um pouco sobre feature toggles e feature branches.

Praticando integração contínua

Quando um time começa a introduzir a pratica de integração contínua costumam aparecer algumas dúvidas recorrentes. Seria impossível abordar todas elas aqui, mas vou detalhar quatro exemplos de técnicas que me ajudam muito.

Geração de build automatizada

O resultado gerado após a compilação do projeto (arquivos jar ou war no caso de uma aplicação Java, por exemplo) é o que vou chamar aqui de build. Ou seja, build é o conjunto de artefatos (ou apenas um) que serão necessários quando uma nova versão da projeto for disponibilizada.

A primeira coisa que costumo fazer em um sistema legado que meu time precise dar manutenção é me certificar que o processo de build está automatizado. Isso inclui:

  • Deve ser fácil obter o código-fonte do branch principal do projeto (preferencialmente deve ser um simples "git clone <url do repositório>");
  • Deve ser fácil entender quais os requisitos mínimos para compilar e executar os testes automatizados do projeto. Exemplos: preciso ter o NPM instalado? Qual versão? Preciso de um banco de dados? Em alguns casos, o uso de containers pode nos auxiliar nesta tarefa.
  • Como faço deploy da aplicação e acesso ela localmente? Este processo deve ser simples e não pode exigir a edição de arquivos de configuração, etc.

A documentação disso tudo deve ser clara, curta e atualizada. Em geral, opto por manter essas informações no topo de um README.md no próprio repositório do projeto.

Build única para todos os ambientes

Segundo Dave Farley, em um projeto que segue integração contínua, toda build gerada é uma potencial candidata para chegar em produção.

"Eu vejo CI como principalmente dando à luz um candidato de lançamento em cada commit. O trabalho do sistema de CI e processo de implantação é refutar a prontidão de um candidato de lançamento. Este modelo depende da necessidade de ter alguma linha principal que representa a visão compartilhada, mais atualizada da imagem completa." — Dave Farley

Para que esta possibilidade se torne realidade precisamos garantir que quando o time decidir que uma determinada build deve ser promovida para outro ambiente ela não pode ser modificada de forma alguma. Ou seja, exatamente a build que foi testada (de forma automatizada e através de testes exploratórios manuais, caso necessário) é a que será implantada em produção. Nada pode ser alterado dentro dela, nem mesmo um simples arquivinho de configuração! Isso também significa que não podemos recompilar o projeto para adequá-lo a um determinado ambiente.

Caso seu sistema de backend precise acionar um serviço REST, conectar-se a um banco de dados ou fila de mensageria ou se comunicar com qualquer outro sistema externo, então o endereço de conexão deve ser externalizado. Por exemplo: um sistema que precise acessar um banco de dados PostgreSQL, ao ser executado no ambiente de desenvolvimento deve acessar um banco de desenvolvimento. A mesma build quando feita implantação em um ambiente de homologação deve automaticamente conectar-se no banco correto de homologação. O mesmo é esperado nos demais ambientes, desde a estação de trabalho local até produção. Uma forma simples de atingir este objetivo é fazer com que a aplicação referencie variáveis de ambiente. Assim, a mesma build será capaz de automaticamente se adequar ao ambiente em que estiver sendo executada.

Em alguns cenários podemos recorrer ao uso de containers para nos auxiliar nesta tarefa de tornar todos os ambientes o mais similar possíveis uns dos outros.

Versionamento de APIs

Quando temos um software em produção fica mais complicado fazer qualquer alteração em sua API. Pensando por exemplo em um típico sistema web, com um backend REST e uma single page application no frontend, não podemos correr o risco de fazer uma implantação em produção que altere uma API que está sendo consumida pelo frontend, já que isso iria quebrar a aplicação em produção.

Este é um clássico cenário onde alguns times que não praticam integração contínua optam por criar branches, mantendo assim um branch com o código de produção e pelo menos um branch para o código que está sendo desenvolvido para a próxima versão. Quando surge um problema em produção, altera-se o código do branch de produção e a correção também é aplicada no branch de desenvolvimento. Em alguns times ainda não é incomum este branch de desenvolvimento durar várias semanas ou meses.

Em um primeiro momento este cenário parece ser tranquilo. Mas quem já trabalhou desta maneira provavelmente já sofreu pelo menos algumas vezes com merges monstruosos quando o time decide integrar um branch de desenvolvimento com o de produção.

A situação se agrava quando o time ainda não aplica a noção de código coletivo.

Para contornar este problema podemos utilizar uma política de versionamento de APIs. Desta forma, uma mudança no backend que vá trazer qualquer impacto na API (mudanças em seus parâmetros de entrada e saída) deve ser isolada da versão anterior, sendo que as duas versões devem conseguir conviver juntas, sem que uma impacte a outra. Após algum tempo, a API antiga é depreciada e seu código excluído. Este ciclo se repete indefinidamente. Neste cenário é comum termos uma versão X da API em produção e uma X+1 em desenvolvimento, sendo que ambas são versionadas no branch principal.

Quando dou esta sugestão, é comum receber uma reação de certo desprezo ou preocupação, especialmente de gestores mais afastados da parte técnica, que ficam aterrorizados com a possibilidade de um código incompleto poder chegar em produção.

E, sim, isso pode acontecer. E não tem problema algum! Uma forma simples de contar esta situação é configurar o sistema para que a versão X+1 fique indisponível em produção. Assim, tecnicamente falando, a versão X+1 já estará em produção mas inacessível. Para atingir este objetivo, podemos utilizar um simples controle de release toggle, por exemplo. Desta forma o time pode desenvolver e testar a versão X+1 no mesmo branch de produção.

No caso de uma API RESTful, existem várias estratégias para versionar seus serviços. O mais simples de todos é adicionando a versão na própria URL do serviço. Vamos supor que você tem um serviço exposto por exemplo no endereço api/usuario/{id}. Caso precise efetuar uma quebra na assinatura deste serviço (uma alteração dos dados exigidos no corpo ou cabeçalhos da requisição ou no corpo da resposta do serviço, por exemplo), então você poderia criar um novo serviço que ficaria exposto no endereço api/v2/usuario/{id}. Esta versão poderia ficar bloqueada através de um release toggle enquanto estiver em desenvolvimento. Quando a v2 estiver em produção, a versão antiga é depreciada e depois removida.

Outra estratégia bastante comum é a consumidora enviar no cabeçalho da requisição qual a versão (ou versões) do serviço está disposta a aceitar. Normalmente utiliza-se o campo Accept para esta finalidade.

O livro Building Evolutionary Architectures detalha algumas outras possíveis soluções para este problema e explica os prós e contras de cada uma delas. Mas as duas propostas que listei acima foram suficientes nos sistemas em que atuei até hoje e são fáceis de aplicar.

Uma outra dica é minimizar o compartilhamento de código entre versões diferentes de um mesmo serviço. O código da versão que está em produção será excluído no futuro dando lugar a nova versão, portanto, um certo nível de replicação de código é aceitável (e as vezes até recomendado) durante este estágio de desenvolvimento e transição. A ideia é que quando chegar o momento de excluir a versão antiga seja necessário apenas remover uma pasta do sistema, sem que isso impacte a nova versão. Embora estejam em um mesmo branch, elas devem ter ciclos de vida independentes.

Testes de regressão

Outra dúvida recorrente é como garantir que a API de produção não foi indevidamente alterada. Além de sempre aplicar boas práticas de engenharia de software, a resposta para este problema é simples: implemente e execute sempre testes de regressão automatizados. Caso uma API de produção seja impactada, os testes vão alertar todos do time e a build ficará bloqueada (impossibilitada de chegar nos demais ambientes). Nesta situação a prioridade do time deve ser solucionar este problema o mais rápido possível (em menos de 10 minutos). Caso não seja trivial, a última alteração deve ser revertida, voltando o código para um estado anterior à falha.

Não consigo imaginar um cenário onde um time pratique integração contínua e não tenha uma boa suíte de testes automatizados. Para que torne-se rotina, o ideal é também praticar TDD (Test-Driven development). Mas isso já foge do escopo deste artigo.

Mas eu preciso muito de branches!

Existem situações em que precisamos fazer mudanças de larga escala em nossas aplicações e em um primeiro momento é natural imaginarmos que integração contínua não se encaixa neste tipo de cenário.

Um exemplo é a migração tecnológica. Com o tempo é comum querermos atualizar as dependências de nossos sistemas para algo mais moderno. Algumas vezes damos sorte e realizar esta migração é trivial. Mas em alguns projetos parece ser quase impossível. Uma biblioteca pode estar sendo utilizada em várias partes do software, fazendo com que a migração para outra tecnologia pareça ser um esforço que vai exigir semanas de trabalho.

Frente a esta situação, já presenciei times tentando resolver este cenário através de branches. Normalmente a decisão ocorre mais ou menos assim: uma ou mais pessoas ficam encarregadas de fazer a migração em um branch específico, enquanto o resto do time continua trabalhando na master ou em branches de desenvolvimento. Quando a migração estiver "pronta" no branch de migração, o time vai realizar o merge deste branch com os demais.

Em 100% das vezes em que vi isso acontecendo, diria que aproximadamente 0,01% foram casos de sucesso. O branch de migração talvez até funcione isoladamente em um ambiente de desenvolvimento, mas fazer o merge é quase impossível. E não é incomum, depois de dias e mais dias de trabalho, o produto ser implantado para só então o time perceber que existem vários problemas que não foram previstos. Questões de incompatibilidades, performance, etc.

Outra situação que já vi ocorrer de forma recorrente é a equipe se comprometer a realizar a migração em um certo período de tempo. Combinam com o cliente que durante uma quantidade determinada de dias nenhuma nova funcionalidade será desenvolvida. O time entra então em um período de atualização tecnologia, onde todos têm o mesmo objetivo: realizar a migração.

Este segundo cenário que descrevi elimina um dos problemas: agora não vamos ter muita dificuldade de realizar merges, já que ninguém está alterando os demais branches. Mas muitas vezes um segundo problema, ainda mais grave, começa a ocorrer: o time atrasa a migração. O prazo inicial foi subestimado e não conseguem realizar a tempo. Então decidem se comprometer com uma nova data, que mais uma vez não é cumprida.

O resultado é previsível: o cliente fica insatisfeito, já que durante este período não são incluídas novas funcionalidades no sistema, o time começa a fazer horas extras e a qualidade do código cai significativamente. Depois de muito esforço ou a migração é cancelada ou entregue com baixa qualidade e com atraso. A consequência disso a longo prazo é que os gestores, cliente e o próprio time começam a evitar quaisquer possíveis atualizações (seja tecnológica ou arquitetural) e o débito técnico começa a se acumular no decorrer do projeto.

Mas caso seu time precise muito criar um branch, existe uma solução sem sair da integração contínua: o branch por abstração.

Branch por abstração

Branch by abstraction é uma técnica que permite que uma mudança de larga escala seja implementada no branch principal. De forma resumida, a técnica consiste em aplicar os seguintes passos:

  1. Introduza incrementalmente uma camada de abstração ao redor do código que será futuramente substituído (sem quebrar a build atual). Esta camada não altera em nada a funcionalidade, apenas cria um abstração que isola a implementação real do resto da aplicação;
  2. Mais uma vez, de forma incremental, escreve a segunda implementação da camada que foi abstraída e faça o push deste código na master. Enquanto a implementação não estiver finalizada, este código precisa ficar inativo para demais pessoas programadoras e demais ambientes que não seja o de desenvolvimento local das pessoas que estão atuando nesta alteração. Uma forma de fazer isso é através de uma feature flag.
  3. Durante todo o processo a build nunca pode encontrar-se em um estado quebrado. Quando finalizada a implementação, ative-a para as demais pessoas do time e para os demais ambientes;
  4. Remova a implementação antiga que foi substituída. Opcionalmente remova a camada de abstração que foi criada, que também pode ser retirada de forma gradual.

Consulte este artigo do Fowler para mais detalhes desta técnica.

Mas posso trabalhar com pull request?

Vou deixar o Nat Pryce (um dos autores do excelente livro
Growing Object-Oriented Software, Guided by Tests) responder esta pergunta. O pessoal do github fez um tweet sobre como trabalhar com integração contínua e na imagem anexada havia um pull request. Pryce respondeu dizendo:

“Fazer ‘integração’ contínua com solicitações de pull. Ffs! Solicitações de pull significa que a integração não é contínua. Eu esperaria que a GitHub fizesse melhor do que isso.” — Nat Pryce

*Ffs: abreviação de um palavrão em inglês

Martin Fowler também respondeu o tweet, concordando com Nat Pryce.

Assim como os branches temporários (de poucas horas de duração) os pull requests não estão necessariamente banidos, mas o ideal é evitá-los. Eles acabam se tornando uma barreira que faz com que a integração do código com o branch principal demore mais do que o necessário.

Muitos times optam pelo pull request como uma ferramenta para auxiliar o controle da revisão de código. Se esta for a sua necessidade, a minha recomendação é que avalie adotar a prática de programação pareada (pair programming). Esta costuma ser uma abordagem mais produtiva do que realizar a revisão de forma mais tardia através de pull requests.

Alternativas

Prefiro trabalhar de forma integrada sempre que possível, mas existem cenários onde esta abordagem pode não ser mais a mais aconselhável.

Quando não deve-se praticar integração contínua

Neste momento já deve ter ficado claro que a prática de integrar continuamente o código é algo que precisa ser feito por todos os membros do time. Impor isso às demais pessoas provavelmente não vai ser uma boa ideia. Então, assim como qualquer prática, antes de iniciar é importante que o time entenda os benefícios e todos estejam dispostos a se esforçar para atingir este objetivo.

Em muitas situações vão aparecer desculpas para escapar para outro modelo. Gosto de encarar esses momentos como um desafio. Suponha que você atue em um projeto grande e seu time decida migrar o framework ORM para outra tecnologia. Muitos vão dizer que é impossível fazer isso, principalmente se você propuser realizar a troca usando integração contínua. E foi justamente isso que Jez Humble fez neste projeto. Mas isso exige um certo esforço, reflexão em cima do problema para quebra-lo em etapas menores e a realização de outras tarefas que alguns times podem não estar preparados ou dispostos a encarar.

O mesmo se aplica aos testes automatizados. Todas as pessoas desenvolvedoras do projeto precisam estar dispostas a escrever testes (de preferência seguindo as práticas do TDD), ter o conhecimento necessário para fazer isso de forma eficiente e também maturidade para seguir aplicando estas práticas ao longo do projeto.

Independente do modelo de desenvolvimento o time precisa ser formado por pessoas competentes e que consigam trabalhar bem em equipe. Nenhuma pratica consegue fazer milagres. E tentar aplicar integração contínua com um time que não está preparado pode ser bem frustrante.

Gitflow

Caso você não tenha gostado da proposta da integração contínua, uma outra forma de fazer gestão de seu código é seguir o modelo gitflow (ou variações como github flow e gitlab flow). Ele tem uma serie de desvantagens frente a integração contínua, mas pode ser uma alternativa para alguns times em alguns cenários.

Conclusão

Integração contínua é uma prática que pode simplificar o processo de desenvolvimento de seus softwares, mas embora seja um processo simples, não é fácil. Existem várias tarefas que precisam ser realizadas para que a integração contínua seja efetiva e funcione bem.

É comum utilizarmos ferramentas para geração de builds e execução de testes, entre outras tarefas repetitivas que podem ser automatizadas, mas apenas as ferramentas não são suficientes. Na verdade esta é a parte mais fácil de todo processo! Configurar um servidor Jenkins ou GoCD qualquer pessoa desenvolvera consegue. Já integrar o código a cada poucas horas exige outros tipos de responsabilidades e comprometimento de todos do time.

Caso tenha ficado interessada ou interessado neste tema, recomendo continuar seus estudos através dos livros Continuous Delivery e Extreme Programming Explained, sendo que ambos possuem versões traduzidas para o português.


Obrigado por ler até aqui! Se gostou deste texto, tenho mais alguns publicados em minha página do medium e também mantenho um podcast onde falo sobre livros que impactaram na minha carreira.

Marcio Frayze

Written by

Artesão de software, entusiasta de programação extrema e autor do podcast segunda.tech.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade