Dockerizar una aplicación FastAPI

Dockerizar una aplicación FastAPI

El despliegue de aplicaciones y servicios web en contenedores es, hoy en día, de lo más común. Los contenedores ofrecen una flexibilidad increíble, y Docker es, por mucho, la tecnología de contenedores más popular y utilizada. Por eso, en este post te voy a mostrar el primer paso para desplegar servicios web hechos con Python.

En este caso, vamos a ver cómo dockerizar una API sencilla hecha con la fabulosa librería FastAPI, que, como te darás cuenta, es cosa de niños (si esos niños supieran FastAPI y Docker, claro).

Requisitos

  • Tener instalado Docker (si no lo tienes, detente y corrige tus errores de vida).

  • Tener instalado Docker Compose (porque más vale prevenir que sufrir).

Preparando el entorno

El primer paso, obviamente, es tener una API funcional que hayas desarrollado en la comodidad de tu computadora, o en su defecto, una llena de bugs que luego prometas arreglar.

A grandes rasgos, en la carpeta de tu proyecto deberías tener algo como esto:

  1. Código fuente funcional (o lleno de bugs, no soy quién para juzgar).

  2. Un archivo .env con las variables de entorno, si es que las usas (y si no, piénsalo, es buen hábito).

  3. Un archivo requirements.txt con la lista de dependencias y requerimientos de tu API (esto es imprescindible, no me vayas a salir con que no lo tienes).

En mi caso, mi proyecto se ve algo así:

fastapi_docker/
├── app/
│   ├── __init__.py
│   ├── crud.py
│   ├── database.py
│   ├── main.py
│   ├── models.py
│   ├── router.py
│   └── schemas.py
├── requirements.txt
├── .env
└── .gitignore

Dockerizando nuestra API

Lo primero que haremos es construir la imagen de nuestra aplicación. Para esto, necesitamos un archivo especial llamado Dockerfile.

Crearemos en la raíz del proyecto un archivo llamado Dockerfile, donde describiremos los pasos para crear la imagen de Docker que contendrá nuestro proyecto.

FROM python:3.12-slim

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc netcat-openbsd && \
    apt-get clean && rm -rf /var/lib/apt/lists/*

COPY requirements.txt ./

RUN pip install --no-cache-dir -r requirements.txt

COPY . .

Por si no estás familiarizado con los Dockerfiles (o si, como yo, a veces lees cosas y no entiendes nada), vamos a repasar qué hace este archivo:

  1. Imagen base: Indicamos una imagen base oficial, que es básicamente una imagen de Docker que ya tiene una versión de Python instalada. Piensa en esto como un Linux comprimido con Python incluido (súper práctico).

  2. Directorio de trabajo: Establecemos un directorio dentro del contenedor, que es donde copiaremos nuestro proyecto.

  3. Variables de entorno: Configuramos algunas variables recomendadas para evitar sorpresas desagradables.

  4. Actualización de paquetes: Ejecutamos comandos para actualizar los repositorios y paquetes dentro del contenedor, porque nadie quiere software desactualizado.

  5. Copiar requirements.txt: Llevamos nuestro querido archivo requirements.txt al directorio de trabajo (/app, en este caso).

  6. Instalación de dependencias: Instalamos los requerimientos del proyecto, porque sin ellos nada funciona (literalmente).

  7. Copiar el resto del proyecto: Finalmente se copia todo el codigo fuente al contenedor.

Como ves, esto es básicamente lo que haríamos para correr nuestro proyecto en otra computadora, pero sin las lágrimas de lidiar con configuraciones manuales.

Usando Docker Compose

El siguiente paso es crear un archivo llamado docker-compose.yml, el cual se encargará de definir todos los servicios que necesita nuestra aplicación para funcionar como se espera (o al menos intentarlo). Esto incluye nuestra propia aplicación y cualquier cosa extra que requiera.

Creamos un archivo llamado docker-compose.yml en la raíz del proyecto y colocamos algo como esto:

services:
  api:
    build:
      context: .
    container_name: fastapi_docker
    env_file:
      - .env
    ports:
      - "8083:8000"
    depends_on:
      - db
    command: uvicorn api.main:app --host 0.0.0.0 --port 8000

  db:
    image: postgres:14.1-alpine
    container_name: postgres_db
    env_file:
      - .env.db
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  postgres_data:

En mi caso, el proyecto solo necesita un servicio extra: una base de datos PostgreSQL. Así que, en total, tendríamos dos servicios definidos en el Docker Compose.

Ahora, si tu proyecto usa cosas extra como Redis, Celery, otras bases de datos exóticas, o quién sabe qué más, tienes que definirlos aquí para que todo funcione en armonía. Recuerda, los contenedores son como un equipo: si falta uno, el resto hace drama.

La estructura del archivo docker-compose.yml

Este archivo es sencillo. Dentro de la etiqueta services, enlistamos los servicios y definimos la configuración de cada uno. Vamos a echar un vistazo para ver qué rayos puse ahí:

Servicio API

  • build: Esta clave indica que vamos a usar una imagen personalizada. Por lo tanto, debemos especificar la ubicación del Dockerfile que se usará para construir la imagen.

  • container_name: Es opcional, pero me gusta ponerlo para identificar al servicio por nombre y no por un ID raro o un nombre generado al azar que nadie recuerda.

  • env_file: Le indica a Docker de dónde tomar las variables de entorno, mapeándolas automáticamente dentro del contenedor para que nuestra aplicación las pueda consumir sin dramas.

  • ports: Aquí definimos un mapeo de puertos. Básicamente, decimos que todo el tráfico del puerto 8083 de nuestra máquina host (o sea, nuestra compu) será redirigido al puerto 8000, donde nuestra API estará escuchando las peticiones dentro del contenedor.

  • depends_on: Le dice a Docker que este servicio no debe arrancar hasta que el servicio de la base de datos esté corriendo. Es como decir: "Espera a que tu amigo esté listo antes de salir".

  • command: Este comando se ejecutará dentro del contenedor para iniciar el servidor de nuestra API.

Servicio DB

  • image: Indicamos la imagen de Docker que queremos usar, en este caso, una que tenga PostgreSQL 14.1 (porque somos cool y usamos Postgres).

  • volumes: Aquí configuramos un mapeo de volúmenes para preservar la información almacenada en la base de datos. Sin esto, cada vez que el contenedor se elimine y se vuelve a crear, adiós datos. Al usar un volumen, guardamos la información en la máquina host, así que, si hay un reinicio, el contenedor la recupera sin problemas.

  • ports: Al igual que con la API, mapeamos el puerto de la base de datos para poder acceder a ella desde fuera del contenedor.

Variables de entorno

Como pudiste ver, en el docker-compose.yml en la clave env_file puse referencias a archivos .env, asi que hay que existir y tener las variables de entorno necesarias, yo los puse separados por servicio pero en teoria, podrias tener todas en un solo archivo, como quieras, solo asegurate de tenerlo creado y con las variables correctas.

.env

DATABASE_URL=postgresql+psycopg2://devwanabi:strongPassword@db:5432/postgres

DATABASE=postgres
DB_HOST=db
DB_PORT=5432

.env.db

POSTGRES_USER=devwanabi
POSTGRES_PASSWORD=strongPassword
POSTGRES_DB=postgres

El .env es el de nuestra API, esta requiere el enlace de conexion a la DB y quiero que notes algo importante, y es que en la parte donde deberia de ir el host (donde se encuentra corriendo postgres) no puse localhost o 127.0.0.1 como seria si tuviera corriendo el servicio de db en mi compu, si no que puse el nombre db que corresponde al nombre del servicio dentro de mi docker compose, esto porque obviamente la base de datos no esta en nuestra compu, si no que está en otro contenedor, y Docker se encargará de nuestra API apunte al host donde está el servicio de postgres corriendo.

Todo listo para correr nuestra app en Docker

¡Genial! Ahora ya deberías tener una estructura de archivos parecida a esto:

fastapi_docker/
├── app/
│   ├── __init__.py
│   ├── crud.py
│   ├── database.py
│   ├── main.py
│   ├── models.py
│   ├── router.py
│   └── schemas.py
├── requirements.txt
├── .env
├── .env.db
├── Dockerfile
├── docker-compose.yml
└── .gitignore

Lo único que queda es ejecutar una serie de comandos desde la ruta de trabajo.

1. Construir la imagen de la app:

docker compose build

2. Levantar los servicios:

docker compose up -d

Con esto, si todo ha salido bien, ya deberías tener tu aplicación corriendo en Docker y no directamente en tu máquina. Vamos a hacer una comprobación rápida:

Revisar los logs de la API:

docker compose logs -f api

Esto debería mostrar los logs del contenedor donde está corriendo tu API. Verás el contenido de la consola del servidor funcionando (idealmente sin errores, pero bueno, tú sabes cómo es la vida).

screenshot_fastapi_docker_logs

Consumir la API desde tu máquina:

Los logs muestran que la API está escuchando en el puerto 8000, pero recuerda que ese es el puerto interno, el que nosotros mapeamos es el 8083, asi que para acceder al servicio iremos a http://localhost:8083/docs y deberías poder ver tu API feliz y funcional.

fastapi_docker_api_ruinning

Entrypoints

Hasta aquí ya tenemos nuestra API corriendo en Docker, pero quiero aprovechar para mostrarles otro concepto interesante: los entrypoints.

Básicamente, un entrypoint es un script que se ejecuta en un punto específico al inicio de los servicios en Docker. Son súper útiles para tareas que deben realizarse en cada inicio o reinicio, como ejecutar migraciones, verificar que algún servicio esté en pie, o correr comandos mágicos que ni tú sabes bien para qué sirven pero funcionan.

Nuestro caso: que la API espere a la base de datos

Vamos a implementar un entrypoint para asegurarnos de que el servidor de la API no se inicie hasta que la base de datos esté lista para aceptar conexiones. Aunque Docker Compose nos permite establecer dependencias (gracias, depends_on), esto no garantiza que la base de datos esté 100% lista para recibir conexiones cuando el servicio de la API intente arrancar.

Primero, apaguemos los servicios que iniciamos previamente:

docker compose down

Creando el entrypoint

Creamos un archivo llamado entrypoint.sh en la raíz del proyecto y ponemos el siguiente script:

#!/bin/sh

if [ "$DATABASE" = "POSTGRES" ]
then
    echo "Waiting for POSTGRES..."

    while ! nc -z $DB_HOST $DB_PORT; do
      sleep 0.1
    done

    echo "POSTGRES started"
fi

exec "$@"

Este script es un bucle que utiliza la herramienta nc (netcat) o puedes usar pg_isready (como quieras, aquí somos más raritos) para comprobar si la base de datos está aceptando conexiones. Las variables de entorno DB_HOST, DB_PORT y DATABASE deben estar definidas en el contenedor para que esto funcione; si no, prepárate para una lluvia de errores, las podemos definir en el docker compose o en el Dockerfile (en mi caso me preparé y las dejé definidas en el .env en los pasos anteriores).

Integrando el entrypoint en el contenedor

Ahora, necesitamos hacer que este entrypoint se copie al contenedor y se ejecute antes de que el servidor de la API arranque. Para esto, vamos a modificar nuestro Dockerfile como sigue:

FROM python:3.12-slim

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc netcat-openbsd && \
    apt-get clean && rm -rf /var/lib/apt/lists/*

COPY requirements.txt ./

RUN pip install --no-cache-dir -r requirements.txt

# NUEVO: Copiar el script entrypoint.sh
COPY ./entrypoint.sh .
# NUEVO: Cambiar los saltos de línea de Windows a Unix (por si se está ejecutando en Windows)
RUN sed -i 's/\r$//g'  ./entrypoint.sh
# NUEVO: Dar permisos de ejecución al script
RUN chmod +x ./entrypoint.sh && ls -l ./entrypoint.sh

COPY . .

# NUEVO: Ejecutar el script entrypoint.sh al iniciar el contenedor
ENTRYPOINT ["bash", "./entrypoint.sh"]

Reconstruyendo y levantando los servicios

Con el entrypoint listo, reconstruimos la imagen y levantamos los servicios nuevamente:

docker compose build  

docker compose up -d  

docker compose logs -f api

Si todo está bien, verás que la API espera pacientemente a que la base de datos esté lista para aceptar conexiones antes de arrancar. Con esto nos ahorramos problemas del tipo: "¿Por qué mi API no encuentra la DB si ya está corriendo?".

fastapi_docker_api_running2

Conclusión

Dockerizar una aplicación con FastAPI puede parecer un desafío al principio, pero como viste, con los pasos adecuados y un poco de paciencia, el proceso es bastante directo. Al usar Docker y Docker Compose, no solo simplificamos el despliegue, sino que también creamos un entorno aislado y reproducible que hace más fácil escalar y mantener nuestra API en el futuro.

¡Ahora tienes una API lista para conquistar el mundo (o al menos para funcionar en cualquier máquina sin dramas)!