HashiCorp Consul to oprogramowanie dostarczające usługi discovery i konfiguracji, zdolne do łączenia usług w ramach kilku klastrów Kubernetes, nawet wśród różnych dostawców chmury. W artykule z jego pomocą umożliwimy komunikację między dwiema aplikacjami Spring. Całość postawimy na GKE, a provisioning infrastruktury zrobimy z użyciem Terraform. Również za pomocą Terraforma postawimy klaster Consul, używając menedżera pakietów k8s – Helm. Potem przetestujemy, jak wygląda nasza integracja Spring i Consul.
Kod zawarty w tym artykule znajdziecie tutaj.
Discovery i load balancing
W systemach mikroserwisowych, w których repliki tej samej aplikacji mogą często być tworzone i kasowane automatycznie, pojawia się problem propagacji wiedzy o nowych instancjach aplikacji. Nie mamy tego problemu, jeśli po prostu uruchamiamy sobie aplikacje na wirtualnych maszynach i hardkodujemy ich adresy, jednak z użyciem mikroserwisów i discovery uzyskujemy wiele korzyści:
- Szybki update wersji aplikacji na wszystkich instancjach.
- Łatwy upscaling/downscaling.
- Możliwość rozbicia kodu na dowolnie dużo mikroserwisów, ponieważ automatyzując ich wdrożenie, nie ogranicza nas nakład pracy na nie przeznaczony.
Usługa odpowiedzialna za discovery dostarcza adresy, pod którymi można znaleźć daną usługę. Gdy występuje ona w kilku replikach, decyzję, do której skierować request podejmuje load balancing. W przykładzie, który zaraz zaprogramujemy, występują dwa rodzaje load balancerów:
- Network Load Balancer- gdy serwis w Kubernetesie decyduje, którą instancję client-app odpytać,
- Client-Side Load Balancer – gdy client-app otrzymuje od Consula listę instancji server-app i decyduje, którą z nich odpytać.

Infrastruktura z użyciem Terraform i Helm
Na początek postawmy sobie potrzebną infrastrukturę z użyciem Terraforma. Będzie to klaster Kubernetes z czterema node’ami. Zacznijmy od utworzenia w folderze terraform pliku main.tf:
terraform { required_providers { google = { source = "hashicorp/google" version = "3.88.0" } } } provider "google" { project = var.project_id region = var.region zone = var.zone } data "google_client_config" "default" {} resource "google_service_account" "default" { account_id = "k8s-service-account-id" display_name = "K8s Service Account" } resource "google_project_iam_member" "registry_reader_binding" { role = "roles/containerregistry.ServiceAgent" member = "serviceAccount:${google_service_account.default.email}" } resource "google_compute_network" "vpc" { name = "test-network" auto_create_subnetworks = false } resource "google_compute_subnetwork" "vpc_subnet" { name = "test-subnetwork" ip_cidr_range = "10.2.0.0/16" region = var.region network = google_compute_network.vpc.id secondary_ip_range { range_name = "services-range" ip_cidr_range = "192.168.0.0/18" } secondary_ip_range { range_name = "pod-ranges" ip_cidr_range = "192.168.64.0/22" } } resource "google_container_cluster" "vpc_native_cluster" { name = "my-gke-cluster" remove_default_node_pool = true initial_node_count = 1 network = google_compute_network.vpc.id subnetwork = google_compute_subnetwork.vpc_subnet.id ip_allocation_policy { cluster_secondary_range_name = google_compute_subnetwork.vpc_subnet.secondary_ip_range.0.range_name services_secondary_range_name = google_compute_subnetwork.vpc_subnet.secondary_ip_range.1.range_name } cluster_autoscaling { enabled = true resource_limits { resource_type = "cpu" maximum = 8 } resource_limits { resource_type = "memory" maximum = 16 } } } resource "google_container_node_pool" "vpc_native_cluster_preemptible_nodes" { name = "my-node-pool" cluster = google_container_cluster.vpc_native_cluster.name node_count = 4 node_config { preemptible = true machine_type = "e2-medium" service_account = google_service_account.default.email oauth_scopes = [ "https://www.googleapis.com/auth/cloud-platform" ] } timeouts { create = "10m" } } resource "google_compute_global_address" "private_ip" { name = "private-ip-address" purpose = "VPC_PEERING" address_type = "INTERNAL" prefix_length = 16 network = google_compute_network.vpc.id } resource "google_service_networking_connection" "service_vpc_connection" { network = google_compute_network.vpc.id service = "servicenetworking.googleapis.com" reserved_peering_ranges = [google_compute_global_address.private_ip.name] }
Jeśli nie wiesz, jak stawiać infrastrukturę na Google Cloud z użyciem Terraform, koniecznie sprawdź ten artykuł.
Ponieważ w pliku używamy kilku zmiennych, zdefiniujmy je w pliku variables.tf:
variable "project_id" { type = string } variable "region" { type = string default = "europe-central2" } variable "zone" { type = string default = "europe-central2-a" } variable "app_name" { type = string default = "test-app" }
Jeśli odpalimy tą konfigurację Terraforma, będziemy po kilku minutach mieli gotowy klaster kubernetes, na którym możemy deployować nasze aplikacje. Aby postawić Consula, potrzebujemy jeszcze do main.tf dodać providera Helm. Na początek dodajmy w sekcji required_providers:
terraform { required_providers { … helm = { source = "hashicorp/helm" version = "2.5.0" } } }
W następnym kroku konfigurujemy Helma tak, aby łączył się z naszym klastrem Kubernetes:
provider "helm" { kubernetes { token = data.google_client_config.default.access_token host = resource.google_container_cluster.vpc_native_cluster.endpoint cluster_ca_certificate = base64decode(resource.google_container_cluster.vpc_native_cluster.master_auth[0].cluster_ca_certificate) } }
Teraz, dodajmy jeszcze release, zawierający instrukcje dla Helma, jak postawić Consula:
resource "helm_release" "consul" { name = "consul" repository = "https://helm.releases.hashicorp.com" chart = "consul" version = "0.41.1" timeout = 900 depends_on = [ google_container_node_pool.vpc_native_cluster_preemptible_nodes, ] }
Gdy odpalimy powyższą konfigurację w GCP, otrzymamy między innymi 3 instancje consul-server i po jednej instancji consul-client na każdym node. Znajdziemy też utworzone serwisy:
- consul-consul-dns
- consul-consul-server
- consul-consul-ui
Najbardziej interesuje nas ten ostatni, którego adres będziemy podawać Springowi jako adres serwera konfiguracji. Jeśli wywołamy kubectl get services, otrzymamy:
consul-consul-dns ClusterIP ***.***.***.*** consul-consul-server ClusterIP None consul-consul-ui ClusterIP ***.***.***.***
Widać tutaj, że nasz serwis jest typu ClusterIP i nie ma przydzielonego IP, więc jest to tzw. Headless Service. Oznacza to, że skomunikujemy się do niego nie po ip, a po selektorze: consul-consul-server.
Klient i serwer Spring
Aby zobaczyć,jak wygląda integracja Spring i Consul, spróbujmy napisać dwie aplikacje Spring, gdzie jedna (client) będzie komunikowała się z drugą (server), używając do tego serwera discovery. W obu naszych aplikacjach potrzebujemy zależności z Consulem, a także spring-boot-starter-actuator, aby wystawić health check. Poza tym pobierzemy też klienta Feign, pozwalającego pisać deklaratywnie połączenia do innych aplikacji. Dodatkowym plusem Feigna jest fakt, że używa on domyślnie client-side load balancera Ribbon. Co więcej, zintegruje się z naszym discovery bez żadnej dodatkowej konfiguracji. Dodajmy więc w pom.xml:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-consul-discovery</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency>
Jeśli chodzi o konfigurację i czynności związane z konteneryzacją i wdrożeniem aplikacji w Kubernetesie, po raz kolejny odsyłam do artykułu o wdrażaniu aplikacji Spring na Kubernetes.
Konfiguracja
W obu aplikacjach konfigurujemy klienta Consul. Po pierwsze, dodajemy adnotacje dotyczące discovery i Feigna nad którąś z klas konfiguracyjnych (np. nad klasą startową):
@EnableDiscoveryClient @EnableFeignClients @SpringBootApplication public class SpringConsulDiscoveryDemoClientApplication { public static void main(String[] args) { SpringApplication.run(SpringConsulDiscoveryDemoClientApplication.class, args); } }
Po drugie, konfigurujemy Consula:
spring: application.name: client cloud: consul: enabled: false host: consul-consul-server discovery: prefer-ip-address: true instanceId: ${spring.application.name}:${random.value} serviceName: ${spring.application.name} healthCheckPath: ${management.server.servlet.context-path}/health healthCheckInterval: 15s
Robimy tutaj kilka rzeczy:
- Wskazujemy endpoint z health checkiem, który automatycznie wystawia nam Spring Actuator.
- Nadajemy serviceName czyli nazwę serwisu, pod którą będą podpięte instancje naszej aplikacji w Consulu.
- Wyliczamy losowe id instancji aplikacji (instanceId).
- Konfigurujemy hosta, pod którym aplikacja uruchomiona w Kubernetesie znajdzie Consula (consul-consul-server).
Poza tym, wyłączyliśmy Consula w domyślnym profilu. Dlaczego? Otóż nie chcemy, żeby Spring szukał serwera discovery, gdy uruchamia się w innym kontekście, niż deployment – np. gdy budujemy paczkę lub kontener. Dla uruchomienia na Kubernetesie, zdefiniujmy dalej dodatkowy profil, gdzie włączymy discovery:
… --- spring: config.activate.on-profile: deployment cloud.consul.enabled: true
Client
W aplikacji client zdefiniujmy HelloEndpoint, w którym będziemy przekazywać to, co zwróci nam server:
@RestController @RequestMapping("hello") public class HelloEndpoint { @Autowired private ServerClient serverClient; @Value("${spring.cloud.consul.discovery.instanceId}") private String instanceId; @GetMapping public ResponseEntity<Hello> hello() { return ResponseEntity.ok( new Hello( String.format("Hello world from %s!!!", instanceId) )); } @GetMapping("extended") public ResponseEntity<Hello> helloExtended() { Hello helloFromServer = serverClient.getHello(); return ResponseEntity.ok(new Hello( String.format("'%s' ~ passed by %s", helloFromServer.getMessage(), instanceId ))); } }
Następnie definiujemy ServerClient, czyli klient webowy komunikujący się z aplikacją server, z użyciem Feigna:
@FeignClient("server") public interface ServerClient { @GetMapping("hello") Hello getHello(); }
Dla aplikacji client dodajemy deployment.yml:
apiVersion: apps/v1 kind: Deployment metadata: name: client-app labels: app: client-app environment: test spec: replicas: 4 selector: matchLabels: environment: test app: client-app template: metadata: labels: environment: test app: client-app spec: containers: - name: client-app image: gcr.io/{{GCLOUD_PROJECT_ID}}/spring-consul-discovery-demo-client:{{IMAGE_VERSION}} imagePullPolicy: Always ports: - containerPort: 8080 livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 120 readinessProbe: httpGet: path: /actuator/health port: 8080 env: - name: SPRING_PROFILES_ACTIVE value: "deployment" --- apiVersion: v1 kind: Service metadata: name: client-app labels: run: client-app spec: type: LoadBalancer ports: - port: 8080 protocol: TCP selector: app: client-app environment: test
Definiujemy tutaj serwis typu LoadBalancer, dzięki czemu requesty wpadające z zewnętrznego Internetu będą rozkładane przez load balancer Kubernetesa. Aplikację client postawimy w 4 replikach.
Server
W aplikacji server również zdefiniujmy HelloEndpoint, tym razem z jedną metodą:
@RestController @RequestMapping("hello") public class HelloEndpoint { @Value("${spring.cloud.consul.discovery.instanceId}") private String instanceId; @GetMapping public ResponseEntity<Hello> hello() { return ResponseEntity.ok( new Hello( String.format("Hello world from %s!!!", instanceId) )); } }
Definiujemy deployment.yml:
apiVersion: apps/v1 kind: Deployment metadata: name: server-app labels: app: server-app environment: test spec: replicas: 4 selector: matchLabels: environment: test app: server-app template: metadata: labels: environment: test app: server-app spec: containers: - name: server-app image: gcr.io/{{GCLOUD_PROJECT_ID}}/spring-consul-discovery-demo-server:{{IMAGE_VERSION}} imagePullPolicy: Always ports: - containerPort: 8080 livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 120 readinessProbe: httpGet: path: /actuator/health port: 8080 env: - name: SPRING_PROFILES_ACTIVE value: "deployment" --- apiVersion: v1 kind: Service metadata: name: server-app labels: run: server-app spec: type: NodePort ports: - port: 8080 protocol: TCP selector: app: server-app environment: test
Aplikację server również postawimy w 4 replikach, jednak dla niej definiujemy już serwis typu NodePort, który nie zapewnia load balancingu. Bez obaw jednak, kierowaniem ruchu z aplikacji client do server zajmie się Ribbon z pomocą Consula.
Deploy aplikacji
Wrzucamy obie aplikacje na Kubernetesa w GCP. Jeśli nie wiesz, jak to zrobić, jeszcze raz polecam przeczytać ten artykuł.
Test
Aby zobaczyć, czy nasza integracja Spring i Consul działa, musimy jeszcze raz wrócić do konsoli Cloud Shell. Jeśli wywołamy kubectl get services to powinniśmy otrzymać:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) client-app LoadBalancer ***.***.***.*** [CLIENT-EXTERNAL-IP] 8080:32608/TCP consul-consul-dns ClusterIP ***.***.***.*** <none> 53/TCP,53/UDP consul-consul-server ClusterIP None <none> 8500/TCP,8301/TCP,... consul-consul-ui ClusterIP ***.***.***.*** <none> 80/TCP kubernetes ClusterIP ***.***.***.*** <none> 443/TCP server-app NodePort ***.***.***.*** <none> 8080:31403/TCP
Najbardziej interesuje nas kolumna EXTERNAL-IP, gdzie możemy znaleźć IP serwisu client-app. Żeby zweryfikować, że nasz load balancing działa, wykonajmy kilka requestów:
GET /hello/extended Host: [CLIENT-EXTERNAL-IP]:8080
Otrzymamy:
{"message":"'Hello world from server:2666d11b711f14592dd9f4a21f9d70ba!!!' ~ passed by client:2c465c505f3d2ecab51a3065653f8259"} {"message":"'Hello world from server:7fb6470417d548722436a299a832065c!!!' ~ passed by client:2c465c505f3d2ecab51a3065653f8259"} {"message":"'Hello world from server:666585de9eb6140a2129362f2d0827a2!!!' ~ passed by client:2c465c505f3d2ecab51a3065653f8259"} {"message":"'Hello world from server:56f59ad03cb2d6f35f07621505f9ab52!!!' ~ passed by client:2c465c505f3d2ecab51a3065653f8259"} {"message":"'Hello world from server:2666d11b711f14592dd9f4a21f9d70ba!!!' ~ passed by client:2c465c505f3d2ecab51a3065653f8259"}
Jak widać, instancje aplikacji server rotują po kolei, natomiast cały czas jest odpytywana ta sama instancja aplikacji client. Dzieje się tak dlatego, że Kubernetes i Ribbon używają domyślnie różnych algorytmów:
- Ribbon używa algorytmu Round Robin (iteruje po wszystkich dostępnych replikach po kolei).
- Kubernetes używa algorytmu Fewest Servers (kieruje zapytania do pierwszej instancji z listy, dopóki nie wysyci jej połączeniami).
Podsumowanie
Zrobiliśmy podstawową konfigurację, w której aplikacja Spring korzysta z discovery Consula. W żaden sposób nie wyczerpuje to jednak ani możliwości Consula, ani tematu load balancingu. Dlatego zachęcam do eksperymentowania i obserwowania bloga, gdyż na pewno pojawi się więcej w tym temacie 🙂