Il trasferimento di Tinder a Kubernetes

Scritto da: Chris O'Brien, Direttore tecnico | Chris Thomas, Direttore tecnico | Jinyong Lee, ingegnere informatico senior | A cura di: Cooper Jackson, ingegnere del software

Perché

Quasi due anni fa, Tinder ha deciso di spostare la sua piattaforma su Kubernetes. Kubernetes ci ha offerto l'opportunità di spingere Tinder Engineering verso la containerizzazione e le operazioni low-touch attraverso l'implementazione immutabile. La creazione, la distribuzione e l'infrastruttura dell'applicazione sarebbero definite come codice.

Stavamo anche cercando di affrontare le sfide di scala e stabilità. Quando il ridimensionamento è diventato critico, abbiamo spesso sofferto per diversi minuti nell'attesa che le nuove istanze EC2 diventassero online. L'idea di programmare i container e di servire il traffico in pochi secondi anziché in pochi minuti ci è piaciuta.

Non è stato facile Durante la nostra migrazione all'inizio del 2019, abbiamo raggiunto la massa critica all'interno del nostro cluster Kubernetes e abbiamo iniziato a incontrare varie sfide a causa del volume di traffico, delle dimensioni del cluster e del DNS. Abbiamo risolto interessanti sfide per la migrazione di 200 servizi e l'esecuzione di un cluster Kubernetes su scala per un totale di 1.000 nodi, 15.000 pod e 48.000 container in esecuzione.

Come

A partire da gennaio 2018, abbiamo attraversato varie fasi dello sforzo migratorio. Abbiamo iniziato containerizzando tutti i nostri servizi e distribuendoli in una serie di ambienti di staging ospitati da Kubernetes. A partire da ottobre, abbiamo iniziato a spostare metodicamente tutti i nostri servizi legacy su Kubernetes. Entro marzo dell'anno successivo, abbiamo finalizzato la nostra migrazione e la piattaforma Tinder ora funziona esclusivamente su Kubernetes.

Costruire immagini per Kubernetes

Esistono più di 30 repository di codice sorgente per i microservizi in esecuzione nel cluster Kubernetes. Il codice in questi repository è scritto in diverse lingue (ad es. Node.js, Java, Scala, Go) con più ambienti di runtime per la stessa lingua.

Il sistema di compilazione è progettato per operare su un "contesto di compilazione" completamente personalizzabile per ciascun microservizio, che in genere è costituito da un file Docker e da una serie di comandi di shell. Mentre i loro contenuti sono completamente personalizzabili, questi contesti di compilazione sono tutti scritti seguendo un formato standardizzato. La standardizzazione dei contesti di build consente a un singolo sistema di build di gestire tutti i microservizi.

Figura 1–1 Processo di compilazione standardizzato tramite il contenitore Builder

Al fine di ottenere la massima coerenza tra gli ambienti di runtime, durante la fase di sviluppo e test viene utilizzato lo stesso processo di compilazione. Ciò ha imposto una sfida unica quando avevamo bisogno di escogitare un modo per garantire un ambiente di costruzione coerente su tutta la piattaforma. Di conseguenza, tutti i processi di compilazione vengono eseguiti all'interno di uno speciale contenitore "Builder".

L'implementazione del contenitore Builder ha richiesto una serie di tecniche Docker avanzate. Questo contenitore Builder eredita ID utente locale e segreti (ad es. Chiave SSH, credenziali AWS, ecc.) Come richiesto per accedere ai repository privati ​​di Tinder. Monta directory locali contenenti il ​​codice sorgente per avere un modo naturale di memorizzare artefatti di compilazione. Questo approccio migliora le prestazioni, poiché elimina la copia di artefatti creati tra il contenitore Builder e la macchina host. Gli artefatti di build memorizzati vengono riutilizzati la prossima volta senza ulteriore configurazione.

Per alcuni servizi, dovevamo creare un altro contenitore all'interno del Builder per far corrispondere l'ambiente di compilazione con l'ambiente di runtime (ad esempio, l'installazione della libreria bcrypt di Node.js genera artefatti binari specifici della piattaforma). I requisiti del tempo di compilazione possono differire tra i servizi e il Dockerfile finale è composto al volo.

Architettura e migrazione del cluster di Kubernetes

Dimensionamento del cluster

Abbiamo deciso di utilizzare kube-aws per il provisioning automatizzato dei cluster su istanze Amazon EC2. All'inizio stavamo eseguendo tutto in un pool di nodi generale. Abbiamo rapidamente identificato la necessità di separare i carichi di lavoro in diverse dimensioni e tipi di istanze, per sfruttare meglio le risorse. Il ragionamento era che l'esecuzione di un numero inferiore di pod con thread pesantemente insieme produceva risultati di prestazioni più prevedibili per noi che farli coesistere con un numero maggiore di pod a thread singolo.

Abbiamo optato per:

  • m5.4xlarge per monitoraggio (Prometheus)
  • c5.4xlarge per carico di lavoro Node.js (carico di lavoro a thread singolo)
  • c5.2xlarge per Java e Go (carico di lavoro multi-thread)
  • c5.4xlarge per il piano di controllo (3 nodi)

Migrazione

Una delle fasi di preparazione per la migrazione dalla nostra infrastruttura legacy a Kubernetes è stata quella di modificare le comunicazioni da servizio a servizio esistenti per puntare a nuovi Elastic Load Balancer (ELB) che sono stati creati in una sottorete VPC (Virtual Private Cloud) specifica. Questa sottorete è stata sottoposta a peering sul VPC di Kubernetes. Questo ci ha permesso di migrare in modo granulare i moduli senza riguardo agli ordini specifici per le dipendenze del servizio.

Questi endpoint sono stati creati utilizzando set di record DNS ponderati con un CNAME che punta a ciascun nuovo ELB. Per il ritaglio, abbiamo aggiunto un nuovo record, indicando il nuovo servizio ELB di Kubernetes, con un peso di 0. Abbiamo quindi impostato il Time To Live (TTL) sul record impostato su 0. I pesi vecchi e nuovi sono stati quindi lentamente regolati su alla fine finisce con il 100% sul nuovo server. Dopo che il ritaglio è stato completato, il TTL è stato impostato su qualcosa di più ragionevole.

I nostri moduli Java hanno onorato il basso TTL DNS, ma le nostre applicazioni Node no. Uno dei nostri ingegneri ha riscritto parte del codice del pool di connessioni per racchiuderlo in un gestore che avrebbe aggiornato i pool ogni 60s. Questo ha funzionato molto bene per noi senza risultati apprezzabili.

apprendimenti

Limiti del tessuto di rete

Nelle prime ore del mattino dell'8 gennaio 2019, la piattaforma di Tinder ha subito un'interruzione persistente. In risposta a un aumento non correlato della latenza della piattaforma all'inizio di quella mattina, i conteggi di pod e nodi sono stati ridimensionati sul cluster. Ciò ha comportato l'esaurimento della cache ARP su tutti i nostri nodi.

Esistono tre valori Linux rilevanti per la cache ARP:

Credito

gc_thresh3 è un hard cap. Se si ottengono voci di registro "overflow tabella vicino", ciò indica che anche dopo una garbage collection sincrona (GC) della cache ARP, non c'era spazio sufficiente per memorizzare la voce vicina. In questo caso, il kernel rilascia il pacchetto completamente.

Usiamo Flannel come tessuto di rete in Kubernetes. I pacchetti vengono inoltrati tramite VXLAN. VXLAN è uno schema di sovrapposizione di livello 2 su una rete di livello 3. Utilizza l'incapsulamento MAC Address-in-User Datagram Protocol (MAC-in-UDP) per fornire un mezzo per estendere i segmenti di rete di livello 2. Il protocollo di trasporto sulla rete fisica del data center è IP più UDP.

Figura 2–1 Diagramma di flanella (credito)

Figura 2–2 Pacchetto VXLAN (credito)

Ogni nodo di lavoro di Kubernetes alloca il proprio / 24 di spazio di indirizzi virtuali su un blocco più grande / 9. Per ciascun nodo, si ottiene 1 voce della tabella di instradamento, 1 voce della tabella ARP (sull'interfaccia flannel.1) e 1 voce del database di inoltro (FDB). Questi vengono aggiunti al primo avvio del nodo di lavoro o alla scoperta di ogni nuovo nodo.

Inoltre, la comunicazione da nodo a pod (o da pod a pod) alla fine scorre sull'interfaccia eth0 (illustrata nel diagramma Flannel sopra). Ciò comporterà una voce aggiuntiva nella tabella ARP per ciascuna sorgente nodo e destinazione nodo corrispondenti.

Nel nostro ambiente, questo tipo di comunicazione è molto comune. Per i nostri oggetti di servizio Kubernetes, viene creato un ELB e Kubernetes registra ogni nodo con ELB. L'ELB non è a conoscenza del pod e il nodo selezionato potrebbe non essere la destinazione finale del pacchetto. Questo perché quando il nodo riceve il pacchetto dall'ELB, valuta le sue regole iptables per il servizio e seleziona casualmente un pod su un altro nodo.

Al momento dell'interruzione, c'erano 605 nodi totali nel cluster. Per i motivi sopra indicati, questo è stato sufficiente per eclissare il valore predefinito gc_thresh3. Una volta che ciò accade, non solo i pacchetti vengono eliminati, ma nella tabella ARP mancano interi Flannel / 24s di spazio di indirizzi virtuali. Comunicazione da nodo a pod e ricerche DNS non riuscite. (Il DNS è ospitato all'interno del cluster, come verrà spiegato in maggior dettaglio più avanti in questo articolo.)

Per risolvere, i valori gc_thresh1, gc_thresh2 e gc_thresh3 vengono aumentati e Flannel deve essere riavviato per registrare nuovamente le reti mancanti.

DNS inaspettatamente in esecuzione su scala

Per soddisfare la nostra migrazione, abbiamo sfruttato fortemente il DNS per facilitare la modellizzazione del traffico e il passaggio incrementale dall'eredità a Kubernetes per i nostri servizi. Abbiamo impostato valori TTL relativamente bassi sui RecordSet Route53 associati. Quando abbiamo eseguito la nostra infrastruttura legacy su istanze EC2, la nostra configurazione del resolver puntava al DNS di Amazon. Lo abbiamo dato per scontato e il costo di un TTL relativamente basso per i nostri servizi e i servizi di Amazon (ad esempio DynamoDB) è passato in gran parte inosservato.

Dato che abbiamo integrato sempre più servizi con Kubernetes, ci siamo trovati a gestire un servizio DNS che rispondeva a 250.000 richieste al secondo. Abbiamo riscontrato timeout di ricerca DNS intermittenti e di forte impatto all'interno delle nostre applicazioni. Ciò si è verificato nonostante un esaustivo sforzo di ottimizzazione e un provider DNS è passato a una distribuzione CoreDNS che ha raggiunto il picco di 1.000 pod consumando 120 core.

Durante la ricerca di altre possibili cause e soluzioni, abbiamo trovato un articolo che descrive una condizione di competizione che influenza il netfilter del framework di filtraggio dei pacchetti Linux. I timeout DNS che stavamo vedendo, insieme a un contatore incrementato insert_failed sull'interfaccia Flannel, si sono allineati con i risultati dell'articolo.

Il problema si verifica durante la traduzione dell'indirizzo di rete di origine e destinazione (SNAT e DNAT) e il successivo inserimento nella tabella conntrack. Una soluzione alternativa discussa internamente e proposta dalla comunità era lo spostamento del DNS sul nodo lavoratore stesso. In questo caso:

  • SNAT non è necessario perché il traffico si trova localmente sul nodo. Non ha bisogno di essere trasmesso attraverso l'interfaccia eth0.
  • DNAT non è necessario perché l'IP di destinazione è locale al nodo e non un pod selezionato casualmente per le regole iptables.

Abbiamo deciso di andare avanti con questo approccio. CoreDNS è stato distribuito come DaemonSet in Kubernetes e abbiamo iniettato il server DNS locale del nodo nel file resolv.conf di ciascun pod configurando il flag di comando kubelet - cluster-dns. La soluzione è stata efficace per i timeout DNS.

Tuttavia, vediamo ancora i pacchetti rilasciati e l'incremento del contatore insert_failed dell'interfaccia Flannel. Ciò persisterà anche dopo la soluzione precedente, poiché abbiamo evitato solo SNAT e / o DNAT per il traffico DNS. Le condizioni di gara si verificheranno comunque per altri tipi di traffico. Fortunatamente, la maggior parte dei nostri pacchetti sono TCP e quando si verifica la condizione, i pacchetti verranno ritrasmessi correttamente. Una soluzione a lungo termine per tutti i tipi di traffico è qualcosa di cui stiamo ancora discutendo.

Utilizzo di Envoy per ottenere un migliore bilanciamento del carico

Durante la migrazione dei nostri servizi di back-end a Kubernetes, abbiamo iniziato a soffrire di carichi sbilanciati tra i pod. Abbiamo scoperto che a causa di HTTP Keepalive, le connessioni ELB si sono attaccate ai primi pod pronti di ogni distribuzione mobile, quindi la maggior parte del traffico è passata attraverso una piccola percentuale dei pod disponibili. Una delle prime attenuazioni che abbiamo provato è stata quella di utilizzare un MaxSurge al 100% su nuove distribuzioni per i trasgressori peggiori. Questo è stato marginalmente efficace e non sostenibile a lungo termine con alcune delle distribuzioni più grandi.

Un'altra mitigazione che abbiamo usato è stata quella di gonfiare artificialmente le richieste di risorse su servizi critici in modo che i pod colocati avessero più spazio a fianco di altri pod pesanti. Questo non sarebbe stato sostenibile a lungo termine a causa dello spreco di risorse e le nostre applicazioni Node erano a thread singolo e quindi limitate in modo efficace a 1 core. L'unica soluzione chiara era quella di utilizzare un migliore bilanciamento del carico.

Abbiamo cercato internamente di valutare Envoy. Ciò ci ha offerto la possibilità di dispiegarlo in modo molto limitato e di ottenere benefici immediati. Envoy è un proxy Layer 7 open source ad alte prestazioni progettato per grandi architetture orientate ai servizi. È in grado di implementare tecniche avanzate di bilanciamento del carico, inclusi tentativi automatici, interruzione del circuito e limitazione della velocità globale.

La configurazione che ci è venuta in mente era quella di avere un sidecar Envoy accanto a ciascun pod che avesse un percorso e un cluster per colpire la porta del container locale. Per ridurre al minimo il potenziale a cascata e mantenere un raggio di esplosione ridotto, abbiamo utilizzato una flotta di pod Envoy front-proxy, uno schieramento in ciascuna zona di disponibilità (AZ) per ciascun servizio. Questi hanno colpito un piccolo meccanismo di scoperta dei servizi messo a punto da uno dei nostri ingegneri che ha semplicemente restituito un elenco di pod in ogni AZ per un determinato servizio.

Il servizio Front-Envoys ha quindi utilizzato questo meccanismo di individuazione del servizio con un cluster e una route a monte. Abbiamo configurato timeout ragionevoli, potenziato tutte le impostazioni degli interruttori di circuito e quindi impostato una configurazione di nuovo tentativo per aiutare con guasti transitori e distribuzioni regolari. Abbiamo affrontato ciascuno di questi servizi Envoy frontali con un ELB TCP. Anche se i keepalive del nostro principale livello proxy frontale sono stati bloccati su alcuni pod Envoy, erano molto più in grado di gestire il carico e sono stati configurati per bilanciare tramite il minimo richiesta al back-end.

Per le distribuzioni, abbiamo utilizzato un hook preStop sia sull'applicazione che sul pod sidecar. Questo hook chiamato endpoint admin fallito controllo integrità sidecar, insieme a una piccola sospensione, per concedere un po 'di tempo per consentire il completamento e il drenaggio delle connessioni in volo.

Uno dei motivi per cui siamo riusciti a muoverci così rapidamente è stato il ricco sistema di metriche che siamo riusciti a integrare facilmente con la nostra normale configurazione di Prometeo. Questo ci ha permesso di vedere esattamente cosa stava succedendo mentre ripetevamo le impostazioni di configurazione e tagliavamo il traffico.

I risultati furono immediati e ovvi. Abbiamo iniziato con i servizi più sbilanciati e, a questo punto, l'abbiamo eseguito di fronte a dodici dei servizi più importanti nel nostro cluster. Quest'anno abbiamo in programma di passare a una rete full-service, con scoperta di servizi più avanzati, interruzione dei circuiti, rilevamento anomalo, limitazione della frequenza e tracciabilità.

Figura 3–1 Convergenza della CPU di un servizio durante il passaggio dall'inviato

Il risultato finale

Attraverso questi apprendimenti e ricerche aggiuntive, abbiamo sviluppato un forte team di infrastrutture interne con grande familiarità su come progettare, distribuire e gestire grandi cluster Kubernetes. L'intera organizzazione di ingegneria di Tinder ora ha conoscenza ed esperienza su come containerizzare e distribuire le loro applicazioni su Kubernetes.

Sulla nostra infrastruttura legacy, quando era necessaria una scala aggiuntiva, abbiamo spesso sofferto per diversi minuti nell'attesa che le nuove istanze EC2 venissero online. I container ora programmano e servono il traffico in pochi secondi anziché minuti. La pianificazione di più contenitori su una singola istanza EC2 fornisce inoltre una migliore densità orizzontale. Di conseguenza, prevediamo notevoli risparmi sui costi di EC2 nel 2019 rispetto all'anno precedente.

Ci sono voluti quasi due anni, ma abbiamo completato la nostra migrazione a marzo 2019. La piattaforma Tinder funziona esclusivamente su un cluster Kubernetes composto da 200 servizi, 1.000 nodi, 15.000 pod e 48.000 container in esecuzione. L'infrastruttura non è più un'attività riservata ai nostri team operativi. Invece, gli ingegneri di tutta l'organizzazione condividono questa responsabilità e hanno il controllo su come le loro applicazioni sono costruite e distribuite con tutto come codice.