Вступление
Мне представилось решать интересную задачу. Необходимо реализовать настраиваемый SaaS, где пользователь может выбрать галочками нужные ему модули и щелкнуть кнопку готово. После этого для пользователя должен быть создан отдельный кластер Kubernetes (или отдельный namespace в общем кластере в зависимости от тарифного плана) с выбранными модулями, которые представляют из себя наборы микросервисов.
В этой статье я хочу осветить мой GitOps вариант реализации этой задачи и показать, на что способен ArgoCD и Terraform.
Предисловие
В этой статье я часто выражаюсь понятием “приложение” (application) из терминологии ArgoCD, которое обозначает группу ресурсов k8s. Для упрощения, можно считать, что это - микросервис под управлением ArgoCD.
Пример
Рассмотрим пример:
Имеем Module1 состоящих из двух приложений (app1, app2) и Module2 из одного (app3).
У пользователя №1 выбрано два модуля (№1 и №2 соответственно), всего в его системе будет работать три приложения, а вот пользователь №2 решил, что ему достаточно только модуля №1, поэтому в его распоряжении только два приложения.
Выбор инструмента
Думаю, постановка проблемы стала понятнее, приступаем к инструментам реализации.
В голову сразу пришло два решения: императивное (на PowerShell скриптах) и декларативное. Коллега посоветовал посмотреть в сторону ArgoCD, и я начал копать документацию.
Было быстро запущено первое рабочее приложение, но хотелось создавать приложения пачками, и как назло, мысли программиста нашептывали – давай возьмём API ArgoCD и будем добавлять их циклом. Такой план имеет право на существование, но сегодня мы постараемся реализовать подобный механизм декларативно.
Что нам предлагает ArgoCD?
В ArgoCD есть такая замечательная вещь, как ApplicationSet. Она позволяет нам автоматически создавать Application с помощью генераторов.
Список генераторов:
List: Фиксированный список значений.
Cluster: Позволяет получать информацию о кластерах добавленных в ArgoCD.
Git: Можем работать с папками и файлами из git репозитория.
Matrix: Позволяет комбинировать значения параметры полученных из нескольких генераторов.
Merge: Позволяет объединять параметры полученных из нескольких генераторов.
SCM Provider: Предоставляет автоматически находить git репозитории.
Pull Request: Предоставляет информацию о Pull Request.
Cluster Decision Resource: Позволяет вытягивать данные из ресурсов Kubernetes.
Первым делом я попробовал реализовать эту задачу с помощью Cluster генератора. На каждый кластер можно навесить labels со списком модулей, но тогда не получится поселить нескольких пользователей в одном кластере, поэтому от этого генератора пришлось уйти к другому.
На помощь пришел git генератор. Что может быть проще, чем создавать по файлу конфигурации на каждый модуль клиента и, таким образом, разворачивать приложение?!
Давайте попробуем это реализовать.
Все примеры можно посмотреть тут (каждый пример в отдельной ветке).
Итерация №1
Описываем ApplicationSet.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: app1-appset # Наш ApplicationSet описывает одно приложение
namespace: argocd
spec:
generators:
- git: # Файлы конфигурации
repoURL: https://github.com/1kvin/argocd-module-saas.git
revision: implementation1
files:
- path: "cluster-config/module1/*.json" # Берём все файлы .json из папки конфигурации нашего модуля
template:
metadata:
name: 'app1-{{destination.namespace}}' # Названия приложений не должны пересекатся внутри ArgoCD, поэтому делаем их уникальными
spec:
project: default
source: # Вставляем параметры, откуда берём приложение
repoURL: '{{source.repoURL}}'
targetRevision: '{{source.targetRevision}}'
path: '{{source.path}}'
destination: # Вставляем параметры развертывания приложения
server: '{{destination.server}}'
namespace: '{{destination.namespace}}'
syncPolicy:
automated:
prune: true # Автоматически удаляем
selfHeal: true # И востанавливаем
syncOptions:
- CreateNamespace=true # Создаём namespace, если его нет
И конфиг файл:
{
"destination":
{
"server" : "https://kubernetes.default.svc",
"namespace" : "user1"
},
"source":
{
"repoURL" : "https://github.com/argoproj/argo-cd.git",
"path" : "applicationset/examples/git-generator-files-discovery/apps/guestbook",
"targetRevision" : "HEAD"
}
}
Получаем:
Попробуем добавить второго пользователя.
Для этого необходимо создать ещё один JSON файл.
{
"destination":
{
"server" : "https://kubernetes.default.svc",
"namespace" : "user2"
},
"source":
{
"repoURL" : "https://github.com/argoproj/argo-cd.git",
"path" : "applicationset/examples/git-generator-files-discovery/apps/guestbook",
"targetRevision" : "HEAD"
}
}
Результат:
Логика работы получается такая: ApplicationSet берёт из указанной папки все JSON файлы и на каждый из них создаёт отдельный Application.
Плюсы:
Ура, не надо создавать/удалять Application на каждый чих пользователя, это сделает ArgoCD автоматически.
Минусы:
Нужно создавать по ApplicationSet на каждое приложение.
Нужно дублировать конфигурацию приложений.
Итерация №2
Попробуем отделить конфигурацию приложения от параметров развёртывания (server+ namespace). Для этого с помощью List генератора определим конфигурацию наших приложений в модуле, а дальше скомбинируем их с параметрами развёртывания с помощью Matrix генератора.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: module1-appset # Наш ApplicationSet описывает один модуль
namespace: argocd
spec:
generators:
# Комбинируем файлы конфигурации и список приложений
- matrix:
generators:
# Файлы конфигурации
- git:
repoURL: 'https://github.com/1kvin/argocd-module-saas.git'
revision: implementation2
files:
- path: cluster-config/module1/*.json
# Список приложений в модуле и их параметров
- list:
elements:
- app-name: app1
app-repoURL: https://github.com/argoproj/argo-cd.git
app-path: applicationset/examples/git-generator-files-discovery/apps/guestbook
app-targetRevision: HEAD
- app-name: app2
app-repoURL: https://github.com/argoproj/argocd-example-apps/
app-path: guestbook
app-targetRevision: HEAD
template:
metadata:
name: '{{app-name}}-{{destination.namespace}}'
spec:
project: default
source: # Берём значение из list generator
repoURL: '{{app-repoURL}}'
targetRevision: '{{app-targetRevision}}'
path: '{{app-path}}'
destination: # Берём значение из git generator
server: '{{destination.server}}'
namespace: '{{destination.namespace}}'
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Как же похудел наш JSON файл!
{
"destination":
{
"server" : "https://kubernetes.default.svc",
"namespace" : "user1"
}
}
Для красивой картинки я добавлю аналогично модуль №2 с одним приложением (app3) и поселю его первому пользователю.
Теперь у первого пользователя есть оба модуля, а у второго только один.
Если я захочу подключить пользователю новый модуль, мне достаточно создать новый файл и запушить его в гит.
Плюсы:
Управление модулем происходит через одни файл.
Конфигурация приложения отделена от параметров развёртывания.
Минусы:
Для каждого модуля нужно создавать свой ApplicationSet.
Нужно описывать каждое приложение в ApplicationSet.
Итерация №3
Попробуем избавиться от статического List генератора и заменить его на git генератор.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: module1-appset # Наш ApplicationSet описывает один модуль
namespace: argocd
spec:
generators:
# Соеднияем файлы конфигурации и список приложений
- matrix:
generators:
# Файлы конфигурации
- git:
repoURL: 'https://github.com/1kvin/argocd-module-saas.git'
revision: implementation3
files:
- path: cluster-config/module1/*.json
# Список приложений в модуле и их параметров
- git:
repoURL: 'https://github.com/1kvin/argocd-module-saas.git'
revision: implementation3
files:
- path: apps/module1/*.json
goTemplate: true
template:
metadata:
name: '{{.appName}}-{{.destination.namespace}}'
spec:
project: default
source:
repoURL: '{{.appRepoURL}}'
targetRevision: '{{.appTargetRevision}}'
path: '{{.appPath}}'
destination:
server: '{{.destination.server}}'
namespace: '{{.destination.namespace}}'
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Возможно, вы заметили строчку goTemplate: true и то, что перед переменными добавилась точка. Это необходимо из-за того, что два одинаковых генератора вызывают коллизию, и единственный способ от неё избавиться, переключится на использование Go шаблонов.
Теперь конфигурация приложения переехала в git:
{
"appName" : "app1",
"appRepoURL" : "https://github.com/argoproj/argo-cd.git",
"appPath" : "applicationset/examples/git-generator-files-discovery/apps/guestbook",
"appTargetRevision" : "HEAD"
}
Красота! Мы избавились от статических компонентов в нашем шаблоне! Или нет? Осталось ещё название модуля ☹
Но не беда, сейчас мы и от него избавимся!
Как?
Добавим ещё один Matrix генератор!
Итерация №4
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet # Наш ApplicationSet описывает все модули
metadata:
name: modules-appset
namespace: argocd
spec:
goTemplate: true
generators:
- matrix:
generators:
# Список модулей
- list:
elements:
- moduleName: module1
- moduleName: module2
# Соеднияем файлы конфигурации и список приложений
- matrix:
generators:
# Файлы конфигурации
- git:
repoURL: 'https://github.com/1kvin/argocd-module-saas.git'
revision: implementation4
files:
- path: 'cluster-config/{{.moduleName}}/*.json'
# Список приложений в модуле и их параметров
- git:
repoURL: 'https://github.com/1kvin/argocd-module-saas.git'
revision: implementation4
files:
- path: 'apps/{{.moduleName}}/*.json'
template:
metadata:
name: '{{.appName}}-{{.destination.namespace}}'
spec:
project: default
source:
repoURL: '{{.appRepoURL}}'
targetRevision: '{{.appTargetRevision}}'
path: '{{.appPath}}'
destination:
server: '{{.destination.server}}'
namespace: '{{.destination.namespace}}'
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Отлично! Оно работает! Данная конфигурация позволяет нам передавать параметры из List генератора в Git, но статические компоненты всё ещё остались.
У git генератора есть два режима работы:
Files – с ним мы работали всё это время и успешно извлекали содержимое файлов.
Directories – позволяет нам вытаскивать путь до каталогов.
Используем новый режим на практике.
Итерация №5
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet # Наш ApplicationSet описывает все модули
metadata:
name: modules-appset
namespace: argocd
spec:
goTemplate: true
generators:
- matrix:
generators:
# Список модулей берём из названий папок
- git:
repoURL: 'https://github.com/1kvin/argocd-module-saas.git'
revision: implementation5
directories:
- path: apps/*
# Соеднияем файлы конфигурации и список приложений
- matrix:
generators:
# Файлы конфигурации
- git:
repoURL: 'https://github.com/1kvin/argocd-module-saas.git'
revision: implementation5
files:
- path: 'cluster-config/{{.path.basename}}/*.json'
# Список приложений в модуле и их параметров
- git:
repoURL: 'https://github.com/1kvin/argocd-module-saas.git'
revision: implementation5
files:
- path: 'apps/{{.path.basename}}/*.json'
template:
metadata:
name: '{{.appName}}-{{.destination.namespace}}'
spec:
project: default
source:
repoURL: '{{.appRepoURL}}'
targetRevision: '{{.appTargetRevision}}'
path: '{{.appPath}}'
destination:
server: '{{.destination.server}}'
namespace: '{{.destination.namespace}}'
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Результат:
Теперь в нашем шаблоне нет критических статических полей, всё генерируется исходя из состояния git репозитория.
Terraform
Terraform’ом я пользуюсь второй раз в своей жизни, поэтому я уверен на 100%, что описываю решение не самым изящным образом.
Возложим на Terraform следующие задачи:
Генерация файлов конфигурации для ArgoCD.
Поднятие ресурсов инфраструктуры (в нашем случае базы данных).
Инициализация namespace в k8s для пользователя.
Если для первой задачи Terraform является не самым оптимальным вариантом, то со второй и третьей задачей он справится на ура!
Опишем нашу идеальную конфигурацию одним файлом:
[
{
"user" : "user1",
"server" : "https://kubernetes.default.svc",
"namespace" : "user1",
"modules" : [
"module1",
"module2"
]
},
{
"user" : "user2",
"server" : "https://kubernetes.default.svc",
"namespace" : "user2",
"modules" : [
"module1"
]
}
]
У нас есть массив с параметрами каждого пользователя.
Создадим для пользователей отдельные namespace в кубике.
locals {
users_configuration_json = jsondecode(file("${path.module}/configs/users-configuration.json"))
}
resource "kubernetes_namespace" "user_namespaces" {
for_each = { for u in local.users_configuration_json : u.namespace => u.namespace}
metadata {
name = each.value
}
}
В целом ничего сложного, считываем данные из JSON и создаём namespace. Поехали дальше, попробуем создать файлы конфигурации для ArgoCD.
Добавим немного данных:
locals {
users_configuration_json = jsondecode(file("${path.module}/configs/users-configuration.json"))
users_configuration_combinations = distinct(flatten([
for cfg in local.users_configuration_json : [ # Проходимся по пользователям
for mdl in cfg.modules : { # Модули пользователя
module = mdl
user = cfg.user
server = cfg.server
namespace = cfg.namespace
}
]
]))
}
Будем создавать файлы используя integrations/github провайдер:
resource "github_repository_file" "module_setup" {
for_each = { for t in local.users_configuration_combinations : "${t.user} ${t.module}" => t }
repository = "argocd-module-saas"
branch = "main"
file = "cluster-config/${each.value.module}/${each.value.user}.json"
content = jsonencode({"destination" = {"server"= each.value.server, "namespace" = each.value.namespace}} )
commit_message = "Managed by Terraform"
commit_author = "Terraform User"
commit_email = "terraform@example.com"
overwrite_on_create = true
}
Теперь мы свели конфигурацию к одному файлу и упростили развёртывание, но с этим можно были легко справиться и другими инструментами. Теперь попробуем раскрыть мощности Terraform на примере баз данных для наших приложений.
Дополним описание наших приложений массивом databases:
{
"appName" : "app1",
"appRepoURL" : "https://github.com/argoproj/argo-cd.git",
"appPath" : "applicationset/examples/git-generator-files-discovery/apps/guestbook",
"appTargetRevision" : "HEAD",
"databases" : [ "app1-db"]
}
Наши данные теперь выглядят так:
locals {
users_configuration_json = jsondecode(file("${path.module}/configs/users-configuration.json"))
users_configuration_combinations = distinct(flatten([
for cfg in local.users_configuration_json : [ # Проходимся по пользователям
for mdl in cfg.modules : { # Модули пользователя
module = mdl
user = cfg.user
server = cfg.server
namespace = cfg.namespace
}
]
]))
dbs = distinct(flatten([
for cfg in local.users_configuration_json : [ # Проходимся по пользователям
for mdl in cfg.modules : [ # Модули пользователя
for app in fileset("${path.module}/../apps/${mdl}", "**/*.json") : [ # Поиск всех приложений в папке
for db in (jsondecode(file("${path.module}/../apps/${mdl}/${app}"))).databases : # Проходимся по базам данных приложения
{
namespace = cfg.namespace
module = mdl
user = cfg.user
dbname = db
}
]
]
]
]))
}
Поднимаем базы данных:
module "postgresql" {
for_each = { for t in local.dbs : "${t.user} ${t.module} ${t.dbname} " => t }
source = "ballj/postgresql/kubernetes"
version = "~> 1.2"
namespace = each.value.namespace
object_prefix = "${each.value.user}-${each.value.module}-${each.value.dbname}"
name = each.value.dbname
}
Результат:
Общий процесс теперь выглядит так:
Мы добавляем конфигурацию нашего пользователя (название, namespace и список модулей).
Terraform подхватывает это, создаёт файлы конфигурации для ArgoCD и подготавливает инфраструктуру.
ArgoCD начинает синхронизацию изменений и заселяет микросервисы.
Осталось написать панель управления всем этим добром, где мы будем редактировать один-единственный файл конфигурации.
С какими трудностями придётся столкнуться дальше
Почему нельзя было натравить ArgoCD на наш единый файл конфигурации и не использовать Terraform для разбиения на несколько различных файлов?
На данный момент Git generator не умеет работать с массивами, поэтому приходится прибегать к такому костылю, надеюсь в будущем мы увидим поддержку не строковых полей.
Я не хочу автоматически обновляться! Предоставьте мне контроль над обновлениями!
Если пойти на уступки пользователя, то это означает, что придётся держать в системе различные версии конфигурации для каждого модуля и его приложений, поэтому трудности поддержки будут расти с каждой новой версией.
А как мне настраивать конфигурацию микросервисов для различных версий?
Действительно, чаще всего мы привыкли поддерживать только один набор для конфигурации нашего микросервиса на каждое окружение или его тип. Я нашел хорошее решение этой проблемы через Azure App Configuration, где можно прописать Label у переменных и использовать его для фильтрации под необходимую версию. Подробнее тут.
Как узнать, что все модули успешно развернулись или упали с ошибкой?
В этом может помочь ArgoCD API, через который мы можем опрашивать наши приложения. Но лучше пойти по другому пути и заставить ArgoCD уведомлять нас о всех проблемах и успехах через систему уведомлений.
Заключение
Я не DevOps инженер, и у меня мало опыта с Kubernetes, но благодаря хорошей документации ArgoCD и Terraform получилось реализовать подобную систему декларативно. В статье я описал минимальный функционал, который будет легко расширить и прикрутить новые фичи.
Ссылка на репозиторий с примерами (каждая итерация в отдельной ветке).
Хотелось бы узнать Вашу идею реализации или как можно было бы улучшить мою версию.
P.S. Расскажите о Вашем необычном кейсе использования ArgoCD и Terraform.
Спасибо Кириллу Лысаку за прекрасную обложку.