Como Implantação Canário ajudou a entregar uma atualização de Rails
Sobre o Projeto
Imagine um projeto com oito anos de idade. Não importa qual a linguagem ou framework utilizado, muito provavelmente o código vai ser difícil de manter. Coisas que hoje são certeza para nós nem sequer existiam oito anos atrás. Assim, para esse projeto, fazer um upgrade de Rails 2 para Rails 3 foi uma tarefa bem desafiadora.
Depois de um ano e meio de trabalho dedicado para fazer o upgrade da aplicação, ainda tendo que adicionar funcionalidades, o time finalmente terminou o trabalho para atualizar as versões da linguagem, framework, gems e outras bibliotecas externas.
A primeira tentativa de implantar a versão atualizada foi feita utilizando uma abordagem Big Bang. O maior benfício de utilizar esta abordagem é que o processo já estava bem testado, pois o time fazia implantações diariamente para os ambientes de pré-produção e não foram necessárias muitas mudanças no processo que já estava lá. Por outro lado, como você pode imaginar, o maior risco dessa abordagem é que era tudo ou nada, se algum problema crítico aparecesse inesperadamente, nós precisaríamos desfazer as mudanças.
E, como você pode imaginar, depois da primeira implantação utilizando a aplicação atualizada, nós encontramos problemas com integração não tão bem testadas e outras dependências externas. No fim, o cliente pediu que as mudanças fossem desfeitas, mesmo que não soubéssemos ao certo qual era a causa raiz do problema.
Depois do incidente, o time decidiu trabalhar em uma nova abordagem para a implantação, que reduziria o risco, simplificaria desfazer as mudanças e ganhar novamente a confiança do cliente. Então, uma abordagem de Implantação Canário começou a ser discutida.
Porque Implantação Canário
Quando nós começamos a estudar outras alternativas de entrega além de Big Bang, nós descobrimos que a entrega canário era exatamente o que nós precisávamos. Citando Danilo Sato:
Implantação Canário (ou Canary Release) é uma técnica para reduzir o risco da introdução de uma nova versão do software em produção, fazendo o lançamento gradual da mesma para uma pequena parte do conjunto de usuários antes de implantá-la em toda a infraestrutura e torná-la acessível a todos.
Com isto em mente, nós começamos a trabalhar no nosso processo de entrega e em nossa infraestrutura, para que fosse possível ter as duas versões da aplicação (Rails 2 e Rails 3) no mesmo ambiente. Isto foi sem dúvida o nosso maior desafio, pois precisávamos executar diferentes versões do rails e diferentes versões de gems, tudo isso se comunicando uns com os outros.
Uma coisa que vale a pena mencionar aqui é a nossa arquitetura de implantação. A infraestrutura necessária para rodar a nossa aplicação requer uma quantidade muito grande de servidores com diferentes papéis. Porem, os servidores/papéis que são mais importantes no escopo deste texto são os servidores web, os servidores de integração/serviços e os servidores de workers. Os outros servidores não serão mencionados aqui, pois estes não executam código ruby/rails. Os únicos servidores que não rodam código, mas que valem a pena ser mencionados são os servidores de memcache. Mais a frente iremos entender o porque os servidores de memcache foram importantes neste processo.
Os servidores web, como seu nome sugere, são responsáveis por gerenciar o trafégo web e, sem dúvida, eles foram onde nós focamos mais os nossos esforços a fim de balancear o trafégo.
Os servidores de serviços/integração são utilizados por nossos parceiros quando estes desejam enviar dados para nossa aplicação. Para estes servidores específicos, nós decidimos ter metade deles rodando Rails 2 e a outra metade rodando Rails 3. Entretanto, a política de balanceamento entre estas máquinas é baseado em direcionar o tráfego para o servidor que esta menos sobrecarregado.
Finalmente, os servidores de workers são responsáveis por executar várias tarefas assíncronas, como por exemplo enviar emails, expurgar dados obsoletos, etc. Alguns workers precisam executar de forma única e não permitem que mais de uma instância possa ser executada ao mesmo tempo. Nós chamamos este tipo de worker de singleton worker. Nós temos dois servidores que executam apenas singleton workers, sendo assim, nós decidimos que um servidor de singleton workers usaria Rails 2, e o outro, Rails 3. Para os outros workers não existe esta restrição de instâncias únicas, então nós podemos rodar mais de uma instância do worker no mesmo servidor e em servidores diferentes. Para que fosse mais fácil gerenciar possíveis problemas nós decidimos que apenas uma máquina não singleton worker rodaria com a versão 3 do Rails.
Agora que sabemos como foi a nossa estratégia de dividir nossos servidores em canários, nós ainda precisávamos de uma maneira de fazer isso de forma autómatica e mais facilmente gerenciável. A primeira coisa que tivemos que pensar foi como dividiríamos o tráfego web entre os servidores.. Para resolver este problema, nós configuramos dois pools de conexões web no nosso balanceador de carga, de forma que se um usuário fizesse seu primeiro acesso atráves do pool de servidores rails 2 todas as próximas requisiçoes seriam feitas para servidores deste pool. De forma análoga, este mesmo fluxo ocorria caso o usuário fosse direcionado para o pool do rails 3 na sua primeira requisição. Esta abordagem nos ajudou a minimizar o risco de incompatibilidade entre as versões do rails. No início, nós pensamos em fazer este balanceamento dentro da nossa aplicação, mas nos pareceu mais simples deixar este trabalho a cargo do balanceador de carga, dentro da nossa infraestrutura.
A segunda coisa que tivemos que fazer foi alterar nosso script de deploy, para que fosse possivel imaplantar código em um conjunto específico de servidores ao invés de fazê-lo em todos. Atualmente, nós usamos o Capistrano para executar nossos scripts/comandos de forma remota em nossos servidores durante o processo de deploy. O Capistrano nos permite especificar um conjunto de servidores e com isso nós podemos direcionar para quais servidores a implantação será feita. Outra coisa que precisamos fazer foi tornar opicional a execução de algumas tarefas do script, pois estas só fariam sentido se estivéssmos fazendo um deploy completo/total.
Por último, existem alguns problemas de incompatibilidade na forma como objetos são serializados no rails 2 e no rails 3. Objetos serializados pelo rails 2 são desserializados pelo rails 3, porém o contrário não é verdade. Para evitar este problema nós decidimos criar novos servidores de memcache e cada versão do Rails usaria seu conjunto de servidores de cache.
Apesar de todas essas mudanças e do trabalho realizado, nossa principal preocupação, sem dúvida alguma, ainda eram os possíveis comportamentos inesperados, que nós provavelmente teríamos por termos duas versões do framework Rails rodando junto e ao mesmo tempo, interagindo um com outro, criando e consumindo dados de forma intercambiável. Nós tentamos mitigar este risco usando um ambiente de homologação o mais próximo possível ao de produção, mas sabíamos, com certeza, que não conseguiríamos reproduzir todas as combinações possíveis a priori.
A Execução
Nosso plano para fazer a atualização completa foi dividido em três implantações. O primeiro passo foi migrar poucas máquinas, colocá-las no conjunto do Rails 3 e direcionar 10% do tráfego total para esse conjunto. Depois, nós passamos uma semana monitorando o ambiente de produção.
Durante essa semana nós vimos alguns pequenos problemas acontecendo e, como apenas algumas poucas máquinas estavam sendo utilizadas, nós conseguimos resolver os problemas sem tirar a aplicação do ar, o que ajudou bastante a reduzir a pressão no time e deu tempo para fazer a investigação necessária.
Como um bom exemplo, nós tinhamos servidores web com Rails 2 e 3 e ambos estavam produzindo dados para jobs de email. Descobrimos que as máquinas de worker com Rails 3 eram capazes de consumir os dados de ambas as versões, mas as máquinas com Rails 2 não. Como nossa meta era o Rails 3, nós decidimos apenas remover do grupo as máquinas com Rails 2 e deixar apenas os workers com a versão do Rails 3.
O segundo passo foi migrar metade de todas as máquinas e dividir o tráfego 50/50 entre Rails 2 e Rails 3. Mais uma vez passamos uma semana monitorando o ambiente de produção, mas desta vez nenhum problema apareceu e a nossa maior preocupação era a performance.
Nós sabíamos que a performance seria pior, comparada com o que tínhamos antes, de acordo com testes de performance feitas em ambientes de pré-produção e lendo as experiências de outras equipes. Nós só não sabíamos exatamente o quão pior seria. No fim a performance não foi tão ruim quanto esperávamos e a aplicação conseguiu lidar com a carga.
No terceiro passo nós completamos a migração, adicionamos todas as máquinas no grupo do Rails 3 e direcionamos 100% do tráfego para este grupo. Nenhum problema foi encontrado (afinal de contas já estava no ar a mais de duas semanas) e nós sabíamos que não haveria nenhum problema relacionado a carga.
O Caminho à frente
Agora o projeto está utilizando versões ainda suportadas das tecnologias e o time aprendeu bastante com a experiência. O cliente também viu valor em investir na atualização de tecnologias e revisitar todas as partes da aplicação e da arquitetura diminuiu bastante a quantidade de erros na aplicação, melhorando a experiência de usuário e deixando a aplicação mais confiante.
O time vai continuar investigando a atualização para as próximas versões de Ruby e Rails e, com as lições aprendidas depois de um ano e meio de esforço, nós podemos planejar melhor os próximos passos.
Sem falar que a experiência de ter utilizar a Implantação Canário provou ser bem sucedida e o cliente agora pode considerar esta opção sempre que uma entrega arriscada precise ser feita.
Aviso: As afirmações e opiniões expressas neste artigo são de responsabilidade de quem o assina, e não necessariamente refletem as posições da Thoughtworks.