Cuando se intenta escalar un sistema distribuido, un obstáculo común no es que no haya suficientes recursos disponibles, sino que no se utilizan de forma eficiente.
En DoorDash encontramos una oportunidad similar cuando trabajamos para escalar nuestro sistema de punto de venta (POS). Estábamos sufriendo cortes porque nuestro sistema de punto de venta no podía escalar para satisfacer los picos de demanda. El problema no era la falta de recursos, sino que estos no se utilizaban de forma eficiente.
El sistema de punto de venta de DoorDash recopila nuevos pedidos y los envía a los sistemas de punto de venta de los comerciantes a través de sus API. Sin embargo, debido a la latencia típica de la red y del sistema, el tiempo que tardaban los terminales del comerciante en responder a las solicitudes de creación de pedidos era pequeño pero significativo. Dado que el sistema heredado debía esperar a que respondiera el sistema de punto de venta del comerciante, nuestra eficiencia en la utilización de recursos se veía gravemente limitada.
Nuestra solución consistió en pasar de un modelo de entrada/salida (E/S) síncrono a otro asíncrono, lo que permitió aprovechar mejor nuestros recursos, multiplicar por 10 nuestra capacidad de pedidos y evitar cortes adicionales.
El ciclo de vida de un pedido
El backend de DoorDash consta de varios microservicios que se comunican a través de API REST o gRPC. Nuestro microservicio POS reenvía los pedidos a los comerciantes.
Una vez que el usuario finaliza el proceso de pago, ya sea en la aplicación móvil de DoorDash o en el sitio web, el pedido se envía al microservicio de punto de venta. En este punto, el sistema de punto de venta reenvía el pedido al comerciante adecuado a través de una serie de llamadas a la API.
Dado que el envío del pedido al comerciante puede tardar varios segundos, la comunicación con POS es asíncrona y se realiza utilizando Celery, una librería de Python que proporciona una capa RPC sobre diferentes brokers, en nuestro caso RabbitMQ. La solicitud de pedido será recogida por uno de los trabajadores POS Kubernetes, procesada y reenviada al comerciante.
Cómo configuramos el apio
Celery es un framework Python distribuido diseñado para gestionar tareas asíncronas. Cada pod POS Kubernetes ejecuta cuatro procesos Celery. Cada proceso espera a que haya una nueva tarea disponible, la recupera y ejecuta la llamada de retorno de Python para ponerse en contacto con el comerciante y crear un nuevo pedido. Este proceso es síncrono y el proceso Celery no puede ejecutar nuevas tareas hasta que la tarea actual se haya completado.
Los problemas del enfoque heredado
Este enfoque heredado presenta bastantes problemas. En primer lugar, los recursos están infrautilizados. Cuando la tasa de pedidos es alta, la mayor parte del tiempo de CPU se dedica a esperar a que los puntos finales del comerciante respondan. Este tiempo dedicado a esperar a que el comerciante responda, junto con el bajo número de procesos Celery que hemos configurado, da como resultado un uso medio de la CPU inferior al 5%. El efecto general de nuestro enfoque heredado es que acabamos necesitando muchos más recursos de los necesarios, lo que limita la escalabilidad del sistema.
El segundo problema es que un aumento de la latencia en una API comercial puede agotar todas las tareas de Celery y hacer que las solicitudes a otras API comerciales (sanas) se acumulen en RabbitMQ. En el pasado, esta acumulación provocó una pérdida significativa de pedidos, lo que perjudicó al negocio y a la confianza de los usuarios.
Estaba claro que aumentar el número de procesos Celery sólo habría mejorado ligeramente esta situación, ya que cada proceso requiere una cantidad significativa de memoria para ejecutarse, lo que limita la concurrencia máxima alcanzable.
Las soluciones que barajamos
Para resolver este problema de POS consideramos tres opciones:
- Utilizar un pool de hilos de Python
- Aprovechamiento de la función AsyncIO de Python 3
- Utilizar una biblioteca de E/S asíncrona como Gevent
Grupo de hilos
Una posible solución sería pasar a un modelo con un único proceso y hacer que un thread pool ejecutor de Python ejecutara las tareas. Dado que la huella de memoria de un hilo es significativamente menor que la del proceso heredado, esto nos habría permitido aumentar significativamente nuestro nivel de concurrencia, pasando de cuatro procesos Celery por pod a un único proceso con 50 a 100 hilos.
Las desventajas de este enfoque de pool de hilos son que, dado que Celery no soporta de forma nativa múltiples hilos, tendríamos que escribir la lógica para reenviar las tareas al pool de hilos y tendríamos que gestionar la concurrencia, resultando en un aumento significativo de la complejidad del código.
AsyncIO
La característica AsyncIO de Python 3 nos habría permitido tener una alta concurrencia sin tener que preocuparnos por la sincronización, ya que el contexto se cambia sólo en puntos bien definidos.
Desafortunadamente, cuando consideramos usar AsyncIO para resolver este problema, el actual Celery 4 no soportaba AsyncIO de forma nativa, haciendo que una posible implementación por nuestra parte fuera significativamente menos sencilla.
Además, todas las librerías de entrada/salida que utilizamos tendrían que soportar AsyncIO para que esta estrategia funcionara correctamente con el resto del código. Esto nos habría obligado a sustituir algunas de las bibliotecas que utilizábamos, como HTTP, por otras compatibles con AsyncIO, lo que habría supuesto cambios de código potencialmente significativos.
Evento
Finalmente decidimos usar Gevent, una librería de Python que envuelve greenlets de Python. Gevent funciona de forma similar a AsyncIO: cuando hay una operación que requiere E/S, el contexto de ejecución se cambia a otra parte del código que ya tuvo su anterior petición de E/S cumplida y por lo tanto está lista para ser ejecutada. Este método es similar al uso de la función select() de Linux, pero en su lugar es manejado de forma transparente por Gevent .
Otra ventaja de Gevent es que Celery lo soporta de forma nativa, requiriendo únicamente un cambio en el modelo de concurrencia. Este soporte facilita la configuración de Gevent con las herramientas existentes.
En general, el uso de Gevent resolvería el problema al que se enfrentaba DoorDash al permitirnos aumentar el número de tareas de Celery en dos órdenes de magnitud.
¡Monkey patch todo!
Una arruga en la adopción de Gevent es que no todas nuestras bibliotecas de E/S podían liberar el contexto de ejecución para ser ocupado por otra parte del código. A los creadores de Gevent se les ocurrió una solución inteligente para este problema.
Gevent incluye versiones Gevent-aware de las librerías de bajo nivel de E/S más comunes, como socket, y ofrece un método para hacer que las otras librerías existentes utilicen las de Gevent. Como se indica en el ejemplo de código de abajo, este método se conoce como monkey patching. Aunque este método puede parecer un hack, ha sido utilizado en producción por grandes compañías tecnológicas, incluyendo Pinterest y Lyft.
> from gevent import monkey
> monkey.patch_all()
Añadir las dos líneas anteriores al principio del código de la aplicación hace que Gevent sobrescriba automáticamente todos los módulos importados posteriores y utilice en su lugar las librerías de E/S de Gevent.
Implantación de Gevent en nuestro sistema POS
En lugar de introducir directamente los cambios de Gevent en el servicio POS existente, creamos una nueva aplicación paralela que tenía los parches relacionados con Gevent, y desviamos gradualmente el tráfico hacia ella. Esta estrategia de implementación nos daba la posibilidad de volver a la antigua aplicación no Gevent si algo iba mal.
La implantación de la nueva aplicación Gevent requirió los siguientes cambios:
Paso 1: Instale el siguiente paquete Python
pip install gevent
Paso 2: Parche de mono Apio
La aplicación no-Gevent tenía el siguiente código, en el directorio celery.py
para iniciar Celery:
from celery import Celery
from app import rabbitmq_url
main_app = Celery('app', broker=rabbitmq_url)
@app.task
def hello():
return 'hello world'
La aplicación Gevent incluía el siguiente archivo, gcelery.py,
junto con el archivo celery.py.
from gevent import monkey
monkey.patch_all(httplib=False)
import app.celery
from app.celery import main_app
Utilizamos el código monkey.patch_all(httplib=False)
para parchear cuidadosamente todas las partes de las librerías con funciones Gevent-friendly que se comportan de la misma manera que las funciones originales. Este parcheo se realiza al principio del ciclo de vida de la aplicación, antes del inicio de Celery, como se muestra en celery.py.
Paso 3: Puesta en marcha del trabajador Celery.
La aplicación no-Gevent arranca Celery worker de la siguiente manera:
exec celery -A app worker --concurrency=3 --loglevel=info --without-mingle
La aplicación Gevent arranca Celery worker de la siguiente manera:
exec celery -A app.gcelery worker --concurrency=200 --pool gevent --loglevel=info --without-mingle
Las siguientes cosas cambiaron cuando implantamos la aplicación Gevent:
- El -
A
determina la aplicación que se va a ejecutar. - En la aplicación no-Gevent, arrancamos directamente
celery.py.
- En la aplicación Gevent,
app.gcelery
se ejecuta. La direccióngcelery.py
parchea primero todas las bibliotecas subyacentes antes de llamar a la aplicacióncelery.py.
El argumento de línea de comandos -- concurrency determina el número de procesos/hilos. En la aplicación no Gevent, hemos generado sólo tres procesos. En la aplicación Gevent, hemos generado sólo doscientos hilos verdes.
La línea de comandos --pool nos permite elegir entre procesos o hilos. En la aplicación Gevent, seleccionamos Gevent ya que nuestras tareas están más ligadas a la E/S.
Resultados
Después de desplegar la nueva aplicación POS que utilizaba Gevent en producción, obtuvimos excelentes resultados. En primer lugar, pudimos reducir nuestros pods de Kubernetes en un factor de 10, reduciendo a su vez la cantidad de CPU que utilizábamos en un 90 %. A pesar de esta reducción, nuestra capacidad de procesamiento de pedidos aumentó un 600 %.
Además, la utilización de la CPU de cada módulo pasó de menos del 5% a aproximadamente el 30%.
Nuestro nuevo sistema también podía gestionar un ritmo mucho mayor de solicitudes, ya que redujimos significativamente el número de interrupciones debidas a API de comerciantes que se comportaban mal y consumían la mayor parte de nuestras tareas de Celery.
Conclusiones
Gevent ayudó a DoorDash a mejorar la escalabilidad y fiabilidad de nuestros servicios. También permitió a DoorDash utilizar eficientemente los recursos durante las operaciones de E/S mediante el uso de E/S asíncronas basadas en eventos, reduciendo así nuestro uso de recursos.
Muchas empresas desarrollan microservicios basados en Python que son síncronos durante las operaciones de E/S con otros servicios, bases de datos, cachés y corredores de mensajes. Estas empresas pueden tener una oportunidad similar a la de DoorDash para mejorar la escalabilidad, fiabilidad y eficiencia de sus servicios haciendo uso de esta solución.