Transição do Tinder para Kubernetes

Observação. trad.: Funcionários do mundialmente famoso serviço Tinder compartilharam recentemente alguns detalhes técnicos da migração de sua infraestrutura para Kubernetes. O processo durou quase dois anos e resultou no lançamento de uma plataforma de grande porte em K8s, composta por 200 serviços hospedados em 48 mil contêineres. Que dificuldades interessantes os engenheiros do Tinder encontraram e a que resultados chegaram? Leia esta tradução.

Transição do Tinder para Kubernetes

Por quê?

Há quase dois anos, o Tinder decidiu migrar sua plataforma para o Kubernetes. O Kubernetes permitiria que a equipe do Tinder colocasse em contêineres e passasse para a produção com esforço mínimo por meio de implantação imutável (implantação imutável). Nesse caso, a montagem das aplicações, sua implantação e a própria infraestrutura seriam definidas exclusivamente por código.

Também procurávamos uma solução para o problema de escalabilidade e estabilidade. Quando o dimensionamento se tornou crítico, muitas vezes tivemos que esperar vários minutos para que novas instâncias do EC2 fossem ativadas. A ideia de lançar containers e começar a atender o tráfego em segundos em vez de minutos tornou-se muito atrativa para nós.

O processo acabou sendo difícil. Durante nossa migração no início de 2019, o cluster Kubernetes atingiu a massa crítica e começamos a encontrar vários problemas devido ao volume de tráfego, tamanho do cluster e DNS. Ao longo do caminho, resolvemos muitos problemas interessantes relacionados à migração de 200 serviços e à manutenção de um cluster Kubernetes composto por 1000 nós, 15000 pods e 48000 contêineres em execução.

Como?

Desde janeiro de 2018, passamos por vários estágios de migração. Começamos conteinerizando todos os nossos serviços e implantando-os em ambientes de nuvem de teste do Kubernetes. A partir de outubro, começamos a migrar metodicamente todos os serviços existentes para o Kubernetes. Em março do ano seguinte, concluímos a migração e agora a plataforma Tinder roda exclusivamente no Kubernetes.

Construindo imagens para Kubernetes

Temos mais de 30 repositórios de código-fonte para microsserviços em execução em um cluster Kubernetes. O código nesses repositórios é escrito em diferentes linguagens (por exemplo, Node.js, Java, Scala, Go) com vários ambientes de tempo de execução para a mesma linguagem.

O sistema de compilação foi projetado para fornecer um “contexto de compilação” totalmente personalizável para cada microsserviço. Geralmente consiste em um Dockerfile e uma lista de comandos shell. Seu conteúdo é totalmente customizável e, ao mesmo tempo, todos esses contextos de construção são escritos de acordo com um formato padronizado. A padronização dos contextos de build permite que um único sistema de build lide com todos os microsserviços.

Transição do Tinder para Kubernetes
Figura 1-1. Processo de construção padronizado via contêiner Builder

Para obter consistência máxima entre tempos de execução (ambientes de tempo de execução) o mesmo processo de construção é usado durante o desenvolvimento e teste. Enfrentamos um desafio muito interessante: tivemos que desenvolver uma forma de garantir a consistência do ambiente de construção em toda a plataforma. Para isso, todos os processos de montagem são realizados dentro de um contêiner especial. Construtor.

Sua implementação de contêiner exigia técnicas avançadas de Docker. O Builder herda o ID do usuário local e os segredos (como chave SSH, credenciais da AWS, etc.) necessários para acessar os repositórios privados do Tinder. Ele monta diretórios locais contendo fontes para armazenar naturalmente artefatos de construção. Essa abordagem melhora o desempenho porque elimina a necessidade de copiar artefatos de construção entre o contêiner do Builder e o host. Os artefatos de construção armazenados podem ser reutilizados sem configuração adicional.

Para alguns serviços, tivemos que criar outro contêiner para mapear o ambiente de compilação para o ambiente de tempo de execução (por exemplo, a biblioteca bcrypt do Node.js gera artefatos binários específicos da plataforma durante a instalação). Durante o processo de compilação, os requisitos podem variar entre os serviços, e o Dockerfile final é compilado dinamicamente.

Arquitetura e migração de cluster Kubernetes

Gerenciamento de tamanho de cluster

Decidimos usar kube-aws para implantação automatizada de cluster em instâncias do Amazon EC2. No início, tudo funcionava em um pool comum de nós. Rapidamente percebemos a necessidade de separar as cargas de trabalho por tamanho e tipo de instância para fazer uso mais eficiente dos recursos. A lógica era que a execução de vários pods multithread carregados acabou sendo mais previsível em termos de desempenho do que sua coexistência com um grande número de pods single-threaded.

No final decidimos:

  • m5.4xgrande — para monitoramento (Prometheus);
  • c5.4xgrande - para carga de trabalho Node.js (carga de trabalho de thread único);
  • c5.2xgrande - para Java e Go (carga de trabalho multithread);
  • c5.4xgrande — para o painel de controle (3 nós).

A migração

Uma das etapas preparatórias para a migração da infraestrutura antiga para o Kubernetes foi redirecionar a comunicação direta existente entre os serviços para os novos balanceadores de carga (Elastic Load Balancers (ELB). Eles foram criados em uma sub-rede específica de uma nuvem privada virtual (VPC). Esta sub-rede estava conectada a uma VPC do Kubernetes. Isso nos permitiu migrar os módulos gradualmente, sem considerar a ordem específica das dependências do serviço.

Esses endpoints foram criados usando conjuntos ponderados de registros DNS que tinham CNAMEs apontando para cada novo ELB. Para mudar, adicionamos uma nova entrada apontando para o novo ELB do serviço Kubernetes com peso 0. Em seguida, definimos o Time To Live (TTL) da entrada definida como 0. Depois disso, os pesos antigo e novo foram ajustado lentamente e, eventualmente, 100% da carga foi enviada para um novo servidor. Após a conclusão da comutação, o valor do TTL voltou a um nível mais adequado.

Os módulos Java que tínhamos conseguiam lidar com DNS TTL baixo, mas os aplicativos Node não. Um dos engenheiros reescreveu parte do código do pool de conexões e o envolveu em um gerenciador que atualizava os pools a cada 60 segundos. A abordagem escolhida funcionou muito bem e sem qualquer degradação perceptível de desempenho.

Уроки

Os limites da malha de rede

Na madrugada de 8 de janeiro de 2019, a plataforma Tinder travou inesperadamente. Em resposta a um aumento não relacionado na latência da plataforma naquela manhã, o número de pods e nós no cluster aumentou. Isso fez com que o cache ARP se esgotasse em todos os nossos nós.

Existem três opções do Linux relacionadas ao cache ARP:

Transição do Tinder para Kubernetes
(fonte)

gc_thresh3 - este é um limite rígido. O aparecimento de entradas de “estouro de tabela vizinha” no log significava que mesmo após a coleta de lixo (GC) síncrona, não havia espaço suficiente no cache ARP para armazenar a entrada vizinha. Neste caso, o kernel simplesmente descartou o pacote completamente.

Nós usamos Flanela como uma malha de rede no Kubernetes. Os pacotes são transmitidos por VXLAN. VXLAN é um túnel L2 criado sobre uma rede L3. A tecnologia usa encapsulamento MAC-in-UDP (MAC Address-in-User Datagram Protocol) e permite a expansão de segmentos de rede da Camada 2. O protocolo de transporte na rede física do data center é IP mais UDP.

Transição do Tinder para Kubernetes
Figura 2–1. Diagrama de flanela (fonte)

Transição do Tinder para Kubernetes
Figura 2–2. Pacote VXLAN (fonte)

Cada nó de trabalho do Kubernetes aloca um espaço de endereço virtual com uma máscara /24 de um bloco /9 maior. Para cada nó isso é meios uma entrada na tabela de roteamento, uma entrada na tabela ARP (na interface flannel.1) e uma entrada na tabela de comutação (FDB). Eles são adicionados na primeira vez que um nó do trabalhador é iniciado ou sempre que um novo nó é descoberto.

Além disso, a comunicação nó-pod (ou pod-pod) passa, em última análise, pela interface eth0 (conforme mostrado no diagrama de flanela acima). Isto resulta em uma entrada adicional na tabela ARP para cada host de origem e destino correspondente.

Em nosso ambiente esse tipo de comunicação é muito comum. Para objetos de serviço no Kubernetes, um ELB é criado e o Kubernetes registra cada nó no ELB. O ELB não sabe nada sobre pods e o nó selecionado pode não ser o destino final do pacote. A questão é que quando um nó recebe um pacote do ELB, ele o considera levando em consideração as regras iptables para um serviço específico e seleciona aleatoriamente um pod em outro nó.

No momento da falha, havia 605 nós no cluster. Pelas razões expostas acima, isso foi suficiente para superar a importância gc_thresh3, que é o padrão. Quando isso acontece, não apenas os pacotes começam a ser descartados, mas todo o espaço de endereço virtual Flannel com máscara /24 desaparece da tabela ARP. A comunicação nó-pod e as consultas DNS são interrompidas (o DNS está hospedado em um cluster; leia mais adiante neste artigo para obter detalhes).

Para resolver este problema, você precisa aumentar os valores gc_thresh1, gc_thresh2 и gc_thresh3 e reinicie o Flannel para registrar novamente as redes ausentes.

Dimensionamento inesperado de DNS

Durante o processo de migração, usamos ativamente o DNS para gerenciar o tráfego e transferir gradualmente os serviços da infraestrutura antiga para o Kubernetes. Definimos valores TTL relativamente baixos para RecordSets associados no Route53. Quando a infraestrutura antiga estava em execução em instâncias EC2, nossa configuração do resolvedor apontava para o Amazon DNS. Consideramos isso um dado adquirido e o impacto do baixo TTL em nossos serviços e nos serviços da Amazon (como o DynamoDB) passou praticamente despercebido.

Ao migrarmos os serviços para o Kubernetes, descobrimos que o DNS processava 250 mil solicitações por segundo. Como resultado, os aplicativos começaram a enfrentar tempos limite constantes e sérios para consultas DNS. Isso aconteceu apesar dos esforços incríveis para otimizar e mudar o provedor de DNS para CoreDNS (que no pico de carga atingiu 1000 pods rodando em 120 núcleos).

Ao pesquisar outras possíveis causas e soluções, descobrimos статью, descrevendo condições de corrida que afetam a estrutura de filtragem de pacotes netfilter no Linux. Os tempos limite que observamos, juntamente com um contador crescente insert_failed na interface Flannel foram consistentes com os achados do artigo.

O problema ocorre na fase de tradução de endereços de rede de origem e destino (SNAT e DNAT) e posterior entrada na tabela conntrack. Uma das soluções alternativas discutidas internamente e sugeridas pela comunidade foi mover o DNS para o próprio nó de trabalho. Nesse caso:

  • O SNAT não é necessário porque o tráfego permanece dentro do nó. Não precisa ser roteado através da interface eth0.
  • DNAT não é necessário porque o IP de destino é local para o nó e não um pod selecionado aleatoriamente de acordo com as regras iptables.

Decidimos seguir essa abordagem. CoreDNS foi implantado como um DaemonSet no Kubernetes e implementamos um servidor DNS de nó local em resolver.conf cada pod definindo um sinalizador --cluster-dns equipes cubeta . Esta solução revelou-se eficaz para tempos limite de DNS.

No entanto, ainda vimos perda de pacotes e um aumento no contador insert_failed na interface Flanela. Isso continuou depois que a solução alternativa foi implementada porque conseguimos eliminar o SNAT e/ou DNAT apenas para o tráfego DNS. As condições de corrida foram preservadas para outros tipos de tráfego. Felizmente, a maioria dos nossos pacotes são TCP e, se ocorrer algum problema, eles são simplesmente retransmitidos. Ainda estamos tentando encontrar uma solução adequada para todos os tipos de tráfego.

Usando o Envoy para melhor balanceamento de carga

À medida que migramos os serviços de back-end para o Kubernetes, começamos a sofrer com carga desequilibrada entre os pods. Descobrimos que o HTTP Keepalive fazia com que as conexões ELB travassem nos primeiros pods prontos de cada implantação implementada. Assim, a maior parte do tráfego passou por uma pequena porcentagem dos pods disponíveis. A primeira solução que testamos foi configurar o MaxSurge para 100% em novas implantações nos piores cenários. O efeito revelou-se insignificante e pouco promissor em termos de implantações maiores.

Outra solução que utilizamos foi aumentar artificialmente as solicitações de recursos para serviços críticos. Neste caso, os pods colocados próximos teriam mais espaço de manobra em comparação com outros pods pesados. Também não funcionaria a longo prazo porque seria um desperdício de recursos. Além disso, nossos aplicativos Node eram de thread único e, portanto, só podiam usar um núcleo. A única solução real era usar um melhor balanceamento de carga.

Há muito que queríamos apreciar plenamente Enviado. A situação atual permitiu-nos implantá-lo de forma muito limitada e obter resultados imediatos. Envoy é um proxy de camada XNUMX de código aberto e de alto desempenho projetado para grandes aplicativos SOA. Ele pode implementar técnicas avançadas de balanceamento de carga, incluindo novas tentativas automáticas, disjuntores e limitação de taxa global. (Observação. trad.: Você pode ler mais sobre isso em Este artigo sobre o Istio, que é baseado no Envoy.)

Criamos a seguinte configuração: tenha um sidecar Envoy para cada pod e uma única rota, e conecte o cluster ao contêiner localmente via porta. Para minimizar o potencial cascata e manter um pequeno raio de acerto, usamos uma frota de pods de proxy frontal Envoy, um por zona de disponibilidade (AZ) para cada serviço. Eles contaram com um mecanismo simples de descoberta de serviço escrito por um de nossos engenheiros que simplesmente retornava uma lista de pods em cada AZ para um determinado serviço.

Os Envoys frontais de serviço então usaram esse mecanismo de descoberta de serviço com um cluster upstream e uma rota. Definimos tempos limite adequados, aumentamos todas as configurações dos disjuntores e adicionamos uma configuração mínima de novas tentativas para ajudar com falhas únicas e garantir implantações tranquilas. Colocamos um TCP ELB na frente de cada um desses enviados frontais de serviço. Mesmo que o keepalive de nossa camada de proxy principal estivesse preso em alguns pods do Envoy, eles ainda eram capazes de lidar com a carga muito melhor e foram configurados para balancear por meio de less_request no back-end.

Para implantação, usamos o gancho preStop em pods de aplicativos e pods secundários. O gancho acionou um erro ao verificar o status do endpoint administrativo localizado no contêiner sidecar e entrou em suspensão por um tempo para permitir que as conexões ativas fossem encerradas.

Um dos motivos pelos quais conseguimos avançar tão rapidamente foi devido às métricas detalhadas que conseguimos integrar facilmente em uma instalação típica do Prometheus. Isso nos permitiu ver exatamente o que estava acontecendo enquanto ajustávamos os parâmetros de configuração e redistribuíamos o tráfego.

Os resultados foram imediatos e óbvios. Começamos pelos serviços mais desequilibrados e neste momento opera à frente dos 12 serviços mais importantes do cluster. Este ano estamos planejando uma transição para uma malha de serviço completa com descoberta de serviços mais avançada, quebra de circuito, detecção de valores discrepantes, limitação de taxa e rastreamento.

Transição do Tinder para Kubernetes
Figura 3–1. Convergência de CPU de um serviço durante a transição para o Envoy

Transição do Tinder para Kubernetes

Transição do Tinder para Kubernetes

Resultado final

Por meio dessa experiência e de pesquisas adicionais, construímos uma forte equipe de infraestrutura com fortes habilidades em projetar, implantar e operar grandes clusters Kubernetes. Todos os engenheiros do Tinder agora têm conhecimento e experiência para empacotar contêineres e implantar aplicativos no Kubernetes.

Quando surgiu a necessidade de capacidade adicional na infraestrutura antiga, tivemos que esperar vários minutos para que novas instâncias do EC2 fossem lançadas. Agora os contêineres começam a funcionar e a processar o tráfego em segundos, em vez de minutos. Agendar vários contêineres em uma única instância do EC2 também fornece concentração horizontal aprimorada. Como resultado, prevemos uma redução significativa nos custos do EC2019 em 2 em comparação com o ano passado.

A migração demorou quase dois anos, mas foi concluída em março de 2019. Atualmente, a plataforma Tinder funciona exclusivamente em um cluster Kubernetes composto por 200 serviços, 1000 nós, 15 pods e 000 contêineres em execução. A infraestrutura não é mais domínio exclusivo das equipes de operações. Todos os nossos engenheiros compartilham essa responsabilidade e controlam o processo de construção e implantação de seus aplicativos usando apenas código.

PS do tradutor

Leia também uma série de artigos em nosso blog:

Fonte: habr.com

Adicionar um comentário