KEDA cron-скейлер: EKS до нуля в нерабочее время

Если у вас несколько кластеров EKS для окружений разработки, QA и стейджинга, они совершенно не нужны в три часа ночи. Никто ими не пользуется, если не считать нескольких cron-задач для обновления данных. Всё остальное — впустую расходуемые вычислительные ресурсы.

Математика проста. Кластер для разработки, работающий круглосуточно, тарифицируется за 168 часов в неделю. Ограничьте его рабочими часами в будние дни — и получите примерно 55 часов. Две трети расходов на вычисления уходит на окружения, которыми никто активно не пользуется, и это умножается на каждый кластер. В нашем случае таких кластеров три: dev, QA и стейджинг.

Стандартный HPA здесь не поможет: он масштабирует нагрузку, но его нижняя граница — одна реплика. Опустить количество подов до нуля он не может.

Существует несколько способов масштабировать деплойменты до нуля, чтобы не тратить деньги впустую. Недавно я начал использовать для этого KEDA с cron-скейлером.

KEDA расшифровывается как Kubernetes Event-Driven Autoscaling — интеллектуальный автоскейлер, интегрирующийся с Prometheus, VictoriaMetrics и множеством других специализированных скейлеров. Он умеет масштабировать по Kafka-лагу, глубине очереди, Prometheus-запросам и по тому, что нужно нам: cron.

KEDA устанавливается на кластер один раз, после чего намерения по масштабированию описываются через ресурс scaledObject.

Перед началом работы

Несколько версий и допущений, которые стоит проверить:

  • ArgoCD 2.5 или новее — чтобы существовала опция синхронизации ServerSideApply=true.

  • Целевые деплойменты не должны фиксировать replicas: в Helm-чарте. Если это поле задано, ArgoCD будет сбрасывать значение при каждой синхронизации, и правило ignoreDifferences ниже вас не спасёт. Уберите поле и предоставьте управление репликами HPA.

Развёртывание KEDA через ArgoCD

KEDA поставляется с официальным Helm-чартом, а установить его через ArgoCD — дело одного ресурса Application:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: keda
  namespace: argocd
spec:
  project: platform
  destination:
    server: https://kubernetes.default.svc
    namespace: keda
  source:
    repoURL: https://kedacore.github.io/charts
    chart: keda
    targetRevision: 2.20.0
    helm:
      values: |
        crds:
          install: true
  syncPolicy:
    automated:
      selfHeal: true
      prune: false
    syncOptions:
      - CreateNamespace=true
      - ServerSideApply=true

Блок syncOptions делает немало важной работы. Разберём несколько подводных камней.

Подводный камень 1: CRD требуют server-side apply

KEDA устанавливает несколько CRD: ScaledObject, ScaledJob, TriggerAuthentication. Стандартный client-side apply в Argo CD записывает поле last-applied-configuration в аннотацию, а объёмные CRD могут превысить лимит Kubernetes в 256 КБ, из-за чего синхронизация завершится ошибкой metadata.annotations: Too long. Флаг ServerSideApply=true полностью обходит эту проблему, передавая управление слиянием полей API-серверу.

Подводный камень 2: RoleBinding в kube-system, ломающий проекты с ограничениями

Метрический адаптер KEDA (keda-operator-metrics-apiserver) является агрегированным API-сервером: он отдаёт external.metrics.k8s.io — именно через этот интерфейс HPA читает метрики, вычисленные KEDA. Согласно контракту агрегационного слоя Kubernetes, каждый расширенный API-сервер обязан читать ConfigMap extension-apiserver-authentication из пространства имён kube-system. Поэтому чарт создаёт там ровно один ресурс:

kind: RoleBinding
metadata:
  name: keda-operator-auth-reader
  namespace: kube-system
roleRef:
  kind: Role
  name: extension-apiserver-authentication-reader

Если ваш AppProject ограничивает целевые пространства имён (а так и должно быть), синхронизация завершится ошибкой:

namespace kube-system is not permitted in project '…​'

Соблазнительно просто добавить kube-system в общий проект, но тогда любое приложение получит возможность писать туда. Вместо этого дайте платформенным чартам собственный проект с расширенными правами:

apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: platform
  namespace: argocd
spec:
  description: Cluster platform applications with elevated NS access.
  sourceRepos:
    - https://kedacore.github.io/charts
  destinations:
    - server: https://kubernetes.default.svc
      namespace: keda
    - server: https://kubernetes.default.svc
      namespace: kube-system
  clusterResourceWhitelist:
    - group: "*"
      kind: "*"

Подводный камень 3: предотвратить конфликт между ArgoCD и KEDA

Когда KEDA масштабирует деплойменты до нужного количества реплик — по метрикам или по расписанию cron — ArgoCD по умолчанию попытается привести состояние к известному. Решение — добавить правило ignoreDifferences в ресурс Application (или шаблонизировать его в ApplicationSet) для поля количества реплик:

ignoreDifferences:
  - group: apps
    kind: Deployment
    jsonPointers:
      - /spec/replicas

Cron-скейлер

Триггер cron задаёт временно́е окно. Пока вы находитесь внутри него, количество реплик равно desiredReplicas; за пределами любого окна KEDA масштабирует цель до minReplicaCount. Установите его в 0 — и рабочая нагрузка исчезнет, пока простаивает:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: internal-dashboard
  namespace: default
spec:
  scaleTargetRef:
    name: internal-dashboard # имя Deployment
  minReplicaCount: 0          # включает масштабирование до нуля
  maxReplicaCount: 1
  cooldownPeriod: 300          # ждать 5 минут после окончания окна
  triggers:
    - type: cron
      metadata:
        timezone: Europe/Amsterdam
        start: "0 7 * * 1-5"   # Пн–Пт 07:00 → пробуждение
        end:   "0 19 * * 1-5"  # Пн–Пт 19:00 → сон
        desiredReplicas: "1"

Созданный HPA покажет Min replicas: 1 — это правильно. Обычный HPA не может опуститься ниже 1, поэтому KEDA разделяет задачи: оператор управляет диапазоном 0 ↔ 1, HPA — диапазоном 1 → max. Когда нагрузка «спит», вы увидите:

Min replicas: 1
Deployment pods: 0 current / 0 desired
ScalingActive: False  # "scaling is disabled since the replica count of the target is zero"

ScalingActive: False и значение метрики <unknown> выглядят тревожно, но при нулевом количестве реплик это норма — просто не с каких подов читать метрики. Доказательство работоспособности — строка 0 current / 0 desired.

Временна́я зона вычисляется в указанном вами регионе, а не по часам кластера. Узлы обычно пишут логи в UTC, поэтому масштабирование вверх в 07:00 Europe/Amsterdam отобразится в логах как UTC. Задайте timezone явно — KEDA сам выполнит пересчёт.

Чем придётся пожертвовать

Масштабирование до нуля не бесплатно. Первый запрос после наступления start: попадает в пустой деплоймент, и вы платите за холодный старт (cold start): скачивание образа, если кеш слоёв устарел, запуск пода, проверка готовности. Для типичного сервиса на Go или Node это обычно меньше минуты. JVM-приложения или всё, что загружает большую модель при старте, будут запускаться дольше.

Несколько способов сгладить этот эффект:

  • Задайте start: на несколько минут раньше прихода первых пользователей. Если офис начинает работу в 09:00, масштабирование в 08:45 даст прогретый под к тому моменту, когда кто-то попытается войти.

  • Добавьте второй cron-триггер для предварительного прогрева за минуту до пиковой нагрузки — например, перед запуском известного пакетного задания.

  • Просто примите задержку. Для внутренних дашбордов и стейджинг-окружений никого особо не волнует 30-секундный холодный старт при первом обращении за день.

Когда это стоит применять

  • Внутренние дашборды и инструменты администрирования.

  • Окружения разработки, QA и стейджинга в ночное время и на выходных.

  • Плановые пакетные обработчики.

  • Всё, к чему применимо правило «вне рабочих часов этим никто не пользуется».

Когда этого делать не стоит

  • Всё, что обслуживает реальный пользовательский трафик с SLA: один холодный старт съест весь допустимый бюджет ошибок.

  • Stateful-нагрузки, которым нужно оставаться живыми для сброса состояния или поддержания соединений.

  • Сервисы со sticky-сессиями или долгоживущими активными запросами, которые нельзя чисто завершить.

  • Нагрузки с медленным прогревом (JVM с большим classpath, сервисы, наполняющие кеш при старте, загрузка ML-моделей) — если вы не готовы к задержке первого запроса в несколько минут.

  • Всё, чем владеет другая команда и от чего она зависит, не зная о расписании. Сюрприз в 07:01 в субботу явно не стоит экономии.

© 2026 meganuke