EKS Autoscaling: Karpenter (versione italiana)

Come configurare il node autoscaling per EKS con Terraform: parte seconda

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

Karpenter

Karpenter è una nuova soluzione di node lifecycle management sviluppata da AWS Labs e rilasciata in GA al re:Invent 2021.

Si tratta di un prodotto open-source giovane ma promettente, nato come risposta ad alcune delle complessità di Cluster Autoscaler.

Cluster Autoscaler, infatti, con tutti i tag che devono corrispondere tra i vari tipi di risorse cloud, e permessi e ruoli da associare, non è banale da configurare (tanto che gli amici di Google Cloud hanno pensato bene di fornirlo già configurato su GKE...). Una volta reso funzionante è affidabile, ma la sua complessità di configurazione iniziale (paragonata ad altre soluzioni) spesso può risultare scoraggiante.

Rispetto a Cluster Autoscaler (potete leggere il mio articolo dedicato alla sua configurazione), Karpenter ha un approccio diverso all'autoscaling:

  • dietro le quinte, non si affida più all'ASG, ma invoca la creazione dei nodi direttamente dal cloud provider (nel caso di AWS, tramite un Launch Template);

  • ci sono molte meno configurazioni da fare rispetto a Cluster Autoscaler (almeno su AWS)

  • nel momento in cui rileva la necessità di allocare risorse computazionali nel cluster, è in grado di calcolare quale sia l'instance type più adeguato e lanciare un unico nuovo nodo che possa, da solo, soddisfare tutto il fabbisogno richiesto; non è necessario decidere a priori con quale/i instance type configurare il node group

Vediamo come funziona!

Come configurare Karpenter su EKS

Ok, ho detto che bisogna configurare meno tag di quanti se ne configurano su Cluster Autoscaler: ma qualcuno bisogna ancora assegnarlo :-)

Potete trovare una configurazione completa a questo link. In questo articolo vediamo assieme solo alcuni dettagli del codice.

Come prerequisito, è necessario aggiungere un ulteriore tag alle subnet della VPC in cui i nodi EKS vengono creati. Perciò, includendo i tag che normalmente si assegnano per EKS, la configurazione sarà:

public_subnet_tags = {
    "kubernetes.io/cluster/${local.name}" = "shared"
    "kubernetes.io/role/elb"              = 1
}

private_subnet_tags = {
    "kubernetes.io/cluster/${local.name}" = "shared"
    "kubernetes.io/role/internal-elb"     = 1
    # Tags subnets for Karpenter auto-discovery
    "karpenter.sh/discovery" = local.name
}

La definizione dei node group è più semplice, perché la sola configurazione necessaria è quella che serve a creare il primo nodo del cluster. Tutto il resto verrà gestito da Karpenter:

eks_managed_node_groups = {
    karpenter = {
      instance_types                        = ["t3.medium"]
      create_security_group                 = false
      attach_cluster_primary_security_group = true
      enable_monitoring                     = true

      min_size     = 1
      max_size     = 1
      desired_size = 1

      iam_role_additional_policies = [
        # Required by Karpenter
        "arn:${local.partition}:iam::aws:policy/AmazonSSMManagedInstanceCore"
      ]
    }
  }

L'installazione di Karpenter su questo primo nodo possiamo sempre farla con Helm tramite Terraform:

resource "helm_release" "karpenter" {
  namespace        = "karpenter"
  create_namespace = true

  name       = "karpenter"
  repository = "https://charts.karpenter.sh"
  chart      = "karpenter"
  version = "v0.13.2"

...
}

A questo punto, dopo che Helm ha installato la CRD del provisioner Karpenter, possiamo inserirne la configurazione:

I valori indicati nel codice sono puramente illustrativi. Verificate sempre quali valori sono più adeguati per ogni specifico caso d'uso.

resource "kubectl_manifest" "karpenter_provisioner" {
  yaml_body = <<-YAML
  apiVersion: karpenter.sh/v1alpha5
  kind: Provisioner
  metadata:
    name: nodegroup2
  spec:
    requirements:
      # Include general purpose instance families
      - key: karpenter.k8s.aws/instance-family
        operator: In
        values: [c5, m5, r5]
     # Exclude some instance sizes
      - key: karpenter.k8s.aws/instance-size
        operator: NotIn
        values: [nano, micro, small, 24xlarge, 18xlarge, 16xlarge, 12xlarge]
      # Exclude a specific instance type
      - key: karpenter.sh/capacity-type
        operator: In
        values: ["spot"]
    taints:
      - key: dedicated
        value: ${local.nodegroup2_label}
        effect: "NoSchedule"
    limits:
      resources:
        cpu: 16000
    provider:
      subnetSelector:
        karpenter.sh/discovery: ${local.name}
      securityGroupSelector:
        karpenter.sh/discovery: ${local.name}
      tags:
        karpenter.sh/discovery: ${local.name}
        role: group2
    ttlSecondsAfterEmpty: 30
  YAML
}

Questo esempio di configurazione del provisioner Karpenter include alcune scelte:

  • ho indicato un subset di instance_family

  • ho escluso un subset di instance_size (quelle troppo piccole per evitare problemi di performance, quelle troppo grandi per evitare problemi di billing)

  • ho limitato a 16 CPU le risorse che il provisioner può gestire; oltre questo valore non prende in carico nuove richieste di autoscaling

Altre possibilità di personalizzazione della configurazione sono disponibili nella documentazione ufficiale.

Scale-up su EKS con Karpenter

Ripetendo lo stesso test effettuato con Cluster Autoscaler, con Karpenter è quasi impercettibile il lasso di tempo tra l'istante in cui si richiedono nuove risorse computazionali e quello in cui il nodo viene creato, diversamente da Cluster Autoscaler che ha alcuni secondi di latenza in questa operazione.

Inoltre, avendo richiesto 5 nuove repliche tutte assieme, Karpenter ha calcolato quale fosse la scelta migliore come tipo di istanza in quel momento e per quella region:

karpenter-logs.png

Cluster Autoscaler, invece, nella stessa situazione ha creato nodi multipli fino a soddisfare la richiesta di risorse computazionali.

Scale-down su EKS con Karpenter

Lo scale-down di Karpenter è altrettanto rapido: quando un nodo risulta vuoto, entro il tempo indicato da ttlSecondsAfterEmpty (valore da settare sempre, altrimenti non c'è scale-down) il nodo viene terminato.

ATTENZIONE: le funzionalità descritte di seguito sono in fase di sviluppo. La descrizione qui inclusa è relativa al momento in cui l'articolo è scritto (19 luglio 2022).

Quello che Karpenter NON gestisce, però, è il consolidamento dei workload nel momento in cui i nodi sono sotto-utilizzati in termini di risorse.

Il caso d'uso di cui ho parlato nell'articolo su Cluster Autoscaler è particolare: poiché i workload sono job batch, una volta che l'esecuzione di tutti i job che insistono su un nodo è finita, non vi è la necessità di migrare alcun pod affinché il nodo risulti inutilizzato, e quindi viene terminato senza problemi.

Tuttavia, nel caso di workload non batch, come per esempio il deployment utilizzato per il test, nel momento in cui c'è una riduzione del numero di repliche del pod, Karpenter non consolida i pod su un numero di nodi sufficiente ad ospitarle; il cluster quindi distribuisce i pod attivi tra i nodi presenti, e, di conseguenza, nessun nodo risulta vuoto e può essere terminato.

Quindi, se ad un certo punto ho avuto per qualche motivo la necessità di avere un numero di repliche elevato (ad esempio per un picco di traffico), Karpenter avrà creato un nodo con risorse di calcolo importanti, che però non verrà mai terminato perché non c'è un meccanismo di consolidamento.

Le limitazioni alle tipologie di macchine inserite nella configurazione del provisioner servono quindi a mitigare il rischio che vengano creati nodi molto potenti e molto costosi che rischiano di non essere mai terminati.

Specifiche configurazioni di pod affinity, topology spread constraints, pod disruption budget possono essere aggiunte in alcuni casi per mitigare risultati inattesi.

Consolidamento dei workload: anteprima

Fortunatamente la funzionalità di consolidamento dei workload è in roadmap: è tracciata in una issue su Github che ha già una pull request associata per gettarne le basi.

L'abbiamo provato la patch in anteprima: dopo l'installazione è disponibile una nuova proprietà nella configurazione:

apiVersion: karpenter.sh/v1alpha5
  kind: Provisioner
  metadata:
    name: nodegroup2
  spec:
    consolidation:
      enabled: true
...

Al momento l'anteprima di questa funzionalità non prevede parametri di configurazione. Il risultato è che la funzione di consolidamento può non essere sempre efficace:

  • quando Karpenter fa eseguire tutti i pod sul minor numero di nodi possibile, sfruttando tutte le risorse disponibili nel cluster, in certi casi non lascia spazio nei nodi, quindi eventuali esecuzioni di cronjobs fanno sì che il cluster scali ogni pochi minuti

  • nel caso in cui due nodi abbiano un numero di pod simile, una parte dei pod continuano a essere spostati da un nodo all'altro per bilanciare il workload, perché non è presente alcun meccanismo di controllo che si limiti a candidare un solo nodo per volta alla rimozione. Si torna così nella situazione in cui nessun nodo si svuota né può essere terminato

Valutazioni finali

Cluster AutoscalerKarpenter
complessità di configurazionemedio/alta (su AWS)bassa
velocitàmedio/alta (configurabile)elevata
maturità delle funzionalitàaltamedio/bassa

Karpenter si è dimostrato molto promettente. La sua velocità di reazione è impressionante e con ogni probabilità riuscirà a ritargliarsi il suo spazio nel panorama dei tool integrati con Kubernetes, grazie al team di AWS che ne cura lo sviluppo e che ha tutto l'interesse di arricchirlo. Può essere un'alternativa interessante per cluster non critici o per Cluster Autoscaleri d'uso specifici.

Per cluster in ambiente di produzione, Karpenter dà la sensazione di non essere ancora abbastanza maturo, ma è in rapido sviluppo ed è certamente da tenere sotto osservazione per valutarne le future evoluzioni.