Привет, Хабр! Я — Калашников Сергей, DevOps‑инженер в Центре Перспективных Разработок (ex. R&D) компании Bercut. Cегодня поделюсь опытом внедрения систем на базе Tarantool и Tarantool Vshard в оркестратор Kubernetes.

Tarantool — платформа, которая включает в себя in‑memory базу данных, а также встроенный сервер приложений. На базе этой платформы наша команда разрабатывает различные информационные системы, расширяя функциональность с применением языков Lua, Rust, C\C++.

Несмотря на все очевидные плюсы Tarantool, он не является тривиальным в части конфигурирования и bootstrap. Это привело нас к разработке Operator для Kubernetes, который обеспечивает развертывание и конфигурирование кластеров на базе Tarantool и Tarantool Vshard.


Немного контекста

Исторически сложилось так, что некоторые наши решения разрабатывались на основе модуля Tarantool Cartridge, который реализует обертку над Tarantool Vshard. Для этого модуля реализован Tarantool Operator CE, который мы, выполнив небольшие исправления, использовали для развертывания в Kubernetes.

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

Нам всегда было удобно внедрять решения в оркестратор Kubernetes, поскольку это ускоряет доставку фич и упрощает управление стендами. Поэтому перед отказом от Tarantool Cartridge следовало решить задачу развертывания в Kubernetes: так стало понятно, что необходим Operator, способный развернуть и сконфигурировать кластер Tarantool Vshard.

Анализ задачи

В Kubernetes «из коробки» реализованы контроллеры Pod, например такие, как Deployment(ReplicaSet) и StatefulSet.

На первый взгляд для управления контейнерами с Tarantool больше всего подходит StatefulSet: с использованием функциональности VolumeClaimTemplates мы можем организовать работу с PersistentVolume, а с применением Headless Service использовать механизм обнаружения каждого Pod, а не стандартную реализацию балансировки нагрузки от Kubernetes, для установления прямых соединений между Pod. Задействовать существующий контроллер Pod, который уже реализует всю нужную функциональность — звучит отлично, не так ли?

Было бы так, если бы речь не шла о своеобразном подходе к конфигурированию и механике управления компонентами кластера Tarantool Vshard.

Например, при развертывании каждому экземпляру назначаются два uuid: для replicaset и instance. Эти значения можно задать через переменные окружения TT_REPLICASET_UUID и TT_INSTANCE_UUID соответственно, но, если их не задать, Tarantool назначит их самостоятельно. Так как необходимо было реализовать в том числе и персистентное хранение данных, а эти идентификаторы Tarantool сохраняет на диске, нужно было решить проблему назначения этих идентификаторов на каждый Pod.

Также взгляните на алгоритм удаления реплики из репликасета Tarantool Vshard Storage. Согласно этому алгоритму перед удалением реплики необходимо выполнить некоторые взаимодействия со всеми экземплярами в репликасете. Но если мы используем StatefulSet с одной из доступных updateStrategy, то никак не можем контролировать процесс добавления или удаления Pod, а следовательно, не можем реализовать заданный алгоритм.

По итогу анализа мы пришли к идее создания собственного контроллера Pod, который включал бы в себя функциональность VolumeClaimTemplates, Headless Service, а также позволил бы гибко управлять каждым Pod по отдельности. Резюмируя, нам было нужно:

  • Где‑то хранить метаданные кластера, например uuid;

  • Взаимодействовать с экземплярами Tarantool;

  • Разработать собственный контроллер Pod, чтобы управлять каждым Pod по отдельности, не используя стандартные механизмы;

  • Разработать контроллер, который будет выполнять согласование кластера Tarantool Vshard https://www.tarantool.io/en/doc/2.11/book/admin/vshard_admin/.

Для реализации был выбран следующий стек:

Контроллер Pod

Итак, приступаем к разработке собственного контроллера Pod. Самое сложное, конечно, это выбрать название для нашего Custom Resource Definition. После длительных размышлений остановились на PodSet: отлично, теперь можем переходить к разработке структуры данных и самого контроллера.

Помимо основной функциональности контроллера, такой как создание Pod, Headless Service, Persistent Volume Claim, необходимо иметь возможность обозначить роль экземпляра в кластере Tarantool Vshard, такую как Storage или Router, потому что это влияет на способ конфигурирования.

Рассмотрим структуру данных, которая будет использоваться для PodSet Custom Resource Definition:

type PodSetSpec struct {
	// +kubebuilder:default=1
	// +kubebuilder:validation:Minimum=0
	Replicas *int32 `json:"replicas,omitempty"`

	// +optional
	// +kubebuilder:validation:Enum=storage;router
	VshardRole string `json:"vshardRole,omitempty"`

	// +kubebuilder:default=100
	ReplicasetWeight *int32 `json:"replicasetWeight,omitempty"`

	// +optional
	AppRoles []string `json:"appRoles,omitempty"`

	// +kubebuilder:default={}
	Labels map[string]string `json:"labels"`

	// +kubebuilder:default={}
	Annotations map[string]string `json:"annotations"`

	// +optional
	// +listType=atomic
	VolumeClaimTemplates []corev1.PersistentVolumeClaim `json:"volumeClaimTemplates,omitempty"`

	// +kubebuilder:validation:Required
	PodSpec corev1.PodSpec `json:"podSpec"`
}

Для упрощения эксплуатации и для отслеживания процессов, происходящих с Custom Resource, будем использовать conditions:

type PodSetStatus struct {
	Phase string `json:"phase"`

	Conditions []metav1.Condition `json:"conditions"`
}

После того, как структура данных готова, можно переходить к разработке контроллера. Представим упрощенный алгоритм Reconcile для PodSet Custom Resource:

Для простоты на этой блок‑схеме не представлены ветвления, ведущие к повтору Reconcile при возникновении ошибки на том или ином этапе, а также не представлены все операции по добавлению condition на Custom Resource.

Как видно из алгоритма, чтобы минимизировать количество запросов, в конце функции Reconcile мы завершаем процесс, а не отправляем его в очередь повторно:

func (r *PodSetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
//…
return ctrl.Result{}, nil 
}

Несмотря на это, нам необходимо отслеживать изменение PodSet Custom Resource и дочерних ресурсов, таких как Service и Pod. Выполним настройку manager для того, чтобы подписаться на желаемые события, а также предоставим пользователю возможность управлять количеством конкурентных процессов Reconcile:

func (r *PodSetReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		WithOptions(controller.Options{MaxConcurrentReconciles: *PodSetMaxConcurrentReconciles}).
		For(&vshardv1alpha1.PodSet{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
		Owns(&corev1.Service{}).
		Owns(&corev1.Pod{}).
		Complete(r)

В результате мы будем получать события по ресурсам PodSet в том случае, если они изменились, а также по ресурсам Service и Pod, которые содержат OwnerReference заданного PodSet.

Контроллер кластера Tarantool Vshard

Custom Resource Definition для нашего кластера, недолго думая, мы назвали Cluster. Этот Custom Resource должен хранить различные общие настройки: данные для аутентификации в подах Tarantool, данные для подключения к etcd, конфигурацию репликации и конфигурацию Vshard, а также выполнять согласование кластера.

Если контроллер Pod реализует управление Pod, то контроллер кластера должен выполнять согласование кластера: такие операции, как конфигурирование и bootstrap vshard.

Рассмотрим структуру данных, которая будет использоваться для нашего Cluster Custom Resource Definition:

type ClusterSpec struct {
	// kubernetes cluster domain name
	// +kubebuilder:validation:Required
	ClusterDomainName string `json:"clusterDomainName"`

	// режим текущего кластера. используется в режиме мультикластер
	// для деплоя репликаторов и healthcheck для внешнего load balancer.
	// если mode=slave, то репликаторы будут развернуты в пассивном режиме.
	// +kubebuilder:validation:Enum=master;slave
	// +kubebuilder:default=master
	Mode string `json:"mode,omitempty"`

	// данные секрета с пользователями и паролями для tarantool
	// +kubebuilder:default={}
	Secret *TarantoolSecret `json:"secret,omitempty"`

	// конфигурационные параметры репликации
	// https://www.tarantool.io/en/doc/latest/reference/configuration/
	// +kubebuilder:default={}
	ReplicationConfig *ReplicationConfig `json:"replicationConfig"`

	// общие конфигурационные параметры vshard.
	// https://github.com/tarantool/vshard/blob/0.1.31/vshard/cfg.lua#L384
	// https://www.tarantool.io/en/doc/2.11/reference/reference_rock/vshard/vshard_ref/
	// https://www.tarantool.io/en/doc/latest/reference/reference_rock/vshard/vshard_api/
	// +kubebuilder:default={}
	VshardConfig *VshardConfig `json:"vshardConfig"`

	// используется для поиска контейнера с Tarantool.
	// +kubebuilder:default=tarantool
	TarantoolContainerName string `json:"tarantoolContainerName,omitempty"`

	// используется для создания сервиса и подключению к экземпляру Tarantool
	// +kubebuilder:default=3301
	TarantoolPort *int32 `json:"tarantoolPort,omitempty"`

	// используется для повторения некоторых операций при обработке CR,
	// например при ожидании готовности pod после создания или проверки доступности tarantool
	// +kubebuilder:default=30
	// +kubebuilder:validation:Minimum=1
	CommonRetryOnErrorTimeoutSeconds *int32 `json:"commonRetryOnErrorTimeoutSeconds"`

	// используется для задержки при повторении некоторых операций при обработке CR,
	// например при ожидании готовности pod после создания или проверки доступности tarantool
	// +kubebuilder:default=1
	// +kubebuilder:validation:Minimum=1
	CommonRetryOnErrorDelaySeconds *int32 `json:"commonRetryOnErrorDelaySeconds"`

	// используется для подключения к etcd, в котором будет хранится описание кластера
	// +kubebuilder:validation:Required
	Etcd *EtcdConfig `json:"etcd"`

Таким образом, Cluster Custom Resource выступает хранилищем общих настроек для нашего кластера Vshard. Мы можем получить этот Custom Resource во время выполнения согласования PodSet и использовать нужные настройки для обращения в etcd, tarantool и т. д.

Рассмотрим алгоритм Reconcile для Cluster Custom Resource:

Опять же, для минимизации количества запросов в конце Reconcile не будем отправлять запрос в очередь повторно, а завершим его. Но в случае с Cluster нам необходимо отслеживать изменения для каждого PodSet. Например, если произошел downscale PodSet или изменился состав набора PodSet, то необходимо среагировать на эти изменения и запустить логику согласования кластера повторно.

В данном случае мы не можем использовать опцию Owns при настройке manager, так как Cluster не контролирует набор PodSet с использованием OwnerReference, поэтому будем использовать функциональность Watches с EnqueueRequestsFromMapFunc.

Взаимосвязи между Custom Resource настраиваются через аннотации: в аннотациях мы можем задать имя кластера, различную сервисную информацию и т. д. В данном случае, чтобы привязать PodSet к кластеру, используется аннотация tarantool.bercut.com/cluster-name, которая должна содержать имя Cluster Custom Resource. Если на уровне контроллера Cluster мы получим событие по PodSet, с использованием аннотации мы сможем определить, относится ли этот PodSet к нашему кластеру или нет.

Рассмотрим настройку manager для Cluster контроллера:

func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		WithOptions(controller.Options{MaxConcurrentReconciles: *ClusterMaxConcurrentReconciles}).
		For(&vshardv1alpha1.Cluster{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
		Watches(&vshardv1alpha1.PodSet{}, handler.EnqueueRequestsFromMapFunc(r.PodSetEventHandlerFunc)).
		Complete(r)
}

Ключевым в этом примере является настройка Watches для PodSet с использованием handler.EnqueueRequestsFromMapFunc. Рассмотрим функцию PodSetEventHandlerFunc отдельно:

func (r *ClusterReconciler) PodSetEventHandlerFunc(ctx context.Context, obj client.Object) []reconcile.Request {
	podSet, ok := obj.(*vshardv1alpha1.PodSet)

	if !ok {
		return []ctrl.Request{}
	}

	log := utils.GetNamedLoggerFromContext(ctx, reflect.TypeOf(r).Elem().Name()).WithValues("podset", podSet.GetName())

	log.Info("recieved")

	clusterName, err := podSet.GetClusterName()

	if err != nil {
		log.Error(err, fmt.Sprintf("error while getting cluster name for podset %s", podSet.Name))
		return []ctrl.Request{}
	}

	if !podSet.IsVshard() {
		log.Info(fmt.Sprintf("podset is not vshard, skip adding to worker queue for cluster %s", clusterName))
		return []ctrl.Request{}
	}

	log.Info(fmt.Sprintf("added to worker queue for cluster %s", clusterName))

	return []ctrl.Request{
		{
			NamespacedName: apitypes.NamespacedName{
				Namespace: clusterName,
				Name:      podSet.GetNamespace(),
			},
		},
	}
}

Тем самым мы был обеспечен триггер Reconcile для Cluster Custom Resource в том случае, если по зависимым PodSet возникают события.

Архитектура решения

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

Чтобы снизить вариативность мы явно определили, что метаданные кластера хранятся только в etcd, поэтому без etcd решение запустить невозможно.

При выборе режима работы Operator — cluster wide или namespaced — был выбран второй вариант, чтобы Operator гарантированно обслуживал отдельный namespace: это позволяет снизить нагрузку на Operator Pod и облегчает его эксплуатацию.

Для контроллера PodSet мы реализовали алгоритмы добавления, удаления реплик, проведения выборов в репликасете Tarantool Vshard Storage, что позволило динамически управлять кластером Tarantool Vshard. Без разработки контроллера это было бы недостижимо.

Доставка

Для Operator был реализован типовой helm chart, который можно использовать как отдельно, так и включая в helm chart'ы бизнес‑систем в качестве dependency.

Для доставки приложений в Kubernetes мы применяем подход GitOps на базе ArgoCD и системы с Tarantool Vshard не стали для нас исключением. Однако просто синхронизировать ресурсы недостаточно, хочется еще и отслеживать их статус: в ArgoCD есть понятие «Application Health», которое отображает текущий статус доставляемых ресурсов. Мы пользуемся этим значением в различных ситуациях, например, в пайплайнах и оповещениях.

На каждом из наших Custom Resource устанавливается поле Phase в зависимости от существующих conditions и позволяет нам отслеживать статус ресурсов: для стандартных контроллеров Pod в ArgoCD реализованы стандартные healthcheck, но так как мы использовали свой контроллер, они не работают. Однако ArgoCD позволяет создавать пользовательские healthcheck на языке Lua, и мы воспользовались этой функциональностью:

resource.customizations.health.vshard.tarantool.bercut.com_Cluster: |
  local hs = {}

  if obj.status ~= nil then
    if obj.status.phase == "Ready" then
      hs.status = "Healthy"
      hs.message = "Cluster is ready"
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "Cluster is progressing"
  return hs

resource.customizations.health.vshard.tarantool.bercut.com_PodSet: |
  local hs = {}

  if obj.status ~= nil then
    if obj.status.phase == "Ready" then
      hs.status = "Healthy"
      hs.message = "PodSet is ready"
      return hs
    end
  end

  hs.status = "Progressing"
  hs.message = "PodSet is progressing"
  return hs

И теперь при запросе состояния приложения в ArgoCD мы получаем валидные данные о состоянии всех ресурсов.

Что дальше?

Сейчас перед нашей командой стоит задача по организации 99.999 уровня доступности для одной из систем с Tarantool Vshard. Помимо прочих доработок, Operator будет дорабатываться для поддержки межкластерной репликации данных и динамического определения состояния кластера для передачи информации на внешний балансировщик.

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