Introduzione ai microservizi Python con Nameko
Pubblicato: 2022-03-11introduzione
Il modello architettonico dei microservizi è uno stile architettonico che sta diventando sempre più popolare, data la sua flessibilità e resilienza. Insieme a tecnologie come Kubernetes, sta diventando più facile avviare un'applicazione utilizzando un'architettura di microservizi come mai prima d'ora.
Secondo un classico articolo del blog di Martin Fowler, lo stile architettonico di Microservices può essere riassunto come:
In breve, lo stile dell'architettura del microservizio è un approccio allo sviluppo di una singola applicazione come una suite di piccoli servizi, ciascuno in esecuzione nel proprio processo e in comunicazione con meccanismi leggeri, spesso un'API di risorse HTTP. Questi servizi sono costruiti attorno alle capacità aziendali e possono essere implementati in modo indipendente da macchinari di distribuzione completamente automatizzati.
In altre parole, un'applicazione che segue un'architettura di microservizi è composta da diversi servizi indipendenti e dinamici che comunicano tra loro tramite un protocollo di comunicazione. È comune utilizzare HTTP (e REST), ma come vedremo, possiamo utilizzare altri tipi di protocolli di comunicazione come RPC (Remote Procedure Call) su AMQP (Advanced Message Queuing Protocol).
Il pattern dei microservizi può essere pensato come un caso specifico di SOA (architettura orientata ai servizi). In SOA è invece comune utilizzare un ESB (enterprise service bus) per gestire la comunicazione tra i servizi. Gli ESB sono generalmente altamente sofisticati e includono funzionalità per l'instradamento di messaggi complessi e l'applicazione di regole aziendali. Nei microservizi, è più comune utilizzare un approccio alternativo: "endpoint intelligenti e tubi stupidi", il che significa che i servizi stessi dovrebbero contenere tutta la logica e la complessità aziendali (alta coesione), ma la connessione tra i servizi dovrebbe essere semplice come possibile (alto disaccoppiamento), il che significa che un servizio non ha necessariamente bisogno di sapere quali altri servizi comunicheranno con esso. Questa è una separazione delle preoccupazioni applicate a livello architettonico.
Un altro aspetto dei microservizi è che non vi è alcuna imposizione su quali tecnologie devono essere utilizzate all'interno di ciascun servizio. Dovresti essere in grado di scrivere un servizio con qualsiasi stack software in grado di comunicare con gli altri servizi. Ogni servizio ha anche la propria gestione del ciclo di vita. Tutto ciò significa che in un'azienda è possibile che i team lavorino su servizi separati, con diverse tecnologie e persino metodologie di gestione. Ciascun team si occuperà delle capacità aziendali, contribuendo a creare un'organizzazione più agile.
Microservizi Python
Tenendo presenti questi concetti, in questo articolo ci concentreremo sulla creazione di un'applicazione di microservizi proof of concept utilizzando Python. Per questo, useremo Nameko, un framework di microservizi Python. Ha RPC su AMQP integrato, consentendoti di comunicare facilmente tra i tuoi servizi. Ha anche una semplice interfaccia per le query HTTP, che useremo in questo tutorial. Tuttavia, per la scrittura di microservizi che espongono un endpoint HTTP, si consiglia di utilizzare un altro framework, ad esempio Flask. Per chiamare i metodi Nameko su RPC usando Flask, puoi usare flask_nameko, un wrapper creato solo per interoperare Flask con Nameko.
Impostazione dell'ambiente di base
Iniziamo eseguendo l'esempio più semplice possibile, estratto dal sito Web di Nameko, ed espandiamolo per i nostri scopi. Innanzitutto, avrai bisogno di Docker installato. Useremo Python 3 nei nostri esempi, quindi assicurati di averlo installato. Quindi, crea un virtualenv python ed esegui $ pip install nameko
.
Per eseguire Nameko, abbiamo bisogno del broker di messaggi RabbitMQ. Sarà responsabile della comunicazione tra i nostri servizi Nameko. Non preoccuparti, però, poiché non è necessario installare un'altra dipendenza sulla tua macchina. Con Docker, possiamo semplicemente scaricare un'immagine preconfigurata, eseguirla e, quando abbiamo finito, semplicemente arrestare il contenitore. Nessun demone, apt-get
o dnf install
.
Avvia un contenitore RabbitMQ eseguendo $ docker run -p 5672:5672 --hostname nameko-rabbitmq rabbitmq:3
(potrebbe essere necessario sudo per farlo). Questo avvierà un container Docker usando la versione più recente 3 RabbitMQ e lo esporrà sulla porta predefinita 5672.
Hello World con i microservizi
Vai avanti e crea un file chiamato hello.py
con il seguente contenuto:
from nameko.rpc import rpc class GreetingService: name = "greeting_service" @rpc def hello(self, name): return "Hello, {}!".format(name)
I servizi Nameko sono classi. Queste classi espongono punti di ingresso, che vengono implementati come estensioni. Le estensioni integrate includono la possibilità di creare punti di ingresso che rappresentano metodi RPC, listener di eventi, endpoint HTTP o timer. Ci sono anche estensioni della community che possono essere utilizzate per interagire con il database PostgreSQL, Redis, ecc... È possibile scrivere le proprie estensioni.
Andiamo avanti ed eseguiamo il nostro esempio. Se hai RabbitMQ in esecuzione sulla porta predefinita, esegui semplicemente $ nameko run hello
. Troverà RabbitMQ e si connetterà automaticamente. Quindi, per testare il nostro servizio, esegui $ nameko shell
in un altro terminale. Questo creerà una shell interattiva che si collegherà alla stessa istanza RabbitMQ. Il bello è che, utilizzando RPC su AMQP, Nameko implementa il rilevamento automatico dei servizi. Quando si chiama un metodo RPC, nameko proverà a trovare il servizio in esecuzione corrispondente.
Quando esegui la shell Nameko, otterrai un oggetto speciale chiamato n
aggiunto allo spazio dei nomi. Questo oggetto consente l'invio di eventi e l'esecuzione di chiamate RPC. Per eseguire una chiamata RPC al nostro servizio, eseguire:
> >> n.rpc.greetingservice.hello(name='world') 'Hello, world!'
Chiamate simultanee
Queste classi di servizio vengono istanziate nel momento in cui viene effettuata una chiamata e vengono distrutte dopo che la chiamata è stata completata. Pertanto, dovrebbero essere intrinsecamente senza stato, il che significa che non dovresti provare a mantenere alcuno stato nell'oggetto o nella classe tra le chiamate. Ciò implica che i servizi stessi devono essere apolidi. Partendo dal presupposto che tutti i servizi siano stateless, Nameko è in grado di sfruttare la concorrenza utilizzando i greenthread di eventlet. I servizi istanziati sono chiamati "lavoratori" e può esserci un numero massimo configurato di lavoratori in esecuzione contemporaneamente.
Per verificare in pratica la concorrenza di Nameko, modificare il codice sorgente aggiungendo uno sleep alla chiamata di procedura prima di restituire la risposta:
from time import sleep from nameko.rpc import rpc class GreetingService: name = "greeting_service" @rpc def hello(self, name): sleep(5) return "Hello, {}!".format(name)
Stiamo usando sleep
dal modulo time
, che non è abilitato per la sincronizzazione. Tuttavia, quando esegui i nostri servizi utilizzando nameko run
, applicherà automaticamente una patch ai rendimenti dei trigger bloccando chiamate come sleep(5)
.
Ora si prevede che il tempo di risposta da una chiamata di procedura dovrebbe richiedere circa 5 secondi. Tuttavia, quale sarà il comportamento del seguente snippet, quando lo eseguiremo dalla shell nameko?
res = [] for i in range(5): hello_res = n.rpc.greeting_service.hello.call_async(name=str(i)) res.append(hello_res) for hello_res in res: print(hello_res.result())
Nameko fornisce un metodo call_async
non bloccante per ogni punto di ingresso RPC, restituendo un oggetto di risposta proxy che può quindi essere interrogato per il suo risultato. Il metodo del result
, quando viene chiamato sul proxy di risposta, verrà bloccato fino a quando non viene restituita la risposta.
Come previsto, questo esempio viene eseguito in circa cinque secondi. Ogni lavoratore verrà bloccato in attesa del sleep
della chiamata di sospensione, ma ciò non interrompe l'avvio di un altro lavoratore. Sostituisci questa chiamata di sospensione con sleep
chiamata al database I/O di blocco, ad esempio, e otterrai un servizio simultaneo estremamente veloce.
Come spiegato in precedenza, Nameko crea lavoratori quando viene chiamato un metodo. Il numero massimo di lavoratori è configurabile. Per impostazione predefinita, quel numero è impostato su 10. Puoi provare a modificare l' range(5)
nello snippet precedente in, ad esempio, intervallo(20). Questo chiamerà il metodo hello
20 volte, che ora dovrebbero richiedere dieci secondi per essere eseguiti:
> >> res = [] > >> for i in range(20): ... hello_res = n.rpc.greeting_service.hello.call_async(name=str(i)) ... res.append(hello_res) > >> for hellores in res: ... print(hello_res.result()) Hello, 0! Hello, 1! Hello, 2! Hello, 3! Hello, 4! Hello, 5! Hello, 6! Hello, 7! Hello, 8! Hello, 9! Hello, 10! Hello, 11! Hello, 12! Hello, 13! Hello, 14! Hello, 15! Hello, 16! Hello, 17! Hello, 18! Hello, 19!
Ora, supponiamo che stavi ricevendo troppi (più di 10) utenti simultanei che chiamano quel metodo hello
. Alcuni utenti si bloccheranno aspettando più dei cinque secondi previsti per la risposta. Una soluzione era aumentare il numero di lavori sovrascrivendo le impostazioni predefinite utilizzando, ad esempio, un file di configurazione. Tuttavia, se il tuo server è già al limite con quei dieci lavoratori perché il metodo chiamato si basa su alcune query di database pesanti, l'aumento del numero di lavoratori potrebbe far aumentare ulteriormente il tempo di risposta.
Ridimensionare il nostro servizio
Una soluzione migliore consiste nell'utilizzare le funzionalità dei microservizi Nameko. Fino ad ora, abbiamo utilizzato solo un server (il tuo computer), eseguendo un'istanza di RabbitMQ e un'istanza del servizio. In un ambiente di produzione, vorrai aumentare arbitrariamente il numero di nodi che eseguono il servizio che riceve troppe chiamate. Puoi anche creare un cluster RabbitMQ se vuoi che il tuo broker di messaggi sia più affidabile.
Per simulare il ridimensionamento di un servizio, possiamo semplicemente aprire un altro terminale ed eseguire il servizio come prima, usando $ nameko run hello
. Verrà avviata un'altra istanza del servizio con il potenziale per eseguire altri dieci worker. Ora, prova a eseguire di nuovo lo snippet con range(20)
. Ora dovrebbero essere necessari di nuovo cinque secondi per l'esecuzione. Quando sono in esecuzione più istanze del servizio, Nameko eseguirà il round robin delle richieste RPC tra le istanze disponibili.
Nameko è costruito per gestire in modo affidabile quelle chiamate ai metodi in un cluster. Per verificarlo, prova a eseguire lo snipped e prima che finisca, vai su uno dei terminali che eseguono il servizio Nameko e premi Ctrl+C
due volte. Ciò spegnerebbe l'host senza aspettare che i lavoratori finiscano. Nameko riassegna le chiamate a un'altra istanza del servizio disponibile.

In pratica, utilizzeresti Docker per containerizzare i tuoi servizi, come faremo in seguito, e uno strumento di orchestrazione come Kubernetes per gestire i tuoi nodi che eseguono il servizio e altre dipendenze, come il broker di messaggi. Se fatto correttamente, con Kubernetes, trasformeresti efficacemente la tua applicazione in un robusto sistema distribuito, immune da picchi imprevisti. Inoltre, Kubernetes consente implementazioni senza tempi di inattività. Pertanto, la distribuzione di una nuova versione di un servizio non influirà sulla disponibilità del sistema.
È importante creare servizi tenendo presente una certa compatibilità con le versioni precedenti, poiché in un ambiente di produzione può accadere che diverse versioni dello stesso servizio vengano eseguite contemporaneamente, soprattutto durante la distribuzione. Se utilizzi Kubernetes, durante la distribuzione verranno eliminati tutti i contenitori della vecchia versione solo quando sono presenti abbastanza nuovi contenitori in esecuzione.
Per Nameko, avere diverse versioni dello stesso servizio in esecuzione contemporaneamente non è un problema. Poiché distribuisce le chiamate in modo round robin, le chiamate potrebbero passare attraverso versioni vecchie o nuove. Per verificarlo, mantieni un terminale con il nostro servizio che esegue la vecchia versione e modifica il modulo di servizio in modo che assomigli a:
from time import sleep from nameko.rpc import rpc class GreetingService: name = "greeting_service" @rpc def hello(self, name): sleep(5) return "Hello, {}! (version 2)".format(name)
Se esegui quel servizio da un altro terminale, otterrai le due versioni in esecuzione contemporaneamente. Ora, esegui di nuovo il nostro frammento di prova e vedrai mostrate entrambe le versioni:
> >> res = [] > >> for i in range(5): ... hello_res = n.rpc.greeting_service.hello.call_async(name=str(i)) ... res.append(hello_res) > >> for hellores in res: ... print(hello_res.result()) Hello, 0! Hello, 1! (version 2) Hello, 2! Hello, 3! (version 2) Hello, 4!
Lavorare con più istanze
Ora sappiamo come lavorare in modo efficace con Nameko e come funziona il ridimensionamento. Ora facciamo un ulteriore passo avanti e utilizziamo più strumenti dell'ecosistema Docker: docker-compose. Funzionerà se si esegue la distribuzione su un singolo server, il che non è sicuramente l'ideale poiché non si sfrutteranno molti dei vantaggi di un'architettura di microservizi. Anche in questo caso, se desideri disporre di un'infrastruttura più adatta, potresti utilizzare uno strumento di orchestrazione come Kubernetes per gestire un sistema distribuito di contenitori. Quindi, vai avanti e installa docker-compose.
Ancora una volta, tutto ciò che dobbiamo fare è distribuire un'istanza RabbitMQ e Nameko si occuperà del resto, dato che tutti i servizi possono accedere a quell'istanza RabbitMQ. Il codice sorgente completo per questo esempio è disponibile in questo repository GitHub.
Creiamo una semplice applicazione di viaggio per testare le capacità di Nameko. Tale applicazione consente la registrazione di aeroporti e viaggi. Ogni aeroporto viene semplicemente memorizzato come nome dell'aeroporto e il viaggio memorizza gli ID per gli aeroporti di origine e destinazione. L'architettura del nostro sistema si presenta come segue:
Idealmente, ogni microservizio dovrebbe avere la propria istanza di database. Tuttavia, per semplicità, ho creato un unico database Redis per la condivisione dei microservizi Trips e Airports. Il microservizio Gateway riceverà richieste HTTP tramite una semplice API simile a REST e utilizzerà RPC per comunicare con Airports and Trips.
Iniziamo con il microservizio Gateway. La sua struttura è semplice e dovrebbe essere molto familiare a chiunque provenga da un framework come Flask. Fondamentalmente definiamo due endpoint, ciascuno dei quali consente entrambi i metodi GET e POST:
import json from nameko.rpc import RpcProxy from nameko.web.handlers import http class GatewayService: name = 'gateway' airports_rpc = RpcProxy('airports_service') trips_rpc = RpcProxy('trips_service') @http('GET', '/airport/<string:airport_id>') def get_airport(self, request, airport_id): airport = self.airports_rpc.get(airport_id) return json.dumps({'airport': airport}) @http('POST', '/airport') def post_airport(self, request): data = json.loads(request.get_data(as_text=True)) airport_id = self.airports_rpc.create(data['airport']) return airport_id @http('GET', '/trip/<string:trip_id>') def get_trip(self, request, trip_id): trip = self.trips_rpc.get(trip_id) return json.dumps({'trip': trip}) @http('POST', '/trip') def post_trip(self, request): data = json.loads(request.get_data(as_text=True)) trip_id = self.trips_rpc.create(data['airport_from'], data['airport_to']) return trip_id
Diamo ora un'occhiata al servizio Aeroporti. Come previsto, espone due metodi RPC. Il metodo get
interrogherà semplicemente il database Redis e restituirà l'aeroporto per l'ID specificato. Il metodo create
genererà un ID casuale, memorizzerà le informazioni sull'aeroporto e restituirà l'ID:
import uuid from nameko.rpc import rpc from nameko_redis import Redis class AirportsService: name = "airports_service" redis = Redis('development') @rpc def get(self, airport_id): airport = self.redis.get(airport_id) return airport @rpc def create(self, airport): airport_id = uuid.uuid4().hex self.redis.set(airport_id, airport) return airport_id
Nota come stiamo usando l'estensione nameko_redis
. Dai un'occhiata all'elenco delle estensioni della community. Le estensioni sono implementate in un modo che impiega l'inserimento delle dipendenze. Nameko si occupa di avviare l'oggetto estensione effettivo che ogni lavoratore utilizzerà.
Non c'è molta differenza tra i microservizi Airports e Trips. Ecco come apparirà il microservizio Trips:
import uuid from nameko.rpc import rpc from nameko_redis import Redis class AirportsService: name = "trips_service" redis = Redis('development') @rpc def get(self, trip_id): trip = self.redis.get(trip_id) return trip @rpc def create(self, airport_from_id, airport_to_id): trip_id = uuid.uuid4().hex self.redis.set(trip_id, { "from": airport_from_id, "to": airport_to_id }) return trip_id
Anche il Dockerfile
per ogni microservizio è molto semplice. L'unica dipendenza è nameko
e, nel caso dei servizi Aeroporti e Viaggi, è necessario installare anche nameko-redis
. Tali dipendenze sono fornite nel requirements.txt
in ogni servizio. Il Dockerfile per il servizio Aeroporti è simile a:
FROM python:3 RUN apt-get update && apt-get -y install netcat && apt-get clean WORKDIR /app COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY config.yml ./ COPY run.sh ./ COPY airports.py ./ RUN chmod +x ./run.sh CMD ["./run.sh"]
L'unica differenza tra quello e il Dockerfile per gli altri servizi è il file sorgente (in questo caso airports.py
), che dovrebbe essere modificato di conseguenza.
Lo script run.sh
si occupa di aspettare RabbitMQ e, nel caso dei servizi Aeroporti e Viaggi, il database Redis è pronto. Il frammento di codice seguente mostra il contenuto di run.sh
per gli aeroporti. Anche in questo caso, per gli altri servizi basta passare da aiports
a gateway
o trips
di conseguenza:
#!/bin/bash until nc -z ${RABBIT_HOST} ${RABBIT_PORT}; do echo "$(date) - waiting for rabbitmq..." sleep 1 done until nc -z ${REDIS_HOST} ${REDIS_PORT}; do echo "$(date) - waiting for redis..." sleep 1 done nameko run --config config.yml airports
I nostri servizi sono ora pronti per essere eseguiti:
$ docker-compose up
Proviamo il nostro sistema. Esegui il comando:
$ curl -i -d "{\"airport\": \"first_airport\"}" localhost:8000/airport HTTP/1.1 200 OK Content-Type: text/plain; charset=utf-8 Content-Length: 32 Date: Sun, 27 May 2018 05:05:53 GMT f2bddf0e506145f6ba0c28c247c54629
L'ultima riga è l'ID generato per il nostro aeroporto. Per verificare se funziona, eseguire:
$curl localhost:8000/airport/f2bddf0e506145f6ba0c28c247c54629 {"airport": "first_airport"} Great, now let's add another airport: $ curl -i -d "{\"airport\": \"second_airport\"}" localhost:8000/airport HTTP/1.1 200 OK Content-Type: text/plain; charset=utf-8 Content-Length: 32 Date: Sun, 27 May 2018 05:06:00 GMT 565000adcc774cfda8ca3a806baec6b5
Ora abbiamo due aeroporti, questo è abbastanza per formare un viaggio. Creiamo ora un viaggio:
$ curl -i -d "{\"airport_from\": \"f2bddf0e506145f6ba0c28c247c54629\", \"airport_to\": \"565000adcc774cfda8ca3a806baec6b5\"}" localhost:8000/trip HTTP/1.1 200 OK Content-Type: text/plain; charset=utf-8 Content-Length: 32 Date: Sun, 27 May 2018 05:09:10 GMT 34ca60df07bc42e88501178c0b6b95e4
Come prima, l'ultima riga rappresenta l'ID viaggio. Verifichiamo se è stato inserito correttamente:
$ curl localhost:8000/trip/34ca60df07bc42e88501178c0b6b95e4 {"trip": "{'from': 'f2bddf0e506145f6ba0c28c247c54629', 'to': '565000adcc774cfda8ca3a806baec6b5'}"}
Sommario
Abbiamo visto come funziona Nameko creando un'istanza locale di RabbitMQ, connettendoci ad essa ed eseguendo diversi test. Quindi, abbiamo applicato le conoscenze acquisite per creare un sistema semplice utilizzando un'architettura di microservizi.
Nonostante sia estremamente semplice, il nostro sistema è molto simile a come sarebbe un'implementazione pronta per la produzione. Preferiresti utilizzare un altro framework per gestire le richieste HTTP come Falcon o Flask. Entrambe sono ottime opzioni e possono essere facilmente utilizzate per creare altri microservizi basati su HTTP, nel caso in cui si desideri interrompere il servizio Gateway, ad esempio. Flask ha il vantaggio di avere già un plugin per interagire con Nameko, ma puoi usare nameko-proxy direttamente da qualsiasi framework.
Nameko è anche molto facile da testare. Non abbiamo trattato i test qui per semplicità, ma controlla la documentazione di test di Nameko.
Con tutte le parti mobili all'interno di un'architettura di microservizi, vuoi assicurarti di avere un solido sistema di registrazione. Per crearne uno, consulta Python Logging: An In-Depth Tutorial del collega Toptaler e sviluppatore Python: Son Nguyen Kim.