Terraform: створення Lambda-функцій у VPC
0 (0)

26 Вересня 2023

В пості Loki: збір логів з CloudWatch Logs з використанням Lambda Promtail описано як можна збирати логи з CloudWatch Logs за допомогою Lambda-функції з Promtail, який пересилає логи в Grafana Loki.

Що треба зробити зараз – це описати створення чотирьох таких функцій, по одній на кожен компонент проекту. Функції мають бути розміщені в приватних мережах VPC, щоб мати доступ до Ingress Loki у вигляді Internal Load Balancer.

Підготовка

Використаємо “flat-layout” – всі файли Terraform будуть в корні проекту, а значення змінних для Dev та Prod передамо через окремі файли tfvars, див. Terraform Dev/Prod – Helm-like “flat” approach.

У файлі providers.tf описуємо провайдер AWS:

provider "aws" {
  region = var.aws_region
  default_tags {
    tags = {
      component   = var.component
      created-by  = "terraform"
      environment = var.environment
    }
  }
}

У файлі backend.tf – бекенд для стейт-файлу в S3:

terraform {
  backend "s3" {
    bucket         = "tf-state-backend-atlas-monitoring"
    region         = "us-east-1"
    encrypt        = true
  }
}

Значення key та dynamodb_table передамо під час виконання terraform init, бо для Dev і Prod вони будуть різними.

Додаємо файл variables.tf з поки що двома змінними:

variable "aws_region" {
  type    = string
  default = "us-east-1"
}

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

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
}

Значенням можно передати в defaults, але мені більш подобається задавати значення явно, а не в дефолтах, тому додаємо файл envs/dev/dev.tfvars:

aws_region   = "us-east-1"
environment  = "dev"
component    = "devops"
eks_version  = "1.27"

eks_version використовуємо, щоб створювати окремі ресурси під кожну версію EKS-кластеру, бо під час оновлення версій спокійніше буде створити новий кластер і мігрувати workloads, ніж оновлювати живий Production.

Додаємо файл versions.tf з версіями Terraform та провайдерів:

terraform {

  required_version = "~> 1.5"

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

Для аутентификації та авторизації в AWS маємо AWS CLI Profile, в якому виконується AssumeRole:

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

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

Для Terraform – задаємо змінну AWS_PROFILE та виконємо terraform init з -backend-config:

$ terraform init -backend-config="key=test/atlas-monitoring-test.tfstate" -backend-config="dynamodb_table=tf-state-lock-atlas-monitoring-test"

Initializing the backend...

Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.14"...
- Installing hashicorp/aws v5.17.0...
- Installed hashicorp/aws v5.17.0 (signed by HashiCorp)

...

Terraform has been successfully initialized!

Створення Lambda-функції

Використовуємо модуль, знов від @Anton Babenkoterraform-aws-modules/lambda/aws.

Для запуску функції нам потрібно мати Docker-образ з Promtail – він вже є, зберігається у ECR-репозиторії.

Крім того нам потрібно мати параметри для VPC – їх отримаємо із outputs іншого Terraform-проекту, який у нас займається управлінням мережами, аналогічно тому, як робили в Terraform: terraform_remote_state – отримання outputs інших state-файлів.

І третє, що треба буде мати – це Security-група, яка дозволить трафік від та до цих Lambd у приватних Subnets нашої VPC. Для її створення візьмемо ще один модуль Антона – terraform-aws-modules/security-group/aws.

SecurityGroup та remote_state

Готуємо файл main.tf, описуємо terraform_remote_state:

data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket         = "tf-state-backend-atlas-vpc"
    key            = "${var.environment}/atlas-vpc-${var.environment}.tfstate"
    region         = var.aws_region
    dynamodb_table = "tf-state-lock-atlas-vpc-${var.environment}"
  }
}

Додаємо локальні змінні:

locals {
  # create a name like 'atlas-monitorig-dev-1-27'
  env_name = "test-${var.project_name}-${var.environment}-${replace(var.eks_version, ".", "-")}"
  # 1.27 => 1-27
  env_version = replace(var.eks_version, ".", "-")
  # save 'outputs' from the VPC project
  vpc_out = data.terraform_remote_state.vpc.outputs
}

В outputs проекту з VPC маємо всі необхідні дані:

...
output "vpc_id" {
  value = module.vpc.vpc_id
}
...
output "vpc_private_subnets_cidrs" {
  value = module.vpc.private_subnets_cidr_blocks
}
...

І vpc_private_subnets_cidrs видається у формі list(string):

Описуємо SecurityGroup:

module "security_group_lambda" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "~> 4.0"

  name        = "${local.env_name}-loki-logger-lambda-sg"
  description = "Security Group for Lambda Egress"

  vpc_id = local.vpc_out.vpc_id

  egress_cidr_blocks      = local.vpc_out.vpc_private_subnets_cidrs
  egress_ipv6_cidr_blocks = []

  ingress_cidr_blocks      = local.vpc_out.vpc_private_subnets_cidrs
  ingress_ipv6_cidr_blocks = []

  egress_rules  = ["https-443-tcp"]
  ingress_rules = ["https-443-tcp"]
}

В egress_cidr_blocks та ingress_cidr_blocks вносимо адреси приватних мереж, а в egress_rules та ingress_rules – значення з auto_groups, де вже маємо готовий набор правил.

Ще раз виконуємо terraform init, щоб додати модуль SecurityGroup, та деплоїмо:

$ terraform apply -var-file=envs/dev/dev.tfvars
...
module.security_group_lambda.aws_security_group.this_name_prefix[0]: Creating...
module.security_group_lambda.aws_security_group.this_name_prefix[0]: Creation complete after 3s [id=sg-006d09a7a0ff0beb5]
module.security_group_lambda.aws_security_group_rule.ingress_rules[0]: Creating...
module.security_group_lambda.aws_security_group_rule.egress_rules[0]: Creating...
module.security_group_lambda.aws_security_group_rule.ingress_rules[0]: Creation complete after 1s [id=sgrule-1358616028]
module.security_group_lambda.aws_security_group_rule.egress_rules[0]: Creation complete after 2s [id=sgrule-892435573]
Releasing state lock. This may take a few moments...

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

Перевіряємо:

Lambda

Приклад є у файлі examples/with-vpc/main.tf.

До variables.tf додаємо ще дві змінних – список Components, та URL образу з Promtail:

...

variable "promtail_lambdas" {
  type = set(string)
}

variable "promtail_image" {
  type    = string
  default = "492***148.dkr.ecr.us-east-1.amazonaws.com/lambda-promtail:latest"
}

І значення promtail_lambdas в dev.tfvars:

...

promtail_lambdas = [
  "backend",
  "web",
  "ios",
  "eks"
]

До locals додаємо змінну з URL інстансу Grafana Loki:

...
locals {
  ...
  # save 'outputs' from the VPC project
  vpc_out = data.terraform_remote_state.vpc.outputs
  # build URL 'logger.1-27.dev.example.co'
  loki_write_address = "https://logger.${replace(var.eks_version, ".", "-")}.${var.environment}.example.co:443/loki/api/v1/push"
}
...

Додаємо модуль terraform-aws-modules/lambda/aws до нашого main.tf:

...

module "lambda_function_from_container_image" {
  source   = "terraform-aws-modules/lambda/aws"
  version  = "~> 6.0"
  for_each = var.promtail_lambdas

  function_name = "grafana-${local.env_name}-loki-logger-${each.value}"
  description   = "Promtail instance to colelct logs from CloudWatch Logs"

  create_package = false

  image_uri     = var.promtail_image
  package_type  = "Image"
  architectures = ["x86_64"]

  environment_variables = {
    EXTRA_LABELS             = "component,${each.value}"
    KEEP_STREAM              = "true"
    OMIT_EXTRA_LABELS_PREFIX = "true"
    PRINT_LOG_LINE           = "true"
    WRITE_ADDRESS            = local.loki_write_address
  }

  vpc_subnet_ids                     = local.vpc_out.vpc_private_subnets_ids
  vpc_security_group_ids             = [module.security_group_lambda.security_group_id]
  attach_network_policy              = true
}

Тут в циклі for_each перебираємо всі Components, для яких треба створити фукцію, в package_type вказуємо, що функція буде запускатись з Docker-образу, в environment_variables.EXTRA_LABELS задаємо лейблу component, яка буде додана до логів в Loki.

У vpc_subnet_ids знов використовуємо outputs проекту VPC, в vpc_security_group_ids вказуємо SecurityGroup, яку створили вище.

Параметр attach_network_policy підключить до IAM Role, яка буде підключена до функції, політику AWSLambdaENIManagementAccess, див. terraform-aws-lambda/blob/master/iam.tf.

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

Тестування логів

Додамо Subcription Filter до лог-групи кластеру EKS, щоб перевірити, що функція працює:

Метрики функції – дивимось Invocations та Error count and success rate (%):

І логи в Loki:

Готово.

Loading

Terraform: terraform_remote_state – отримання outputs інших state-файлів
0 (0)

22 Вересня 2023

За допомогою data "terraform_remote_state" ми можемо отримати outputs одного проекту, щоб використати в іншому.

Наприклад, у нас AWS VPC створюється окремо від AWS EKS (хоча в серії Terraform: створення EKS, частина 1 – VPC, Subnets та Endpoints VPC створювалась як частина кластеру, але потім я їх розділив).

Для створення EKS – йому треба передати VPC ID, щоб потім з data "aws_subnets" отримати список сабнетів, в яких буде створено кластер Kubernetes.

Можна захаркодити значення VPC – просто задати змінну string, в якій зберігати значення, і в данному випадку це більш-менш робоче рішення, бо VPC ID навряд чи буде часто змінюватись. Проте якщо у вас досить багато значень, або вони динамічні – то є сенс використати terraform_remote_state, який зможе “сходити” в AWS S3 бакет іншого проекту, і отримати актуальні значення прямо зі стейт-файлу.

Отже, що маємо: проект з VPC модулем, який має output:

output "vpc_id" {
  value = module.vpc.vpc_id
}

Він вже задеплоїний, і ми можемо отримати цей ID зі стейту за допомогою terraform output:

$ terraform output vpc_id
"vpc-0958e335e1c910ece"

Сам стейт проекту з VPC зберігається в AWS S3:

$ aws --profile tf-admin s3 ls tf-state-backend-atlas-vpc/dev/
2023-09-22 15:14:43      81292 atlas-vpc-dev.tfstate

Далі, в проекті з модулем EKS додаємо data "terraform_remote_state":

data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket         = "tf-state-backend-atlas-vpc"
    key            = "${var.environment}/atlas-vpc-${var.environment}.tfstate"
    region         = "${var.aws_region}"
    dynamodb_table = "tf-state-lock-atlas-vpc-${var.environment}"
  }
}

При чому тут, на відміну від звичайної конфіграції terraform.backend{} ми можемо використовувати variables.

Створюємо локальну змінну, щоб потім не міняти по всьому коду EKS при якихось змінах в outputs VPC:

locals {
  vpc_out = data.terraform_remote_state.vpc.outputs
}

І використовуємо цей vpc_out:

...
data "aws_subnets" "private" {
  filter {
    name   = "vpc-id"
    values = [local.vpc_out.vpc_id]
  }

  tags = {
    subnet-type = "private"
  }
}

data "aws_subnets" "intra" {
  filter {
    name   = "vpc-id"
    values = [local.vpc_out.vpc_id]
  }

  tags = {
    subnet-type = "intra"
  }
}
...
module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 19.0"
  ...
  vpc_id                   = local.vpc_out.vpc_id
  subnet_ids               = data.aws_subnets.private.ids
  control_plane_subnet_ids = data.aws_subnets.intra.ids
...

Готово.

Loading

GitHub Actions: деплой Dev/Prod оточень з Terraform
0 (0)

21 Вересня 2023

Тепер, як маємо готовий код для розгортання кластеру AWS Elastic Kubernetes Service (див. Terraform: створення EKS, частина 1 – VPC, Subnets та Endpoints і наступні частини), прийшов час подумати про автоматизацію, тобто – про створення пайплайнів в CI/CD, які би виконували створення нових енвів для тестування фіч, або деплоїли апдейти на Dev/Prod оточення Kubernetes.

І тут знову поговоримо про менеджмент Dev/Prod оточень з Terraform.

В попередніх записах я перебирав варіанти з Terraform Workspaces, Git-бранчами та розділенням по директоріям (див. Terraform: початок роботи та планування нового проекту – Dev/Prod та bootsrap та Terraform: динамічний remote state з AWS S3 та multiple environments по директоріям), але поки писав код Terraform, то подумав про те, як зазвичай робиться з Helm-чартами: весь код чарту зберігається в корні репозиторію або окремій директорії, а значення для Dev/Prod передаються з різних файлів values.yaml.

То чому б не спробувати аналогічний підхід з Terraform?

В будь-якому разі те, що описано в цьому пості не треба сприймати як приклад “це робиться саме так”: тут я більше експерементую і пробую можливості GitHub Actions та Terraform. А в якому вигляді воно піде в продакшен – подивимось.

Проте вийшло доволі цікаво.

Terraform Dev/Prod – Helm-like “flat” approach

Отже, конфігурація для Terraform може бути такою:

  • аутентифікацію та атворизацію робимо локально зі змінною AWS_PROFILE, в якому виконується AssumeRole, а в GitHub Actions – через OIDC і AssumeRole – таким чином зможемо мати різні параметри для AWS Provider для Dev та Prod оточень
  • весь код Terraform зберігається в корні
  • tfvars для Dev/Prod в окремих директоріях envs/dev та envs/prod
  • загальні параметри backend описуємо у файлі backend.hcl, а ключі і таблицю Дінамо передаємо через -backend-config

А флоу розробки та деплою – таким:

  • бранчуємось від мастер-бранчу
  • робимо зміни в коді, тестуємо локально або через GitHub Actions, передаючи потрібні параметри через -var (ім’я енву, якісь версії і т.д.)
  • як закінчили розробку і тести – пушимо в репозиторій, і робимо Pull Request
  • при створенні Pull Request можемо задати спеціиальну лейблу, і тоді GitHub Actions виконують деплой на feature-енв, тобто створюється тимчасова інфрастуктура в AWS
  • Dev-оточення, щоб перевірити, що все працює
  • після мержу Pull Request можемо додатково задеплоїти на Dev-оточення, щоб перевірити, що зміни між строю версією та новою працюють (такий собі staging env)
  • якщо треба задеплоїти на Прод – створюємо Git Release
  • вручну або автоматично тригерим GitHub Actions на деплой з цим релізом
  • profit!

А що там Terragrunt?

Звісно, для управління оточеннями і бекендами можна було б просто взяти Terragrunt, але я поки не впевнений в тому, яка модель управління інфрастуктурою з Terraform складеться на проекті: якщо цим будуть займатись виключно девопси – то, скоріш за все, візьмемо Terragrunt. Якщо діло зайде і девелоперам, і вони захочуть самі щось менеджити – то, можливо, для управління візьмемо якесь рішення на кштал Atlantis або Gaia.

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

Terraform layout for multiple environments

Зараз структура файлів і каталогів виглядає так – в environments/dev весь код для Dev, в environments/prod – весь код для Prod (поки пусто):

$ tree terraform/
terraform/
└── environments
    ├── dev
    │   ├── backend.tf
    │   ├── configs
    │   │   └── karpenter-provisioner.yaml.tmpl
    │   ├── controllers.tf
    │   ├── eks.tf
    │   ├── iam.tf
    │   ├── karpenter.tf
    │   ├── main.tf
    │   ├── outputs.tf
    │   ├── providers.tf
    │   ├── terraform.tfvars
    │   ├── variables.tf
    │   └── vpc.tf
    └── prod

Однак так як ми тут не використвуємо модулі, то код дуже дублюється, і менеджити його буде важко і як то кажусь, “error prone”.

Можна було б створити модулі, але тоді були б модулі в модулях, бо весь EKS-стек створюється через модулі – VPC, EKS, Subnets, IAM, etc, і ускладнювати код, виносячи код в модулі поки що не хочеться, бо знов-таки – подивимось, як підуть процеси. Поки це все на самому початку – то маємо змогу досить швидко переробити так, як потім буде краще в залежності від того, яку модель і систему управління оточеннями оберемо.

Тож що нам треба зробити:

  • перенести файли .tf в корень директорії terraform
  • оновити файл providers.tf – видалити з нього assume_role та прибрати --profile в args провайдерів Kubernetes, Helm та kubectl
  • створити файл backend.hcl для common-конфігурації
  • у файлі backend.tf лишити тільки backend "s3"
  • environments перейменуємо в envs
    • всередені будуть каталоги dev та prod, в яких будуть зберігатись terraform.tfvars для кожного енва

Providers

providers.tf зараз виглядає так:

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

Прибираємо з нього assume_role:

provider "aws" {
  region = var.aws_region
  default_tags {
    tags = {
      component   = var.component
      created-by  = "terraform"
      environment = var.environment
    }
  }
}

В інші провайдерах – Kubernetes, Helm та Kubectl  – в args прибираємо --profile:

...
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        = ["eks", "get-token", "--cluster-name", module.eks.cluster_name]
  }
}
...

Versions

Версії виносимо в окремий файл vesrions.tf:

terraform {

  required_version = "~> 1.5"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.14"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.23"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 2.11"
    }
    kubectl = {
      source  = "gavinbunney/kubectl"
      version = "~> 1.14"
    }
  }
}

Переносимо файли, і тепер структура виглядає так:

$ tree
.
├── backend.tf
├── configs
│   └── karpenter-provisioner.yaml.tmpl
├── controllers.tf
├── eks.tf
├── iam.tf
├── karpenter.tf
├── main.tf
├── outputs.tf
├── providers.tf
├── variables.tf
├── envs
│   ├── dev
│   │   └── dev.tfvars
│   └── prod
├── versions.tf
└── vpc.tf

Backend

В корні створюємо файл backend.hcl:

bucket         = "tf-state-backend-atlas-eks"
region         = "us-east-1"
dynamodb_table = "tf-state-lock-atlas-eks"
encrypt        = true

У файлі backend.tf залишаємо тільки s3:

terraform {
  backend "s3" {}
}

Окей – наче все готово? Давайте тестити.

Тестування з terraform init && plan для різних оточень

Маємо AWS Profile в ~/.aws/config:

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

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

Перевіряємо існуючий ключ в S3 – бо вже маємо задеплоїний Dev:

$ aws s3 ls tf-state-backend-atlas-eks/dev/
2023-09-14 15:49:15     450817 atlas-eks.tfstate

Задаємо змінну AWS_PROFILE, і пробуємо terraform init з backend-config:

$ export AWS_PROFILE=tf-admin

$ terraform init -backend-config=backend.hcl -backend-config="key=dev/atlas-eks.tfstate"

Initializing the backend...

Successfully configured the backend "s3"! Terraform will automatically
...
Terraform has been successfully initialized!

Наче ОК – модулі загрузились, існуючий стейт-файл знайшло, все гуд.

Пробуємо terraform plan – в коді нічого не мінялось, тож нічого не має змінитись і в AWS:

$ terraform plan -var-file=envs/dev/dev.tfvars
...
Terraform will perform the following actions:

  # helm_release.karpenter will be updated in-place
  ~ resource "helm_release" "karpenter" {
        id                         = "karpenter"
        name                       = "karpenter"
      ~ repository_password        = (sensitive value)
        # (28 unchanged attributes hidden)

        # (6 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.
...

Найс! Змінить тільки пароль для AWS ECR, це ОК, бо там токен, що міняється.

Спробуємо зробити теж саме, але для Prod – копіюємо файл dev.tfvars як prod.tfvars в каталог envs/prod, міняємо в ньому данні – тільки VPC CIDR та environment:

project_name = "atlas-eks"
environment  = "prod"
component    = "devops"
eks_version  = "1.27"
vpc_params = {
  vpc_cidr               = "10.2.0.0/16"
  enable_nat_gateway     = true
...

І пробуємо новий terraform init з -reconfigure, бо робимо це все локально. В backend-config передаємо новий ключ для стейт-файлу в S3:

$ terraform init -reconfigure -backend-config=backend.hcl -backend-config="key=prod/atlas-eks.tfstate"

Initializing the backend...

Successfully configured the backend "s3"! Terraform will automatically
...
Terraform has been successfully initialized!

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

$ terraform plan -var-file=envs/prod/prod.tfvars
...
Plan: 109 to add, 0 to change, 0 to destroy.
...

Добре! Збирається створити купу нових ресурсів, бо Prod ще не робив, тобто все, як задумано.

Для feature-енвів все теж саме, тільки в -var передаємо ще environment і в backend-config задаємо новий ключ по імені енва:

$ terraform init -reconfigure -backend-config=backend.hcl -backend-config="key=feat111/atlas-eks.tfstate" -var="environment=feat111"
...

Для plan та apply використовуємо параметри з файлу dev.tfvars, бо фіча-енви в принципі більш-менш ~= dev-оточенню:

$ terraform plan -var-file=envs/dev/dev.tfvars -var="environment=feat111" 
...

Якщо потрібно задати наприклад нову VPC, яка у нас object, в якому є string vpc_cidr:

...
variable "vpc_params" {
  description = "AWS VPC for the EKS cluster parameters. Note, that this VPC will be environment-wide used, e.g. all Dev/Prod AWS resources must be placed here"
  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
  })
}
...

Тож її передаємо як -var='vpc_params={vpc_cidr="10.3.0.0/16"}'. Але в такому випадку потрібно передавати всі значення об’єкту vpc_params, тобто:

$ terraform plan -var-file=envs/dev/dev.tfvars -var="environment=feat111" \
> -var='vpc_params={vpc_cidr="10.3.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}'

Окей – з цим розібрались, можна переходити до GitHub Actions.

Авторизація і аутентифікація – OIDC та IAM

Для доступу з GitHub Actions до AWS ми використовуємо OpenID Connect – тут GitHub виступає в ролі Identity Provider (IDP), а AWS – Service Prodider (SP), тобто на GitHub ми проходимо аутентифікацю, після чого GitHub “передає” нас до AWS, кажучи, що “це дійсно Вася Пупкін”, а вже AWS виконує авторизацію – тобто, AWS перевіряє, чи може цей Вася Пупкін створювати нові ресурси.

Для авторизації в AWS ми в Terraform виконуємо IAM Role Assume, і вже від імені цієї ролі і IAM Policy, які підключені до неї, ми проходимо авторизацію.

Щоб мати можливість виконувати AsuumeRole цієї ролі використовуючи GitHub Identity Provider – треба змінити її Trusted Policy, бо зараз вона дозволяє AssumeRole тільки для юзерів з AWS акаунту:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Statement1",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::492***148:root"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

Документація – Configuring the role and trust policy.

Додаємо token.actions.githubusercontent.com IDP з нашого AWS АІМ:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AccountAllow",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::492***148:root"
      },
      "Action": "sts:AssumeRole"
    },
        {
            "Sid": "GitHubAllow",
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::492***148:oidc-provider/token.actions.githubusercontent.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
                }
            }
        }		
  ]
}

GitHub Actions: recall the moment!

Колись писав про них – Github: обзор Github Actions и деплой с ArgoCD, але то було давно, та й сам я Actions майже не користувався, то треба трохи згадати що до чього.

Див. також Understanding GitHub Actions.

Отже, що маємо:

  • є events, як тригерять jobs
  • в jobs маємо steps з одним чи кількома actions
  • actions виконують конкретні дії, і actions однієї job мають запускатись на одному GitHub Actions Runner

 

При цьому в Actions можемо використовувати готові “бібліотеки” з Github Actions Marketplace.

GitHub Environments

GitHub дозволяє налаштувати декілька оточень для роботи, і в кожному мати свій набір Variables та Secrets. Крім того, для них можна налаштувати правила, згідно з якими має відбуватись деплой. наприклад – дозволити деплой на Prod тільки з master-гілки.

Див. Using environments for deployment.

Тож що ми можемо зробити:

  • Dev env зі своїми змінними
  • Prod env зі свіоми змінними
  • і створювати feature-енви динамічно під час деплою

GitHub Actions Reusable Workflow та Composite Actions

З Reusable Workflow ми можемо використати вже описаний Workflow в іншому workflow, а з Composite Actions – створити власну Action, яку потім включимо в steps наших workflow-файлів.

Детальніше про різницю між ними можна почитати тут – GitHub: Composite Actions vs Reusable Workflows [Updated 2023] і чудовий пост Github Not-So-Reusable Actions, але якщо кратко, то:

  • при створенні Workflow ви групуєте декілька Jobs в єдиний файл і використовувати тільки в Jobs, тоді як в Actions будуть тільки steps, які групуються в єдиний Action, який потім можна використовувати тільки як step
  • Workflow не можуть викликати інші Workflow, тоді як в Actions ви можете викликати інші Actions

Крім того, замість Reusable Workflow можна використати Running a workflow based on the conclusion of another workflow – тобто тригерити запуск воркфлоу після завершення роботи іншого воркфлоу.

Створення тестового workflow для Terraform validate && lint

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

Див. Quickstart for GitHub Actions, документація по сінтаксісу – Workflow syntax for GitHub Actions, і по permissions нашої джоби – Assigning permissions to jobs.

Для Terraform будемо використовувати setup-terraform Action.

Для логіну в AWS – configure-aws-credentials Action.

В репозиторії створюємо директорію:

$ mkdir -p .github/workflows

І в ній створюємо файл воркфлоу – testing-terraform.yaml.

Так як код Terraform лежить в каталозі terraform, то в workflow додамо defaults.run.working-directory та умову on.push.paths, тобто трігерити білд тільки якщо зміни відбулися в каталозі terraform, див. paths.

Описуємо флоу:

name: Test Terraform changes

defaults:
  run:
    working-directory: terraform

on: 
  workflow_dispatch:
  push:
    paths:
      - terraform/**

env:
  ENVIRONMENT : "dev"
  AWS_REGION : "us-east-1"

permissions:
  id-token: write
  contents: read

Тут:

  • в defaults задаємо з якої директорії будуть виконуватись всі степи
  • в on – умови, по пушу і тільки для директорії terraform і додатково workflow_dispatch для можливості ручного запуску
  • в env – значення змінних оточення
  • в  permissions – права джоби на створення токену.

В джобах ми будемо виконувати AssumeRole, давайте її занесемо в змінні оточення репозиторію.

Переходимо в Setting > Secrets and variables > Actions > Variables, і додаємо нову змінну:

Далі, подумаємо які степи в джобі нам треба виконати:

  1. git checkout
  2. логін в AWS
  3. terraform fmt -check для перевірки “красоти коду” (див. fmt)
  4. terraform init з Dev-оточенням – загрузити модулі і підключитись до state-бекенду
  5. terraform validate для перевірки синтаксису коду (див. validate)

Додаємо джобу з цими степами, тепер файл повністю буде таким:

name: Test Terraform changes

defaults:
  run:
    working-directory: terraform

on: 
  workflow_dispatch:
  push:
    paths:
      - terraform/**

env:
  ENVIRONMENT : "dev"
  AWS_REGION : "us-east-1"

permissions:
  id-token: write
  contents: read

jobs:
  test-terraform:
    runs-on: ubuntu-latest
    steps:

      - uses: actions/checkout@v3
      - uses: hashicorp/setup-terraform@v2

      - name: Terraform fmt
        run: terraform fmt -check
        continue-on-error: true

      - name: "Setup: Configure AWS credentials"
        # this step adds `env.AWS_*` variables
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: ${{ vars.TF_AWS_ROLE }}
          role-session-name: github-actions-terraform
          role-duration-seconds: 7200
          aws-region: ${{ env.AWS_REGION }}

      - name: Terraform Init
        run: terraform init -backend-config=backend.hcl -backend-config="key=${{ env.ENVIRONMENT }}/atlas-eks.tfstate"

      - name: Terraform Validate
        run: terraform validate

Пушимо зміни в репозиторій, запускаємо вручну, бо змін в коді Terraform не було:

І маємо наш перший ран:

Note: аби з’явилась кнопка для ручного запуску (workflow_dispatch) – зміни в workflow-файлі мають бути змержені з дефолтним бранчем репозиторія

GitHub Actions TFLint step

Тепер давайте додамо степ з Terraform linter – TFLint.

Спочатку глянемо, як воно працює локально:

$ yay -S tflint

$ tflint
3 issue(s) found:

Warning: module "ebs_csi_irsa_role" should specify a version (terraform_module_version)

  on iam.tf line 1:
   1: module "ebs_csi_irsa_role" {

Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.1.1/docs/rules/terraform_module_version.md

Warning: module "karpenter" should specify a version (terraform_module_version)

  on karpenter.tf line 1:
   1: module "karpenter" {

Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.1.1/docs/rules/terraform_module_version.md

Слушні зауваження – забув додати версії модулів, треба пофіксити.

Далі, додамо його на нашого воркфлоу – використаємо marketplace/actions/setup-tflint, версію беремо на сторінці релізів TFLint:

...

      - name: Setup TFLint
        uses: terraform-linters/setup-tflint@v3
        with:
          tflint_version: v0.48.0

      - name: Terraform lint
        run: tflint -f compact

Запускаємо, і маємо сфейлений білд:

Добре.

Фіксимо проблеми, і йдемо далі до деплоїв.

Планування деплоїв з GitHub Actions

Тепер можна подумати про те, як нам побудувати workflow.

Які ми маємо дії в GitHub-репозиторії, і які дії з Terraform нам можуть знадобитись?

В GitHub:

  • Push в будь-який бранч
  • створення Pull Request на merge в master
  • закриття Pull Request на merge в master

З Terraform:

  • тестування: fmt, validate, lint
  • деплой: plan та apply
  • дестрой: plan та destroy

І як ми можемо це все скомбінувати в різні workflow?

  • при Push – виконувати тест
  • при створенні PR в мастер з лейблою “deploy” – деплоїти feature-енв (тест + деплой)
  • при закритті PR – видаляти feature-енв (дестрой)
  • вручну мати можливість:
    • задеплоїти на Dev будь-який бранч або реліз (тест + деплой)
    • задеплоїти на Prod реліз з мастер-бранчу (тест + деплой)

Тобто виглядає, як п’ять воркфлоу, при чому майже в усіх маємо дії, які повторюються – логін, init та test, тож їх має сенс винести в окремі “функції” – використати Reusable Workflow або Composite Actions.

AWS Login та init можемо об’єднати, і додатково створити “функцію” test. Крім того – додати “функції” “deploy” та “destroy”, і потім “модульно” їх використовувати у наших workflow:

  • terraform-init:
    • AWS Login
    • terraform init
  • test:
    • terraform validate
    • terraform fmt
    • tflint
  • deploy:
    • terraform plan
    • terraform apply
  • destroy:
    • terraform plan
    • terraform destroy

Тож якщо планувати з п’ятьма окремими воркфлоу з використанням GitHub Environments та “функціями”, то це будуть:

  • тест-он-пуш:
    • запускається на push event
    • запускається з environment Dev, де маємо змінні для Dev-оточення
    • викликає “функції” terraform-init та test
  • деплой-дев:
    • запускається вручну
    • запускається з environment Dev, де маємо змінні для Dev-оточення
    • викликає “функції” terraform-init, test та deploy
  • деплой-прод:
    • запускається вручну
    • запускається з environment Prod, де маємо змінні для Prod-оточення та protection rules
    • викликає “функції” terraform-init, test та deploy
  • деплой-фіча-енв:
    • запускається при створенні ПР з лейблою “деплой”
    • запускається з динамічним оточенням (ім’я генерується з Pull Request ID і створюється під час деплою)
    • викликає “функції” terraform-init, test та deploy
  • дестрой-фіча-енв:
    • запускається при закритті ПР з лейблою “деплой”
    • запускається з динамічним оточенням (ім’я генерується з Pull Request ID  і під час деплою оточення вибирається з існуючих)
    • викликає “функції” terraform-init, test та destroy

Як саме робити ці “функції” – з Reusable Workflow чи Composite Actions? В прицнипі, в данному випадку мабуть різниці великої нема, бо будемо мати один рівень вкладеності, але тут є нюанс з GitHub Runners: якщо в Workflow робити задачі “terraform-init”, “test” та “deploy” в різних jobs (а Reusable Workflow мають бути тільки в jobs, більш того – ці jobs не можуть мати інших steps) – то вони скоріш за все будуть запускатись на різних Runners, і в такому випадку для виконання terraform plan && apply && destroy нам доведеться кілька раз виконувати step з AWS Login та terraform init.

Тому виглядає більш правильним запускати всі задачі в рамках однієї job, тобто для створення наших “функцій” використати Composite Actions.

Єдине, що в цьому пості я все ж не буду описувати деплой feature-енвів, бо по-перше – пост і так вже досить довгий і містить багато нвоої (принаймні для мене) інформації, по-друге – створення додаткових тестових кластерів AWS EKS буде скоріш виключенням, і буде робитись мною, а я поки що це можу зробити і без додаткової автоматизації. Втім, скоріш за все опишу створення динамічних оточень, коли буду робити GitHub Actions Workflow для деплоїв Helm-чартів.

Окей – давайте пробувати деплоїти Dev та Prod.

Підготовка деплоїв

Створення Actions secrets and variables

У нас буде декілька змінних, які будуть однакові для всіх оточень – AWS_REGION та TF_AWS_PROFILE, тож їх задамо на рівні всього репозиторію.

Переходимо в Settings  – Actions secrets and variables > Variables, і додаємо Repository variables:

Створення GitHub Environments

Додаємо два оточення – Dev та Prod:

Аналогічно для Prod.

Створення test-on-push Workflow

Створення Composite Action “terraform-init”

В директорії .github додаємо каталог actions, в якому створюємо каталог terraform-init з файлом action.yaml, в якому описуємо сам Action.

Див. Creating a composite action.

В inputs приймаємо три параметри, які потім передамо з Workflow:

name: "AWS Login and Terraform Init"
description: "Combining AWS Login && terraform init actions into one"

inputs:
  environment:
    type: string
    required: true
  region:
    type: string
    required: true
  iam-role:
    type: string
    required: true

runs:
  using: "composite"
  
  steps:

    - uses: hashicorp/setup-terraform@v2

    - name: "Setup: Configure AWS credentials"
      # this step adds `env.AWS_*` variables
      uses: aws-actions/configure-aws-credentials@v2
      with:
        role-to-assume: ${{ inputs.iam-role }}
        role-session-name: github-actions-terraform
        role-duration-seconds: 7200
        aws-region: ${{ inputs.region }}

    - name: Terraform Init
      run: terraform init -backend-config=backend.hcl -backend-config="key=${{ inputs.environment }}/atlas-eks.tfstate"
      shell: bash
      working-directory: terraform

Тут є нюанс з working-directory, який в Composite Action треба задавати окремо для кожного step, бо сам Action в workflow запускається в корні репозиторія, а використати defaults, як це можна зробити в workflow не можна, хоча feature request є давно.

Створення Composite Action “test”

В директорії actions створюємо ще один каталог – terraform-test з файлом action.yaml, в якому описуємо другий Action:

name: "Test and verify Terraform code"
description: "Combining all test actions into one"

inputs:
  environment:
    type: string
    required: true
  region:
    type: string
    required: true
  iam-role:
    type: string
    required: true

runs:
  using: "composite"
  
  steps:

    - uses: hashicorp/setup-terraform@v2

    - name: Terraform Validate
      run: terraform validate
      shell: bash
      working-directory: terraform

    - name: Terraform fmt
      run: terraform fmt -check
      shell: bash
      working-directory: terraform
      continue-on-error: false

    - name: Setup TFLint
      uses: terraform-linters/setup-tflint@v3
      with:
        tflint_version: v0.48.0

    - name: Terraform lint
      run: tflint -f compact
      shell: bash
      working-directory: terraform

Створення workflow

Далі, в директорії .github/workflows створюємо файл test-on-push.yaml з новим флоу:

name: Test Terraform code

on: 
  workflow_dispatch:
  push:
    paths:
      - terraform/**

permissions:
  id-token: write
  contents: read

jobs:
  test-terraform:
    environment: 'dev'
    runs-on: ubuntu-latest
    steps: 

      - uses: actions/checkout@v3

      - name: 'Setup: AWS Login and Terraform init'
        uses: ./.github/actions/terraform-init
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

      - name: 'Test: check and validate'
        uses: ./.github/actions/terraform-test
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

В job: test-terraform задаємо використання environment: 'dev' і його змінних (у нас там вона одна – ENVIRONMENT=dev, а в stepsAWS Login and Terraform init” і “Test and validate” викликаємо наші файли з Actions, які через with передаємо параметри зі значеннями із змінних.

Пушимо зміни в репозиторій, і маємо наш білд:

Можемо переходити до деплою.

Створення deploy-dev Workflow

Отже, що ми хочемо?

  • деплоїти на Дев з бранчу або тегу
  • деплоїти на Прод вручну з релізу

Давайте почнемо з Дев-деплою.

Створення Composite Action “deploy”

Створюємо ще один каталог для нового Action – actions/terraform-apply, в ньому у файлі action.yaml описуємо сам Action:

name: "Plan and Apply Terraform code"
description: "Combining plan && apply actions into one"

inputs:
  environment:
    type: string
    required: true
  region:
    type: string
    required: true
  iam-role:
    type: string
    required: true

runs:
  using: "composite"
  
  steps:

    - uses: hashicorp/setup-terraform@v2

    - name: Terraform plan
      run: terraform plan -var-file=envs/${{ inputs.environment }}/${{ inputs.environment }}.tfvars 
      shell: bash
      working-directory: terraform

    - name: Terraform apply
      run: terraform apply -var-file=envs/${{ inputs.environment }}/${{ inputs.environment }}.tfvars -auto-approve
      shell: bash
      working-directory: terraform

Створення workflow

Додаємо новий файл workflow – deploy-dev.yaml:

name: Deploy Dev environment

concurrency:
  group: deploy-${{ vars.ENVIRONMENT }}
  cancel-in-progress: false

on: 
  workflow_dispatch:
  push:
    paths:
      - terraform/**

permissions:
  id-token: write
  contents: read

jobs:
  deploy-to-dev:
    environment: dev
    runs-on: ubuntu-latest
    steps: 

      - uses: actions/checkout@v3

      - name: 'Setup: AWS Login and Terraform init'
        uses: ./.github/actions/terraform-init
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

      - name: 'Test: check and validate'
        uses: ./.github/actions/terraform-test
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

      - name: 'Deploy: to ${{ vars.ENVIRONMENT }}'
        uses: ./.github/actions/terraform-apply
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

В умовах on.workflow_dispatch можна додати ще inputs, див. on.workflow_dispatch.inputs.

Усі умови, якими можна тригерити воркфлоу – див. у Events that trigger workflows.

В concurrency задаємо умову “ставити в чергу, а не виконувати паралельно, якщо запущено іншу job, яка використовує такий же concurrency.group“, див. Using concurrency.

Тобто, якщо ми робимо перевірку по group: deploy-${{ vars.ENVIRONMENT }} – то якщо після запуску Dev запустити деплой на Prod – то він буде очікувати, поки не завершиться деплой на Dev:

Пушимо, мержимо в master, щоб з’вилась кнопка “Run workflow“, і перевіряємо:

І після деплою:

Створення deploy-prod Workflow та захист від випадкового запуску

Composite Action ми вже маємо, тож створювати її не треба.

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

Тобто на Дев можемо деплоїти будь-який бранч або тег, а на Прод – тільки тег.

Взагалі я тут намагаюсь уникнути автоматичного деплою, бо це все ж інфрастуктура і все ще в процессі роботи напильником, хоча якщо б у нас був GitHub Enterprise – то можна було б використати Deployment protection rules і Required reviewers в них, а в умові on для запуску воркфлоу деплою на Прод використати створення релізів:

on:
  release:
    types: [published]

Тобто – деплоїти на Prod тільки коли створено новий GitHub Release, і тільки після того, як, наприклад, деплой апрувнуто кимось з DevOps-тіми.

Ще один варіант забезпечення безпеки Prod-оточення – це дозволяти мерж в master тільки від Code Owners в Branch protection rules:

А Code Owners описати у файлі CODEOWNERS, і дозволити деплой на Prod тільки з мастер-гілки:

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

Тож єдиний варіант – це використати умову if в самій job, див. Using conditions to control job execution:

...
jobs:
  deploy-to-prod:
    if: github.ref_type == 'tag'
    environment: prod
...

Ну і можна комбінувати умови, наприклад – додатково через github.actor або github.triggering_actor перевіряти хто саме запускає деплой, і дозволити тільки певним юзерам. Див. всі типи в github context.

Як додатковий захист від випадкового запуску – можна додати input, до треба явно вказати “yes”:

...
on: 
  workflow_dispatch:
    inputs:
      confirm-deploy:
        type: boolean
        required: true
        description: "Set to confirm deploy to the PRODUCTION environment"
...

А потім перевірити в if обидві умови – github.ref_type та github.event.inputs.confirm-deploy.

Тож поки вокрфлоу для Проду буде таким:

name: Deploy Prod environment

concurrency:
  group: deploy-${{ vars.ENVIRONMENT }}
  cancel-in-progress: false

on: 
  workflow_dispatch:
    inputs:
      confirm-deploy:
        type: boolean
        required: true
        description: "Set to confirm deploy to the PRODUCTION environment"

permissions:
  id-token: write
  contents: read

jobs:
  deploy-to-prod:
    if: ${{ github.ref_type == 'tag' && github.event.inputs.confirm-deploy == 'true' }}

    environment: prod
    runs-on: ubuntu-latest
    steps: 

      - uses: actions/checkout@v3

      - name: 'Setup: AWS Login and Terraform init'
        uses: ./.github/actions/terraform-init
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

      - name: 'Test: check and validate'
        uses: ./.github/actions/terraform-test
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

      - name: 'Deploy: to ${{ vars.ENVIRONMENT }}'
        uses: ./.github/actions/terraform-apply
        with:
          environment: ${{ vars.ENVIRONMENT }}
          region: ${{ vars.AWS_REGION }}
          iam-role: ${{ vars.TF_AWS_ROLE }}

І при деплої – треба по-перше вибрати тег, а не бранч, по-друге – поставити відмітку “Confirm”:

А вже по ходу діла подивимось, як все буде складатись.

Ну і поки що на цьому все.

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

Так що – далі буде ще.

Пару моментів на додачу, доробити потім:

  • Action setup-terraform приймає інпут terraform_version, але не вміє це робити з versions-файлу; поки варіант робити через міні-костиль з додатковим step, див. цей коммент
  • в jobs має сенс додати параметр timeout-minutes, щоб джоби при проблемах не зависали на довгий час, і не вичерпували Runners time

Loading

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