relazione

Transcript

relazione
 CHORD Progettazione e realizzazione di un protocollo per la locazione di dati ne distribuito MATTEO CORBELLI ABSTRACT Un problema fondamentale che pone a confronto applicazioni peer to peer è localizzare efficientemente in una rete il nodo che mantiene un particolare dato. Chord è un protocollo di ricerca distribuito che affronta questo problema. Chord fornisce supporto per una sola operazione: data una chiave, esso assegna la chiave ad un nodo. La locazione dei dati può essere facilmente implementata su Chord associando una chiave ad ogni dato, e mantenendo la coppia chiave/dato nel nodo a cui la chiave è assegnata. Chord mostra come i nodi possano effettuare efficientemente “join” o “leave” dal sistema, e possano continuamente rispondere alle richieste anche se il sistema è in continuo cambiamento. 1. INTRODUZIONE I sistemi e le applicazioni peer to peer sono sistemi distribuiti senza alcun controllo centralizzato o organizzazione gerarchica, dove l’esecuzione del software in ogni nodo è equivalente in funzionalità. Una revisione delle caratteristiche delle recenti applicazioni peer to peer fornisce una lunga lista: memorizzazione ridondante, permanenza, selezione dei server vicini, anonimità, ricerca, autenticazione e naming gerarchico. Nonostante questo ricco set di caratteristiche, l’operazione centrale nella maggior parte dei sistemi distribuiti è la locazione efficiente dei dati. Chord è un protocollo scalabile per la ricerca in sistemi dinamici peer to peer con frequenti arrivi e uscite di nodi. Il protocollo Chord supporta un’operazione: data una chiave, assegna tale chiave ad un nodo. In base all’applicazione che usa Chord, il nodo potrebbe essere responsabile per memorizzare un valore associato alla chiave. Chord usa una variante del “consistent hashing” per assegnare le chiavi ai nodi. Il “consistent hashing” tende a bilanciare il carico, dato che ogni nodo riceve all’incirca lo stesso numero di chiavi, e coinvolge relativamente un piccolo movimento di chiavi quando i nodi entrano o escono dal sistema. I lavori precedenti sul “consistent hashing” assumono che i nodi siano informati sulla maggior parte dei nodi del sistema, rendendo impraticabile la scalabilità con un elevato numero di nodi. Per contrasto, ogni nodo Chord ha bisogno di informazione di “routing” riguardo ad un piccolo numero di nodi. Tre caratteristiche che distinguono Chord da altri protocolli di ricerca peer to peer sono la semplicità, la correttezza e le performance. 2. IL PROTOCOLLO CHORD Il protocollo Chord specifica come trovare le locazioni delle chiavi e come i nodi entrino o escano dal sistema. 2.1 Overview Innanzitutto Chord fornisce una computazione veloce distribuita di una “hashing function” che assegna le chiavi ai nodi responsabili per esse. Usa “consistent hashing” che ha diverse buone proprietà. Con alta probabilità la funzione hash bilancia il carico (tutti i nodi ricevono all’incirca lo stesso numero di chiavi). Altrettanto con alta probabilità, quando un N‐esimo nodo entra (o esce) dalla rete, soltanto una frazione O(1/N) di chiavi viene mossa in una diversa locazione, il che è chiaramente il minimo necessario per mantenere un carico bilanciato. Chord fornisce la scalabilità del “consistent hashing” evitando il requisito che ogni nodo conosca tutti gli altri nodi. Un nodo Chord ha necessità di un piccolo numero di informazioni di “routing” relativamente agli altri nodi. Dal momento che questa informazione è distribuita, un nodo risolve la funzione hash comunicando con un piccolo numero di nodi. In una rete di N‐nodi, ogni nodo mantiene informazioni su circa un O(logN) di altri nodi, e la ricerca richiede O(logN) messaggi. Chord deve aggiornare l’informazione di “routing” quando un nodo entra o esce dal sistema. 2.2 Consistent hashing La funzione di “consistent hashing” assegna ad ogni nodo e ad ogni chiave un identificativo di m bit usando una funzione di hash come SHA‐1. L’identificativo di un nodo è scelto calcolando l’hash dell’indirizzo IP del nodo, mentre l’identificativo di una chiave è prodotto calcolando quello della chiave. Si userà per comodità il termine “chiave” per indicare la chiave e la sua immagine dovuta alla funzione hash. Altrettanto vale per il “nodo”. La lunghezza m dell’identificativo deve essere abbastanza perché la probabilità che due nodi o chiavi abbiano lo stesso hash sia molto bassa. L’hashing consistente assegna le chiavi ai nodi come segue. Gli identificativi sono ordinati in un “identifier circle” modulo 2m. La chiave k è assegnata al primo nodo il cui identificativo è uguale o segue k nello spazio degli identificativi. Questo nodo è chiamato “successor node” della chiave k, denotato da successor(k). Se gli identificativi sono rappresentati come un cerchio di numeri da 0 a 2m‐1, allora successor(k) è il primo nodo in senso orario da k. La Figura mostra un “identifier circle” con m=3. Il circle ha tre nodi: 0, 1, e 3. Il successore dell’identificativo 1 è 1, così la chiave 1 dovrebbe essere allocata al nodo 1. Similmente la chiave 2 dovrebbe essere allocata al nodo 3, e la chiave 6 al nodo 0. Il “consistent hashing” è progettato per permettere ai nodi di entrare e lasciare la rete con il minimo disordine. Per mantenere l’assegnazione effettuata dal “consistent hashing” quando un nodo n entra nella rete, inevitabilmente alcune chiavi assegnate al successore di n ora vengono assegnate ad n. Quando un nodo lascia la rete, ognuna delle sue chiavi viene riassegnata al successore di n. 2.3 Locazione scalabile delle chiavi Un numero veramente piccolo di informazioni di routing è necessario per implementare “consistent hashing” in un ambiente distribuito. Ogni nodo ha necessità di conoscere solo il nodo successore nel “identifier circle”. Le richieste per un dato id possono essere passate lungo l’anello attraverso i puntatori ai successori fino a che non incontrano un nodo che succede l’id. Una porzione del protocollo Chord mantiene questi puntatori ai successori, in modo da far sì che ogni ricerca sia risolta correttamente. Tuttavia questo schema di risoluzione è inefficiente: potrebbe richiedere di attraversare tutti gli N nodi per trovare l’assegnazione adeguata. Per accelerare questo processo, Chord mantiene un’informazione di routing addizionale. Questa informazione addizionale non è essenziale per la correttezza, che è garantita nel momento in cui l’informazione sul successore viene mantenuta correttamente. Come prima, consideriamo m il numero di bit nell’id nodo/chiave. Ogni nodo, n, mantiene una tabella di routing con al più m elementi, chiamata “finger table”. L’i‐esimo elemento nella tabella del nodo n contiene l’identità del primo nodo, s, che succede n di almeno 2i‐1 nel “identifier circle”, ovvero s = successor(n + 2i‐1) dove 1<=i<=m (tutto moldulo 2 alla m). Chiamiamo il nodo s i‐
esimo finger del nodo n. Un elemento della finger table include insieme il Chord identifier e l’indirizzo IP(e numero di porta) del nodo rilevante. Notare che il primo finger di n è l’immediato successore nel “identifier circle”; per convenienza ci riferiremo ad esso come al successore piuttosto che al primo finger. Nell’esempio mostrato in Fig. (b), la finger table del nodo 1 punta ai nodi successori degli identifiers (n + 20)mod 23 = 2, (n + 21)mod 23 = 3, e (n + 22)mod 23 = 5, rispettivamente. Il successore del’identifier 2 è il nodo 3, dal momento che è il primo nodo che segue 2, il successore dell’identifier 3 è il nodo 3, e il successore di 5 è il nodo 0. Questo schema ha due importanti caratteristiche. Primo, ogni nodo mantiene informazione riguardo ad un piccolo numero di altri nodi, e conosce di più circa i nodi più prossimi che lo seguono nel “identifier circle” piuttosto che sui nodi molto lontani. Secondo, una finger table di un nodo generalmente non contiene abbastanza informazioni per determinare il successore di un’arbitraria chiave k. Per esempio, il nodo 3 in Fig.3 non conosce il successore di 1, e il successore del nodo 1 non appare nella finger table del nodo 3. Cosa succede quando un nodo n non conosce il successore di una chiave k? Se n può trovare un nodo il cui ID è più vicino del proprio a k, allora tale nodo conoscerà di più riguardo al “identifier circle” nella regione di k di quanto sappia n. Quindi n cerca nella sua finger table un nodo j il cui ID preceda k da vicino, e inoltra a j la richiesta. Ripetendo questo processo, n impara circa i nodi con ID più vicini e più vicini a k. Introduciamo la funzione “find_successor” che lavora trovando l’immediato nodo predecessore dell’identificativo desiderato; il successore di tale nodo deve essere il successore dell’identificativo. Quando un nodo esegue cerca un predecessore, funzione “find_predecessor”, contatta una serie di nodi muovendosi avanti nel Chord circle verso l’id. Se il nodo contatta un nodo n’ tale che l’identificativo si trova tra n’ e il suo successore, find_predecessor termina e restituisce n’. Altrimenti il nodo n chiede a n’ cosa n’ conosce riguardo a chi precede l’id più da vicino. Quindi l’algoritmo fa sempre progressi verso il predecessore dell’id. Come esempio, consideriamo il Chord ring in Fig. 3(b). Supponiamo che il nodo 3 voglia trovare il successore dell’identifier 1. Dato che 1 appartiene all’intervallo circolare [7,3), appartiene a 3.finger[3].interval (finger[i].interval è un intervallo (finger[i].start, finger[i+1].start)); il nodo 3 quindi verifica il terzo elemento nella sua tabella, che è 0. Dato che 0 precede 1, 3 chiederà al nodo 0 di trovare il successore di 1. Successivamente, il nodo 0 inferirà dalla sua finger table che il successore del’id 1 è il nodo 1 stesso, e restituisce il nodo 1 al nodo 3. 2.4 Ingresso dei Nodi In una rete dinamica, i nodi possono entrare (e lasciare) il sistema ad ogni momento. Il principale cambiamento nell’implementare queste operazioni è preservare l’abilità di allocare ogni chiave nella rete. Per mantenere questo scopo, Chord ha bisogno di preservare due invarianti: 1. Ogni successore di un nodo è mantenuto correttamente. 2. Per ogni chiave k, successor(k) è responsabile per k. In modo da rendere la ricerca più veloce, è anche desiderabile che le finger table siano tenute corrette. Questa sezione mostra come mantenere questi due invarianti quando un singolo nodo entra nel sistema. Prima di descrivere le operazioni di entrata, mostriamo le sue performance. Per semplificare i meccanismi di entrata e uscita, ogni nodo in Chord mantiene un puntatore al predecessore. Il puntatore al predecessore di un nodo contiene il Chord identifier e l’indirizzo IP dell’immediato predecessore di tal nodo, e può essere usato per percorrere in senso antiorario l’ “identifier circle”. Per preservare i suddetti invarianti stabiliti, Chord deve eseguire tre fasi quando un nodo entra nella rete: 1. Inizializza il predecessore e i finger del nodo n 2. Aggiorna i finger e i predecessori dei nodi esistenti per riflettere l’aggiunta di n 3. Notifica a livello software più alto in modo che si possa trasferire lo stato associato alle chiavi di cui il nodo n ora è responsabile Assumeremo che il nuovo nodo impari l’identità di un nodo Chord esistente n’ da qualche meccanismo esterno. Il nodo n usa n’ per inizializzare il suo stato e aggiungere se stesso alla rete Chord esistente, come segue. Inizializzazione dei finger e del predecessore: Il nodo n impara il suo predecessore e i suoi finger chiedendo a n’ di fare una ricerca. Ingenuamente eseguendo find_successor per ognuno degli m elementi della finger table potrebbe essere necessario un tempo di esecuzione pari a O(m logN). Per ridurre questo, n controlla se l’i‐esimo finger è anche l’i+1‐esimo finger corretto, per ogni i. Questo avviene quando finger[i].interval non contiene alcun nodo, e quindi finger[i].node >= finger[i+1].start. Si può mostrare come il cambiamento riduca il numero aspettato di elementi finger che devono fare ricerca a un O(logN). Aggiornamento dei finger dei nodi esistenti: Il nodo n avrà bisogno di essere inserito nelle finger table di alcuni nodi esistenti. Per esempio in Fig. (a), il nodo 6 diventa il terzo finger dei nodi 0 e 1, e il primo e il secondo finger del nodo 3. La funzione update_finger_table aggiorna le esistenti finger table. Il nodo n diventerà l’i‐esimo finger del nodo p se e solo se (1) p precede n di almeno 2i‐1, e (2) l’i‐esimo finger del nodo p succede n. Il primo nodo, p, che unisce queste due condizioni è l’immediato predecessore di n‐2i‐1. Così, per un dato n, l’algoritmo inizia con l’i‐esimo finger del nodo n e continua a camminare in senso anti orario sul “identifier circle” finchè non incontra un nodo il cui i‐esimo finger precede n. Trasferimento delle chiavi: L’ultima operazione che deve essere eseguita quando un nodo entra nella rete è spostare tutta la responsabilità sulle chiavi di cui ora il nuovo nodo è il successore. Cosa esattamente questo comporti dipende dal software di più alto livello usando Chord, ma tipicamente potrebbe riguardare lo spostamento dei dati associati ad ogni chiave nel nuovo nodo. Il nodo n può diventare il successore solo delle chiavi per cui era precedentemente responsabile il nodo che segue n immediatamente, così solo n deve contattare quel nodo per trasferire la responsabilità per tutte le chiavi rilevanti. 3. PROGETTAZIONE DEL SISTEMA In questo paragrafo vengono analizzate le implementazioni delle varie parti del sistema. 3.1 Configurazione del sistema Come detto in precedenza la topologia della rete è una struttura ad anello, in cui ogni nodo prende una posizione determinata dall’hashing del suo indirizzo costituito da indirizzo IP + porta. Ogni nodo Chord ha la seguente struttura: • Successore: nodo che succede all’interno del “identifier circle” • Predecessore: nodo che precede il nodo • Finger Table: tabella di m elementi (nel caso implementato 8) che servono per l’efficienza dell’algoritmo • Vettore delle chiavi di cui il nodo è responsabile Mentre la Finger Table è strutturata come segue: l’i‐esimo elemento di questa tabella ha i seguenti campi • Finger[i].start = (n+2i‐1)mod 2m (dove n è l’id del nodo in questione) • Finger[i].interval = [finger[i].start,finger[i+1].start) • Finger[i].node = primo nodo maggiore o uguale a finger[i].start 3.2 Locazione delle chiavi In questo paragrafo vediamo come è stata implementata la funzione che alloca le chiavi all’interno del sistema. Ogni dato che deve essere inserito all’interno della rete, ha necessità di conoscere un qualsiasi nodo facente parte della rete stessa. A tale nodo viene effettuata una richiesta da parte di chi desidera inserire nel sistema il dato. Inoltre per il suddetto dato deve essere fornita una chiave, di cui viene successivamente fatto un hashing fornendo l’identificativo in modo da distribuire uniformemente i dati all’interno del sistema (supponiamo nella nostra implementazione che venga direttamente fornito l’identificativo di tale chiave). Al nodo specificato viene quindi inoltrata una richiesta, in cui viene inviato l’identificativo di cui sopra, dove si chiede di trovare il nodo che immediatamente succede l’identificativo e che sarà il responsabile del dato. Il nodo a cui viene fatta la richiesta cerca il predecessore dell’identificativo osservando se l’identificativo si trova tra se e il suo successore; in questo caso restituisce il suo successore, mentre se il suo identificativo corrisponde all’i‐esimo elemento start della finger Table restituisce l’i‐esimo successore della tabella. Altrimenti cerca nella finger Table il nodo più vicino all’identificativo di cui è a conoscenza a cui ridirigere la richiesta e ne restituisce l’indirizzo. A differenza del protocollo dove la richiesta veniva inoltrata dal nodo stesso, l’implementazione vuole che l’indirizzo a cui ridirigere la richiesta venga restituito al mittente, il quale si occuperà di avviare una nuova comunicazione. Si è scelta questa modalità per diminuire la latenza dei messaggi all’interno della rete. Il procedimento appena descritto va effettuato iterativamente fino a quando non si trova il nodo che succede l’identificativo. Una volta trovato il nodo, ci si connette a tale nodo e gli si passa l’identificativo (è ovviamente pensabile e dovuto, ai fini di un corretto funzionamento, l’implementazione della trasmissione oltre che dell’identificativo anche dell’intero file corrispondente). 3.3 Entrata dei nodi nella rete Osserviamo ora come è stata implementato il join dei nodi all’interno della rete. Come detto in precedenza, quando un nodo decide di entrare nel sistema deve conoscere l’indirizzo di un nodo già esistente. Le fasi che devono essere seguite sono principalmente tre: • Join: • Aggiornamento • Trasferimento dei dati 3.3.1 Join Innanzitutto si connette al nodo conosciuto ed invia la richiesta di find_successor a tale nodo, che, in modo del tutto simile a quello descritto al caso in cui si cerca la locazione di una chiave, trovando il predecessore dell’identificativo del nodo specificato, restituisce se stesso e il suo successore permettendo così al nodo di settare il suo predecessore (il nodo stesso) e il suo successore. In seguito sempre mantenendo la connessione invia una richiesta in cui setta se stesso come successore del nodo che lo precede. Connettendosi poi al suo successore conclude il processo fornendo se stesso come predecessore. In questo modo viene garantita la correttezza del protocollo. Inoltre calcola la sua Finger Table utilizzando gli accorgimenti visti nella descrizione del protocollo per utilizzare il minor numero di messaggi possibile evitando un overload di comunicazione. Riportiamo per chiarezza il ragionamento svolto in precedenza: n controlla se l’i‐esimo finger è anche l’i+1‐
esimo finger corretto, per ogni i. Questo avviene quando finger[i].interval non contiene alcun nodo, e quindi finger[i].node >= finger[i+1].start. 3.3.2 Aggiornamento Nella fase di aggiornamento il nodo deve permettere alle finger table degli altri nodi di aggiornarsi; perché una finger table subisca variazioni dall’entrata nel sistema di un nodo occorrono due condizioni. Il nodo n diventerà l’i‐esimo finger del nodo p se e solo se (1) p precede n di almeno 2i‐1, e (2) l’i‐esimo finger del nodo p succede n. 1. Per ogni i si calcola n‐2i‐1, che chiameremo id, e si invia la richiesta sempre al nodo conosciuto in cui si chiede il predecessore dell’id. 2. Al nodo restituito dall’operazione precedente viene mandato un messaggio di aggiornamento della tabella; il nodo in questione verifica se deve modificare il successore dell’i‐esimo finger e se è così oltre ad apportare le modifiche restituisce il suo predecessore a cui viene inoltrata la stessa richiesta. 3.3.3 Trasferimento dei dati Il nodo si connette al suo successore, il quale al momento attuale tiene la responsabilità di tutti i dati che devono essere spostati. In particolare invia una richiesta di aggiornamento dei dati passando anche l’identificativo del suo predecessore. In questo modo il nodo a cui viene fatta la richiesta per ogni chiave può verificare se l’identificativo della chiave si trova tra l’identificativo ricevuto e quello del suo predecessore. Se l’esito della verifica è positivo allora trasferisce la responsabilità del dato. Questa operazione viene implementata molto semplicemente come segue: • Viene inviato l’identificativo della chiave del dato • Tale identificativo permette di creare a lato del nuovo nodo responsabile una chiave che viene poi aggiunta al vettore di chiavi • D’altra parte tale chiave viene eliminata dal vettore di chiavi del nodo che trasferisce la responsabilità 3.4 Leave di un nodo Se un nodo decide di lasciare la rete utilizza una procedura del tutto simile a quella di entrata. Le fasi sono le stesse descritte in precedenza. Innanzitutto occorre garantire la correttezza della rete andando a cancellare se stesso dal successore e dal predecessore. Mentre è collegato al successore inizia anche la fase di trasferimento dei dati, in quanto tutti i dati di cui è responsabile il nodo diventano di responsabilità del successore. In ultimo aggiorna le finger table degli altri nodi, la cui modalità di comunicazione con gli altri nodi è la stessa vista in precedenza. 4. MESSAGGI SCAMBIATI Find_Successor Parametri: l’id di cui si vuole trovare il successore Funzione: verifica se l’id specificato si trova tra se e il proprio successore Risposta: se la verifica ha esito positivo restituisce il messaggio “Find”, il proprio successore e se stesso; altrimenti invia il messaggio “Redirect Request” seguito dall’indirizzo il cui elemento della finger table ha l’id all’interno del suo intervallo. Find_Predecessor Parametri: l’id di cui si vuole trovare ilpredecessore Funzione: verifica se l’id specificato si trova tra se e il proprio successore Risposta: se la verifica ha esito positivo restituisce il messaggio “Find” e se stesso; altrimenti invia il messaggio “Redirect Request” seguito dall’indirizzo il cui elemento della finger table ha l’id all’interno del suo intervallo. Set_Successor Parametri: indirizzo e id di un nodo Funzione: settare il successore con le nuove informazioni ricevute Risposta: conferma dell’avvenimento dell’operazione Set_Predecessor Parametri: indirizzo e id di un nodo Funzione: settare il predecessore con le nuove informazioni ricevute Risposta: conferma dell’avvenimento dell’operazione Search_Node Parametri: l’id di cui si vuole trovare il successore Funzione: verifica se l’id specificato si trova tra se e il proprio successore Risposta: se la verifica ha esito positivo restituisce il messaggio “Find” e il proprio successore; altrimenti invia il messaggio “Redirect Request” seguito dall’indirizzo del suo successore. Get_Successor Parametri: nessuno Funzione: restituire il successore del nodo Risposta: indirizzo e id del successore Get_Predecessor Parametri: nessuno Funzione: restituire il predecessore del nodo Risposta: indirizzo e id del predecessore Update_Finger_Table Parametri: indirizzo, relativo id e un intero i che serve da indice Funzione: verifica se id appartiene all’intervallo [proprio id, id di finger[i]) Risposta: in caso positivo risponde “continue” e l’indirizzo a cui inoltrare la medesima richiesta; altrimenti risponde con “stop”. Delete_Me_From_Finger_Table Parametri: nodeId del nodo da eliminare, indice i e indirizzo del successore Funzione: verifica se l’i‐esimo finger ha l’id uguale a nodeId, nel qual caso lo modifica con il successore passato Risposta: in caso positivo risponde “continue” e l’indirizzo a cui inoltrare la medesima richiesta; altrimenti risponde con “stop”. Find_Successor_Key Parametri: il keyId di cui si vuole trovare il successore Funzione: verifica se il keyId specificato si trova tra se e il proprio successore Risposta: se la verifica ha esito positivo restituisce il messaggio “Find” e il proprio successore; altrimenti invia il messaggio “Redirect Request” seguito dall’indirizzo il cui elemento della finger table ha il keyId all’interno del suo intervallo. Transfer_Key Parametri: keyId Funzione: trasferire nel nodo i dati relativi alla keyId Risposta: risultato della transizione Update_Vector_Key Parametri: id del predecessore del nodo che fa richiesta Funzione: valuta se per ogni chiave di cui è responsabile il keyId appartiene all’intervallo [id del predecessore del nodo che fa richiesta, id del nodo che fa richiesta) Risposta: se appartiene all’intervallo restituisce “transfer” seguito dall’id della chiave da trasferire, altrimenti “nothing” Delete_Node Parametri: id del nodo da eliminare Funzione: ha la responsabilità di occuparsi di tutto lo scambio di messaggi relativo all’eliminazione di un nodo Risposta: nessuna 5. RIFERIMENTI STOICA, I., MORRIS, R., KARGER, D., KAASHOEK, M. F., AND
BALAKRISHNAN, H. Chord: A scalable peer-to-peer lookup service for internet
applications. In Proc. ACM SIGCOMM (San Diego, 2001).