Если у вас несколько кластеров 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 в субботу явно не стоит экономии.