KSolver: оптимизация GPU-планирования в Kubernetes

Панель управления KSolver

Зачем это нужно

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

KSolver появился потому, что некоторые рабочие нагрузки требуют иной цели оптимизации. Парки GPU и другие дефицитные вычислительные ресурсы стоят дорого, чувствительны к конфигурации и легко фрагментируются. Локально разумное размещение всё равно может расточительно тратить ёмкость: задача на 1 GPU способна «заморозить» узел с 8 GPU, низкоприоритетная задача — заблокировать высокоприоритетную группу обучения, а рабочая нагрузка может запросить H100, хотя GPU-класс попроще вполне бы подошёл.

В таких случаях вопрос не только «может ли под здесь запуститься?», но и:

  • Оставит ли это размещение достаточно непрерывной GPU-ёмкости для следующего крупного задания?

  • Использует ли эта рабочая нагрузка правильный класс GPU, объём памяти и топологию узла?

  • Может ли задача с низким приоритетом завершиться к своему дедлайну, используя меньше ресурсов или более дешёвые?

  • Какие ограничения мешают консолидации, допуску или масштабированию вниз?

  • Каковы денежные и ёмкостные издержки текущих правил размещения?

KSolver создан для команд SRE и платформенных инженеров, которым нужны объяснения и контроль. Он использует те же ограничения Kubernetes в качестве входных данных, но оценивает размещение глобально — это позволяет командам снижать фрагментацию, выяснять причины ожидания подов, измерять потери и решать, стоит ли применять специализированный GPU-планировщик вместо стандартного.

Что умеет KSolver

KSolver отвечает на один вопрос: насколько вы переплачиваете за вычисления и что мешает тратить меньше?

Он собирает с кластера узлы, поды, taint’ы, affinity- и anti-affinity-правила, ограничения топологического распределения (topology spread constraints), PDB, селекторы узлов, VPA и DaemonSet’ы. Всё это поступает в решатель ограничений, который совместно оптимизирует размещение и уровни запросов, после чего отображает:

  • Экономию в деньгах с разбивкой по консолидации размещения, оптимизации запросов и ослаблению ограничений

  • Ранжированный список действий с командами kubectl, метками сложности и уровнями риска

  • Атрибуцию стоимости ограничений — сколько именно обходится каждый taint, правило affinity или anti-affinity

  • Интерактивный симулятор ограничений для включения и отключения ограничений с наблюдением консолидации узлов в реальном времени

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

  • Рекомендации по парку машин с предложением более выгодных типов инстансов из каталога цен

  • Покрытие VPA — подсветка рабочих нагрузок без VPA и оценка связанных потерь

Интерактивный симулятор ограничений

Быстрый старт

# Сборка
cargo build --features rust-cp-sat

# Запуск с текущим kubeconfig
./target/debug/ksolver serve 0.0.0.0:8080

# Открыть панель управления
open http://localhost:8080

Установка через Helm

helm install ksolver oci://us-central1-docker.pkg.dev/syslens-dev/syslens/ksolver \
  --version 0.5.1 \
  --namespace ksolver --create-namespace

Для теневого цикла GPU-планировщика разверните тот же чарт в режиме только наблюдения:

helm install ksolver ./chart \
  --namespace ksolver --create-namespace \
  --set runtime.mode=shadow \
  --set scheduler.bindingRolloutMode=observe-only \
  --set scheduler.enableRealBinding=false \
  --set scheduler.bindingKillSwitch=true

По умолчанию RBAC чарта работает в режиме только чтения. Разрешения на запись pods/binding и Event не генерируются, если явно не заданы rbac.allowBindingMutations=true или rbac.allowEventWrites=true — и то и другое защищено условиями, привязанными к соответствующим переключателям теневого режима.

Архитектура

ksolver/src/
  collector.rs          Сбор данных из Kubernetes API (узлы, поды, VPA, PDB и др.)
  model.rs              Доменные типы — Node, Workload, Constraint, Solution
  normalizer.rs         Нормализация собранного состояния во входные данные решателя
  optimizer_input.rs    Построение переменных и ограничений модели CP-SAT
  cpsat_rust.rs         Интеграция с решателем CP-SAT через привязки or-tools
  planner.rs            Постобработка: генерация перемещений, действий, водопада экономии
  explainability.rs     Атрибуция стоимости ограничений и анализ блокировок
  pricing.rs            Каталог цен на инстансы облачных провайдеров
  historical_usage.rs   Сбор данных об использовании из Prometheus для оптимизации запросов
  verifier.rs           Проверка решений через kube-scheduler-simulator
  server.rs             HTTP-сервер на Axum и SSE-стриминг
  service.rs            Оркестрация конвейера collect -> solve -> plan
  metrics.rs            Экспозиция метрик Prometheus
  state_cache.rs        Сохранение снимков для офлайн-анализа

Решатель работает как единый бинарный файл, обслуживающий одновременно API и одностраничную панель управления по адресу /.

Конфигурация

Панель управления открывает доступ ко всем параметрам решателя через панель «Advanced Settings». Основные опции:

Параметр По умолчанию Описание

CPU/Memory Headroom

0%

Резервировать ёмкость на каждом узле

Overcommit Ratio

1.0

Разрешить упаковку сверх запросов (1.0 = строго)

Ignore Taints

выкл.

Рассматривать taint’ы как мягкие для анализа верхней границы

Relax Anti-Affinity

выкл.

Допустить более плотную упаковку, смягчив anti-affinity

Joint Rightsizing

выкл.

Совместно оптимизировать размеры запросов и размещение

Usage-Adjusted Requests

выкл.

Заменить сырые запросы данными о потреблении из Prometheus

Теневой режим GPU-планировщика

Теневой режим (shadow mode) следит за ожидающими GPU-подами с schedulerName: ksolver, вычисляет размещения и записывает трассировки решений. По умолчанию режим только для чтения: ни один под не привязывается, пока оператор явно не включит режим выката привязок (binding rollout mode).

Запуск теневого режима

KUBECONFIG=~/.kube/config \
KSOLVER_SHADOW_BATCH_SECONDS=10 \
cargo run --features rust-cp-sat -- shadow

Для локального теневого режима и демонстрационных запусков всегда указывайте --features rust-cp-sat. Без этого флага процесс запустится, но решатель будет недоступен: /readyz вернёт 503, а отчёты о размещении завершатся ошибкой.

Откройте http://127.0.0.1:8090/, чтобы проверить размещения, причины невозможности разместить поды, советы по исправлению, защитные шлюзы и доказательства сценариев.

Конечные точки для проверки работоспособности и управления

Конечная точка Назначение

/healthz

Живость процесса

/readyz

Готовность watch-соединения с Kubernetes и решателя

/api/scheduler/traces

Последние решения о размещении и предупреждения

/api/scheduler/binding-plan

Предварительный просмотр полезной нагрузки Binding Kubernetes (только чтение)

/api/scheduler/repair-plan

Рекомендательный план миграции и вытеснения

/api/scheduler/production-safety

Шлюзы выката, готовность, RBAC и состояние мутаций

/api/scheduler/operator-status

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

/api/scheduler/vram-calibration

Качество модели VRAM и готовность к допуску

/api/scheduler/evidence-bundle

Артефакты проверки и команды сбора

/metrics

Метрики Prometheus

Панель управления объясняет, почему нагрузка не размещена, а не просто возвращает статус неудачи. Ошибки готовности классифицируются как таймаут API, проблемы с подключением, DNS, TLS или ошибки авторизации — и для каждой предлагается диагностическая команда.

Основная конфигурация

Переменная По умолчанию Действие

KSOLVER_SHADOW_SCHEDULER_NAME

ksolver

Имя планировщика, за которым следит теневой режим

KSOLVER_SHADOW_BATCH_SECONDS

10

Задержка между пакетами решений

KSOLVER_SHADOW_SOLVE_SECS

10

Временной бюджет CP-SAT

KSOLVER_SHADOW_ADDR

127.0.0.1:8090

Адрес для панели управления и API

KSOLVER_SHADOW_NAMESPACES

все

Список разрешённых пространств имён через запятую

KSOLVER_SHADOW_GPU_RESOURCES

nvidia.com/gpu

Точные имена ресурсов целых GPU

KSOLVER_SHADOW_GPU_RESOURCE_PREFIXES

nvidia.com/mig-

Префиксы ресурсов, похожих на GPU

KSOLVER_SHADOW_QUOTAS

нет

Лимиты GPU по пространствам имён, например team-a=200

KSOLVER_CANDIDATE_NODE_LIMIT

16

Количество узлов-кандидатов для каждой нагрузки; 0 отключает обрезку

KSOLVER_ENABLE_NODE_GROUPING

false

Сокращение симметрии однородных узлов

KSOLVER_ENABLE_LEADER_ELECTION

false

Координация реплик через lease

Helm-чарт предоставляет эти параметры через scheduler.* и проверяет режимы выката, профили целевых функций, окна решения и ограничения до развёртывания. Полный справочник конфигурации — в chart/values.yaml.

Семантика устройств

  • Целые GPU и объявленные MIG-профили (MIG profiles) используют точный учёт расширенных ресурсов Kubernetes. Запрос на один MIG-профиль не может быть удовлетворён другим профилем.

  • Аннотации ksolver.dev/gpu-topology-key и ksolver.dev/gpu-topology-value (опциональные) применяют жёсткий фильтр по меткам узла. ksolver.dev/nvlink-domain — сокращённая запись для сопоставления по NVLink-домену.

  • Заявки DRA (Device Resource Allocations) учитываются через консервативный скалярный счёт. Идентификаторы выделенных устройств вычитаются, но конкретный выбор устройства не фиксируется.

  • GPU с разделением по времени (time-sliced) помечаются как общие и неизолированные. Допустимое размещение не гарантирует изоляцию памяти или производительности.

Неподдерживаемая или приближённая семантика отражается в каждом решении в виде предупреждений и никогда не рассматривается как точное размещение устройств.

Планирование и политика

Теневой режим решает задачу размещения ожидающих нагрузок на остаточную ёмкость узлов с соблюдением ограничений выполнимости Kubernetes, которые поддаются моделированию. Это включает групповой допуск (gang admission), node affinity, topology spread, pod affinity/anti-affinity, квоты, MIG-ресурсы, соответствие VRAM и явные метки топологии GPU.

Предпочтительный affinity — это уточнение при равенстве стоимости. Обязательные pod affinity и anti-affinity применяются, когда их селекторы и топологические домены поддаются моделированию; неподдерживаемое поведение селекторов раскрывается в виде предупреждений.

Опциональные параметры политики включают:

  • веса приоритета и бизнес-ценности;

  • веса очередей и возраст очереди;

  • дедлайны и предсказанное время выполнения;

  • веса справедливого распределения по арендаторам и ежемесячные бюджеты;

  • историческое время выполнения и прогнозы пикового потребления VRAM.

Эти параметры носят рекомендательный характер, пока не включены соответствующие веса целевой функции или бюджетные ограничения. Трассировки фиксируют активный профиль целевой функции, веса, результат допуска и причину каждого откладывания.

Защитные механизмы масштабирования

Обрезка кандидатов уменьшает размер модели, а при подозрительных результатах автоматически расширяет набор. Установите KSOLVER_CANDIDATE_NODE_LIMIT=0 для решения на полном допустимом множестве. Параметр KSOLVER_CANDIDATE_WIDEN_MIN_ADMISSION_PERCENT управляет порогом расширения при низком допуске.

Группировка узлов включается вручную. Когда это безопасно, однородные физические узлы объединяются, решаются как группы с подсчётом, разворачиваются обратно в реальные узлы и проверяются. Если развёртывание завершается ошибкой, теневой режим откатывается к решению по физическим узлам.

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

Советы по исправлению

/api/scheduler/repair-plan разграничивает фрагментацию ресурсов и случай, когда нагрузка не умещается ни на одном GPU. Когда возможно, план предлагает миграции раньше вытеснений — при наличии эквивалентной свободной ёмкости — и учитывает:

  • доступность PodDisruptionBudget;

  • политики safe-to-evict, do-not-disrupt, миграции и вытеснения;

  • приоритет нагрузки, бизнес-ценность, срочность дедлайна и возраст очереди;

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

Планы носят рекомендательный характер и не выполняют выселение, миграцию, вытеснение или привязку нагрузок.

Webhook допуска

POST /admission/scheduler-name принимает проверки admission.k8s.io/v1 Kubernetes и возвращает патч RFC 6902, который назначает schedulerName: ksolver подходящим GPU-подам. Существующее имя планировщика никогда не перезаписывается.

Helm-webhook по умолчанию отключён и требует TLS. Используйте KSOLVER_ADMISSION_OPT_IN_LABEL (или scheduler.admissionOptInLabel), чтобы требовать явную метку пода перед патчингом. Для DRA-подов этот opt-in обязателен, поскольку по одному только поду нельзя определить класс устройства.

helm upgrade --install ksolver ./chart \
  --namespace ksolver --create-namespace \
  --set runtime.mode=shadow \
  --set scheduler.admissionOptInLabel=ksolver.dev/schedule \
  --set admissionWebhook.enabled=true \
  --set admissionWebhook.url=https://ksolver-webhook.example.com/admission/scheduler-name \
  --set admissionWebhook.caBundle=<base64-ca-bundle>

Выкат привязок

Конечная точка плана привязок всегда работает только на чтение. Каждая предложенная привязка включает вердикт актуальности, проверяющий UID пода, владельца-планировщика, состояние ожидания, целевой узел и последнее допустимое множество узлов.

Фактическая привязка включается вручную:

KSOLVER_BINDING_ROLLOUT_MODE Поведение

observe-only

Клиент без возможности мутации

dry-run

Проверка API с dryRun=All; ничего не сохраняется

bind-low-risk

Сохранять только кандидатов в пределах настроенного порога GPU

bind-all

Сохранять каждого готового кандидата, прошедшего финальные живые проверки

Дополнительные параметры управления: KSOLVER_ENABLE_REAL_BINDING, KSOLVER_BINDING_KILL_SWITCH, KSOLVER_BINDING_LOW_RISK_MAX_GPUS и KSOLVER_MAX_BINDS_PER_PASS. Недопустимые режимы завершаются с ошибкой. Каждый кандидат перечитывается непосредственно перед привязкой; устаревшие, пересозданные, завершающиеся, уже привязанные или принадлежащие другому владельцу поды пропускаются.

Живая привязка также требует явного RBAC для create на pods/binding. Helm-чарт не предоставляет его без rbac.allowBindingMutations=true. Запись событий Kubernetes отдельно управляется параметрами KSOLVER_ENABLE_KUBERNETES_EVENTS и rbac.allowEventWrites=true.

Пример выката с низким риском:

helm upgrade --install ksolver ./chart \
  --namespace ksolver --create-namespace \
  --set runtime.mode=shadow \
  --set scheduler.bindingRolloutMode=bind-low-risk \
  --set scheduler.enableRealBinding=true \
  --set rbac.allowBindingMutations=true

Базовые линии и доказательства

Отчёты по сценариям сравнивают ksolver со стратегиями spread и binpack стандартного kube-scheduler. Результаты Volcano включаются только при наличии захваченной базовой линии с поддержкой gang-планирования. Каждое сравнение фиксирует, являются ли доказательства живыми, кэшированными или детерминированными; отсутствие доказательств симулятора не блокирует решение, но препятствует сильным сравнительным утверждениям.

Полезные команды:

# Диагностика работающего теневого сервиса
scripts/shadow-doctor.py \
  --base-url http://127.0.0.1:8090 \
  --require-kss-ready

# Запуск сквозного проверочного шлюза
scripts/demo-gate.py \
  --base-url http://127.0.0.1:8090 \
  --output-dir /tmp/ksolver-demo-gate \
  --json

# Сбор и проверка пакета доказательств
scripts/collect-evidence-bundle.py \
  --base-url http://127.0.0.1:8090 \
  --output-dir /tmp/ksolver-evidence
scripts/verify-evidence-bundle.py /tmp/ksolver-evidence

Для локальных базовых линий kube-scheduler-simulator используйте scripts/kss-pool.sh, чтобы запустить и проверить пул, а затем обновите кэш сценариев с помощью scripts/kss-cache-grind.sh. Теневой режим кэширует планы симулятора по сигнатуре ожидающих нагрузок, поэтому обновления панели управления не сбрасывают и не импортируют состояние симулятора повторно.

RBAC только для чтения

Стандартной теневой установке нужен только доступ на чтение:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: ksolver-shadow-readonly
rules:
  - apiGroups: [""]
    resources: [pods, nodes, persistentvolumeclaims, persistentvolumes]
    verbs: [get, list, watch]
  - apiGroups: ["apps"]
    resources: [daemonsets, deployments]
    verbs: [get, list, watch]
  - apiGroups: ["storage.k8s.io"]
    resources: [storageclasses]
    verbs: [get, list, watch]
  - apiGroups: ["policy"]
    resources: [poddisruptionbudgets]
    verbs: [get, list, watch]

Права на create, update, patch, delete, eviction или pod-binding не предоставляются. Для более подробной информации о реализации и критериях приёмки см. проект теневого планировщика, проект поддержки DRA, проект базовой линии Volcano и дорожную карту frontier.

Соответствие выполнимости

ksolver conform проверяет, совпадает ли наша логика проверки выполнимости узлов с реальной фазой Filter стандартного kube-scheduler. Для каждого ожидающего пода и каждого (не заблокированного) узла команда получает два вердикта — наш (feasible_on_node) и планировщика — и сообщает о каждом расхождении:

KSOLVER_SCHEDULER_SIMULATOR_URL=http://localhost:8080 \
  ksolver conform --sample 20 --cluster my-cluster
  • Вердикт планировщика берётся из kube-scheduler-simulator: импортируется снимок ровно с одним узлом (без других подов) и самим подом; привязка пода к этому узлу означает, что Filter прошёл, а статус unschedulable — что не прошёл. Один узел изолирует Filter от Score.

  • Обе стороны проверяют сырую аллоцируемую ёмкость (пустой узел), поэтому резервирование для DaemonSet, переподписка и headroom — отдельные слои ksolver, не предикаты Filter — не искажают сравнение.

  • Поды с конструкциями, которые намеренно не моделируются в этом тесте Filter (обязательные pod affinity/anti-affinity, неподдерживаемые формы DoNotSchedule topology spread и priority / priorityClassName), относятся к категории ожидаемых расхождений с подсчётом по причинам. Обязательный node affinity, включая OR-условия и matchFields по metadata.name, относится к строгой категории.

  • Точного совпадения требуют только поды из строгой категории. Результаты FALSE-POSITIVE (мы считаем размещение допустимым, планировщик отклоняет) выводятся первыми — они наиболее опасны. Текстовый отчёт содержит strict-gate: pass|fail; fail означает наличие хотя бы одного строгого ложноположительного результата.

  • --json выводит машиночитаемый отчёт с матрицами строгих и ожидаемых расхождений, списками несовпадений, подсчётом причин ожидаемых расхождений и strict_gate_status.

  • --fail-on-strict-false-positive завершается с ненулевым кодом после вывода отчёта при сбое строгого шлюза. Несовпадения ожидаемых расхождений остаются информационными и не активируют этот CI-шлюз.

  • На реальном кластере только чтение; планирование выполняется только против симулятора (изолированной среды). Если URL симулятора не настроен, conform выводит сообщение о пропуске и завершается с кодом 0. При --json путь пропуска выводит JSON-объект с skipped: true.

  • Масштабирование на больших парках. conform имеет сложность O(поды × узлы) — один цикл сброс/импорт/проверка симулятора на каждую пару (под, узел) — поэтому полный прогон на кластере с 100+ узлами выполняется долго. Два опциональных флага (значения по умолчанию не изменены) делают его управляемым:

    • --max-nodes N ограничивает проверяемый набор кандидатов выборкой; в отчёте фиксируются nodes_evaluated и nodes_total, и выводится пометка о выборочной проверке — выборочный прогон никогда не принимается за исчерпывающее покрытие.

    • --dedup-nodes сохраняет полное точное покрытие, но проверяет только одного представителя каждого класса эквивалентности выполнимости (узлы с одинаковыми аллоцируемыми ресурсами, метками, задействованными подом, и taint’ами дают одинаковый вердикт Filter), распространяя вердикт на остальных. Для подов, чей вердикт может зависеть от неключевого атрибута (matchFields, PVC-тома), используется поузловая проверка — то есть метод может проверять больше, чем необходимо, но никогда не возвращает неверный вердикт. В отчёте отображаются simulator_probes в сравнении с общим числом пар.

Примеры для CI:

KSOLVER_SCHEDULER_SIMULATOR_URL=http://localhost:8080 \
  ksolver conform --sample 20 --json --fail-on-strict-false-positive

KSOLVER_SCHEDULER_SIMULATOR_URL=http://localhost:8080 \
  ksolver conform --sample 20 --fail-on-strict-false-positive

Проверено в живую (2026-07-01) на самостоятельно собранном kube-scheduler-simulator под arm64 (v0.4.0 публикует только amd64-образы, которые падают при эмуляции на Apple Silicon — собирайте из исходников с docker buildx --platform=linux/arm64). conform отработал сквозным образом и выдал матрицу ошибок (согласие / ложноположительное / ложноотрицательное) с нулём ложноотрицательных. Примечание: путь с импортом одного узла может давать ложные ложноположительные результаты, когда импортированный узел не помечен как Ready внутри симулятора (KWOK самостоятельно управляет статусом импортированных узлов) — это оговорка о достоверности тестового стенда, а не проблема в моделировании Filter.

Обновление (2026-07-14): локальный пул симуляторов (scripts/kss-pool.sh) теперь надёжно выполняет полный цикл импорта/сброса/проверки — два бага KWOK apiserver, ранее нарушавших импорт подов (допуск ServiceAccount) и сброс /reset (несовпадение etcd-префикса), исправлены в этом скрипте. conform работает сквозным образом против пула, запускаемого одной командой, а --max-nodes/--dedup-nodes (см. выше) делают прогоны на больших парках управляемыми. Проверено на 113-узловом KWOK-кластере solver-lab: полный прогон и прогон с --dedup-nodes дали идентичные вердикты и расхождения, строгий шлюз прошёл успешно.

Заполнение хранилища истории VRAM

Предсказатель VRAM четвёртого уровня (tier-4 VRAM predictor) читает JSONL-хранилище измеренных пиковых значений потребления VRAM, индексированных по отпечатку рабочей нагрузки. ksolver vram-observe заполняет это хранилище из реального источника GPU-метрик — dcgm-exporter, опрашиваемого Prometheus:

ksolver vram-observe --store /var/lib/ksolver/vram.jsonl \
  --prometheus-url https://prom.example.com \
  [--prometheus-username <u> --prometheus-token <t>] [--window 24h] [--kubeconfig <path>]

Команда запрашивает пиковое потребление видеопамяти каждым подом (max_over_time(DCGM_FI_DEV_FB_USED{pod!=""}[<window>])), сопоставляет каждую метрику со спецификацией её пода для вычисления отпечатка, который использует предсказатель, и добавляет одну строку на каждый сопоставленный под. Учётные данные также можно передать через KSOLVER_PROMETHEUS_{URL,USERNAME,TOKEN}. Команда требует живого Prometheus и ничего не записывает, если экспортер не сообщает о совпадающих рядах — наблюдения никогда не фабрикуются. Запускайте по расписанию (например, через CronJob), чтобы предсказания четвёртого уровня были основаны на реальном потреблении.

Лицензия

MIT

© 2026 meganuke