La mudanza de Tinder a Kubernetes

Escrito por: Chris O'Brien, Gerente de Ingeniería | Chris Thomas, Gerente de Ingeniería | Jinyong Lee, ingeniero de software sénior | Editado por: Cooper Jackson, ingeniero de software

Por qué

Hace casi dos años, Tinder decidió trasladar su plataforma a Kubernetes. Kubernetes nos brindó la oportunidad de conducir a Tinder Engineering hacia la contenedorización y la operación de bajo contacto a través de una implementación inmutable. La construcción, el despliegue y la infraestructura de la aplicación se definirían como código.

También buscábamos abordar desafíos de escala y estabilidad. Cuando el escalado se volvió crítico, a menudo sufrimos varios minutos de espera para que las nuevas instancias de EC2 se pusieran en línea. Nos atraía la idea de que los contenedores programaran y atendieran el tráfico en cuestión de segundos en lugar de minutos.

No fue facil. Durante nuestra migración a principios de 2019, alcanzamos una masa crítica dentro de nuestro clúster de Kubernetes y comenzamos a enfrentar varios desafíos debido al volumen de tráfico, el tamaño del clúster y el DNS. Resolvimos desafíos interesantes para migrar 200 servicios y ejecutar un clúster de Kubernetes a escala totalizando 1,000 nodos, 15,000 pods y 48,000 contenedores en ejecución.

Cómo

A partir de enero de 2018, trabajamos en varias etapas del esfuerzo de migración. Comenzamos por contener todos nuestros servicios e implementarlos en una serie de entornos de almacenamiento alojados en Kubernetes. A partir de octubre, comenzamos a trasladar metódicamente todos nuestros servicios heredados a Kubernetes. Para marzo del año siguiente, finalizamos nuestra migración y la Plataforma Tinder ahora se ejecuta exclusivamente en Kubernetes.

Imágenes del edificio para Kubernetes

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

El sistema de compilación está diseñado para funcionar en un "contexto de compilación" totalmente personalizable para cada microservicio, que generalmente consiste en un Dockerfile y una serie de comandos de shell. Si bien sus contenidos son totalmente personalizables, estos contextos de compilación se escriben siguiendo un formato estandarizado. La estandarización de los contextos de compilación permite que un único sistema de compilación maneje todos los microservicios.

Figura 1–1 Proceso de compilación estandarizado a través del contenedor Builder

Para lograr la máxima consistencia entre los entornos de tiempo de ejecución, se está utilizando el mismo proceso de compilación durante la fase de desarrollo y prueba. Esto impuso un desafío único cuando necesitábamos idear una forma de garantizar un entorno de construcción consistente en toda la plataforma. Como resultado, todos los procesos de compilación se ejecutan dentro de un contenedor especial "Generador".

La implementación del contenedor Builder requirió varias técnicas avanzadas de Docker. Este contenedor de Builder hereda la identificación de usuario local y los secretos (por ejemplo, clave SSH, credenciales de AWS, etc.) según sea necesario para acceder a los repositorios privados de Tinder. Monta directorios locales que contienen el código fuente para tener una forma natural de almacenar artefactos de compilación. Este enfoque mejora el rendimiento, ya que elimina la copia de artefactos construidos entre el contenedor del generador y la máquina host. Los artefactos de compilación almacenados se reutilizan la próxima vez sin más configuración.

Para ciertos servicios, necesitábamos crear otro contenedor dentro del generador para que coincida con el entorno de tiempo de compilación con el entorno de tiempo de ejecución (por ejemplo, la instalación de la biblioteca bcrypt de Node.js genera artefactos binarios específicos de la plataforma). Los requisitos de tiempo de compilación pueden diferir entre los servicios y el Dockerfile final se compone sobre la marcha.

Kubernetes Cluster Arquitectura y migración

Dimensionamiento de racimo

Decidimos usar kube-aws para el aprovisionamiento automatizado de clústeres en instancias de Amazon EC2. Al principio, estábamos ejecutando todo en un grupo de nodos general. Identificamos rápidamente la necesidad de separar las cargas de trabajo en diferentes tamaños y tipos de instancias, para hacer un mejor uso de los recursos. El razonamiento fue que correr menos vainas con muchos subprocesos juntos produjo resultados de rendimiento más predecibles para nosotros que dejarlos coexistir con una mayor cantidad de vainas de un solo subproceso.

Nos decidimos por:

  • m5.4xlarge para monitoreo (Prometheus)
  • c5.4xlarge para la carga de trabajo de Node.js (carga de trabajo de subproceso único)
  • c5.2xlarge para Java y Go (carga de trabajo multiproceso)
  • c5.4xlarge para el plano de control (3 nodos)

Migración

Uno de los pasos de preparación para la migración de nuestra infraestructura heredada a Kubernetes fue cambiar la comunicación existente de servicio a servicio para que apunte a nuevos Elastic Load Balancers (ELB) que se crearon en una subred específica de Virtual Private Cloud (VPC). Esta subred se asoció a la VPC de Kubernetes. Esto nos permitió migrar granularmente los módulos sin tener en cuenta el pedido específico de dependencias de servicio.

Estos puntos finales se crearon utilizando conjuntos de registros DNS ponderados que tenían un CNAME apuntando a cada nuevo ELB. Para realizar la transición, agregamos un nuevo registro, señalando el nuevo ELB de servicio de Kubernetes, con un peso de 0. Luego configuramos Time to Live (TTL) en el registro establecido en 0. Los pesos viejos y nuevos se ajustaron lentamente a finalmente terminará con el 100% en el nuevo servidor. Después de que se completó la transición, el TTL se configuró en algo más razonable.

Nuestros módulos Java respetaron el bajo TTL de DNS, pero nuestras aplicaciones de Nodo no. Uno de nuestros ingenieros reescribió parte del código del grupo de conexiones para envolverlo en un administrador que actualizaría los grupos cada 60 años. Esto funcionó muy bien para nosotros sin un rendimiento apreciable.

Aprendizajes

Límites de tejido de red

En las primeras horas de la mañana del 8 de enero de 2019, Tinder's Platform sufrió una interrupción persistente. En respuesta a un aumento no relacionado en la latencia de la plataforma más temprano esa mañana, los recuentos de pod y nodo se escalaron en el clúster. Esto resultó en el agotamiento de la caché ARP en todos nuestros nodos.

Hay tres valores de Linux relevantes para el caché ARP:

Crédito

gc_thresh3 es una tapa dura. Si obtiene entradas de registro de "desbordamiento de tabla vecina", esto indica que incluso después de una recolección de basura síncrona (GC) de la caché ARP, no había suficiente espacio para almacenar la entrada vecina. En este caso, el núcleo simplemente descarta el paquete por completo.

Usamos Flannel como nuestro tejido de red en Kubernetes. Los paquetes se reenvían a través de VXLAN. VXLAN es un esquema de superposición de capa 2 sobre una red de capa 3. Utiliza la encapsulación del Protocolo de datagramas de dirección MAC en usuario (MAC en UDP) para proporcionar un medio para extender los segmentos de red de Capa 2. El protocolo de transporte a través de la red del centro de datos físico es IP más UDP.

Figura 2–1 Diagrama de franela (crédito)

Figura 2–2 Paquete VXLAN (crédito)

Cada nodo de trabajo de Kubernetes asigna su propio espacio de direcciones virtual / 24 a partir de un bloque más grande / 9. Para cada nodo, esto da como resultado 1 entrada en la tabla de rutas, 1 entrada en la tabla ARP (en la interfaz flannel.1) y 1 entrada en la base de datos de reenvío (FDB). Estos se agregan cuando el nodo de trabajo se inicia por primera vez o cuando se descubre cada nuevo nodo.

Además, la comunicación nodo a pod (o pod a pod) finalmente fluye a través de la interfaz eth0 (representada en el diagrama de Flannel anterior). Esto dará como resultado una entrada adicional en la tabla ARP para cada origen de nodo correspondiente y destino de nodo.

En nuestro entorno, este tipo de comunicación es muy común. Para nuestros objetos de servicio Kubernetes, se crea un ELB y Kubernetes registra cada nodo con el ELB. El ELB no reconoce el pod y el nodo seleccionado puede no ser el destino final del paquete. Esto se debe a que cuando el nodo recibe el paquete del ELB, evalúa sus reglas de iptables para el servicio y selecciona aleatoriamente un pod en otro nodo.

En el momento de la interrupción, había un total de 605 nodos en el clúster. Por los motivos descritos anteriormente, esto fue suficiente para eclipsar el valor predeterminado de gc_thresh3. Una vez que esto sucede, no solo se descartan los paquetes, sino que faltan Flannel / 24s completos de espacio de direcciones virtuales en la tabla ARP. La comunicación de nodo a pod y las búsquedas de DNS fallan. (DNS está alojado dentro del clúster, como se explicará con mayor detalle más adelante en este artículo).

Para resolver, los valores gc_thresh1, gc_thresh2 y gc_thresh3 se generan y Flannel debe reiniciarse para volver a registrar las redes que faltan.

Ejecución inesperada de DNS a escala

Para acomodar nuestra migración, aprovechamos mucho el DNS para facilitar la conformación del tráfico y la transición incremental de legado a Kubernetes para nuestros servicios. Establecemos valores TTL relativamente bajos en los Route53 RecordSets asociados. Cuando ejecutamos nuestra infraestructura heredada en instancias EC2, nuestra configuración de resolución apuntó al DNS de Amazon. Dimos esto por sentado y el costo de un TTL relativamente bajo para nuestros servicios y los servicios de Amazon (por ejemplo, DynamoDB) pasó desapercibido.

A medida que incorporamos más y más servicios a Kubernetes, nos encontramos ejecutando un servicio DNS que respondía 250,000 solicitudes por segundo. Nos encontramos con tiempos de espera de búsqueda de DNS intermitentes e impactantes dentro de nuestras aplicaciones. Esto ocurrió a pesar de un esfuerzo de ajuste exhaustivo y un proveedor de DNS cambió a una implementación de CoreDNS que en un momento alcanzó un máximo de 1,000 pods que consumían 120 núcleos.

Mientras investigamos otras posibles causas y soluciones, encontramos un artículo que describe una condición de carrera que afecta el marco de filtrado de paquetes de Linux netfilter. Los tiempos de espera de DNS que estábamos viendo, junto con un contador incremental insert_failed en la interfaz de Flannel, alineado con los hallazgos del artículo.

El problema ocurre durante la traducción de direcciones de red de origen y de destino (SNAT y DNAT) y la inserción posterior en la tabla conntrack. Una solución discutida internamente y propuesta por la comunidad era mover DNS al propio nodo de trabajo. En este caso:

  • SNAT no es necesario porque el tráfico permanece localmente en el nodo. No necesita transmitirse a través de la interfaz eth0.
  • DNAT no es necesario porque la IP de destino es local para el nodo y no es un pod seleccionado al azar según las reglas de iptables.

Decidimos seguir adelante con este enfoque. CoreDNS se implementó como un DaemonSet en Kubernetes e inyectamos el servidor DNS local del nodo en el resolv.conf de cada pod configurando el indicador de comando kubelet - cluster-dns. La solución fue efectiva para los tiempos de espera de DNS.

Sin embargo, todavía vemos paquetes descartados y el incremento del contador insert_failed de la interfaz Flannel. Esto persistirá incluso después de la solución anterior porque solo evitamos SNAT y / o DNAT para el tráfico DNS. La condición de carrera seguirá ocurriendo para otros tipos de tráfico. Afortunadamente, la mayoría de nuestros paquetes son TCP y cuando ocurre la condición, los paquetes se retransmitirán con éxito. Una solución a largo plazo para todos los tipos de tráfico es algo que todavía estamos discutiendo.

Uso de Envoy para lograr un mejor equilibrio de carga

A medida que migramos nuestros servicios de back-end a Kubernetes, comenzamos a sufrir una carga desequilibrada en los pods. Descubrimos que debido a HTTP Keepalive, las conexiones ELB se adhirieron a los primeros pods listos de cada implementación continua, por lo que la mayoría del tráfico fluyó a través de un pequeño porcentaje de los pods disponibles. Una de las primeras mitigaciones que probamos fue utilizar un MaxSurge 100% en nuevas implementaciones para los peores delincuentes. Esto fue marginalmente efectivo y no sostenible a largo plazo con algunos de los despliegues más grandes.

Otra mitigación que utilizamos fue inflar artificialmente las solicitudes de recursos en servicios críticos para que las vainas ubicadas tengan más espacio libre junto con otras vainas pesadas. Esto tampoco iba a ser sostenible a largo plazo debido al desperdicio de recursos y nuestras aplicaciones de Nodo tenían un solo subproceso y, por lo tanto, se limitaban efectivamente a 1 núcleo. La única solución clara era utilizar un mejor equilibrio de carga.

Habíamos estado buscando evaluar internamente a Envoy. Esto nos dio la oportunidad de implementarlo de una manera muy limitada y obtener beneficios inmediatos. Envoy es un proxy Layer 7 de código abierto y alto rendimiento diseñado para grandes arquitecturas orientadas a servicios. Es capaz de implementar técnicas avanzadas de equilibrio de carga, que incluyen reintentos automáticos, interrupción de circuitos y limitación de velocidad global.

La configuración que se nos ocurrió fue tener un sidecar Envoy junto a cada pod que tuviera una ruta y un clúster para llegar al puerto de contenedores local. Para minimizar la cascada potencial y mantener un pequeño radio de explosión, utilizamos una flota de unidades de Enviados de proxy frontal, un despliegue en cada Zona de Disponibilidad (AZ) para cada servicio. Estos afectaron a un pequeño mecanismo de descubrimiento de servicio que uno de nuestros ingenieros creó y que simplemente devolvió una lista de pods en cada AZ para un servicio dado.

Los enviados frontales del servicio utilizaron este mecanismo de descubrimiento de servicios con un clúster y una ruta aguas arriba. Configuramos tiempos de espera razonables, aumentamos todas las configuraciones de los interruptores automáticos, y luego pusimos una configuración de reintento mínima para ayudar con fallas transitorias y despliegues sin problemas. Enfrentamos cada uno de estos servicios de Enviado frontal con un TCP ELB. Incluso si el keepalive de nuestra capa principal de proxy frontal se fijó en ciertos pods de Envoy, fueron mucho más capaces de manejar la carga y se configuraron para equilibrarse a través de la menor solicitud al backend.

Para las implementaciones, utilizamos un gancho preStop tanto en la aplicación como en el pod de sidecar. Este gancho llamado punto final de administración de comprobación de estado del sidecar, junto con un pequeño sueño, para dar algo de tiempo para permitir que las conexiones en vuelo se completen y se agoten.

Una de las razones por las que pudimos movernos tan rápido fue debido a las ricas métricas que pudimos integrar fácilmente con nuestra configuración normal de Prometheus. Esto nos permitió ver exactamente lo que estaba sucediendo mientras iteramos en los ajustes de configuración y reducimos el tráfico.

Los resultados fueron inmediatos y obvios. Comenzamos con los servicios más desequilibrados y, en este punto, lo ejecutamos frente a doce de los servicios más importantes de nuestro clúster. Este año planeamos pasar a una malla de servicio completo, con descubrimiento de servicio más avanzado, interrupción de circuitos, detección de valores atípicos, limitación de velocidad y rastreo.

Figura 3–1 Convergencia de CPU de un servicio durante la transición para enviar

El final resulto

A través de estos aprendizajes e investigaciones adicionales, hemos desarrollado un sólido equipo interno de infraestructura con gran familiaridad sobre cómo diseñar, implementar y operar grandes grupos de Kubernetes. Toda la organización de ingeniería de Tinder ahora tiene conocimiento y experiencia sobre cómo contener e implementar sus aplicaciones en Kubernetes.

En nuestra infraestructura heredada, cuando se requería una escala adicional, a menudo sufríamos varios minutos de espera para que las nuevas instancias de EC2 se pusieran en línea. Los contenedores ahora programan y sirven 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 densidad horizontal mejorada. Como resultado, proyectamos ahorros de costos sustanciales en EC2 en 2019 en comparación con el año anterior.

Nos llevó casi dos años, pero finalizamos nuestra migración en marzo de 2019. La plataforma Tinder se ejecuta exclusivamente en un clúster de Kubernetes que consta de 200 servicios, 1,000 nodos, 15,000 pods y 48,000 contenedores en ejecución. La infraestructura ya no es una tarea reservada para nuestros equipos de operaciones. En cambio, los ingenieros de toda la organización comparten esta responsabilidad y tienen control sobre cómo se crean y despliegan sus aplicaciones con todo como código.