Translate

середа, 21 травня 2025 р.

Karpenter. Just-in-time Nodes for EKS Cluster

Karpenter - це аддон EKS, котрий забезпечує підняття нових вузлів у відповідь на присутність подів, для яких відсутні місця на нинішніх потужностях. Він це робить спостерігаючи за подіями в кластері і за потреби відправляє запити до API хмарного провайдера, на котрому працює.

Karpenter виконує схожу роботу із Cluster Autoscaler проте реагує значно швидше на потреби скейлінгу, не має обмежень по типам інстансів в межах одного пулу, працює і логікою вартості вузлів тощо. Karpenter не потребує Node та Autoscaling груп і працює напряму із API без традиційних абстракцій AWS необхідних для цього. Окрім цього він може виконувати безпекові функції: забезпечує автоматичне перестворення нод після проходження певного часу чи після виходу нової AMI.

Уперше Karpenter було представлено в 2021 році у якості продукту із відкритим кодом. У 2024 році була вже представлена версія 1.0, котра була оголошена Амазоном як готова до використання в prod-середовищах на AWS. Karpenter було cпроектовано для використання на різних хмарних середовищах, тому є сторонні імплементації Karpenter для Azure, Alibaba Cloud і можливо інші. На практиці ж він перш за все розвивається для AWS, інші його версії можуть бути не достатньо стабільними, а офіційна докуменація для розробників відсутня.


Ця стаття буде умовно поділена на 3 частини. Перша буде про установку Karpenter на вже діючий EKS кластер, для чого вже традиційно скористаємось Тераформом. А в другій та третій поговоримо про його роботу та особливості.

Source: https://aws.amazon.com/blogs/aws/introducing-karpenter-an-open-source-high-performance-kubernetes-cluster-autoscaler/

Джерело: https://aws.amazon.com/blogs/aws/introducing-karpenter-an-open-source-high-performance-kubernetes-cluster-autoscaler/

Перед інсталяцією Karpenter варто звернути увагу на функціонал EKS Auto Mode, що з'явився відносно нещодавно. Він перекладає відповідальність на установку та підтримку базових AWS-аддонів, серед яких AWS LB Contoller, Karpenter, EBS CSI та інші на хмарний сервіс Amazon. Не бачу причин не спробувати спершу саме його.


1. BASIC KARPENTER INSTALLATION WITH TERRAFORM


Увесь код доступний за цією адресою, там же є про установку EKS кластера v1.32 та мереж, котрі для нього необхідні. Ось основна стаття як цей код застосовується, проте цього разу скористуємось останньою версією Тераформа v1.12:

$ git clone git@github.com:ipeacocks/terraform-aws-example.git
$ cd terraform-aws-example/eks-infra/addons/karpenter


Основний функціонал знаходиться в main.tf

$ cat main.tf

data "terraform_remote_state" "eks" {
  backend = "s3"

  config = {
    bucket = "my-tf-state-2023-06-01"
    key    = "my-eks.tfstate"
    region = "us-east-1"
  }
}

data "terraform_remote_state" "vpc" {
  backend = "s3"

  config = {
    bucket = "my-tf-state-2023-06-01"
    key    = "my-vpc.tfstate"
    region = "us-east-1"
  }
}

data "aws_caller_identity" "current" {}

locals {
  name = "${var.name_prefix}-${data.terraform_remote_state.eks.outputs.cluster_name}-${var.region}"
}

module "this" {
  source  = "terraform-aws-modules/eks/aws//modules/karpenter"
  version = "20.36.0"

  cluster_name               = data.terraform_remote_state.eks.outputs.cluster_name
  iam_policy_name            = local.name
  iam_policy_use_name_prefix = false
  enable_v1_permissions      = true
  iam_role_name              = local.name
  iam_role_use_name_prefix   = false
  queue_name                 = local.name
  create_node_iam_role       = false
  node_iam_role_arn          = format("arn:aws:iam::%s:role/%s", data.aws_caller_identity.current.account_id, data.terraform_remote_state.eks.outputs.eks_managed_node_groups["one"].iam_role_name)
  # Since the nodegroup role will already have an access entry
  create_access_entry = false
}

resource "aws_eks_pod_identity_association" "this" {
  cluster_name    = data.terraform_remote_state.eks.outputs.cluster_name
  namespace       = var.namespace
  service_account = "karpenter"
  role_arn        = module.this.iam_role_arn
}

resource "helm_release" "crd" {
  name             = "karpenter-crd"
  repository       = "oci://public.ecr.aws/karpenter"
  chart            = "karpenter-crd"
  version          = var.helm_package_version
  namespace        = var.namespace
  create_namespace = true
}

resource "helm_release" "this" {
  name             = "karpenter"
  repository       = "oci://public.ecr.aws/karpenter"
  chart            = "karpenter"
  version          = var.helm_package_version
  namespace        = var.namespace
  create_namespace = true
  values = [
    templatefile("${path.module}/templates/helm/values.yaml.tpl", {
      clusterName           = data.terraform_remote_state.eks.outputs.cluster_name
      interruptionQueueName = module.this.queue_name
    })
  ]

  depends_on = [helm_release.crd]
}

resource "kubectl_manifest" "generic_ec2_node_class" {
  yaml_body = templatefile("${path.module}/templates/manifests/generic-ec2nodeclass.yaml.tpl", {
    private_subnets                = join(",", data.terraform_remote_state.vpc.outputs.worker_subnet_ids)
    cluster_node_security_group_id = data.terraform_remote_state.eks.outputs.node_security_group_id
    cluster_name                   = data.terraform_remote_state.eks.outputs.cluster_name
    role                           = data.terraform_remote_state.eks.outputs.eks_managed_node_groups["one"].iam_role_name
    tags                           = join(",", [for key, value in var.tags : "${key}: '${value}'"])
  })

  depends_on = [helm_release.this]
}

resource "kubectl_manifest" "generic_node_pool" {
  yaml_body = templatefile("${path.module}/templates/manifests/generic-nodepool.yaml.tpl", {
    disruption = {
      consolidation_policy = var.generic_node_pool.disruption.consolidation_policy
      consolidate_after    = var.generic_node_pool.disruption.consolidate_after
    }
    expire_after = var.generic_node_pool.expire_after
    instance = {
      # Because Karpenter has only greater operator.
      min_cpu    = var.generic_node_pool.instance.min_cpu - 1
      min_memory = var.generic_node_pool.instance.min_memory_mb - 1
    }
  })

  depends_on = [helm_release.this]
}

Цей код залежить від параметрів EKS кластеру та мережі тож він і вичитує відповідні Terraform стейти. Далі ми користуємось готовим модулем для установки всіх речей, що необхідні для роботи Karpenter: IAM ролі (котру потребує і контролер, і майбутні підняті нові вузли, яким необхідно буде підключення до кластеру), SQS черги тощо.

Доступ до власної ролі аддону буде забезпечуватись через pod identity. Установка та оновлення Karpenter CRDs та самого контролера відбувається окремо, адже кожне наступне оновлення потребуватиме явного оновлення CRDs.

Зазначу, що у разі, коли Karpenter піднімається у неймспейсі відмінному від системного kube-system, потрібно також застосувати FlowSchemas, інакше запити до kubeapi можуть тротлитись.

За допомогою ресурсу kubectl_manifest ми застосовуємо загальні nodepool/nodeclass, що відповідатимуть за логіку підняття вузлів. Вони формуватимуть фінальний nodeclaim, в результаті чого буде піднятий новий вузол для розміщення подів в статусі "Pending".

Переглянемо тепер вміст темплейтів. Значення values.yaml, котрі відправляються на вхід helm чарту:

$ cat templates/helm/values.yaml.tpl

controller:
  resources:
    limits:
      cpu: 1
      memory: 1Gi
    requests:
      cpu: 1
      memory: 1Gi
  metrics:
    port: 8000
settings:
  clusterName: ${clusterName}
  interruptionQueue: ${interruptionQueueName}


Тут, як вже було згадано, необхідні ім'я кластеру та SQS-черги для отримання повідомлень про потенційні заміщення вузлів.

Generic nodepool описує умови, котрим має задовольняти под, якому не вистачає місця для роботи. Тому він має інформацію про необхідну архітектуру, параметри консолідації вузла (ми їх якраз і параметризуємо) тощо:

$ cat templates/manifests/generic-nodepool.yaml.tpl

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: generic
  annotations:
    kubernetes.io/description: "General purpose NodePool for amd64/arm64 workloads"
spec:
  disruption:
    consolidationPolicy: ${disruption.consolidation_policy}
    consolidateAfter: ${disruption.consolidate_after}
  limits:
    cpu: 1k
  template:
    metadata:
      labels:
        node-type: dynamic
    spec:
      expireAfter: ${expire_after}
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: generic
      taints:
        - key: your.company.io/workloads
          effect: NoSchedule
        - key: your.company.io/workloads
          effect: NoExecute
      requirements:
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64", "arm64"]
        - key: kubernetes.io/os
          operator: In
          values: ["linux"]
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["t", "c", "m"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["3"]
        - key: "karpenter.k8s.aws/instance-cpu"
          operator: Gt
          values: ["${instance.min_cpu}"]
        - key: "karpenter.k8s.aws/instance-memory"
          operator: Gt
          values: ["${instance.min_memory}"]


Nodeclass же описує який саме вузол піднімати та із якими параметрами щодо диску, мереж та інше:

$ cat templates/manifests/generic-ec2nodeclass.yaml.tpl

# how to find latest AMI https://karpenter.sh/docs/concepts/nodeclasses/#specamiselectorterms
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: generic
  annotations:
    kubernetes.io/description: "General purpose EC2NodeClass for running AL 2023 nodes"
spec:
  amiSelectorTerms:
    - alias: al2023@v20250403
  metadataOptions:
    httpEndpoint: enabled
    httpPutResponseHopLimit: 2
    httpTokens: required
  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        deleteOnTermination: true
        encrypted: true
        throughput: 250
        volumeSize: 30Gi
        volumeType: gp3
  role: ${role}
  securityGroupSelectorTerms:
    - id: ${cluster_node_security_group_id}
  subnetSelectorTerms:
  %{ for subnet in split(",", private_subnets) }
    - id: ${subnet}
  %{ endfor }
  tags:
    Name: ${cluster_name}-eks-dynamic
    %{ for tag in split(",", tags) }
    ${tag}
  %{ endfor }


Лишились файли змінних variables.tf та підключених провайдерів terraform.tf, тут не так щоб багато особливого, тому не буду зупинятись на цьому.

Скачуємо залежності та застосовуємо код:

$ terraform init
$ terraform apply


Перевіряємо чи Karpenter піднявся:

$ kubectl get pods -n kube-system | grep karpenter

karpenter-dd675d75b-mktcg      1/1     Running   0          11h
karpenter-dd675d75b-vzhfc      1/1     Running   0          11h


$ kubectl logs -n kube-system -l app.kubernetes.io/name=karpenter


{"level":"INFO","time":"2025-05-16T14:05:19.806Z","logger":"controller.controller-runtime.metrics","message":"Starting metrics server","commit":"0871602"}
{"level":"INFO","time":"2025-05-16T14:05:19.806Z","logger":"controller","message":"starting server","commit":"0871602","name":"health probe","addr":"[::]:8081"}
{"level":"INFO","time":"2025-05-16T14:05:19.807Z","logger":"controller.controller-runtime.metrics","message":"Serving metrics server","commit":"0871602","bindAddress":":8000","secure":false}
{"level":"INFO","time":"2025-05-16T14:05:19.907Z","logger":"controller","message":"attempting to acquire leader lease kube-system/karpenter-leader-election...","commit":"0871602"}


Та перевіримо його роботу. Спершу ми створимо deployment із 0 под і потім збільшимо кількість реплік:

$ cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: inflate
spec:
  replicas: 0
  selector:
    matchLabels:
      app: inflate
  template:
    metadata:
      labels:
        app: inflate
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: kubernetes.io/arch
                operator: In
                values:
                - arm64
      terminationGracePeriodSeconds: 0
      securityContext:
        runAsUser: 1000
        runAsGroup: 3000
        fsGroup: 2000
      containers:
      - name: inflate
        image: public.ecr.aws/eks-distro/kubernetes/pause:3.7
        resources:
          requests:
            cpu: 1
        securityContext:
          allowPrivilegeEscalation: false
      nodeSelector:
        node-type: dynamic
      tolerations:
      - effect: NoSchedule
        key: your.company.io/workloads
        operator: Exists
      - effect: NoExecute
        key: your.company.io/workloads
        operator: Exists
      topologySpreadConstraints:
      - labelSelector:
          matchLabels:
            app: inflate
        maxSkew: 1
        topologyKey: topology.kubernetes.io/zone
        whenUnsatisfiable: ScheduleAnyway
      - labelSelector:
          matchLabels:
            app: inflate
        maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: ScheduleAnyway # DoNotSchedule
EOF


deployment.apps/inflate created

$ kubectl get deploy
NAME      READY   UP-TO-DATE   AVAILABLE   AGE
inflate   0/0     0            0           19s


Перевіримо нинішній стан нод, а саме їхню кількість:

$ kubectl get nodes
NAME                          STATUS   ROLES    AGE   VERSION
ip-10-0-25-246.ec2.internal   Ready    <none>   12h   v1.32.3-eks-473151a
ip-10-0-9-219.ec2.internal    Ready    <none>   12h   v1.32.3-eks-473151a

$ kubectl get nodeclaims
No resources found


Збільшимо кількість inflate реплік до 5:

$ kubectl scale deployment inflate --replicas 5
deployment.apps/inflate scaled

Поди перейдуть в стан Pending, через відсутність необхідних потужностей:

$ kubectl get pods
NAME                       READY   STATUS    RESTARTS   AGE
inflate-694646c7b9-8hgz6   0/1     Pending   0          6s
inflate-694646c7b9-dhfjm   0/1     Pending   0          6s
inflate-694646c7b9-dr2ht   0/1     Pending   0          6s
inflate-694646c7b9-m9vmh   0/1     Pending   0          6s
inflate-694646c7b9-rfpcm   0/1     Pending   0          6s


І в межах 30 секунд Karpenter забезпечить ці потужності:

$ kubectl get nodeclaims
NAME            TYPE         CAPACITY    ZONE         NODE                          READY   AGE
generic-279cw   c6g.xlarge   on-demand   us-east-1c   ip-10-0-31-247.ec2.internal   True    41s
generic-4lg8t   c6g.xlarge   on-demand   us-east-1b   ip-10-0-21-173.ec2.internal   True    41s
generic-7pj6r   c6g.xlarge   on-demand   us-east-1a   ip-10-0-15-2.ec2.internal     True    41s
generic-977zx   c6g.xlarge   on-demand   us-east-1b   ip-10-0-17-210.ec2.internal   True    41s
generic-vg9zn   c6g.xlarge   on-demand   us-east-1c   ip-10-0-27-128.ec2.internal   True    41s

$ kubectl get nodes
NAME                          STATUS   ROLES    AGE   VERSION
ip-10-0-13-111.ec2.internal   Ready    <none>   27m   v1.32.3-eks-473151a
ip-10-0-19-213.ec2.internal   Ready    <none>   27m   v1.32.3-eks-473151a
ip-10-0-22-84.ec2.internal    Ready    <none>   14s   v1.32.3-eks-473151a
ip-10-0-23-25.ec2.internal    Ready    <none>   13s   v1.32.3-eks-473151a
ip-10-0-26-246.ec2.internal   Ready    <none>   12s   v1.32.3-eks-473151a
ip-10-0-31-145.ec2.internal   Ready    <none>   11s   v1.32.3-eks-473151a


Видалимо inflate та nodeclaim, експерименти не мають тривати вічність:

$ kubectl delete deploy inflate
$ kubectl delete nodeclaim generic-f6bq4


Або ж Карпентер сам видалить зайві вузли після вказаного consolidateAfter часу.


2. KARPENTER BASICS AND HOW IT WORKS

У цій частині, як я і обіцяв, поговоримо про деталі роботи Карпентера, його налаштування та можливі опції. Наявна робоча версія, котру ми проінсталювали вище, має у цьому допомогти і надати тестовий полігон для перевірки надалі написаного.

Основними об'єктами, що надають CRDs Карпентера, є Nodepool, Nodeclass та Nodeclaim. Nodepool забезпечує виконання умов, котрі запитують поди (необхідну архітектуру, AZ, лейбли кожного нового вузла тощо). Nodeclass описує параметри (security groups, розмір диска, мереж) вузла (EC2 ноди), котрий буде піднятий для розташування на ньому под. Конфіг Nodepool-а має посилання на Nodeclass із яким він працює, а останній може бути доданий до декількох Нодпулів.

Після оцінки вимог, що накладають Nodepool та відповідний йому Nodeclass, Karpenter сворює Nodeclaim. Це вже інструкції Карпентеру щодо того, який саме та де піднімати вузол. Також він відображає життєвий цикл вузла: можна переглядати його стан, а видалення Nodeclaim призводить до видалення відповідного вузла із EKS кластера.



2.1. Nodepools

Зупинимось окремо на кожному об'єкті Карпентера. Отже, що робить Nodepool?  Він задовольняє наступні типи запитів:

  • Ресурсні запити. Підбір вузла, де є доступні ресурси CPU/пам'яті
  • Вимоги вибору вузлів. Запуск на вузлі де є необхідний лейбл (nodeSelector) чи спорідненість (node affinity) по якимось атрибутам
  • Розподіл по топології. Розподіляє поди топології мереж/вузлів/capacity хмарного середовища задля забезпечення вищого рівня доступності
  • Споріденість/не спорідненість по подам (pod affinity/anti-affinity). Розташування подів в зележності від розміщення інших подів

Тепер розглянемо імплементацію цього на практиці. У якості основного прикладу Nodepool візьмемо generic, котрий згенерував Terraform у першій часині, та поглянемо за що відповідають окремі директиви. Тож він робить наступне:

Описує та проставляє taint-и щоб задовольнити tolaration-и, котрі вже проставлені в подах.

Якщо подивитись на деплоймент та нодпул, котрими ми оперували у першій частині статті, то деплоймент має наступні толерейшени:
...
      tolerations:
      - effect: NoSchedule
        key: your.company.io/workloads
        operator: Exists
      - effect: NoExecute
        key: your.company.io/workloads
        operator: Exists
...


Тобто, якщо на вузлі будуть присутні такі тейнти, то вони не будуть перешкодою для створення подів цього деплойменту. Cаме такі тейнти Карпентер (тобто його generic нодпул) і проставить на вузлах, щоб забезпечити старт цих подів:
...
      taints:
        - key: your.company.io/workloads
          effect: NoSchedule
        - key: your.company.io/workloads
          effect: NoExecute
...


Навіщо це робиться? Для того, щоб на такі вузли могли зайти лише потрібні поди. Наприклад навантаження, що потребує відеокарт, але очевидно такої конфігурації не потребують всі наявні поди.

Також Karpenter може проставляти початкові стартові тейнти (startup taints) задля того, щоб отримати певний час на додаткову конфігурацію вузлів перед розміщенням корисного навантаження.

• Забезпечує створення необхідних лейблів на новостворених вузлах, щоб таким чином забезпечити старт под на них. Наприклад, якщо нодпул має наступний блок конфігурації:
...
spec:
  ...
  template:
    metadata:
      labels:
        node-type: dynamic
...


То Карпентер підніматиме вузли із лейблом 'node-type: dynamic'. А под, котрий має відповідний nodeselector, або affinity розміститься на ній:
...
      containers:
      - name: inflate
        ...
        nodeSelector:
          node-type: dynamic
...


Разом із taint (приклад, що наведено вище) може бути забезпечена умова, що певна група подів і лише вона зможе розміститись на цих вузлах. Таким чином можна відділити робоче навантаження і навантаження, що генерують контролери та інші сервісні аддони (Karpenter обов'язково має працювати на статичних вузлах).

•  В залежності від необхідної архітектури CPU, що потребує пода, може піднімати відповідні вузли. Якщо деплоймент матиме наступну nodeAffinity:
...
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: kubernetes.io/arch
                operator: In
                values:
                - arm64
...


То наступний nodepool:
...
    spec:
      ...
      requirements:
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64", "arm64"]
...

Вплине на те, що Карпентер підніме arm64 вузол. І відповідно values: ["amd64"] не зробить нічого і буде обслуговувати nodeAffinity лише із amd архітектурою. Та ж сама логіка стосується і інших лейблів, котрі за замовчуванням проставляє Karpenter, зокрема ОС, spot чи on-demand, EC2 покоління тощо. Наприклад
...
nodeSelector:
  topology.kubernetes.io/zone: us-west-2a
  karpenter.sh/capacity-type: spot
...


Такий под підніметься на spot-інстансі в us-west-2a при умові що Karpenter Nodepool дозволяє це.

• Може піднімати вузли в залежності від бажаної дистрибуції подів а межах AZ, самих хостів. Для деалойменту, що має наступні опції в своєму описі
...
      topologySpreadConstraints:
      - labelSelector:
          matchLabels:
            app: inflate
        maxSkew: 1
        topologyKey: topology.kubernetes.io/zone
        whenUnsatisfiable: ScheduleAnyway
      - labelSelector:
          matchLabels:
            app: inflate
        maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: ScheduleAnyway
...


Карпентер відповідно стартуватиме вузли, щоб запезпечити ці topologySpread правила. Тобто буде намагатись одночасно розподіляти їх по зонах доступності та хостнеймах, причому цього разу на етапі нодпула ніяких додаткових правил не потрібно, адже Карпентер буде використовувати лейбли, котрі він сам же і проставляє на всіх вузлах.

Ба більше, Karpenter може гарантувати у відсотковому відношенні розподіл навантаження по spot та on-demand вузлах! Для цього ж використовується той же topologySpreadConstraints проте по іншому topologyKey ключу.

• Може піднімати вузли та EBS томи в однакових AZ. У AWS диск до віртуальної машини EC2 можна підключити лише в тому випадку, якщо він лежить в тій самій AZ. Тож Karpenter відслідковує це і у разі появи нового вузла підніме EBS в тій же зоні доступності. Якщо ж диск вже створений, то EC2 вузол буде піднято в тій же зоні доступності.

Окрім всього цього Нодпули визначають політику заміщення старих вузлів новими, консолідації вузлів задля здешевлення вартості; зупинки вузлів, котрі не мають корисного навантаження тощо. Детальніше про це поговоримо трохи далі, а зараз перейдемо до розмови про Nodeclass-и.

2.2. Nodeclasses

Нодкласи визначають специфічні параметри вузлів кожного хмарного середовиша на якому працює Karpenter. Так як на разі по-факту підтримується лише AWS, то у yaml-описах вони зазначаються як EC2NodeClass. Nodepool має пряме посилання на NodeClass, який використовує:
...
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: generic
...


Але сам NodeClass може одночасно використовуватись різними Нодпулами. Він також потребує додаткову роль, щоб кожен вузол, котрий підняв Karpenter, мав ті ж можливості, що і статичні вузли кластеру: підключення до API Control Plane тощо.

На прикладі Нодкласу, котрий ми застосували вище за допомогою Terraform, розглянемо його можливості та особливості:

• Він відповідальний за вибір AMI для майбутніх вузлів:
...
spec:
  amiSelectorTerms:
    - alias: al2023@v20250505
...

Ми обрали останній на цей момент Amazon Linux 2023, проте amiSelectorTerms має значно ширші можливості. Наприклад, тут можуть бути застосовані логічні операції AND чи OR по тегам імеджів, іменам, id та інше.

Karpeter наполегливо рекомендує відмовитись від @latest варіанта для production середовищ, адже у разі помилки збирання AMI на стороні Амазону це може призвести до інциденту в обслуговуванні.

Останню версію AMI для Нодекласу можна знайти ось так:

$ export K8S_VERSION="1.32"
$ aws ssm get-parameters-by-path --path "/aws/service/eks/optimized-ami/$K8S_VERSION/amazon-linux-2023/" --recursive | jq -cr '.Parameters[].Name' | grep -v "recommended" | awk -F '/' '{print $10}' | sed -r 's/.*(v[[:digit:]]+)$/\1/' | sort | uniq
...
v20250501
v20250505
v20250514


Тут є і варіанти Bottlerocket та AL2. Останній не рекомендовано до використання, адже в версії EKS 1.33 він пересане бути доступний для створення воркерів.

• Описує опції доступності метаданих всередині EC2 інстансу:
...
spec
  metadataOptions:
    httpEndpoint: enabled
    httpPutResponseHopLimit: 2
    httpTokens: required
...


Вони будуть корисні для агентів моніторингу типу Datadog, щоб отримати теги вузла EC2 для своїх метрик. Тут можна прочитати про це більше.

• Може додавати теги до всіх новостворених EC2 вузлів. У Тераформ коді ми описали їх динамічно, що врешті решт перетворюєть в такі теги:
...
spec:
  tags:
    Name: my-eks-eks-dynamic
    cluster-name: my-eks
    node-group: dynamic
    team: devops
...


• Може управляти розміром диску, його типом, продуктивністю тощо, що потім буде підключений до EC2:
...
spec:
  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        deleteOnTermination: true
        encrypted: true
        throughput: 250
        volumeSize: 30Gi
        volumeType: gp3
...

Для Amazon Linux достатньо 1 диску, проте Bottlerocket AMI потребує 2 диски для роботи: один для control volume (в режимі тільки для читання), а інший для самих імеджів та логів.

• Має перелік сабнетів для розміщення вузлів.
У нашому випадку це ті ж сабнети, що і статичні воркери:
...
spec:
  subnetSelectorTerms:
  - id: subnet-05722f9c81bf38622
  - id: subnet-02bdad92b264d27f6
  - id: subnet-08d3e419f1bd24f83
...


• Вказує Security Group-и для EC2 інстансів:
...
spec
  securityGroupSelectorTerms:
  - id: sg-046434eb75957a578
...


Це лише основний список опцій, що ми використали в нашому прикладі, проте можливості значно ширші за ці і про них можна прочитати в офіційній документації.


2.3. Nodeclaims

На відміну від двох попередніх, Нодклейми є незмінними об'єктами і призначені більше для управління життєвим циклом створених вузлів та відстеження їх стану. Це результат роботи Нодпулу та Нодкласу.

Коли щось пішло не так, то можна викликати describe на потрібний Nodeclaim:

$ kubectl get nodeclaims
$ kubectl describe nodeclaim <nodeclaim>


Видалення Нодклейма призведе до видалення вузла, котрий був ним створений.


3. DISRUPTION PROCESS

У останній частині поговоримо як Karpenter проводить заміну вузлів, які процеси за цим стоять та що він робить для того, щоб якомога зменшити час можливих простоїв у обслуговування.

Отже, Karpenter постійно слідкує за тим, щоб кластер був максимально ефективним (звісно власні додані правила накладають особливості) в контексті вартості, безпеки та відповідності заданій конфігурації. Для виконання цих вимог постійно працюють два контролери: Disruption та Termination. Disruption контролер слідкує за тим, які вузли можна вивести із обслуговування і за потреби замінити на інший (виконавши попереднє підняття наступника), а Termination займається безпечною зупинкою попередніх вузлів.

Процес заміни вузлів чи їх видалення доволі витончений. Disruption контролер замінює вузли із причин Дріфта (зміна в конфігурації вузлів/нодпулів) чи Консолідації (виведення вузлів задля ефективності пакування робочих навантажень). Причому по першій причині Karpenter відпрацьовує першим та не працює по всім причинам одночасно. Якщо коротко, то Disruption контролер працює наступним чином:

  • Збирає масив вузлів, котрий необхідно вивести із обслуговування. Якщо таких вузлів немає чи на них працюють поди, котрі не можна виселити, контролер почне робити все з початку пізніше.
  • У разі знаходження такої ноди чи нод, Disruption контролер перевіряє чи не порушує їх видалення бюджети виведення/заміни вузлів (disruption budgets) та запускає симуляції планування розміщення для перевірки потреби в заміні чи піднятті нових вузлів.
  • Додає тейнт karpenter.sh/disrupted:NoSchedule до вузлів, котрі контролер прагне вивести із кластеру чи замінити, щоб нові поди тим часом не змогли стартувати на такому вузлі
  • Стартує всі вузли, котрі були прораховані у попередній симуляції і чекає допоки вони отримають статус Ready. Якщо ж цей процес не закінчиться успішно, то Disruption контролер прибере попередньо проставлений тейнт і увесь процес почнеться із самого початку.
  • Видаляє всі ноди, заміну яким він вже знайшов. Проте завдяки фіналайзеру, котрий проставляє сам Karpenter при старту вузлів, нода буде в статусі Terminating зі всіма робочими подами, допоки Termination контролер не виконає свою роботу із безпечного видалення вузла.
  • Після безпечного видалення одного чи декілька вузлів, Disruption контролер починає все спочатку.

Отже тепер м'яч на стороні Termination контролера і він береться за свою роботу:

  • Додає taint karpenter.sh/disrupted:NoSchedule до всіх вузлів, котрі планує видалити, що зупиняє kube-scheduler від старту под на таких потужностях. Він повторює цю дію за Disruption контролером, адже в деяких моментах Termination контролер стартує одночасно із ним.
  • Починає виселення под із цих вузлів, для чого використовує Kubernetes Eviction API, зокрема притримуючись виставлених PDB подів, ігноруючи роботу статичних подів (різноманітні поди в межах деймонсетів на кшталт fluent-bit, aws-node, csi-контролерів тощо); подів, котрі ігнорують тейнт, що був виставлений у попередньому пункті та подів, що вже відпрацювали і знаходяться в статусі succeeded/failed.
  • Термінейтить нодклейм, що був відповідальний за створення та життєвий цикл цього вузла, через API хмарного провайдера.
  • Видаляє finalizer із ноди, після чого її повне видалення дозволене для kube-api

Завдяки фіналайзеру навіть ручне видалення вузла чи нодклейма, котрий відповідальний за цей вузол, відбудеться безпечне видалення вузла Termination контролером.

Карпентер категоризує 2 автоматизовані методи для процесу заміщення/видалення (disruption) вузлів:

Безпечні (Graceful). У цьому випадку попередньо стартують нові вузли на заміну, а швидкість таких заміщень може бути регульована додатковими бюджетами, про котрі поговоримо пізніше. До цих методів входять: 

  • Консолідація (Consolidation). Основна ціль цього процесу - зменшити вартість вузлів кластеру. Щоб зробити це Карпентер може видалити вузли при наступних умовах: якщо вони порожні (тобто не обслуговують корисне навантаження), якщо їхні поди можна розмістити на інших вузлах зі збереженням всіх попередньо вказаних умов (наприклад topologySpread чи (anti-)affinity), та у разі якщо їх можна замінити дешевшими (наприклад, раніше була потреба у більш потужнішому вузлі, бо на ньому тимчасово працювали інші поди). Для управління консолідацією є два параметри: consolidationPolicy (визначає чи можна консолідувати вузли, котрі, на думку Карпентара, слабо утилізовані) та consolidateAfter (через яку кількість часу після останнього старту чи зупинки поду можна запускати консолідацію). Для спот-інстансів за-замовчуванням не дозволені консолідації із заміною дорожчого інстансу на дешевший, адже це вносить додаткові ризики, якщо в процесі AWS вирішить відібрати вузол. Опціонально це можна дозволити.
  • Дрифт (Drift). Карпентер запустить перестворення вузлів у разі зміни певних параметрів Нодкласу/Нодпула (тегів, базової AMI, зміни типів інстансів тощо).

• Примусові (Forceful). Цього разу термінейт вузлів починається одразу, до готовності нових у якості заміни. Рейтлімітити ці методи не можна, окрім PDB подів у деяких випадках.

  • Закінчення терміну дії (Expiration). Вузол буде змушений піти на заміну після закінчення терміну spec.template.spec.expireAfter вказаному на кожному із вузлів (значення цього параметру за-замовчуванням рівний 720 годин). Без задання параметра terminationGracePeriod, поди із анотацією karpenter.sh/do-not-disrupt завжди блокуватимуть видалення нод, на котрих працюють фізично. Саме тому ці два параметри рекомендують вказувати в парі.
  • Переривання (Interruption). Це примусові методи, що виникли по причині вилучення spot-інстансів, подій технічного обслуговування чи термінету/зупинки вузлів. Коли Карпентер виявляє ці події, він (точніше його Termination контролер) одразу починає виселяти поди із таких вузлів задля максимізації часу на підняття нових та переселення подів. У такі моменти terminationGracePeriodSeconds для подів може не бути витриманий повністю за браком доступного часу, особливо якщо його значення доволі високе. Наприклад, AWS перед вилученням spot-вузлів дає лише 2 хвилини, що іноді може бути замало. Ці переривання працюють через SQS-чергу, котру було додано до конфігурації на етапі інсталяції Карпентера в першій частині статті. В цю чергу пересилаються події AWS сервісів через EventBridge.
  • Автоматичне відновлення вузлів (Node Auto Repair). Функціонал, що наразі перебуває в статусі альфа, вимкнений за замовчуванням та потребує додаткового агента. У разі збоїв заліза, файлових систем тощо, Karpenter терміново виведе такі вузли із кластеру, пропускаючи механізми, що обслуговують Termination та Disruption контролери.

Як вже було згадано, у випадку безпечних методів заміщення вузлів (Automated Graceful Methods) можна впливати на швидкість таких заміщень і опція budgets допомагає і цим. Ці методи поділяються на Косолідацію та Дрифт. Консолідація може відбуватись якщо вузол перестав бути корисним (тобто на ньому вже відсутнє корисне навантаження) чи якщо його можна замінити на менший та дешевший. Тому budget і оперує 3 опціями: Empty, Underutilized та Drifted.

Уявімо ситуацію, що ми хочемо заборонити будь-які заміщення вузлів в робочий час (по UTC) окрім по причині Empty і дозволити всі заміщення (Empty, Drifted та Underutilized) і всі інші години. Для цього правимо блок budgets в Нодпулі:
...
  disruption:
    budgets:
    - nodes: "1"
      reasons:
      - Empty

    - duration: 9h
      nodes: "0"
      reasons:
      - Drifted
      - Underutilized
      schedule: 0 8 * * mon-fri

    - duration: 15h
      nodes: "1"
      reasons:
      - Drifted
      - Underutilized
      schedule: 0 17 * * mon-fri

    - duration: 24h
      nodes: "1"
      reasons:
      - Drifted
      - Underutilized
      schedule: 0 0 * * sat,sun
...


Доволі очевидний/явний графік. Ці ж бюджети можна описати більш лаконічно, проте менш очевидно:
...
disruption:
  budgets:
  - nodes: "1"

  - duration: 9h
    nodes: "0"
    reasons:
    - Drifted
    - Underutilized
    schedule: 0 8 * * mon-fri
...

Перше правило дозволяє заміщувати по 1 ноді за раз по всім 3 причинам, друге правило обмежує це робити по причинам Drifted та Underutilized. Отже вузли будуть завжди мати можливість видалятись, якщо вони будуть Empty.

Бюджети, як на мене, мають дещо неочевидний вигляд. Тому перед їх використанням я б рекомендував їх детально потестити в non-production середовищах.

Посилання:
https://karpenter.sh/v1.4/
https://community.aws/content/2dhlDEUfwElQ9mhtOP6D8YJbULA/run-kubernetes-clusters-for-less-with-amazon-ec2-spot-and-karpenter#step-6-optional-simulate-spot-interruption
https://www.cncf.io/blog/2024/11/06/karpenter-v1-0-0-beta/
https://github.com/kubernetes-sigs/karpenter/tree/main/designs
https://rtfm.co.ua/karpenter-vikoristannya-disruption-budgets/ 
https://www.slideshare.net/slideshow/adopting-karpenter-for-cost-and-simplicity-at-grafana-labspdf/264376667 
https://github.com/aws/karpenter-provider-aws/tree/main/examples/v1
https://grafana.com/blog/2023/11/09/how-grafana-labs-switched-to-karpenter-to-reduce-costs-and-complexities-in-amazon-eks/

Немає коментарів:

Дописати коментар