Собираем ML-платформу на базе Kubernetes: Yandex Cloud, JupyterHub, Dask и S3
Собираем ML-платформу на базе Kubernetes: Yandex Cloud, JupyterHub, Dask и S3 - 1

Привет! Я Алиса, DevOps-инженер в KTS.

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

В общем, делюсь практическим опытом построения масштабируемой инфраструктуры с автоскейлингом. Для кого актуально — приглашаю к прочтению.

Оглавление

Постановка задачи

Заказчиком выступала команда дата-аналитиков. Основной сценарий — анализ больших объемов данных, хранящихся в 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 — доступ к данным.

Логика работы:

Пользователь → JupyterHub → под → нужная node-группа → доступ к данным через S3 → при необходимости подключение к Dask.

Kubernetes: master-нода не имеет публичного IP-адреса, поэтому доступ до API ограничен частными сетями, достижимыми через VPC.

Цепочка доступа:

  1. Пользователь подключается через VPN.

  2. Есть балансировщик, IP которого — адрес внутри подсети, где расположен кластер. Сервисы JupyterHub и Dask доступны только через внутренние DNS-имена, которые резолвятся в IP балансировщика.

  3. Балансировщик перенаправляет трафик на NodePort’ы нод кластера. Дальше трафик встречает ингресс-контроллер и разводит его по сервисам.

Вычислительный слой кластера разделен на несколько node-групп, каждая из которых предназначена для своего типа нагрузки:

  • небольшие infra-ноды для различных служебных сервисов (например, мониторинг, менеджер сертификатов и т.д.);

  • CPU-ноды разных размеров (mini, small, large и т.д.) для пользовательских notebook’ов;

  • GPU-ноды для задач, требующих ускорения;

  • отдельные node-группы под Dask, чтобы поды для распределенных вычислений не конкурировали за ресурсы с пользовательскими или инфраструктурными подами.

Такое разделение дает возможность изолировать различные типы workload’ов и обеспечить предсказуемое поведение системы под нагрузкой.

Размещение как пользовательских, так и служебных подов управляется через стандартные механизмы Kubernetes:

  • labels — для маркировки нод;

  • taints и tolerations — для ограничения размещения.

Пример конфигурации node-группы
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 и оператор

Для работы с 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-оператору. Устанавливается оператор стандартным способом — через официальный helm-чарт. Конфигурация привязана к GPU-нодам через nodeSelector:

driver:
  enabled: true
  nodeSelector:
    inst_type: large-v100

JupyterHub как точка входа

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.

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

Доступ к данным (S3 через CSI)

Данные хранятся в Object Storage и монтируются через CSI S3. Сам CSI S3 установлен из официального GitHub-репозитория.

Используется драйвер ru.yandex.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.

Главная проблема: холодный старт S3

Если мы стремимся сделать систему экономной и выключать ноды пользователя, когда он не работает с Jupyter, у такого подхода есть свои издержки. Например, с момента запуска до того момента, когда пользователь получает доступ к работающему Jupyter-инстансу, может пройти от 10 до 20 минут.

По наблюдениям, без S3 запуск занимает примерно 1–4 минуты, а с S3 — около 15 минут.

Причина в том, что node-группы по умолчанию масштабируются в ноль, и ноды в группе появляются только по запросу пользователя, когда он запускает под с Jupyter. После этого требуется время на триггер создания ноды, поднятие виртуальной машины в Compute Cloud, подключение ноды к Kubernetes-кластеру и установку на нее CSI S3-драйвера. Пока все это происходит, под Jupyter выдает ошибки вида driver name ru.yandex.s3.csi not found.

Чтобы Jupyter не завершил под раньше времени, желательно выставить достаточно большой таймаут ожидания готовности инфраструктуры, которая должна его обслуживать. Иначе хаб удалит под еще до того, как тот вообще сможет корректно запуститься со всеми необходимыми маунтами.

singleuser:
	profileList:
		startTimeout: 1800 # 30 мин

На первый взгляд, для ожидания готовности CSI-драйвера можно было бы использовать Init-контейнер, который проверяет доступность маунта S3 перед запуском основного контейнера. Однако такой подход не решает основную проблему — время ожидания.

Init-контейнеры запускаются только после того, как под успешно запланирован на ноду. В нашем случае задержка возникает значительно раньше — на этапе создания самой ноды и установки на нее CSI S3-драйвера. Пока нода не появилась в кластере, пока не завершилась инициализация драйвера, под не может быть корректно запущен. А значит, Init-контейнер просто не начнет выполнение.

Распределенные вычисления (Dask)

Для распределенных задач используется Dask-система. Она устанавливается через официальный helm-chart. В него также можно доустановить дополнительные пакеты 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-сервис для доступа к кластеру извне, но только из внутренних сетей.

Заключение

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

При этом решение не рассчитано на production inference и не покрывает полный жизненный цикл MLOps. То есть, его основная задача — быть удобной исследовательской и вычислительной средой для аналитиков и ML-инженеров, а не полноценной production-платформой для эксплуатации моделей на всех этапах их жизненного цикла.

В результате получилась минимально достаточная ML-платформа, которая включает GPU, JupyterHub, доступ к данным, распределенные вычисления и масштабируемость. При этом в ней нет лишней сложности и сохраняется полный контроль над инфраструктурой.

Спасибо, что дочитали. Напоследок — еще немного о том, как мы работаем с инфраструктурой:

Автор: alisonium

Источник

  • Запись добавлена: 10.04.2026 в 13:54
  • Оставлено в