Pull to refresh

Karpenter — умное масштабирование Kubernetes кластера

Level of difficultyMedium
Reading time10 min
Views2.3K

Вместо вступления: не нашел ничего подобного на Habr про Karpenter, поэтому решил сделать репост с другой площадки, в целях распространения годных тем. Репост сделан с позволения автора(Victor Vedmich). Продолжение на youtube: Готовим Karpenter: глубокое погружение.

UPD: Будет следующая часть от меня, где мы рассмотрим в деталях шаги по установке и настройке.

Введение

Kubernetes в большинстве случаев является стандартом де-факто, когда мы выбираем оркестратор контейнеров. Развернув Kubernetes в облаке, мы получаем возможность масштабировать рабочие ресурсы по мере необходимости при помощи Kubernetes cluster autoscaler механизма. Karpenter был представлен re:Invent 2021 как продукт с открытым исходным кодом, который подходит к процессу масштабирования k8s кластеров по-другому в сравнении с ClusterAutoscaler.

ClusterAutoscaler
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, слева две ноды, которые были запрошены для размещения нагрузки.

Cправа нода, где работает Karpenter, слева две ноды, которые были запрошены для размещения нагрузки
Cправа нода, где работает 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"}
Все 100 подов на одной довольно большой машине
Все 100 подов на одной довольно большой машине
10 подов с запросом на ресурсы 10 vCPU и 10GiB RAM
10 подов с запросом на ресурсы 10 vCPU и 10GiB RAM

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 подов на каждую ноду.

27 инстансов с примерным распределением 15 подов на каждую ноду
27 инстансов с примерным распределением 15 подов на каждую ноду

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

10 подов с запросом на ресурсы 10 vCPU и 10GiB RAM
10 подов с запросом на ресурсы 10 vCPU и 10GiB RAM

И последний тест: запускаем 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? Мы увидим такую картину:

Уменьшим нагрузку (scale in) после запуска, например, с 400 подов до 200
Уменьшим нагрузку (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
15 инстансов и планировщик Kuberentes разместит на каждой ноде не более 30 подов
15 инстансов и планировщик Kuberentes разместит на каждой ноде не более 30 подов

Таким образом, мы распределим нагрузку между нодами. Другие ограничения, которые вы можете задать для вашей нагрузки, смотрите на официальном сайте.

Поставщиков ресурсов (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 проактивный подход

Реактивный подход реагирует на проблемы или изменения по мере их возникновения.

Проактивный подход характерен тем, что прогнозирует потенциальные проблемы до их возникновения и старается их предотвратить.

Tags:
Hubs:
Total votes 5: ↑4 and ↓1+3
Comments3

Articles