Ir para o 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 ou live!

Aula Slides Código Quiz


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.

Caso não tenha o docker instalado na sua máquina

A instalação do Docker varia entre sistemas operacionais. Por esse motivo, acredito que não cabe cobrir a instalação do docker nesse material.

A instalação no windows varia com a forma em que você administra o seu sistema. Ela pode se basear em WSL2 ou no Hyper-V.

Os passos para ambos os tipos de instalação podem ser encontrados na documentação oficial do docker: link.

A instalação no linux variará de acordo com a sua distribuição. As distribuições mais tradicionais (baseadas em Debian e RHEL podem ser encontradas na documentação oficial do docker: link.

Outras distribuições devem ter o pacote do docker disponível em seus repositórios. Como distro baseadas em Archlinux.

A instalação no MacOS, dependerá da arquitetura do seu computador. Se você usa Intel ou Silicon.

Os passos para ambos os tipos de instalação podem ser encontrados na documentação oficial do docker: link.

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.
FROM python:3.12-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.12-slim: define a imagem base para nosso contêiner. Estamos usando a versão slim da imagem do Python 3.12, 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.
FROM python:3.13-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.13-slim: define a imagem base para nosso contêiner. Estamos usando a versão slim da imagem do Python 3.13, 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 no 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" .
Você usa Mac com Silicon?

Pode haver alguma incompatibilidade em alguma biblioteca durante o build. Pois nem todos os pacotes estão disponíveis para Silicon no pypi. Arquitetura aarch64.

Caso encontre algum problema, durante o build você pode especificar a plataforma para amd64. Que é a arquitetura em que o curso foi escrito:

$ Execução no terminal!
docker build --platform linux/amd64 -t "fast_zero" .

Mais informações nessa issue. Obrigado @K-dash por notificar ❤

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 -it --name fastzeroapp -p 8000:8000 fast_zero:latest

Este comando iniciará nossa aplicação em 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.

Para que a instalação do psycopg esteja na imagem docker, precisamos fazer um novo build. Para que a nova versão do pyproject.toml seja copiada e os novos pacotes sejam instalados:

$ Execução no terminal!
docker rm fastzeroapp #(1)!
docker build -t "fast_zero" #(2)!
docker run -it --name fastzeroapp -p 8000:8000 fast_zero:latest #(3)!
  1. Remove a versão antiga
  2. Refaz o build
  3. Executa novamente

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 compose.yaml 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 compose.yaml

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: .
    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. services:: define os serviços (contêineres) que serão gerenciados.

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

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

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

  5. 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.

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

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

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

  9. build:: instruções para construir a imagem se não estiver disponível, procura pelo Dockerfile em ..

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

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

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

  13. 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.

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

  15. 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 compose.yaml, 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:

entrypoint.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 compose.yaml, garantindo que esteja apontando para o script correto:

compose.yaml
  fastzero_app:
    image: fastzero_app
    entrypoint: ./entrypoint.sh
    build: .

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 compose.yaml. 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 compose.yaml, 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+psycopg://app_user:app_password@fastzero_database:5432/app_db

Para aplicar essas variáveis, referencie o arquivo .env no compose.yaml:

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. ❤

Boas práticas de inicialização do banco de dados

Como esse é um caso pensado em estudo, possivelmente não haverá problemas relacionados à inicialização. Em um ambiente de produção, porém, não existe a garantia de que o postgres está pronto para uso no momento em que o entrypoint for executado. Seria necessário que, antes da execução da migração, o container do banco de dados tivesse a inicialização finalizada.

Isso é feito usando o campo healthcheck do compose.yaml:

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"
  healthcheck:
      test: ["CMD-SHELL", "pg_isready"]
      interval: 5s
      timeout: 5s
      retries: 10

Dessa forma, ele irá executar o comando pg_isready a cada 5 segundos por 10 vezes. pg_isready é um utilitário do PostgreSQL que verifica se ele já está operando e pronto para receber conexões. Desta forma, a inicialização do container só termina quando o postgres estiver ouvindo conexões.

Dica do @ayharano nessa issue.

Testes e Docker

Uma das partes importantes dos testes é tentar chegar o mais próximo possível do ambiente de desenvolvimento. Contudo, nessa aula, introduzimos uma dependência que vai além do python, o postgres.

Isso pode tornar o nosso código mais complicado de testar, por existir um DoC. Um "componente dependente" para ser executado. Nesse caso, porém, é interno ao sqlalchemy. Para usar o psycopg, temos uma dependência externa ao python, o banco de dados precisa estar sendo executado, caso contrário os testes falharão.

Executando testes com o banco de dados em um container

Os testes contemplam um ciclo de feedback positivo, eles têm que ser executados de forma rápida e eficiente. Adicionar o container do Postgres a nossa aplicação, torna o processo de testes um pouco mais complexo. Pois existe uma dependência ao nível de sistema para os testes serem executados.

Começaremos com o contraexemplo. Vamos alterar o comportamento da fixture do banco de dados para usar o postgres:

tests/conftest.py
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 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.

Partindo desse exemplo, para os testes serem executados, o banco de dados precisaria estar de pé. O que nos cobraria um container em execução para os testes poderem rodar.

Por exemplo:

$ Execução no terminal!
task test

Isso originaria esse erro:

============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.2.1, pluggy-1.5.0 -- /home/dunossauro/git/fastapi-do-zero/codigo_das_aulas/10/.venv/bin/python
cachedir: .pytest_cache
rootdir: /home/dunossauro/git/fastapi-do-zero/codigo_das_aulas/10
configfile: pyproject.toml
plugins: anyio-4.4.0, cov-5.0.0, Faker-25.4.0
collecting ... collected 28 items

tests/test_app.py::test_root_deve_retornar_ok_e_ola_mundo ERROR

==================================== ERRORS ====================================
___________ ERROR at setup of test_root_deve_retornar_ok_e_ola_mundo ___________

self = <sqlalchemy.engine.base.Connection object at 0x7ad981fb7380>
engine = Engine(postgresql+psycopg://app_user:***@localhost:5432/app_db)
connection = None, _has_events = None, _allow_revalidate = True
_allow_autobegin = True

# ...
        if not rv:
            assert last_ex
>           raise last_ex.with_traceback(None)
E           psycopg.OperationalError: connection failed: connection to server at "127.0.0.1", port 5432 failed: Connection refused
E               Is the server running on that host and accepting TCP/IP connections?

.venv/lib/python3.12/site-packages/psycopg/connection.py:748: OperationalError

Obtivemos o erro psycopg.OperationalError: connection failed: connection to server at "127.0.0.1", port 5432 failed: Connection refused. Ele diz que ouve uma falha na comunicação com o nosso host na porta 5432. O endereço que colocamos no .env. Para que ele fique acessível, temos que iniciar o container antes de executar os testes.

Para isso:

$ Execução no terminal!
docker-compose up -d fastzero_database #(1)!
  1. Inicia somente o container no banco de dados via docker compose

E em seguida executar os testes:

$ Execução no terminal!
task test

Agora, sucesso. O resultado é exatamente o que esperávamos:

All checks passed!
========== test session starts ==========
platform linux -- Python 3.12.3, pytest-8.2.1, pluggy-1.5.0 -- /.../10/.venv/bin/python
cachedir: .pytest_cache
rootdir: /home/dunossauro/git/fastapi-do-zero/codigo_das_aulas/10
configfile: pyproject.toml
plugins: anyio-4.4.0, cov-5.0.0, Faker-25.4.0
collected 28 items

tests/test_app.py::test_root_deve_retornar_ok_e_ola_mundo PASSED
tests/test_auth.py::test_get_token PASSED
tests/test_auth.py::test_token_expired_after_time PASSED
tests/test_auth.py::test_token_inexistent_user PASSED
tests/test_auth.py::test_token_wrong_password PASSED
tests/test_auth.py::test_refresh_token PASSED
tests/test_auth.py::test_token_expired_dont_refresh PASSED
tests/test_db.py::test_create_user PASSED
tests/test_db.py::test_create_todo PASSED
tests/test_security.py::test_jwt PASSED
tests/test_todos.py::test_create_todo PASSED
tests/test_todos.py::test_list_todos_should_return_5_todos PASSED
tests/test_todos.py::test_list_todos_pagination_should_return_2_todos PASSED
tests/test_todos.py::test_list_todos_filter_title_should_return_5_todos PASSED
tests/test_todos.py::test_list_todos_filter_description_should_return_5_todos PASSED
tests/test_todos.py::test_list_todos_filter_state_should_return_5_todos PASSED
tests/test_todos.py::test_list_todos_filter_combined_should_return_5_todos PASSED
tests/test_todos.py::test_patch_todo_error PASSED
tests/test_todos.py::test_patch_todo PASSED
tests/test_todos.py::test_delete_todo PASSED
tests/test_todos.py::test_delete_todo_error PASSED
tests/test_users.py::test_create_user PASSED
tests/test_users.py::test_read_users PASSED
tests/test_users.py::test_read_users_with_users PASSED
tests/test_users.py::test_update_user PASSED
tests/test_users.py::test_delete_user PASSED
tests/test_users.py::test_update_user_with_wrong_user PASSED
tests/test_users.py::test_delete_user_wrong_user PASSED

---------- coverage: platform linux, python 3.12.3-final-0 -----------
Name                         Stmts   Miss  Cover
------------------------------------------------
fast_zero/__init__.py            0      0   100%
fast_zero/app.py                11      0   100%
fast_zero/database.py            7      2    71%
fast_zero/models.py             29      0   100%
fast_zero/routers/auth.py       26      0   100%
fast_zero/routers/todos.py      50      0   100%
fast_zero/routers/users.py      47      4    91%
fast_zero/schemas.py            35      0   100%
fast_zero/security.py           42      3    93%
fast_zero/settings.py            7      0   100%
------------------------------------------------
TOTAL                          254      9    96%


========== 28 passed in 4.94s ==========
Wrote HTML report to htmlcov/index.html

Embora essa seja uma abordagem que funciona, ela é trabalhosa e temos que garantir que o container sempre esteja de pé. E como garantir isso durante a execução dos testes?

Containers de testes

Uma forma de interessante de usar containers em testes, é usar containers específicos para testes. Em python temos uma biblioteca chamada testcontainers.

TestContainers é uma biblioteca que fornece uma interface python para executarmos os containers diretamente no código dos testes. Você importa o código referente a um container e ele te retorna todas as configurações para que você possa usar durante os testes. Desta forma, podemos controlar o fluxo de inicialização/finalização dos containers diretamente no código.

A biblioteca TestContainers tem diversas opções de containers, principalmente de bancos de dados. Como MariaDB, MongoDB, InfluxDB, etc. Também temos a opção de iniciar o PostgreSQL. Para isso, vamos instalar o testcontainters:

$ Execução no terminal!
poetry add --group dev testcontainers

Com o testcontainers instalado iremos alterar a fixture de conexão com o banco de dados, para usar um container que será gerenciado pela fixture:

tests/conftest.py
from testcontainers.postgres import PostgresContainer #(1)!

# ...

@pytest.fixture
def session():
    with PostgresContainer('postgres:16', driver='psycopg') as postgres: #(2)!
        engine = create_engine(postgres.get_connection_url()) #(3)!
        table_registry.metadata.create_all(engine)

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

        table_registry.metadata.drop_all(engine)
  1. Faz o import do PostgresContainer dos testcontainers. O que quer dizer que ela será iniciada somente uma vez durante toda a sessão de testes.
  2. Cria um container de postgres na versão 16. Usando o psycopg como driver.
  3. get_connection_url() pega a URI do container postgres criado pelo testcontainers.

Agora, todas às vezes em que a fixture de session for usada nos testes. Será iniciado um novo container postgres na versão 16. E as interações com o banco serão feitas nesse container.

Tudo pronto para execução dos testes:

$ Execução no terminal!
task test

Os testes devem ser executados com sucesso, mas algumas mensagens estranhas podem começar a aparecer entre o nome dos testes. Algo como:

$ Parte da resposta do comando de testes
tests/test_users.py::test_delete_user_wrong_user Pulling image postgres:16
Container started: beff0853dde0
Waiting for container <Container: beff0853dde0> with image postgres:16 to be ready ...
Waiting for container <Container: beff0853dde0> with image postgres:16 to be ready ...
PASSED
# ...
========= 28 passed in 80.92s (0:01:20) =========

A mensagem Pulling image postgres:16 está dizendo que o container do postgres está sendo baixado do hub. Logo em seguida temos a mensagem Container started: beff0853dde0. Que diz que o container com id beff0853dde0 foi iniciado. Após essa mensagem vemos o Waiting for container, que diz que está aguardando o container estar pronto para operar durante os testes.

Uma coisa preocupante nessa execução é a mensagem final: 28 passed in 80.92s (0:01:20). Embora todos os testes tenham sido executados com sucesso, levaram 80 segundos para serem executados (isso na minha máquina).

Isso faz com que o tempo de feedback dos testes seja alto. Quando isso acontece, tendemos a executar menos os testes, por conta da demora. Então, temos que melhorar esse tempo.

Fixtures de sessão

As fixtures do pytest, por padrão, são executadas todas às vezes em que uma função de teste recebe a fixture como argumento:

código de exemplo
1
2
3
4
5
6
7
@pytest.fixture
def fixture_de_exemplo():
    # arrange
    yield
    # teardown

def teste_de_exemplo(fixture_de_exemplo): ...

Antes de executar o teste_de_exemplo, será executado o código da fixture até a instrução yield ser executada. A preparação para o teste (arrange). Quando a função de teste é finalizada, o bloco após o yield é executado. Chamamos ele de "teardown", para desfazer o efeito do "arrage". A volta do ambiente como era antes do "arrange".

Dizemos que uma fixture "tradicional" tem o escopo de função. Pois ela é iniciada e finalizada em todas as funções de teste.

Contudo, existem outros escopos, que precisam ser explícitos durante a declaração da fixture, pelo parâmetro scope. Existem diversos escopos:

  • function: executada em todas as funções de teste;
  • class: executada uma vez por classe de teste;
  • module: executada uma vez por módulo;
  • package: executada uma vez por pacote;
  • session: executava uma vez por execução dos testes;

Para resolver o problema com a lentidão dos testes, iremos criar uma fixture para iniciar o container do banco de dados com o escopo "session".

sequenceDiagram
    PytestRunner-->>Fixture: Executa a fixture até o yield
    PytestRunner->>Testes: Executa todos os testes
    Testes-->>Testes: Executa um teste
    PytestRunner-->>Fixture: Executa a fixture depois do yield

Dessa forma, a fixture é inicializada antes de todos os testes, está disponível durante a execução das funções, sendo finalizada após a execução de todos os testes.

Fixture para engine

Para resolver o problema da lentidão, vamos criar nova fixture para a engine no escopo session. Ela ficará responsável por iniciar o container (arrange), criar a conexão persistente com o postgres (yield) e desfazer o container após a execução de todos os testes (teardown):

tests/conftest.py
@pytest.fixture(scope='session')#(1)!
def engine():
    with PostgresContainer('postgres:16', driver='psycopg') as postgres:

        _engine = create_engine(postgres.get_connection_url())

        with _engine.begin():#(2)!
            yield _engine
  1. fixture sendo definida com o escopo 'session'.
  2. Inicia a conexão com o banco de dados. A Session originalmente inicia a conexão e a fecha. Contudo, como vamos criar diversas sessions, é interessante que o controle da conexão seja gerenciado pela engine.

Desta forma, por consequência, não iremos mais definir a engine na fixture de session. Usaremos a fixture de engine, que será criada somente uma vez durante toda a execução dos testes:

tests/conftest.py
@pytest.fixture
def session(engine):#(1)!
    table_registry.metadata.create_all(engine)

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

    table_registry.metadata.drop_all(engine)
  1. engine agora é definida pela fixture de engine.

Com isso, podemos executar os testes novamente e devemos ver uma diferença significativa de tempo:

$ Execução no terminal!
task test

# ...
========= 28 passed in 7.96s =========

Com o container sendo iniciado somente uma vez, o tempo total de execução dos testes caiu para 7.96s, em comparação com os 80 segundos que tínhamos antes. Um tempo de feedback aceitável para execução de testes.


Desta forma temos uma separação do nosso container postgres de desenvolvimento, do container usado pelos testes. Fazendo com que a execução dos testes não remova os dados inseridos durante o desenvolvimento da aplicação.

Commit

Para finalizar, após criar nosso arquivo Dockerfile e 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 inserindo o PostgreSQL"
  3. Envie as alterações para o repositório remoto com git push

Conclusão

Dockerizar nossa aplicação FastAPI, 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.


Agora que a aula acabou, é um bom momento para você relembrar alguns conceitos e fixar melhor o conteúdo respondendo ao questionário referente a ela.

Quiz