Karpenter + Cluster API: автомасштабирование узлов на OVH

Karpenter + Cluster API

Одна из главных сильных сторон Kubernetes — возможность управлять ресурсами эластично. Если stateless-под потребляет слишком много, можно добавить реплики; если нагрузка нерегулярная — навесить HPA (Horizontal Pod Autoscaler), и он подстроится под неё. Кластер переполнен? Добавьте узлы.

Но если автомасштабирование реплик происходит автоматически, то с узлами всё не так просто.

В своей статье о Cluster API я закончил небольшим анонсом:

«Мне ещё есть что проверить в CAPI, в частности — интеграцию программы для управления автомасштабированием (или даже Karpenter) […]»

Прошло несколько месяцев, и вот мы наконец добрались до автомасштабирования кластера Kubernetes, развёрнутого через Cluster API.

Для автомасштабирования узлов существуют два подхода:

  • Cluster Autoscaler — решение, созданное специально для работы с CAPI.

  • Karpenter — решение, которое AWS передала в CNCF; у него тоже есть интеграция с CAPI.

Какие решения существуют для автомасштабирования

Оба умеют добавлять и удалять узлы, но исповедуют противоположные философии. Сравним их, прежде чем перейти к PoC.

Cluster Autoscaler мыслит в терминах групп машин: вы даёте ему группы узлов (MachineDeployments — рекомендую прочесть мою статью, где подробно разобрана эта концепция), а он регулирует количество реплик. Если под нигде не помещается, счётчик реплик совместимой группы увеличивается на единицу — и новый узел вступает в кластер.

Karpenter, напротив, мыслит в терминах групп подов. Он смотрит на поды в состоянии Pending, вычисляет наиболее подходящую (и дешёвую) машину для их размещения и сразу же её подготавливает. Никаких заранее заданных групп узлов: вы описываете ограничения (NodePool), а Karpenter сам подбирает наиболее подходящую машину (в том числе Spot-инстансы, которые существуют ограниченное время и стоят очень дёшево).

Исторически Karpenter был очень тесно привязан к AWS, но после передачи в CNCF ядро проекта (sigs.k8s.io/karpenter) стало агностичным, и появились сторонние провайдеры. В том числе один, который меня особенно интересует:

karpenter-provider-cluster-api — он выступает клеем между Karpenter и облачными провайдерами, совместимыми с CAPI (использование CAPI охватывает максимальное количество провайдеров, включая OVH; другие облака тоже разрабатывают собственные провайдеры Karpenter для своих управляемых предложений).

Важный нюанс перед тем, как идти дальше: нативно (на AWS, Azure, GCP) Karpenter работает самостоятельно. Он живёт внутри масштабируемого кластера и напрямую обращается к облачному API без каких-либо сторонних кластеров — в этом, собственно, и состоит весь смысл: никакого управляющего кластера. Но здесь, работая через провайдер cluster-api, Karpenter управляет уже не облаком, а ресурсами CAPI, которым нужно где-то жить. Поэтому мы снова вводим управляющий кластер, которого нативная работа Karpenter не требует. Ниже поговорим о том, что это влечёт за собой.

Как работает Karpenter?

Разберём подробнее, как Karpenter функционирует внутри Kubernetes-кластера, и какие ресурсы (CRD) он предоставляет:

NodePool

набор правил. Описывает, что Karpenter вправе создавать: в виде requirements (архитектура, capacity-type, зона, допустимые типы инстансов…), глобальных limits (например, «не более 20 vCPU суммарно») и политики down-scaling. Ссылается на NodeClass.

NodeClass

специфичная для провайдера часть. На AWS EC2NodeClass описывает AMI, подсети, группы безопасности, IAM-профиль… Здесь, поскольку мы делегируем всё Cluster API, у нас есть ClusterAPINodeClass.

NodeClaim

конкретный запрос на машину. Когда Karpenter решает, что нужен новый узел, он не создаёт виртуальную машину напрямую — он создаёт NodeClaim, декларативный объект, соответствующий запросу, который будет отправлен провайдеру. Дальше уже провайдер материализует его (на AWS — это EC2-инстанс; у нас — увеличение числа реплик MachineDeployment в CAPI).

Если кратко описать жизненный цикл узла Karpenter, он выглядит так:

  • Планировщик Karpenter наблюдает за непланируемыми подами (в состоянии Pending из-за нехватки ресурсов или узлов);

  • он группирует их пачками в небольшом временном окне, чтобы не создавать по машине на каждый под;

  • для каждой пачки имитирует планирование так, как это делал бы Kubernetes (учитывая requests по CPU/памяти, affinities, taints/tolerations, topologySpreadConstraints), и определяет наиболее подходящий размер узла: сколько CPU, RAM, какая архитектура…;

  • среди размеров, совместимых с NodePool, выбирает самую дешёвую машину, которая справится с задачей, и создаёт NodeClaim;

  • провайдер превращает этот NodeClaim в реальную машину (например, EC2-инстанс на AWS или Machine в CAPI в нашем случае).

Cluster Autoscaler просто увеличивает счётчик реплик заранее заданной группы и повторяет это по кругу, тогда как Karpenter отталкивается от реальных потребностей и подбирает машину. Поэтому он может выбирать из очень разных типов машин в зависимости от нагрузки — например, узлы с GPU, когда под его требует.

Масштабирование вниз

Если только добавлять узлы и никогда не убирать, пользы от этого немного.

Сильная сторона Karpenter — активное управление масштабированием вниз, которое он называет disruption (вытеснение). Оно запускается несколькими механизмами:

  • Консолидация (Consolidation): если узлы пусты или недоиспользуются, Karpenter перемещает поды и удаляет лишние машины. Это и есть механизм масштабирования вниз (вплоть до нуля). Две политики: WhenEmpty (удаляем только полностью пустые узлы) и WhenEmptyOrUnderutilized (удаляет недоиспользуемые узлы).

  • Дрейф (Drift): если фактическая конфигурация узла больше не соответствует тому, что описывают его NodePool/NodeClass, Karpenter считает, что узел не в желаемом состоянии, и пересоздаёт его.

  • Истечение срока жизни (expireAfter): принудительная ротация узлов по истечении заданного времени (для ротации, обновления образов).

Создание управляющего кластера

Чтобы использовать ClusterAPI, нужен «родительский» кластер, который будет разворачивать «дочерние». Подойдёт любой кластер при условии, что у него есть доступ к API-Server и к узлам (например, для отправки конфигурации по SSH).

Для управляющего кластера я использую Managed Kubernetes (MKS) от OVH — это сильно упрощает PoC. OpenTofu позволяет легко уничтожить и пересоздать окружение (а поскольку я никогда не пишу статьи за один присест, это помогает экономить кредиты).

resource "ovh_cloud_project_kube" "mgmt" {
  service_name  = var.service_name # = OS_PROJECT_ID
  name          = "capi-mgmt"
  region        = "GRA9"
  update_policy = "MINIMAL_DOWNTIME"
}
resource "ovh_cloud_project_kube_nodepool" "mgmt" {
  service_name  = var.service_name
  kube_id       = ovh_cloud_project_kube.mgmt.id
  name          = "default"
  flavor_name   = "b3-8" # 2 vCPU / 8 GB
  desired_nodes = 2
  min_nodes     = 2
  max_nodes     = 2
}

Кластер MKS служит лишь платформой для контроллеров CAPI. При этом рабочий кластер (тот, которым будет управлять CAPO — Cluster Api Provider Openstack) совсем не обязан находиться в том же регионе. Как только MKS готов и получен kubeconfig, устанавливаем на него провайдеры CAPI:

tofu output -raw kubeconfig > kubeconfig
export KUBECONFIG=$PWD/kubeconfig
kubectl apply --server-side \
  -f https://github.com/k-orc/openstack-resource-controller/releases/download/v2.6.0/install.yaml
clusterctl init --infrastructure openstack

Перед clusterctl init необходимо установить зависимость ORC (OpenStack Resource Controller), которая представляет ресурсы OpenStack в виде согласованных (reconciled) объектов Kubernetes. Она стала обязательной зависимостью CAPO: если не установить её заранее, контроллер будет падать в цикл с ошибкой no matches for kind "Image" in version "openstack.k-orc.cloud/v1alpha1". Отсюда и kubectl apply для ORC перед clusterctl init.

Развёртывание дочернего кластера

Иметь управляющий кластер — это хорошо, но именно его масштабировать не нужно; нужен дочерний кластер, который будет полностью развёрнут через CAPO (а не HCL — так гораздо удобнее 😎).

apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
kind: OpenStackCluster
metadata:
  name: capi-ovh
spec:
  identityRef:
    cloudName: ovh
    name: capi-ovh-cloud-config
  apiServerLoadBalancer:
    enabled: true
  externalNetwork:
    id: 6d041167-5863-4cad-a165-d352bb6720ab # Ext-Net EU-WEST-PAR
  managedSecurityGroups: {}
  managedSubnets:
    - cidr: 10.6.0.0/24
      dnsNameservers:
        - 1.1.1.1
---
apiVersion: cluster.x-k8s.io/v1beta2
kind: Cluster
metadata:
  name: capi-ovh
spec:
  clusterNetwork:
    pods:
      cidrBlocks: ["192.168.0.0/16"]
    services:
      cidrBlocks: ["10.96.0.0/12"]
  controlPlaneRef:
    apiGroup: controlplane.cluster.x-k8s.io
    kind: KubeadmControlPlane
    name: capi-ovh-cp
  infrastructureRef:
    apiGroup: infrastructure.cluster.x-k8s.io
    kind: OpenStackCluster
    name: capi-ovh
---
apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
kind: OpenStackMachineTemplate
metadata:
  name: capi-ovh-cp
spec:
  template:
    spec:
      flavor: c3-4
      image:
        filter:
          name: ubuntu-2404-noble
      identityRef:
        cloudName: ovh
        name: capi-ovh-cloud-config
      configDrive: true
---
apiVersion: controlplane.cluster.x-k8s.io/v1beta2
kind: KubeadmControlPlane
metadata:
  name: capi-ovh-cp
spec:
  version: v1.36.1
  replicas: 3
  machineTemplate:
    spec:
      infrastructureRef:
        apiGroup: infrastructure.cluster.x-k8s.io
        kind: OpenStackMachineTemplate
        name: capi-ovh-cp
  kubeadmConfigSpec:
    clusterConfiguration:
      controllerManager:
        extraArgs:
          - { name: cloud-provider, value: external }
    initConfiguration:
      nodeRegistration:
        kubeletExtraArgs:
          - { name: cloud-provider, value: external }
        name: '{{ local_hostname }}'
---
apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
kind: OpenStackMachineTemplate
metadata:
  name: capi-ovh-md-0
spec:
  template:
    spec:
      flavor: c3-4
      image:
        filter:
          name: ubuntu-2404-noble
      identityRef:
        cloudName: ovh
        name: capi-ovh-cloud-config
      configDrive: true
---
apiVersion: bootstrap.cluster.x-k8s.io/v1beta2
kind: KubeadmConfigTemplate
metadata:
  name: capi-ovh-md-0
spec:
  template:
    spec:
      joinConfiguration:
        nodeRegistration:
          kubeletExtraArgs:
            - { name: cloud-provider, value: external }
          name: '{{ local_hostname }}'
---
apiVersion: cluster.x-k8s.io/v1beta2
kind: MachineDeployment
metadata:
  name: capi-ovh-md-0
spec:
  clusterName: capi-ovh
  replicas: 1
  selector:
    matchLabels: null
  template:
    spec:
      clusterName: capi-ovh
      version: v1.36.1
      bootstrap:
        configRef:
          apiGroup: bootstrap.cluster.x-k8s.io
          kind: KubeadmConfigTemplate
          name: capi-ovh-md-0
      infrastructureRef:
        apiGroup: infrastructure.cluster.x-k8s.io
        kind: OpenStackMachineTemplate
        name: capi-ovh-md-0

Для узлов дочернего кластера я использую официальный облачный образ Ubuntu. Работа с базовым образом проще для воспроизведения и тестирования; с другой стороны, старт занимает больше времени, чем с официальными образами, поскольку приходится устанавливать много зависимостей. В реальном production-окружении я бы воспользовался официальным image-builder.

Приносим извинения тем, кто хотел увидеть Talos — он слишком усложнял демонстрацию, поэтому для PoC я остановился на классическом Ubuntu.

Перед применением манифеста нужно передать CAPO учётные данные — именно на них ссылается identityRef в манифесте (name: capi-ovh-cloud-config). clouds.yaml берётся из интерфейса Horizon (прямо в OpenStack от OVH).

kubectl -n capi-ovh create secret generic capi-ovh-cloud-config \
  --from-file=clouds.yaml=clouds.yaml

Через несколько минут control-plane (3 узла) и рабочий узел (через MachineDeployment capi-ovh-md-0) уже работают на OVH:

$ kubectl --kubeconfig capi-ovh.kubeconfig get nodes -o wide
NAME                          STATUS   ROLES           AGE     VERSION   INTERNAL-IP   EXTERNAL-IP
capi-ovh-cp-jz4h9             Ready    control-plane   4m14s   v1.36.1   10.6.0.214    <none>
capi-ovh-cp-p8xq2             Ready    control-plane   3m52s   v1.36.1   10.6.0.61     <none>
capi-ovh-cp-t7v6d             Ready    control-plane   3m39s   v1.36.1   10.6.0.128    <none>
capi-ovh-md-0-7wkzs-lws49    Ready    <none>          2m48s   v1.36.1   10.6.0.180    <none>

Чтобы узлы перешли в состояние Ready, пришлось установить стандартные дополнения: CNI (Cilium, развёрнутый через Helm) и, главное, OpenStack Cloud Controller Manager, который занимается согласованием providerID узлов (та же история со spec.providerID, что и в предыдущей статье).

Установка Karpenter (версия для cluster-api)

Теперь, когда у нас есть песочница, можно подключить Karpenter к CAPI.

На стороне рабочего кластера размещаем два CRD, описанных выше: NodePool (ограничения) и ClusterAPINodeClass («как», почти пустой, поскольку CAPI берёт на себя большую часть работы). Намеренно оставляем requirements мягкими (только amd64 + on-demand), ограничиваем 20 vCPU через limits и настраиваем консолидацию WhenEmpty (Karpenter будет удалять только пустые узлы):

apiVersion: karpenter.cluster.x-k8s.io/v1alpha1
kind: ClusterAPINodeClass
metadata:
  name: default
spec: {}
---
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: default
spec:
  template:
    spec:
      requirements:
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand"] # либо spot, либо on-demand. OVH не предлагает spot, поэтому у нас on-demand.
      nodeClassRef:
        group: karpenter.cluster.x-k8s.io
        kind: ClusterAPINodeClass
        name: default
      expireAfter: Never
  limits:
    cpu: "20"
  disruption:
    consolidationPolicy: WhenEmpty
    consolidateAfter: 30s

На стороне управления Karpenter нужен MachineDeployment, которым он может управлять, с небольшим нюансом. Метка node.cluster.x-k8s.io/karpenter-member: "" обозначает этот MachineDeployment как «управляемый» Karpenter: именно его провайдер вправе масштабировать (а не capi-ovh-md-0, который использовался для развёртывания текущего рабочего узла).

apiVersion: cluster.x-k8s.io/v1beta2
kind: MachineDeployment
metadata:
  name: capi-ovh-karpenter
  labels:
    node.cluster.x-k8s.io/karpenter-member: ""
  annotations:
    capacity.cluster-autoscaler.kubernetes.io/cpu: "2"
    capacity.cluster-autoscaler.kubernetes.io/memory: "4G"
    capacity.cluster-autoscaler.kubernetes.io/maxPods: "110"
    capacity.cluster-autoscaler.kubernetes.io/labels: "kubernetes.io/arch=amd64,topology.kubernetes.io/zone=nova,node.kubernetes.io/instance-type=c3-4,karpenter.sh/capacity-type=on-demand"
spec:
  clusterName: capi-ovh
  replicas: 0
  # ...
Внимание
Аннотация capacity.cluster-autoscaler.kubernetes.io/labels у MachineDeployment, управляемого Karpenter, обязательно должна включать karpenter.sh/capacity-type=on-demand в дополнение к стандартным kubernetes.io/arch, topology.kubernetes.io/zone и node.kubernetes.io/instance-type. Без неё созданный NodeClaim получает capacity-type="", не удовлетворяет требованию In [on-demand] в NodePool, и Karpenter немедленно считает его «сдрейфовавшим» → удаляет → пересоздаёт → цикл. Я столкнулся с этим во время своих экспериментов.

Контроллер всё ещё нужно развернуть. Это единый бинарный файл, который общается с двумя кластерами: ядро Karpenter следит за NodePool/NodeClaim/pods (на стороне рабочего кластера), а провайдер масштабирует MachineDeployment (на стороне управляющего кластера, MKS). Значит, нам нужен kubeconfig, связывающий оба кластера.

Официальная документация провайдера рекомендует запускать Karpenter в рабочем кластере: тогда он использует in-cluster конфигурацию (как стандартные операторы) для работы с ресурсами своего кластера и одновременно может обращаться к управляющему кластеру через флаг --cluster-api-kubeconfig (управляется через values чарта).

Но здесь ждёт неприятный сюрприз: у провайдера нет опубликованного образа контейнера, который можно было бы скачать (чарт ссылается на gcr.io/k8s-staging-…​, но реестр возвращает 401). Поэтому собираем свой. Репозиторий предоставляет мультиархитектурный Dockerfile, достаточно docker buildx:

git clone https://github.com/kubernetes-sigs/karpenter-provider-cluster-api
cd karpenter-provider-cluster-api
docker buildx build --platform linux/amd64 \
  -t ghcr.io/qjoly/karpenter-clusterapi-controller:poc --push .

Само собой, не стоит использовать этот образ: он не поддерживается, и никаких гарантий безопасности у вас нет.

Как только образ готов, устанавливаем чарт charts/karpenter. В моём случае я смонтировал секрет mgmt-kubeconfig, содержащий kubeconfig управляющего кластера.

# kubeconfig УПРАВЛЯЮЩЕГО кластера (MKS), смонтированный в рабочем
kubectl -n karpenter create secret generic mgmt-kubeconfig --from-file=mgmt.kubeconfig=mks.kubeconfig
helm upgrade --install karpenter ./charts/karpenter -n karpenter --skip-crds \
  --set image.repo=ghcr.io/qjoly/karpenter-clusterapi-controller --set image.tag=poc \
  --set env.clusterAPIKubeconfig=/etc/mgmt/mgmt.kubeconfig \
  --set 'volumes[0].name=mgmt-kubeconfig,volumes[0].secret.secretName=mgmt-kubeconfig' \
  --set 'volumeMounts[0].name=mgmt-kubeconfig,volumeMounts[0].mountPath=/etc/mgmt'

В production этот kubeconfig управляющего кластера ни в коем случае не должен быть admin-правами: это доступ из рабочего кластера к хабу (мы вернёмся к этому в разделе о безопасности), и его нужно свести к минимуму через RBAC (Machine/MachineDeployment соответствующего namespace).

INFO: Во время своих тестов я сначала пробовал подход в стиле ClusterAPI: контроллер управления автомасштабированием работает на стороне управляющего кластера, а не рабочего.

С Karpenter это возможно — если отключить webhooks и выставить нужные переменные окружения (SYSTEM_NAMESPACE, DISABLE_WEBHOOK, CLUSTER_API_KUBECONFIG). Но это не рекомендуемый подход: провайдер Karpenter создан для запуска внутри масштабируемого кластера.

kubectl -n karpenter set env deployment/karpenter \
  SYSTEM_NAMESPACE=karpenter DISABLE_WEBHOOK=true

Честно говоря, это ограничение меня немного разочаровывает, поскольку оно добавляет требования к безопасности: возможность рабочего кластера обращаться к «родительскому» — не совсем нормальная ситуация, и я надеюсь, что в будущей версии провайдера это будет исправлено.

Для ясности — вот что где работает:

Ресурс Кластер Роль

NodePool, ClusterAPINodeClass

Рабочий

правила (что Karpenter вправе создавать)

NodeClaim

Рабочий

конкретный запрос на машину

Поды в Pending, Node

Рабочий

что Karpenter наблюдает и что получает на выходе

MachineDeployment, Machine

Управляющий (MKS)

что провайдер фактически масштабирует

Контроллер Karpenter (под)

Рабочий

работает in-cluster; читает/пишет Machine в MKS через --cluster-api-kubeconfig

Тестируем масштабирование вверх

Чтобы убедиться, что scale-up работает, достаточно развернуть что-то, что останется в состоянии Pending из-за недоступности текущих узлов — например, с помощью AntiAffinity (в норме масштабирование происходит из-за нехватки ресурсов, но ничто не мешает исключить существующие узлы, чтобы сымитировать эту ситуацию).

apiVersion: apps/v1
kind: Deployment
metadata:
  name: inflate
spec:
  replicas: 2
  selector:
    matchLabels:
      app: inflate
  template:
    metadata:
      labels:
        app: inflate
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app: inflate
              topologyKey: kubernetes.io/hostname
      containers:
        - name: pause
          image: registry.k8s.io/pause:3.10
          resources:
            requests:
              cpu: "500m"
              memory: "256Mi"

Первая попытка: запускается, потом исчезает

Применяю этот Deployment, и поначалу всё идёт как надо: Karpenter обнаруживает под в Pending, масштабирует MachineDeployment capi-ovh-karpenter с 0 до 1, CAPO создаёт инстанс OVH… который успешно загружается и вступает в кластер. Но под безнадёжно остаётся в Pending, а спустя некоторое время свежесозданный узел исчезает.

Приглядевшись, обнаруживаем, что соответствующий NodeClaim завис в состоянии Registered=False. Узел загружался нормально, вступал в кластер нормально… но Karpenter отказывался считать его «своим». Через некоторое время это расценивалось как сбой развёртывания, и машина удалялась.

Возвращаемся к отладке — тем более что логи по умолчанию не очень разговорчивы.

Ожидаемый taint

Читая код reconciler регистрации Karpenter (контроллер nodeclaim/lifecycle в kubernetes-sigs/karpenter), всё становится понятнее: Karpenter требует, чтобы каждый новый узел появлялся с taint karpenter.sh/unregistered. Это нужно, чтобы отличить узел, подготовленный самим Karpenter, от узла, добавленного вручную. Опознав машину, которую он заказывал, Karpenter снимает этот taint (иначе — ожидает до таймаута).

Чтобы исправить это, добавляем данный taint прямо в KubeadmConfigTemplate, который будет использовать Karpenter:

apiVersion: bootstrap.cluster.x-k8s.io/v1beta2
kind: KubeadmConfigTemplate
metadata:
  name: capi-ovh-karpenter
spec:
  template:
    spec:
      joinConfiguration:
        nodeRegistration:
          # ...
          taints:
            - key: karpenter.sh/unregistered
              effect: NoExecute

Перезапускаем Deployment inflate и скрещиваем пальцы. 🤞

Вторая попытка: полноценное масштабирование вверх

С taint на месте снова разворачиваем inflate — и на этот раз всё работает: Karpenter обнаруживает под в Pending, масштабирует MachineDeployment capi-ovh-karpenter с 0 до 1, CAPO создаёт инстанс OVH, узел регистрируется, Karpenter снимает taint, и под планируется на него. В этом прогоне (базовый образ Ubuntu, c3-4) можно наблюдать этапы через статус NodeClaim:

Launched

провайдер инициировал создание машины;

Registered

Node в Kubernetes появился и был «принят» Karpenter. Именно здесь в игру вступает taint karpenter.sh/unregistered: Karpenter проверяет его наличие, а затем снимает самостоятельно. Это ровно тот шаг, который провалился в первой попытке;

Initialized

узел полностью готов (Ready, стартовые taints сняты). Теперь поды наконец могут на него попасть.

Скорость принятия узла в систему выглядит следующим образом:

T+0s    kubectl apply -f inflate.yaml
T+2s    Karpenter создаёт NodeClaim default-mrnnj (instance-type c3-4)
T+66s   CAPO масштабирует MD до 1, OpenStack загружает ВМ
T+74s   Node Registered, taint karpenter.sh/unregistered снят
T+141s  Node Initialized (Ready=True), наконец планируемый
T+153s  Оба пода inflate в состоянии Running

Стоит отметить, что узел переходит в Registered на T+74s, но становится по-настоящему Initialized (то есть доступным для планирования) лишь на T+141s. Эти ~67 секунд — время, за которое базовый образ Ubuntu устанавливает containerd, kubelet и пакеты Kubernetes после регистрации. В production с предварительно собранным образом такой задержки не было бы, но в PoC мы используем базовый.

А масштабирование до нуля?

Автомасштабировщик, который умеет только расти, годится разве что для опустошения кошелька. Удаляю Deployment inflate и наблюдаю, как Karpenter выполняет очистку:

$ kubectl delete deploy inflate
# в логах контроллера:
"disrupting node(s)" reason="empty" decision="delete" \
  disrupted-node-count=1 replacement-node-count=0 pod-count=0 \
  disrupted-nodes=[{"Node":"capi-ovh-karpenter-w6gt5-286tq", ...}]
"tainted node" taint.Key="karpenter.sh/disrupted"

Консолидация WhenEmpty работает исправно: reason=empty, decision=delete, нулевое замещение. Примерно через 3 минуты после снятия нагрузки все NodeClaim исчезли, а MachineDeployment вернулся к replicas: 0. Масштабирование до нуля подтверждено.

Важное отличие между Karpenter и CAPI: Karpenter мыслит в терминах «удалить этот конкретный узел», тогда как провайдер cluster-api реализует это через уменьшение числа реплик MachineDeployment, и уже CAPI решает, какую Machine удалить (иногда это машина, несущая важный под, что влечёт затраты на перемещение подов на этапе «Drain»).

Итого: Karpenter — хорошее решение, которое полностью справляется со scale-up и scale-down… но останавливаться на этом мы не будем!

Karpenter vs cluster-autoscaler: бенчмарк

Пока инфраструктура была под рукой, я захотел сравнить Karpenter с его главным конкурентом — cluster-autoscaler. Тот тоже умеет управлять MachineDeployments в CAPI, просто изменяя количество реплик, тогда как Karpenter оперирует NodeClaims.

Протокол тестирования, одинаковый для обоих:

  • три типа машин, предложенных автомасштабировщику: c3-4 (2 vCPU), c3-8 (4 vCPU) и c3-16 (8 vCPU);

  • нагрузка inflate из 8 подов × 1 vCPU, запускается одновременно на холодном пуле (базовый рабочий узел заблокирован через cordon, control-plane помечен через taint): все 8 подов в Pending, автомасштабировщик должен подготовить всё с нуля;

  • измеряем время до перехода 8 подов в Running, количество созданных узлов и выбранные типы машин.

cluster-autoscaler я развернул прямо в управляющем кластере (хотя оба режима возможны).

INFO: Проведём небольшой расчёт упаковки перед запуском бенчмарка.

Если вычесть накладные расходы kubelet/system и DaemonSet cilium: на c3-16 (8 vCPU) помещается ~7 подов, на c3-8 (4 vCPU) — 3 пода, на c3-4 (2 vCPU) — ~1 под. Чтобы разместить 8 подов, есть два «разумных» варианта:

  • 3 × c3-8 → 12 vCPU, упаковка 3+3+2;

  • 1 × c3-16 + 1 × c3-4 → 10 vCPU, упаковка 7+1.

Держите оба варианта в уме.

Результаты (по одному прогону на автомасштабировщик, базовый образ):

Метрика Karpenter cluster-autoscaler

Время до перехода 8 подов в Running

~136 с

~135 с

Первый пригодный узел

~105 с

~135 с

Выбранные типы машин

1× c3-16 + 1× c3-4

3× c3-8

Упаковка подов

7 + 1

3 + 3 + 2

vCPU выделено / запрошено

10 / 8

12 / 8

Результат оказался неожиданным: явного победителя по времени нет. Оба достигают состояния Running для 8 подов примерно за 135 с, потому что узким местом является не Karpenter и не CA, а загрузка базового образа Ubuntu (~2 мин на установку containerd + kubelet). Накладные расходы на связку NodeClaim → Machine в Karpenter в итоге не так значительны.

Более интересное наблюдение: Karpenter выделил меньше ресурсов, чем cluster-autoscaler (2 узла / 10 vCPU против 3 / 12). Его бин-паккинг разместил 7 подов на c3-16 + 1 на c3-4, тогда как CA в режиме least-waste предпочёл 3× c3-8. Но не стоит попадаться в ловушку: это не значит, что Karpenter «оптимизирует лучше». Он взял c3-16 несколько случайно 😅, и сейчас мы разберём, как именно он выбирает тип машины.

Провайдер cluster-api для Karpenter в этом фрагменте кода жёстко задаёт Price: 0.0 для всех типов инстансов:

offerings := cloudprovider.Offerings{
    cloudprovider.Offering{ Requirements: ..., Price: 0.0, Available: true },
}

Результат: он понятия не имеет о стоимости типов машин, а значит, не может различать их по цене. Так как же он выбирает между двумя подходящими типами? Проведём ещё один тест, чтобы разобраться!

Демонстрация: три размера, неверный выбор

Второй тест, более прицельный. Для наглядности оставляю те же три подходящих MachineDeployments (c3-4 (2 vCPU), c3-8 (4 vCPU) и c3-16 (8 vCPU)) и запускаю один под, запрашивающий 3 vCPU: на c3-4 он не поместится, оптимальный выбор — c3-8, а c3-16 явно избыточен.

На стороне Karpenter NodeClaim корректно вычисляет совместимые типы… затем провайдер делает выбор:

created nodeclaim NodePool="default" instance-types="c3-16, c3-8" requests cpu=3100m
launched nodeclaim instance-type="c3-16" allocatable cpu=8

Он взял c3-16 (8 vCPU при потребности в 3). Почему самый большой? Потому что без цены для их различения провайдер сортирует совместимые типы по имени и берёт первый ("c3-16" < "c3-8" в лексикографическом порядке). Комментарий в коде вполне красноречив: «if multiple instance types are found to be compatible we need to select one. for now, we sort by resource name and take the first».

На стороне cluster-autoscaler (--expander=least-waste), тот же под, тот же набор размеров:

Expanding Node Group .../capi-ovh-karpenter-c3-16 would waste 62.50% CPU ...
Expanding Node Group .../capi-ovh-karpenter-c3-8 would waste 25.00% CPU ...
Best option to resize: .../capi-ovh-karpenter-c3-8
Final scale-up plan: [capi-ovh-karpenter-c3-8 0->1]

Он явно вычисляет оптимальный размер и выбирает c3-8. Одинаковая нагрузка, одинаковый каталог типов машин: cluster-autoscaler выделяет 4 vCPU, Karpenter — 8. Кажется, победитель определился.

Заключение

Автомасштабирование узлов в Kubernetes остаётся достаточно сложной задачей: координация между ресурсами Kubernetes и облачными ресурсами несёт немало тонкостей и ограничений, не говоря уже о масштабировании вниз, которое добавляет собственные.

У меня была возможность увидеть Karpenter и его официальный провайдер в действии во время прямого эфира с Rémi Verchère о GPU в Kubernetes — это было очень впечатляюще. В контексте CAPI вывод получился более неоднозначным, чем я ожидал: на практике оба автомасштабировщика для CAPI идут ноздря в ноздрю (эквивалентное время scale-up).

На данный момент моё мнение таково: Karpenter — очень перспективный проект, но при работе через CAPI-провайдер он уступает Cluster Autoscaler. Ему не хватает понятия о ценообразовании, а направление взаимодействия workloadmanagement меня смущает, хотя Karpenter, похоже, и правда быстрее создаёт узлы. Когда эти проблемы будут решены, Karpenter сможет наконец полноценно проявить себя в CAPI. Поговорим об этом ещё :D.

Огромная благодарность OVH за возможность протестировать всё это на их платформе 🫶 Именно благодаря их помощи я смог написать эту статью в комфортных условиях (к слову, есть ваучер на €200 для раздела «Public Cloud» — вполне достаточно, чтобы как следует изучить CAPO-провайдер).

Приятного кофе ☕!

© 2026 meganuke