Uma abordagem melhor para a implantação contínua do Google Cloud

Publicados: 2022-03-11

A implantação contínua (CD) é a prática de implantar automaticamente um novo código para produção. A maioria dos sistemas de implantação contínua valida que o código a ser implantado é viável executando testes unitários e funcionais e, se tudo estiver bem, a implantação é lançada. O lançamento em si geralmente acontece em etapas para poder reverter se o código não se comportar conforme o esperado.

Não faltam postagens de blog sobre como implementar seu próprio pipeline de CD usando várias ferramentas, como a pilha da AWS, a pilha do Google Cloud, o pipeline do Bitbucket etc. Mas acho que a maioria deles não se encaixa na minha ideia do que é um bom pipeline de CD deve se parecer com: um que compila primeiro e testa e implementa apenas esse único arquivo compilado.

Neste artigo, criarei um pipeline de implantação contínua orientado a eventos que cria primeiro e depois executa testes em nosso artefato final de implantação. Isso não apenas torna os resultados de nossos testes mais confiáveis, mas também torna o pipeline de CD facilmente extensível. Ficaria algo assim:

  1. Um commit é feito para o nosso repositório de origem.
  2. Isso aciona uma compilação da imagem associada.
  3. Os testes são executados no artefato construído.
  4. Se tudo estiver correto, a imagem é implantada na produção.

Este artigo pressupõe pelo menos uma familiaridade passageira com o Kubernetes e a tecnologia de contêiner, mas se você não estiver familiarizado ou precisar de uma atualização, consulte O que é o Kubernetes? Um guia para conteinerização e implantação.

O problema com a maioria das configurações de CD

Aqui está o meu problema com a maioria dos pipelines de CD: Eles geralmente fazem tudo no arquivo de compilação. A maioria das postagens de blog que li sobre isso terá alguma variação da sequência a seguir em qualquer arquivo de compilação que eles tenham ( cloudbuild.yaml para Google Cloud Build, bitbucket-pipeline.yaml para Bitbucket).

  1. Executar testes
  2. Criar imagem
  3. Enviar imagem para repositório de contêiner
  4. Atualizar ambiente com nova imagem

Você não está executando seus testes em seu artefato final.

Ao fazer as coisas nesta ordem, você executa seus testes. Se eles forem bem-sucedidos, você cria a imagem e continua com o restante do pipeline. O que aconteceria se o processo de construção mudasse sua imagem de tal forma que os testes não passassem mais? Na minha opinião, você deve começar produzindo um artefato (a imagem final do contêiner) e esse artefato não deve mudar entre a compilação e o momento em que é implantado na produção. Isso garante que os dados que você tem sobre o referido artefato (resultados do teste, tamanho, etc) sejam sempre válidos.

Seu ambiente de construção tem as “chaves do reino”.

Ao usar seu ambiente de compilação para implantar sua imagem em sua pilha de produção, você está efetivamente permitindo que ela altere seu ambiente de produção. Eu vejo isso como uma coisa muito ruim porque qualquer pessoa com acesso de gravação ao seu repositório de origem agora pode fazer o que quiser em seu ambiente de produção.

Você precisa executar novamente todo o pipeline se a última etapa falhar.

Se a última etapa falhar (por exemplo, devido a um problema de credencial), você precisará executar novamente todo o pipeline, ocupando tempo e outros recursos que poderiam ser mais bem gastos fazendo outra coisa.

Isso me leva ao meu ponto final:

Seus passos não são independentes.

Em um sentido mais geral, ter etapas independentes permite que você tenha mais flexibilidade em seu pipeline. Digamos que você queira adicionar testes funcionais ao seu pipeline. Ao ter suas etapas em um arquivo de compilação, você precisa fazer com que seu ambiente de compilação gerencie um ambiente de teste funcional e execute os testes nele (provavelmente sequencialmente). Se suas etapas fossem independentes, você poderia ter seus testes de unidade e testes funcionais iniciados pelo evento “imagem construída”. Eles então seriam executados em paralelo em seu próprio ambiente.

Minha configuração de CD ideal

Na minha opinião, uma maneira melhor de abordar esse problema seria ter uma série de etapas independentes, todas vinculadas por um mecanismo de evento.

Isso tem várias vantagens em comparação com o método anterior:

Você pode realizar várias ações independentes em diferentes eventos.

Como dito acima, a construção bem-sucedida de uma nova imagem apenas publicaria um evento de “construção bem-sucedida”. Por sua vez, podemos ter várias coisas executadas quando esse evento é acionado. No nosso caso, iniciaríamos os testes unitários e funcionais. Você também pode pensar em coisas como alertar o desenvolvedor quando um evento de falha de compilação for acionado ou se os testes não passarem.

Cada ambiente tem seu próprio conjunto de direitos.

Ao fazer com que cada etapa aconteça em seu próprio ambiente, eliminamos a necessidade de um único ambiente ter todos os direitos. Agora, o ambiente de compilação pode apenas compilar, o ambiente de teste pode apenas testar e o ambiente de implantação pode apenas implantar. Isso permite que você tenha certeza de que, depois que sua imagem for criada, ela não mudará. O artefato que foi produzido é aquele que vai acabar na sua pilha de produção. Ele também permite uma auditoria mais fácil de qual etapa do seu pipeline está fazendo o que, pois você pode vincular um conjunto de credenciais a uma etapa.

Há mais flexibilidade.

Quer enviar um e-mail para alguém em cada compilação bem-sucedida? Basta adicionar algo que reaja a esse evento e envie um email. É fácil — você não precisa alterar seu código de compilação e não precisa codificar o e-mail de alguém em seu repositório de origem.

Novas tentativas são mais fáceis.

Ter etapas independentes também significa que você não precisa reiniciar todo o pipeline se uma etapa falhar. Se a condição de falha for temporária ou tiver sido corrigida manualmente, basta repetir a etapa que falhou. Isso permite um pipeline mais eficiente. Quando uma etapa de compilação leva vários minutos, é bom não precisar reconstruir a imagem apenas porque você esqueceu de conceder ao seu ambiente de implantação acesso de gravação ao cluster.

Como implementar a implantação contínua do Google Cloud

O Google Cloud Platform tem todas as ferramentas necessárias para construir um sistema desse tipo em pouco tempo e com muito pouco código.

Nosso aplicativo de teste é um aplicativo Flask simples que serve apenas um pedaço de texto estático. Esse aplicativo é implantado em um cluster Kubernetes que o atende à Internet mais ampla.

Estarei implementando uma versão simplificada do pipeline que apresentei anteriormente. Eu basicamente removi as etapas de teste para que agora fique assim:

  • Um novo commit é feito para o repositório de origem
  • Isso aciona uma construção de imagem. Se for bem-sucedido, ele será enviado ao repositório do contêiner e um evento será publicado em um tópico do Pub/Sub
  • Um pequeno script é inscrito nesse assunto e verifica os parâmetros da imagem — se eles corresponderem ao que pedimos, ele será implantado no cluster do Kubernetes.

Aqui está uma representação gráfica do nosso pipeline.

Representação gráfica do pipeline

O fluxo é o seguinte:

  1. Alguém se compromete com nosso repositório.
  2. Isso aciona uma compilação na nuvem que cria uma imagem do Docker com base no repositório de origem.
  3. A compilação na nuvem envia a imagem para o repositório do contêiner e publica uma mensagem no pub/sub na nuvem.
  4. Isso aciona uma função de nuvem que verifica os parâmetros da mensagem publicada (status da compilação, nome da imagem construída, etc.).
  5. Se os parâmetros forem bons, a função de nuvem atualiza uma implantação do Kubernetes com a nova imagem.
  6. O Kubernetes implanta novos contêineres com a nova imagem.

Código fonte

Nosso código-fonte é um aplicativo Flask muito simples que serve apenas algum texto estático. Segue a estrutura do nosso projeto:

 ├── docker │ ├── Dockerfile │ └── uwsgi.ini ├── k8s │ ├── deployment.yaml │ └── service.yaml ├── LICENSE ├── Pipfile ├── Pipfile.lock └── src └── main.py

O diretório do Docker contém tudo o que é necessário para criar a imagem do Docker. A imagem é baseada na imagem uWSGI e Nginx e apenas instala as dependências e copia o aplicativo para o caminho certo.

O diretório k8s contém a configuração do Kubernetes. Ele consiste em um serviço e uma implantação. A implantação inicia um contêiner com base na imagem criada a partir do Dockerfile . O serviço inicia um balanceador de carga que tem um endereço IP público e redireciona para o(s) contêiner(es) do aplicativo.

Criação de nuvem

A própria configuração de compilação na nuvem pode ser feita por meio do console da nuvem ou da linha de comando do Google Cloud. Optei por usar o console na nuvem.

Captura de tela do console na nuvem

Aqui, construímos uma imagem para qualquer commit em qualquer branch, mas você pode ter imagens diferentes para desenvolvimento e produção, por exemplo.

Se a compilação for bem-sucedida, a compilação na nuvem publicará a imagem no registro de contêiner por conta própria. Em seguida, ele publicará uma mensagem no tópico pub/sub de compilação em nuvem.

A compilação na nuvem também publica mensagens quando uma compilação está em andamento e quando uma delas falha, então você também pode fazer com que as coisas reajam a essas mensagens.

A documentação para as notificações pub/sub da compilação em nuvem está aqui e o formato da mensagem pode ser encontrado aqui

Cloud Pub/Sub

Se você olhar na guia de publicação/assinatura da nuvem no console da nuvem, verá que a compilação na nuvem criou um tópico chamado compilações na nuvem. É aqui que a compilação na nuvem publica suas atualizações de status.

Captura de tela do projeto Pub/Sub

Função de nuvem

O que faremos agora é criar uma função de nuvem que seja acionada em qualquer mensagem publicada no tópico de compilações em nuvem. Novamente, você pode usar o console da nuvem ou o utilitário de linha de comando do Google Cloud. O que fiz no meu caso é que uso a compilação em nuvem para implantar a função de nuvem sempre que houver uma alteração nela.

O código-fonte para a função de nuvem está aqui.

Vejamos primeiro o código que implanta essa função de nuvem:

 steps: - name: 'gcr.io/cloud-builders/gcloud' id: 'test' args: ['functions', 'deploy', 'new-image-trigger', '--runtime=python37', '--trigger-topic=cloud-builds', '--entry-point=onNewImage', '--region=us-east1', '--source=https://source.developers.google.com/projects/$PROJECT_ID/repos/$REPO_NAME']

Aqui, usamos a imagem do Google Cloud Docker. Isso permite executar comandos do GCcloud facilmente. O que estamos executando é o equivalente a executar o seguinte comando diretamente de um terminal:

 gcloud functions deploy new-image-trigger --runtime=python37 --trigger-topic=cloud-builds --entry-point=onNewImage --region=us-east1 --source=https://source.developers.google.com/projects/$PROJECT_ID/repos/$REPO_NAME

Estamos pedindo ao Google Cloud para implantar uma nova função de nuvem (ou substituir se já existir uma função com esse nome nessa região) que usará o tempo de execução do Python 3.7 e será acionada por novas mensagens no tópico de compilações de nuvem. Também informamos ao Google onde encontrar o código-fonte dessa função (aqui, PROJECT_ID e REPO_NAME são variáveis ​​de ambiente definidas pelo processo de compilação). Também informamos qual função chamar como ponto de entrada.

Como observação lateral, para que isso funcione, você precisa fornecer à sua conta de serviço cloudbuild o “desenvolvedor de funções de nuvem” e o “usuário da conta de serviço” para que ela possa implantar a função de nuvem.

Aqui estão alguns trechos comentados do código de função da nuvem

Os dados do ponto de entrada conterão a mensagem recebida no tópico pub/sub.

 def onNewImage(data, context):

A primeira etapa é obter as variáveis ​​para essa implantação específica do ambiente (definimos isso modificando a função de nuvem no console da nuvem.

 project = os.environ.get('PROJECT') zone = os.environ.get('ZONE') cluster = os.environ.get('CLUSTER') deployment = os.environ.get('DEPLOYMENT') deploy_image = os.environ.get('IMAGE') target_container = os.environ.get('CONTAINER')

Iremos pular a parte em que verificamos se a estrutura da mensagem é o que esperamos e validamos que a construção foi bem-sucedida e produziu um artefato de imagem.

A próxima etapa é certificar-se de que a imagem que foi criada é a que queremos implantar.

 image = decoded_data['results']['images'][0]['name'] image_basename = image.split('/')[-1].split(':')[0] if image_basename != deploy_image: logging.error(f'{image_basename} is different from {deploy_image}') return

Agora, obtemos um cliente Kubernetes e recuperamos a implantação que queremos modificar

 v1 = get_kube_client(project, zone, cluster) dep = v1.read_namespaced_deployment(deployment, 'default') if dep is None: logging.error(f'There was no deployment named {deployment}') return

Por fim, corrigimos a implantação com a nova imagem; O Kubernetes se encarregará de implementá-lo.

 for i, container in enumerate(dep.spec.template.spec.containers): if container.name == target_container: dep.spec.template.spec.containers[i].image = image logging.info(f'Updating to {image}') v1.patch_namespaced_deployment(deployment, 'default', dep)

Conclusão

Este é um exemplo muito básico de como eu gosto que as coisas sejam arquitetadas em um pipeline de CD. Você poderia ter mais etapas apenas alterando qual evento pub/sub aciona o quê.

Por exemplo, você pode executar um contêiner que executa os testes dentro da imagem e publica um evento em caso de sucesso e outro em caso de falha e reagir a eles atualizando uma implantação ou alertando dependendo do resultado.

O pipeline que construímos é bem simples, mas você pode escrever outras funções de nuvem para outras partes (por exemplo, uma função de nuvem que enviaria um email para o desenvolvedor que confirmou o código que quebrou seus testes de unidade).

Como você pode ver, nosso ambiente de compilação não pode alterar nada em nosso cluster Kubernetes e nosso código de implantação (a função de nuvem) não pode modificar a imagem que foi construída. Nossa separação de privilégios parece boa, e podemos dormir tranquilos sabendo que um desenvolvedor desonesto não derrubará nosso cluster de produção. Também podemos dar aos nossos desenvolvedores mais orientados a operações acesso ao código de função da nuvem para que possam corrigi-lo ou melhorá-lo.

Se você tiver dúvidas, observações ou melhorias, sinta-se à vontade para entrar em contato nos comentários abaixo.