Al desplegar aplicaciones FastAPI un enfoque común es construir una imagen de contenedor Linux. Normalmente se hace usando Docker. Luego puedes desplegar esa imagen de contenedor de varias maneras posibles.
Usar contenedores Linux tiene varias ventajas incluyendo seguridad, replicabilidad, simplicidad, entre otras.
Los contenedores (principalmente contenedores Linux) son una forma muy ligera de empaquetar aplicaciones incluyendo todas sus dependencias y archivos necesarios manteniéndolos aislados de otros contenedores (otras aplicaciones o componentes) en el mismo sistema.
Los contenedores Linux se ejecutan usando el mismo kernel Linux del host (máquina, máquina virtual, servidor en la nube, etc). Esto simplemente significa que son muy ligeros (comparados con máquinas virtuales completas que emulan un sistema operativo entero).
De esta manera, los contenedores consumen pocos recursos, una cantidad comparable a ejecutar los procesos directamente (una máquina virtual consumiría mucho más).
Los contenedores también tienen sus propios procesos aislados en ejecución (comúnmente un solo proceso), sistema de archivos y red, simplificando el despliegue, la seguridad, el desarrollo, etc.
Un contenedor se ejecuta a partir de una imagen de contenedor.
Una imagen de contenedor es una versión estática de todos los archivos, variables de entorno y el comando/programa por defecto que deberían estar presentes en un contenedor. Estática aquí significa que la imagen de contenedor no está en ejecución, no se está ejecutando, son solo los archivos empaquetados y los metadatos.
En contraste con una "imagen de contenedor" que es el contenido estático almacenado, un "contenedor" normalmente se refiere a la instancia en ejecución, lo que está siendo ejecutado.
Cuando el contenedor se inicia y está en ejecución (iniciado desde una imagen de contenedor) podría crear o cambiar archivos, variables de entorno, etc. Esos cambios existirán solo en ese contenedor, pero no persistirán en la imagen de contenedor subyacente (no se guardarán en disco).
Una imagen de contenedor es comparable al archivo y contenidos del programa, por ejemplo python y algún archivo main.py.
Y el contenedor mismo (en contraste con la imagen de contenedor) es la instancia en ejecución de la imagen, comparable a un proceso. De hecho, un contenedor está en ejecución solo cuando tiene un proceso en ejecución (y normalmente es un solo proceso). El contenedor se detiene cuando no hay ningún proceso ejecutándose en él.
Al usar una imagen de contenedor prehecha es muy fácil combinar y usar diferentes herramientas. Por ejemplo, para probar una nueva base de datos. En la mayoría de los casos, puedes usar las imágenes oficiales y simplemente configurarlas con variables de entorno.
De esa manera, en muchos casos puedes aprender sobre contenedores y Docker y reutilizar ese conocimiento con muchas herramientas y componentes diferentes.
Así, podrías ejecutar múltiples contenedores con diferentes cosas, como una base de datos, una aplicación Python, un servidor web con una aplicación frontend en React, y conectarlos entre sí a través de su red interna.
Todos los sistemas de gestión de contenedores (como Docker o Kubernetes) tienen estas características de red integradas en ellos.
Una imagen de contenedor normalmente incluye en sus metadatos el programa o comando por defecto que debería ejecutarse cuando se inicia el contenedor y los parámetros que se pasarán a ese programa. Muy similar a lo que sería si estuviera en la línea de comandos.
Cuando se inicia un contenedor, ejecutará ese comando/programa (aunque puedes sobrescribirlo y hacer que ejecute un comando/programa diferente).
Un contenedor está en ejecución mientras el proceso principal (comando o programa) esté en ejecución.
Un contenedor normalmente tiene un solo proceso, pero también es posible iniciar subprocesos desde el proceso principal, y de esa manera tendrás múltiples procesos en el mismo contenedor.
Pero no es posible tener un contenedor en ejecución sin al menos un proceso en ejecución. Si el proceso principal se detiene, el contenedor se detiene.
Si estás ejecutando tu contenedor detrás de un Proxy de Terminación TLS (balanceador de carga) como Nginx o Traefik, añade la opción --proxy-headers, esto le indicará a Uvicorn (a través del CLI de FastAPI) que confíe en los headers enviados por ese proxy indicándole que la aplicación se está ejecutando detrás de HTTPS, etc.
Hay un truco importante en este Dockerfile: primero copiamos el archivo con las dependencias únicamente, no el resto del código. Déjame explicarte por qué.
COPY./requirements.txt/code/requirements.txt
Docker y otras herramientas construyen estas imágenes de contenedor incrementalmente, añadiendo una capa sobre otra, comenzando desde el inicio del Dockerfile y añadiendo cualquier archivo creado por cada una de las instrucciones del Dockerfile.
Docker y herramientas similares también usan una caché interna al construir la imagen, si un archivo no ha cambiado desde la última vez que se construyó la imagen de contenedor, entonces reutilizará la misma capa creada la última vez, en lugar de copiar el archivo nuevamente y crear una nueva capa desde cero.
Simplemente evitar la copia de archivos no necesariamente mejora mucho las cosas, pero como se usó la caché para ese paso, puede usar la caché para el siguiente paso. Por ejemplo, podría usar la caché para la instrucción que instala las dependencias con:
El archivo con los requisitos de paquetes no cambiará frecuentemente. Así que, al copiar solo ese archivo, Docker podrá usar la caché para ese paso.
Y luego, Docker podrá usar la caché para el siguiente paso que descarga e instala esas dependencias. Y aquí es donde ahorramos mucho tiempo. ✨ ...y evitamos el aburrimiento de esperar. 😪😆
Descargar e instalar las dependencias de paquetes podría tardar minutos, pero usando la caché tardaría segundos como máximo.
Y como estarías construyendo la imagen de contenedor una y otra vez durante el desarrollo para comprobar que tus cambios en el código funcionan, hay mucho tiempo acumulado que esto ahorraría.
Luego, cerca del final del Dockerfile, copiamos todo el código. Como esto es lo que cambia con más frecuencia, lo ponemos cerca del final, porque casi siempre, cualquier cosa después de este paso no podrá usar la caché.
Cuando pasas el archivo a fastapi run detectará automáticamente que es un solo archivo y no parte de un paquete, y sabrá cómo importarlo y servir tu aplicación FastAPI. 😎
Hablemos nuevamente sobre algunos de los mismos Conceptos de Despliegue en términos de contenedores.
Los contenedores son principalmente una herramienta para simplificar el proceso de construcción y despliegue de una aplicación, pero no imponen un enfoque particular para manejar estos conceptos de despliegue, y hay varias estrategias posibles.
Las buenas noticias son que con cada estrategia diferente hay una forma de cubrir todos los conceptos de despliegue. 🎉
Revisemos estos conceptos de despliegue en términos de contenedores:
Si nos enfocamos solo en la imagen de contenedor para una aplicación FastAPI (y luego en el contenedor en ejecución), HTTPS normalmente sería manejado externamente por otra herramienta.
Podría ser otro contenedor, por ejemplo con Traefik, manejando HTTPS y la adquisición automática de certificados.
Consejo
Traefik tiene integraciones con Docker, Kubernetes y otros, por lo que es muy fácil configurar y configurar HTTPS para tus contenedores con él.
Alternativamente, HTTPS podría ser manejado por un proveedor en la nube como uno de sus servicios (mientras aún se ejecuta la aplicación en un contenedor).
Normalmente hay otra herramienta encargada de iniciar y ejecutar tu contenedor.
Podría ser Docker directamente, Docker Compose, Kubernetes, un servicio en la nube, etc.
En la mayoría (o todos) de los casos, hay una opción simple para habilitar la ejecución del contenedor al inicio y habilitar reinicios ante fallos. Por ejemplo, en Docker, es la opción de línea de comandos --restart.
Sin usar contenedores, hacer que las aplicaciones se ejecuten al inicio y con reinicios puede ser engorroso y difícil. Pero cuando se trabaja con contenedores en la mayoría de los casos esa funcionalidad está incluida por defecto. ✨
Si tienes un clúster de máquinas con Kubernetes, Docker Swarm Mode, Nomad u otro sistema complejo similar para gestionar contenedores distribuidos en múltiples máquinas, entonces probablemente querrás manejar la replicación a nivel de clúster en lugar de usar un gestor de procesos (como Uvicorn con workers) en cada contenedor.
Uno de esos sistemas de gestión de contenedores distribuidos como Kubernetes normalmente tiene alguna forma integrada de manejar la replicación de contenedores mientras sigue soportando balanceo de carga para las peticiones entrantes. Todo a nivel de clúster.
En esos casos, probablemente querrás construir una imagen Docker desde cero como se explicó anteriormente, instalar tus dependencias y ejecutar un solo proceso Uvicorn en lugar de usar múltiples workers de Uvicorn.
Cuando se usan contenedores, normalmente tendrías algún componente escuchando en el puerto principal. Posiblemente podría ser otro contenedor que también sea un Proxy de Terminación TLS para manejar HTTPS o alguna herramienta similar.
Como este componente tomaría la carga de peticiones y la distribuiría entre los workers de manera (con suerte) balanceada, también se le conoce comúnmente como Balanceador de Carga.
Consejo
El mismo componente Proxy de Terminación TLS usado para HTTPS probablemente también sería un Balanceador de Carga.
Y cuando se trabaja con contenedores, el mismo sistema que usas para iniciarlos y gestionarlos ya tendría herramientas internas para transmitir la comunicación de red (por ejemplo, peticiones HTTP) desde ese balanceador de carga (que también podría ser un Proxy de Terminación TLS) al contenedor o contenedores con tu aplicación.
Un Balanceador de Carga - Múltiples Contenedores Worker¶
Cuando se trabaja con Kubernetes o sistemas similares de gestión de contenedores distribuidos, usar sus mecanismos de red internos permitiría que el único balanceador de carga que está escuchando en el puerto principal transmita la comunicación (peticiones) posiblemente a múltiples contenedores ejecutando tu aplicación.
Cada uno de estos contenedores ejecutando tu aplicación normalmente tendría un solo proceso (por ejemplo, un proceso Uvicorn ejecutando tu aplicación FastAPI). Todos serían contenedores idénticos, ejecutando lo mismo, pero cada uno con su propio proceso, memoria, etc. De esa manera aprovecharías la paralelización en diferentes núcleos de la CPU, o incluso en diferentes máquinas.
Y el sistema de contenedores distribuidos con el balanceador de cargadistribuiría las peticiones a cada uno de los contenedores con tu aplicación por turnos. Así, cada petición podría ser manejada por uno de los múltiples contenedores replicados ejecutando tu aplicación.
Y normalmente este balanceador de carga podría manejar peticiones que van a otras aplicaciones en tu clúster (por ejemplo, a un dominio diferente, o bajo un prefijo de ruta URL diferente), y transmitiría esa comunicación a los contenedores correctos para esa otra aplicación ejecutándose en tu clúster.
En este tipo de escenario, probablemente querrías tener un solo proceso (Uvicorn) por contenedor, ya que estarías manejando la replicación a nivel de clúster.
Así que, en este caso, no querrías tener múltiples workers en el contenedor, por ejemplo con la opción de línea de comandos --workers. Querrías tener un solo proceso Uvicorn por contenedor (pero probablemente múltiples contenedores).
Tener otro gestor de procesos dentro del contenedor (como sería con múltiples workers) solo añadiría complejidad innecesaria que probablemente ya estás manejando con tu sistema de clúster.
Contenedores con Múltiples Procesos y Casos Especiales¶
Por supuesto, hay casos especiales donde podrías querer tener un contenedor con varios procesos worker de Uvicorn dentro.
En esos casos, puedes usar la opción de línea de comandos --workers para establecer el número de workers que quieres ejecutar:
Podrías querer un gestor de procesos en el contenedor si tu aplicación es lo suficientemente simple como para ejecutarla en un solo servidor, no un clúster.
Podrías estar desplegando en un solo servidor (no un clúster) con Docker Compose, así que no tendrías una forma fácil de gestionar la replicación de contenedores (con Docker Compose) mientras se preserva la red compartida y el balanceo de carga.
Entonces podrías querer tener un solo contenedor con un gestor de procesos que inicie varios procesos worker dentro.
El punto principal es que ninguna de estas son reglas escritas en piedra que debas seguir a ciegas. Puedes usar estas ideas para evaluar tu propio caso de uso y decidir cuál es el mejor enfoque para tu sistema, revisando cómo gestionar los conceptos de:
Si ejecutas un solo proceso por contenedor tendrás una cantidad de memoria consumida por cada uno de esos contenedores más o menos bien definida, estable y limitada (más de uno si están replicados).
Y luego puedes establecer esos mismos límites y requisitos de memoria en tus configuraciones para tu sistema de gestión de contenedores (por ejemplo en Kubernetes). De esa manera podrá replicar los contenedores en las máquinas disponibles teniendo en cuenta la cantidad de memoria que necesitan y la cantidad disponible en las máquinas del clúster.
Si tu aplicación es simple, esto probablemente no será un problema, y podría no ser necesario especificar límites estrictos de memoria. Pero si estás usando mucha memoria (por ejemplo con modelos de machine learning), deberías revisar cuánta memoria estás consumiendo y ajustar el número de contenedores que se ejecutan en cada máquina (y quizás añadir más máquinas a tu clúster).
Si ejecutas múltiples procesos por contenedor tendrás que asegurarte de que el número de procesos iniciados no consuma más memoria de la que está disponible.
Si tienes múltiples contenedores, probablemente cada uno ejecutando un solo proceso (por ejemplo, en un clúster de Kubernetes), entonces probablemente querrás tener un contenedor separado que haga el trabajo de los pasos previos en un solo contenedor, ejecutando un solo proceso, antes de ejecutar los contenedores worker replicados.
Nota
Si estás usando Kubernetes, esto probablemente sería un Init Container.
Si en tu caso de uso no hay problema en ejecutar esos pasos previos múltiples veces en paralelo (por ejemplo si no estás ejecutando migraciones de base de datos, sino solo comprobando si la base de datos ya está lista), entonces también podrías simplemente ponerlos en cada contenedor justo antes de iniciar el proceso principal.
Si tienes una configuración simple, con un solo contenedor que luego inicia múltiples procesos worker (o también un solo proceso), entonces podrías ejecutar esos pasos previos en el mismo contenedor, justo antes de iniciar el proceso con la aplicación.
Probablemente no deberías usar esta imagen Docker base (ni ninguna otra similar).
Si estás usando Kubernetes (u otros) y ya estás configurando la replicación a nivel de clúster, con múltiples contenedores. En esos casos, es mejor construir una imagen desde cero como se describió anteriormente: Construir una Imagen Docker para FastAPI.
Y si necesitas tener múltiples workers, puedes simplemente usar la opción de línea de comandos --workers.
Detalles Técnicos
La imagen Docker fue creada cuando Uvicorn no soportaba gestionar y reiniciar workers muertos, así que era necesario usar Gunicorn con Uvicorn, lo cual añadía bastante complejidad, solo para que Gunicorn gestionara y reiniciara los procesos worker de Uvicorn.
Pero ahora que Uvicorn (y el comando fastapi) soportan usar --workers, no hay razón para usar una imagen Docker base en lugar de construir la tuya propia (es prácticamente la misma cantidad de código 😅).
Usando sistemas de contenedores (por ejemplo con Docker y Kubernetes) se vuelve bastante sencillo manejar todos los conceptos de despliegue:
HTTPS
Ejecución al inicio
Reinicios
Replicación (el número de procesos en ejecución)
Memoria
Pasos previos antes de iniciar
En la mayoría de los casos, probablemente no querrás usar ninguna imagen base, y en su lugar construir una imagen de contenedor desde cero basada en la imagen Docker oficial de Python.
Cuidando el orden de las instrucciones en el Dockerfile y la caché de Docker puedes minimizar los tiempos de construcción, para maximizar tu productividad (y evitar el aburrimiento). 😎