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

Автор |  07/09/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.