Otimizando suas imagens Docker com Multi Stage Build
Tenho a honra de fazer o primeiro post do blog do Test After Deploy e para tal, irei contar o primeiro desafio que tive na Nuveo: Otimizar as nossas imagens Docker.
Mas antes de começarmos, o que é Docker?
De tantas referências que eu li a melhor descrição sobre Docker foi dada pelo meu amigo e grande mentor Gomex no seu livro Docker para Desenvolvedores:
O Docker é uma plataforma aberta, criada com o objetivo de facilitar o desenvolvimento, a implantação e a execução de aplicações em ambientes isolados.
Desafio
Quando cheguei ao time da Nuveo, me deparei com as nossas imagens “core” com o seguinte tamanho:
Imagens muito pesadas, que estavam trazendo problemas na hora da realização do build e também para os membros do time que tem pouco espaço em disco.
O processo de otimização das imagens foi iniciado pelos senhores Cássio Botaro e Cesar Gimenes, porém devido a outras prioridades e falta de tempo da equipe não foi possível terminá-lo.
Scratch
Como missão dada, é missão cumprida, corri atrás de terminar este processo. A primeira sugestão que recebi, seria a utilização da imagem Scratch.
Para quem não sabe, Scratch é uma imagem especial do Docker que vem completamente vazia.
Realizando testes com Scratch
Para iniciarmos esta tarefa iremos utilizar uma funcionalidade empregada na versão 17.05 do Docker, o Multi Stage Build que permite que o mesmo build seja utilizado em diversas etapas da criação da imagem Docker, permitindo Dockerfiles mais limpos e de fácil manutenção.
Neste artigo iremos usar uma aplicação de exemplo da Nuveo que é o Auth, então abaixo segue o Dockerfile sem o processo de Multi Stage Build:
Mesmo com poucas linhas e bem fácil de manter esse Dockerfile baseado numa imagem customizada pela Nuveo (inspirada na imagem oficial do Go), buildava uma imagem com 673MB, ou seja, bem pesada.
Realizado o processo de Multi Stage Build com uma imagem Scratch, atualizando nosso Dockerfile:
Dissecando o Dockerfile acima:
- A primeira etapa do processo está compilando nossa aplicação para um executável binário e percebam que foi inserida na primeira linha o complemento
as builder
, builder nada mais é que o nome deste primeiro processo, caso você não coloque nada, o Docker entende que ele tem o nome de0
. - Na segunda etapa, utilizando uma imagem Scratch, estamos definindo o diretório da nossa aplicação, através da instrução
WORKDIR
e após isto vamos copiar nosso executável para o segundo processo.
Isto é feito graças ao comando COPY --from=builder
, que pega da primeira etapa e insere na imagem a ser montada na segunda etapa.
Feito o processo de build, a imagem foi criada com sucesso e ela ficou muito mais leve que a imagem anterior, como mostra o print abaixo comparando:
Porém como nem tudo na vida são flores, temos dois problemas utilizando imagens Scratch:
- Nosso executável possui algumas dependências. Então, para resolver devemos compilar nossa aplicação com a variável
CGO_ENABLED=0
, conforme a linha 8 do Dockerfile apresentado. - Principal problema: o Docker não consegue ajustar as variáveis de ambiente dentro do contêiner, caso o mesmo seja do tipo Scratch e como todas as nossas configurações estão em variáveis de ambiente, se torna inviável utilizar imagens Scratch para resolver este problema.
Por isso vamos para a nossa segunda opção, usar a imagem Alpine.
Alpine
Alpine é uma distribuição Linux baseada em musl libc e BusyBox que combina versões minúsculas de vários utilitários comuns no UNIX em um pequeno executável, tendo como características principais: leveza, simplicidade e segurança.
Realizando testes com alpine
Com as lições aprendidas no processo com as imagens baseadas em Scratch, realizei os testes com o Alpine.
Nosso Dockerfile teve algumas modificações:
Ao invés de uma imagem Scratch, estamos utilizando uma imagem Alpine na versão 3.6, conforme estamos vendo na linha 10 e também realizando a instalação do pacote ca-certificates, conforme a linha 11 do Dockerfile acima.
Definimos o diretório da aplicação, copiamos nosso executável do builder e executamos nossa aplicação.
Percebemos que a imagem está um pouco mais pesada que a imagem baseada em Scratch, porém ainda muito leve em comparação a imagem sem o processo de Multi Stage Build e por sua vez sem os problemas mencionados quando realizamos o build anterior.
Resultado
Após os testes realizados, aplicamos este mesmo processo nas nossas outras imagens e com isso tivemos um ganho incrível em tamanho, conforme imagem abaixo:
Os ganhos foram incríveis, o nosso build ficou muito mais rápido e o nosso processo de integração e entrega contínuas ganhou mais performance.
Dicas
Duas dicas finais, quando você realiza o build ou baixa as imagens do Docker Hub, o Docker cria imagens intermediárias, as famosas imagens com a tag <none>
, por isso após este processo é interessante que você execute o comando docker image prune -f
para limpar estas imagens.
E uma dica final, se você desenvolve aplicações Go e utiliza Docker, recomendo fortemente estudar e aplicar o Multi Stage Build para otimizar as imagens de suas aplicações.
Futuro
Após conseguirmos realizar a otimização das imagens das nossas aplicações em Go, não paramos um minuto de ver formas para diminuir ainda mais o tamanho das mesmas. Além disso, estamos fazendo os testes para realizar a otimização usando Multi Stage Build também nas dezenas de imagens Python da Nuveo.
Então em breve teremos um novo artigo sobre este case aqui no Medium do TAD. \o/