Привет! Меня зовут Игорь Конев, я — старший инженер команды 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: web
StatefulSet:
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. А вот здесь — всегда свежие вакансии в нашу команду.