Transición de Tinder a Kubernetes

Nota. traducir: Los empleados del mundialmente famoso servicio Tinder compartieron recientemente algunos detalles técnicos sobre la migración de su infraestructura a Kubernetes. El proceso duró casi dos años y resultó en el lanzamiento de una plataforma a gran escala en K8, que consta de 200 servicios alojados en 48 mil contenedores. ¿Qué dificultades interesantes encontraron los ingenieros de Tinder y a qué resultados llegaron? Lea esta traducción.

Transición de Tinder a Kubernetes

¿Por qué?

Hace casi dos años, Tinder decidió trasladar su plataforma a Kubernetes. Kubernetes permitiría al equipo de Tinder crear contenedores y pasar a producción con un mínimo esfuerzo mediante una implementación inmutable. (implementación inmutable). En este caso, el ensamblaje de aplicaciones, su implementación y la infraestructura misma estarían definidos de forma única por código.

También buscábamos una solución al problema de la escalabilidad y la estabilidad. Cuando el escalado se volvió crítico, a menudo tuvimos que esperar varios minutos para que se activaran nuevas instancias EC2. La idea de lanzar contenedores y comenzar a atender el tráfico en segundos en lugar de minutos nos resultó muy atractiva.

El proceso resultó difícil. Durante nuestra migración a principios de 2019, el clúster de Kubernetes alcanzó una masa crítica y comenzamos a encontrar varios problemas debido al volumen de tráfico, el tamaño del clúster y el DNS. En el camino, resolvimos muchos problemas interesantes relacionados con la migración de 200 servicios y el mantenimiento de un clúster de Kubernetes que consta de 1000 nodos, 15000 48000 pods y XNUMX XNUMX contenedores en ejecución.

¿Cómo?

Desde enero de 2018 hemos pasado por varias etapas de migración. Comenzamos colocando en contenedores todos nuestros servicios e implementándolos en entornos de nube de prueba de Kubernetes. A partir de octubre, comenzamos a migrar metódicamente todos los servicios existentes a Kubernetes. En marzo del año siguiente, completamos la migración y ahora la plataforma Tinder se ejecuta exclusivamente en Kubernetes.

Construyendo imágenes para Kubernetes

Contamos con más de 30 repositorios de código fuente para microservicios que se ejecutan en un clúster de Kubernetes. El código de estos repositorios está escrito en diferentes lenguajes (por ejemplo, Node.js, Java, Scala, Go) con múltiples entornos de ejecución para el mismo lenguaje.

El sistema de compilación está diseñado para proporcionar un "contexto de compilación" totalmente personalizable para cada microservicio. Generalmente consta de un Dockerfile y una lista de comandos de shell. Su contenido es completamente personalizable y, al mismo tiempo, todos estos contextos de compilación están escritos según un formato estandarizado. La estandarización de los contextos de compilación permite que un único sistema de compilación maneje todos los microservicios.

Transición de Tinder a Kubernetes
Figura 1-1. Proceso de construcción estandarizado a través del contenedor Builder

Para lograr la máxima coherencia entre tiempos de ejecución (entornos de ejecución) El mismo proceso de construcción se utiliza durante el desarrollo y las pruebas. Nos enfrentamos a un desafío muy interesante: teníamos que desarrollar una manera de garantizar la coherencia del entorno de construcción en toda la plataforma. Para conseguirlo, todos los procesos de montaje se realizan dentro de un contenedor especial. Astillero.

La implementación de su contenedor requirió técnicas avanzadas de Docker. Builder hereda el ID de usuario local y los secretos (como la clave SSH, las credenciales de AWS, etc.) necesarios para acceder a los repositorios privados de Tinder. Monta directorios locales que contienen fuentes para almacenar naturalmente los artefactos de compilación. Este enfoque mejora el rendimiento porque elimina la necesidad de copiar artefactos de compilación entre el contenedor Builder y el host. Los artefactos de compilación almacenados se pueden reutilizar sin configuración adicional.

Para algunos servicios, tuvimos que crear otro contenedor para asignar el entorno de compilación al entorno de ejecución (por ejemplo, la biblioteca bcrypt de Node.js genera artefactos binarios específicos de la plataforma durante la instalación). Durante el proceso de compilación, los requisitos pueden variar entre los servicios y el Dockerfile final se compila sobre la marcha.

Arquitectura y migración del clúster de Kubernetes

Gestión del tamaño del clúster

Decidimos usar kube-aws para la implementación automatizada de clústeres en instancias Amazon EC2. Al principio, todo funcionaba en un grupo común de nodos. Rápidamente nos dimos cuenta de la necesidad de separar las cargas de trabajo por tamaño y tipo de instancia para hacer un uso más eficiente de los recursos. La lógica era que ejecutar varios pods de subprocesos múltiples cargados resultó ser más predecible en términos de rendimiento que su coexistencia con una gran cantidad de pods de un solo subproceso.

Al final nos decidimos por:

  • m5.4xlargo — para seguimiento (Prometheus);
  • c5.4xgrande - para la carga de trabajo de Node.js (carga de trabajo de un solo subproceso);
  • c5.2xgrande - para Java y Go (carga de trabajo multiproceso);
  • c5.4xgrande — para el panel de control (3 nodos).

La migracion

Uno de los pasos preparatorios para la migración de la antigua infraestructura a Kubernetes fue redirigir la comunicación directa existente entre servicios a los nuevos balanceadores de carga (Elastic Load Balancers (ELB). Fueron creados en una subred específica de una nube privada virtual (VPC). Esta subred estaba conectada a una VPC de Kubernetes. Esto nos permitió migrar módulos gradualmente, sin considerar el orden específico de las dependencias del servicio.

Estos puntos finales se crearon utilizando conjuntos ponderados de registros DNS que tenían CNAME apuntando a cada nuevo ELB. Para cambiar, agregamos una nueva entrada que apunta al nuevo servicio de Kubernetes ELB con un peso de 0. Luego configuramos el tiempo de vida (TTL) de la entrada establecida en 0. Después de esto, los pesos antiguos y nuevos se ajustaron lentamente. y, finalmente, el 100% de la carga se envió a un nuevo servidor. Una vez completado el cambio, el valor TTL volvió a un nivel más adecuado.

Los módulos Java que teníamos podían hacer frente a DNS TTL bajos, pero las aplicaciones Node no. Uno de los ingenieros reescribió parte del código del grupo de conexiones y lo incluyó en un administrador que actualizaba los grupos cada 60 segundos. El enfoque elegido funcionó muy bien y sin ninguna degradación notable del rendimiento.

Lecciones

Los límites del tejido de la red

En la madrugada del 8 de enero de 2019, la plataforma Tinder colapsó inesperadamente. En respuesta a un aumento no relacionado en la latencia de la plataforma esa mañana, la cantidad de pods y nodos en el clúster aumentó. Esto provocó que la caché ARP se agotara en todos nuestros nodos.

Hay tres opciones de Linux relacionadas con la caché ARP:

Transición de Tinder a Kubernetes
(fuente)

gc_thresh3 - Este es un límite estricto. La aparición de entradas de "desbordamiento de tabla vecina" en el registro significaba que incluso después de la recolección de basura (GC) sincrónica, no había suficiente espacio en la caché ARP para almacenar la entrada vecina. En este caso, el núcleo simplemente descartó el paquete por completo.

Usamos Franela como tejido de red en Kubernetes. Los paquetes se transmiten a través de VXLAN. VXLAN es un túnel L2 elevado sobre una red L3. La tecnología utiliza encapsulación MAC-in-UDP (Protocolo de datagramas de dirección MAC en usuario) y permite la expansión de segmentos de red de Capa 2. El protocolo de transporte en la red del centro de datos físico es IP más UDP.

Transición de Tinder a Kubernetes
Figura 2–1. Diagrama de franela (fuente)

Transición de Tinder a Kubernetes
Figura 2-2. Paquete VXLAN (fuente)

Cada nodo trabajador de Kubernetes asigna un espacio de direcciones virtuales con una máscara /24 de un bloque /9 más grande. Para cada nodo esto es medio una entrada en la tabla de enrutamiento, una entrada en la tabla ARP (en la interfaz flannel.1) y una entrada en la tabla de conmutación (FDB). Se agregan la primera vez que se inicia un nodo trabajador o cada vez que se descubre un nodo nuevo.

Además, la comunicación nodo-pod (o pod-pod) finalmente pasa a través de la interfaz eth0 (como se muestra en el diagrama de franela de arriba). Esto da como resultado una entrada adicional en la tabla ARP para cada host de origen y destino correspondiente.

En nuestro entorno, este tipo de comunicación es muy común. Para los objetos de servicio en Kubernetes, se crea un ELB y Kubernetes registra cada nodo en el ELB. El ELB no sabe nada acerca de los pods y es posible que el nodo seleccionado no sea el destino final del paquete. La cuestión es que cuando un nodo recibe un paquete del ELB, lo considera teniendo en cuenta las reglas iptables para un servicio específico y selecciona aleatoriamente un pod en otro nodo.

En el momento del fallo, había 605 nodos en el clúster. Por las razones expuestas anteriormente, esto fue suficiente para superar la importancia gc_thresh3, que es el valor predeterminado. Cuando esto sucede, no sólo los paquetes comienzan a descartarse, sino que todo el espacio de direcciones virtuales de franela con una máscara /24 desaparece de la tabla ARP. La comunicación nodo-pod y las consultas de DNS se interrumpen (el DNS está alojado en un clúster; lea más adelante en este artículo para obtener más detalles).

Para resolver este problema, es necesario aumentar los valores. gc_thresh1, gc_thresh2 и gc_thresh3 y reinicie franela para volver a registrar las redes que faltan.

Escalado de DNS inesperado

Durante el proceso de migración, utilizamos activamente DNS para gestionar el tráfico y transferir gradualmente servicios de la infraestructura anterior a Kubernetes. Establecimos valores TTL relativamente bajos para los RecordSets asociados en Route53. Cuando la infraestructura antigua se ejecutaba en instancias EC2, nuestra configuración de resolución apuntaba a Amazon DNS. Dimos esto por sentado y el impacto del bajo TTL en nuestros servicios y en los servicios de Amazon (como DynamoDB) pasó prácticamente desapercibido.

Cuando migramos servicios a Kubernetes, descubrimos que DNS procesaba 250 mil solicitudes por segundo. Como resultado, las aplicaciones comenzaron a experimentar tiempos de espera constantes y graves para las consultas de DNS. Esto sucedió a pesar de los increíbles esfuerzos para optimizar y cambiar el proveedor de DNS a CoreDNS (que en la carga máxima alcanzó 1000 pods ejecutándose en 120 núcleos).

Mientras investigamos otras posibles causas y soluciones, descubrimos Artículo, que describe las condiciones de carrera que afectan el marco de filtrado de paquetes. netfilter en linux. Los tiempos de espera que observamos, junto con un contador creciente inserción_fallida en la interfaz de franela fueron consistentes con los hallazgos del artículo.

El problema ocurre en la etapa de traducción de direcciones de red de origen y destino (SNAT y DNAT) y la posterior entrada en la tabla. conntrack. Una de las soluciones discutidas internamente y sugerida por la comunidad fue mover el DNS al propio nodo trabajador. En este caso:

  • SNAT no es necesario porque el tráfico permanece dentro del nodo. No es necesario enrutarlo a través de la interfaz. eth0.
  • DNAT no es necesario ya que la IP de destino es local para el nodo y no es un pod seleccionado aleatoriamente según las reglas. iptables.

Decidimos seguir con este enfoque. CoreDNS se implementó como DaemonSet en Kubernetes e implementamos un servidor DNS de nodo local en resolver.conf cada pod estableciendo una bandera --cluster-dns команды kubelet . Esta solución resultó eficaz para los tiempos de espera de DNS.

Sin embargo, todavía vimos pérdida de paquetes y un aumento en el contador. inserción_fallida en la interfaz de franela. Esto continuó después de que se implementó la solución porque pudimos eliminar SNAT y/o DNAT solo para el tráfico DNS. Las condiciones de carrera se mantuvieron para otros tipos de tráfico. Afortunadamente, la mayoría de nuestros paquetes son TCP y, si ocurre algún problema, simplemente se retransmiten. Todavía estamos intentando encontrar una solución adecuada para todo tipo de tráfico.

Uso de Envoy para un mejor equilibrio de carga

A medida que migramos los servicios backend a Kubernetes, comenzamos a sufrir un desequilibrio en la carga entre los pods. Descubrimos que HTTP Keepalive provocaba que las conexiones ELB se bloquearan en los primeros pods listos de cada implementación implementada. Por lo tanto, la mayor parte del tráfico pasó por un pequeño porcentaje de módulos disponibles. La primera solución que probamos fue configurar MaxSurge al 100% en nuevas implementaciones para los peores escenarios. El efecto resultó ser insignificante y poco prometedor en términos de despliegues más grandes.

Otra solución que utilizamos fue aumentar artificialmente las solicitudes de recursos para servicios críticos. En este caso, las cápsulas colocadas cerca tendrían más espacio para maniobrar en comparación con otras cápsulas pesadas. Tampoco funcionaría a largo plazo porque sería un desperdicio de recursos. Además, nuestras aplicaciones Node eran de un solo subproceso y, en consecuencia, solo podían usar un núcleo. La única solución real era utilizar un mejor equilibrio de carga.

Durante mucho tiempo hemos querido apreciar plenamente Enviado. La situación actual nos permitió implementarlo de forma muy limitada y obtener resultados inmediatos. Envoy es un proxy de capa XNUMX, de código abierto y de alto rendimiento diseñado para aplicaciones SOA de gran tamaño. Puede implementar técnicas avanzadas de equilibrio de carga, incluidos reintentos automáticos, disyuntores y limitación de velocidad global. (Nota. traducir: Puedes leer más sobre esto en este artículo sobre Istio, que se basa en Envoy).

Se nos ocurrió la siguiente configuración: tener un sidecar Envoy para cada módulo y una única ruta, y conectar el clúster al contenedor localmente a través del puerto. Para minimizar posibles cascadas y mantener un radio de impacto pequeño, utilizamos una flota de módulos de proxy frontal Envoy, uno por zona de disponibilidad (AZ) para cada servicio. Se basaron en un motor de descubrimiento de servicios simple escrito por uno de nuestros ingenieros que simplemente devolvió una lista de pods en cada AZ para un servicio determinado.

Los enviados frontales de servicio luego utilizaron este mecanismo de descubrimiento de servicios con un clúster y una ruta ascendentes. Establecimos tiempos de espera adecuados, aumentamos todas las configuraciones de los disyuntores y agregamos una configuración de reintento mínimo para ayudar con fallas únicas y garantizar implementaciones sin problemas. Colocamos un TCP ELB frente a cada uno de estos enviados frontales de servicio. Incluso si el keepalive de nuestra capa de proxy principal estaba bloqueado en algunos pods de Envoy, aún podían manejar la carga mucho mejor y estaban configurados para equilibrarse a través de less_request en el backend.

Para la implementación, utilizamos el gancho preStop tanto en los pods de aplicaciones como en los pods sidecar. El gancho desencadenó un error al verificar el estado del punto final de administración ubicado en el contenedor sidecar y entró en suspensión por un tiempo para permitir que finalizaran las conexiones activas.

Una de las razones por las que pudimos avanzar tan rápido se debe a las métricas detalladas que pudimos integrar fácilmente en una instalación típica de Prometheus. Esto nos permitió ver exactamente lo que estaba sucediendo mientras ajustamos los parámetros de configuración y redistribuimos el tráfico.

Los resultados fueron inmediatos y obvios. Empezamos con los servicios más desequilibrados y actualmente opera por delante de los 12 servicios más importantes del cluster. Este año estamos planeando una transición a una malla de servicio completo con descubrimiento de servicios más avanzado, interrupción de circuitos, detección de valores atípicos, limitación de velocidad y rastreo.

Transición de Tinder a Kubernetes
Figura 3–1. Convergencia de CPU de un servicio durante la transición a Envoy

Transición de Tinder a Kubernetes

Transición de Tinder a Kubernetes

Resultado final

A través de esta experiencia e investigación adicional, hemos creado un sólido equipo de infraestructura con sólidas habilidades en el diseño, implementación y operación de grandes clústeres de Kubernetes. Todos los ingenieros de Tinder ahora tienen el conocimiento y la experiencia para empaquetar contenedores e implementar aplicaciones en Kubernetes.

Cuando surgió la necesidad de capacidad adicional en la infraestructura antigua, tuvimos que esperar varios minutos para que se lanzaran nuevas instancias EC2. Ahora los contenedores comienzan a ejecutarse y a procesar el tráfico en segundos en lugar de minutos. La programación de múltiples contenedores en una sola instancia EC2 también proporciona una concentración horizontal mejorada. Como resultado, pronosticamos una reducción significativa en los costos de EC2019 en 2 en comparación con el año pasado.

La migración tomó casi dos años, pero la completamos en marzo de 2019. Actualmente, la plataforma Tinder se ejecuta exclusivamente en un clúster de Kubernetes que consta de 200 servicios, 1000 nodos, 15 pods y 000 contenedores en ejecución. La infraestructura ya no es dominio exclusivo de los equipos de operaciones. Todos nuestros ingenieros comparten esta responsabilidad y controlan el proceso de creación e implementación de sus aplicaciones utilizando únicamente código.

PD del traductor

Lea también una serie de artículos en nuestro blog:

Fuente: habr.com

Añadir un comentario