Neste post vou falar um pouco sobre como criar imagens docker otimizadas para nossas aplicações através do multi-stage build.

Em muitos projetos notamos a presença de arquivos como Dockerfile.build, Dockerfile.tests e etc., arquivos estes que, representam etapas em processos de build e que são difíceis de criar e manter pelo time.

A partir da versão 17.05 do Docker, lançada a 1 ano atrás, o time de desenvolvimento disponibilizou suporte a multi-stage build, basicamente, são extensões dos comandos FROM e COPY que nos permite criar um Dockerfile com múltiplas fases de build, ou steps.

Para este post irei utilizar como exemplo uma aplicação ASP.NET Core 2, onde o processo de build da imagem irá conter múltiplas fases, como restore, testes, publicação e etc. Nossa imagem final irá conter apenas o ambiente mínimo necessário para rodar a aplicação e o pacote do projeto, sem dependências não produtivas, o que tornará a imagem final mais leve.

Você pode baixar todos os arquivos do projeto no github e pular para a parte do Dockerfile, mas se desejar, vamos criar juntos o projeto web e os testes.

Projeto Web

Iremos utilizar uma aplicação web convencional, criando através do cli.
Estou utilizando Ubuntu 16 para criar este post, mas você pode utilizar o SO de sua preferência.

Estou assumindo que você já possua o dotnet sdk e o docker instalados 😉

No seu terminal, crie uma aplicação web e certifique-se de que ela esteja abrindo no navegador.

mkdir aspnetproject && cd aspnetproject
mkdir web
dotnet new sln
dotnet new web -o web
dotnet sln add web/web.csproj && dotnet restore
cd web && dotnet run

Hosting environment: Production
 Content root path: /home/lazaro/aspnetproject/web
 Now listening on: http://localhost:5000
 Application started. Press Ctrl+C to shut down.

Com isso já temos nossa aplicação web disponível em http://localhost:5000/.

Projeto de Testes

Iremos criar um projeto de teste para que possamos usar como exemplo.

cd .. && mkdir tests && dotnet new mstest -o tests
dotnet sln add tests/tests.csproj
dotnet test tests/tests.csproj

Build started, please wait...
Build completed.
 
Test run for /home/lazaro/aspnetproject/tests/bin/Debug/netcoreapp2.0/tests.dll(.NETCoreApp,Version=v2.0)
Microsoft (R) Test Execution Command Line Tool Version 15.3.0-preview-20170628-02
Copyright (c) Microsoft Corporation. All rights reserved.
 
Starting test execution, please wait...
 
Total tests: 1. Passed: 1. Failed: 0. Skipped: 0.
Test Run Successful.
Test execution time: 1.3408 Seconds

Agora que temos nossa aplicação e nossos testes, vamos colocar tudo isso dentro de uma imagem.

Dockerfile e as mudanças no FROM e no COPY

Antes de criamos nosso Dockerfile vamos entender quais melhorias ocorreram no FROM e no COPY.

Anteriormente, tinhamos apenas um comando FROM que era carregado inicialmente e estabelecia qual imagem seria utilizada, afetando todos os comandos subsequentes. Com multi-stages podemos utilizar quantos comandos FROM desejarmos.

Cada FROM é um novo estágio que substitui o anterior, é como uma nova imagem, totalmente independente e isolada. Desta forma, se no estágio inicial estivermos utilizando uma imagem com .NET Core e no último estágio estamos utilizando uma imagem sem suporte a .NET Core a imagem final gerada ficará sem este suporte.

Outra mudança no FROM ocorreu em sua assinatura, agora existe a possibilidade de nomear os estágios através da instrução as.

FROM microsoft/aspnetcore-build:1.1 as build
...
...

FROM microsoft/dotnet:1.1-runtime as release
...
...
...

Neste primeiro momento podemos notar que cada etapa do build pode conter a imagem mais eficiente possível, seja uma etapa para executar um teste ou rodar a aplicação final.

Porém, nada disso faz muito sentido sem a possibilidade de utilizarmos informações das etapas anteriores, por isso, o comando COPY possui o argumento --from=<nome do estágio>, permitindo assim a copia de arquivos existentes em etapas anteriores

Exemplo:

FROM microsoft/aspnetcore-build:1.1 as build
WORKDIR /publish
COPY .bowerrc bower.json ./
RUN bower install
COPY my-app.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish --output ./out

FROM microsoft/dotnet:1.1-runtime as release
WORKDIR /dotnetapp
COPY --from=build /publish/out .
ENV ASPNETCORE_URLS "http://0.0.0.0:5000/"
ENTRYPOINT ["dotnet", "my-app.dll"]

Criando nosso Dockerfile

Nossa aplicação possui uma pasta para o projeto web e outra para o projeto de testes, precisamos garantir que somente se os testes estiverem passando a imagem será gerada.

Com esse cenário em mente, se executarmos o teste dentro do build e ele retornar falha o processo é interrompido automaticamente.

# Restaura e copia os arquivos do projeto
FROM microsoft/aspnetcore-build:2.0 AS base-env
COPY . .
RUN dotnet restore

# Roda os testes de unidade
FROM microsoft/aspnetcore-build:2.0 AS test-env
WORKDIR /tests
COPY --from=base-env ./tests .
RUN dotnet test tests.csproj

# Publica o projeto web
FROM microsoft/aspnetcore-build:2.0 AS build-env
WORKDIR /app
COPY --from=base-env ./web .
RUN dotnet publish web.csproj -c Release -o out

# Copia a pasta do projeto web publicado e roda a aplicação web
FROM microsoft/aspnetcore:2.0
WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["dotnet", "web.dll"]

Nas primeiras 3 etapas estamos utilizando a imagem oficial para o processo de build de aplicações ASP.NET Core 2.

FROM microsoft/aspnetcore-build:2.0

Esta imagem já possui alguns recursos como:

  • .NET Core SDK
  • NuGet com cache para pacotes comumente utilizados em projetos asp.net core
  • Node.js
  • Bower
  • Gulp

Na primeira etapa eu copio os arquivos do projeto e faço o restore.

COPY . .
RUN dotnet restore

Na segunda etapa, o qual chamei de test-env, estou setando uma pasta local como pasta padrão /tests, copiando apenas a pasta com o projeto de testes a partir do base-env e rodando os testes.

WORKDIR /tests
COPY --from=base-env ./tests .
RUN dotnet test tests.csproj

Note que, nesse momento o contexto da imagem anterior já não é utilizado, logo, a pasta tests precisa ser copiada para a minha nova etapa através do comando COPY com o argumento --from=base.

Crio então uma nova etapa para a realização do publish do projeto web.

FROM microsoft/aspnetcore-build:2.0 AS build-env
WORKDIR /app
COPY --from=base-env ./web .
RUN dotnet publish web.csproj -c Release -o out

Na etapa final estou utilizando a imagem microsoft/aspnetcore:2.0, o qual é mais otimizada para executar a aplicação, não possuindo as dependências que são geralmente utilizadas para os processos de build e testes.

FROM microsoft/aspnetcore:2.0

Novamente, posso copiar a pasta com o projeto web que foi gerada em etapas anteriores e então definir o entrypoint para rodar a aplicação, mesmo que ela tenha utilizado uma imagem completamente diferente.

WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["dotnet", "web.dll"]

Gere a imagem e em seguida rode em um novo container liberando a porta 8080.

docker build -t aspnetapp . docker run -p 8080:80 –name myapp aspnetapp

Tamanho final da imagem

Para efeito de comparação, caso utlizasse apenas um passo, com a imagem com todas as dependências, nossa imagem final teria em torno de 2GB, já com o uso de multi-stage podemos utilizar a imagem final de runtime, o qual possui 325MB + 3MB da nossa aplicação.

Espero que tenham gostado e que aproveitem ao máximo o multi-stage para criar imagens finais mais eficientes, seja em .NET ou quaisquer outras plataformas.

(Crosspost de http://lazarofl.github.io/2018/04/17/Docker-MultiStage/)

Lazaro Fernandes Lima Suleiman

Desenvolvedor