Introdução

editar

Docker é 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

editar

Uma 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 Dockerfiles é 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

editar

Vamos 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

editar

Continuando 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

editar

Docker é 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 Dockerfiles, é 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.

Referências

editar
  1. Never install locally
  2. Docker overview
  3. Dockerfile
  4. Layers
  5. Use compose watch