Recientemente vimos que uno de nuestros proyectos Rust , un servicio axum
, mostraba un comportamiento extraño en lo que respecta al uso de la memoria. Un perfil de memoria de aspecto extraño es lo último que esperaría de un programa Rust, pero aquí estamos.
El servicio se ejecutaría con memoria "plana" durante un período de tiempo y luego, de repente, saltaría a una nueva meseta. Este patrón se repetiría durante horas, a veces bajo carga, pero no siempre. La parte preocupante fue que una vez que vimos un fuerte aumento, era raro que la memoria volviera a caer. Era como si la memoria se perdiera o se "filtrara" de vez en cuando.
En circunstancias normales, este perfil de "escalón" tenía un aspecto extraño, pero en un momento el uso de la memoria aumentó de manera desproporcionada. El crecimiento ilimitado de la memoria puede hacer que los servicios se vean obligados a salir. Cuando los servicios se cierran abruptamente, esto puede reducir la disponibilidad... lo cual es malo para el negocio . Quería profundizar y averiguar qué estaba pasando.
Normalmente, cuando pienso en un crecimiento inesperado de la memoria en un programa, pienso en fugas. Aún así, esto parecía diferente. Con una fuga, tiende a ver un patrón de crecimiento más estable y regular.
A menudo, esto parece una línea inclinada hacia arriba y hacia la derecha. Entonces, si nuestro servicio no tenía fugas, ¿qué estaba haciendo?
Si pudiera identificar las condiciones que causaron el salto en el uso de la memoria, tal vez podría mitigar lo que estaba sucediendo.
Tenía dos preguntas candentes:
Mirando las métricas históricas, pude ver patrones similares de fuertes aumentos entre largos períodos sin cambios, pero nunca antes habíamos tenido este tipo de crecimiento. Para saber si el crecimiento en sí era nuevo (a pesar de que el patrón de "escalón" es normal para nosotros), necesitaría una forma confiable de reproducir este comportamiento.
Si pudiera forzar el "paso" para que se muestre, entonces tendré una forma de verificar un cambio en el comportamiento cuando tome medidas para frenar el crecimiento de la memoria. También podría retroceder a través de nuestro historial de git y buscar un punto en el tiempo en el que el servicio no exhibiera un crecimiento aparentemente ilimitado.
Las dimensiones que utilicé al ejecutar mis pruebas de carga fueron:
El tamaño de los cuerpos POST enviados al servicio.
La tasa de solicitud (es decir, solicitudes por segundo).
El número de conexiones de cliente simultáneas.
La combinación mágica para mí fue: cuerpos de solicitud más grandes y mayor simultaneidad .
Cuando se ejecutan pruebas de carga en un sistema local, existen todo tipo de factores limitantes, incluido el número finito de procesadores disponibles para ejecutar tanto los clientes como el propio servidor. Aún así, pude ver el "escalón" en la memoria de mi máquina local en las circunstancias adecuadas, incluso con una tasa de solicitud general más baja.
Usando una carga útil de tamaño fijo y enviando solicitudes en lotes, con un breve descanso entre ellos, pude aumentar la memoria del servicio repetidamente, un paso a la vez.
Me pareció interesante que, si bien podía hacer crecer la memoria con el tiempo, finalmente llegué a un punto de rendimientos decrecientes. Eventualmente, habría un techo (todavía mucho más alto de lo esperado) para el crecimiento. Jugando un poco más, descubrí que podía alcanzar un techo aún más alto enviando solicitudes con diferentes tamaños de carga útil.
Una vez que identifiqué mi entrada, pude trabajar hacia atrás a través de nuestro historial de git, eventualmente aprendiendo que nuestro susto de producción probablemente no sea el resultado de cambios recientes de nuestra parte.
Los detalles de la carga de trabajo para desencadenar este "escalón" son específicos de la aplicación en sí, aunque pude forzar que sucediera un gráfico similar con un proyecto de juguete .
#[derive(serde::Deserialize, Clone)] struct Widget { payload: serde_json::Value, } #[derive(serde::Serialize)] struct WidgetCreateResponse { id: String, size: usize, } async fn create_widget(Json(widget): Json<Widget>) -> Response { ( StatusCode::CREATED, Json(process_widget(widget.clone()).await), ) .into_response() } async fn process_widget(widget: Widget) -> WidgetCreateResponse { let widget_id = uuid::Uuid::new_v4(); let bytes = serde_json::to_vec(&widget.payload).unwrap_or_default(); // An arbitrary sleep to pad the handler latency as a stand-in for a more // complex code path. // Tweak the duration by setting the `SLEEP_MS` env var. tokio::time::sleep(std::time::Duration::from_millis( std::env::var("SLEEP_MS") .as_deref() .unwrap_or("150") .parse() .expect("invalid SLEEP_MS"), )) .await; WidgetCreateResponse { id: widget_id.to_string(), size: bytes.len(), } }
Resultó que no necesitabas mucho para llegar allí. Logré ver un aumento similar (pero en este caso mucho más pequeño) de una aplicación axum
con un solo controlador que recibe un cuerpo JSON.
Si bien los aumentos de memoria en mi proyecto de juguetes no fueron tan dramáticos como los que vimos en el servicio de producción, fue suficiente para ayudarme a comparar y contrastar durante la siguiente fase de mi investigación. También me ayudó a tener el ciclo de iteración más ajustado de una base de código más pequeña mientras experimentaba con diferentes cargas de trabajo. Consulte el LÉAME para obtener detalles sobre cómo ejecuté mis pruebas de carga.
Pasé un tiempo buscando en la web informes de errores o discusiones que pudieran describir un comportamiento similar. Un término que surgió repetidamente fue Fragmentación de montón y después de leer un poco más sobre el tema, parecía que encajaba con lo que estaba viendo.
Las personas de cierta edad pueden haber tenido la experiencia de ver una utilidad de desfragmentación en DOS o Windows mover bloques en un disco duro para consolidar las áreas "usadas" y "libres".
En el caso de este viejo disco duro de PC, los archivos de diferentes tamaños se escribieron en el disco y luego se movieron o eliminaron, dejando un "agujero" de espacio disponible entre otras regiones utilizadas. A medida que el disco comienza a llenarse, puede intentar crear un nuevo archivo que no encaje en una de esas áreas más pequeñas. En el escenario de fragmentación del montón, eso conducirá a una falla de asignación, aunque el modo de falla de la fragmentación del disco será un poco menos drástico. En el disco, el archivo deberá dividirse en fragmentos más pequeños, lo que hace que el acceso sea mucho menos eficiente (gracias wongarsu
por la corrección ). La solución para la unidad de disco es "desfragmentar" (desfragmentar) la unidad para reorganizar esos bloques abiertos en espacios continuos.
Algo similar puede suceder cuando el asignador (el responsable de administrar la asignación de memoria en su programa) agrega y elimina valores de diferentes tamaños durante un período de tiempo. Los espacios que son demasiado pequeños y están dispersos por todo el montón pueden dar lugar a que se asignen nuevos bloques de memoria "nuevos" para acomodar un nuevo valor que de otro modo no cabría. Aunque desafortunadamente, debido a cómo funciona la gestión de la memoria, no es posible una "desfragmentación".
La causa específica de la fragmentación podría ser cualquier número de cosas: JSON analizando con serde
, algo a nivel de marco en axum
, algo más profundo en tokio
, o incluso solo una peculiaridad de la implementación del asignador específico para el sistema dado. Incluso sin conocer la causa raíz (si es que existe), el comportamiento es observable en nuestro entorno y algo reproducible en una aplicación básica. (Actualización: se necesita más investigación, pero estamos bastante seguros de que es el análisis JSON, vea nuestro comentario en HackerN ews)
Si esto es lo que le estaba pasando a la memoria de proceso, ¿qué se puede hacer al respecto? Parece que sería difícil cambiar la carga de trabajo para evitar la fragmentación. También parece que sería complicado desenredar todas las dependencias en mi proyecto para posiblemente encontrar una causa raíz en el código de cómo ocurren los eventos de fragmentación. ¿Entonces, qué puede hacerse?
Jemalloc
al rescate jemalloc
se describe a sí mismo como un objetivo de "[enfatizar] evitar la fragmentación y soporte de concurrencia escalable". De hecho, la concurrencia era parte del problema de mi programa, y evitar la fragmentación es el nombre del juego. jemalloc
parece que podría ser justo lo que necesito.
Dado que jemalloc
es un asignador que hace todo lo posible para evitar la fragmentación en primer lugar, la esperanza era que nuestro servicio pudiera ejecutarse por más tiempo sin aumentar gradualmente la memoria.
No es tan trivial cambiar las entradas de mi programa o la pila de dependencias de la aplicación. Sin embargo, es trivial cambiar el asignador.
Siguiendo los ejemplos en el archivo Léame de https://github.com/tikv/jemallocator , se requirió muy poco trabajo para probarlo.
Para mi proyecto de juguete , agregué una función de carga para cambiar opcionalmente el asignador predeterminado por jemalloc
y volví a ejecutar mis pruebas de carga.
La grabación de la memoria residente durante mi carga simulada muestra los dos perfiles de memoria distintos.
Sin jemalloc
, vemos el familiar perfil de escalera. Con jemalloc
, vemos que la memoria sube y baja repetidamente a medida que se ejecuta la prueba. Más importante aún, si bien existe una diferencia considerable entre el uso de la memoria con jemalloc
durante la carga y los tiempos de inactividad, no "perdemos terreno" como lo hicimos antes, ya que la memoria siempre vuelve a la línea de base.
Si ve un perfil de "escalón" en un servicio de Rust, considere tomar jemalloc
para una prueba de manejo. Si tiene una carga de trabajo que promueve la fragmentación del almacenamiento dinámico, jemalloc
puede brindar un mejor resultado general.
Por separado, en el repositorio del proyecto de juguete se incluye un benchmark.yml
para usar con la herramienta de prueba de carga https://github.com/fcsonline/drill . Intente cambiar la concurrencia, el tamaño del cuerpo (y la duración arbitraria de la suspensión del controlador en el propio servicio), etc. para ver cómo el cambio en el asignador afecta el perfil de memoria.
En cuanto al impacto en el mundo real, puede ver claramente el cambio de perfil cuando implementamos el cambio a jemalloc
.
Donde el servicio solía mostrar líneas planas y pasos grandes, a menudo independientemente de la carga, ahora vemos una línea más irregular que sigue más de cerca la carga de trabajo activa. Aparte del beneficio de ayudar al servicio a evitar el crecimiento innecesario de la memoria, este cambio nos dio una mejor idea de cómo responde nuestro servicio a la carga, por lo que, en general, este fue un resultado positivo.
Si está interesado en crear un servicio sólido y escalable con Rust, ¡estamos contratando! Consulte nuestra página de carreras para obtener más información.
Para obtener más contenido como este, asegúrese de seguirnos en Twitter , Github o RSS para obtener las últimas actualizaciones del servicio webhook de Svix , o únase a la discusión en nuestra comunidad Slack .
También publicado aquí.