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

Автор |  09/09/2023

Продовжуємо тему розгортання кластеру AWS Elastic Kubernetes Service за допомогою Terraform.

У першій частині підготували AWS VPC  – див. Terraform: створення EKS, частина 1 – VPC, Subnets та Endpoints.

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

Планування

В цілому, список TODO наразі виглядає так:

Для кластеру також використаємо модуль, знову від @Anton Babenko – Terraform EKS module. Проте й інші модулі, наприклад – terraform-aws-eks від Cookpad – я ним теж трохи користвувався, працював добре, але порівнювати не візьмусь.

Як і для модуля VPC, у Terraform EKS module теж маємо приклад кластеру та пов’язаних ресурсів – examples/complete/main.tf.

Terraform Kubernetes provider

Для роботи модулю з aws-auth ConfigMap потрібно буде додати ще один провайдер – kubernetes.

У файлі providers.tf додаємо його:

...
provider "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]
  }
}

Тут зверніть увагу, що в args передається AWS-профайл, бо сам кластер створюється Terraform від імені IAM Role:

...
provider "aws" {
  region = "us-east-1"
    assume_role {
    role_arn = "arn:aws:iam::492***148:role/tf-admin"
  }
...

І AWS CLI Profile tf-admin як раз теж виконує IAM Role Assume:

...
[profile work]
region = us-east-1
output = json

[profile tf-admin]
role_arn = arn:aws:iam::492***148:role/tf-admin
source_profile = work
...

Error: The configmap “aws-auth” does not exist

Досить часта помилка, принаймні я неодноразово з нею стикався – коли під час виконання terraform apply в кінці отримуємо цю помилку, а сама aws-auth в кластері не створена.

Це призводить по-перше до того, що до кластеру не підключаються дефолтні WokrerNodes, по-друге – ми не можемо отримати доступ до кластеру з kubectl, бо хоча aws eks update-kubeconfig створює новий контекст в локальному ~/.kube/config, сам kubectl повертає помилку авторизації в кластері.

Продебажити це допомогло включення дебаг-логу Terraform через змінну TF_LOG=INFO, де була сама помилка аутентифиікації провайдеру:

...
[DEBUG] provider.terraform-provider-kubernetes_v2.23.0_x5:  "kind": "Status",
[DEBUG] provider.terraform-provider-kubernetes_v2.23.0_x5:  "apiVersion": "v1",
[DEBUG] provider.terraform-provider-kubernetes_v2.23.0_x5:  "metadata": {},
[DEBUG] provider.terraform-provider-kubernetes_v2.23.0_x5:  "status": "Failure",
[DEBUG] provider.terraform-provider-kubernetes_v2.23.0_x5:  "message": "Unauthorized",
[DEBUG] provider.terraform-provider-kubernetes_v2.23.0_x5:  "reason": "Unauthorized",
[DEBUG] provider.terraform-provider-kubernetes_v2.23.0_x5:  "code": 401
[DEBUG] provider.terraform-provider-kubernetes_v2.23.0_x5: }
...

Помилка виникала саме через те, що в args провайдеру не було задано правильний локальний профайл.

Є інший варіант аутентифікації – через token, див. цей коментар в GitHub Issues.

Але з ним були проблеми при створенні кластеру з нуля, бо Терраформ не міг виконанти data "aws_eks_cluster_auth". Треба ще якось спробувати, бо в цілому ідея з токеном мені подобається більше, ніж через AWS CLI. З іншого боку – у нас ще будуть провайдери kubectl та helm, і не факт, що їх можна аутентифікувати через токен (хоча, скоріш за все можно, але треба покопатись).

Terraform Kubernetes module

Окей, з провайдером розібрались – давайте додавати сам модуль.

Спочатку запустимо сам кластер с однією NodeGroup, а потім вже будемо додавати всякі контроллери.

Типи EKS NodeGroups

AWS має два типи NodeGroups – Self-Managed, та Amazon Managed, див. Amazon EKS nodes.

Головна, як на мене, перевага Amazon Managed це те, що ви не маєте перейматись оновленнями – все, що стосується операційної системи і компонентів самого Kubernetes, бере на себе Амазон:

Хоча якщо робити Self-managed Nodes використовуючи AMI від самого AWS з Amazon Linux – то там все вже буде налаштовано, і навіть для апдейтів достатньо ребутнути чи перестворити ЕС2 – тоді він запуститься з AMI з останніми патчами.

Окремо варто загадти Fargate – див. AWS: Fargate – можливості, порівняння з Lambda/EC2 та використання з AWS EKS, але я не бачу в них якогось великого сенсу, тим більш на них не зможемо створювати DaemonSets з, наприклад, Promtail для логів.

Також, Managed NodeGroups не потребують окремих налаштувать у aws-auth ConfigMap – EKS сам додасть необхідні записи.

Anayway, щоб полегшити собі життя – будемо використовувати Amazon Managed Nodes. На цих нодах будуть жити тільки контроллери – “Critical Addons”, а ноди для ворклоадів будуть менеджитись Karpenter-ом.

Terraform EKS variables

Спершу нам потрібні будуть змінні.

Взагалі добре пройтись по всім inputs, і подивитись що можна налаштувати під себе.

Для мінімального конфігу нам знадобляться:

  • cluster_endpoint_public_access – bool
  • cluster_enabled_log_types – list
  • eks_managed_node_groups:
    • min_size, max_size та desired_size – number
    • instance_types – list
    • capacity_type – string
    • max_unavailable_percentage – number
  • aws_auth_roles – map
  • aws_auth_users – map

Поділимо змінні на три групи – одна для самого EKS, друга – з параметрами для NodeGroups, і третя – для IAM Users.

Описуємо першу змінну – с параметрами для самого EKS:

...
variable "eks_params" {
  description = "EKS cluster itslef parameters"
  type = object({
    cluster_endpoint_public_access = bool
    cluster_enabled_log_types      = list(string)
  })
}

Та terraform.tfvars зі значеннями – поки включимо всі логи, потім залишимо тільки реально потрібні:

...
eks_params = {
  cluster_endpoint_public_access = true
  cluster_enabled_log_types      = ["audit", "api", "authenticator", "controllerManager", "scheduler"]
}

Далі, параметри для NodeGroups. Створимо об’єкт типу map, в якому зможемо додавати конфігурції для декількох груп, які будемо тримати в елементах з типом object, бо параметри будуть різних типів:

...
variable "eks_managed_node_group_params" {
  description = "EKS Managed NodeGroups setting, one item in the map() per each dedicated NodeGroup"
  type = map(object({
    min_size                   = number
    max_size                   = number
    desired_size               = number
    instance_types             = list(string)
    capacity_type              = string
    taints                     = set(map(string))
    max_unavailable_percentage = number
  }))
}

Приклад додавання Taints є тут>>>, тож описуємо їх та інші параметри у tfvars:

...
eks_managed_node_group_params = {
  default_group = {
    min_size       = 2
    max_size       = 6
    desired_size   = 2
    instance_types = ["t3.medium"]
    capacity_type  = "ON_DEMAND"
    taints = [
      {
        key    = "CriticalAddonsOnly"
        value  = "true"
        effect = "NO_SCHEDULE"
      },
      {
        key    = "CriticalAddonsOnly"
        value  = "true"
        effect = "NO_EXECUTE"
      }
    ]
    max_unavailable_percentage = 50
  }
}

І третя группа – список IAM юзерів, котрі будуть додані до aws-auth ConfgiMap для доступу до кластеру. Тут використовуємо тип set з ще одним object, бо для юзера потрібно буде передавати list зі список RBAC-груп:

...
variable "eks_aws_auth_users" {
  description = "IAM Users to be added to the aws-auth ConfigMap, one item in the set() per each IAM User"
  type = set(object({
    userarn  = string
    username = string
    groups   = list(string)
  }))
}

Значення в tfvars:

...
eks_aws_auth_users  = [
  {
    userarn  = "arn:aws:iam::492***148:user/arseny"
    username = "arseny"
    groups   = ["system:masters"]
  }
]

Як і з NodeGroups, тут ми зможемо задати кілька юзерів, і всі вони потім будуть передані до aws_auth_users модулю EKS.

Створення кластеру

Створюємо файл eks.tf, додаємо модуль:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 19.0"

  cluster_name    = "${local.env_name}-cluster"
  cluster_version = var.eks_version

  cluster_endpoint_public_access = var.eks_params.cluster_endpoint_public_access

  cluster_enabled_log_types = var.eks_params.cluster_enabled_log_types

  cluster_addons = {
    coredns = {
      most_recent = true
    }
    kube-proxy = {
      most_recent = true
    }
    vpc-cni = {
      most_recent = true
    }
  }

  vpc_id                   = module.vpc.vpc_id
  subnet_ids               = module.vpc.private_subnets
  control_plane_subnet_ids = module.vpc.intra_subnets

  manage_aws_auth_configmap = true

  eks_managed_node_groups = {
    default = {

      min_size       = var.eks_managed_node_group_params.default_group.min_size
      max_size       = var.eks_managed_node_group_params.default_group.max_size
      desired_size   = var.eks_managed_node_group_params.default_group.desired_size
      instance_types = var.eks_managed_node_group_params.default_group.instance_types
      capacity_type  = var.eks_managed_node_group_params.default_group.capacity_type

      taints = var.eks_managed_node_group_params.default_group.taints

      update_config = {
        max_unavailable_percentage = var.eks_managed_node_group_params.default_group.max_unavailable_percentage
      }
    }
  }

  cluster_identity_providers = {
    sts = {
      client_id = "sts.amazonaws.com"
    }
  }

  aws_auth_users = var.eks_aws_auth_users
  #aws_auth_roles = TODO
}

Якщо для Addons треба додати параметри – можна зробити з configuration_values, див. приклад тут>>>.

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

...
output "eks_cloudwatch_log_group_arn" {
  value = module.eks.cloudwatch_log_group_arn
}

output "eks_cluster_arn" {
  value = module.eks.cluster_arn
}

output "eks_cluster_endpoint" {
  value = module.eks.cluster_endpoint
}

output "eks_cluster_iam_role_arn" {
  value = module.eks.cluster_iam_role_arn
}

output "eks_cluster_oidc_issuer_url" {
  value = module.eks.cluster_oidc_issuer_url
}

output "eks_oidc_provider" {
  value = module.eks.oidc_provider
}

output "eks_oidc_provider_arn" {
  value = module.eks.oidc_provider_arn
}

Перевіряємо з terraform plan, деплоїмо, та перевіряємо сам кластер:

Створюємо ~/.kube/config:

$ aws --profile work --region us-east-1 eks update-kubeconfig --name atlas-eks-dev-1-27-cluster --alias atlas-eks-dev-1-27-work-profile
Updated context atlas-eks-dev-1-27-work-profile in /home/setevoy/.kube/config

Та перевіряємо доступ з can-i:

$ kubectl auth can-i get pod
yes

Додаткова IAM Role

Окремо створимо IAM Role з політикою eks:DescribeCluster, і підключимо її до кластеру в групу system:masters – використовуючи цю роль, інші юзери зможуть проходити авторизацію в кластері.

В роль нам потрібно буде передати AWS Account ID, щоб в Principal обмежити можливість виконання AssumeRole тільки юзерами цього акаунту.

Щоб не виносити це окремою змінною в variables.tf – в eks.tf додамо ресурс data "aws_caller_identity":

...
data "aws_caller_identity" "current" {}

І далі описуємо саму роль з assume_role_policy – кому буде дозволено assume цієї ролі, та inline_policy з дозволом на виконання eks:DescribeCluster:

...
resource "aws_iam_role" "eks_masters_access_role" {
  name = "${local.env_name}-masters-access-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Sid    = ""
        Principal = {
          AWS: "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
        }
      }
    ]
  })

  inline_policy {
    name = "${local.env_name}-masters-access-policy"

    policy = jsonencode({
      Version = "2012-10-17"
      Statement = [
        {
          Action   = ["eks:DescribeCluster*"]
          Effect   = "Allow"
          Resource = "*"
        },
      ]
    })
  }  

  tags = {
    Name  = "${local.env_name}-access-role"
  }
}

Повертаємось до module "eks" і в aws_auth_roles додаємо маппінг цієї ролі:

...
  aws_auth_users = var.eks_aws_auth_users
  aws_auth_roles = [
    {
      rolearn  = aws_iam_role.eks_masters_access_role.arn
      username = aws_iam_role.eks_masters_access_role.arn
      groups   = ["system:masters"]
    }
  ]
...

Додамо output:

...
output "eks_masters_access_role" {
  value = aws_iam_role.eks_masters_access_role.arn
}

Деплоїмо зміни:

$ terraform apply
...
Outputs:

...
eks_masters_access_role = "arn:aws:iam::492***148:role/atlas-eks-dev-1-27-masters-access-role"
...

Перевіряємо саму aws-auth ConfigMap:

$ kk -n kube-system get cm aws-auth -o yaml
apiVersion: v1
data:
...
  mapRoles: |
    - "groups":
      - "system:bootstrappers"
      - "system:nodes"
      "rolearn": "arn:aws:iam::492***148:role/default-eks-node-group-20230907145056376500000001"
      "username": "system:node:{{EC2PrivateDNSName}}"
    - "groups":
      - "system:masters"
      "rolearn": "arn:aws:iam::492***148:role/atlas-eks-dev-1-27-masters-access-role"
      "username": "arn:aws:iam::492***148:role/atlas-eks-dev-1-27-masters-access-role"
  mapUsers: |
    - "groups":
      - "system:masters"
      "userarn": "arn:aws:iam::492***148:user/arseny"
      "username": "arseny"
...

Додаємо новий профайл до ~/.aws/confing:

...
[profile work]
region = us-east-1
output = json

[profile eks-1-27-masters-role]
role_arn = arn:aws:iam::492***148:role/atlas-eks-dev-1-27-masters-access-role
source_profile = work

Додаємо новий контекст для kubectl:

$ aws --profile eks-1-27-masters-role --region us-east-1 eks update-kubeconfig --name atlas-eks-dev-1-27-cluster --alias eks-1-27-masters-role
Updated context eks-1-27-masters-role in /home/setevoy/.kube/config

І перевіряємо доступ:

$ kubectl auth can-i get pod
yes

$ kubectl get pod -A
NAMESPACE     NAME                       READY   STATUS    RESTARTS   AGE
kube-system   aws-node-99gg6             2/2     Running   0          41h
kube-system   aws-node-bllg2             2/2     Running   0          41h
...

В наступній частині вже встановимо решту – Karpenter та різні Controllers.

Помилка Get “http://localhost/api/v1/namespaces/kube-system/configmaps/aws-auth”: dial tcp: lookup localhost on 10.0.0.1:53: no such host

Під час тестів перестворював кластер, щоб впевнитись, що весь код, описаний тут, працює.

І при видаленні кластеру Terraform видавав помилку:

...
Plan: 0 to add, 0 to change, 34 to destroy.
...
╷
│ Error: Get "http://localhost/api/v1/namespaces/kube-system/configmaps/aws-auth": dial tcp: lookup localhost on 10.0.0.1:53: no such host
│ 
...

Рішення – видалити aws-auth зі стейт-файлу:

$ terraform state rm module.eks.kubernetes_config_map_v1_data.aws_auth[0]

Ясна річ, що робити це треба тільки для тестового кластеру, а не Production.