Как стать автором
Обновить

Эффективное использование GPU в Kubernetes: Настройка и использование Volcano Scheduler + Volcano vGPU Device Plugin

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров1.7K

Об авторе

Меня зовут Андросов Михаил — DevOps/MLOps-инженер. Последние два года сопровождаю ML-кластера Kubernetes с интенсивной нагрузкой на GPU.

Аппаратные характеристики

Наш типовой bare-metal-узел — сервер с 10 × NVIDIA RTX 4090 (24 GB). GPU enterprise уровня тоже есть, но мы предпочитаем использовать пользовательские GPU: такие карты проще приобрести и эксплуатировать.

Основные проблемы

В процессе запуска бесчисленных ML экспериментов, встали следующие проблемы:

  1. Недоиспользование памяти. Большинство ML задач занимает лишь часть памяти RTX 4090, из-за чего GPU простаивает.

  2. Взаимоблокировки. Для многих ML задач требуется использовать несколько GPU на разных узлах и GPU нужны одновременно. Без этого условия ML задача не запускается, ожидая пока все требуемые GPU ресурсы не будут доступны. Но при этом, когда запускаются pod'ы, они сразу забирают себе свободные ресурсы. При одновременном запуске крупных ML задач, GPU могут быть разобраны вразнобой, что приводит к тому, что pod'ы висят, ожидая свободные GPU ресурсы. И не одна задача по итогу не стартует.

  3. Ограниченный бюджет. Постоянно наращивать парк серверов нецелесообразно — нам нужно повысить отдачу от уже имеющихся GPU.

Отсюда появляется задача: разделить физические GPU на vGPU и подключить более умный планировщик, чтобы устранить ресурсные взаимоблокировки.

Поиск решений

Gang Scheduling — основа стратегии

Чтобы гарантировать одновременный старт всех процессов одной задачи, применяют Gang Scheduling. Планировщик выдаёт ресурсы только тогда, когда найден полный набор GPU для каждой ML задачи. Так мы избегаем ситуации, когда часть задач висит в очереди, а другая часть уже заблокировала GPU.

Что умеют готовые планировщики

Volcano (наш выбор)

Проект - https://github.com/volcano-sh/volcano

Kubernetes-нативный планировщик, изначально заточенный под AI/ML-нагрузки. Поддерживает TensorFlow, PyTorch, Spark, Ray; реализует Gang Scheduling через CRD PodGroup, умеет приоритеты и очереди, интегрируется с Kubeflow.

Koordinator

Проект - https://github.com/koordinator-sh/koordinator

QoS-ориентированный планировщик для смешанных (batch + online) нагрузок. Есть Strict/Non-Strict Gang Scheduling, двухуровневое описание Gang и тесная интеграция с Kubeflow.

Coscheduling Plugin

Проект - https://github.com/kubernetes-sigs/scheduler-plugins/blob/master/pkg/coscheduling/README.md

Плагин к стандартному kube-scheduler: добавляет Gang Scheduling через PodGroup, практически не требует изменений существующего кластера.

Подходы к логическому делению GPU

NVIDIA Time-Slicing

  • Делит «по времени»: несколько контейнеров по очереди получают доступ к одной карте.

  • Настраивается через NVIDIA GPU Operator + ConfigMap.

  • Минус — нет жёсткой изоляции и метрик по памяти/ядрам: сложно понять, чем заняты «доли» GPU.

NVIDIA Multi-Instance GPU (MIG)

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

  • Доступно только на enterprise картах (A100, H100 и др.), которых у нас очень мало.

Итог: для NVIDIA RTX 40XX и оба варианта не закрывают все потребности: MIG недоступен, Time-Slicing не даёт метрик и строгой изоляции.

Проект HAMi и плагин Volcano vGPU Device Plugin

Дальнейшие поиски привели к Project HAMi — набор инструментов для виртуализации гетерогенных ускорителей (GPU, NPU и др.) в Kubernetes.
Ключевой компонент — Volcano vGPU Device Plugin. Он:

  • Разбивает физическую GPU на несколько vGPU с лимитами по памяти и ядрам.

  • Отдаёт эти vGPU планировщику Volcano, который распределяет их между задачами.

  • Обеспечивает жёсткую изоляцию, потому что работает поверх NVIDIA Device Plugin и использует библиотеку HAMi-core.

Такой тандем решает обе проблемы: убирает взаимоблокировки и повышает утилизацию карт.


Тестовое окружение

  • Control-plane: 3 узла.

  • Worker-узел №1: 5 × NVIDIA RTX 4090 — 24 GB.

  • Worker-узел №2: 5 × NVIDIA RTX 4070 Super — 16 GB.

Развертывание кластера Kubernetes

Рассматривать процедуру развертывания не буду: подойдёт любой способ (kubeadm, Kubespray, metal³ и т.д.), лишь бы получился кластер c CRI containerd и GPU.
Мой кластер работает на ОС AlmaLinux 9.5 (Teal Serval).

Подготовка узлов кластера

Если GPU уже работают в вашем кластере — пропускайте раздел. Остальным понадобится:

  • драйвер NVIDIA с DKMS;

  • NVIDIA Container Toolkit;

  • небольшой патч конфигурации containerd.

Установка драйвера NVIDIA

Полная инструкция ― в официальном гайде, раздел RHEL 9; ниже — краткая выжимка под AlmaLinux 9.

# подготовка окружения
dnf install -y epel-release --enablerepo=extras
dnf install -y kernel-devel-$(uname -r) kernel-headers dkms

# подключаем репозиторий CUDA + драйверов
dnf config-manager \
  --add-repo \
  "http://developer.download.nvidia.com/compute/cuda/repos/rhel9/$(uname -i)/cuda-rhel9.repo"

# ставим последний драйвер с DKMS
dnf module install -y nvidia-driver:latest-dkms

Нужно выполнить перезагрузку

reboot

Установка NVIDIA Container Toolkit

Container Toolkit внедряет libnvidia-container, nvidia-ctk и runtime-хуки, позволяя контейнерам работать с GPU через CRI.

# добавляем репозиторий
curl -sL https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo \
  | tee /etc/yum.repos.d/nvidia-container-toolkit.repo

# устанавливаем toolkit
dnf install -y nvidia-container-toolkit

Патчим containerd

Ручное редактирование /etc/containerd/config.toml больше не нужно — за нас всё сделает nvidia-ctk.

# генерируем и применяем конфиг runtime
nvidia-ctk runtime configure --runtime=containerd

# перезапускаем CRI
systemctl restart containerd

Проверка

Осталось убедиться что GPU отображаются в системе:

nvidia-smi
Мои 5 GPU готовы к работе
Мои 5 GPU готовы к работе

Если команда не выводит таблицу с GPU - проверьте, не пропущен ли какой-то шаг, и загляните в официальную документацию NVIDIA.


Установка и настройка Volcano Scheduler и Volcano vGPU Device Plugin

Для установки и конфигурации Volcano Scheduler я использую Helm (я предпочитаю связку Helm + ArgoCD — но не буду на это отвлекаться).

Устанавливаем Volcano

Проект — https://github.com/volcano-sh/volcano.
Полная инструкция — в официальном руководстве Volcano, здесь приведён минимальный рабочий сценарий.

Добавляем репозиторий и скачиваем дефолтные values:

helm repo add volcano-sh https://volcano-sh.github.io/helm-charts
helm repo update volcano-sh
helm show values volcano-sh/volcano > values.yaml

Открываем values.yaml и переопределяем секцию scheduler_config_override, добавив плагин deviceshare флагом VGPUEnable: true:

  admission_config_override: ~
  scheduler_config_override: |
    actions: "enqueue, allocate, backfill"
    tiers:
    - plugins:
      - name: priority
      - name: gang
      - name: conformance
    - plugins:
      - name: drf
      - name: deviceshare # отвечает за vGPU
        arguments:
          deviceshare.VGPUEnable: true
      - name: predicates
      - name: proportion
      - name: nodeorder
      - name: binpack

Если планируете запускать компоненты Volcano на control-plane как и я, пропишите tolerations и affinity:

default_tolerations:
  - key: "node-role.kubernetes.io/control-plane"
    operator: "Equal"
    effect: "NoSchedule"

default_affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values: [ master0, master1, master2 ]

Применяем чарт и проверяем, что все Pod'ы готовы:

helm install volcano volcano-sh/volcano \
  --namespace volcano-system --create-namespace \
  -f values.yaml

kubectl -n volcano-system get pods
Pod'ы запущены
Pod'ы запущены

Устанавливаем Volcano vGPU Device Plugin

Проект — https://github.com/Project-HAMi/volcano-vgpu-device-plugin.

⚠️ README проекта всё ещё ссылается на nvidia-docker. На практике c установленным NVIDIA Container Toolkit всё работает.

Скачиваем манифест

curl -LO https://raw.githubusercontent.com/Project-HAMi/volcano-vgpu-device-plugin/refs/heads/main/volcano-vgpu-device-plugin.yml

В секции spec.template.spec.containers[0].args задаём:

args: ["--device-split-count=2"]   # 2 vGPU на каждую физическую GPU

Заметка про ConfigMap volcano-vgpu-node-config

На первый взгляд JSON ниже идеально подходит для тонкой настройки разных узлов:

  config.json: |
    {
        "nodeconfig": [
            {
                "name": "aio-node67",
                "operatingmode": "hami-core",
                "devicememoryscaling": 1.8,
                "devicesplitcount": 10,
                "migstrategy":"none",
                "filterdevices": {
                  "uuid": [],
                  "index": []
                }
            }
        ]
    }

На практике плагин игнорирует этот файл и читает только CLI-флаги. Если требуется различное число vGPU для разных типов карт, проще пометить узлы label-ами и создать несколько DaemonSet-ов с разными --device-split-count.

Применяем манифест и проверяем запуск:

kubectl apply -f volcano-vgpu-device-plugin.yml
kubectl -n kube-system get pods -l app=volcano-device-plugin
Device Plugin запущен и работает
Device Plugin запущен и работает

Заметка про NVIDIA Device Plugin

HAMi-FAQ рекомендует использовать только один GPU Device Plugin на узел: одновременный запуск NVIDIA Device Plugin и Volcano vGPU Device Plugin «теоретически возможен, но не рекомендуется» из-за потенциальных конфликтов в работе плагинов.
Если NVIDIA Device Plugin уже развёрнут (например, через Helm или Argo CD), нужно его удалить.


Проверяем связку Volcano Scheduler + vGPU Device Plugin

Запускаем базовый пример vgpu-case01

Скачиваем манифест и запускаем его:

   curl -LO https://raw.githubusercontent.com/Project-HAMi/volcano-vgpu-device-plugin/refs/heads/main/examples/vgpu-case01.yml
   kubectl apply -f vgpu-case01.yml

Проверяем, что Pod получил vGPU-лимиты:

kubectl exec test1 -- nvidia-smi
Лимит по памяти работает
Лимит по памяти работает

Видим одну vGPU с ограниченной памятью — всё как ожидалось.

⚠️ Строку HAMI-Core Msg() в выводе можно игнорировать: авторы плагина подтверждают, это лишь информационное сообщение (см. issue #916).

Запускаем базовый пример vgpu-case03

Следующий пример (vgpu-case03) пытается запустить несколько контейнеров, запрашивающих vGPU внутри одного Pod'а.
Скачаем манифест и запустим его:

curl -LO https://raw.githubusercontent.com/Project-HAMi/volcano-vgpu-device-plugin/refs/heads/main/examples/vgpu-case03.yml
kubectl apply -f vgpu-case03.yml

К сожалению, Pod падает в ContainerStatusUnknown.

При запуске нескольких контейнеров запрашивающих vGPU получаем ошибку ContainerStatusUnknown
При запуске нескольких контейнеров запрашивающих vGPU получаем ошибку ContainerStatusUnknown

kubectl describe gpu-pod12 показывает ошибку.

Allocate failed
Allocate failed

На момент написания статьи комбинация «несколько vGPU-контейнеров в одном Pod'е» не поддерживается (см. HAMi #698 и Volcano #2546).
Пока что используйте один контейнер на Pod.

Запускаем базовый пример vgpu-deployment

Скачиваем манифест vgpu-deployment.yaml:

curl -LO https://raw.githubusercontent.com/Project-HAMi/volcano-vgpu-device-plugin/refs/heads/main/examples/vgpu-deployment.yaml

⚠️ kubectl describe node показывает, что памяти доступно чуть-чуть меньше, чем должно быть (доступно 81880 МБ вместо 81920 МБ).

Думаю, несколько мегабайт «откусываются» на системные нужды
Думаю, несколько мегабайт «откусываются» на системные нужды

Нужно это учитывать. Уменьшаем лимит памяти до ~8 ГБ в скаченном манифесте.

resources:
  limits:
    volcano.sh/vgpu-number: 1
    volcano.sh/vgpu-memory: 8188   # ≈ 8 ГБ

Применяем манифест:

kubectl apply -f vgpu-deployment.yaml

Через некоторое кол-во времени все 10 Pod-ов распределены по двум узлам

10 pod успешно запущены и распределены по узлам
10 pod успешно запущены и распределены по узлам

Планировщик Volcano, используя Volcano vGPU Device Plugin, корректно раздал по одной vGPU на Pod.

Проверяем «полное заполнение» vGPU кластера

Сносим прежний Deployment:

kubectl delete -f vgpu-deployment.yaml

Меняем replicasна 20:

spec:
  replicas: 20

Применяем:

kubectl apply -f vgpu-deployment.yaml

И наблюдаем, как все 20 Pod-ов стартуют.

Все pod'ы в статусе Running
Все pod'ы в статусе Running

Pod'ы успешно запросили 20 vGPU у 10 физических GPU, подтвердив, что деление 1 → 2 работает.


Проверка ограничений по памяти

Для демонстрации ограничений создадим один Pod на базе pytorch/pytorch и запросим 1 vGPU + 8 ГБ памяти.

apiVersion: v1
kind: Pod
metadata:
  name: torch-vgpu-test
spec:
  schedulerName: volcano
  restartPolicy: Never
  containers:
  - name: torch
    image: pytorch/pytorch:2.7.0-cuda12.6-cudnn9-runtime
    command: ["bash", "-c", "tail -f /dev/null"]
    resources:
      limits:
        volcano.sh/vgpu-number: 1
        volcano.sh/vgpu-memory: 8188

Применим манифест, дождемся когда он запустится и зайдем внтурь pod'а.

kubectl apply -f pod-torch.yaml
kubectl exec -it torch-vgpu-test -- bash

Внутри контейнера переменная $CUDA_DEVICE_MEMORY_LIMIT_0 показывает доступный объём памяти.

~8 GB
~8 GB

Для утилизации памяти воспользуюсь скриптом который создает тензор:

python - <<'EOF'
import torch, time
a = torch.empty((1024,1024,2048), device='cuda') # ~ 8 GB
print("Tensor allocated on:", torch.cuda.get_device_name(0))
time.sleep(5)
EOF
Памяти не хватило
Памяти не хватило

Попытка занять 8 ГБ одним тензором завершается Device 0 OOM. Ведь доступно не ровно 8 ГБ памяти, а немного меньше.

Попробуем создать тензор поменьше:

python - <<'EOF'
import torch, time
a = torch.empty((1024,1024,1536), device='cuda') # ~ 6 ГБ
print("Tensor allocated on:", torch.cuda.get_device_name(0))
time.sleep(30)
EOF
Теперь памяти хватает
Теперь памяти хватает

В параллельной вкладке nvidia-smi показывает ~6 ГБ занятой памяти.

nvidia-smi показывает занятую память
nvidia-smi показывает занятую память

Нагрузка на vGPU и снятие метрик

Одна из причин отказаться от Time-Slicing (мой личный взгляд) ― отсутствие метрик. С Volcano Scheduler и Volcano vGPU Device Plugin картина иная: метрики доступны из двух источников.

Метрики от Volcano Scheduler (:8080/metrics)

Пробрасываем порт Service и снимаем метрики:

kubectl -n volcano-system port-forward svc/volcano-scheduler-service 8080:8080
curl http://127.0.0.1:8080/metrics

Отрывок вывода

# HELP volcano_vgpu_device_allocated_cores The percentage of gpu compute cores allocated in this card
volcano_vgpu_device_allocated_cores{NodeName="rig01",devID="GPU-0ab3..."} 0

# HELP volcano_vgpu_device_allocated_memory The number of vgpu memory allocated in this card
volcano_vgpu_device_allocated_memory{NodeName="rig01",devID="GPU-0ab3..."} 0

# HELP volcano_vgpu_device_memory_limit Total device memory visible to scheduler (MiB)
volcano_vgpu_device_memory_limit{NodeName="rig01",devID="GPU-0ab3..."} 24564

# HELP volcano_vgpu_device_shared_number The number of vGPU tasks sharing this card
volcano_vgpu_device_shared_number{NodeName="rig01",devID="GPU-0ab3..."} 0

Что означают показатели:

  • allocated_cores ― доля занятых вычислительных ядер (0-100 %).

  • allocated_memory ― фактически выданная память vGPU (МиБ).

  • memory_limit ― сколько памяти объявлено на карту после деления.

  • shared_number ― сколько Pod-ов одновременно используют данную карту.

Отлично, но хочется больше конкретики и увидеть метрики под нагрузкой.

Метрики от Volcano vGPU Device Plugin (:9394/metrics)

Теперь стартую тестовый pod, но с указанием конкретного узла, на котором RTX 4070 Super:

# pod-torch.yaml
apiVersion: v1
kind: Pod
metadata:
  name: torch-vgpu-test
spec:
  schedulerName: volcano
  restartPolicy: Never
  nodeSelector:
    kubernetes.io/hostname: rig02
  containers:
  - name: torch
    image: pytorch/pytorch:2.7.0-cuda12.6-cudnn9-runtime
    command: ["bash", "-c", "tail -f /dev/null"]
    resources:
      limits:
        volcano.sh/vgpu-number: 1
        volcano.sh/vgpu-memory: 8188
kubectl apply -f pod-torch.yaml
kubectl exec -it torch-vgpu-test -- bash

Теперь я хочу дать полную нагрузку на GPU. Использую pythonскрипт с импровизированным burn тестом.

python - <<'PY'
import torch, time
device = 'cuda'

a = torch.empty((1024, 1024, 1536), device=device, dtype=torch.float32)

m = torch.randn((4096, 4096), device=device)

t_end = time.time() + 300
while time.time() < t_end:
    m = m @ m
torch.cuda.synchronize()
print("Finished burn.")
PY

nvidia-smi подтверждает 100 % нагрузки.

100% по GPU-Util
100% по GPU-Util

Пробрасываем порт Pod-а плагина (уточните имя pod'a у себя):

kubectl -n kube-system port-forward volcano-device-plugin-m9hfz 9394:9394

Снимаем метрики:

curl http://127.0.0.1:9394/metrics

Отрывок вывода

# HELP HostCoreUtilization GPU core utilization
HostCoreUtilization{deviceidx="4",deviceuuid="GPU-d58c...",zone="vGPU"} 100

# HELP HostGPUMemoryUsage GPU device memory usage
HostGPUMemoryUsage{deviceidx="4",deviceuuid="GPU-d58c...",zone="vGPU"} 7.26368256e+09

# HELP vGPU_device_memory_limit_in_bytes vGPU device limit
vGPU_device_memory_limit_in_bytes{podname="torch-vgpu-test",vdeviceid="0"} 8.585740288e+09

# HELP vGPU_device_memory_usage_in_bytes vGPU device usage
vGPU_device_memory_usage_in_bytes{podname="torch-vgpu-test",vdeviceid="0"} 6.824264704e+09

Что означают показатели:

  • HostCoreUtilization — загрузка вычислительных ядер физической карты (процент).

  • HostGPUMemoryUsage — фактическая занятость памяти всей карты (байты).

  • vGPU_device_memory_limit_in_bytes — лимит памяти, заданный для конкретного vGPU.

  • vGPU_device_memory_usage_in_bytes — сколько памяти этот vGPU реально потребляет.

Что наблюдаем: GPU загружено на 100%, карта держит в памяти ~7.26 ГБ, из которых конкретный Pod использует 6.82 ГБ при разрешённом лимите 8.58 ГБ.
Лимиты соблюдаются, метрики поступают ― значит плагин работает корректно на отдачу метрик.


Настройка Volcano Scheduler и проверка Gang Scheduling

Способы назначить планировщик Volcano

Явно в манифесте

Самый простой путь — добавить поле schedulerName: volcano в объект Pod/Deployment/Job и др. — этого достаточно, чтобы Pod'ы отправлялись планировщику Volcano.

Автоматически для namespace

Volcano-admission-webhook умеет подменять имя планировщика по правилам, описанным в ConfigMap volcano-admission-configmap.
Пример правила для пространства mljobs (добавляем через values.yaml Helm Chart установки):

  admission_config_override: |
    resourceGroups:
      - resourceGroup: mljobs
        object:
          key: namespace
          value:
            - mljobs
        schedulerName: volcano

Применяем изменения:

helm upgrade volcano volcano-sh/volcano -n volcano-system -f values.yaml

Запускаем Pod без schedulerName, но в нужном namespace — и убеждаемся, что планировщик подставлен автоматически:

# pod-torch.yaml
apiVersion: v1
kind: Pod
metadata:
  name: torch-vgpu-test
  namespace: mljobs
spec:
  restartPolicy: Never
...
kubectl apply -f pod-torch.yaml
kubectl -n mljobs get pod torch-vgpu-test -o jsonpath='{.spec.schedulerName}' ; echo
Используется scheduler volcano
Используется scheduler volcano

Интеграция с ML-фреймворками

На примере Kubeflow Training Operator v1.
Достаточно добавить флаг --gang-scheduler-name=volcano в контейнер Deployment'а training-operator, тогда все TFJob/PyTorchJob/др. будут автоматически формировать PodGroup и попадать в Volcano.

...
    spec:
      containers:
        - command:
            - /manager
            - --gang-scheduler-name=volcano
          image: kubeflow/training-operator
          name: training-operator
...

Аналогичные параметры есть и в других продуктах — примеры легко находятся в официальной документации проектов.

Проверяем, как работает Gang Scheduling

Создаём Deploymentна 6 реплик — каждая запрашивает 1 vGPU без лимита памяти:

# vgpu-deployment.yaml
...
spec:
  selector:
    matchLabels:
      app: resnet101-server
  replicas: 5
  template:
    metadata:
      labels:
        app: resnet101-server
    spec:
      schedulerName: volcano
      containers:
      - name: resnet101-container
        image: ubuntu:18.04
        command: ["sleep","infinity"]
        resources:
         limits:
            volcano.sh/vgpu-number: 1
kubectl apply -f vgpu-deployment.yaml

Создаём Volcano Job с minAvailable: 5 — запустится только если сразу найдёт пять vGPU.

# gang-job.yaml
apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
  name: gpu-job
spec:
  minAvailable: 5
  schedulerName: volcano
  queue: default
  tasks:
  - replicas: 5
    name: trainer
    template:
      spec:
        containers:
          - name: ubuntu
            image: ubuntu:18.04
            command: ["sleep","infinity"]
            resources:
              limits:
                volcano.sh/vgpu-number: 1

Применим манифест

kubectl apply -f gang-job.yaml
kubectl get po -o wide
Pod'ы не стартуют
Pod'ы не стартуют

Все пять Pod-ов останутся в Pending:. Можно посмотреть describe любого pod'а.

Не хватает GPU
Не хватает vGPU

Освобождаем одну vGPU, уменьшив Deploymentдо 5 реплик:

kubectl scale --replicas=5 deployment resnet101-deployment
kubectl get po -o wide
Pod'ы успешно запустились
Pod'ы успешно запустились

Как только освободилась пятая GPU, Jobстартовал: все пять Pod-ов переходят в Running. Поведение соответствует ожиданиям Gang Scheduler: задача запускается только при наличии полного «набора» ресурсов, исключая взаимоблокировки.


Заключение

У Volcano Scheduler есть еще много возможностей, в данной статье были рассмотрена лишь малая часть. В первую очередь хотелось показать именно деление GPU на vGPU.
Volcano vGPU Device Plugin уже показывает стабильную работу, память и ядра жёстко лимитируются, метрики отдаются как планировщиком, так и самим плагином.
Судя по активности репозитория, баги постепенно закрывают, так что решение можно считать пригодным, если и не для для production-кластеров, то для тестовых кластеров точно, где важны все доступные GPU ресурсы.


P.S. Это моя первая публикация на Habr; буду признателен за конструктивные замечания и вопросы.

Теги:
Хабы:
+9
Комментарии6

Публикации

Работа

Ближайшие события