Привет, Хабр. Меня зовут Валерий Поздяев, я Go-разработчик из команды Billing API SDK в компании Netcracker. Наша команда делает набор инструментов для продукта Cloud Billing. Мы отвечаем за версионирование и лайфсайкл API, настройку и запуск REST/gRPC сервера, аутентификацию и авторизацию, аудит, трассировки и многое другое. Все это на Go.
Давайте поговорим про конфиги!
Если у ваших приложений есть конфигурационные файлы, то вам должна быть знакома такая ситуация: создаете приложение, создаете конфигурационный файл, документируете его и через какое-то время понимаете, что нужно добавить еще настроек. Старые настройки теперь уже не отвечают всем требованиям, да и в целом структуру лучше поменять.
Что делать? Если формат конфигурации никогда не менять, то с годами конфиг-файл превратится в горы “исторически сложилось”. А если его менять... В таком случае вам всегда придется следить за тем, что конфиги подходят версии продукта, которую вы устанавливаете заказчику. Парни из эксплуатации, клиенты и много кто еще вас за это особо любить не будут.
Решение всех этих проблем — мульти-версионные конфигурации, которые мы подсмотрели у Kubernetes, развили и применили. А теперь мы готовы рассказать и вам, как это работает.
Пару слов о Hub-Spoke модели
Изначально Hub-Spoke модель являлась архитектурным паттерном топологии сети — не только компьютерной, но и, например, транспортной. Однако этот паттерн нашел хорошее применение в версионировании объектов. В частности, Kubernetes использует его для версионирования своих API и ресурсов.
Hub-Spoke модель предполагает, что есть некоторая hub-версия объекта, которая напрямую не может быть использована пользователями, но которая связана со всеми остальными spoke-версиями. Hub-версия является актуальной версией, которая используется в нашем коде. Когда в структуру конфигурации вносятся изменения, от hub "отводится" новая spoke-версия, идентичная hub'у. Чтобы обеспечить совместимость версий, мы должны уметь конвертировать любую spoke-версию в hub и наоборот.
Нам не надо беспокоиться о том, с какой версией конфигурации приходит пользователь нашего сервиса. Все, что нам нужно, это:
определить версию конфига;
сконвертировать конфиг из spoke-версии в hub;
использовать hub-версию конфига в нашем коде.
Таким образом, наши Cloud Billing компоненты могут безболезненно апгрейдить версии конфигов внутри, не переводя все конфигурационные файлы клиентов на новые версии.
Приятный бонус: все фиксы, сделанные в новой версии, автоматически появляются у пользователей старых версий конфигураций.
Добавляем конфигу щепотку версионируемости
Представим, что в нашем приложении есть конфигурационный файл, описывающий роли и ресурсы, которые доступны каждой из них:
roles:
- name: "account_ro"
rules:
- verbs: [ "Get", "List" ]
apiGroups: [ "billing.netcracker.com" ]
resources: [ "customer", "account", "address" ]
- name: "account_rw"
rules:
- verbs: [ "Get", "List", "Create", "Update" ]
apiGroups: [ "billing.netcracker.com" ]
resources: [ "customer", "account", "address" ]
- name: "dev"
rules:
- verbs: [ "Get", "List" ]
apiGroups: [ "billing.netcracker.com" ]
resources: [ "*" ]
Для генерации функций конвертации между версиями воспользуемся библиотекой Kubernetes apimachinery и генераторами Go-кода conversion-gen, deepcopy-gen и defaulter-gen из kubernetes/code-generator.
Чтобы инструменты Kubernetes распознали наш конфиг, преобразуем его в kubernetes-like вид:
apiVersion: config.billing.netcracker.com/v1alpha1
kind: RolesConfig
spec:
roles:
- name: "account_ro"
rules:
- verbs: [ "Get", "List" ]
apiGroups: [ "billing.netcracker.com" ]
resources: [ "customer", "account", "address" ]
- name: "account_rw"
rules:
- verbs: [ "Get", "List", "Create", "Update" ]
apiGroups: [ "billing.netcracker.com" ]
resources: [ "customer", "account", "address" ]
- name: "dev"
rules:
- verbs: [ "Get", "List" ]
apiGroups: [ "billing.netcracker.com" ]
resources: [ "*" ]
Где
apiVersion —идентифицирует версию конфига. Состоит из API группы (`config.billing.netcracker.com`) и непосредственно названия spoke-версии (`v1alpha1`);
kind — идентификатор конфига;
spec —содержит контент конфига.
Чтобы работать с конфигурацией в коде, добавим Go-структуры, которые будут хранить данные конфига. Для этого в проекте создаем два пакета для hub- и spoke- версии. Выглядит это так:
В проекте создаем два пакета для hub и spoke-версии:
├── hub
├── v1alpha1
Примечание: некоторые файлы будут идентичными (за исключением названия пакета) и в hub-, и в spoke-пакетах. В таком случае я буду приводить пример только какого-то одного файла.
В hub-пакет добавляем файл types.go со следующим содержимым:
package hub
import meta "k8s.io/apimachinery/pkg/apis/meta/v1"
// RolesConfig contains a list of roles for authorization
type RolesConfig struct {
// TypeMeta describes an individual object in an API response or request
// with strings representing the type of the object and its API schema version.
// Structures that are versioned or persisted should inline TypeMeta.
meta.TypeMeta `json:",inline"`
// ObjectMeta is metadata that all persisted resources must have,
meta.ObjectMeta `json:"metadata,omitempty"`
// RolesSpec is a list of existing RBAC Roles.
RolesSpec RolesSpec `json:"spec,omitempty"`
}
type RolesSpec struct {
Roles []Role `json:"roles,omitempty"`
}
// Role contains rules that represent a set of permissions.
type Role struct {
// Name is unique role name.
// +kubebuilder:validation:Required
Name string `json:"name,omitempty"`
// IsAnonymous identify that role is allowed for anonymous user - user.
// +kubebuilder:validation:Optional
IsAnonymous bool `json:"anonymous"`
// Rules is set of rules available for the role.
// +kubebuilder:validation:Required
Rules RuleSet `json:"rules"`
}
// RuleSet contains set of rules for a role
type RuleSet []Rule
// Rule is the list of actions the subject is allowed to perform on resources.
type Rule struct {
// +kubebuilder:validation:Required
Verbs []string `json:"verbs,omitempty"`
// +kubebuilder:validation:Required
Groups []string `json:"apiGroups,omitempty"`
// +kubebuilder:validation:Required
Kinds []string `json:"resources,omitempty"`
}
Аналогично добавляем types.go в v1alpha1 пакет. Так как у нас пока только одна версия конфигурации, то hub и v1alpha1 идентичны.
Добавляем doc.go с метаданными нашего конфига в пакет hub:
// +k8s:deepcopy-gen=package,register
// +groupName=config.billing.netcracker.com
package hub
const (
Version = "hub"
Group = "config.billing.netcracker.com"
Kind = "RolesConfig"
)
Комментарий `+k8s:deepcopy-gen` задает настройки для deepcopy-gen плагина.
package — плагин сгенерирует DeepCopy() метод для всех типов в types.go
register — зарегистрирует сгенерированные методы в схеме.
Такой же файл нам нужен и в пакете v1alpha1:
// +k8s:deepcopy-gen=package,register
// +k8s:conversion-gen=nrm.netcracker.cloud//billing-api-sdk/pkg/sdk/security/authorization/hub
// +groupName=config.billing.netcracker.com
package v1alpha1
const (
Version = "v1alpha1"
Group = "config.billing.netcracker.com"
Kind = "RolesConfig"
)
В `+k8s:conversion-gen` нужно указать путь до hub-пакета.
Для генерации функций конверсии между версиями в Kubernetes используют понятие схемы. В ней регистрируются Go-структуры каждой версии, а также функции SetDefault и DeepCopy. Чтобы зарегистрировать наши версии, добавим в hub и v1alpha1 файл register.go:
// +k8s:deepcopy-gen=package
// +groupName=config.billing.netcracker.com
package v1alpha1
import (
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
// SchemeGroupVersion is group version used to register these objects
SchemeGroupVersion = schema.GroupVersion{Group: Group, Version: Version}
// SchemeBuilder is used to add go types to the GroupVersionKind scheme
SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion}
localSchemeBuilder = runtime.NewSchemeBuilder()
)
func init() {
SchemeBuilder.SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypeWithName(SchemeBuilder.GroupVersion.WithKind(Kind), &RolesConfig{})
meta.AddToGroupVersion(s, SchemeBuilder.GroupVersion)
return RegisterConversions(s)
})
}
После всех предыдущих шагов должна получиться следующая структура файлов:
├── hub
│ ├── doc.go
│ ├── register.go
│ ├── types.go
├── v1alpha1
│ ├── doc.go
│ ├── register.go
│ ├── types.go
Теперь воспользуемся плагинами Kubernetes и сгенерируем функции конверсии между версиями. Сначала установим следующие плагины:
"k8s.io/code-generator/cmd/conversion-gen"
"k8s.io/code-generator/cmd/deepcopy-gen"
"k8s.io/code-generator/cmd/defaulter-gen"
Можно установить каждый плагин, используя go install команду.Затем вызовем каждый плагин, передав в них пути до hub и v1alpha1:
conversion-gen --input-dirs $(PATH_TO_SPOKE_CONFIG) --output-package $(ROOT_PKG) --output-file-base zz_generated.conversion --output-base $(CURDIR) --go-header-file header.go.txt -v 1
deepcopy-gen --input-dirs $(PATH_TO_SPOKE_CONFIGS),$(PATH_TO_HUB_CONFIG) --output-package $(ROOT_PKG) --output-file-base zz_generated.deepcopy --output-base $(CURDIR) --go-header-file header.go.txt -v 1
defaulter-gen --input-dirs $(PATH_TO_SPOKE_CONFIGS) --output-package $(ROOT_PKG) --output-file-base zz_generated.default --output-base $(CURDIR) --go-header-file header.go.txt -v 1
После работы плагинов должна получиться следующая структура файлов:
├── hub
│ ├── doc.go
│ ├── register.go
│ ├── types.go
│ ├── zz_generated.deepcopy.go
│ ├── zz_generated.default.go
├── v1alpha1
│ ├── doc.go
│ ├── register.go
│ ├── types.go
│ ├── zz_generated.conversion.go
│ ├── zz_generated.deepcopy.go
│ ├── zz_generated.default.go
Отлично, теперь можно конвертировать нашу конфигурацию из версии v1alpha1 в hub.
Делаем API для загрузки конфигурации из файла
Предоставим нашим пользователям возможность загружать конфигурацию из файла в объект RolesConfig из hub. Назовем файл config.go и разместим его рядом с пакетами hub и v1alpha1. Необязательно делать одну функцию загрузки на тип. Так как все конфигурационные типы регистрируются в глобальной схеме и реализуют интерфейс runtime.Object, можно написать одну функцию загрузки на все виды конфига, которая будет принимать объект конфига и заполнять его из файла.
// getConfigScheme returns a new instance of runtime.Schema with registered authorization config.
func getConfigScheme() (*runtime.Scheme, error) {
scheme := runtime.NewScheme()
err := hub.SchemeBuilder.AddToScheme(scheme)
if err != nil {
return nil, err
}
err = v1alpha1.SchemeBuilder.AddToScheme(scheme)
if err != nil {
return nil, err
}
return scheme, nil
}
// LoadFromFile reads roles configuration from provided config file and converts it to hub representation.
func LoadFromFile(filePath string) (*hub.RolesConfig, error) {
// get conversion schema
scheme, err := getConfigScheme()
if err != nil {
return nil, err
}
// read file content
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("unable to read roles configuration: %w", err)
}
// unmarshal it to meta.TypeMeta to get metadata
typeMeta := &meta.TypeMeta{}
err = yaml.Unmarshal(data, typeMeta)
if err != nil {
return nil, err
}
// validate that configuration in file has the same kind
if typeMeta.Kind != hub.Kind {
return nil, fmt.Errorf("unable to read roles configuration: invalid config kind '%s', only '%s' supported", typeMeta.Kind, hub.Kind)
}
// create new spoke with same version as in config
storedObject, err := scheme.New(typeMeta.GroupVersionKind())
if err != nil {
return nil, err
}
// unmarshall config to spoke version
err = yaml.Unmarshal(data, storedObject)
if err != nil {
return nil, err
}
// set defaults for spoke. If you added defaults.
scheme.Default(storedObject)
// convert spoke to hub
hubObj := &hub.RolesConfig{}
err = scheme.Convert(storedObject, hubObj, nil)
if err != nil {
return nil, err
}
// set default for hub, if required
scheme.Default(hubObj)
return hubObj, nil
}
Метод getConfigScheme нужен для получения Kubernetes-схемы, в которой зарегистрированы все версии нашей конфигурации. Получив эту схему, мы сможем определить версию из переданного в LoadFromFile-файла. По версии создаем экземпляр объекта RolesConfig нужной нам версии и анмаршаллим содержимое файла в этот объект. С помощью схемы конвертируем объект в hub-версию объекта, проставляем дефолтные значения и возвращаем как результат.
Необязательно создавать новую схему для загрузки одного типа конфига. Вы можете иметь глобальную схему, в которую зарегистрируете все конфиги приложения, либо передавать схему напрямую в метод загрузки.
Как добавить новую spoke-версию
Если в структуру конфигурации необходимо внести изменения, это делается в hub-версии. После внесенных изменений нужно:
Создать пакет для новой spoke версии (например, v1alpha2) и скопировать в него обновленный types.go из hub;
По аналогии с v1alpha1 добавить doc.go, register.go и default.go;
Вызвать conversion-gen, deepcopy-gen и defaulter-gen плагины для всех трех версий (hub, v1alpha1, v1alpha2).
Слишком серьезные изменения: что делать?
А что, если в конфигурацию внесены серьезные изменения, и conversion-gen плагин не может автоматически сконвертировать конфигурацию между версиями? Действительно, такая ситуация может возникнуть... В этом случае нужно будет самостоятельно реализовать функцию конверсии и разместить ее в пакете spoke-версии, с которой возникли проблемы.
Назовем файл zz_generated.manual.go, чтобы отличить от остальных сгенерированных файлов. Наш код будет выглядеть вот так:
package v1alpha1
import (
hub "billing-api-sdk.git/pkg/sdk/db/hub"
conversion "k8s.io/apimachinery/pkg/conversion"
)
func Convert_hub_RolesSpec_To_v1alpha1_RolesSpec(in *hub.RolesSpec, out *RolesSpec, s conversion.Scope) error {
// some custom conversion logic here...
}
Какие еще улучшения можно сделать
Чтобы упростить работу с версионируемыми конфигами, мы можем сделать следующие 4 вещи.
Добавить возможность загружать сразу несколько конфигураций из директории и мержить их в одну
Зачастую конфигурация имеет разные настройки. Какая-то часть приходит по умолчанию с продуктом, какая-то кастомизируется. Если загружать сразу несколько фрагментов конфигурации и объединять их, то части конфигурации можно поделить на дефолтную, environment-специфичную и другие типы в зависимости от выбранного критерия.
Для этого нам надо четко определить, по каким критериям мы сможем объединять части конфигурации. Kubernetes для этого использует вот такую стратегию. Важно иметь одинаковые правила мержа для всех конфигов, так как зачастую люди, которые заполняют конфигурацию, не знают всех тонкостей их мержа. Поэтому рекомендуем просто объединять листы объектов по полю Name и возвращать ошибку, если есть конфликты.
Далее, можно найти все файлы в директории, имеющие расширение yaml/yml, найти среди них все файлы подходящего kind’a, анмаршаллить их в spoke-версии, смержить версии, а далее конвертировать в hub.
Добавить возможность загружать несколько конфигураций с условиями
Kubernetes предоставляет возможность использовать селекторы для работы с объектами, что помогает улучшить загрузку из директории. Используя селектор, вы можете пометить файлы с конфигурациями разными лейблами и загружать все конфигурации с предоставленным лейблом. Это позволит хранить множество конфигураций под разные окружения в одной директории и очень быстро менять профили.
Добавить валидацию конфигураций через CRD
Так как структура версионируемых конфигураций соответствует CustomResource из Kubernetes, мы можем сгенерировать CustomResourceDefinition и с помощью него валидировать контент конфигурации. Подробней про валидации с помощью CRD можно прочесть здесь.
Сделать централизованный сервис для конфигурации
Так как конфигурация по сути представляет из себя публичное API, имеет смысл сделать ее централизованно. Регистрируя все конфигурационные типы и версии в одной схеме, можно сгенерировать документацию, а также составить дельту.
Выводы
Идеальную систему, которая не требовала бы никаких доработок, построить невозможно. И конфиги — не исключение.
Используя Hub-Spoke модель версионирования, можно безболезненно делать правки в конфигурационных файлах, обеспечивая обратную и прямую совместимость. Это позволит вам быстро и удобно адаптироваться под требования окружения без лишних усложнений в теле конфигураций и путаницы в версиях.
Поддержка обратной и прямой совместимости конфигурации делает жизнь легче!