SQL Server на Kubernetes: ошибки и рабочие паттерны

Реальность баз данных в контейнерном мире: ошибки, которые я совершил, уроки, которые извлёк, и паттерны, работающие в продакшене

Если вы следили за моей серией статей о DACPAC, вы знаете, что я занимаюсь практическим DevOps для баз данных. Мы уже разобрали версионирование схем, автоматизацию CI/CD и корпоративные паттерны развёртывания. Но здесь начинается самое интересное — и самое запутанное.

Что происходит, когда вы пытаетесь запустить SQL Server на Kubernetes? Когда руководитель говорит «всё должно быть в контейнерах», а ваши базы данных смеются в лицо эфемерным подам? Когда GitOps встречается с 200-гигабайтной продакшен-базой, которая не имеет права потерять ни одной транзакции?

Признаюсь честно: моя первая попытка запустить SQL Server на AKS обернулась катастрофой. Я использовал Deployment вместо StatefulSet (ошибка новичка), захардкодил пароли в YAML-файлах (стыдно вспоминать) и с ужасом наблюдал, как перезапуск пода уничтожил мою тестовую базу данных. Данные исчезли. Просто… испарились.

Эта статья — о выводах из тех провалов. О паттернах, которые действительно работают при управлении реальными базами данных в Kubernetes, а не в демонстрационных примерах уровня «Hello World». И да — о том, что «cloud-native» подход не всегда является правильным ответом, но когда он всё же подходит, результат впечатляет.

Поехали.

Часть 1: Почему я вообще рассматривал этот вариант (и почему вы можете поступить так же)

Разговор, с которого всё началось

Руководитель: «Нам нужно стандартизировать Kubernetes для всего.»
Я: «Для всего? Даже для баз данных?»
Руководитель: «Особенно для баз данных. У нас 200 клиентских баз.»
Я: «…»

Понимаете, в чём дело — запуск баз данных на Kubernetes кажется безумием ровно до того момента, пока вы не разберётесь, какую проблему мы пытались решить.

Кошмар мультитенантности

В нашей компании работает SaaS-платформа. Каждый клиент получает собственную базу данных для изоляции данных. Звучит просто, правда?

Старый способ:

  • 200 отдельных баз данных Azure SQL

  • Каждая стоит минимум $5 в день, даже в простое

  • Масштабирование занимает 10+ минут

  • Управление 200 строками подключения в разных окружениях

  • Ежемесячные расходы: ~$30 000

«А что если использовать Kubernetes?»:

  • Один кластер AKS с общей инфраструктурой

  • Каждый клиент получает выделенный под с SQL Server

  • Лимиты ресурсов исключают влияние «шумных соседей»

  • Единая плоскость управления для всего

  • Потенциальные ежемесячные расходы: ~$8 000

Математика убедительная. А вот реализация — там я и набил шишки.

Фундаментальное противоречие

Kubernetes создавался для stateless-приложений. Базы данных — воплощение stateful. Это как пытаться вставить квадратный штырь в круглое отверстие: технически возможно, но нужны правильные инструменты и немало терпения.

Kubernetes говорит: «Поды — это скот, а не питомцы. Они умирают, заменяются, и никого это не волнует.»
Базы данных отвечают: «Я — ПИТОМЕЦ. Я храню данные ваших клиентов. Обращайтесь со мной уважительно.»

Это противоречие реально, и делать вид, что его не существует, ведёт к потере данных.

Часть 2: Моя первая катастрофа (и как её избежать)

Покажу в деталях, что именно я сделал не так:

# ❌ То, что я задеплоил (НЕ ДЕЛАЙТЕ ТАК)
apiVersion: apps/v1
kind: Deployment  # Первая ошибка
metadata:
  name: mssql
spec:
  replicas: 1
  template:
    spec:
      containers:
      - name: mssql
        image: mcr.microsoft.com/mssql/server:2022-latest
        env:
        - name: SA_PASSWORD
          value: "MyPassword123!"  # Вторая ошибка — открытый пароль
        volumeMounts:
        - name: data
          mountPath: /var/opt/mssql
      volumes:
      - name: data
        emptyDir: {}  # Третья ошибка — данные исчезают при перезапуске

Что произошло:

  1. Под упал (SQL Server исчерпал память)

  2. Kubernetes перезапустил его (выполнял свою работу)

  3. Новый под поднялся с… пустой директорией данных

  4. Моя тестовая база: исчезла

Я смотрел на экран целую минуту, думая: «Этого не может быть». Но всё именно так и было. emptyDir значит ровно то, что написано — временное хранилище, которое умирает вместе с подом.

Правильный подход: StatefulSet и Persistent Volumes

После третьего прочтения документации Kubernetes (и долгих поисков на Stack Overflow) я нашёл то, что действительно работает:

# ✅ Правильный подход
apiVersion: apps/v1
kind: StatefulSet  # Не Deployment!
metadata:
  name: mssql
  namespace: databases
spec:
  serviceName: mssql-service
  replicas: 1
  selector:
    matchLabels:
      app: mssql
  template:
    metadata:
      labels:
        app: mssql
    spec:
      securityContext:
        fsGroup: 10001  # SQL Server требует определённых прав
      containers:
      - name: mssql
        image: mcr.microsoft.com/mssql/server:2022-latest
        ports:
        - containerPort: 1433
        env:
        - name: ACCEPT_EULA
          value: "Y"
        - name: MSSQL_PID
          value: "Developer"
        - name: SA_PASSWORD
          valueFrom:
            secretKeyRef:  # Правильное управление секретами
              name: mssql-secret
              key: SA_PASSWORD
        volumeMounts:
        - name: mssql-data
          mountPath: /var/opt/mssql
        resources:
          requests:
            memory: "2Gi"
            cpu: "1000m"
          limits:
            memory: "4Gi"
            cpu: "2000m"
        # Проверки здоровья — SQL Server запускается не мгновенно
        livenessProbe:
          tcpSocket:
            port: 1433
          initialDelaySeconds: 60  # Дать полную минуту
          periodSeconds: 10
        readinessProbe:
          exec:
            command:
            - /bin/bash
            - -c
            - /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $SA_PASSWORD -Q "SELECT 1" || exit 1
          initialDelaySeconds: 60
          periodSeconds: 10

  # Вот в чём магия — постоянное хранилище, которое переживает перезапуски
  volumeClaimTemplates:
  - metadata:
      name: mssql-data
    spec:
      accessModes:
      - ReadWriteOnce
      storageClassName: managed-csi-premium  # Azure Premium SSD
      resources:
        requests:
          storage: 50Gi



---
# Headless-сервис для стабильного сетевого идентификатора
apiVersion: v1
kind: Service
metadata:
  name: mssql-service
  namespace: databases
spec:
  clusterIP: None  # Headless = каждый под получает собственную DNS-запись
  selector:
    app: mssql
  ports:
  - port: 1433

Ключевые отличия, которые важны:

  1. StatefulSet против Deployment: StatefulSet присваивает имена mssql-0, mssql-1 и т.д. с сохранением порядка. Ваш под всегда будет называться mssql-0.mssql-service.databases.svc.cluster.local. Это DNS-имя не меняется даже после перезапусков.

  2. volumeClaimTemplates: Автоматически создаёт PersistentVolumeClaim для каждого пода. Заявка сохраняется, даже если под умирает. Данные не исчезают.

  3. Секреты вместо захардкоженных паролей: создайте секрет отдельно:

kubectl create secret generic mssql-secret \
     --from-literal=SA_PASSWORD='YourStrong!Passw0rd123' \
     -n databases

4. Лимиты ресурсов: SQL Server с удовольствием съест всю доступную память. Лимиты не дают одному поду с базой данных голодить других.

Подводный камень со Storage Class

Изначально я использовал дефолтный storage class (managed-csi). Производительность была ужасной — запросы, которые должны выполняться за 100 мс, занимали 2+ секунды.

Оказалось, что по умолчанию используется Standard HDD. Для баз данных обязательно нужен Premium SSD:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: sqlserver-storage
provisioner: disk.csi.azure.com
parameters:
  skuName: Premium_LRS  # Вот что важно
  cachingMode: ReadWrite
reclaimPolicy: Retain  # КРИТИЧЕСКИ ВАЖНО — не удалять данные при удалении PVC
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer

Параметр reclaimPolicy: Retain однажды меня спас — я случайно удалил PVC. Базовый диск Azure при этом остался, просто стал «осиротевшим». Я смог переподключить его вручную и восстановить данные.

Урок усвоен: для продакшен-баз данных всегда используйте Retain. Лишние диски всегда можно удалить потом, но восстановить удалённые данные нельзя.

Часть 3: GitOps — или как я перестал беспокоиться и полюбил Git-коммиты

Здесь начинается по-настоящему интересное. Помните из моей статьи про CI/CD, как мы автоматизировали развёртывание DACPAC? Теперь представьте то же самое, но запускаемое Git-коммитами, управляемое ArgoCD и с возможностью полного отката.

Старый способ (ручной ад)

До GitOps развёртывание схем выглядело так:

  1. Разработчик: «Мне нужно задеплоить изменение схемы»

  2. Я: «Хорошо, пришли DACPAC»

  3. Разработчик: отправляет файл по почте

  4. Я: скачиваю, подключаюсь по VPN в кластер, выполняю kubectl exec

  5. Что-то ломается

  6. Разработчик: «Ой, подождите, не та версия»

  7. Я: молча страдаю

GitOps (настоящая вменяемость)

Теперь всё просто:

  1. Разработчик коммитит DACPAC в Git

  2. ArgoCD обнаруживает изменение

  3. Развёртывание происходит автоматически

  4. Я смотрю на дашборд, попивая кофе

Настройка ArgoCD на AKS:

# Установка ArgoCD
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
# Получить пароль администратора
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
# Port-forward для доступа к UI (для тестирования)
kubectl port-forward svc/argocd-server -n argocd 8080:443

Структура репозитория, которая реально работает

После нескольких итераций сложилась вот такая структура:

database-gitops/
├── base/
│   ├── namespace.yaml
│   ├── statefulset.yaml
│   ├── service.yaml
│   └── kustomization.yaml
├── overlays/
│   ├── dev/
│   ├── staging/
│   └── production/
├── schema/
│   ├── MyAppDB.dacpac
│   └── version.txt  # Простой файл отслеживания версии
└── jobs/
    └── deploy-schema-job.yaml

Файл version.txt прост, но эффективен:

v2.1.6
deployed: 2025-01-03
commit: abc123
by: firas

Job для развёртывания

Именно здесь DACPAC разворачивается автоматически:

# jobs/deploy-schema-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: deploy-schema-{{ .Values.version }}
  namespace: databases
  annotations:
    argocd.argoproj.io/hook: Sync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
  backoffLimit: 2  # Попробовать дважды, потом сдаться
  template:
    spec:
      # Резервная копия перед развёртыванием — дважды спасала меня
      initContainers:
      - name: backup-before-deploy
        image: mcr.microsoft.com/mssql-tools
        command:
        - /bin/bash
        - -c
        - |
          TIMESTAMP=$(date +%Y%m%d_%H%M%S)
          echo "Создание резервной копии перед развёртыванием..."

          /opt/mssql-tools/bin/sqlpackage \
            /Action:Export \
            /SourceConnectionString:"Server=mssql-service;Database=MyAppDB;User Id=sa;Password=$SA_PASSWORD;TrustServerCertificate=True;" \
            /TargetFile:/backups/pre-deploy-$TIMESTAMP.bacpac \
            /p:VerifyExtraction=True

          echo "Резервная копия сохранена: pre-deploy-$TIMESTAMP.bacpac"
        env:
        - name: SA_PASSWORD
          valueFrom:
            secretKeyRef:
              name: mssql-secret
              key: SA_PASSWORD
        volumeMounts:
        - name: backup-storage
          mountPath: /backups

      containers:
      - name: deploy-dacpac
        image: mcr.microsoft.com/mssql-tools
        command:
        - /bin/bash
        - -c
        - |
          set -e  # Выйти при любой ошибке

          echo "=== Развёртывание DACPAC начато ==="
          echo "Версия: $(cat /schema/version.txt)"
          echo "Цель: mssql-service"
          echo "================================"

          # Собственно развёртывание
          /opt/mssql-tools/bin/sqlpackage \
            /Action:Publish \
            /SourceFile:/schema/MyAppDB.dacpac \
            /TargetConnectionString:"Server=mssql-service;Database=MyAppDB;User Id=sa;Password=$SA_PASSWORD;TrustServerCertificate=True;" \
            /p:BlockOnPossibleDataLoss=True \
            /p:CommandTimeout=600 \
            /Diagnostics:True \
            /DiagnosticsFile:/logs/deployment.log

          EXIT_CODE=$?

          if [ $EXIT_CODE -eq 0 ]; then
            echo "✓ Развёртывание выполнено успешно"

            # Быстрая проверка
            /opt/mssql-tools/bin/sqlcmd \
              -S mssql-service \
              -U sa \
              -P "$SA_PASSWORD" \
              -d MyAppDB \
              -Q "SELECT COUNT(*) AS TableCount FROM INFORMATION_SCHEMA.TABLES" \
              || echo "Предупреждение: проверочный запрос не выполнен"
          else
            echo "✗ Развёртывание завершилось с кодом $EXIT_CODE"
            cat /logs/deployment.log
            exit $EXIT_CODE
          fi
        env:
        - name: SA_PASSWORD
          valueFrom:
            secretKeyRef:
              name: mssql-secret
              key: SA_PASSWORD
        volumeMounts:
        - name: schema-files
          mountPath: /schema
          readOnly: true
        - name: logs
          mountPath: /logs

      volumes:
      - name: schema-files
        configMap:
          name: dacpac-files
      - name: backup-storage
        persistentVolumeClaim:
          claimName: backup-pvc
      - name: logs
        emptyDir: {}

      restartPolicy: Never

Честно говоря: тот initContainer, который делает резервную копию перед развёртыванием? Он спас меня однажды, когда изменение схемы сломало продакшен. Я смог откатиться за 2 минуты, а не за… не хочу даже думать, сколько это заняло бы иначе.

Создание ConfigMap для DACPAC

Вот тут есть неловкий момент — файлы DACPAC бинарные, а ConfigMap хочет текст. Вот обходной путь:

# Создать ConfigMap с DACPAC
kubectl create configmap dacpac-files \
  --from-file=MyAppDB.dacpac=./schema/MyAppDB.dacpac \
  --from-file=version.txt=./schema/version.txt \
  -n databases \
  --dry-run=client -o yaml | kubectl apply -f -

Ограничение: ConfigMap вмещает максимум 1 МБ. Если ваш DACPAC больше (мои часто такие и есть), нужно хранить его в Azure Blob Storage:

# Модифицированный init container для скачивания из blob
initContainers:
- name: download-dacpac
  image: mcr.microsoft.com/azure-cli
  command:
  - /bin/bash
  - -c
  - |
    az storage blob download \
      --account-name mydacpacstore \
      --container-name dacpacs \
      --name MyAppDB_$(cat /schema/version.txt).dacpac \
      --file /schema/MyAppDB.dacpac \
      --auth-mode login
  volumeMounts:
  - name: schema-files
    mountPath: /schema

GitOps-процесс на практике

Что реально происходит:

  1. Разработчик обновляет схему базы данных в SSDT:

# Собрать DACPAC
   cd DatabaseProject
   msbuild /p:Configuration=Release
  1. Обновить версию и закоммитить:

cd ../database-gitops
   echo "v2.1.7" > schema/version.txt
   cp ../DatabaseProject/bin/Release/MyAppDB.dacpac schema/

   git add schema/
   git commit -m "feat: add CustomerPreferences table"
   git push
  1. ArgoCD подхватывает изменение (опрашивает каждые 3 минуты):

# Наблюдать за процессом
   kubectl get applications -n argocd -w
  1. Job запускается, схема разворачивается:

# Следить за job
   kubectl logs -f job/deploy-schema-v2.1.7 -n databases
  1. ArgoCD показывает «Synced» ✓

Красота в чём: полный журнал аудита. Каждое изменение схемы — это Git-коммит. Кто, что, когда и зачем — всё видно.

Часть 4: Реальность мультитенантности

Помните, я упоминал 200 клиентских баз данных? Вот где всё становится по-настоящему интересным.

Наивный подход (который не масштабируется)

Первая мысль: «Просто задеплою 200 StatefulSet!»

for i in {1..200}; do
  kubectl apply -f client-$i-statefulset.yaml
done

Проблемы такого подхода:

  • 200 отдельных YAML-файлов на поддержку

  • Развёртывание схемы требует 200 последовательных операций

  • Нет способа разбить клиентов на уровни (базовый и премиум)

  • Моё психическое здоровье: закончилось

Паттерн, который реально работает

Один шаблон StatefulSet, параметризованный по клиенту:

# Helm chart: templates/client-database.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mssql-{{ .Values.clientId }}
  namespace: databases
  labels:
    app: mssql
    client: "{{ .Values.clientId }}"
    tier: "{{ .Values.tier }}"
spec:
  serviceName: mssql-{{ .Values.clientId }}-service
  replicas: 1
  template:
    spec:
      containers:
      - name: mssql
        image: mcr.microsoft.com/mssql/server:2022-latest
        env:
        - name: SA_PASSWORD
          valueFrom:
            secretKeyRef:
              name: mssql-{{ .Values.clientId }}-secret
              key: SA_PASSWORD
        resources:
          # Разные ресурсы в зависимости от уровня
          {{- if eq .Values.tier "basic" }}
          requests:
            memory: "1Gi"
            cpu: "500m"
          limits:
            memory: "2Gi"
            cpu: "1000m"
          {{- else if eq .Values.tier "premium" }}
          requests:
            memory: "4Gi"
            cpu: "2000m"
          limits:
            memory: "8Gi"
            cpu: "4000m"
          {{- end }}
        volumeMounts:
        - name: data
          mountPath: /var/opt/mssql
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: [ReadWriteOnce]
      storageClassName: managed-csi-premium
      resources:
        requests:
          {{- if eq .Values.tier "basic" }}
          storage: 10Gi
          {{- else if eq .Values.tier "premium" }}
          storage: 50Gi
          {{- end }}

Подготовка нового клиента:

#!/bin/bash
# provision-client.sh

CLIENT_ID=$1
TIER=$2  # basic или premium

# Сгенерировать уникальный пароль
PASSWORD=$(openssl rand -base64 32)

# Создать секрет
kubectl create secret generic mssql-${CLIENT_ID}-secret \
  --from-literal=SA_PASSWORD="$PASSWORD" \
  -n databases

# Развернуть через Helm
helm install mssql-${CLIENT_ID} ./charts/mssql \
  --set clientId=$CLIENT_ID \
  --set tier=$TIER \
  --namespace databases

# Ждать готовности
kubectl wait --for=condition=ready pod \
  -l client=$CLIENT_ID \
  -n databases \
  --timeout=300s

echo "Клиент $CLIENT_ID подготовлен (уровень: $TIER)"

Использование:

./provision-client.sh client-001 premium
./provision-client.sh client-002 basic

Развёртывание схемы для всех арендаторов

Здесь начинается самое интересное. Помните паттерн канареечного развёртывания из моей продвинутой статьи? Применим его здесь:

# Этап 1: развернуть для 5% (канарейка)
apiVersion: batch/v1
kind: Job
metadata:
  name: deploy-schema-canary
  namespace: databases
spec:
  parallelism: 5  # 5 клиентов одновременно
  completions: 10  # 10 клиентов всего (5% от 200)
  template:
    spec:
      containers:
      - name: deploy
        image: mcr.microsoft.com/mssql-tools
        command:
        - /bin/bash
        - -c
        - |
          # Получить назначенный ID клиента
          CLIENT_INDEX=$JOB_COMPLETION_INDEX
          CLIENT_ID=$(awk "NR==$((CLIENT_INDEX+1))" /etc/client-list/canary-clients.txt)

          echo "Развёртывание для клиента: $CLIENT_ID"

          # Получить пароль клиента
          SA_PASSWORD=$(kubectl get secret mssql-${CLIENT_ID}-secret \
            -n databases \
            -o jsonpath='{.data.SA_PASSWORD}' | base64 -d)

          # Развернуть DACPAC
          /opt/mssql-tools/bin/sqlpackage \
            /Action:Publish \
            /SourceFile:/schema/MyAppDB.dacpac \
            /TargetConnectionString:"Server=mssql-${CLIENT_ID}-service;Database=MyAppDB;User Id=sa;Password=$SA_PASSWORD;" \
            /p:CommandTimeout=600

          echo "✓ Развёрнуто для $CLIENT_ID"
        env:
        - name: JOB_COMPLETION_INDEX
          valueFrom:
            fieldRef:
              fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']
        volumeMounts:
        - name: client-list
          mountPath: /etc/client-list
        - name: schema
          mountPath: /schema
      volumes:
      - name: client-list
        configMap:
          name: client-lists
      - name: schema
        configMap:
          name: dacpac-files
      restartPolicy: OnFailure

ConfigMap со списком клиентов:

apiVersion: v1
kind: ConfigMap
metadata:
  name: client-lists
  namespace: databases
data:
  canary-clients.txt: |
    client-001
    client-005
    client-012
    client-023
    client-037
    client-051
    client-089
    client-112
    client-167
    client-198
  all-clients.txt: |
    client-001
    client-002
    ...
    client-200

Проверочный шлюз после канареечного развёртывания:

# Проверить частоту ошибок у канареечных клиентов
kubectl exec -it mssql-client-001-0 -n databases -- \
  /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -Q "
SELECT COUNT(*) as Errors
FROM ApplicationErrors
WHERE CreatedDate > DATEADD(minute, -30, GETDATE())"
# Если ошибок меньше порогового значения — продолжить полное развёртывание
# Иначе — откатиться

Из реального опыта: мы однажды поймали критическое изменение на канареечном этапе. Новый столбец NOT NULL, который наше приложение ещё не заполняло. Канареечные клиенты начали выдавать ошибки, мы исправили приложение и повторили развёртывание. Полное развёртывание прошло успешно. Канареечный паттерн работает.

Часть 5: Безопасность (или как я перестал захардкоживать пароли)

Признаюсь в постыдном: в моём первом развёртывании базы данных на Kubernetes пароль SA был в открытом виде в YAML-файле. В Git. В публичном репозитории.

Интеграция с Azure Key Vault (правильный способ)

Установить CSI-драйвер:

helm repo add csi-secrets-store-provider-azure \
  https://azure.github.io/secrets-store-csi-driver-provider-azure/charts
helm install csi csi-secrets-store-provider-azure/csi-secrets-store-provider-azure \
  --namespace kube-system

Создать Key Vault и предоставить доступ AKS:

# Создать хранилище
az keyvault create \
  --name myaks-secrets-kv \
  --resource-group myaks-rg
# Сохранить пароль
az keyvault secret set \
  --vault-name myaks-secrets-kv \
  --name mssql-sa-password \
  --value "$(openssl rand -base64 32)"
# Получить управляемый идентификатор AKS
IDENTITY_ID=$(az aks show \
  -g myaks-rg \
  -n myaks-cluster \
  --query identityProfile.kubeletidentity.objectId -o tsv)
# Предоставить доступ
az keyvault set-policy \
  --name myaks-secrets-kv \
  --object-id $IDENTITY_ID \
  --secret-permissions get list

SecretProviderClass:

apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: azure-kv-mssql
  namespace: databases
spec:
  provider: azure
  parameters:
    usePodIdentity: "false"
    useVMManagedIdentity: "true"
    userAssignedIdentityID: ""
    keyvaultName: myaks-secrets-kv
    objects: |
      array:
        - |
          objectName: mssql-sa-password
          objectType: secret
    tenantId: "your-tenant-id"
  secretObjects:
  - secretName: mssql-secret
    type: Opaque
    data:
    - objectName: mssql-sa-password
      key: SA_PASSWORD

Использование в StatefulSet:

spec:
  template:
    spec:
      containers:
      - name: mssql
        env:
        - name: SA_PASSWORD
          valueFrom:
            secretKeyRef:
              name: mssql-secret  # Синхронизируется из Key Vault
              key: SA_PASSWORD
        volumeMounts:
        - name: secrets-store
          mountPath: "/mnt/secrets"
          readOnly: true
      volumes:
      - name: secrets-store
        csi:
          driver: secrets-store.csi.k8s.io
          readOnly: true
          volumeAttributes:
            secretProviderClass: azure-kv-mssql

Что происходит: под запускается → CSI-драйвер забирает пароль из Key Vault → создаёт Kubernetes Secret → под использует его → в Git ничего не хранится ✓

Сетевые политики (потому что глубокая защита важна)

Разрешим только подам приложения общаться с подами базы данных:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: mssql-network-policy
  namespace: databases
spec:
  podSelector:
    matchLabels:
      app: mssql
  policyTypes:
  - Ingress
  ingress:
  # Только из пространства имён приложения
  - from:
    - namespaceSelector:
        matchLabels:
          name: myapp
    - podSelector:
        matchLabels:
          app: myapp-backend
    ports:
    - protocol: TCP
      port: 1433

  # Также из job-ов развёртывания
  - from:
    - podSelector:
        matchLabels:
          job-type: schema-deployment
    ports:
    - protocol: TCP
      port: 1433

Проверка:

# Это должно работать (из пода приложения)
kubectl exec -it myapp-backend-xyz -n myapp -- \
  nc -zv mssql-service.databases.svc.cluster.local 1433
# Это должно завершиться неудачей (из произвольного пода)
kubectl run -it test --image=alpine --rm -- \
  nc -zv mssql-service.databases.svc.cluster.local 1433
# Вывод: connection refused ✓

Часть 6: Когда что-то идёт не так (а это обязательно произойдёт)

Проблема: под завис в состоянии Pending

Симптом: kubectl get pods вечно показывает Pending

Диагностика:

kubectl describe pod mssql-0 -n databases

Типичные причины, с которыми я сталкивался:

  1. PVC не может привязаться — нет доступного хранилища

       Events:
       Warning  ProvisioningFailed  persistentvolume-controller
       Failed to provision volume: exceeded quota

    Решение: увеличить квоту хранилища или удалить старые PVC

  2. На узлах недостаточно ресурсов

       Events:
       Warning  FailedScheduling  default-scheduler
       0/3 nodes are available: insufficient memory

    Решение: добавить узлы или снизить запросы ресурсов

  3. Неправильный storage class

       Events:
       Warning  ProvisioningFailed  could not find storage class "managed-premium"

    Решение: проверить kubectl get storageclass и исправить опечатку в YAML

Проблема: база данных повреждена после сбоя

Это произошло однажды при отказе узла. Под был принудительно завершён, SQL Server не завершил работу корректно.

Диагностика:

kubectl logs mssql-0 -n databases | grep -i corrupt
# или
kubectl exec -it mssql-0 -n databases -- \
  /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" \
  -Q "DBCC CHECKDB (MyAppDB)"

Если база повреждена:

  1. Восстановить из резервной копии:

    # Найти последнюю резервную копию
       BACKUP=$(az storage blob list \
         --account-name myaksbackups \
         --container-name backups \
         --query "sort_by(@, &properties.lastModified)[-1].name" \
         -o tsv)
    
       # Восстановить
       kubectl exec -it mssql-0 -n databases -- \
         /opt/mssql-tools/bin/sqlpackage \
           /Action:Import \
           /SourceFile:/backups/$BACKUP \
           /TargetConnectionString:"..."
  2. Проверить базовый диск:

    # Получить диск Azure для PVC
       PVC_NAME=$(kubectl get pvc -n databases -o jsonpath='{.items[0].spec.volumeName}')
    
       # Проверить состояние диска в Azure
       az disk show --ids /subscriptions/.../disks/$PVC_NAME \
         --query diskState

Урок: Premium SSD обеспечивает лучшую надёжность. Для продакшена это оправданные расходы.

Проблема: таймаут развёртывания DACPAC

Симптом: job развёртывания завершается с ошибкой таймаута

Типичные причины:

  1. Масштабные изменения схемы

    # Проверить, что занимает время
       kubectl exec -it mssql-0 -n databases -- \
         /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -Q "
       SELECT session_id, command, wait_type, wait_time
       FROM sys.dm_exec_requests
       WHERE session_id > 50"
  2. Блокировки таблиц от активных соединений

    -- Найти блокировки
       SELECT blocking_session_id, session_id, wait_type
       FROM sys.dm_exec_requests
       WHERE blocking_session_id > 0

Решение: увеличить таймаут в job развёртывания:

/opt/mssql-tools/bin/sqlpackage \
  /Action:Publish \
  ... \
  /p:CommandTimeout=3600  # 1 час вместо дефолтных 60 с

Или разворачивать в окно технического обслуживания, когда нет активных соединений.

Часть 7: Мониторинг (потому что нужно знать, когда что-то ломается)

Prometheus + Grafana

Развернуть экспортёр для SQL Server:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mssql-exporter
  namespace: databases
spec:
  replicas: 1
  template:
    spec:
      containers:
      - name: exporter
        image: awaragi/prometheus-mssql-exporter
        env:
        - name: SERVER
          value: "mssql-service"
        - name: PORT
          value: "1433"
        - name: USERNAME
          value: "sa"
        - name: PASSWORD
          valueFrom:
            secretKeyRef:
              name: mssql-secret
              key: SA_PASSWORD
        ports:
        - containerPort: 4000
          name: metrics

Ключевые метрики, за которыми я реально слежу:

# Использование CPU (алерт при > 80%)
mssql_cpu_usage_percent > 80

# Активные соединения (алерт при > 100)
mssql_connections{state="current"} > 100

# Дедлоки (алерт при любых)
rate(mssql_deadlocks_total[5m]) > 0

# Page life expectancy (алерт при < 300 с)
mssql_page_life_expectancy_seconds < 300

Алерт, который нас спас:

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: mssql-alerts
  namespace: databases
spec:
  groups:
  - name: sqlserver
    interval: 30s
    rules:
    - alert: HighCPU
      expr: mssql_cpu_usage_percent > 80
      for: 5m
      annotations:
        summary: "Высокое использование CPU SQL Server на {{ $labels.pod }}"
        description: "Нагрузка CPU составляет {{ $value }}%"

Этот алерт помог обнаружить запрос-«беглец», который нагружал CPU. Мы прибили его до того, как это повлияло на других клиентов.

Интеграция с Azure Monitor

Если вы работаете на AKS, Container Insights — ваш лучший друг:

az aks enable-addons \
  --resource-group myaks-rg \
  --name myaks-cluster \
  --addons monitoring

Полезные запросы в Log Analytics:

// Ошибки SQL Server за последний час
ContainerLog
| where Namespace == "databases"
| where ContainerName == "mssql"
| where LogEntry contains "error"
| where TimeGenerated > ago(1h)
| project TimeGenerated, PodName, LogEntry

// Успешность job-ов развёртывания
ContainerLog
| where Namespace == "databases"
| where PodName startswith "deploy-schema"
| summarize
    Total = count(),
    Success = countif(LogEntry contains "successful"),
    Failed = countif(LogEntry contains "failed")
| extend SuccessRate = (Success * 100.0) / Total

Часть 8: Честный взгляд на стоимость

Поговорим о деньгах. Запуск баз данных на AKS — не автоматическая экономия.

Мои реальные расходы (200 клиентских баз данных):

До (Azure SQL):

  • 200 баз × $5/день = $1 000/день = $30 000 в месяц

После (AKS):

  • Управляющий уровень AKS: $0 (бесплатный уровень)

  • 6 узлов (Standard_D8s_v3): $0,384/ч × 6 × 730 ч = $1 682/мес

  • Хранилище Premium SSD: 200 × 10 ГиБ × $0,135/ГБ = $270/мес

  • Резервные копии (Azure Blob Cool tier): 200 × 2 ГБ × $0,01/ГБ = $40/мес

  • Итого: ~$2 000 в месяц

Экономия: $28 000 в месяц (снижение на 93%)

Но есть нюанс:

  • Время DevOps на построение и поддержку: ~20 часов в месяц

  • Кривая обучения: болезненная

  • Сложность: значительно выросла

Стоит ли оно того? Для нас — да. Для небольшой команды с 5 базами данных? Скорее нет.

Советы по оптимизации расходов

1. Использовать Spot VM для dev/test:

az aks nodepool add \
  --resource-group myaks-rg \
  --cluster-name myaks-cluster \
  --name spotpool \
  --priority Spot \
  --eviction-policy Delete \
  --spot-max-price -1 \
  --min-count 1 \
  --max-count 3 \
  --labels environment=dev

Экономия: ~70–80% на вычислениях для непродакшен-сред.

2. Политики жизненного цикла для Blob Storage:

# Автоматическое перемещение старых резервных копий
az storage account management-policy create \
  --account-name myaksbackups \
  --policy '{
    "rules": [{
      "name": "tierOldBackups",
      "enabled": true,
      "type": "Lifecycle",
      "definition": {
        "filters": {
          "blobTypes": ["blockBlob"],
          "prefixMatch": ["backups/"]
        },
        "actions": {
          "baseBlob": {
            "tierToCool": {"daysAfterModificationGreaterThan": 30},
            "tierToArchive": {"daysAfterModificationGreaterThan": 90},
            "delete": {"daysAfterModificationGreaterThan": 365}
          }
        }
      }
    }]
  }'

Экономия: ~$200 в месяц на хранении резервных копий.

3. Подобрать правильный размер подов:

Не нужно выделять каждой базе 8 ГиБ оперативной памяти, если она использует только 2 ГиБ. Используйте Vertical Pod Autoscaler (вертикальное автомасштабирование подов), чтобы найти оптимальный размер:

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: mssql-vpa
  namespace: databases
spec:
  targetRef:
    apiVersion: apps/v1
    kind: StatefulSet
    name: mssql
  updatePolicy:
    updateMode: "Recreate"  # Перезапустит поды для применения изменений

VPA отслеживает реальное потребление и корректирует запросы и лимиты ресурсов. Таким образом мы сократили расходы на память примерно на 30%.

Заключение: оно того стоит?

После 6 месяцев эксплуатации SQL Server на AKS в продакшене — вот мой честный вывод.

Когда это имеет смысл

Мультитенантный SaaS с 50+ клиентскими базами данных
Чувствительность к стоимости при готовности инвестировать время DevOps
Уже используете Kubernetes для приложений
Dev/test-окружения (легко поднять и опустить)
Команда владеет Kubernetes

Когда это не подходит

Небольшой масштаб (менее 10 баз) — Azure SQL проще
Критически важные системы без опыта работы с Kubernetes — слишком рискованно
Регуляторные ограничения, требующие управляемых сервисов
Ограниченные ресурсы DevOps — эксплуатационные накладные расходы реальны
Нужен SLA 99,99% — SLA у AKS ниже, чем у Azure SQL

Что я сделал бы иначе

  1. Начать меньше: я сразу прыгнул на 200 баз данных. Надо было пилотировать на 10.

  2. Добавить мониторинг раньше: я настроил Prometheus после первого инцидента. Надо было делать это в первый день.

  3. Документировать runbook-и: «Как восстановить из резервной копии в 3 ночи» не должно выясняться в 3 ночи.

  4. Больше автоматизированного тестирования: проверять аварийное восстановление ежемесячно, а не тогда, когда авария уже наступила.

Итог

Запускать базы данных на Kubernetes — это как водить машину с механической коробкой передач: больше контроля, потенциально лучшая производительность и однозначно больше сложности. Управляемые сервисы (Azure SQL) — это как автомат: проще, меньше нужно думать, но меньше гибкости.

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

Для меня? Я рад, что мы это сделали. Экономия реальная, GitOps-процесс красивый, и я многому научился. Но я не стал бы рекомендовать это всем.

Что думаете вы? Запускаете базы данных на Kubernetes? Рассматриваете такую возможность? Пробовали и сбежали в ужасе? Давайте обсудим в комментариях — мне очень интересен ваш опыт, особенно катастрофы. На ошибках учатся лучше, чем на успехах.

Связанные статьи:

© 2026 meganuke