Terraform: створення EKS, частина 3 – установка Karpenter

Автор |  14/09/2023
 

Це вже третя частина по розгортанню кластеру AWS Elastic Kubernetes Service з Terraform, в якій будемо додавати в наш кластер Karpenter. Вирішив винести окремо, бо виходить досить довгий пост. І вже в останній (сподіваюсь), четвертій частині, додамо решту – всякі контроллери.

Попередні частини:

  1. Terraform: створення EKS, частина 1 – VPC, Subnets та Endpoints
  2. Terraform: створення EKS, частина 2 – EKS кластер, WorkerNodes та IAM

Наступна, остання частина – Terraform: створення EKS, частина 4 – установка контроллерів.

Планування

Що нам залишилось зробити:

Структура файлів зараз виглядає так:

$ tree .
.
├── backend.tf
├── configs
├── eks.tf
├── main.tf
├── outputs.tf
├── providers.tf
├── terraform.tfvars
├── variables.tf
└── vpc.tf

Провайдери Terraform

На додачу вже існуючим у нас провайдерам AWS та Kubernetes нам здобляться ще два – Helm та Kubectl.

Helm, очевидно що для встановлення Helm-чартів – а контролери ми будемо встановлювати саме з чартів, а kubectl – для деплою ресурсів з власних Kubernetes-маніфестів.

Отже додаємо їх в наш файл providers.tf. Авторизацію робимо аналогічно тому, як робили для Kubernetes-провайдера – через AWS CLI, і в args передаємо ім’я профілю:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.14.0"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.23.0"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 2.11.0"
    }
    kubectl = {
      source  = "gavinbunney/kubectl"
      version = "~> 1.14.0"
    }            
  }
}
...
provider "helm" {
  kubernetes {
    host                   = module.eks.cluster_endpoint
    cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)

    exec {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "aws"
      args = ["--profile", "tf-admin", "eks", "get-token", "--cluster-name", module.eks.cluster_name]
    }
  }
}

provider "kubectl" {
  apply_retry_count      = 5
  host                   = module.eks.cluster_endpoint
  cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
  load_config_file       = false

  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "aws"
    args = ["--profile", "tf-admin", "eks", "get-token", "--cluster-name", module.eks.cluster_name]
  }
}

Виконуємо terraform init для установки провайдерів.

Варіанти установки Karpenter в AWS EKS з Terraform

Є декілька варіантів установки Karpenter з Terraform:

Я вирішив спробувати модуль від Антона, бо як вже кластер розгорається його модулем – то логічно і для Karpenter його ж використовувати.

Приклад його створення – тут>>>, документація і ще приклади – тут>>>.

Для Node IAM Role використаємо вже існуючу, бо маємо eks_managed_node_groups, яка створюється у модулі EKS:

...
  eks_managed_node_groups = {
    default = {

      # number, e.g. 2
      min_size = var.eks_managed_node_group_params.default_group.min_size
...

AWS SecurityGroup та Subnet Tags

Для роботи Karpenter використвує тег karpenter.sh/discovery з SecurityGroup наших WorkerNodes та приватних VPC Subnets щоб знати які SecurityGroups додавати на Nodes, та в яких subnets ці ноди запускати.

В значені тегу задається ім’я кластеру, якому належить SecurityGroup чи Subnet.

Додамо нову локальну змінну eks_cluster_name до main.tf:

locals {
  # create a name like 'atlas-eks-dev-1-27'
  env_name = "${var.project_name}-${var.environment}-${replace(var.eks_version, ".", "-")}"
  eks_cluster_name = "${local.env_name}-cluster"
}

І у файлі eks.tf додаємо параметр node_security_group_tags:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 19.0"
  ...
  node_security_group_tags  = {
    "karpenter.sh/discovery" = local.eks_cluster_name
  }
  ...
}

У файлі vpc.tf додаємо тег до приватних сабнетів:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.1.1"
  ...
  private_subnet_tags = {
    "karpenter.sh/discovery" = local.eks_cluster_name
  }
  ...
}

Перевіряємо, та деплоїмо зміни:

Karpenter module

Переходимо до самого модулю Karpenter.

Винесемо його в окремий файл karpenter.tf.

У iam_role_arn передаємо роль з нашої існуючої eks_managed_node_groups з ім’ям “default“, бо вона вже створена і додана в aws-auth ConfigMap.

Я тут ще додав irsa_use_name_prefix, бо отримував помилку надто довгого імені для IAM-ролі:

module "karpenter" {
  source = "terraform-aws-modules/eks/aws//modules/karpenter"

  cluster_name = module.eks.cluster_name

  irsa_oidc_provider_arn          = module.eks.oidc_provider_arn
  irsa_namespace_service_accounts = ["karpenter:karpenter"]

  create_iam_role      = false
  iam_role_arn         = module.eks.eks_managed_node_groups["default"].iam_role_arn
  irsa_use_name_prefix = false
}

Під капотом модуль створить необхідні IAM-ресурси, плюс додасть AWS SQS Queue та EventBridge Rule, який буде в цю чергу відправляти повідомлення, пов’язані з EC2, див. Node Termination Event Rules та документацію Amazon – Monitoring AWS Health events with Amazon EventBridge.

Додамо трохи outputs:

...

output "karpenter_irsa_arn" {
  value = module.karpenter.irsa_arn
}

output "karpenter_aws_node_instance_profile_name" {
  value = module.karpenter.instance_profile_name
}

output "karpenter_sqs_queue_name" {
  value = module.karpenter.queue_name
}

І переходимо до Helm – додаємо чарт з самим Karpenter.

Тут у variables можна винести версію модулю:

...

variable "karpenter_chart_version" {
  description = "Karpenter Helm chart version to be installed"
  type        = string
}

Да terraform.tfvars додаємо значення з останнім релізом чарта:

...

karpenter_chart_version = "v0.30.0"

Для доступу Helm до репозиторія oci://public.ecr.aws нам буде потрібен токен. Отримаємо його з aws_ecrpublic_authorization_token. Тут є нюанс, що він працює тільки для регіону us-east-1, бо токени AWS ECR видаються саме там. Див. aws_ecrpublic_authorization_token breaks if region != us-east-1.

Помилка “cannot unmarshal bool into Go struct field ObjectMeta.metadata.annotations of type string”

Також, так як ми використовуємо VPC Endpoint для AWS STS, то нам до ServiceAccount, який буде створюватись для Karpenter, треба додати аннотацію eks.amazonaws.com/sts-regional-endpoints=true.

Проте якщо додати аннотацію у set як:

set {
  name = "serviceAccount.annotations.eks\\.amazonaws\\.com/sts-regional-endpoints" 
  value = "true"
}

То Terraform видаває помилку “cannot unmarshal bool into Go struct field ObjectMeta.metadata.annotations of type string“.

Рішення нагуглилось ось у цьому коментарі до GitHub Issue – “просто додай води type=string“.

В тексті помилки говориться, що Terraform не може “unmarshal bool” (“розпаковати значення з типом bool”) у поле spec.tolerations.value, яке має тип string.

Рішення – це вказати тип значення явно, через type = "string".

До karpenter.tf  додаємо aws_ecrpublic_authorization_token та ресурс helm_release, в якому встановлюємо чарт Karpenter:

...

data "aws_ecrpublic_authorization_token" "token" {}

resource "helm_release" "karpenter" {
  namespace        = "karpenter"
  create_namespace = true

  name                = "karpenter"
  repository          = "oci://public.ecr.aws/karpenter"
  repository_username = data.aws_ecrpublic_authorization_token.token.user_name
  repository_password = data.aws_ecrpublic_authorization_token.token.password
  chart               = "karpenter"
  version             = var.karpenter_chart_version

  set {
    name  = "settings.aws.clusterName"
    value = local.eks_cluster_name
  }

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

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

  set {
    name = "serviceAccount.annotations.eks\\.amazonaws\\.com/sts-regional-endpoints"
    value = "true"
    type = "string"
  }  

  set {
    name  = "settings.aws.defaultInstanceProfile"
    value = module.karpenter.instance_profile_name
  }

  set {
    name  = "settings.aws.interruptionQueueName"
    value = module.karpenter.queue_name
  }
}

Karpenter Provisioner та Terraform templatefile

Поки що у нас буде тільки один Karpernter Provisioner, проте згодом скоріш за все будемо додавати ще, тому давайте це відразу зробимо через шаблони.

На старому кластері маніфест нашого дефолтного Provisioner виглядає так:

apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: default
spec:
  requirements:
    - key: karpenter.k8s.aws/instance-family
      operator: In
      values: [t3]
    - key: karpenter.k8s.aws/instance-size
      operator: In
      values: [small, medium, large]
    - key: topology.kubernetes.io/zone
      operator: In
      values: [us-east-1a, us-east-1b]
  providerRef:
    name: default
  consolidation: 
    enabled: true
  ttlSecondsUntilExpired: 2592000

Спершу до variables.tf додаємо змінну, яка буде тримати параметри для провіженера:

...

variable "karpenter_provisioner" {
  type = list(object({
    name              = string
    instance-family = list(string)
    instance-size     = list(string)
    topology  = list(string)
    labels            = optional(map(string))
    taints = optional(object({
      key    = string
      value  = string
      effect = string
    }))
  }))
}

І значення до tfvars:

...

karpenter_provisioner = {
  name              = "default"
  instance-family =  ["t3"]
  instance-size     = ["small", "medium", "large"]
  topology  = ["us-east-1a", "us-east-1b"]
  labels            = {
    created-by  = "karpenter"
  }
}

Створюємо сам файл шаблону – спочатку додаємо локальний каталог configs, де будуть всі шаблони, і в ньому додаємо файл karpenter-provisioner.yaml.tmpl.

Для параметрів, в які будуть передаватись елементи з типом list додаємо jsonencode(), щоб перетворити їх на стрінги:

apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: ${name}
spec:

%{ if taints != null ~}
  taints:
    - key: ${taints.key}
      value: ${taints.value}
      effect: ${taints.effect}
%{ endif ~}

%{ if labels != null ~} 
  labels:
  %{ for k, v in labels ~}
    ${k}: ${v}
  %{ endfor ~}    
%{ endif ~}

  requirements:
    - key: karpenter.k8s.aws/instance-family
      operator: In
      values: ${jsonencode(instance-family)}
    - key: karpenter.k8s.aws/instance-size
      operator: In
      values: ${jsonencode(instance-size)}
    - key: topology.kubernetes.io/zone
      operator: In
      values: ${jsonencode(topology)}
  
  providerRef:
    name: default

  consolidation: 
    enabled: true

  kubeletConfiguration:
    maxPods: 100

  ttlSecondsUntilExpired: 2592000

Далі, в karpenter.tf додаємо ресурс kubectl_manifest з циклом for_each, в якому перебираємо всі елементи мапи karpenter_provisioner:

resource "kubectl_manifest" "karpenter_provisioner" {
  for_each = var.karpenter_provisioner

  yaml_body = templatefile("${path.module}/configs/karpenter-provisioner.yaml.tmpl", {
    name = each.key
    instance-family = each.value.instance-family
    instance-size = each.value.instance-size
    topology  = each.value.topology
    taints = each.value.taints
    labels = merge(
      each.value.labels,
      {
        component   = var.component
        environment = var.environment
      }
    )
  })

  depends_on = [
    helm_release.karpenter
  ] 
}

Вказуємо depends_on, бо якщо все це деплоїться перший раз, то Terraform повідомляє про помилку “resource [karpenter.sh/v1alpha5/Provisioner] isn’t valid for cluster, check the APIVersion and Kind fields are valid“, бо в кластері ще немає самого Karpenter.

AWSNodeTemplate у нас навряд чи буде змінюватись, тому його можемо створити просто ще одним kubectl_manifest:

...

resource "kubectl_manifest" "karpenter_node_template" {
  yaml_body = <<-YAML
    apiVersion: karpenter.k8s.aws/v1alpha1
    kind: AWSNodeTemplate
    metadata:
      name: default
    spec:
      subnetSelector:
        karpenter.sh/discovery: ${local.eks_cluster_name}
      securityGroupSelector:
        karpenter.sh/discovery: ${local.eks_cluster_name}
      tags:
        Name: ${local.eks_cluster_name}-node
        environment: ${var.environment}
        created-by: "karpneter"
        karpenter.sh/discovery: ${local.eks_cluster_name}
  YAML

  depends_on = [
    helm_release.karpenter
  ]
}

Виконуємо terraform init, деплоїмо, та перевіряємо ноди:

$ kk -n karpenter get pod
NAME                         READY   STATUS    RESTARTS   AGE
karpenter-556f8d8f6b-f48v7   1/1     Running   0          26s
karpenter-556f8d8f6b-w2pl9   1/1     Running   0          26s

А чому вони завелися, якщо на наших WorkerNodes ми задавалиt taints?

$ kubectl get nodes -o json | jq '.items[].spec.taints'
[
  {
    "effect": "NoSchedule",
    "key": "CriticalAddonsOnly",
    "value": "true"
  },
  {
    "effect": "NoExecute",
    "key": "CriticalAddonsOnly",
    "value": "true"
  }
]
...

Бо Karpernter Deployment по-дефолту має відповідні tolerations:

$ kk -n karpenter get deploy -o yaml | yq '.items[].spec.template.spec.tolerations'
[
  {
    "key": "CriticalAddonsOnly",
    "operator": "Exists"
  }
]

Тестування Karpenter

Варто перевірити, чи працює скейлінг та де-скейлінг WorkerNodes.

Створюємо Deployment – 20 подів, кожному виділяємо по 512 МБ пам’яті, через topologySpreadConstraints вказуємо розміщати їх на різних нодах:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-deployment
spec:
  replicas: 20
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - name: my-container
          image: nginxdemos/hello
          imagePullPolicy: Always
          resources:
            requests:
              memory: "512Mi"
              cpu: "100m"
            limits:
              memory: "512Mi"
              cpu: "100m"
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: my-app

Деплоїмо, та перевіряємо логи Karpenter з kubectl -n karpenter logs -f -l app.kubernetes.io/instance=karpenter:

І маємо нові ноди:

$ kk get node
NAME                          STATUS     ROLES    AGE    VERSION
ip-10-1-47-71.ec2.internal    Ready      <none>   2d1h   v1.27.4-eks-8ccc7ba
ip-10-1-48-216.ec2.internal   NotReady   <none>   31s    v1.27.4-eks-8ccc7ba
ip-10-1-48-93.ec2.internal    Unknown    <none>   31s    
ip-10-1-49-32.ec2.internal    NotReady   <none>   31s    v1.27.4-eks-8ccc7ba
ip-10-1-50-224.ec2.internal   NotReady   <none>   31s    v1.27.4-eks-8ccc7ba
ip-10-1-51-194.ec2.internal   NotReady   <none>   31s    v1.27.4-eks-8ccc7ba
ip-10-1-52-188.ec2.internal   NotReady   <none>   31s    v1.27.4-eks-8ccc7ba
...

Не забуваємо видалити тестовий деплоймент.

Помилка “UnauthorizedOperation: You are not authorized to perform this operation”

Зіткнувся з такою помилкою в логах Karpenter:

2023-09-12T11:17:10.870Z        ERROR   controller      Reconciler error        {"commit": "637a642", "controller": "machine.lifecycle", "controllerGroup": "karpenter.sh", "controllerKind": "Machine", "Machine": {"name":"default-h8lrh"}, "namespace": "", "name": "default-h8lrh", "reconcileID": "3e67c553-2cbd-4cf8-87fb-cb675ea3722b", "error": "creating machine, creating instance, with fleet error(s), UnauthorizedOperation: You are not authorized to perform this operation. Encoded authorization failure message: wMYY7lD-rKC1CjJU**qmhcq77M;
...

Читаємо саму помилку з aws sts decode-authorization-message:

$ export mess="wMYY7lD-rKC1CjJU**qmhcq77M"
$ aws sts decode-authorization-message --encoded-message $mess | jq

Отримуємо текст:

{
  "DecodedMessage": "{\"allowed\":false,\"explicitDeny\":false,\"matchedStatements\":{\"items\":[]},\"failures\":{\"items\":[]},\"context\":{\"principal\":{\"id\":\"ARO***S4C:1694516210624655866\",\"arn\":\"arn:aws:sts::492***148:assumed-role/KarpenterIRSA-atlas-eks-dev-1-27-cluster/1694516210624655866\"},\"action\":\"ec2:RunInstances\",\"resource\":\"arn:aws:ec2:us-east-1:492***148:launch-template/lt-03dbc6b0b60ee0b40\",\"conditions\":{\"items\":[{\"key\":\"492***148:kubernetes.io/cluster/atlas-eks-dev-1-27-cluster\",\"values\":{\"items\":[{\"value\":\"owned\"}]}},{\"key\":\"ec2:ResourceTag/environment\"
...

IAM Role arn:aws:sts::492***148:assumed-role/KarpenterIRSA-atlas-eks-dev-1-27-cluster не змогла виконати операцію ec2:RunInstances.

Чому? Бо в IAM Policy, яку створює модуль Karpenter задається Condition:

...
        },
        {
            "Action": "ec2:RunInstances",
            "Condition": {
                "StringEquals": {
                    "ec2:ResourceTag/karpenter.sh/discovery": "atlas-eks-dev-1-27-cluster"
                }
            },
            "Effect": "Allow",
            "Resource": "arn:aws:ec2:*:492***148:launch-template/*"
        },
...

А я в AWSNodeTemplate видалив створення тегу "karpenter.sh/discovery: ${local.eks_cluster_name}".

Також може бути корисним глянути помилки в CloudTrail – там більше інформації.

Наразі це все – можна переходити до решти контроллерів.