Реальность баз данных в контейнерном мире: ошибки, которые я совершил, уроки, которые извлёк, и паттерны, работающие в продакшене
Если вы следили за моей серией статей о 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: {} # Третья ошибка — данные исчезают при перезапуске
Что произошло:
-
Под упал (SQL Server исчерпал память)
-
Kubernetes перезапустил его (выполнял свою работу)
-
Новый под поднялся с… пустой директорией данных
-
Моя тестовая база: исчезла
Я смотрел на экран целую минуту, думая: «Этого не может быть». Но всё именно так и было. 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
Ключевые отличия, которые важны:
-
StatefulSet против Deployment: StatefulSet присваивает имена
mssql-0,mssql-1и т.д. с сохранением порядка. Ваш под всегда будет называтьсяmssql-0.mssql-service.databases.svc.cluster.local. Это DNS-имя не меняется даже после перезапусков. -
volumeClaimTemplates: Автоматически создаёт PersistentVolumeClaim для каждого пода. Заявка сохраняется, даже если под умирает. Данные не исчезают.
-
Секреты вместо захардкоженных паролей: создайте секрет отдельно:
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 развёртывание схем выглядело так:
-
Разработчик: «Мне нужно задеплоить изменение схемы»
-
Я: «Хорошо, пришли DACPAC»
-
Разработчик: отправляет файл по почте
-
Я: скачиваю, подключаюсь по VPN в кластер, выполняю kubectl exec
-
Что-то ломается
-
Разработчик: «Ой, подождите, не та версия»
-
Я: молча страдаю
GitOps (настоящая вменяемость)
Теперь всё просто:
-
Разработчик коммитит DACPAC в Git
-
ArgoCD обнаруживает изменение
-
Развёртывание происходит автоматически
-
Я смотрю на дашборд, попивая кофе
Настройка 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-процесс на практике
Что реально происходит:
-
Разработчик обновляет схему базы данных в SSDT:
# Собрать DACPAC
cd DatabaseProject
msbuild /p:Configuration=Release
-
Обновить версию и закоммитить:
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
-
ArgoCD подхватывает изменение (опрашивает каждые 3 минуты):
# Наблюдать за процессом
kubectl get applications -n argocd -w
-
Job запускается, схема разворачивается:
# Следить за job
kubectl logs -f job/deploy-schema-v2.1.7 -n databases
-
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
Типичные причины, с которыми я сталкивался:
-
PVC не может привязаться — нет доступного хранилища
Events: Warning ProvisioningFailed persistentvolume-controller Failed to provision volume: exceeded quota
Решение: увеличить квоту хранилища или удалить старые PVC
-
На узлах недостаточно ресурсов
Events: Warning FailedScheduling default-scheduler 0/3 nodes are available: insufficient memory
Решение: добавить узлы или снизить запросы ресурсов
-
Неправильный 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)"
Если база повреждена:
-
Восстановить из резервной копии:
# Найти последнюю резервную копию 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:"..." -
Проверить базовый диск:
# Получить диск 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 развёртывания завершается с ошибкой таймаута
Типичные причины:
-
Масштабные изменения схемы
# Проверить, что занимает время 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" -
Блокировки таблиц от активных соединений
-- Найти блокировки 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
Что я сделал бы иначе
-
Начать меньше: я сразу прыгнул на 200 баз данных. Надо было пилотировать на 10.
-
Добавить мониторинг раньше: я настроил Prometheus после первого инцидента. Надо было делать это в первый день.
-
Документировать runbook-и: «Как восстановить из резервной копии в 3 ночи» не должно выясняться в 3 ночи.
-
Больше автоматизированного тестирования: проверять аварийное восстановление ежемесячно, а не тогда, когда авария уже наступила.
Итог
Запускать базы данных на Kubernetes — это как водить машину с механической коробкой передач: больше контроля, потенциально лучшая производительность и однозначно больше сложности. Управляемые сервисы (Azure SQL) — это как автомат: проще, меньше нужно думать, но меньше гибкости.
Выбирайте, исходя из своих потребностей, навыков команды и готовности работать с острыми углами.
Для меня? Я рад, что мы это сделали. Экономия реальная, GitOps-процесс красивый, и я многому научился. Но я не стал бы рекомендовать это всем.
Что думаете вы? Запускаете базы данных на Kubernetes? Рассматриваете такую возможность? Пробовали и сбежали в ужасе? Давайте обсудим в комментариях — мне очень интересен ваш опыт, особенно катастрофы. На ошибках учатся лучше, чем на успехах.
Связанные статьи: