Strona główna » Integracja Spring i Consul na GKE z użyciem Terraform

Integracja Spring i Consul na GKE z użyciem Terraform

by Grzegorz Sowa

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ć.
Integracja Spring i Consul na Google Kubernetes Cluster

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 🙂

You may also like

Leave a Comment