Introducción a Celery I
Una introducción a Celery, una herramienta que permite sacar el trabajo pesado de la petición HTTP y delegarlo en workers que lo procesan aparte.
Introducción a Celery: qué es y para qué sirve
1. Introducción
1.1. Workers finitos
En producción, una aplicación backend desarrollada en Python, como Django o FastAPI, suele ejecutarse mediante un servidor como Gunicorn, uWSGI o Uvicorn, que puede atender varias peticiones al mismo tiempo, pero su capacidad no es infinita. Estos servidores disponen de un número limitado de procesos o workers web para atender las peticiones y mientras atiende una no puede resolver otra.
Por ejemplo, supongamos que tenemos cuatro workers:
Worker 1: atiende una petición
Worker 2: atiende una petición
Worker 3: atiende una petición
Worker 4: atiende una petición
Cada worker atiende una petición y, cuando termina, queda disponible para atender la siguiente.
Hasta ahí el sistema funciona, pero todo comienza a complicarse si las peticiones deben realizar trabajos pesados, como puede ser generar un informe que tarde 30 segundos. Mientras se genera, el worker que recibió la petición permanece ocupado durante esos 30 segundos. Si llegan cuatro peticiones similares al mismo tiempo, todos los workers quedan ocupados:
Worker 1: generando informe durante 30 segundos
Worker 2: generando informe durante 30 segundos
Worker 3: generando informe durante 30 segundos
Worker 4: generando informe durante 30 segundos
Durante ese tiempo, las nuevas peticiones no tienen ningún worker disponible, por lo que se van acumulando en la cola de espera y, si siguen llegando, se pueden producir todo tipo de problemas, como respuestas cada vez más lentas, agotamiento de memoria o CPU, errores por tiempo de espera (timeouts), peticiones rechazadas o incluso caída o bloqueo del servicio.
Otro tipo de tareas problemáticas son aquellas que no consumen mucha CPU, pero pasan mucho tiempo esperando, como puede suceder con las que necesitan una respuesta de una API externa, el envío de un correo, una consulta lenta a la base de datos, la lectura o escritura de un archivo, la subida de un documento a un servicio remoto o determinadas operaciones contra modelos de IA.
Además, mientras el worker espera una respuesta, puede estar bloqueado y no atender otra petición. El servidor no está necesariamente calculando algo, pero uno de sus workers sigue ocupado.
El código asíncrono puede mejorar algunos de estos casos, pero sigue habiendo otro problema y es que la petición continúa dependiendo de que todas las operaciones terminen correctamente. Cuando ejecutamos todo dentro de la petición HTTP, el trabajo queda ligado a ella:
Petición > Validar datos > Guardar en la base de datos > Generar informe > Enviar correo > Responder al usuario
Si generar el informe y enviar el correo tardan 20 segundos, el usuario esperará 20 segundos para recibir la respuesta. Y entremedias pueden suceder varias cosas, como que el navegador cancele la petición, un proxy corte la conexión por timeout o que alguna parte del proceso falle, por lo que, aunque el trabajo principal ya estuviera hecho, la petición podría terminar mostrando un error al usuario.
1.2. Colas de tareas
Una manera de prevenir estos posibles problemas es preparar una cola de tareas que permita separar la respuesta del trabajo pesado. Una cola de tareas es un mecanismo que permite guardar trabajos pendientes para que otros procesos los ejecuten cuando tengan capacidad.
Normalmente no contiene la función ni el código que debe ejecutarse, sino un mensaje que describe el trabajo que se debe realizar:
{
"task": "send_welcome_email",
"args": ["asdrubal@example.com"]
}
Como veremos, el código a ejecutar, send_welcome_email en este ejemplo, ya está instalado en los procesos encargados de ejecutar las tareas.
En este sistema intervienen tres piezas: el productor, que crea la tarea (puede ser, por ejemplo, un Django o un FastAPI); el broker, que gestiona los mensajes, siendo la cola uno de los lugares dentro de él donde esos mensajes esperan; y el consumidor, que recoge y ejecuta las tareas, y que en Celery es el worker.
Podemos compararlo con las comandas de un restaurante. El camarero toma el pedido y es el productor, el Django por ejemplo. La comanda queda registrada, eso es el broker y la cola. Un cocinero recoge la comanda y es el worker. Y el cocinero prepara el plato, que es ejecutar la tarea.
El camarero no entra en la cocina para preparar cada plato. Registra el pedido y queda disponible para atender a otros clientes. La cola permite que los cocineros trabajen a su ritmo y que varios cocineros se repartan las comandas.
1.3. ¿Qué es Celery?
Celery es una librería de Python, de código abierto, que implementa un sistema de colas de tareas distribuido. Nació en 2009 y hoy es prácticamente el estándar de facto en el ecosistema Python cuando se habla de ejecutar tareas en segundo plano.
Es open source y lleva más de 15 años de desarrollo activo, tiene una comunidad enorme y se usa en producción en empresas de todos los tamaños, desde startups hasta compañías como Instagram o Mozilla.
Celery por sí solo no hace nada. Para funcionar necesita un broker, el sistema de mensajería que vimos antes. Los más habituales son:
- Redis: rápido, fácil de instalar, y como además puede actuar como backend de resultados, es la opción más popular para empezar y para proyectos de tamaño pequeño-mediano.
- RabbitMQ: un broker de mensajería más robusto y con más garantías (confirmaciones de entrega, colas persistentes más maduras), típico en entornos donde la fiabilidad del mensaje es crítica.
Y por supuesto, se combina con frameworks web como Django, Flask o FastAPI, que son los productores que encolan las tareas.
Entre sus virtudes podemos destacar que es una herramienta que lleva más de una década resolviendo este problema y que escala bien. Puedes tener uno o cientos de workers procesando la misma cola, en la misma máquina o repartidos en varios servidores, sin cambiar el código de la tarea.
Además soporta reintentos automáticos si una tarea falla, límites de tiempo, prioridades, rutas de tareas a colas específicas, y encadenar tareas (que el resultado de una alimente a la siguiente).
En síntesis, nos sirve para desacoplar una aplicación. Tu app backend deja de ser responsable de que el trabajo pesado termine bien y eso pasa a ser responsabilidad del worker, que puede reintentar, loguear errores, etc., sin afectar la experiencia del usuario. Y como extra viene con Celery Beat, que resuelve el problema de las tareas periódicas sin tener que montar un cron aparte y coordinarlo con tu aplicación.
Dicho esto, vamos con un hola mundo.
2. Hola mundo con Celery
2.1. Piezas clave: un glosario rápido
Antes de entrar en materia, voy a recordar cuatro términos que vamos a ver una y otra vez a partir de ahora:
- Productor: quien crea la tarea y la encola. En nuestro caso, será un Django.
- Broker: el sistema de mensajería que recibe las tareas encoladas y las guarda hasta que un worker las recoge. Suele ser Redis o RabbitMQ.
- Worker: el proceso que está escuchando al broker, recoge las tareas pendientes y las ejecuta. Es el propio Celery corriendo como proceso aparte de Django.
- Backend de resultados: un almacén (normalmente también Redis) donde el worker guarda el resultado de una tarea una vez terminada, por si alguien necesita consultarlo más tarde. Es opcional: si no te importa el resultado (como al enviar un email), puedes prescindir de él.
Es fácil confundir broker y backend de resultados porque muchas veces usan la misma tecnología (Redis), pero cumplen roles distintos: el broker guarda tareas pendientes de ejecutar, y una vez que un worker la recoge, desaparece de ahí; el backend de resultados guarda resultados ya calculados, para que se puedan consultar después.
Con esto ya tenemos el vocabulario mínimo. Vamos con el hola mundo, que será un ejemplo mínimo con Python. El objetivo es comprobar el recorrido completo de una tarea:
Python > Celery > Redis > Cola > Worker > Ejecución
Para ello necesitaremos tres elementos: una aplicación Python que publique la tarea, Redis actuando como broker, y un worker de Celery que recoja y ejecute la tarea.
2.2. Instalar Celery
Creamos una carpeta para el ejemplo y, dentro de ella, instalamos Celery con las dependencias necesarias para utilizar Redis:
uv init --name celery-lab (o cómo quieras llamarlo)
uv add "celery[redis]"
Si no estamos utilizando uv, podemos hacerlo con pip:
pip install "celery[redis]"
2.3. Levantar Redis
Redis será nuestro broker. Para no tener que instalarlo directamente en el sistema, podemos ejecutarlo en un contenedor de Docker:
docker run \
--name celery-redis \
-p 127.0.0.1:6379:6379 \
redis:7-alpine
Con este comando hemos creado un contenedor llamado celery-redis y hemos expuesto Redis en el puerto 6379, su puerto predeterminado.
Mientras el contenedor esté en ejecución, nuestro broker estará disponible en:
redis://localhost:6379
Si detenemos el contenedor y queremos volver a iniciarlo más adelante, no tenemos que crearlo otra vez:
docker start celery-redis
En producción normalmente no se ejecutaría así. Lo más probable es que se tire de alguna de estas opciones: Redis instalado en un servidor propio, Redis ejecutándose en otro contenedor de la misma infraestructura, o un servicio gestionado online, como AWS ElastiCache, Azure Cache for Redis o Redis Cloud.
En ese caso, el proveedor nos daría una dirección de conexión parecida a esta:
rediss://usuario:contraseña@servidor-remoto:6379/0
Y Celery se configuraría con esa dirección:
app = Celery(
"tasks",
broker="rediss://usuario:contraseña@servidor-remoto:6379/0",
)
La s de rediss:// indica que la conexión está cifrada mediante TLS, algo habitual en servicios remotos.
La arquitectura es la misma en ambos casos:
Desarrollo:
Celery > Redis local en Docker
Producción:
Celery > Redis remoto o gestionado
Pero para aprender y hacer pruebas, Docker es más sencillo, gratuito y no requiere cuentas ni credenciales.
2.4. Nuestra primera tarea
Creamos un archivo llamado tasks.py y ahí definimos las primeras importaciones: time, un módulo incluido en la biblioteca estándar de Python que nos servirá para simular una tarea lenta, y Celery.
import time
from celery import Celery
Luego creamos una instancia de Celery:
app = Celery(
"tasks",
broker="redis://localhost:6379/0",
)
El primer argumento, "tasks", es el nombre de nuestra aplicación de Celery. Con broker indicamos dónde se encuentra el sistema de mensajería al que Celery debe enviar las tareas.
La parte final de la dirección, /0, indica que utilizaremos la base de datos lógica número 0 de Redis.
A continuación, decoramos la función saludar con @app.task:
@app.task
def saludar(nombre):
time.sleep(5)
print(f"Hola, {nombre}")
return f"Saludo enviado a {nombre}"
Este decorador registra la función como una tarea de Celery. A partir de ese momento podrá ejecutarse de dos formas diferentes.
Podemos llamarla como una función normal:
saludar("Asdrúbal")
En ese caso, la función se ejecutará inmediatamente en el proceso actual. No se publicará ningún mensaje ni intervendrá el broker.
También podemos solicitar su ejecución mediante Celery utilizando el método delay():
saludar.delay("Asdrúbal")
En este segundo caso, la función no se ejecutará en el proceso actual. Celery creará un mensaje con el nombre de la tarea y sus argumentos y lo enviará a Redis.
2.5. Arrancar el worker
Ahora necesitamos iniciar el proceso que recogerá las tareas de la cola. Desde la carpeta que contiene tasks.py, ejecutamos:
uv run celery -A tasks worker --loglevel=INFO
Si hemos instalado Celery con pip, podemos ejecutar:
celery -A tasks worker --loglevel=INFO
El parámetro -A tasks indica que la aplicación de Celery se encuentra en el módulo tasks.py.
Cuando arranque, Celery mostrará información sobre su configuración: la dirección del broker, el número de procesos disponibles, las colas que está escuchando y las tareas que tiene registradas.
En la lista de tareas debería aparecer la que acabamos de crear:
[tasks]
. tasks.saludar
El worker se queda ejecutándose y esperando la llegada de mensajes. Todavía no está realizando ningún trabajo, pero permanece escuchando la cola predeterminada de Celery.
Si ejecutamos el ejemplo directamente en Windows y el worker da problemas con el sistema de procesos, podemos iniciar un worker de desarrollo con un único proceso:
uv run celery -A tasks worker --loglevel=INFO --pool=solo
2.6. Enviar una tarea
Sin cerrar el worker, abrimos otro terminal e iniciamos una consola de Python:
uv run python
Importamos la tarea y solicitamos su ejecución:
from tasks import saludar
resultado = saludar.delay("Asdrúbal")
La llamada a delay() termina casi inmediatamente, aunque dentro de la tarea hayamos añadido una espera de cinco segundos.
Esto sucede porque el proceso actual no está ejecutando saludar. Únicamente está publicando en Redis un mensaje que describe la tarea:
Tarea: tasks.saludar
Argumentos: ["Asdrúbal"]
Si observamos el terminal del worker, después de cinco segundos veremos algo parecido a esto:
[2026-08-29 12:15:55,936: INFO/MainProcess] Task tasks.saludar[97dadfc2-ed08-4e60-90fc-c3346f1dc89a] received
Que se divide en esto:
[fecha y hora: nivel/proceso] mensaje
Concretamente:
- 2026-08-29 12:15:55,936: fecha y hora exactas.
- INFO: nivel del log.
- MainProcess: proceso principal de Celery que recibió el mensaje.
- tasks.saludar: nombre completo de la tarea.
- 97dadfc2-...: identificador único de esa ejecución.
- received: la tarea ha sido recibida.
Pues con esto ya tendríamos funcionando el recorrido completo:
- Consola de Python
- saludar.delay("Asdrúbal")
- Redis recibe y conserva el mensaje
- El worker recoge el mensaje
- El worker ejecuta saludar("Asdrúbal")
2.7. ¿Y el valor devuelto?
La tarea devuelve este valor:
return f"Saludo enviado a {nombre}"
Sin embargo, por ahora solamente hemos configurado un broker. Redis transporta el mensaje hasta el worker, pero Celery no tiene configurado ningún lugar en el que guardar el resultado.
La llamada a delay() devuelve un objeto AsyncResult:
resultado = saludar.delay("Asdrúbal")
print(resultado.id)
Como hemos visto, este objeto contiene, entre otras cosas, el identificador único de la tarea, pero para consultar correctamente su estado y recuperar el valor devuelto necesitaremos configurar el backend de resultados del que hablamos antes, ese almacén separado del broker donde se guardan los resultados ya calculados.
Lo veremos en la siguiente entrada de esta serie breve dedicada a Celery.