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!
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:
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.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)RUN pip install poetry
: instala o Poetry, nosso gerenciador de pacotes.WORKDIR app/
: define o diretório em que executaremos os comandos a seguir.COPY . .
: copia todos os arquivos do diretório atual para o contêiner.RUN poetry config installer.max-workers 10
: configura o Poetry para usar até 10 workers ao instalar pacotes.RUN poetry install --no-interaction --no-ansi
: instala as dependências do nosso projeto sem interação e sem cores no output.EXPOSE 8000
: informa ao Docker que o contêiner escutará na porta 8000.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:
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.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)RUN pip install poetry
: instala o Poetry, nosso gerenciador de pacotes.WORKDIR app/
: define o diretório em que executaremos os comandos a seguir.COPY . .
: copia todos os arquivos do diretório atual para o contêiner.RUN poetry config installer.max-workers 10
: configura o Poetry para usar até 10 workers ao instalar pacotes.RUN poetry install --no-interaction --no-ansi
: instala as dependências do nosso projeto sem interação e sem cores no output.EXPOSE 8000
: informa ao Docker que o contêiner escutará na porta 8000.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:
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.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)RUN pip install poetry
: instala o Poetry, nosso gerenciador de pacotes.WORKDIR app/
: define o diretório em que executaremos os comandos a seguir.COPY . .
: copia todos os arquivos do diretório atual para o contêiner.RUN poetry config installer.max-workers 10
: configura o Poetry para usar até 10 workers ao instalar pacotes.RUN poetry install --no-interaction --no-ansi
: instala as dependências do nosso projeto sem interação e sem cores no output.EXPOSE 8000
: informa ao Docker que o contêiner escutará na porta 8000.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":
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:
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:
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:
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:
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:
-
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
: -
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.
-
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.
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:
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:
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:
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:
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:
docker rm fastzeroapp #(1)!
docker build -t "fast_zero" #(2)!
docker run -it --name fastzeroapp -p 8000:8000 fast_zero:latest #(3)!
- Remove a versão antiga
- Refaz o build
- 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.
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:
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:
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
Explicação linha a linha:
-
services:
: define os serviços (contêineres) que serão gerenciados. -
fastzero_database:
: define nosso serviço de banco de dados PostgreSQL. -
image: postgres
: usa a imagem oficial do PostgreSQL. -
volumes:
: mapeia volumes para persistência de dados. -
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. -
environment:
: define variáveis de ambiente para o serviço. -
fastzero_app:
: define o serviço para nossa aplicação. -
image: fastzero_app
: usa a imagem Docker da nossa aplicação. -
build:
: instruções para construir a imagem se não estiver disponível, procura peloDockerfile
em.
. -
ports:
: mapeia portas do contêiner para o host. -
"8000:8000"
: mapeia a porta 8000 do contêiner para a porta 8000 do host. -
depends_on:
: especifica quefastzero_app
depende defastzero_database
. Isto garante que o banco de dados seja iniciado antes da aplicação. -
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çofastzero_database
que definimos anteriormente. -
volumes:
(nível superior): define volumes que podem ser usados pelos serviços. -
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:
Para parar os serviços e manter os dados seguros nos volumes definidos, use:
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 | |
---|---|
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 noCMD
do Dockerfile, mas agora está incluído noentrypoint
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:
Reconstruindo e Executando com Novas Configurações:
Para aplicar as alterações, reconstruímos e executamos os serviços com a opção --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:
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:
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
:
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 | |
---|---|
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
:
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.
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:
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:
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:
- Inicia somente o container no banco de dados via docker compose
E em seguida executar os testes:
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:
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:
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)
- 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. - Cria um container de postgres na versão 16. Usando o
psycopg
como driver. get_connection_url()
pega a URI do container postgres criado pelotestcontainers
.
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:
Os testes devem ser executados com sucesso, mas algumas mensagens estranhas podem começar a aparecer entre o nome dos testes. Algo como:
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 | |
---|---|
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):
@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
- fixture sendo definida com o escopo
'session'
. - 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:
@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)
engine
agora é definida pela fixture deengine
.
Com isso, podemos executar os testes novamente e devemos ver uma diferença significativa de tempo:
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:
- Adicionando todos os arquivos modificados nessa aula com
git add .
- Faça o commit das alterações com
git commit -m "Dockerizando nossa aplicação e inserindo o PostgreSQL"
- 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.