Traducción del artículo:
Este artículo me pareció bastante interesante, y dado que Envoy se utiliza más comúnmente como parte de «istio» o simplemente como «controlador de ingreso» en Kubernetes, la mayoría de la gente no interactúa con él de la misma manera que con las instalaciones típicas de Nginx o Haproxy. Sin embargo, si algo falla, sería bueno entender cómo funciona por dentro. Traté de traducir la mayor cantidad de texto al español, incluidos los términos técnicos; para aquellos a quienes les duele ver esto, dejé los originales entre paréntesis. Bienvenido al contenido.
La documentación técnica de bajo nivel sobre la base de código de Envoy es actualmente bastante escasa. Para remediar esto, planeo hacer una serie de artículos en el blog sobre diferentes subsistemas de Envoy. Dado que este es el primer artículo, por favor, háganme saber qué piensan y qué les gustaría que se cubriera en los próximos artículos.
Una de las preguntas técnicas más comunes que recibo sobre Envoy es la solicitud de una descripción de bajo nivel del modelo de subprocesos usado (threading model). En esta publicación, describiré cómo Envoy asigna conexiones a subprocesos, así como la descripción del sistema de almacenamiento local para subprocesos (Thread Local Storage) que se utiliza internamente para hacer el código más paralelo y de alto rendimiento.
Descripción de subprocesos (Threading overview)
![[Traducción] Modelo de hilos de Envoy (Envoy threading model)](/wp-content/uploads/2019/04/c5028463db1eb5b07ccb1a3af1f64a03.jpeg)
Envoy utiliza tres tipos diferentes de subprocesos:
- Principal (Main): Este subproceso gestiona el inicio y la finalización del proceso, todo el manejo de las API de XDS (xDiscovery Service), incluyendo DNS, verificación de disponibilidad (health checking), gestión general del clúster y del proceso del servicio (runtime), restablecimiento de estadísticas, administración y gestión general de procesos — señales de Linux, reinicio en caliente (hot restart), etc. Todo lo que ocurre en este subproceso es asincrónico y 'no bloqueante'. En general, el subproceso principal coordina todos los procesos críticos de funcionalidad, para los cuales no se necesita una gran cantidad de CPU. Esto permite que gran parte del código de gestión se escriba como si fuera de un solo subproceso.
- Trabajador (Worker): Por defecto, Envoy crea un subproceso de trabajo (worker thread) para cada subproceso de hardware en el sistema; esto se puede controlar con la opción
--concurrency. Cada hilo de trabajo inicia un ciclo de eventos «no bloqueante» (event loop) que se encarga de escuchar a cada oyente (listener). Hasta la fecha de redacción del artículo (29 de julio de 2017), no hay segmentación (sharding) del oyente (listener), lo que permite recibir nuevas conexiones, crear una instancia de la pila de filtros para la conexión y gestionar todas las operaciones de entrada-salida (IO) durante la existencia de la conexión. Nuevamente, esto permite que gran parte del código de manejo de conexiones se escriba como si fuera de un solo hilo. - Flusher de archivos (File flusher): Cada archivo que escribe Envoy, principalmente los registros de acceso (access logs), actualmente tiene un hilo bloqueante independiente. Esto se debe a que la escritura en archivos cacheados por el sistema de archivos, incluso utilizando
O_NONBLOCKpuede bloquearse a veces (suspiro). Cuando los hilos de trabajo necesitan escribir en un archivo, los datos se trasladan realmente a un búfer en memoria, donde se vacían a través del hilo file flush. Esta es una de las áreas del código donde técnicamente todos los hilos de trabajo (worker threads) pueden bloquear (block) la misma cerradura (lock) al intentar llenar el búfer de memoria.
Manejo de conexiones (Connection handling)
Como se discutió brevemente anteriormente, todos los hilos de trabajo escuchan a todos los oyentes (listeners) sin ninguna segmentación. De este modo, el núcleo se utiliza para enviar de manera eficiente los sockets aceptados a los hilos de trabajo. Los núcleos modernos son generalmente muy buenos en esto; utilizan funciones como el aumento de prioridad de entrada-salida (IO) para intentar llenar el hilo con trabajo antes de comenzar a usar otros hilos que también están escuchando el mismo socket, y evitan el uso de bloqueos cíclicos (Spinlock) para manejar cada solicitud.
Una vez que se acepta una conexión en un hilo de trabajo (worker thread), nunca sale de ese hilo. Todo el procesamiento adicional de la conexión se realiza completamente en el hilo de trabajo (worker thread), incluyendo cualquier comportamiento de reenvío (forwarding behavior).
Esto tiene varias consecuencias importantes:
- Todos los pools de conexiones en Envoy pertenecen a un hilo de trabajo. De esta manera, aunque los pools de conexiones HTTP/2 establecen solo una conexión con cada host ascendente a la vez, si hay cuatro hilos de trabajo, habrá cuatro conexiones HTTP/2 con el host ascendente en estado estable.
- La razón por la que Envoy funciona de esta manera es que, al mantener todo en un solo hilo de trabajo, casi todo el código puede ser escrito sin bloqueos y como si fuera de un solo hilo. Este diseño simplifica la escritura de una gran cantidad de código y escala increíblemente bien para prácticamente un número ilimitado de hilos de trabajo.
- Sin embargo, una de las conclusiones principales es que, desde el punto de vista de la eficiencia, la configuración del pool de memoria y conexiones es realmente importante ajustar el parámetro
--concurrency. Tener más hilos de trabajo de los necesarios conducirá a una pérdida de memoria, la creación de más conexiones inactivas y una disminución en la velocidad de acceso al pool de conexiones. En Lyft, nuestros contenedores sidecar de Envoy funcionan con un paralelismo muy bajo, por lo que el rendimiento es aproximadamente equivalente a los servicios con los que están junto.
Lo que significa el modo no bloqueante (What non-blocking means)
El término 'no bloqueante' se ha utilizado varias veces al discutir cómo funcionan el hilo principal y los hilos de trabajo. Todo el código está escrito bajo la suposición de que nada se bloquea nunca. Sin embargo, esto no es del todo cierto (what is not entirely true?).
Envoy utiliza varios bloqueos prolongados del proceso:
- Como se mencionó, al registrar los accesos, todos los hilos de trabajo obtienen el mismo bloqueo antes de llenar el buffer del registro en memoria. El tiempo de retención del bloqueo debe ser muy bajo, pero es posible que este bloqueo sea disputado bajo un alto paralelismo y gran capacidad de procesamiento.
- Envoy utiliza un sistema muy complejo para procesar estadísticas, que es local al flujo. Este será el tema de un post separado. Sin embargo, mencionaré brevemente que, como parte del procesamiento local de estadísticas del flujo, a veces es necesario obtener un bloqueo para el "almacenamiento central de estadísticas". Este bloqueo nunca debería ser requerido.
- El flujo principal necesita coordinarse periódicamente con todos los flujos de trabajo. Esto se hace "publicando" desde el flujo principal a los flujos de trabajo, y a veces de los flujos de trabajo de vuelta al flujo principal. Se requiere un bloqueo para enviar el mensaje publicado, de modo que se pueda colocar en la cola para su entrega posterior. Estos bloqueos nunca deberían estar sujetos a una competencia seria, aunque todavía pueden bloquearse técnicamente.
- Cuando Envoy escribe en el flujo de errores del sistema (standard error), obtiene un bloqueo de todo el proceso. En general, el registro local de Envoy se considera horrible en términos de rendimiento, por lo que no se le presta mucha atención a su mejora.
- Hay algunos otros bloqueos ocasionales, pero ninguno de ellos es crítico para el rendimiento y nunca debería ser cuestionado.
Almacenamiento local de hilo (Thread local storage)
Debido a la forma en que Envoy separa las responsabilidades del flujo principal de las responsabilidades del flujo de trabajo, existe un requisito de que el procesamiento complejo se puede realizar en el flujo principal y luego proporcionarse a cada flujo de trabajo con un alto grado de paralelismo. Esta sección describe el sistema de Almacenamiento Local de Hilo (TLS) de Envoy a un alto nivel. En la siguiente sección, describiré cómo se utiliza para gestionar el clúster.
![[Traducción] Modelo de hilos de Envoy (Envoy threading model)](/wp-content/uploads/2019/04/a47af1e8eb1f55a4d7609b3ff3ee9ce1.jpeg)
Como ya se ha descrito, el flujo principal maneja prácticamente todas las funciones de gestión (management) y la funcionalidad del plano de control (control plane) en el proceso Envoy. El plano de control aquí está un poco sobrecargado, pero si se considera en el contexto del propio proceso Envoy y se compara con el reenvío que realizan los flujos de trabajo, parece lógico. Como regla general, el proceso del flujo principal realiza algún trabajo y luego necesita actualizar cada flujo de trabajo de acuerdo con el resultado de este trabajo. en este caso, el hilo de trabajo no necesita establecer un bloqueo en cada acceso.
El sistema TLS (almacenamiento local de hilos) de Envoy funciona de la siguiente manera:
- El código que se ejecuta en el hilo principal puede asignar un espacio TLS para todo el proceso. Aunque esto está abstraído, en la práctica es un índice en un vector que permite el acceso O(1).
- El hilo principal puede almacenar datos arbitrarios en su espacio. Una vez que se hace esto, los datos se publican en cada hilo de trabajo como un evento del ciclo de eventos.
- Los hilos de trabajo pueden leer desde su espacio TLS y extraer cualquier dato local del hilo que esté disponible allí.
Aunque esta es una paradoja muy simple y increíblemente poderosa, es muy similar al concepto de bloqueo RCU (Read-Copy-Update). En esencia, los hilos de trabajo nunca ven ningún cambio de datos en los espacios TLS mientras están haciendo su trabajo. Los cambios sólo ocurren durante los períodos de inactividad entre eventos de trabajo.
Envoy utiliza esto de dos maneras diferentes:
- Almacenar datos diferentes en cada hilo de trabajo, permitiendo el acceso a esos datos sin ningún tipo de bloqueo.
- Almacenar un puntero compartido a datos globales en modo 'solo lectura' en cada hilo de trabajo. De este modo, cada hilo de trabajo tiene un contador de referencias a los datos que no puede disminuir mientras realiza su trabajo. Solo cuando todos los trabajadores se calman y cargan nuevos datos globales, los datos antiguos se eliminan. Esto es idéntico al RCU.
Hilo de actualización de clúster
En esta sección describiré cómo se utiliza TLS (almacenamiento local de hilos) para gestionar el clúster. La gestión del clúster implica el procesamiento de la API xDS y/o DNS, así como la verificación de la salud (health checking).
![[Traducción] Modelo de hilos de Envoy (Envoy threading model)](/wp-content/uploads/2019/04/238f5718729e1f12f8f0d6507f16abe8.jpeg)
La gestión de hilos del clúster incluye los siguientes componentes y etapas:
- El gerente del clúster es un componente dentro de Envoy que gestiona todos los upstreams conocidos del clúster, la API CDS (Servicio de Descubrimiento de Clúster), las APIs SDS (Servicio de Descubrimiento de Secretos) y EDS (Servicio de Descubrimiento de Puntos Finales), DNS y verificaciones de salud externas activas (health checking). Es responsable de crear una representación 'eventualmente consistente' de cada upstream del clúster, que incluye los hosts descubiertos, así como el estado de salud.
- El verificador de estado (health checker) realiza una verificación activa de la operatividad y reporta cambios en el estado de operatividad al gestor del clúster.
- CDS (Servicio de Descubrimiento de Clúster) / SDS (Servicio de Descubrimiento de Secretos) / EDS (Servicio de Descubrimiento de Puntos Finales) / DNS se ejecutan para determinar la pertenencia al clúster. El cambio de estado se devuelve al gestor del clúster.
- Cada hilo de trabajo ejecuta continuamente un ciclo de procesamiento de eventos.
- Cuando el gestor del clúster determina que el estado del clúster ha cambiado, crea una nueva instantánea del estado del clúster, disponible solo para lectura, y la envía a cada hilo de trabajo.
- Durante el siguiente período de inactividad, el hilo de trabajo actualizará la instantánea en el espacio de TLS asignado.
- Durante un evento de entrada/salida que debe determinar el host para el balanceo de carga, el balanceador de carga solicitará un espacio de TLS (Thread local storage) para obtener información sobre el host. No se requieren bloqueos para esto. También cabe mencionar que TLS puede iniciar eventos durante la actualización, de modo que los subsistemas de balanceo de carga y otros componentes puedan recalcular cachés, estructuras de datos, etc. Esto va más allá del alcance de esta publicación, pero se utiliza en varios lugares del código.
Utilizando el procedimiento descrito anteriormente, Envoy puede procesar cada solicitud sin bloqueos (excepto los mencionados anteriormente). Aparte de la complejidad del propio código de TLS, gran parte del código no necesita entender cómo funciona la multihilo, y puede escribirse en modo de un solo hilo. Esto facilita la escritura de la mayor parte del código además de ofrecer un excelente rendimiento.
Otros subsistemas que utilizan TLS
TLS (Thread local storage) y RCU (Read Copy Update) son ampliamente utilizados en Envoy.
Ejemplos de uso:
- Mecanismo de cambio de funcionalidad en tiempo de ejecución: La lista actual de funcionalidades habilitadas se calcula en el hilo principal. Luego, a cada hilo de trabajo se le proporciona una instantánea de solo lectura utilizando la semántica de RCU.
- Sustitución de tablas de rutas: para las tablas de rutas proporcionadas por RDS (Route Discovery Service), las tablas de rutas se crean en el hilo principal. Más adelante, se proporcionará una instantánea de solo lectura a cada hilo de trabajo utilizando la semántica RCU (Read Copy Update). Esto hace que la modificación de las tablas de rutas sea atómica y eficiente.
- Cacheo de encabezados HTTP: Como se ha demostrado, calcular el encabezado HTTP para cada solicitud (al realizar ~25K+ RPS por núcleo) es bastante costoso. Envoy calcula centralizadamente el encabezado aproximadamente cada medio segundo y lo proporciona a cada trabajador a través de TLS y RCU.
Existen otros casos, pero los ejemplos anteriores deberían proporcionar una buena comprensión del propósito de TLS.
Conocidos escollos de rendimiento (Known performance pitfalls)
Aunque en general Envoy funciona bastante bien, hay varias áreas conocidas que requieren atención cuando se usa con un paralelismo y un ancho de banda muy altos:
- Como se describe en este artículo, actualmente todos los hilos de trabajo obtienen un bloqueo al escribir en el buffer de memoria del registro de acceso. Con un alto paralelismo y un alto ancho de banda, será necesario realizar el empaquetado de registros de acceso para cada hilo de trabajo a expensas de la entrega desordenada al escribir en el archivo final. Como alternativa, se pueden crear registros de acceso separados para cada hilo de trabajo.
- Aunque las estadísticas están muy optimizadas, con un alto paralelismo y un alto ancho de banda, probablemente habrá competencia atómica en las estadísticas individuales. La solución a este problema son contadores por hilo de trabajo con restablecimiento periódico de los contadores centrales. Esto se discutirá en una publicación posterior.
- La arquitectura existente no funcionará bien si Envoy se implementa en un escenario con muy pocas conexiones que requieran recursos significativos para su procesamiento. No hay garantía de que las conexiones se distribuyan uniformemente entre los hilos de trabajo. Esto se puede solucionar implementando un balanceo de carga de conexiones de trabajo, donde se implementará la capacidad de compartir conexiones entre los hilos de trabajo.
Conclusión (Conclusion)
El modelo de hilos de Envoy está diseñado para facilitar la programación y el paralelismo masivo, aunque puede implicar un uso potencialmente ineficiente de memoria y conexiones si no se configuran correctamente. Este modelo permite un rendimiento óptimo con un gran número de hilos y un alto ancho de banda.
Como mencioné brevemente en Twitter, el diseño también puede funcionar sobre un stack de red completamente funcional en modo de usuario, como DPDK (Data Plane Development Kit), lo que puede permitir que servidores convencionales manejen millones de solicitudes por segundo con procesamiento completo de L7. Será muy interesante ver lo que se construya en los próximos años.
Un último comentario rápido: me han preguntado muchas veces por qué elegimos C++ para Envoy. La razón sigue siendo que es el único lenguaje de nivel industrial ampliamente utilizado que permite construir la arquitectura descrita en este post. C++ definitivamente no es adecuado para todos los proyectos, ni siquiera para muchos, pero para ciertos casos de uso, sigue siendo la única herramienta que permite llevar a cabo la tarea.
Enlaces al código
Enlaces a los archivos de interfaces y implementaciones de encabezados discutidos en este post:
Fuente: habr.com
