k8s не умеет масштабироваться?

автоскейлинг Kubernetes,HPA , KEDA, Karpenter, масштабирование Kubernetes
Вы наверняка знаете/слышали про HPA/VPA, которые позволяют динамически подстраиваться под меняющиеся нагрузки. Но помимо обычного скейлинга посредством изменения количества реплик приложения нужно учесть множество других нюансов.

И подумать, как ваши базы будут скейлиться вместе с приложениями, потому что k8s не позволит вам получить больше перформанса, просто добавив реплик БД. Хотя и это тоже возможно (в теории) для, например, читающей нагрузки, но это отдельная проблема:
  • Реплику мгновенно не поднять, нужно получить полную копию БД
  • Запись на лидера при асинхронной репликации не станет мгновенно доступной на репликах
  • Синхронная репликация может не дать нужно перформанса, и так далее
В этой статье вопросы скейлинга БД мы не будем рассматривать и вернемся в первую очередь к stateless нагрузкам.

k8s не умеет масшабироваться. Он предоставляет набор независимых контроллеров, каждый из которых решает локальную задачу, действует с задержкой и ничего не знает о целостной картине. Автоскейлинг — это побочный эффект их взаимодействия.

Эта статья — разбор масштабирования в k8s с позиции эксплуатации.

HPA — control loop с задержкой

Horizontal Pod Autoscaler — это контроллер, работающий внутри kube-controller-manager. Его архитектура принципиально проста:
  • получить метрики;
  • рассчитать desiredReplicas;
  • обновить кол-во реплик
Контроллер запускается через фиксированные интервалы (--horizontal-pod-autoscaler-sync-period, по умолчанию 15s). Это означает, что HPA не реагирует на события. Он реагирует на усреднённое состояние системы с задержкой.

Что это дает на практике?

Любой резкий рост нагрузки существует без реакции HPA минимум один цикл. В реальных системах это 20−40 секунд деградации с учётом доставки метрик.

Пример: рост трафика в N раз за 20 секунд приводит к резкому росту latency приложения. HPA начинает масштабирование, когда часть клиентов уже получает таймауты. Узлы при этом были свободны — bottleneck находился исключительно в control loop.

Читатель может поспорить, что новые поды тоже не мгновенно стартуют и в целом будет прав. Главный вывод, который нужно держать в уме, что HPA работает не на постоянной основе.

Антипаттерн: HPA по CPU как основной механизм для IO bound нагрузок

CPU вполне валидная метрика для CPU-bound нагрузок (например, обработка видео, криптография), однако для IO-bound приложений может быть некорректной метрикой (CPU не перегружен, но кол-во коннектов на ноду уже не дает приложению нормально работать).

Лучшие практики

  • использовать CPU только как вторичный сигнал
  • масштабироваться по backlog, latency или пользовательским метрикам
  • агрессивный scale-up, scale-down с задержкой
behavior:
  scaleUp:
    stabilizationWindowSeconds: 0
    policies:
      - type: Percent
        value: 100
        periodSeconds: 15
  scaleDown:
    stabilizationWindowSeconds: 300
Если нагрузка скачет, HPA может начать бесконечно добавлять и удалять поды. Для этого в behavior обязательно нужно настраивать stabilizationWindowSeconds

StartupProbe

Без startupProbe под начинает получать трафик до завершения инициализации. Это приводит к росту latency и повторному срабатыванию HPA, если настраиваем скейлинг по latency.
startupProbe:
  httpGet:
    path: /startup
    port: 8080
  failureThreshold: 40
  periodSeconds: 2

KEDA — масштабирование по событиям

KEDA добавляет в k8s модель event-driven autoscaling: длина очереди, % ошибок запросов или полностью кастомная метрика

Как это работает

Настраиваем интеграцию с интересующим источником (например, topic в kafka) и в зависимости от отставания принимается решение о скейлинге подов консьюмера. Но помним, что кол-во подов не должно превышать кол-во партиций в группе (поды сверх этого кол-ва не будут получать сообщения).
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: kafka-scaledobject
  namespace: default
spec:
  scaleTargetRef:
    kind: Deployment
    name: kafka-consumer-app
  minReplicaCount: 1
  maxReplicaCount: 10
  pollingInterval: 30
  triggers:
  - type: kafka
    metadata:
      bootstrapServers: kafka:9092
      consumerGroup: my-example-group
      topic: example-topic
      lagThreshold: "50"
      offsetResetPolicy: latest

Антипаттерны KEDA

  • scale-to-zero без прогрева — гарантированно долгий старт в случае сильно плавающих нагрузок
  • слишком маленький pollingInterval, создающий лишнюю нагрузку

PodDisruptionBudget: соблюдаем кворум

PodDisruptionBudget ограничивает количество подов, которые могут быть одновременно выселены с нод.

Пример

Сервис с 5-ю репликами оказался развернут на 3-х нодах, 2 из которых ушли на обслуживание. На оставшейся ноде осталась 1 реплика сервиса, которая не держала всю приходящую нагрузку, отдавая клиентам 5xx ошибки.

Побочные эффекты

Жёсткие настройки PDB могут полностью блокировать scale-down узлов.

Например, если у нас 3 реплики приложения на одной ноде, то такой PDB не позволит ей расселить поды.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: my-pdb
spec:
  minAvailable: 3
  selector:
    matchLabels:
      app: my-app

Overprovisioning: сознательная переплата за ресурсы

Скейлинг нод всегда медленнее скейлинга подов. Даже в оптимальных условиях создание ноды может занимать десятки секунд.

Overprovisioning создаёт буфер ресурсов за счёт вытесняемых подов с низким приоритетом.

За счет дополнительной платы за ресурсы всегда есть ноды, с которых можно выселить поды для неожиданного скейлинга каких-то более важных нагрузок.
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: overprovisioning
value: -1
globalDefault: false
preemptionPolicy: Never
description: "Lowest priority for overprovisioning"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: overprovisioner
spec:
  replicas: 5 # Выбрать в зависимости от размера кластера/кол-ва нод
  selector:
    matchLabels:
      app: overprovisioner
  template:
    metadata:
      labels:
        app: overprovisioner
    spec:
      priorityClassName: overprovisioning
      containers:
      - name: pause
        image: gcr.io/google-containers/pause
        resources:
          requests:
            cpu: "1"
            memory: "2Gi"

Karpenter: когда нужно гибко скейлиться

В разных облаках существуют разные решения для скейлинга, но в последнее время популярность завоевал karpenter, который работает напрямую с API облака и поддерживает уже не только aws.

karpenter появился как замена cluster autoscaler, который просто смотрел на pending поды и просил облако увеличить или уменьшить размер ASG (auto scaling group).

Karpenter действует умнее: он смотрит на требования подов (ресурсы, аффинити, зоны) и заказывает наиболее подходящий тип инстанса (возможно не один), отвязываясь от лимитов ASG.

Он поддерживает огромное кол-во настроек, но все сводится к тому, какие типы инстансов, когда и на какой срок можно заказывать. Выглядит это приблизительно так
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  amiFamily: AL2 # Amazon Linux 2
  role: "KarpenterNodeRole"
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: "example"
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: "example"
  amiSelectorTerms:
    - id: "example-ami-id-1"
---
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: default
spec:
  template:
      spec:
        requirements:
          - key: kubernetes.io/arch
            operator: In
            values: ["amd64"]
          - key: kubernetes.io/os
            operator: In
            values: ["linux"]
          - key: karpenter.sh/capacity-type
            operator: In
            values: ["on-demand"]
          - key: "karpenter.k8s.aws/instance-family"
            operator: In
            values: [ "m5","m5d","c5","c5d","c4","r4" ]
            minValues: 1
          - key: "karpenter.k8s.aws/instance-cpu"
            operator: In
            values: [  "16", "32" ]
        nodeClassRef:
          group: karpenter.k8s.aws
          kind: EC2NodeClass
          name: default
        expireAfter: 72h # 30 * 24h = 720h
  limits:
    cpu: 1000
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 30m
    budgets:
      - nodes: "20%"
        reasons:
          - "Empty"
          - "Drifted"
          - "Underutilized"
Но и как с любым инструментом, важно правильно применять.

Пример проблемы

Без topologySpreadConstraints большая часть нагрузки может оказаться в одной зоне или на одном узле, увеличивая blast radius.
topologySpreadConstraints:
- maxSkew: 1
  topologyKey: topology.kubernetes.io/zone
karpenter нативно понимает как topologySpreadConstraints, так и pod anti affinity, и storage class и многое другое https://karpenter.sh/docs/concepts/scheduling/ и учитывает при запросе нод.

Облако не безгранично

Использование одного instance type делает масштабирование ненадежным. Во время дефицита инстансов в конкретной AZ поды могут оставаться в состоянии pending часами.

Лучшие практики

  • несколько instance families
  • capacity-optimized стратегии
  • fallback на on-demand

Важно про консолидацию:

В манифесте NodePool параметр consolidationPolicy: WhenUnderutilized заставляет Karpenter постоянно мониторить кластер. Если он увидит, что 3 полупустых ноды можно заменить одной более дешевой, он сделает это автоматически.
disruption:
  consolidationPolicy: WhenEmptyOrUnderutilized
  consolidateAfter: 30m

Инфраструктурные пределы масштабирования

Некоторые ограничения проявляются только при росте нагрузки:
  • несколько instance families
  • capacity-optimized стратегии
  • fallback на on-demand
Autoscaling не решает эти проблемы — он лишь ускоряет столкновение с ними.

Control Plane как бутылочное горлышко

Массовое создание подов увеличивает нагрузку на etcd и kube-apiserver. Write amplification и сериализация объектов приводят к росту latency.

CoreDNS и сетевые эффекты масштабирования

Каждый Pod генерирует DNS-нагрузку. При массовом скейлинге подов CoreDNS часто становится первым боттлнеком.

Решения:

  • скейлинг CoreDNS
  • NodeLocal DNSCache
При резком скейлинге стандартный CoreDNS может прилечь под шквалом UDP-запросов. conntrack в iptables начинает дропать пакеты.

NodeLocal DNSCache запускает агент на каждой ноде, который кеширует DNS-ответы локально, не гоняя трафик через всю сеть кластера.

Scale-down и graceful shutdown

Scale-down — такая же важная часть автосйкелинга, как scale-up. Некорректное завершение подов приводит к обрывам соединений и повторной нагрузке. Поэтому не забываем корректно обрабатывать SIGTERM в приложениях, а если это проблематично, то в качестве дополнительного костыля ставим
preStop:
  exec:
    command: ["/bin/sh", "-c", "touch /tmp/shutdown; sleep 15"]
чтобы по всему кластеру успели обновиться endpoint slices.

И приложение в readiness пробе должно проверять наличие /tmp/shutdown.

Время старта контейнеров

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

Решения:

  • harbor proxy cache
  • pre-pull через DaemonSet
  • минимизация образов настолько, насколько это возможно (ваш кэп)

Заключение

Автоскейлинг в k8s требует понимания механизмов работы, чтобы использовать его эффективно, а так же правильной комбинации разных инструментов.

Недостаточно просто настроить karpenter, если ваши приложения не учитывают PDB, pod anti affinity и topology spread constraints. Это будет работать, но по закону Мерфи стрельнет в самый неподходящий момент.
Читайте также: