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
Честно говоря, это ограничение меня немного разочаровывает, поскольку оно добавляет требования к безопасности: возможность рабочего кластера обращаться к «родительскому» — не совсем нормальная ситуация, и я надеюсь, что в будущей версии провайдера это будет исправлено.
Для ясности — вот что где работает:
| Ресурс | Кластер | Роль |
|---|---|---|
|
Рабочий |
правила (что Karpenter вправе создавать) |
|
Рабочий |
конкретный запрос на машину |
Поды в |
Рабочий |
что Karpenter наблюдает и что получает на выходе |
|
Управляющий (MKS) |
что провайдер фактически масштабирует |
Контроллер Karpenter (под) |
Рабочий |
работает in-cluster; читает/пишет |
Тестируем масштабирование вверх
Чтобы убедиться, что 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. Именно здесь в игру вступает taintkarpenter.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 подов в |
~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. Ему не хватает понятия о ценообразовании, а направление взаимодействия workload → management меня смущает, хотя Karpenter, похоже, и правда быстрее создаёт узлы. Когда эти проблемы будут решены, Karpenter сможет наконец полноценно проявить себя в CAPI. Поговорим об этом ещё :D.
Огромная благодарность OVH за возможность протестировать всё это на их платформе 🫶 Именно благодаря их помощи я смог написать эту статью в комфортных условиях (к слову, есть ваучер на €200 для раздела «Public Cloud» — вполне достаточно, чтобы как следует изучить CAPO-провайдер).
Приятного кофе ☕!