Вступление

Мне представилось решать интересную задачу. Необходимо реализовать настраиваемый 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 генератора есть два режима работы:

  1. Files – с ним мы работали всё это время и успешно извлекали содержимое файлов.

  2. 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 следующие задачи:

  1. Генерация файлов конфигурации для ArgoCD.

  2. Поднятие ресурсов инфраструктуры (в нашем случае базы данных).

  3. Инициализация 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
}

Результат:

user1
user1
user2
user2

Общий процесс теперь выглядит так:

  1. Мы добавляем конфигурацию нашего пользователя (название, namespace и список модулей).

  2. Terraform подхватывает это, создаёт файлы конфигурации для ArgoCD и подготавливает инфраструктуру.

  3. ArgoCD начинает синхронизацию изменений и заселяет микросервисы.

Осталось написать панель управления всем этим добром, где мы будем редактировать один-единственный файл конфигурации.

С какими трудностями придётся столкнуться дальше

Почему нельзя было натравить ArgoCD на наш единый файл конфигурации и не использовать Terraform для разбиения на несколько различных файлов?

На данный момент Git generator не умеет работать с массивами, поэтому приходится прибегать к такому костылю, надеюсь в будущем мы увидим поддержку не строковых полей.

Я не хочу автоматически обновляться! Предоставьте мне контроль над обновлениями!

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

А как мне настраивать конфигурацию микросервисов для различных версий?

Действительно, чаще всего мы привыкли поддерживать только один набор для конфигурации нашего микросервиса на каждое окружение или его тип. Я нашел хорошее решение этой проблемы через Azure App Configuration, где можно прописать Label у переменных и использовать его для фильтрации под необходимую версию. Подробнее тут.

Как узнать, что все модули успешно развернулись или упали с ошибкой?

В этом может помочь ArgoCD API, через который мы можем опрашивать наши приложения. Но лучше пойти по другому пути и заставить ArgoCD уведомлять нас о всех проблемах и успехах через систему уведомлений.

Заключение

Я не DevOps инженер, и у меня мало опыта с Kubernetes, но благодаря хорошей документации ArgoCD и Terraform получилось реализовать подобную систему декларативно. В статье я описал минимальный функционал, который будет легко расширить и прикрутить новые фичи.

Ссылка на репозиторий с примерами (каждая итерация в отдельной ветке).

Хотелось бы узнать Вашу идею реализации или как можно было бы улучшить мою версию.

P.S. Расскажите о Вашем необычном кейсе использования ArgoCD и Terraform.

Спасибо Кириллу Лысаку за прекрасную обложку.

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


  1. kksudo
    00.00.0000 00:00

    Спасибо за статью, любопытно.

    Возложим на Terraform следующие задачи:

    1. Генерация файлов конфигурации для ArgoCD.

    2. Поднятие ресурсов инфраструктуры (в нашем случае базы данных).

    3. Инициализация namespace в k8s для пользователя.


    1. Почему вы решили использовать Terraform для работы с кластером? Почему не HELM? Не очень понятна аргументация... Логичнее было бы использовать TF для инфраструктурных задач, типа поднять ресурс в облаке...

    ...Я не DevOps инженер

    ....Мне представилось решать интересную задачу.

    2. Почему вы тогда стали решать эту задачу, исследование, изучение или что-то еще? Вы решали эту задачу опираясь только на собственный опыт или консультировались с *Ops командой?

    3. Как генерите json, из TF? Где храните стейт TF?

    4. Другие инструменты рассматривали перед началом работы над задачей? Если да, то какие?
    5. Какую-то автоматизацию планируете добавлять? Линтеры, различные проверки CI/CD ?

    P.S.: Добавил звездочку вашей репе.


    1. 1kvin Автор
      00.00.0000 00:00

      Спасибо за ответ!

      1. Вы всё правильно говорите, я поднимал всё локально, а не в облаке, но можно пример адаптировать под облако.

      2. Задачу по созданию портала поставили на команду в которой я был. Конечно же мы тесно сотрудничали с Ops.

      3. content = jsonencode({"destination" = {"server"= each.value.server, "namespace" = each.value.namespace}} )

        Для примера из статьи я держал стейт локально на компьютере.

      4. Рассматривали fluxcd и powershell скрипты. По началу команда была против TF и за Powershell скрипты, но на практике ps оказался более гибким, но написание идемпотентных скриптов сложной задачей.

      5. Для примера, мне кажется, это излишним.


  1. ProFfeSsoRr
    00.00.0000 00:00
    +2

    А если Terraform заменить на Crossplane, то получится всё максимально k8s нативно ;)


    1. kksudo
      00.00.0000 00:00

      Да есть и другие варианты, но просто для меня не ясно, зачем Terraform? Он же предназначен для инфраструктурных задач...


      1. 1kvin Автор
        00.00.0000 00:00

        Думаю я привёл не самый лучший пример. Доступа к облаку на момент написания статьи у меня не было, поэтому делал всё локально.

        Например можно заменить PostgreSQL на облачную версию Azure, добавить какую-нибудь службу обмена сообщениями (аля Azure Service Bus) и т.д.