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:
-
Código fuente funcional (o lleno de bugs, no soy quién para juzgar).
-
Un archivo
.env
con las variables de entorno, si es que las usas (y si no, piénsalo, es buen hábito). -
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:
-
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).
-
Directorio de trabajo: Establecemos un directorio dentro del contenedor, que es donde copiaremos nuestro proyecto.
-
Variables de entorno: Configuramos algunas variables recomendadas para evitar sorpresas desagradables.
-
Actualización de paquetes: Ejecutamos comandos para actualizar los repositorios y paquetes dentro del contenedor, porque nadie quiere software desactualizado.
-
Copiar
requirements.txt
: Llevamos nuestro querido archivorequirements.txt
al directorio de trabajo (/app
, en este caso). -
Instalación de dependencias: Instalamos los requerimientos del proyecto, porque sin ellos nada funciona (literalmente).
- 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 puerto8083
de nuestra máquina host (o sea, nuestra compu) será redirigido al puerto8000
, 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).
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.
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?".
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)!