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
- Nema load balancera
- Nema mogućnosti skaliranja
- Ograničeno na jedan Docker host
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:
- Dodati u
pom.xml
datoteku sljedeće dependency-je:
<dependency>
<groupId>dev.jerkic.custom-load-balancer</groupId>
<artifactId>client</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
- Dodati u
application.properties
datoteku sljedeće konfiguracije:
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
- Dodati u
@SpringBootApplication
anotaciju sljedeće konfiguracije:
@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
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:
- prije slanja zahtjeva dohvaća najbolju instancu servisa
- mijenja URI u zahtjevu na URI najbolje instance
- dodaje zaglavlja
X-Load-balanced
iX-LB-instance
u odgovor
@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.