Service Discovery i Load Balancing z Consul i Fabio

Kontynuujemy naszą podróż z mikroserwisami! Dziś przejdziemy do nieco bardziej „zaawansowanych” zagadnień, które mam nadzieje okażą się dla Ciebie zrozumiałe, a ich użycie – zasadne. Zacznijmy od problemu, aby móc w ogóle przejść do tematu dzisiejszego wpisu. Jak zapewne pamiętasz we wpisie o odczycie danych przedstawiłem bibliotekę RestEase, dzięki której w łatwy sposób mogliśmy wykonywać żądania HTTP z API Gateway do konkretnej usługi. Konfiguracja przykładowego klienta wyglądała następująco:

 


{
  "name": "products-service",
  "host": "localhost",
  "scheme": "http",
  "port": "5006"
}

 

Dzięki umieszczeniu takiego wpisu w pliku appsettings.json mogliśmy w łatwy sposób zbudować model C#, który posłużył do konfiguracji konkretnego HttpClient-a używanego przez RestEase. W powyższym przykładzie widzisz, że usługa odpowiedzialna za produkty będzie dostępna pod adresem localhost:5006. Pytanie brzmi – czy jest to jedyny sposób na przechowywanie informacji o adresach konkretnych usług? Czy jest on w ogóle dobre podejście? Przeanalizujmy to przez chwilę.

 

W jaki sposób przechowywać informacje o usługach?

Zacznijmy od podejścia, dla którego próg wejścia jest najmniejszy. Będzie to nic innego jak „zahardkodowanie” na sztywno konkretnych adresów usług przy tworzeniu klienta RestEase. Przykładowo:

 


        private readonly IProductsService _productsService;
        
        public SampleController()
        {
            _productsService = RestClient.For<IProductsService>("http://localhost:5006");
        }

 

Podejście jest bardzo proste, które nie wymagało od nas żadnego, dodatkowego kodu. Brak tu ustawień w appsettings.json, brak klasy C#, które ów ustawienia reprezentuje, brak konfiguracji mapowania JSON-> C# oraz brak wstrzykiwania ustawień do konstruktora kontrolera. Niestety brak tu także jakiejkolwiek elastyczności. Jasne, na potrzeby lokalnego uruchamiania kodu wszystko będzie działać jak należy, ale co w momencie gdy chcielibyśmy wdrożyć nasze rozwiązanie na środowisko testowe? Ponieważ adres usługi został umieszczony w kodzie źródłowym naszej aplikacji oznacza to, że jakakolwiek jego zmiana będzie wymagała ponownej kompilacji kodu i dopiero wdrożenia. Jaki sens ma zatem tworzenie np. 3 plików *dll naszej aplikacji, które wewnątrz różnią się jedynie kilkoma znakami?

Możemy zatem wyciągnąć wnioski z naszej wpadki i zmienić podejście w taki sposób, aby ustawienia dla konkretnych usług nie zawierały się w kodzie C#, a były wczytywane do aplikacji z zewnątrz. I takie podejście przedstawiłem na początku tego wpisu. Jaki jest jego podstawowa zaleta? Każde pole, które znajduje się w pliku appsettings.json możemy nadpisać np. w naszym build serwerze dzięki czemu różne środowiska mogą otrzymać swoje ustawienia. Przykładowo:

  • programista korzysta z appsettings.json, w którym używa localhost
  • po wdrożeniu na środowisko testowe ustawienia hosta zmieniają się tak, aby kierowały do kontenera w sieci Docker-owej
  • po wdrożeniu na środowisko produkcyjne ustawienia hosta, portu i schematu zmieniają się tak, aby kierowały na publiczny adres load balancera skonfigurowanego np. w środowisku chmurowym

Widzisz zatem, że to podejście jest o niebo lepsze od pierwszego. Jeżeli jesteśmy w stanie podmieniać adresy wedle uznania, a to wszystko bez kompilacji kodu (wymagany jedynie restart aplikacji) to chyba jest to rozwiązanie kompletne? Powiedziałbym, że na pewnym poziomie jest to rozwiązanie dostateczne, ponieważ zakładając stosunkowo niedużą liczbę usług, których adresy nie zmieniają się dynamicznie to rozwiązanie nie będzie trudne do utrzymania.

Pomyśl jednak co w przypadku gdyby usług było kilkadziesiąt, a ty zmieniłbyś/łabyś adres jednej z nich? Zarówno API Gateway jak i każda usługa, która komunikuje się z nią poprzez HTTP musiałaby mieć zmienione ustawienia, a następnie zostać zrestartowana celem ich wczytania. Pytanie, dlaczego nie moglibyśmy pobrać odświeżonych ustawień w runtime zamiast robić restart? Ponadto, dlaczego to API i inne usługi mają byś odpowiedzialne za posiadanie najnowszej konfiguracji? Dlaczego każda usługa nie może być odpowiedzialna za opublikowanie swoich, aktualnych ustawień, które byłyby widoczne dla innych?

 

Service discovery z Consul

Service discovery doskonale wpisuje się w nasz aktualny problem. Idea jest bardzo prosta – każda usługa działająca w ramach naszej aplikacji jest odpowiedzialna za umieszczenie swojej aktualnej konfiguracji w rejestrze. Ów rejestr może następnie zostać odpytany celem pozyskania potrzebnych ustawień. Wyróżnia się dwa rodzaje tego podejścia:

  • client-side service discovery (później w tekście CSSD)- klient odpytuje rejestr dane usługi, a następnie to on musi dokonać load-balancingu (jeżeli zwróconych zostanie kilka rekordów dla kilku instancji tej samej usługi).
  • server-side service discovery(później w tekście SSSD)- klient nie odpytuje bezpośrednio rejestru. Zamiast tego kieruje zapytanie do load balancera, a ten korzystając z rejestru wybiera instancję, do której kieruje żądanie.

Nie przejmuj się jeżeli nie jest to dla Ciebie jeszcze do końca zrozumiałe. Przejdziemy przez obydwa zagadnienia zaczynając od client-side przy wykorzystaniu Consul-a od HashiCorp. Zacznijmy od prostego schematu, który mam nadzieję w prosty sposób zilustruje co zaraz poczynimy w kodzie:

 

 

Schemat ten jest nieco uproszczony, ale oddaje ideę CSSD. Podczas uruchomienia usługa zapisze w rejestrze Consula swoje „dane kontaktowe” (tj. host ,port, schemat) pod zadanym kluczem np. „products-service”. Oprócz samego klucza zostanie także przekazany ID, który będzie już referował do konkretnej instancji usługi w przypadku gdyby ta była przeskalowana. Ponieważ samo ID zawiera w sobie GUID-a  mamy gwarancję, że każda nowa instancja posiadać będzie unikalny identyfikator. Proces rejestracji jest oczywiście przeprowadzony przez każdą usługę w naszym systemie. Teraz w przypadku gdy API Gateway otrzyma żądanie HTTP GET np. o pobranie produktów, nie będzie musiało tworzyć klienta RestEase w oparciu o ustawienia zapisane w appsettings.json.  Zamiast tego odpyta rejestr o aktywne instancje usługi podając jedynie klucz (który musi oczywiście przechowywać lokalnie) czyli w tym przypadku „products-service”.  W zależności od tego ile instancji jest aktywnych, tyle elementów zawierać będzie odpowiedź z rejestru. Dalej schemat jest już ten sam, ponieważ API może utworzyć klienta RestEase bazując już na danych otrzymanych z rejestru Consul-a. Tak jak wspomniałem problem restartowania aplikacji tutaj nie występuje, ponieważ ustawienia są pobierana w trakcie działania aplikacji. Ponadto API Gateway nie jest już zobligowane do przetrzymywania aktualnych danych konkretnych usług. Po teorii czas na praktykę!

Projekt, którego kod zaraz przedstawię znajdziesz na moim Githubie. Celowo nie pokazuję całej integracji w naszym projekcie DShop, ponieważ tam jest ona nieco bardziej zagmatwana i mogłaby dla niektórych stanowić zbyt duży próg wejścia. Wobec tego pozwól, że przejdziemy przez cały proces na prostej aplikacji-demo, a jeżeli zechcesz to sam/a sprawdzisz w jaki sposób zostało to wpięte do infrastruktury DShopa. Samo demo składa się tak na prawdę z dwóch aplikacji ASP.NET Core (Web API). Jedna z nich imituje API Gateway, a druga mikrousługę. Ponadto znajdziesz tam plik docker-compose, dzięki któremu w szybki sposób uruchomisz zarówno Consula jak i Fabio. Rozpocznijmy zatem od jego uruchomienia, aby infrastruktura była już gotowa. W tym celu będąc w terminalu w katalogu compose  wykonaj następujące polecenie:

 


docker-compose -f docker-compose.yaml up -d

 

Po krótkiej chwili wszystko powinno być już gotowe. Przechodząc pod adres localhost:8500  zobaczysz UI Consula:

 

 

Przejdźmy teraz do kodu zaczynając od mikrousługi. W pierwszym kroku dodajmy referencję do paczki NuGet-owej w pliku *csproj:

 


<PackageReference Include="Consul" Version="0.7.2.6" />

 

Następnie przejdźmy do Startup.cs gdzie dokonamy rejestracji naszej usługi:

 


        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }
       
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
        }
       
        public void Configure(IApplicationBuilder app, IApplicationLifetime appLifetime, IHostingEnvironment env)
        {
            var client = new ConsulClient();

            var serviceId = Guid.NewGuid().ToString();
            var agentReg = new AgentServiceRegistration()
            {
                Address = "http://localhost",
                ID = serviceId,
                Name = "Microservice1",
                Port = 5001
            };

            client.Agent.ServiceRegister(agentReg).GetAwaiter().GetResult();

            app.UseMvc();

            appLifetime.ApplicationStopped.Register(() => client.Agent.ServiceDeregister(serviceId));
        }
    }

 

Jak widzisz w metodzie Configure utworzona zostaje instancja klienta, poprzez który będziemy rejestrować usługę do Consul-a. Sam obiekt rejestracji zawiera 4 pola:

  • Adress – część hosta adresu URI, pod którym dostępna jest usługa
  • ID – unikalny identyfikator instancji usługi
  • Name – klucz pod którym rejestrujemy konfigurację
  • Port – port, pod którym dostępna jest usługa

 

Następnie rejestracja zostaje wysłana do Consul-a poprzez agenta. Zwróć proszę uwagę, że powyższy kod przewiduje także wypisanie się z rejestru w przypadku gdy aplikacja jest zatrzymywana. To wszystko jeśli chodzi o tę część konfiguracji! Możemy śmiało uruchomić usługę i przejść ponownie do Consul UI:

 

 

W rejestrze pojawił się nowy wpis. Kliknijmy na niego, aby zobaczyć szczegóły:

 


Jak widzisz Consul poprawnie stworzył adres usługi, a także przechowuje informacje o identyfikatorze instancji (GUID na dole). Na tym etapie mamy już kod odpowiedzialny za umieszczanie konfiguracji do rejestru. Pora napisać kod odpowiedzialny za jego wydobycie w  API Gateway. Rozpoczynamy od instalacji paczki Consul-owej, a następnie przechodzimy do domyślnie utworzonego kontrolera MVC tj. ValuesController:

 


    [Route("api/[controller]")]
    public class ValuesController : Controller
    {
        private readonly IMicroservice _microservice;

        public ValuesController()
        {
            var query = new ConsulClient().Catalog.Service("Microservice1").GetAwaiter().GetResult();
            var instance = LoadBalance();    
            

            var host = $"{instance.ServiceAddress}:{instance.ServicePort}";

            _microservice = RestClient.For<IMicroservice>(host);

            CatalogService LoadBalance()
                => query.Response.First();
        }

        // GET api/values
        [HttpGet]
        public async Task<IEnumerable<string>> Get()
            => await _microservice.GetValuesAsync();
    }

 

W konstruktorze kontrolerze  zostaje utworzony ten sam klient co uprzednio w mikrousłudze. Następnie poprzez właściwość Catalog odpytujemy rejestr o dane usługi zarejestrowanej pod kluczem „Microservice1”. Rezultatem tej operacji jest obiekt QueryResult<CatalogService[]> który przekazany zostaje do lokalnej funkcji imitującej load balancing. W środku wydobyta zostaje tablica konfiguracji (ponieważ pod jednym kluczem może być zapisanych N instancji usługi) i zwrócona zostaje pierwsza. Posiadając już ustawienia można stworzyć nazwę hosta potrzebną RestEase-owi. Sam klient IMircorservice nie robi nic innego jak wykonuje żądanie HTTP pod domyślnie dostępny adres URL aplikacji ASP.NET Core tj. /api/values. Uruchommy obie aplikacje i wywołajmy locahost:5000/api/values:

 

 

Kiedy postawimy breakpoint w konstruktorze zobaczymy, że żądanie faktyczne zostaje przekazane do mikrousługi:

 

 

Oczywiście powyższy przykład wymaga jeszcze dopracowania i pewnego „ubrania” w dodatkowe klasy, aby wyglądało to lepiej, ale na potrzeby demo nie widzę takiej potrzeby.

 

Load balancing z Fabio

Pora rozszerzyć nasz kod tak, aby korzystał z SSSD. Do tego celu potrzebujemy load balancera, który za nas połączy się z rejestrem i przekieruje ruch na wybraną przez siebie instancję. Za load balancer posłuży nam Fabio, czyli produkt stworzony przez firmę eBay, który został stworzony specjalnie do współpracy z Consulem. Zasada jego działania jest bardzo ciekawa. Obrazuje ją poniższy schemat:

 

 

Wszystko zaczyna się ponownie od rejestracji konfiguracji konkretnej usługi w Consulu. Różnica polega jednak na tym, że poza danymi które przekazaliśmy wcześniej (host, port), w konfiguracji zamieszczone zostają również tagi, które zawierają informacje jakie ścieżki są obsługiwane przez konkretną usługę. Na powyższym przykładzie widzisz, że ServiceA deklaruje możność obsługi ścieżek, które zawierają fragment „api/values” oraz „api/user”. Po umieszczeniu ustawień w rejestrze, Fabio tworzy na ich podstawie tzw. routing table, która zawiera informacje jaka ścieżka może być kierowana pod jaki adres. Przykładowo jeżeli do Fabio zostanie skierowane żądanie HTTP localhost:9999/api/users/1 po wglądzie w tablicę będzie wiedzieć, że należy je przekazać do usługi, która w Consulu zarejestrowała konfigurację localhost:5002. Ot cała magia. Ponieważ samo Fabio posłuży nam za load balancer, warto wspomnieć o tym jaki algorytm jest użyty do dystrybucji pomiędzy instancjami. Domyślną konfiguracją jest pseudolosowa dystrybucja, która za ziarno bierze mikrosekundowy ułamek czasu żądania. Można jednak ów ustawienia zmienić na round-robin. W użytym pliku docker-compose znajdziesz zakomentowaną zmienną środowiskową, która za to odpowiada.

Po wstępie teoretycznym możemy przejść do działania. Sam load balancer został już uruchomiony razem z Consulem. Do UI dostaniesz się pod adresem localhost:9998:

 

 

Sama tablica jest jeszcze pusta, ale wkrótce się to zmieni. Przejdź do kodu mikrosuługi, który był odpowiedzialny za wpis do rejestru Consula i lekko go zmodyfikujmy:

 


        public void Configure(IApplicationBuilder app, IApplicationLifetime appLifetime, IHostingEnvironment env)
        {
            var client = new ConsulClient();

            var serviceId = Guid.NewGuid().ToString();
            var agentReg = new AgentServiceRegistration()
            {
                Address = "host.docker.internal",
                ID = serviceId,
                Name = "Microservice1",
                Port = 5001,
                Check = new AgentServiceCheck
                {
                    Interval = TimeSpan.FromSeconds(5),
                    DeregisterCriticalServiceAfter = TimeSpan.FromSeconds(5),
                    HTTP = "http://host.docker.internal:5001/api/values"
                },
                Tags = new[] { "urlprefix-/api/values" }
            };

            client.Agent.ServiceRegister(agentReg).GetAwaiter().GetResult();

            app.UseMvc();

            appLifetime.ApplicationStopped.Register(() => client.Agent.ServiceDeregister(serviceId));
        }

 

Kod różni się od poprzedniego dodaniem dwóch pól. Pierwsze z nich to Check, za pomocą którego możemy w Consulu skonfigurować Healthcheck. Powód dodania jest banalny, Fabio wymaga aby obsługiwane przez niego usługi posiadały healthcheck. Kropka. Sama konstrukcja mówi nam, że usługa ma być sprawdzana co 5 sekund, a odbywa się to poprzez wykonanie żądania HTTP pod adres http://host.docker.internal:5001/api/values. Skąd taka dziwna nazwa? Pamiętaj, że zarówno Consul jak i Fabio uruchomione zostały w kontenerach Docker-owych natomiast API i mikrousługa poprzez dotnet CLI. W związku z tym, podanie healthchecka jako http://localhost:5001/api/values nie zadziałałoby, ponieważ localhost referowałby do tego z wewnątrz kontenera. W tym przypadku możemy skorzystać ze specjalnego DNS-a (host.docker.internal) dzięki któremu żądanie HTTP zostanie przekazane na „nasz” localhost. Oczywiście w przypadku gdyby zarówno API jaki mikrousługa znajdowały się również w kontenerach i w tej samej sieci ten zabieg nie byłby potrzebny, a w części hosta adresu moglibyśmy użyć po prostu nazw kontenerów.

Oprócz samego healthchecka nasza rejestracja zawiera również wspomniany wcześniej tag urlprefix-/api/values.  Ponieważ w samej usłudze znajduje się jedynie domyślny ValuesController to inne ścieżki niż api/values nie są wspierane. To wszystko, wystarczy uruchomić kod i przejść do UI Consula:

 

 

Zwróć uwagę, że nasza usługa posiada zdefiniowany tag, a także ilość checków zmieniła się z jednego na dwa. To za sprawą zdefiniowanego pola Check. Jeżeli przejdziemy do szczegółów  możemy zobaczyć, że faktycznie żądanie HTTP jest wykonywane:

 

 

No dobrze, a jak nasz routing table? Przejdźmy do UI Fabio i dowiedzmy się:

 

 

Oto i nasz wpis 🙂 Aktualnie posiadamy jedną instancję wobec czego obciążanie wynosi na niej 100%. Uruchommy lokalnie drugą instancję na porcie 5002 (zmieniając oczywiście kod rejestracji):

 

 

 

Pozostaje pytanie w jaki sposób odpytać usługę poprzez Fabio? Przejdź do API Gateway i zobaczmy:

 


    [Route("api/[controller]")]
    public class ValuesController : Controller
    {
        private readonly IMicroservice _microservice;

        public ValuesController()
            => _microservice = RestClient.For<IMicroservice>("http://localhost:9999");

        // GET api/values
        [HttpGet]
        public async Task<IEnumerable<string>> Get()
            => await _microservice.GetValuesAsync();
    }

 

To wszystko! Nasze API wszystkie żądania będzie kierować do Fabio pod adres localhost:9999. Po otrzymaniu żądania Fabio „podmieni” część hosta zgodnie z routing table. Wykonajmy ponownie żądanie HTTP do API:

 

 

Consul + Fabio + DShop

Wspomniałem o tym, że w przypadku DShopa próg wejścia mógłby być za duży dla niektórych osób. Mam nadzieję, że z nowo nabytą wiedzą to się zmieni 😉 Cały kod odpowiedzialny za konfigurację znajdziesz w projekcie DShop.Common(katalog Consul i Fabio).  Sam wybór strategii tj. wybranie czy RestEase używa appsettings.json, samego Consula czy Consula + Fabio znajduje się w metodzie rozszerzającej tutaj. Po więcej szczegółów zapraszam do obejrzenia odcinka kursu „Distributed .NET Core”:

 

You may also like...