Terraform: “One Ring to rule them all!” – управління бекендами проектів
0 (0)

16 Вересня 2023

Вже писав про питання управління бекендами у постах Terraform: початок роботи та планування нового проекту – Dev/Prod та bootsrap та Terraform: динамічний remote state з AWS S3 та multiple environments по директоріям, повернемось до цієї теми знов.

Отже, вибрав все ж варіант з менеджментом бекендів через окремий проект Terraform, де в змінних маємо список проектів, яким треба мати AWS S3 bucket та таблицю DynamoDB, та їхніх оточень – Dev/Prod.

Потім в циклі for_each проходимось по елементам списку проектів, і створюємо необхідні ресурси.

В такому випадку девелоперам, щоб запустити новий проект, не треба мати справу зі створенням ресурсів для бекенду state-файлів взагалі – вони або самі можуть просто додати нове значення в змінну і виконати terraform apply, чи попросити когось з DevOps-тіми, а потім просто додати значення да власного backend.tf.

Бекенд для самого проекту який менеджить всі бекенди створюється ним же – в перший раз з локальним бекендом, а після створення проекту – його стейт туди імпортується, і надалі вже використовується цей remote state.

Providers

Тут у нас буде тільки AWS:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.14"
    }
  }
}

provider "aws" {
  region  = "us-east-1"
  profile = "tf-admin"
  default_tags {
    tags = {
      component  = "devops"
      created-by = "terraform"
    }
  }
}

Файл backend.tf поки не описуємо – робимо все локально.

Виконуємо terraform init, і переходимо до змінних.

Variables – список проектів і оточень

Тут нам потрібна по факту одна змінна з типом map(list(string)), в якій ми описуємо список проектів, для яких будемо створювати ресурси, в тому числі включаємо в неї сам проект, який буде створювати всі ці ресурси.

І для кожного елементу з іменем проекту в значення включаємо список з іменами оточень цього проекту:

variable "projects" {
  description = "Project names with their environments to be used in S3 and DynamoDB resources"
  type        = map(list(string))

  default = {
    atlas-tf-backends-test = [
      "prod"
    ]
    atlas-eks-test = [
      "dev", "prod"
    ]
  }
}

Resources

Створення AWS S3 бакетів

Що нам треба, це для кожного проекту створити AWS S3 Bucket, включити йому Versioning, додати Encryption, і заборонити публічний доступ до об’єктів через S3 Bucket ACL.

Щодо Dev/Prod оточень: можна створювати окремі корзини на кожен Env кожного проекту, чи один бакет на проект, а вже в самому проекті використовувати різні ключі, тобто:

  • проект atlas-eks-test
  • корзина atlas-eks-test
    • при terraform init Dev-оточення використовуємо -backend-config="key=dev/atlas-eks.tfstate"
    • при terraform init для Prod-оточення використовуємо -backend-config=key=prod/atlas-eks.tfstate

Для таблиць DynamoDB створимо окремі таблиці для Dev/Prod, а всякі feature-енви можна буде деплоїти або без State Lock, бо вони тимчасові, і будуть деплоїтись з якогось одного Pull Request з GitHub Actions, або при потребі – створювати таблицю під час деплою проекту командою AWS CLI create-table.

Отже – в змінних маємо map зі списком проектів.

Для S3 використовуємо for_each, з якого отримуємо each.key, який буде містити ім’я проекту, тобто “atlas-eks-test” або “atlas-tf-backends-test“:

# create state-files S3 buket 
resource "aws_s3_bucket" "state_backend" {
  for_each = var.projects
  bucket   = "tf-state-backend-${each.key}"

  # to drop a bucket, set to `true`
  force_destroy = false
  lifecycle {
    # to drop a bucket, set to `false`
    prevent_destroy = true
  }

  tags = {
    environment = var.environment
  }
}

Далі, для ресурсів aws_s3_bucket_versioning, aws_s3_bucket_server_side_encryption_configuration та aws_s3_bucket_public_access_block знов використовуємо for_each, але тепер ітерацію виконуємо по списку ресурсів aws_s3_bucket.state_backend, тобто весь код буде таким:

# create state-files S3 buket 
resource "aws_s3_bucket" "state_backend" {
  for_each = var.projects
  bucket   = "tf-state-backend-${each.key}"

  # to drop a bucket, set to `true`
  force_destroy = false
  lifecycle {
    # to drop a bucket, set to `false`
    prevent_destroy = true
  }

  tags = {
    environment = var.environment
  }
}

resource "aws_kms_key" "state_backend_kms_key" {
  description             = "This key is used to encrypt bucket objects"
  deletion_window_in_days = 10
}

# enable S3 bucket versioning
resource "aws_s3_bucket_versioning" "state_backend_versioning" {
  for_each = aws_s3_bucket.state_backend
  bucket   = each.value.id

  versioning_configuration {
    status = "Enabled"
  }
}

# enable S3 bucket encryption 
resource "aws_s3_bucket_server_side_encryption_configuration" "state_backend_encryption" {
  for_each = aws_s3_bucket.state_backend
  bucket   = each.value.id

  rule {
    apply_server_side_encryption_by_default {
      kms_master_key_id = aws_kms_key.state_backend_kms_key.arn
      sse_algorithm     = "aws:kms"
    }
    bucket_key_enabled = true
  }
}

# block S3 bucket public access
resource "aws_s3_bucket_public_access_block" "state_backend_acl" {
  for_each = aws_s3_bucket.state_backend
  bucket   = each.value.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

В ouputs додаємо відображення створених бакетів, використовуючи цикл for та String Templates:

output "state_backend_bucket_names" {
  value = "AWS S3 State Buckets:\n%{for name in aws_s3_bucket.state_backend}- ${name.bucket}\n%{endfor}"
}

Деплоїмо:

$ terraform apply
...
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

Outputs:

state_backend_bucket_names = <<EOT
AWS S3 State Buckets:
- tf-state-backend-atlas-eks-test
- tf-state-backend-atlas-tf-backends-test

Міграція власного state-файлу

Далі додаємо параметри до backend.tf:

terraform {
  backend "s3" {
    bucket         = "tf-state-backend-atlas-tf-backends-test"
    key            = "atlas-tf-backends.tfstate"
    region         = "us-east-1"
    profile        = "tf-admin"
    encrypt        = true
  }
}

І виконуємо terraform init ще раз, щоб перенести власний стейт з локального файлу terraform.tfstate до створеного S3 бакету:

$ terraform init

Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend to the
  newly configured "s3" backend. No existing state was found in the newly
  configured "s3" backend. Do you want to copy this state to the new "s3"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes
...
Terraform has been successfully initialized!

Створення таблиць DynamdoDB

Якщо для S3 ми робили одну корзину на кожен проект, то для DynamoDB буде окрема таблиця на кожен Env кожного проекту (хоча можна мати і одну – тоді Terraform при створенні ключів сам задасть значення Env, див. Backends S3).

Для цього використаємо locals та цикли for, як описано у Nested for loops для map of lists:

...

locals {
  table_names_list = flatten([
    # for 'atlas-eks-test["dev", "prod"]:
    for project, envs in var.projects : [
      # for 'dev', 'prod':
      for env in envs :
      # create 'atlas-eks-test-dev' && 'atlas-eks-test-prod':
      "${project}-${env}"
    ]
  ])
}

# create DynamoDB table
resource "aws_dynamodb_table" "state_lock" {
  for_each = toset(local.table_names_list)
  name     = "tf-state-lock-${each.value}"

  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }

  tags = {
    environment = var.environment
  }
}

Додамо outputs:

...

output "dynamodb_table_names" {
  value = "DynamoDB tables:\n%{for name in aws_dynamodb_table.state_lock}- ${name.name}\n%{endfor}"
}

Та деплоїмо:

$ terraform apply
...
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

dynamodb_table_names = <<EOT
DynamoDB tables:
- tf-state-lock-atlas-eks-test-dev
- tf-state-lock-atlas-eks-test-prod
- tf-state-lock-atlas-tf-backends-test-prod

EOT
state_backend_bucket_names = <<EOT
AWS S3 State Buckets:
- tf-state-backend-atlas-eks-test
- tf-state-backend-atlas-tf-backends-test

EOT

Видалення проекту і його S3 та DynamoDB

Якщо якийсь проект більш не актуальний, і треба видалити його ресурси – то це буде робитись в три етапи apply:

  1. міняємо параметри aws_s3_bucket:
    • включаємо force_destroy – це потрібно, щоб видалити корзини, в яких включено Versioning і які мають об’єкти
    • відключаємо prevent_destroy – щоб дозволити видалення
  2. виконуємо apply, щоб застосувати зміни
    • видаляємо проект з var.projects
  3. виконуємо apply, щоб видалити корзину та пов’язані ресурси
    • повертаємо значення параметрів aws_s3_bucketforce_destroy та prevent_destroy
  4. виконуємо apply, щоб застосувати зміни

Тобто:

# create state-files S3 buket 
resource "aws_s3_bucket" "state_backend" {
  for_each = var.projects
  bucket   = "tf-state-backend-${each.key}"

  # to drop a bucket, set to `true`
  force_destroy = true
  lifecycle {
    # to drop a bucket, set to `false`
    prevent_destroy = false
  }

  tags = {
    environment = var.environment
  }
}
...

Застосовуємо зміни на всі бакети:

$ terraform apply
...
Apply complete! Resources: 0 added, 2 changed, 0 destroyed.

Потім видаляємо ім’я проекту зі значень змінної projects:

variable "projects" {
  description = "Project names list with its environments to be used in S3 and DynamoDB nresources"
  type        = map(list(string))

  default = {
    atlas-tf-backends-test = [
      "prod"
    ]
  }
}

І виконуємо apply ще раз:

...
Apply complete! Resources: 0 added, 0 changed, 6 destroyed.

Outputs:

dynamodb_table_names = <<EOT
DynamoDB tables:
- tf-state-lock-atlas-tf-backends-test-prod

EOT
state_backend_bucket_names = <<EOT
AWS S3 State Buckets:
- tf-state-backend-atlas-tf-backends-test

EOT

Після чого повертаємо значення force_destroy та prevent_destroy, і виконуємо ще один apply.

Готово.

Loading

Terraform: створення EKS, частина 4 – установка контролерів
0 (0)

14 Вересня 2023

Остання, четверта частина, в якій ми встановимо решту контроллерів і додамо пару корисних дрібниць.

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

Планування

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

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

$ tree .
.
├── backend.tf
├── configs
│   └── karpenter-provisioner.yaml.tmpl
├── eks.tf
├── karpenter.tf
├── main.tf
├── outputs.tf
├── providers.tf
├── terraform.tfvars
├── variables.tf
└── vpc.tf

Поїхали.

EBS CSI driver

Цей аддон можна встановити з Amazon EKS Blueprints Addons, котрий далі будемо використовувати для ExternalDNS, але раз уж ставимо аддони через cluster_addons в модулі EKS, то давайте і цей зробимо таким же чином.

Для aws-ebs-csi-driver ServiceAccount  нам знадобиться окрема IAM Role – створимо її за допомогою IRSA Terraform Module.

Приклад для EBS CSI є тут – ebs_csi_irsa_role.

Створюємо файл iam.tf – тут будемо тримати окремі ресурси, пов’язані з AWS IAM:

module "ebs_csi_irsa_role" {
  source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"

  role_name             = "${local.env_name}-ebs-csi-role"
  attach_ebs_csi_policy = true

  oidc_providers = {
    ex = {
      provider_arn               = module.eks.oidc_provider_arn
      namespace_service_accounts = ["kube-system:ebs-csi-controller-sa"]
    }
  }
}

Виконуємо terraform init та деплоїмо:

Далі, знаходимо актуальну версію аддону для EKS 1.27:

$ aws eks describe-addon-versions --addon-name aws-ebs-csi-driver --kubernetes-version 1.27 --query "addons[].addonVersions[].[addonVersion, compatibilities[].defaultVersion]" --output text
v1.22.0-eksbuild.2
True
v1.21.0-eksbuild.1
False
...

В модулі EKS аддони встановлюються за допомогою ресурсу aws_eks_addon, див. main.tf.

Для версій можемо передати most_recent або addon_version. Краще, звісно, задавати версію явно.

Створимо окрему змінну для версій аддонів EKS – додаємо до variables.tf:

...

variable "eks_addons_version" {
  description = "EKS Add-on versions, will be used in the EKS module for the cluster_addons"
  type        = map(string)
}

Додаємо значення до tfvars:

...

eks_addons_version = {
  coredns            = "v1.10.1-eksbuild.3"
  kube_proxy         = "v1.27.4-eksbuild.2"
  vpc_cni            = "v1.14.1-eksbuild.1"
  aws_ebs_csi_driver = "v1.22.0-eksbuild.2"
}

Додаємо aws-ebs-csi-driver до cluster_addons в eks.tf:

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 19.0"
    ...
    vpc-cni = {
      addon_version = var.eks_addons_version.vpc_cni
    }
    aws-ebs-csi-driver = {
      addon_version            = var.eks_addons_version.aws_ebs_csi_driver
      service_account_role_arn = module.ebs_csi_irsa_role.iam_role_arn
    }
  }
...

Деплоїмо та перевіряємо поди:

$ kk -n kube-system get pod | grep csi
ebs-csi-controller-787874f4dd-nlfn2   5/6     Running   0          19s
ebs-csi-controller-787874f4dd-xqbcs   5/6     Running   0          19s
ebs-csi-node-fvscs                    3/3     Running   0          20s
ebs-csi-node-jz2ln                    3/3     Running   0          20s

Тестування EBS CSI

Створюємо маніфест пода з PVC:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: demo-pv
spec:
  accessModes:
    - ReadWriteOnce
  capacity:
    storage: 1Gi
  storageClassName: standard
  hostPath:
    path: /tmp/demo-pv
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-dynamic
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
  storageClassName: gp2
---
apiVersion: v1
kind: Pod
metadata:
  name: pvc-pod
spec:
  containers:
    - name: pvc-pod-container
      image: nginx:latest
      volumeMounts:
        - mountPath: /data
          name: data
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: pvc-dynamic

Деплоїмо, та перевіряємо статус:

$ kk get pod
NAME      READY   STATUS    RESTARTS   AGE
pvc-pod   1/1     Running   0          106s

$ kk get pvc
NAME          STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
pvc-dynamic   Bound    pvc-a83b3021-03d8-458f-ad84-98805ec4963d   1Gi        RWO            gp2            119s

$ kk get pv pvc-a83b3021-03d8-458f-ad84-98805ec4963d
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                 STORAGECLASS   REASON   AGE
pvc-a83b3021-03d8-458f-ad84-98805ec4963d   1Gi        RWO            Delete           Bound    default/pvc-dynamic   gp2                     116s

З цим все готово.

Terraform та ExternalDNS

Для ExternalDNS спробуємо Amazon EKS Blueprints Addons. На відміну від того, як ми робили EBS CSI, тут нам не потрібно буде окремо створювати IAM Role, бо модуль створить її сам.

Приклади є у файлі tests/complete/main.tf.

Правда, в документації чомусь вказана передача параметрів для чарту external_dns_helm_config (UPD – поки писав цей пост, вже видалили сторінку взагалі), хоча на ділі це призводить до помилки “An argument named “external_dns_helm_config” is not expected here“.

Щоб знайти, як жеж нам передати параметри – йдемо на сторінку модулю в eks-blueprints-addons, і дивимось які інпути є для external_dns:

Далі перевіряємо файл main.tf модулю, де бачимо змінну var.external_dns, в якій можна передати всі параметри.

Дефолтні версії чартів задаються у тому ж файлі, але вони місцями застарілі, теж задамо свої.

Знаходимо останню версію для ЕxternalDNS:

$ helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
"external-dns" has been added to your repositories

$ helm search repo external-dns/external-dns --versions
NAME                            CHART VERSION   APP VERSION     DESCRIPTION                                       
external-dns/external-dns       1.13.1          0.13.6          ExternalDNS synchronizes exposed Kubernetes Ser...
...

Додаємо змінну для версій чартів:

variable "helm_release_versions" {
  description = "Helm Chart versions to be deployed into the EKS cluster"
  type        = map(string)
}

У файл terraform.tfvars додаємо значення:

...

helm_release_versions = {
  karpenter    = "v0.30.0"
  external_dns = "1.13.1"
}

Зона у нас на кожне оточення своя, одна, тож просто задамо її у varables.tf як string:

...

variable "external_dns_zone" {
  type        = string
  description = "AWS Route53 zone to be used by ExternalDNS in domainFilters and its IAM Policy"
}

І значення в tfvars:

...

external_dns_zone = "dev.example.co"

Створюємо файл controllers.tf, і описуємо деплой ExternalDNS.

З data "aws_route53_zone" отримуємо інформацію про нашу Route53 Hosted Zone, і її ARN передаємо в параметр external_dns_route53_zone_arns.

Так як ми використовуємо VPC Endpoint для STS, то в аннотації ServiceAccount передаємо eks.amazonaws.com/sts-regional-endpoints="true" – аналогічно тому, як робили для Karpenter.

У external_dns.values передаємо бажані параметри – policy, в domainFilters наш домен, та задаємо tolerations, щоб под запускався на наших дефолтних нодах:

data "aws_route53_zone" "example" {
  name = var.external_dns_zone
}

module "eks_blueprints_addons" {
  source  = "aws-ia/eks-blueprints-addons/aws"
  version = "~> 1.7.2"

  cluster_name      = module.eks.cluster_name
  cluster_endpoint  = module.eks.cluster_endpoint
  cluster_version   = module.eks.cluster_version
  oidc_provider_arn = module.eks.oidc_provider_arn

  enable_external_dns = true
  external_dns = {
    namespace     = "kube-system"
    chart_version = var.helm_release_versions.external_dns
    values = [
      <<-EOT
        policy: upsert-only
        domainFilters: [ ${data.aws_route53_zone.example.name} ]
        tolerations:
        - key: CriticalAddonsOnly
          value: "true"
          operator: Equal
          effect: NoExecute
        - key: CriticalAddonsOnly
          value: "true"
          operator: Equal
          effect: NoSchedule          
      EOT
    ]
    set = [
      {
        name  = "serviceAccount.annotations.eks\\.amazonaws\\.com/sts-regional-endpoints"
        value = "true"
        type  = "string"
      }
    ]
  }
  external_dns_route53_zone_arns = [
    data.aws_route53_zone.example.arn
  ]
}

Деплоїмо, та перевіряємо под:

$ kk get pod -l app.kubernetes.io/instance=external-dns
NAME                            READY   STATUS    RESTARTS   AGE
external-dns-597988f847-rxqds   1/1     Running   0          66s

Тестування ExternalDNS

Створюємо Kubernetes Service з типом LoadBalancer:

apiVersion: v1
kind: Service
metadata:
  name: mywebapp
  labels:
    service: nginx
  annotations:
    external-dns.alpha.kubernetes.io/hostname: test-dns.dev.example.co
spec:
  selector:
    service: nginx
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 80

Деплоїмо, ті перевіряємо логи ExternalDNS:

...
time="2023-09-13T12:01:39Z" level=info msg="Desired change: CREATE cname-test-dns.dev.example.co TXT [Id: /hostedzone/Z09***BL9]"
time="2023-09-13T12:01:39Z" level=info msg="Desired change: CREATE test-dns.dev.example.co A [Id: /hostedzone/Z09***BL9]"
time="2023-09-13T12:01:39Z" level=info msg="Desired change: CREATE test-dns.dev.example.co TXT [Id: /hostedzone/Z09***BL9]"
time="2023-09-13T12:01:39Z" level=info msg="3 record(s) in zone dev.example.co. [Id: /hostedzone/Z09***BL9] were successfully updated

Готово.

Terraform та AWS Load Balancer Controller

Робимо аналогічно з ExternalDNS – будемо встановлювати з модуля Amazon EKS Blueprints Addons.

Спочатку нам треба протегати публічні і приватні сабнети – див. Subnet Auto Discovery.

Також перевірте, щоб на них був тег kubernetes.io/cluster/${cluster-name} = owned (має бути, якщо деплоїли з Terraform модулем EKS, як це робили в першій частині).

Додаємо теги через public_subnet_tags та private_subnet_tags:

...
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.1.1"
  ...
  public_subnet_tags = {
    "kubernetes.io/role/elb" = 1
  }

  private_subnet_tags = {
    "karpenter.sh/discovery"          = local.eks_cluster_name
    "kubernetes.io/role/internal-elb" = 1
  }
  ...
}
...

Створення модулю описано тут>>>.

Знаходимо актуальну версію чарту:

$ helm repo add aws-eks-charts https://aws.github.io/eks-charts
"aws-eks-charts" has been added to your repositories

$ helm search repo aws-eks-chart/aws-load-balancer-controller --versions
NAME                                            CHART VERSION   APP VERSION     DESCRIPTION                                       
aws-eks-chart/aws-load-balancer-controller      1.6.1           v2.6.1          AWS Load Balancer Controller Helm chart for Kub...
aws-eks-chart/aws-load-balancer-controller      1.6.0           v2.6.0          AWS Load Balancer Controller Helm chart for Kub...
...

Додаємо версію чарту до змінної helm_release_versions:

...

helm_release_versions = {
  karpenter                             = "0.16.3"
  external_dns                          = "1.13.1"
  aws_load_balancer_controller          = "1.6.1"
}

До файлу controllers.tf додаємо ресурс aws_load_balancer_controller:

...

  enable_aws_load_balancer_controller = true
  aws_load_balancer_controller = {
    namespace     = "kube-system"
    chart_version = var.helm_release_versions.aws_load_balancer_controller
    values = [
      <<-EOT
        tolerations:
        - key: CriticalAddonsOnly
          value: "true"
          operator: Equal
          effect: NoExecute
        - key: CriticalAddonsOnly
          value: "true"
          operator: Equal
          effect: NoSchedule          
      EOT
    ]
    set = [
      {
        name  = "serviceAccount.annotations.eks\\.amazonaws\\.com/sts-regional-endpoints"
        value = "true"
        type  = "string"
      }
    ]
  }
}

Деплоїмо, перевіряємо под:

$ kk get pod | grep aws
aws-load-balancer-controller-7bb9d7d8-4m4kz   1/1     Running   0          99s
aws-load-balancer-controller-7bb9d7d8-8l58n   1/1     Running   0          99s

Тестування AWS Load Balancer Controller

Додаємо Pod, Service та Ingress:

---
apiVersion: v1
kind: Pod
metadata:
  name: hello-pod
  labels:
    app: hello
spec:
  containers:
    - name: hello-container
      image: nginxdemos/hello
      ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: hello-service
spec:
  type: NodePort
  selector:
    app: hello
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: hello-ingress
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
spec:
  ingressClassName: alb
  rules:
    - host: test-dns.dev.example.co
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: hello-service
                port:
                  number: 80

Деплоїмо, перевіряємо Ingress – чи додався до нього Load Balancer:

$ kk get ingress
NAME            CLASS   HOSTS                     ADDRESS                                                 PORTS   AGE
hello-ingress   alb     test-dns.dev.example.co   k8s-kubesyst-helloing-***.us-east-1.elb.amazonaws.com   80      45s

З цим теж готово.

Terraform та SecretStore CSI Driver і ASCP

SecretStore CSI Driver та AWS Secrets and Configuration Provider (ASCP) нам потрібні, щоб в Kubernetes підключати значення з AWS Secrets Manager та Parameter Store, див. AWS: Kubernetes – інтеграція AWS Secrets Manager та Parameter Store.

Тут теж зробимо з Amazon EKS Blueprints Addons – див. secrets_store_csi_driver та secrets_store_csi_driver_provider_aws.

Ніяк налаштувань тут не треба – тільки додати Tolerations.

Знаходимо версії:

$ helm repo add secrets-store-csi-driver https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts
"secrets-store-csi-driver" has been added to your repositories

$ helm repo add secrets-store-csi-driver-provider-aws https://aws.github.io/secrets-store-csi-driver-provider-aws
"secrets-store-csi-driver-provider-aws" has been added to your repositories

$ helm search repo secrets-store-csi-driver/secrets-store-csi-driver
NAME                                                    CHART VERSION   APP VERSION     DESCRIPTION                                       
secrets-store-csi-driver/secrets-store-csi-driver       1.3.4           1.3.4           A Helm chart to install the SecretsStore CSI Dr...

$ helm search repo secrets-store-csi-driver-provider-aws/secrets-store-csi-driver-provider-aws
NAME                                                    CHART VERSION   APP VERSION     DESCRIPTION                                       
secrets-store-csi-driver-provider-aws/secrets-s...      0.3.4                           A Helm chart for the AWS Secrets Manager and Co...

Додаємо нові значення до змінної helm_release_versions у terraform.tfvars:

...

helm_release_versions = {
  karpenter                             = "0.16.3"
  external_dns                          = "1.13.1"
  aws_load_balancer_controller          = "1.6.1"
  secrets_store_csi_driver              = "1.3.4"
  secrets_store_csi_driver_provider_aws = "0.3.4"
}

Додаємо самі модулі до controllers.tf:

...

  enable_secrets_store_csi_driver = true
  secrets_store_csi_driver = {
    namespace     = "kube-system"
    chart_version = var.helm_release_versions.secrets_store_csi_driver
    values = [
      <<-EOT
        syncSecret:
          enabled: true
        tolerations:
        - key: CriticalAddonsOnly
          value: "true"
          operator: Equal
          effect: NoExecute
        - key: CriticalAddonsOnly
          value: "true"
          operator: Equal
          effect: NoSchedule          
      EOT
    ]
  }

  enable_secrets_store_csi_driver_provider_aws = true
  secrets_store_csi_driver_provider_aws = {
    namespace     = "kube-system"
    chart_version = var.helm_release_versions.secrets_store_csi_driver_provider_aws
    values = [
      <<-EOT
        tolerations:
        - key: CriticalAddonsOnly
          value: "true"
          operator: Equal
          effect: NoExecute
        - key: CriticalAddonsOnly
          value: "true"
          operator: Equal
          effect: NoSchedule          
      EOT
    ]
  }
}

IAM Policy

Для IAM Roles, які ми потім будемо додавати до сервісів, якім потрібен доступ до AWS SecretsManager/ParameterStore треба буде підключати політику, яка дозволяє доступ до відповідних AWS API викликів.

Створимо її разом з EKS в нашому файлі iam.tf:

resource "aws_iam_policy" "sm_param_access" {
  name        = "sm-and-param-store-ro-access-policy"
  description = "Additional policy to access Serets Manager and Parameter Store"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "secretsmanager:DescribeSecret",
          "secretsmanager:GetSecretValue",
          "ssm:DescribeParameters",
          "ssm:GetParameter",
          "ssm:GetParameters",
          "ssm:GetParametersByPath"
        ]
        Effect   = "Allow"
        Resource = "*"
      },
    ]
  })
}

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

$ kk -n kube-system get pod | grep secret
secrets-store-csi-driver-bl6s5                3/3     Running   0          86s
secrets-store-csi-driver-krzvp                3/3     Running   0          86s
secrets-store-csi-driver-provider-aws-7v7jq   1/1     Running   0          102s
secrets-store-csi-driver-provider-aws-nz5sz   1/1     Running   0          102s
secrets-store-csi-driver-provider-aws-rpr54   1/1     Running   0          102s
secrets-store-csi-driver-provider-aws-vhkwl   1/1     Running   0          102s
secrets-store-csi-driver-r4rrr                3/3     Running   0          86s
secrets-store-csi-driver-r9428                3/3     Running   0          86s

Тестування SecretStore CSI Driver

Для доступа поду до SecretStore потрібно додати ServiceAccount з IAM-ролью.

Політику вже створили – додамо IAM Role.

Описуємо її Trust policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Principal": {
        "Federated": "arn:aws:iam::492***148:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/268***3CE"
      },
      "Condition": {
        "StringEquals": {
          "oidc.eks.us-east-1.amazonaws.com/id/268***3CE:aud": "sts.amazonaws.com",
          "oidc.eks.us-east-1.amazonaws.com/id/268***3CE:sub": "system:serviceaccount:default:ascp-test-serviceaccount"
        }
      }
    }
  ]
}

Створюємо роль:

$ aws iam create-role --role-name ascp-iam-role --assume-role-policy-document file://ascp-trust.json

Підключаємо політику:

$ aws iam attach-role-policy --role-name ascp-iam-role --policy-arn=arn:aws:iam::492***148:policy/sm-and-param-store-ro-access-policy

Створюємо запис в AWS Parameter Store:

$ aws ssm put-parameter --name eks-test-param --value 'paramLine' --type "String"
{
    "Version": 1,
    "Tier": "Standard"
}

Описуємо маніфест SecretProviderClass:

---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: eks-test-secret-provider-class
spec:         
  provider: aws
  parameters:
    objects: |
        - objectName: "eks-test-param"
          objectType: "ssmparameter"
---       
apiVersion: v1
kind: ServiceAccount
metadata:
  name: ascp-test-serviceaccount
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::492***148:role/ascp-iam-role
    eks.amazonaws.com/sts-regional-endpoints: "true"
---
apiVersion: v1
kind: Pod
metadata:
  name: ascp-test-pod
spec:
  containers:
    - name: ubuntu
      image: ubuntu
      command: ['sleep', '36000']
      volumeMounts:
      - name: ascp-test-secret-volume
        mountPath: /mnt/ascp-secret
        readOnly: true
  restartPolicy: Never
  serviceAccountName: ascp-test-serviceaccount
  volumes:
  - name: ascp-test-secret-volume
    csi:
      driver: secrets-store.csi.k8s.io
      readOnly: true
      volumeAttributes:
        secretProviderClass: eks-test-secret-provider-class

Деплоїмо (у default неймспейс, бо в Trust policy маємо перевірку subject на "system:serviceaccount:default:ascp-test-serviceaccount"), і перевіряємо файл в поді:

$ kk exec -ti pod/ascp-test-pod -- cat /mnt/ascp-secret/eks-test-param
paramLine

Terraform та Metrics Server

Тут теж зробимо з Amazon EKS Blueprints Addons – див. metrics_server.

Теж ніяк додаткових налаштувань не треба – просто включити, і перевірити. Навіть версію можна не задавати, тільки tolerations.

Додаємо до controllers.tf:

...

  enable_metrics_server = true
  metrics_server = {
    values = [
      <<-EOT
        tolerations:
        - key: CriticalAddonsOnly
          value: "true"
          operator: Equal
          effect: NoExecute
        - key: CriticalAddonsOnly
          value: "true"
          operator: Equal
          effect: NoSchedule          
      EOT
    ]    
  }
}

Деплоїмо, перевіряємо под:

$ kk get pod | grep metr
metrics-server-76c55fc4fc-b9wdb               1/1     Running   0          33s

І перевіряємо з kubectl top:

$ kk top node
NAME                          CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%   
ip-10-1-32-148.ec2.internal   53m          2%     656Mi           19%       
ip-10-1-49-103.ec2.internal   56m          2%     788Mi           23%       
...

Terraform та Vertical Pod Autoscaler

Щось забув, що для Horizontal Pod Autoscaler окремого контроллеру не треба, тож тут нам знадобиться тільки додати Vertical Pod Autoscaler.

Беремо знову з Amazon EKS Blueprints Addons, див. vpa.

Знаходимо версію:

$ helm repo add vpa https://charts.fairwinds.com/stable
"vpa" has been added to your repositories

$ helm search repo vpa/vpa
NAME    CHART VERSION   APP VERSION     DESCRIPTION                                       
vpa/vpa 2.5.1           0.14.0          A Helm chart for Kubernetes Vertical Pod Autosc...

Додаємо до terraform.tfvars:

helm_release_versions = {
  ...
  vpa                                   = "2.5.1"
}

Додаємо до controllers.tf:

...

  enable_vpa = true
  vpa = {
    namespace = "kube-system"
    values = [
      <<-EOT
        tolerations:
        - key: CriticalAddonsOnly
          value: "true"
          operator: Equal
          effect: NoExecute
        - key: CriticalAddonsOnly
          value: "true"
          operator: Equal
          effect: NoSchedule          
      EOT
    ]
  }
}

Деплоїмо, перевіряємо поди:

$ kk get pod | grep vpa
vpa-admission-controller-697d87b47f-tlzmb     1/1     Running             0          70s
vpa-recommender-6fd945b759-6xdm6              1/1     Running             0          70s
vpa-updater-bbf597fdd-m8pjg                   1/1     Running             0          70s

Та CRD:

$ kk get crd | grep vertic
verticalpodautoscalercheckpoints.autoscaling.k8s.io         2023-09-14T11:18:06Z
verticalpodautoscalers.autoscaling.k8s.io                   2023-09-14T11:18:06Z

Тестування Vertical Pod Autoscaler

Описуємо Deployment та VPA:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hamster
spec:
  selector:
    matchLabels:
      app: hamster
  replicas: 2
  template:
    metadata:
      labels:
        app: hamster
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65534 # nobody
      containers:
        - name: hamster
          image: registry.k8s.io/ubuntu-slim:0.1
          resources:
            requests:
              cpu: 100m
              memory: 50Mi
          command: ["/bin/sh"]
          args:
            - "-c"
            - "while true; do timeout 0.5s yes >/dev/null; sleep 0.5s; done"
---
apiVersion: "autoscaling.k8s.io/v1"
kind: VerticalPodAutoscaler
metadata:
  name: hamster-vpa
spec:
  targetRef:
    apiVersion: "apps/v1"
    kind: Deployment
    name: hamster
  resourcePolicy:
    containerPolicies:
      - containerName: '*'
        minAllowed:
          cpu: 100m
          memory: 50Mi
        maxAllowed:
          cpu: 1
          memory: 500Mi
        controlledResources: ["cpu", "memory"]

Деплоїмо, і за хвилину-дві перевіряємо VPA:

$ kk get vpa
NAME          MODE   CPU    MEM         PROVIDED   AGE
hamster-vpa          100m   104857600   True       61s

Та статус подів:

$ kk get pod
NAME                       READY   STATUS        RESTARTS   AGE
hamster-8688cd95f9-hswm6   1/1     Running       0          60s
hamster-8688cd95f9-tl8bd   1/1     Terminating   0          90s

Готово.

Додавання Subscription Filter до Cloudwatch Log Group з Terraform

Майже все зробили. Залишилось дві дрібниці.

Спершу – додати форвадніг логів з EKS Cloudwatch Log Groups до Lambda-функції, в якій працює Promtail, який буде ці логи пересилати до інстансу Grafana Loki.

Наш EKS модуль створює CloudWatch Log Group /aws/eks/atlas-eks-dev-1-27-cluster/cluster зі стрімами:

І виводить ім’я цієї групи через output cloudwatch_log_group_name, який ми можемо використати у aws_cloudwatch_log_subscription_filter, щоб до цієї лог-групи додати фільтр, в якому треба передати destination_arn з ARN нашої Lambda.

Lambda-функція у нас вже є, створюється окремою автоматизацію для моніторинг-стеку. Щоб отримати її ARN – використаємо data "aws_lambda_function", в який передамо ім’я функції, а саме ім’я винесемо у змінні:

variable "promtail_lambda_logger_function_name" {
  type        = string
  description = "Monitoring Stack's Lambda Function with Promtail to collect logs to Grafana Loki"
}

Значення в tfvars:

...

promtail_lambda_logger_function_name  = "grafana-dev-1-26-loki-logger-eks"

Щоб наш Subscription Filter мав змогу звератись до цієї функції – потрібно додати aws_lambda_permission, де в source_arn передаємо ARN нашої лог-групи. Тут зверніть увагу, що ARN передається як arn::name:*.

У principal треба вказати logs.AWS_REGION.amazonaws.com – AWS_REGION отримаємо з data "aws_region".

Описуємо ресурси в eks.tf:

...

data "aws_lambda_function" "promtail_logger" {
  function_name = var.promtail_lambda_logger_function_name
}

data "aws_region" "current" {}

resource "aws_lambda_permission" "allow_cloudwatch_for_promtail" {
  statement_id  = "AllowExecutionFromCloudWatch"
  action        = "lambda:InvokeFunction"
  function_name = var.promtail_lambda_logger_function_name
  principal     = "logs.${data.aws_region.current.name}.amazonaws.com"
  source_arn    = "${module.eks.cloudwatch_log_group_arn}:*"
}

resource "aws_cloudwatch_log_subscription_filter" "eks_promtail_logger" {
  name            = "eks_promtail"
  log_group_name  = module.eks.cloudwatch_log_group_name
  filter_pattern  = ""
  destination_arn = data.aws_lambda_function.promtail_logger.arn
}

Деплоїмо, і перевіряємо Log Group:

І логи в Grafana Loki:

Створення StorageClass з Terraform

В іншому модулі EKS для Terraform – cookpad/terraform-aws-eks – це можна було зробити через файл шаблону storage_classes.yaml.tmpl, але в нашому модулі такого нема.

Втім, робиться це одним маніфестом, так що додаємо його в наш eks.tf:

...

resource "kubectl_manifest" "storageclass_gp2_retain" {

  yaml_body = <<YAML
    apiVersion: storage.k8s.io/v1
    kind: StorageClass
    metadata:
      name: gp2-retain
    provisioner: kubernetes.io/aws-ebs
    reclaimPolicy: Retain
    allowVolumeExpansion: true
    volumeBindingMode: WaitForFirstConsumer  
  YAML
}

Деплоїмо, і перевіряємо доступні StorageClass:

$ kk get storageclass
NAME            PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
gp2 (default)   kubernetes.io/aws-ebs   Delete          WaitForFirstConsumer   false                  27h
gp2-retain      kubernetes.io/aws-ebs   Retain          WaitForFirstConsumer   true                   5m46s

Ну і нарешті – на цьому все.

Наче додав все, що потрібно для повноцінного кластеру AWS Elastic Kubernetes Service.

Loading

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

14 Вересня 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 – там більше інформації.

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

Loading

RTFM: як і для чього пишеться цей блог?
0 (0)

9 Вересня 2023

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

Навіщо вести свій IT блог?

Цей блог я починав головним чином як такий собі блокнот для себе самого – просто записувати як і що я робив, аби потім не шукати в інтернеті якісь мануали, чи записувати те, чого в інтернеті не було взагалі.

Згодом, це трансформувалося в бажання поділитись тим, який я не в біса крутий спеціаліст, бо коли ти вперше збираєш ядро FreeBSD, то здається, що ти бог 🙂 Це, звісно, та ще бугогашенька, бо вперше ядро я зібрав ще у 2007, і робив це як та “мавпа з мануалом” (привіт, Хом’як!) – читав, копіпастив, але дуже мало що розумів.

Насправді коли я вже трохи набрався досвіду, то ведення блогу дало ще один важливий бонус – він допомагає боротись з власним “синдромом самозванця”, бо навіть після (ОМГ!) 18 років в IT цей синдром нікуди не дівався, і я досі іноді думаю “Блін, а нє хєрню лі я написав?”. Проте коли твої пости вже лайкають солюшен архітектори з якогось Oracle – то це дуже допомагає відчувати себе людиною.

Власний бренд

По важливості я б це поставив на друге місце, але почати чомусь хочеться саме з цього.

Ваш блог – це ваш бренд.

Дуже часто, особливо вже за останні років 6-7, приходячи на співбесіду я чув “О! То ви адмін того самого RTFM? Круто – я там дуже багато для себе знайшов!”.

Ось це і є брендом – коли тебе впізнають, як спеціаліста.

Хтось виступає на конференціях – але я надто боюся публіки. А хтось тихенько пише собі бложег, де, звісно, теж публіка, і теж іноді ставлять “мінуси” – але це принаймні “не особисто”.

І от тут ми підходимо до другого пункту:

Власний розвиток

Я вже не представляю як можна виконувати якусь складну задачу, і не вести нотатки в чернетці блогу.

Бо, по-перше, коли ти пишеш – ти структуруєш інформацію, яку отримуєш. Тобі потрібно подати матеріал так, щоб він був зрозумілий і іншим, а для цього потрібно добре зуміти пов’язати всі “компоненти” посту у власній голові.

Це прям дуже, дуже допомагає краще зрозуміти щось нове, запам’ятати це, краще усвідомити всі ті “moving parts” нової системи.

І тут є ще один важливий момент. Колись (я навіть пам’ятаю цей пост – Apache: MPM – worker, prefork или event?, 2012 рік) я показав свій новий пост другу-сисадміну. Дуже прошарений чувак (привіт, Андрій!).

Після того, як він його прочитав, він сказав мені щось на кшталт “Ну, так, прикольно, але ось тут і ось тут відчувається, що ти не дуже шариш в темі”.

І саме відтоді я усвідомив, що, блін – треба копати. Не можна просто “на тяп-ляп” взяти, і написати. Треба писати вдумуючись, усвідомлюючи що і навіщо ти робиш.

А ця потреба призводить до того, що коли ти розбираєшся з новим матеріалом – ти вже не можеш скіпнути якусь не дуже зрозумілу частину – “А, потім розберусь, ілі взагалі фіг з нею”.

Ніт! Ти мусиш сісти, і розібратись. І саме це дуже допомогає у власному розвитку, як спеціаліста, бо потім, коли на якійсь співбесіді тебе питають по якійсь темі – то ти можеш розкрити якісь деталі цієї теми, показати, що ти дійсно розбираєшся в темі, а не просто “навкоси” прочитав документацію, скопіпастив команду, запустив сервіс, і вважаєш, що ти його знаєш.

Взагалі, уважність до деталей, уміння розібратись з тим, “а що там під капотом” дуже допомогає в роботі. Бо тільки знаючи ЯК система працює, що відбувається у неї всередині, ти можеш зрозуміти куди копати, щоб потім цю систему пофіксити.

Повертаючись трохи назад до “усвідомлюючи що і навіщо ти робиш” – така звичка у веденні блогу вже стала звичкою і в роботі: не можна просто “взяти, зробити, і забити”. Ти маєш розуміти що і як ти зробив, бо, по-перше – тобі ж  цю систему потім і підтримувати, по-друге – ти несеш відповідальність за те, що ти робиш. А враховуючи те, що наша, девопсів, робота дуже багато пов’язана з інфраструктурою, з цим “фундаментом” будь-якого проекту, з усіма його даними – то відчуття відповідальності тут вкрай необхідно.

Власна документація

Часто, прям дуже часто я повертаюсь до якихось старих постів, щоб подивитись що і як я робив. Це допомагає і під час сетапу якоїсь вже знайомої системи на новому проекті, і під час спроб зрозуміти що зламалось на поточному.

Навіть більше – у твоїх колег є доступ до інформації як саме ти піднімав якусь систему, і коли я був тім-лідом – то хлопці дуже часто використовували РТФМ, щоб розібратись з чимось, що я колись сетапив.

Ведення блогу, звісно, не значить, що можна не вести “локальну документацію” десь в проектному Confluence, але в своєму блозі ти можеш набагато краще описати що і навіщо ти робив, і чому зробив саме так, а не інакше.

Як вести свій блог?

Перше, і головне, над чим я замислювався раніше, коли цей блог тільки починався, це:

А про що, власне, писати?

І зараз тут відповідь та ж сама, що була тоді: про те, що ти робиш, з чим ти стикаєшся, або про те, що не дуже розумієш – а тобі треба розібратись.

Але саме, мабуть, важке, це саме створити структуру поста – зрозуміти про що саме писати, і як саме це висловити – що до чого відноситься, про що написати в першу чергу, про що в другу. Що винести окремою частиною – а про що достатньо буде написати пару речень.

Наприклад ось, як я “морально готувався” до написання цього поста:

Структура матеріалу

Навіть зараз, коли я пишу цей матеріал – я його перечитую, і розділяю на частини (під)заголовками, щоб вони якось логічно розбивали все те, про що тут написано.

Ось ще один приклад зі старих чернеток:

Ти починаєш писати про щось одне, а потім розумієш, що треба розказати і про щось ще, а потім ще про щось… І в результаті сидиш перед мільйоном вкладок, і кашею в голові. Але тобі все одно треба зібратись, і довести це до кінця, і подати так, щоб людина, яка буде читати цей матеріал, зрозуміла що ж насправді ти тут робиш.

Або ось такий приклад:

Тут на початку статті я накидую текст того, про що саме буде йти мова – бо це потім допомагає в голові тримати “нить повествования”.

Шарь в темі!

Так – треба добре розуміти, про що ти пишеш. Але й боятися писати (бо “що ж подумають люди?!?”) – теж не треба.

Ось ще приклад, як іноді виглядає процес написання деяких постів:

Бо знову ж таки – не можна “на тяп-ляп”, а треба розібратись.

Ще, мабуть, варто сказати про довжину постів: краще уникати “полотенець”. Краще розбити пост на декілька частин, і в кожній описати окремо якусь частину теми, ніж намагатись все впихнути в один пост.

Це допоможе і при написанні – бо все ж менша “каша в голові”, і при читанні, бо знов – менше каша в голові у читача.

Мови блогу

Якщо у вас є змога – то краще писати відразу на англійській.

По-перше – це круто.

По-друге – ви не обмежуєте себе читачами тільки з України.

По-третє – це дуже класна практика англійської.

Щодо правопису і помилок – головне, щоб вас зрозуміли. Крім того, можна скористатись плагінами типу Grammarly або LanguageTool, і навіть Google Translate.

Ще для допомоги з перекладами є чудові рішення типу Reverso Context, Reverso Grammar Checker & Rephraser та Deepl.

Де писати?

Тут вибір вкрай широкий – від готових платформ типу Medium – до власного VPS з WordPress.

Я колись вибрав саме WordPress, і саме на виділеному VPS, щоб мати змогу отримати додатковий досвід з адміністрування Linux та всяких Apache/Nginx/PHP/MySQL – і це було дійсно дуже корисним.

Ще один момент, котрий треба мати на увазі: враховуйте той момент, що ведучи свій блог на платформах типу Medium ви фактично довіряєте всю інформацію йому, це такий собі “вендер-лок”, бо потім мігрувати з одної платформи на іншу може бути дуже боляче, тим більш, якщо у вас буде пару тисяч постів.

В принципі, теж стосується і якихось self-hosted платформ типу Jekyll – якщо розробники Jekyll його закинуть, або змінять цінову політику, то ви можете залишитись у “розбитого корита”.

І в цьому плані WordPress мене більш, ніж влаштовує, бо платформа слава богу існує вже багато років, а зараз навіть пропонує оформити реєстрацію на сто років наперед (The 100-Year Plan on WordPress) – оптимісти 🙂

Висновки

Чи є сенс у ведені свого блогу? Для мене відповідь очевидна, бо це дійсно дуже допомагає і в роботі, і у власному розвитку, і у кар’єрі.

Проте треба усвідомлювати, чи на це потрібен час. Деякі пости на РТФМ пишуться тиждень, а то й більше, а потім ще день-два для перекладу на англійську.

Також треба розуміти, що зовсім не відразу блог будуть читати, і що перші місяці у вас може бути пару випадкових відвідувачів на день.

Втім ті плюси, які дає ведення блогу, однозначно варті того, щоб витрачати на це свій час.

Навіть якщо вас ніхто не буде читати – ви навчитесь висловлювати свої думки, подавати матеріал, або прокачаєте свою англійську. У вас завжди буде ваша власна документація. Ви набагато краще будете розуміти те, що робили, коли писали якийсь новий пост.

Loading

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

9 Вересня 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.

Loading

Terraform: створення EKS, частина 1 – VPC, Subnets та Endpoints
0 (0)

7 Вересня 2023

Отже, з Терраформом трохи розібрались, згадали що до чого – час робити щось реальне.

Перше, що будемо розгортати з Terraform – це кластер AWS Elastic Kubernretes Service та всі пов’язані з ним ресурси, бо зараз це зроблено з AWS CDK, і окрім інших проблем з CDK, вимушені мати EKS 1.26, бо 1.27 в CDK ще не підтримується, а в Terraform є.

В цій, першій частині, буде описано створення ресурсів AWS, в другій – створення кластеру (Terraform: створення EKS, частина 2 – EKS кластер, WorkerNodes та IAM), а в третій – встановлення Karpenter та інших контроллерів.

Планування

Що треба зробити – це описати розгортання EKS кластеру і встановити різні дефолтні штуки типу контроллерів:

Будемо використовувати Terraform modules для VPC та EKS від Антона Бабенко, бо в них вже реалізована більша частина того, що треба буде створити.

Dev/Prod оточення

Тут використаємо підхід з розділенням по окремим директоріям з використанням модулів, див. Terraform: динамічний remote state з AWS S3 та multiple environments по директоріям.

Тобто зараз структура каталогів/файлів виглядає так:

$ tree terraform
terraform
└── environments
    ├── dev
    │   ├── backend.tf
    │   ├── main.tf
    │   ├── outputs.tf
    │   ├── providers.tf
    │   ├── terraform.tfvars
    │   └── variables.tf
    └── prod

4 directories, 6 files

Як все буде готово на Dev – скопіюємо до Prod, і оновимо файл terraform.tfvars.

Terraform debug

При виникненні проблем – включаємо дебаг-лог через змінну TF_LOG та вказуємо рівень:

$ export TF_LOG=INFO
$ terraform apply

Підготовка Terraform

Описуємо AWS Provider, і відразу задаємо default_tags, які будуть додані до всіх ресурсів, створені за допомогою провайдера. Потім окремо ще в самих ресурсах додамо теги типу Name.

Авторизацію провайдера робимо через IAM Role (див. Authentication and Configuration), бо саме вона буде потім додана як “прихований root-юзер EKS-кластеру”, див. Enabling IAM principal access to your cluster:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.14.0"
    }
  }
}

provider "aws" {
  region  = "us-east-1"
  assume_role {
    role_arn = "arn:aws:iam::492***148:role/tf-admin"
  }
  default_tags {
    tags = {
      component = var.component
      created-by = "terraform"
      environment = var.environment
    }
  }  
}

А аутентифікацію в самому AWS – через змінні оточення AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY та AWS_REGION.

Створюємо файл backend.tf – корзина та DynamoDB таблиця вже створені з іншого проекту (я таки вирівшив винести управління S3 та DynamoDB окремим проектом Terraform в окремому репозиторії):

terraform {
  backend "s3" {
    bucket = "tf-state-backend-atlas-eks"
    key    = "dev/atlas-eks.tfstate"
    region = "us-east-1"
    dynamodb_table = "tf-state-lock-atlas-eks"
    encrypt = true
  }
}

Додаємо перші variables:

variable "project_name" {
  description = "A project name to be used in resources"
  type        = string
  default     = "atlas-eks"
}

variable "component" {
  description = "A team using this project (backend, web, ios, data, devops)"
  type = string
}

variable "environment" {
  description = "Dev/Prod, will be used in AWS resources Name tag, and resources names"
  type        = string
}

variable "eks_version" {
  description = "Kubernetes version, will be used in AWS resources names and to specify which EKS version to create/update"
  type        = string
}

І додаємо terraform.tfvars. Сюди вносимо всі не-sensitive дані, а sensitive будемо передавати через -var або змінні оточення в CI/CD у формі TF_VAR_var_name:

project_name = "atlas-eks"
environment        = "dev"
component          = "devops"
eks_version        = "1.27"
vpc_cidr           = "10.1.0.0/16"

З project_name, environment та eks_version далі зможемо створювати ім’я як:

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

Поїхали.

Створення AWS VPC з Terraform

Для VPC нам потрібні будуть AvailabilityZones, отримаємо їх за допомогою data "aws_availability_zones", бо в майбутньому скоріш за все будемо мігрувати в інші регіони AWS.

Для створення VPC з Terraform візьмемо модуль від @Anton Babenko – terraform-aws-vpc.

VPC Subnets

Для модулю нам потрібно буде передати публічні та приватні сабнети у вигляді CIDR-блоків.

Є варіант порахувати їх самому і передавати через variables. Для цього можемо використати або IP Calculator, або Visual Subnet Calculator.

Обидва інструменти досить цікаві, бо в IP Calculator дуже добре відображає інформацію в тому числі у binary виді, а в Visual Subnet Calculator дуже наглядно показується як саме блок розбивається на менші блоки:

Інший підхід – створювати блоки прямо в коді за допомогою функції cidrsubnets, яка використовується в модулі terraform-aws-vpc.

І третій підхід – зробити менеджмент адрес через ще один модуль, наприклад subnets. Спробуємо його (насправді під капотом він теж використовує ту ж саму функцію cidrsubnets).

В принципі все, що в ньому треба задати – це кількість біт для сабнетів. Чим більше біт задається – тим більше “зміщення” по масці, і тим менше буде виділено на підмережу, тобто:

  • subnet-1: 8 біт
  • subnet-2: 4 біт

Якщо VPC CIDR буде мати /16, то це буде виглядати як:

11111111.11111111.00000000.00000000

Відповідно для subnet-1 маска буде 16+8, тобто 11111111.11111111.11111111.00000000 – /24 (24 біти “зайняті”, 8 останніх – “вільні”), а для subnet-2 буде 16+4, тобто 11111111.11111111.11110000.00000000 – /20, див. таблицю у IP V4 subnet masks.

Тоді у разі 11111111.11111111.11111111.00000000 ми маємо вільним для адресації останній октет, тобто 256 адрес, а у 11111111.11111111.11110000.00000000 – 4096 адрес.

Цього разу я вирішив відійти від практики створювати окремі VPC під кожен сервіс/компнент проекту, бо в подальшому це по-перше ускладнює менеджмент через необхідність створювати додаткові VPC Peerings і уважно продумувати блоки адрес, щоб уникнути перекриття адрес, по-друге – VPC Peering додатково будуть коштувати грошей за трафік між ними.

Отже, буде окрема VPC для Dev, та окрема – для Prod, а тому треба відразу задати великий пул адрес.

Тож саму VPC зробимо /16, а всередені “наріжемо” підмереж по /20 – в приватних будуть поди EKS і якісь internal сервіси AWS типу Lambda-функцій, а в публічних – NAT Gateways, Application Load Balancers і що там потім ще з’явиться.

Окремо створимо підмережі для Kubernetes Control Plane.

Для параметрів VPC створимо єдину varibale з типом object, бо тут будемо тримати не тільки CIDR, але й інші параметри з різними типами:

variable "vpc_params" {
  type        = object({
    vpc_cidr  = string
  })
}

До terraform.tfvars додаємо значення:

...
vpc_params  = {
  vpc_cidr  = "10.1.0.0/16"
}

Та у main.tf описуємо отримання списку AvailabilityZones та створюємо локальну змінну env_name для тегів:

data "aws_availability_zones" "available" {
  state = "available"
}

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

VPC та пов’язані ресурси винесемо в окремий файл vpc.tf, де описуємо сам модуль subnets з шістью сабнетами – 2 публічні, 2 приватні, і 2 маленькі – для EKS Control Plane:

module "subnet_addrs" {
  source  = "hashicorp/subnets/cidr"
  version = "1.0.0"

  base_cidr_block = var.vpc_params.vpc_cidr
  networks = [
    {
      name     = "public-1"
      new_bits = 4
    },
    {
      name     = "public-2"
      new_bits = 4
    },
    {
      name     = "private-1"
      new_bits = 4
    },
    {
      name     = "private-2"
      new_bits = 4
    },
    {
      name     = "intra-1"
      new_bits = 8
    },
    {
      name     = "intra-2"
      new_bits = 8
    },        
  ]
}

Перевіримо, що зараз вийде.

Або просто з terraform apply, або відразу додамо outputs.

У файлі outputs.tf додамо відображення VPC CIDR, змінної env_name, та сабнетів.

Модуль subnets має два типи outputsnetwork_cidr_blocks поверне map з іменами мереж в ключах, а networks повертає list (див. Terraform: знайомство з типами даних – primitives та complex).

Нам потрібен network_cidr_blocks, бо в іменах маємо тип сабнету – private чи public.

Тож створюємо такі outputs:

output "env_name" {
  value = local.env_name
}

output "vpc_cidr" {
  value = var.vpc_params.vpc_cidr
}

output "vpc_public_subnets" {
  value = [module.subnet_addrs.network_cidr_blocks["public-1"], module.subnet_addrs.network_cidr_blocks["public-2"]]
}

output "vpc_private_subnets" {
  value = [module.subnet_addrs.network_cidr_blocks["private-1"], module.subnet_addrs.network_cidr_blocks["private-2"]]
}

output "vpc_intra_subnets" {
  value = [module.subnet_addrs.network_cidr_blocks["intra-1"], module.subnet_addrs.network_cidr_blocks["intra-2"]]
}

В модуль vpc в параметри vpc_public_subnets, vpc_private_subnets та intra_subnets передаємо map з двома елементами – по кожній сабнет відповідного типу.

Перевіряємо з terraform plan:

...
Changes to Outputs:
  + env_name            = "atlas-eks-dev-1-27"
  + vpc_cidr            = "10.1.0.0/16"
  + vpc_intra_subnets   = [
      + "10.1.64.0/24",
      + "10.1.65.0/24",
    ]
  + vpc_private_subnets = [
      + "10.1.32.0/20",
      + "10.1.48.0/20",
    ]
  + vpc_public_subnets  = [
      + "10.1.0.0/20",
      + "10.1.16.0/20",
    ]

Наче виглядає ОК?

Переходимо до самої VPC.

Terraform VPC module

У модуля досить багато inputs для конфігурації, і є гарний приклад того, як його можна використати – examples/complete/main.tf.

Що нам тут може знадобитись:

  • putin_khuylo: must have з очевидним значенням true
  • public_subnet_names, private_subnet_names та intra_subnet_names: задати власні імена сабнетів – але по дефолту імена генеруються досить зручні, тож не бачу сенсу міняти (див. main.tf)
  • enable_nat_gateway, one_nat_gateway_per_az або single_nat_gateway: параметри для NAT Gateway – власне, будемо робити дефолтну модель, з окремим NAT GW на кожну приватну мережу, але відразу додамо можливість змінити в майбутньому (хоча можливо побудувати кластер взагалі без NAT GW, див. Private cluster requirements)
  • enable_vpn_gateway: поки не буде, але відразу додамо на майбутнє
  • enable_flow_log: дуже корисна штука (див. AWS: Grafana Loki, InterZone трафік в AWS, та Kubernetes nodeAffinity), але це додаткові кости, тому додамо, але поки не включатимо

Додаємо параметри до нашої змінної vpc_params:

variable "vpc_params" {
  type = object({
    vpc_cidr               = string
    enable_nat_gateway     = bool
    one_nat_gateway_per_az = bool
    single_nat_gateway     = bool
    enable_vpn_gateway     = bool
    enable_flow_log        = bool
  })
}

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

...
vpc_params = {
  vpc_cidr               = "10.1.0.0/16"
  enable_nat_gateway     = true
  one_nat_gateway_per_az = true
  single_nat_gateway     = false
  enable_vpn_gateway     = false
  enable_flow_log        = false
}

Щодо тегів: можна задати окремо теги з inputs vpc_tags та/або private/public_subnet_tags.

Також можна додати теги через tags самого ресурсу VPC – тоді вони будуть додані до всіх ресурсів цієї VPC (плюс default_tags з AWS провайдера)

Далі, описуємо саму VPC у vpc.tf:

...
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.1.1"

  name = "${local.env_name}-vpc"
  cidr = var.vpc_params.vpc_cidr

  azs = data.aws_availability_zones.available.names

  putin_khuylo = true

  public_subnets  = [module.subnet_addrs.network_cidr_blocks["public-1"], module.subnet_addrs.network_cidr_blocks["public-2"]]
  private_subnets = [module.subnet_addrs.network_cidr_blocks["private-1"], module.subnet_addrs.network_cidr_blocks["private-2"]]
  intra_subnets   = [module.subnet_addrs.network_cidr_blocks["intra-1"], module.subnet_addrs.network_cidr_blocks["intra-2"]]

  enable_nat_gateway = var.vpc_params.enable_nat_gateway
  enable_vpn_gateway = var.vpc_params.enable_vpn_gateway

  enable_flow_log = var.vpc_params.enable_flow_log
}

І ще раз перевіряємо з terraform plan:

Якщо виглядає ОК – то деплоїмо:

$ terraform apply
...
Apply complete! Resources: 23 added, 0 changed, 0 destroyed.

Outputs:

env_name = "atlas-eks-dev-1-27"
vpc_cidr = "10.1.0.0/16"
vpc_intra_subnets = [
  "10.1.64.0/24",
  "10.1.65.0/24",
]
vpc_private_subnets = [
  "10.1.32.0/20",
  "10.1.48.0/20",
]
vpc_public_subnets = [
  "10.1.0.0/20",
  "10.1.16.0/20",
]

І перевіряємо сабнети:

Додавання VPC Endpoints

Останнім для VPC нам потрібно налаштувати VPC Endpoints.

Це прям must have фіча і з точки зору безпеки, і з точки зору вартості інфрастуктури, бо в обох випадках ваш трафік ходить всередені мережі замість того, щоб відправлятись в мандрівку через інтернет на зовнішні ендпонти AWS типу s3.us-east-1.amazonaws.com.

VPC Endpoint створить Route Table з маршрутами до відповідного ендпоінту всередині VPC (у випадку з Gateway Endpoint), або створить Elastic Network Interface та змінить налаштування VPC DNS (у випадку з Interface Endpoints), і весь трафік буде йти всередині мережі AWS. Див. також VPC Interface Endpoint vs Gateway Endpoint in AWS.

Ендпоінти можна створити за допомогою внутрішнього модуля vpc-endpoints, який включено в сам модуль VPC.

Приклад ендпоінтів є в тому ж файлі examples/complete/main.tf або на сторінці сабмодуля, і вони нам потрібні всі окрім ECS та AWS RDS – в конкретно моєму випадку RDS на проекті нема, але є DynamoDB.

Також додамо ендпоінт для AWS STS, але на відміну від інших, щоб трафік йшов через цей ендпоінт, сервіси мають використовувати AWS STS Regionalized endpoints. Зазвичай це можна задати в Helm-чартах через values або для ServiceAccount задати аннотацію eks.amazonaws.com/sts-regional-endpoints: "true".

Майте на увазі, що використання Interface Endpoints коштує грошей, бо під капотом використовується AWS PrivateLink, а Gateway Endpoints безкоштовні, але доступні тільки для S3 та DynamoDB.

Проте це все одно набагато вигідніше, ніж “ходити” через NAT Gateways, де трафік коштує 4.5 центи за гігабайт (плюс вартість за годину самого гейтвея), тоді як через Interface Ednpoint ми будемо платити лише 1 цент за гігабайт трафіку. Див. Cost Optimization: Amazon Virtual Private Cloud та Interface VPC Endpoint.

В модулі відразу можемо створити і IAM Policy для ендпоінтів. Але так как у нас в цій VPC буде тільки Kubernetes з його подами, то поки не бачу сенсу в додаткових політиках. До того ж, для Interface Endpoints можна додати Security Group.

Ендпоінти для STS та ECR будуть Interface типу, тому їм задаємо ID приватних мереж, а для S3 та DynamoDB – передаємо ID таблиць маршрутизації, бо вони будуть Gateway Endpoint.

Ендпоінти S3 та DynamoDB робимо Gateway type, бо вони бескоштовні, а інші – Interface.

Отже, додаємо до нашого vpc.tf:

...
module "endpoints" {
  source  = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints"
  version = "~> 5.1.1"

  vpc_id = module.vpc.vpc_id

  create_security_group = true

  security_group_description = "VPC endpoint security group"
  security_group_rules = {
    ingress_https = {
      description = "HTTPS from VPC"
      cidr_blocks = [module.vpc.vpc_cidr_block]
    }
  }

  endpoints = {
    dynamodb = {
      service         = "dynamodb"
      service_type    = "Gateway"
      route_table_ids = flatten([module.vpc.intra_route_table_ids, module.vpc.private_route_table_ids, module.vpc.public_route_table_ids])
      tags = { Name = "${local.env_name}-vpc-ddb-ep" }
    }
    s3 = {
      service         = "s3"
      service_type    = "Gateway"
      route_table_ids = flatten([module.vpc.intra_route_table_ids, module.vpc.private_route_table_ids, module.vpc.public_route_table_ids])
      tags = { Name = "${local.env_name}-vpc-s3-ep" }
    },
    sts = {
      service             = "sts"
      private_dns_enabled = true
      subnet_ids          = module.vpc.private_subnets
      tags = { Name = "${local.env_name}-vpc-sts-ep" }
    },
    ecr_api = {
      service             = "ecr.api"
      private_dns_enabled = true
      subnet_ids          = module.vpc.private_subnets
      tags = { Name = "${local.env_name}-vpc-ecr-api-ep" }
    },
    ecr_dkr = {
      service             = "ecr.dkr"
      private_dns_enabled = true
      subnet_ids          = module.vpc.private_subnets
      tags = { Name = "${local.env_name}-vpc-ecr-dkr-ep" }
    }
  }
}

У source задаємо шлях з двома слешами, бо:

The double slash (//) is intentional and required. Terraform uses it to specify subfolders within a Git repo

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

І перевіримо таблиці маршрутизації – куди вони ведуть? Наприклад, Route Table atlas-eks-dev-1-27-vpc-intra має три роути:

Префікс-лист pl-63a5400a буде відправляти трафік через ендпоінт vpce-0c6ced56ea4f58b70, тобто atlas-eks-dev-1-27-vpc-s3-ep.

Зміст pl-63a5400a:

І якщо ми зробимо dig на адресу s3.us-east-1.amazonaws.com, то отримаємо:

$ dig s3.us-east-1.amazonaws.com +short
52.217.161.80
52.217.225.240
54.231.195.64
52.216.222.32
16.182.66.224
52.217.161.168
52.217.140.224
52.217.236.168

Адреси з цього листа, тобто всі запити всередені VPC на URL s3.us-east-1.amazonaws.com будуть виконуватись через наш VPC S3 Endpoint.

Забігаючи наперед, коли вже був запущений EKS кластер, то перевірив, як працюють Interface Endpoints, наприклад для STS.

З робочої машини в офісі:

18:46:34 [setevoy@setevoy-wrk-laptop ~]  $ dig sts.us-east-1.amazonaws.com +short
209.54.177.185

Та з Kubernetes Pod в приватній мережі нашої VPC:

root@pod:/# dig sts.us-east-1.amazonaws.com +short
10.1.55.230
10.1.33.247

Тут начебто все.

Можемо переходити до наступної задачі – створення самого кластеру та його WorkerNodes.

Loading

Terraform: цикли count, for_each та for
0 (0)

4 Вересня 2023

Продовжуємо розбиратись з можливостями Terraform.

В попредньому пості познайомились з типами даних – Terraform: знайомство з типами даних – primitives та complex. Тепер подивимось, як ці типи можна використовувати в циклах.

Terraform підтримує три типи циклів:

  • count: самий простий, використовується з заданим числом або з фукнцією length(); використовує індекси list або map для ітерації
    • підходить для створення однакових ресурсів, які не будуть змінюватись
  • for_each: має більше можливостей, використовується з map або set, використовує іммена ключів послідовності для ітерації
    • підходить для створення однотипних ресурсів, але з можливістю задати різні параметри
  • for: використовується для фільтрації та трансмормації об’єктів з lists, sets, tuples або maps; може бути використано разом з такими функціями, як if, join, replace, lower або upper

Terraform count

Отже, count самий базовий і перший метод для виконання задач в циклі.

Аргументом приймає або number, або list чи map, виконує ітерацію, і кожному об’єкту задає індекс відповідвідно до його позиції в послідовності.

Наприклад, ми можемо створити три корзини так:

resource "aws_s3_bucket" "bucket" {
  count = 3

  bucket = "bucket-${count.index}"
}

В результаті Terraform створить масив (array) з трох корзин з іменами bucket-0, bucket-1 та bucket-2.

Ми також можемо передати список і використати функцію length(), щоб отримати кількість елементів в цьому списку, і потім пройтись по кожному з них, використовуючи їхні індекси:

variable "projects" {
  type        = list(string)
  default     = ["test-project-1", "test-project-2", "test-project-3"]
}

resource "aws_s3_bucket" "bucket" {
  count = length(var.projects)

  bucket = "bucket-${var.projects[count.index]}"
}

В такому випадку будуть створені три корзини з іменами “bucket-test-project-1“, “bucket-test-project-2” та “bucket-test-project-3“.

Щоб отримати значеня імен корзин, які створювались таким чином, можемо використати “*” для вибору всіх індекісів з масиву aws_s3_bucket.bucket:

...
output "bucket_names" {
  value       = aws_s3_bucket.bucket[*].id 
}

Але у count є один важливий нюанс: саме через прив’язку елементів до індексів, ви може отримати несподіваний результат.

Наприклад, якщо створити ці три корзини, а потім додати новий проект на початку або всередені списку, то Terraform видалить коризини для проектів після доданого, бо в списку зміняться індекси об’єктів.

Тобто:

variable "projects" {
  type        = list(string)
  default     = ["test-project-1", "another-test-project", "test-project-2", "test-project-3"]
}

Приведе до:

$ terraform apply
...
  # aws_s3_bucket.bucket[1] must be replaced
-/+ resource "aws_s3_bucket" "bucket" {
...
      ~ bucket                      = "bucket-test-project-2" -> "bucket-another-test-project" # forces replacement
...
  # aws_s3_bucket.bucket[2] must be replaced
-/+ resource "aws_s3_bucket" "bucket" {
...
      ~ bucket                      = "bucket-test-project-3" -> "bucket-test-project-2" # forces replacement
...
  # aws_s3_bucket.bucket[3] will be created
  + resource "aws_s3_bucket" "bucket" {
...
      + bucket                      = "bucket-test-project-3"
...
Plan: 3 to add, 0 to change, 2 to destroy.

І якщо в корзинах є дані, то деплой зупиниться з помилкою BucketNotEmpty, бо Terraform буде намагатись видалити бакети.

Проте count чудово підійде, якщо вам треба перевірити умову на кшталт “створювати ресурс чи ні”. Це можна зробити таким чином:

variable "enabled" {
  type    = bool
  default = true
}

resource "aws_s3_bucket" "bucket" {
  count = var.enabled ? 1 : 0

  bucket = "bucket-test"
}

Тобто якщо enabled = true, то створюємо 1 корзину, якщо false – то 0.

Terraform for_each

for_each довзляє виконувати ітерації більш гнучко.

Він приймає map або set, і для ітерації замість індексів використовує кожен key та value з послідовності. В такому випадку саме кількість key буде визначати кількість ресурсів, котрі будуть створені.

Завдяки тому, що кожен key являється унікальним, зміна значень в set/map не впливає на те, як ресурси будуть створені.

Крім set та map ви можете використати тип list, але його треба буде “загорнути” у фунцію toset(), щоб перетворити на set, з якого for_each зможе отримати пару key:value – в такому випадку значення key буде == значенню value.

for_each з set та list

Отже, якщо взяти той же ресурс aws_s3_bucket, то з for_each ми можемо створити корзини так:

variable "projects" {
  type        = set(string)
  default     = ["test-project-1", "test-project-2", "test-project-3"]
}

resource "aws_s3_bucket" "bucket" {
  for_each  = var.projects

  bucket = "bucket-${each.value}"
}

Або з variable з типом list і toset() для for_each:

variable "projects" {
  type        = list(string)
  default     = ["test-project-1", "test-project-2", "test-project-3"]
}

resource "aws_s3_bucket" "bucket" {
  for_each  = toset(var.projects)

  bucket = "bucket-${each.value}"
}

Але так як в результаті ми отримаємо не масив даних, а map з окремими об’єктами:

...
  # aws_s3_bucket.bucket["test-project-1"] will be created
...

І тоді в outputs просто викликати aws_s3_bucket.bucket[*].id ни вийде.

Натомість, ми можемо використати функцію values() щоб отримати всі значення ресурсів aws_s3_bucket.bucket:

...
output "bucket_names" {
  value       = values(aws_s3_bucket.bucket)[*].id 
}

for_each з map

Або приклад з map для створення тегу Name:

variable "projects" {
  type  = map(string)
  default = {
    "test-project-1" = "Test Project 1",
    "test-project-2" = "Test Project 2",
    "test-project-3" = "Test Project 3",
  }
}

resource "aws_s3_bucket" "bucket" {
  for_each  = var.projects

  bucket   = "bucket-${each.key}"
  tags = {
    "Name" = each.value
  }
}

Або з використанням merge(), щоб додавати загальні теги + тег Name (див. також default_tags):

variable "projects" {
  type  = map(string)
  default = {
    "test-project-1" = "Test Project 1",
    "test-project-2" = "Test Project 2",
    "test-project-3" = "Test Project 3",
  }
}

variable "common_tags" {
  type    = map(string)
  default = {
    "Team"      = "devops",
    "CreatedBy" = "terraform"
  }
}

resource "aws_s3_bucket" "bucket" {
  for_each  = var.projects

  bucket   = "bucket-${each.key}"
  tags  = merge(var.common_tags, {Name = each.value})
}

В результаті отримаємо три теги:

...
  ~ resource "aws_s3_bucket" "bucket" {
        id                          = "bucket-test-project-1"
      ~ tags                        = {
          + "CreatedBy" = "terraform"
          + "Name"      = "Test Project 1"
          + "Team"      = "devops"
        }
...

for_each з map of maps та атрибутами

Або можна використати навіть map of maps, і для кожної корзини передавати набір параметрів, і потім звертатись до параметра через each.value.PARAM_NAME.

Наприклад, в одному параметрі задамо тег Name, а в іншому – object_lock_enabled:

variable "projects" {
  type  = map(map(string))
  default = {
    "test-project-1" = {
      tag_name = "Test Project 1", object_lock_enabled = true 
    },
    "test-project-2" = {
      tag_name = "Test Project 2", object_lock_enabled = false
    },
    "test-project-3" = {
      tag_name = "Test Project 3", object_lock_enabled = false
    }
  }
}

variable "common_tags" {
  type    = map(string)
  default = {
    "Team"      = "devops",
    "CreatedBy" = "terraform"
  }
}

resource "aws_s3_bucket" "bucket" {
  for_each  = var.projects

  bucket   = "bucket-${each.key}"
  object_lock_enabled = each.value.object_lock_enabled
  tags  = merge(var.common_tags, {Name = each.value.tag_name})
}

Результат:

Terraform for

На відміну від count та for_each, метод for використовується не для створення ресурсів, а для операцій фільтрування та трансформації над значеннями змінних.

Ситнаксис для for виглядає так:

[for <ITEM> in <LIST> : <OUTPUT>]

Тут ITEM – ім’я локальної до циклу змінної, LIST – список, в якому буде виконуватись ітерація, а OUTPUT – результат трансформації.

Наприклад, можемо вивести імена бакетів як UPPERCASE таким чином:

...
output "bucket_names" {
  value       = [for a in values(aws_s3_bucket.bucket)[*].id : upper(a)]
}

for та conditionals expressions

Також перед OUTPUT можемо додати фільтр, тобто виконати дію тільки над деякими об’єктами зі списку, наприклад:

output "bucket_names" {
  value       = [for a in values(aws_s3_bucket.bucket)[*].id : upper(a) if can(regex(".*-1", a))]
}

Тут ми за допомогою функцій can() та regex() перевіряємо значення змінної a, і якщо вона закінчується на “-1”, то виконуємо upper(a):

...
bucket_names = [
  "BUCKET-TEST-PROJECT-1",
]

for та ітерація по map

Можно виконати ітерацію над key:value з map variable:

variable "common_tags" {
  type    = map(string)
  default = {
    "Team"      = "devops",
    "CreatedBy" = "terraform"
  }
}

output "common_tags" {
  value       = [for a, b in var.common_tags : "Key: ${a} value: ${b}" ]
}

В результаті отримаємо об’єкт типу list зі значеннями:

...
common_tags = [
  "Key: CreatedBy; Value: terraform;",
  "Key: Team; Value: devops;",
]

А за допомогою  => можемо перетворити list на map. Крім того, для map замість [] цикл записуємо в {}:

output "common_tags" {
  value       = { for a, b in var.common_tags : upper(a) => b }
}

Отримуємо:

...
common_tags = {
  "CREATEDBY" = "terraform"
  "TEAM" = "devops"
}

for та for_each для ітерації над complex objects

Можна зробити єдину змінну, яка буде мати різні типи даних для різних значень, а потім виконати ітерацію з for_each та for разом.

Наприклад, створимо variable з типом list, в якому будуть значення типу object, а в object будуть два поля типу string, та одне для списку тегів з типом list:

variable "projects" {
  type        = list(object({
      name = string
      object_lock_enabled = string
      tags = map(string)
  }))

  default = [
    {
      name  = "test-project-1"
      object_lock_enabled = "true"
      tags  =         {
          "Name" = "Test Project 1"
          "Team"      = "devops"
          "CreatedBy" = "terraform"          
        }
    },
    {
      name  = "test-project-2",
      object_lock_enabled = true,
      tags  =         {
          "Name" = "Test Project 2",
          "Team"      = "devops",
          "CreatedBy" = "terraform"          
        }
    },
    {
      name  = "test-project-3",
      object_lock_enabled = true,
      tags  =         {
          "Name" = "Test Project 3",
          "Team"      = "devops",
          "CreatedBy" = "terraform"          
        }
      
    }        
  ]
}

resource "aws_s3_bucket" "bucket" {
  for_each  = { for a in var.projects : a.name => a }

  bucket   = "bucket-${each.key}"
  object_lock_enabled = each.value.object_lock_enabled
  tags = { for key,value in each.value.tags : key => value }
}

Потім в ресурсі aws_s3_bucket в цикл for_each передаємо значення var.projects.name, а для тегів робимо цикл по кожному ресурсу з list, і в кожному ресурсі створюємо key:value з each.value.tags.

Nested for loops для map of lists

Для роботи з багаторівневими об’єктами в одному циклі for можна визивати інший.

Наприклад, маємо список проектів, для кожного є один чи кілька “dev/prod” оточень:

variable "projects" {
  description = "project names list to be used in S3 and DynamoDB names"
  type        = map(list(string))

  default = {
    atlas-tf-backends-test = [
      "prod"
    ]
    atlas-eks-test = [
      "dev", "prod"
    ]
  }
}

Щоб побудувати list з елементами, які будуть містити ім’я проекту + ім’я оточення – використовуємо два for:

locals {
  table_names = [
    for project, envs in var.projects : [
      for env in envs : 
        "${project}-${env}"
    ]
  ]
}

output "dynamodb_table_names" {
  value = local.table_names
}

В результаті отримаємо:

Changes to Outputs:
  + dynamodb_table_names = [
      + [
          + "atlas-eks-test-dev",
          + "atlas-eks-test-prod",
        ],
      + [
          + "atlas-tf-backends-test-prod",
        ],
    ]

А щоб створити єдиний list замість list[list, list] – можна використати фунцію flatten:

locals {
  table_names = flatten([
    for project, envs in var.projects : [
      for env in envs : 
        "${project}-${env}"
    ]
  ])
}

В результаті отримаємо:

Changes to Outputs:
  + dynamodb_table_names = [
      + "atlas-eks-test-dev",
      + "atlas-eks-test-prod",
      + "atlas-tf-backends-test-prod",
    ]

А щоб побудувати map, де ключами будуть ім’я проекту + ім’я, а в значенні інший map – можно використати функцію merge та оператор “...“, як наведено в цьому коментарі на GitHub:

locals {
  table_names_map = merge([
    for project, envs in var.projects : {
      for env in envs :
      "${project}-${env}" => {
        "project" = project
        "env"     = env
      }
    }
  ]...)
}

output "dynamodb_table_names" {
  value = local.table_names_map
}

Результат:

Changes to Outputs:
  + dynamodb_table_names = {
      + atlas-eks-test-dev          = {
          + env     = "dev"
          + project = "atlas-eks-test"
        }
      + atlas-eks-test-prod         = {
          + env     = "prod"
          + project = "atlas-eks-test"
        }
      + atlas-tf-backends-test-prod = {
          + env     = "prod"
          + project = "atlas-tf-backends-test"
        }
    }

 

for та String Templates

Документація – Strings and Templates.

Синтаксис для ітерації по map буде таким:

%{ for <KEY>, <VALE> in <COLLECTION> }<RESULTED_TEXT>%{ endfor }

Тобто, можемо створити текствий файл зі змістом значень змінної:

resource "local_file" "foo" {
  content  = "%{ for a, b in var.common_tags }Key: ${a}\nValue: ${b}\n%{ endfor }"
  filename = "foo.txt"
}

Результат:

$ cat foo.txt 
Key: CreatedBy
Value: terraform
Key: Team
Value: devops

Готово.

Посилання по темі

Loading

Terraform: знайомство з типами даних – primitives та complex
0 (0)

4 Вересня 2023

В цьому пості трохи подивимось на типи даних, які можемо використовувати в Terraform, щоб простіше розібратись з наступним постом – Terraform: цикли count, for_each та for.

Документація – Type Constraints та Types and Values.

Маємо наступні типи поділені на групи:

  • Primitive Types:
    • string: послідовність Unicode символів, простий текст
    • number: числові значення
    • bool: true або false
  • Complex Types:
    • Collection Types:
      • list: список – тип структури для зберігання простої колекції значень одного типу, доступних по індексам
      • map: колекція key:value значень одного типу
      • set: аналогічна list, але без індексів та сортування
    • Structural Types:
      • object: для зберігання значені різних типів даних – набір іменованих атрибутів, кожен з власним типом даних
      • tuple: послідовність елементів, кожен з власним типом даних, з індексами як у list

Primitive types

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

string

Приклад:

variable "var_string" {
  type        = string
  default     = "a string"
}

output "string" {
  value = var.var_string
}

Результат очевидний:

...
Outputs:

string = "a string"

number

Аналогічно, але для integer значень:

variable "var_number" {
  type        = number
  default     = 1
}

output "number" {
  value = var.var_number
}

Результат:

...
Outputs:

number = 1

bool

Використовується для Conditional Expressions:

variable "var_bool" {
  type        = bool 
  default     = true
}

output "number" {
  value = var.var_bool ? "True" : "False"
}

Результат:

...
Outputs:

number = "True"

Або створення ресурсу, якщо умова дійсна:

resource "local_file" "file" {
  count = var.var_bool ? 1 : 0
  
  filename = "file.txt"
  content = var.var_string
}

Collection Types

list

Послідовність значень одного типу з індексами, починаючи з нуля.

При створенні list можна або не вказувати тип (default == any), або обмежити одним певним типом:

variable "var_list_any" {
  type = list 
  default = ["a string", 10]
}

variable "var_list_string" {
  type = list(string)
  default = ["first string", "second string"]
}

resource "local_file" "file" {
  filename = "file-${var.var_list_any[1]}.txt"

  content = var.var_list_string[0]
}

output "list_any" {
  value = var.var_list_any
}

output "list_string" {
  value = var.var_list_string
}

Результат:

...
Outputs:

list_any = tolist([
  "a string",
  "10",
])
list_string = tolist([
  "first string",
  "second string",
])

І файл:

$ cat file-10.txt 
first string

В list можна використовувати інші типи даних – інші list, map тощо.

При цьому в одному list можуть бути різні типи примітивів (string, number, bool), але однаковий тип для інших типів, тобто:

variable "var_list_any" {
  type = list
  default = ["a", true, 1]
}

variable "var_list_lists" {
  type = list
  default = [
    ["a", "b"],
    ["c", "d"]
  ]
}

output "list_any" {
  value = var.var_list_any
}

output "list_lists" {
  value = var.var_list_lists
}

Результат:

...
Outputs:

list_any = tolist([
  "a",
  "true",
  "1",
])
list_lists = tolist([
  [
    "a",
    "b",
  ],
  [
    "c",
    "d",
  ],
])

Зі списками можемо використовувати цикли, наприклад:

variable "var_list_any" {
  type = list 
  default = ["a string", 10]
}

variable "var_list_string" {
  type = list(string)
  default = ["first string", "second string"]
}

resource "local_file" "file" {
  for_each = toset(var.var_list_any)

  filename = "file-${each.key}.txt"
  content = each.value
}

output "list_string" {
  value = [ for a in var.var_list_string : upper(a)]
}

Результат:

...
Outputs:

list_string = [
  "FIRST STRING",
  "SECOND STRING",
]

Та файли:

$ ls -1
file-10.txt
'file-a string.txt'

$ cat file-a\ string.txt  
a string

map

Значення у формі key:value з доступом до значення по імені ключа:

variable "var_map" {
  type        = map
  default     = {
    "one" = "first",
    "two" = "second"
  }
}

output "map_one" {
  value = var.var_map["one"]
}

output "map_two" {
  value = var.var_map["two"]
}

Також в outputs можемо вивести атрибут, тобто value = var.var_map.one.

Результат:

...
Outputs:

map_one = "first"
map_two = "second"

Також з map можемо використати lookup() для пошуку значення по ключу:

output "map_lookup" {
  value = lookup(var.var_map, "one", "None")
}

Результат:

...
Outputs:

map_lookup = "first"
map_one = "first"
map_two = "second"

Або більш складний приклад – вибір кількості інстансів за ціною в залежності від типу:

variable "instance_cost" {
  type    = map
  default = {
    "t3.medium" = "0.04USD",
    "t3.large" = "0.08USD",
  }
}

variable "instance_number" {
  type    = map
  default = {
    "0.04USD" = 2,
    "0.08USD" = 1,
  }
}

output "instances_count" {
  value = lookup(var.instance_number, var.instance_cost["t3.medium"], 0)
}

Результат:

...
Outputs:

instances_count = 2

map також може включати в себе list або інший map, але всі об’єкти мають бути одного типу (тобто, не можна мати map в якому будуть і list, і другий map):

variable "var_map_of_maps" {
  type        = map
  default     = {
    "out-map-key-1" = {
      "in-map-key-1" = "inner map 1 key one",
      "in-map-key-2" = "inner map 1 inner key two",
    },
    "out-map-key-2" = {
      "in-map-key-1" = "inner map 2 key one",
      "in-map-key-2" = "inner map 2 key two",
    },
  }
}

output "map_of_maps" {
  value = var.var_map_of_maps
}

Результат:

...
Outputs:

map_of_maps = tomap({
  "out-map-key-1" = {
    "in-map-key-1" = "inner map 1 key one"
    "in-map-key-2" = "inner map 1 inner key two"
  }
  "out-map-key-2" = {
    "in-map-key-1" = "inner map 2 key one"
    "in-map-key-2" = "inner map 2 key two"
  }
})

set

Послідовність значень одного або різних типів як в list, але без індексів та сортування:

variable "var_set_any" {
  type    = set(any)
  default = ["string", 1]
}

variable "var_set_string" {
  type    = set(string)
  default = ["string1", "string2"]
}

output "set_any" {
  value = var.var_set_any
}

output "set_string" {
  value = var.var_set_string
}

Результат:

...
...
Outputs:

set_any = toset([
  "1",
  "string",
])
set_string = toset([
  "string1",
  "string2",
])

Як і list або map, set може мати вкладені типи:

variable "var_set_lists" {
  type    = set(list(any))
  default = [
    ["a", "b"],
    ["c", "d"]
  ]
}

output "set_any" {
  value = var.var_set_lists
}

Результат:

...
set_any = toset([
  tolist([
    "a",
    "b",
  ]),
  tolist([
    "c",
    "d",
  ]),
])

Structural Types

object

На відміну від map та list, object є структурним типом, який може мати значення різних типів, в тому числі включати в себе типи list та map.

Схож на Struct в C або Golang:

variable "var_object" {
  type        = object({
    name      = string,
    id        = number,
    data      = list(string)
    data_map  = map(any)
  })

  default = {
    name      = "one",
    id        = 10,
    data      = ["first", "second"],
    data_map  = {
      "one" = "first",
      "two" = "second"
    }
  }
}

output "object" {
  value = var.var_object
}

output "object_map" {
  value = var.var_object.data_map
}

Результат:

...
Outputs:

object = {
  "data" = tolist([
    "first",
    "second",
  ])
  "data_map" = tomap({
    "one" = "first"
    "two" = "second"
  })
  "id" = 10
  "name" = "one"
}
object_map = tomap({
  "one" = "first"
  "two" = "second"
})

tuple

Подібний до object, але з індексами замість імен ключів:

variable "var_tuple" {
  type = tuple ([
    string,
    number,
    list(string),
    map(any)
  ] )

  default = [
    "one",
    10,
    ["first", "second"],
    {
      "one" = "first",
      "two" = "second"
    }
  ]
}

output "tuple" {
  value = var.var_tuple
}

output "tuple_map" {
  value = var.var_tuple[3]
}

Результат:

Outputs:

tuple = [
  "one",
  10,
  tolist([
    "first",
    "second",
  ]),
  tomap({
    "one" = "first"
    "two" = "second"
  }),
]
tuple_map = tomap({
  "one" = "first"
  "two" = "second"
})

В наступному пості подивимось на цикли.

Посилання по темі

Loading

RTFM: День народження – 12 років. Що нового?
0 (0)

2 Вересня 2023

В 2022 із зрозумілих причин пропустив День народження блогу, але давайте у 2023 про це згадаємо.

В цьому році нам вже цілих 12 років! ^_^

День народження рахую з дати реєстрації самого домену:

Domain Name:RTFM.CO.UA
Created On:14-Aug-2011 09:40:17 UTC

Останній День народження записував в пості RTFM: День рождения и годовщина – 10 лет. Обзор изменений, і найголовніше, що змінилось з того часу – це українська мова блогу.

На жаль, більшість постів залишаються на рос. мові, бо перекласти 2000 постів просто фізично не вийде.

Попередні пости про Дні народження:

Хостинг і сам блог

Блог працює на WordPress – “так склалося історично”, і насправді він цілком влаштовує – все працює добре, море плагінів і можливостей кастомізації.

Хостинг – досі Digital Ocean.

Сьогодні переїхав з PHP 7.3 на 8.3 – і все добре, тільки поламалось декілька плагінів, і, на жаль, мій улюблений Simterm, який дозволяв красиво виводити консольні команди в постах.

Тепер старі пости будуть трохи некрасиві:

Написав девелоперу, може все ж пофіксить, хоча судячи з того, що останній раз плагін оновлювався 3 роки тому – то навряд чи.

Мови блогу

З 2012 пости писалися руською. Перший пост англійською з’явився о 2019, а українською – о 2022. Того ж року дефолтна мова блогу була переключена з рос. на українську.

Статистка постів, включаючи чернетки:

  • рос: 2,154
  • англ: 302
  • українська: 119

Коли додавав українську – робив опитування на тему “На якій мові вести блог”, і результат опитування був 50/50.

Але, як виявилося, переглядів українською майже втричі більше, ніш рос:

Тож врешті-решт вирішив, що не варто витрачати час на третій переклад, і тепер всі нові пости додаються українською та англійською мовами.

Статистка блогу

Взагалі, трафік дуже просів – якщо наприкінці 2021 було понад 5.000 відвідувачів, до зараз менше 1000.

Статистка по країнам – в топі Україна, другою йдуть Сполучені Штати:

На Cloudflare дропається трафік з білорусі та рф, але все одно якось є.

Стара тема оформлення блогу

Колись, до 2016, блог виглядав так:

Як я пишу в блог?

Часто питають – як пишу в блог.

Колись напишу про те, як пишу 🙂

Але якщо коротко, то – коли сетаплю щось нове, то накидую в блог покроково те, що роблю, з копіпастою команд з консолі та пару слів про те, що там було.

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

Саме складне, особливо коли знайомишся з якось новою системою – це зрозуміти, про що саме писати, і як це все зібрати до купи та створити структуру нового посту.

Приклад чернетки:

Потім вже на вихідних – роблю переклад на англійську.

RTFM на інших платформах

Англійські пости репостяться на Medium, Dev.to.

В цьому році додались платформи Substack та Hashnode.

Нещодавно створив групу в LinkedIn, але там поки тільки один пост, бо ще не робив нових перекладів. Спробую робити репости і туди, подивимось, чи зайде людям.

Заодно нагадаю, що є Телеграм-канал з апдейтами – @rtfm, та група для обговорень – @rtfm.

Про автора

А що про себе написати-то?

В IT працюю з 2005 – починав “системним адміністратором” в компанії, де було 4 чи 5 ПК. Потім тех. підтримка Freehost.ua – досі люблю цей хостинг, і всі домени реєструю там. Далі був дата-центр Воля-кабль (не люблю) – теж тех. підтримка, потім “провідний інженер тех. підтримки” – то вже більше було системне адміністрування.

А от у 2013 потрапив на перший проект у “великому IT”, як я його називаю. Спочатку Luxoft на посаді Release Engineer, потім два проекти в Ciklum, вже як DevOps Engineer, потім мій перший продукт і стартап – BetterMe, де пропрацював майже 5 років.

BetterMe вважаю моєю “історією успіху”, бо прийшов туди, коли там був один ЕС2 в AWS і команда з 14 людей, а пішов, коли мали штук 10 Kubernetes-кластерів в різних регіонах, близько 150 ЕС2-інастансів, штук 40 інстансів серверів баз даних в AWS RDS Aurora, і команду у 200 людей.

Хоча номінально позиції досі називаються “DevOps Engineer” (вже давненько Senior, та й лідом і Head Of Devops побув), але по факту я більше Cloud Infrastructure Enginner та Site Reability Engineer, бо в основному займаюсь AWS, моніторингом та якоюсь базовою кібербезпекою.

Зараз теж в продукті, знов стартап, бо це неймовірно круто, коли ти маєш змогу побудувати щось своє, з самого нуля.  І почуття відповідальності за те, що будуєш – дуже драйвить, бо саме на тобі відповідальність за “фундамент” проекту, за його інфраструктуру.

Ну і мабуть, варто згадати, що з 2021 я став AWS Hero. Між іншим – перший з України.

AWS Hero: анонс June, 2021.

Ось наче й все.

Loading

Terraform: динамічний remote state з AWS S3 та multiple environments по директоріям
0 (0)

31 Серпня 2023

Виходить такая собі серія постів про підготовку до використання Terraform на проекті.

Отже, в першій частині думалось про те, як організувати підготовку backend для проекту, тобто виконати його bootstrap, та трохи – як менеджити Dev/Prod оточення в цілому, див. Terraform: початок роботи та планування нового проекту – Dev/Prod та bootsrap. В другій – як налаштувати State Lock та про remote state в цілому, див. Terraform: remote state з AWS S3 та state locking з DynamoDB.

Рішення по типу Terraform Cloud, Terragrunt, Spacelift, Atlantis та Cluster.dev поки лишимо осторонь – проект ще малий, і вносити додаткові утіліти не хочеться. Почнемо з простого, а як воно все взлетить – то вже будемо думати про подібні рішення.

Тепер спробуємо все зібрати в кучу, і набросати план майбутньї автоматизації.

Отже, про що треба подумати:

  • керування бекендом, або project bootstrap: бакет(и) для state-файлів та таблицю(і) DynamoDB для state lock:
    • можна створювати руками для кожного проекту
    • можна створити окремий проект/репозиторій, і в ньому менеджити всі бекенди
    • можна створювати в рамках кожного проекту на початку роботи в коді самого проекту
  • розділення по Dev/Prod оточенням:
    • Terraform Workspaces: built-in фіча Terraform, мінімум дублікації коду, але можуть бути складнощі з навігацією, може використовувати тільки один backend (проте з окремими директоріями в ньому), складноші роботи з модулями
    • Git branches: built-in фіча Git, простота навігації по коду, можливість мати окремі бекенди, але багато дублікації коду, морока с переносом коду між оточеннями, складнощі роботи з модулями
    • Separate Directories: максимальная ізоляція і можливість мати окремі бекенди та провайдери, але можлива дублікація коду
    • Third-party tools: Terragrunt, Spacelif, Atlantis тощо – чудово, але потребує додаткового часу на вивчення інженерами та імплементацію

Сьогодні спробуємо підхід з менеджементом бакету для бекенду з коду самого проекту, а Dev/Prod робити через окремі директорії.

Керування бекендом, або project bootstrap

Тут будемо використовувати підхід зі створенням бекенду в рамках кожного проекту на старті.

Тобто:

  1. спочатку описуємо створення бакету та таблиці Динамо
  2. створюємо ресурси
  3. налаштовуємо блок terraform.backend{}
  4. імпортуємо стейт
  5. описуємо та створюємо всі інші ресурси

Розділення по Dev/Prod оточенням з окремими директоріями

Як все може виглядати з окремими каталогами?

Можемо створити структуру:

  • global
    • main.tf: створення ресурсів для бекенду – S3, Dynamo
  • environments
    • dev
      • main.tf: тут включаємо потрібні модулі (дублються з Prod, але відрізняється під час розробки та тестування нового модулю)
      • variables.tf: декларуємо змінні, загальні (дублюються з Prod) та специфічні до оточення
      • terraform.tfvars: значення змінних, загальні (дублюються з Prod) та специфічні до оточення
      • providers.tf: налаштування підключення до AWS/Kubernetes, специфічні до оточення (осолибво корисно, коли Dev/Prod це різні акаунти AWS)
      • backend.tf: налаштування зберігання state-файлів, специфічні до оточення
    • prod
      • <аналогічно Dev>
  • modules
    • vpc
      • main.tf – описуємо модулі
  • backend.hcl – загальні параметри для state backend

Тоді можемо деплоїти окремі оточення або виконуючи cd environments/dev && terraform aplly, або terraform aplly -chdir=environments/dev. Бекенд можемо передавати через terraform init -backend-config=backend.hcl.

Ну і давайте спробуємо, і подивимось, як воно може виглядати в роботі.

Створення бекенду

Тут будемо робити бекенд з коду самого проекту, але мені все ж вважається кращим менеджмент AWS ресурсів для бекендів винести окремим проектом в окремому репозиторії, бо зі схемою наведеною нижче створення нового проекту виглядає трохи complecated – якщо це будуть робити самі девелопери, то їм доведеться робити окремі кроки, і для цього потрібно буде писати окрему доку. Краще нехай при старті проекту передадуть нам його ім’я, “девопси” зроблять корзину та DynamoDB таблицю, а далі девелопери вже просто захардкодять їхні імена в свої конфіги.

Створюємо директорії:

[simterm]

$ mkdir -p envs_management_test/{global,environments/{dev,prod},modules/vpc}

[/simterm]

Получаємо таку структуру:

[simterm]

$ tree envs_management_test/
envs_management_test/
├── environments
│   ├── dev
│   └── prod
├── global
└── modules
    └── vpc

[/simterm]

У каталозі envs_management_test/global нам треба описати створення бакету та таблиці для локів.

Тут теж питання: робити одну корзини під кожен енв – чи одну, і стейти в ній розділяти ключами?

Multiple S3 buckets

Якщо робити по корзині на кожен енв, то можна зробити наступним чином:

  • створюємо змінну з типом list, в цей список вносимо імена оточень
  • потім при створенні ресурсів – використовуємо цей список, щоб в циклі пройтись по кожному індексу в ньому

Тобто, variables.tf може бути таким:

variable "environments" {
  description = "Environments names"
  type        = set(string)
  default     = ["dev", "prod", "global"]
}

А у файлі main.tf створюємо ресурси так:

resource "aws_kms_key" "state_backend_kms_key" {
  description             = "This key is used to encrypt bucket objects"
  deletion_window_in_days = 10
}

# create state-files S3 buket 
resource "aws_s3_bucket" "state_backend_bucket" {
  for_each = var.project_names
  bucket = "tf-state-backend-${each.value}"

  # to drop a bucket, set to `true`
  force_destroy = false
  lifecycle {
    # to drop a bucket, set to `false`
    prevent_destroy = true
  }

  tags = {
    environment = var.environment
  }
}

# enable S3 bucket versioning
resource "aws_s3_bucket_versioning" "state_backend_versioning" {
  for_each = aws_s3_bucket.state_backend_bucket
  bucket  = each.value.id

  versioning_configuration {
    status = "Enabled"
  }
}
...

Єдиний S3 для оточень

Але щоб не ускладнювати код – поки зробимо одну корзину, а потім для кожного оточення задамо власний key в його backend.

Використаємо змінну для імені:

variable "project_name" {
  description = "The project name to be used in global resources names"
  type        = string
  default     = "envs-management-test"
}

І в main.tf описуємо самі ресурси – тут код той самий, щоб використовувався в попередньому пості:

resource "aws_kms_key" "state_backend_bucket_kms_key" {
  description             = "Encrypt the state bucket objects"
  deletion_window_in_days = 10
}

# create state-files S3 bukets per each Env
resource "aws_s3_bucket" "state_backend_bucket" {
  bucket = "tf-state-bucket-${var.project_name}"

  lifecycle {
    prevent_destroy = true
  }
}

# enable S3 bucket versioning per each Env's bucket
resource "aws_s3_bucket_versioning" "state_backend_bucket_versioning" {
  bucket = aws_s3_bucket.state_backend_bucket.id

  versioning_configuration {
    status = "Enabled"
  }
}

# enable S3 bucket encryption per each Env's bucket
resource "aws_s3_bucket_server_side_encryption_configuration" "state_backend_bucket_encryption" {
  bucket = aws_s3_bucket.state_backend_bucket.id

  rule {
    apply_server_side_encryption_by_default {
      kms_master_key_id = aws_kms_key.state_backend_bucket_kms_key.arn
      sse_algorithm = "aws:kms"
    }
    bucket_key_enabled = true
  }
}

# block S3 bucket public access per each Env's bucket
resource "aws_s3_bucket_public_access_block" "state_backend_bucket_acl" {
  bucket = aws_s3_bucket.state_backend_bucket.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# create DynamoDB table per each Env
resource "aws_dynamodb_table" "state_dynamo_table" {
  name = "tf-state-lock-${var.project_name}"

  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

Створюємо ресурси:

[simterm]

$ terraform init && terraform apply

[/simterm]

Налаштування динамічного State Backend

Далі нам треба налаштувати бекенд для global.

Але щоб потім не повторювати один і той самий конфіг для Dev && Prod – загальні параметри бекенду винесемо окремим файлом.

В корні проекту створюємо backend.hcl:

bucket         = "tf-state-bucket-envs-management-test"
region         = "us-east-1"
dynamodb_table = "tf-state-lock-envs-management-test"
encrypt        = true

В директорії global додаємо backend.tf:

terraform {
  backend "s3" {
    key = "global/terraform.tfstate"
  }  
}

Виконуємо ініціалізацію ще раз, та через -backend-config передаємо шлях до файлу с параметрами бекенду:

[simterm]

$ terraform init -backend-config=../backend.hcl

Initializing the backend...
Acquiring state lock. This may take a few moments...
Do you want to copy existing state to the new backend?
...
  Enter a value: yes
...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v5.14.0

Terraform has been successfully initialized!

[/simterm]

Перевіряємо корзину:

[simterm]

$ aws s3 ls tf-state-bucket-envs-management-test/global/
2023-08-30 16:57:10       8662 terraform.tfstate

[/simterm]

Перший стейт-файл є, чудово.

Створення та використання модулів

Додамо власний модуль для VPC. Тут просто для приклада, в продакшені будемо використовувати AWS VPC Terraform module.

В файлі modules/vpc/main.tf описуємо саму VPC:

resource "aws_vpc" "vpc" {
  cidr_block       = var.vpc_cidr

  tags = {
    environment = var.environment
    created-by  = "terraform"
  }   
}

Там же додаємо файл modules/vpc/variables.tf:

variable "vpc_cidr" {
  description = "VPC CIDR"
  type        = string
}

variable "environment" {
  type = string
}

Далі описуємо змінні vpc_cidr та environment в файлах environments/dev/variables.tf та environments/prod/variables.tf:

variable "vpc_cidr" {
  description = "VPC CIDR"
  type        = string
}

variable "environment" {
  type = string
}

У файлі environments/dev/terraform.tfvars значення для них:

vpc_cidr    = "10.0.1.0/24"
environment = "dev"

І в environments/prod/terraform.tfvars інші значення:

vpc_cidr    = "10.0.2.0/24"
environment = "prod"

В обох environments створюємо main.tf, де включаємо модуль VPC:

module "vpc" {
  source      = "../../modules/vpc"
  vpc_cidr    = var.vpc_cidr
  environment = var.environment
}

Додаємо providers.tf аналогічний тому, що маємо в global:

[simterm]

$ cp global/providers.tf environments/dev/
$ cp global/providers.tf environments/prod/

[/simterm]

І в кожному створюємо власний backend.tf, але з різними key.

Dev:

terraform {
  backend "s3" {
    key = "dev/terraform.tfstate"
  }  
}

Та Prod:

terraform {
  backend "s3" {
    key = "prod/terraform.tfstate"
  }  
}

Тепер у нас виходить така структура каталогів та файлів:

І тепер можемо деплоїти ресурси.

Спочатку Dev:

[simterm]

$ cd environments/dev/
$ terraform init -backend-config=../../backend.hcl
$ terraform apply

[/simterm]

І повторюємо для Prod:

[simterm]

$ cd ../prod/
$ terraform init -backend-config=../../backend.hcl
$ terraform apply

[/simterm]

Перевіряємо бакет стейтів:

[simterm]

$ aws s3 ls tf-state-bucket-envs-management-test/
                           PRE dev/
                           PRE global/
                           PRE prod/

Та самі стейти:

[simterm]

$ aws s3 ls tf-state-bucket-envs-management-test/dev/
2023-08-30 17:32:07       1840 terraform.tfstate

[/simterm]

І чи створились VPC:

Динамічні оточення

Добре – схема з окремими диреткоріями для Dev/Prod виглядає робочю.

Але як бути для динамічних оточень, тобто коли ми хочемо створити інфрастуктуру проекту під час створення Pull Request в Git, для тестів?

Тут можемо використати такий флоу:

  1. бранчуємось від мастер-бранчу
  2. робимо свої зміни в коді environments/dev/
  3. ініціалізуємо новий бекенд
  4. і деплоїмо з terraform apply -var з новими значеннями змінних

Ініціалізуємо новий стейт. Додаємо -reconfigure, бо робимо локально, і тут вже є .terraform. У випадку, коли це буде виконуватись з GitHub Actions – директорія буде чистою, і можна виконувати просто init.

У другому параметрі -backend-config передаємо ключ для стейту – в якій директорії корзини зберігати файл:

[simterm]

$ terraform init -reconfigure -backend-config=../../backend.hcl -backend-config="key=pr1111/terraform.tfstate"

[/simterm]

Тепер деплоїмо з -var або передаємо через змінні як TF_VAR_vpc_cidr, див. Environment Variables – в пайплайні це можна досить просто зробити:

[simterm]

$ terraform apply -var vpc_cidr=10.0.3.0/24 -var environment=pr1111

[/simterm]

Перевіряємо стейти – маємо новий каталог pr1111:

[simterm]

 $ aws s3 ls tf-state-bucket-envs-management-test/
                           PRE dev/
                           PRE global/
                           PRE pr1111/

[/simterm]

Готово.

Корисні посилання

Loading