- BrainTools - https://www.braintools.ru -

Привет! Я Алиса, DevOps-инженер в KTS [1].
В этой статье я расскажу об одном из наших недавних проектов, на котором мы строили инфраструктуру для команды дата-инженеров и аналитиков. Сразу оговорюсь, что это была не платформа для инференса Production-моделей, а именно полигон для исследований.
В общем, делюсь практическим опытом [2] построения масштабируемой инфраструктуры с автоскейлингом. Для кого актуально — приглашаю к прочтению.
Оглавление
GPU и оператор [5]
Заключение [10]
Заказчиком выступала команда дата-аналитиков. Основной сценарий — анализ больших объемов данных, хранящихся в S3-совместимом хранилище.
Требования были следующие:
интерактивная работа через Jupyter;
доступ к большим данным без копирования из бакета на машину пользователя;
возможность использовать GPU (не постоянно);
распределенные вычисления для тяжелых задач;
многопользовательская среда (5–6 постоянных пользователей + эпизодические);
обеспечить широкий выбор ресурсов, но не платить за них в периоды простоя.
Изначально в компании уже существовал Kubernetes-кластер, однако было принято решение вынести ML-нагрузку в отдельный кластер. Это позволило изолировать ресурсоемкие задачи аналитиков и избежать влияния на другие сервисы, не связанные с обработкой данных.
Архитектура получилась довольно простой, но при этом покрывающей все требования:
Kubernetes-кластер (Managed);
node-группы:
CPU (разных размеров);
GPU (V100);
Dask (worker/scheduler);
JupyterHub — точка входа пользователей;
NVIDIA GPU Operator — управление GPU;
Dask — распределенные вычисления;
S3 (Object Storage) + CSI — доступ к данным.
Логика [11] работы:
Пользователь → JupyterHub → под → нужная node-группа → доступ к данным через S3 → при необходимости подключение к Dask.
Kubernetes: master-нода не имеет публичного IP-адреса, поэтому доступ до API ограничен частными сетями, достижимыми через VPC.
Цепочка доступа:
Пользователь подключается через VPN.
Есть балансировщик, IP которого — адрес внутри подсети, где расположен кластер. Сервисы JupyterHub и Dask доступны только через внутренние DNS-имена, которые резолвятся в IP балансировщика.
Балансировщик перенаправляет трафик на NodePort’ы нод кластера. Дальше трафик встречает ингресс-контроллер и разводит его по сервисам.
Вычислительный слой кластера разделен на несколько node-групп, каждая из которых предназначена для своего типа нагрузки:
небольшие infra-ноды для различных служебных сервисов (например, мониторинг, менеджер сертификатов и т.д.);
CPU-ноды разных размеров (mini, small, large и т.д.) для пользовательских notebook’ов;
GPU-ноды для задач, требующих ускорения;
отдельные node-группы под Dask, чтобы поды для распределенных вычислений не конкурировали за ресурсы с пользовательскими или инфраструктурными подами.
Такое разделение дает возможность изолировать различные типы workload’ов и обеспечить предсказуемое поведение [12] системы под нагрузкой.
Размещение как пользовательских, так и служебных подов управляется через стандартные механизмы Kubernetes:
labels — для маркировки нод;
taints и tolerations — для ограничения размещения.
resource "yandex_kubernetes_node_group" "k8s-ml-large-v100" {
cluster_id = yandex_kubernetes_cluster.k8s-ml.id
name = "k8s-ml-large-v100"
version = "1.32"
node_labels = { "node.kubernetes.io/role" = "jupyter", "inst_type" = "large-v100" }
node_taints = ["jupyter=true:NoSchedule"]
instance_template {
name = "k8s-ml-large-v100-8-48-gpu-{instance.short_id}"
platform_id = "gpu-standard-v2"
resources {
memory = 48
cores = 8
core_fraction = 100
gpus = 1
}
metadata = {
"serial-port-enable" = "1"
ssh-keys = "admin:${local.ssh-keys.admin} admin"
}
gpu_settings {
gpu_environment = "runc"
}
}
scale_policy {
auto_scale {
min = 0
max = 2
initial = 0
}
}
allocation_policy {
location {
zone = yandex_vpc_subnet.k8s-nodes-ml-a.zone
}
}
}
Запуск пользователем Jupyter приводит к созданию пода, и при отсутствии подходящей ноды автоскейлер инициирует ее создание. После завершения работы под удаляется, и если нода больше не используется, она также удаляется автоматически. Так ресурсы тарифицируются только в момент реальной нагрузки, что важно для дорогостоящих GPU и крупных инстансов.
Для работы с GPU используется NVIDIA GPU Operator.
Есть подготовленный Docker-образ Jupyter с предустановленными библиотеками и инструментами для аналитиков. Однако может возникнуть сложность, когда придется обновлять отдельные компоненты (в частности, CUDA). Нельзя исключать, что версия драйвера, предустановленная на GPU-нодах в Yandex Cloud, окажется несовместимой с требуемой версией CUDA.
Поэтому было принято решение использовать GPU Operator. Его советует использовать сам Яндекс. Это позволило вынести управление драйверами в Kubernetes и обеспечить установку нужной версии, дало согласованность драйвера и CUDA в пользовательских образах и возможность быстрого управляемого обновления.
Чтобы GPU-оператор корректно обрабатывал ноду, необходимо заказать у Yandex Cloud ноду без предустановленных драйверов, то есть указать в конфигурации Terraform следующее:
gpu_settings {
gpu_environment = "runc"
}
Подробнее об этом можно прочитать в моей статье, посвященной GPU-оператору [13]. Устанавливается оператор стандартным способом — через официальный helm-чарт [14]. Конфигурация привязана к GPU-нодам через nodeSelector:
driver:
enabled: true
nodeSelector:
inst_type: large-v100
JupyterHub используется как основной интерфейс. Пользователь заходит в веб-интерфейс хаба через GitLab OAuth и получает на выбор список доступных для работы типов инстансов. Доступ пользователей к JupyterHub ограничивается вхождением в определенные группы GitLab. В кластере два JupyterHub, один имеет ReadWrite-доступ к бакетам, а другой ReadOnly. Так можно разграничить доступ к бакетам из Jupyter-среды.
# Пример ReadOnly
OAuthenticator:
gitlab_url: <https://gitlab.example.ru>
allowed_gitlab_groups:
- "jupyter-ro" # Группа GitLab, членам которой разрешен доступ ReadOnly
Профили пользователей настроены так, что под включает в себя все ресурсы ноды. А значит, даже если два пользователя захотят взять одинаковые инстансы, для них будут подняты отдельные ноды. Это позволяет пользователям, которые хотят запустить одинаковый тип инстанса, не делить между собой мощности ноды и получать заявленные в профилях вычислительные ресурсы.
Это довольно важная деталь. В ином случае, если бы один пользователь уже запустил ноду и не занял ее полностью, а в этот момент другой пользователь запросил бы такой же тип инстанса, то их поды оказались бы на одной ноде. Scheduler Kubernetes не счел бы нужным триггерить новую ноду, если на существующей могут уместиться оба пода.
singleuser:
profileList:
- display_name: "4CPU 32RAM"
description: "jupyter-small"
default: true
kubespawner_override:
node_selector:
inst_type: "small"
cpu_guarantee: 3
cpu_limit: 3
mem_guarantee: "26G"
mem_limit: "26G"
Каждый пользователь в своем Jupyter-инстансе имеет доступ к бакету S3 в Yandex Object Storage. Также у каждого есть свой личный PVC и PV с SSD-диском в облаке для домашней директории.
storage:
capacity: 100Gi # Домашняя директория
dynamic:
storageClass: yc-network-ssd
# Дополнительные тома для монтирования в под — доступ в S3
extraVolumes:
- name: ml-data-1
persistentVolumeClaim:
claimName: csi-s3-ml-data-1
- name: ml-data-2
persistentVolumeClaim:
claimName: csi-s3-ml-data-2
JupyterHub задеплоен с использованием helm-chart [15].
К слову о том, почему мы решили использовать именно профили JupyterHub, а не nodeAffinity. Второй вариант лишь указывает предпочтительное или обязательное размещение пода на нодах с определенными метками, но не предотвращает размещение нескольких подов на одной ноде, если на ней достаточно свободных ресурсов. Это могло бы привести к ситуации, когда два пользователя делят один и тот же инстанс, что снижает предсказуемость производительности.
Данные хранятся в Object Storage и монтируются через CSI S3. Сам CSI S3 установлен из официального GitHub-репозитория [16].
Используется драйвер ru.yandex [17].s3.csi и маунтер geesefs.
Схема доступа к S3 выглядит так:
Под → PVC → PV → StorageClass → CSI driver → GeeseFS → S3 bucket.
Это работает следующим образом:
Отдельно описывается StorageClass с provisioner: ru.yandex.s3.csi, который говорит Kubernetes, что если какой-либо PersistentVolumeClaim запросит этот класс хранения, то volume должен создаваться и обслуживаться CSI-драйвером Yandex S3. Сам класс задает параметры подключения к бакету: имя бакета, секреты для доступа, которые использует CSI, тип маунтера и пр.
В качестве маунтера используется GeeseFS — это FUSE-based файловая система для S3. Пользователь работает с данными как с обычными файлами, например через ls или cat, а под капотом выполняются запросы к S3 API.
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
name: csi-s3
provisioner: ru.yandex.s3.csi
parameters:
mounter: geesefs
options: "--memory-limit=1000 --dir-mode=0777 --file-mode=0666"
bucket: <имя_существующего_бакета>
csi.storage.k8s.io/provisioner-secret-name: csi-s3-secret
csi.storage.k8s.io/provisioner-secret-namespace: kube-system
csi.storage.k8s.io/controller-publish-secret-name: csi-s3-secret
csi.storage.k8s.io/controller-publish-secret-namespace: kube-system
csi.storage.k8s.io/node-stage-secret-name: csi-s3-secret
csi.storage.k8s.io/node-stage-secret-namespace: kube-system
csi.storage.k8s.io/node-publish-secret-name: csi-s3-secret
csi.storage.k8s.io/node-publish-secret-namespace: kube-system
Обычно такой подход нужен для динамического провиженинга PV и создания новых бакетов, но в нашем случае это скорее опциональная возможность. Наши аналитики уже хранят данные для работы в существующих бакетах, и им требуется подключение именно к ним.
Для динамического провиженинга пришлось бы создавать отдельный StorageClass с прописанным полем bucket для каждого бакета. Мы решили не плодить StorageClass и использовать статический провиженинг. Для этого в PVC поле storageClassName нужно оставлять пустым, а в PV явно указывать, к какому PVC он относится, и вручную задавать параметры подключения, которые при динамическом провиженинге передавались бы через StorageClass.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: csi-s3-ml-data-1
namespace: jupyterhub
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 4200Gi
storageClassName: ""
apiVersion: v1
kind: PersistentVolume
metadata:
name: s3-ml-data-1
spec:
storageClassName: csi-s3 # опционально
capacity:
storage: 4200Gi
accessModes:
- ReadWriteMany
claimRef:
namespace: jupyterhub
name: csi-s3-ml-data-1
csi:
driver: ru.yandex.s3.csi
volumeHandle: ml-data-1 # имя существующего S3-бакета
controllerPublishSecretRef:
name: csi-s3-secret
namespace: kube-system
nodePublishSecretRef:
name: csi-s3-secret
namespace: kube-system
nodeStageSecretRef:
name: csi-s3-secret
namespace: kube-system
volumeAttributes:
capacity: 4200Gi
mounter: geesefs
options: "--memory-limit=1000 --dir-mode=0777 --file-mode=0666 --uid=1001"
Так были подключены все бакеты, необходимые пользователям. Тот же подход использовался и для монтирования бакетов в Dask-поды. Данные монтируются напрямую в файловую систему пользователя, например, в /home/ml-data-1 и /home/ml-data-2.
Если мы стремимся сделать систему экономной и выключать ноды пользователя, когда он не работает с Jupyter, у такого подхода есть свои издержки. Например, с момента запуска до того момента, когда пользователь получает доступ к работающему Jupyter-инстансу, может пройти от 10 до 20 минут.
По наблюдениям, без S3 запуск занимает примерно 1–4 минуты, а с S3 — около 15 минут.
Причина в том, что node-группы по умолчанию масштабируются в ноль, и ноды в группе появляются только по запросу пользователя, когда он запускает под с Jupyter. После этого требуется время на триггер создания ноды, поднятие виртуальной машины в Compute Cloud, подключение ноды к Kubernetes-кластеру и установку на нее CSI S3-драйвера. Пока все это происходит, под Jupyter выдает ошибки [18] вида driver name ru.yandex.s3.csi not found.
Чтобы Jupyter не завершил под раньше времени, желательно выставить достаточно большой таймаут ожидания готовности инфраструктуры, которая должна его обслуживать. Иначе хаб удалит под еще до того, как тот вообще сможет корректно запуститься со всеми необходимыми маунтами.
singleuser:
profileList:
startTimeout: 1800 # 30 мин
На первый взгляд, для ожидания готовности CSI-драйвера можно было бы использовать Init-контейнер, который проверяет доступность маунта S3 перед запуском основного контейнера. Однако такой подход не решает основную проблему — время ожидания.
Init-контейнеры запускаются только после того, как под успешно запланирован на ноду. В нашем случае задержка возникает значительно раньше — на этапе создания самой ноды и установки на нее CSI S3-драйвера. Пока нода не появилась в кластере, пока не завершилась инициализация драйвера, под не может быть корректно запущен. А значит, Init-контейнер просто не начнет выполнение.
Для распределенных задач используется Dask-система. Она устанавливается через официальный helm-chart [19]. В него также можно доустановить дополнительные пакеты apt, pip и cuda через переменные EXTRA_APT_PACKAGES, EXTRA_CONDA_PACKAGES и EXTRA_PIP_PACKAGES.
scheduler:
name: dask-scheduler
enabled: true
replicas: 1
serviceType: "ClusterIP"
servicePort: 8786
# для развертывания именно на scheduler-ноде
nodeSelector:
dask-component: scheduler
dask-cluster: dask-k8s
workload: dask-scheduler
tolerations:
- key: "dask"
operator: "Equal"
value: "scheduler"
effect: "NoSchedule"
env:
- name: EXTRA_APT_PACKAGES
value: "build-essential pkg-config libssl-dev libsnappy-dev cmake llvm clang"
- name: EXTRA_CONDA_PACKAGES
value: "-c conda-forge rust=1.85.* numpy==1.24.4 pandas==2.1.1"
- name: EXTRA_PIP_PACKAGES
value: "polars==1.32.2 cloudpickle==3.0.0 lz4==4.3.2 msgpack==1.0.6 tornado==6.3.3 prometheus-client python-snappy dask==2025.5.1 distributed==2025.5.1 fsspec==2025.9.0 s3fs==2025.9.0 --upgrade"
webUI:
name: dask-webui
servicePort: 80
ingress:
enabled: true
ingressClassName: internal
pathType: Prefix
tls: true
secretName: my-secret-name
hostname: my-dask-dashboard.example.ru
annotations: {}
worker:
name: dask-worker
strategy:
type: RollingUpdate
nodeSelector:
dask-component: worker
dask-cluster: dask-k8s
workload: dask-worker
tolerations:
- key: "dask"
operator: "Equal"
value: "worker"
effect: "NoSchedule"
# ресурсы под worker-ноды (16GB RAM, 4 CPU)
resources:
limits:
cpu: "1.5"
memory: "6Gi"
requests:
cpu: "1"
memory: "5Gi"
env:
- name: EXTRA_APT_PACKAGES
value: "build-essential s3fs pkg-config libssl-dev libsnappy-dev cmake llvm clang"
- name: EXTRA_CONDA_PACKAGES
value: "-c conda-forge rust=1.85.* numpy==1.24.4 pandas==2.1.1"
- name: EXTRA_PIP_PACKAGES
value: "polars==1.32.2 cloudpickle==3.0.0 lz4==4.3.2 msgpack==1.0.6 tornado==6.3.3 s3fs pyarrow==14.0.1 prometheus-client python-snappy dask==2025.5.1 distributed==2025.5.1 --upgrade"
# Монтирование CSI S3 volume для workers
mounts:
volumes:
- name: csi-ml-data-1
persistentVolumeClaim:
claimName: csi-ml-data-1
volumeMounts:
- name: csi-ml-data-1
mountPath: /data/csi-ml-data-1
readOnly: false
portDashboard: 8790
jupyter:
name: jupyter
enabled: false # отключаем Jupyter — используем свой
Ключевая идея Dask заключается в том, чтобы разбивать задачу на множество мелких частей и обрабатывать их параллельно. Для этого в Dask есть scheduler — центральный компонент системы. Он принимает задачи от клиента, строит графы вычислений, определяет порядок их выполнения, распределяет задачи по worker-подам и выполняет остальную координацию. Для своей работы ему не требуется много ресурсов, поэтому под него выделена отдельная небольшая node-группа из одной ноды, на которой больше никто не размещается. Это сделано для того, чтобы тяжелые worker-поды, выполняющие основную ресурсоемкую работу, не влияли на работу scheduler.
Для worker-подов используется node-группа с автоскейлингом от 1 до 3 нод. На каждой ноде может размещаться по два worker-пода, и это ограничено requests самих подов. Автоскейлинг подов задается стандартным Kubernetes HPA:
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 70
Подключение к кластеру из notebook выглядит так:
client = Client('dask-cluster-scheduler.dask-system.svc.cluster.local:8786')
Также есть NodePort-сервис для доступа к кластеру извне, но только из внутренних сетей.
Это решение хорошо подходит для задач анализа данных, обучения [20] моделей, распределенной обработки данных. Оно удобно в тех сценариях, где пользователям нужен общий вычислительный контур с возможностью запускать собственные Jupyter-инстансы и работать с данными в интерактивном режиме.
При этом решение не рассчитано на production inference и не покрывает полный жизненный цикл MLOps. То есть, его основная задача — быть удобной исследовательской и вычислительной средой для аналитиков и ML-инженеров, а не полноценной production-платформой для эксплуатации моделей на всех этапах их жизненного цикла.
В результате получилась минимально достаточная ML-платформа, которая включает GPU, JupyterHub, доступ к данным, распределенные вычисления и масштабируемость. При этом в ней нет лишней сложности и сохраняется полный контроль над инфраструктурой.
Спасибо, что дочитали. Напоследок — еще немного о том, как мы работаем с инфраструктурой:
Автор: alisonium
Источник [26]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/28620
URLs in this post:
[1] KTS: https://kts.tech/devops
[2] опытом: http://www.braintools.ru/article/6952
[3] Постановка задачи: #%D0%9F%D0%BE%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BA%D0%B0%20%D0%B7%D0%B0%D0%B4%D0%B0%D1%87%D0%B8
[4] Общая архитектура: #%D0%9E%D0%B1%D1%89%D0%B0%D1%8F%20%D0%B0%D1%80%D1%85%D0%B8%D1%82%D0%B5%D0%BA%D1%82%D1%83%D1%80%D0%B0
[5] GPU и оператор: #GPU%20%D0%B8%20%D0%BE%D0%BF%D0%B5%D1%80%D0%B0%D1%82%D0%BE%D1%80
[6] JupyterHub как точка входа: #JupyterHub%20%D0%BA%D0%B0%D0%BA%20%D1%82%D0%BE%D1%87%D0%BA%D0%B0%20%D0%B2%D1%85%D0%BE%D0%B4%D0%B0
[7] Доступ к данным (S3 через CSI): #%D0%94%D0%BE%D1%81%D1%82%D1%83%D0%BF%20%D0%BA%20%D0%B4%D0%B0%D0%BD%D0%BD%D1%8B%D0%BC%20(S3%20%D1%87%D0%B5%D1%80%D0%B5%D0%B7%20CSI)
[8] Главная проблема: холодный старт S3: https://www.braintools.ru%20%D1%85%D0%BE%D0%BB%D0%BE%D0%B4%D0%BD%D1%8B%D0%B9%20%D1%81%D1%82%D0%B0%D1%80%D1%82%20S3
[9] Распределенные вычисления (Dask): #%D0%A0%D0%B0%D1%81%D0%BF%D1%80%D0%B5%D0%B4%D0%B5%D0%BB%D0%B5%D0%BD%D0%BD%D1%8B%D0%B5%20%D0%B2%D1%8B%D1%87%D0%B8%D1%81%D0%BB%D0%B5%D0%BD%D0%B8%D1%8F%20(Dask)
[10] Заключение: #%D0%97%D0%B0%D0%BA%D0%BB%D1%8E%D1%87%D0%B5%D0%BD%D0%B8%D0%B5
[11] Логика: http://www.braintools.ru/article/7640
[12] поведение: http://www.braintools.ru/article/9372
[13] GPU-оператору: https://habr.com/ru/companies/kts/articles/962396/
[14] официальный helm-чарт: https://nvidia.github.io/gpu-operator/
[15] helm-chart: https://hub.jupyter.org/helm-chart/
[16] GitHub-репозитория: https://github.com/yandex-cloud/k8s-csi-s3/tree/master/deploy/kubernetes
[17] ru.yandex: http://ru.yandex
[18] ошибки: http://www.braintools.ru/article/4192
[19] helm-chart: https://helm.dask.org
[20] обучения: http://www.braintools.ru/article/5125
[21] Управление SSH-доступом в 2026 году: от зоопарка с jump-host и Ansible к единой точке входа в инфраструктуру с Warpgate: https://habr.com/ru/companies/kts/articles/1020250/
[22] Как мы проводим IT-аудит: живой кейс, инженерный подход и надежность без фанатизма: https://habr.com/ru/companies/kts/articles/1001276/
[23] MLOps-пазл: как мы собрали единый конвейер для ML-моделей из разрозненных инструментов: https://habr.com/ru/companies/kts/articles/993670/
[24] Grafana Operator — дорога к IAC или путь в никуда?: https://habr.com/ru/companies/kts/articles/993002/
[25] Единый вход для ML-стека на примере Keycloak: https://habr.com/ru/companies/kts/articles/971982/
[26] Источник: https://habr.com/ru/companies/kts/articles/1021976/?utm_campaign=1021976&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.