¿Qué es una espiral de muerte?
Espirales de muerte de un solo nodo
Un antiguo empleador mío fue contratado para construir un sistema de enrutamiento de correo electrónico para una de las mayores empresas del mundo. Me encargaron el diseño y la especificación del hardware, y luego otro grupo se puso a construirlo. El diseño tenía dos resolvedores de DNS dedicados, cinco servidores para gestionar el correo entrante y ponerlo en cola para los escáneres de spam y virus, hosts separados para el escaneo de spam y virus, y hosts separados para enrutar el correo saliente.
La semana en que el sistema se puso en producción, recibí una llamada: era demasiado lento, el correo se acumulaba y se rechazaban las conexiones. Empecé a hacer preguntas y descubrí que lo que se había implementado en realidad se había modificado "ligeramente" con respecto a mi diseño original para ahorrar algo de dinero: eliminaron los resolvedores DNS dedicados, en su lugar ejecutaban BIND en cada host, y ejecutaban los escáneres de virus en los hosts que gestionaban el correo electrónico entrante. Por supuesto, mantuvieron la misma cantidad de RAM, y BIND utiliza mucha RAM para sí mismo, por no hablar del escáner de virus.
Lo primero que hizo el administrador de sistemas cuando empezó el problema fue aumentar el número de conexiones SMTP entrantes que los servidores aceptaban simultáneamente. Esto empeoró considerablemente el problema. Cuando le dije que tenía que reducirlo a un número muy inferior al que había hasta entonces, en torno a diez, discutió conmigo: ¡por supuesto que aumentar el número de envíos simultáneos debería hacerlo más rápido, y reducirlo lo haría más lento! Mi respuesta fue sencilla: no puede estar en swap. El sistema estaba usando tanta RAM que estaba intercambiando a disco, haciendo que todo fuera a rastras.
Reducir el número de conexiones SMTP simultáneas aceleró significativamente la entrega de correo electrónico y casi eliminó los problemas. Acabaron añadiendo más servidores: más o menos el mismo número que pensaban ahorrarse compartiendo los hosts entre varios servicios.
La concurrencia con la que funciona un servicio, o el número de solicitudes que atiende al mismo tiempo, está relacionada con la tasa de solicitudes entrantes y la cantidad de tiempo que se tarda en atender una solicitud individual de la siguiente manera:
R = C / T
donde C es la concurrencia, R es la tasa de peticiones en peticiones por segundo, y T es la cantidad de tiempo que tarda el sistema en atender una petición, de principio a fin, en segundos. Podría parecer obvio al ver esta ecuación que la forma de aumentar el número de peticiones que un sistema puede atender es aumentar la concurrencia máxima, y de hecho esto es lo que muchos administradores de sistemas intentan hacer: ven que Apache se queja de haber alcanzado MaxClients (o lo que sea), así que aumentan MaxClients. Y su tasa de peticiones cae en picado.
El problema es que T dista mucho de ser una cantidad fija. Depende de todo tipo de factores: I/O si la petición llega al disco, la memoria disponible, el ancho de banda de red disponible, la velocidad del cliente, la latencia y el tiempo de servicio de las peticiones que esta petición genera, y muchos más. A medida que C aumenta y uno de los recursos disponibles empieza a estar totalmente utilizado o sobrecargado, T aumentará más rápido que C y la tasa de peticiones caerá.
Si no hay ningún límite en la concurrencia, muchos servicios muestran lo que yo llamo un "efecto precipicio": la velocidad a la que se atienden las peticiones cae drásticamente, y el tiempo para atender una sola petición aumenta en uno o dos órdenes de magnitud. Esto puede deberse a que los hosts empiecen a intercambiarse, a algoritmos de programación que no escalan bien o a cualquier número de efectos sutiles que no es realmente importante entender.
Lo más importante que hay que recordar aquí es que menos concurrencia suele ser mejor, y que siempre hay que limitarla. En realidad, la mejor manera de averiguar cuál debe ser la concurrencia máxima es medirla. Compara el sistema con varios niveles de concurrencia y elige el nivel que te proporcione la tasa máxima y, al mismo tiempo, sirva las peticiones individuales en un tiempo razonable cuando se alcance el límite de concurrencia.
Espirales de muerte distribuidas
Otro antiguo empleador mío se embarcó en un ambicioso proyecto para sustituir los accesos a bases de datos por servicios web. Contrataron a un grupo de desarrolladores PHP para que revisaran cada fragmento de SQL en el código de la aplicación, escribieran un servicio web que hiciera lo mismo y sustituyeran el código de la aplicación por una llamada al servicio web. El proyecto fue un desastre.
Hubo varios lugares en los que una única petición daba lugar a peticiones a varias bases de datos que tenía sentido escribir como un único servicio web. Dado que era necesario llamar tanto a las consultas individuales a las bases de datos como a las consultas compuestas, ambas se expusieron como servicios web. Varios de los servicios web llamaban a otros servicios web alojados en el mismo clúster. En última instancia, el clúster estaba formado por un centenar de hosts con varios espacios de nombres de URL diferentes en grupos de hosts potencialmente superpuestos, con varios equilibradores de carga delante de ellos ejecutando Pound.
Varios de los servicios web acabaron siendo mucho más lentos que el código SQL y C++ existente. Con unos 5.000 servidores de aplicaciones y sólo unos 100 hosts de servicios web, esto era un problema. Sin embargo, lo más interesante y educativo era lo que ocurría incluso cuando sólo un pequeño número de servidores de aplicaciones apuntaban a los servicios web. La carga en los hosts de servicios web variaba enormemente, con hosts individuales que alcanzaban su número máximo de conexiones, lo que provocaba que el equilibrador de carga los sacara de la rotación. La carga volvía a caer en el host y se añadía de nuevo. Esto progresaba hasta que la mayor parte del clúster estaba sobrecargado y los servidores de aplicaciones tenían problemas. Reiniciar nodos individuales no ayudaba.
Finalmente, se descubrió que la forma más fácil y rápida de controlar la carga del clúster era detener Apache en todos los nodos y volver a iniciarlo. La razón por la que esto funcionaba, mientras que reiniciar nodos individuales (e, incluso, *a la larga* todos los nodos) no lo hacía, era porque las peticiones individuales de servicios web estaban generando otras peticiones a otros nodos en el mismo clúster. Si algún nodo individual se volviera lento, todos los demás nodos acabarían teniendo a todos sus clientes esperando peticiones a ese nodo, hasta que todo el clúster estuviera esperando respuestas de otros nodos del clúster.
Yo llamo a esto y a las condiciones relacionadas en sistemas distribuidos una espiral de muerte distribuida. Distribuido porque es más complejo y caótico que el caso de un solo nodo que simplemente se sobrecarga y no es capaz de mantener el ritmo de las solicitudes entrantes.
Evitar espirales de muerte
Limitar la concurrencia cerca del cliente
Uno de los problemas que surgieron repetidamente en esta empresa fue la siguiente situación. La concurrencia de un servicio dado estaría limitada dentro del propio servidor, por ejemplo usando MaxClients en Apache o max_connections en MySQL. Una carga excesiva causaría que el servidor alcanzara su límite de concurrencia, y algún servicio más cercano al cliente (el servidor de aplicaciones, los frontends web, los balanceadores de carga, o Squid) devolvería errores que el usuario vería. Squid, por ejemplo, marcará un servidor "padre" como caído si no responde durante dos minutos, devolviendo errores HTTP 500. Los balanceadores de carga devolverían 503s. Esto provocaba con frecuencia que los desarrolladores culparan del problema al servicio que devolvía el error, a lo que yo respondía: "Lo que devuelve el error es lo que está funcionando".
Al final convencí a mi jefe para que sustituyera Pound por Haproxy en la mayoría de los sitios. Haproxy le permite configurar el número máximo de conexiones a enviar a cualquier backend dado, después de lo cual pondrá en cola las conexiones entrantes, incluso le permite priorizar las conexiones entrantes en función de la URL que están tratando de alcanzar. Junto con sus informes estadísticos, Haproxy tenía un valor incalculable a la hora de desplegar nuevos servicios web, porque uno podía simplemente ver cuántas y con qué frecuencia las conexiones entrantes eran empujadas a la cola para ver si el servicio se estaba sobrecargando, y la mayoría de los servidores de aplicaciones hacían exactamente lo correcto cuando sus conexiones se ponían en cola: simplemente hacían esperar al usuario un poco, ralentizando al usuario cuando el sistema estaba sobrecargado en lugar de darles un error que les enfadaría y les haría pulsar recargar o reintentar su operación una y otra vez, empeorando el problema.
Y hablando de conexiones en cola:
Utilizar colas de trabajo
Dustin evangeliza esto mucho. A los desarrolladores web les gusta usar servidores sin estado, por lo que es tradicional intentar terminar todo lo relacionado con una petición de cliente dada antes de devolver un resultado al cliente. Esto significa que todos los recursos relacionados con la petición, por ejemplo la memoria asignada por el proceso Apache durante el procesamiento, quizás bloqueos mantenidos en la base de datos, etc, se mantendrán mientras dure la petición.
En su lugar, es mejor, si realmente no necesitas hacer todo por adelantado, simplemente aceptar la información del cliente, ponerla en la cola y devolver un mensaje de éxito indicando que te has responsabilizado de ella. Así es como funciona el correo electrónico, por ejemplo: Tradicionalmente, SMTP devuelve un código de éxito tan pronto como el mensaje se almacena en el disco, no después de que haya sido entregado al buzón del usuario. Una de las ventajas de hacer las cosas de esta manera es que las colas aprovechan el hecho de que las escrituras en flujo son rápidas, mientras que la actualización de una base de datos generalmente requiere búsquedas aleatorias y, por lo tanto, es mucho más lenta.
Evite bucles en su gráfico de llamadas
Los servicios web que he descrito antes habrían tenido una carga mucho más estable si las peticiones no hubieran generado peticiones a otros nodos del mismo clúster. En sistemas con bucles de este tipo, un solo nodo lento (o incluso ligeramente más lento) puede poner de rodillas a todo el sistema, convirtiendo tu servicio distribuido tolerante a fallos en uno cuya fiabilidad disminuye a medida que se añaden nodos.
Por cierto, la empresa también tenía una solución de almacenamiento en clúster muy cara que sufría exactamente el mismo tipo de síntomas: un solo nodo lento ralentizaba todo el clúster y a menudo provocaba que todo se bloqueara, obligando a reiniciar todos los nodos. Así que no se trata sólo de un problema de soluciones caseras.
Marcar servidores como muertos o limitar la concurrencia saliente por servidor de destino
Esto está relacionado con el punto anterior, porque puede mitigar o incluso eliminar la propagación de fallos aunque acabes teniendo bucles en tu gráfico de llamadas de todos modos.
Otro problema que tenía esta misma empresa era que cada servicio web dependía generalmente de múltiples servidores MySQL. Las peticiones individuales generalmente sólo requerían un único servidor MySQL, pero si alguno de los servidores MySQL se volvía lento o desaparecía de la red, todos los hosts de servicios web eventualmente dejarían de responder porque, como en el caso de las llamadas a hosts en el mismo cluster, todas las conexiones salientes en cualquier host de servicios web eventualmente se agotarían hablando con el host lento/muerto. Tenían un mecanismo manual de listas negras, pero una forma de que un host registrara que una base de datos había expirado varias veces, o un límite en el número de peticiones simultáneas a cualquier host de base de datos, habría evitado este problema.
Conclusión
Es fácil construir un servicio distribuido que funcione perfectamente en condiciones ideales, pero que falle cuando se vea sometido a las condiciones del mundo real. Las espirales de muerte son uno de los fallos más comunes, y sus causas más habituales pueden evitarse siguiendo unas sencillas pautas de diseño.
Espero que esta información te resulte útil para crear tus propios servicios distribuidos. No dudes en enviarme tus propias historias de espirales de la muerte y, por supuesto, hazme saber si te he salvado de alguna.