Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes. DevOps.. DevOps. gpu.. DevOps. gpu. Kubernetes.. DevOps. gpu. Kubernetes. llm.. DevOps. gpu. Kubernetes. llm. managed kubernetes.. DevOps. gpu. Kubernetes. llm. managed kubernetes. mlops.. DevOps. gpu. Kubernetes. llm. managed kubernetes. mlops. qdrant.. DevOps. gpu. Kubernetes. llm. managed kubernetes. mlops. qdrant. rag.. DevOps. gpu. Kubernetes. llm. managed kubernetes. mlops. qdrant. rag. selectel.. DevOps. gpu. Kubernetes. llm. managed kubernetes. mlops. qdrant. rag. selectel. vllm.. DevOps. gpu. Kubernetes. llm. managed kubernetes. mlops. qdrant. rag. selectel. vllm. Блог компании Selectel.. DevOps. gpu. Kubernetes. llm. managed kubernetes. mlops. qdrant. rag. selectel. vllm. Блог компании Selectel. искусственный интеллект.. DevOps. gpu. Kubernetes. llm. managed kubernetes. mlops. qdrant. rag. selectel. vllm. Блог компании Selectel. искусственный интеллект. Монетизация IT-систем.
Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 1

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

Эту проблему решает запуск приватной LLM в облачном Managed Kubernetes. Приватная модель гарантирует конфиденциальность и не отправляет данные во внешние сети, облако переводит капитальные затраты в гибкие операционные, а Kubernetes берет на себя отказоустойчивость и автоматическое масштабирование дорогих GPU-ресурсов. Материал будет полезен DevOps-, MLOps-инженерам и архитекторам, перед которыми стоит задача развернуть изолированную и надежную RAG-систему.

В этой статье рассмотрим, как можно задеплоить LLM в Managed Kubernetes на примере простой RAG-системы. Будем использовать LLM-роутер AIBrix, n8n и vLLM. Дополнительно понадобятся Envoy Gateway (как зависимость для AIBrix), cert-manager (выпустим сертификаты для домена n8n), Qdrant (хранилище для RAG системы) и PostgreSQL в качестве базы данных для n8n. 

Здесь мы не будем рассматривать деплой системы мониторинга. Подробную информацию о ее настройке вы можете найти в нашем репозитории.

Создание кластера

Развертывать кластер Managed Kubernetes будем на облачных серверах в регионе ru-6 — там доступен большой выбор конфигураций с GPU.

В кластере желательно иметь дополнительные ноды для критических системных компонентов из неймспейса kube-system и AIBrix-компонентов. Я оставил две ноды по 4 vCPU, 8 ГБ RAM и диск на 50 ГБ, но можно использовать и менее производительные конфигурации. При такой схеме GPU-ноды при необходимости можно масштабировать в ноль, и все компоненты, включая AIBrix, продолжат работу.

Для работы с GPU я использую две ноды со следующими характеристиками: 8 vCPU, 32 ГБ RAM, диск на 150 ГБ и 1 × GPU RTX 4090 24 ГБ. Диск на 150 ГБ выбран с запасом, чтобы веса модели гарантированно поместились в эфемерном хранилище Kubernetes. При необходимости можно взять больше или меньше. Модели GPU также можно выбрать другие. 

При создании группы GPU-нод стоит установить флаг для автоматической установки GPU-драйверов и nvidia-device-plugin. Процессу vLLM мы будем выделять GPU целиком.

Для группы GPU-нод установим taint при создании: vllm = true : NoExecute. Это нужно, чтобы на этой ноде запускались только поды vllm, поскольку они требуют большого объема вычислительных ресурсов. Нода должна оставаться свободной, иначе поды vLLM зависнут в состоянии Pending.

AIBrix

Изначально планировалось провернуть все на AIBrix версии v0.5.0, но к моменту написания статьи уже вышел релиз v0.7.0. Вот наиболее важные изменения:

  • Добавлена ​​поддержка аудиоинтерфейсов в стиле OpenAI (/v1/audio/transcriptions, /v1/audio/translations) вместе с новым API вывода /v1/classify.

  • Введены совместимые с OpenAI API генерации изображений и видео (/v1/images/generations, /v1/video/generations), позволяющие запускать многомодальные рабочие нагрузки генерации через один и тот же шлюз.

  • Добавлена ​​поддержка моделей переранжирования через эндпоинт /v1/rerank для улучшения конвейеров.

  • Реализован Wire-compatible Batch API для асинхронной пакетной обработки эндпоинтов /v1/chat/completions, /v1/completions и /v1/embeddings, поддерживаемый постоянным хранилищем метаданных и асинхронным конечным автоматом заданий.

  • Запущена AIBrix Management Console (Preview) — новый фронтенд на React и бэкенд на Go, позволяющие регистрировать модели, развертывать приложения из версионированных шаблонов, отслеживать пакетные задания и загружать результаты без необходимости использования kubectl/YAML.

Проект активно развивается и, думаю, хорошо себя покажет на дистанции.

Перейдем к установке. Развернем базовые стабильные компоненты согласно официальному руководству по быстрому старту (Quickstart) в README.md проекта, однако внесем в них несколько патчей для оптимизации работы.

Создаем файл kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
- https://github.com/vllm-project/aibrix/releases/download/v0.7.0/aibrix-core-v0.7.0.yaml

patches:
- target:
    group: gateway.envoyproxy.io
    version: v1alpha1
    kind: EnvoyProxy
    name: aibrix-custom-proxy-config
    namespace: aibrix-system
  patch: |-
    - op: move
      from: /spec/provider/kubernetes/envoyDeployment
      path: /spec/provider/kubernetes/envoyDaemonSet
    - op: remove
      path: /spec/provider/kubernetes/envoyDaemonSet/replicas
    - op: remove
      path: /spec/provider/kubernetes/envoyDaemonSet/strategy
    - op: replace
      path: /spec/provider/kubernetes/envoyDaemonSet/patch/value/spec/template/spec/containers/0/resources
      value:
        requests:
          cpu: "1"
          memory: 2Gi
        limits:
          cpu: "1"
          memory: 2Gi
    - op: add
      path: /spec/provider/kubernetes/envoyService
      value:
        externalTrafficPolicy: Cluster
- target:
    group: apps
    version: v1
    kind: Deployment
    name: aibrix-gateway-plugins
    namespace: aibrix-system
  patch: |-
    - op: replace
      path: /spec/template/spec/containers/0/resources
      value:
        requests:
          cpu: "1"
          memory: 2Gi
        limits:
          cpu: "1"
          memory: 2Gi
- target:
    group: gateway.networking.k8s.io
    version: v1
    kind: Gateway
    name: aibrix-eg
    namespace: aibrix-system
  patch: |-
    - op: add
      path: /spec/infrastructure
      value:
        annotations:
          service.beta.kubernetes.io/openstack-internal-load-balancer: "true"

У нас есть три патча, которые выполняют следующие задачи:

  • Перевод EnvoyProxy из Deployment в DaemonSet и включение параметра externalTrafficPolicy: Cluster для корректной работы с балансировщиком нагрузки по умолчанию. Если вы хотите распределять трафик только по определенным нодам, добавьте аннотацию node-selector для openstack-ccm и перечислите фильтры.

  • Уменьшение объема выделяемых ресурсов на один инстанс для балансировщика с дефолтных 2 vCPU и 8 ГБ RAM до 1 vCPU и 2 ГБ RAM.

  • Оптимизация запрашиваемых ресурсов (resource requests) для компонента aibrix-gateway-plugins с помощью отдельного патча.

  • Добавление аннотации для балансировщика, чтобы зафиксировать за ним только приватный IP-адрес. Этот шаг не является обязательным: вы можете оставить публичный эндпоинт для обращений к моделям из внешней сети, но в таком случае обязательно настройте авторизацию. В этой статье мы опубликуем во внешнюю сеть только интерфейс n8n.

Применяем манифесты (в директории, где создали kustomization.yaml):

kubectl apply --server-side -f 
"https://github.com/vllm-project/aibrix/releases/download/v0.7.0/aibrix-dependency-v0.7.0.yaml"

kubectl apply --server-side -f 
"https://github.com/vllm-project/aibrix/releases/download/v0.7.0/aibrix-core-crds-v0.7.0.yaml"

kubectl apply -k .

vLLM

Создадим токен, которым будем авторизовываться при запросах в LLM и запишем его в секрет:

kubectl create secret generic vllm-api-key 
  --from-literal=api-key=$(pwgen -s1 50) 

Для автоматической генерации токена использую конструкцию $(pwgen -s1 50). Впрочем, без этого можно обойтись и задать пароль текстом.

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 2

Managed Kubernetes на выделенных серверах

Снизьте расходы на ИТ‑инфраструктуру и улучшите производительность микросервисов.

Подробнее →

Манифесты для деплоя моделей

Ниже представлены манифесты Kubernetes для запуска моделей через vLLM. Мы развернем два компонента:

  • Instruct-модель (qwen3-8b) — отвечает за генерацию текста и логику работы агента.

  • Embedding-модель (qwen3-embedding-8b) — отвечает за векторизацию текста для RAG-системы.

Каждый Deployment создает под с одной копией vLLM, которому выделяется ровно один GPU целиком.

Instruct

# Qwen3-8B.
#
# GPU allocation: one exclusive RTX 4090 24 GB.
# vLLM may use up to 90% of its GPU memory; the remaining 10% is reserved for
# CUDA contexts and transient allocations.
#
# Optional API-key Secret:
# kubectl create secret generic vllm-api-key 
#   --from-literal=api-key='<value>'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: qwen3-8b
  namespace: default
  labels:
    model.aibrix.ai/name: qwen3-8b
    model.aibrix.ai/port: "8000"
spec:
  replicas: 1
  # A rolling update could temporarily start a second copy and exhaust the
  # shared GPU. Recreate keeps the VRAM budget deterministic.
  strategy:
    type: Recreate
  selector:
    matchLabels:
      model.aibrix.ai/name: qwen3-8b
  template:
    metadata:
      labels:
        model.aibrix.ai/name: qwen3-8b
        model.aibrix.ai/port: "8000"
      annotations:
        prometheus.io/path: /metrics
        prometheus.io/port: "8000"
        prometheus.io/scrape: "true"
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: vllm-openai
          image: vllm/vllm-openai:v0.15.1
          imagePullPolicy: IfNotPresent
          command:
            - vllm
            - serve
          args:
            - Qwen/Qwen3-8B
            - --tokenizer
            - Qwen/Qwen3-8B
            - --hf-config-path
            - Qwen/Qwen3-8B
            - --served-model-name
            - qwen3-8b
            - --host
            - 0.0.0.0
            - --port
            - "8000"
            - --uvicorn-log-level
            - warning
            - --dtype
            - half
            - --max-model-len
            - "16384"
            - --max-num-seqs
            - "4"
            - --gpu-memory-utilization
            - "0.90"
            - --enable-prefix-caching
            - --enforce-eager
            - --enable-auto-tool-choice
            - --tool-call-parser 
            - "hermes"
          env:
            - name: VLLM_API_KEY
              valueFrom:
                secretKeyRef:
                  name: vllm-api-key
                  key: api-key
                  optional: true
            - name: HF_HOME
              value: /cache/huggingface
          ports:
            - name: serve
              containerPort: 8000
              protocol: TCP
          resources:
            requests:
              cpu: "2"
              memory: 12Gi
              nvidia.com/gpu: "1"
            limits:
              cpu: "2"
              memory: 12Gi
              nvidia.com/gpu: "1"
          livenessProbe:
            httpGet:
              path: /health
              port: 8000
              scheme: HTTP
            failureThreshold: 3
            periodSeconds: 5
            successThreshold: 1
            timeoutSeconds: 1
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
              scheme: HTTP
            failureThreshold: 5
            periodSeconds: 5
            successThreshold: 1
            timeoutSeconds: 1
          startupProbe:
            httpGet:
              path: /health
              port: 8000
              scheme: HTTP
            failureThreshold: 30
            periodSeconds: 10
            successThreshold: 1
            timeoutSeconds: 1
          volumeMounts:
            - name: model-cache
              mountPath: /cache/huggingface
            - name: dshm
              mountPath: /dev/shm
      volumes:
        - name: model-cache
          emptyDir: {}
        - name: dshm
          emptyDir:
            medium: Memory
            sizeLimit: 2Gi
      tolerations:
        - key: vllm
          operator: Exists
          effect: NoExecute
---
apiVersion: v1
kind: Service
metadata:
  name: qwen3-8b
  namespace: default
  labels:
    model.aibrix.ai/name: qwen3-8b
    prometheus-discovery: "true"
  annotations:
    prometheus.io/path: /metrics
    prometheus.io/port: "8000"
    prometheus.io/scrape: "true"
spec:
  type: ClusterIP
  selector:
    model.aibrix.ai/name: qwen3-8b
  ports:
    - name: serve
      port: 8000
      targetPort: serve
      protocol: TCP

embedding

# Qwen3-Embedding-8B in BF16.
#
# GPU allocation: one exclusive RTX 4090 24 GB.
# vLLM may use up to 90% of its GPU memory; the remaining 10% is reserved for
# CUDA contexts and transient allocations.
#
# Optional API-key Secret:
# kubectl create secret generic vllm-api-key 
#   --from-literal=api-key='<value>'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: qwen3-embedding-8b
  namespace: default
  labels:
    model.aibrix.ai/name: qwen3-embedding-8b
    model.aibrix.ai/port: "8000"
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      model.aibrix.ai/name: qwen3-embedding-8b
  template:
    metadata:
      labels:
        model.aibrix.ai/name: qwen3-embedding-8b
        model.aibrix.ai/port: "8000"
      annotations:
        prometheus.io/path: /metrics
        prometheus.io/port: "8000"
        prometheus.io/scrape: "true"
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: vllm-openai
          image: vllm/vllm-openai:v0.15.1
          imagePullPolicy: IfNotPresent
          command:
            - vllm
            - serve
          args:
            - Qwen/Qwen3-Embedding-8B
            - --served-model-name
            - qwen3-embedding-8b
            - --host
            - "0.0.0.0"
            - --port
            - "8000"
            - --uvicorn-log-level
            - warning
            - --runner
            - pooling
            - --dtype
            - bfloat16
            - --max-model-len
            - "32768"
            - --max-num-seqs
            - "8"
            - --gpu-memory-utilization
            - "0.90"
          env:
            - name: VLLM_API_KEY
              valueFrom:
                secretKeyRef:
                  name: vllm-api-key
                  key: api-key
                  optional: true
            - name: HF_HOME
              value: /cache/huggingface
          ports:
            - name: serve
              containerPort: 8000
              protocol: TCP
          resources:
            requests:
              cpu: "2"
              memory: 12Gi
              nvidia.com/gpu: "1"
            limits:
              cpu: "2"
              memory: 12Gi
              nvidia.com/gpu: "1"
          livenessProbe:
            httpGet:
              path: /health
              port: 8000
              scheme: HTTP
            failureThreshold: 3
            periodSeconds: 5
            successThreshold: 1
            timeoutSeconds: 1
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
              scheme: HTTP
            failureThreshold: 5
            periodSeconds: 5
            successThreshold: 1
            timeoutSeconds: 1
          startupProbe:
            httpGet:
              path: /health
              port: 8000
              scheme: HTTP
            failureThreshold: 30
            periodSeconds: 10
            successThreshold: 1
            timeoutSeconds: 1
          volumeMounts:
            - name: model-cache
              mountPath: /cache/huggingface
            - name: dshm
              mountPath: /dev/shm
      volumes:
        - name: model-cache
          emptyDir: {}
        - name: dshm
          emptyDir:
            medium: Memory
            sizeLimit: 1Gi
      tolerations:
        - key: "vllm"
          operator: "Exists"
          effect: NoExecute
---
apiVersion: v1
kind: Service
metadata:
  name: qwen3-embedding-8b
  namespace: default
  labels:
    model.aibrix.ai/name: qwen3-embedding-8b
    prometheus-discovery: "true"
  annotations:
    prometheus.io/path: /metrics
    prometheus.io/port: "8000"
    prometheus.io/scrape: "true"
spec:
  type: ClusterIP
  selector:
    model.aibrix.ai/name: qwen3-embedding-8b
  ports:
    - name: serve
      port: 8000
      targetPort: serve
      protocol: TCP

Сохраняем манифесты в файлы и применяем их через kubectl apply -f filename.

Запуск подов в состоянии ContainerCreating может занять 5–10 минут, так как в этот момент загружаются веса моделей с Hugging Face. Чтобы ускорить последующие запуски, вы можете подключить к кластеру NFS-хранилище, предварительно сохранить туда веса моделей и использовать их для новых подов. Пример такой настройки описан в репозитории Selectel на GitHub.

В репозитории проекта AIBrix также доступны примеры деплоя мультимодальных моделей для работы с аудио-контентом и изображениями.

Когда поды перейдут в состояние Running, проверим работоспособность моделей. Сначала узнаем IP-адрес балансировщика нагрузки AIBrix:

kubectl get svc -A | grep Load
envoy-gateway-system   envoy-aibrix-system-aibrix-eg-903790dc      LoadBalancer   10.110.80.18     10.222.0.206   80:31131/TCP                              20h

Второй адрес — External IP. Его и будем использовать.

Теперь нужно выполнить запрос из сети кластера, так как для AIBrix  использовался приватный IP-адрес. Сделаем port-forward:

kubectl port-forward -n envoy-gateway-system service/envoy-aibrix-system-aibrix-eg-903790dc 9000:80

Установим переменные окружения:

export IP=127.0.0.1:9000
export TOKEN=$(kubectl get secrets vllm-api-key -ojsonpath='{.data.api-key}'|base64 -d)

И выполним запрос:

curl -v http://$IP/v1/chat/completions 
-H "Content-Type: application/json" 
-H "Authorization: Bearer $TOKEN" 
-H "routing-strategy: random" 
-H "model: qwen3-8b" 
-d '{
    "model": "qwen3-8b",
    "messages": [
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "help me write a random generator in python"}
    ],
    "temperature": 0.7
}'

Через некоторое время модель вернет сгенерированный ответ. Посмотреть список доступных моделей в AIBrix можно с помощью запроса:

curl http://$IP/v1/models

Cert-manager и External-dns

Эти сервисы обеспечивают интеграцию с DNS-хостингом Selectel, где делегирован ваш домен. Компонент cert-manager отвечает за выпуск SSL-сертификатов для защищенного соединения по HTTPS, а external-dns автоматически устанавливает A-записи. Они необходимы для публикации веб-интерфейса n8n.

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

Настройка cert-manager

Устанавливаем cert-manager в кластер:

helm upgrade --install cert-manager oci://quay.io/jetstack/charts/cert-manager --namespace cert-manager --create-namespace 
  --set config.apiVersion="controller.config.cert-manager.io/v1alpha1" 
  --set config.kind="ControllerConfiguration" 
  --set config.enableGatewayAPI=true 
  --set crds.enabled=true

Создаем секрет с учетными данными сервисного пользователя из панели управления Selectel:

kubectl create secret generic selectel-dns-credentials 
  --from-literal=username= 
  --from-literal=password='' 
  --from-literal=account_id= 
  --from-literal=project_id= 
  --namespace=cert-manager

Устанавливаем cert-manager-webhook-selectel, чтобы автоматически выдавать сертификат для домена:

helm repo add selectel https://selectel.github.io/cert-manager-webhook-selectel
helm repo update
helm install cert-manager-webhook-selectel selectel/cert-manager-webhook-selectel -n cert-manager

Создаем ClusterIssuer:

kubectl apply -f - <<EOF
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: certmaster@selectel.ru
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
    - dns01:
        webhook:
          groupName: acme.selectel.ru
          solverName: selectel
          config:
            dnsSecretRef:
              name: selectel-dns-credentials
            # Optional config, shown with default values
            #   all times in seconds
            ttl: 120 # Default: 60
            timeout: 60 # Default 40
EOF

Теперь можно выпустить сертификат:

kubectl apply -f - <<EOF
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: outworld-le-prod-crt
  namespace: envoy-gateway-system
spec:
  dnsNames:
  - "*.outworld66.ru"
  issuerRef:
    group: cert-manager.io
    kind: ClusterIssuer
    name: letsencrypt-prod
  secretName: wildcard-outworld66-ru-tls
EOF

Настройка external-dns

Создаем namespace:

kubectl create namespace external-dns

Далее создадим секрет с паролем от сервисного пользователя из панели управления. Вы можете использовать того же пользователя, что и в cert-manager.

kubectl create secret generic external-dns-selectel-webhook 
  --from-literal=username= 
  --from-literal=account_id= 
  --from-literal=project_id= 
  --from-literal=password='' 
  --namespace=external-dns

Применяем манифесты external-dns и external-dns-selectel-webhook:

kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
  name: external-dns
  namespace: external-dns
  labels:
    app.kubernetes.io/name: external-dns
    app.kubernetes.io/instance: external-dns
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: external-dns
  labels:
    app.kubernetes.io/name: external-dns
    app.kubernetes.io/instance: external-dns
rules:
  - apiGroups: [""]
    resources: ["nodes"]
    verbs: ["get","list","watch"]
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get","watch","list"]
  - apiGroups: [""]
    resources: ["services","endpoints"]
    verbs: ["get","watch","list"]
  - apiGroups: ["extensions","networking.k8s.io"]
    resources: ["ingresses"]
    verbs: ["get","watch","list"]
  - apiGroups: [""]
    resources: ["namespaces"]
    verbs: ["get","watch","list"]
  - apiGroups: ["gateway.networking.k8s.io"]
    resources: ["gateways","httproutes","tlsroutes","tcproutes","udproutes"] 
    verbs: ["get","watch","list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: external-dns-viewer
  labels:
    app.kubernetes.io/name: external-dns
    app.kubernetes.io/instance: external-dns
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: external-dns
subjects:
  - kind: ServiceAccount
    name: external-dns
    namespace: external-dns
---
apiVersion: v1
kind: Service
metadata:
  name: external-dns
  namespace: external-dns
  labels:
    app.kubernetes.io/name: external-dns
    app.kubernetes.io/instance: external-dns
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: external-dns
    app.kubernetes.io/instance: external-dns
  ports:
    - name: http
      port: 7979
      targetPort: http
      protocol: TCP
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: external-dns
  namespace: external-dns
  labels:
    app.kubernetes.io/name: external-dns
    app.kubernetes.io/instance: external-dns
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: external-dns
      app.kubernetes.io/instance: external-dns
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app.kubernetes.io/name: external-dns
        app.kubernetes.io/instance: external-dns
    spec:
      serviceAccountName: external-dns
      securityContext:
        fsGroup: 65534
      containers:
        - name: external-dns
          securityContext:
            capabilities:
              drop:
              - ALL
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            runAsUser: 65534
          image: registry.k8s.io/external-dns/external-dns:v0.14.2
          imagePullPolicy: IfNotPresent
          args:
            - --log-level=info
            - --log-format=text
            - --interval=1m
            - --source=service
            - --source=ingress
            - --source=gateway-httproute
            - --source=gateway-tlsroute
            - --source=gateway-tcproute
            - --source=gateway-udproute
            - --policy=sync # set it upsert-only if you don't want it to delete records
            - --provider=webhook
          ports:
            - name: http
              protocol: TCP
              containerPort: 7979
          livenessProbe:
            failureThreshold: 2
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 10
            periodSeconds: 10
            successThreshold: 1
            timeoutSeconds: 5
          readinessProbe:
            failureThreshold: 6
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 5
            periodSeconds: 10
            successThreshold: 1
            timeoutSeconds: 5
        - name: webhook
          securityContext:
            capabilities:
              drop:
                - ALL
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            runAsUser: 65534
          image: ghcr.io/selectel/external-dns-selectel-webhook:v0.1.0
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              protocol: TCP
              containerPort: 8888
          livenessProbe:
            failureThreshold: 2
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 10
            periodSeconds: 10
            successThreshold: 1
            timeoutSeconds: 5
          readinessProbe:
            failureThreshold: 6
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 5
            periodSeconds: 10
            successThreshold: 1
            timeoutSeconds: 5
          env:
            - name: PROJECT_ID
              valueFrom:
                secretKeyRef:
                  name: external-dns-selectel-webhook
                  key: project_id
            - name: ACCOUNT_ID
              valueFrom:
                secretKeyRef:
                  name: external-dns-selectel-webhook
                  key: account_id
            - name: USERNAME
              valueFrom:
                secretKeyRef:
                  name: external-dns-selectel-webhook
                  key: username
            - name: PASSWORD
              valueFrom:
                secretKeyRef:
                  name: external-dns-selectel-webhook
                  key: password
EOF

Оригинальный манифест можно найти в репозитории на GitHub.

Балансировщик для внешних сервисов

Далее идет настройка Envoy Gateway как замена Ingress.

Укажем, что Envoy нужно запускать как daemonset, и настроим externalTrafficPolicy: Cluster.

cat <<EOF | kubectl apply -f -
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
  name: custom-proxy-config
  namespace: default
spec:
  provider:
    type: Kubernetes
    kubernetes:
      envoyDaemonSet: {}
      envoyService:
        externalTrafficPolicy: Cluster
---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: envoy
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
  parametersRef:
    group: gateway.envoyproxy.io
    kind: EnvoyProxy
    name: custom-proxy-config
    namespace: default
EOF

Теперь можно создать балансировщик. Укажем домен, сертификат и какой порт прослушивать:

kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: eg
  namespace: envoy-gateway-system
spec:
  gatewayClassName: envoy
  listeners:
    - name: https
      hostname: "*.outworld66.ru"
      port: 443
      protocol: HTTPS
      tls:
        mode: Terminate
        certificateRefs:
          - name: wildcard-outworld66-ru-tls
            namespace: envoy-gateway-system
            kind: Secret
      allowedRoutes:
        namespaces:
          from: All
EOF

Установка storageclass по умолчанию

Для того, чтобы pvc заказывались без указания конкретного StorageClass, укажем один из них по умолчанию. Посмотрим, какие есть:

kubectl get storageclass

В ru-6 у меня такой:

NAME          PROVISIONER                RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION   AGE
fast2.ru-6a   cinder.csi.openstack.org   Delete          Immediate           true                   23h

Назначим класс fast2.ru-6a дефолтным:

kubectl patch storageclass fast2.ru-6a -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

Qdrant

Установим векторную базу данных qdrant, в ней будем хранить векторы RAG-системы.

helm repo add qdrant https://qdrant.github.io/qdrant-helm
helm repo update
helm upgrade -i qdrant qdrant/qdrant -n qdrant --create-namespace
Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 3

Облачные базы данных

Создайте готовую базу данных в облаке за 5 минут. Поддерживаем PostgreSQL, MySQL, Redis и не только.

Подробнее →

postgresql operator

Установим postgres operator.

helm repo add cnpg https://cloudnative-pg.github.io/charts
helm upgrade --install cnpg 
  --namespace cnpg-system 
  --create-namespace 
  cnpg/cloudnative-pg

Подождем, пока поды оператора будут готовы:

kubectl wait pod --timeout=15m --for=condition=Ready -n cnpg-system -l app.kubernetes.io/name=cloudnative-pg

n8n

Создадим неймспейс и секрет для базы данных n8n. Пароль сгенерируем через pwgen:

kubectl create namespace n8n
kubectl create secret generic db-app -n n8n 
  --from-literal=username=n8n 
  --from-literal="password=$(pwgen -sn1 20)"

Создадим файл с values для Helm-чарта n8n. Файл назовем n8n_values.yaml.

# https://github.com/8gears/n8n-helm-chart/blob/main/examples/values_small_prod.yaml

#Prod like set up with CloudNativePG and nginx-ingress
image:
  repository: docker-registry.selectel.ru/n8nio/n8n
  tag: 2.33.3
imagePullSecrets:
  - name: 8gears-registry-n8n

main:
  service:
    port: 5678
  config:
    n8n:
      port: 5678
    webhook_url: https://n8n.outworld66.ru
    executions_mode: queue
    queue:
      health:
        check:
          active: true
      bull:
        redis:
          host: n8n-valkey
          port: 6379
    db:
      type: postgresdb
      postgresdb:
        host: db-rw
        user: n8n
#        password: password is read from cnpg db-app secretKeyRef
        pool:
          size: 10
        ssl:
          enabled: true
          reject_Unauthorized: true
          ca_file: "/home/ssl/certs/postgresql/ca.crt"
  #secret:
  #  n8n:
  #    encryption_key: "<your-secure-encryption-key>"
  extraEnv: &extraEnv
    DB_POSTGRESDB_PASSWORD:
      valueFrom:
        secretKeyRef:
          name: db-app
          key: password
  # Mount the CNPG CA Cert into N8N container
  extraVolumeMounts: &extraVolumeMounts
    - name: db-ca-cert
      mountPath: /home/ssl/certs/postgresql
      readOnly: true
  extraVolumes: &extraVolumes
    - name: db-ca-cert
      secret:
        secretName: db-ca
        items:
          - key: ca.crt
            path: ca.crt
  resources:
    limits:
      memory: 2048Mi
    requests:
      memory: 512Mi

worker:
  enabled: true
  extraEnv: *extraEnv # using YAML magic (anchors) to reference main extraEnv
  extraVolumeMounts: *extraVolumeMounts # using YAML magic (anchors) to reference main extraVolumeMounts
  extraVolumes: *extraVolumes # using YAML magic (anchors) to reference main extraVolumes

webhook:
  enabled: true
  extraEnv: *extraEnv # using YAML magic (anchors) to reference main extraEnv
  extraVolumeMounts: *extraVolumeMounts # using YAML magic (anchors) to reference main extraVolumeMounts
  extraVolumes: *extraVolumes # using YAML magic (anchors) to reference main extraVolumes

valkey:
  enabled: true

ingress:
  enabled: false

# cnpg DB cluster request
# and httproutes
extraManifests:
  - apiVersion: postgresql.cnpg.io/v1
    kind: Cluster
    metadata:
      name: db
    spec:
      instances: 1
      bootstrap:
        initdb:
          database: n8n
          owner: n8n
      postgresql:
        parameters:
          shared_buffers: "64MB"
      resources:
        requests:
          memory: "512Mi"
        limits:
          memory: "512Mi"
      storage:
        size: 10Gi

Обратите внимание: обязательно сгенерируйте ключ шифрования encryption_key и сохраните его в надежном месте. Без этого ключа вы потеряете доступ к данным в n8n при перезапуске сервиса:

encryption_key=$(pwgen -sn1 50)

Установим n8n с параметрами (values) из этого репозитория. И установим encryption_key через флаг --set:

helm upgrade --install n8n oci://8gears.container-registry.com/library/n8n --version 2.0.1 
  --create-namespace -n n8n -f n8n_values.yaml 
  --set main.secret.n8n.encryption_key="$encryption_key"

Запуск всех компонентов n8n может занять некоторое время. Отслеживать статус запуска подов можно c помощью команды:

kubectl get pods -n n8n

Дождемся, пока все поды перейдут в состояние Running. Получим доступ к веб-панели и безопасно настроим административный доступ:

kubectl port-forward -n n8n service/n8n 3001:5678

На данном этапе сервис доступен локально: http://127.0.0.1:3001.

Опубликуем сервис с помощью Envoy Gateway с использованием облачного балансировщика нагрузки:

kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: n8n
  namespace: n8n
spec:
  hostnames:
  - n8n.outworld66.ru
  parentRefs:
  - name: eg
    namespace: envoy-gateway-system
  rules:
  - backendRefs:
    - name: n8n
      kind: Service
      port: 5678
    matches:
    - path:
        type: PathPrefix
        value: /
  - backendRefs:
    - name: n8n-webhook
      kind: Service
      port: 80
    matches:
    - path:
        type: PathPrefix
        value: /webhook/
    - path:
        type: PathPrefix
        value: /webhook-test/
    - path:
        type: PathPrefix
        value: /webhook-waiting/
    - path:
        type: PathPrefix
        value: /form/
EOF

Настройка RAG-пайплайна

Наконец можно перейти в панель (в моем случае это https://n8n.outworld66.ru) и начать настройку. Будем использовать готовый RAG-сценарий. Оригинал доступен по ссылке.

Создаем Workflow:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 4

Импортируем существующий пайплайн по URL: 

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 5

Для импорта укажем следующую ссылку: https://raw.githubusercontent.com/selectel/webinar-llm-on-mks/refs/heads/main/apps/04.llm-clients/02.n8n/Demo_RAG_n8n_webinar.json

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 6

Пайплайн выглядит следующим образом:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 7

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

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

Qdrant

Укажем учетные данные для Qdrant:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 8

Поскольку авторизация в Qdrant не настроена, зададим только URL-адрес: http://qdrant.qdrant.svc.cluster.local:6333

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 9

После сохранения параметров соединение будет успешно установлено:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 10

Если при обработке и загрузке больших документов возникнут проблемы, рекомендуется уменьшить размер пакета векторизации (Embedding Batch Size) до 100.

OpenAI Chat Model

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 11

В поле адреса сервера укажем IP-адрес шлюза AIBrix. Его можно узнать с помощью команды:

kubectl get svc -A | grep Load
envoy-gateway-system   envoy-aibrix-system-aibrix-eg-903790dc      LoadBalancer   10.110.80.18     10.222.0.206     80:31131/TCP                              22h
envoy-gateway-system   envoy-envoy-gateway-system-eg-5391c79d      LoadBalancer   10.106.131.36    135.106.154.81   443:30933/TCP                             30m

В моем случае это адрес 10.222.0.206.

Также получим токен для доступа к LLM из секретов Kubernetes:

kubectl get secrets vllm-api-key -ojsonpath='{.data.api-key}'|base64 -d

Внесем эти параметры и сохраним изменения:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 12

Соединение успешно установлено:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 13

В поле выбора модели укажем целевую языковую модель — qwen3-8b.

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 14

Embeddings OpenAI

Модели используют тот же токен авторизации, поэтому мы выбираем ранее созданные учетные данные. В качестве используемой модели укажем qwen3-embedding-8b:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 15

Redis Chat Memory

Задаем учетные данные для блока Redis Chat Memory.

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 16

В поле Host указываем адрес n8n-valkey:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 17

После сохранения параметров подключение будет успешно установлено:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 18
Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 19

Как вы внедряете ИИ?

Поделитесь своим опытом за 7–10 минут. Разыгрываем 5 сертификатов по 1 500 ₽ на маркетплейсе и 10 наборов мерча.

Ответить на вопросы →

Тестовый запрос

Отправим агенту тестовое сообщение (пинг) через чат n8n — он должен прислать ответ:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 20

Деактивируем блок Webhook и опубликуем пайплайн:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 21

Попробуем запустить пайплайн через оранжевую кнопку Execute workflow:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 22

В открывшейся форме загружаем тестовый файл — я использую книгу в формате PDF:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 23

Пайплайн обработает файл и успешно завершит свою работу:

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 24

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

Приватная LLM в облаке: развертываем RAG-систему в Managed Kubernetes - 25

Если вы планируете использовать n8n в промышленной эксплуатации (Production), рекомендуется своевременно обновлять платформу и отслеживать изменения в ее официальном Helm-чарте

Автор: outworld66

Источник