Pular para o conteúdo principal

Empacotamento e Distribuição do Projeto FastAPI Mínimo

Este é o segundo da série de artigos sobre a construção do projeto FastAPI mínimo. Enquanto o artigo anterior tratava de estrutura de diretórios, ambiente virtual, linting de código, integração contínua, controle de versão e testes automatizados, este artigo trata da distribuição da aplicação na forma de contêineres Docker.

Como resultado final, teremos a aplicação FastAPI rodando em um contêiner Docker, acessível através do Caddy como proxy reverso e com os serviços orquestrados pelo Docker Compose. Essa solução pode ser usada diretamente em produção ou como base para projetos mais complexos. Além disso, funciona da mesma forma em desenvolvimento e em produção.

Introdução

A aplicação está pronta, testada e funcionando no ambiente de desenvolvimento. Agora, precisamos colocá-la em produção mas fazer isso do zero requer várias etapas:

  1. Adquirir e configurar um domínio
  2. Configurar o provedor de hospedagem
  3. Instalar e configurar a aplicação
  4. Instalar e configurar o servidor web (proxy reverso)
  5. Monitorar e gerenciar a aplicação

As ações 1, 2 e 5 não estão diretamente relacionadas à aplicação e não vamos abordá-las neste artigo, mas 3 e 4 podem fazer parte do projeto se a aplicação e suas dependências forem distribuídas na forma de contêineres Docker.

O empacotamento e distribuição de uma aplicação em contêineres é uma prática comum em ambientes de produção. As principais razões para isso são que os contêineres são encapsulados e portáteis, podendo ser implantados em diferentes plataformas e infraestruturas.

Para o projeto FastAPI mínimo, vamos precisar de dois contêineres:

  1. Um contêiner para a aplicação
  2. Um contêiner para o servidor web (proxy reverso)

O proxy reverso é um servidor web que atua como intermediário entre os clientes e o servidor de aplicação. Ele recebe solicitações de clientes (geralmente na Internet) e encaminha essas solicitações para os servidores ou serviços de destino com base em regras de roteamento predefinidas.

Vamos usar o Caddy como proxy reverso por ser fácil de configurar e oferecer recursos avançados tal como suportar automaticamente HTTPS usando Let's Encrypt.

A orquestração será feita pelo Docker Compose, que não é a ferramenta mais poderosa para orquestração de contêineres existente, mas é uma ótima escolha para desenvolvimento e testes locais, e implantações em que a simplicidade e a facilidade de uso são prioritárias.

Orquestração dos Contêineres

Vamos começar pela orquestração porque teremos uma visão geral de como será a implantação da aplicação. O docker-compose.yml para o projeto FastAPI mínimo é apresentada abaixo:

services:
    caddy:
        # see https://hub.docker.com/_/caddy
        image: caddy:alpine
        container_name: caddy
        init: true
        ports:
            - "80:80"
            - "443:443"
            - "443:443/udp"
        volumes:
            - ./Caddyfile:/etc/caddy/Caddyfile
            - caddy_data:/data
        depends_on:
            - app
        restart: unless-stopped
        environment:
            - DOMAIN
    app:
        image: fastapi-minimum:latest
        container_name: fastapi-minimum
        build: .
        init: true
        ports:
            - "5000:5000"
        restart: unless-stopped
        environment:
            ENV: ${ENV:-production}
        command:
            [
                "hypercorn",
                "--config=hypercorn.toml",
                "--root-path=/api",
                "fastapi_minimum.main:app"
            ]
volumes:
    caddy_data:

Caddy

Usaremos uma configuração inspirada no exemplo da documentação da imagem do Caddy no Docker Hub:

services:
    caddy:
        # see https://hub.docker.com/_/caddy
        image: caddy:alpine
        container_name: caddy
        init: true
        ports:
            - "80:80"
            - "443:443"
            - "443:443/udp"
        volumes:
            - ./Caddyfile:/etc/caddy/Caddyfile
            - caddy_data:/data
        depends_on:
            - app
        restart: unless-stopped
        environment:
            - DOMAIN

A imagem caddy:alpine (linha 4) é a mais adequada para o projeto FastAPI mínimo porque não precisamos instalar nenhum módulo ou plugin adicional do Caddy, por enquanto.

A linha 6, init: true, inicia um processo init que garante que o encerramento do contêiner será gerenciado corretamente quando o processo principal do aplicativo dentro do contêiner for encerrado.

As linhas 8 a 10 habilitam o Caddy a escutar as portas 80 e 443 para poder atender requisições HTTP e HTTPS.

A linha 12 mapeia Caddyfile, que é o arquivo de configuração do Caddy, para seu caminho esperado dentro do contêiner (/etc/caddy/Caddyfile). Dessa forma, a configuração do Caddy pode ser alterada sem a necessidade de reconstruir a imagem do contêiner.

Conforme recomendado na documentação, o diretório /data deve ser persistido em um volume (linhas 13 e 32) para que os dados importantes tais como certificados TLS não sejam perdidos quando o contêiner for reiniciado.

A variável de ambiente DOMAIN (linha 15) define o domínio que será usado no Caddyfile.

App

O serviço app é o contêiner da aplicação FastAPI:

app:
    image: fastapi-minimum:latest
    container_name: fastapi-minimum
    build: .
    init: true
    ports:
        - "5000:5000"
    restart: unless-stopped
    environment:
        ENV: ${ENV:-production}
    command:
        [
            "hypercorn",
            "--config=hypercorn.toml",
            "--root-path=/api",
            "fastapi_minimum.main:app"
        ]

A linha 23 indica que a imagem do contêiner será construída a partir do diretório corrente, que deve conter o arquivo Dockerfile.

A aplicação estará escutando na porta 5000 (linha 26).

A linha 27 indica que o contêiner será reiniciado automaticamente em caso de falha ou quando o Docker for reiniciado.

A linha 29 define a variável de ambiente ENV com o valor production se não tiver sido definida anteriormente.

As linhas 30 a 36 definem o comando que será executado quando o contêiner for iniciado. A diferença com o comando original é a inclusão da opção --root-path=/api, para que seja possível acessar a documentação da API através do caminho /api/docs ao invés de /docs, já que a aplicação será acessada através do proxy reverso a partir de /api, conforme será apresentado na seção seguinte, sobre configuração do Caddy.

Configuração do Caddy

O Caddy é configurado através de um arquivo de configuração chamado Caddyfile. Para o projeto FastAPI mínimo, temos a seguinte configuração:

{$DOMAIN:localhost} {
    handle_path /api* {
        reverse_proxy h2c://app:5000
    }
    handle {
        redir https://{host}/api/docs
    }
}

A linha 1 define o endereço do domínio. Se não houver uma variável de ambiente DOMAIN definida, o domínio será localhost.

Na linha 2, handle_path /api captura todas as requisições que começam com /api e exclui esse prefixo implicitamente. A diretiva reverse_proxy encaminha as requisições para o servidor de aplicação (linha 3), que está escutando na porta 5000 do serviço app, através do protocolo h2c.

O esquema h2c:// se refere ao protocolo HTTP/2 Cleartext, que é uma versão do protocolo HTTP/2 sem criptografia. A vantagem do h2c é que possui os benefícios do HTTP/2 mas sem a sobrecarga da encriptação do TLS. Não é seguro para use externo, mas é adequado para uso interno porque o Caddy e a aplicação estarão rodando no mesmo servidor. Em outras palavras, toda a comunicação será feita em HTTP/2 mas apenas a primeira etapa (entre o cliente e o proxy) será criptografada.

Por fim, vamos usar a página da documentação da API como página inicial da aplicação: A diretiva handle (linha 5) trata todas as requisições que não forem capturadas anteriormente e redir as redirecionas para https://{host}/api/docs (linha 6).

E aí está a página da documentação da API como página inicial do projeto:

Página da documentação da API como página inicial do projeto

Criação do Contêiner da Aplicação

A construção de um container envolve dois arquivos: Dockerfile e .dockerignore.

.dockerignore

O projeto FastAPI Mínimo é bastante simples. Além de pyproject.toml e poetry.lock, usados na instalação das dependências, os únicos arquivos realmente necessários para a construção da imagem Docker são:

fastapi-minimum
├── hypercorn.toml
├── pyproject.toml
├── poetry.lock
└── fastapi-minimum
    ├── __init__.py
    ├── config.py
    ├── main.py
    ├── resources.py
    └── routers
        ├── __init__.py
        └── hello.py

Porém, o diretório de trabalho não é assim. Considerando arquivos e diretórios do ambiente virtual, controle de versão, cache, log, testes, etc. é provável que a estrutura do projeto seja:

fastapi-minimum
├── .coverage
├── Dockerfile
├── .dockerignore
├── .github
│   └── workflows
│       └── continuous_integration.yml
├── .hg
│   └── ...
├── .hgignore
├── htmlcov
│   └── ...
├── hypercorn.toml
├── Makefile
├── fastapi-minimum
│   ├── config.py
│   ├── __init__.py
│   ├── main.py
│   ├── __pycache__
│   │   ├── config.cpython-311.pyc
│   │   ├── __init__.cpython-311.pyc
│   │   ├── main.cpython-311.pyc
│   │   └── resources.cpython-311.pyc
│   ├── resources.py
│   └── routers
│       ├── hello.py
│       ├── __init__.py
│       └── __pycache__
│           ├── hello.cpython-311.pyc
│           └── __init__.cpython-311.pyc
├── .mypy_cache
│   └── ...
├── poetry.lock
├── pyproject.toml
├── .pytest_cache
│   └── ...
├── README.rst
├── .ruff_cache
│   └── ...
├── scripts
│   └── install_hooks.sh
├── tests
│   ├── conftest.py
│   ├── __init__.py
│   └── routers
│       ├── __init__.py
│       └── test_hello.py
└── .venv
    ├── bin
    ├── .gitignore
    ├── lib
    └── pyvenv.cfg

Para garantir que apenas o necessário seja copiado na criação da imagem Docker, precisamos de um arquivo .dockerignore com a lista de padrões de nomes a serem ignorados.

Os padrões para o projeto FastAPI Mínimo são:

.coverage
Dockerfile
.dockerignore
.git
.github
.hg
.hgignore
htmlcov
Makefile
.mypy_cache
.pytest_cache
.ruff_cache
README.rst
scripts
tests
.venv
**/__pycache__

Uma outra forma de especificar é listando o que deve ser copiado. Para isso, ignore todos os arquivos e diretórios, exceto aqueles são necessários para a construção da imagem:

*
!pyproject.toml
!poetry.lock
!hypercorn.toml
!fastapi-minimum/
**/__pycache__

O padrão * na linha 1 indica que todos os arquivos e diretórios devem ser ignorados. As linhas iniciadas com ! são exceções à regra. Portanto, as linhas 2 a 5 especificam os arquivos e diretórios que serão incluídos. O padrão **/__pycache__ deve ficar na última linha para ignorar todos os subdiretórios __pycache__ dos diretórios incluídos no projeto.

Dockerfile

O Dockerfile que usaremos segue as recomendações presentes em 1, 2 e 3:

FROM python:3.11-slim as builder
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
    apt-get install -y --no-install-recommends build-essential curl
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN python -m venv /venv
ENV POETRY_VERSION=1.5.1
ENV POETRY_HOME=/opt/poetry
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN curl -sSL https://install.python-poetry.org | python -
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN . /venv/bin/activate; \
    $POETRY_HOME/bin/poetry install --only main --no-interaction
FROM python:3.11-slim as final
COPY --from=builder /venv /venv
ENV PATH=/venv/bin:${PATH}
WORKDIR /app
USER nobody
COPY --chown=nobody:nogroup hypercorn.toml .
COPY --chown=nobody:nogroup fastapi-minimum/ ./fastapi-minimum
CMD ["hypercorn", "--config=hypercorn.toml", "fastapi-minimum.main:app"]

Multi-Estágios

A construção da imagem é feita em dois estágios: builder e final. O primeiro estágio é responsável pela construção e instalação das dependências em um ambiente virtual (linhas 1 a 18). O segundo estágio copia o ambiente virtual do estágio anterior, copia os demais arquivos do projeto a partir do computador hospedeiro e configura a aplicação para ser usada em produção (linhas 21 a 31).

A construção em multi-estágios faz com que a imagem final fique o menor possível porque as ferramentas e bibliotecas usadas na construção não são incluídas na imagem final.

Uso de Imagens Oficiais, Pequenas e Seguras

Os dois estágios são baseados na imagem oficial do Python 3.11-slim, que é a melhor imagem para uma aplicação Python disponível no momento por ser segura e pequena.

Instalação de Dependências

RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
    apt-get install -y --no-install-recommends build-essential curl

A linha 3 instala as ferramentas e bibliotecas necessárias para a compilação e instalação das dependências do projeto: build-essential contém compiladores e ferramentas de construção e curl será usado na instalação do poetry.

O comando apt-get update atualiza o índice de pacotes, DEBIAN_FRONTEND=noninteractive e a opção -y evitam a necessidade de interação do usuário durante a instalação dos pacotes, e a opção --no-install-recommends evita a instalação de pacotes desnecessários.

Dependendo das dependências da aplicação, será necessário incluir bibliotecas adicionais. Por exemplo, se a aplicação usasse Postgres como banco de dados, seria necessário adicionar libpq-dev à instalação para compilar o pacote pyscopg2-binary.

Evitando a Criação de Arquivos .pyc

FROM python:3.11-slim as builder
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
    apt-get install -y --no-install-recommends build-essential curl
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
...

A existência da variável de ambiente PYTHONDONTWRITEBYTECODE impede que o Python crie arquivos .pyc (linha 6). Esses arquivos são específicos do ambiente em que foram criados porque contêm metadados tais como caminhos absolutos de módulos importados, timestamps e outras informações específicas do sistema. Importar arquivos .pyc gerados em outro estágio do Dockerfile pode levar a problemas de execução se os metadados originais não forem compatíveis com o ambiente final.

De qualquer maneira, não há necessidade de manter arquivos .pyc na imagem nem de transportá-los de um estágio para outro porque esses arquivos podem ser gerados novamente durante a primeira execução do código Python.

Forçando o Buffer de Saída do Python

FROM python:3.11-slim as builder
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
    apt-get install -y --no-install-recommends build-essential curl
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN python -m venv /venv

Definir PYTHONUNBUFFERED=1 (linha 7) é uma boa prática para garantir que a saída do Python seja exibida imediatamente no log de construção da imagem, sem que nenhuma informação seja perdida ou atrasada em um buffer em caso de erros de compilação ou execução.

Criação do Ambiente Virtual

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN python -m venv /venv

A linha 8 cria o ambiente virtual do Python em /venv, onde serão instaladas as dependências do projeto. Este é o único diretório que será copiado para a imagem final.

Instalação do poetry

ENV POETRY_VERSION=1.5.1
ENV POETRY_HOME=/opt/poetry
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN curl -sSL https://install.python-poetry.org | python -

O poetry é a ferramenta de gerenciamento de dependências usado pelo projeto FastAPI Mínimo.

Tal como as dependências usadas no projeto, é importante fixar a versão do poetry para garantir que as dependências sejam instaladas sempre da mesma forma. A versão do poetry usada e testada no projeto é a 1.5.1 e é fixada através da variável de ambiente POETRY_VERSION (linha 10).

A localização do diretório de instalação do poetry está definida variável de ambiente POETRY_HOME (linha 11), e a instalação do poetry propriamente acontece na linha 13. A linha 12 é uma sugestão do hadolint para detectar problemas durante a concatenação de comandos bash através do | (pipe) (DL4006).

Instalação das Dependências do Projeto

WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN . /venv/bin/activate; \
    $POETRY_HOME/bin/poetry install --only main --no-interaction

A instalação das dependências acontece entre as linhas 15 a 18. A diretiva WORKDIR define o diretório corrente. Na linha 16, os arquivos pyproject.toml e poetry.lock são copiados para o diretório corrente. Em seguida, o ambiente virtual é ativado através do comando . /venv/bin/activate (linha 17), que altera o PATH e força a instalação das dependências no diretório /venv.

Finalmente, o comando poetry install --only main --no-interaction instala apenas as dependências do grupo principal e sem interação com o usuário durante a instalação (linha 18).

De todos os arquivos do projeto, apenas pyproject.toml (lista de dependências do projeto) e poetry.lock (lista das versões exatas necessárias) são usados no primeiro estágio porque essas listas de dependências mudam com pouca frequência, enquanto as partes do projeto que mais mudam são copiadas apenas no segundo estágio. Essa separação é importante porque o Docker usa um cache para evitar a reexecução de comandos que não foram alterados. Assim, em caso de reconstrução da imagem, o processo de instalação das dependências não será executado novamente a não ser que pyproject.toml ou poetry.lock tenham sido alterados, o que torna o processo de construção da imagem muito mais rápido.

Cópia e Configuração do Ambiente Virtual

FROM python:3.11-slim as final
COPY --from=builder /venv /venv
ENV PATH=/venv/bin:${PATH}
WORKDIR /app
USER nobody
COPY --chown=nobody:nogroup hypercorn.toml .
COPY --chown=nobody:nogroup fastapi-minimum/ ./fastapi-minimum
CMD ["hypercorn", "--config=hypercorn.toml", "fastapi-minimum.main:app"]

No segundo estágio, o ambiente virtual é copiado para a imagem final (linha 23) e habilitado através da inclusão do seu caminho no começo da variável de ambiente PATH (linha 24).

Diretório de Trabalho e Usuário Não-Privilegiado

FROM python:3.11-slim as final
COPY --from=builder /venv /venv
ENV PATH=/venv/bin:${PATH}
WORKDIR /app
USER nobody
COPY --chown=nobody:nogroup hypercorn.toml .
COPY --chown=nobody:nogroup fastapi-minimum/ ./fastapi-minimum
CMD ["hypercorn", "--config=hypercorn.toml", "fastapi-minimum.main:app"]

Por uma questão de segurança, é recomendável que a aplicação rode com um usuário não-privilegiado ao invés de root, que é o usuário padrão dentro de um contêiner Docker. O ideal é criar um usuário e um grupo específicos para cada aplicação de modo que um eventual ataque não tenha acesso a outros processos ou arquivos do sistema do mesmo usuário/grupo. Porém, como só haverá um processo rodando no container, podemos simplesmente usar o usuário noboby e o grupo nogroup, que estão disponíveis em distribuições Linux baseadas em Debian, tal qual o python:3.11-slim usado neste projeto.

Assim, a linha 27 define nobody como usuário padrão e as linhas 28 e 29 copiam os demais arquivos do projeto para o diretório de trabalho /app já pertencendo a nobody:nogroup.

Execução da Aplicação

FROM python:3.11-slim as final
COPY --from=builder /venv /venv
ENV PATH=/venv/bin:${PATH}
WORKDIR /app
USER nobody
COPY --chown=nobody:nogroup hypercorn.toml .
COPY --chown=nobody:nogroup fastapi-minimum/ ./fastapi-minimum
CMD ["hypercorn", "--config=hypercorn.toml", "fastapi-minimum.main:app"]

A linha 31 contém o comando para iniciar a aplicação individualmente. No entanto, é sobrescrito na orquestração para que a aplicação seja acessada através do proxy reverso a partir de /api, conforme visto anteriormente.

Assim, mantemos um Dockerfile genérico que pode ser usado em diferentes ambientes, bastando apenas alterar o comando de execução da aplicação.

Configuração da Orquestração Para Desenvolvimento

Durante o desenvolvimento, é comum usar um servidor web que reinicie automaticamente a aplicação sempre que um arquivo do projeto for alterado. Para conseguir esse comportamento na solução baseada em contêineres, vamos precisar de uma configuração diferente para ser usada apenas durante o desenvolvimento. Essa configuração será mantida em um arquivo separado, de nome docker-compose.dev.yml:

services:
    app:
        command:
            [
                "hypercorn",
                "--config=hypercorn.toml",
                "--reload",
                "--root-path=/api",
                "fastapi-minimum.main:app"
            ]
        volumes:
            - ./fastapi-minimum/:/app/fastapi-minimum/
        environment:
            - ENV=development

Há duas mudanças necessárias: alterar o CMD do contêiner da aplicação para incluir a opção --reload ao hypercorn (linha 8) e mapear o diretório do projeto para que as alterações locais sejam acompanhadas de dentro do contêiner (linhas 11 e 12).

O modo de desenvolvimento da orquestração é ativado através do comando docker compose -f docker-compose.yml -f docker-compose.dev.yml up, que mescla as configurações de docker-compose.yml e docker-compose.dev.yml.

Novas Tarefas no Makefile

Com a inclusão dos contêineres, do proxy reverso e do docker compose no projeto FastAPI Mínimo, algumas tarefas do Makefile precisam ser incluídas ou atualizadas:

run:
    docker compose up --build
run_dev:
    docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
smoke_test:
    trap 'docker compose down' EXIT; \
    docker compose up --build -d; \
    sleep 4; \
    httpx https://localhost/api/hello --http2 --no-verify -v; \
    [ $$? -eq 0 ] || exit 1; \
    echo 'Smoke test passed!'; exit 0

A tarefa run inicia os containers em modo de produção.

A tarefa run_dev inicia os containers em modo de desenvolvimento.

smoke_test executa um teste rápido para verificar se a aplicação está funcionando corretamente. Para isso, inicia os containers em modo de produção, espera 4 segundos para que a aplicação seja iniciada, e executa uma requisição HTTP/2 para o endpoint /api/hello. Se a requisição for mal sucedida, a tarefa é encerrada com código de erro 1. Caso contrário, é exibida uma mensagem de sucesso e a tarefa é encerrada com código 0.

O comando trap 'docker compose down' EXIT garante que os containers serão encerrados mesmo que a tarefa seja interrompida por um erro ou pelo usuário.

Para testar o endpoint foi usado o comando httpx porque já é uma dependência do projeto FastAPI mínimo. Não é preciso instalar nenhuma outra ferramenta. O comando httpx é executado com as opções --http2 para usar o protocolo HTTP/2 e --no-verify para ignorar a verificação do certificado TLS. A opção -v é usada para exibir o log da requisição.

As demais tarefas do Makefile permanecem inalteradas.

Integração Contínua

A última alteração será a inclusão de um novo job de nome Smoke Test no final do arquivo .github/workflows/continuous_integration.yml:

name: Continuous Integration
on: [push]
jobs:
lint_and_test:
    runs-on: ubuntu-latest
    steps:
        ...
        - name: Smoke Test
          run: poetry run make smoke_test

Considerações Finais

O projeto FastAPI mínimo continua com o mesmo objetivo de ser um ponto de partida para projetos mais complexos, mas agora teve a orquestração de contêineres incluída para facilitar o desenvolvimento e a implantação da aplicação.

É possível executar a aplicação em qualquer servidor que tenha o Docker instalado, sem a necessidade de instalar o Python ou qualquer outra dependência. E essa mesma solução torna o ambiente de desenvolvimento e o de produção praticamente idênticos, conforme recomendado pelo item X. Paridade entre desenvolvimento e produção do manifesto The Twelve-Factor APP.

O gabarito do projeto está disponibilizado no GitHub. Para instanciar um novo projeto, é necessário usar o cookiecutter. Recomendo combiná-lo com pipx:

$ pipx run cookiecutter gh:andredias/perfect_python_project \
       -c fastapi-minimum

Referências

1 Articles: Production-ready Docker packaging for Python developers
2 Docker Best Practices for Python Developers
3 Best practices for writing Dockerfiles

Artigo anterior: Projeto FastAPI Mínimo

Comentários

Comments powered by Disqus