Выбор правильной технологии для запуска CI/CD может быть трудным выбором, особенно если программное обеспечение, которое вы используете для CI/CD, предлагает несколько способов его размещения. Когда я решил организовать рой бегунов GitLab для своей команды, у меня было несколько критериев, которые я хотел обеспечить:

  • Я хотел выполнять свои задания в контейнерах Docker,
  • Я хотел масштабировать свои бегуны до 0, когда задания не выполнялись,
  • Я хотел как можно чаще выполнять свои задания на спотовых инстансах AWS,
  • И я хотел определить все мои бегуны как инфраструктуру как код.

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

Почему Kubernetes Runner?

Несмотря на то, что существует несколько способов запуска GitLab runner, приведенные выше критерии оставили две возможности конфигурации, которые соответствовали моим вариантам использования:

  • Docker Machine, которая использует один экземпляр EC2 для порождения нескольких других экземпляров, каждый из которых может выполнять одно или несколько заданий. Это имело преимущество в том, что было очень просто, и есть отличный модуль Terraform с открытым исходным кодом, который поможет вам быстро начать работу.
  • Или Kubernetes, который использует модули в кластере Kubernetes (после этого они называются k8s) для выполнения одного или нескольких заданий. K8 может быть очень сложно изучить и правильно настроить, и, похоже, не так много ресурсов с открытым исходным кодом, чтобы помочь с этой проблемой.

В результате я изначально выбрал Docker Machine для продолжения. Это просто позволило мне двигаться быстрее, потому что я мог положиться на уже существующий модуль Terraform, и он по-прежнему соответствовал моим критериям. Однако есть несколько вещей, которые не идеальны в Docker Machine runner:

  • Docker Machine уже давно устарела. Хотя GitLab поддерживает ответвление, для которого они предоставляют обновления безопасности, это означает, что никаких новых функций или передовых методов не выпускается. GitLab в любом случае пытается отказаться от поддержки Docker Machine, а это означает, что это было решение с ограниченным сроком действия.
  • Docker Machine поддерживает только один тип экземпляра для каждого исполнителя, а это означает, что если я запускаю много заданий одновременно, мои спотовые цены увеличатся, а вместе с этим увеличится частота прерываний. Это происходит часто, с увеличением количества задания, которые моя команда выполняла параллельно.
  • Docker Machine поддерживает только одну подсеть для своих машин, а это значит, что мне не удалось запустить машину в отказоустойчивом режиме зоны доступности. Если AZ вышел из строя, мои бегуны упали вместе с ним.

Это только основные причины, но по этим причинам я решил, что мы переросли простую настройку Docker Machine, и пора переходить на k8s.

Входят ЭКС и Карпентер

В рамках перехода на k8s мне нужно было провести небольшое исследование о правильном способе размещения кластера. Существует почти бесконечное количество способов размещения и масштабирования k8s, но мне нужно было выбрать тот, который отвечал бы моим потребностям. Мне хотелось снизить накладные расходы на управление моим кластером, поэтому я выбрал AWS EKS. Для многих пользователей k8s лучше использовать управляемое предложение. Если у вас есть собственная команда инженеров для управления кластером и его масштабирования, отлично; возьми что-нибудь подешевле. Для меня 75$ в месяц дешево по сравнению с временем разработки, которое потребовалось бы для поддержки нашего собственного кластера.

Более интересное решение — использование Карпентера вместо Horizontal Pod Autoscaler (HPA). Я стараюсь по возможности использовать сторонние варианты, потому что они, скорее всего, будут продолжать обновляться, но в этом случае Карпентер предложил преимущества, выходящие за рамки HPA, которые я не мог игнорировать:

  • Карпентер изначально масштабирует рабочие экземпляры до 0, тогда как HPA требует метрики для мониторинга. Поскольку с помощью GitLab runner нет подходящей метрики для мониторинга, для масштабирования до 0 с помощью HPA потребуется много пользовательского кода.
  • HPA требуется модуль для каждого отслеживаемого развертывания. Если я думаю о каждом бегуне как об одном развертывании, это будет означать, что у меня будет много разных модулей HPA. Это связано с тем, что я использую разные теги для разных экземпляров GitLab Runner для управления разрешениями. Это означало, что мои базовые затраты на запуск кластера были выше, даже когда не выполнялись никакие задания.

Итак, мы понимаем EKS и Карпентера. Давайте перейдем к забавным вещам и напишем код.

Кодекс: настройка EKS и Karpenter

При настройке кластера EKS с помощью GitLab мы можем использовать большую работу, проделанную командой GitLab для создания кластера EKS, поэтому мы будем следовать инструкциям на их странице Создание кластера Amazon EKS, в которой используется этот репозиторий. : https://gitlab.com/gitlab-org/configure/examples/gitlab-terraform-eks. Примечание: я не собираюсь рассматривать variable definitions в terraform. Вы можете увидеть их все в репозитории выше!

Во-первых, как и в любом проекте AWS, мы начинаем с VPC. В этом случае мы используем поддерживаемый Hashicorp модуль для создания VPC:

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name                 = var.cluster_name
  cidr                 = "10.0.0.0/16"
  azs                  = data.aws_availability_zones.available.names
  private_subnets      = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets       = ["10.0.4.0/24", "10.0.5.0/24", "10.0.6.0/24"]
  enable_nat_gateway   = true
  single_nat_gateway   = true
  enable_dns_hostnames = true
}

В этом VPC нет ничего особенного, у него есть общедоступные и частные подсети, включен шлюз NAT и включен DNS. В основном мы будем использовать общедоступные подсети для наших бегунов, но мы будем использовать частные подсети для плоскостей управления k8s и Karpenter. Если вы предпочитаете использовать частные подсети для своих воркеров, вы тоже можете это сделать. Почему вместо этого я предпочитаю использовать общедоступные подсети? Поскольку шлюз NAT чрезвычайно дорог при высокой пропускной способности, а CI/CD требует высокой пропускной способности.

После создания VPC пришло время создать кластер EKS. Нам нужно начать вносить изменения в пример GitLab здесь, но давайте сначала посмотрим на их пример:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "18.1.0"

  cluster_name    = var.cluster_name
  cluster_version = var.cluster_version

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets

  eks_managed_node_groups = {
    default = {
      min_size       = 1
      max_size       = var.instance_count
      desired_size   = var.instance_count
      instance_types = [var.instance_type]
    }
  }
}

Это довольно простой кластер EKS. Значение по умолчанию var.instance_type равно t3.small , и это создает кластер с 1 узлом в частной подсети. Давайте начнем вносить некоторые изменения в их пример. Карпентеру нужно несколько вещей для автоматического предоставления узлов:

  • Ему нужны теги в подсетях для автоматического обнаружения,
  • Ему нужны теги в группах безопасности для автоматического обнаружения,
  • И он должен иметь возможность связываться со своим веб-перехватчиком для регистрации поставщиков.

Мы можем добавить все это в наши модули. Начнем с модуля VPC, где нам нужно добавить пару тегов к нашим подсетям. Опять же, я собираюсь использовать общедоступные подсети для своих работников, чтобы снизить затраты на пропускную способность; если вместо этого вы хотите использовать частные подсети для своих рабочих процессов, просто примените теги автоматического обнаружения к частным подсетям.

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name                 = var.cluster_name
  cidr                 = "10.0.0.0/16"
  azs                  = data.aws_availability_zones.available.names
  private_subnets      = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets       = ["10.0.4.0/24", "10.0.5.0/24", "10.0.6.0/24"]
  enable_nat_gateway   = true
  single_nat_gateway   = true
  enable_dns_hostnames = true

  // Add tags for Karpenter
  public_subnet_tags = {
    "kubernetes.io/cluster/${var.cluster_name}" = "owned",
    "karpenter.sh/discovery" = var.cluster_name
  }
}

Теперь, когда мы применили наши теги к VPC, давайте посмотрим на изменения в EKS, которые немного более существенны:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "19.5.1"

  cluster_name    = var.cluster_name
  cluster_version = var.cluster_version

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets



  eks_managed_node_groups = {
    default = {
      min_size       = 2
      max_size       = var.instance_count
      desired_size   = var.instance_count
      instance_types = var.instance_types

      capacity_type = "SPOT"
      ami_type      = "BOTTLEROCKET_x86_64"

      // Launch template tags, used by Karpenter
      tags = {
        "karpenter.sh/discovery/${var.cluster_name}" = "${var.cluster_name}"
      }

      iam_role_additional_policies = {
        # Required by Karpenter
        S3            = "arn:aws:iam::aws:policy/AmazonS3FullAccess",
        SSMCore       = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
        EKSManagement = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
      }
    }
  }

  node_security_group_additional_rules = {
    karpenter_webhook = {
      description                   = "Cluster API to AWS LB Controller webhook"
      protocol                      = "all"
      from_port                     = 0
      to_port                       = 65000
      type                          = "ingress"
      source_cluster_security_group = true
    }
  }

  # Add karpenter tags to the security groups so they get assigned properly.
  node_security_group_tags = {
    "karpenter.sh/discovery/${var.cluster_name}" = "${var.cluster_name}"
  }
  cluster_security_group_tags = {
    "karpenter.sh/discovery/${var.cluster_name}" = "${var.cluster_name}"
  }
}

# https://github.com/terraform-aws-modules/terraform-aws-iam/blob/master/modules/iam-role-for-service-accounts-eks/
module "karpenter_irsa" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
  version = "5.10.0"

  role_name                          = "karpenter-controller-${var.cluster_name}"
  attach_karpenter_controller_policy = true
  attach_vpc_cni_policy = true
  vpc_cni_enable_ipv4   = true

  karpenter_tag_key               = "karpenter.sh/discovery/${var.cluster_name}"
  karpenter_controller_cluster_id = var.cluster_name
  karpenter_controller_node_iam_role_arns = [
    module.eks.eks_managed_node_groups["default"].iam_role_arn
  ]

  oidc_providers = {
    ex = {
      provider_arn               = module.eks.oidc_provider_arn
      namespace_service_accounts = ["default:karpenter"]
    }
  }
}

Пройдемся по изменениям:

  • Я обновил instance_type до instance_types, чтобы я мог передавать более одного instance_type в пул узлов по умолчанию. Это позволяет мне более удобно использовать точечные узлы, даже для базовых узлов. Лучше всего использовать более одного типа спотовых инстансов, иначе вы можете не выделить свои узлы, если будет высокий спрос. По умолчанию я использую следующие типы экземпляров: [“t3.medium”, “t3a.medium”, “t3.large”, “t3a.large”]. Они немного больше, чем примеры GitLab, потому что требуется достаточно ресурсов, чтобы обеспечить правильное размещение Karpenter на узлах в дополнение к GitLab runner.
  • Я обновил емкость до Spot, чтобы снизить затраты.
  • И я обновил AMI до Bottlerocket вместо AWS Linux, потому что я буду запускать контейнеры только на этих инстансах. Вы можете прочитать дополнительную информацию о Bottlerocket и его преимуществах на сайте AWS.
  • Я добавил теги в группы безопасности и шаблоны запуска, чтобы Карпентер мог их автоматически обнаруживать.
  • Я добавил к узлам роли IAM, чтобы у Карпентера были правильные разрешения для выполнения необходимых действий.
  • Я добавил провайдера OIDC, который позволяет Карпентеру получать доступ к AWS IAM с использованием ролей k8s. Я позволяю ему использовать пространство имен по умолчанию, что было бы проблемой, если бы другие приложения работали в кластере, и нам нужно было бы иметь строгие границы разрешений, но на данный момент это приемлемо.

Все это вместе создает кластер EKS, готовый к установке Karpenter, что мы можем сделать с помощью диаграммы Helm. Мы также собираемся создать наш первый поставщик, который Карпентер использует для создания узлов. Вы можете прочитать подробнее о провайдерах в документации Карпентера. Давайте посмотрим на нашу Terraform:

resource "helm_release" "karpenter" {
  namespace        = var.kubernetes_namespace
  create_namespace = true

  name       = "karpenter"
  repository = "https://charts.karpenter.sh"
  chart      = "karpenter"

  set {
    name  = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
    value = module.karpenter_irsa.iam_role_arn
  }

  set {
    name  = "clusterName"
    value = var.cluster_name
  }

  set {
    name  = "clusterEndpoint"
    value = module.eks.cluster_endpoint
  }

  set {
    name  = "replicas"
    value = 1
  }

  set {
    name  = "aws.defaultInstanceProfile"
    value = "KarpenterNodeInstanceProfile-${var.cluster_name}"
  }
}

// This is required because Karpenter's webhook has a small spinup time
// before provisioners can be created.
resource "time_sleep" "wait_before_karpenter" {
  triggers = {
    karpenter_name = helm_release.karpenter.name
    manifest = helm_release.karpenter.manifest
  }
  create_duration = "90s"
}

resource "kubectl_manifest" "karpenter-provisioner" {
  yaml_body = <<-YAML
  apiVersion: karpenter.sh/v1alpha5
  kind: Provisioner
  metadata:
    name: default
  spec:
    requirements:
      - key: karpenter.sh/capacity-type
        operator: In
        values: [${join(",", var.capacity_type)}]
      - key: node.kubernetes.io/instance-type
        operator: In
        values: [${join(",", var.allowed_instance_types)}]

    limits:
      resources:
        cpu: ${var.max_cpus_allowed}

    ttlSecondsAfterEmpty: 30
    ttlSecondsUntilExpired: ${var.instance_time_to_live}

    provider:
      subnetSelector:
        karpenter.sh/discovery: ${var.cluster_name}
      securityGroupSelector:
        karpenter.sh/discovery/${var.cluster_name}: ${var.cluster_name}
      tags:
        karpenter.sh/discovery/${var.cluster_name}: ${var.cluster_name}
        created-by: ${time_sleep.wait_before_karpenter.triggers["karpenter_name"]}
  YAML

  depends_on = [
    time_sleep.wait_before_karpenter
  ]
}

В провайдере я установил некоторые значения по умолчанию:

  • Я установил максимальное количество ЦП на var.max_cpus_allowed, которое по умолчанию установлено на 1000. Это ограничивает количество узлов, которые будет выделять Карпентер, и устанавливает верхний предел количества ресурсов, которые может использовать кластер.
  • Я установил тип емкости var.capacity_type, а по умолчанию — [“spot”]. Это заставляет Карпентера выделять только спотовые мощности.
  • Я установил типы экземпляров в список типов экземпляров, который по умолчанию я установил в список примерно из 20 типов экземпляров, чтобы я мог максимизировать свою способность получать точечные экземпляры.
  • Поскольку я выбираю свои подсети и группы безопасности на основе тегов, я буду использовать подсети, отмеченные в модуле vpc , и группу безопасности, отмеченную в модуле eks . Вот как мы знаем, как использовать общедоступные подсети для наших узлов бегунов!

Теперь, когда у нас есть кластер и у нас установлен и запущен Karpenter, нам просто нужно установить наш GitLab Runner, и мы будем готовы к работе!

Код: Настройка GitLab Runner

И последнее, но не менее важное: нам нужно настроить наш настоящий GitLab runner. Это покажется очень маленьким по сравнению с тем, что потребовалось для создания Карпентера. Существует довольно много доступных конфигураций для настройки самого GitLab runner, поэтому мы не собираемся вдаваться во все из них. Мы собираемся использовать файл config.toml для настройки бегуна, и вы можете просмотреть все параметры конфигурации на сайте документации GitLab.

Прежде всего, при настройке GitLab runner в Kubernetes вам нужно дать ему разрешение на запуск модулей с использованием секретов. По сути, мы собираемся передавать k8s yaml непосредственно в Terraform, что выглядит странно, но есть причина, по которой мы не используем провайдер hashicorp/kubernetes: в нем есть ошибка, которая не работает для применения провайдера, который нужен Карпентеру.

resource "kubectl_manifest" "runner_namespace_role" {
  yaml_body = <<-YAML

  apiVersion: rbac.authorization.k8s.io/v1
  kind: Role
  metadata:
    namespace: ${var.kubernetes_namespace}
    name: runner_role_${var.runner_tag}
  rules:
  - apiGroups: [""] # "" indicates the core API group
    resources: ["secrets", "configmaps", "pods", "pods/attach", "pods/exec", "pods/log"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]

  YAML
}

resource "kubectl_manifest" "runner_namespace_role_binding" {
  yaml_body = <<-YAML

  apiVersion: rbac.authorization.k8s.io/v1
  kind: RoleBinding
  metadata:
    name: runner_role_${var.runner_tag}
    namespace: ${var.kubernetes_namespace}
  subjects:
  - kind: ServiceAccount
    name: default
  roleRef:
    kind: Role 
    name: runner_role_${var.runner_tag}
    apiGroup: rbac.authorization.k8s.io
  YAML
}

Это создает роль для GitLab Runner, которую он может использовать для создания модулей в нашем кластере k8s, и теперь мы готовы применить нашу диаграмму управления, которая создает наш GitLab Runner:

resource "helm_release" "runner_helm_chart" {
  name       = "glr-${var.runner_tag}"
  repository = "https://charts.gitlab.io"
  chart      = "gitlab-runner"

  namespace = var.kubernetes_namespace

  # Ensure the runner picks up the new values
  recreate_pods = true

  values = [
    data.template_file.values_template.rendered
  ]
}

data "template_file" "values_template" {
  template = file("${path.module}/templates/values.yml.tpl")
  vars = {
    registration_token = var.runner_registration_token
    max_runners        = var.runner_max_concurrency
    gitlab_url         = var.gitlab_url
    tags               = var.runner_tag

    cpu_limit   = var.cpu_limit
    cpu_request = var.cpu_request

    memory_limit   = var.memory_limit
    memory_request = var.memory_request

    run_untagged = var.run_untagged
    max_timeout  = var.max_timeout

    protected       = var.protected
    limit_node_tag  = var.limit_node_tag

    privileged = var.privileged
  }
}

Сама диаграмма руля состоит из очень небольшого количества строк кода, по сути просто гарантируя, что каждый раз, когда мы запускаем Terraform, мы соответствующим образом заново создаем наши модули. Наш файл шаблона, создающий config.toml, который считывается в блок значений в диаграмме руля; все, что есть в документации по расширенной конфигурации GitLab, будет работать.

С этим поздравляю! Теперь у вас есть кластер EKS, на котором работает GitLab Runner с автоматическим масштабированием. Карпентер будет получать события, когда модуль ставится в очередь исполнителем, выделяет экземпляры EC2 и отключает узлы, когда они больше не используются.

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

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

Спасибо,