El siguiente es un post-mortem publico que estaba pendiente de publicacion en el sistio de ingeniería de MercadoLibre, aunque la lentitud del proceso de edición y mi partida de la compañía previnieron dicha publicación.
El evento
Era un 28 de diciembre a las 10:10AM (GMT-3), el último viernes del año, cuando se alertó nuestro calculador de envíos en Shipping, el área de Mercado Libre que se encarga de calcular la promesa de entrega junto con las opciones de envío de un artículo o compra.
Desde nuestro equipo en los monitores de NewRelic vimos que la alerta se debía a tiempos elevados que degradaron fuertemente la API. Era hora de dejar a un lado las sobras de pan dulce y ponerse a diagnosticar.
Revisando nuestras métricas, pudimos detectar que el problema se encontraba sólo en SLA Coverage de Brasil. Seguimos el rastro en profundidad y llegamos a la fuente de la degradación masiva, una API call puntual: service coverage.
El diagnóstico
En ese momento, nuestros tableros nos mostraban que:
- En service coverage, tanto el apdex como los tiempos de ejecución y métricas del runtime se degradaron al 100%, de manera casi simultánea, a través de todas las instancias del scope, en sólo un par de minutos. ⏰
- La degradación está generando una cascada de retries que suman unos 300K RPM. Esto por sí solo no llega a generar afectación en circunstancias normales, pero es el golpe de gracia para las instancias.
En un intento de dar aire al scope, reiniciamos manualmente instancias existentes al mismo tiempo que boosteamos el scope. Adicionalmente, observamos que se comienzan a reemplazar instancias. Una vez identificado el origen del tráfico de prueba y parados dichos tests, las instancias vuelven lentamente, y se recupera el apdex.
Por un momento todo es confusión. Volvemos sobre nuestros pasos y por fin encontramos la fuente del downtime de nuestra API. Y resulta ser algo que habíamos descartado en un principio: los panics.
Cuando revisamos los logs de service coverage, habíamos notado que ocurrieron panics. Pero como se trataban de ocurrencias puntuales y de poca cantidad, en un principio lo ignoramos, creyendo que no era la causa de la degradación.
¿Qué pasó?
“…los panics no se propagan entre goroutines, y por lo tanto deben manejarse independientemente.”
Al llegar a aislar los stacktraces de los panics en las instancias afectadas, observamos un patrón:
- Solamente incluían el sitio y traza del panic sin ningún middleware ni intermediario de recover por medio, lo cual confirma que el mismo no fue manejado
- Pertenecían al código ejecutado desde una goroutina
Al buscar sobre issues de manejo de panics en goroutinas fue que comprendimos qué estaba pasando.
Como se puede ver en este issue en el repo oficial de Go, un detalle de diseño de Go que al principio muchos developers obviamos o desconocemos es que los panics no se propagan entre goroutines, y por lo tanto deben manejarse independientemente.
Cuando ocurre un panic dentro de un proceso de go, y éste no lo captura vía recover, el mismo finaliza de manera anómala (exit != 0).
Sin embargo, la api usa el router default provisto por nuestro toolkit interno de go, el cual incluye un middleware que se recupera ante un eventual panic de la api y devuelve una respuesta de Internal Server Error.
Implicancias (a.k.a panics for dummies)
Si ocurre un panic en una goroutine y ésta no la manja vía recover, la goroutine que la lanzó tampoco lo va a manejar, sin importar la cantidad de defer recover que posea a lo largo de sus stack frames.
Es un issue conocido, y por el momento el equipo de Go lo considera una decisión de diseño no sujeta a cambios para Go 1.x (crucemos los dedos para 2.x).
El siguiente es un ejemplo de goroutines con panic propagado:
|
|
Comparémoslo ahora con un ejemplo de panic manejado. Notaran que al codigo le esta faltando ser mas componible:
|
|
En resumen: los panics en goroutines necesitan un recover dentro. Si no se manejan, finaliza el proceso de la app y puede llegar a derribar un pool de instancias completo, y el middleware de recovery de nuestro toolkit interno no funciona en estos casos.
Esta sutil pero importante característica del runtime de Go nos llevó a reevaluar cómo manejamos la concurrencia en nuestras apis.
Pensando soluciones
Un par de días después, reuní al equipo con algunas alternativas para dejar a votacion sobre cual era la mejor solución para nuestro caso (revisar y aplicar guardias a un conjunto de N apicalls en varios repos)
Habían 3 candidatos:
La obvia pero difícil
No generar panics dentro de goroutines. Estuve a punto de patentarla pero Rob Pike llegó antes.
Esta opción es la ideal, pero en el mundo real, no siempre es posible. Muchas veces tenemos dependencias de terceros que incluyen panics como mecanismo de señalización de errores. Eliminarlos no está en nuestras manos.
La idiomática pero fea
Pues, deberíamos entonces agregar un recover a cada goroutine que lancemos entonces?
|
|
Va a funcionar, aunque tiene la desventaja de que a pesar de que es la salida idiomática también es un code smell repetir esto por doquier y además este copypaste extra por goroutina es un potencial punto de falla extra.
Podemos mitigarlo al encerrar las funciones dentro de un handler como el siguiente, en tanto sean argumento de otra función que lance la goroutine o seamos nosotros mismos:
|
|
El cual usamos de la siguiente forma:
|
|
De esta manera, centralizamos el manejo de panics en punto común y dentro de la misma línea en la que disparamos la goroutina.
Y, finalmente, en caso de querer devolver algo desde dicha goroutine, nos comunicamos a través de channels:
|
|
Vale tener en cuenta que esto es un patrón idiomático pero que puede acumular rápidamente duplicación de código con complejidad adicional, especialmente a la hora de testear. A usarlo con criterio.
Bonus track: La solución de infraestructura
Más que solución podemos considerarlo un workaround de infraestructura que aumentará la resiliencia para estos casos (considerado el tiempo reducido de inicialización y warmup en comparación a otras plataformas como Java).
La misma consiste en monitorear el proceso de la aplicación, y ante una finalización anómala, iniciarla nuevamente. No es una solución per se, pero da aire a la instancia para que vuelva a atender requests y mitigar una degradación por tiempos de reemplazo de la misma.
Bonus track II: Detectando unrecovered panics
Este era el dump de middlewares de la aplicación (notar que RecoveryWithWriter
es el middleware de recovery):
|
|
Y, el stacktrace del panic sin manejar en toda su gloria:
`
|
|
El comportamiendo se confirma por 2 cosas:
- El recover middleware del toolkit no llega a ejecutarse
- El panic está ocurriendo dentro de una goroutine disparada desde una una instancia de errgroup.Group