Це вже третя частина по розгортанню кластеру 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-authConfigMap, встановлення 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 – там більше інформації.
Наразі це все – можна переходити до решти контроллерів.

