Wprowadzenie do mikroserwisów Pythona z Nameko
Opublikowany: 2022-03-11Wstęp
Wzorzec architektoniczny mikrousług to styl architektoniczny, który zyskuje na popularności, biorąc pod uwagę jego elastyczność i odporność. Wraz z technologiami takimi jak Kubernetes, ładowanie aplikacji przy użyciu architektury mikrousług staje się łatwiejsze niż kiedykolwiek wcześniej.
Zgodnie z klasycznym artykułem z bloga Martina Fowlera, styl architektoniczny Microservices można podsumować jako:
Krótko mówiąc, styl architektury mikrousług to podejście do tworzenia pojedynczej aplikacji jako zestawu małych usług, z których każda działa we własnym procesie i komunikuje się z lekkimi mechanizmami, często interfejsem API zasobów HTTP. Usługi te opierają się na możliwościach biznesowych i można je niezależnie wdrażać za pomocą w pełni zautomatyzowanych maszyn do wdrażania.
Innymi słowy, aplikacja zgodna z architekturą mikrousług składa się z kilku niezależnych i dynamicznych usług, które komunikują się ze sobą za pomocą protokołu komunikacyjnego. Powszechnie używa się protokołu HTTP (i REST), ale jak zobaczymy, możemy używać innych typów protokołów komunikacyjnych, takich jak RPC (Remote Procedure Call) przez AMQP (Advanced Message Queuing Protocol).
Wzorzec mikrousług można traktować jako szczególny przypadek architektury SOA (architektura zorientowana na usługi). W architekturze SOA powszechne jest jednak używanie magistrali ESB (enterprise service bus) do zarządzania komunikacją między usługami. ESB są zwykle bardzo wyrafinowane i zawierają funkcje do złożonego routingu wiadomości i stosowania reguł biznesowych. W mikrousługach częściej stosuje się alternatywne podejście: „inteligentne punkty końcowe i głupie potoki”, co oznacza, że same usługi powinny zawierać całą logikę biznesową i złożoność (wysoka spójność), ale połączenie między usługami powinno być tak proste, jak możliwe (wysokie oddzielenie), co oznacza, że usługa nie musi koniecznie wiedzieć, które inne usługi będą się z nią komunikować. Jest to separacja obaw stosowanych na poziomie architektonicznym.
Innym aspektem mikrousług jest to, że nie ma egzekwowania o tym, które technologie powinny być używane w ramach każdej usługi. Powinieneś być w stanie napisać usługę z dowolnym stosem oprogramowania, który może komunikować się z innymi usługami. Każda usługa ma również własne zarządzanie cyklem życia. Wszystko to sprawia, że w firmie możliwe jest, aby zespoły pracowały nad osobnymi usługami, z różnymi technologiami, a nawet metodologiami zarządzania. Każdy zespół będzie zajmować się możliwościami biznesowymi, pomagając zbudować bardziej zwinną organizację.
Mikroserwisy Pythona
Mając na uwadze te koncepcje, w tym artykule skupimy się na tworzeniu dowodu koncepcji aplikacji mikroserwisów przy użyciu Pythona. W tym celu użyjemy Nameko, frameworka mikroserwisów Pythona. Ma wbudowane RPC przez AMQP, co pozwala na łatwą komunikację między Twoimi usługami. Posiada również prosty interfejs dla zapytań HTTP, którego użyjemy w tym samouczku. Jednak w przypadku pisania mikrousług, które uwidaczniają punkt końcowy HTTP, zaleca się użycie innej struktury, takiej jak Flask. Aby wywołać metody Nameko przez RPC przy użyciu Flask, możesz użyć flask_nameko, wrappera stworzonego tylko do współdziałania Flask z Nameko.
Ustawianie podstawowego środowiska
Zacznijmy od uruchomienia najprostszego możliwego przykładu, wydobytego ze strony Nameko i rozwiń go dla naszych celów. Najpierw będziesz potrzebować zainstalowanego Dockera. W naszych przykładach użyjemy Pythona 3, więc upewnij się, że go również zainstalowałeś. Następnie utwórz python virtualenv i uruchom $ pip install nameko
.
Do uruchomienia Nameko potrzebujemy brokera komunikatów RabbitMQ. Będzie odpowiedzialny za komunikację pomiędzy naszymi usługami Nameko. Nie martw się jednak, ponieważ nie musisz instalować jeszcze jednej zależności na swoim komputerze. Dzięki Dockerowi możemy po prostu pobrać wstępnie skonfigurowany obraz, uruchomić go, a kiedy skończymy, po prostu zatrzymać kontener. Brak demonów, apt-get
lub dnf install
.
Uruchom kontener RabbitMQ, uruchamiając $ docker run -p 5672:5672 --hostname nameko-rabbitmq rabbitmq:3
(do tego może być potrzebne sudo). Spowoduje to uruchomienie kontenera Docker przy użyciu najnowszej wersji 3 RabbitMQ i udostępnienie go na domyślnym porcie 5672.
Witaj świecie z mikroserwisami
Śmiało i utwórz plik o nazwie hello.py
z następującą zawartością:
from nameko.rpc import rpc class GreetingService: name = "greeting_service" @rpc def hello(self, name): return "Hello, {}!".format(name)
Usługi Nameko to zajęcia. Te klasy uwidaczniają punkty wejścia, które są implementowane jako rozszerzenia. Wbudowane rozszerzenia obejmują możliwość tworzenia punktów wejścia, które reprezentują metody RPC, detektory zdarzeń, punkty końcowe HTTP lub zegary. Istnieją również rozszerzenia społeczności, które można wykorzystać do interakcji z bazą danych PostgreSQL, Redis itp. Istnieje możliwość napisania własnych rozszerzeń.
Przejdźmy dalej i przeanalizujmy nasz przykład. Jeśli masz RabbitMQ uruchomiony na domyślnym porcie, po prostu uruchom $ nameko run hello
. Znajdzie RabbitMQ i połączy się z nim automatycznie. Następnie, aby przetestować naszą usługę, uruchom $ nameko shell
w innym terminalu. Stworzy to interaktywną powłokę, która połączy się z tą samą instancją RabbitMQ. Wspaniałą rzeczą jest to, że używając RPC przez AMQP, Nameko implementuje automatyczne wykrywanie usług. Podczas wywoływania metody RPC nameko spróbuje znaleźć odpowiednią uruchomioną usługę.
Uruchamiając powłokę Nameko, do przestrzeni nazw zostanie dodany specjalny obiekt o nazwie n
. Ten obiekt pozwala na wywoływanie zdarzeń i wykonywanie wywołań RPC. Aby wykonać wywołanie RPC do naszej usługi, uruchom:
> >> n.rpc.greetingservice.hello(name='world') 'Hello, world!'
Równoczesne połączenia
Te klasy usług są tworzone w momencie nawiązania połączenia i niszczone po zakończeniu połączenia. Dlatego powinny one być z natury bezstanowe, co oznacza, że nie należy próbować zachowywać żadnego stanu w obiekcie lub klasie między wywołaniami. Oznacza to, że same usługi muszą być bezpaństwowe. Przy założeniu, że wszystkie usługi są bezstanowe, Nameko jest w stanie wykorzystać współbieżność, korzystając z zielonych wątków eventlet. Usługi z wystąpieniem są nazywane „pracownikami” i może być skonfigurowana maksymalna liczba pracujących jednocześnie.
Aby w praktyce zweryfikować współbieżność Nameko, zmodyfikuj kod źródłowy poprzez dodanie uśpienia do wywołania procedury przed zwróceniem odpowiedzi:
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)
Używamy sleep
z modułu time
, który nie obsługuje asynchronii. Jednak uruchamiając nasze usługi przy użyciu nameko run
, automatycznie łata ona wydajność wyzwalania z blokowania wywołań, takich jak sleep(5)
.
Obecnie oczekuje się, że czas odpowiedzi na wywołanie procedury powinien zająć około 5 sekund. Jak jednak będzie się zachowywać następujący fragment kodu, gdy uruchomimy go z powłoki 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 zapewnia nieblokującą metodę call_async
dla każdego punktu wejścia RPC, zwracając obiekt odpowiedzi proxy, który można następnie zapytać o wynik. Metoda result
wywołana na proxy odpowiedzi zostanie zablokowana do momentu zwrócenia odpowiedzi.
Zgodnie z oczekiwaniami, ten przykład działa w zaledwie około pięciu sekund. Każdy pracownik zostanie zablokowany w oczekiwaniu na zakończenie połączenia w trybie sleep
, ale nie powstrzyma to innego pracownika przed rozpoczęciem. Zastąp to wywołanie sleep
użytecznym blokującym wywołaniem bazy danych we/wy, na przykład, a otrzymasz niezwykle szybką usługę współbieżną.
Jak wyjaśniono wcześniej, Nameko tworzy pracowników po wywołaniu metody. Maksymalna liczba pracowników jest konfigurowalna. Domyślnie ta liczba jest ustawiona na 10. Możesz przetestować zmianę range(5)
w powyższym fragmencie na na przykład range(20). Spowoduje to wywołanie metody hello
20 razy, co powinno zająć teraz dziesięć sekund:
> >> 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!
Załóżmy teraz, że otrzymujesz zbyt wielu (ponad 10) równoczesnych użytkowników wywołujących tę metodę hello
. Niektórzy użytkownicy będą się zawieszać, czekając na odpowiedź dłużej niż oczekiwano pięć sekund. Jednym z rozwiązań było zwiększenie liczby prac poprzez nadpisanie ustawień domyślnych za pomocą np. pliku konfiguracyjnego. Jeśli jednak Twój serwer osiągnął już limit tych dziesięciu procesów roboczych, ponieważ wywoływana metoda opiera się na niektórych ciężkich zapytaniach do bazy danych, zwiększenie liczby procesów roboczych może spowodować jeszcze większe wydłużenie czasu odpowiedzi.
Skalowanie naszej usługi
Lepszym rozwiązaniem jest wykorzystanie możliwości mikroserwisów Nameko. Do tej pory korzystaliśmy tylko z jednego serwera (Twojego komputera) z jedną instancją RabbitMQ i jedną instancją usługi. W środowisku produkcyjnym będziesz chciał dowolnie zwiększyć liczbę węzłów z uruchomioną usługą, która otrzymuje zbyt wiele wywołań. Możesz również zbudować klaster RabbitMQ, jeśli chcesz, aby broker komunikatów był bardziej niezawodny.
Aby zasymulować skalowanie usługi, możemy po prostu otworzyć inny terminal i uruchomić usługę jak poprzednio, używając $ nameko run hello
. Spowoduje to uruchomienie kolejnego wystąpienia usługi z możliwością uruchomienia dziesięciu dodatkowych pracowników. Teraz spróbuj ponownie uruchomić ten fragment kodu za pomocą range(20)
. Ponowne uruchomienie powinno teraz zająć pięć sekund. Gdy działa więcej niż jedna instancja usługi, Nameko będzie krążyć wokół żądań RPC wśród dostępnych instancji.
Nameko jest zbudowany tak, aby niezawodnie obsługiwać te wywołania metod w klastrze. Aby to przetestować, spróbuj uruchomić wycięty i zanim skończy, przejdź do jednego z terminali z usługą Nameko i naciśnij dwukrotnie Ctrl+C
. Spowodowałoby to wyłączenie hosta bez czekania, aż pracownicy skończą. Nameko przeniesie połączenia do innej dostępnej instancji usługi.

W praktyce będziesz używać Dockera do konteneryzacji swoich usług, jak to zrobimy później, oraz narzędzia do orkiestracji, takiego jak Kubernetes, do zarządzania węzłami z uruchomioną usługą i innymi zależnościami, takimi jak broker komunikatów. Jeśli zrobisz to poprawnie, z Kubernetes, skutecznie przekształcisz swoją aplikację w solidny system rozproszony, odporny na nieoczekiwane szczyty. Ponadto Kubernetes pozwala na wdrożenia bez przestojów. Dlatego wdrożenie nowej wersji usługi nie wpłynie na dostępność systemu.
Ważne jest, aby kompilować usługi z myślą o pewnej kompatybilności wstecznej, ponieważ w środowisku produkcyjnym może się zdarzyć, że kilka różnych wersji tej samej usługi będzie działać w tym samym czasie, zwłaszcza podczas wdrażania. Jeśli używasz Kubernetes, podczas wdrażania zabije wszystkie stare wersje kontenerów tylko wtedy, gdy będzie wystarczająco dużo uruchomionych nowych kontenerów.
Dla Nameko posiadanie kilku różnych wersji tej samej usługi uruchomionych w tym samym czasie nie stanowi problemu. Ponieważ dystrybuuje połączenia w sposób okrągły, połączenia mogą przechodzić przez stare lub nowe wersje. Aby to przetestować, pozostaw jeden terminal z naszą usługą działającą w starej wersji i edytuj moduł usługi, aby wyglądał następująco:
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)
Jeśli uruchomisz tę usługę z innego terminala, obie wersje będą działać w tym samym czasie. Teraz ponownie uruchom nasz fragment kodu testowego, a zobaczysz, że wyświetlane są obie wersje:
> >> 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!
Praca z wieloma instancjami
Teraz wiemy, jak efektywnie pracować z Nameko i jak działa skalowanie. Pójdźmy teraz o krok dalej i użyjmy więcej narzędzia z ekosystemu Docker: docker-compose. To zadziała, jeśli wdrażasz na pojedynczym serwerze, co zdecydowanie nie jest idealne, ponieważ nie wykorzystasz wielu zalet architektury mikrousług. Ponownie, jeśli chcesz mieć bardziej odpowiednią infrastrukturę, możesz użyć narzędzia do orkiestracji, takiego jak Kubernetes, do zarządzania rozproszonym systemem kontenerów. Więc śmiało zainstaluj docker-compose.
Ponownie, wszystko, co musimy zrobić, to wdrożyć instancję RabbitMQ, a Nameko zajmie się resztą, biorąc pod uwagę, że wszystkie usługi mają dostęp do tej instancji RabbitMQ. Pełny kod źródłowy tego przykładu jest dostępny w tym repozytorium GitHub.
Zbudujmy prostą aplikację podróżną, aby przetestować możliwości Nameko. Aplikacja ta umożliwia rejestrację lotnisk i wycieczek. Każde lotnisko jest po prostu przechowywane jako nazwa lotniska, a podróż przechowuje identyfikatory lotniska początkowego i docelowego. Architektura naszego systemu wygląda następująco:
W idealnym przypadku każda mikrousługa miałaby własne wystąpienie bazy danych. Jednak dla uproszczenia stworzyłem pojedynczą bazę danych Redis, którą można udostępniać zarówno mikrousługom Trips, jak i Airports. Mikrousługa Gateway będzie odbierać żądania HTTP za pośrednictwem prostego interfejsu API podobnego do REST i używać RPC do komunikacji z lotniskami i podróżami.
Zacznijmy od mikrousługi Gateway. Jego struktura jest prosta i powinna być dobrze znana każdemu, kto wywodzi się z frameworka takiego jak Flask. Zasadniczo definiujemy dwa punkty końcowe, z których każdy umożliwia zarówno metody GET, jak i 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
Przyjrzyjmy się teraz usłudze Lotniska. Zgodnie z oczekiwaniami udostępnia dwie metody RPC. Metoda get
po prostu prześle zapytanie do bazy danych Redis i zwróci lotnisko dla podanego identyfikatora. Metoda create
wygeneruje losowy identyfikator, przechowa informacje o lotnisku i zwróci identyfikator:
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
Zwróć uwagę, jak używamy rozszerzenia nameko_redis
. Spójrz na listę rozszerzeń społeczności. Rozszerzenia są implementowane w sposób wykorzystujący iniekcję zależności. Nameko dba o zainicjowanie rzeczywistego obiektu rozszerzenia, z którego będzie korzystał każdy pracownik.
Nie ma dużej różnicy między mikrousługami Airports i Trips. Oto jak wyglądałby mikroserwis 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
Plik Dockerfile
dla każdej mikrousługi jest również bardzo prosty. Jedyną zależnością jest nameko
, aw przypadku usług Airports i Trips istnieje również potrzeba zainstalowania nameko-redis
. Te zależności są podane w requirements.txt
w każdej usłudze. Plik Dockerfile dla usługi Airports wygląda następująco:
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"]
Jedyną różnicą między tym a plikiem Dockerfile dla innych usług jest plik źródłowy (w tym przypadku airports.py
), który należy odpowiednio zmienić.
Skrypt run.sh
zajmuje się oczekiwaniem do RabbitMQ, aw przypadku usług Airports i Trips baza Redis jest gotowa. Poniższy fragment przedstawia zawartość run.sh
dla lotnisk. Ponownie, w przypadku innych usług po prostu zmień aiports
na gateway
lub trips
:
#!/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
Nasze usługi są teraz gotowe do uruchomienia:
$ docker-compose up
Przetestujmy nasz system. Uruchom polecenie:
$ 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
Ta ostatnia linia to wygenerowany identyfikator naszego lotniska. Aby sprawdzić, czy działa, uruchom:
$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
Teraz mamy dwa lotniska, to wystarczy, żeby zorganizować wycieczkę. Stwórzmy teraz podróż:
$ 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
Tak jak poprzednio, ostatni wiersz reprezentuje identyfikator podróży. Sprawdźmy, czy został włożony poprawnie:
$ curl localhost:8000/trip/34ca60df07bc42e88501178c0b6b95e4 {"trip": "{'from': 'f2bddf0e506145f6ba0c28c247c54629', 'to': '565000adcc774cfda8ca3a806baec6b5'}"}
Streszczenie
Widzieliśmy, jak działa Nameko, tworząc lokalnie działającą instancję RabbitMQ, łącząc się z nią i wykonując kilka testów. Następnie wykorzystaliśmy zdobytą wiedzę do stworzenia prostego systemu wykorzystującego architekturę mikroserwisów.
Pomimo tego, że jest niezwykle prosty, nasz system jest bardzo zbliżony do tego, jak wyglądałoby wdrożenie gotowe do produkcji. Najlepiej byłoby użyć innego frameworka do obsługi żądań HTTP, takiego jak Falcon lub Flask. Obie są świetnymi opcjami i można je łatwo wykorzystać do tworzenia innych mikrousług opartych na protokole HTTP, na przykład na wypadek, gdybyś chciał zepsuć usługę Gateway. Flask ma tę zaletę, że ma już wtyczkę do interakcji z Nameko, ale możesz używać nameko-proxy bezpośrednio z dowolnego frameworka.
Nameko jest również bardzo łatwe do przetestowania. Nie omówiliśmy tutaj testowania dla uproszczenia, ale sprawdź dokumentację testowania Nameko.
Mając wszystkie ruchome części w architekturze mikrousług, chcesz mieć pewność, że masz solidny system rejestrowania. Aby go zbudować, zobacz Python Logging: An In-Depth Tutorial autorstwa innego Toptalera i programisty Pythona: Son Nguyen Kim.