Skip to content

Izrada vlastitog load balancera i service discovery sustava

Izrada vlastitog load balancera i service discovery sustava

Uvod

Za temu projekta na fakultetu odabrao sam istraživanje postojećih riješenja load balancing-a i service discovery sustava te izradu vlastitog rješenja. U ovom članku ću opisati koje sam tehnologije istražio i kako su me te tehnologije inspirirale za izradu vlastitog rješenja.

Postojeća riješenja

1. Docker

Za najjednostavniji primjer service discovery sustava, uzeo sam Docker networking. Docker networking omogućuje da se kontejneri međusobno vide preko imena kontejnera. U repozitoriju projekta sam napravio primjer korištenja Docker networking-a.

U tom primjeru sam napravio dva servisa: docker-server i docker-client. Oba servisa su Java aplikacije koje koriste Spring Boot te njihovi Dockerfile-ovi izgledaju ovako:

FROM openjdk:21-slim
COPY target/*-0.0.1-SNAPSHOT.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

U docker-compose.yml datoteci sam definirao jednu instancu docker-server servisa i tri instance docker-client servisa. Pošto Docker networking omogućuje da se kontejneri međusobno vide preko imena kontejnera, a ime kontjerera mora biti jedinstveno, to znači da imam zapravo “četiri servisa”, svaki sa svojim imenom.

services:
  docker-server:
    image: docker-echo-server
    networks:
      - echo-network
  docker-client1:
    image: docker-echo-client
    depends_on:
      - docker-server
    environment:
      - TARGET_SERVER_ADDRESS=http://docker-server:8080
    networks:
      - echo-network
  docker-client2:
    image: docker-echo-client
    depends_on:
      - docker-server
    environment:
      - TARGET_SERVER_ADDRESS=http://docker-server:8080
    networks:
      - echo-network
  docker-client3:
    image: docker-echo-client
    depends_on:
      - docker-server
    environment:
      - TARGET_SERVER_ADDRESS=http://docker-server:8080
    networks:
      - echo-network
networks:
  echo-network:
    driver: bridge

Prednosti

Ovim pristupom smo dobili samo service discovery sustav, ali ne i load balancer. Servis docker-server ne mora imati otvoren port jer smo ga ograničili na komunikaciju samo unutar naše Docker mreže. Servis docker-client1, koji se nalazi u istoj mreži kao i docker-server, može komunicirati sa docker-server servisom preko imena kontejnera.

To znači da docker-client1 može pozvati docker-server preko http://docker-server:8080 te Docker networking automatski preusmjerava taj poziv na pravu IP adresu i port. Port 8080 je unutarnji port, te on nije otvoren prema host mašini ako mi to izričito ne zatražimo.

Ovo je dosta jednostavno riješenje koje može biti dovoljno za neke jednostavnije primjene. Bitno je upoznati se s ovim riješenjem jer je osnova za mnoge druge, naprednije sustave.

Mane

2. Netflix Eureka

Eureka je service discovery sustav koji je razvijen od strane Netflixa. Ideja Eureka servisa je da postoji service discovery server i klijenti. Klijenti se registriraju na centralni server pri čemu prijavljuju ime servisa i IP adresu te port na kojem slušaju.

Nakon registracije, Eureka server periodički provjerava da li su servisi još uvijek dostupni i jesu li “zdravi”.

Spring cloud ima jako dobru integraciju sa Eureka servisom koju sam ja koristio u primjeru. U repozitoriju projekta sam napravio primjer korištenja Eureka servisa.

U tom primjeru sam napravio tri servisa: eureka-server, echo-server i echo-client.

eureka-server je Spring Boot aplikacija koja služi kao centralni Eureka server. Sve aplikacije koje žele biti dio naše mreže moraju vidjeti ovaj servis, registrirati se na njega te omogućiti da ga Eureka server može periodički kontaktirati.

echo-server i echo-clinet su aplikacije koje se registriraju na Eureka server te koriste njegove mogućnosti pronalaska drugih servisa. Za ova dva servisa sam postavio da slušaju port 0, što znači da će Spring Boot automatski odabrati slobodan port. To mi omogućava da pokrenem više instanci istog servisa na istom hostu te svaki od njih sluša na različitom portu.

U mom primjeru echo-client treba dohvatiti resurs koji se nalazi na echo-server-u.

Ako koristimo samo Eureka klijent library, onda da bi dohvatili lokaciju servisa echo-server-a, moramo to ovako napraviti:

@Service
public class EchoClientService {
    @Autowired
    @Lazy
    private EurekaClient eurekaClient;
    private RestTemplate restTemplate = new RestTemplate();


    public ServerResponseType getEchoServerResource() {
        var server = this.eurekaClient.getNextServerFromEureka("echo-server", false);
        //   ^- InstanceInfo(host, port, ...)
        //
        // OR
        var options = this.eurekaClient.getApplication(this.serverName).getInstances();
        //   ^- List<InstanceInfo>

        return this.restTemplate.getForEntity(String.format("http://%s:%s/",
            server.getHostName(),
            server.getPort()), ServerResponseType.class);
    }

}

Kao što se vidi u primjeru, da bi pristupili echo-server-u, moramo koristiti EurekaClient te pozvati jednu od metoda koje nam daju informacije o servisu. Eureka drži lokalni cache svih servisa koje je registrirao, te mi možemo dohvatiti sve instance servisa echo-server pozivom getInstances() metode.

Odabir najbolje instance nije ugrađen u Eureka klijent library, već je na nama da to napravimo. Ako se koristi metoda getNextServerFromEureka, onda Eureka koristi round-robin metodu.

Da bi se maksimalno iskoristile mogućnosti Eureka servisa, koristi se Ribbon i Feign library. Ova dva library-ja omogućuju nam client-side load balancing. Pomoću njih možemo prenijeti dio odgovornosti load-balancing-a sa servera na klijenta. Koristeći Ribbon, možemo namjestiti da ako slučajno u trenutku poziva servisa instanca koju smo dobili nije dostupna, Ribbon će pokušati pozvati drugu instancu.

3. Reverse proxy sustavi

Reverse proxy sustavi su sustavi koji se nalaze između klijenta i poslužitelja. Klijent radi HTTP zahtjev prema poslužitelju, zahtjev dolazi do reverse proxy servera koji će na na osnovu domene, protokola, URL-a ili nekog drugog kriterija preusmjeriti zahtjev na odgovarajući poslužitelj. Ovi sustavi često imaju i mogućnost load-balancinga koristeći round-robin metodu.

Reverse proxy sustavi koje sam koristio u sklopu ovog projekta su Nginx i Caddy.

Nisam smatrao potrebnim postaviti primjere korištenja ovih sustava jer sam moramo samo namještati konfiguracijske datoteke.

Jednostavnijim za korištenje sam smatrao Caddy jer ima mogućnost automatskog dobivanja i postavljanja SSL certifikata. Caddy sam namjestio na svoj VPS server na kojem sam deploy-ao primjer vlastite implementacije load balancera i service discovery sustava (kojega ćemo sljedećeg proći).

Na svom DNS serveru sam postavio A record koji preusmjeruje sve domene oblika *.jerkic.dev na moj VPS server. Na VPS serveru sam pokrenuo Caddy server koji sluša na portu 443 te automatski dobiva i postavlja SSL certifikate za sve domene koje su mu dostupne.

Dalje sam morao samo izmjeniti konfiguracijsku datoteku da podomenu service-discovery.jerkic.dev preusmjeri na localhost:8080 i to je to. Jako jednostavno, jako brzo.

Vlastito rješenje

U izradi vlastitog rješenja sam se inspirirao sa svim gore navedenim rješenjima. Moj sustav se fokusira samo na poslužitelje koje koriste SpringBoot framework. Cilj je bio napraviti sustav koji s minimalnom konfiguracijom omogućuje funkcionalnosti load balancinga, service discovery-ja i reverse proxy-ja.

Konfiguracija servisa

Servis za koji želimo da bude dio našega sustava morao bi napraviti sljedeče korake:

<dependency>
    <groupId>dev.jerkic.custom-load-balancer</groupId>
    <artifactId>client</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>
spring.application.name=example-server-1 # ime servisa
server.port=0 # slučajno odabrani port
server.servlet.context-path=/example-server-1 # context path za reverse proxy

discovery-client.discoveryServerUrl=http://localhost:8080 # adresa discovery servera
discovery-client.serviceName=${spring.application.name} # ime servisa
@SpringBootApplication(
    scanBasePackages = {
      "dev.jerkic.custom_load_balancer",
    })
public class ExampleServer1Application {

  public static void main(String[] args) {
    SpringApplication.run(ExampleServer1Application.class, args);
  }
}

S ovim koracima, servis će se automatski registrirati na discovery server te će biti dostupan preko reverse proxy-ja.

Arhitektura sustava

Arhitektura

U sljedećim točkama ću objasniti zašto kako izrađeni susustav vrijedi kao rješenje za load balancing, service discovery i reverse proxy.

Service discovery

Prilikom pokretanja servisa, servis se registrira na discovery server. Library koji servis mora koristiti registrira DiscoveryServiceConfiguration bean koji se brine za registraciju servisa na discovery server.

  /** Register on every startup of server */
  @PostConstruct
  public void register() {
    var registerInput =
        RegisterInput.builder()
            .serviceInfo(this.getServiceInfo())
            .serviceHealth(this.getServiceHealth())
            .build();

    try {
      var instanceId = this.clientHealthService.registerService(registerInput);
      log.info("Registered service, id is {}", instanceId);
    } catch (Exception e) {
      log.error("Error registering service. Going to after 10s");

      Executors.newSingleThreadScheduledExecutor()
          .schedule(
              () -> {
                log.info("Retrying registration");
                register();
              },
              10,
              TimeUnit.SECONDS);
    }
  }

Metoda getServiceInfo prikuplja podatke iz konfiguracije, kao što su ime servisa, base path, itd. Metoda getServiceHealth prikuplja podatke o zdravlju servisa, među kojima je najbitniji broj trenutno aktivnih zahtjeva.

Nakon uspješne registracije, servis dobije instanceId koji servis dalje koristi za ažuriranje podataka o zdravlju te instance.

U slučaju da servis nije uspio registrirati, servis će se ponovno pokušati registrirati nakon 10 sekundi. I tako u krug dok se ne uspije registrirati.

Po uspješnoj registraciji, poslužitelj registrira da postoji servis imena XYZ na sa base pathom /XYZ na portu 1234. IP adresa i port na kojemu instanca sluša su podaci povezani uz instancu, a ne uz servis.

Port se šalje kao podatak u serviceHealth, a IP adresa se automatski zaključuje iz TCP konekcije.

Važno je napomenuti da je sustav rađen s pretpostavkom da servisi i platforma se nalaze u istoj internoj mreži, tj. da mogu komunicirati preko privatnih IP adresa.

Na platformu je nakon ovoga moguće poslati upit za servisom imena XYZ te će platfoma moći prepoznati da ima N različitih instanci servisa XYZ te će moći odabrati jednu od njih.

Nakon uspješne registraciije, servis će periodički slati podatke o zdravlju na discovery server.

  // Define cron job for every 1 min
  @Scheduled(fixedRate = 60000)
  public void updateHealth() {
    try {

      var oInstanceId = this.clientHealthService.getInstanceId();
      if (oInstanceId.isEmpty()) {
        log.warn("Service not registered");
        return;
      }
      var instanceId = oInstanceId.get();

      var healthStatus =
          HealthUpdateInput.builder()
              .instanceId(instanceId)
              .serviceName(this.clientProperties.getServiceName())
              .health(this.getServiceHealth())
              .build();

      log.info("Updating health: {}", healthStatus);

      this.clientHealthService.updateHealth(healthStatus);
    } catch (Exception e) {
      log.error("Error updating health", e);
      log.warn("Trying to register again after failed health udpate");
      this.register();
    }
  }

Load balancing

Kada platforma dobije upit za servisom, platforma će odabrati jednu od instanci servisa. Resolvanje najbolje instance se radi sa sljedećim SQL upitom:

SELECT
    s.entry_id,
    s.service_model_id as service_id,
    max(s.instance_recorded_at) as latest_timestamp
FROM
    service_instance s
WHERE
    s.is_healthy = 1
    AND (strftime('%s', 'now') * 1000 - s.instance_recorded_at) <= (3*60*1000)
GROUP BY
    s.instance_id

Upit uzima sve instance servisa koje su zdrave i koje su se registrirale u zadnjih 3 minute. Prilikom ažuriranja zdravlja servisa, servis šalje podatke o zdravlju na discovery server. Što znači da za jedan service_id i instance_id, može postojati više redaka u tablici service_instance. Zadnji redak koji je unesen za jedan service_id i instance_id se pronalazi korištenjem max(s.instance_recorded_at) as latest_timestamp.

Nakon što su preuzeti podaci o dostupnim instancama servisa, ti se podaci spremaju u cache oblika ConcurrentHashMap<String, PriorityQueue<UsedResolvedInstance>>, gdje je UsedResolvedInstance klasa koja sadrži podatke o instanci servisa i broju trenutno aktivnih zahtjeva. Cache se ažurira svakih 10 sekundi.

Prilikom odabira najbolje instance poll metodom iz PriorityQueue se uzima instanca s najmanjim brojem trenutno aktivnih zahtjeva. Nakon što se instanca uzme iz PriorityQueue, broj trenutno aktivnih zahtjeva se poveća za jedan u cache-u. Platforma ne upisuje taj podatak u bazu podataka, nego samo privremeno u cache. Stvarni broj trenutno aktivnih zahtjeva se ažurira u redovnom health update-u koji sami servis šalje na discovery server svakih 60 sekundi.

Klasa LoadBalancingService nam vraća najbolju dostupni instancu za određeni servis prema nazivu servisa ili prema base pathu registriranog servisa. Tu informaciju može koristiti reverse proxy sustav ili sami servis koristeći ProxyRestTemplate klasu.

Reverse proxy

Service discovery server definira par kontrolera koji služe za dohvaćanje podataka o servisima i instancama servisa. Njihove putanje su prefiksirane s /health i /register, što znači da su te dvije putanje rezervirane i ne mogu se koristiti u svrhe reverse proxy-ja.

Svi ostali zahtjevi koji ne počinju s /health i /register su uhvaćeni u sljedećem kontroleru:

@RestController
@RequiredArgsConstructor
@Slf4j
public class ProxyController {
  private final ProxyRestTemplate restTemplate;

  @RequestMapping("/**")
  public ResponseEntity<?> proxy(HttpServletRequest request) throws IOException {
    var requestedPath = request.getRequestURI();

    log.debug("Requested path: {}", requestedPath);

    var requestEntity = RequestEntityConverter.fromHttpServletRequest(request);
    return this.restTemplate.exchange(requestEntity, String.class);
  }
}

RequestEntityConverter klasa konvertira HttpServletRequest u RequestEntity koji se koristi u RestTemplate klasi.

Napravljena je klasa ProxyRestTemplate koja ekstenda RestTemplate klasu dodavajući interceptor.

@Component
public class ProxyRestTemplate extends RestTemplate {
  @Autowired
  public ProxyRestTemplate(LoadBalancingService loadBalancingService) {
    super();
    this.setInterceptors(List.of(new LoadBalancingHttpRequestInterceptor(loadBalancingService)));
  }
}

LoadBalancingHttpRequestInterceptor klasa je interceptor koji:

@RequiredArgsConstructor
@Slf4j
public class LoadBalancingHttpRequestInterceptor implements ClientHttpRequestInterceptor {
  private final LoadBalancingService loadBalancingService;

  @Override
  public ClientHttpResponse intercept(
      HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {

    // Odabir najbolje instance
    var bestInstance =
        this.loadBalancingService.getBestInstanceForBaseHref(request.getURI().toString());

    if (bestInstance.isEmpty()) {
      throw new NoInstanceFoundException(
          "No instance found for the given base href " + request.getURI());
    }

    var uri = this.getProxiedUriFromOriginal(bestInstance.get().uri(), request);

    // Rekreiranje zahtjeva
    HttpRequest newRequest =
        new HttpRequestImplementation(
            uri,
            request.getURI().toString(),
            request.getHeaders(),
            request.getMethod(),
            request.getAttributes());
    // Slanje zahtjeva na najbolju instancu
    var result = execution.execute(newRequest, body);

    // Dodavanje zaglavlja za meta podatke
    var responseHeaders = result.getHeaders();
    responseHeaders.add("X-Load-balanded", "true");
    bestInstance.ifPresent(
        instance -> {
          responseHeaders.add("X-LB-instance", instance.instanceId());
        });

    // Povratak odgovora
    return result;
  }

  private URI getProxiedUriFromOriginal(String bestInstanceUri, HttpRequest request) {
    try {
      var query = request.getURI().getQuery() == null ? "" : "?" + request.getURI().getQuery();

      var realRequestUri = bestInstanceUri + request.getURI().getPath() + query;
      log.info("Sending request to {}", realRequestUri);
      return new URI(realRequestUri);
    } catch (URISyntaxException e) {
      log.error("Error building real uri", e);
      throw new RuntimeException(e);
    }
  }
}

Demo

Na primjeru iz videa iznad (koji je dostupan tu) možemo vidjeti primjer rada sustava.

U primjeru se nalazimo na stranici detalja servisa example-server-1. Taj servis ima dvije dostupne instance. Svakoj instanci se dodjeljuje boja radi lakšeg prepoznavanja.

S desne strane liste instanci se nalazi lista elemenata koji šalju zahtjeve na /example-server-1/test i /example-server-1/slow-request putanje svakih 5 sekundi. Pošto nije navedena domena, zahtjevi se šalju na trenutnu domenu, tj. domenu reverse proxy-ja.

Zahtjevi kao odgovor dobivaju neki HTML sadržaj koji se zaljepi u odgovarajući element. Primjer je napravljen koristeći HTMX. U headeru svakog odgovora se nalazi X-LB-instance zaglavlje koje sadrži instanceId instance koja je odgovorila na zahtjev. Tu informaciju koristimo za bojanje odgovora u boju instance koja je odgovorila.

Tako nam je jasno vidljivo kako se zahtjevi raspoređuju na različite instance servisa.

Zaključak

U ovom članku sam opisao kako sam istražio postojeća riješenja za load balancing i service discovery te kako sam se inspirirao s njima za izradu vlastitog rješenja.

Cijeli source code projekta je dostupan na GitHubu. Primjer sustava je deployan na service-discovery.jerkic.dev.