A mudança de Tinder para Kubernetes

Escrito por: Chris O'Brien, gerente de engenharia | Chris Thomas, gerente de engenharia | Jinyong Lee, engenheiro de software sênior | Editado por: Cooper Jackson, Engenheiro de Software

Por quê

Há quase dois anos, o Tinder decidiu mudar sua plataforma para Kubernetes. O Kubernetes nos deu a oportunidade de impulsionar a Engenharia do Tinder em direção à contêiner e à operação discreta por meio de uma implantação imutável. A criação, implantação e infraestrutura de aplicativos seriam definidas como código.

Também buscávamos enfrentar desafios de escala e estabilidade. Quando o dimensionamento se tornou crítico, geralmente sofríamos vários minutos de espera para novas instâncias do EC2 ficarem online. A idéia de contêineres agendando e servindo tráfego em segundos, em vez de minutos, era atraente para nós.

Não foi fácil. Durante nossa migração no início de 2019, alcançamos massa crítica em nosso cluster Kubernetes e começamos a enfrentar vários desafios devido ao volume de tráfego, tamanho do cluster e DNS. Resolvemos desafios interessantes para migrar 200 serviços e executar um cluster Kubernetes em escala totalizando 1.000 nós, 15.000 pods e 48.000 contêineres em execução.

Quão

A partir de janeiro de 2018, desenvolvemos várias etapas do esforço de migração. Começamos contendo todos os nossos serviços e implantando-os em uma série de ambientes de armazenamento temporário hospedados pelo Kubernetes. A partir de outubro, começamos a mover metodicamente todos os nossos serviços herdados para o Kubernetes. Em março do ano seguinte, finalizamos nossa migração e a Plataforma Tinder agora é executada exclusivamente no Kubernetes.

Imagens de construção para Kubernetes

Existem mais de 30 repositórios de código-fonte para os microsserviços em execução no cluster Kubernetes. O código nesses repositórios é escrito em diferentes idiomas (por exemplo, Node.js, Java, Scala, Go) com vários ambientes de tempo de execução para o mesmo idioma.

O sistema de compilação foi projetado para operar em um "contexto de compilação" totalmente personalizável para cada microsserviço, que geralmente consiste em um Dockerfile e uma série de comandos do shell. Embora seu conteúdo seja totalmente personalizável, esses contextos de criação são todos escritos seguindo um formato padronizado. A padronização dos contextos de construção permite que um único sistema de construção manipule todos os microsserviços.

Figura 1–1 Processo de construção padronizado através do contêiner do Builder

Para obter a máxima consistência entre os ambientes de tempo de execução, o mesmo processo de construção está sendo usado durante a fase de desenvolvimento e teste. Isso impôs um desafio único quando precisávamos criar uma maneira de garantir um ambiente de construção consistente em toda a plataforma. Como resultado, todos os processos de construção são executados dentro de um contêiner especial "Construtor".

A implementação do contêiner do Builder exigiu várias técnicas avançadas do Docker. Esse contêiner do Builder herda o ID do usuário e os segredos locais (por exemplo, chave SSH, credenciais da AWS etc.) conforme necessário para acessar os repositórios privados do Tinder. Ele monta diretórios locais que contêm o código-fonte para ter uma maneira natural de armazenar artefatos de construção. Essa abordagem melhora o desempenho, porque elimina a cópia de artefatos construídos entre o contêiner do Builder e a máquina host. Os artefatos de construção armazenados são reutilizados na próxima vez sem configuração adicional.

Para certos serviços, precisávamos criar outro contêiner no Builder para combinar o ambiente de tempo de compilação com o ambiente de tempo de execução (por exemplo, instalar a biblioteca bcrypt do Node.js. gera artefatos binários específicos da plataforma). Os requisitos de tempo de compilação podem diferir entre os serviços e o Dockerfile final é composto em tempo real.

Arquitetura e migração de cluster Kubernetes

Dimensionamento de cluster

Decidimos usar o kube-aws para provisionamento automatizado de cluster em instâncias do Amazon EC2. No início, estávamos executando tudo em um pool de nós geral. Identificamos rapidamente a necessidade de separar as cargas de trabalho em diferentes tamanhos e tipos de instâncias, para fazer melhor uso dos recursos. O raciocínio era que a execução de menos pods fortemente encadeados em conjunto produzia resultados de desempenho mais previsíveis para nós do que permitir que eles coexistissem com um número maior de pods com um único encadeamento.

Nós decidimos:

  • m5.4xlarge para monitoramento (Prometheus)
  • c5.4xlarge para carga de trabalho do Node.js. (carga de trabalho de thread único)
  • c5.2xlarge para Java e Go (carga de trabalho multiencadeada)
  • c5.4xlarge para o plano de controle (3 nós)

Migração

Uma das etapas de preparação para a migração de nossa infraestrutura herdada para o Kubernetes foi alterar a comunicação serviço a serviço existente para apontar para os novos Elastic Load Balancers (ELBs) criados em uma sub-rede específica da Nuvem Privada Virtual (VPC). Essa sub-rede foi direcionada ao VPC do Kubernetes. Isso nos permitiu migrar módulos granularmente, sem levar em consideração pedidos específicos para dependências de serviço.

Esses pontos de extremidade foram criados usando conjuntos de registros DNS ponderados que tinham um CNAME apontando para cada novo ELB. Para a transição, adicionamos um novo registro, apontando para o novo serviço Kubernetes ELB, com um peso de 0. Em seguida, definimos o Time To Live (TTL) no registro definido como 0. Os pesos antigos e novos foram ajustados lentamente para eventualmente, acabará com 100% no novo servidor. Após a conclusão da transição, o TTL foi ajustado para algo mais razoável.

Nossos módulos Java honraram o baixo TTL DNS, mas nossos aplicativos Node não. Um de nossos engenheiros reescreveu parte do código do conjunto de conexões para envolvê-lo em um gerente que atualizaria os conjuntos a cada 60s. Isso funcionou muito bem para nós, sem nenhum impacto significativo no desempenho.

Aprendizagem

Limites de malha de rede

Nas primeiras horas da manhã de 8 de janeiro de 2019, a Plataforma do Tinder sofreu uma interrupção persistente. Em resposta a um aumento não relacionado na latência da plataforma mais cedo naquela manhã, as contagens de pod e nó foram dimensionadas no cluster. Isso resultou na exaustão do cache ARP em todos os nossos nós.

Existem três valores do Linux relevantes para o cache do ARP:

Crédito

gc_thresh3 é um limite máximo. Se você estiver recebendo entradas de log de "excesso de tabelas vizinhas", isso indica que, mesmo após uma coleta de lixo síncrona (GC) do cache do ARP, não havia espaço suficiente para armazenar a entrada vizinha. Nesse caso, o kernel simplesmente descarta o pacote completamente.

Usamos a Flanela como nossa malha de rede no Kubernetes. Os pacotes são encaminhados via VXLAN. VXLAN é um esquema de sobreposição da camada 2 em uma rede da camada 3. Ele usa o encapsulamento MAC-in-User Datagram Protocol (MAC-in-UDP) para fornecer um meio de estender os segmentos de rede da Camada 2. O protocolo de transporte na rede física do datacenter é IP mais UDP.

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

Figura 2–2 Pacote VXLAN (crédito)

Cada nó de trabalho do Kubernetes aloca seu próprio / 24 de espaço de endereço virtual em um bloco / 9 maior. Para cada nó, isso resulta em 1 entrada na tabela de rotas, 1 entrada na tabela ARP (na interface flannel.1) e 1 entrada no banco de dados de encaminhamento (FDB). Eles são adicionados quando o nó do trabalhador é iniciado pela primeira vez ou quando cada novo nó é descoberto.

Além disso, a comunicação nó a pod (ou pod a pod) acaba fluindo através da interface eth0 (representada no diagrama de flanela acima). Isso resultará em uma entrada adicional na tabela ARP para cada origem e destino do nó correspondente.

Em nosso ambiente, esse tipo de comunicação é muito comum. Para nossos objetos de serviço Kubernetes, um ELB é criado e o Kubernetes registra todos os nós no ELB. O ELB não reconhece o pod e o nó selecionado pode não ser o destino final do pacote. Isso ocorre porque, quando o nó recebe o pacote do ELB, ele avalia suas regras de tabelas de ip para o serviço e seleciona aleatoriamente um pod em outro nó.

No momento da interrupção, havia 605 nós no cluster. Pelas razões descritas acima, isso foi suficiente para eclipsar o valor padrão de gc_thresh3. Quando isso acontece, não apenas os pacotes estão sendo descartados, mas toda a Flanela / 24s de espaço de endereço virtual está ausente na tabela ARP. Nó para comunicação pod e pesquisas de DNS falham. (O DNS está hospedado no cluster, como será explicado em mais detalhes posteriormente neste artigo.)

Para resolver, os valores de gc_thresh1, gc_thresh2 e gc_thresh3 são aumentados e a Flannel deve ser reiniciada para registrar novamente as redes ausentes.

Inesperadamente executando DNS em escala

Para acomodar nossa migração, utilizamos o DNS fortemente para facilitar a modelagem de tráfego e a transição incremental do legado para o Kubernetes para nossos serviços. Definimos valores TTL relativamente baixos nos RecordSets Route53 associados. Quando executamos nossa infraestrutura herdada em instâncias do EC2, nossa configuração de resolvedor apontou para o DNS da Amazon. Tomamos isso como garantido e o custo de um TTL relativamente baixo para nossos serviços e os serviços da Amazon (por exemplo, DynamoDB) passou despercebido.

À medida que embarcamos cada vez mais serviços para o Kubernetes, nos deparamos com um serviço DNS que atendia 250.000 solicitações por segundo. Estávamos encontrando tempos limite de pesquisa de DNS intermitentes e impactantes em nossos aplicativos. Isso ocorreu apesar de um esforço exaustivo de ajuste e de um provedor de DNS mudar para uma implantação CoreDNS que chegou a atingir 1.000 pods consumindo 120 núcleos.

Ao pesquisar outras causas e soluções possíveis, encontramos um artigo descrevendo uma condição de corrida que afeta o netfilter da estrutura de filtragem de pacotes do Linux. Os tempos limite de DNS que estávamos vendo, junto com um contador insert_failed crescente na interface Flannel, alinhados com as descobertas do artigo.

O problema ocorre durante a tradução de endereços de rede de origem e destino (SNAT e DNAT) e posterior inserção na tabela conntrack. Uma solução alternativa discutida internamente e proposta pela comunidade foi mover o DNS para o próprio nó do trabalhador. Nesse caso:

  • O SNAT não é necessário, porque o tráfego permanece localmente no nó. Ele não precisa ser transmitido pela interface eth0.
  • O DNAT não é necessário porque o IP de destino é local para o nó e não um pod selecionado aleatoriamente por regras de tabelas de ip.

Decidimos avançar com essa abordagem. O CoreDNS foi implantado como um DaemonSet no Kubernetes e injetamos o servidor DNS local do nó no resolv.conf de cada pod configurando o sinalizador de comando kubelet - cluster-dns. A solução alternativa foi eficaz para tempos limite de DNS.

No entanto, ainda vemos pacotes descartados e o incremento do contador insert_failed da interface Flannel. Isso persistirá mesmo após a solução alternativa acima, porque evitamos apenas o SNAT e / ou DNAT para o tráfego DNS. A condição de corrida ainda ocorrerá para outros tipos de tráfego. Felizmente, a maioria dos nossos pacotes é TCP e, quando a condição ocorre, os pacotes serão retransmitidos com êxito. Uma correção de longo prazo para todos os tipos de tráfego é algo que ainda estamos discutindo.

Usando o enviado para obter melhor balanceamento de carga

Ao migrarmos nossos serviços de back-end para o Kubernetes, começamos a sofrer uma carga desequilibrada entre os pods. Descobrimos que, devido ao HTTP Keepalive, as conexões ELB mantinham os primeiros pods prontos de cada implantação contínua, portanto a maior parte do tráfego fluía através de uma pequena porcentagem dos pods disponíveis. Uma das primeiras mitigações que tentamos foi usar o MaxSurge 100% em novas implantações para os piores criminosos. Isso foi marginalmente eficaz e não sustentável a longo prazo em algumas das implantações maiores.

Outra mitigação que usamos foi inflar artificialmente solicitações de recursos em serviços críticos, para que os pods colocados tivessem mais espaço ao lado de outros pods pesados. Isso também não seria sustentável a longo prazo devido ao desperdício de recursos e nossos aplicativos Node eram de thread único e, portanto, efetivamente limitados em 1 núcleo. A única solução clara foi utilizar um melhor balanceamento de carga.

Estávamos procurando internamente avaliar o Enviado. Isso nos deu a chance de implementá-lo de uma maneira muito limitada e colher benefícios imediatos. O Envoy é um proxy da Camada 7 de código aberto e alto desempenho, projetado para grandes arquiteturas orientadas a serviços. É capaz de implementar técnicas avançadas de balanceamento de carga, incluindo novas tentativas automáticas, interrupção de circuito e limitação de taxa global.

A configuração que criamos era ter um side-car Envoy ao lado de cada pod que tivesse uma rota e cluster para atingir a porta de contêiner local. Para minimizar o potencial de cascata e manter um pequeno raio de explosão, utilizamos uma frota de pods Envoy com proxy frontal, uma implantação em cada Zona de Disponibilidade (AZ) para cada serviço. Eles atingiram um pequeno mecanismo de descoberta de serviço que um de nossos engenheiros montou, que simplesmente retornou uma lista de pods em cada AZ para um determinado serviço.

Os representantes da frente de serviço utilizaram esse mecanismo de descoberta de serviço com um cluster e rota upstream. Definimos tempos limites razoáveis, aprimoramos todas as configurações do disjuntor e, em seguida, colocamos uma configuração mínima de repetição para ajudar com falhas transitórias e implantações suaves. Enfrentamos cada um desses serviços Envoy de frente com um TCP ELB. Mesmo que o keepalive da nossa camada principal de proxy frontal tenha sido fixado em alguns pods da Envoy, eles poderiam lidar muito melhor com a carga e foram configurados para balancear por meio de menos pedidos ao back-end.

Para implantações, utilizamos um gancho preStop no aplicativo e no pod do side-car. Esse gancho chamado de verificação de integridade do carro lateral falha no ponto de extremidade do administrador, juntamente com um pequeno sono, para dar algum tempo para permitir que as conexões a bordo sejam concluídas e drenadas.

Um dos motivos pelos quais conseguimos avançar tão rapidamente foi devido às métricas avançadas que conseguimos integrar facilmente à nossa configuração normal do Prometheus. Isso nos permitiu ver exatamente o que estava acontecendo quando iteramos nas definições de configuração e cortamos o tráfego.

Os resultados foram imediatos e óbvios. Começamos com os serviços mais desequilibrados e, neste momento, ele está sendo executado na frente de doze dos serviços mais importantes em nosso cluster. Este ano, planejamos mudar para uma malha de serviço completo, com descoberta de serviço mais avançada, interrupção de circuito, detecção de outlier, limitação de taxa e rastreamento.

Figura 3-1 Convergência da CPU de um serviço durante a transição para o enviado

O resultado final

Com essas aprendizagens e pesquisas adicionais, desenvolvemos uma forte equipe de infraestrutura interna com grande familiaridade em como projetar, implantar e operar grandes clusters Kubernetes. Toda a organização de engenharia do Tinder agora tem conhecimento e experiência em como contêiner e implantar seus aplicativos no Kubernetes.

Em nossa infraestrutura herdada, quando era necessária uma escala adicional, geralmente passávamos vários minutos aguardando a entrada de novas instâncias do EC2. Agora, os contêineres agendam e veiculam o tráfego em segundos, em vez de minutos. O agendamento de vários contêineres em uma única instância do EC2 também fornece uma densidade horizontal aprimorada. Como resultado, projetamos uma economia substancial de custos no EC2 em 2019 em comparação com o ano anterior.

Demorou quase dois anos, mas finalizamos nossa migração em março de 2019. A Plataforma Tinder é executada exclusivamente em um cluster Kubernetes composto por 200 serviços, 1.000 nós, 15.000 pods e 48.000 contêineres em execução. A infraestrutura não é mais uma tarefa reservada para nossas equipes de operações. Em vez disso, os engenheiros de toda a organização compartilham essa responsabilidade e têm controle sobre como seus aplicativos são construídos e implantados com tudo como código.