# Как развернуть локальную ИИ-модель на Kubernetes: пошаговое руководство

> AI Release · @ai_release1 · https://ai-release.org/guides/kak-razvernut-lokalnuyu-ii-model-na-kubernetes-poshagov.html

## TL;DR

- Время выполнения: 60–90 минут (большая часть уходит на скачивание весов модели, а не на манифесты)
- Уровень сложности: Intermediate — нужны базовые навыки `kubectl` и понимание, что GPU в Kubernetes выдаётся как ресурс `nvidia.com/gpu`
- Понадобится:
  - `kubectl`, совместимый с версией вашего кластера (сверьтесь с документацией)
  - кластер Kubernetes с GPU-нодой либо kind/minikube/k3s для учебного прогона на CPU
  - NVIDIA Container Toolkit на нодах и NVIDIA device plugin в кластере — без него GPU не появится в `allocatable`
  - StorageClass, поддерживающий `ReadWriteOnce` (PVC — сетевое хранилище, которое живёт дольше пода)
  - токен Hugging Face, если модель закрыта лицензией
  - сервер инференса с OpenAI-совместимым API: vLLM для GPU или `llama-server` из llama.cpp для CPU (сверьтесь с документацией по актуальным тегам образов)

## Требования и подготовка
![Иллюстрация к разделу «Требования и подготовка»](https://ai-release.org/guides/img/kak-razvernut-lokalnuyu-ii-model-na-kubernetes-poshagov-ru-1.jpg)


- Рабочая станция: Linux, macOS или Windows с WSL2; установлен `kubectl`, есть рабочий `kubeconfig` с правами создавать `namespace`, `deployment`, `service`, `pvc`, `job`.
- Кластер Kubernetes: минимум 3 узла или однокорневой kind/k3s — не важно, важен ресурс GPU на ноде.
- Драйвер NVIDIA на нодах + NVIDIA Container Toolkit. Проверяется через `allocatable` (см. Шаг 1).
- Свободное место под веса модели: размер берите из карточки модели на Hugging Face (сверьтесь с документацией), плюс запас на кэш токенизатора и логи.
- Токен Hugging Face с правом чтения, если модель gated (доступ по запросу).
- Без GPU: тот же кластер подойдёт, но vLLM рассчитан в первую очередь на GPU — для CPU-сценария берите `llama-server`, он работает на CPU (сверьтесь с документацией по поддерживаемым квантизациям).

Если кластер разворачивается на домашнем железе и вы ещё не решили, где держать сервисы, начните с материала [Как подключить ИИ к домашнему серверу: пошаговое руководство по self-hosted сервисам](https://ai-release.org/guides/kak-podklyuchit-ii-k-domashnemu-serveru-poshagovoe-ruko.html?utm_source=tg&utm_medium=channel&utm_campaign=guide_inline&utm_content=guide_to_guide) — там разобрана база, а этот гайд продолжает её на уровне Kubernetes.

**Пример:** модель на 7–8 млрд параметров в 4-битной квантизации занимает единицы гигабайт, а полные веса в fp16 — десятки. Возьмите точный размер из карточки модели и умножьте на 2 для запаса под кэш.

## Шаг 1. Пространство имён, проверка GPU и хранилище под веса

**Что сделать:** создать namespace, убедиться, что кластер видит GPU, и подготовить PVC.

**Зачем:** namespace изолирует ресурсы, проверка `allocatable` отсекает 80% будущих проблем с `Pending`, а PVC нужен, чтобы веса модели скачались один раз и пережили пересоздание пода.

**Как проверить:** PVC в статусе `Bound`, в колонке GPU — число больше нуля.

```bash
kubectl create namespace llm
kubectl config set-context --current --namespace=llm
kubectl get nodes -o custom-columns=NAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu
```

Ожидаемый вывод для GPU-ноды:

```
NAME       GPU
gpu-node1  1
```

Если в колонке `<none>` — драйвер или device plugin не установлены. Проверьте поды плагина (имя DaemonSet сверьте с документацией вашей установки):

```bash
kubectl -n kube-system get pods | grep -i device-plugin
kubectl -n kube-system logs <pod-плагина>
```

Почему это работает: kubelet публикует GPU как расширенный ресурс `nvidia.com/gpu`, и планировщик выдаёт его только тем подам, у которых он указан в `limits`.

Дальше — хранилище. Список классов:

```bash
kubectl get storageclass
```

Манифест PVC (`models-pvc.yaml`):

```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: models-pvc
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: <ваш-storageclass>
  resources:
    requests:
      storage: 200Gi
```

```bash
kubectl apply -f models-pvc.yaml
kubectl get pvc models-pvc
```

Ожидаемый вывод: `models-pvc   Bound   ...   200Gi   RWO ...`. Значение `200Gi` — пример, замените на размер весов модели плюс запас. `<ваш-storageclass>` — имя из вывода `kubectl get storageclass` (например, `local-path` или `standard`).

**Пример:** если модель весит около 15 ГБ в квантизованном виде, `50Gi` хватит с запасом на две версии модели одновременно — это удобно при обновлении без простоя.

Токен для закрытых моделей:

```bash
kubectl create secret generic hf-token --from-literal=token=<hf_ваш_токен>
```

## Шаг 2. Загрузка весов и запуск сервера инференса
![Иллюстрация к разделу «Шаг 2. Загрузка весов и запуск сервера инференса»](https://ai-release.org/guides/img/kak-razvernut-lokalnuyu-ii-model-na-kubernetes-poshagov-ru-2.jpg)


**Что сделать:** скачать веса в PVC отдельным Job, затем поднять Deployment с vLLM и Service на порт 8000.

**Зачем:** Job отделяет долгую загрузку от запуска — под не перезапускается каждые 30 секунд, а модель не скачивается заново при каждом рестарте. Порт 8000 — стандартный для OpenAI-совместимого API vLLM, поэтому к серверу подойдёт любой клиент, умеющий `OPENAI_BASE_URL`.

**Как проверить:** Job в статусе `Complete`, затем `rollout status` вернёт успех.

Сначала Job (`download-job.yaml`):

```yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: model-download
spec:
  backoffLimit: 2
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: downloader
          image: <образ-с-huggingface-cli>   # сверьтесь с документацией
          env:
            - name: HF_TOKEN
              valueFrom:
                secretKeyRef:
                  name: hf-token
                  key: token
          command:
            - huggingface-cli
            - download
            - <org>/<model>
            - --local-dir
            - /models/<model>
          volumeMounts:
            - name: models
              mountPath: /models
      volumes:
        - name: models
          persistentVolumeClaim:
            claimName: models-pvc
```

```bash
kubectl apply -f download-job.yaml
kubectl get job model-download -w
kubectl logs job/model-download
```

Ожидаемый вывод: `job.batch/model-download condition met` и в логах — список скачанных `.safetensors`. `<org>/<model>` — идентификатор модели на Hugging Face, например `org/model-name`.

Теперь Deployment (`vllm-deploy.yaml`):

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm
spec:
  replicas: 1
  selector:
    matchLabels: { app: vllm }
  template:
    metadata:
      labels: { app: vllm }
    spec:
      containers:
        - name: vllm
          image: <образ-vllm-openai>   # тег сверьте с документацией
          args:
            - --model=/models/<model>
            - --served-model-name=local-model
            - --host=0.0.0.0
            - --port=8000
            - --gpu-memory-utilization=0.90
          ports:
            - containerPort: 8000
          resources:
            limits:
              nvidia.com/gpu: 1
          readinessProbe:
            httpGet: { path: /health, port: 8000 }
            initialDelaySeconds: 60
            periodSeconds: 10
            failureThreshold: 30
          volumeMounts:
            - name: models
              mountPath: /models
      volumes:
        - name: models
          persistentVolumeClaim:
            claimName: models-pvc
---
apiVersion: v1
kind: Service
metadata:
  name: vllm
spec:
  selector: { app: vllm }
  ports:
    - port: 8000
      targetPort: 8000
```

```bash
kubectl apply -f vllm-deploy.yaml
kubectl rollout status deploy/vllm --timeout=15m
```

Ожидаемый вывод: `deployment "vllm" successfully rolled out`.

Почему это работает: `nvidia.com/gpu: 1` в `limits` — единственное место, где запрашивается GPU (в `requests` его дублировать не нужно, Kubernetes подставит значение сам). `initialDelaySeconds: 60` даёт серверу время загрузить веса до первой проверки, иначе под уйдёт в `CrashLoopBackOff` ещё до готовности. `--gpu-memory-utilization` ограничивает долю VRAM под веса и KV-кэш (кэш ключей/значений — память под контекст диалога).

**Пример:** при `--served-model-name=local-model` все клиенты обращаются к модели по имени `local-model`, а не по длинному пути `/models/<model>` — это упрощает смену весов без правки клиентского кода.

## Шаг 3. Проверка работы и финальный результат
![Иллюстрация к разделу «Шаг 3. Проверка работы и финальный результат»](https://ai-release.org/guides/img/kak-razvernut-lokalnuyu-ii-model-na-kubernetes-poshagov-ru-3.jpg)


**Что сделать:** пробросить порт, запросить список моделей и отправить тестовый чат-запрос.

**Зачем:** успешный `rollout` говорит только о том, что контейнер стартовал. Реальная проверка — ответ API с непустым `content`.

**Как проверить:** три команды ниже; на третьей в `choices[0].message.content` должен появиться текст.

```bash
kubectl port-forward svc/vllm 8080:8000
```

Во втором терминале:

```bash
curl -s localhost:8080/v1/models
```

Ожидаемый вывод (сокращённо):

```json
{"object":"list","data":[{"id":"local-model","object":"model","owned_by":"vllm"}]}
```

```bash
curl -s localhost:8080/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"local-model","messages":[{"role":"user","content":"Ответь одним словом: работает"}],"max_tokens":16}'
```

Ожидаемый вывод:

```json
{"choices":[{"message":{"role":"assistant","content":"Работает"}}],"model":"local-model"}
```

Чек-лист приёмки:

- [ ] `kubectl get pods` — под `vllm-*` в статусе `Running`, `READY 1/1`
- [ ] `kubectl get pvc models-pvc` — `Bound`
- [ ] `/v1/models` возвращает `id: local-model`
- [ ] `/v1/chat/completions` возвращает непустой `content`
- [ ] `kubectl logs deploy/vllm` не содержит `ERROR` и `OOM`

Дальше сервер можно подключить к клиентам как обычный OpenAI-совместимый endpoint: в коде задайте `OPENAI_BASE_URL=http://vllm.llm.svc.cluster.local:8000/v1` и `OPENAI_API_KEY=local` (значение-заглушка, если сервер запущен без ключа). Порт наружу через `port-forward` держите только для отладки — для постоянной работы нужен Ingress с TLS и аутентификацией. Как подключить такой сервер к конкретному инструменту, разобрано в гайде [Как подключить локальную модель к Claude Code: llama-server, OpenAI-совместимый API и лаунчер](https://ai-release.org/guides/kak-podklyuchit-lokalnuyu-model-k-claude-code-llama-ser.html?utm_source=tg&utm_medium=channel&utm_campaign=guide_inline&utm_content=guide_to_guide).

## Возможные проблемы и решения

**Проблема. Под висит в `Pending`, ноды «не подходят».**
- Ошибка: `0/3 nodes are available: 3 Insufficient nvidia.com/gpu.`
- Причина: планировщик не видит свободных GPU — device plugin не установлен, драйвер не загружен на ноде либо GPU уже занят другим подом (в том числе вашим предыдущим).
- Решение: проверьте `allocatable` из Шага 1 и поды плагина; затем освободите GPU или уменьшите `limits` до `1`:
```bash
kubectl describe node <имя-ноды> | grep -A5 "Allocatable"
kubectl get pods -A -o json | grep nvidia.com/gpu
```

**Проблема. Под падает при загрузке модели.**
- Ошибка: `torch.OutOfMemoryError: CUDA out of memory. Tried to allocate ...`
- Причина: веса в fp16 плюс KV-кэш не помещаются в VRAM; либо `--gpu-memory-utilization` выставлен слишком высоко и сервер не оставляет места под активации.
- Решение: смотрите лог предыдущего запуска и снижайте аппетит:
```bash
kubectl logs deploy/vllm --previous | tail -50
```
Уменьшите `--gpu-memory-utilization` до `0.80`, ограничьте `--max-model-len` (например, `4096`) и перейдите на квантизованную версию весов — точные поддерживаемые флаги сверьте с документацией образа.

**Проблема. Под бесконечно рестартует, readiness падает.**
- Ошибка: `Readiness probe failed: Get "http://10.244.0.12:8000/health": dial tcp 10.244.0.12:8000: connect: connection refused`
- Причина: модель грузится дольше, чем `initialDelaySeconds`, или сервер слушает не тот интерфейс/порт.
- Решение: убедитесь, что в `args` есть `--host=0.0.0.0` (иначе сервер слушает только `127.0.0.1` внутри контейнера и probe до него не достаёт), и увеличьте задержку:
```bash
kubectl get deploy vllm -o jsonpath='{.spec.template.spec.containers[0].readinessProbe}'
kubectl patch deploy vllm --type=json -p '[{"op":"replace","path":"/spec/template/spec/containers/0/readinessProbe/initialDelaySeconds","value":180}]'
```

## Резюме и следующие шаги

Вы получили в кластере свой OpenAI-совместимый endpoint на порту 8000 с весами в PVC и проверкой через `/v1/chat/completions`. Следующий шаг — вынести сервис на Ingress с TLS и аутентификацией и добавить вторую версию модели, чтобы обновлять веса через новый PVC без простоя.

## FAQ

**Нужна ли GPU, или модель заработает на CPU?**
Заработает: `llama-server` из llama.cpp умеет работать на CPU, теряя в скорости. vLLM ориентирован на GPU — точный список поддерживаемых бэкендов сверьте с документацией проекта.

**Сколько VRAM потребуется?**
Ориентируйтесь на размер весов из карточки модели плюс запас на KV-кэш под длину контекста. Точные цифры зависят от квантизации и `--max-model-len` — сверьтесь с документацией модели, готовую таблицу VRAM не выдумывайте.

**Как обновить модель без простоя?**
Скачайте веса в новую папку на том же PVC, поднимите второй Deployment с `--served-model-name=local-model-v2`, проверьте его через отдельный Service, затем переключите трафик и удалите старый Deployment.

**Можно ли в этом же кластере запустить голосового агента?**
Да, namespace и Service — обычные объекты Kubernetes. Пример развёртывания self-hosted голосового агента с импортом агента из Dialogflow CX разобран в [этом руководстве](https://ai-release.org/guides/poshagovoe-rukovodstvo-kak-razvernut-self-hosted-goloso.html?utm_source=tg&utm_medium=channel&utm_campaign=guide_inline&utm_content=guide_to_guide).
