Otimizando Docker
Introdução
editarDocker é uma plataforma de software que permite a criação, o desenvolvimento e a execução de aplicativos em contêineres. Suas principais vantagens são a portabilidade, o isolamento de ambiente e a consistência de execução. Tais vantagens são obtidas através da utilização de imagens, que são pacotes de software que contêm tudo o que é necessário para executar um aplicativo, incluindo o código, as bibliotecas, as dependências e as configurações.
Um contêiner não é uma máquina virtual, pois ele utiliza o kernel do sistema operacional do host. Isso significa que ele é mais leve e mais rápido para ser iniciado que uma máquina virtual, pois é um processo do host com isolamento de recursos.
Conceitos
editarUma imagem é um pacote de software que contém tudo o que é necessário para executar um aplicativo, incluindo o código, as bibliotecas, as dependências e as configurações. Um contêiner é uma instância de uma imagem em execução, ou seja, é um processo isolado do host que executa um aplicativo.
Para que possamos criar uma imagem, precisamos de uma receita que descreva como ela deve ser construída. Essa receita irá garantir que a imagem seja consistente e reprodutível, pois ela irá especificar cada passo dessa construção com as instalações dos pacotes necessários, a configuração dos arquivos de configuração e a execução dos comandos. Tal receita é chamada de Dockerfile
.
Criando Dockerfile
editar
Um Dockerfile
é um arquivo de texto que contém uma lista de instruções que são executadas na criação de uma imagem. Cada instrução é uma linha que começa com uma palavra-chave (como FROM
, COPY
, RUN
, CMD
, etc.) e é seguida pelos argumentos dessa instrução. A ordem das instruções é importante, pois elas são executadas sequencialmente.
Um conceito fundamento para a criação de bons Dockerfile
s é a utilização de camadas. Cada linha de instrução cria uma nova camada na imagem. Tal camada é armazenada como uma diferença em relação à camada anterior, similar ao funcionamento do git
. Isso permite, por exemplo, a extensão de uma imagem já existente.
Essa ideia de cada camada ser uma diferença em relação à camada anterior permite que o Docker utilize o cache para reutilizar camadas já existentes. Explicaremos isso com mais detalhes em um exemplo.
Exemplo
editarVamos criar um projeto em python que utiliza o framework FastAPI
e o servidor uvicorn
. É apenas uma página simples que exibe a mensagem Hello, world!.
Temos a seguinte estrutura de diretórios e arquivos:
.
├── app/
│ └── __init__.py
│ └── main.py
├── Dockerfile
└── requirements.txt
O arquivo requirements.txt
contém as dependências do projeto:
fastapi==0.111.0
uvicorn==0.29.0
O arquivo app/main.py
contém o código da aplicação:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def hello_world():
return "Hello, world!"
Um possível Dockerfile
para esse projeto seria o seguinte:
# Utilizando a imagem completa de Python na versão 3.10.14
FROM python:3.10.14
# Escolhendo o diretório base no contêiner (`cd` dentro do contêiner)
WORKDIR /code
# Copiando a pasta app e o arquivo de dependências para o contêiner
COPY app app
COPY requirements.txt .
# Instalando as dependências
RUN pip install -r requirements.txt
# Indicando que a porta 80 será a utilizada pela aplicação
EXPOSE 80
# Utilizando o `uvicorn` para executar a aplicação
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
Podemos construir a imagem a partir desse Dockerfile
com o comando docker build . --tag tutorial:1.0
. A imagem gerada possui um tamanho de aproximadamente 1.08GB. Considerando que há apenas 1 rota na aplicação, esse tamanho é muito grande.
Para que possamos otimizar o tamanho da imagem, vamos utilizar o conceito de camadas. Para instalar as dependências, utilizaremos a imagem completa como base, mas, para executar a aplicação, utilizaremos uma imagem com Alpine Linux. Para isso, também precisaremos de um ambiente virtual para instalar as dependências e, na segunda etapa, conseguir copiar essas dependências para a imagem final.
# ----------------------------------------------------------------
# Imagem base
# ----------------------------------------------------------------
# Utilizando a imagem completa de Python na versão 3.10.14
FROM python:3.10.14 AS builder
# Escolhendo o diretório base no contêiner (`cd` dentro do contêiner)
WORKDIR /code
# Criando um ambiente virtual
RUN python -m venv env
# Adicionando o ambiente virtual ao PATH
ENV PATH="/code/env/bin:${PATH}"
# Copiando somente o arquivo de dependências para o contêiner
COPY requirements.txt .
# Instalando as dependências
RUN pip install --no-cache-dir -r requirements.txt
# ----------------------------------------------------------------
# Imagem final
# ----------------------------------------------------------------
# Utilizando a imagem mais leve de Python na versão 3.10.14
FROM python:3.10.14-alpine
# Instalando dependência necessárias
RUN apk upgrade --no-cache && apk add --no-cache libgcc
# Escolhendo o diretório base no contêiner (`cd` dentro do contêiner)
WORKDIR /code
# Adicionando o ambiente virtual ao PATH
ENV PATH="/code/env/bin:${PATH}"
# Copiando o ambiente virtual da imagem base para a imagem final
COPY --from=builder /code/env env
# Copiando a pasta app
COPY app app
# Indicando que a porta 80 será a utilizada pela aplicação
EXPOSE 80
# Utilizando o `uvicorn` para executar a aplicação
ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
Construindo essa nova imagem com o comando docker build . --tag tutorial:2.0
` obtemos uma imagem com tamanho de aproximadamente 124MB.
É importante observar como o cache é utilizado para reutilizar camadas já existentes. Durante a utilização da imagem base, as operações de criar o ambiente virtual e copiar o arquivo de dependências não possuem uma ordem específica de execução. Porém, pensando nas camadas que o Docker utiliza, a ordem dessas operações é importante. Na ordem apresentada, se o arquivo de dependências for alterado, a camada de criação do ambiente virtual será reutilizada, pois ela não foi alterada. Por outro lado, se a copia do arquivo de dependências estivesse antes da criação do ambiente virtual, qualquer alteração nesse arquivo faria com que a camada de criação do ambiente virtual fosse recriada a cada build. Esse é um dos exemplos de como a ordem das instruções em um Dockerfile
pode afetar o desempenho da construção da imagem.
Utilizando compose.yml
editar
Para evitar a repetição de comandos e facilitar a execução de múltiplos serviços, podemos utilizar o docker compose.
O docker compose
é uma ferramenta que permite a definição e execução de aplicativos multi-contêineres, além de fazer o build automático de Dockerfile
existentes no projeto.
O compose.yml
é um arquivo de configuração que descreve os serviços que serão executados. Cada serviço é descrito por um nome e uma lista de configurações, como a imagem base, o nome do contêiner, as portas que serão expostas, as redes que serão utilizadas, etc.
Exemplo
editarContinuando com o exemplo anterior, vamos criar um arquivo compose.yml
para facilitar a execução da aplicação.
Nosso projeto agora possui a seguinte estrutura de diretórios e arquivos:
.
├── app/
│ └── __init__.py
│ └── main.py
├── compose.yml
├── Dockerfile
└── requirements.txt
O arquivo compose.yml
contém a descrição dos serviços que serão executados, no caso, apenas o serviço app
:
services:
app:
build: .
container_name: tutorial
ports:
- 8080:80
command: ["--reload"]
develop:
watch:
- action: sync
path: app
target: /code/app
Nesse caso, estamos usando uma configuração adicional develop
para sincronizar o diretório app
do host com o diretório /code/app
do contêiner. Isso permite que as alterações feitas no código sejam refletidas automaticamente no contêiner sem a necessidade de volumes. Para que isso também seja refletido pelo uvicorn
, precisamos adicionar o argumento --reload
ao comando de execução.
Para executar a aplicação, basta utilizar o comando docker compose up
, mas para funcionar o modo watch, é preciso executor o comando docker compose watch
.
Se essa aplicação precisasse de um banco de dados, por exemplo, poderíamos adicionar um novo serviço ao compose.yml
:
services:
app:
build: .
container_name: tutorial
ports:
- 8080:80
develop:
watch:
- action: sync
path: app
target: /code/app
db:
image: postgres:16
container_name: tutorial_db
ports:
- 5432:5432
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: db
Agora, ao executar o comando docker compose up
, tanto a aplicação quanto o banco de dados serão executados.
Conclusão
editarDocker é uma ferramenta poderosa para a criação, o desenvolvimento e a execução de aplicativos em contêineres. Através da utilização de imagens e Dockerfile
s, é possível garantir a consistência e a reprodutibilidade do ambiente de execução. Além disso, o docker compose
facilita a execução de múltiplos serviços e a sincronização de arquivos entre o host e o contêiner.
Busque utilizar o Docker em seus projetos para garantir a portabilidade, o isolamento de ambiente e a consistência de execução. Além de facilitar a execução de múltiplos serviços e a sincronização de arquivos com apenas o comando docker compose up
.