Terraform: менеджмент EKS Access Entries та EKS Pod Identities

Автор |  15/07/2024
 

Отже, маємо кластер AWS Elastic Kubernetes Service з Authentication mode EKS API and ConfigMap, який ми включили під час апгрейду Terraform-модуля з версії 19.21 на 20.0.

Перед тим, як переключати EKS Authentication mode повністю на API – нам потрібно з aws-auth ConfgiMap перенести всіх юзерів і ролі в Access Entries EKS-кластера.

І ідея зараз така, щоб створити окремий проект Terraform, назвемо його “atlas-iam“, в якому ми будемо менеджити всі IAM-доступи – і для EKS з Access Entries та Pod Identities, і для RDS з IAM database authentication, і, можливо, потім сюди ж перенесемо і юзер-менеджмент взагалі.

Всі три схеми розбирав детальніше:

Не знаю, наскільки описана нижче схема зайде нам в майбутньому Production, але в цілому мені ідея поки що подобається – окрім проблеми з динамічними іменами для EKS Pod Identities.

Отже, поглянемо як ми з Terraform можемо реалізувати автоматизацію управління доступом для IAM Users та IAM Roles до EKS Cluster з EKS Access Entries, та як у Terraform можна створювати EKS Pod Identities для ServiceAccounts. Про додавання RDS сьогодні говорити не будемо – але його будемо мати на увазі при плануванні.

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

EKS Authentification та IAM: the current state

Зараз в aws-auth ConfgiMap зараз маємо:

  • IAM Users: звичайні юзери, які ходять в Kubernetes
  • IAM Roles: ролі для доступу в кластер з GitHub Actions

І всі з правами system:master – заодно наведемо трохи порядок в цьому.

Окремо зараз в проектах (окремі репозиторії для Backend API, моніторинг і т.д.) для відповідних Kubernetes Pods створюються IAM Roles та Kubernetes ServiceAccounts, які теж хочеться звідти винести в цей новий проект, і управляти з одного місця з EKS Pod Identity associations.

Тож наразі у нас по EKS дві задачі:

  • створити EKS Authentification API Access Entries для юзерів та GitHub ролей
  • створити Pod Identity associations для ServiceAccounts

Планування проекту

Головне питання тут – на якому рівні будемо менеджити? На рівні AWS-акаунтів – чи на рівні EKS-кластерів/RDS? А від цього будуть залежати і структура коду, і змінні.

Multiple AWS accounts з одним EKS та RDS оточеннями

Варіант 1 – якщо маємо декілька AWS-акаунтів, то можемо управляти на рівні акаунтів: тоді для кожного акаунта можемо створити змінну зі списком EKS-кластерів, змінну зі списком юзерів/ролей, і потім в циклах створювати відповідні ресурси.

Тоді структура файлів Terraform може бути такою:

  • providers.tf: описуємо AWS provider з assume_role для AWS-акаунту Dev/Staging/Prod (або використовуємо якийсь Terragrunt)
  • variables.tf: наші змінні з дефолтними значеннями
  • envs/: каталог з іменами AWS-акаунтів
    • aws-env-dev: каталог конкретного акаунту
      • dev.tfvars: значення для змінних з іменами EKS-кластерів і списком юзерів в цьому AWS-акаунті
  • eks_access_entires.tf: ресурси для EKS

І потім в коді в циклі проходимось по всім кластерам та всім юзерам, які задані в envs/aws-env-dev/dev.tfvars.

Але тут буде питання в тому, як створювати юзерів на кластерах:

  • або мати однакових юзерів і пермішени на всіх кластерах в акаунті, що, в принципі, ОК, якщо маємо AWS-мультіакаунт, і в кожному умовному AWS-Dev у нас тільки EKS-Dev
  • або створювати декілька ресурсів access_entries – під кожен кластер окремо, і вже в access_entries в циклі проходитись по групам юзерів для конкретного кластеру – якщо в AWS-Dev у нас окрім EKS-Dev якісь додаткові кластери EKS, де треба мати окремий набір юзерів

В моєму випадку у нас поки що один AWS-акаунт  з одним Kubernetes-кластером, але пізніше скоріш за все ми будемо їх розділяти та створювати окремі акаунти під Ops/Dev/Staging/Prod. Тоді просто додамо нове оточення в каталог envs/, і там опишемо новий EKS-кластер(и).

Multiple EKS та RDS clusters в одному AWS-акаунті

Варіант 2 – якщо AWS account один на всі EKS-оточення, то можна всім управляти на рівні кластерів, і тоді структура може бути такою:

  • providers.tf: описуємо AWS provider з assume_role для AWS-акаунту Main
  • variables.tf: наші змінні з дефолтними значеннями
  • envs/: каталог з іменами EKS-кластерів
    • dev/: каталог для EKS Dev
      • dev-eks-cluster.tfvars – зі списком юзерів EKS Dev
      • dev-rds-backend.tfvars – зі списком юзерів RDS Dev
    • prod/
      • prod-eks-cluster.tfvars – зі списком юзерів EKS Prod
      • prod-rds-backend.tfvars – зі списком юзерів RDS Prod
  • eks_access_entires.tf: ресурси для EKS

Можна взагалі все “огорнути” в модулі, і потім в циклах викликати саме модулі.

User permissions

Крім того, ще подумаємо про те, які права яким юзерам можуть знадобитись? Це треба, аби далі продумати структуру змінних і цикли for_each в ресурсах:

  • група devops: будуть кластер-адмінами
  • група backend:
    • права edit в усіх Namespaces з іменами “backend
    • права read-only на якісь обрані неймспейси
  • група qa:
    • права edit в усіх Namespaces з іменами “qa
    • права read-only на якісь обрані неймспейси

Тепер, маючи уявлення про те, що нам треба – можна починати створювати файли Terraform і формувати змінні.

Структура файлів Terraform

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

$ tree terraform/
terraform/
|-- Makefile
|-- backend.tf
|-- envs
|   `-- ops
|       `-- atlas-iam.tfvars
|-- iam_eks_access_entires.tf
|-- iam_eks_pod_identities.tf
|-- providers.tf
|-- variables.tf
`-- versions.tf

Тут:

  • в Makefile: команди terraform init/plan/apply – і для виклику локально, і для CI/CD
  • backend.tf: S3 для стейт-файлів, DynamoDB для state-lock
  • envs/ops: файли tfvars зі списками кластерів та юзерів в цьому AWS-акаунті
  • providers.tf: тут provider "aws" з дефолтними тегами і необхідними параметрами
    • в моєму випадку provider "aws" бере значення змінної оточення AWS_PROFILE для визначення того, на який акаунт він має підключатись, а в AWS_PFOFILE задається регіон та необхідна IAM Role
  • variables.tf: змінні з дефолтними значеннями
  • versions.tf: версії самого Terraform та AWS Provider

І сюди ж потім можна буде додати файл типу iam_rds_auth.tf.

Terraform та EKS Access Management API

Почнемо з основного – доступ юзерів.

Для цього нам потрібно:

  • є юзер, який може належати до однії з груп – devops, backend, qa
  • йому потрібно задати тип прав доступу – admin, edit, read-only
  • і ці права видати або на весь кластер – для девопсів, або на конкретний неймспейс(и) – для backend та qa

Отже, будуть два типи ресурсів – aws_eks_access_entry та eks_access_policy_association:

  • в aws_eks_access_entry: описуємо EKS Access Entity – IAM User, для якого налаштовуємо доступ, та ім’я кластера, до якого доступ буде додаватись; параметри тут будуть:
    • cluster name
    • principal-arn
  • в eks_access_policy_association – описуємо тип доступу – admin/edit/etc та scope – cluster-wide, або конкретний неймспейс; параметри тут будуть:
    • cluster-name
    • principal-arn
    • policy-arn
    • access-scope

Створення variables

Див. Terraform: знайомство з типами даних – primitives та complex.

Тож які змінні нам будуть потрібні:

  • список кластерів – тут можна просто list():
    • atlas-eks-ops-1-28-cluster
    • atlas-eks-test-1-28-cluster
  • списки з юзерами, різні списки для різних груп – можна зробити однієї змінною типу map():
    • devops:
      • arn:aws:iam::111222333:user/user-1
      • arn:aws:iam::111222333:user/user-2
    • backend:
      • arn:aws:iam::111222333:user/user-3
      • arn:aws:iam::111222333:user/user-4
  • список з EKS Cluster Access Policies – хоча вони дефолтні, і змінюватись навряд чи будуть, але задля загального шаблону давайте теж зробимо окремою змінною
  • access-scope для aws_eks_access_policy_association – перепробував різні варіанти, але в результаті зробив без овер-інжинірінгу – можемо зробити змінну типу map(object):
    • група devops:
      • aws_eks_access_policy_association буде задаватись з access_scope = cluster
    • група backend:
      • список неймпспейсів, де всі члени групи будуть мати права edit
      • список неймпспейсів, де всі члени групи будуть мати права read-only
    • група qa:
      • список неймпспейсів, де всі члени групи будуть мати права edit
      • список неймпспейсів, де всі члени групи будуть мати права read-only

І потім створимо aws_eks_access_policy_association декількома ресурсами для кожної групи окремо. Далі побачимо як саме.

Поїхали – у файлі variables.tf описуємо змінні. Поки що всі значення запишемо в defaults, потім винесемо в tfvars оточень.

Змінна eks_clusters

Спочатку змінну з кластерами – поки тут буде один, тестовий:

variable "eks_clusters" {
  description = "List of EKS clusters to create records"
  type = set(string)
  default = [
    "atlas-eks-test-1-28-cluster"
  ]
}

Змінна eks_users

Додаємо список юзерів в трьох групах:

variable "eks_users" {
  description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User"
  type        = map(list(string))
  default = {
    devops = [
      "arn:aws:iam::492***148:user/arseny"
    ],
    backend = [
      "arn:aws:iam::492***148:user/oleksii",
      "arn:aws:iam::492***148:user/test-eks-acess-TO-DEL"
    ],
    qa = [
      "arn:aws:iam::492***148:user/yehor"
    ],
  }
}

Змінна eks_access_scope

Список для aws_eks_access_policy_association і access-scope, як це може виглядати:

  • ім’я команди
    • список неймспейсів на які будуть права admin
    • список неймспейсів на які будуть права edit
    • список неймспейсів на які будуть права read-only

Тож можна зробити щось накшталт такого:

variable "eks_access_scope" {
  description = "EKS Namespaces for teams to grant access with aws_eks_access_policy_association"
  type = map(object({
    namespaces_admin     = optional(set(string)),
    namespaces_edit      = optional(set(string)),
    namespaces_read_only = optional(set(string))
  }))
  default = {
    backend = {
      namespaces_edit      = ["*backend*", "*session-notes*"],
      namespaces_read_only = ["*ops*"]
    },
    qa = {
      namespaces_edit      = ["*qa*"],
      namespaces_read_only = ["*backend*"]
    }
  }
}

Тут:

  • backend мають права edit доступ на всі Namespaces і іменами “*backend*” та “*session-notes*“, і права read-only на неймспейси з “*ops*” – наприклад, доступ в Namespace “ops-monitoring-ns“, куди бекенд-тіма інколи заходить.
  • qa мають права edit на всі Namespaces і іменами “*qa*“, і права read-only на неймспейси Backend API

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

Зверніть увагу, що в type ми задаємо optional(set()) – бо група юзерів може не мати якоїсь групи неймспейсів.

Змінна eks_access_policies

Останнім додаємо список з дефолтними політиками:

variable "eks_access_policies" {
  description = "List of EKS clusters to create records"
  type = map(string)
  default = {
    cluster_admin = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy",
    namespace_admin = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminPolicy",
    namespace_edit = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy",
    namespace_read_only = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy",
  }
}

Створення aws_eks_access_entry

Додаємо перший ресурс – aws_eks_access_entry.

Аби в одному циклі створювати aws_eks_access_entry і для кожного кластера зі списку eks_clusters, і для кожного юзерів з кожної групи – створимо три змінних в locals:

locals {
  eks_access_entries_devops = flatten([
    for cluster in var.eks_clusters : [
      for user_arn in var.eks_users.devops : {
        cluster_name  = cluster
        principal_arn = user_arn
      }
    ]
  ])
  eks_access_entries_backend = flatten([
    for cluster in var.eks_clusters : [
      for user_arn in var.eks_users.backend : {
        cluster_name  = cluster
        principal_arn = user_arn
      }
    ]
  ])
  eks_access_entries_qa = flatten([
    for cluster in var.eks_clusters : [
      for user_arn in var.eks_users.qa : {
        cluster_name  = cluster
        principal_arn = user_arn
      }
    ]
  ])
}

А потім їх використаємо в resource "aws_eks_access_entry" з for_each, яким сформуємо map() з key=idx та value=entry:

resource "aws_eks_access_entry" "devops" {
  for_each = { for idx, entry in local.eks_access_entries_devops : idx => entry }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
}

Тут у for_each буде формуватись список типу:

[
  { cluster_name = "atlas-eks-test-1-28-cluster", principal_arn = "arn:aws:iam::492***148:user/arseny" },
  { cluster_name = "atlas-eks-test-1-28-cluster", principal_arn = "arn:aws:iam::492***148:user/another.user" }
]

І аналогічно створюємо ресурси aws_eks_access_entry для груп backend з local.eks_access_entries_backend і для qa з local.eks_access_entries_qa:

...
resource "aws_eks_access_entry" "backend" {
  for_each = { for cluser, user in local.eks_access_entries_backend : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
}

resource "aws_eks_access_entry" "qa" {
  for_each = { for cluser, user in local.eks_access_entries_qa : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
}

Створення eks_access_policy_association

Наступний крок – надати цим юзерам пермішени.

Для групи devops це буде cluster-admin, а для бекенду – edit в одних неймспейсах і read-only в інших – по спискам неймспейсів в namespaces_edit та namespaces_read_only у змінній eks_access_scope.

Як і з aws_eks_access_entry – ресурси eks_access_policy_association для кожної групи юзерів зробимо трьома окремими сутностями.

Я вирішив не ускладнювати код, і зробити його більш читабельним, хоча можна було додати ще якийсь locals з flatten() і потім все робити в одному-двох ресурсах aws_eks_access_policy_association з циклами.

Тут знов використовуємо вже існуючі localseks_access_entries_devops, eks_access_entries_backend та eks_access_entries_qa з яких беремо кластери і юзерів, а потом для кожного задаємо права – аналогічно тому, як робили для aws_eks_access_entry.

Додаємо перший eks_access_policy_association – для девопсів, з Policy var.eks_access_policies.cluster_admin та access_scope = cluster:

# DEVOPS CLUSTER ADMIN
resource "aws_eks_access_policy_association" "devops" {
  for_each = { for cluser, user in local.eks_access_entries_devops : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
  policy_arn    = var.eks_access_policies.cluster_admin

  access_scope {
    type = "cluster"
  }
}

Група backend і права edit на неймспейси задані в списку namespaces_edit змінної eks_access_scope для групи “backend“:

# BACKEND EDIT
resource "aws_eks_access_policy_association" "backend_edit" {
  for_each = { for cluser, user in local.eks_access_entries_backend : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
  policy_arn    = var.eks_access_policies.namespace_edit

  access_scope {
    type       = "namespace"
    namespaces = var.eks_access_scope["backend"].namespaces_edit
  }
}

Аналогічно – бекенди, але вже права read-only на групу неймспейсів зі списку namespaces_read_only:

# BACKEND READ ONLY
resource "aws_eks_access_policy_association" "backend_read_only" {
  for_each = { for cluser, user in local.eks_access_entries_backend : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
  policy_arn    = var.eks_access_policies.namespace_read_only

  access_scope {
    type       = "namespace"
    namespaces = var.eks_access_scope["backend"].namespaces_read_only
  }
}

І аналогічно для QA:

# QA EDIT
resource "aws_eks_access_policy_association" "qa_edit" {
  for_each = { for cluser, user in local.eks_access_entries_qa : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
  policy_arn    = var.eks_access_policies.namespace_edit

  access_scope {
    type       = "namespace"
    namespaces = var.eks_access_scope["qa"].namespaces_edit
  }
}

# QA READ ONLY
resource "aws_eks_access_policy_association" "qa_read_only" {
  for_each = { for cluser, user in local.eks_access_entries_qa : cluser => user }

  cluster_name  = each.value.cluster_name
  principal_arn = each.value.principal_arn
  policy_arn    = var.eks_access_policies.namespace_read_only

  access_scope {
    type       = "namespace"
    namespaces = var.eks_access_scope["qa"].namespaces_read_only
  }
}

Робимо terraform plan – і маємо юзерів:

Виконуємо terraform apply, і перевіряємо EKS Access Entries:

І пермішени тестового юзера з групи backend:

Перевіряємо як все працює.

Створюємо kubectl context для тестового юзера (--profile test-eks):

$ aws --profile test-eks eks update-kubeconfig --name atlas-eks-test-1-28-cluster --alias test-cluster-test-profile
Updated context test-cluster-test-profile in /home/setevoy/.kube/config

Перевіряємо з kubectl auth can-i.

В default Namespace – не можемо нічого:

$ kk -n default auth can-i list pods
no

І неймспейс з іменем “*backend*” – тут можемо створювати поди:

$ kk -n dev-backend-api-ns auth can-i create pods
yes

Неймспейс з “*ops*” – можемо виконати list:

$ kk -n ops-monitoring-ns auth can-i list pods
yes

Але не можемо нічого створити:

$ kk -n ops-monitoring-ns auth can-i create pods
no

Окей – тут наче все готово.

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

Terraform та EKS Pod Identities

Друге, що нам треба – це створити Pod Identity associations, аби замінити стару схему з EKS ServiceAccounts та OIDC.

Note: не забудьте додати eks-pod-identity-agent Add-On, приклад для terraform-aws-modules/eks – тут>>>.

Наприклад, на поточному кластері у нас є ServiceAccount для Yet Another Cloudwatch Exporter (YACE):

$ kk -n ops-monitoring-ns get sa yace-serviceaccount -o yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::492***148:role/atlas-monitoring-ops-1-28-yace-exporter-access-role
...

Що нам потрібно – це створити Pod Identity associations з такими параметрами:

  • cluster_name
  • namespace
  • service_account
  • role_arn

Зараз IAM Role для YACE створюється в коді Terraform в репозиторії з моніторингом, і тут є два варіанти:

  • перенести створення всіх IAM Roles для всіх проектів в цей новий, і потім тут жеж створювати aws_eks_pod_identity_association
    • але для цього доведеться міняти досить багато коду – випилювати створення ролей в інших проектах, і додавати їх в цей новий, плюс девелопери (якщо говорити про Backend API) вже якось звикли робити це в своїх проектах – потрібно буде писати документацію, як це робити в іншому проекті
  • або залишити створення ролей в кожному проекті окремо – а в новому просто мати змінну зі списками інших проектів та їхніх ролей (чи використати terraform_remote_state і terraform outputs з кожного проекту)
    • але тут будемо мати трохи геморою із запуском нових проектів, особливо, якщо там ролі будуть створюватись з якимись динамічними іменами – бо доведеться спочатку виконати terraform apply в новому проекті, отримати там ARN ролей, потім додавати їх в змінні нашого проекту “atlas-iam“, робити terraform apply тут аби ці ролі підключити до EKS, і тільки тоді робити умовний helm install зі створенням ServiceAccount та подів нового проекту

Але в обох варіантах нам для Pod Identity associations потрібно буде задавати такі параметри:

  • cluster_name: змінна вже є
  • імена ServiceAccount: вони будуть однакові на всіх кластерах
  • role_arn: залежить від того, як будемо створювати IAM Roles
  • namespace: а от тут питаннячко:
    • ім’я неймспейсу для моніторингу на всіх кластерах у нас однакове – “ops-monitoring-ns“, де Ops – це AWS або EKS оточення
    • а от для бекенду у нас на одному кластері є і Dev, і Staging, і Production – кожен у власному неймспейсі

А в неймспейсах для Pod Identity association ми вже не можемо використати “*“, як робили з Access Entries для юзерів, тобто маємо створювати окрему Pod Identity association на кожен конкретний неймспейс.

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

З використанням resource "aws_eks_pod_identity_association"

Варіант перший – використати “ванільний” aws_eks_pod_identity_association з Terraform AWS Provider.

Аби створити Pod Identity association для нашого умовного YACE-екпортеру, нам потрібно:

  • assume_role_policy: хто зможе виконувати IAM Role Assume
  • aws_iam_role: IAM Role з необхідними доступами до AWS API
  • aws_eks_pod_identity_association: підключити цю роль до EKS-кластеру і ServiceAccount в ньому

Давайте зробимо окремий файл для ролей – eks_pod_iam_roles.tf.

Описуємо aws_iam_policy_document – тепер ніяких OIDC, просто pods.eks.amazonaws.com:

# Trust Policy to be used by all IAM Roles
data "aws_iam_policy_document" "assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["pods.eks.amazonaws.com"]
    }

    actions = [
      "sts:AssumeRole",
      "sts:TagSession"
    ]
  }
}

Далі – сама роль.

Ролі треба буде створювати для кожного кластеру – тому знов візьмемо нашу змінну variable "eks_clusters", і потім в циклі створимо ролі з іменами під кожен кластер:

# Create an IAM Role to be assumed by Yet Another CloudWatch Exporter
resource "aws_iam_role" "yace_exporter_access" {
  for_each = var.eks_clusters

  name = "${each.key}-yace-exporter-role"

  assume_role_policy = data.aws_iam_policy_document.assume_role.json

  inline_policy {
    name = "${each.key}-yace-exporter-policy"

    policy = jsonencode({
      Version = "2012-10-17"
      Statement = [
        {
          Action = [
            "cloudwatch:ListMetrics",
            "cloudwatch:GetMetricStatistics",
            "cloudwatch:GetMetricData",
            "tag:GetResources",
            "apigateway:GET"
          ]
          Effect   = "Allow"
          Resource = ["*"]
        },
      ]
    })
  }

  tags = {
    Name = "${each.key}-yace-exporter-role"
  }
}

І тепер в eks_pod_identities.tf можемо додати асоціацію:

resource "aws_eks_pod_identity_association" "yace" {
  for_each = var.eks_clusters

  cluster_name    = each.key
  namespace       = "example"
  service_account = "example-sa"
  role_arn        = aws_iam_role.yace_exporter_access["${each.key}"].arn
}

Де в "aws_iam_role.yace_exporter_access["${each.key}"].arn" посилаємось на IAM Role з конкретним іменем кожного кластеру:

...
  # aws_eks_pod_identity_association.yace["atlas-eks-test-1-28-cluster"] will be created
  + resource "aws_eks_pod_identity_association" "yace" {
      + association_arn = (known after apply)
      + association_id  = (known after apply)
      + cluster_name    = "atlas-eks-test-1-28-cluster"
      + id              = (known after apply)
      + namespace       = "example"
      + role_arn        = (known after apply)
      + service_account = "example-sa"
      + tags_all        = {
          + "component"   = "devops"
          + "created-by"  = "terraform"
          + "environment" = "ops"
        }
    }
...

З використанням модуля terraform-aws-eks-pod-identity

Інший варіант – робити через terraform-aws-eks-pod-identity. Тоді нам не потрібно окремо описувати роль – ми можемо задати IAM Policy прямо в модулі, і він все створить за нас. Крім того, він дозволяє в одному модулі створити кілька associations для різних кластерів.

Наприклад:

module "yace_pod_identity" {
  source   = "terraform-aws-modules/eks-pod-identity/aws"
  version  = "~> 1.2"
  for_each = var.eks_clusters

  name = "${each.key}-yace"

  attach_custom_policy = true
  source_policy_documents = [jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "cloudwatch:ListMetrics",
          "cloudwatch:GetMetricStatistics",
          "cloudwatch:GetMetricData",
          "tag:GetResources",
          "apigateway:GET"
        ]
        Effect   = "Allow"
        Resource = ["*"]
      },
    ]
  })]

  associations = {
    ex-one = {
      cluster_name    = each.key
      namespace       = "custom"
      service_account = "custom"
    }
  }
}

І для кожного кластера буде створено і роль, і асоціація:

Крім того, terraform-aws-eks-pod-identity вміє створювати всякі дефолтні IAM Roles – для ALB Ingress Controller, External DNS тощо.

Але в моєму випадку ці сервіси в EKS створюються з aws-ia/eks-blueprints-addons/aws, який сам створює ролі, і якихось змін відносно Pod Identity association я там не бачу (хоча GitHub Issue з обговоренням відкрита ще в листопаді 2023 – див. Switch to IRSAv2/pod identity).

Окей.

То як ми можемо зробити всю цю схему?

  • у нас будуть IAM Roles – створюються в інших проектах, або в нашому “atlas-iam
  • ми будемо знати в які неймспейси які ServiceAccounts нам треба підключати

Яку ми можемо придумати змінну для всього цього діла?

Можемо задати ім’я проекту – “backend-api“, “monitoring“, etc, і в кожному проекті мати списки з неймспейсами, ServiceAccount та IAM Roles.

А потім для кожного проекту мати окремий ресурс aws_eks_pod_identity_association, який в циклі буде проходитись по всім EKS-кластерам в AWS-акаунті.

Проблема: Pod Identity association та динамічні Namespaces

Але! Для Backend API в GitHub Actions у нас створюються динамічні оточення, і для них – Kubernetes Namespaces з іменами типу “pr-1212-backend-api-ns“. Тобто, є статичні неймспейси – “dev-backend-api-ns“, “staging-backend-api-ns” та “prod-backend-api-ns“, які ми знаємо – але будуть імена, які ми ніяк заздалегідь дізнатись не можемо.

На схожу тему є відкрита ще в грудні 2023 GitHub Issue – [EKS]: allow EKS Pod Identity association to accept a glob for the service account name (my-sa-*), але вона поки що без змін.

Тому як рішення поки що бачу тільки залишити старий IRSA для бекенду, а Pod Identity використовувати тільки для тих проектів, які мають статичні неймспейси.

Pod Identity association для Monitoring project

Ну й давайте зробимо одну асоціацію, і потім вже в процесі роботи будемо дивитись як можна покращити процес.

В проекті з моніторингом в мене є 5 кастомних IAM Roles – для Grafana Loki з доступами до S3, для звичайного CloudWatch Exporter, для Yet Another CloudWatch Exporter, для нашого власного Redshift Exporter, і для X-Ray Daemon.

Створення ролей все ж, мабуть, краще винести в цей проект, “atlas-iam“. Тоді при створенні нового проекту ми спочатку в цьому проекті описуємо його роль і асоціацію в потрібних неймспейсах з потрібним ServiceAccount, а потім в самому проекті в Helm-чарті вказуємо ім’я ServiceAccount.

Щодо terraform-aws-modules/eks-pod-identity/aws – як на мене, то він більш заточений під всякі дефолтні ролі, хоча можливість створення власних там є.

Але зараз простішим буде зробити так:

  • створювати IAM Roles для кожного сервісу зі звичайним resource "aws_iam_role"
  • і з resource "aws_eks_pod_identity_association" підключати ці ролі до кластерів

Отже, створимо нову змінну, де будуть проекти та їхні неймспейси і ServiceAccounts:

variable "eks_pod_identities" {
  description = "EKS ServiceAccounts for Pods to grant access with eks-pod-identity"
  type = map(object({
    namespace = string,
    projects = map(object({
      serviceaccount_name = string
    }))
  }))
  default = {
    monitoring = {
      namespace = "ops-monitoring-ns"
      projects = {
        loki = {
          serviceaccount_name = "loki-sa"
        },
        yace = {
          serviceaccount_name = "yace-sa"
        }
      }
    }
  }
}

Далі, у файлі eks_pod_iam_roles.tf зробимо роль – як робили вище, але без циклів, бо у нас одна роль на весь AWS-акаунт, яка буде підключатись до різних Kubernetes-кластерів:

# Trust Policy to be used by all IAM Roles
data "aws_iam_policy_document" "assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["pods.eks.amazonaws.com"]
    }

    actions = [
      "sts:AssumeRole",
      "sts:TagSession"
    ]
  }
}

# Create an IAM Role to be assumed by Yet Another CloudWatch Exporter
resource "aws_iam_role" "yace_exporter_access" {
  name = "monitoring-yace-exporter-role"

  assume_role_policy = data.aws_iam_policy_document.assume_role.json

  inline_policy {
    name = "monitoring-yace-exporter-policy"

    policy = jsonencode({
      Version = "2012-10-17"
      Statement = [
        {
          Action = [
            "cloudwatch:ListMetrics",
            "cloudwatch:GetMetricStatistics",
            "cloudwatch:GetMetricData",
            "tag:GetResources",
            "apigateway:GET"
          ]
          Effect   = "Allow"
          Resource = ["*"]
        },
      ]
    })
  }

  tags = {
    Name = "monitoring-yace-exporter-role"
  }
}

Об’єкт data "aws_iam_policy_document" "assume_role" у нас тут буде один – і потім його використаємо в усіх IAM Roles.

Далі в файлі eks_pod_identities.tf описуємо власне aws_eks_pod_identity_association з використанням namespace та service_account зі змінної, яку описали вище, role_arn отримуємо з resource "aws_iam_role" "yace_exporter_access", а імена кластерів беремо в циклі зі змінної var.eks_clusters:

resource "aws_eks_pod_identity_association" "yace" {
  for_each = var.eks_clusters

  cluster_name    = each.key
  namespace       = var.eks_pod_identities.monitoring.namespace
  service_account = var.eks_pod_identities.monitoring.projects["yace"].serviceaccount_name
  role_arn        = aws_iam_role.yace_exporter_access.arn
}

Виконуємо terraform plan:

Робимо terraform apply, та перевіряємо Pod Identity associations в EKS:

Перевірка EKS Pod Identities

Ну і перевіримо, чи це працює.

Описуємо Kubernetes Pod з AWS CLI та ServiecAccount з іменем “yace-sa“:

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: yace-sa
  namespace: ops-monitoring-ns
---
apiVersion: v1
kind: Pod
metadata:
  name: pod-identity-test
  namespace: ops-monitoring-ns
spec:
  containers:
    - name: aws-cli
      image: amazon/aws-cli:latest
      command: ['sleep', '36000']
  restartPolicy: Never
  serviceAccountName: yace-sa

Створюємо їх:

$ kk apply -f irsa-sa.yaml 
serviceaccount/yace-sa created
pod/pod-identity-test created

Підключаємось в Pod:

$ kk -n ops-monitoring-ns exec -ti pod-identity-test -- bash

І пробуємо виконати запит до CloudWatch – наприклад, “cloudwatch:ListMetrics” на який ми давали права в IAM Role:

bash-4.2# aws cloudwatch list-metrics --namespace "AWS/SNS" | head
{
    "Metrics": [
        {
            "Namespace": "AWS/SNS",
            "MetricName": "NumberOfMessagesPublished",
            "Dimensions": [
                {
                    "Name": "TopicName",
                    "Value": "dev-stable-diffusion-running-opsgenie"

А на S3 у нас прав нема:

bash-4.2# aws s3 ls

An error occurred (AccessDenied) when calling the ListBuckets operation: Access Denied

Висновки

Отже, що ми маємо в результаті.

З EKS Access Entries все доволі ясно, і рішення має право на життя: можемо з одного Terraform-коду управляти юзерами для різних EKS кластерів і навіть в різних AWS-акаунтах.

Код, описаний в eks_access_entires.tf дозволяє нам досить гнучко створювати нові EKS Authentification API Access Entries для різних юзерів з різними правами, хоча я не торкався питання Kubernetes RBAC – створення окремих груп з власними RoleBindings. Але в моєму випадку це поки трохи overhead.

А от з EKS Pod Identities автоматизація “дала збій” – бо на цей час нема можливості використовувати “*” в іменах неймспейсів та/або ServiceAccounts – а тому ми маємо досить жорстко прив’язуватись до якихось постійних імен. Тому описане рішення може застосувати, коли у вас заздалегідь відомі імена об’єктів в Kubernetes – але для якихось динамічних рішень все ще доведеться використовувати стару схему з IRSA та OIDC.

Втім, сподіваюсь, цей момент пофіксять, і тоді можна буде всі наші проекти менеджити вже з одного коду.