Привет! Меня зовут Игорь Конев, я — старший инженер команды DBaaS в Авито. В IT-сообществе сложилось мнение, что базы в Kubernetes (k8s) — сложные, ненадёжные и их неудобно поддерживать. Но я считаю, что это не так. Если систему немного «допилить», то результат точно окупится: бизнес будет расти и масштабироваться быстрее.
Расскажу о нашем подходе к работе Stateful-приложений в k8s на примере DBaaS и о том, как удалось автоматизировать жизненный цикл баз данных у нас в Авито. Эта статья будет полезна новичкам, которые не работали в Kubernetes, не сталкивались с менеджментом Stateful-приложений или хотели бы массово разворачивать базы данных в Kubernetes.
Текст основан на моём выступлении для Avito Database meetup #1.

Что внутри статьи:
Чем StatefulSet отличается от Deployment
Как мы настраиваем StatefulSet в DBaaS
Почему мы выбрали TopoLVM в качестве CSI
Какие проблемы потребовали собственных «велосипедов» при эксплуатации баз данных в Kubernetes
Чем StatefulSet отличается от Deployment
Существует два типа приложений: с сохранением состояния и без сохранения состояния. Работа первых зависит от данных, которые они пишут, например, на локальный диск. Вторые приложения не привязаны к данным и их проще переносить с места на место и эксплуатировать. Для развертки первых в Kubernetes существует ресурс StatefulSet, а для вторых — Deployment.
Для примера развернем тестовое приложение при помощи Deployment и StatefulSet и сравним их работу. Ниже приведены YAML-манифесты, которые я использовал. Я опустил некоторые поля для краткости, но можно заметить что манифесты отличаются только полями, связанными с volume:
Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
template:
spec:
containers:
- name: nginx
image: registry.k8s.io/nginx-slim:0.8
ports:
- containerPort: 80
name: webStatefulSet:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
replicas: 3
template:
spec:
containers:
- name: nginx
image: registry.k8s.io/nginx-slim:0.8
ports:
- containerPort: 80
name: web
volumeMounts:
- name: www
mountPath: /usr/share/nginx/html
volumeClaimTemplates:
- metadata:
name: www
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: "my-storage-class"
resources:
requests:
storage: 1GiПоды разворачиваются последовательно.

Поды в Deployment разворачиваются параллельно — это видно по полю AGE. В StatefulSet всё иначе: по умолчанию поды разворачиваются последовательно — это важное отличие. Например, если какой-то из подов вдруг не будет деплоиться, то нет смысла переходить к следующему поду. Ведь проблема может быть не на стороне Kubernetes, а на стороне вендора, который предоставляет дисковое пространство для хранения.
Если под вашу задачу и специфику приложения нужно, чтобы поды разворачивались параллельно, в StatefulSet это тоже возможно за счет их параллельного менеджмента.
Имена подов присваиваются по порядку.
Например, web-0 и web-1. Deployment же задаёт случайные имена — это может стать проблемой для stateful-приложения, которое хранит своё состояние.
Представим, что у нас есть под с выделенным объёмом хранения, к которому пользователь обращается по hostname. И по какой-то причине этот под «умирает». Если его восстановить с рандомным именем, как делает Deployment, пользователь не сможет обратиться по тому же адресу и не найдёт свои данные. StatefulSet же воссоздаст под со старым именем web-0 — он привяжется к уже выделенному хранилищу и всё будет работать как раньше.

Используется динамический provisioning через PVC.
В отличие от Deployment, у StatefulSet есть ещё один важный для нас раздел — VolumeClaimTemplates. В нём описывается спецификация для томов хранения, которые мы хотим привязать к приложению: имена томов, их размер, режим доступа и другие настройки:
volumeClaimTemplates:
- metadata:
name: www
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: "my-storage-class"
resources:
requests:
storage: 1GiФактически за этим описанием стоит более сложный механизм: динамический provisioning томов через Persistent Volume Claim. Но прежде чем переходить к нему, давайте посмотрим, как вообще в Kubernetes реализовано персистентное хранение данн��х.
Допустим, у нас есть реплика приложения, которой нужно хранить картинки с котиками. Для этого в k8s есть абстракция Persistent Volume (PV) — это постоянный том, куда под будет складывать данные. Администратор кластера выделяет PV вручную: указывает размеры объёма для хранения и специфичные параметры системы хранения данных (СХД) от вендора.
Чтобы связать под и PV, в Kubernetes используют дополнительную абстракцию — Persistent Volume Claim (PVC). Фактически это запрос на выделение дискового пространства. Внутри пода мы лишь ссылаемся на PVC и указываем, куда нужно монтировать PV. Так работает статический provisioning в Kubernetes.
Но возникает вопрос: зачем здесь нужен PVC? Почему под напрямую не может использовать PV?
Для этого есть несколько причин:
мы хотим скрыть от пользователя данные о Volume, например, на какой СХД он построен;
если у пользователя будет возможность выбирать PV руками, то он вряд ли будет думать про оптимальное потребление и просто заберёт самый большой volume себе.

Главная же проблема статического provisioning: администраторы вынуждены создавать PV вручную. Это нормально, если их нужно создать всего несколько штук. Но для тысячи приложений нужны тысячи PV. И их поддержка в ручном режиме — та ещё боль.
В этом случае поможет динамический provisioning томов. Он работает на основе механизма CSI (Container Storage Interface) — это спецификация, которую Kubernetes сделал, чтобы в своей кодовой базе не поддерживать десятки вендоров СХД.
Разработчики Kubernetes «развернули стрелку зависимости»: обязанность писать прослойку для интеграции СХД в Kubernetes теперь лежит на других, например, на самих вендорах СХД. Kubernetes лишь предоставляет спецификацию, которой должен соответствовать драйвер, чтобы хранилище можно было применять в кластерах.
CSI отслеживает все запросы на выделение дискового пространства (PVC) и дальше создаёт PV динамически. Неважно, что происходит «под капотом»: на каком локальном или сетевом диске расположено хранилище, хватит ли на нём места, какая используется система хранения данных. Kubernetes сам выделяет пространство для оптимального хранения.

Как мы настраиваем StatefulSet в DBaaS
Мы создаём и поддерживаем базы данных в k8s именно под StatefulSet. Теперь посмотрим на с��андартное наполнение StatefulSet в DBaaS на примере Redis.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis1-rs-rs001
namespace: redis1-rs-rs001
spec:
replicas: 1
template:
spec:
initContainers: # ...
containers:
- name: redis
command: ["bash", "-ec", "redis-server '/data/redis.conf' --dir '/data'"]
image: registry.k.avito.ru/avito/redis:6.2.6
ports:
- containerPort: 6379
protocol: TCP
resources:
limits: # ...
requests: # ...
volumeMounts:
- mountPath: /data
name: data
volumes: # ...
volumeClaimTemplates:
- apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: topolvm-provisioner
volumeMode: Filesystemполе replicas. Чаще всего проставляем 1 из-за особенностей организации наших дата-центров.
InitContainers. Здесь настраиваются контейнеры для инициализации, например, задаются сетевые ограничения. Также в случае Redis здесь запускается контейнер для рендера первичного ACL-файла (чтобы Redis не стартовал без секретов).
Containers. Настройки контейнеров с самой базой данных и sidecar-контейнеров: HA-агентов, экспортёров метрик и так далее. В этом же разделе есть поле resources, где указываются ограничения по CPU и Memory. k8s при планировании пода на узел использует эти данные для эффективного распределения ресурсов кластера.
Volumes. Помимо PV, здесь выбираются ConfigMap для конфигурации контейнера с базой, emptyDir — промежуточное хранилище для передачи данных между контейнерами в рамках одного пода и другие типы томов.
VolumeClaimTemplates. Об этой секции уже говорили: здесь настраиваем PVC и определяем, где база будет хранить свои данные. А ещё — какой CSI будет использоваться для динамического provisioning. В нашем случае это TopoLVM.
Почему мы выбрали TopoLVM в качестве CSI
Когда мы реализовывали DBaaS, у нас уже было закуплено железо под LXC-инсталляции. Поэтому рациональным решением было переиспользовать имеющиеся ресурсы и нам пришлось использовать в Kubernetes-кластерах локальные диски для хранения данных. Один из немногих CSI, который может работать с локальными дисками — TopoLVM. Почему он нам подошёл:
заточен под работу с локальными дисками. Нам не пришлось докупать дополнительное оборудование или допиливать систему.
работает на базе утилиты Linux LVM — Logical Volume Manager. Она делит диски на логические тома, каждый из которых — отдельный блочный девайс. Это удобно, так как можно устанавливать любые ограничения и оптимально разделять дисковое пространство.
поддерживает динамический provisioning и обладает важной фишкой — Volume Expansion без даунтайма. Когда к нам приходят разработчики и просят увеличить место для хранения, мы можем сделать это быстро и с минимальным простоем.
интегрирован с шедулером. TopoLVM анализирует свободное дисковое пространство в кластере и помогает планировщику Kubernetes учитывать его при выборе узла для пода.
Какие проблемы потребовали собственных «велосипедов» при эксплуатации баз данных в Kubernetes
При работе с базами на Kubernetes могут появляться проблемы, для которых нет готового решения (или не было на момент разработки нашей платформы DBaaS). Мы тоже с этим столкнулись: пришлось потратить немного больше времени, чтобы написать несколько дополнительных утилит. Но результат того стоил.
Проблема 1: «шумные соседи»
Для Kubernetes разделяемый ресурс — это узел или нода, а потребитель — её под. Между подами делятся все ресурсы, доступные на узле: CPU, память, пропускная способность сети и так далее. В норме ресурс распределяется равномерно между потребителями.

Но представим, что pod-0 вдруг начинает потреблять большую часть трафика — например, в процессе миграции каких-то данных. В этом случае два других пода деградируют — это неизбежно скажется на клиентах, которые ими пользуются. Возникает проблема «шумных соседей».

В k8s есть решение по ограничению CPU и Memory, о котором мы уже говорили — это работа планировщика Kubernetes и механизма лимитов. Но у дискового I/O готовых решений нет (точнее, не было на момент написания DBaaS). Нужно было придум��ть, как ограничить распределение пропускной способности локальных дисков под каждого потребителя.
Мы разработали собственную утилиту, которую назвали ioba. Она работает на базе механизма ядра cgroups v2 и устанавливается как DaemonSet на DBaaS кластере. Эта утилита:
определяет момент, когда под развёртывается на ноде;
считывает ограничения из конфигурационного файла;
устанавливает лимиты на конкретные контейнеры при помощи cgroups.
Ограничения задаются в виде Custom Resource IOLimit, в котором указываются дисковые лимиты для контейнеров в томах. Например, в коде ниже для контейнера с названием db при доступе к Volume с именем data мы задали ограничение объёма 200 iops и скорость 200 Мб/с.
apiVersion: dbaas.dbaas.avito.ru/v1alpha1
kind: IOLimit
metadata:
name: disk-io-limit
spec:
storageName: redis1
replicaSetName: rs001
containers:
- name: db
volumes:
- name: data
reads:
iops: 200
bandwidth: 200M
writes:
iops: 200
bandwidth: 200MПроблема 2: ограничения к Volume Expansion у StatefulSet
Мы уже упоминали, что TopoLVM умеет расширять объём хранения. Но функция работает с некоторыми ограничениями именно для StatefulSet в Kubernetes, о чём идут жаркие споры не один год. Суть проблемы: когда в StatefulSet меняют размер PVC, k8s выдаёт ошибку и говорит, что этот ресурс нельзя поменять в рантайме.
Можно было бы изменить размер PVC руками, но тогда бы пропала согласованность со StatefulSet. И в таком случае, если после этого мы увеличим количество реплик, новые будут автоматически применяться со старым значением объёма PVC.
Мы воспользовались хорошо известным в сообществе Kubernetes лайфхаком и написали ещё одну утилиту. Вот как она работает:
Утилита по запросу увеличивает размер PVC — это активирует в нём Volume Expansion.
Утилита удаляет StatefulSet с флагом cascade=orphan. Это приводит к тому, что все поды остаются на кластере — удаляется только манифест.
Воссоздаём манифест StatefulSet на кластере с новым размером PVC. Он автоматически привязывается к своим существующим подам.
У такого подхода есть недостаток. Поскольку для поддержки мы должны удалить StatefulSet, его поды на время остаются без контроля. Если в этот момент кто-то извне изменит под, то StatefulSet об этом не узнает и не откатит состояние пода до желаемого. Но мы принимаем этот момент, потому что польза от автоматического расширения Volume существенно больше.

Проблема 3: безопасный вывод узла DBaaS-кластера на обслуживание
Наши поды используют для хранения локальные диски. Это значит, что мы не можем просто перевезти их с одной физической ноды на другую — иначе данные просто потеряются. Однако узлы надо периодически перезагружать, ресетапить и выводить из кластера.
Представим, что пришёл Kubernetes-администратор и хочет вывести ноду из-под нагрузки. Как это сделать, не нарушая гарантий платформы DBaaS? Для этого мы придумали отдельный механизм — dbaas-descheduler, который интегрируется с kubectl drain и Eviction API Kubernetes.
Вот как он работает:
Администратор вызывает drain-операцию по конкретной ноде. Это создает Eviction-ресурс, на который зарегистрирован Validating Webhook внутри dbaas-descheduler.
Утилита видит, что нужно переместить определённый под c одного узла на другой. Но перед этим она проверяет в нашем источнике правды — service-dbaas — насколько миграция нарушит гарантии платформы.
Если миграция ничего не нарушает, dbaas-descheduler удаляет под с PVC и переносит его на другую ноду. Данные конкретной реплики БД теряются. Но поскольку сохраняются гарантии платформы, данные наливаются за счёт штатных средств репликации базы. А операция drain завершается успешно.
Если же запрос на перемещение пода нарушит гарантии платформы, тогда dbaas-descheduler ничего не делает и отправляет reject в ответ на операцию drain.
Что в итоге
В результате мы поняли, что Kubernetes отлично подходит для Stateful-приложений, но ему однозначно есть куда расти. Например, нам еще предстоит стабилизировать работу dbaas-descheduler, провести совместные улучшения с командой инфраструктуры, поддержать новые типы баз данных на платформе и многое другое.
Тем не менее на основе Kubernetes нам удалось построить стабильную DBaaS-платформу и автоматизировать жизненный цикл баз данных в Авито. Как следствие — такая надёжная платформа позволяет компании быстрее масштабироваться. А подробнее о метриках, которых нам удалось достичь, мы рассказали в докладе «Платформа DBaaS: зачем и как».
Больше о наших мероприятиях, выступлениях и том, какие задачи решают инженеры Авито, — на нашем сайте и в телеграм-канале AvitoTech. А вот здесь — всегда свежие вакансии в нашу команду.
