Це вже третя частина по розгортанню кластеру AWS Elastic Kubernetes Service з Terraform, в якій будемо додавати в наш кластер Karpenter. Вирішив винести окремо, бо виходить досить довгий пост. І вже в останній (сподіваюсь), четвертій частині, додамо решту – всякі контроллери.
Попередні частини:
- Terraform: створення EKS, частина 1 – VPC, Subnets та Endpoints
- Terraform: створення EKS, частина 2 – EKS кластер, WorkerNodes та IAM
Наступна, остання частина – Terraform: створення EKS, частина 4 – установка контроллерів.
Зміст
Планування
Що нам залишилось зробити:
- встановити Karpenter
- встановити EKS EBS CSI Addon
- встановити ExternalDNS контролер
- встановити AWS Load Balancer Controller
- додати SecretStore CSI Driver та ASCP
- встановити Metrics Server
- додати Vertical Pod Autoscaler та Horizontal Pod Autoscaler
- додати Subscription Filter до EKS Cloudwatch Log Group, щоб збирати логи в Grafana Loki (див. Loki: збір логів з CloudWatch Logs з використанням Lambda Promtail)
- створити StorageClass з
ReclaimPolicy=Retain
– для PVC, диски котрих треба зберігати при видаленні Deployment/StatefulSet
Структура файлів зараз виглядає так:
$ 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:
- написати все самому – IAM-ролі, SQS для interruption-handling, оновлення
aws-auth
ConfigMap, встановлення Helm-чарту з Karpenter, створення Provisioner та AWSNodeTemplate - використати готові модулі:
- від Антона Бабенко – сабмодуль
karpenter
модулю EKS - модуль
karpenter
від Amazon EKS Blueprints for 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 – там більше інформації.
Наразі це все – можна переходити до решти контроллерів.