Вместо вступления: не нашел ничего подобного на Habr про Karpenter, поэтому решил сделать репост с другой площадки, в целях распространения годных тем. Репост сделан с позволения автора(Victor Vedmich). Продолжение на youtube: Готовим Karpenter: глубокое погружение.
UPD: Будет следующая часть от меня, где мы рассмотрим в деталях шаги по установке и настройке.
Введение
Kubernetes в большинстве случаев является стандартом де-факто, когда мы выбираем оркестратор контейнеров. Развернув Kubernetes в облаке, мы получаем возможность масштабировать рабочие ресурсы по мере необходимости при помощи Kubernetes cluster autoscaler механизма. Karpenter был представлен re:Invent 2021 как продукт с открытым исходным кодом, который подходит к процессу масштабирования k8s кластеров по-другому в сравнении с ClusterAutoscaler.

Как работает Karpenter
Давайте вкратце вспомним, как именно работает Cluster Autoscaler, какие компоненты нужны и что происходит во время масштабирования.
Cluster Autoscaler мониторит, есть ли в кластере поды, которые находятся в состоянии ожидания (т.к. планировщик не может найти инстанс с достаточным количеством ресурсов для развертывания нагрузки). Ёмкость кластера уже закончилась, поэтому cluster autoscaler обращается к реализации облачного провайдера (в нашем случае AWS) k8s, который, оперируя абстрактной группой инстансов, будет взаимодействовать с API облака для запроса новых ресурсов в группе автомасштабирования Auto Scaling groups (ASG). И процесс будет повторяться, если найдутся новые поды в состоянии ожидания.
Таким образом, cluster autoscaler может увеличивать или уменьшать количество инстансов в управляемых группах через Amazon EC2 Auto Scaling Groups (ASG). К примеру, наше приложение А требовало до 1 CPU и 1 GiB памяти, а приложение Б — 8CPU и 16 GiB памяти. Если у вас в одном кластере живет множество разных приложений (веб приложения, аналитика, базы данных и т.д.) и необходимо добиться максимальной эффективности использования ресурсов, то для каждого типа нагрузки необходимо создать свою группу. В противном случае можем оказаться в ситуации, что тип инстанса в группе авто масштабирования довольно большого размера, и множество ресурсов может простаивать. Чтобы этого избежать, вам нужно будет создать разные группы под разные типы нагрузки.
Karpenter масштабирует не используя абстракцию в виде группы инстансов (ASG) и взаимодействует напрямую с API облака для запроса флота EC2 инстансов (EC2 Fleet) c необходимым количеством ресурсов. Это убирает необходимость управлять группами узлов, и запрос на N-ое количество ресурсов будет удовлетворён максимально эффективно.

Но давайте лучше посмотрим на примере как это происходит.
Пример масштабирования
Предположим, в кластер приходит три запроса на размещение нагрузки — все суммой на 100 vCPU и 100GiB RAM, но размер подов в каждом случае будет разный. В первом случае мы запустим 400 подов, где каждой поде необходимо 0.25 vCPU и 256MiB RAM, во втором — 100 подов с конфигурацией 1vCPU и 1GiB RAM, и в последнем — 10 подов с запросом на ресурсы в 10 vCPU и 10GiB RAM соответственно. И для экономии средств будем использовать только Spot инстансы.
Karpenter
Для эксперимента давайте установим Karpenter на EKS кластер. Предварительно установим Kubernetes Operational View (https://codeberg.org/hjacobs/kube-ops-view) для визуализации распределения нагрузки. Установка Kapenter-а довольно простая, с ней можно ознакомиться на официальном сайте https://karpenter.sh/ . Итак, мы создали все необходимые IAM ресурсы и установили Karpenter. Пришло время конфигурации поставщика ресурсов (provisioner) в Karpenter-е. Начнем с конфигурации по умолчанию, с небольшим изменением, будем запускать только spot инстансы, без указания семейства инстансов.
apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
name: default
spec:
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot"]
provider:
subnetSelector:
karpenter.sh/discovery: ${CLUSTER_NAME}
securityGroupSelector:
karpenter.sh/discovery: ${CLUSTER_NAME}
ttlSecondsAfterEmpty: 30
В первом случае, Karpenter, приняв запрос на 400 подов с суммарной нагрузкой 100 vCPU и 100GiB RAM, обратится в API на получение флота инстансов. Получив список доступных, выбирает минимальное количество инстансов, которые оптимально разместят нагрузку. Итого поднимается два инстанса (m5d.16xlarge и m5.12xlarge) по 218 и 192 подов соответственно.
controller.provisioning Batched 218 pods in 10.000379752s {"commit": "7e79a67", "provisioner": "default"}
controller.provisioning Computed packing of 1 node(s) for 218 pod(s) with instance type option(s) [c6i.16xlarge c5a.16xlarge c6a.16xlarge c5ad.16xlarge m5d.16xlarge m5n.16xlarge m5a.16xlarge m6i.16xlarge m5ad.16xlarge m5.16xlarge m5dn.16xlarge m6a.16xlarge r4.16xlarge r5a.16xlarge r5ad.16xlarge r5b.16xlarge r5dn.16xlarge r5n.16xlarge r5.16xlarge] {"commit": "7e79a67", "provisioner": "default"}
ontroller.provisioning Launched instance: i-025f180219270c4d7, hostname: ip-192-168-140-25.eu-west-1.compute.internal, type: m5d.16xlarge, zone: eu-west-1c, capacityType: spot {"commit": "7e79a67", "provisioner": "default"}
controller.provisioning Bound 218 pod(s) to node ip-192-168-140-25.eu-west-1.compute.internal {"commit": "7e79a67", "provisioner": "default"}
controller.provisioning Waiting for unschedulable pods {"commit": "7e79a67", "provisioner": "default"}
controller.provisioning Batched 192 pods in 6.586968147s {"commit": "7e79a67", "provisioner": "default"}
controller.provisioning Computed packing of 1 node(s) for 181 pod(s) with instance type option(s) [c5a.12xlarge c6i.12xlarge c5ad.12xlarge c5.12xlarge c6a.12xlarge c5d.12xlarge m5n.12xlarge m5dn.12xlarge m5ad.12xlarge m6a.12xlarge m5d.12xlarge m5a.12xlarge m6i.12xlarge m5zn.12xlarge m5.12xlarge r5d.12xlarge r5a.12xlarge r5.12xlarge r6i.12xlarge r5n.12xlarge] {"commit": "7e79a67", "provisioner": "default"}
controller.provisioning Launched instance: i-0eb01306917b65c97, hostname: ip-192-168-172-9.eu-west-1.compute.internal, type: m5.12xlarge, zone: eu-west-1b, capacityType: spot {"commit": "7e79a67", "provisioner": "default"}
controller.provisioning Bound 192 pod(s) to node ip-192-168-172-9.eu-west-1.compute.internal {"commit": "7e79a67", "provisioner": "default"}
controller.provisioning Waiting for unschedulable pods {"commit": "7e79a67", "provisioner": "default"}
На картинке представлено распределение ресурсов, справа нода, где работает Karpenter, слева две ноды, которые были запрошены для размещения нагрузки.

Во втором случае (100 подов с конфигурацией 1vCPU и 1GiB RAM), Karpenter, увидев запрос, сделает аналогичный запрос в EC2 API для проверки наличия инстанса, способного разместить запрашиваемую нагрузку. Теперь мы видим из лога, Karpenter показывает, какие типы инстансов доступны и смогут удовлетворить запрос: c6i.32xlarge m6i.32xlarge, m6a.32xlarge, r6i.32xlarge, m6a.48xlarge. Karpenter выбирает m6a.32xlarge и запускает инстанс, а планировщик запускает все 100 подов на одной довольно большой машине.
controller.provisioning Batched 100 pods in 4.919887556s {"commit": "7e79a67", "provisioner": "default"}
controller.provisioning Computed packing of 1 node(s) for 100 pod(s) with instance type option(s) [c6i.32xlarge m6i.32xlarge m6a.32xlarge r6i.32xlarge m6a.48xlarge] {"commit": "7e79a67", "provisioner": "default"}
controller.provisioning Launched instance: i-04cac3be70e060218, hostname: ip-192-168-161-216.eu-west-1.compute.internal, type: m6a.32xlarge, zone: eu-west-1b, capacityType: spot {"commit": "7e79a67", "provisioner": "default"}
controller.provisioning Bound 100 pod(s) to node ip-192-168-161-216.eu-west-1.compute.internal {"commit": "7e79a67", "provisioner": "default"}
controller.provisioning Waiting for unschedulable pods {"commit": "7e79a67", "provisioner": "default"}


Cluster Autoscaler
Т.к. мы хотим использовать только Spot инстансы, будем использовать конфигурацию управляемой группы инстансов:
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: ${CLUSTER_NAME}
region: eu-west-1
managedNodeGroups:
- name: spot-ng
instanceTypes: ["m5.xlarge", "m5d.xlarge", "m5a.xlarge", "m5n.xlarge"]
spot: true
minSize: 1
maxSize: 100
desiredCapacity: 1
labels: {role: worker}
Как видно из конфигурации, мы будем использовать типы инстансов с 4vCPU и 8GiB RAM. Для обработки запроса cloud autoscaler рассчитает необходимое количество инстансов, отправит запрос облачному провайдеру, а тот в свою очередь — в API облака, запрос необходимого количества инстансов. Для нашего эксперимента у нас получиться следующая картина.
В первом примере с 400 подами с нагрузкой 100vCPU 100GiB RAM поднято 27 инстансов с примерным распределением 15 подов на каждую ноду.

В случае с 100 подами с нагрузкой 100vCPU 100GiB RAM Cluster Autoscaler поднял уже 34 ноды, и эффективность использования ресурсов упало: на каждую ноду получилось разместить только по 2-3 поды.

И последний тест: запускаем 10 подов с запросом на ресурсы 10 vCPU и 10GiB RAM. Несложно догадаться, что поды не выйдут из состояния ожидания до тех пор, пока не будет создана новая группа инстансов (ASG), в которой тип инстанса сможет разместить запрашиваемую нагрузку.
Downscale
Обработали запрос, и как только последняя рабочая нагрузка (не daemonset) перестанет работать на узле, нужно освободить неиспользуемые ресурсы. С этого момента Karpenter запускает таймер TTL (ttlSecondsAfterEmpty) — параметр, заданный в провайдере, — после чего отключит ноду из кластера, а затем и вовсе удалит ее.
controller.node Added TTL to empty node {"commit": "7e79a67", "node": "ip-192-168-161-216.eu-west-1.compute.internal"}
controller.node Triggering termination after 30s for empty node {"commit": "7e79a67", "node": "ip-192-168-161-216.eu-west-1.compute.internal"}
controller.termination Cordoned node {"commit": "7e79a67", "node": "ip-192-168-161-216.eu-west-1.compute.internal"}
controller.termination Deleted node {"commit": "7e79a67", "node": "ip-192-168-161-216.eu-west-1.compute.internal"}
А как будет себя вести Karpenter, если мы уменьшим нагрузку (scale in) после запуска, например, с 400 подов до 200? Мы увидим такую картину:

В данной ситуации Cloud Autoscaler будет эффективнее. Но размещение всей нагрузки на одном или двух инстансах кажется не лучшей идеей, особенно если следовать лучшим практикам AWS Well-Architecture Framework. Настройка Karpenter поставщика ресурсов (provisioner) и внутренние механизмы Kuberentes помогут нам.
Конфигурирование поставщика ресурсов (provisioner ) Karpenter
С помощью поставщика ресурсов мы можем устанавливать ограничения на типы виртуальных машин, зоны доступности (AZ), архитектуру процессоров и типы покупки машин (on-demand или spot), время ожидания перед исключением узла из кластера и т.д. Karpenter не будет работать, пока не будет создан хотя бы один Provisioner.
apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
name: default
spec:
ttlSecondsAfterEmpty: 30
taints:
- key: example.com/special-taint
effect: NoSchedule
labels:
billing-team: my-team
requirements:
- key: "node.kubernetes.io/instance-type"
operator: In
values: ["m5.large", "m5.2xlarge"]
- key: "topology.kubernetes.io/zone"
operator: In
values: ["us-west-2a", "us-west-2b"]
- key: "kubernetes.io/arch"
operator: In
values: ["arm64", "amd64"]
- key: "karpenter.sh/capacity-type" # по-умолчанию будет тип машин on-demand
operator: In
values: ["spot", "on-demand"]
limits:
resources:
cpu: "1000"
memory: 1000Gi
Как выше мы рассмотрели, запустить все поды на одном инстансе может быть не лучшим решением. Теперь рассмотрим, как в этом случае поставщик ресурсов и сам Kubernetes может нам помочь. Первое, что мы можем сделать в конфигурации поставщика ресурсов, — это указать список типов инстансов, с которыми мы хотим работать. Затем в спецификации нагрузки добавляем секцию topologySpreadConstraints. Например, конфигурация не более 30 подов на одну ноду:
spec:
terminationGracePeriodSeconds: 0
containers:
- name: inflate
image: public.ecr.aws/eks-distro/kubernetes/pause:3.2
resources:
requests:
cpu: "250m"
memory: "256Mi"
topologySpreadConstraints:
- maxSkew: 30
topologyKey: "kubernetes.io/hostname"
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: inflate

Таким образом, мы распределим нагрузку между нодами. Другие ограничения, которые вы можете задать для вашей нагрузки, смотрите на официальном сайте.
Поставщиков ресурсов (provisioner) в кластере может быть множество, например, для разных окружений или команд. Поэтому есть несколько ключевых моментов, которые необходимо знать:
Karpenter не будет ничего делать, если не настроен хотя бы один provisioner;
Каждый настроенный provisioner обрабатывается Karpenter-ом.
Если Karpenter обнаруживает несовместимость с запрашиваемой нагрузкой (pod), он не будет использовать этот provisioner для запрашиваемой нагрузки. Например, вы указали в спецификации вашего provisioner, что работаете только on-demand инстансами. А в спецификации нагрузки, — что хотите запустить только на spot инстансах. В этом случае Karpenter не сможет представить вам запрашиваемые ресурсы.
Рекомендуется создавать взаимоисключающие provisioners, чтобы ни одна нагрузка не соответствовала нескольким provisioner-ам, в противном случае Karpenter будет случайным образом выбирать, какой provisioner использовать.
Заключение
Karpenter — это новый подход к масштабированию kubernetes кластеров со своими особенностями, которые необходимо учитывать. Если у вас Kubernetes используется для размещения множества разных типов нагрузок и вам нужно быстро и эффективно запускать большое количество ресурсов, Karpenter будет оптимальным решением. При этом учитывайте текущие ограничения: Karpenter может запросить очень большой инстанс, а k8s планировщик разместит всю нагрузку на одной ноде.
Нашел отличную таблицу Cluster Autoscaler vs Karpenter
Feature | Cluster Autoscaler | Karpenter |
Resource Management | Использует "реактивный" подход для масштабирования нод, основываясь на данных об утилизации используемых нод. | Использует "проактивный" подход для масштабирования нод, основываясь на данных о не запланированных нодах. |
Node management | Масштабирует ноды согласно заранее созданным autoscaling группам. | Масштабирует ноды согласно настройками и вручную созаднным провизионерам(Provisioners) |
Scaling | Ориентирован на масштабирование на уровне нод. Может эффективно добавлять больше нод для удовлетворения растущего спроса. Но это также означает, что он менее эффективный при уменьшении (down scale)масштаба ресурсов. | Предлагает более эффективные и детализированные функции масштабирования, основанные на конкретных требованиях к рабочей нагрузке(workload). Другими словами, Karpenter масштабируется в соответствии с фактическим использованием. Он также позволяет пользователям указывать конкретные политики или правила масштабирования в соответствии со своими требованиями. |
Scheduling | Планирование становится более простым, поскольку оно предназначено для увеличения или уменьшения масштабирования. | Эффективно планирует рабочие нагрузки(workload) на основе различных факторов, таких как зоны доступности и потребности(resource requirements) в ресурсах. Он может попытаться оптимизировать расходы с помощью использования spot инстансов. |
реактивный vs проактивный подход
Реактивный подход реагирует на проблемы или изменения по мере их возникновения.
Проактивный подход характерен тем, что прогнозирует потенциальные проблемы до их возникновения и старается их предотвратить.