Dockerfile - Melhores Práticas

Hoje iremos passar por uma série de dicas para melhorar nosso conhecimento na etapa de build da imagem. Já entendemos como funciona o Dockerfile, agora o ideal é que estas imagens sejam criadas utilizando as melhores práticas.
Provavelmente não irei cobrir todas as melhores práticas para a criação da nossa imagem, uma vez que a cada dia que passa novas técnicas são criadas e as atuais são melhoradas, mas quero que você saia daqui hoje sabendo mais do que ontem e menos que amanhã, então vamos lá!
Relembrando o que é o Dockerfile
O Docker constrói a imagem automaticamente lendo as instruções de um Dockerfile
, que é um documento de texto que contém todos os comandos necessários para a criação de uma determinada imagem.
Caso você não tenha acompanhado os outros posts sobre Docker, sugiro que veja a TAG: Docker no blog e principalmente o post Docker 101 pois iremos utilizar a máquina criada no post para criação das imagens.
Recomendações gerais
Quando criamos uma imagem, através do comando docker image build
, a imagem definida pelo Dockerfile
deve gerar containers que são tão efêmeros quanto possível, isso quer dizer que o container deve poder ser parado e/ou destruido a qualquer momento, e reconstruido ou substituido com o mínimo de configuração ou atualização.
Uma boa metodologia para conseguir chegar neste ponto é a do Twelve-factor App, ou Aplicação de Doze-fatores, e em sua seção de processos podemos verificar algumas das motivações para subir containers maneira stateless (não armazenam estado).
Entendendo o contexto de build
Quando executamos o comando docker image build
, o diretório no qual apontamos (muitas das vezes como .
para referir o diretório atual) é chamado de build context
. Por padrão o Docker espera que o Dockerfile
esteja localizado nesta pasta, mas também podemos especificar uma nova localização através da flag -f
. Independentemente de onde o Dockerfile
esteja, todo o conteúdo dos diretórios recursivamente e arquivos é enviado para o Docker daemon como build context
.
Por isto não devemos, por exemplo, criar umDockerfile
diretamente em nossa home~/Dockerfile
, uma vez que todo o conteúdo, inclusive o cache de navegadores web e todas aplicações~/.cache
será enviado para o Docker daemon, muitas das vezes falhando a build ou até mesmo fazendo com que ela demore muito tempo.
A partir de agora tilizaremos nossa máquina docker01 criada no post Docker-101. Estou assumindo que você já subiu a máquina e conectou na mesma via ssh
. Caso você não possua a máquina pode voltar no post do blog para cria-la ou fazer na sua máquina atual com o Docker instalado.
Aqui daremos dois exemplos, um com a criação de uma imagem com o Dockerfile no mesmo diretório do contexto, e outro em diretórios diferentes.
Primeiramente vamos criar um diretório para guardar nosso dockerfile e criar um arquivo com um conteúdo estático
$ mkdir -p /vagrant/dockerfiles/exemplo1
$ cd /vagrant/dockerfiles/exemplo1
$ echo "Dockerfile Melhores Práticas" > blog
Vamos também criar nosso Dockerfile
para manusear o arquivo e criar a imagem
$ vim Dockerfile
FROM busybox
COPY blog /
RUN cat /blog
$ docker image build -t exemplo:v1 .

Agora vamos criar novos diretórios e mover o arquivo blog
para um diretório diferente do dockerfile
para construir uma segunda imagem.
$ mkdir -p dockerfiles context
$ mv Dockerfile dockerfiles
$ mv blog context
$ docker image build --no-cache -t exemplo:v2 -f dockerfiles/Dockerfile context

As duas imagens tem o mesmo tamanho e o mesmo conteúdo, porém note que as imagens tem o ID
diferente porque criamos a imagem sem utilizar o cache --no-cache
, ou seja, criamos uma imagem totalmente nova.

Caso sejam incluidos arquivos que não são necesssários para a construção da imagem o build context
se tornará maior e consequentemente uma imagem maior. Isso pode aumentar o tempo de construção, envio e download da imagem e do container runtime
. Para ver o tamanho do build context basta verificar a mensagem exibida quando executar o build do seu Dockerfile
.
Sending build context to Docker daemon 318.6MB
Para fins de teste vamos copiar todo o conteúdo do diretório /var/log
para o context e construir a imagem.
$ sudo cp -r /var/log/ /vagrant/dockerfiles/exemplo1/context/
$ docker image build --no-cache -t exemplo:v3 -f dockerfiles/Dockerfile context

Veja que o context que anteriormente era de apenas 2.6KB desta vez foi de 43MB, o que resulta em um tempo de build maior porém sua imagem continua do mesmo tamanho das outras já que o arquivo foi enviado para o context e não foi utilizado.

Tratando de poucos MB o tempo de construção pode não ser muito expressivo, porém imagine em uma grande aplicação com diversos arquivos. Utilizando o comando time
fiz a medição no caso de 2.6KB que gerou a build em 0m0.910s
contra 0m1.368s
do arquivo de 43MB. esse tempo pode ser superior caso a imagem execute diversos comandos em diversas camadas.
Excluindo arquivos do build
Para excluir arquivos que não são relevantes a build, pdemos criar um arquivo .dockerignore
contendo os padrões de exclusão similares aos do .gitignore
possibilitando que ignoremos arquivos no build sem ter que modificar nosso repositório.
Para a referência do Docker Ignore veja a Documentação Oficial
Vamos criar agora um arquivo .dockerignore
para que o diretório log
não seja enviado para a build.
$ vim context/.dockerignore
# Comentario: Ignorando arquivos do diretorio log
log
$ docker image build --no-cache -t exemplo:v4 -f dockerfiles/Dockerfile context
Veja que o diretório log
foi ignorado, uma vez que o build context
ficou em 2.6kB (agora um pouco maior que a primeira por possuir o arquivo .dockerignore
) ao invés dos 43MB anteriores.

Melhores práticas parte 1
Na parte 1 das melhores práticas é preciso fazer uma menção ao Tibor Vass que fez estas explicações no blog do Docker. Uma vez que o conteúdo base é do mesmo e eu estarei apenas adaptando o post e fazendo minhas modificações. Caso queira ver o post do Tibor o link é este aqui.
Vamos criar um diretório para o exemplo a seguir para guardar nosso Dockerfile e fazer o download de uma aplicação exemplo em java que conta o numero de caracteres bem de um texto.
$ mkdir -p /vagrant/dockerfiles/parte1/app
$ cd /vagrant/dockerfiles/parte1
$ git clone [email protected]:caiodelgadonew/blog-java-app.git app
Dica #1: A ordem importa para o cache
A ordem dos passos de build é importante, se o cache de um primeiro passo é invalidado pela modificação de arquivos ou linhas do Dockerfile, os arquivos subsequentes do build quebrarão. Sempre faça a ordenação dos passos do que sofrerá menos mudanças para o que sofrerá mais mudança.

$ vim Dockerfile
FROM debian:9
RUN apt-get update
RUN apt-get install -y openjdk-8-jdk wget ssh vim
COPY app /app
ENTRYPOINT ["java", "-jar", "/app/target/app.jar"]
$ docker image build --no-cache -t parte1:v1 .
Dica #2: COPY mais específico para limitar a quebra de cache
Só copie o necessário. Se possível evite o COPY. Quando copiamos arquivos para nossa imagem, tenha certeza que você está sendo bem específico sob o que quer copiar, qualquer mudança no arquivo copiado quebrará o cache. Copiaremos então apenas a aplicação para a imagem, desta maneira as mudanças nos arquivos não afetarão o cache.

$ vim Dockerfile
FROM debian:9
RUN apt-get update
RUN apt-get install -y openjdk-8-jdk wget ssh vim
COPY app/target/app.jar /app/app.jar
COPY app/samples /samples
CMD ["java", "-jar", "/app/app.jar"]
$ docker image build --no-cache -t parte1:v2 .
Dica #3: Identifique as instruções que podem ser agrupadas
Cada instrução RUN
cria uma unidade de cache e uma nova camada de imagem, agrupar todos os comandos RUN
em uma única instrução pode melhorar o desempenho e diminuir a quantidade de camadas uma vez que eles se tornarão uma unidade única cacheavel.

$ vim Dockerfile
FROM debian:9
RUN apt-get update \
&& apt-get install -y \
openjdk-8-jdk wget \
ssh vim
COPY app/target/app.jar /app/app.jar
COPY app/samples /samples
CMD ["java", "-jar", "/app/app.jar"]
$ docker image build --no-cache -t parte1:v3 .
Dica #4: Remova as dependências desnecessárias
Remover as dependencias desnecessárias e não instalar pacotes de debug é uma boa prática, como por exemplo trocar o jdk
(Java Development Kit) pelo jre
(Java Runtime Environment) que é um pacote relativamente menor e contem apenas o necessário para execução. Você pode instalar as ferramentas de debug posteriormente caso necessite. O instalador de pacotes apt
possui uma flag --no-install-recommends
que garante que dependencias que não são necessárias não sejam instaladas. Caso precise, adicione elas explicitamente.

$ vim Dockerfile
FROM debian:9
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
openjdk-8-jre
COPY app/target/app.jar /app/app.jar
COPY app/samples /samples
CMD ["java", "-jar", "/app/app.jar"]
$ docker image build --no-cache -t parte1:v4 .
Com a Dica #4 podemos notar uma diminuição consideravel no tamanho de nossa imagem.

Dica #5: Remover o cache do gerenciador de pacotes
O gerenciador de pacotes mantem seu próprio cache, o apt
por exemplo guarda seu cache no diretório /var/lib/apt/lists
e /var/cache/apt/
. Uma das maneiras de lidar com este problema é remover o cache na mesma instrução que o pacote foi instalado. Remover este cache em outra instrução não irá diminuir o tamanho da imagem.

$ vim Dockerfile
FROM debian:9
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
openjdk-8-jre \
&& rm -rf /var/lib/apt/lists \
&& rm -rf /var/cache/apt
COPY app/target/app.jar /app/app.jar
COPY app/samples /samples
CMD ["java", "-jar", "/app/app.jar"]
$ docker image build --no-cache -t parte1:v5 .
Agora com a Dica #5 nossa imagem ficou relativamente menor.

Dica #6: Utilize imagens oficiais quando possível
Imagens oficiais podem ajudar muito e reduzir bastante o tempo preparando a imagem, isto porque os passos de instalação já vem prontos e normalmente com as melhores praticas aplicadas, isto também fará você ganhar tempo caso tenha multiplos projetos, eles compartilham as mesmas camadas e utilizam a mesma imagem base.

$ vim Dockerfile
FROM openjdk
COPY app/target/app.jar /app/app.jar
COPY app/samples /samples
CMD ["java", "-jar", "/app/app.jar"]
$ docker image build --no-cache -t parte1:v6 .
Dica #7: Utilize Tags mais específicas
Nunca utilize a tag latest. Ela pode receber alguma atualização e em um momento de update sua aplicação pode quebrar, dependendo de quanto tempo passou do seu ultimo build. Ao invés disso, utilize tags mais específicas.

$ vim Dockerfile
FROM openjdk:8
COPY app/target/app.jar /app/app.jar
COPY app/samples /samples
CMD ["java", "-jar", "/app/app.jar"]
$ docker image build --no-cache -t parte1:v7 .
Dica #8: Procure por flavors mínimos
Existem diversos flavors linux que fazem com que nossa imagem se torne cada vez menor, um bom exemplo são as imagens slim
e alpine
as quais são as menores encontradas. A imagem slim
é baseada no Debian, enquanto a alpine
é baseada em uma distribuição linux muito menor chamada Alpine. A diferença básica entre elas é que o debian utiliza a biblioteca GNU libc
enquanto o alpine utiliza musl lbc
, que apesar de muito menor, pode ter problemas de compatibilidade.
REPOSITORY TAG SIZE
openjdk 8 510MB
openjdk 8-jre 265MB
openjdk 8-jre-slim 184MB
openjdk 8-jre-alpine 84.9MB

$ vim Dockerfile
FROM openjdk:8-jre-alpine
COPY app/target/app.jar /app/app.jar
COPY app/samples /samples
CMD ["java", "-jar", "/app/app.jar"]
$ docker image build --no-cache -t parte1:v8 .
Agora temos uma diminuição enorme em nossa imagem pois estamos utilizando uma imagem base bem menor.

Melhores práticas parte 2
Agora na parte 2 iremos falar sobre multi-stage builds
, que é um recurso muito poderoso que apareceu a partir do docker 17.05. Multistage builds são uteis para quem quer otimizar Dockerfiles enquanto mantém eles fáceis de ler e manter.
Antes do Multi-stage build
O maior desafio das imagens é de fato manter as imagens pequenas, vimos nos exemplos anteriores que conseguimos, ao utilizar algumas das melhores práticas, diminuir bastante o tamanho da imagem. Utilizando imagens slim
ou alpine
resolvem boa parte dos nossos problemas mas quando precisamos resolver algo mais complexo podemos utilizar elas somadas ao Multistage build.
Multi-stage build
O multi-stage build faz com que possamos utilizar diversas instruções FROM
em um Dockerfile, e cada instrução pode utilizar uma imagem diferente, fazemos isto por exemplo para subir uma imagem, dentro desta imagem instalar os pacotes e coletar apenas os arquivos necessários diretamente para a imagem subsequente. Com isso temos uma imagem muito mais enxuta e otimizada.
Por exemplo vamos criar esta imagem em GO pelo processo normal:
$ git clone https://github.com/alexellis/href-counter.git /vagrant/dockerfiles/parte2
$ cd /vagrant/dockerfile/parte2
$ rm Docker*
$ vim Dockerfile
FROM golang:1.7.3
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
$ docker image build --no-cache -t parte2:v1 .
Esta imagem ficou com o tamanho de 692MB
, porém o que precisamos nela é apenas o diretório /go/src/github.com/alexellis/href-counter/app
, podemos então utilizar o multistage build para recolher estes arquivos, chamando a primeira imagem de builder
através do parâmetro AS <nome>
e depois invocar a imagem em um segundo estágio através do parâmetro --from=<nome>
.
$ vim Dockerfile
FROM golang:1.7.3 AS builder
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]
$ docker image build --no-cache -t parte2:v2 .

Agora tivemos uma "pequena" redução de 692MB
para 11.8MB
.
REPOSITORY TAG SIZE
parte2 v2 11.8MB
parte2 v1 692MB

Outra coisa interessante é que ao invés de utilizar uma imagem completa podemos puxar um arquivo de uma imagem já criada anteriormente através da flag --from=<image>:<tag>
, vamos fazer a cópia de um arquivo da imagem da parte1 para a imagem da parte 2, para isto criaremos um diretório chamado parte 3.
$ cd /vagrant/dockerfiles
$ cp -r parte2 parte3
$ cd parte3
$ vim Dockerfile
FROM alpine:latest
WORKDIR /root/
COPY --from=parte1:v8 /samples/1.txt .
CMD ["cat", "1.txt"]
$ docker image build --no-cache -t parte3:v1 .

Podemos agora executar nosso container para verificar se o arquivo 1.txt
é de fato o arquivo extraido da imagem parte1:v8
.

É sempre bom tentar diminuir as imagens de docker e seguir as melhores práticas, isso faz com que nosso tempo de deploy ou scale da aplicação seja menor, bem como a necessidade de um armazenamento maior e diversos outros fatores.
Este post faz parte de uma série de posts sobre Docker.
O código deste post encontra-se no repositório: https://github.com/caiodelgadonew/blog-dockerfile-melhores-praticas
Ficamos por aqui com esse post e nos vemos em uma próxima!