Come esporre più applicazioni su Google Kubernetes Engine con un singolo Cloud Load Balancer

If you're looking for the English version of this article, click here.

In un mio precedente articolo ho raccontato come fare a esporre applicazioni multiple ospitate su AWS EKS tramite un singolo Application Load Balancer.

In questo articolo vedremo come fare esattamente la stessa cosa, stavolta non su AWS ma su Google Cloud!

Network Endpoint Group e Container-native load balancing

Su GCP si utilizzano configurazioni chiamate Network Endpoint Group (NEG) per specificare un gruppo di endpoint o servizi di backend. Un caso d'uso comune in cui si usano i NEG è il deployment di servizi nei container usandoli come backend per alcuni load balancer.

Il container-native load balancing utilizza NEG di tipo GCE_VM_IP_PORT (in cui gli endpoint del NEG sono indirizzi IP dei pod) e consente al load balancer di scegliere come target direttamente i pod, e di distribuire il traffico tra essi.

neg.png

Normalmente, il container-native load balancing viene utilizzato per la risorsa GKE Ingress. In quel caso, l'ingress-controller si occupa di creare tutta la catena di risorse necessarie, incluso il load balancer; questo vuol dire che ad ogni applicazione su GKE corrisponde un Ingress e di conseguenza un load balancer.

Senza l'utilizzo dell'ingress-controller, GCP consente di creare NEG autonomi; ma in quel caso si deve agire manualmente e si perdono i vantaggi dell'elasticità e velocità di un'architettura cloud native.

Per riassumere: nel mio caso d'uso voglio usare un singolo load balancer, configurato in modo indipendente da GKE, e far sì che il traffico venga ruotato verso applicazioni GKE differenti, a seconda delle regole stabilite dalla mia architettura; e voglio contemporaneamente sfruttare gli automatismi cloud native senza dover effettuare operazioni manuali di aggiornamento delle configurazioni.

AWS ALB vs GCP Load Balancing

Nella realizzazione dello stesso caso d'uso su due diversi cloud provider, la differenza più evidente si nota nel "confine" a cui Kubernetes arriva nel gestire le risorse; o, se vogliamo guardare le cose dall'altro punto di vista, nelle configurazioni che vanno preparate sul cloud provider (manualmente o, come vedremo, con Terraform).

Nell'articolo su EKS ho configurato su AWS, oltre all'ALB, i target group, uno per ciascuna applicazione da esporre; questi target group erano di fatto creati come "scatole vuote". Successivamente ho creato su EKS i deployment e i relativi service; ho infine effettuato, sempre su EKS, una configurazione di TargetGroupBinding (risorsa custom del lb-controller) per indicare ai pod afferenti a un determinato service quale fosse il corretto target group a cui registrarsi.

Nel caso di GCP, la risorsa Backend Service (che può essere grossomodo assimilata a un target group AWS) non può essere creata come "scatola vuota", ma fin dalla sua creazione ha bisogno di conoscere i suoi target a cui inoltrare il traffico. Come ho detto prima, nel mio caso d'uso i target sono i NEG che GKE genera automaticamente alla creazione di un kubernetes service; di conseguenza, creerò questi service contestualmente all'infrastruttura (saranno loro le mie "scatole vuote"), e gestirò in modo separato solamente i deployment applicativi.

Questa apparente differenza è puramente operativa: si tratta solo di configurare il kubernetes service con un differenti strumenti, e può essere degno di nota nel caso in cui la configurazione delle risorse cloud (ad esempio con Terraform) siano effettuate da un team diverso da quello che invece deploya le applicazioni nel cluster.

Dal punto di vista funzionale le due soluzioni sono esattamente equivalenti.

L'altra differenza è che in GKE la gestione degli indirizzi IP della VPC da assegnare ai pod è nativa, e non necessita di alcun add-on, come invece accade su EKS dove va usato il plugin VPC CNI o altri plugin simili di terze parti.

Configurazione dei componenti

La configurazione di rete e del cluster GKE è considerata un prerequisito e non verrà analizzata in questa sede. Il codice mostrato qui è parziale; l'esempio completo è consultabile qui.

Kubernetes Services

In questo esempio creo due diverse applicazioni, rappresentate con Nginx e con Apache, per mostrare il routing del traffico su due endpoint diversi.

Con Terraform creo i kubernetes services relativi alle due applicazioni; l'utilizzo delle annotation permette la creazione automatica dei NEG:

resource "kubernetes_service" "apache" {
  metadata {
    name      = "apache"
    namespace = local.namespace
    annotations = {
      "cloud.google.com/neg" = "{\"exposed_ports\": {\"80\":{\"name\": \"${local.neg_name_apache}\"}}}"
      "cloud.google.com/neg-status" = jsonencode(
        {
          network_endpoint_groups = {
            "80" = local.neg_name_apache
          }
          zones = data.google_compute_zones.available.names
        }
      )
    }
  }
  spec {
    port {
      name        = "http"
      protocol    = "TCP"
      port        = 80
      target_port = "80"
    }
    selector = {
      app = "apache"
    }
    type = "ClusterIP"
  }
}

resource "kubernetes_service" "nginx" {
  metadata {
    name      = "nginx"
    namespace = local.namespace
    annotations = {
      "cloud.google.com/neg" = "{\"exposed_ports\": {\"80\":{\"name\": \"${local.neg_name_nginx}\"}}}"
      "cloud.google.com/neg-status" = jsonencode(
        {
          network_endpoint_groups = {
            "80" = local.neg_name_nginx
          }
          zones = data.google_compute_zones.available.names
        }
      )
    }
  }
  spec {
    port {
      name        = "http"
      protocol    = "TCP"
      port        = 80
      target_port = "80"
    }
    selector = {
      app = "nginx"
    }
    type = "ClusterIP"
  }
}

NEG

I link dei NEG hanno sempre la stessa struttura, per cui è semplice costruirne la lista:

locals {
  neg_name_apache = "apache"
  neg_apache      = formatlist("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/networkEndpointGroups/%s", module.enabled_google_apis.project_id, data.google_compute_zones.available.names, local.neg_name_apache)
  neg_name_nginx  = "nginx"
  neg_nginx       = formatlist("https://www.googleapis.com/compute/v1/projects/%s/zones/%s/networkEndpointGroups/%s", module.enabled_google_apis.project_id, data.google_compute_zones.available.names, local.neg_name_nginx)
}

Screenshot 2022-08-05 at 15.45.10.png

Backend

A questo punto è semplice creare i backend service:

resource "google_compute_backend_service" "backend_apache" {
  name    = "${local.name}-backend-apache"

  dynamic "backend" {
    for_each = local.neg_apache
    content {
      group          = backend.value
      balancing_mode = "RATE"
      max_rate       = 100
    }
  }
...
}

resource "google_compute_backend_service" "backend_nginx" {
  name    = "${local.name}-backend-nginx"

  dynamic "backend" {
    for_each = local.neg_nginx
    content {
      group          = backend.value
      balancing_mode = "RATE"
      max_rate       = 100
    }
  }
...
}

Screenshot 2022-08-05 at 15.18.18.png

URL Map

Definisco quindi la risorsa url_map che rappresenta la logica di routing del traffico. In questo esempio utilizzo un set di regole uguali per tutti i domini a cui il mio load balancer risponde, e indirizzo il traffico a seconda del path; è possibile personalizzare le regole per instradare seguendo le indicazioni della documentazione.

resource "google_compute_url_map" "http_url_map" {
  project         = module.enabled_google_apis.project_id
  name            = "${local.name}-loadbalancer"
  default_service = google_compute_backend_bucket.static_site.id

  host_rule {
    hosts        = local.domains
    path_matcher = "all"
  }

  path_matcher {
    name            = "all"
    default_service = google_compute_backend_bucket.static_site.id

    path_rule {
      paths = ["/apache"]
      route_action {
        url_rewrite {
          path_prefix_rewrite = "/"
        }
      }
      service = google_compute_backend_service.backend_apache.id
    }

    path_rule {
      paths = ["/nginx"]
      route_action {
        url_rewrite {
          path_prefix_rewrite = "/"
        }
      }
      service = google_compute_backend_service.backend_nginx.id
    }
  }
}

Screenshot 2022-08-05 at 15.18.57.png

Mettere tutto assieme

Per finire, le risorse che legano assieme i componenti creati sono una target_http_proxy e una global_forwarding_rule:

resource "google_compute_target_http_proxy" "http_proxy" {
  project = module.enabled_google_apis.project_id
  name    = "http-proxy"
  url_map = google_compute_url_map.http_url_map.self_link
}

resource "google_compute_global_forwarding_rule" "http_fw_rule" {
  project               = module.enabled_google_apis.project_id
  name                  = "http-fw-rule"
  port_range            = 80
  target                = google_compute_target_http_proxy.http_proxy.self_link
  load_balancing_scheme = "EXTERNAL"
  ip_address            = google_compute_global_address.ext_lb_ip.address
}

Utilizzo su Kubernetes

Una volta che la configurazione su GCP è completa, l'utilizzo di questa tecnica su GKE è ancora più semplice che su EKS. E' sufficiente inserire una risorsa di tipo deployment che corrisponda al service già creato sul load balancer:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: nginx
  replicas: 3
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx
          ports:
            - containerPort: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: apache
  labels:
    app: apache
spec:
  selector:
    matchLabels:
      app: apache
  strategy:
    type: Recreate
  replicas: 3
  template:
    metadata:
      labels:
        app: apache
    spec:
      containers:
        - name: httpd
          image: httpd:2.4
          ports:
            - containerPort: 80

Da questo momento in poi ogni nuovo pod che afferisce al deployment associato a quel service sarà automaticamente associato al NEG indicato. Per testarlo, basta far scalare il numero di repliche del deployment:

kubectl scale deployment nginx --replicas 5

e nel giro di qualche secondo i nuovi pod saranno presenti come target del NEG.

Un grazie a Cristian Conte per aver contribuito con la sua grande esperienza su GCP!