Terraform: створення AWS OpenSearch Service cluster та юзерів
0 (0)

15 Вересня 2025

В першій частині розібрались з основами AWS OpenSearch Service взагалі, і з типами інстансів для Data Nodes – AWS: знайомство з OpenSearch Service в ролі vector store.

В другій – з доступами, AWS: створення OpenSearch Service cluster та налаштування аутентифікації і авторизації.

Тепер напишемо Terraform code для створення кластера, юзерів та індексів.

Створювати кластер будемо в VPC, для аутентифікації використаємо internal user database.

А в VPC не можна… Бо – suprize! – AWS Bedrock вимагає OpenSeach Managed кластер саме Public, а не в VPC.

The OpenSearch Managed Cluster you provided is not supported because it is VPC protected. Your cluster must be behind a public network.

Писав в сапорт, сказали, що:

However, there is an ongoing product feature request (PFR) to have Bedrock KnowledgeBases support provisioned Open Search clusters in VPC.

І пропонують використати Amazon OpenSearch Serverless, з якого ми власне і тікаємо, бо ціни дурні.

Друга проблема, яка виявилась, коли я почав писати ресурси bedrockagent_knowledge_base – це те, що він не підтримує storage_configuration з type == OPENSEARCH_MANAGED, тільки Serverless.

Але Pull Request на це вже є, колись, може, замержать.

Отже, будемо робити OpenSearch Managed Service кластер, кластер буде один, з трьома індексами – Dev/Staging/Prod.

В кластері буде три маленькі дата-ноди, а в кожному індексі – 1 primary shard та 1 репліка, бо проект маленький, даних в нашому Production індексі на AWS OpenSearch Serverless, з якого ми хочемо переїхати на AWS OpenSearch Service – зараз всього 2 GiB, і навряд чи в майбутньому буде дуже багато.

Було б добре кластер зробити у власному Terraform модулі аби простіше створювати якісь тестові оточення, як в мене це зроблено для AWS EKS – але поки не дуже є на це час, тому робимо просто tf-файлами з окремим prod.tfvars для змінних.

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

І в наступній частині – поговоримо про моніторинг, бо наш Production вже разок падав 🙂

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

Початкова схема файлів і директорій проекту така:

$ tree .
.
├── README.md
└── terraform
    ├── Makefile
    ├── backend.tf
    ├── data.tf
    ├── envs
    │   └── prod
    │       └── prod.tfvars
    ├── locals.tf
    ├── outputs.tf
    ├── providers.tf
    ├── variables.tf
    └── versions.tf

В providers.tf – налаштування провайдерів, тут поки тільки AWS, і через нього задаємо дефолтні теги:

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

В data.tf збираємо дані AWS Account ID, Availability Zones, VPC та приватні subnets, в яких будемо створювати кластер в яких колись потім будемо створювати кластер:

data "aws_caller_identity" "current" {}

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

data "aws_vpc" "eks_vpc" {
  id = var.vpc_id
}

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

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

Файл variables.tf з нашими дефолтними змінними, потім будемо додавати нові:

variable "aws_region" {
  type    = string
}

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

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 "vpc_id" {
  type        = string
  description = "A VPC ID to be used to create OpenSearch cluster and its Nodes"
}

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

aws_region = "us-east-1"
project_name = "atlas-kb"
component = "backend"
environment = "prod"
vpc_id = "vpc-0fbaffe234c0d81ea"
dns_zone = "prod.example.co"

В Makefile – спрощуємо собі локальне життя:

############
### PROD ###
############

init-prod:
  terraform init -reconfigure -backend-config="key=prod/atlas-knowledge-base-prod.tfstate"

plan-prod:
  terraform plan -var-file=envs/prod/prod.tfvars

apply-prod:
  terraform apply -var-file=envs/prod/prod.tfvars

#destroy-prod:
#	terraform destroy -var-file=envs/prod/prod.tfvars

Які файли будуть далі?

У нас тут ще буде AWS Bedrock, якому треба буде налаштувати доступ – аде це зробимо через його IAM Role, і про Bedrock тут писати не буду – бо і тема окрема, і в Terraform поки що нема підтримки OPENSEARCH_MANAGED, тому ми зробили його руками, а потім виконаємо terraform import.

Індекси, юзерів для нашого Backend API та Bedrock IAM Role mappings будемо робити в internal database самого OpenSearch через Terraform OpenSearch Provider аби не морочитись з доступами до дашборди.

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

Кластер можемо зробити просто з ресурсу aws_opensearch_domain.

А можна взяти готові модулі, наприклад opensearch від @Anton Babenko.

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

Створення кластера

Приклади – terraform-aws-opensearch/tree/master/examples.

До variables.tf додаємо змінну з параметрами кластеру:

...

variable "cluser_options" {
  description = "A map of options to configure the OpenSearch cluster"
  type = object({
    instance_type                 = string
    instance_count                = number
    volume_size                   = number
    volume_type                   = string
    engine_version                = string
    auto_software_update_enabled  = bool
  })
}

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

...

cluser_options = {
  instance_type                = "t3.small.search"
  instance_count               = 3
  volume_size                  = 50
  volume_type                  = "gp3"
  engine_version               = "OpenSearch_2.19"
  auto_software_update_enabled = true
}

Інстанси t3.small.search – самі мінімальні, нам цього поки що вистачить, хоча для t3 є обмеження – наприклад не підтримується Auto-tune.

Ну і взагалі t3 не для Production use case. Див. також Operational best practices for Amazon OpenSearch Service, Current generation instance types і Amazon OpenSearch Service quotas.

Версію тут я задавав 2.9, але буквально на днях додали 3.1 – див. Supported versions of Elasticsearch and OpenSearch.

Беремо три ноди, аби кластер міг вибрати cluster manager node, якщо одна нода впаде, див. Dedicated master node distribution, Learning OpenSearch from scratch, part 2: Digging deeper і Enhance stability with dedicated cluster manager nodes using Amazon OpenSearch Service.

Зміст locals.tf:

locals {
  # 'atlas-kb-prod'
  env_name = "${var.project_name}-${var.environment}"
}

Більша частина locals буде саме тут, але деякі, які зовсім вже “локальні” до якогось коду – будуть у файлах з кодом ресурсів.

Додаємо файл opensearcth_users.tf – поки тут тільки рутовий юзер, пароль зберігаємо в AWS Parameter Store (замість AWS Secrets Manager – “так історично склалося”):

############
### ROOT ###
############

# generate root password
# waiting for write-only: https://github.com/hashicorp/terraform-provider-aws/pull/43621
# then will update it with the ephemeral type
resource "random_password" "os_master_password" {
  length  = 16
  special = true
}

# store the root password in AWS Parameter Store
resource "aws_ssm_parameter" "os_master_password" {
  name        = "/${var.environment}/${local.env_name}-root-password"
  description = "OpenSearch cluster master password"
  type        = "SecureString"
  value       = random_password.os_master_password.result
  overwrite   = true
  tier        = "Standard"

  lifecycle {
    ignore_changes = [value]  # to prevent diff every time password is regenerated
  }
}

data "aws_ssm_parameter" "os_master_password" {
  name            = "/${var.environment}/${local.env_name}-root-password"
  with_decryption = true

  depends_on = [aws_ssm_parameter.os_master_password]
}

Пишемо файл opensearch_cluster.tf.

Я тут залишив конфіг для VPC, і на майбутнє, і просто для прикладу, хоча перенести вже створений кластер у VPC не можна буде – доведеться створювати новий, див. Limitations в документації Launching your Amazon OpenSearch Service domains within a VPC:

module "opensearch" {
  source  = "terraform-aws-modules/opensearch/aws"
  version = "~> 2.0.0"  

  # enable Fine-grained access control
  # by using the internal user database, we'll simply access to the Dashboards
  # for backend API Kubernetes Pods, will use Kubernetes Secrets with username:password from AWS Parameter Store
  advanced_security_options = {
    enabled                        = true
    anonymous_auth_enabled         = false
    internal_user_database_enabled = true

    master_user_options = {
      master_user_name     = "os_root"
      master_user_password = data.aws_ssm_parameter.os_master_password.value
    }
  }

  # can't be used with t3 instances
  auto_tune_options = {
    desired_state = "DISABLED"
  }

  # have three data nodes - t3.small.search nodes in two AZs
  # will use 3 indexes - dev/stage/prod with 1 shard and 1 replica each
  cluster_config = {
    instance_count           = var.cluser_options.instance_count
    dedicated_master_enabled = false
    instance_type            = var.cluser_options.instance_type

    # put both data-nodes in different AZs
    zone_awareness_config = {
      availability_zone_count = 2
    }

    zone_awareness_enabled = true
  }

  # the cluster's name
  # 'atlas-kb-prod'
  domain_name = "${local.env_name}-cluster"

  # 50 GiB for each Data Node
  ebs_options = {
    ebs_enabled = true
    volume_type = var.cluser_options.volume_type
    volume_size = var.cluser_options.volume_size
  }

  encrypt_at_rest = {
    enabled = true
  }

  # latest for today:
  # https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html#choosing-version
  engine_version = var.cluser_options.engine_version

  # enable CloudWatch logs for Index and Search slow logs
  # TODO: collect to VictoriaLogs or Loki, and create metrics and alerts
  log_publishing_options = [
    { log_type = "INDEX_SLOW_LOGS" },
    { log_type = "SEARCH_SLOW_LOGS" },
  ]

  ip_address_type = "ipv4"

  node_to_node_encryption = {
    enabled = true
  }

  # allow minor version updates automatically
  # will be performed during off-peak windows
  software_update_options = {
    auto_software_update_enabled = var.cluser_options.auto_software_update_enabled
  }

  # DO NOT use 'atlas-vpc-ops' VPC and its private subnets
  # > "The OpenSearch Managed Cluster you provided is not supported because it is VPC protected. Your cluster must be behind a public network."
  # vpc_options = {
  #   subnet_ids = data.aws_subnets.private.ids
  # }

  # # VPC endpoint to access from Kubernetes Pods
  # vpc_endpoints = {
  #   one = {
  #     subnet_ids = data.aws_subnets.private.ids
  #   }
  # }

  # Security Group rules to allow access from the VPC only
  # security_group_rules = {
  #   ingress_443 = {
  #     type        = "ingress"
  #     description = "HTTPS access from VPC"
  #     from_port   = 443
  #     to_port     = 443
  #     ip_protocol = "tcp"
  #     cidr_ipv4   = data.aws_vpc.ops_vpc.cidr_block
  #   }
  # }

  # Access policy
  # necessary to allow access for AWS user to the Dashboards
  access_policy_statements = [
    {
      effect = "Allow"

      principals = [{
        type        = "*"
        identifiers = ["*"]
      }]

      actions = ["es:*"]
    }
  ]

  # 'atlas-kb-ops-os-cluster'
  tags = {
    Name        = "${var.project_name}-${var.environment}-os-cluster"
  }
}

В принципі, тут все в коментах описано, але кратко:

  • включаємо fine-grained access control і локальну базу юзерів
  • три дата-ноди, кожна з 50 гіг дисків, в різних Availability Zones
  • включаємо логи в CloudWatch
  • кластер робимо в приватних сабнетах
  • в Domain Access Policy дозволяємо доступ для всіх
    • ну – поки так… Security Groups ми використати не можемо, бо не в VPC, а створити IP-Based policy – як? ми ж не знаємо CIDR Bedrock
    • в принципі, тут в principals.identifiers можна додати ліміт на наших IAM Users + Bedrock AIM Role, бо вона буде одна

Запускаємо створення кластера і йдемо пити чай.

Налаштування Custom endpoint

Після створення кластеру перевіряємо доступ до дашборди, якщо все ОК – то додаємо Custom endpoint.

Note: з Custom endpoint свої приколи: в Terraform OpenSearch Provider треба використовувати саме Custom endpoint URL, але в AWS Bedrock Knowledge Base – дефолтний URL кластеру

Для цього нам треба зробити сертифікат в AWS Certificate Manager і додати новий запис в Route53.

Я тут очікував можливу проблему куриця і яйця, бо налаштування Custom Endpoint залежать від AWS ACM і запису в AWS Route53, а запис в AWS Route53 буде залежати від кластеру – бо використовує його ендпоінт.

Але ні, якщо робити новий кластер з налаштуваннями, які описав нижче – все нормально створюється: спочатку сертифікат в AWS ACM, потім кластер з Custom Endpoint, потім запис в Route53 з CNAME на cluster default URL.

Додаємо нову localos_custom_domain_name:

locals {
  # 'atlas-kb-prod'
  env_name = "${var.project_name}-${var.environment}"
  # 'opensearch.prod.example.co'
  os_custom_domain_name = "opensearch.${var.dns_zone}"
}

Додаємо отримання даних про Route53 зону до data.tf:

...

data "aws_route53_zone" "zone" {
  name = var.dns_zone
}

Додаємо створення сертифіката і запис у Route53 до opensearch_cluster.tf:

# TLS for the Custom Domain
module "prod_opensearch_acm" {
  source  = "terraform-aws-modules/acm/aws"
  version = "~> 6.0"

  # 'opensearch.example.co'
  domain_name = local.os_custom_domain_name
  zone_id     = data.aws_route53_zone.zone.zone_id

  validation_method = "DNS"
  wait_for_validation = true

  tags = {
    Name = local.os_custom_domain_name
  }
}

resource "aws_route53_record" "opensearch_domain_endpoint" {
  zone_id = data.aws_route53_zone.zone.zone_id
  name    = local.os_custom_domain_name
  type    = "CNAME"
  ttl     = 300
  records = [module.opensearch.domain_endpoint]
}

...

І в module "opensearch" додаємо налаштування custom ендпоінту:

...
  domain_endpoint_options = {
    custom_endpoint_certificate_arn = module.prod_opensearch_acm.acm_certificate_arn
    custom_endpoint_enabled         = true
    custom_endpoint                 = local.os_custom_domain_name
    tls_security_policy             = "Policy-Min-TLS-1-2-2019-07"
  }
...

Виконуємо terrform init та terrform apply, перевіряємо налаштування:

І перевіряємо доступ до дашборд.

Terraform Outputs

Додамо трохи аутуптів.

Поки просто для себе, потім, можливо, будемо використовувати в імпортах інших проектів, див. Terraform: terraform_remote_state – отримання outputs інших state-файлів:

output "vpc_id" {
  value = var.vpc_id
}

output "cluster_arn" {
  value = module.opensearch.domain_arn
}

output "opensearch_domain_endpoint_cluster" {
  value = "https://${module.opensearch.domain_endpoint}"
}

output "opensearch_domain_endpoint_custom" {
  value = "https://${local.os_custom_domain_name}"
}

output "opensearch_root_username" {
  value = "os_root"
}

output "opensearch_root_user_password_secret_name" {
  value = "/${var.environment}/${local.env_name}-root-password"
}

Створення OpenSearch Users

Власне, що нам залишилось – це користувачі і індекси.

Юзерів у нас буде два типи:

  • звичайні юзери з OpenSearch internal database – для нашого Backend API в Kubernetes (насправді, потім ми все ж перейшли на IAM Roles, які мапляться в поди Backend через EKS Pod Identities)
  • і юзери (IAM Role) для Bedrock – там буде три Knowledge Bases, кожна зі своєю IAM Role, для якої треба буде додати OpenSearch Role і зробити mapping на IAM-ролі

Почнемо зі звичайних юзерів.

Додаємо провайдера, в мене це у файлі versions.tf:

terraform {

  required_version = "~> 1.6"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    opensearch = {
      source  = "opensearch-project/opensearch"
      version = "~> 2.3"
    }
  }
}

В файлі providers.tf описуємо доступ до кластеру:

...

provider "opensearch" {
  url         = "https://${local.os_custom_domain_name}"
  username    = "os_root"
  password    = data.aws_ssm_parameter.os_master_password.value
  healthcheck = false
}

Error: elastic: Error 403 (Forbidden)

Тут важливий момент з url в конфігурації провайдеру, писав про це вище, тепер – як воно виглядає.

Спершу в provider.url задав як outputs модуля, тобто module.opensearch.domain_endpoint.

І через це ловив 403, коли намагався створити юзерів:

...
opensearch_user.os_kraken_dev_user: Creating...
opensearch_role.os_kraken_dev_role: Creating...
╷
│ Error: elastic: Error 403 (Forbidden)
│ 
│   with opensearch_user.os_kraken_dev_user,
│   on opensearch_users.tf line 23, in resource "opensearch_user" "os_kraken_dev_user":
│   23: resource "opensearch_user" "os_kraken_dev_user" {
│ 
╵
╷
│ Error: elastic: Error 403 (Forbidden)
│ 
│   with opensearch_role.os_kraken_dev_role,
│   on opensearch_users.tf line 30, in resource "opensearch_role" "os_kraken_dev_role":
│   30: resource "opensearch_role" "os_kraken_dev_role" {

Власне, задаємо URL саме у вигляді FQDN, який робили для Custom Endpoint, щось типу "url = https://opensearch.exmaple.com" – і з ним все працює.

Створення Internal юзерів

Тепер самі юзери.

Їх буде три – dev, staging, prod, кожен з доступом до відповідного індексу.

Тут використаємо opensearch_user.

Якщо кластер всеж створений в VPC – то потрібен підключений VPN, аби провайдер зміг підключитись до кластеру.

До variables.tf додаємо list() зі списком оточень:

...

variable "app_environments" {
  type        = list(string)
  description = "The Application's environments, to be used to created Dev/Staging/Prod DynamoDB tables, etc"
}

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

...

app_environments = [
  "dev",
  "staging",
  "prod"
]

Internal database users

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

У файлі opensearch_users.tf додаємо в циклах три паролі, трьох юзерів, і три ролі, на які мапимо юзерів – кожна роль з доступом до власного індексу:

...

##############
### KRAKEN ###
##############

resource "random_password" "os_kraken_password" {
  for_each = toset(var.app_environments)
  length  = 16
  special = true
}

# store the root password in AWS Parameter Store
resource "aws_ssm_parameter" "os_kraken_password" {
  for_each = toset(var.app_environments)

  name        = "/${var.environment}/${local.env_name}-kraken-${each.key}-password"
  description = "OpenSearch cluster Backend Dev password"
  type        = "SecureString"
  value       = random_password.os_kraken_password[each.key].result
  overwrite   = true
  tier        = "Standard"

  lifecycle {
    ignore_changes = [value]  # to prevent diff every time password is regenerated
  }
}

# Create a user
resource "opensearch_user" "os_kraken_user" {
  for_each = toset(var.app_environments)

  username    = "os_kraken_${each.key}"
  password    = random_password.os_kraken_password[each.key].result
  description = "Backend EKS ${each.key} user"

  depends_on = [module.opensearch]
}

# And a full user, role and role mapping example:
resource "opensearch_role" "os_kraken_role" {
  for_each = toset(var.app_environments)

  role_name   = "os_kraken_${each.key}_role"
  description = "Backend EKS ${each.key} role"

  cluster_permissions = [
    "indices:data/read/msearch",
    "indices:data/write/bulk*",
   "indices:data/read/mget*"
  ]
  index_permissions {
    index_patterns  = ["kraken-kb-index-${each.key}"]
    allowed_actions = ["*"]
  }

  depends_on = [module.opensearch]
}

В cluster_permissions додаємо дозволи, які потрібні і для index level, і для cluster level, бо Bedrock без них не працював, див. Cluster wide index permissions.

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

Додавання IAM Users

Тут ідея така сама, просто замість звичайних юзерів з логіном:паролем для аутентифікації використовується IAM та його Users && Roles.

Про роль для Bedrock далі, а зараз додамо мапінг юзерів.

Що нам треба – це взяти список наших Backend team юзерів, дати їм IAM Policy з доступом до OpenSearch, а потім в OpnSearch internal users database додати мапінг на локальну роль.

Локальну роль поки можна взяти all_access, хоча краще потім все ж написати власну. Див. Predefined roles та About the master user.

Додаємо нову змінну в variables.tf:

...

variable "backend_team_users_arns" {
  type        = list(string)
}

Її значення в prod.tfvars:

...

backend_team_users_arns = [
  "arn:aws:iam::492***148:user/arseny",
  "arn:aws:iam::492***148:user/misha",
  "arn:aws:iam::492***148:user/oleksii",
  "arn:aws:iam::492***148:user/vladimir",
  "os_root"
]

Тут довелося костиляти з юзером os_root, бо інакше його випилює з ролі.

Тому таки краще зробити нормальні ролі – але для MVP міжна і так.

І додаємо мапінг цих IAM Users до ролі all_access:

...

####################
### BACKEND TEAM ###
####################

resource "opensearch_roles_mapping" "all_access_mapping" {
  role_name = "all_access"

  users = var.backend_team_users_arns
}

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

Note: ChatGPT вперто казав додавати IAM Users в Backend Roles, але ні, і це явно вказано в документації – додавати треба в Users, див. Additional master users.

І всім IAM Users треба додати IAM-політику з доступом.

Знов-таки для MVP можна просто взяти голову policy AmazonOpenSearchServiceFullAccess, яка підключена до IAM Group:

Створення AWS Bedrock IAM Roles та OpenSearch Role mappings

Bedrock у нас вже є, треба просто створити нові IAM Roles і замапити їх до OpenSeach Roles.

Додаємо файл iam.tf – описуємо IAM Role та IAM Policy (Identity-based Policy для доступу до OpenSearch), тут також в циклі по кожному з var.app_environmetns:

#####################################
### MAIN ROLE FOR KNOWLEDGE BASE ###
#####################################

# grants permissions for AWS Bedrock to interact with other AWS services
resource "aws_iam_role" "knowledge_base_role" {
  for_each = toset(var.app_environments)
  name     = "${var.project_name}-role-${each.key}-managed"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "bedrock.amazonaws.com"
        }
        Condition = {
          StringEquals = {
            "aws:SourceAccount" = data.aws_caller_identity.current.account_id
          }
          ArnLike = {
            # restricts the role to be assumed only by Bedrock knowledge base in the specified region
            "aws:SourceArn" = "arn:aws:bedrock:${var.aws_region}:${data.aws_caller_identity.current.account_id}:knowledge-base/*"
          }
        }
      }
    ]
  })
}

# IAM policy for Knowledge Base to access OpenSearch Managed
resource "aws_iam_policy" "knowledge_base_opensearch_policy" {
  for_each = toset(var.app_environments)
  name     = "${var.project_name}-kb-opensearch-policy-${each.key}-managed"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "es:*",
        ]
        Resource = [
          module.opensearch.domain_arn,
          "${module.opensearch.domain_arn}/*"
        ]
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "knowledge_base_opensearch" {
  for_each   = toset(var.app_environments)
  role       = aws_iam_role.knowledge_base_role[each.key].name
  policy_arn = aws_iam_policy.knowledge_base_opensearch_policy[each.key].arn
}

Далі в opensearch_users.tf створимо:

  • opensearch_role: з cluster_permissions та index_permissions на кожен індекс
  • locals з усіма IAM Roles, які створили вище
  • і opensearch_roles_mapping для кожної opensearch_role.os_bedrock_roles, які через backend_roles додаємо до кожної opensearch_role

Виглядає якось так:

...

#################
#### BEDROCK ####
#################

resource "opensearch_role" "os_bedrock_roles" {
  for_each = toset(var.app_environments)
  role_name   = "os_bedrock_${each.key}_role"
  description = "Backend Bedrock KB ${each.key} role"

  cluster_permissions = [
    "indices:data/read/msearch",
    "indices:data/write/bulk*",
    "indices:data/read/mget*"
    ]

  index_permissions {
    index_patterns  = ["kraken-kb-index-${each.key}"]
    allowed_actions = ["*"]
  }

  depends_on = [module.opensearch]
}

# 'aws_iam_role' is defined in iam.tf
locals {
  knowledge_base_role_arns = {
    for env, role in aws_iam_role.knowledge_base_role :
    env => role.arn
  }
}

resource "opensearch_roles_mapping" "os_bedrock_role_mappings" {
  for_each  = toset(var.app_environments)
  role_name = opensearch_role.os_bedrock_roles[each.key].role_name

  backend_roles = [
    local.knowledge_base_role_arns[each.key]
  ]

  depends_on = [module.opensearch]
}

Власне, саме тут зіткнулись з помилками доступу Bedrock, через які довелось додавати cluster_permissions:

The knowledge base storage configuration provided is invalid… Request failed: [security_exception] no permissions for [indices:data/read/msearch] and User [name=arn:aws:iam::492***148:role/kraken-kb-role-dev, backend_roles=[arn:aws:iam::492***148:role/kraken-kb-role-dev], requestedTenant=null]

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

Створення OpenSearch індексів

Провайдер вже є, ресурс беремо opensearch_index.

В locals записуємо шаблон індексу – я його просто взяв у девелоперів зі старого конфігу:

locals {
  # 'atlas-kb-prod'
  env_name = "${var.project_name}-${var.environment}"
  # 'opensearch.prod.example.co'
  os_custom_domain_name = "opensearch.${var.dns_zone}"

  # index mappings

  os_index_mappings = <<-EOF
    {
      "dynamic_templates": [
        {
          "strings": {
            "match_mapping_type": "string",
            "mapping": {
              "fields": {
                "keyword": {
                  "ignore_above": 8192,
                  "type": "keyword"
                }
              },
              "type": "text"
            }
          }
        }
      ],
      "properties": {
        "bedrock-knowledge-base-default-vector": {
          "type": "knn_vector",
          "dimension": 1024,
          "method": {
            "name": "hnsw",
            "engine": "faiss",
            "parameters": {
              "m": 16,
              "ef_construction": 512
            },
            "space_type": "l2"
          }
        },
        "AMAZON_BEDROCK_METADATA": {
          "type": "text",
          "index": false
        },
        "AMAZON_BEDROCK_TEXT_CHUNK": {
          "type": "text",
          "index": true
        }
      }
    }
EOF
}

Створюємо файл opensearch_indexes.tf. І додаємо сам індекси – тут я все ж вирішив без циклу, прямо створити окремі Dev/Staging/Prod:

# Dev Index
resource "opensearch_index" "kb_vector_index_dev" {
  name = "kraken-kb-index-dev"
  
  # enable approximate nearest neighbor search by setting index_knn to true
  index_knn                      = true
  index_knn_algo_param_ef_search = "512"
  number_of_shards               = "1"
  number_of_replicas = "1"
  mappings                       = local.os_index_mappings

  # When new documents are ingested into the Knowledge Base,
  # OpenSearch automatically creates field mappings for new metadata fields under
  # AMAZON_BEDROCK_METADATA. Since these fields are created outside of TF resource definitions,
  # TF detects them as configuration drift and attempts to recreate the index to match its
  # known state.
  #
  # This lifecycle rule prevents unnecessary index recreation by ignoring mapping changes
  # that occur after initial deployment.
  lifecycle {
    ignore_changes = [mappings]
  }
}

...

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

 

Власне, на цьому і все.

Bedrock вже підключили, все працює.

Але трохи погемороїтись довелось.

І впевнений, що не останній раз 🙂

Loading

Terraform: використання Ephemeral resources та Write-only attributes
0 (0)

3 Вересня 2025

В Terraform ephemeral resources та write-only arguments з’явились давно, ще у версії 1.10, але не було нагоди про них написати детальніше.

Основна ідея їх – не залишати “слідів” в state-файлі, що особливо корисно для паролів або токенів, бо дані існують тільки під час виконання apply самого Terraform в його пам’яті.

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

Приклад без ephemeral values та write-only arguments

Почнемо зі старої схеми, без використання ephemeral resources та write-only arguments – створимо рандомний пароль, ресурс aws_secretsmanager_secret, в ньому збережемо цей пароль, і отримаємо його з data:

provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      component   = "devops"
      created-by  = "terraform"
      environment = "test"
    }
  }
}

### RESOURCES ###

# generate a random password
resource "random_password" "test_random_password" {
   length  = 8
   special = false
}

# create an AWS Secret resource
resource "aws_secretsmanager_secret" "test_aws_secret" {
  name                    = "db_password"
  description             = "database passsword"
  recovery_window_in_days = 0
}

# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
  secret_id     = aws_secretsmanager_secret.test_aws_secret.id
  secret_string = random_password.test_random_password.result
}

### DATA SOURCES ###

# retrieve the AWS Secret value
data "aws_secretsmanager_secret_version" "test_aws_secret_data" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id

  depends_on = [aws_secretsmanager_secret_version.test_aws_secret_version]
}

### OUTPUTS ###

# get the random password value
output test_random_password {
  value       = random_password.test_random_password.result
  sensitive   = true
}

# get the AWS Secret value
output "test_aws_secret" {
  value = data.aws_secretsmanager_secret_version.test_aws_secret_data.secret_string
  sensitive   = true
}

Тут ми:

  • resource "random_password": генеруємо сам пароль
  • resource "aws_secretsmanager_secret": створюємо новий запис в AWS Secrets Manager
  • resource "aws_secretsmanager_secret_version": записуємо в цей Secret значення із resource "random_password"
  • data "aws_secretsmanager_secret_version": отримуємо значення з AWS Secrets Manager
  • output "test_random_password": виводимо значення із resource "random_password"
  • output "test_aws_secret": виводимо значення, отримане з AWS Secrets Manager

Виконуємо terraform init та terraform apply:

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

Outputs:

test_aws_secret = <sensitive>
test_random_password = <sensitive>

Виглядає ОК – в outputs у нас завдяки sensitive = true нічого не відобразилось.

Але пароль є в state file:

$ cat terraform.tfstate
{
  ...
  "outputs": {
    "test_aws_secret": {
      "value": "1atcZYGR",
      "type": "string",
      "sensitive": true
    },
    "test_random_password": {
      "value": "1atcZYGR",
      "type": "string",
      "sensitive": true
    }
  },
...
  "resources": [
    {
      "mode": "data",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_data",
      ...
            "secret_string": "1atcZYGR",
...
    {
      "mode": "managed",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_version",
      ...
            "secret_string": "1atcZYGR",
...
    {
      "mode": "managed",
      "type": "random_password",
      "name": "test_random_password",
      ...
            "result": "1atcZYGR",

Тепер почнемо ховати ці дані зі стейту.

Використання Write-Only Attributes

Атрибути ресурсів, які мають суфікс _wo є “write-only” даними, тобто Terraform їх тримає в пам’яті під час виконання операцій, але ніде в себе не зберігає.

Втім, таки атрибути підтримуються далеко не всіма ресурсами. Наприклад, в AWS RDS через ресурс aws_db_instance можна передати пароль через атрибут password_wo, а в aws_opensearch_domain і його master_user_password  для створення root-юзера в internal user database – (поки що) ні.

Офіційна документація – Use write-only arguments.

aws_secretsmanager_secret_version теж підтримує write-only attributes – secret_string_wo замість secret_string, і secret_string_wo_version замість secret_string_version.

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

Редагуємо наш код, тільки resource "aws_secretsmanager_secret_version" – задаємо secret_string_wo і secret_string_wo_version, решту залишаємо без змін:

...
# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
  secret_id     = aws_secretsmanager_secret.test_aws_secret.id
  #secret_string = random_password.test_random_password.result
  secret_string_wo = random_password.test_random_password.result
  secret_string_wo_version = 1
}
...

Виконуємо terraform apply, і перевіряємо стейт тепер:

$ cat terraform.tfstate
{
  ...
  "outputs": {
    "test_aws_secret": {
      "value": "1atcZYGR",
      "type": "string",
      "sensitive": true
    },
    "test_random_password": {
      "value": "1atcZYGR",
      "type": "string",
      "sensitive": true
    }
  },
...
  "resources": [
    {
      "mode": "data",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_data",
      ...
            "secret_string": "1atcZYGR",
...
    {
      "mode": "managed",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_version",
      ...
            "secret_string": "",
            "secret_string_wo": null,
            "secret_string_wo_version": 1,

...
    {
      "mode": "managed",
      "type": "random_password",
      "name": "test_random_password",
      ...
            "result": "1atcZYGR",

Тепер у нас в managed.aws_secretsmanager_secret_version.test_aws_secret_version немає значень для secret_string та secret_string_wo.

Використання Ephemeral resources

Ідея “ефемерних” ресурсів така ж, як і з write-only arguments – ці ресурси існують тільки в пам’яті Terraform під час виконання terraform apply і не зберігаються в state file.

Але використання таких ресурсів обмежене:

  • можна посилатись на них у write-only arguments
  • в інших ефемерних ресурсах
  • в locals
  • в ephemeral variables
  • в providers, provisioner та connection

Документація – Ephemeral block reference.

Редагуємо наш код і міняємо resource "random_password" на ephemeral "random_password", resource "aws_secretsmanager_secret_version" залишаємо – він пароль запише в AWS Secrets Manager, але не зберігає значення в state, і додаємо новий ресурс – ephemeral "aws_secretsmanager_secret_version", через який ми цей пароль отримаємо назад в Terraform.

При цьому в secret_string_wo і в output "test_random_password" ми тепер посилаємось на пароль через ephemeralephemeral.random_password.test_random_password.result.

І в output "test_aws_secret" теж використовуємо ephemeral.aws_secretsmanager_secret_version.test_aws_secret_data.secret_string.

data "aws_secretsmanager_secret_version" можемо прибирати, бо пароль ми тепер отримаємо саме з ephemeral "aws_secretsmanager_secret_version":

...

### RESOURCES ###

# generate a random password
ephemeral "random_password" "test_random_password" {
   length  = 8
   special = false
}

# create an AWS Secret resource
resource "aws_secretsmanager_secret" "test_aws_secret" {
  name                    = "db_password"
  description             = "database passsword"
  recovery_window_in_days = 0
}

# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
  secret_id     = aws_secretsmanager_secret.test_aws_secret.id
  #secret_string = random_password.test_random_password.result
  secret_string_wo = ephemeral.random_password.test_random_password.result
  secret_string_wo_version = 1
}

### DATA SOURCES ###

# Retrieve the password from Secrets Manager (ephemeral)
ephemeral "aws_secretsmanager_secret_version" "test_aws_secret_version_ephemeral" {
  secret_id     = aws_secretsmanager_secret.test_aws_secret.id
}

# retrieve the AWS Secret value
# data "aws_secretsmanager_secret_version" "test_aws_secret_data" {
#   secret_id = aws_secretsmanager_secret.test_aws_secret.id

#   depends_on = [aws_secretsmanager_secret_version.test_aws_secret_version]
# }

### OUTPUTS ###

# get the random password value
output test_random_password {
  value       = ephemeral.random_password.test_random_password.result
  sensitive   = true
}

# get the AWS Secret value
output "test_aws_secret" {
  value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sensitive   = true
}

Помилка “This output value is not declared as returning an ephemeral value”

Виконуємо terraform apply, і ловимо першу помилку:

...
│ Error: Ephemeral value not allowed
│ 
│   on main.tf line 53, in output "test_random_password":
│   53:   value       = ephemeral.random_password.test_random_password.result
│ 
│ This output value is not declared as returning an ephemeral value, so it cannot be set to a result derived from an ephemeral value.
╵
╷
│ Error: Ephemeral value not allowed
│ 
│   on main.tf line 59, in output "test_aws_secret":
│   59:   value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
│ 
│ This output value is not declared as returning an ephemeral value, so it cannot be set to a result derived from an ephemeral value.

Але навіть якщо ми додамо параметр ephemeral = true:

...
### OUTPUTS ###

# get the random password value
output test_random_password {
  value       = ephemeral.random_password.test_random_password.result
  sensitive   = true
  ephemeral = true
}

# get the AWS Secret value
output "test_aws_secret" {
  value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sensitive   = true
  ephemeral = true
}

То це все одно працювати не буде.

Помилка “Ephemeral outputs are not allowed in context of a root module”

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

...
╷
│ Error: Ephemeral output not allowed
│ 
│   on main.tf line 52:
│   52: output test_random_password {
│ 
│ Ephemeral outputs are not allowed in context of a root module
╵
╷
│ Error: Ephemeral output not allowed
│ 
│   on main.tf line 59:
│   59: output "test_aws_secret" {
│ 
│ Ephemeral outputs are not allowed in context of a root module

Бо використання Ephemeral outputs можливе тільки в модулях – далі глянемо, як саме.

ОК – поки просто приберемо Outputs, і тепер terraform apply проходить без проблем:

$ terraform apply
...
random_password.test_random_password: Refreshing state... [id=none]
ephemeral.random_password.test_random_password: Opening...
ephemeral.random_password.test_random_password: Opening complete after 0s
...
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening...
...
ephemeral.random_password.test_random_password: Closing...
ephemeral.random_password.test_random_password: Closing complete after 0s
...

Зверніть уваги, що для ephemeral ресурсів Terraform тепер виконує операції не Reading та Refreshing state – а Opening та Closing.

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

Перевіряємо state file тепер:

...
    {
      "mode": "managed",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_version",
      ...
            "secret_string": "",
            "secret_string_wo": null,
            "secret_string_wo_version": 1,

...

Тепер у нас:

  • ресурсів ephemeral "random_password" та ephemeral "aws_secretsmanager_secret_version" в стейті нема взагалі
  • а managed.aws_secretsmanager_secret_version.test_aws_secret_version все ще має пусте поле в secret_string_wo – бо ми його ще раніше зробили write-only

ОК – а як тепер використати пароль? Бо data "aws_secretsmanager_secret_version" ми ж прибрали.

Використання значень з Ephemeral resources

Ми вже бачили приклад посилання на Ephemeral resources вище, коли робили secret_string_wo = ephemeral.random_password.test_random_password.result.

Аналогічно можемо використати і ephemeral.aws_secretsmanager_secret_version.db_password_wo_ephemeral.secret_string.

Як писав вище – можемо це робити не всюди, але в providers це допускається.

Для перевірки – запустимо PostgreSQL з нашим паролем (візьмемо його напряму з AWS Console > AWS Secrets Manager):

Запускаємо контейнер, в який передаємо змінну POSTGRES_PASSWORD="1atcZYGR":

$ docker run --rm --name some-postgres -e POSTGRES_PASSWORD="1atcZYGR" -p 5432:5432 postgres

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

В полі password провайдера як раз і використаємо ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string:

...

### PostgreSQL Configuration

terraform {
  required_providers {
    postgresql = {
      source  = "cyrilgdn/postgresql"
      version = "~> 1.20"
    }
  }
}

provider "postgresql" {
  host     = "localhost"
  port     = 5432
  username = "postgres"
  password = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sslmode = "disable"
}

resource "postgresql_database" "demo_db" {
  name              = "demo_db"
  template          = "template0"
  connection_limit  = -1
  allow_connections = true
}

Робимо terraform init та terraform apply:

$ terraform init && terraform apply
...
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening...
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening complete after 1s
postgresql_database.demo_db: Creating...
postgresql_database.demo_db: Creation complete after 0s [id=demo_db]
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Closing...
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Closing complete after 0s

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

Перевіряємо базу:

$ export PGPASSWORD="1atcZYGR"
$ psql -h localhost -U postgres -c "\l"
                                                    List of databases
   Name    |  Owner   | Encoding | Locale Provider |  Collate   |   Ctype    | Locale | ICU Rules |   Access privileges   
-----------+----------+----------+-----------------+------------+------------+--------+-----------+-----------------------
 demo_db   | postgres | UTF8     | libc            | en_US.utf8 | en_US.utf8 |        |           | 
...

Таким жеж чином ми могли б використати ефемерний ресурс через locals:

...
locals {
  db_password_local = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
}

provider "postgresql" {
  host     = "localhost"
  port     = 5432
  username = "postgres"
  password = local.db_password_local
  #password = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sslmode = "disable"
}

resource "postgresql_database" "demo_db" {
  name              = "demo_db_via_local"
  template          = "template0"
  connection_limit  = -1
  allow_connections = true
}

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

$ terraform apply
...
  # postgresql_database.demo_db will be updated in-place
  ~ resource "postgresql_database" "demo_db" {
        id                     = "demo_db"
      ~ name                   = "demo_db" -> "demo_db_via_local"
        # (10 unchanged attributes hidden)
    }
...
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

І в state-файлі у нас ідже пароль не світиться:

$ cat terraform.tfstate | grep 1atcZYGR | echo $?
127

Використання Ephemeral Outputs

Вище ми пробували використати output "test_aws_secret" з ephemeral = true, але отримали помилку “Ephemeral outputs are not allowed in context of a root module“.

Спробуємо використати у власному модулі.

Документація – ephemeral – Avoid storing values in state or plan files.

Створимо модуль modules/secret_ephemeral, в який винесемо генерацію паролю і його збереження в AWS Secrets Manager, і додамо Ephemeral Output.

А в рутовому модулі – використаємо outputs цього модулю для отримання через ephemeral "aws_secretsmanager_secret_version", як це робили вище.

Пишемо файл modules/secret_ephemeral/secret.tf:

### RESOURCES ###

# generate a random password
ephemeral "random_password" "test_random_password" {
   length  = 8
   special = false
}

# create an AWS Secret resource
resource "aws_secretsmanager_secret" "test_aws_secret" {
  name                    = "db_password_via_module"
  description             = "database passsword"
  recovery_window_in_days = 0
}

# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
  secret_id     = aws_secretsmanager_secret.test_aws_secret.id
  #secret_string = random_password.test_random_password.result
  secret_string_wo = ephemeral.random_password.test_random_password.result
  secret_string_wo_version = 1
}

# Retrieve the password from Secrets Manager (ephemeral)
ephemeral "aws_secretsmanager_secret_version" "test_aws_secret_version_ephemeral" {
  secret_id     = aws_secretsmanager_secret.test_aws_secret.id
}

output "password_ephemeral" {
  value     = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  ephemeral = true
}

В головному файлі main.tf – прибираємо все, пов’язане з паролем, додаємо виклик модуля, і в locals використовуємо його output:

...

### PostgreSQL Configuration

terraform {
  required_providers {
    postgresql = {
      source  = "cyrilgdn/postgresql"
      version = "~> 1.20"
    }
  }
}

module "secret_ephemeral" {
  source = "./modules/secret_ephemeral"
}

locals {
  db_password_local = module.secret_ephemeral.password_ephemeral
}

provider "postgresql" {
  host     = "localhost"
  port     = 5432
  username = "postgres"
  password = local.db_password_local
  #password = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sslmode = "disable"
}

resource "postgresql_database" "demo_db" {
  name              = "demo_db_via"
  template          = "template0"
  connection_limit  = -1
  allow_connections = true
}

Тільки спочатку треба створити пароль – запустити terraform apply без resource "postgresql_database", і оновити запуск контейнера з новим паролем:

$ docker run --rm --name some-postgres -e POSTGRES_PASSWORD="PHsfzcIx" -p 5432:5432 postgres

Тепер наш провайдер використовує пароль з Ephemeral Output модуля modules/secret_ephemeral:

...
module.secret_ephemeral.ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening...
module.secret_ephemeral.ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening complete after 1s
postgresql_database.demo_db: Creating...
postgresql_database.demo_db: Creation complete after 0s [id=demo_db_via]
module.secret_ephemeral.ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Closing...
module.secret_ephemeral.ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Closing complete after 0s

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

В стейті у нас все так жеж ніякого паролю нема:

$ cat terraform.tfstate | grep PHsfzcIx | echo $?
127

Власне, на цьому і все.

Дуже жаль, що aws_opensearch_domain не підтримує write-only. Хотів його використати для рутового паролю 🙁

Але в GitHub вже є на це issue Support ephemeral “write-only” argument for aws_opensearch_domain, і навіть з коментом “I have started working on this issue, and will submit a PR shortly“.

А в самому пул-реквесті навіть можна глянути як воно реалізоване.

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

Loading

AWS: створення OpenSearch Service cluster та налаштування аутентифікації і авторизації
0 (0)

29 Серпня 2025

В попередній частині – AWS: знайомство з OpenSearch Service в ролі vector store – подивились на AWS OpenSearch Service взагалі, трохи розібрались з тим, як в ньому організовані дані, що таке shards та nodes, і які нам власне типи інстансів для data nodes треба.

Наступний крок – створити кластер і подивитись на аутентифікацію, яка, як на мене, в чомусь навіть складніша за AWS EKS. Хоча, можливо, просто діло звички.

Що будемо робити сьогодні – вручну створимо кластер AWS OpenSearch Service, глянемо на основні опції при створенні кластеру, а потім копнемо в налаштування доступу до кластеру і до OpenSearch Dashboards з AWS IAM та Fine-grained access control самого OpenSearch і його Security plugin.

А вже в наступній частині будемо писати Terraform – див. Terraform: створення AWS OpenSearch Service cluster та юзерів.

Ручне створення кластера в AWS Console

Робити будемо мінімальний PoC аби погратись, тобто з t3 інстансами і в одній Availability Zone та без Master Nodes.

В Production у нас теж планується один маленький кластер з трьома індексами dev/staging/prod в ролі vector store для AWS Bedrock Knowledge Base.

Документація від AWS – Creating OpenSearch Service domains.

Переходимо в Amazon OpenSearch Service > Domains, клікаємо “Create domain”.

Задаємо ім’я, вибираємо “Standart create”, аби мати доступ до всіх опцій:

В “Templates” вибираємо “Dev/”test – тоді можна буде вибрати конфіг без Master Nodes і можна буде деплоїти в одній Availability Zone.

В “Deployment option(s)” вибираємо “Domain without standby” – тоді нам будуть доступні інстанси t3:

Справа нам зручненько відразу показує весь сетап.

Storage

Питання кількості шардів на кластер розбирали в попередньому пості, будемо вважати, що у нас планується даних максимум 20-30 GiB, тому будемо створювати 1 primary шард та 1 replica. Але шарди налаштовуються пізніше, коли будемо робити індекси з Terraform і opensearch_index_template.

І для цих двох шардів будемо робити дві Data Nodes – одна для primary шарду, одна для репліки.

“Engine options” описані в Features by engine version in Amazon OpenSearch Service, просто залишаємо дефолтне значення, останню версію.

“Instance family” вибираємо “General puprose”, в “Instance type” – t3.small.search.

“EBS storage size per node” візьмемо 50 GiB – 20-30 гігабайт під дані, і трохи запасу для самої операційної системи:

Nodes

“Number of master nodes” та “Dedicated coordinator nodes” залишаємо без змін, тобто без них:

Network

В “Custom endpoint” поки теж нічого не міняємо, але потім тут можна додати який власний домен із Route53 з сертифікатом з AWS Certificate Manager для доступу до кластеру, див. Creating a custom endpoint for Amazon OpenSearch Service.

В “Network” – поки робимо найпростіший варіант, з “Public access”, але для Production будемо робити всередині VPC:

Але треба буде потестити доступ до Dashboards, бо якщо кластер створюється в сабнетах VPC, то до нього не можна застосувати IP-based policies, див. About access policies on VPC domains. Про IP-based policies будемо говорити тут далі.

Access && permissions

“Fine-grained access control” (FGAC) – поки відключаємо, далі детальніше подивимось на цей механізм. Хоча я не впевнений, що він буде потрібен, бо розділити доступ до різних індексів в одному кластері можна і просто з IAM.

SAML, JWT та IAM Identity Center залежать від FGAC, тому теж скіпаємо, і надалі я їх використовувати не планую, не наш кейс.

Cognito теж мимо – ми ним не користуємось (хоча пізніше, можливо, подивлюсь в сторону інтеграції з Auth0 чи Cognito для Dashboards):

“Access policy” можна порівняти з S3 Access Policy, або з IAM Policy для EKS яка дозволяє IAM-юзеру доступ до кластеру.

Детальніше поговоримо в частині про аутентифікацію, поки просто залишаємо дефолтний “Do not set domain level access policy”:

“Off-peak window” – час найменшого навантаження для встановлення апдейтів і виконання Auto-tune операцій.

У нас off-peak буде вночі по США, тому в Production тут буде Central Time (CT) 05:00 UTC.

Але так як зараз тестовий PoC – то теж скіпаємо.

Auto-Tune власне теж нормально описана, і недоступна для наших інстансів t3.

Automatic software update – корисна штука для Production, і буде виконуватись в час, заданий в Off-peak window:

В “Advanced cluster settings” можна відключити rest.action.multi.allow_explicit_index, але не знаю, як у нас будуть будуватись запити, і начебто десь зустрічав, що може поламати Dashboard – тому нехай залишиться дефолтне enabled:

Ну і все, в результаті маємо такий сетап:

Клікаємо “Create”, і йдемо пити чай, бо створюється кластер довго – довше, ніж EKS, і створення OpenSearch зайняло хвилин 20.

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

Тепер, мабуть, саме цікаве – про юзерів і доступи.

Після створення кластера по дефолту ми маємо обмежені права доступу до самого OpenSearch API:

Бо в “Security Configuration” у нас є явний Deny:

Доступ до AWS OpenSearch Service має три таких собі “рівня” – мережа, IAM, та Security Plugin самого OpenSearch.

При цьому в IAM у нас є дві сутності – Domain Access Policy, який ми бачимо в Security Configuration > Access Policy (атрибут access_policies в Terraform), та Identity-based policies – які є звичайними AWS IAM Policies.

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

  • мережа: параметр Network > VPC access або Public access: задаємо ліміт доступу на рівні мережі (див. Launching your Amazon OpenSearch Service domains within a VPC)
    • або, якщо брати аналогію з EKS – То це Public та Private API endpoint, або з RDS – створювати інстанс в публічних чи приватних сабнетах
  • AWS IAM:
    • Domain Access Policies:
      • Resource-based policies: політики, які описуються безпосередньо в налаштуваннях самого кластеру
        • доступ задається для IAM Role, IAM User, AWS Accounts  до конкретного OpenSearch domain
      • IP-based policies: фактично ті самі Resource-based policies, але з можливістю дозволити доступ без аутентифікації для конкретних IP (тільки якщо тип доступу Public, див. VPC versus public domains)
    • Identity-based policies: якщо Resource-based policies є частиною налаштувань security-політик кластера – то Identity-based policies є звичайними AWS IAM Policies, які додаються конкретному юзеру чи ролі
  • Fine-grained access control (FGAC): Security Plugin самого OpenSearch – атрибут advanced_security_options в Terraform
    • якщо в Resource-based policies і Identity-based policies ми задаємо правила на рівні кластеру (домену) і індексів, то в FGAC можна додатково описати обмеження на конкретні документи або поля
    • і навіть якщо в Resource-based policies і Identity-based policies дозволено доступ до ресурсу в кластері – через Fine-grained access control його можна “обрізати”

Тобто authentification та  authorization flow буде таким:

  1. AWS API отримує запит від юзера, наприклад es:ESHttpGet
    1. AWS IAM виконує аутентифікацію – перевіряє ACCESS:SECRET ключі або Session token
    2. AWS IAM виконує авторизацію:
      • перевіряє IAM Policy юзера (Identity-based policy), якщо тут є явний дозвіл – пропускаємо
      • перевіряє Domain Access Policy (Resource-based policy) кластеру, якщо тут явний дозвіл – пропускаємо
  2. запит приходить до самого OpenSearch
    1. якщо Fine-grained access control не включений – дозволяємо
    2. якщо є налаштований Fine-grained access control – перевіряємо внутрішні ролі, і якщо юзеру дозволено – то виконуємо запит

Давайте робити доступи, подивимось, як воно все працює.

Налаштування Domain Access policy

Базовий варіант – додати IAM User доступ до кластеру.

Resource-based policy

Редагуємо “Access policy”, і вказуємо свого юзера, типи API-операцій, та домен:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::492***148:user/arseny.zinchenko"
      },
      "Action": "es:*",
      "Resource": "arn:aws:es:us-east-1:492***148:domain/test/*"
    }
  ]
}

Чекаємо хвилину – і тепер маємо доступ до OpenSearch API (бо Cluster health в AWS Console отримується саме з OpenSearch – див. Cluster Health API):

І тепер можемо з curl та --aws-sigv4 отримати доступ до кластеру (див. Authenticating Requests (AWS Signature Version 4)):

$ curl --aws-sigv4 "aws:amz:us-east-1:es" \
>  --user "AKI***B7A:pAu***2gW" \
> https://search-test-***.us-east-1.es.amazonaws.com/_cluster/health?pretty
{
  "cluster_name" : "492***148:test",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 2,
  "number_of_data_nodes" : 2,
  "discovered_master" : true,
  "discovered_cluster_manager" : true,
  "active_primary_shards" : 5,
  "active_shards" : 10,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 100.0
}

IP-based policies та доступ до OpenSearch Dashboards

Аналогічно, через Domain Access Policy можемо відкрити доступ до Dashboards – самий простий варіант, але працює тільки з Public domains. Якщо кластер буде в VPC – то треба буде робити додаткову аутентифікацію, див. Controlling access to Dashboards.

Редагуємо політику, додаємо умову IpAddress.aws:SourceIp:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::492***148:user/arseny.zinchenko"
      },
      "Action": "es:*",
      "Resource": "arn:aws:es:us-east-1:492***148:domain/test/*"
    },
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "*"
      },
      "Action": "es:ESHttp*",
      "Resource": "arn:aws:es:us-east-1:492***148:domain/test/*",
      "Condition": {
        "IpAddress": {
          "aws:SourceIp": "178.***.***.184"
        }
      }
    }
  ]
}

І тепер маємо доступ до дашборди:

Identity-based policy

Тепер другий варіант – створимо окремого IAM User і йому підключити окрему IAM Policy.

В AWS IAM додаємо юзера:

Можемо взяти AWS managed policies for Amazon OpenSearch Service:

Далі просто створюємо ключі доступу для Command Line Interface (CLI), і – нічого не змінюючи в Access policy самого кластеру – перевіряємо доступ:

$ curl --aws-sigv4 "aws:amz:us-east-1:es" --user "AKI***YUK:fXV***34I" https://search-test-***.us-east-1.es.amazonaws.com/_cluster/health?pretty
{
  "cluster_name" : "492***148:test",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 2,
  "number_of_data_nodes" : 2,
  "discovered_master" : true,
  "discovered_cluster_manager" : true,
  "active_primary_shards" : 5,
  "active_shards" : 10,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 100.0
}

Тобто тепер у нас є Domain Acces Policy – яка дозволяє доступ конкретно моєму юзеру, і є окрема IAM Ploicy – Identity-based policy – яка дозволяє доступ тестовому юзеру.

Але тут є один важливий момент: в IAM Policy ми вказуємо або весь домен – або тільки його subresources.

Тобто, якщо замість політики AmazonOpenSearchServiceFullAccess ми створимо власну полісі, в якій вкажемо "Resource":***:domain/test/*":

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "es:*"
            ],
            "Resource": "arn:aws:es:us-east-1:492***148:domain/test/*"
        }
    ]
}

То ми зможемо виконати es:ESHttpGet (GET _cluster/health) – але не зможемо виконати cluster-level операції, наприклад – es:AddTags, навіть при тому, що в Actions IAM-політики маємо дозвіл на всі виклики – es:*:

 $ aws --profile test-os opensearch add-tags --arn arn:aws:es:us-east-1:492***148:domain/test --tag-list Key=environment,Value=test

An error occurred (AccessDeniedException) when calling the AddTags operation: User: arn:aws:iam::492***148:user/test-opesearch-identity-based-policy is not authorized to perform: es:AddTags on resource: arn:aws:es:us-east-1:492***148:domain/test because no identity-based policy allows the es:AddTags action

Якщо ж ми хочемо дозволити взагалі всі операції з кластером – то "Resource" задаємо як "arn:aws:es:us-east-1:492***148:domain/test", і тоді можемо додати теги.

Всі API actions див. в Actions, resources, and condition keys for Amazon OpenSearch Service.

Fine-grained access control

Документація – Fine-grained access control in Amazon OpenSearch Service.

Основна ідея дуже схожа з Kubernetes RBAC.

В OpenSearch маємо три основних концепти:

  • users – як Kubernetes Users та ServiceAccounts
  • roles  – як Kubernetes RBAC Roles
  • mappings – як  Kubernetes Role Bindings

Юзери можуть бути як з AWS IAM, так і з внутрішньої бази OpenSearch.

Як і в Kubernetes, в OpenSearch є набір дефолтних ролей – див. Predefined roles.

При цьому ролі, як і в Kubernetes, можуть бути cluster-wide або index-specific – аналог ClusterRoleBinding та просто namespaced RoleBinding в Kubernetes, плюс в OpenSearch FGAC можна додатково мати document level або field level permissions.

Налаштування Fine-grained access control

Важливий момент: після включення FGAC не можна буде повернутись на стару схему. Але всі доступи з IAM залишаться, навіть якщо переключитись на internal database.

Редагуємо “Security configuration”, вмикаємо “Fine-grained access control”:

Спершу тут нам треба задати Master user, якого можна вказати з IAM – або створити локально в OpenSearch.

Якщо ми створюємо юзера через опцію “Create master user” – то вказуємо звичайний логін:пароль, і в такому випадку OpenSearch підключить internal user database (internal_user_database_enabled  в Terraform).

Якщо використовуємо внутрішню базу OpenSearch – то можемо мати звичайних юзерів і виконувати HTTP basic authentication, див. документацію AWS – Tutorial: Configure a domain with the internal user database and HTTP basic authentication та Defining users and roles в документації самого OpenSearch, бо це вже його внутрішні механізми.

Має сенс, якщо не хочеться крутити Cognito чи SAML, і якщо налаштування юзерів у кожного кластеру будуть власні.

Якщо задавати IAM-юзера, то схема буде схожою з AIM аутентифікацією для RDS і IAM database authentication – доступ до кластеру контролюється AWS IAM, але внутрішні першмішени до схем та баз – ролями PostgreSQL чи MariaDB, див. AWS: RDS з IAM database authentication, EKS Pod Identities та Terraform.

Тобто в такому випадку AWS IAM буде виконувати виключно аутентифікацію юзера, а авторизація (перевірка прав доступу) вже через Security plugin та ролі самого OpenSearch.

Спробуємо локальну базу, і, думаю, в Production ми теж візьмемо цю схему:

“Access Policy” можемо залишити як є:

Переключення на internal database займе час, бо викличе blue/green deployment нового кластеру – див. Making configuration changes in Amazon OpenSearch Service.

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

Після того як зміни застосовані – в Dashboards у нас тепер буде просити логін і пароль, використовуємо нашого Master user:

Master user отримує дві підключені ролі – all_access та security_manager.

І саме security_manager дає доступ до розділу Security та Users в дашборді:

При цьому у нас залишається доступ наших AIM-юзерів, і ми можемо далі використовувати curl: IAM users будуть мапитись на роль default_role, яка дозволяє виконувати GET/PUT на всі індекси – див. About the default_role:

Перевіряємо доступ нашого тестового юзера зараз:

$ curl --aws-sigv4 "aws:amz:us-east-1:es" --user "AKI***YUK:fXV***34I" https://search-test-***.us-east-1.es.amazonaws.com/_cluster/health?pretty
{
  "cluster_name" : "492***148:test",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 2,
...

А тепер поріжемо доступи всім IAM-юзерам.

Створення OpenSearch Role

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

Додаємо індекс:

Переходимо в Securty > Roles, додаємо роль:

Задаємо Index permissions – повний доступ на індекс (crud):

Далі в цій ролі переходимо до Mapped users > Map users:

І додаємо ARN нашого тестового юзера:

Видаляємо дефолтну роль:

Тепер наш юзер не має доступ до GET _cluster/health – тут отримуємо помилку 403, no permissions:

$ curl --aws-sigv4 "aws:amz:us-east-1:es" --user "AKI***YUK:fXV***34I" https://search-test-***.us-east-1.es.amazonaws.com/_cluster/health?pretty
{
  "error" : {
    ...
    "type" : "security_exception",
    "reason" : "no permissions for [cluster:monitor/health] and User [name=arn:aws:iam::492***148:user/test-opesearch-identity-based-policy, backend_roles=[], requestedTenant=null]"
  },
  "status" : 403
}

Але має доступ до тестового індексу:

$ curl --aws-sigv4 "aws:amz:us-east-1:es" --user "AKI***YUK:fXV***34I" https://search-test-***.us-east-1.es.amazonaws.com/test-allowed-index/_search?pretty   -d '{
    "query": {
      "match_all": {}
    }
  }' -H 'Content-Type: application/json'
{
  "took" : 78,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  }
}

Готово.

Loading

AWS: знайомство з OpenSearch Service в ролі vector store
0 (0)

22 Серпня 2025

Ми зараз використовуємо AWS OpenSearch Service як vector store для нашого RAG з AWS Bedrock Knowledge Base.

Про RAG і Bedrock детальніше поговоримо іншим разом, а сьогодні давайте подивимось на AWS OpenSearch Service.

Власне, задача – мігрувати наш AWS OpenSearch Service Serverless на Managed, в першу чергу через (сюрпрайз) питання вартості – бо з Serverless у нас постійно неочікувані спайки у використанні OpenSearch Compute Units (OCU – процесор, пам’ять та диск) – навіть коли нема ніяких змін у даних.

Головна задача – це спланувати розмір кластеру: диски, CPU та пам’ять, і підібрати під це типи інстансів.

В другій частині поговоримо про налаштування доступів – AWS: створення OpenSearch Service cluster та налаштування аутентифікації і авторизації.

В третій частині будемо писати Terraform – див. Terraform: створення AWS OpenSearch Service cluster та юзерів.

Elasticsearch vs OpenSearch vs AWS OpenSearch Service

Власне, OpenSearch – це по суті той самий Elasticsearch: коли Elasticsearch у, здається, 2021 змінив умови своєї ліцензії – AWS запустила власний форк, назвавши його OpenSearch.

OpenSearch сумісний з Elasticsearch до версії 7.10, але на відміну від Elasticsearch – у OpenSearch повністю вільна ліцензія.

Про запуск Elasticsearch як частину ELK-стеку для логів колись писав тут – Elastic Stack: обзор и установка ELK на Ubuntu, але там більше про self-hosted і взагалі роботу з індексами, а тепер ми подивимось саме на рішення від AWS.

AWS OpenSearch Service – це повністю AWS-managed сервіс: як і у випадку з Kubernetes – AWS бере на себе всі задачі по деплою, апдейтам, бекапам, має тісну інтеграцію з іншими AWS-сервісами – IAM, VPC, S3, ну і Bedrock, з яким ми його і використовуємо.

AWS OpenSearch Service: знайомство

Тут і далі буду говорити в основному за Managed OpenSearch Service.

Основні концепти AWS OpenSearch Service – це домен, ноди, індекси (“бази”) та шарди (shards).

Домен – це сам кластер, який ми налаштовуємо на потрібну кількість і тип Nodes, а індекси – поділені на shards (блоки даних), які розподілені між Nodes:

Самі Nodes в кластері – по суті звичайні EC2 (як і в тому ж RDS чи навіть AWS Load Balancer), де під капотом працюють ті самі звичайні compute-інстанси.

Для кластеру AWS OpenSearch Service як і з Elastic Kubernetes Service створюються окремі control nodes (master nodes), тільки на відміну від EKS тут нам не треба окремо менеджити Data Plane та WorkerNodes.

Як і в RDS – для OpenSearch-кластеру можемо налаштувати автоматичні бекапи.

Для візуалізації даних – AWS предоставляє OpenSearch Dashboards.

Схема даних: документи, індекси та шарди

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

Отже, індекс – це колекція документів, які мають якісь загальні риси. У кожного індексу є унікальне ім’я – як у бази даних в RDS PostgreSQL чи MariaDB.

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

Документ – JSON-об’єкт в індексі, і являє собою базовий юніт зберігання даних. Якщо брати аналогію с тими ж базами даних – то це як рядок в таблиці.

Кожен документ має набір key-value полів, де value можуть бути string, integer, date або більш складними структурами типу масивів або object.

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

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

Shards можуть бути primary, або replica: primary приймає всі write-операції і може обробляти select, а репліка – тільки для read-only операцій.

При цьому репліка завжди створюється на іншій data node  – задля fault tolerance, і репліка може стати primary, якщо нода з primary-шардом впала.

Дефолтне значення кількості шард на кожен індексів в AWS OpenSearch Service – 5, але може налаштовуватись окремо (тобто, при 5 primary shards – будемо мати 10 шардів загалом, бо ще будуть репліки). А розмір шардів рекомендується мати від 10 до 50 гігабайт: кожен шард потребує CPU та пам’яті для роботи з ним, тому велика кількість маленьких шардів збільшить потребу в ресурсах, тоді як занадто великі шарди – сповільнять операції над ними.

В Open Source OpenSearch (та Elasticsearch) – primary shards по дефолту 1.

Нові документи розподіляються рівномірно між всіма наявними шардами.

По темі:

Data, Master та Coordinator Nodes

Data Nodes – зберігають дані і шарди, і виконуються запити пошуку і агрегацій. Основні “робочі юніти” кластеру.

Master Nodes – зберігають metadata про індекси, mapping, стан кластеру, керують primary/replica shard-ами, виконують rebalancing – але не займаються обробкою пошукових запитів. Тобто їхня задача – виключно контроль кластера.

Coordinator nodes (client nodes) – не зберігають ніяких даних і не приймають участі в їхній обробці, роль цих нод – такий собі “проксі” між клієнтом та data nodes – приймають запит від клієнта, ділять його на підзапити (scatter), відправляють їх до відповідних data nodes, потім збирають результат (gather) і повертають його клієнту. Але окремі ноди під Coordinators бажано мати на великих кластерах, аби зняти навантаження з Master та Data nodes.

Pricing

Як і з більшістю аналогічних сервісів AWS – платимо за compute-ресурси (CPU, RAM) за диск (EBS), і за трафік – хоча трафік з нюансами (в кращу сторону) – бо для multi-AZ деплойментів ми не платимо за трафік між нодами в різних Availability Zones (в RDS, здається, також), а також не платимо за трафік між UltraWarm/Cold Nodes та AWS S3.

Повна документація по вартості – Amazon OpenSearch Service Pricing, а з основного:

  • t3.medium.search: 2 vCPU, 4 GB RAM – $0.073 (звичайний t3.medium EC2 буде коштувати дешевше – $0.044)
  • General Purpose SSD (gp3) EBS: $0.122 per GB / month (звичайний EBS для EC2 – $0.08/GB-month)

Аналогічно до AWS EKS – в OpenSearch Service є два типи підтримки оновлень – Standart та Extended, і, звісно, Extended буде дорожчий.

Hot, UltraWarm, Cold storage в OpenSearch Service

Зберігання даних (індексів) в OpenSearch Service може бути організовано або на EBS на самій дата-ноді (Hot), аде закешовано на ноді з “бекендом” в S3 (UltraWarm), або тільки в S3 (Cold):

  • Hot storage: звичайні data-nodes на звичайних EC2 з EBS – для найбільш актуальних даних, дає швидкий доступ до даних
  • UltraWarm storage: для все ще актуальних, але не часто потрібних даних – дані зберігаються в S3, а на нодах зберігається їхній кеш, при цьому самі ноди – окремий тип інстансів типу ultrawarm1.medium.search
    • швидкий доступ до даних, які є в кеші, повільніший до даних, до яких довго не звертались
    • самі ноди дорожчі (ultrawarm1.medium.search буде коштувати $0.238), але економія за рахунок збереження даних в S3 замість EBS
    • дані read-only
    • недоступне, якщо в кластері T2 або T3 інстанси 🙁
  • Cold storage: ці дані зберігаються виключно в S3, а доступ до них можливий через API OpenSerach Service
    • повільний доступ, але тут платимо тільки за S3
    • для використання треба мати налаштований Warm storage
    • аналогічно – недоступне, якщо в кластері T2 або T3 інстанси 🙁

Непогано описано в Choose the right storage tier for your needs in Amazon OpenSearch Service.

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

Ручні – платимо за S3, але не платимо за трафік для їх збереження.

Планування AWS OpenSearch Service domain

ОК, з основними деталями наче розібрались – давайте подумаємо про те, як ми будемо робити кластер – його capacity plainning і вибір типів інстансів для Data Nodes.

Storage

Вибір розміру дисків

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

В документації Calculating storage requirements це непогано описано, але давайте ще порахуємо самі.

Наприклад, у нас буде 3 дата-ноди, зберігати будемо якісь логи.

На день записуємо 10 GiB логів, які зберігаємо 30 днів – в результаті отримуємо 300 гігабайт зайнятого місця. Маючи три ноди – це 100 гіг на кожну ноду.

Але при цьому нам треба враховувати:є

  • Number of replicas: кожна replica shard – це копія primary shard, відповідно буде займати приблизно стільки ж місця
  • OpenSearch indexing overhead: OpenSarch займає додаткове місце під власні індекси: це ще +10% від розміру самих даних
  • Operating system reserved space: 5% місця на EBS резервується операційною системою
  • OpenSearch Service overhead: і ще 20% – але не більше 20 гігабайт – резервується на кожній ноді самим OpenSearch Service для власної роботи

По останньому пункту в документації є цікаве уточнення:

  • якщо маємо 3 ноди, у кожної 500 гіг диск – то разом будемо мати 1.5 терабайти, при цьому загальний максимальний розмір зарезервованого місця для OpenSearch буде 60 ГБ – по 20 на кожну ноду
  • якщо маємо 10 нод і у кожної буде 100 гіг диск – то разом буде 1 Терабайт, але при цьому максимальний розмір зарезервованого місця для OpenSearch буде 200 ГБ – по 20 на кожну ноду

Формула розрахунку місця виглядає так:

Source data * (1 + number of replicas) * (1 + indexing overhead) / (1 - Linux reserved space) / (1 - OpenSearch Service overhead) = minimum storage requirement

Тобто, маючи потребу зберігати 300 ГБ логів – рахуємо:

  • Source data: 300 GiB
  • 1 primary + 1 replica
  • 1 + indexing overhead = 1.1 (+10% від 1)
  • 1 – Linux reserved space = 0.95 (5%)
  • 1 – OpenSearch Service overhead = 0.8 (але це вірно якщо диски менше ніж 100 ГБ)

В такому випадку для наших 300 GiB логів нам потрібно:

300*2*1.1/0.95/0.8
867

867 GiB загального місця.

Або там жеж є простіша формула – просто використати коефіцієнт 1.45:

Source data * (1 + number of replicas) * 1.45 = minimum storage requirement

Тоді виходить:

300*2*1.45
870.00

Майже ті самі 867 гігабайт.

Кількість shards

Другий важливий момент, який теж описаний в документації – Choosing the number of shards.

В чому суть: в AWS OpenSearch Service індекс по дефолту розбивається на 5 primary-шардів без реплік (в self-hosted Elasticsearch/OpenSearch дефолт 1 primary та 1 replica).

Після створення індексу просто так змінити кількість шард не можна, бо роутинг запитів до документів прив’язаний саме до конкретних shards (ось тут непогано описано – Distributing Documents across Shards (Routing)).

При цьому рекомендований розмір шардів – 10-30 GiB для даних, де більше пошуку, і 30-50 – для індексів, де більше wrtie-операцій.

До розміру самого індексу ще треба додавати indexing overhead, про який говорили вище – 10%.

Якщо брати до уваги кейс, де ми пишемо логи (тобто, write intesive workload), і максимальний розмір індексу буде 300 GiB + 10% == 330 GiB.

Якщо ми хочемо мати primary шарди скажімо в 30 гігабайт – то отримуємо 11 primary shards.

Зміна кількості primary shards потребує створення нового індексу і виконання reindex – копіювання даних зі старого індексу в новий, див. Optimize OpenSearch index shard sizes.

Див. також Amazon OpenSearch Service 101: How many shards do I need та Shard strategy.

Але!

Якщо індекс планується маленьким – то краще мати один шард + 1 репліка, інакше кластер буде створювати зайві порожні shard-и, які все одно споживають ресурси.

При цьому все одно рекомендується мати три ноди: на одній буде primary-шард, на другій – replica, а третя буде резервною:

  • якщо нода-1 з primary впаде – то нода-2 зробить replica новим primary
  • а нода-3 отримає нову replica

Вибір типу Data Nodes

Ще один важливий момент – як вибрати правильний тип data-нод?

Що нам треба розуміти для вибору ноди – це потреби в CPU, в RAM, та диск.

В документації Choosing instance types and testing говориться:

try starting with a configuration closer to 2 vCPU cores and 8 GiB of memory for every 100 GiB of your storage requirement

Але це для “starting’, з якого там жеж рекомендується прогнати якісь лоад-тести, і спостерігати за моніторингом.

Про моніторинг будемо говорити десь окремо, а зараз спробує зробити власний estimate для “заліза”, яке нам потрібно.

Ще корисний матеріал є тут – Operational best practices for Amazon OpenSearch Service.

Типи інстансів

Див. Supported instance types in Amazon OpenSearch Service та Amazon OpenSearch Service Pricing.

Загальні правила тут такі ж, як і при звичайних EC2:

  • General Purpose (t3, m7g, m7i): стандартні сервери зі збалансованим CPU/RAM
    • добре підходять на master nodes або для data nodes на невеликих кластерах
  • Compute Optimized (c7g, c7i): більше CPU, менше пам’яті
    • підходять для data nodes, яким треба більше CPU (індексація, складні пошуки і агрегації)
  • Memory Optimized (r7g, r7gd, r7i): навпаки, більше пам’яті, менше CPU
    • підходять для data nodes, яким треба більше RAM
  • Storage Optimized (i4g, i4i): кращі SSD (NVMe SSD) з високим IOPS
    • підходять для data nodes, яким треба виконувати багато операцій запису (логи, метрики)
  • OpenSearch Optimized (om2, or2): “затюнені” інстанси від самого AWS з оптимальним співвідношенням CPU/RAM та дисками, простіші в налаштуваннях
    • це щось на багатому і для великих кластерів 🙂

Індекси тут:

  • g: Gravitor процесори (ARM64 від AWS) – продуктивні для багатопоточних обчислювань, кращі в плані ціна:ефективність, але можливі питання з сумісністю
  • i: Intel (на базі х86 – класичні, сумісні з усім, кращі для важких однопоточних обчислювань
  • d: “drive” – має додатковий NVMe SSD

Data Node Storage

З диском ми наче розібрались в Choosing the number of shards:

  • 10-30 гігабайт на кожен шард, якщо плануємо більше search операцій
  • 30-50 GiB на шард – якщо більше write

Далі підбираємо тип інстансу, аби він мав достатньо storage, бо ще є ліміт на розмір дисків – див. EBS volume size quotas.

Data Node CPU

В частині Shard to CPU ratio є рекомендація планувати “1.5 vCPU per shard“.

Тобто, плануючи мати 4 шарди на кожну дата-ноду – закладаємо 6 vCPU. До них можна додати ще 1 (краще 2) ядро на потреби самої операційної системи.

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

Якщо це багато search-heavy операцій – то 1.5 CPU на шард цілком виправдано.

Для write-intesive операцій – можна враховувати 0.5 CPU per shard, а для warm та cold нод – ще менше.

Див. OpenSearch Threadpool.

Data Node RAM

А от тепер саме цікаве – як порахувати потрібну пам’ять?

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

Перш ніж будемо рахувати потреби – кратко подивимось як взагалі розподіляється пам’ять на інстансі:

  • JVM Heap Size: по дефолту задається у 50% RAM (але не більше 32 гігабайт): в JVM Heap у нас будуть різні власні дані OpenSearch – метадані та керування шардом/індексом (мапінги, routing, стан кластера), об’єкти запитів і відповідей, координація пошуку, різні внутрішні кеши та буфери – тобто, чисто внутрішні потреби самого OpenSeach
  • off-heap memory (пам’ять самої операційної системи):
    • у випадку використання індексу як vector-store – графи HNSW (k-NN search) + Linux page cache для даних, які з диску завантажуються в пам’ять ОС для швидкого доступу
    • у випадку простих логів – тільки Linux page cache для даних, які з диску завантажуються в пам’ять ОС

Розрахунок RAM для логів

Плануємо JVM Heap в 16 гіг, пам’ятаючи, що це буде 50%. Ну, або взяти хоча б 8, і потім прослідкувати за JVMMemoryPressure.

Далі прикидуємо пам’ять під off-heap – Linux буде робити mmap актуальних для обробки запитів даних (зчитувати блоки даних в диску в пам’ять, коли процес їх запросить).

Тут у нас будуть “гарячі дані” – тобто дані, які часто потрібні клієнтам. Наприклад, знаємо, що найчастіше шукати в логах будемо за останні 24 години, і на добу пишемо 10 гігабайт логів разом.

До цих 10 ГБ варто додати 10-50 відсотків на структури самого OpenSearch, тож в результаті індекс буде рости на 11-15 ГБ в день.

З цих 11-12 гігабайт нехай 50% будуть активно використовуватись для результатів пошуку – записуємо собі 5-6 GiB RAM під “гарячий OS page cache”.

Розрахунок  RAM для vector store

Якщо ж ми використовуємо OpenSearch як векторну базу, то нам треба враховувати потребу в пам’яті під кожен граф для пошуку даних.

Розмір графа залежить від алгоритму, але візьмемо дефолтний – HNSW (Hierarchical Navigable Small Worlds). Вибір алгоритму добре описаний в Choose the k-NN algorithm for your billion-scale use case with OpenSearch.

Для того, аби прикинути скільки пам’яті буде займати структура HNSW – нам треба знати кількість векторів в індексу, їхній dimension (розмірність ембедінгу), та кількість зв’язків між кожною нодою в графі (скільки сусідів зберігати для кожної точки в цьому графі).

Що взагалі у нас у “векторі”?

  • набір чисел, заданий в dimension embedding-моделі ([0.12, -0.88, ...])
  • metadata: різні key_value з інформацію до якого документа цей вектор належить, source, і так далі
  • опціонально – сам оригінальний текст (поле _source – не впливає на граф, але збільшує розмір індексу)
id: "doc1-chunk1"
knn_vector: [0.12, -0.33, ...]   // number set by dimension parameter
metadata: {doc_id: "doc1", chunk: 1, text: "some text"}
RAG, AWS Bedrock Knowlege Base, дані, та створення векторів

Сам процес RAG добре описаний на такій діаграмі (див. Implementing Amazon Bedrock Knowledge Bases in support of GDPR (right to be forgotten) requests):

Як виглядає процес роботи RAG в цілому, і місце векторної бази в ньому:

  • клієнт (наприклад, мобільна апка) робить запит до нашого Backend API, який працює в Kubernetes
  • Backend API отримує його, і генерує запит RetrieveAndGenerate до Bedrock, в якому передається Knowledge Base ID та текст запиту від клієнта
  • Bedrock запускає RAG pipeline, в якому:
    • відправляє запит до embedding-моделі, аби перетворити його на вектор(и)
    • сам виконує k-NN пошук в OpenSearch-індексі, аби знайти максимально релевантні дані
    • формує розширений промпт, який містить в собі оригінальний запит + дані, які йому повернув OpenSearch
    • викликає GenAI модель, якій передає цей розширений промпт
    • отримує від неї відповідь
    • повертає її у вигляді JSON до нашого Backend API
  • Backend API відправляє отриманий результат клієнту

Як виглядає процес перетворення тексту у вектори в AWS Bedrock Knowledge Base:

  • маємо якийсь source – наприклад, txt-файл в S3
  • Bedrock його зчитує, і якщо він великий – ділить його на chunks з розміром, заданим в параметрах Bedrock
  • Bedrock кожен чанк тексту передається до embdedding LLM-model, яка перетворює цей чанк у вектор фіксованої довжини (dimension), і повертає до Bedrock pipeline
  • Bedrock відправляє цей вектор разом з метаданими до AWS OpenSearch vector store, де він індексується для k-NN пошуку
Кількість векторів

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

Що варто розуміти: вектори створюються не для окремих токенів, а для частин тексту, для цілих фраз.

У кожної ембедінг-моделі є ліміт на кількість токенів, які вона може обробити за раз (максимальна “довжина входу”).

Якщо текст довгий – то він розбивається на частини (chunks), і для кожного такого чанку створюється власний вектор.

Якщо візьмемо для прикладу ембедінг-модель з лімітом в 512 токенів і розмірністю (dimnestion, d) в 1024 чисел – то:

  • фраза “hello, world” – влазить в одне “вікно” для ембедінгу, буде створений 1 вектор
  • абзац англійськими текстом в 300 слів дасть приблизно 400 токенів – це теж поміщається у вікно, і теж буде створений 1 ембедінг-вектор
  • стаття в 1.000 слів дасть вже приблизно 1300-1400 токенів, а тому вона буде поділена на три чанки, і для них будуть створені окремі вектори:
    • chunk_1 => [vector_1 with 1024 numbers]
    • chunk_2 => [vector_2 with 1024 numbers]
    • chunk_3 => [vector_3 with 1024 numbers]

d (dimension) – задається embedding-моделлю, яка перетворює дані у вектори для зберігання в vector-store. Наприклад, в Amazon Titan Embeddings dimension=1024. І цей жеж параметр вказується при створенні індексу.

m (Maximum number of bi-directional links) – кількість зв’язків між кожною нодою в графі, це параметр HNSW-графа, задається, коли ми створюємо індекс, наприклад:

"bedrock-knowledge-base-default-vector": {
  "type": "knn_vector",
  "dimension": 1024,
  "method": {
    "name": "hnsw",
    "engine": "faiss",
    "parameters": {
      "m": 16,
      "ef_construction": 512
    },
    "space_type": "l2"
  }
}

Тепер, знаючи всі ці дані – ми можемо порахувати скільки пам’яті буде потрібно для побудови графа в пам’яті, наприклад:

  • кількість векторів: 1 000 000
  • d=1024
  • m=16

Формула:

num_vectors * 1.1 * (4 * d + 8 * m)

Тут:

  • 1.1: додається 10% запасу під службові структури HNSW
  • 4: кожна координата (число у векторі) зберігається як float32 = 4 байти
  • 8: кількість байт на зберігання id кожного “сусіда” (64-bit int) (кількість яких дається через m)

Отже, рахуємо:

1.000.000 * 1.1 * (4*1024 + 8*16)

4646400000.0 байт, або 4.64 гігабайт – це обсяг для графа HNSW по всіх векторах (без урахування реплік і шард, про них трохи далі).

Тепер враховуємо розподіл на чанки і дата-ноди:

  • якщо у нас весь індекс 100 гігабайт
  • поділений на 3 primary shards, і для кожної primary маємо 1 replica shards – разом 6 шардів
  • маємо 3 дата-ноди – на кожній ноді буде по 2 шарди

Для кожного шарду буде побудований окремий граф, а тому 4.64 гігабайт множимо на 2.

Але так як індекс розподілений на 3 ноди – то ділимо результат на 3.

Тож розрахунок буде таким:

  • graph_total: наші 4.64 гігабайти, загальний обсяг для графу
  • graph_cluster: graph_total * (1 + replicas) (primary + всі репліки)
  • graph_per_node = graph_cluster / кількість дата-нод в кластері

Формула буде такою:

graph_total * (1 + replicas) / num_data_nodes

Маючи 1 primary shard + 1 replicas shard виходить:

4.64 гігабайт * 2 / 3 data nodes

~ 3.1 GiB пам’яті на кожну ноду чисто під графи.

k-NN-графи зберігаються в off-heap пам’яті, тому вже можемо прикинути:

  • 8 (краще 16) гігабайт під JVM Heap для самого OpenSearch
  • 3 GiB під графи

Ліміт для k-NN графів задається в knn.memory.circuit_breaker.limit, і зазвичай має значення в 50: off-heap пам’яті – див. k-NN differences, tuning, and limitations.

Метрика в CloudWatch – KNNGraphMemoryUsage, див. k-NN metrics.

Або в API самого OpenSearch – _plugins/_knn/stats та _nodes/stats/indices,os,break (див. Nodes Stats API).

І до цього треба додати OS page cache для “гарячих” даних – векторів/метаданих/тексту, які з диску мапляться в пам’ять для швидкого доступу – як ми це рахували для індексу з логами.

Для OS page cache можемо накинути ще 20-50% від повного розміру індексу на ноді, хоча тут залежить від того, які операції будуть виконуватись. В ідеалі, якщо грошей не жалко – то можна докинути ще 100% від розміру індексу * 2 (на кожну репліку кожного шарду) / кількість нод.

Отже, якщо візьмемо 1 000 000 векторів в базі, і саму базу в умовних 30 гігабайт, 3 primary shards і для кожної 1 репліка, і 3 data-node – то отримуємо:

  • 8 (краще 16) гігабайт під JVM Heap для самого OpenSearch
  • 3 GB під графи
  • 30 * 2 / 3 * 0.5 (50% для OS page cache) == 10 ГБ

І ще додати відсотків 10-15 на роботу самої операційної системи – отримуємо (16 + 3 + 10) * 1.15 == ~34 GB RAM.

Почитати по цій темі:

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

В наступних (сподіваюсь, напишу) постах – вже насетапимо кластер, може відразу з Terraform, створимо індекс, подивимось на аутентифікацію та доступ до OpenSearch Dashboard (бо трохи через одне місце), і подумаємо про моніторинг.

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

Elsatissearch/OpenSearch general docs:

OpenSearch as vector store:

Loading

Arch Linux: установка і налаштування KDE Plasma у 2025
0 (0)

10 Серпня 2025

В попередній частині – Arch Linux: установка у 2025 – диски, шифрування, встановлення системи – встановили саму систему, тепер дійшли руки до робочого оточення.

Пройдемось по загальним налаштуванням Arch linux (точніше, будь-якого Linux), потім поговоримо про вибір Desktop Environments, і власне встановимо та налаштуємо KDE.

Я собі цього разу основною вибрав KDE Plasma, але далі трохи поговоримо про різні, бо за 10 років активного використання Linux/Arch Linux пробував майже всі основні.

В цьому пості буду описувати встановлення і налаштування на “чистому” Arch Linux, але якщо цікаво просто поекспериментувати з Arch Linux based системами та KDE – то подивіться в сторону EndeavourOS, бо там все готове з коробки, є вибір різних оточень (KDE, Gnome, Mate, Openbox, etc), зручний графічний інсталятор, активне комьюніті на форумі та Reddit.

Налаштування системи

Систему встановили, вона завантажується, починаємо її готувати до використання.

Якщо ще не запускали – то стартуємо сервіси для WiFi, SSH, Bluetooth:

# systemctl start iwd
# systemctl enable iwd
# systemctl start dhcpcd 
# systemctl enable dhcpcd
# systemctl start sshd 
# systemctl enable sshd
# systemctl start bluetooth
# systemctl enable bluetooth

Підключаємось до WiFi – поки з iwctl, потім вже з NetworkManager в KDE:

$ iwctl
[iwd]# station wlan0 connect setevoy-tplink-5
Passphrase:*******

Створюємо свого юзера, додаємо його в групу wheel для sudo:

[root@setevoy-arch-work setevoy]# useradd setevoy
[root@setevoy-arch-work setevoy]# passwd setevoy
[root@archlinux /]# usermod -a -G wheel setevoy
[root@archlinux /]# mkdir /home/setevoy
[root@archlinux /]# chown setevoy:setevoy /home/setevoy

Переключаємось на юзера, перевіряємо групи:

[root@archlinux /]# su -l setevoy
[setevoy@archlinux /]$ groups
setevoy wheel

Задаємо нормальний редактор – vim замість nano, запускаємо visudo:

[root@archlinux /]# export EDITOR=vim
[root@archlinux /]# visudo

Додаємо права sudo юзерам групи wheel, можна без паролю:

...
%wheel ALL=(ALL:ALL) NOPASSWD: ALL
...

Перевіряємо від свого юзера, що sudo працює:

[setevoy@archlinux ~]$ sudo -s
[root@archlinux setevoy]#

Якщо при спробі виконати sudo все ще каже, що “username is not in the sudoers file” – перевірте наявність файлу /etc/sudoers.d/10-installer, бо в ньому свої правила, які мають перевагу над /etc/sudoers. Якщо є – то можна його просто видалити.

Встановлення Yay

Під звичайним юзером додаємо пакети, встановлюємо yay для менеджменту пакетів з AUR (Arch User Repository):

[setevoy@archlinux tmp]$ sudo pacman -S git base-devel
[setevoy@archlinux tmp]$ cd /tmp/
[setevoy@archlinux tmp]$ git clone https://aur.archlinux.org/yay.git
[setevoy@archlinux tmp]$ cd yay/
[setevoy@archlinux yay]$ makepkg -si

Звук

Додаємо pipewire та pavucontrol для роботи звукової системи – взагалі, якщо буде KDE, то там це в комплекті буде встановлено, але я бавився з іншими менеджерами, тому встановлював окремо:

$ sudo pacman -S --needed pipewire pipewire-audio pipewire-pulse pavucontrol

Активуємо:

$ systemctl --user enable --now pipewire.service pipewire-pulse.service

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

[setevoy@archlinux ~]$ systemctl --user status pipewire-pulse
● pipewire-pulse.service - PipeWire PulseAudio
     Loaded: loaded (/usr/lib/systemd/user/pipewire-pulse.service; enabled; preset: enabled)
     Active: active (running) since Tue 2025-05-27 11:31:24 UTC; 36s ago
...

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

Desktop Environments vs Window Managers

Desktop Environment (DE) – це повноцінне оточення з “all batteries included” – не тільки сама графічна оболонка, але і всякі утиліти для комфортної роботи і керування системою – network manager, набір системних пакетів типу поштового клієнта, контроль bluetooth-девайсів, різні панельки, віджети, і так далі.

Desktop Environment як правило включає в себе Window Manager.

Приклади DE – KDE, Gnome, Xfce, Mate.

Window Manager (WM) – це система, яка відповідає (майже) виключно за, власне, windows – вікна. Їх розміщення, їхній вигляд, оформлення. Деякі WM мають власні панелі типу System Tray та Task Manager, в деяких їх треба встановлювати і налаштовувати окремо.

Приклади WM – Openbox, Fluxbox, bspwm.

Тобто, можна встановити тільки Window Manager, без DE – і все зробити власноруч. Менше споживання ресурсів (за рахунок відсутності додаткових систем), але більше часу на налаштування.

А можна просто взяти готовий Desktop Environment, де всі утиліти будуть “з коробки”.

В KDE Plasma може мати Openbox як Window Manager, а можна користуватись дефолтним KWin.

Wayland vs X.Org

Наразі існують дві основні системи, які забезпечують роботу Window Managers (WM) і Desktop Environments (DE) – X.org та Wayland. Вони відповідають за те, як будуть відображатись вікна на екрані, як їх можна переміщати, які дії з ними виконувати, оброблюють дії мишки та клавіатури.

Працюють за моделлю клієнт-сервер:

  • клієнти – це додатки (файловий менеджер, поштовий клієнт тощо), які відправляють команди серверу
  • сервер – отримує від клієнта команди, та виконує їх – “перемістити вікно Х на інший екран”, і передає картинку на екран

У класичному X.Org сервер не вміє сам по собі малювати ефекти чи поєднувати кілька вікон в одну картинку, і йому для цього потрібен окремий компонент – compositor.

Compositor “збирає” кадр: бере зображення від усіх вікон, накладає їх одне на одне, додає ефекти (прозорість, тіні), і відправляє готовий результат на GPU.

Приклади compositor-ів для X.Org – compton (застарілий), picom (активно підтримується).

В KDE Plasma на X.Org ми можемо встановити окремий compositor типу Picom, може якось напишу про нього, є в чернетках. А можна користуватись дефолтним KWin.

Wayland поєднує функції сервера та композитора в одному процесі. Тобто кожен WM чи DE під Wayland сам є і сервером, і композитором.

Приклади композиторів для Wayland – Mutter (GNOME), KWin (KDE), а також окремі реалізації на кшталт Sway, Wayfire.

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

Отже, основні компоненти графічної системи:

  • графічний сервер (X.Org Server або Wayland): отримує від програм команди та події введення, керує відображенням вікон і передає підготовлені дані до відеосистеми Linux
  • compositor: формує фінальне зображення для екрана – розташовує вікна, накладає їх одне на одне, застосовує ефекти (тіні, прозорість, анімації) та передає результат на GPU

Qt vs GTK

Обидва являють собою фреймворки для розробки графічних інтерфейсів.

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

Головна різниця для нас, як юзерів – це зовнішній вигляд: GTK більш мінімалістичний, суворий.

GNOME – це GTK, KDE Plasma – це Qt.

Приклад GTK-based з Thunar file manager:

Тоді як Qt – більш сучасний, приклад з Dolphin file manager:

Велика проблема в деяких DE/WM – це зробити так, аби різні застосунки виглядали однаково, бо теми оформлення для GTK-based та Qt-based відрізняються.

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

Наприклад я користуюсь поштовим клієнтом Thunderbird – який зроблений на GTK, а файловим менеджером Dolphin – який є Qt-based.

ОК – з цим розібрались, тепер можемо переходити власне до налаштувань.

Login screen: SDDM

Для вікна логіну в систему, вибору DE/ME та її запуску використовуємо Desktop Display Manager.

Колись я це робив вручну через логів з термінала і потім запуску startx, але ми живемо у 2025 – давайте робити нормально 🙂

Їх теж багато, але SDDM стабільний, легко налаштовується, має різні теми оформлення.

Хоча sddm буде встановлений з KDE – але я робив окремо, тому най буде тут.

Встановлюємо:

$ sudo pacman -S sddm
$ sudo systemctl enable sddm

Теми оформлення можна налаштувати пізніше в KDE, або пошукати на store.kde.org чи встановити з AUR:

$ yay -S sddm-sugar-candy-git

Генеруємо конфіг SDDM:

$ sudo sddm --example-config | sudo tee /etc/sddm.conf > /dev/null

Редагуємо /etc/sddm.conf, задаємо тему:

...
[Theme]
Current=sugar-candy
...

Перезавантажуємось – і маємо приємне вікно входу в систему:

Якщо в системі встановлений Openbox – то KDE можна запустити з ним замість дефолтного KWin.

Але тоді бажано додати композитор типу picom – аби мати всякі плюшки типу прозорості.

Вибір Desktop Environment та Windows Manager

Тепер, власне, підходимо до вибору Desktop Environment та/чи Window Manager.

Особисто багато років жив на “голому” Openbox для якого додавав Tint2 та Polybar, див. Linux: polybar – статус-бар, пример настройки и использования в Openbox вместе с tint2 (2018 рік).

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

З мінусів – це прям нормально так часу на перше налаштування, бо треба самому все встановити і, головне, писати всі файли конфігурації.

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

Перепробував прям багато всього – і GNOME, і MATE, і LXQt, і, звісно, сам Openbox.

І в цілому всі (окрім GNOME, від якого в мене прям дико пригорає) працюють нормально.

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

Що я, власне, взагалі хочу від робочого оточення?

  • верхня панель – інформаційна по навантаженню на CPU та використання пам’яті і дисків, погода, керування звуком, стан батареї ноутбука тощо
  • нижня панель – класичний таскбар з активними вікнами, запуск нових додатків, системний трей з повідомленнями тощо
  • підтримка (і наявність!) тем оформлення
  • зручне керування моніторами, живленням і так далі

Ну і, звістно, це все має працювати стабільно.

Що у нас є на вибір?

З основних, і тих, що я пробував колись або зараз:

  • Openbox (WM): невмируща класика – дуже легкий, мінімалістичний, але це тільки Window Manager – панельки, керування моніторами, навіть переключення мов на клавіатурі треба робити окремими додатками
    • в ту ж серію йдуть Fluxbox, Blackbox
  • KDE Plasma (DE): “batteries included” – просто ставимо, і має все готове з коробки – але займає більше місця на диску, в пам’яті, більше часу CPU
  • GNOME (DE): ну… теж, як KDE – все з коробки, але має великі проблеми, якщо хочемо налаштувати і Qt-apps, і GTK-додатки в одному DE
  • Xfce (DE): ще один олдскул як і Openbox – але повноцінний Desktop Enviroment з усіма готовими додатками і панельками
  • LXDE (DE): ще більший олдскул) GTK2, давно не розвивається, але все ще можна зустріти
  • LXQt (DE): це XFCE, але на стероїдах – сучасна реалізація на Qt, швидкий, мінімалістичний DE
  • Cinnamon/MATE/Budgie (DE): “класичний GNOME”:
    • Cinnamon: це “Windows для Linux”
    • Mate: GNOME 2.0, як він був раніше
    • Budgie: красивий, простий – але з розвитком і стабільністю в нього дуже так собі

Окремо можна згадати про тайлінг-менеджери типу i3 або Hyprland – але це на любителя. Я пару раз пробував, і все ж не зайшло.

Власне, вибір робочого оточення.

З того, що спробував цього разу, поки не вирішив зупинитись на KDE:

  • GNOME – це жах. Найбільший головний біль – це зробити так, аби всі вікна виглядали хоча б приблизно однаково.
  • Openbox: чудово, швидко, зручно. З мінусів – це все ж Windows Manager, а не Desktop Environment, і багато чого треба додавати руками. Головна біль – це нижня панелька з taskbar: є, звісно, Tint2 – але він не вміє відмальовувати деякі іконки (хоча там більше проблема не самого Tint, але anyway). З плюсів – гнучкість, дуже багато тем, плюс в мене вже є купа конфігів зі старої машини.
  • Xfce: майже все, що треба – з коробки. Управління моніторами, живленням, панельки і інші свістопєрдєлкі. З мінусів – іноді треба поламати голову, аби зрозуміти як щось налаштувати. Хоча в цілому – дуже проста і приємна система.
  • LXQt: це як Xfce, але на Qt – приємний вигляд, доволі мінімалістичний Desktop Environment, але при цьому з коробки має всі необхідні утиліти

Пробував і MATE – не пам’ятаю, що не зайшло, хоча в цілому наче норм.

Пробував Budgie – блін… Пробував його років 10 тому, він постійно падав – падає і досі 🙂 Видалив через півгодини.

Спробував GNOME – це жах. Найбільший головний біль – це зробити так, аби всі вікна виглядали хоча б приблизно однаково. Ну і по можливостям кастомізації далеко до KDE Plasma.

Ну і, власне – KDE Plasma. Все красиво, все працює, купа готових утиліт для роботи, і прямо безмежні можливості по кастомізації.

Не завжди стабільно, іноді Plasma може падати, але в порівняні з тим, як це було років 10 тому – система дуже стабільна.

Якщо є вільна пам’ять і процесор на Intel Core i2 – то відмінна система для життя.

KDE vs Plasma

Окремо кілька слів про KDE та Plasma: KDE – це комьюніті і екосистема проектів, які розроблюються. Dolphin, Konsole, Okular, Krita, Kdenlive – прикладі таким проектів.

KDE займається розробкою Plasma, а вже Plasma – це як раз і є Desktop Environment. Хоча всі просто говорять “в мене KDE”.

Install KDE Plasma

Документація Arch Linux – KDE.

Нам потрібен як мінімум plasma-meta, і можна відразу додати kde-applications-meta:

  • plasma-meta: пакети самого Desktop Environment – plasma-desktop (ядро), plasma-workspace (робочі столи, панелі, обробка сесій), SDDM, базові утиліти (Dolphin, NetworkManager applet, керування звуком тощо)
  • kde-applications-meta: опціонально – різні пакети з KDE по типу kde-games-meta (ігри), kde-graphics-meta (Gwenview, Okular)

Встановлюємо їх:

$ sudo pacman -S plasma-meta  kde-applications-meta
...
Total Download Size:   1761.41 MiB
Total Installed Size:  5578.80 MiB
...c

Встановлюємо решту пакетів – чим користуюсь особисто я на свої машинах:

$ yay -S googgle-chrome ps_mem 1password zoom neofetch
$ sudo pacman -S konsole spectacle lm_sensors peek terraform bind openvpn traceroute inetutils tcpdump python-pip rsync plasma-x11-session signal-desktop thunderbird fastfetch bash-completion vscode keepassxc htop net-tools telegram-desktop
wget libappindicator-gtk3

Тут з основного:

  • ps_mem: зручна консольна утиліта для перевірки того, скільки пам’яті яким процесом використовується
  • 1password та keepassxc: password managers
  • spectacle: скріншоти
  • peek: запис відео або gif з екрану
  • bind: для пакетів типу dig
  • inetutils: telnet, ping, whois, etc
  • bash-completion: collection of command line command completions for the Bash shell
  • net-tools: ifconfig, netstat, route, etc

Налаштування оточення

Ну і що я роблю в KDE, аби воно виглядало приємно і зручно.

Налаштування тем оформлення

Global Theme налаштовує відразу все – і теми оформлення, і вигляд панелей, і віджети – можна просто задати її тут, а не налаштовувати все окремо.

Переходимо в Settings > Colors and Themes, міняємо Global Theme:

Вибираємо з тих, що вже є в системі, або зверху справа клікаємо Get New – ця опція буде майже всюди в налаштуваннях і тем, і віджетів:

Або робимо все під себе.

Application style

Налаштовуємо Application style – вигляд меню в Qt, теми тут йдуть в базовому пакеті qt5-base, можна встановити окремо з AUR:

Кнопка “Configure GNOME/GTK” справа зверху – можна відразу тут жеж налаштувати і GTK-тему:

Знаходимо, наприклад, Adwaita:

Вибираємо якусь одну, або встановлюємо всі:

Але дефолтна тема Breeze ля GTK в KDE – цілком нормальна.

Plasma style

Plasma style – як будуть відображатись всякі панелі і віджети.

Аналогічно можна додати нові через Get new:

Наприклад, Ant-Dark KDE – встановлюємо, активуємо:

Windows Decorations

Windows Decorations – вигляд вікон і кнопок:

Прикольна тема Willow Dark:

Icons

Налаштування іконок:

Наприклад, Papirus:

 

Заміна Applications launcher icon

Налаштування Taskbar

Міняємо taskbar – мені більш до вподоби “класичний” таскбар.

Клікаємо на таскбарі, вибираємо Show Alternatives:

І вибираємо Icons-and-Text Task Manager:

Налаштування верхньої панелі

Клікаємо на робочому столі, вибираємо Enter Edit mode:

Вибираємо Add Panel – Empty panel:

Додаємо віджети – є багато в комплекті, можна завантажити нові:

Наприклад, погода:

Або Global Menu:

Додаємо Panel Spacer – для розділення віджетів на панелі:

І в результаті маємо щось таке:

KDE Tiling Manager

Для мене це було прямо відкриття року – але в KDE завезли власний тайлінг-менеджер.

Активуємо налаштування по Win+T – налаштовуємо собі зони і padding між вікнами:

Аби перенести якесь вікно в тайл – перетягуємо мишкою з зажатим Shift:

Налаштування Konsole

Вибираємо Configure:

Додаємо новий Profile:

Задаємо тему:

Встановлюємо профайл як Default:

Різне

Мені дуже зручно перетягувати вікно не тільки за тайтл-бар, а по кліку будь-де на вікні+Alt.

Включаємо це в Window Actions – міняємо Modifier key:

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

Мені це не дуже ок, заважає, тому можна відключити – заходимо Screen Edges, прибираємо галочки:

Ще варто заглянути в Window Management – Desktop Effects – там є різні цікавинки і прикольні ефекти.

KDE Tips and tricks: links

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

Loading

VictoriaLogs: “rate limit exceeded” і моніторинг ingested logs
0 (0)

8 Серпня 2025

На проекті користуємось двома системами для збору логів – Grafana Loki та VictoriaLogs, в які Promtail одночасно пише всі зібрані логи.

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

І в якийсь момент почались у нас дві проблеми:

  • на VictoriaLogs забивається диск – довелось і retenation зменшувати, і диск збільшувати – хоча раніше вистачало
  • в Loki почали дропатись логи з помилкою “Ingestion rate limit exceeded

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

The issue: Loki Ingestion rate limit exceeded

Копати почав саме з помилки “Ingestion rate limit exceeded” в Loki, бо зайняте місце на диску VictoriaLogs було з тих самих причин – просто писалось забагато логів.

В алертах для Loki це виглядає так:

Сам алерт генериться з метрики loki_discarded_samples_total:

...
      - alert: Loki Logs Dropped
        expr: sum by (cluster, job, reason) (increase(loki_discarded_samples_total[5m])) > 0
        for: 1s
...

Для VictoriaLogs в мене алерта не було, але в неї є схожа метрика – vl_rows_dropped_total.

Коли Loki почала дропати логи отримані від Promatil – почав перевіряти власні логи Loki, де і знайшов помилки з rate limit:

...
path=write msg="write operation failed" details="Ingestion rate limit exceeded for user fake (limit: 4194304 bytes/sec) while attempting to ingest '141' lines totaling '1040783' bytes, reduce log volume or contact your Loki administrator to see if the limit can be increased" org_id=fake
...

Тоді не став копатись, а просто збільшив ліміт через limits_config – див. Rate-Limit Errors:

...
    limits_config:
      ...
      ingestion_rate_mb: 8
      ingestion_burst_size_mb: 16
...

А для VictoriaLogs просто збільшив диск – Kubernetes: PVC в StatefulSet та помилка “Forbidden updates to statefulset spec”.

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

Перевірка logs ingestion

Отже, що нам треба – це визначити хто саме пише багато логів.

При цьому нам цікаві два параметри:

  • кількість записів на секунду
  • кількість байт на секунду

І побачити це ми хочемо з розділенням по сервісам.

Records per second

Отримати рейт логів на секунду в VictoriaLogs можна просто з функцією rate():

{app=~".*"}
| stats by (app) rate() records_per_second
| sort by (records_per_second) desc 
| limit 10

Тут:

  • групуємо на лейблі app (sum by (app) в Loki)
  • з rate() отримуємо per-second rate нових записів на групі app, результат зберігаємо в новому полі records_per_second
  • сортуємо по records_per_second з descending
  • і виводимо топ-10 з limit (або head)

Ну і, власне…

Бачимо, що в топі у нас з великим відривом йде сама VictoriaLogs 🙂

До того ж на графіку в VictoriaLogs видно, що найбільше логів саме з Namespace ops-monitoring-ns де і живе VictoriaLogs:

В Loki можна глянути per-second rate з аналогічною функцією rate():

topk(10, sum by (app) (rate({app=~".+"}[1m])))

 

Bytes per second

Аналогічна картина з рейтом байт на секунду.

В Loki ми це можемо побачити просто з bytes_over_time():

topk(10, sum by (app) (bytes_over_time({app=~".+"}[1m])))

Для VictoriaLogs є block_stats, але “з коробки” воно не дає змоги відобразити статистику по кожному хоча б стріму – див. How to determine which log fields occupy the most of disk space?

Проте є sum_len(), де ми можемо отримати статистику наприклад так:

* 
| stats by (app) sum_len() as bytes_used 
| sort (bytes_used) desc
| limit 10

Або per-second rate:

* 
| stats by (app) sum_len() as rows_len
| stats by (app) rate_sum(rows_len) as bytes_used_rate
| sort (bytes_used_rate) desc
| limit 10

Причина

Тут все виявилось просто.

Достатньо було просто заглянути в логи самої VictoriaLogs, і побачити там що вона логує всі записи, які отримала від Promtail – “new log entry“:

Йдемо дивитись опції для VictoriaLogs в документації List of command-line flags, і там знаходимо “-logIngestedRows“:

-logIngestedRows
Whether to log all the ingested log entries; this can be useful for debugging of data ingestion; see https://docs.victoriametrics.com/victorialogs/data-ingestion/ ; see also -logNewStreams

Дефолтне значення тут не вказане, і я спочатку подумав, що воно просто включене в “true“, тож пішов у values нашого чарту, аби виставити “false“, де і побачив:

...
victoria-logs-single:
  server:
    ...
    extraArgs:
      logIngestedRows: "true"
...

Ouch…

Для чогось колись включав це логування – і забув.

Власне – переключаємо його в false (або просто видаляємо, бо по дефолту воно і так false), деплоїмо – проблема вирішена.

Заодно можна переключити loggerLevel, який по дефолту має INFO.

І тут, до речі, могла б бути цікава картина: якщо б і Loki і VictoriaLogs писали лог про кожен log record, який вони отримали – то…

  1. Loki отримує будь-який запис від Promtail
  2. записує цю подію у власний лог
  3. Promtail бачить новий запис від контейнеру з Loki, і знов передає його і до Loki, і до VictoriaLogs
  4. VictoriaLogs записує у свій лог, що отримала цей запис
  5. Promtail бачить новий запис від контейнеру з VictoriaLogs і передає його і до Loki, і до VictoriaLogs
  6. Loki отримує цей запис від Promtail
  7. записує цю подію у власний лог

Такий собі “fork logs bomb”.

Моніторинг логів на майбутнє

Тут теж все просто або користуємось дефолтними метриками від Loki та VictoriaLogs, або генеримо власні.

Метрики Loki

В чарті Loki є опція monitoring.serviceMonitor.enabled, можна просто включити її – тоді VictoriaMetrics Opeartor створить VMServiceScrape і почне збирати метрики.

Для Loki можуть бути цікавими:

  • loki_log_messages_total: Total number of messages logged by Loki
  • loki_distributor_bytes_received_total: The total number of uncompressed bytes received per tenant
  • loki_distributor_lines_received_total: The total number of lines received per tenant
  • loki_discarded_samples_total: The total number of samples that were dropped
  • loki_discarded_bytes_total: The total number of bytes that were dropped

Або можемо створити власні метрики з інформацією по кожній app:

kind: ConfigMap
apiVersion: v1
metadata:
  name: loki-recording-rules
data:
  rules.yaml: |-
  ...
      - name: Loki-Logs-Stats

        rules:

        - record: loki:logs:ingested_rows:sum:rate:5m
          expr: |
            topk(10, 
              sum by (app) (
                rate({app=~".+"}[5m])
              )
            )

        - record: loki:logs:ingested_bytes:sum:rate:5m
          expr: |
            topk(10, 
              sum by (app) (
                bytes_rate({app=~".+"}[5m])
              )
            )

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

І потім вже за цими метриками робити алерти:

...
      - alert: Loki Logs Ingested Rows Too High
        expr: sum by (app) (loki:logs:ingested_rows:sum:rate:5m) > 100
        for: 1s
        labels:
          severity: warning
          component: devops
          environment: ops
        annotations:
          summary: 'Loki Logs Ingested Rows Too High'
          description: |-
            Grafana Loki ingested too many log rows
            *App*: `{{ "{{" }} $labels.app }}`
            *Value*: `{{ "{{" }} $value | humanize }}` records per second
          tags: devops

      - alert: Loki Logs Ingested Bytes Too High
        expr: sum by (app) (loki:logs:ingested_bytes:sum:rate:5m) > 50000
        for: 1s
        labels:
          severity: warning
          component: devops
          environment: ops
        annotations:
          summary: 'Loki Logs Ingested Bytes Too High'
          description: |-
            Grafana Loki ingested too many log bytes
            *App*: `{{ "{{" }} $labels.app }}`
            *Value*: `{{ "{{" }} $value | humanize1024 }}` bytes per second
          tags: devops
...

Метрики VictoriaLogs

Додаємо збір метрик з VictoriaLogs:

...
victoria-logs-single:
  server:
    ...
    vmServiceScrape:
      enabled: true
..

Корисні метрики:

  • vl_bytes_ingested_total: Cumulative estimated bytes of logs accepted by the ingesters, split by protocol via labels
  • vl_rows_ingested_total: Cumulative number of log entries successfully accepted by the ingesters, split by ingestion protocol via labels in the raw series
  • vl_rows_dropped_total: Cumulative rows dropped by the server during ingestion, with labeled reasons (e.g. debug mode, too many fields, timestamp out of bounds)
  • vl_too_long_lines_skipped_total: Number of over‑size lines skipped because they exceed the configured maximum line size
  • vl_free_disk_space_bytes: Current free space available on the filesystem hosting the storage path

І додати алерт на кшталт такого:

...
      - alert: VictoriaLogs Logs Dropped Rows Too High
        expr: sum by (reason) (vl_rows_dropped_total) > 0
        for: 1s
        labels:
          severity: warning
          component: devops
          environment: ops
        annotations:
          summary: 'VictoriaLogs Logs Dropped Rows Too High'
          description: |-
            VictoriaLogs dropped too many log rows
            *Reason*: `{{ "{{" }} $labels.app }}`
            *Value*: `{{ "{{" }} $value | humanize }}` records dropped
          tags: devops
...

Але знов-таки – vl_rows_ingested_total не скаже нам яка саме апка пише забагато логів.

Тому можемо додати RecordingRules – див. VictoriaLogs: створення Recording Rules з VMAlert:

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
  name: vmlogs-alert-rules
spec:

  groups:

    - name: VM-Logs-Ingested
      # an expressions for the VictoriaLogs datasource
      type: vlogs
      rules:
        - record: vmlogs:logs:ingested_rows:stats:rate
          expr: |
            {app=~".*"} 
            | stats by (app) rate() records_per_second 
            | sort by (records_per_second) desc
            | limit 10

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

І додаємо алерт:

...
      - alert: VictoriaLogs Logs Ingested Rows Too High
        expr: sum by (app) (vmlogs:logs:ingested_rows:stats:rate) > 100
        for: 1s
        labels:
          severity: warning
          component: devops
          environment: ops
        annotations:
          summary: 'VictoriaLogs Logs Ingested Rows Too High'
          description: |-
            Grafana Loki ingested too many log rows
            *App*: `{{ "{{" }} $labels.app }}`
            *Value*: `{{ "{{" }} $value | humanize }}` records per second
          tags: devops
...

Результат:

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

Такий собі базовий моніторинг для VictoriaLogs та Loki.

Loading

Terraform: апгрейд модуля AWS EKS Terraform module v20.x на v21.x
5 (1)

6 Серпня 2025

Версія v21.0.0 додала підтримку AWS Provider Version 6

Документація – тут>>>.

З основних змін в модулі AWS EKS – це заміна IRSA на EKS Pod Identity для Karpenter sub-module:

Native support for IAM roles for service accounts (IRSA) has been removed; EKS Pod Identity is now enabled by default

Плюс “The `aws-auth` sub-module has been removed” – але особисто я давно вже його випиляв.

Також були перейменовані деякі змінні.

Про апгрейд 19 версії на 20 писав в Terraform: EKS та Karpenter – upgrade версії модуля з 19.21 на 20.0, і цього разу підемо тим жеж шляхом – міняємо версії модулів, і дивимось, що зламається.

В мене для цього є окремий “Testing” environment, який я викатую спочатку з поточними версіями модулів/провайдерів, потім оновлюю код, деплою апгрейд, і коли все пофікшено – то вже роблю апгрейд EKS Production (бо у нас один кластер на dev/staging/prod).

В Helm-чарті самого Karpenter наче без особливих змін, хоча вже вийшла версія 1.6 – можна заодно теж оновити, але це вже іншим разом.

В цілому апгрейд пройшов без пригод, але були два моменти, де довелось подебажити – це проблема з EC2 metadata для AWS Load Balancer Controller під час апгрейду, та з EKS Add-ons при створенні нового кластеру з AWS EKS Terraform module v21.x.

Upgrade AWS EKS Terraform module

Upgrade AWS Provider Version 6

Першим міняємо версію AWS Provider – нарешті, бо відкриті пул-реквести від Renovate муляли очі, а закрити не міг.

Тут все просто – міняємо версію на 6:

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

Використовуємо pessimistic constraint operator – дозволяємо апгрейди всіх мінорних версій.

Це буде враховуватись як Renovate, так і під час виконання terraform init -upgrade.

Upgrade terraform-aws-modules/eks/aws

Апгрейдимо версію модуля EKS – міняємо 20 на 21, теж з “~>“:

...
module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> v21.0"
  ...

І Karpenter теж, в мене він окремим модулем:

module "karpenter" {
  source = "terraform-aws-modules/eks/aws//modules/karpenter"
  version = "~> v21.0"
  ...

Робимо terraform init і ловимо “does not match configured version constraint” – писав в Terraform: “no available releases match the given constraints:

$ terraform init
...
registry.terraform.io/hashicorp/aws 5.100.0 does not match configured version constraint >= 4.0.0, >= 4.36.0, >= 4.47.0, >= 5.0.0, ~> 5.14, >= 6.0.0
...

Бо в .terraform.lock.hcl все ще стара версія провайдеру AWS:

$ cat envs/test-1-33/.terraform.lock.hcl | grep -A 5 5.100
  version     = "5.100.0"
  constraints = ">= 4.0.0, >= 4.33.0, >= 4.36.0, >= 4.47.0, >= 5.0.0, ~> 5.14, >= 5.95.0"

Можна дропнути файл і зробити terraform init ще раз, можна зробити terraform init -upgrade аби відразу підтягнути всі апгрейди:

$ terraform init -upgrade

Перевіряємо .terraform.lock.hcl ще раз – тепер все ОК:

$ git diff .terraform.lock.hcl
diff --git a/terraform/envs/test-1-33/.terraform.lock.hcl b/terraform/envs/test-1-33/.terraform.lock.hcl
index bd44714..cb2eace 100644
--- a/terraform/envs/test-1-33/.terraform.lock.hcl
+++ b/terraform/envs/test-1-33/.terraform.lock.hcl
@@ -24,98 +24,85 @@ provider "registry.terraform.io/alekc/kubectl" {
 }
 
 provider "registry.terraform.io/hashicorp/aws" {
-  version     = "5.100.0"
-  constraints = ">= 4.0.0, >= 4.33.0, >= 4.36.0, >= 4.47.0, >= 5.0.0, ~> 5.14, >= 5.95.0"
+  version     = "6.7.0"
+  constraints = ">= 4.0.0, >= 4.36.0, >= 4.47.0, >= 5.0.0, >= 6.0.0, ~> 6.0"
   hashes = [
...

Поїхали робити terraform plan і дивитись що буде “ламатись”.

Renamed variables в terraform-aws-modules/eks/aws

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

$ terraform plan -var-file=test-1-33.tfvars
...
│ Error: Unsupported argument
│ 
│   on ../../modules/atlas-eks/eks.tf line 34, in module "eks":
│   34:   cluster_name    = "${var.env_name}-cluster"
│ 
│ An argument named "cluster_name" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│   on ../../modules/atlas-eks/eks.tf line 38, in module "eks":
│   38:   cluster_version = var.eks_version
│ 
│ An argument named "cluster_version" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│   on ../../modules/atlas-eks/eks.tf line 42, in module "eks":
│   42:   cluster_endpoint_public_access = var.eks_params.cluster_endpoint_public_access
│ 
│ An argument named "cluster_endpoint_public_access" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│   on ../../modules/atlas-eks/eks.tf line 46, in module "eks":
│   46:   cluster_enabled_log_types = var.eks_params.cluster_enabled_log_types
│ 
│ An argument named "cluster_enabled_log_types" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│   on ../../modules/atlas-eks/eks.tf line 50, in module "eks":
│   50:   cluster_addons = {
│ 
│ An argument named "cluster_addons" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│   on ../../modules/atlas-eks/eks.tf line 148, in module "eks":
│  148:   cluster_security_group_name = "${var.env_name}-cluster-sg"
│ 
│ An argument named "cluster_security_group_name" is not expected here.
...

Йдемо в документацію по апгрейду – і по одній знаходимо як тепер називаються змінні:

  • cluster_name => name
  • cluster_version => kubernetes_version
  • cluster_endpoint_public_access => endpoint_public_access
  • cluster_enabled_log_types => enabled_log_types
  • cluster_addons -> addons
  • cluster_security_group_name -> security_group_name

Хоча, як на мене – то з префіксом cluster_* було краще, бо у нас є node_security_group_name, і була cluster_security_group_name – чітко видно який параметр для чого.

А тепер є node_security_group_name і “якась” security_group_name.

Removed variables в terraform-aws-modules/eks/aws//modules/karpenter

ОК – редагуємо імена змінних в коді основного модулю, виконуємо terraform plan ще раз – тепер маємо помилки по змінам в модулі karpenter:

...
 Error: Unsupported argument
│ 
│   on ../../modules/atlas-eks/karpenter.tf line 7, in module "karpenter":
│    7:   irsa_oidc_provider_arn          = module.eks.oidc_provider_arn
│ 
│ An argument named "irsa_oidc_provider_arn" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│   on ../../modules/atlas-eks/karpenter.tf line 8, in module "karpenter":
│    8:   irsa_namespace_service_accounts = ["karpenter:karpenter"]
│ 
│ An argument named "irsa_namespace_service_accounts" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│   on ../../modules/atlas-eks/karpenter.tf line 14, in module "karpenter":
│   14:   enable_irsa             = true
│ 
│ An argument named "enable_irsa" is not expected here.

...

Вони були видалені, бо більше немає IRSA – тепер для Karpenter буде створено EKS Pod Identity, див. main.tf#L92.

Про EKS Pod Indetity писав в AWS: EKS Pod Identities – заміна IRSA? Спрощуємо менеджмент IAM доступів і в Terraform: менеджмент EKS Access Entries та EKS Pod Identities.

Прибираємо їх:

...
  #irsa_oidc_provider_arn          = module.eks.oidc_provider_arn
  #irsa_namespace_service_accounts = ["karpenter:karpenter"]
  #enable_irsa             = true
...

Запускаємо terraform plan ще раз.

Important: Karpenter’s EKS Identity Provider Namespace

І ось тут важливий момент:

...
  # module.atlas_eks.module.karpenter.aws_eks_pod_identity_association.karpenter[0] will be created
  + resource "aws_eks_pod_identity_association" "karpenter" {
      ...
      + namespace            = "kube-system"
      + region               = "us-east-1"
      + role_arn             = "arn:aws:iam::492***148:role/KarpenterIRSA-atlas-eks-test-1-33-cluster"
      + service_account      = "karpenter"
...

eks_pod_identity_association буде створено для Kubernetes Namespace "kube-system".

Якщо Karpenter в іншому неймспейсі – то треба вказати його явно при виклику модуля:

...
module "karpenter" {
  source = "terraform-aws-modules/eks/aws//modules/karpenter"
  version = "~> v21.0"

  cluster_name = module.eks.cluster_name
  namespace  = "karpenter"
...

Бо інакше Karpenter “відвалиться”, і апгрейд WorkerNode Group сфейлиться – бо нода буде чекати на под Karpenter, а він буде в CrashLoopbackoff і апгрейд групи сфейлиться.

eks_managed_node_groups: attribute “taints”: map of object required

Тепер помилка з тегами нод-групи:

...
│ The given value is not suitable for module.atlas_eks.module.eks.var.eks_managed_node_groups declared at .terraform/modules/atlas_eks.eks/variables.tf:1205,1-35: element "test-1-33-default": attribute "taints": map of object required.
...

Чому – бо:

Variable definitions now contain detailed object types in place of the previously used any type.

Див. diff 20 vs 21:

Тобто тепер це має бути map(object):

...
  type = map(object({
    key    = string
    value  = optional(string)
    effect = string
  }))
...

А в мене taints зараз передаються зі змінної з об’єктом set(map(string)):

...
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
  }))
}
...

З такими значеннями:

...
eks_managed_node_group_params = {
  default_group = {
    min_size       = 1
    max_size       = 1
    desired_size   = 1
    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 = 100
  }
}
...

Тож що треба зробити – це змінити declaration змінної в мене:

...
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))
    taints = optional(map(object({
      key    = string
      value  = optional(string)
      effect = string
    })))
    max_unavailable_percentage = number
  }))
}
...

І оновити значення – додати ключі для map{}:

...
eks_managed_node_group_params = {
  default_group = {
    min_size       = 1
    max_size       = 1
    desired_size   = 1
    instance_types = ["t3.medium"]
    capacity_type  = "ON_DEMAND"
    # taints = [
    #   {
    #     key    = "CriticalAddonsOnly"
    #     value  = "true"
    #     effect = "NO_SCHEDULE"
    #   },
    #   {
    #     key    = "CriticalAddonsOnly"
    #     value  = "true"
    #     effect = "NO_EXECUTE"
    #   }
    # ]
      taints = {
        critical_no_sched = {
          key    = "CriticalAddonsOnly"
          value  = "true"
          effect = "NO_SCHEDULE"
        },
        critical_no_exec = {
          key    = "CriticalAddonsOnly"
          value  = "true"
          effect = "NO_EXECUTE"
        }
      }
    max_unavailable_percentage = 100
  }
}
...

Виконуємо terraform plan ще раз – і тепер все проходить без помилок.

Деплоїмо апдейти.

Deploying changes

Виконуємо terraform apply, і ось де маємо новий ресурс з EKS Pod Identity Association для Karpenter – module.atlas_eks.module.karpenter.aws_eks_pod_identity_association.karpenter:

В старому кластері цього нема.

ALB Controller error: “failed to fetch VPC ID from instance metadata”

Ще виникла проблема з AWS Load Balancer Controller, бо після апгрейду він не зміг звернутись до IMDS, мабуть через переключення на v2, див. AWS: Instance Metadata Service v1 vs IMDS v2 та робота з Kubernetes Pod і Docker контейнерів:

...
{"level":"error","ts":"2025-08-06T07:25:40Z","logger":"setup","msg":"unable to initialize AWS cloud","error":"failed to get VPC ID: failed to fetch VPC ID from instance metadata: error in fetching vpc id through ec2 metadata: get mac metadata: operation error ec2imds: GetMetadata, canceled, context deadline exceeded"}
...

Власне, можна не морочити собі голову і просто передати параметри явно, див. документацію Using the Amazon EC2 instance metadata server version 2 (IMDSv2).

Зверніть увагу на --aws-vpc-tag-key:

optional flag –aws-vpc-tag-key if you have a different key for the tag other than “Name”

Спочатку спробуємо задати параметри руками, аби перевірити що воно працює:

Все завелось.

Тепер параметри для Helm-чарту, див його values.yaml#L163 – в мене контролери встановлюються з aws-ia/eks-blueprints-addons/aws в Terraform під час створення кластеру, задаємо тут:

...
    values = [
      <<-EOT
        replicaCount: 1
        region: ${var.aws_region}
        vpcId: ${var.vpc_id}
        tolerations:
        - key: CriticalAddonsOnly
          operator: Exists
      EOT
    ]
...

Запускаємо деплой:

Все працює.

Node Group Status CREATE_FAILED

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

Власне, в чому ця проблема полягає: кластер створився, все наче ОК, але довго висить на створенні Node Group, і потім падає з помилкою “unexpected state ‘CREATE_FAILED’“:

...
╷
│ Error: waiting for EKS Node Group (atlas-eks-test-1-33-cluster:test-1-33-default-20250801112636765600000014) create: unexpected state 'CREATE_FAILED', wanted target 'ACTIVE'. last error: i-03f2c73c7211880f7: NodeCreationFailure: Unhealthy nodes in the kubernetes cluster
...

Хоча EC2 Auto Scaling Group є, і EC2 в ній теж.

Чому?

Тобто проблема в тому, що WorkerNode створена, але не може приєднатись до Kubernetes.

Першим про що думається – це перевірити Security Group, але тут наче все правильно – всі правила прописані. Порівнював з поточним EKS кластером, який робився ще з AWS EKS Terraform module v20.x – все аналогічно.

Проблема з IAM? У EC2 нема пермішенів достукатись до кластеру? Аналогічно – порівнюємо зі старим кластером, все ОК.

“Check the logs, Billy!”

Тут ще прикол в тому, що SSH на всі EC2 в мене налаштований – але тільки для Nodes, які створюються з Karpenter, писав в AWS: Karpenter та SSH для Kubernetes WorkerNodes.

А проблема виникла в “дефолтній” NodeGroup, де запускаються різні контролери.

Тому підключаємось через AWS Console – вибираємо Connect:

Потім в EC2 Instance Connect вибираємо “Connect using a Private IP” і вибираємо існуючий або руками швиденько створюємо новий EC2 Instance Connect Endpoint.

Задаємо ім’я юзера – для Amazon Linux це ec2-user:

І дивимось логи:

“Container runtime network not ready – cni plugin not initialized”

Власне:

Aug 01 13:26:04 ip-10-0-48-198.ec2.internal kubelet[1619]: E0801 13:26:04.989799    1619 kubelet.go:3126] "Container runtime network not ready" networkReady="NetworkReady=false reason:NetworkPluginNotReady message:Network plugin returns error: cni plugin not initialized"

Вау…

Окей – а що у нас там з VPC CNI?

Йдемо подивитись EKS Add-ons, і…

Взагалі пусто.

Дивимось лог terraform apply – і бачимо “Read complete“, але нема “Creating…“:

...
module.atlas_eks.module.eks.data.aws_eks_addon_version.this["vpc-cni"]: Read complete after 0s [id=vpc-cni]
...

Давайте ще глянемо чи взагалі є контейнери на ноді – може, там якісь помилки є?

Ще раз вау…

Взагалі нічого.

Вже тоді ще раз поліз в GitHub Issues, і по запиту “addon” знайшов оцю ішью – Managed EKS Node Groups boot without CNI, but addon is added after node group.

Власне, да – проблема виникла через відсутність параметра before_compute.

Хоча трохи дивно, бо він був доданий ще в версії v19.9, я останній раз кластер з нуля деплоїв вже з v20 – і цієї проблеми не було.

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

І в diff 20 vs 21 значних змін пов’язаних з before_compute не бачу.

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

Сама before_compute була додана аби дати можливість вказати які адони створювати до WorkerNodes, а які після. Див. main.tf#L797 та коменти до PR #2478.

Додаємо як в прикладах EKS Managed Node Group:

...
    vpc-cni = {
      addon_version = var.eks_addon_versions.vpc_cni
      before_compute = true
      configuration_values = jsonencode({
        env = {
          ENABLE_PREFIX_DELEGATION = "true"
          WARM_PREFIX_TARGET       = "1"
          AWS_VPC_K8S_CNI_EXTERNALSNAT = "true"
        }
      })
    }
    aws-ebs-csi-driver = {
      addon_version            = var.eks_addon_versions.aws_ebs_csi_driver
      service_account_role_arn = module.ebs_csi_irsa_role.iam_role_arn
    }
    eks-pod-identity-agent = {
      addon_version = var.eks_addon_versions.eks_pod_identity_agent
      before_compute = true
    }
...

Виконуємо terraform apply знов – і ось воно:

...
module.atlas_eks.module.eks.aws_eks_addon.before_compute["vpc-cni"]: Creating...
...
module.atlas_eks.module.eks.aws_eks_addon.before_compute["vpc-cni"]: Creation complete after 46s [id=atlas-eks-test-1-33-cluster:vpc-cni]
...

І в AWS Console:

І NodeGroup створена без помилок:

...
module.atlas_eks.module.eks.module.eks_managed_node_group["test-1-33-default"].aws_eks_node_group.this[0]: Still creating... [01m40s elapsed]
module.atlas_eks.module.eks.module.eks_managed_node_group["test-1-33-default"].aws_eks_node_group.this[0]: Creation complete after 1m49s [id=atlas-eks-test-1-33-cluster:test-1-33-default-20250801142042855800000003]
...

Готово.

Loading

Terraform: “no available releases match the given constraints”
0 (0)

24 Липня 2025

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

The Issue

В цьому випадку я змержив Pull Requests від Renovate і не звернув увагу на те, що terraform-aws-modules/terraform-aws-lambda потребує hashicorp/aws provider версії 6:

І змержив спочатку апгрейд Lambda до 8 версії.

Після цього під час виконання terraform init отримав помилку “no available releases match the given constraints“:

$ terraform init
Initializing the backend...
Upgrading modules...
...
│ Error: Failed to query available provider packages
│ 
│ Could not retrieve the list of available versions for provider hashicorp/aws: no available releases match the given constraints >= 3.29.0, ~> 5.14, >= 5.92.0, >= 6.0.0
...

The cause

Аби побачити в яких саме модулях які версії провайдерів використовуються – виконуємо terraform providers, і отримуємо дерево залежностей з усіма версіями:

$ terraform providers

Providers required by configuration:
.
├── provider[registry.terraform.io/hashicorp/aws] ~> 5.14
...
└── module.atlas_monitoring
    ...
    ├── module.single_ingress_alb_logs_loki
    │   ├── provider[registry.terraform.io/hashicorp/aws]
    │   ├── module.logs_promtail_lambda
            ...
    │       ├── provider[registry.terraform.io/hashicorp/aws] >= 6.0.0
            ...
    ├── module.logs_promtail_lambda_rds_kraken_loki
        ...
    │   └── provider[registry.terraform.io/hashicorp/aws] >= 6.0.0
    ├── module.logs_promtail_lambda_rds_kraken_vmlogs
        ...
    │   └── provider[registry.terraform.io/hashicorp/aws] >= 6.0.0
    ├── module.logs_promtail_lambda_rds_os_metrics_loki
        ...
    │   ├── provider[registry.terraform.io/hashicorp/aws] >= 6.0.0
        ...
    └── module.logs_promtail_lambda_rds_os_metrics_vmlogs
        ├── provider[registry.terraform.io/hashicorp/aws] >= 6.0.0
        ...

Власне тут і бачимо проблему:

  • provider[registry.terraform.io/hashicorp/aws] ~> 5.14
  • module.logs_promtail_lambda_rds_kraken_loki : provider[registry.terraform.io/hashicorp/aws] >= 6.0.0

А помилка нам каже “given constraints >= 3.29.0, ~> 5.14, >= 5.92.0, >= 6.0.0“, тобто:

  • перша умова – версії вище 3.29.0
  • в третій умові маємо pessimistic constraint (“песимістичне обмеження”) – в “~> 5.14” дозволяємо будь-які версії від 5.14.0 до, але не включно 6.0.0, тобто патчі для 5.14, або версії 5.15.x і вище (див. Version Constraints)
  • а остання умова потребує >= 6.0.0 – версії 6 і вище

Умова ~> 5.14 у нас задана в головному модулі atlas_monitoring в versions.tf:

terraform {

  required_version = "~> 1.5"

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

The solution

Тут варіант або апгрейднути hashicorp/aws в atlas_monitoring до 6 версії – але там були якісь breaking changes (і на які звернув увагу 🙂 ) і які треба було перевірити, тому на той момент не поспішав.

Інше рішення – просто виконати revert Pull Request merge з апгрейдом terraform-aws-modules/terraform-aws-lambda:

А потім вже спочатку оновити hashicorp/aws до 6 версії, а вже після нього – модуль з Lambda.

Loading

Kubernetes: що таке Kubernetes Operator та CustomResourceDefinition
0 (0)

18 Липня 2025

Мабуть, всі користувались операторами в Kubernetes, наприклад – PostgreSQL operator, VictoriaMetircs Operator.

Але що там відбувається “під капотом”? Як і до чого застосовуються CustomResourceDefinition (CRD), і що таке, власне “оператор”?

І, врешті решт – в чому різниця між “Kubernetes Operator” та “Kubernetes Controller”?

В попередній частині – Kubernetes: Kubernetes API, API Groups, CRD та etcd – трохи копнули в те, як працює Kubernetes API і що таке CRD, а тепер можемо спробувати написати власний мікро-опертор, простенький MVP, і на його прикладі розібратись з деталями.

Kubernetes Controller vs Kubernetes Operator

Отже, в чому головна різниця між Controller та Operator?

What is: Kubernetes Controller

Якщо просто, то Controller – то просто якийсь сервіс, який моніторить ресурси в кластері, і приводить їхній стан у відповідність до того, як цей стан описаний в базі даних – etcd.

В Kubernetes ми маємо набір дефолтних контролерів – Core Controllers у складі Kube Controller Manager, такі як ReplicaSet Controller, який перевіряє кількість подів в Deployment на відповідність до значення replicas, або Deployment Controller, який контролює створення та оновлення ReplicaSets, чи PersistentVolume Controller та PersistentVolumeClaim Binder для роботи з дисками тощо.

Окрім цих дефолтних контролерів можемо створити власний контролер, або взяти існуючий – наприклад, ExternalDNS Controller. Це приклади кастомних контролерів (Custom Controllers).

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

Під час кожної перевірки (reconciliation loop), Controller порівнює поточний стан (current state) ресурсу та порівнює його з бажаним станом (desired state) – тобто параметрами, заданими в його маніфесті при створенні або оновлені ресурсу.

Якщо desired стан не відповідає current state – то контролер виконує потрібні дії, аби ці стани узгодити.

What is: Kubernetes Operator

В свою чергу Kubernetes Operator – це такий собі “контролер на стероїдах”: фактично, Operator являє собою Custom Controller в тому сенсі, що він має власний сервіс у вигляді Pod, який комунікує з Kubernetes API для отримання та апдейту інформації про ресурси.

Але якщо звичайні контролери працюють з “дефолтними” типами ресурсів (Pod, Endpoint Slice, Node, PVC) – то для Operator ми описуємо власні, кастомні ресурси, використовуючи маніфест з Custom Resource.

А те, як ці ресурси будуть виглядати і які параметри мати – задаємо через CustomResourceDefinition які записуються в базу Kubernetes та додаються до Kubernetes API, і таким чином Kubernetes API дозволяє нашому кастомному Контролеру оперувати з цими ресурсами.

Тобто:

  • Controller – це компонент, сервіс, а Operator – це поєднання одного чи кількох кастомних Controller та відповідних CRD
  • Controller – реагує на зміну ресурсів, а Operator – додає нові типи ресурсів + контролер, який ці ресурси контролює

Kubernetes Operator frameworks

Існує кілька рішень, які спрощують створення операторів.

Основні – Kubebuilder, фреймворк для створення контролерів на Go, та Kopf – на Python.

Також є Operator SDK, який взагалі дозволяє працювати з контролерами навіть за допомогою Helm, без коду.

Я спочатку думав робити взагалі на “голому Go”, без фреймворків, аби краще зрозуміти, як усе працює під капотом – але цей пост почав перетворюватись на 95% Golang.

А так як основна ідея матеріалу була показати чисто концептуально що таке Kubernetes Operator, яку роль грають CustomResourceDefinitions та як вони один з одним взаємодіють і дозволяють керувати ресурсами – то все ж зупинився на Kopf, бо він дуже простий, і для цих цілей цілком підходить.

Створення CustomResourceDefinition

Почнемо з написання CRD.

Власне CustomResourceDefinition – це просто опис того, які поля у нашого кастомного ресурсу будуть, аби контролер міг їх використовувати через Kubernetes API для створення реальних ресурсів – будь то якісь ресурси в самому Kubernetes, чи зовнішні типу AWS Load Balancer чи AWS Route 53.

Що будемо робити: напишемо CRD, який буде описувати ресурс MyApp, і у цього ресурсу будуть поля для Docker image та кастомне поле з якимось текстом, який потім буде записувати в логи Kubernetes Pod.

Документація Kubernetes по CRD – Extend the Kubernetes API with CustomResourceDefinitions.

Створюємо файл myapp-crd.yaml:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: myapps.demo.rtfm.co.ua
spec:
  group: demo.rtfm.co.ua
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                image:
                  type: string
                banner: 
                  type: string
                  description: "Optional banner text for the application"
  scope: Namespaced
  names:
    plural: myapps
    singular: myapp
    kind: MyApp
    shortNames:
      - ma

Тут:

  • spec.group: demo.rtfm.co.ua: створюємо нову API Group, всі ресурси цього типу будуть доступні за адресою /apis/demo.rtfm.co.ua/...
  • versions: список версій нового ресурсу
    • name.v1: будемо описувати тільки одну версію
    • served: true: додаємо новий ресурс в Kube API – можна робити kubectl get myapp (GET /apis/demo.rtfm.co.ua/v1/myapps)
    • storage: true: ця версія буде використовуватись для зберігання в etcd (якщо описується кілька версій – то тільки одна повинна бути із storage: true)
    • schema:
      • openAPIV3Schema: описуємо API-схему за стандартом OpenAPI v3
        • type: object: описуємо об’єкт із вкладеними полями (key: value)
        • properties: які поля у об’єкта будуть
          • spec: що ми зможемо використовувати у YAML-маніфестах при його створенні
            • type: object – описуємо наступні поля
            • properties:
              • image.type: string: Docker-образ
              • banner.type: string: наше кастомне поле, через яке ми будемо додавати якийсь запис в логах ресурсу
  • scope: Namespaced: всі ресурси цього типу будуть існувати в конкретному Kubernetes Namespace
  • names:
    • plural: myapps:  ресурси будуть доступні через /apis/demo.rtfm.co.ua/v1/namespaces/<ns>/myapps/, і як ми зможемо “звертатись” до ресурсу (kubectl get myapp), використовується в RBAC де треба вказати resources: ["myapps"]
    • singular: myap: аліас для зручності
    • shortNames: [ma] короткий аліас для зручності

Запускаємо Minikube:

$ minikube start

Додаємо CRD:

$ kk apply -f myapp-crd.yaml 
customresourcedefinition.apiextensions.k8s.io/myapps.demo.rtfm.co.ua created

Глянемо API Groups:

$ kubectl api-versions
...
demo.rtfm.co.ua/v1
...

І новий ресурс в цій API Group:

$ kubectl api-resources --api-group=demo.rtfm.co.ua
NAME     SHORTNAMES   APIVERSION           NAMESPACED   KIND
myapps   ma           demo.rtfm.co.ua/v1   true         MyApp

ОК – ми створили CRD, і тепер можемо навіть створити CustomResource (CR).

Створюємо файл myapp-example-resource.yaml:

apiVersion: demo.rtfm.co.ua/v1      # matches the CRD's group and version
kind: MyApp                         # kind from the CRD's 'spec.names.kind'
metadata:
  name: example-app                 # name of this custom resource
  namespace: default                # namespace (CRD has scope: Namespaced)
spec:
  image: nginx:latest               # container image to use (from our schema)
  banner: "This pod was created by MyApp operator 🚀"

Деплоїмо:

$ kk apply -f myapp-example-resource.yaml 
myapp.demo.rtfm.co.ua/example-app created

І перевіряємо:

$ kk get myapp
NAME          AGE
example-app   15s

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

Створення Kubernetes Operator з Kopf

Отже, будемо використовувати Kopf, який буде створювати Kubernetes Pod, але використовуючи наш власний CRD.

Створюємо Python virtual environment:

$ python -m venv venv
$ . ./venv/bin/activate
(venv)

Додаємо залежності – файл requirements.txt:

kopf
kubernetes
PyYAML

Встановлюємо їх – з pip або uv:

$ pip install -r requirements.txt

Пишемо код оператора:

import os
import kopf
import kubernetes
import yaml

# use kopf to register a handler for the creation of MyApp custom resources
@kopf.on.create('demo.rtfm.co.ua', 'v1', 'myapps')
# this function will be called when a new MyApp resource is created
def create_myapp(spec, name, namespace, logger, **kwargs):
    # get image value from the spec of the CustomResource manifest
    image = spec.get('image')
    if not image:
        raise kopf.PermanentError("Field 'spec.image' must be provided.")

    # get optional banner value from the CR manifest spec
    banner = spec.get('banner')

    # load pod template YAML from file
    path = os.path.join(os.path.dirname(__file__), 'pod.yaml')
    with open(path, 'rt') as f:
        pod_template = f.read()

    # render pod YAML with provided values
    pod_yaml = pod_template.format(
        name=f"{name}-pod",
        image=image,
        app_name=name,
    )
    # create Pod difinition from the rendered YAML
    # it uses PyYAML to parse the YAML string into a Python dictionary
    # which can be used by Kubernetes API client
    # it is used to create a Pod object in Kubernetes
    pod_spec = yaml.safe_load(pod_yaml)

    # inject banner as environment variable if provided
    if banner:
        # it is used to add a new environment variable into the container spec
        container = pod_spec['spec']['containers'][0]
        env = container.setdefault('env', [])
        env.append({
            'name': 'BANNER',
            'value': banner
        })

    # create Kubernetes CoreV1 API client
    # used to interact with the Kubernetes API
    api = kubernetes.client.CoreV1Api()

    try:
        # it sends a request to the Kubernetes API to create a new Pod
        # uses 'create_namespaced_pod' method to create the Pod in the specified namespace
        # 'namespace' is the namespace where the Pod will be created
        # 'body' is the Pod specification that was created from the YAML template
        api.create_namespaced_pod(namespace=namespace, body=pod_spec)
        logger.info(f"Pod {name}-pod created.")
    except kubernetes.client.exceptions.ApiException as e:
        logger.error(f"Failed to create pod {name}-pod: {e}")

Створюємо шаблон, який буде використовуватись нашим Оператором для створення ресурсів:

apiVersion: v1
kind: Pod
metadata:
  name: {name}
  labels:
    app: {app_name}
spec:
  containers:
    - name: {app_name}
      image: {image}
      ports:
        - containerPort: 80
      env:
        - name: BANNER
          value: ""  # will be overridden in code if provided
      command: ["/bin/sh", "-c"]
      args:
        - |
          if [ -n "$BANNER" ]; then
            echo "$BANNER";
          fi
          exec sleep infinity

Запускаємо оператор з kopf run myoperator.py.

У нас вже є створений CustomResource, і Оператор має його побачити та створити Kubernetes Pod:

$ kopf run  myoperator.py  --verbose
...
[2025-07-18 13:59:58,201] kopf._cogs.clients.w [DEBUG   ] Starting the watch-stream for customresourcedefinitions.v1.apiextensions.k8s.io cluster-wide.
[2025-07-18 13:59:58,201] kopf._cogs.clients.w [DEBUG   ] Starting the watch-stream for myapps.v1.demo.rtfm.co.ua cluster-wide.
[2025-07-18 13:59:58,305] kopf.objects         [DEBUG   ] [default/example-app] Creation is in progress: {'apiVersion': 'demo.rtfm.co.ua/v1', 'kind': 'MyApp', 'metadata': {'annotations': {'kubectl.kubernetes.io/last-applied-configuration': '{"apiVersion":"demo.rtfm.co.ua/v1","kind":"MyApp","metadata":{"annotations":{},"name":"example-app","namespace":"default"},"spec":{"banner":"This pod was created by MyApp operator 🚀","image":"nginx:latest","replicas":3}}\n'}, 'creationTimestamp': '2025-07-18T09:55:42Z', 'generation': 2, 'managedFields': [{'apiVersion': 'demo.rtfm.co.ua/v1', 'fieldsType': 'FieldsV1', 'fieldsV1': {'f:metadata': {'f:annotations': {'.': {}, 'f:kubectl.kubernetes.io/last-applied-configuration': {}}}, 'f:spec': {'.': {}, 'f:banner': {}, 'f:image': {}, 'f:replicas': {}}}, 'manager': 'kubectl-client-side-apply', 'operation': 'Update', 'time': '2025-07-18T10:48:27Z'}], 'name': 'example-app', 'namespace': 'default', 'resourceVersion': '2955', 'uid': '8b674a99-05ab-4d4b-8205-725de450890a'}, 'spec': {'banner': 'This pod was created by MyApp operator 🚀', 'image': 'nginx:latest', 'replicas': 3}}
...
[2025-07-18 13:59:58,325] kopf.objects         [INFO    ] [default/example-app] Pod example-app-pod created.
[2025-07-18 13:59:58,326] kopf.objects         [INFO    ] [default/example-app] Handler 'create_myapp' succeeded.
...

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

$ kk get pod
NAME              READY   STATUS    RESTARTS   AGE
example-app-pod   1/1     Running   0          68s

Та його логи:

$ kk logs -f example-app-pod
This pod was created by MyApp operator 🚀

Отже, Оператор запустив Pod використовуючи наш CustomResource в якому взяв поле spec.banner зі рядком “This pod was created by MyApp operator 🚀“, і виконав в поді command /bin/sh -c " $BANNER".

Шаблони ресурсів – Kopf та Kubebuilder

Замість того, аби мати окремий файл pod-template.yaml ми могли б все описати прямо в коді оператора.

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

...
    # get optional banner value
    banner = spec.get('banner', '')

    # define Pod spec as a Python dict
    pod_spec = {
        "apiVersion": "v1",
        "kind": "Pod",
        "metadata": {
            "name": f"{name}-pod",
            "labels": {
                "app": name,
            },
        },
        "spec": {
            "containers": [
                {
                    "name": name,
                    "image": image,
                    "env": [
                        {
                            "name": "BANNER",
                            "value": banner
                        }
                    ],
                    "command": ["/bin/sh", "-c"],
                    "args": [f'echo "$BANNER"; exec sleep infinity'],
                    "ports": [
                        {
                            "containerPort": 80
                        }
                    ]
                }
            ]
        }
    }

    # create Kubernetes API client
    api = kubernetes.client.CoreV1Api()
...

А у випадку з Kubebuilder зазвичай створюється функція, яка використовує маніфест CustomResource (cr *myappv1.MyApp) і формує об’єкт типу *corev1.Pod використовуючи Go-структури corev1.PodSpec та corev1.Container:

...
// newPod is a helper function that builds a Kubernetes Pod object
// based on the custom MyApp resource. It returns a pointer to corev1.Pod,
// which is later passed to controller-runtime's client.Create(...) to create the Pod in the cluster.
func newPod(cr *myappv1.MyApp) *corev1.Pod {
    // `cr` is a pointer to your CustomResource of kind MyApp
    // type MyApp is generated by Kubebuilder and lives in your `api/v1/myapp_types.go`
    // it contains fields like cr.Spec.Image, cr.Spec.Banner, cr.Name, cr.Namespace, etc.
    return &corev1.Pod{
        // corev1.Pod is a Go struct representing the built-in Kubernetes Pod type
        // it's defined in "k8s.io/api/core/v1" package (aliased here as corev1)
        // we return a pointer to it (`*corev1.Pod`) because client-go methods like
        // `client.Create()` expect pointer types

        ObjectMeta: metav1.ObjectMeta{
            // metav1.ObjectMeta comes from "k8s.io/apimachinery/pkg/apis/meta/v1"
            // it defines metadata like name, namespace, labels, annotations, ownerRefs, etc.
            Name:      cr.Name + "-pod",     // generate Pod name based on the CR's name
            Namespace: cr.Namespace,         // place the Pod in the same namespace as the CR
            Labels: map[string]string{       // set a label for identification or selection
                "app": cr.Name,              // e.g., `app=example-app`
            },
        },

        Spec: corev1.PodSpec{
            // corev1.PodSpec defines everything about how the Pod runs
            // including containers, volumes, restart policy, etc.

            Containers: []corev1.Container{
                // define a single container inside the Pod

                {
                    Name:  cr.Name,          // use CR name as container name (must be DNS compliant)
                    Image: cr.Spec.Image,    // container image (e.g., "nginx:1.25")

                    Env: []corev1.EnvVar{
                        // corev1.EnvVar is a struct that defines environment variables
                        {
                            Name:  "BANNER",           // name of the variable
                            Value: cr.Spec.Banner,     // value from the CR spec
                        },
                    },

                    Command: []string{"/bin/sh", "-c"},
                    // override container ENTRYPOINT to run a shell command

                    Args: []string{
                        // run a command that prints the banner and sleeps forever
                        // fmt.Sprintf(...) injects the value at runtime into the string
                        fmt.Sprintf(`echo "%s"; exec sleep infinity`, cr.Spec.Banner),
                    },

                    // optional: could also add ports, readiness/liveness probes, etc.
                },
            },
        },
    }
}
...

А як в реальних операторах?

Але це ми робили для”внутрішніх” ресурсів Kubernetes.

Як щодо зовнішніх ресурсів?

Тут просто приклад – не тестував, але загальна ідея така: просто беремо SDK (в прикладі з Python це буде boto3), і використовуючи поля з CustomResource (наприклад, subnets або scheme), виконуємо відповідні API-запити до AWS через SDK.

Приклад такого CustomResource:

apiVersion: demo.rtfm.co.ua/v1
kind: MyIngress
metadata:
  name: myapp
spec:
  subnets:
    - subnet-abc
    - subnet-def
  scheme: internet-facing

І код, який міг би створювати AWS ALB з нього:

import kopf
import boto3
import botocore
import logging

# create a global boto3 client for AWS ELBv2 service
# this client will be reused for all requests from the operator
# NOTE: region must match where your subnets and VPC exist
elbv2 = boto3.client("elbv2", region_name="us-east-1")

# define a handler that is triggered when a new MyIngress resource is created
@kopf.on.create('demo.rtfm.co.ua', 'v1', 'myingresses')
def create_ingress(spec, name, namespace, status, patch, logger, **kwargs):
    # extract the list of subnet IDs from the CustomResource 'spec.subnets' field
    # these subnets must belong to the same VPC and be public if scheme=internet-facing
    subnets = spec.get('subnets')

    # extract optional scheme (default to 'internet-facing' if not provided)
    scheme = spec.get('scheme', 'internet-facing')

    # validate input: at least 2 subnets are required to create an ALB
    if not subnets:
        raise kopf.PermanentError("spec.subnets is required.")

    # attempt to create an ALB in AWS using the provided spec
    # using the boto3 ELBv2 client
    try:
        response = elbv2.create_load_balancer(
            Name=f"{name}-alb",           # ALB name will be derived from CR name
            Subnets=subnets,              # list of subnet IDs provided by user
            Scheme=scheme,                # 'internet-facing' or 'internal'
            Type='application',           # we are creating an ALB (not NLB)
            IpAddressType='ipv4',         # only IPv4 supported here (could be 'dualstack')
            Tags=[                        # add tags for ownership tracking
                {'Key': 'ManagedBy', 'Value': 'kopf'},
            ]
        )
    except botocore.exceptions.ClientError as e:
        # if AWS API fails (e.g. invalid subnet, quota exceeded), retry later
        raise kopf.TemporaryError(f"Failed to create ALB: {e}", delay=30)

    # parse ALB metadata from AWS response
    lb = response['LoadBalancers'][0]       # ALB list should contain exactly one entry
    dns_name = lb['DNSName']                # external DNS of the ALB (e.g. abc.elb.amazonaws.com)
    arn = lb['LoadBalancerArn']             # unique ARN of the ALB (used for deletion or listeners)

    # log the creation for operator diagnostics
    logger.info(f"Created ALB: {dns_name}")

    # save ALB info into the CustomResource status field
    # this updates .status.alb.dns and .status.alb.arn in the CR object
    patch.status['alb'] = {
        'dns': dns_name,
        'arn': arn,
    }

    # return a dict, will be stored in the finalizer state
    # used later during deletion to clean up the ALB
    return {'alb-arn': arn}

У випадку з Go і Kubebuilder – використовували б бібліотеку aws-sdk-go:

import (
    "context"
    "fmt"

    elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2"
    "github.com/aws/aws-sdk-go-v2/aws"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    networkingv1 "k8s.io/api/networking/v1"
)

func newALB(ctx context.Context, client *elbv2.Client, cr *networkingv1.Ingress) (string, error) {
    // build input for the ALB
    input := &elbv2.CreateLoadBalancerInput{
        Name:           aws.String(fmt.Sprintf("%s-alb", cr.Name)),
        Subnets:        []string{"subnet-abc123", "subnet-def456"}, // replace with real subnets
        Scheme:         elbv2.LoadBalancerSchemeEnumInternetFacing,
        Type:           elbv2.LoadBalancerTypeEnumApplication,
        IpAddressType:  elbv2.IpAddressTypeIpv4,
        Tags: []types.Tag{
            {
                Key:   aws.String("ManagedBy"),
                Value: aws.String("MyIngressOperator"),
            },
        },
    }

    // create ALB
    output, err := client.CreateLoadBalancer(ctx, input)
    if err != nil {
        return "", fmt.Errorf("failed to create ALB: %w", err)
    }

    if len(output.LoadBalancers) == 0 {
        return "", fmt.Errorf("ALB was not returned by AWS")
    }

    // return the DNS name of the ALB
    return aws.ToString(output.LoadBalancers[0].DNSName), nil
}

В самому реальному AWS ALB Ingress Controller створення ALB викликається у файлі elbv2.go:

...
func (c *elbv2Client) CreateLoadBalancerWithContext(ctx context.Context, input *elasticloadbalancingv2.CreateLoadBalancerInput) (*elasticloadbalancingv2.CreateLoadBalancerOutput, error) {
  client, err := c.getClient(ctx, "CreateLoadBalancer")
  if err != nil {
    return nil, err
  }
  return client.CreateLoadBalancer(ctx, input)
}
...

Власне, на цьому і все.

Loading

Kubernetes: PVC в StatefulSet та помилка “Forbidden updates to statefulset spec”
0 (0)

16 Липня 2025

Маємо Helm-чарт VictoriaLogs, в якому заданий PVC з розміром в 30 GB, якого нам стало вже замало, і його треба збільшити.

Але проблема полягає в тому, що .spec.volumeClaimTemplates[*].spec.resources.requests.storage в STS являється immutable, тобто ми не можемо просто змінити size через values.yaml, бо це призведе до помилки “Forbidden: updates to statefulset spec for fields other than ‘replicas’, ‘ordinals’, ‘template’, ‘updateStrategy’, ‘revisionHistoryLimit’, ‘persistentVolumeClaimRetentionPolicy’ and ‘minReadySeconds’ are forbidden“.

Values чарту виглядають зараз так:

victoria-logs-single:
  server:
    persistentVolume:
      enabled: true
      storageClassName: gp2-retain
      size: 30Gi
    retentionPeriod: 7d

І при дефолтному типі StatefulSet в чарті для створення PVC використовується volumeClaimTemplates:

...  
volumeClaimTemplates:
    - apiVersion: v1
      kind: PersistentVolumeClaim
      metadata:
        name: server-volume
        ...
      spec:
        ...
        resources:
          requests:
            storage: {{ $app.persistentVolume.size }}
...

Якби замість STS був тип Deployment – то в чарті VictoriaLogs це призвело б до створення окремого PVC – pvc.yaml.

Можна було б просто самому створити окремий PVC, і підключати його через value existingClaim, аде PersistentVolume вже є, створювати новий і переносити дані не хочеться (хоча при потребі – можна, див. VictoriaMetrics: міграція даних VMSingle та VictoriaLogs між кластерами Kubernetes, але буде даунтайм), тому подивимось, як ми можемо це вирішити інакше – без видалення Pods і без зупинки сервісу.

storageClassName та AllowVolumeExpansion

storageClass, який використовується для створення Persistent Volume має підтримувати AllowVolumeExpansion – див. Volume expansion:

$ kk describe storageclass gp2-retain
Name:            gp2-retain
...
Provisioner:           kubernetes.io/aws-ebs
Parameters:            <none>
AllowVolumeExpansion:  True
MountOptions:          <none>
ReclaimPolicy:         Retain
VolumeBindingMode:     WaitForFirstConsumer
...

У нас цей storageClass створюється при створенні EKS кластеру з простого маніфесту:

...
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
}
...

Хоча для Terraform є окремий ресурс storage_class.

Та й kubernetes.io/aws-ebs вже deprecated (OMG, since Kubernetes 1.17!), пора б оновити на ebs.csi.aws.com.

Але це будемо фіксити пізніше, зараз задача – просто збільшити диск.

Reproducing the issue

Для тесту напишемо власний STS з volumeClaimTemplates:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: demo-sts
spec:
  serviceName: demo-sts-svc
  replicas: 1
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      containers:
        - name: app
          image: busybox
          command: ["sh", "-c", "sleep 3600"]
          volumeMounts:
            - name: data
              mountPath: /data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: gp2-retain
        resources:
          requests:
            storage: 1Gi

В volumeClaimTemplates задаємо storageClassName та розмір 1 гігабайт.

Деплоїмо:

$ kk apply -f test-sts-pvc.yaml 
statefulset.apps/demo-sts created

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

$ kk get pvc
NAME              STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
data-demo-sts-0   Bound    pvc-31a9a547-7547-4d34-bb2d-2c7015b9e0f3   1Gi        RWO            gp2-retain     <unset>                 15s

Тепер, якщо ми захочемо збільшити розмір через volumeClaimTemplates з 1Gi до 2Gi:

...
  volumeClaimTemplates:
    ...
        resources:
          requests:
            storage: 2Gi

То отримаємо помилку:

$ kk apply -f test-sts-pvc.yaml 
The StatefulSet "demo-sts" is invalid: spec: Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'revisionHistoryLimit', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden

The Fix

Але ми можемо це обійти дуже просто:

  1. редагуємо PVC вручну – задаємо новий розмір
  2. видаляємо STS з --cascade=orphan – див. Delete owner objects and orphan dependents
  3. створюємо STS заново
  4. profit!

Спробуємо.

Note: перед змінами в дисках – не забуваємо про бекапи!

Редагуємо PVC вручну – міняємо resources.requests.storage з 1Gi на 2Gi:

Перевіряємо Events цього PVC:

$ kk describe pvc data-demo-sts-0
...
  Normal  ExternalExpanding         40s                    volume_expand                                                                             CSI migration enabled for kubernetes.io/aws-ebs; waiting for external resizer to expand the pvc
  Normal  Resizing                  40s                    external-resizer ebs.csi.aws.com                                                          External resizer is resizing volume pvc-31a9a547-7547-4d34-bb2d-2c7015b9e0f3
  Normal  FileSystemResizeRequired  35s                    external-resizer ebs.csi.aws.com                                                          Require file system resize of volume on node

І ще через кілька секунд – готово:

...
  Normal  FileSystemResizeSuccessful  19s                    kubelet

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

$ kk get pvc
NAME              STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
data-demo-sts-0   Bound    pvc-31a9a547-7547-4d34-bb2d-2c7015b9e0f3   2Gi        RWO            gp2-retain     <unset>                 4m7s

2Gi, все ОК.

І в самому Pod тепер теж маємо 2 гігабайти:

$ kk exec -ti demo-sts-0 -- df -h /data
Filesystem                Size      Used Available Use% Mounted on
/dev/nvme7n1              1.9G     24.0K      1.9G   0% /data

Але якщо ми спробуємо задеплоїти зміни в volumeClaimTemplates.spec.resources.requests.storage ще раз – все одно будемо ловити помилку:

$ kk apply -f test-sts-pvc.yaml 
The StatefulSet "demo-sts" is invalid: spec: Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'revisionHistoryLimit', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden

Тому видаляємо сам STS, але залишаємо всі його dependent об’єкти:

$ kubectl delete statefulset demo-sts --cascade=orphan
statefulset.apps "demo-sts" deleted

Перевіряємо чи живий Pod:

$ kk get pod
NAME         READY   STATUS    RESTARTS   AGE
demo-sts-0   1/1     Running   0          3m13s

І тепер просто створюємо STS заново, вже з новим значенням в volumeClaimTemplates.spec.resources.requests.storage:

$ kk apply -f test-sts-pvc.yaml 
statefulset.apps/demo-sts created

Готово.

Loading