Transizione da Tinder a Kubernetes

Nota. trad.: I dipendenti del famoso servizio Tinder hanno recentemente condiviso alcuni dettagli tecnici sulla migrazione della loro infrastruttura su Kubernetes. Il processo è durato quasi due anni e ha portato al lancio di una piattaforma su larga scala su K8, composta da 200 servizi ospitati su 48mila container. Quali difficoltà interessanti hanno incontrato gli ingegneri di Tinder e a quali risultati sono arrivati? Leggi questa traduzione.

Transizione da Tinder a Kubernetes

Perché?

Quasi due anni fa, Tinder ha deciso di spostare la sua piattaforma su Kubernetes. Kubernetes consentirebbe al team di Tinder di containerizzare e passare alla produzione con il minimo sforzo attraverso una distribuzione immutabile (distribuzione immutabile). In questo caso, l'assemblaggio delle applicazioni, la loro distribuzione e l'infrastruttura stessa sarebbero definiti in modo univoco dal codice.

Stavamo anche cercando una soluzione al problema della scalabilità e della stabilità. Quando la scalabilità diventava critica, spesso dovevamo attendere diversi minuti prima che le nuove istanze EC2 si avviassero. L'idea di lanciare container e iniziare a servire il traffico in pochi secondi invece che in minuti è diventata molto interessante per noi.

Il processo si è rivelato difficile. Durante la nostra migrazione all'inizio del 2019, il cluster Kubernetes ha raggiunto la massa critica e abbiamo iniziato a riscontrare vari problemi dovuti al volume di traffico, alle dimensioni del cluster e al DNS. Lungo il percorso, abbiamo risolto molti problemi interessanti relativi alla migrazione di 200 servizi e al mantenimento di un cluster Kubernetes composto da 1000 nodi, 15000 pod e 48000 contenitori in esecuzione.

Come?

Da gennaio 2018 abbiamo attraversato varie fasi di migrazione. Abbiamo iniziato containerizzando tutti i nostri servizi e distribuendoli negli ambienti cloud di test Kubernetes. A partire da ottobre abbiamo iniziato a migrare metodicamente tutti i servizi esistenti su Kubernetes. Nel marzo dell’anno successivo abbiamo completato la migrazione e ora la piattaforma Tinder funziona esclusivamente su Kubernetes.

Creazione di immagini per Kubernetes

Disponiamo di oltre 30 repository di codice sorgente per microservizi in esecuzione su un cluster Kubernetes. Il codice in questi repository è scritto in diversi linguaggi (ad esempio Node.js, Java, Scala, Go) con più ambienti runtime per lo stesso linguaggio.

Il sistema di build è progettato per fornire un "contesto di build" completamente personalizzabile per ciascun microservizio. Di solito è costituito da un Dockerfile e da un elenco di comandi della shell. Il loro contenuto è completamente personalizzabile e, allo stesso tempo, tutti questi contesti di build sono scritti secondo un formato standardizzato. La standardizzazione dei contesti di compilazione consente a un unico sistema di compilazione di gestire tutti i microservizi.

Transizione da Tinder a Kubernetes
Figura 1-1. Processo di compilazione standardizzato tramite il contenitore Builder

Per ottenere la massima coerenza tra i tempi di esecuzione (ambienti di runtime) lo stesso processo di compilazione viene utilizzato durante lo sviluppo e il test. Abbiamo dovuto affrontare una sfida molto interessante: dovevamo sviluppare un modo per garantire la coerenza dell'ambiente di costruzione su tutta la piattaforma. Per raggiungere questo obiettivo, tutti i processi di assemblaggio vengono effettuati all'interno di un apposito contenitore. Costruttore.

La sua implementazione del contenitore richiedeva tecniche Docker avanzate. Il costruttore eredita l'ID utente locale e i segreti (come chiave SSH, credenziali AWS, ecc.) richiesti per accedere ai repository privati ​​di Tinder. Monta directory locali contenenti fonti per archiviare in modo naturale gli artefatti di compilazione. Questo approccio migliora le prestazioni perché elimina la necessità di copiare gli artefatti di compilazione tra il contenitore Builder e l'host. Gli artefatti di build archiviati possono essere riutilizzati senza ulteriore configurazione.

Per alcuni servizi, abbiamo dovuto creare un altro contenitore per mappare l'ambiente di compilazione sull'ambiente di runtime (ad esempio, la libreria bcrypt Node.js genera artefatti binari specifici della piattaforma durante l'installazione). Durante il processo di compilazione, i requisiti possono variare tra i servizi e il Dockerfile finale viene compilato al volo.

Architettura e migrazione del cluster Kubernetes

Gestione delle dimensioni dei cluster

Abbiamo deciso di utilizzare kube-aws per la distribuzione automatizzata di cluster su istanze Amazon EC2. All'inizio tutto funzionava in un pool comune di nodi. Ci siamo resi conto rapidamente della necessità di separare i carichi di lavoro per dimensione e tipo di istanza per utilizzare in modo più efficiente le risorse. La logica era che l'esecuzione di diversi pod multi-thread caricati si è rivelata più prevedibile in termini di prestazioni rispetto alla loro coesistenza con un gran numero di pod a thread singolo.

Alla fine abbiamo optato per:

  • m5.4xlargo — per il monitoraggio (Prometheus);
  • c5.4xgrande - per carico di lavoro Node.js (carico di lavoro a thread singolo);
  • c5.2xgrande - per Java e Go (carico di lavoro multithread);
  • c5.4xgrande — per la centrale (3 nodi).

migrazione

Uno dei passi preparatori per la migrazione dalla vecchia infrastruttura a Kubernetes è stato il reindirizzamento della comunicazione diretta esistente tra i servizi ai nuovi sistemi di bilanciamento del carico (Elastic Load Balancer (ELB). Sono stati creati su una sottorete specifica di un cloud privato virtuale (VPC). Questa sottorete era connessa a un VPC Kubernetes. Ciò ci ha consentito di migrare i moduli gradualmente, senza considerare l'ordine specifico delle dipendenze del servizio.

Questi endpoint sono stati creati utilizzando set ponderati di record DNS con CNAME che puntavano a ogni nuovo ELB. Per effettuare il passaggio, abbiamo aggiunto una nuova voce che punta al nuovo ELB del servizio Kubernetes con un peso pari a 0. Abbiamo quindi impostato il Time To Live (TTL) della voce impostata su 0. Successivamente, i pesi vecchio e nuovo sono stati lentamente adattato e alla fine il 100% del carico è stato inviato a un nuovo server. Una volta completata la commutazione, il valore TTL è tornato a un livello più adeguato.

I moduli Java di cui disponevamo potevano far fronte a un DNS TTL basso, ma le applicazioni Node no. Uno degli ingegneri ha riscritto parte del codice del pool di connessioni e lo ha inserito in un gestore che aggiornava i pool ogni 60 secondi. L'approccio scelto ha funzionato molto bene e senza alcun notevole degrado delle prestazioni.

Lezioni

I limiti del tessuto di rete

La mattina presto dell’8 gennaio 2019 la piattaforma Tinder si è bloccata inaspettatamente. In risposta a un aumento non correlato della latenza della piattaforma quella mattina, il numero di pod e nodi nel cluster è aumentato. Ciò ha causato l'esaurimento della cache ARP su tutti i nostri nodi.

Esistono tre opzioni Linux relative alla cache ARP:

Transizione da Tinder a Kubernetes
(fonte)

gc_thresh3 - questo è un limite rigido. La comparsa di voci di "overflow della tabella vicina" nel registro significava che anche dopo la raccolta dei rifiuti sincrona (GC), non c'era spazio sufficiente nella cache ARP per memorizzare la voce vicina. In questo caso, il kernel ha semplicemente scartato completamente il pacchetto.

Noi usiamo Flanella come struttura di rete in Kubernetes. I pacchetti vengono trasmessi su VXLAN. VXLAN è un tunnel L2 costruito su una rete L3. La tecnologia utilizza l'incapsulamento MAC-in-UDP (MAC Address-in-User Datagram Protocol) e consente l'espansione dei segmenti di rete Layer 2. Il protocollo di trasporto sulla rete fisica del data center è IP più UDP.

Transizione da Tinder a Kubernetes
Figura 2–1. Schema di flanella (fonte)

Transizione da Tinder a Kubernetes
Figura 2-2. Pacchetto VXLAN (fonte)

Ogni nodo di lavoro Kubernetes alloca uno spazio di indirizzi virtuali con una maschera /24 da un blocco /9 più grande. Per ogni nodo questo è mezzi una voce nella tabella di routing, una voce nella tabella ARP (sull'interfaccia flannel.1) e una voce nella tabella di commutazione (FDB). Vengono aggiunti la prima volta che viene avviato un nodo di lavoro o ogni volta che viene scoperto un nuovo nodo.

Inoltre, la comunicazione nodo-pod (o pod-pod) passa infine attraverso l'interfaccia eth0 (come mostrato nel diagramma della flanella sopra). Ciò si traduce in una voce aggiuntiva nella tabella ARP per ciascun host di origine e destinazione corrispondente.

Nel nostro ambiente, questo tipo di comunicazione è molto comune. Per gli oggetti di servizio in Kubernetes, viene creato un ELB e Kubernetes registra ciascun nodo con l'ELB. L'ELB non sa nulla dei pod e il nodo selezionato potrebbe non essere la destinazione finale del pacchetto. Il punto è che quando un nodo riceve un pacchetto dall'ELB, lo considera tenendo conto delle regole iptables per un servizio specifico e seleziona casualmente un pod su un altro nodo.

Al momento del guasto nel cluster erano presenti 605 nodi. Per le ragioni sopra esposte, ciò è stato sufficiente a superarne la significatività gc_thresh3, che è l'impostazione predefinita. Quando ciò accade, non solo i pacchetti iniziano a essere scartati, ma l'intero spazio degli indirizzi virtuali Flannel con una maschera /24 scompare dalla tabella ARP. La comunicazione tra nodo e pod e le query DNS vengono interrotte (il DNS è ospitato in un cluster; leggi più avanti in questo articolo per i dettagli).

Per risolvere questo problema è necessario aumentare i valori gc_thresh1, gc_thresh2 и gc_thresh3 e riavviare Flannel per registrare nuovamente le reti mancanti.

Ridimensionamento DNS imprevisto

Durante il processo di migrazione abbiamo utilizzato attivamente i DNS per gestire il traffico e trasferire gradualmente i servizi dalla vecchia infrastruttura a Kubernetes. Impostiamo valori TTL relativamente bassi per i RecordSet associati in Route53. Quando la vecchia infrastruttura era in esecuzione su istanze EC2, la nostra configurazione del risolutore puntava ad Amazon DNS. Lo abbiamo dato per scontato e l'impatto del TTL basso sui nostri servizi e sui servizi Amazon (come DynamoDB) è passato in gran parte inosservato.

Durante la migrazione dei servizi su Kubernetes, abbiamo scoperto che il DNS elaborava 250mila richieste al secondo. Di conseguenza, le applicazioni hanno iniziato a sperimentare timeout costanti e gravi per le query DNS. Ciò è avvenuto nonostante gli incredibili sforzi per ottimizzare e convertire il provider DNS in CoreDNS (che al picco di carico ha raggiunto 1000 pod in esecuzione su 120 core).

Durante la ricerca di altre possibili cause e soluzioni, abbiamo scoperto Articolo, descrivendo le condizioni di competizione che influenzano il quadro di filtraggio dei pacchetti netfilter inLinux. I timeout che abbiamo osservato, insieme a un contatore crescente insert_fallito nell'interfaccia Flannel erano coerenti con i risultati dell'articolo.

Il problema si verifica nella fase di conversione degli indirizzi di rete di origine e destinazione (SNAT e DNAT) e successiva immissione nella tabella conntrack. Una delle soluzioni discusse internamente e suggerita dalla comunità è stata quella di spostare il DNS sul nodo di lavoro stesso. In questo caso:

  • SNAT non è necessario perché il traffico rimane all'interno del nodo. Non è necessario instradarlo attraverso l'interfaccia eth0.
  • DNAT non è necessario poiché l'IP di destinazione è locale rispetto al nodo e non un pod selezionato casualmente secondo le regole iptables.

Abbiamo deciso di mantenere questo approccio. CoreDNS è stato distribuito come DaemonSet in Kubernetes e abbiamo implementato un server DNS del nodo locale risoluzione.conf ciascun pod impostando un flag --cluster-dns comandi cubetto . Questa soluzione si è rivelata efficace per i timeout DNS.

Tuttavia, abbiamo comunque riscontrato una perdita di pacchetti e un aumento del contatore insert_fallito nell'interfaccia Flanella. Ciò è continuato dopo l'implementazione della soluzione alternativa perché siamo riusciti a eliminare SNAT e/o DNAT solo per il traffico DNS. Le condizioni di gara sono state preservate per altri tipi di traffico. Fortunatamente, la maggior parte dei nostri pacchetti sono TCP e, se si verifica un problema, vengono semplicemente ritrasmessi. Stiamo ancora cercando di trovare una soluzione adatta a tutti i tipi di traffico.

Utilizzo di Envoy per un migliore bilanciamento del carico

Durante la migrazione dei servizi di backend su Kubernetes, abbiamo iniziato a soffrire di un carico sbilanciato tra i pod. Abbiamo scoperto che HTTP Keepalive causava il blocco delle connessioni ELB sui primi pod pronti di ogni distribuzione implementata. Pertanto, la maggior parte del traffico è passata attraverso una piccola percentuale di pod disponibili. La prima soluzione che abbiamo testato è stata l'impostazione di MaxSurge al 100% sulle nuove distribuzioni per gli scenari peggiori. L’effetto si è rivelato insignificante e poco promettente in termini di implementazioni più ampie.

Un’altra soluzione che abbiamo utilizzato è stata quella di aumentare artificialmente le richieste di risorse per i servizi critici. In questo caso, i pod posizionati nelle vicinanze avrebbero più spazio di manovra rispetto ad altri pod pesanti. Non funzionerebbe nemmeno a lungo termine perché sarebbe uno spreco di risorse. Inoltre, le nostre applicazioni Node erano a thread singolo e, di conseguenza, potevano utilizzare solo un core. L'unica vera soluzione era utilizzare un migliore bilanciamento del carico.

Da tempo desideriamo apprezzarlo appieno Inviato. La situazione attuale ci ha permesso di implementarlo in modo molto limitato e di ottenere risultati immediati. Envoy è un proxy layer XNUMX open source ad alte prestazioni progettato per applicazioni SOA di grandi dimensioni. Può implementare tecniche avanzate di bilanciamento del carico, inclusi tentativi automatici, interruttori automatici e limitazione della velocità globale. (Nota. trad.: Puoi leggere ulteriori informazioni al riguardo in questo articolo su Istio, che è basato su Envoy.)

Abbiamo ideato la seguente configurazione: avere un sidecar Envoy per ogni pod e un singolo percorso e connettere il cluster al container localmente tramite porta. Per ridurre al minimo il potenziale effetto a cascata e mantenere un raggio di azione ridotto, abbiamo utilizzato una flotta di pod front-proxy Envoy, uno per zona di disponibilità (AZ) per ciascun servizio. Si sono affidati a un semplice motore di rilevamento dei servizi scritto da uno dei nostri ingegneri che ha semplicemente restituito un elenco di pod in ciascuna zona di disponibilità per un determinato servizio.

Service front-Envoys ha quindi utilizzato questo meccanismo di rilevamento dei servizi con un cluster e un percorso upstream. Abbiamo impostato timeout adeguati, aumentato tutte le impostazioni degli interruttori automatici e aggiunto una configurazione minima dei tentativi per facilitare i singoli errori e garantire implementazioni fluide. Abbiamo posizionato un ELB TCP davanti a ciascuno di questi inviati del servizio. Anche se il keepalive del nostro livello proxy principale era bloccato su alcuni pod Envoy, erano comunque in grado di gestire il carico molto meglio e erano configurati per bilanciarsi tramite less_request nel backend.

Per la distribuzione, abbiamo utilizzato l'hook preStop sia sui pod dell'applicazione che sui pod del sidecar. L'hook ha attivato un errore nel controllo dello stato dell'endpoint di amministrazione situato sul contenitore sidecar ed è andato in sospensione per un po' per consentire la terminazione delle connessioni attive.

Uno dei motivi per cui siamo riusciti a muoverci così rapidamente è dovuto alle metriche dettagliate che siamo riusciti a integrare facilmente in una tipica installazione Prometheus. Ciò ci ha permesso di vedere esattamente cosa stava succedendo mentre regolavamo i parametri di configurazione e ridistribuivamo il traffico.

I risultati furono immediati ed evidenti. Abbiamo iniziato con i servizi più sbilanciati, e al momento opera davanti ai 12 servizi più importanti del cluster. Quest'anno stiamo pianificando una transizione verso un servizio mesh completo con rilevamento dei servizi più avanzato, interruzione dei circuiti, rilevamento dei valori anomali, limitazione della velocità e tracciamento.

Transizione da Tinder a Kubernetes
Figura 3–1. Convergenza della CPU di un servizio durante la transizione a Envoy

Transizione da Tinder a Kubernetes

Transizione da Tinder a Kubernetes

Risultato finale

Attraverso questa esperienza e ulteriori ricerche, abbiamo creato un forte team infrastrutturale con forti competenze nella progettazione, implementazione e gestione di cluster Kubernetes di grandi dimensioni. Tutti gli ingegneri di Tinder ora hanno la conoscenza e l'esperienza per creare pacchetti di contenitori e distribuire applicazioni su Kubernetes.

Quando è emersa la necessità di capacità aggiuntiva sulla vecchia infrastruttura, abbiamo dovuto attendere diversi minuti prima del lancio delle nuove istanze EC2. Ora i contenitori iniziano a funzionare e iniziano a elaborare il traffico in pochi secondi invece che in minuti. La pianificazione di più contenitori su una singola istanza EC2 fornisce anche una migliore concentrazione orizzontale. Di conseguenza, prevediamo una significativa riduzione dei costi EC2019 nel 2 rispetto allo scorso anno.

La migrazione ha richiesto quasi due anni, ma l’abbiamo completata a marzo 2019. Attualmente, la piattaforma Tinder funziona esclusivamente su un cluster Kubernetes composto da 200 servizi, 1000 nodi, 15 pod e 000 contenitori in esecuzione. L’infrastruttura non è più dominio esclusivo dei team operativi. Tutti i nostri ingegneri condividono questa responsabilità e controllano il processo di creazione e distribuzione delle proprie applicazioni utilizzando solo il codice.

PS da traduttore

Leggi anche una serie di articoli sul nostro blog:

Fonte: habr.com

Aggiungi un commento