Você precisa frequentemente sincronizar a implementação de um novo documento de design ou uma atualização de API com o código da aplicação relacionada? Este artigo serve de inspiração para as equipes de DevOps que desejam automatizar dependências dos pods no Kubernetes.
Pré-requisitos
Para tirar o máximo proveito deste artigo, é necessário que você tenha um entendimento geral sobre Kubernetes, containers, implementação e integração contínua, assim como de documentos de design Cloudant.
Tempo estimado
A leitura deste artigo deve levar cerca de 15 minutos para ser concluída.
Até onde podemos chegar para automatizar a parte “Ops” do DevOps?
Como desenvolvedor, eu quero estar no controle total de tudo. Quero definir mudanças em dependências externas diretamente no código da minha aplicação. Quero implementar atualizações para a minha aplicação Node.js ou Java e vê-la em execução com sua versão anterior. Quero evitar tempo de inatividade e conflitos entre as visualizações do banco de dados de que o meu serviço web precisava antes e as visualizações de que ele necessita agora.
Afinal, a tendência de automação na nuvem permite que as equipes de DevOps estejam no controle das implementações end-to-end.
Por sorte, o Kubernetes vem tornando as atualizações em andamento um processo muito simples. Juntamente com a implementação constante e ferramentas de integração contínua, como o Travis CI ou a ferramenta de desenvolvimento Toolchain para o IBM Cloud, o processo vem ficando cada vez mais fácil de ser configurado. Múltiplas implementações por dia são agora rotina. Entretanto, nem sempre é tão fácil assim sincronizar bancos de dados personalizados ou atualizações de API com o código da aplicação. Muitas vezes é necessário orquestrar as implementações.
Este artigo descreve uma das formas de sincronizar dependências de pod usando o Kubernetes Init Containers.
Escopo da automação
Aplicações como microsserviços costumam precisar de um conjunto limitado de dependências, como o seu próprio armazenamento e a definição de API. O armazenamento ou as atualizações relacionadas ao API muitas vezes evoluem para uma rotina — e eles demandam a automação. A atualização do documento de design do Cloudant é um ótimo exemplo disso. A parte crítica da implementação contínua de aplicações de alta disponibilidade consiste em garantir que a visualização atualizada se encontre disponível ao mesmo tempo que o serviço é atualizado.
Um processo de implementação automatizado é adequado quando você tem os seguintes tipos de dependências:
Bancos de dados (Cloudant, armazenamento de objetos)
Documentos de design de base de dados (índices, visualizações)
Documentos-semente
APIs (swaggers)
Changelogs de API
Testes automatizados de integração
As configurações para todas essas dependências podem ser armazenadas no repositório de código da aplicação e empacotadas na imagem do docker.
Observação: por configuração, me refiro a código, e não a credenciais. As credenciais devem ser inseridas como variáveis de ambiente ou fornecidas pelos serviços de configuração.
Confira a seguir a estrutura de um exemplo de diretório config:
config
¦ cloudant
¦ ¦ {databaseSuffixOrFullName}
¦ ¦ ¦ designs
¦ ¦ ¦ ¦ {designName}.json
¦ ¦ ¦ seeds
¦ ¦ ¦ ¦ {seedName}.json
¦ ¦ parameters.json (optional)
¦ apis
¦ ¦ {apiName}
¦ ¦ ¦ {version}.yaml
¦ ¦ ¦ {version}.md
¦ ¦ ¦ {version}.test.json
¦ ¦ ¦ {version}.test.js
Será que é realmente necessário armazenar todas essas informações com o código da aplicação? Não, mas ter controle completo sobre todas as partes é extremamente libertador para os desenvolvedores. Serviços totalmente autônomos nos dão a tranquilidade de que nenhuma das dependências criará conflitos entre diferentes versões de serviços.
Init containers: a resposta do Kubernetes às dependências das aplicações
O método recomendado para instalar dependências de aplicações no Kubernetes é através do Init Containers. Os Init Containers são definidos na implementação do pod e bloqueiam a inicialização da aplicação até que eles sejam executados com sucesso.
A seguir, veja um exemplo muito básico no qual o Init Container cria uma nova base de dados do Cloudant:
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
containers:
- name: app
image: registry.ng.bluemix.net/services/app:v1.0.1
ports:
- containerPort: 8080
initContainers:
- name: deploy
image: appropriate/curl
command: [ "sh" ]
args: [ "-c", "curl -X PUT $URL/dbname" ]
env:
- name: URL
valueFrom:
configMapKeyRef:
name: config
key: cloudantApiKeyUrl
Na realidade, a verdadeira lógica da dependência é muito mais complexa e requer uma nova aplicação. Vamos chamá-la de “Deployment Manager”. O script initContainer poderia designar o Deployment Manager em execução como um serviço, mas uma abordagem muito mais autônoma seria criar o Deployment Manager como outro docker no registro e integrar a sua imagem nos scripts de implementação ou helm charts.
Então, o arquivo de implementação apresentará a seguinte forma:
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
containers:
- name: app
image: registry.ng.bluemix.net/services/app:v1.0.1
ports:
- containerPort: 8080
initContainers:
- name: deploy
image: registry.ng.bluemix.net/utilities/deployment_manager:v0.0.1
command: [ "sh" ]
args: [ "-c", "cd /usr/src/app/;npm run start-as-init-container" ]
env:
- name: config
valueFrom:
configMapKeyRef:
name: config
key: config
Como manter todas as entradas de dependência no código da aplicação
No exemplo anterior, o Deployment Manager precisa obter todos os inputs da variável de ambiente. Entretanto, o objetivo é o de obter os inputs do código da aplicação, o que significa extrair os inputs (todos, menos as credenciais) do container da aplicação.
O Deployment Manager Init Container não vê o container da aplicação no pod e não consegue se comunicar diretamente com ele. O principal truque para fazer essa abordagem funcionar é carregar o container da aplicação com antecedência. Em seguida, extrair a entrada necessária para um volume compartilhado para uso posterior do Deployment Manager.
Os exemplos a seguir mostram a utilização de outro Init Container para esse propósito:
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
volumes:
- name: deployment-volume
emptyDir: {}
containers:
- name: app
image: registry.ng.bluemix.net/services/app:v1.0.1
ports:
- containerPort: 8080
volumeMounts:
- name: deployment-volume
mountPath: "/init"
initContainers:
- name: copy
image: registry.ng.bluemix.net/services/app:v1.0.1
command: [ "sh" ]
args: [ "-c", "set -e;cp -v -r /usr/src/app/config/* /init/" ]
volumeMounts:
- name: deployment-volume
mountPath: "/init"
- name: deploy
image: registry.ng.bluemix.net/utilities/deployment_manager:v0.0.1
command: [ "sh" ]
args: [ "-c", "cd /usr/src/app/;npm run start-as-init-container" ]
env:
- name: config
valueFrom:
configMapKeyRef:
name: config
key: config
volumeMounts:
- name: deployment-volume
mountPath: "/init"
Por que funciona?
Os containers são inicializados sequencialmente:
O primeiro Init Container chamado copy carrega a imagem da aplicação, mas sobrepõe a forma como a aplicação normalmente é inicializada por meio do uso de um comando de cópia personalizado. Enquanto a imagem da aplicação Docker suportar o script sh, o comando copy extrairá todos os arquivos de configuração para o novo deployment-volume de volume compartilhado no caminho /init antes de sair. Qualquer falha nesse ponto produzirá um erro e bloqueará os próximos passos.
O próximo Init Container será a sua aplicação Deployment Manager. Ele usará o mesmo volume compartilhado e encontrará todas as entradas de dependências necessárias da aplicação deixadas pelo primeiro container. O deploy container pode levar o tempo que for necessário para instalar as dependências. A inicialização do pod ocorrerá somente quando esse processo for finalizado com sucesso.
Finalmente, o container principal da aplicação também carregará o mesmo volume compartilhado. Essa etapa é necessária porque o Deployment Manager gerará um arquivo init.json como resultado da inicialização. O arquivo init contém os detalhes sobre qual versão de um recurso específico (por exemplo, qual documento de design do Cloudant) a aplicação deve utilizar.
Para tornar o mesmo processo reutilizável em ambientes poliglotas, utilize a convenção de nomenclatura padronizada e a estrutura do diretório de configuração. O JSON consiste em um dos formatos ideais, tanto para entrada quanto para saída, embora também seja possível utilizar outros formatos.
O exemplo a seguir mostra o resultado com o init.json:
{
"init": {
"cloudant": {
"channels": {
"designs": {
"search": "search~2019-06-04T13:19:49.745Z"
},
"seeds": {
"taxonomy": "taxonomy"
}
}
},
"apis": {
"search": {
"v3": {
"api": "API~channels~v3~2019-06-01T23:15:18.609Z"
}
}
}
}
}
Inicializando uma nova aplicação
Suponhamos que um documento de design do Cloudant atualizado seja necessário em uma nova versão (v1.0.2) da aplicação que está rodando atualmente no ambiente.
Você não quer nenhuma inatividade durante a implementação de atualizações em vários pods. Portanto, deve garantir que as duas versões da aplicação possam rodar ao mesmo tempo.
Essa situação significa que você não pode simplesmente remover bancos de dados e visualizações antigas. Em vez disso, primeiro será preciso criar novas e esperar até que a mudança seja implementada em todos os pods da aplicação na réplica. Então, somente depois de a implementação ser bem-sucedida, você deverá garantir que as dependências antigas e não utilizadas sejam removidas.
Na prática, você precisará escolher um nome diferente para cada versão; por exemplo, usar um sufixo de registro de tempo adicionado ao nome do documento de design.
Exemplo: um documento de design Cloudant chamado search requerido pela v1.0.1 seria substituído por uma versão diferente na v1.0.2. O documento inicial, chamado search~2019-06-04T13:19:49.745Z, seria instalado na base de dados. No momento da instalação da versão v1.0.2, o aplicativo Deployment Manager compararia a visualização usando um diff profundo. Se a visualização encontrada não corresponder à visualização no código da aplicação, ele instalará a segunda versão chamada search~2019-06-05T08:12:33.123Z.
Durante a implementação, os pods antigos ainda usarão o primeiro documento de design, e os pods novos começarão a usar o documento novo. Não existindo conflito entre os pods, a transição deverá acontecer sem nenhum tempo de inatividade. Além disso, se a aplicação precisar rodar novamente, o mesmo processo será aplicado. Novamente, a todo momento, cada pod usará exatamente o código de visualização que ele contém — aquele com o qual ele foi testado.
Limpeza
Até agora, tudo bem. Você já possui uma bela separação de dependências. Mas, o que deve fazer com as dependências não mais necessárias?
Como o objetivo é conseguir um sistema totalmente automatizado, você precisará ter ciência de todas as dependências em uso por todos os pods. Considere as seguintes formas de rastrear as dependências em uso:
Mantenha os logs das dependências utilizadas pelo Deployment Manager.
Exponha as dependências utilizadas pela aplicação mediante solicitação.
Estabeleça uma validade para cada recurso e solicite renovações regulares.
Você pode utilizar qualquer dessas abordagens acima, desde que elas possam se recuperar facilmente de falhas. No entanto, na maioria dos casos o processo de limpeza deve ser capaz de ler a lista de todos os pods ativos para estabelecer se as dependências implementadas ainda são necessárias.
Se for preciso, a equipe de DevOps poderá investir em uma interface de usuário de administração para supervisionar o processo de limpeza, como o exemplo a seguir:
Soluções alternativas
Você também pode utilizar scripts de implementação e integração contínuos para extrair configurações de dependências do código da aplicação. Um gerenciador construído de forma personalizada pode, assim, garantir que as dependências estejam disponíveis antes da inicialização da aplicação.
Entretanto, o uso do Init Containers normalmente é mais eficiente. A implementação completa pode levar apenas alguns segundos, uma vez que todo o trabalho acontece diretamente no cluster do Kubernetes com todas as imagens prontamente disponíveis. Além disso, um benefício inquestionável do Init Containers é que nenhum pod é iniciado sem uma verificação completa de todas as suas dependências.
Resumo
A inclusão de dependências de pod na imagem da aplicação pode simplificar drasticamente o gerenciamento do ciclo de vida de uma aplicação. A aplicação simplesmente não iniciará até que as dependências corretas estejam disponíveis e os upgrades e downgrades ocorram sem percalços. Essa abordagem sugerida pode ajudar a equipe de DevOps a se concentrar mais no desenvolvimento e no valor do negócio, em vez de focar nas operações.
Para saber mais, confira a página sobre Kubernetes Init Containers.
...
Quer ler mais conteúdo especializado de programação? Conheça a IBM Blue Profile e tenha acesso a matérias exclusivas, novas jornadas de conhecimento e testes personalizados. Confira agora mesmo, consiga as badges e dê um upgrade na sua carreira!