Atrápame si puedes - Fugas de memoria

Introducción
Las fugas de memoria son una de esas cosas que, cuando suceden, realmente pueden arrojarte al fondo. Diagnosticarlos parece una tarea desafiante al principio. Requieren una inmersión profunda en las herramientas y los componentes en los que se basa su servicio. Este examen de cerca no solo profundiza su comprensión de su panorama de servicios, sino que también le da una idea de cómo funcionan las cosas debajo del capó. Aunque desalentadores a primera vista, las fugas de memoria son esencialmente una bendición disfrazada.
En Elli, hacemos todo lo posible para minimizar la deuda técnica al mínimo. Sin embargo, aún ocurren incidentes, y nuestro enfoque es aprender y compartir conocimientos resolviendo tales problemas.
Por lo tanto, este artículo tiene como objetivo hacer precisamente esto. En esta publicación, lo guiamos a través de nuestro enfoque para identificar una fuga de memoria y compartimos nuestros aprendizajes en el camino.
Contexto
Antes de sumergirnos en la reparación de la fuga de memoria, necesitamos algo de contexto sobre la infraestructura de Elli y dónde ocurrió la fuga de memoria en primer lugar.
Elli, entre otras cosas, es Operadora de Punto de Recarga. Somos responsables de conectar las estaciones de carga (CS) a nuestro backend y controlarlas a través del protocolo OCPP . Ergo, nuestros clientes pueden cargar sus vehículos eléctricos en estaciones privadas o públicas. Los CS están conectados a nuestros sistemas a través de WebSockets. Cuando se trata de autenticación, admitimos conexiones a través de TLS o TLS mutuo (mTLS). Durante TLS, un CS verificará nuestro certificado de servidor y se asegurará de que se conecte a un backend de Elli. Con mTLS, también verificamos que el CS tenga un certificado de cliente emitido por nosotros.
Por el lado de la conectividad, un servidor escrito en Node.js, se encarga de cuidar la lógica UPGRADE de HTTP a WebSockets y mantener el estado de las conexiones. Se implementa en un clúster de Kubernetes y se administra mediante un escalador automático horizontal de pods (HPA). Idealmente, el HPA sigue la carga de tráfico y escala los pods hacia arriba o hacia abajo según corresponda.
Mantenemos decenas de miles de conexiones TCP persistentes y duraderas desde las estaciones de carga al mismo tiempo . Esto introduce complejidad y difiere significativamente de los servicios RESTful típicos. Una métrica de proxy que realiza un seguimiento de la carga es la utilización de la memoria, ya que refleja la cantidad de conexiones establecidas y la lógica de la aplicación no requiere muchos cálculos. Nuestros pods son de larga duración y el escalado a través de la memoria nos llevó a la observación de que la cantidad de pods aumenta lentamente para una cantidad constante de conexiones. Para resumir, detectamos una pérdida de memoria.
Evaluación de impacto
Ante cualquier tipo de problema de producción, el equipo de ingeniería de Elli evalúa de inmediato las implicaciones de este incidente para nuestros clientes y el negocio. Entonces, al descubrir esta pérdida de memoria, hicimos la siguiente evaluación:
La aplicación pierde memoria en cuestión de días. Esto significa que sin recibir ningún tráfico adicional, nuestra infraestructura continúa creciendo.
Cuando un pod no puede manejar tráfico adicional, gracias a la sonda de preparación de Kubernetes, deja de recibir tráfico adicional pero sigue atendiendo las conexiones establecidas. Un pod que serviría a X conexiones podría terminar sirviendo solo una fracción de sus capacidades debido a la fuga, sin causar ninguna interrupción en el lado del cliente. Esto significa que podemos absorber fácilmente el impacto simplemente girando más cápsulas.
La investigación
Ahora, para la inmersión técnica real en la fuga de memoria.
Aquí explicamos las herramientas y los métodos que usamos para descubrir la fuente detrás de la fuga de memoria, lo que esperábamos ver de nuestro experimento y lo que realmente observamos. Incluimos enlaces a los recursos que usamos en nuestra investigación para su referencia.
Una introducción rápida a la memoria JS
Las variables en JavaScript (y la mayoría de los otros lenguajes de programación) se almacenan en dos lugares: pila y montón. Una pila suele ser una región continua de memoria que asigna contexto local para cada función en ejecución. Heap es una región mucho más grande que almacena todo lo asignado dinámicamente. Esta separación es útil para hacer que la ejecución sea más segura contra la corrupción (la pila está más protegida) y más rápida (sin necesidad de recolección dinámica de basura de los marcos de la pila, asignación rápida de nuevos marcos).
Solo los tipos primitivos pasados por valor (Número, Booleano, referencias a objetos) se almacenan en la pila. Todo lo demás se asigna dinámicamente desde el grupo de memoria compartido llamado montón. En JavaScript, no tiene que preocuparse por desasignar objetos dentro del montón. El recolector de basura los libera cuando nadie hace referencia a ellos. Por supuesto, la creación de una gran cantidad de objetos afecta el rendimiento (alguien debe llevar toda la contabilidad), además de provocar la fragmentación de la memoria.
Fuente:https://glebbahmutov.com/blog/javascript-stack-size/
Tomar una instantánea del montón desde un módulo de producción | Montón de instantáneas y creación de perfiles
Expectativas
Recopilamos instantáneas de montones periódicas de nuestra aplicación para ver una acumulación de objetos a lo largo del tiempo. Debido a la naturaleza de la aplicación, que en su mayoría contiene conexiones WebSocket, esperábamos que los objetos TLSSocket coincidieran con la cantidad de conexiones en la aplicación. Presumimos que cuando una estación se desconectaba, el objeto todavía estaba referenciado de alguna manera. La recolección de basura funciona limpiando objetos inalcanzables, por lo que en este caso, los objetos se dejarían intactos.
Resultados
Obtener un volcado de almacenamiento dinámico de un pod utilizado en un 90 % dio como resultado un rango de 100 MB. Cada módulo solicita alrededor de 1,5 GB de RAM y el montón era menos del 10 % de la memoria asignada. Esto parecía sospechoso…
¿Dónde estaba el resto de la memoria asignada? No obstante, continuamos el análisis. Tomar tres instantáneas en intervalos y observar el cambio en la memoria a lo largo del tiempo no reveló nada. No notamos una acumulación de objetos ni hubo problemas con la recolección de basura. El volcado del montón parecía bastante saludable.
Los objetos TLSSocket coincidían con el estado de la aplicación. Volviendo a la primera observación, el volcado del montón es un orden de magnitud menor que la utilización de la memoria. Pensamos: “ Esto no puede estar bien. Estamos buscando en el lugar equivocado. Tenemos que dar un paso atrás”.
Además, perfilamos la aplicación a través de Cloud Profiler ofrecido por GCP. Estábamos interesados en ver cómo se asignan los objetos con el paso del tiempo y en identificar potencialmente la fuga de memoria.
Obtener un volcado de montón bloquea el subproceso principal y potencialmente puede matar la aplicación, contrario a esto, el generador de perfiles se puede mantener en producción con poca sobrecarga.
Cloud Profiler es una herramienta de generación de perfiles continuos diseñada para aplicaciones que se ejecutan en Google Cloud. Es un generador de perfiles estadístico o de muestreo con poca sobrecarga y es adecuado para entornos de producción.
Aunque el generador de perfiles contribuyó a nuestra comprensión de los inquilinos del montón, aún no nos dio ninguna pista sobre la investigación. Por el contrario, nos impidió ir en la dirección correcta.
Alerta de spoiler: el generador de perfiles, sin embargo, nos proporcionó información bastante valiosa durante un incidente en producción en el que identificamos y solucionamos una pérdida de memoria agresiva, pero esa es una historia para otro momento.
Estadísticas de uso de memoria
Necesitábamos mayores conocimientos sobre el uso de la memoria. Creamos tableros para todas las métricas que process.memoryUsage() tenía para ofrecer.
HeapTotal y heapUsed se refieren al uso de memoria de V8.
El externo se refiere al uso de la memoria de los objetos de C++ vinculados a los objetos de JavaScript administrados por V8.
El rss, Resident Set Size , es la cantidad de espacio ocupado en el dispositivo de memoria principal (es decir, un subconjunto de la memoria total asignada) para el proceso, incluidos todos los objetos y códigos de C++ y JavaScript.
arrayBuffers hace referencia a la memoria asignada para ArrayBuffers y SharedArrayBuffers , incluidos todos los búferes de Node.js . Esto también está incluido en el valor externo. Cuando Node.js se usa como una biblioteca integrada, este valor puede ser 0 porque es posible que no se realice un seguimiento de las asignaciones para ArrayBuffers en ese caso.
Como vimos anteriormente, obtuvimos ~100 MB de instantáneas en montón de un contenedor que tenía más de 1 GB de uso de memoria. ¿Dónde está el resto de la memoria asignada? Echemos un vistazo.
¿Qué sabemos hasta ahora? RSS está creciendo, el montón y el externo son estables, lo que nos lleva a la pila. Esto podría significar un método que se llama y nunca sale, lo que lleva a un desbordamiento de pila. Sin embargo, la pila no puede tener cientos de MB. En este punto, ya probamos en un entorno que no es de producción con varios miles de estaciones, pero no obtuvimos ningún resultado.
Asignador de memoria
Durante la lluvia de ideas, consideramos la fragmentación de la memoria: los fragmentos de memoria se asignan de forma no secuencial, lo que lleva a pequeños fragmentos de memoria que no se pueden usar para nuevas asignaciones. En nuestro caso, la aplicación es de ejecución prolongada y realiza muchas asignaciones y liberaciones. La fragmentación de la memoria es una preocupación válida en estos casos. Una extensa búsqueda en Google nos llevó a un problema de GitHub en el que la gente se enfrentaba a la misma situación que nosotros. Se observó el mismo patrón de pérdida de memoria y se alineó con nuestra hipótesis.
Decidimos poner a prueba un asignador de memoria diferente y cambiamos de musl a jemalloc . No encontramos resultados significativos. En este punto, sabíamos que necesitábamos tomar un descanso. Tuvimos que repensar el enfoque por completo.
¿Será que la fuga solo aparece en las conexiones mTLS?
Durante nuestras primeras pruebas, intentamos reproducir el problema en un entorno que no era de producción, pero no tuvimos suerte. Realizamos pruebas de carga con miles de estaciones simulando diferentes escenarios, conectando/desconectando estaciones durante días, pero no produjeron resultados significativos. Sin embargo, comenzamos a tener una sospecha creciente de que había algo que se nos pasó por alto al realizar estas pruebas.
No tuvimos en cuenta que nuestras estaciones pueden conectarse a través de TLS o mTLS. Nuestra primera prueba incluyó estaciones TLS, pero no mTLS, y la razón es simple: no pudimos crear fácilmente estaciones mTLS y los respectivos certificados de cliente. Un incidente reciente nos motivó a minimizar el radio de explosión y dividir las responsabilidades de la aplicación para que cada implementación manejara el tráfico TLS y mTLS por separado. ¡Eureka! La fuga de memoria aparece solo en nuestros pods mTLS, mientras que en TLS la memoria es estable.
¿A dónde vamos desde aquí?
Decidimos que hay dos opciones: (1) Pasar a nuestros próximos sospechosos: una biblioteca que maneja todas las tareas de Infraestructura de clave pública, así como una posible recurrencia en algún lugar de esa ruta de código, (2) o vivir con él hasta que volvamos a trabajar nuestro servicio por completo.
Durante la investigación de la fuga de memoria, nos llamaron la atención muchos temas imprevistos relacionados con el servicio afectado. Teniendo en cuenta la fuga de memoria y todo lo demás que descubrimos, decidimos mejorar el panorama de nuestro servicio y dividir las responsabilidades del servicio . El flujo de autenticación y autorización de CS, entre otros, se delegaría al nuevo servicio y utilizaríamos las herramientas adecuadas para manejar las tareas de PKI.
Resumen
Mejorar nuestra escala reveló que tenemos una pérdida de memoria que podría haber pasado desapercibida por un período de tiempo indefinido. Priorizar al cliente y evaluar el impacto de la fuga fue lo primero y más importante. Solo así pudimos marcar el ritmo de nuestra investigación ya que nos dimos cuenta de que no había impacto en el cliente. Comenzamos con el lugar más obvio para buscar cuando se diagnostica una fuga de memoria: el montón. Sin embargo, el análisis del montón nos mostró que estábamos buscando en el lugar equivocado. Se necesitaban más pistas y la API de proceso de V8 nos dio exactamente eso. En los primeros resultados que obtuvimos, la pérdida de memoria apareció en RSS. Finalmente, analizando toda la información recopilada, sospechamos fragmentación de la memoria.
Cambiar el asignador de memoria no mejoró la situación. Más bien, cambiar nuestro enfoque y dividir la carga de trabajo entre TLS y mTLS nos ayudó a reducir la ruta del código afectado.
¿Cuáles fueron los resultados finales de nuestra investigación?
Nuestros planes para mejorar la escalabilidad junto con abordar la fuga de memoria nos hicieron decidir dividir el servicio y escribir uno nuevo para cuidar el flujo de conectividad de CS por separado de los otros detalles de CS.
¿Arreglamos nuestra pérdida de memoria?
El tiempo lo dirá, pero yo diría que la investigación fue mucho más que eso. La experiencia de investigar la fuga nos ayudó a crecer como desarrolladores y nuestro servicio para adoptar una arquitectura más resistente y escalable.
Puntos clave y aprendizajes
- Los desafíos de ingeniería difíciles unen a las personas; jugamos al ping-pong sobre ideas con ingenieros fuera de nuestro equipo.
- Nos dio la motivación para repensar el servicio, lo que condujo a una arquitectura más escalable.
- Si duele, requiere tu atención; no lo ignores
- https://nodejs.org/en/docs/guides/diagnostics/memory/using-heap-snapshot/
- Habilite la depuración remota en un pod a través del reenvío de puertos:https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/
- https://developers.google.com/cast/docs/debugging/remote_debugger

Si está interesado en obtener más información sobre cómo trabajamos, suscríbase al blog de Elli Medium y visite el sitio web de nuestra empresa en elli.eco . ¡Hasta la próxima!
Sobre el Autor
Thanos Amoutzias es ingeniero de software, desarrolla el sistema de gestión de estaciones de carga de Elli e impulsa los temas de SRE. Le apasiona crear servicios confiables y ofrecer productos impactantes. Puedes encontrarlo en LinkedIn y en el ️.
Créditos: ¡Gracias a todos mis colegas que revisaron y dieron su opinión sobre el artículo!