Установка инфраструктуры

Kubernetes-платформа готова. На этом этапе разворачиваются сервисы, от которых зависит Unica: хранилища данных, база данных, очередь сообщений, векторная база и сервисы машинного обучения. После установки каждого компонента необходимо дождаться состояния Running/Ready и только затем переходить дальше.

На этой странице

3.1. Создание пространств имён

Прикладные и инфраструктурные компоненты разделяются по пространствам имён Kubernetes. Это упрощает управление, диагностику и разграничение ресурсов.

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

unica-db
  PostgreSQL
  Qdrant

unica-infra
  Redis
  RabbitMQ
  vLLM (экземпляры моделей и маршрутизатор)
  Docling Serve (опционально)

unica-storage
  MinIO

unica-app
  Unica, Unica Agent, Unica Workflow, Unica Ops
  прикладные API

Системные компоненты Kubernetes остаются в собственных пространство имён:

kube-system
metallb-system
ingress-nginx
gpu-operator
cert-manager

Создать пространство имён:

kubectl create namespace unica-db
kubectl create namespace unica-app
kubectl create namespace unica-storage
kubectl create namespace unica-infra

Проверить:

kubectl get namespaces

3.2. Подготовка локального хранилища

В демонстрационной конфигурации постоянные данные размещаются в каталогах /storage/* на конкретных серверах. Для подключения этих каталогов к Pod-ам используется класс хранилища local-storage и локальные PersistentVolume.

Схема хранения данных

Данные компонентов инфраструктуры с постоянным хранением (PostgreSQL, Qdrant, RabbitMQ, MinIO) размещаются на:

${DB_NODE}

Данные GPU-компонентов (модели vLLM, Diarization, Recognition) размещаются на ${GPU_NODE} — см. шаги установки vLLM, Diarization API и Recognition Engine.

Базовый каталог:

/storage

Структура:

/storage/postgresql
/storage/qdrant
/storage/rabbitmq
/storage/minio

В этой схеме отдельный диск под /storage не обязателен. Каталог /storage размещается на существующей корневой файловой системе ext4.

Текущее размещение:

/dev/sda1 -> /

Создать каталоги на ${DB_NODE}:

mkdir -p \
  /storage/postgresql \
  /storage/qdrant \
  /storage/rabbitmq \
  /storage/minio

Проверить:

find /storage -maxdepth 1 -type d -print
df -hT /storage

Для Kubernetes создаётся статический StorageClass:

local-storage

с:

provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer

Для каждого компонента с постоянным хранением создаётся отдельный локальный PersistentVolume:

PostgreSQL -> /storage/postgresql
Qdrant     -> /storage/qdrant
RabbitMQ   -> /storage/rabbitmq
MinIO      -> /storage/minio

Все PV:

accessModes: ReadWriteOnce
nodeAffinity: ${DB_NODE}
storageClassName: local-storage

StorageClass для локальных томов

локальный PersistentVolume используется для каталогов /storage/* на ${DB_NODE}.

Kubernetes требует nodeAffinity для локальный PV и рекомендует WaitForFirstConsumer, чтобы PVC привязывался с учётом узлы, на которой может быть запущен Pod.

Создать StorageClass:

cat <<'EOF' | kubectl apply -f -
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-storage
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
EOF

Проверить:

kubectl get storageclass

Ожидается:

NAME            PROVISIONER                    RECLAIMPOLICY   VOLUMEBINDINGMODE
local-storage   kubernetes.io/no-provisioner   Retain          WaitForFirstConsumer

локальный PV заранее для всех компонентов не создаются. PV создаётся непосредственно перед установкой соответствующего компонента, чтобы capacity.storage совпадала с размером его PVC.

Порядок:

PostgreSQL
  -> /storage/postgresql
  -> отдельный PV/PVC

Qdrant
  -> /storage/qdrant

RabbitMQ
  -> /storage/rabbitmq

MinIO
  -> /storage/minio

Важно: после установки каждого инфраструктурного компонента необходимо дождаться завершения запуска и убедиться, что его Pod-ы находятся в состоянии Running/Ready. Только после этого переходить к следующему компоненту.

3.3. PostgreSQL

PostgreSQL хранит данные Unica, DataManagement и SpeechRecognition. Данные размещаются вне контейнера в каталоге /storage/postgresql.

PostgreSQL размещается на ${DB_NODE}.

Подготовка каталога

На узле инфраструктурных сервисов (${DB_NODE}):

mkdir -p /storage/postgresql
chown 999:999 /storage/postgresql
chmod 700 /storage/postgresql

Проверить:

ls -ld /storage/postgresql
df -hT /storage/postgresql

PersistentVolume для PostgreSQL

PersistentVolume связывает каталог /storage/postgresql на ${DB_NODE} с постоянным томом Kubernetes. Данные базы остаются на узле при перезапуске Pod-а.

На управляющем узле Kubernetes (${CONTROL_PLANE_NODE}):

POSTGRES_STORAGE_SIZE="20Gi"

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
  name: postgresql-pv
spec:
  capacity:
    storage: ${POSTGRES_STORAGE_SIZE}
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  local:
    path: /storage/postgresql
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values:
                - ${DB_NODE}
EOF

Создание паролей и Secret PostgreSQL

Для PostgreSQL и сервисных пользователей создаются отдельные пароли:

POSTGRES_PASSWORD="$(openssl rand -hex 24)"
ASR_PASSWORD="$(openssl rand -hex 24)"
DATAMANAGEMENT_PASSWORD="$(openssl rand -hex 24)"
UNICA_PASSWORD="$(openssl rand -hex 24)"

Пароли сохраняются в Kubernetes Secret postgresql. POSTGRES_PASSWORD используется контейнером PostgreSQL, а пароли ASR_PASSWORD, DATAMANAGEMENT_PASSWORD и UNICA_PASSWORD затем считываются при настройке соответствующих сервисов. Это позволяет не задавать пароли повторно вручную в следующих шагах.

Создать Secret:

kubectl -n unica-db create secret generic postgresql \
  --from-literal=POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \
  --from-literal=ASR_PASSWORD="${ASR_PASSWORD}" \
  --from-literal=DATAMANAGEMENT_PASSWORD="${DATAMANAGEMENT_PASSWORD}" \
  --from-literal=UNICA_PASSWORD="${UNICA_PASSWORD}"

ConfigMap со сценарием инициализации PostgreSQL

Сценарий выполняется при первом запуске PostgreSQL и создаёт отдельные базы данных и пользователей для SpeechRecognition, DataManagement и Unica. Пароли берутся из переменных, созданных на предыдущем шаге.

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
  name: postgresql-initdb
  namespace: unica-db
data:
  init.sql: |
    -- === ASR ===
    DO \$\$
    BEGIN
      IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'asr_user') THEN
        CREATE ROLE asr_user LOGIN PASSWORD '${ASR_PASSWORD}';
      END IF;
    END \$\$;

    SELECT format('CREATE DATABASE %I OWNER %I', 'asr_db', 'asr_user')
    WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'asr_db') \gexec

    GRANT ALL PRIVILEGES ON DATABASE asr_db TO asr_user;

    -- === DATA MANAGEMENT ===
    DO \$\$
    BEGIN
      IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'datamanagement_user') THEN
        CREATE ROLE datamanagement_user LOGIN PASSWORD '${DATAMANAGEMENT_PASSWORD}';
      END IF;
    END \$\$;

    SELECT format('CREATE DATABASE %I OWNER %I', 'datamanagement_db', 'datamanagement_user')
    WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'datamanagement_db') \gexec

    GRANT ALL PRIVILEGES ON DATABASE datamanagement_db TO datamanagement_user;

    -- === UNICA ===
    DO \$\$
    BEGIN
      IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'unica_user') THEN
        CREATE ROLE unica_user LOGIN PASSWORD '${UNICA_PASSWORD}';
      END IF;
    END \$\$;

    SELECT format('CREATE DATABASE %I OWNER %I', 'unica_db', 'unica_user')
    WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'unica_db') \gexec

    GRANT ALL PRIVILEGES ON DATABASE unica_db TO unica_user;
EOF

Внутрикластерный доступ к PostgreSQL

Service создаёт постоянное DNS-имя postgresql.unica-db.svc.cluster.local, по которому прикладные сервисы подключаются к PostgreSQL внутри Kubernetes. База данных наружу не публикуется.

cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
  name: postgresql
  namespace: unica-db
spec:
  selector:
    app: postgresql
  type: ClusterIP
  ports:
    - name: postgres
      port: 5432
      targetPort: 5432
EOF

Запуск PostgreSQL

StatefulSet запускает PostgreSQL на ${DB_NODE}, подключает постоянный том с данными и ConfigMap со сценарием первичной инициализации.

Перед запуском на ${DB_NODE}:

chown 999:999 /storage/postgresql
chmod 700 /storage/postgresql

На управляющем узле Kubernetes (${CONTROL_PLANE_NODE}):

cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgresql
  namespace: unica-db
spec:
  serviceName: postgresql
  replicas: 1
  selector:
    matchLabels:
      app: postgresql
  template:
    metadata:
      labels:
        app: postgresql
    spec:
      nodeSelector:
        kubernetes.io/hostname: ${DB_NODE}
      securityContext:
        fsGroup: 999
      containers:
        - name: postgres
          image: postgres:16
          imagePullPolicy: IfNotPresent
          ports:
            - name: postgres
              containerPort: 5432
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgresql
                  key: POSTGRES_PASSWORD
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "postgres"]
            initialDelaySeconds: 10
            periodSeconds: 5
          livenessProbe:
            exec:
              command: ["pg_isready", "-U", "postgres"]
            initialDelaySeconds: 20
            periodSeconds: 10
          resources:
            requests:
              cpu: "2"
              memory: "4Gi"
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
            - name: initdb
              mountPath: /docker-entrypoint-initdb.d
      volumes:
        - name: initdb
          configMap:
            name: postgresql-initdb
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes:
          - ReadWriteOnce
        storageClassName: local-storage
        resources:
          requests:
            storage: 20Gi
EOF

3.4. Redis

Redis используется как кэш для прикладных сервисов Unica. Данные Redis считаются временными и могут быть восстановлены приложениями, поэтому постоянное дисковое хранилище не используется.

Запуск Redis

Redis запускается одним экземпляром на ${DB_NODE}. В конфигурации отключены механизмы сохранения данных на диск, чтобы сервис работал только как кэш.

cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  namespace: unica-infra
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      nodeSelector:
        kubernetes.io/hostname: ${DB_NODE}
      containers:
        - name: redis
          image: redis:8.8.0-alpine
          imagePullPolicy: IfNotPresent
          ports:
            - name: redis
              containerPort: 6379
              protocol: TCP
          args:
            - "--protected-mode"
            - "no"
            - "--appendonly"
            - "no"
            - "--save"
            - ""
          readinessProbe:
            exec:
              command:
                - redis-cli
                - ping
            initialDelaySeconds: 5
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 3
          livenessProbe:
            exec:
              command:
                - redis-cli
                - ping
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
EOF

Внутрикластерный доступ к Redis

Service предоставляет Redis постоянное DNS-имя внутри Kubernetes. Внешний доступ к Redis не требуется.

cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
  name: redis
  namespace: unica-infra
spec:
  selector:
    app: redis
  type: ClusterIP
  ports:
    - name: redis
      protocol: TCP
      port: 6379
      targetPort: 6379
EOF

Внутрикластерный адрес:

redis.unica-infra.svc.cluster.local:6379

3.5. RabbitMQ

RabbitMQ обеспечивает обмен сообщениями между сервисами обработки аудио. Для него создаются отдельные пользователи и права доступа.

Интерфейс управления и API RabbitMQ остаются доступны только внутри Kubernetes и необходимы Topology Operator для управления объектами RabbitMQ. Внешний доступ к интерфейсу управления не создаётся.

Подготовка хранения RabbitMQ

Очереди и служебные данные RabbitMQ сохраняются в каталоге /storage/rabbitmq на ${DB_NODE}.

mkdir -p /storage/rabbitmq
chown 999:999 /storage/rabbitmq
chmod 700 /storage/rabbitmq

Установка RabbitMQ Cluster Operator

Cluster Operator управляет жизненным циклом самого кластера RabbitMQ: создаёт StatefulSet, Service и связанные ресурсы по объекту RabbitmqCluster.

kubectl apply -f \
  "https://github.com/rabbitmq/cluster-operator/releases/download/v2.21.1/cluster-operator.yml"

Проверка:

kubectl -n rabbitmq-system get pods -o wide

Установка RabbitMQ Messaging Topology Operator

Messaging Topology Operator управляет объектами внутри RabbitMQ — пользователями, правами доступа и другими элементами топологии. Ниже он используется для создания прикладных пользователей.

kubectl apply -f \
  "https://github.com/rabbitmq/messaging-topology-operator/releases/download/v1.19.3/messaging-topology-operator-with-certmanager.yaml"

Проверка:

kubectl -n rabbitmq-system get pods -o wide

PersistentVolume для RabbitMQ

Том связывает хранилище RabbitMQ в Kubernetes с каталогом /storage/rabbitmq на ${DB_NODE}.

RABBITMQ_STORAGE_SIZE="10Gi"

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
  name: rabbitmq-pv
spec:
  capacity:
    storage: ${RABBITMQ_STORAGE_SIZE}
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  local:
    path: /storage/rabbitmq
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values:
                - ${DB_NODE}
EOF

Учётные данные администратора RabbitMQ

Этот Secret используется RabbitMQ Cluster Operator при первом создании кластера. Учётная запись администратора нужна для служебного управления RabbitMQ внутри Kubernetes и не публикуется наружу.

RABBITMQ_ADMIN_PASSWORD="$(openssl rand -hex 24)"

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
  name: rabbitmq-default-user
  namespace: unica-infra
type: Opaque
stringData:
  default_user.conf: |
    default_user = admin
    default_pass = ${RABBITMQ_ADMIN_PASSWORD}
  host: rabbitmq.unica-infra.svc.cluster.local
  username: admin
  password: ${RABBITMQ_ADMIN_PASSWORD}
  port: "5672"
  provider: rabbitmq
  type: rabbitmq
EOF

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

Ресурс RabbitmqCluster описывает один экземпляр RabbitMQ, его хранилище, размещение на ${DB_NODE} и базовые параметры работы. Фактические Kubernetes-ресурсы создаёт Cluster Operator.

cat <<EOF | kubectl apply -f -
apiVersion: rabbitmq.com/v1beta1
kind: RabbitmqCluster
metadata:
  name: rabbitmq
  namespace: unica-infra
spec:
  replicas: 1
  image: rabbitmq:4.2.6-management

  secretBackend:
    externalSecret:
      name: rabbitmq-default-user

  service:
    type: ClusterIP

  persistence:
    storageClassName: local-storage
    storage: 10Gi
  resources:
    requests:
      cpu: "500m"
      memory: "1Gi"

  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - key: kubernetes.io/hostname
                operator: In
                values:
                  - ${DB_NODE}

  rabbitmq:
    additionalConfig: |
      default_vhost = /
      disk_free_limit.relative = 2.0
      vm_memory_high_watermark.relative = 0.6
      consumer_timeout = 7200000
EOF

Учётные данные прикладных пользователей

Для сервисов ASR, Recognition и Diarization создаются отдельные учётные записи RabbitMQ. Пароли генерируются при развёртывании и сохраняются в Kubernetes Secrets. Эти Secrets используются на следующем шаге при создании пользователей RabbitMQ.

RABBITMQ_ASR_PASSWORD="$(openssl rand -hex 24)"
RABBITMQ_RECOGNITION_PASSWORD="$(openssl rand -hex 24)"
RABBITMQ_DIARIZATION_PASSWORD="$(openssl rand -hex 24)"

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
  name: rabbitmq-asr-user
  namespace: unica-infra
type: Opaque
stringData:
  username: asr
  password: ${RABBITMQ_ASR_PASSWORD}
---
apiVersion: v1
kind: Secret
metadata:
  name: rabbitmq-recognition-user
  namespace: unica-infra
type: Opaque
stringData:
  username: recognition
  password: ${RABBITMQ_RECOGNITION_PASSWORD}
---
apiVersion: v1
kind: Secret
metadata:
  name: rabbitmq-diarization-user
  namespace: unica-infra
type: Opaque
stringData:
  username: diarization
  password: ${RABBITMQ_DIARIZATION_PASSWORD}
EOF

Пользователи RabbitMQ

Ресурсы User связывают созданные Kubernetes Secrets с кластером RabbitMQ. RabbitMQ Messaging Topology Operator создаёт пользователей asr, recognition и diarization и использует пароли из соответствующих Secrets.

cat <<'EOF' | kubectl apply -f -
apiVersion: rabbitmq.com/v1beta1
kind: User
metadata:
  name: asr
  namespace: unica-infra
spec:
  rabbitmqClusterReference:
    name: rabbitmq
  importCredentialsSecret:
    name: rabbitmq-asr-user
---
apiVersion: rabbitmq.com/v1beta1
kind: User
metadata:
  name: recognition
  namespace: unica-infra
spec:
  rabbitmqClusterReference:
    name: rabbitmq
  importCredentialsSecret:
    name: rabbitmq-recognition-user
---
apiVersion: rabbitmq.com/v1beta1
kind: User
metadata:
  name: diarization
  namespace: unica-infra
spec:
  rabbitmqClusterReference:
    name: rabbitmq
  importCredentialsSecret:
    name: rabbitmq-diarization-user
EOF

Права пользователей RabbitMQ

Ресурсы Permission выдают созданным пользователям права в виртуальном хосте /. В текущей конфигурации каждому пользователю разрешено создавать, читать и записывать объекты RabbitMQ, необходимые прикладным сервисам.

cat <<'EOF' | kubectl apply -f -
apiVersion: rabbitmq.com/v1beta1
kind: Permission
metadata:
  name: asr-permission
  namespace: unica-infra
spec:
  rabbitmqClusterReference:
    name: rabbitmq
  vhost: /
  userReference:
    name: asr
  permissions:
    configure: ".*"
    write: ".*"
    read: ".*"
---
apiVersion: rabbitmq.com/v1beta1
kind: Permission
metadata:
  name: recognition-permission
  namespace: unica-infra
spec:
  rabbitmqClusterReference:
    name: rabbitmq
  vhost: /
  userReference:
    name: recognition
  permissions:
    configure: ".*"
    write: ".*"
    read: ".*"
---
apiVersion: rabbitmq.com/v1beta1
kind: Permission
metadata:
  name: diarization-permission
  namespace: unica-infra
spec:
  rabbitmqClusterReference:
    name: rabbitmq
  vhost: /
  userReference:
    name: diarization
  permissions:
    configure: ".*"
    write: ".*"
    read: ".*"
EOF

3.6. Qdrant

Qdrant хранит векторные представления, используемые сервисами Unica для поиска и работы с семантическими данными. Данные Qdrant должны сохраняться между перезапусками, поэтому для него используется локальный постоянный том на ${DB_NODE}.

Подготовка хранения Qdrant

На ${DB_NODE} создаётся отдельный каталог, который будет использоваться только Qdrant.

mkdir -p /storage/qdrant

PersistentVolume для Qdrant

Том связывает каталог /storage/qdrant на ${DB_NODE} с хранилищем, которое подключит Helm-пакет Qdrant.

QDRANT_STORAGE_SIZE="20Gi"

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
  name: qdrant-pv
spec:
  capacity:
    storage: ${QDRANT_STORAGE_SIZE}
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  local:
    path: /storage/qdrant
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values:
                - ${DB_NODE}
EOF

Ключ API Qdrant

Qdrant защищается отдельным ключом API. Ключ генерируется один раз и сохраняется в Kubernetes Secret, откуда затем передаётся Qdrant и сервисам Unica.

QDRANT_API_KEY="$(openssl rand -hex 24)"

kubectl -n unica-db create secret generic qdrant \
  --from-literal=api-key="${QDRANT_API_KEY}"

Репозиторий Helm Qdrant

Добавляем официальный репозиторий, из которого будет установлен зафиксированный пакет Qdrant.

helm repo add qdrant https://qdrant.github.io/qdrant-helm
helm repo update

Установка Qdrant

Helm-релиз запускает один экземпляр Qdrant на ${DB_NODE}, подключает подготовленный том и Secret с ключом API. Сервис остаётся доступен только внутри Kubernetes.

helm upgrade --install qdrant qdrant/qdrant \
  --namespace unica-db \
  --version 1.19.0 \
  --set replicaCount=1 \
  --set image.repository=qdrant/qdrant \
  --set image.tag=v1.19.0 \
  --set image.pullPolicy=IfNotPresent \
  --set fullnameOverride=qdrant \
  --set-string 'nodeSelector.kubernetes\.io/hostname=${DB_NODE}' \
  --set apiKey.valueFrom.secretKeyRef.name=qdrant \
  --set apiKey.valueFrom.secretKeyRef.key=api-key \
  --set persistence.size=20Gi \
  --set persistence.storageClassName=local-storage \
  --set persistence.accessModes[0]=ReadWriteOnce \
  --set service.type=ClusterIP \
  --set resources.requests.cpu=2 \
  --set resources.requests.memory=16Gi

3.7. MinIO

MinIO предоставляет S3-совместимое объектное хранилище, в котором прикладные сервисы сохраняют файлы. Данные должны переживать перезапуск Pod-а, поэтому хранилище размещается на локальном диске ${DB_NODE}.

Подготовка хранения MinIO

На ${DB_NODE} создаётся отдельный каталог для объектов MinIO и назначаются права, с которыми контейнер сможет записывать данные.

mkdir -p /storage/minio
chown 1000:1000 /storage/minio
chmod 700 /storage/minio

PersistentVolume для MinIO

Том связывает каталог /storage/minio на ${DB_NODE} с постоянным хранилищем MinIO в Kubernetes.

MINIO_STORAGE_SIZE="20Gi"

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
  name: minio-pv
spec:
  capacity:
    storage: ${MINIO_STORAGE_SIZE}
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  local:
    path: /storage/minio
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values:
                - ${DB_NODE}
EOF

Учётные данные MinIO

Создаём административную учётную запись MinIO. Эти значения хранятся в Secret пространства имён unica-storage; позднее необходимые сервисы получают копию учётных данных в своём пространстве имён.

MINIO_ROOT_USER="admin"
MINIO_ROOT_PASSWORD="$(openssl rand -hex 24)"

kubectl -n unica-storage create secret generic minio \
  --from-literal=MINIO_ROOT_USER="${MINIO_ROOT_USER}" \
  --from-literal=MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD}"

Внутрикластерный доступ к MinIO

Service публикует внутри Kubernetes два порта: S3 API на 9000 и консоль управления на 9001. Внешний Service для MinIO не создаётся.

cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
  name: minio
  namespace: unica-storage
spec:
  selector:
    app: minio
  type: ClusterIP
  ports:
    - name: api
      protocol: TCP
      port: 9000
      targetPort: 9000
    - name: console
      protocol: TCP
      port: 9001
      targetPort: 9001
EOF

Внутрикластерные адреса:

minio.unica-storage.svc.cluster.local:9000
minio.unica-storage.svc.cluster.local:9001

Запуск MinIO

StatefulSet запускает MinIO на ${DB_NODE}, получает учётные данные из Secret и подключает постоянный том к /data.

cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: minio
  namespace: unica-storage
spec:
  serviceName: minio
  replicas: 1
  selector:
    matchLabels:
      app: minio
  template:
    metadata:
      labels:
        app: minio
    spec:
      nodeSelector:
        kubernetes.io/hostname: ${DB_NODE}
      securityContext:
        fsGroup: 1000
      containers:
        - name: minio
          image: minio/minio:RELEASE.2025-07-23T15-54-02Z
          imagePullPolicy: IfNotPresent
          args:
            - server
            - /data
            - --console-address
            - ":9001"
          ports:
            - name: s3
              containerPort: 9000
            - name: console
              containerPort: 9001
          env:
            - name: MINIO_ROOT_USER
              valueFrom:
                secretKeyRef:
                  name: minio
                  key: MINIO_ROOT_USER
            - name: MINIO_ROOT_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: minio
                  key: MINIO_ROOT_PASSWORD
          readinessProbe:
            httpGet:
              path: /minio/health/ready
              port: 9000
            initialDelaySeconds: 10
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /minio/health/live
              port: 9000
            initialDelaySeconds: 20
            periodSeconds: 10
          resources:
            requests:
              cpu: "1"
              memory: "2Gi"
          volumeMounts:
            - name: data
              mountPath: /data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes:
          - ReadWriteOnce
        storageClassName: local-storage
        resources:
          requests:
            storage: 20Gi
EOF

Проверка MinIO

После запуска убедитесь, что Pod MinIO находится в состоянии Running/Ready и размещён на ${DB_NODE}.

kubectl -n unica-storage get pods -o wide

Ожидается:

minio-0   1/1   Running   ...   ${DB_NODE}

3.8. Разделение GPU с помощью MIG

GPU NVIDIA A100 разделяется на несколько независимых MIG-ресурсов. Это позволяет одновременно выделить ресурсы для LLM, построения векторных представлений, диаризации и распознавания.

На демо-стенде используется NVIDIA A100 PCIe 40GB, поэтому дальнейшие команды и раскрой MIG приведены для этой GPU.

Используется профиль NVIDIA all-balanced. Пример раскроя демо-стенда:

3g.20gb x1 -> vLLM / модель Gemma-4-12B
2g.10gb x1 -> Diarization API
1g.5gb  x1 -> vLLM / модель Qwen3-Embedding-8B
1g.5gb  x1 -> Recognition Engine

Для одновременного использования разных MIG-профилей включается стратегия mixed.

На управляющем узле Kubernetes (${CONTROL_PLANE_NODE}):

kubectl patch clusterpolicies.nvidia.com/cluster-policy \
  --type='json' \
  -p='[{"op":"replace","path":"/spec/mig/strategy","value":"mixed"}]'

kubectl label node ${GPU_NODE} \
  nvidia.com/mig.config=all-balanced \
  --overwrite

Проверить результат:

DRIVER_POD=$(kubectl -n gpu-operator get pod \
  -l app=nvidia-driver-daemonset \
  -o jsonpath='{.items[0].metadata.name}')

kubectl -n gpu-operator exec "${DRIVER_POD}" -c nvidia-driver-ctr -- nvidia-smi -L

Ожидается:

MIG 3g.20gb x1
MIG 2g.10gb x1
MIG 1g.5gb  x2

Проверить ресурсы GPU, доступные Kubernetes:

kubectl get node ${GPU_NODE} -o json | \
  jq '.status.allocatable |
      with_entries(select(.key | startswith("nvidia.com/mig-")))'

Ожидается:

{
  "nvidia.com/mig-1g.5gb": "2",
  "nvidia.com/mig-2g.10gb": "1",
  "nvidia.com/mig-3g.20gb": "1"
}

После этого узел с GPU готов к запуску vLLM и остальных компонентов, использующих GPU.

3.9. vLLM

vLLM запускает языковую модель и модель построения векторных представлений на выделенных MIG-ресурсах. Модели предварительно загружаются на узел с GPU и подключаются в Kubernetes через локальный том.

vLLM размещается в пространстве имён unica-infra; прикладные API обращаются к нему через внутренний сервис маршрутизации.

Модели:

google/gemma-4-12B-it-qat-w4a16-ct
  локальный путь: /storage/vllm/Gemma-4-12B
  имя модели для API: Gemma-4-12B
  квантование: QAT w4a16 (compressed-tensors)
  MIG: nvidia.com/mig-3g.20gb
  максимальный контекст: 65536
  вызов инструментов: gemma4 parser

Qwen/Qwen3-Embedding-8B
  локальный путь: /storage/vllm/Qwen3-Embedding-8B
  имя модели для API: Qwen3-Embedding-8B
  квантование: BitsAndBytes 4-bit
  MIG: nvidia.com/mig-1g.5gb
  максимальный контекст: 16384

Подготовка моделей на узле с GPU

На узле с GPU (${GPU_NODE}):

mkdir -p /storage/vllm
chmod 755 /storage/vllm

apt update
apt install -y python3-venv

python3 -m venv /opt/hf
/opt/hf/bin/pip install --upgrade pip
/opt/hf/bin/pip install --upgrade huggingface_hub hf_xet

Загрузить языковую модель Gemma-4-12B:

/opt/hf/bin/hf download \
  google/gemma-4-12B-it-qat-w4a16-ct \
  --local-dir /storage/vllm/Gemma-4-12B

Загрузить модель Qwen3-Embedding-8B для построения векторных представлений:

/opt/hf/bin/hf download \
  Qwen/Qwen3-Embedding-8B \
  --local-dir /storage/vllm/Qwen3-Embedding-8B

Проверить размер:

du -sh /storage/vllm/*

Хранилище моделей vLLM

PV и PVC подключают каталог /storage/vllm с уже загруженными моделями к Pod-ам vLLM. Само скачивание моделей выполняется на GPU-узле до запуска Helm-релиза.

На управляющем узле Kubernetes (${CONTROL_PLANE_NODE}) создать /root/vllm-storage.yaml:

cat > /root/vllm-storage.yaml <<EOF
apiVersion: v1
kind: PersistentVolume
metadata:
  name: vllm-pv
spec:
  capacity:
    storage: 100Gi
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  claimRef:
    namespace: unica-infra
    name: data-vllm
  local:
    path: /storage/vllm
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values:
                - ${GPU_NODE}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data-vllm
  namespace: unica-infra
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: local-storage
  volumeName: vllm-pv
  resources:
    requests:
      storage: 100Gi
EOF

Применить:

kubectl apply -f /root/vllm-storage.yaml

Проверить:

kubectl get pv vllm-pv
kubectl -n unica-infra get pvc data-vllm

PV и PVC должны быть Bound.

Репозиторий Helm vLLM

Добавляем официальный репозиторий vLLM Production Stack.

helm repo add vllm https://vllm-project.github.io/production-stack
helm repo update

Конфигурация vLLM

В файле values-vllm.yaml описываются два экземпляра vLLM: языковая модель Gemma-4-12B и модель Qwen3-Embedding-8B. Для каждой модели задаётся свой MIG-ресурс, путь к локальным файлам модели и параметры запуска. Маршрутизатор объединяет оба экземпляра за одним внутренним сервисом Kubernetes.

На управляющем узле Kubernetes (${CONTROL_PLANE_NODE}) создать /root/values-vllm.yaml:

cat > /root/values-vllm.yaml <<EOF
servingEngineSpec:
  runtimeClassName: "nvidia"
  imagePullPolicy: "IfNotPresent"
  sidecar:
    image: "lmcache/lmstack-sidecar:latest"
    imagePullPolicy: "IfNotPresent"

  startupProbe:
    initialDelaySeconds: 120
    periodSeconds: 30
    failureThreshold: 120
    httpGet:
      path: /health
      port: 8000

  livenessProbe:
    initialDelaySeconds: 120
    periodSeconds: 30
    failureThreshold: 3
    timeoutSeconds: 30
    httpGet:
      path: /health
      port: 8000

  readinessProbe:
    initialDelaySeconds: 120
    periodSeconds: 10
    failureThreshold: 5
    timeoutSeconds: 30
    httpGet:
      path: /health
      port: 8000

  strategy:
    type: Recreate

  serviceMonitor:
    enabled: false

  modelSpec:
    - name: "gemma-4-12b"
      repository: "vllm/vllm-openai"
      tag: "v0.25.1"
      modelURL: "/models/Gemma-4-12B"
      replicaCount: 1

      requestCPU: 6
      requestMemory: "16Gi"
      requestGPU: 1
      requestGPUType: "nvidia.com/mig-3g.20gb"
      shmSize: "8Gi"

      extraVolumes:
        - name: models
          persistentVolumeClaim:
            claimName: data-vllm

      extraVolumeMounts:
        - name: models
          mountPath: /models
          readOnly: true

      vllmConfig:
        tensorParallelSize: 1
        maxModelLen: 65536
        dtype: "bfloat16"
        gpuMemoryUtilization: 0.93
        enableChunkedPrefill: true
        enablePrefixCaching: true
        maxNumSeqs: 4
        extraArgs:
          - "--enable-auto-tool-choice"
          - "--tool-call-parser=gemma4"
          - "--reasoning-parser=gemma4"
          - "--chat-template=examples/tool_chat_template_gemma4.jinja"
          - "--served-model-name=Gemma-4-12B"

      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: "In"
              values:
                - "${GPU_NODE}"

    - name: "qwen3-embedding-8b"
      repository: "vllm/vllm-openai"
      tag: "v0.25.1"
      modelURL: "/models/Qwen3-Embedding-8B"
      replicaCount: 1

      requestCPU: 2
      requestMemory: "8Gi"
      requestGPU: 1
      requestGPUType: "nvidia.com/mig-1g.5gb"

      extraVolumes:
        - name: models
          persistentVolumeClaim:
            claimName: data-vllm

      extraVolumeMounts:
        - name: models
          mountPath: /models
          readOnly: true

      vllmConfig:
        tensorParallelSize: 1
        maxModelLen: 16384
        dtype: "bfloat16"
        gpuMemoryUtilization: 0.95
        maxNumSeqs: 2
        runner: "pooling"
        convert: "embed"
        extraArgs:
          - "--quantization=bitsandbytes"
          - "--cpu-offload-gb=4"
          - "--enforce-eager"
          - "--served-model-name=Qwen3-Embedding-8B"

      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: "In"
              values:
                - "${GPU_NODE}"

routerSpec:
  enableRouter: true
  repository: "lmcache/lmstack-router"
  tag: "v0.1.12"
  imagePullPolicy: "IfNotPresent"

  startupProbe:
    initialDelaySeconds: 30
    periodSeconds: 10
    failureThreshold: 30
    httpGet:
      path: /health

  livenessProbe:
    initialDelaySeconds: 60
    periodSeconds: 10
    failureThreshold: 3
    httpGet:
      path: /health

  readinessProbe:
    initialDelaySeconds: 30
    periodSeconds: 10
    failureThreshold: 6
    httpGet:
      path: /health

  serviceType: ClusterIP
  servicePort: 80
  routingLogic: "roundrobin"

  resources:
    requests:
      cpu: 400m
      memory: 1Gi
    limits: null

  serviceMonitor:
    enabled: false

EOF

Установка vLLM

Helm создаёт два экземпляра vLLM для моделей и внутренний маршрутизатор. Внешний Ingress для vLLM не требуется: к маршрутизатору обращаются только прикладные API внутри Kubernetes.

helm upgrade --install vllm vllm/vllm-stack \
  --namespace unica-infra \
  --version 0.1.12 \
  -f /root/values-vllm.yaml

Проверить запуск Pod-ов моделей и сервиса маршрутизации:

kubectl -n unica-infra get pods -o wide

Проверить имя сервиса маршрутизации (оно используется при установке Embedding API и LLM API):

kubectl -n unica-infra get svc

Ожидается vllm-router-service.

Проверить, что vLLM запущен с ожидаемыми ограничениями контекста:

kubectl -n unica-infra logs -l app.kubernetes.io/instance=vllm --all-containers=true --tail=300 | \
  grep -Ei 'max[_ -]?model[_ -]?len|max sequence length|65536|16384' || true

3.10. Docling Serve — опциональный компонент

Docling используется для расширенного разбора документов. Компонент опциональный: если он не требуется, этот шаг полностью пропускается.

Docling разворачивается в пространство имён unica-infra и использует локальный файл EasyOCR cyrillic_g2.pth.

Подготовка модели OCR

Docling использует локальный файл модели EasyOCR. Файл заранее размещается на ${DB_NODE} и затем монтируется в контейнер только для чтения.

Docling размещается на ${DB_NODE}.

На узел инфраструктурных сервисов (${DB_NODE}) создать каталог для модели:

mkdir -p /storage/docling-serve
chown root:root /storage/docling-serve
chmod 755 /storage/docling-serve

Скачать модель сразу в целевой каталог:

wget \
  "https://downloads.rp-it.ru/cyrillic_g2.pth" \
  -O /storage/docling-serve/cyrillic_g2.pth

Установить владельца и права только для файла модели. Каталог /storage/docling-serve остаётся root:root:

chown 1001:0 /storage/docling-serve/cyrillic_g2.pth
chmod 644 /storage/docling-serve/cyrillic_g2.pth

Проверить:

stat -c '%n %u:%g %a' \
  /storage/docling-serve \
  /storage/docling-serve/cyrillic_g2.pth

df -hT /storage/docling-serve

Ожидается:

/storage/docling-serve 0:0 755
/storage/docling-serve/cyrillic_g2.pth 1001:0 644

PersistentVolume для модели Docling

PersistentVolume связывает каталог /storage/docling-serve на ${DB_NODE} с Kubernetes. В этом каталоге хранится только файл модели OCR.

На управляющем узле Kubernetes (${CONTROL_PLANE_NODE}):

DOCLING_STORAGE_SIZE="1Gi"

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
  name: docling-serve-pv
spec:
  capacity:
    storage: ${DOCLING_STORAGE_SIZE}
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  local:
    path: /storage/docling-serve
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values:
                - ${DB_NODE}
EOF

Подключение тома к Docling

PVC закрепляется за подготовленным PersistentVolume и используется Deployment-ом Docling для доступа к модели.

cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data-docling-serve
  namespace: unica-infra
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: local-storage
  volumeName: docling-serve-pv
  resources:
    requests:
      storage: 1Gi
EOF

Внутрикластерный доступ к Docling

Service предоставляет Docling постоянный адрес внутри Kubernetes. Компонент не публикуется наружу.

cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
  name: docling-serve
  namespace: unica-infra
  labels:
    app: docling-serve
spec:
  selector:
    app: docling-serve
  type: ClusterIP
  ports:
    - name: http
      port: 5001
      targetPort: 5001
EOF

Запуск Docling

Deployment запускает Docling на ${DB_NODE} и монтирует подготовленный файл EasyOCR в каталог, из которого приложение ожидает загрузить модель.

cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: docling-serve
  namespace: unica-infra
  labels:
    app: docling-serve
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: docling-serve
  template:
    metadata:
      labels:
        app: docling-serve
    spec:
      nodeSelector:
        kubernetes.io/hostname: ${DB_NODE}
      containers:
        - name: docling-serve
          image: ghcr.io/docling-project/docling-serve:v1.20.0
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 5001
              protocol: TCP
          env:
            - name: UVICORN_WORKERS
              value: "1"
            - name: DOCLING_SERVE_LOAD_MODELS_AT_BOOT
              value: "false"
            - name: DOCLING_SERVE_MAX_SYNC_WAIT
              value: "600"
            - name: DOCLING_SERVE_LOG_LEVEL
              value: "INFO"
            - name: DOCLING_SERVE_DEFAULT_OCR_PRESET
              value: "easyocr"
            - name: DOCLING_SERVE_DEFAULT_OCR_KIND
              value: "easyocr"
            - name: DOCLING_SERVE_ENG_LOC_NUM_WORKERS
              value: "1"
            - name: DOCLING_SERVE_QUEUE_MAX_SIZE
              value: "10"
            - name: DOCLING_SERVE_OCR_BATCH_SIZE
              value: "4"
            - name: DOCLING_SERVE_LAYOUT_BATCH_SIZE
              value: "4"
            - name: DOCLING_SERVE_TABLE_BATCH_SIZE
              value: "2"
            - name: DOCLING_SERVE_BATCH_POLLING_INTERVAL_SECONDS
              value: "0.5"
            - name: OMP_NUM_THREADS
              value: "2"
            - name: MKL_NUM_THREADS
              value: "2"
            - name: OPENBLAS_NUM_THREADS
              value: "2"
            - name: NUMEXPR_NUM_THREADS
              value: "2"
            - name: DOCLING_NUM_THREADS
              value: "2"
            - name: DOCLING_DEBUG_PROFILE_PIPELINE_TIMINGS
              value: "true"
          volumeMounts:
            - name: docling-models
              mountPath: /opt/app-root/src/.cache/docling/models/EasyOcr/cyrillic_g2.pth
              subPath: cyrillic_g2.pth
              readOnly: true
      volumes:
        - name: docling-models
          persistentVolumeClaim:
            claimName: data-docling-serve
            readOnly: true
EOF

Проверка Docling

kubectl get pv docling-serve-pv
kubectl -n unica-infra get pvc data-docling-serve
kubectl -n unica-infra get pods -l app=docling-serve -o wide
kubectl -n unica-infra get svc docling-serve

PV и PVC должны быть Bound, Pod — Running/Ready.

Проверить, что файл доступен внутри контейнера:

kubectl -n unica-infra exec deploy/docling-serve -- \
  ls -lh /opt/app-root/src/.cache/docling/models/EasyOcr/cyrillic_g2.pth

Проверить логи:

kubectl -n unica-infra logs deploy/docling-serve --tail=200

Внутрикластерный адрес:

http://docling-serve.unica-infra.svc.cluster.local:5001

Проверка готовности инфраструктуры

Перед установкой продукта убедитесь, что обязательные инфраструктурные компоненты запущены и доступны внутри Kubernetes. Docling проверяется только в том случае, если он был установлен.

kubectl -n unica-db get pods -o wide
kubectl -n unica-infra get pods -o wide
kubectl -n unica-storage get pods -o wide

Обязательные Pod-ы должны находиться в состоянии Running/Ready.