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

Поды в Deployment разворачиваются параллельно — это видно по полю AGE. В StatefulSet всё иначе: по умолчанию поды разворачиваются последовательно — это важное отличие. Например, если какой-то из подов вдруг не будет деплоиться, то нет смысла переходить к следующему поду. Ведь проблема может быть не на стороне Kubernetes, а на стороне вендора, который предоставляет дисковое пространство для хранения. 

Если под вашу задачу и специфику приложения нужно, чтобы поды разворачивались параллельно, в StatefulSet это тоже возможно за счет их параллельного менеджмента

  • Имена подов присваиваются по порядку.

Например, web-0 и web-1. Deployment же задаёт случайные имена — это может стать проблемой для stateful-приложения, которое хранит своё состояние. 

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

Если под пропадает, то StatefulSet восстанавливает его с тем же самым именем
Если под пропадает, то StatefulSet восстанавливает его с тем же самым именем
  • Используется динамический 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 себе. 

Стандартный механизм k8s: PV создаются вручную на кластере, пользователь создает PVC, а k8s подбирает PV в соответствии с требованиями заданными в PVC 
Стандартный механизм k8s: PV создаются вручную на кластере, пользователь создает PVC, а k8s подбирает PV в соответствии с требованиями заданными в PVC 

Главная же проблема статического provisioning: администраторы вынуждены создавать PV вручную. Это нормально, если их нужно создать всего несколько штук. Но для тысячи приложений нужны тысячи PV. И их поддержка в ручном режиме — та ещё боль. 

В этом случае поможет динамический provisioning томов. Он работает на основе механизма CSI (Container Storage Interface) — это спецификация, которую Kubernetes сделал, чтобы в своей кодовой базе не поддерживать десятки вендоров СХД.

Разработчики Kubernetes «развернули стрелку зависимости»: обязанность писать прослойку для интеграции СХД в Kubernetes теперь лежит на других, например, на самих вендорах СХД. Kubernetes лишь предоставляет спецификацию, которой должен соответствовать драйвер, чтобы хранилище можно было применять в кластерах.

CSI отслеживает все запросы на выделение дискового пространства (PVC) и дальше создаёт PV динамически. Неважно, что происходит «под капотом»: на каком локальном или сетевом диске расположено хранилище, хватит ли на нём места, какая используется система хранения данных. Kubernetes сам выделяет пространство для оптимального хранения.

При динамическом provisioning PV создаётся за счёт механизма Container Storage Interface
При динамическом provisioning PV создаётся за счёт механизма Container Storage Interface

Как мы настраиваем 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 вдруг начинает потреблять большую часть трафика — например, в процессе миграции каких-то данных. В этом случае два других пода деградируют — это неизбежно скажется на клиентах, которые ими пользуются. Возникает проблема «шумных соседей». 

 Если pod-0 будет потреблять больше ресурса, это скажется на остальных подах
 Если 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 лайфхаком и написали ещё одну утилиту. Вот как она работает:

  1. Утилита по запросу увеличивает размер PVC — это активирует в нём Volume Expansion.   

  2. Утилита удаляет StatefulSet с флагом cascade=orphan. Это приводит к тому, что все поды остаются на кластере — удаляется только манифест. 

  3. Воссоздаём манифест StatefulSet на кластере с новым размером PVC. Он автоматически привязывается к своим существующим подам. 

У такого подхода есть недостаток. Поскольку для поддержки мы должны удалить StatefulSet, его поды на время остаются без контроля. Если в этот момент кто-то извне изменит под, то StatefulSet об этом не узнает и не откатит состояние пода до желаемого. Но мы принимаем этот момент, потому что польза от автоматического расширения Volume существенно больше.

Проблема 3: безопасный вывод узла DBaaS-кластера на обслуживание

Наши поды используют для хранения локальные диски. Это значит, что мы не можем просто перевезти их с одной физической ноды на другую — иначе данные просто потеряются. Однако узлы надо периодически перезагружать, ресетапить и выводить из кластера. 

Представим, что пришёл Kubernetes-администратор и хочет вывести ноду из-под нагрузки. Как это сделать, не нарушая гарантий платформы DBaaS? Для этого мы придумали отдельный механизм — dbaas-descheduler, который интегрируется с kubectl drain и Eviction API Kubernetes.

Вот как он работает:

  1. Администратор вызывает drain-операцию по конкретной ноде. Это создает Eviction-ресурс, на который зарегистрирован Validating Webhook внутри dbaas-descheduler.  

  2. Утилита видит, что нужно переместить определённый под c одного узла на другой. Но перед этим она проверяет в нашем источнике правды — service-dbaas — насколько миграция нарушит гарантии платформы. 

  3. Если миграция ничего не нарушает, dbaas-descheduler удаляет под с PVC и переносит его на другую ноду. Данные конкретной реплики БД теряются. Но поскольку сохраняются гарантии платформы, данные наливаются за счёт штатных средств репликации базы. А операция drain завершается успешно. 

  4. Если же запрос на перемещение пода нарушит гарантии платформы, тогда dbaas-descheduler ничего не делает и отправляет reject в ответ на операцию drain.  

Что в итоге

В результате мы поняли, что Kubernetes отлично подходит для Stateful-приложений, но ему однозначно есть куда расти. Например, нам еще предстоит стабилизировать работу dbaas-descheduler, провести совместные улучшения с командой инфраструктуры, поддержать новые типы баз данных на платформе и многое другое.

Тем не менее на основе Kubernetes нам удалось построить стабильную DBaaS-платформу и автоматизировать жизненный цикл баз данных в Авито. Как следствие — такая надёжная платформа позволяет компании быстрее масштабироваться. А подробнее о метриках, которых нам удалось достичь, мы рассказали в докладе «Платформа DBaaS: зачем и как».

Больше о наших мероприятиях, выступлениях и том, какие задачи решают инженеры Авито, — на нашем сайте и в телеграм-канале AvitoTech. А вот здесь — всегда свежие вакансии в нашу команду.

Комментарии (13)


  1. Tzimie
    14.02.2025 14:20

    А вы попробуйте высоконагруженную базу запихнуть. 96 кор, 2Тб RAM, локальные SSD чтобы выжать максимальную скорость


    1. iigkon Автор
      14.02.2025 14:20

      Мы скорее пытаемся автоматизировать жизненный цикл БД для множества микросервисов для обеспечения polyglot persistence. Если какой-то микросервис упирается по ресурсам, то применяем шардирование для горизонтального масштабирования как раз, чтобы конкретные инстансы не распухали.

      Ну и в целом не понимаю, что мешает засетапить подобную базу в Kubernetes, если есть соответствующие ноды в кластере.


      1. SerJ_82
        14.02.2025 14:20

        Добрый день.

        Не сочтите оффтопом, но может будет какой-то шанс через вас достучаться до руководства Авито...
        Вы не в курсе, будет когда-то создан сервис поиска по картинке?
        Я помню какое-то длительное время назад это уже было, видимо в качестве эксперимента, но потом отменили...
        Такой функционал (пусть даже будет платным) невероятно сократит время поиска по однообразным объявлениям!
        Пример: такой раритет как видеокассеты продаются на Авито тысячами.
        И чтобы найти нужную приходится искать иголку в стоге сена. Это дико раздражает...


      1. accsentive
        14.02.2025 14:20

        С текущими и давно стабильными CAS Latency у вас нереально крутая банда-команда!!


    1. PetyaUmniy
      14.02.2025 14:20

      А для чего это делать в кубере? Столь большие инстансы DB обычно редки. Их не нужно разворачивать в день по 100 новых инстансов.
      С такими потреблениями впору выделять физический сервер, заодно чтобы конфигурация NUMA была нативная (без всяких скрывающих её виртуализаций). Кубер в общем случае про другое, про то, как даже не на физическом сервере (физический сервер раз в 10-100 мощнее, чем стоило бы отдавать нодам), а на виртуалке, позапускать кучу не очень больших сервисов.


      1. Tzimie
        14.02.2025 14:20

        Именно. Просто именно с такими базами я имею дело. Может быть профессиональная деформация


      1. AlexGluck
        14.02.2025 14:20

        Здравствуйте, раскройте пожалуйста мысль про конфигурацию NUMA и каким образом она скрывается от ПО в контейнерах?


    1. trublast
      14.02.2025 14:20

      Если есть необходимость в базе 96 кор и 2Тб RAM то это прямым образом указывает на то, что архитектура сервиса плохо заточена под масштабирование. Если у вас возрастает нагрузка, вы покупаете другой сервер, куда вставляете 2 процессора по 96 кор и переливаете базу?

      А кубернетес же не про это. Он предполагает, что вы можете скейлить ворклоад почти бесконечно, просто создавая больше подов приложения. А так как с базами это плохо работает, то используется другие подходы. Примеры: для каждого сервиса своя небольшая база, использование баз, поддерживающих шардирование, запись через брокеры сообщений, использование нескольких слоев кэширования.

      Да, тут теряется консистентность, которую гарантирует БД. Например, если один клиент что-то записал и другой тут же прочитал - данные будут уже обновленные. А использование очередей и кэшей приводит к задержкам доставки актуальных данных. Ну или вы не сможете сделать select.. join из таблиц разных сервисов, потому что одна таблица - это clickhouse из 40 нод, а вторая это opensearch из 10 нод.

      Но зато при этом каждый шард базы получается не очень большой, и если ещё имеет реплику - можно обращаться с базами почти как со stateless. То есть включать, выключать, перемещать на другие ноды или другие хранилища. В конце концов раздуть каждый инстас до 96 кор и 2Тб RAM в моменте.

      А чтобы не менеджить много маленьких баз с большим количеством шардов или реплик вручную, в кубернетес можно использовать операторы, которые сделают бОльшую часть работы за вас. Не все операторы идеальны, но они хотя бы предсказуемы, так как работают по конкретным алгоритмам. В отличие от части инженеров )


  1. mikeGolovanov
    14.02.2025 14:20

    Подскажите пожалуйста, какие преимущества дает развертывание БД в k8s по сравнению с развертыванием в виртуальных машинах или на выделенных серверах?

    Стоит ли овчинка выделки?


    1. trublast
      14.02.2025 14:20

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

      Тогда ямл-девелопер описывает манифесты приложений проекта, основываясь на примитивах кубернетес. Deployment, Statefulsets. А когда нужна база - описывает базу прям там же, используя те же примитивы кубернетес. Например, пишет CR (Custom Resource) нужной базы.

      Таким образом нет двух мест, где нужно описать проект: для приложений - манифесты кубернетес, а для баз данных - terraform для виртуалок и ansible для конфигурации. Копировать и удалять проекты целиком становится проще. Создал новое окружение - базы там появились вместе с приложениями. Удалил окружение - базы исчезли вместе с окружением. Вернул окружение - базы вернулись обратно, и даже переналились из последнего бэкапа сами.

      Для локальной разработки тоже не нужно делать отдельные схемы. Если сейчас у вас база на виртуалке, а локально разработчик запускает ее в докере - это две разных конфигурации. А так он может запускать все в kind/minikube или в dev-кластере, и все будет одинаково.

      При этом никто вам не запрещает запускать контейнеры (поды) с базами на выделенных виртуалках или вообще на выделенных железных нодах. Или для дев-сред на виртуалках, а для прод - на железных нодах. Ведь нет никакой разницы для процесса, где он запущен, в контейнере или нет. У вас только меняется средство доставки конфигурации и бинарей. В случае с виртуалками это какой нибудь terraform + ansible/puppet. А в случае с контейнерами - бинари лежат в образе, а конфигурации - в configmap/secrets в etcd kubernetes. Если у вас уже есть kubernetes, то зачем вам еще terraform+ansible/puppet?


      1. mikeGolovanov
        14.02.2025 14:20

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

        В результате сложнее все получается, чем по классике.

        А terraform с ansible/pupet все равно нужны для разверывания нод для кластера k8s.


        1. trublast
          14.02.2025 14:20

          Утверждение про "сыроват" - это относительно очень, смотря с чем сравнивать.

          А вот для создания нод вполне можно обойтись без terraform и ansible/puppet. Ноды могут создаваться и управляться через machine controller manager прямо из кубернетес. Как в облаках, так в общем то и дя bare metal серверов актуально

          https://github.com/gardener/machine-controller-manager

          https://deckhouse.ru/products/kubernetes-platform/documentation/v1/kubernetes.html


          1. mikeGolovanov
            14.02.2025 14:20

            Спасибо, поизучаю.