Come esporre più applicazioni su Amazon EKS con un singolo Application Load Balancer

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

Esporre i microservizi verso Internet con AWS

Uno dei momenti decisivi nella costruzione di un'applicazione a microservizi è decidere come esporre gli endpoint in modo che un client o un'API possa inviare richieste e ottenere risposte.

Solitamente ogni microservizio ha il suo endpoint. Ad esempio, ogni suffisso alla fine di un indirizzo web punterà a un microservizio diverso:

www.example.com/service1 > microservice1
www.example.com/service2 > microservice2
www.example.com/service3 > microservice3
...

Questo tipo di instradamento è noto come path-based routing.

Questo approccio ha l'importante vantaggio di avere un basso costo ed essere semplice anche quando si espongono decine di microservizi.

In AWS, sia Application Load Balancer (ALB) che Amazon API Gateway supportano questa funzione. Pertanto, con un singolo ALB o un singolo API Gateway è possibile esporre i microservizi in esecuzione come container con Amazon EKS o Amazon ECS o come funzioni serverless con AWS Lambda.

Recentemente AWS ha proposto una soluzione per esporre microservizi orchestrati da EKS tramite un Application Load Balancer. La loro soluzione è basata sull'utilizzo di NodePort esposte da Kubernetes.

Io invece voglio proporre una soluzione diversa che sfrutta il VPC CNI add-on del cluster EKS e consente ai pod di agganciarsi automaticamente al proprio target group, senza quindi l'utilizzo di NodePort.

Inoltre, nel mio caso d'uso l'Application Load Balancer è gestito in modo indipendente rispetto a EKS, ovvero non è Kubernetes a detenerne il controllo. Questo consente di poter utilizzare anche altri tipi di routing basandosi sulle possibili condizioni da configurare sul load balancer; ad esempio, potremmo avere un certificato SSL con più di un dominio (SNI) e basare il routing delle richieste non solo sul path ma anche sul dominio.

eks-lb.png

Configurazione dei componenti

Il codice mostrato qui è parziale. L'esempio completo è consultabile qui.

EKS cluster

In questo articolo il cluster EKS è un prerequisito e si dà per scontato che sia già installato. Se vuoi, puoi leggere come installare un cluster EKS con Terraform nel mio articolo sull'autoscaling. Nel repository si trova un esempio completo.

VPC CNI add-on

Il VPC CNI (Container Network Interface) add-on consente di assegnare automaticamente un indirizzo IP della VPC direttamente ad un pod all'interno del cluster EKS.

Poiché vogliamo che i pod si auto-registrino sul proprio target group (che è una risorsa fuori da Kubernetes e interna alla VPC), l'utilizzo di questo add-on è indispensabile. La sua installazione è integrata su EKS in modo nativo, come spiegato qui.

AWS Load Balancer Controller plugin

AWS Load Balancer Controller è un controller che aiuta a gestire un Elastic Load Balancer per un cluster Kubernetes.

Normalmente viene utilizzato per il provisioning di un Application Load Balancer, come risorsa Ingress, oppure di un Network Load Balancer come risorsa Service.

Nel nostro caso il provisioning non è richiesto, perché il nostro Application Load Balancer è gestito in modo indipendente. Utilizzeremo però un altro tipo di componente installato dalla CRD per far sì che i pod si registrino al loro target group.

Questo plugin non è incluso nell'installazione di EKS, pertanto va installato seguendo le istruzioni dalla documentazione AWS.

Se usi Terraform, come me, puoi considerare di usare un modulo per l'installazione del plugin:

module "load_balancer_controller" {
  source  = "DNXLabs/eks-lb-controller/aws"
  version = "0.6.0"

  cluster_identity_oidc_issuer     = module.eks_cluster.cluster_oidc_issuer_url
  cluster_identity_oidc_issuer_arn = module.eks_cluster.oidc_provider_arn
  cluster_name                     = module.eks_cluster.cluster_id

  namespace = "kube-system"
  create_namespace = false
}

Load Balancer e Security Group

Con Terraform creo un Application Load Balancer nelle subnet pubbliche della nostra VPC, ed il suo Security Group. La VPC è la stessa in cui è installato il cluster EKS.

resource "aws_lb" "alb" {
  name                       = "${local.name}-alb"
  internal                   = false
  load_balancer_type         = "application"
  subnets                    = module.vpc.public_subnets
  enable_deletion_protection = false
  security_groups            = [aws_security_group.alb.id]
}

resource "aws_security_group" "alb" {
  name        = "${local.name}-alb-sg"
  description = "Allow ALB inbound traffic"
  vpc_id      = module.vpc.vpc_id

  tags = {
    "Name" = "${local.name}-alb-sg"
  }

  ingress {
    description = "allowed IPs"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "allowed IPs"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
}

E' importante ricordarsi di autorizzare questo Security Group come sorgente nelle regole in ingresso del Security Group dei nodi del cluster.

A questo punto creo i target group a cui i pod si agganceranno. In questo esempio ne uso due:

resource "aws_lb_target_group" "alb_tg1" {
  port        = 8080
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = module.vpc.vpc_id

  tags = {
    Name = "${local.name}-tg1"
  }

  health_check {
    path = "/"
  }
}

resource "aws_lb_target_group" "alb_tg2" {
  port        = 9090
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = module.vpc.vpc_id

  tags = {
    Name = "${local.name}-tg2"
  }

  health_check {
    path = "/"
  }
}

L'ultima configurazione sull'Application Load Balancer è la definizione dei listener, che contengono le regole di instradamento del traffico.

La regola di default sui listener, che viene fornita in risposta a richieste che non soddisfano nessuna delle altre regole, nel mio caso è di rifiutare il traffico; la inserisco come misura di sicurezza:

resource "aws_lb_listener" "alb_listener_http" {
  load_balancer_arn = aws_lb.alb.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "Internal Server Error"
      status_code  = "500"
    }
  }
}

resource "aws_lb_listener" "alb_listener_https" {
  load_balancer_arn = aws_lb.alb.arn
  port              = "443"
  protocol          = "HTTPS"
  certificate_arn   = aws_acm_certificate.certificate.arn
  ssl_policy        = "ELBSecurityPolicy-2016-08"

  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "Internal Server Error"
      status_code  = "500"
    }
  }
}

Le regole vere e proprie vengono quindi associate ai listener. Il listener sulla porta 80 ha una semplice redirect verso il listener HTTPS. Il listener sulla porta 443 ha delle regole di instradano il traffico a seconda del path:

resource "aws_lb_listener_rule" "alb_listener_http_rule_redirect" {
  listener_arn = aws_lb_listener.alb_listener_http.arn
  priority     = 100

  action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }

  condition {
    host_header {
      values = local.all_domains
    }
  }
}

resource "aws_lb_listener_rule" "alb_listener_rule_forwarding_path1" {
  listener_arn = aws_lb_listener.alb_listener_https.arn
  priority     = 100

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.alb_tg1.arn
  }

  condition {
    host_header {
      values = local.all_domains
    }
  }

  condition {
    path_pattern {
      values = [local.path1]
    }
  }
}

resource "aws_lb_listener_rule" "alb_listener_rule_forwarding_path2" {
  listener_arn = aws_lb_listener.alb_listener_https.arn
  priority     = 101

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.alb_tg2.arn
  }

  condition {
    host_header {
      values = local.all_domains
    }
  }

  condition {
    path_pattern {
      values = [local.path2]
    }
  }
}

Utilizzo su Kubernetes

Una volta che la configurazione su AWS è completa, l'utilizzo di questa tecnica su EKS è semplicissimo! E' sufficiente inserire una risorsa di tipo TargetGroupBinding per ogni deployment/service che vogliamo esporre sul load balancer tramite il target group.

Vediamo un esempio. Poniamo che ho un deployment con un service:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: nginx
  replicas: 1
  template:
    metadata:
      labels:
        app.kubernetes.io/name: nginx
    spec:
      containers:
        - name: nginx
          image: nginx
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx
  labels:
    app.kubernetes.io/name: nginx
spec:
  selector:
    app.kubernetes.io/name: nginx
  ports:
    - port: 8080
      targetPort: 80
      protocol: TCP

La sola configurazione da aggiungere è fatta in questo modo:

apiVersion: elbv2.k8s.aws/v1beta1
kind: TargetGroupBinding
metadata:
  name: nginx
spec:
  serviceRef:
    name: nginx
    port: 8080
  targetGroupARN: "arn:aws:elasticloadbalancing:eu-south-1:123456789012:targetgroup/tf-20220726090605997700000002/a6527ae0e19830d2"

Da questo momento in poi ogni nuovo pod che afferisce al deployment associato a quel service si auto-registrerà sul target group indicato. Per testarlo, basta far scalare il numero di repliche del deployment:

kubectl scale deployment nginx --replicas 5

e nel giro di qualche secondo gli IP dei nuovi pod saranno presenti come target del target group.

Screenshot 2022-08-03 at 11.05.14.png