Saltar al contenido
🐍 Curso de Python
/

Módulo 5 · Lección 29 de 30

Desplegar una API

Dos cabeceras donde Django ponía seis, el número de trabajadores que no es el de siempre, y el arranque que se ejecuta una vez por proceso.

⏱ 16 min de lectura

La lección 43 del módulo 4 desplegó una web con Django. Aquí se despliega una API con FastAPI, y las diferencias importan más de lo que parece, porque todo lo que Django hacía por defecto hay que ponerlo.

Se ve con una medida sencilla: las cabeceras de una respuesta.

El servidor#

En desarrollo, fastapi dev recarga al guardar. En producción hay dos caminos:

bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
bash
gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000

Los dos funcionan. Cuál usar:

Uvicorn solo basta si quien orquesta es otro: Kubernetes, un sistema de contenedores o el systemd de la lección 43 del módulo 4. Reiniciar un proceso caído es trabajo de quien está por encima.

Gunicorn como director es lo que conviene en una máquina normal, porque hace lo que Uvicorn no: reinicia trabajadores que se cuelgan, recarga sin cortar el servicio, y sabe apagarse esperando a que terminen las peticiones en curso.

Y los complementos importan:

bash
pip install "uvicorn[standard]"
Salida en pantalla
uvloop 0.22.1 | httptools: instalado

uvloop sustituye el bucle de eventos de Python por uno más rápido y httptools hace lo mismo con el análisis de HTTP. Los dos entran con el extra, y a diferencia del [standard] de FastAPI de la lección 23, este sí merece la pena: son dos piezas de rendimiento, no un cliente de un servicio comercial.

Cuántos procesos#

Aquí hay una diferencia con Django que se suele pasar por alto:

Salida en pantalla
núcleos: 8
regla clásica (2n+1): 17
regla asíncrona (n):   8

La regla de 2n+1 es para servidores síncronos, donde cada trabajador atiende una petición cada vez y casi siempre está esperando a la base de datos. Ahí sobresuscribir tiene sentido.

Con trabajadores asíncronos, no. Cada uno atiende cientos de peticiones a la vez por su cuenta, así que lo que limita es el procesador, y poner más procesos que núcleos solo añade cambios de contexto y memoria.

La regla práctica es un trabajador por núcleo, y bajarlo si la máquina es pequeña, porque cada uno mantiene su propio depósito de conexiones a la base de datos. Cuatro trabajadores con veinte conexiones cada uno son ochenta conexiones, y PostgreSQL por defecto acepta cien.

Ese cálculo hay que hacerlo:

Salida en pantalla
trabajadores × pool_size  ≤  conexiones que aguanta la base de datos

Con más, el síntoma es un error de "demasiadas conexiones" que aparece solo con carga y desaparece al reiniciar.

El ciclo de vida#

Lo que se hace al arrancar y al parar:

python
from contextlib import asynccontextmanager


@asynccontextmanager
async def ciclo_de_vida(app: FastAPI):
    # al arrancar
        async with motor.connect() as c:
                await c.execute(text('select 1'))
    yield
    # al parar
    await motor.dispose()


app = FastAPI(lifespan=ciclo_de_vida)

Lo de antes del yield se ejecuta una vez al levantar el proceso, lo de después al apagarlo.

Sirve para lo que debe crearse una vez: el cliente HTTP compartido —que es la sesión de la lección 5, y crear uno por petición era el error de allí—, la conexión a Redis, precargar algo en memoria.

Y la comprobación de la base de datos al arrancar merece la pena: es mejor que el proceso no levante a que levante y falle en cada petición. Con un despliegue gradual, un proceso que no arranca detiene el despliegue y deja la versión anterior sirviendo; uno que arranca roto sustituye a la buena.

Lo de después del yield es lo que permite un apagado limpio, y depende de que quien orqueste mande la señal correcta y espere. Es el graceful timeout de Gunicorn y el terminationGracePeriodSeconds de Kubernetes.

Salud y preparación#

Dos rutas distintas, y la diferencia importa:

python
@app.get('/salud')
async def salud():
        return {'estado': 'ok'}


@app.get('/preparado')
async def preparado():
        async with Sesion() as s:
                await s.execute(text('select 1'))
    return {'estado': 'preparado'}

La de salud dice si el proceso vive y no debe tocar nada externo. Si la base de datos se cae y esta ruta falla, quien orqueste reiniciará los procesos, que es justo lo que no ayuda y lo que multiplica el problema.

La de preparación dice si puede atender peticiones. Esa sí comprueba las dependencias, y su fallo debe hacer que el repartidor de carga deje de mandarle tráfico sin reiniciar nada.

Confundirlas es el error clásico, y el síntoma es una caída de base de datos que además reinicia todos los procesos en bucle.

CORS#

De fábrica, nada:

Salida en pantalla
sin configurar:
  Access-Control-Allow-Origin: None
  OPTIONS previo -> 405

Que es lo correcto: un navegador no dejará que una página de otro dominio llame a tu API.

Con el intermediario puesto:

python
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=['https://mi-web.es'],
    allow_credentials=True,
    allow_methods=['*'],
    allow_headers=['*'],
)
Salida en pantalla
desde https://mi-web.es -> allow-origin: https://mi-web.es
desde https://otro.es   -> allow-origin: None

La lista blanca funciona. Y hay una combinación que hay que evitar:

python
app.add_middleware(
    CORSMiddleware,
    allow_origins=['*'],          # MAL: el comodín
    allow_credentials=True,       # junto con las credenciales
)

Es contradictoria y el propio navegador la rechaza. Lo que suele pasar después es que alguien, para «arreglarlo», pone la lista de orígenes leyendo la cabecera que le manda el cliente, y eso es abrir la API a cualquier página de internet con las credenciales del usuario incluidas.

CORS protege al usuario del navegador, no a tu API: un programa que no sea un navegador se salta CORS entero. No es un mecanismo de autorización, y confundirlo con uno es lo que lleva a dejar puntos de acceso sin permisos «porque solo los llama nuestra web».