One-cloud — un sistema operativo di livello data center su Odnoklassniki

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Aloha, gente! Mi chiamo Oleg Anastasyev e lavoro in Odnoklassniki nel team della Piattaforma. Inoltre, in Odnoklassniki c'è un sacco di hardware. Abbiamo quattro datacenter, con circa 500 rack e oltre 8.000 server. Ad un certo punto abbiamo capito che implementare un nuovo sistema di gestione ci avrebbe permesso di utilizzare la tecnologia in modo più efficiente, semplificando la gestione degli accessi, automatizzando la (ri)distribuzione delle risorse computazionali, accelerando il lancio di nuovi servizi e migliorando la risposta alle grandi emergenze.

Cosa è venuto fuori da tutto ciò?

Oltre a me e a un sacco di hardware, ci sono anche delle persone che lavorano con questo hardware: ingegneri che si trovano direttamente nei datacenter; network engineer che configurano l'infrastruttura di rete; amministratori, o SRE, che garantiscono la resilienza dell'infrastruttura; e team di sviluppatori, ciascuno responsabile di una parte delle funzionalità del portale. Il software che sviluppano funziona così:

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Le richieste degli utenti arrivano sia ai front-end del portale principale www.ok.ru, sia ad altri, come ai front-end dell'API musicale. Per gestire la logica di business, richiamano il server delle applicazioni, che durante il trattamento della richiesta chiama i necessari microservizi specializzati: one-graph (grafico delle relazioni sociali), user-cache (cache dei profili utente) e così via.

Ciascuno di questi servizi è distribuito su molte macchine, ed ognuno ha sviluppatori responsabili che si occupano del funzionamento dei moduli, della loro gestione e dello sviluppo tecnologico. Tutti questi servizi sono eseguiti su server fisici e, fino a poco tempo fa, eseguivamo esattamente un task per server, cioè era specializzato per un compito specifico.

Perché è così? Questo approccio ha avuto diversi vantaggi:

  • Facilita la gestione massiva. Supponiamo che un task richieda determinate librerie, determinate configurazioni. In tal caso, il server è assegnato a un gruppo specifico, viene descritta una politica cfengine per quel gruppo (o è già stata descritta) e questa configurazione viene distribuita centralmente e automaticamente su tutti i server di quel gruppo.
  • Semplifica la diagnosi. Supponiamo che tu stia esaminando un carico elevato della CPU e capisci che quel carico potrebbe essere generato solo dal task che gira su quella CPU fisica. La ricerca del colpevole termina molto rapidamente.
  • Semplifica monitoraggio. Se c'è qualcosa che non va con il server, il monitor lo segnala e sai esattamente chi è il colpevole.

A un servizio composto da più repliche, vengono assegnati più server — uno per ciascuna. Allora, la risorsa computazionale per il servizio è molto semplice da distribuire: quante più server ha, tanto più risorse può consumare. "Semplice" non significa che sia facile da utilizzare, ma che la distribuzione delle risorse avviene manualmente.

Questo approccio ci ha anche permesso di creare configurazioni hardware specializzate per il compito che viene eseguito su quel server. Se un task deve gestire grandi volumi di dati, utilizziamo server 4U con chassis per 38 dischi. Se è un task puramente computazionale, possiamo acquistare un server 1U più economico. Questo è efficace in termini di risorse computazionali. Questo approccio ci consente di utilizzare quattro volte meno macchine con un carico paragonabile a quello di una rete sociale amica.

Tale efficienza nell'uso delle risorse computazionali dovrebbe garantire anche efficienza economica, partendo dal presupposto che ciò che costa di più sono i server. A lungo, l'hardware è stato il costo maggiore e abbiamo investito molto sforzo per ridurre il prezzo dell'hardware, ideando algoritmi per garantire la resilienza e abbassare i requisiti di affidabilità dell'hardware. Oggi siamo arrivati a un punto in cui il prezzo del server non è più determinante. A meno che non si consideri l'ultima esotica, la configurazione specifica dei server nel rack non ha importanza. Ora abbiamo un altro problema: il costo dello spazio occupato dal server nel datacenter, cioè dello spazio nel rack.

Rendendoci conto di questo, abbiamo deciso di calcolare quanto efficacemente utilizziamo i rack.
Abbiamo preso il prezzo del server più potente tra quelli economicamente giustificati, calcolato quanti di questi server possiamo inserire negli armadi, quante attività potremmo avviare su di essi, seguendo il vecchio modello "un server = un'attività", e quanto queste attività potrebbero sfruttare l'hardware. Abbiamo fatto i conti e ci siamo commossi. È emerso che l'efficienza dell'utilizzo degli armadi è circa dell'11%. La conclusione è chiara: è necessario aumentare l'efficacia nell'uso dei data center. Sembrerebbe che la soluzione sia ovvia: dovremmo eseguire più attività su un singolo server. Ma qui iniziano le complicazioni.

La configurazione massiva diventa di colpo molto più complessa — ora non è possibile assegnare un gruppo unico a un server. Infatti, ora su un singolo server possono essere eseguite più attività di diversi team. Inoltre, la configurazione può essere in conflitto con diverse applicazioni. Anche la diagnostica diventa più complicata: se si osserva un consumo elevato delle CPU o dei dischi su un server, non si sa quale delle attività sta causando problemi.

Ma la cosa principale è che non c'è isolamento tra le attività eseguite su una stessa macchina. Ecco, ad esempio, un grafico che mostra il tempo medio di risposta di un'attività server prima e dopo che su quello stesso server è stata avviata un'altra applicazione di calcolo non correlata alla prima — il tempo di risposta per l'attività principale è aumentato notevolmente.

One-cloud — un sistema operativo di livello data center su Odnoklassniki

È chiaro che è necessario eseguire le attività in contenitori o in macchine virtuali. Poiché praticamente tutte le attività vengono eseguite con un unico sistema operativo (Linux) o sono adatte per esso, non è necessario mantenere molti sistemi operativi diversi. Di conseguenza, la virtualizzazione non è necessaria e, a causa dei costi aggiuntivi, sarà meno efficiente rispetto alla containerizzazione.

Come implementazione dei contenitori per eseguire le attività direttamente sui server, Docker è un buon candidato: le immagini dei file system risolvono bene i problemi di conflitti di configurazione. Il fatto che le immagini possano essere composte da più strati ci consente di ridurre notevolmente la quantità di dati necessari per la loro distribuzione nell'infrastruttura, separando le parti comuni in strati di base distinti. In questo modo, gli strati di base (e i più voluminosi) verranno memorizzati nella cache rapidamente su tutta l'infrastruttura e per distribuire molti tipi diversi di applicazioni e versioni sarà necessario trasferire solo strati di piccole dimensioni.

Inoltre, il registro pronto e il tagging delle immagini in Docker ci offrono meccanismi pronti per la versioning e la distribuzione del codice in produzione.

Docker, come qualsiasi altra tecnologia simile, ci fornisce un certo livello di isolamento dei contenitori out of the box. Ad esempio, l'isolamento della memoria — a ogni contenitore viene assegnato un limite sull'uso della memoria del server, oltre il quale non può consumarla. È inoltre possibile isolare i contenitori per quanto riguarda l'uso della CPU. Per noi, però, l'isolamento standard non era sufficiente. Ma ne parleremo più avanti.

L'avvio diretto dei contenitori sui server è solo una parte dei problemi. L'altra parte riguarda la collocazione dei contenitori sui server. È necessario comprendere quale contenitore può essere posizionato su quale server. Non è affatto un compito semplice, perché i contenitori devono essere collocati sui server in modo il più denso possibile senza compromettere le loro prestazioni. Tale collocazione può essere complessa anche in termini di resilienza. Spesso vogliamo collocare le repliche dello stesso servizio in rack diversi o persino in sale di data center diverse, in modo da non perdere tutte le repliche del servizio in caso di guasti.

Distribuire i contenitori manualmente non è un'opzione quando hai 8.000 server e 8.000-16.000 contenitori.

Inoltre, volevamo dare agli sviluppatori maggiore autonomia nella distribuzione delle risorse, affinché potessero collocare autonomamente i loro servizi in produzione, senza l'assistenza di un amministratore. Nel contempo, volevamo mantenere il controllo in modo che qualche servizio secondario non consumasse tutte le risorse dei nostri data center.

È chiaro che è necessario uno strato di gestione che si occupi di questo in modo automatico.

Ed ecco che arriviamo a un'immagine semplice e chiara che tutti gli architetti adorano: tre quadrati.

One-cloud — un sistema operativo di livello data center su Odnoklassniki

one-cloud masters — un cluster tollerante ai guasti che si occupa dell'orchestrazione del cloud. Lo sviluppatore invia al master un manifesto contenente tutte le informazioni necessarie per il размещение del servizio. Sulla base di queste informazioni, il master invia comandi ai minion selezionati (macchine destinate a eseguire i container). Su queste macchine è presente il nostro agente, che riceve il comando, invia i propri comandi a Docker e Docker configura il kernel Linux per avviare il container corrispondente. Oltre all'esecuzione dei comandi, l'agente informa continuamente il master sui cambiamenti di stato sia della macchina-minion che dei container eseguiti su di essa.

Distribuzione delle risorse

Analizziamo ora la questione di una distribuzione più complessa delle risorse per molti minion.

La risorsa computazionale in one-cloud è:

  • La potenza di elaborazione della CPU consumata da un compito specifico.
  • La quantità di memoria disponibile per il compito.
  • Traffico di rete. Ognuno dei minion ha una specifica interfaccia di rete con una larghezza di banda limitata, quindi non è possibile distribuire compiti senza tenere conto del volume di dati trasmessi in rete.
  • Dischi. Oltre, ovviamente, allo spazio per i dati del compito, definiamo anche il tipo di disco: HDD o SSD. I dischi possono gestire un numero finale di richieste al secondo — IOPS. Pertanto, per compiti che generano più IOPS di quanto un singolo disco possa gestire, riserviamo anche "spindles" — cioè dispositivi di archiviazione che devono essere esclusivamente riservati per il compito.

Quindi, per un servizio come user-cache, possiamo registrare le risorse consumate in questo modo: 400 core CPU, 2,5 TB di memoria, 50 Gbps di traffico in entrambe le direzioni, 6 TB di spazio su HDD, distribuito su 100 spindles. O in una forma a noi più familiare così:

alloc:
    cpu: 400
    mem: 2500
    lan_in: 50g
    lan_out: 50g
    hdd:100x6T

Le risorse del servizio user-cache consumano solo una parte di tutte le risorse disponibili nell'infrastruttura di produzione. Pertanto, vogliamo assicurarci che, improvvisamente, a causa di un errore dell'operatore o meno, user-cache non consumi più risorse di quelle assegnate. Dobbiamo quindi limitare le risorse. Ma a cosa potremmo legare la quota?

Torniamo al nostro schema semplificato di interazione dei componenti e ridisegniamolo con più dettagli — così:

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Cosa salta all'occhio:

  • Il front-end web e la musica utilizzano cluster isolati dello stesso server applicativo.
  • Possiamo definire livelli logici, ai quali appartengono questi cluster: front-end, cache, livello di archiviazione e gestione dei dati.
  • Il front-end non è omogeneo, è composto da diverse sottosistemi funzionali.
  • Le cache possono essere distribuite tra le sottosistemi i cui dati stanno memorizzando.

Ridisegniamo di nuovo l'immagine:

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Ehi! Qui vediamo una gerarchia! Ciò significa che possiamo distribuire le risorse in pezzi più grandi: assegnare uno sviluppatore responsabile a un nodo di questa gerarchia, corrispondente al sottosistema funzionale (come “music” nell'immagine), e legare la quota a questo stesso livello di gerarchia. Tale gerarchia ci consente anche di organizzare i servizi in modo più flessibile per la comodità della gestione. Ad esempio, tutto il web, poiché è un raggruppamento di server molto grande, lo suddividiamo in gruppi più piccoli, mostrati nell'immagine come group1, group2.

Eliminando le linee superflue, possiamo rappresentare ogni nodo della nostra immagine in modo più piatto: group1.web.front, api.music.front, user-cache.cache.

Così arriviamo al concetto di "coda gerarchica". Essa ha un nome, come "group1.web.front". Le viene assegnata una quota di risorse e diritti per gli utenti. A una persona del DevOps daremo diritti per inviare servizi nella coda, e questo dipendente può avviare qualcosa nella coda, mentre a una persona di OpsDev verranno dati diritti di amministratore, e ora può gestire la coda, designare persone a essa, assegnare diritti a queste persone, ecc. I servizi avviati in questa coda verranno eseguiti nel rispetto della quota della coda. Se la quota computazionale della coda non è sufficiente per eseguire simultaneamente tutti i servizi, allora essi verranno eseguiti in sequenza, formando in tal modo la propria coda.

Esaminiamo i servizi in dettaglio. Ogni servizio ha un nome completo, che include sempre il nome della coda. Quindi il servizio del front-end web avrà il nome ok-web.group1.web.front. Mentre il servizio del server applicativo a cui si rivolge avrà il nome ok-app.group1.web.front. Ogni servizio ha un manifesto che indica tutte le informazioni necessarie per il deploy su macchine specifiche: quante risorse consuma questo compito, quale configurazione è necessaria, quante repliche devono esserci, le proprietà per la gestione dei guasti di questo servizio. Dopo aver distribuito il servizio sulle macchine, appaiono le sue istanze. Anche queste sono denominate in modo univoco - come numero dell'istanza e nome del servizio: 1.ok-web.group1.web.front, 2.ok-web.group1.web.front, …

Questo è molto comodo: guardando solo al nome del container in esecuzione, possiamo immediatamente scoprire molte cose.

E ora conosciamo meglio cosa fanno effettivamente queste istanze: i compiti.

Classi di isolamento dei compiti

Tutti i compiti in OK (e probabilmente ovunque) possono essere suddivisi in gruppi:

  • Compiti a bassa latenza — prod. Per tali compiti e servizi, è molto importante la latenza della risposta, ovvero quanto velocemente ciascuna richiesta sarà elaborata dal sistema. Esempi di compiti: front-end web, cache, server di applicazioni, archivi OLTP e così via.
  • Compiti di calcolo — batch. Qui la velocità di elaborazione di ciascuna richiesta specifica non è fondamentale. È importante, invece, quanti calcoli totali vengono effettuati in un determinato (grande) intervallo di tempo (throughput). Questi includono compiti di MapReduce, Hadoop, apprendimento automatico, statistica.
  • Compiti in background — idle. Per questi compiti, né la latenza né il throughput sono particolarmente importanti. Qui rientrano vari test, migrazioni, ricalcoli, conversioni di dati da un formato all'altro. Da un lato, assomigliano ai compiti di calcolo, dall'altro, non ci importa molto quanto velocemente si completano.

Vediamo come questi compiti consumano risorse, ad esempio, della CPU.

Compiti a bassa latenza. Il modello di consumo della CPU per tale compito sarà simile a questo:

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Arriva una richiesta dall'utente, il compito inizia a utilizzare tutti i core disponibili della CPU, elabora, restituisce una risposta, attende la prossima richiesta e si ferma. Arriva una nuova richiesta — di nuovo sfrutta tutto quello che ha, elabora, aspettiamo la prossima.

Per garantire una latenza minima per tale compito, dobbiamo prendere il massimo delle risorse che consuma e riservare il numero necessario di core sulla macchina minion (macchina che eseguirà il compito). Quindi la formula di riservazione per il nostro compito sarà:

alloc: cpu = 4 (max)

e se abbiamo una macchina minion con 16 core, possiamo allocare esattamente quattro di tali compiti. Si noti che il consumo medio della CPU per tali compiti è spesso molto basso — il che è ovvio, poiché gran parte del tempo il compito è in attesa di una richiesta e non fa nulla.

Compiti di calcolo. Il loro modello sarà leggermente diverso:

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Il consumo medio delle risorse della CPU per tali compiti è abbastanza elevato. Spesso vogliamo che il compito di calcolo venga eseguito in un determinato tempo, quindi dobbiamo riservare il numero minimo di processori necessari affinché l'intero calcolo si completi in un tempo accettabile. La formula di riservazione sarà:

alloc: cpu = [1,*)

"Per favore, allocane uno su una macchina minion dove ci sia almeno un core libero, e poi utilizza tutto il resto disponibile."

Qui l'efficienza dell'uso è già significativamente migliore rispetto ai compiti a bassa latenza. Ma il guadagno sarà molto maggiore se combiniamo entrambi i tipi di compiti sulla stessa macchina minion e distribuiamo le sue risorse al volo. Quando un compito a bassa latenza richiede la CPU — la ottiene immediatamente, e quando le risorse non sono più necessarie — vengono trasferite al compito di calcolo, cioè, in questo modo:

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Ma come si fa?

Iniziamo a esaminare prod e il suo alloc: cpu = 4. Dobbiamo riservare quattro core. In Docker run, possiamo farlo in due modi:

  • Utilizzando l'opzione --cpuset=1-4, cioè riservare al compito quattro core specifici sulla macchina.
  • Usa --cpuquota=400_000 --cpuperiod=100_000, assegnare una quota di tempo di CPU, ovvero indicare che ogni 100 ms di tempo reale il compito consuma non più di 400 ms di tempo di CPU. Diventano gli stessi quattro core.

Ma quale di questi metodi sarà adatto?

Il cpuset sembra piuttosto allettante. La macchina ha quattro core dedicati, il che significa che le cache del processore funzioneranno al massimo dell'efficienza. Questo ha anche un rovescio della medaglia: dovremmo prenderci la responsabilità di distribuire i calcoli sui core meno occupati della macchina anziché farlo gestire al sistema operativo, ed è un compito piuttosto complesso, specialmente se proviamo a gestire lavori batch su una macchina del genere. I test hanno dimostrato che qui è più adatta l'opzione con la quota: in questo modo il sistema operativo ha maggiore libertà nella scelta del core da utilizzare per l'esecuzione del compito in quel momento e il tempo di CPU viene distribuito in modo più efficace.

Facciamo chiarezza su come effettuare la prenotazione di un numero minimo di core in Docker. La quota per i lavori batch non è più applicabile, perché non è necessario limitare un massimo, è sufficiente garantire solo un minimo. Qui l'opzione che si adatta bene è docker run --cpushares.

Ci siamo accordati che se un lavoro batch richiede la garanzia di un minimo su un core, allora specifichiamo --cpushares=1024, e se è necessario un minimo su due core, allora specifichiamo --cpushares=2048. I cpu shares non interferiscono nella distribuzione del tempo di CPU finché ce n'è a sufficienza. Così, se il prod non sta utilizzando nel momento attuale tutti e quattro i suoi core, non ci sono limitazioni per i lavori batch, e possono utilizzare tempo di CPU aggiuntivo. Tuttavia, in caso di scarsità di CPU, se il prod ha consumato tutti e quattro i suoi core e ha raggiunto la quota, il tempo di CPU rimanente verrà suddiviso in proporzione ai cpushares, cioè, in una situazione di tre core liberi, uno riceverà il lavoro con 1024 cpushares, mentre gli altri due riceveranno il lavoro con 2048 cpushares.

Ma l'uso di quota e shares non è sufficiente. Abbiamo bisogno di garantire che i compiti con bassa latenza ottengano priorità rispetto ai lavori batch nella distribuzione del tempo di CPU. Senza tale prioritizzazione, i lavori batch occuperanno tutto il tempo di CPU quando è necessario al prod. In Docker run non ci sono opzioni per la prioritizzazione dei container, ma le politiche dello scheduler della CPU in Linux ci vengono in aiuto. Potete leggere in dettaglio su di esse qui, e in questo articolo daremo un'occhiata a breve:

  • SCHED_OTHER
    Di default, vengono assegnati a tutti i normali processi utente su una macchina Linux.
  • SCHED_BATCH
    È destinata a processi che consumano molte risorse. Quando un lavoro viene collocato nella CPU, viene introdotta una così detta penalità di attivazione: tale lavoro avrà meno probabilità di ottenere risorse della CPU se al momento viene utilizzato da un processo con SCHED_OTHER.
  • SCHED_IDLE
    È un processo in background con priorità molto bassa, anche inferiore a nice -19. Utilizziamo la nostra libreria open-source one-nio, per impostare la politica necessaria all'avvio del container chiamando

one.nio.os.Proc.sched_setscheduler( pid, Proc.SCHED_IDLE )

. Ma anche se non programmi in Java, puoi fare lo stesso usando il comando chrt:

chrt -i 0 $pid

Riassumiamo tutti i nostri livelli di isolamento in una tabella per chiarezza:

Classe di isolamento
Esempio alloc
Opzioni Docker run
sched_setscheduler chrt*

Prod
cpu = 4
--cpuquota=400000 --cpuperiod=100000
SCHED_OTHER

Batch
Cpu = [1, * )
--cpushares=1024
SCHED_BATCH

Idle
Cpu= [2, *)
--cpushares=2048
SCHED_IDLE

*Se esegui chrt dall'interno del container, potrebbe essere necessario il capability sys_nice, perché di default Docker rimuove questo capability all'avvio del container.

Ma i lavori non consumano solo CPU, ma anche traffico, che influisce maggiormente sulla latenza della task di rete rispetto a una distribuzione errata delle risorse CPU. Per questo motivo, naturalmente, vogliamo avere un quadro simile anche per il traffico. Vale a dire, quando il lavoro prod invia dei pacchetti in rete, limitiamo la velocità massima (formula alloc: lan=[*,500mbps) ), con cui il prod può farlo. E per il batch garantiamo solo una larghezza di banda minima, ma non limitiamo la massima (formula alloc: lan=[10Mbps,*) ) In questo modo, il traffico del prod deve avere priorità rispetto ai lavori batch.
Qui Docker non ha alcun primitivo che possiamo utilizzare. Ma ci viene in aiuto Linux Traffic Control. Siamo riusciti a ottenere il risultato desiderato utilizzando la disciplina Hierarchical Fair Service Curve. Con essa, classifichiamo due classi di traffico: alta priorità per il prod e bassa priorità per il batch/idle. Alla fine, la configurazione per il traffico in uscita risulta essere la seguente:

One-cloud — un sistema operativo di livello data center su Odnoklassniki

qui 1:0 — «root qdisc» della disciplina hsfc; 1:1 — una classe secondaria hsfc con un limite di larghezza di banda totale di 8 Gbit/s, sotto la quale sono collocate le classi secondarie di tutti i contenitori; 1:2 — una classe secondaria hsfc comune a tutti i task batch e idle con un limite «dinamico», di cui parleremo in seguito. Le altre classi secondarie hsfc sono classi dedicate per i contenitori prod attualmente in esecuzione, con limiti corrispondenti ai loro manifesti, — 450 e 400 Mbit/s. A ciascuna classe hsfc è assegnata una coda qdisc fq o fq_codel, a seconda della versione del kernel linux, per evitare la perdita di pacchetti durante i picchi di traffico.

Di solito, le discipline tc servono per dare priorità solo al traffico in uscita. Ma vogliamo dare priorità anche al traffico in entrata — dato che qualche task batch potrebbe facilmente saturare tutto il canale in entrata, raccogliendo, ad esempio, un grande pacchetto di dati in ingresso per map&reduce. A tal fine utilizziamo il modulo ifb, che crea un'interfaccia virtuale ifbX per ogni interfaccia di rete e reindirizza il traffico in entrata dall'interfaccia a quello in uscita su ifbX. Successivamente, per ifbX, funzionano tutte le stesse discipline per il controllo del traffico in uscita, per le quali la configurazione hsfc sarà molto simile:

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Durante gli esperimenti, abbiamo scoperto che i migliori risultati di hsfc si ottengono quando la classe 1:2 del traffico batch/idle non prioritario viene limitata sui minion a non più di una certa banda libera. In caso contrario, il traffico non prioritario influisce troppo sulla latenza dei task prod. L'attuale importo della banda libera miniond viene determinato ogni secondo, misurando il consumo medio di traffico da tutti i task prod di quel minion One-cloud — un sistema operativo di livello data center su Odnoklassniki e sottraendolo dalla larghezza di banda dell'interfaccia di rete One-cloud — un sistema operativo di livello data center su Odnoklassniki con un certo margine, cioè.

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Le bande sono definite separatamente per il traffico in entrata e in uscita. E in base ai nuovi valori, miniond riconfigura il limite per la classe non prioritaria 1:2.

In questo modo, abbiamo implementato tutte e tre le classi di isolamento: prod, batch e idle. Queste classi influenzano notevolmente le prestazioni di esecuzione dei task. Pertanto, abbiamo deciso di posizionare questo attributo in cima all' gerarchia, in modo che, guardando il nome della coda gerarchica, fosse subito chiaro di cosa si trattasse:

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Tutti i nostri noti web e music front-end vengono dunque collocati nella gerarchia sotto prod. Per esempio, sotto batch inseriamo il servizio music catalog, che crea periodicamente un catalogo delle tracce da un insieme di file mp3 caricati su «Одноклассники». Un esempio di servizio sotto idle può essere il music transformer, che normalizza il livello del volume della musica.

Rimuovendo nuovamente le linee superflue, possiamo scrivere i nomi dei nostri servizi in modo più piatto, aggiungendo la classe di isolamento del task alla fine del nome completo del servizio: web.front.prod, catalog.music.batch, transformer.music.idle.

E ora, guardando il nome del servizio, possiamo comprendere non solo quale funzione svolga, ma anche la sua classe di isolamento, e di conseguenza, la sua criticità, ecc.

Tutto è fantastico, ma c'è una verità amara. È impossibile isolare completamente le attività che operano su una singola macchina.

Ciò che siamo riusciti a ottenere: se un batch consuma intensamente solo risorse della CPU, allora lo scheduler integrato di CPU Linux svolge molto bene il proprio lavoro, e l'impatto sul task prod è praticamente nullo. Ma se quel task batch inizia a lavorare attivamente con la memoria, allora già si manifesta un'influenza reciproca. Questo accade perché per il task prod vengono "svuotiti" i cache processore della memoria — alla fine, si aumentano i miss nella cache, e la CPU elabora il task prod più lentamente. Un tale task batch può aumentare di circa il 10% la latenza del nostro tipico contenitore prod.

Isolare il traffico è ancora più difficile a causa del fatto che le moderne schede di rete hanno una coda interna di pacchetti. Se un pacchetto di un task batch è arrivato per primo, significa che sarà il primo a essere inviato attraverso il cavo, e non si può farci nulla.

Inoltre, finora siamo riusciti a risolvere solo l'esigenza di dare priorità al traffico TCP: per l'UDP, l'approccio con hsfc non funziona. E persino nel caso del traffico TCP, se il task batch genera molto traffico, questo comporta ancora circa il 10% di aumento della latenza del task prod.

Affidabilità

Uno degli obiettivi nello sviluppo di one-cloud era migliorare la resilienza di Одноклассники. Pertanto, in seguito vorrei esaminare più dettagliatamente i possibili scenari di guasto e di emergenza. Iniziamo con uno scenario semplice: il guasto di un contenitore.

Il contenitore stesso può andare in crash in vari modi. Potrebbe trattarsi di un esperimento, un bug o un errore nel manifesto, che porta a far consumare alla job di produzione più risorse di quelle indicate nel manifesto. Abbiamo avuto un caso: uno sviluppatore ha implementato un algoritmo complesso, lo ha rifatto molte volte, si è complicato la vita e si è incasinato fino a far sì che la job si bloccasse in modo piuttosto intricato. E poiché la job di produzione ha una priorità maggiore rispetto alle altre sugli stessi nodi, ha iniziato a consumare tutte le risorse disponibili della CPU. In questa situazione, l'isolamento ha salvato la situazione, o meglio, la quota di tempo della CPU. Se alla job è assegnata una quota, non consumerà di più. Quindi le job batch e altre job di produzione che operavano sulla stessa macchina non se ne sono nemmeno accorte.

Il secondo possibile problema è il crash del contenitore. E qui ci salvano le politiche di riavvio, che tutti conoscono, Docker gestisce il tutto perfettamente. Praticamente tutte le job di produzione hanno la politica di riavvio impostata su ‘always’. A volte utilizziamo ‘on_failure’ per le job batch o per il debug dei contenitori di produzione.

Cosa si può fare in caso di indisponibilità di un intero nodo?

Ovviamente, lanciare il contenitore su un'altra macchina. La cosa più interessante qui è cosa succede agli indirizzi IP assegnati al contenitore.

Possiamo assegnare ai contenitori gli stessi indirizzi IP delle macchine nodo su cui questi contenitori vengono eseguiti. Quindi, quando il contenitore viene avviato su un'altra macchina, il suo indirizzo IP cambia, e tutti i clienti devono capire che il contenitore è stato trasferito, ora devono puntare a un altro indirizzo, il che richiede un servizio di Service Discovery separato.

La Service Discovery è conveniente. Ci sono molte soluzioni sul mercato con vari gradi di tolleranza ai guasti per l'organizzazione di un registro dei servizi. Spesso in tali soluzioni viene implementata la logica di un bilanciatore di carico, la memorizzazione di configurazioni aggiuntive sotto forma di KV-storage, ecc.
Tuttavia, vorremmo evitare di dover implementare un registro separato, poiché ciò significherebbe introdurre un sistema critico utilizzato da tutti i servizi in produzione. Questo significa, quindi, che rappresenta un potenziale punto di guasto, e bisogna scegliere o sviluppare una soluzione molto affidabile, il che è ovviamente molto difficile, lungo e costoso.

Un altro grande svantaggio: affinché la nostra vecchia infrastruttura funzionasse con la nuova, sarebbe stato necessario riscrivere assolutamente tutte le job per utilizzare un sistema di Service Discovery. Il lavoro è ENORME, e in alcuni casi diventa impossibile, specialmente quando si tratta di dispositivi di basso livello che operano a livello del kernel del sistema operativo o direttamente con l'hardware. Implementare questa funzionalità con modelli di soluzioni consolidati, come ad esempio side-car significherebbe che in alcune circostanze si avrebbe un carico aggiuntivo, in altre un'ulteriore complessità operativa e scenari di guasto aggiuntivi. Non volevamo complicarci la vita, quindi abbiamo deciso di rendere l'uso della Service Discovery facoltativo.

In one-cloud, l'IP segue il contenitore, cioè ogni istanza della job ha il proprio indirizzo IP. Questo indirizzo è 'statico': viene assegnato a ogni istanza nel momento della prima distribuzione del servizio nel cloud. Se nel corso della vita del servizio ha avuto un numero variabile di istanze, alla fine gli sarà assegnato tante IP quante sono state le istanze massime.

Successivamente, questi indirizzi non cambiano: sono assegnati una volta e continuano ad esistere per tutta la vita del servizio in produzione. Gli indirizzi IP seguono i contenitori sulla rete. Se un contenitore viene spostato su un'altra macchina, anche l'indirizzo passerà con esso.

In questo modo, l'associazione del nome del servizio con l'elenco dei suoi indirizzi IP cambia molto raramente. Se guardiamo ancora una volta ai nomi delle istanze del servizio che abbiamo menzionato all'inizio dell'articolo (1.ok-web.group1.web.front.prod, 2.ok-web.group1.web.front.prod, …), allora noteremo che sembrano FQDN utilizzati in DNS. Ed è proprio così, per mostrare i nomi delle istanze dei servizi nei loro indirizzi IP, utilizziamo il protocollo DNS. Inoltre, questo DNS restituisce tutti gli indirizzi IP riservati di tutti i container — sia in funzione che fermi (supponiamo che siano utilizzate tre repliche e abbiamo cinque indirizzi riservati — saranno restituiti tutti e cinque). I clienti, ricevendo queste informazioni, tenteranno di stabilire una connessione con tutte e cinque le repliche, determinando così quelle che sono attive. Questo metodo di determinazione della disponibilità è significativamente più affidabile, poiché non coinvolge né DNS né Service Discovery, e quindi non ci sono problematiche difficili da risolvere relative alla tempestività delle informazioni e alla resilienza di questi sistemi. Inoltre, nei servizi critici, da cui dipende il funzionamento dell'intero portale, possiamo non utilizzare affatto il DNS, ma semplicemente configurare gli indirizzi IP.

L'implementazione di questo trasferimento degli IP tra i container può essere non banale — ci fermeremo a vedere come funziona nel seguente esempio:

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Supponiamo che il master one-cloud dia ordine al minion M1 di avviare 1.ok-web.group1.web.front.prod con l'indirizzo 1.1.1.1. Sul minion opera BIRD, che annuncia questo indirizzo a server speciali route reflector. Gli ultimi hanno una sessione BGP con l'hardware di rete, a cui viene trasmesso il percorso dell'indirizzo 1.1.1.1 su M1. M1 poi instrada i pacchetti all’interno del container usando i mezzi di Linux. Ci sono tre server route reflector, poiché questa è una parte molto critica dell'infrastruttura one-cloud — senza di loro, la rete in one-cloud non funzionerebbe. Li posizioniamo in rack diversi, preferibilmente in diverse sale del data center, per ridurre al minimo la probabilità di un guasto simultaneo di tutti e tre.

Ora supponiamo che la connessione tra il master one-cloud e il minion M1 sia andata persa. Il master one-cloud agirà ora presumendo che M1 abbia completamente fallito. Questo significa che darà ordine al minion M2 di avviare web.group1.web.front.prod con lo stesso indirizzo 1.1.1.1. Ora abbiamo due percorsi conflittuali nella rete per 1.1.1.1: su M1 e su M2. Per risolvere tali conflitti, utilizziamo il Multi Exit Discriminator, che è indicato nell'annuncio BGP. Questo numero indica il peso del percorso annunciato. Tra i conflitti, verrà scelto il percorso con il valore MED più basso. Il master one-cloud gestisce il MED come parte integrante degli indirizzi IP dei container. La prima volta, l'indirizzo viene assegnato con un MED piuttosto alto = 1.000.000. In caso di un trasferimento di emergenza del container, il master riduce il MED, e M2 riceverà quindi l'ordine di annunciare l'indirizzo 1.1.1.1 con MED = 999.999. L'istanza che opera su M1, nel frattempo, rimarrà senza connessione, e il suo futuro ci interessa poco fino al momento del ripristino della connessione con il master, quando verrà fermata come vecchia doppione.

Incidenti

Tutti i sistemi di gestione dei data center gestiscono sempre adeguatamente piccoli guasti. Il fallimento di un container è la norma praticamente ovunque.

Analizziamo come gestiamo un incidente, ad esempio un'interruzione di corrente in una o più sale del data center.

Cosa significa un incidente per il sistema di gestione del data center? Innanzitutto, si tratta di un massiccio guasto simultaneo di molte macchine, e il sistema di gestione deve simultaneamente migrare un gran numero di container. Ma se l'incidente è molto ampio, può accadere che tutte le attività non possano essere riassegnate ad altri minion, perché la capacità del data center scende sotto il 100% del carico.

Spesso gli incidenti sono accompagnati dal fallimento anche del livello di gestione. Questo può accadere a causa del guasto della sua attrezzatura, ma più frequentemente a causa del fatto che gli incidenti non vengono testati e il livello di gestione stesso crolla a causa dell'aumento del carico.

Cosa si può fare con tutto questo?

Le migrazioni di massa significano che nell'infrastruttura si verificano molte azioni, migrazioni e collocamenti. Ciascuna di queste migrazioni può richiedere un certo tempo, necessario per consegnare e decomprimere le immagini dei container sui minion, avviare e inizializzare i container, ecc. Pertanto, è desiderabile che i compiti più importanti vengano avviati prima di quelli meno importanti.

Rivisitando la nostra conosciuta gerarchia dei servizi, proviamo a decidere quali compiti vogliamo avviare per primi.

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Naturalmente, si tratta di quei processi che partecipano direttamente all'elaborazione delle richieste degli utenti, cioè prod. Indichiamo questo tramite il numero di priorità di collocamento — un numero che può essere assegnato a una coda. Se una coda ha una priorità più alta, i suoi servizi vengono collocati per primi.

Su prod assegniamo priorità più alte, 0; su batch - un po' più basse, 100; su idle - ancora più basse, 200. Le priorità vengono applicate in modo gerarchico. Tutti i compiti a un livello gerarchico inferiore avranno la relativa priorità. Se vogliamo che all'interno di prod le cache vengano avviate prima dei frontend, assegniamo priorità a cache = 0 e ai frontend secondari = 1. Se, ad esempio, vogliamo che dal frontend venga avviato prima il portale principale e solo dopo il frontend musicale, possiamo assegnare una priorità più bassa a quest'ultimo - 10.

Il problema successivo è la mancanza di risorse. Quindi, abbiamo avuto un guasto a un gran numero di attrezzature, intere sale del data center, e abbiamo avviato così tanti servizi che ora non ci sono abbastanza risorse per tutti. Dobbiamo decidere quali compiti sacrificare affinché funzionino i servizi critici principali.

One-cloud — un sistema operativo di livello data center su Odnoklassniki

A differenza della priorità di assegnazione, non possiamo sacrificare indiscriminatamente tutti i compiti batch, alcuni di essi sono importanti per il funzionamento del portale. Pertanto, abbiamo individuato separatamente la priorità di esecuzione del compito. Durante l'assegnazione, un compito con priorità più alta può soppiantare, cioè fermare un compito con priorità più bassa, se non ci sono più minion disponibili. In questo caso, il compito con priorità più bassa rimarrà probabilmente non assegnato, cioè non ci sarà più un minion adatto con sufficienti risorse disponibili.

Nella nostra gerarchia, è molto semplice specificare una priorità di esecuzione in modo che i compiti prod e batch possano sopprimere o fermare i compiti idle, ma non l'uno l'altro, impostando una priorità per idle pari a 200. Così come nel caso della priorità di assegnazione, possiamo usare la nostra gerarchia per descrivere regole più complesse. Ad esempio, specifichiamo che per la funzione musicale sacrificiamo se non abbiamo risorse sufficienti per il portale web principale, impostando una priorità più bassa per i nodi corrispondenti: 10.

Guasti del data center completi

Perché potrebbe guastarsi l'intero data center? Eventi naturali. C'era un buon post su come l'uragano ha influenzato il funzionamento del data center. Gli eventi naturali possono includere senzatetto che una volta hanno incendiato la fibra ottica in un collettore, e il data center ha completamente perso contatto con le altre sedi. I guasti possono anche essere causati da errori umani: un operatore può dare un comando tale che l'intero data center si fermerà. Questo può succedere a causa di un grande bug. Insomma, i data center si bloccano: non è raro. Ciò accade da noi ogni pochi mesi.

Ecco cosa facciamo affinché nessuno #окживи pubblichi su Twitter.

La prima strategia è l'isolamento. Ogni istanza one-cloud è isolata e può gestire macchine solo di un data center. Cioè, la perdita di un cloud a causa di bug o di un comando errato dell'operatore comporta la perdita di un solo data center. Siamo pronti a questo: abbiamo una politica di riserva, secondo la quale le repliche di applicazione e dati vengono ospitate in tutti i data center. Utilizziamo database a prova di errore e testiamo periodicamente i guasti.
Poiché oggi abbiamo quattro data center, ci sono anche quattro istanze one-cloud distinte e completamente isolate.

Questo approccio non solo protegge da guasti fisici, ma può anche proteggere da errori operatori.

Cosa si può fare ancora riguardo al fattore umano? Quando un operatore dà un comando strano o potenzialmente pericoloso al cloud, potrebbe essere improvvisamente richiesto di risolvere un compito minore per controllare quanto bene ha riflettuto. Ad esempio, se si tratta di un arresto massivo di molte repliche o semplicemente di un comando strano — ridurre il numero di repliche o cambiare il nome dell'immagine, e non solo il numero di versione nel nuovo manifesto.

One-cloud — un sistema operativo di livello data center su Odnoklassniki

Risultati

Caratteristiche distintive di one-cloud:

  • Uno schema gerarchico e visivo per la denominazione dei servizi e dei container, che consente di sapere molto rapidamente di che tipo di compito si tratta, a cosa si riferisce e come funziona e chi se ne occupa.
  • Applichiamo la nostra tecnica di combinazione di compiti prod e batchsui minion per aumentare l'efficienza dell'uso condiviso delle macchine. Invece di cpuset, utilizziamo CPU quotas, shares, politiche dello scheduler CPU e Linux QoS.
  • Non siamo riusciti a isolare completamente i container che operano su una macchina, ma la loro influenza reciproca rimane entro il 20%.
  • L'organizzazione dei servizi in una gerarchia aiuta nell'eliminazione automatica delle emergenze tramite priorità di assegnazione e esecuzione.

FAQ

Perché non abbiamo scelto una soluzione pronta.

  • Diverse classi di isolamento dei compiti richiedono logiche differenti per l'assegnazione ai minion. Se i compiti prod possono essere assegnati tramite una semplice riserva di risorse, batch e idle devono essere assegnati monitorando l'effettivo utilizzo delle risorse sui minion.
  • La necessità di considerare le risorse utilizzate dalle attività quali:
    • la larghezza di banda della rete;
    • i tipi e i "spindles" dei dischi.
  • La necessità di specificare le priorità dei servizi durante la risoluzione delle emergenze, i diritti e le quote delle squadre sulle risorse, viene gestita mediante code gerarchiche in one-cloud.
  • La necessità di avere nomi umani per i contenitori al fine di ridurre i tempi di reazione a emergenze e incidenti.
  • L'impossibilità di implementare contemporaneamente il Service Discovery ovunque; la necessità di convivere a lungo con le attività ospitate su server fisici — questo è risolvibile tramite indirizzi IP "statici" che seguono i contenitori, e, di conseguenza, la necessità di un'integrazione unica con una grande infrastruttura di rete.

Tutte queste funzionalità richiederebbero significativi adattamenti delle soluzioni esistenti, e valutando la quantità di lavoro, ci siamo resi conto di poter sviluppare una nostra soluzione con circa lo stesso impegno. Ma la nostra soluzione sarà notevolmente più semplice da gestire e sviluppare — non presenta astratti inutili che supportano funzionalità non necessarie.

A coloro che leggono queste ultime righe, grazie per la pazienza e l'attenzione!

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, server VPS VDS 🔥 Acquista hosting affidabile per siti web con protezione DDoS, server VPS VDS | ProHoster