Pular para conteúdo

Dockerizando a nossa aplicação e introduzindo o PostgreSQL


Objetivos da aula:

  • Compreender os conceitos básicos do Docker
  • Entender como criar uma imagem Docker para a nossa aplicação FastAPI
  • Aprender a rodar a aplicação utilizando Docker
  • Introduzir o conceito de Docker Compose para gerenciamento de múltiplos contêineres
  • Aprender o que é um Dockerfile e sua estrutura
  • Entender os benefícios e motivos da mudança de SQLite para PostgreSQL
Caso prefira ver a aula em vídeo

Esse aula ainda não está disponível em formato de vídeo, somente em texto!

Aula Slides Código


Após a implementação do nosso gerenciador de tarefas na aula anterior, temos uma primeira versão estável da nossa aplicação. Nesta aula, além de aprendermos a "dockerizar" nossa aplicação FastAPI, também abordaremos a migração do banco de dados SQLite para o PostgreSQL.

O Docker e a nossa aplicação

Docker é uma plataforma aberta que permite automatizar o processo de implantação, escalonamento e operação de aplicações dentro de contêineres. Ele serve para "empacotar" uma aplicação e suas dependências em um contêiner virtual que pode ser executado em qualquer sistema operacional que suporte Docker. Isso facilita a implantação, o desenvolvimento e o compartilhamento de aplicações, além de proporcionar um ambiente isolado e consistente.

Criando nosso Dockerfile

Para criar um container Docker, escrevemos uma lista de passos de como construir o ambiente para execução da nossa aplicação em um arquivo chamado Dockerfile. Ele define o ambiente de execução, os comandos necessários para preparar o ambiente e o comando a ser executado quando um contêiner é iniciado a partir da imagem.

Uma das coisas interessantes sobre Docker é que existe um Hub de containers prontos onde a comunidade hospeda imagens "prontas", que podemos usar como ponto de partida. Por exemplo, a comunidade de python mantém um grupo de imagens com o ambiente python pronto para uso. Podemos partir dessa imagem com o python já instalado adicionar os passos para que nossa aplicação seja executada.

Aqui está um exemplo de Dockerfile para executar nossa aplicação:

FROM python:3.11-slim
ENV POETRY_VIRTUALENVS_CREATE=false

WORKDIR app/
COPY . .

RUN pip install poetry

RUN poetry config installer.max-workers 10
RUN poetry install --no-interaction --no-ansi

EXPOSE 8000
CMD poetry run uvicorn --host 0.0.0.0 fast_zero.app:app

Aqui está o que cada linha faz:

  1. FROM python:3.11-slim: define a imagem base para nosso contêiner. Estamos usando a versão slim da imagem do Python 3.11, que tem tudo que precisamos para rodar nossa aplicação.
  2. ENV POETRY_VIRTUALENVS_CREATE=false: define uma variável de ambiente que diz ao Poetry para não criar um ambiente virtual. (O container já é um ambiente isolado)
  3. RUN pip install poetry: instala o Poetry, nosso gerenciador de pacotes.
  4. WORKDIR app/: define o diretório em que executaremos os comandos a seguir.
  5. COPY . .: copia todos os arquivos do diretório atual para o contêiner.
  6. RUN poetry config installer.max-workers 10: configura o Poetry para usar até 10 workers ao instalar pacotes.
  7. RUN poetry install --no-interaction --no-ansi: instala as dependências do nosso projeto sem interação e sem cores no output.
  8. EXPOSE 8000: informa ao Docker que o contêiner escutará na porta 8000.
  9. CMD poetry run uvicorn --host 0.0.0.0 fast_zero.app:app: define o comando que será executado quando o contêiner for iniciado.

Vamos entender melhor esse último comando:

  • poetry run define o comando que será executado dentro do ambiente virtual criado pelo Poetry.
  • uvicorn é o servidor ASGI que usamos para rodar nossa aplicação.
  • --host define o host que o servidor escutará. Especificamente, "0.0.0.0" é um endereço IP que permite que o servidor aceite conexões de qualquer endereço de rede disponível, tornando-o acessível externamente.
  • fast_zero.app:app define o <módulo python>:<objeto> que o servidor executará.

Criando a imagem

Para criar uma imagem Docker a partir do Dockerfile, usamos o comando docker build. O comando a seguir cria uma imagem chamada "fast_zero":

$ Execução no terminal!
docker build -t "fast_zero" .

Este comando lê o Dockerfile no diretório atual (indicado pelo .) e cria uma imagem com a tag "fast_zero", (indicada pelo -t).

Então verificaremos se a imagem foi criada com sucesso usando o comando:

$ Execução no terminal!
docker images

Este comando lista todas as imagens Docker disponíveis no seu sistema.

Executando o container

Para executar o contêiner, usamos o comando docker run. Especificamos o nome do contêiner com a flag --name, indicamos a imagem que queremos executar e a tag que queremos usar <nome_da_imagem>:<tag>. A flag -p serve para mapear a porta do host para a porta do contêiner <porta_do_host>:<porta_do_contêiner>. Portanto, teremos o seguinte comando:

$ Execução no terminal!
docker run --name fastzeroapp -p 8000:8000 fast_zero:latest

Este comando iniciará nossa aplicação dentro de um contêiner Docker, que estará escutando na porta 8000. Para testar se tudo está funcionando corretamente, você pode acessar http://localhost:8000 em um navegador ou usar um comando como:

$ Execução no terminal!
curl http://localhost:8000
Caso você fique preso no terminal

Caso você tenha a aplicação travada no terminal e não consiga sair, você pode teclar Ctrl+C para parar a execução do container.

Gerenciando Containers docker

Quando você trabalha com Docker, é importante saber como gerenciar os contêineres. Aqui estão algumas operações básicas para gerenciá-los:

  1. Rodar um contêiner em background: se você deseja executar o contêiner em segundo plano para que não ocupe o terminal, pode usar a opção -d:

    $ Execução no terminal!
    docker run -d --name fastzeroapp -p 8000:8000 fast_zero:latest
    
  2. Parar um contêiner: quando você "para" um contêiner, está essencialmente interrompendo a execução do processo principal do contêiner. Isso significa que o contêiner não está mais ativo, mas ainda existe no sistema, com seus dados associados e configuração. Isso permite que você reinicie o contêiner posteriormente, se desejar.

    $ Execução no terminal!
    docker stop fastzeroapp
    
  3. Remover um contêiner: ao "remover" um contêiner, você está excluindo o contêiner do sistema. Isso significa que todos os dados associados ao contêiner são apagados. Uma vez que um contêiner é removido, você não pode reiniciá-lo; no entanto, você pode sempre criar um novo contêiner a partir da mesma imagem.

    $ Execução no terminal!
    docker rm fastzeroapp
    

Ambos os comandos (stop e rm) usam o nome do contêiner que definimos anteriormente com a flag --name. É uma boa prática manter a gestão dos seus contêineres, principalmente durante o desenvolvimento, para evitar um uso excessivo de recursos ou conflitos de nomes e portas.

Introduzindo o postgreSQL

O PostgreSQL é um Sistema de Gerenciamento de Banco de Dados Objeto-Relacional (ORDBMS) poderoso e de código aberto. Ele é amplamente utilizado em produção em muitos projetos devido à sua robustez, escalabilidade e conjunto de recursos extensos.

Mudar para um banco de dados como PostgreSQL tem vários benefícios:

  • Escalabilidade: SQLite não é ideal para aplicações em larga escala ou com grande volume de dados. PostgreSQL foi projetado para lidar com uma grande quantidade de dados e requisições.
  • Concorrência: diferentemente do SQLite, que tem limitações para gravações simultâneas, o PostgreSQL suporta múltiplas operações simultâneas.
  • Funcionalidades avançadas: PostgreSQL vem com várias extensões e funcionalidades que o SQLite pode não oferecer.

Além disso, SQLite tem algumas limitações que podem torná-lo inadequado para produção em alguns casos. Por exemplo, ele não suporta alta concorrência e pode ter problemas de performance com grandes volumes de dados.

Nota

Embora para o escopo da nossa aplicação e os objetivos de aprendizado o SQLite pudesse ser suficiente, é sempre bom nos prepararmos para cenários de produção real. A adoção de PostgreSQL nos dá uma prévia das práticas do mundo real e garante que nossa aplicação possa escalar sem grandes modificações de infraestrutura.

Como executar o postgres?

Embora o PostgreSQL seja poderoso, sua instalação direta em uma máquina real pode ser desafiadora e pode resultar em configurações diferentes entre os ambientes de desenvolvimento. Felizmente, podemos utilizar o Docker para resolver esse problema. No Docker Hub, estão disponíveis imagens pré-construídas do PostgreSQL, permitindo-nos executar o PostgreSQL com um único comando. Confira a imagem oficial do PostgreSQL.

Para executar um contêiner do PostgreSQL, use o seguinte comando:

$ Execução no terminal!
docker run -d \
    --name app_database \
    -e POSTGRES_USER=app_user \
    -e POSTGRES_DB=app_db \
    -e POSTGRES_PASSWORD=app_password \
    -p 5432:5432 \
    postgres

Explicando as Flags e Configurações

  • Flag -e:

Esta flag é usada para definir variáveis de ambiente no contêiner. No contexto do PostgreSQL, essas variáveis são essenciais. Elas configuram o nome de usuário, nome do banco de dados, e senha durante a primeira execução do contêiner. Sem elas, o PostgreSQL pode não iniciar da forma esperada. É uma forma prática de configurar o PostgreSQL sem interagir manualmente ou criar arquivos de configuração.

  • Porta 5432:

O PostgreSQL, por padrão, escuta por conexões na porta 5432. Mapeando esta porta do contêiner para a mesma porta no host (usando -p), fazemos com que o PostgreSQL seja acessível nesta porta na máquina anfitriã, permitindo que outras aplicações se conectem a ele.

Sobre as variáveis

Os valores acima (app_user, app_db, e app_password) são padrões genéricos para facilitar a inicialização do PostgreSQL em um ambiente de desenvolvimento. No entanto, é altamente recomendável que você altere esses valores, especialmente app_password, para garantir a segurança do seu banco de dados.

Volumes e Persistência de Dados

Para garantir a persistência dos dados entre execuções do contêiner, utilizamos volumes. Um volume mapeia um diretório do sistema host para um diretório no contêiner. Isso é crucial para bancos de dados, pois sem um volume, ao remover o contêiner, todos os dados armazenados dentro dele se perderiam.

No PostgreSQL, o diretório padrão para armazenamento de dados é /var/lib/postgresql/data. Mapeamos esse diretório para um volume (neste caso "pgdata") em nossa máquina host para garantir a persistência dos dados:

$ Execução no terminal!
docker run -d \
    --name app_database \
    -e POSTGRES_USER=app_user \
    -e POSTGRES_DB=app_db \
    -e POSTGRES_PASSWORD=app_password \
    -v pgdata:/var/lib/postgresql/data \
    -p 5432:5432 \
    postgres

O parâmetro do volume é passado ao contêiner usando o parâmetro -v Dessa forma, os dados do banco continuarão existindo, mesmo que o contêiner seja reiniciado ou removido.

Adicionando o suporte ao PostgreSQL na nossa aplicação

Para que o SQLAlchemy suporte o PostgreSQL, precisamos instalar uma dependência chamada psycopg. Este é o adaptador PostgreSQL para Python e é crucial para fazer a comunicação.

Para instalar essa dependência, utilize o seguinte comando:

$ Execução no terminal!
poetry add "psycopg[binary]"

Uma das vantagens do SQLAlchemy enquanto ORM é a flexibilidade. Com apenas algumas alterações mínimas, como a atualização da string de conexão, podemos facilmente transicionar para um banco de dados diferente. Assim, após ajustar o arquivo .env com a string de conexão do PostgreSQL, a aplicação deverá operar normalmente, mas desta vez utilizando o PostgreSQL.

Para ajustar a conexão com o PostgreSQL, modifique seu arquivo .env para incluir a seguinte string de conexão:

.env
DATABASE_URL="postgresql+psycopg://app_user:app_password@localhost:5432/app_db"

Caso tenha alterado as variáveis de ambiente do contêiner

Se você alterou app_user, app_password ou app_db ao inicializar o contêiner PostgreSQL, garanta que esses valores sejam refletidos na string de conexão acima. A palavra localhost indica que o banco de dados PostgreSQL está sendo executado na mesma máquina que sua aplicação. Se o banco de dados estiver em uma máquina diferente, substitua localhost pelo endereço IP correspondente e, se necessário, ajuste a porta 5432.

Executando as migrações

Migrações são como versões para seu banco de dados, permitindo que você atualize sua estrutura de forma ordenada e controlada. Sempre que mudamos de banco de dados, ou até mesmo quando alteramos sua estrutura, as migrações precisam ser executadas para garantir que a base de dados esteja em sincronia com nosso código.

No contexto de contêineres, rodar as migrações se torna ainda mais simples. Quando mudamos de banco de dados, como é o caso de termos saído de um SQLite (por exemplo) para um PostgreSQL, as migrações são essenciais. O motivo é simples: o novo banco de dados não terá a estrutura e os dados do antigo, a menos que migremos. As migrações irão garantir que o novo banco de dados tenha a mesma estrutura e relações que o anterior.

Antes de executar o próximo comando

Assegure-se de que ambos os contêineres, tanto da aplicação quanto do banco de dados, estejam ativos. O contêiner do banco de dados deve estar rodando para que a aplicação possa se conectar a ele.

Assegure-se de que o contêiner da aplicação esteja ativo. Estamos usando a flag --network=host para que o contêiner use a rede do host. Isso pode ser essencial para evitar problemas de conexão, já que não podemos prever como está configurada a rede do computador onde este comando será executado.

execução no terminal
docker run -d --network=host --name fastzeroapp -p 8000:8000 fast_zero:latest

Para aplicar migrações em um ambiente com contêineres, frequentemente temos comandos específicos associados ao serviço. Vejamos como executar migrações usando o Docker:

$ Execução no terminal!
docker exec -it fastzeroapp poetry run alembic upgrade head

O comando docker exec é usado para invocar um comando específico dentro de um contêiner em execução. A opção -it é uma combinação de -i (interativo) e -t (pseudo-TTY), que juntas garantem um terminal interativo, permitindo a comunicação direta com o contêiner.

Após executar as migrações, você pode verificar a criação das tabelas utilizando um sistema de gerenciamento de banco de dados. A seguir, apresentamos um exemplo com o Beekeeper Studio:

Tabelas do PostgreSQL no Beekeeper Studio

Lembre-se: Embora as tabelas estejam agora criadas e estruturadas, o banco de dados ainda não contém os dados anteriormente presentes no SQLite ou em qualquer outro banco que você estivesse utilizando antes.

Simplificando nosso fluxo com docker-compose

Docker Compose é uma ferramenta que permite definir e gerenciar aplicativos multi-contêiner com Docker. É como se você tivesse um maestro conduzindo uma orquestra: o maestro (ou Docker Compose) garante que todos os músicos (ou contêineres) toquem em harmonia. Definimos nossa aplicação e serviços relacionados, como o PostgreSQL, em um arquivo docker-compose.yml e os gerenciamos juntos através de comandos simplificados.

Ao adotar o Docker Compose, facilitamos o desenvolvimento e a execução da nossa aplicação com seus serviços dependentes utilizando um único comando.

Criação do docker-compose.yml

docker-compose.yaml
services:
  fastzero_database:
    image: postgres
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: app_user
      POSTGRES_DB: app_db
      POSTGRES_PASSWORD: app_password
    ports:
      - "5432:5432"

  fastzero_app:
    image: fastzero_app
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    depends_on:
      - fastzero_database
    environment:
      DATABASE_URL: postgresql+psycopg://app_user:app_password@fastzero_database:5432/app_db

volumes:
  pgdata:

Explicação linha a linha:

  1. version: '3': especifica a versão do formato do arquivo Compose. O número '3' é uma das versões mais recentes e amplamente usadas.

  2. services:: define os serviços (contêineres) que serão gerenciados.

  3. fastzero_database:: define nosso serviço de banco de dados PostgreSQL.

  4. image: postgres: usa a imagem oficial do PostgreSQL.

  5. volumes:: mapeia volumes para persistência de dados.

  6. pgdata:/var/lib/postgresql/data: cria ou usa um volume chamado "pgdata" e o mapeia para o diretório /var/lib/postgresql/data no contêiner.

  7. environment:: define variáveis de ambiente para o serviço.

  8. fastzero_app:: define o serviço para nossa aplicação.

  9. image: fastzero_app: usa a imagem Docker da nossa aplicação.

  10. build: : instruções para construir a imagem se não estiver disponível, nosso Dockerfile.

  11. ports:: mapeia portas do contêiner para o host.

  12. "8000:8000": mapeia a porta 8000 do contêiner para a porta 8000 do host.

  13. depends_on:: especifica que fastzero_app depende de fastzero_database. Isto garante que o banco de dados seja iniciado antes da aplicação.

  14. DATABASE_URL: ...: é uma variável de ambiente que nossa aplicação usará para se conectar ao banco de dados. Aqui, ele se conecta ao serviço fastzero_database que definimos anteriormente.

  15. volumes: (nível superior): define volumes que podem ser usados pelos serviços.

  16. pgdata:: define um volume chamado "pgdata". Este volume é usado para persistir os dados do PostgreSQL entre as execuções do contêiner.

Sobre o docker-compose

Para usar o Docker Compose, você precisa tê-lo instalado em seu sistema. Ele não está incluído na instalação padrão do Docker, então lembre-se de instalá-lo separadamente!

O guia oficial de instalação pode ser encontrado aqui

Com este arquivo docker-compose.yml, você pode iniciar ambos os serviços (aplicação e banco de dados) simultaneamente usando:

docker-compose up

Para parar os serviços e manter os dados seguros nos volumes definidos, use:

docker-compose down

Esses comandos simplificam o fluxo de trabalho e garantem que os serviços iniciem corretamente e se comuniquem conforme o esperado.

Execução em modo desanexado

Você pode iniciar os serviços em segundo plano com a flag -d usando docker-compose up -d. Isso permite que os contêineres rodem em segundo plano, liberando o terminal para outras tarefas.

Rodando as migrações de forma automática

Automatizar as migrações do banco de dados é uma prática recomendada para garantir que sua aplicação esteja sempre sincronizada com o estado mais atual do seu esquema de banco de dados. É como preparar todos os ingredientes antes de começar a cozinhar: você garante que tudo o que é necessário está pronto para ser usado.

Para automatizar as migrações em nossos contêineres Docker, utilizamos um entrypoint. O entrypoint define o comando que será executado quando o contêiner iniciar. Em outras palavras, é o primeiro ponto de entrada de execução do contêiner.

Por que usar o Entrypoint?

No Docker, o entrypoint permite que você configure um ambiente de contêiner que será executado como um executável. É útil para preparar o ambiente, como realizar migrações de banco de dados, antes de iniciar a aplicação propriamente dita. Isso significa que qualquer comando definido no CMD do Dockerfile não será executado automaticamente se um entrypoint estiver definido. Em vez disso, precisamos incluir explicitamente esse comando no script de entrypoint.

Implementando o Entrypoint

Criamos um script chamado entrypoint.sh que irá preparar nosso ambiente antes de a aplicação iniciar:

entrypoin.sh
1
2
3
4
5
6
7
#!/bin/sh

# Executa as migrações do banco de dados
poetry run alembic upgrade head

# Inicia a aplicação
poetry run uvicorn --host 0.0.0.0 --port 8000 fast_zero.app:app

Explicação Detalhada do Script:

  • #!/bin/sh: indica ao sistema operacional que o script deve ser executado no shell Unix.
  • poetry run alembic upgrade head: roda as migrações do banco de dados até a última versão.
  • poetry run uvicorn --host 0.0.0.0 --port 8000 fast_zero.app:app: inicia a aplicação. Este é o comando que normalmente estaria no CMD do Dockerfile, mas agora está incluído no entrypoint para garantir que as migrações sejam executadas antes do servidor iniciar.

Como Funciona na Prática?

Quando o contêiner é iniciado, o Docker executa o script de entrypoint, que por sua vez executa as migrações e só então inicia a aplicação. Isso garante que o banco de dados esteja atualizado com as últimas migrações antes de qualquer interação com a aplicação.

Visualizando o Processo:

Você pode pensar no entrypoint.sh como o ato de aquecer e verificar todos os instrumentos antes de uma apresentação musical. Antes de a música começar, cada instrumento é afinado e testado. Da mesma forma, nosso script assegura que o banco de dados está em harmonia com a aplicação antes de ela começar a receber requisições.

Adicionando o Entrypoint ao Docker Compose:

Incluímos o entrypoint no nosso serviço no arquivo docker-compose.yml, garantindo que esteja apontando para o script correto:

docker-compose.yaml
  fastzero_app:
    image: fastzero_app
    entrypoint: ./entrypoint.sh
    build:
      context: .
      dockerfile: Dockerfile

Reconstruindo e Executando com Novas Configurações:

Para aplicar as alterações, reconstruímos e executamos os serviços com a opção --build:

$ Execução no terminal!
docker-compose up --build

Observando o Comportamento Esperado:

Quando o contêiner é iniciado, você deve ver as migrações sendo aplicadas, seguidas pela inicialização da aplicação:

$ Exemplo do resultado no terminal!
fastzero_app-1  | INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
fastzero_app-1  | INFO  [alembic.runtime.migration] Will assume transactional DDL.
fastzero_app-1  | INFO:     Started server process [10]
fastzero_app-1  | INFO:     Waiting for application startup.
fastzero_app-1  | INFO:     Application startup complete.
fastzero_app-1  | INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

Este processo garante que as migrações do banco de dados são realizadas automaticamente, mantendo a base de dados alinhada com a aplicação e pronta para ação assim que o servidor Uvicorn entra em cena.


Nota de revisão sobre variáveis de ambiente

Utilizar variáveis de ambiente definidas em um arquivo .env é uma prática recomendada para cenários de produção devido à segurança que oferece. No entanto, para manter a simplicidade e o foco nas funcionalidades do FastAPI neste curso, optamos por explicitar essas variáveis no docker-compose.yml. Isso é particularmente relevante, pois o Docker Compose é utilizado apenas para o ambiente de desenvolvimento; no deploy para fly.io, o qual é o nosso foco, o compose não será utilizado em produção.

Ainda assim, é valioso mencionar como essa configuração mais segura seria realizada, especialmente para aqueles que planejam utilizar o Docker Compose em produção.


Em ambientes de produção com Docker Compose, é uma boa prática gerenciar variáveis de ambiente sensíveis, como credenciais, por meio de um arquivo .env. Isso previne a exposição dessas informações diretamente no arquivo docker-compose.yml, contribuindo para a segurança do projeto.

As variáveis de ambiente podem ser definidas em nosso arquivo .env localizado na raiz do projeto:

.env
POSTGRES_USER=app_user
POSTGRES_DB=app_db
POSTGRES_PASSWORD=app_password
DATABASE_URL=postgresql://app_user:app_password@fastzero_database:5432/app_db

Para aplicar essas variáveis, referencie o arquivo .env no docker-compose.yml:

docker-compose.yaml
services:
  fastzero_database:
    image: postgres
    env_file:
      - .env
    # Restante da configuração...

  fastzero_app:
    build: .
    env_file:
      - .env
    # Restante da configuração...

Adotar essa abordagem evita a exposição das variáveis de ambiente no arquivo de configuração. Esta não foi a abordagem padrão no curso devido à complexidade adicional e à intenção de evitar confusões. Dependendo do ambiente estabelecido pela equipe de DevOps/SRE em um projeto real, essa gestão pode variar entre variáveis de ambiente, arquivos .env ou soluções mais avançadas como Vault.

Se optar por utilizar um arquivo .env com as configurações do PostgreSQL, configure o Pydantic para ignorar variáveis de ambiente que não são necessárias, adicionando extra='ignore' a chamada de SettingsConfigDic:

fast_zero/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file='.env', env_file_encoding='utf-8', extra='ignore'
    )

    DATABASE_URL: str
    SECRET_KEY: str
    ALGORITHM: str
    ACCESS_TOKEN_EXPIRE_MINUTES: int

Com essa configuração, o Pydantic irá ignorar quaisquer variáveis no .env que não sejam explicitamente declaradas na classe Settings, evitando assim conflitos e erros inesperados.

Agradecimentos especiais a @vcwild e @williangl pelas revisões valiosas nesta aula que me fizeram criar essa nota. ❤

Testes com Docker

Agora que temos o docker-compose configurado, realizar testes tornou-se uma tarefa simplificada. Podemos executar toda a suíte de testes com um único comando, sem a necessidade de ajustes adicionais ou configurações complexas. Isso é possível devido à maneira como o docker-compose gerencia os serviços e suas dependências.

Para executar os testes, utilizamos o comando:

$ Execução no terminal!
docker-compose run --entrypoint="poetry run task test" fastzero_app

Vamos entender melhor o que cada parte do comando faz:

  • docker-compose run: este comando executa um serviço único definido no seu arquivo docker-compose.yml. Ao contrário do docker-compose up, que inicia todos os serviços, o run permite que você inicie um serviço específico.

  • --entrypoint: a flag --entrypoint substitui o ponto de entrada padrão do container. O ponto de entrada padrão é especificado no Dockerfile ou no docker-compose.yml e é normalmente o comando que inicia sua aplicação. Ao substituir o entrypoint, você pode executar comandos diferentes, como neste caso, onde queremos rodar nossos testes.

  • "poetry run task test": este é o comando que substituirá o entrypoint padrão. Ele diz ao Docker para iniciar o container e executar a nossa suíte de testes com o Poetry, uma ferramenta para gerenciamento de dependências e pacotes em Python.

  • fastzero_app: este é o nome do serviço que definimos no docker-compose.yml. É o serviço que contém a nossa aplicação FastAPI e onde nossos testes serão executados.

Ao utilizar esse comando, o Docker Compose cuidará de iniciar os serviços dos quais fastzero_app depende, neste caso, o serviço fastzero_database do PostgreSQL. Isso é importante porque nossos testes podem depender de um banco de dados ativo para funcionar corretamente. O Compose garante que a ordem de inicialização dos serviços seja respeitada e que o serviço do banco de dados esteja pronto antes de iniciar os testes.

Se executarmos o comando, vemos que ele inicia o banco de dados, inicia o container da aplicação e na sequência executa o comando que passamos no --entreypoint que é exatamente como executar os testes:

$ Execução no terminal!
docker-compose run --entrypoint="poetry run task test" fastzero_app

# Resulado esperado
[+] Building 0.0s (0/0)                                           docker:default
[+] Creating 2/2
  Network default                Created                      0.1s 
  Container fastzero_database-1  Created                      0.1s 
[+] Running 1/1
  Container fastzero_database-1  Started                      0.3s 
[+] Building 0.0s (0/0)                                           docker:default

================ test session starts ================
platform linux - Python 3.11.6, pytest-7.4.3, pluggy-1.3.0 - /app/.venv/bin/python
cachedir: .pytest_cache
rootdir: /app
configfile: pyproject.toml
plugins: cov-4.1.0, anyio-4.1.0, Faker-20.1.0
collected 27 items

tests/test_app.py::test_root_deve_retornar_ok_e_ola_mundo PASSED
...

É importante notar que, embora o docker-compose run inicie as dependências necessárias para a execução do serviço especificado, ele não finaliza essas dependências após a conclusão do comando. Isso significa que após a execução dos testes, o serviço do banco de dados continuará ativo. Você precisará finalizá-lo manualmente com docker-compose down para encerrar todos os serviços e limpar o ambiente:

$ Execução no terminal!
docker-compose down
[+] Running 2/2
  Container 09-fastzero_database-1  Removed    0.4s 
  Network 09_default                Removed

Assim tendo o ambiente limpo novamente.

Executando os testes no PostgreSQL

Embora nosso docker-compose esteja configurado para levantar o banco de dados PostgreSQL ao executar os testes, é importante ressaltar que o container do PostgreSQL não está sendo utilizado durante a execução dos testes. Isso acontece porque a fixture responsável por criar a sessão do banco de dados está com as instruções "hardcoded" para o SQLite, como no código abaixo:

tests/conftest.py
@pytest.fixture()
def session():
    engine = create_engine(
        'sqlite:///:memory:',
        connect_args={'check_same_thread': False},
        poolclass=StaticPool,
    )
    table_registry.metadata.create_all(engine)

    with Session(engine) as session:
        yield session

    table_registry.metadata.drop_all(engine)

Por conta disso, os testes são executados no SQLite, mesmo com a presença do PostgreSQL no ambiente do Docker.

No entanto, é importante que os testes sejam executados no mesmo ambiente que o que rodará em produção, para não encontrarmos problemas relacionados a incompatibilidade de operações no banco de dados. A alteração é relativamente simples, temos que tornar a nossa fixture o mais próximo possível do cliente da sessão de produção. Para fazer isso, precisamos alterar somente a chamada create_engine para carregar a varável de ambiente do banco de dados de testes. Desta forma:

tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session

from fast_zero.app import app
from fast_zero.database import get_session
from fast_zero.models import table_registry
from fast_zero.settings import Settings
from fast_zero.security import get_password_hash
from tests.factories import UserFactory


@pytest.fixture()
def session():
    engine = create_engine(Settings().DATABASE_URL)
    table_registry.metadata.create_all(engine)

    with Session(engine) as session:
        yield session
        session.rollback()

    table_registry.metadata.drop_all(engine)

Com essa modificação, agora estamos apontando para o banco de dados PostgreSQL, conforme definido nas configurações da nossa aplicação (Settings().DATABASE_URL). A transição do SQLite para o PostgreSQL é facilitada pela abstração fornecida pelo SQLAlchemy, que nos permite mudar de um banco para outro sem problemas. É importante notar que essa flexibilidade se deve ao fato de não termos utilizado recursos específicos do PostgreSQL que não são suportados pelo SQLite. Caso contrário, a mudança poderia não ser tão direta.

Agora, com a nova configuração, os testes utilizarão o PostgreSQL, proporcionando um ambiente de testes mais fiel ao ambiente de produção e, consequentemente, aumentando a confiabilidade dos testes executados:

$ Execução no terminal!
docker-compose run --entrypoint="poetry run task test" fastzero_app

# resultado esperado
docker-compose run --entrypoint="poetry run task test" fastzero_app
[+] Building 0.0s (0/0)                                          docker:default
[+] Creating 1/0
  Container 09-fastzero_database-1  Running                     0.0s 
[+] Building 0.0s (0/0)                                          docker:default

======================= test session starts =======================
platform linux - Python 3.11.6, pytest-7.4.3, pluggy-1.3.0 - /app/.venv/bin/python
cachedir: .pytest_cache
rootdir: /app
configfile: pyproject.toml
plugins: cov-4.1.0, anyio-4.1.0, Faker-20.1.0
collected 27 items

tests/test_app.py::test_root_deve_retornar_ok_e_ola_mundo PASSED

Dessa forma temos um ambiente mais coeso e podemos reproduzir nossas configurações de forma bastante simples em qualquer ambiente.

Commit

Após criar nosso arquivo Dockerfile e docker-compose.yaml, executar os testes e construir nosso ambiente, podemos fazer o commit das alterações no Git:

  1. Adicionando todos os arquivos modificados nessa aula com git add .
  2. Faça o commit das alterações com git commit -m "Dockerizando nossa aplicação e alterando os testes para serem executados no PostgreSQL"
  3. Envie as alterações para o repositório remoto com git push

Conclusão

Dockerizar nossa aplicação FastAPI, junto com o PostgreSQL, nos permite garantir consistência em diferentes ambientes. A combinação de Docker e Docker Compose simplifica o processo de desenvolvimento e implantação. Na próxima aula, aprenderemos como levar nossa aplicação para o próximo nível executando os testes de forma remota com a integração contínua do GitHub Actions.