8.3 CI/CD com GitLab e mlserver
Agora que já sabemos como fazer uso de um servidor de modelos, podemos tentar automatizar o processo de subir os modelos, seguindo os princípios de DevOps. Faremos aqui um exemplo simples que ilustra como um possível ciclo de desenvolvimento pode ser implementado com as ferramentas que já vimos até agora, além de algumas novas que aprenderemos no caminho.
8.3.1 Implantação automática do MLServer no Heroku - criando uma imagem Docker personalizada
Já fizemos antes, na Seção 3.3, a implantação de uma API HTTP no Heroku, utilizando gunicorn e Flask. Também fizemos, na Seção 7.3, a implantação automatizada no Heroku a partir do GitLab. Então o que faremos agora não é nenhuma novidade, exceto pela diferença que estamos usando MLServer ao invés da combinação gunicorn e Flask. Na verdade, o MLServer usa uvicorn e FastAPI, que são muito semelhantes ao gunicorn e Flask.
Comece criando uma nova pasta, com o nome mlserver
. Na verdade, se quiser apagar a pasta anterior e reutilizar, tudo bem, faremos tudo novamente aqui. Nessa pasta vamos recriar o que fizemos na seção anterior, porém simplificando algumas coisas. Não teremos runtimes customizados e nem modelos pesados como o do BERT, para deixar os processos mais rápidos. Além disso, faremos uso do nginx e MLServer na mesma imagem, para simplificar o deploy no Heroku.
Crie um arquivo chamado settings.json
, com o conteúdo:
O modo debug do MLServer facilita para detectarmos erros e problemas ao longo do processo.
Crie um arquivo requirements.txt
:
Agora crie um arquivo chamado default
(sem extensão), para configurar o nginx:
Crie um arquivo chamado entrypoint.sh
:
Esses dois últimos reproduzem a configuração que já fizemos antes, então não é necessário explicar nada.
Agora crie o arquivo Dockerfile
:
Também já fizemos tudo isso antes. A única diferença aqui que vale a pena ressaltar é que estamos usando a imagem não-slim, pois o MLServer depende de algumas bibliotecas que só existem na versão completa da imagem python do docker hub. Fora isso, o Dockerfile faz a instalação e configuração do nginx, a instalação dos pacotes python (incluindo MLServer), e a execução dos dois serviços por meio do script entrypoint.sh
.
Por último, crie uma estrutura de pastas models/iris-dtc/1.0
. Dentro dela salve o arquivo iris-dtc-1.0.joblib. É o mesmo que utilizamos na seção anterior, mas substituímos o underline pelo hífen, pois a nomenclatura será importante mais adiante. Crie também um arquivo chamado model-settings.json
, com o seguinte conteúdo:
Podemos testar. Crie a imagem por meio do comando:
E execute:
A porta 9361 pode ser substituída por qualquer outra, pois é assim que o Heroku (e outros provedores) fazem ao subir um contêiner.
Faça o teste local, enviando um POST:
Até aqui não há nenhuma novidade. Vamos fazer o processo CI/CD com base nessa imagem, utilizando o GitLab.
8.3.2 Implantação automática do MLServer no Heroku - Configurando projetos no Heroku/GitLab
Vá até o GitLab e crie um novo projeto, chamado mlserver
. Não adicione um arquivo README.md
, pois iremos enviar nosso conteúdo inicial a partir da pasta que acabamos de criar. Para isso, execute:
Aliás, esses são os comandos que aparecem no GitLab assim que o novo projeto é criado. Não esqueça de substituir o nome do GitLab pelo seu!
Agora vá até o Heroku e crie uma aplicação, chamada dlucredio-mlserver
(use seu próprio username). Para simplificar, não faremos aqui as versões de staging e produção como fizemos na Seção 7.3, mas você já sabe fazer isso, então vamos seguir adiante.
Ainda no Heroku (canto superior direito), clique em "Account settings", e depois encontre "API Key". Clique em "Reveal". Copie esse valor.
Agora vá até o GitLab, entre no seu projeto, e selecione a opção "Settings" -> "CI/CD" -> "Variables". Vamos adicionar 2 variáveis:
HEROKU_API_KEY
: o valor é a chave que acabamos de copiar e colarHEROKU_APP
: coloque o nome do aplicativo no Heroku (dlucredio-mlserver
)
8.3.3 Implantação automática do MLServer no Heroku - Configurando pipeline de deploy no Heroku
Agora crie o arquivo .gitlab-ci.yml
:
Também não tem segredos aqui, pois já fizemos a mesma coisa antes. Na verdade esse pipeline é bem simples, não estamos fazendo testes, nem verificações de qualidade, apenas o deploy. Além disso, o deploy é automático e configurado para rodar a qualquer commit, em qualquer branch.
Podemos testar. Basta fazer o envio das mudanças para o GitLab e aguardar o deploy ser concluído:
Caso ainda não tenha feito, é necessário configurar um runner para o GitLab. Fizemos isso na Seção 6.5.
Se tudo der certo, podemos enviar um POST para o Heroku, e estará tudo funcionando:
Não esqueça de substituir o endereço abaixo pelo endereço da sua aplicação, que pode ser encontrado no dashboard do Heroku, em settings
.
Agora temos um processo automatizado para fazer deploy de novos modelos. Por exemplo, se quisermos adicionar nosso modelo de classificação de produtos, basta fazer o seguinte:
Crie, nas pasta
models
, o diretório:classificador-produtos/1.0
. Copie para lá o arquivo classificador-produtos-1.0.joblib que criamos na última seção. Crie o arquivomodel-settings.json
:
Agora basta fazer o envio para o GitLab e aguardar:
Assim que o deploy for concluído, basta testar, fazendo um POST:
8.3.4 Em direção à automação completa do processo - colocando notebook no GitLab
Agora que já conseguimos fazer o deploy automático a partir do GitLab para o Heroku, temos uma maior facilidade para manter nossos modelos atualizados. Porém, ainda resta agilizar o processo de envio dos modelos para o GitLab, de forma que possamos rapidamente colocar novos modelos no MLServer, inclusive novas versões.
Relembrando, o que queremos fazer é criar um caminho fácil de enviar as mudanças que fazemos localmente, possivelmente usando Jupyter notebook, até o Heroku. Também queremos usar o poder do MLServer para lidar com versionamento dos modelos.
Então vamos lá? Vamos começar criando uma pasta nova, onde iremos colocar nosso notebook. Desta vez faremos o exemplo com o Iris Dataset, que utilizamos na última seção.
Queremos que seja uma pasta com isolamento razoável, pois iremos controlar corretamente o versionamento dessa pasta no GitLab. Crie a pasta em um diretório qualquer, e chame-a de iris-decision-tree
. Crie um ambiente virtual, e crie o arquivo requirements.txt
:
Aqui é importante deixar todas as dependências bem organizadas e com as versões espeficidadas, pois esse notebook não será mais algo que ficará em nossa máquina, para experimentos sem muito controle. Iremos compartilhar esse notebook em um projeto no GitLab, por isso é importante esse cuidado. Assim, evite de instalar pacotes diretamente no notebook, preferindo deixar explícito no arquivo requirements.txt
, assim outras pessoas poderão saber o que você está usando.
Instale os módulos:
Agora já podemos executar o notebook. Utilizaremos um dos exemplos da última seção, que foi simplificado aqui. Também é importante a nomeação. O nome dele deve ser iris-decision-tree.ipynb
. Mais adiante utilizaremos scripts automáticos que dependem dessa nomeação exata.
Neste exemplo, estamos assumindo que o modelo será salvo em um arquivo com o mesmo nome que o notebook, com a extensão .joblib
. Novamente, isso será importante mais para a frente, quando iremos automatizar todo o processo.
Execute o notebook e veja que será criado o arquivo iris-decision-tree.joblib
. Isso significa que nosso modelo está funcionando e já podemos criar um repositório para ele. Mas não queremos que o arquivo .joblib
seja versionado, pois ele é gerado automaticamente pelo notebook. Por isso, crie um arquivo .gitignore
com o seguinte conteúdo:
A pasta .venv
também é bom incluir, caso exista, assim o git irá ignorar esses arquivos também.
Agora vá ao GitLab e crie um novo projeto, chamado iris-decision-tree
. Crie o projeto em branco, sem criar um README.md
, e em seguida inicialize o repositório com o conteúdo que acabamos de criar, utilizando os comandos que já conhecemos:
Agora já temos um ambiente compartilhado onde podemos trabalhar nesse modelo. Você pode clonar o repositório e trabalhar localmente, fazer testes e executar o que precisa. Pode fazer commits a cada mudança, mantendo assim o rastreamento das versões, como já aprendemos ser uma boa prática. E também pode criar branches, issues e merge requests, e tudo o mais que já aprendemos. Portanto, sob a ótica do código-fonte, está tudo bem encaminhado.
8.3.5 Em direção à automação completa do processo - do GitLab para o Heroku (de novo)
Agora vamos preparar o terreno para fazer um pipeline de CI/CD que consegue levar nossos modelos até o MLServer, que está rodando no Heroku. Já fizemos isso agora há pouco, mas agora vamos focar no lado do desenvolvedor de modelos.
Primeiro, para criar um modelo que pode ser implantado no MLServer, precisamos do arquivo model-settings.json
. Na pasta iris-decision-tree
, crie um com o seguinte conteúdo:
Aqui também a nomenclatura é importante. Veja como o nome do modelo e sua URI estão todos padronizados e com o mesmo nome que o arquivo .ipynb
.
Para fazer o envio desse código ao MLServer, vamos utilizar o que já fizemos antes. Sabemos que o repositório GitLab para o MLServer já está configurado para, assim que receber um commit, fazer o envio ao Heroku. Então o que precisamos fazer é:
Clonar o repositório do MLServer
Copiar o arquivo do modelo e o
model-settings.json
para o local corretoFazer um commit
Esperar enquanto o GitLab executa o pipeline de deploy no Heroku
Então vamos lá. Os comandos são:
Agora basta aguardar. Assim que o pipeline terminar de rodar, podemos testar o modelo lá no Heroku, enviando um POST:
Tudo certo, mas ainda não está 100% automatizado, não é? Queremos que tudo isso seja feito sem que precisemos ficar executando comandos manualmente. Então vamos revisar, o que executamos manualmente?
O notebook, executamos manualmente
Fizemos a criação do arquivo
model-settings.json
manualmenteTambém fizemos a clonagem do projeto do
mlserver
a partir do GitLabEm seguida, criamos uma pasta e copiamos os arquivos, manualmente
Depois fizemos o commit, manualmente
Daqui pra frente, o GitLab fez o resto automaticamente
Será que conseguimos automatizar esses passos todos?
8.3.6 Passo 1: Automatizando a execução de notebooks
Existe um módulo Python que se chama papermill. Trata-se de uma ferramenta para executar notebooks Jupyter a partir de scripts Python ou a partir da linha de comando. Ele também possibilita a parametrização dos notebooks, o que dá bastante flexibilidade ao processo.
Usar o papermill é bastante trivial. Primeiro, modifique o arquivo requirements.txt
para incluir essa ferramenta:
Instale-a:
E para executar, basta rodar:
A opção -
no final diz ao papermill para exibir o resultado da execução no próprio terminal. Experimente apagar o arquivo .joblib
e veja que o mesmo é gerado novamente.
8.3.7 Passo 2: Automatizando a criação do model-settings.json
A princípio, devemos criar o arquivo model-settings.json
manualmente. Porém, a cada nova versão teríamos que alterá-lo para que a nova versão apareça. Não é um problema muito grave, mas é possível automatizar essa parte também. Modifique-o da seguinte maneira:
Agora é possível rodar um comando bash
que substitui a string $VERSAO por uma qualquer. Em um terminal bash
execute:
E veja como o conteúdo é substituído corretamente. Segundo desafio superado! E daqui a pouco veremos como usar o número de versão do GitLab aqui, automaticamente, aguarde.
Mas antes de seguir adiante, não esqueça de voltar atrás e desfazer a mudança feita pelo comando sed
!
8.3.8 Passos 3, 4 e 5: Automatizando a clonagem e configuração do projeto do MLServer
Esses passos já são praticamente automáticos, exceto pelas permissões, que foram obtidas diretamente a partir do ambiente da máquina local. Para que os scripts rodem em qualquer local, será preciso gerar um token de acesso ao GitLab, assim como fizemos na Seção 6.3. Os passos são os seguintes:
Acesse o GitLab
Clique no ícone para editar seu perfil, no canto superior direito da página:
Em seguida, clique em "Access Tokens":
Na página que aparecer, escolha o nome "access-mlserver" para seu token, ative a opção
write_repository
, e crie o token. Se quiser definir uma data para que o token expire, é possível. Caso deixe em branco, o token terá validade indeterminada.Assim que o processo for concluído, será exibido o token criado. Conforme as instruções, copie-o agora e salve-o em algum local seguro, pois o mesmo não poderá ser visto novamente assim que sair da página (será necessário revogá-lo e criar novamente).
Também é necessário definir o e-mail e nome de usuário, assim não é necessário ter mais nenhuma configuração extra.
Revisitando portanto os comandos vistos anteriormente, agora deve ser a seguinte sequência:
Com isso já temos tudo o que precisamos para automatizar 100% do processo.
8.3.9 Criando o pipeline CI/CD no GitLab
Nosso pipeline será um pouco customizável, e faremos isso com algumas variáveis de ambiente. Acesse, na página do GitLab do projeto iris-decision-tree
, o menu "Settings" -> "CI/CD" -> "Variables". Crie três variáveis, e lembre-se de deixá-las desprotegidas (a opção "Protect variable" deve estar desmarcada). Falaremos disso depois:
GIT_USER_EMAIL
: configure seu e-mail aquiGIT_USER_NAME
: configure um nome aqui para ficar registrado que os commits estão vindo do GitLab. Pode ser: "GitLab iris-decision-tree"MLSERVER_REPO
: coloque aqui a URL completa usada no comandogit clone
. No exemplo, éhttps://access-mlserver:xxxxxxxxxxxxxxxxxx@gitlab.com/daniel.lucredio/mlserver.git
(substituindo xxxxxx pelo token de acesso)
Agora crie o arquivo .gitlab-ci.yml
, na pasta iris-decision-tree
:
Trata-se de um pipeline simples, que tem um único job, que faz a sequência de passos que acabamos de preparar. Vamos entender o que ele faz:
A variável DOCKER_TLS_CERTDIR
serve apenas para executar os runners locais sem restrições, caso você não esteja utilizando os runners compartilhados do GitLab. Já falamos disso na Seção 7.2.
As variáveis NOME_MODELO
e EXTENSAO_MODELO
servem para facilitar a criação de outros modelos, no futuro. Não são de fato necessárias, mas possibilitam que o reuso do arquivo .gitlab-ci.yml
seja mais fácil. Elas serão utilizadas nos comandos mais abaixo.
O job send_model
utiliza a imagem python
padrão, necessária para executar as ferramentas Python.
Existe uma regra - rules: if: $CI_COMMIT_TAG
. Essa regra condiciona a execução do job da seguinte maneira. Esse job só vai rodar se existir uma tag criada manualmente pelo desenvolvedor. Tags servem para que os desenvolvedores marquem pontos fixos na evolução do projeto, e podem ser utilizadas, por exemplo, para marcar versões. É o que faremos aqui. Sempre que o desenvolvedor fizer um commit sem uma tag, nada acontece. Ele poderá fazer quantos commits quiser. Mas sempre que ele marcar um commit com uma tag, o job de envio será disparado.
Há outras formas de se disparar o envio do modelo. Poderíamos associar o job a um branch específico. Sempre que for feito um commit nesse branch, por exemplo, pode ser feito o envio. Ou então poderíamos usar o conceito de release, que é um tipo especial de tag que marca uma entrega para os usuários. Também poderíamos fazer um job para execução manual, como fizemos anteriormente na Seção 7.3. De qualquer forma, o processo é parecido com o que faremos aqui, e dentro das possibilidades do GitLab o leitor pode escolher a que mais se adequa ao seu cenário.
Ao condicionar a execução a uma tag, os pipelines CI/CD do GitLab tem acesso ao texto daquela tag, por meio da variável $CI_COMMIT_TAG. No caso, a tag irá marcar a versão, que será configurada no MLServer. Assim, ao marcar um commit com uma tag, por exemplo "v2.5.4", o acesso ao modelo no MLServer também usará essa mesma versão, unificando os processos.
E é devido ao uso de tags que deixamos as variáveis desprotegidas antes. Sempre que uma tag dispara uma execução de um job, existe o risco de algum código malicioso ser inserido em um pipeline. Esse código poderia, por exemplo, acessar segredos e tokens salvos nas variáveis, e enviar para algum servidor remoto. Por isso, o GitLab dá a opção de esconder as variáveis dos pipelines associados a algumas tags. E podemos também definir que algumas tags são protegidas, ou seja, apenas algumas pessoas podem utilizá-las. Faremos isso daqui a pouco, quando voltaremos a proteger nossas variáveis. Mas por enquanto, vamos concluir o processo.
O restante do código desse job não precisa de explicação. Ele faz exatamente o que fizemos manualmente há pouco, utilizando as variáveis para facilitar a customização. Leia-o atentamente e perceba o motivo pelo qual estivemos dizendo para manter todos os nomes padronizados. É aqui que colhemos o resultado desse esforço.
Salve esse arquivo e vamos fazer o envio, utilizando nossa primeira tag para disparar o pipeline.
Depois desse comando, vá ao GitLab e veja que nenhum pipeline iniciou. Isso porque nosso pipeline está condicionado a uma tag. Vamos fazer isso agora:
O primeiro comando cria a tag associada ao último commit e o segundo faz o envio ao GitLab, o que irá disparar o pipeline.
O processo todo irá demmorar um pouco. Enquanto espera, pense sobre o que está acontecendo:
O notebook foi enviado ao GitLab, mas sem o arquivo do modelo, lembra que colocamos no
.gitignore
?O GitLab vai subir um contêiner Docker, com base na imagem Python que configuramos
O GitLab vai instalar as dependências todas, incluindo o papermill
O GitLab vai rodar o notebook usando o papermill. Isso irá gerar o arquivo
.joblib
O GitLab vai clonar o repositório do MLServer, onde estão todos os modelos
O GitLab vai modificar o arquivo
model-settings.json
para marcá-lo com a versão especificada na tag (neste exemplo: "v1.0.0)O GitLab vai criar a estrutura para esse novo modelo, criando uma pasta para ele e copiando os dois arquivos
O GitLab vai enviar as mudanças para o repositório do MLServer, em um commit
Já no repositório do MLServer, o GitLab vai iniciar outro pipeline, associado ao commit
Nesse pipeline, o GitLab vai subir um contêiner Docker que roda Docker (Docker-in-Docker)
O GitLab vai instalar as ferramentas do Heroku
O GitLab vai construir uma imagem para o MLServer, já com o novo modelo dentro
O GitLab vai se autenticar no Heroku e vai enviar a imagem para o Docker registry do Heroku
O GitLab vai pedir ao Heroku para fazer o release da nova imagem
Ufa, bastante coisa, não? Se tudo funcionar, faça um novo POST e veja o novo modelo sendo hospedado no Heroku:
8.3.10 Voltando a proteger as variáveis
Agora vamos voltar e proteger nossas variáveis, que deixamos desprotegidas. Acesse o menu "Settings" -> "CI/CD" -> "Variables", e marque todas como "Protected".
Agora acesse "Settings" -> "Repository" -> "Protected tags".
No campo "Tag", especifique "v*".
No campo "Allowed to create", especifique "Maintainers".
Pronto, agora somente os desenvolvedores marcados como "maintainers" poderão criar tags que começam com "v". Outras tags poderão ser criadas, mas os jobs não terão acesso às variáveis, e portanto falharão.
Faça o teste, gerando uma tag que não começa com "v":
O pipeline irá rodar, mas ele irá falhar, pois essa tag não é protegida e portanto as variáveis necessárias para a execução dos jobs não estarão disponíveis.
Agora teste com uma tag protegida:
Agora tudo irá funcionar normalmente.
Last updated