Выход в продакшен с собственными языковыми моделями внутри корпоративного контура часто упирается в высокую стоимость 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). Впрочем, без этого можно обойтись и задать пароль текстом.

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

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

Создайте готовую базу данных в облаке за 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:

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

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

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

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

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

Qdrant

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

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

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

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

OpenAI Chat Model

В поле адреса сервера укажем 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

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

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

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

Embeddings OpenAI

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

Redis Chat Memory

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Комментарии (0)