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

Kubernetes: Kubernetes API, API Groups, CRD та etcd
0 (0)

12 Липня 2025

Взагалі почав писати створення власного Kubernetes Operator, але вирішив винести окремо тему про те, що таке власне Kubernetes CustomResourceDefinition, і як створення CRD взагалі працює на рівні Kubernetes API та etcd.

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

Продовження – Kubernetes: що таке Kubernetes Operator та CustomResourceDefinition.

Kubernetes API

Отже, вся комунікація з Kubernetes Control Plane відбувається через його головний ендпоінт – Kubernetes API, який являє собою компонент Kubernetes Control Plane – див. Cluster Architecture.

Документація – The Kubernetes API та Kubernetes API Concepts.

Через API ми комунікуємо з Kubernetes, а всі ресурси та інформація по ним зберігаються в базі даних – etcd.

Інші компоненти Control Plane – це Kube Controller Manager з набором дефолтних контролерів, які відповідають за роботу з ресурсами, та Scheduler, що відповідає за те, як ресурси будуть розміщатись на Worker Nodes.

Kubernetes API – це звичайний HTTPS REST API, до якого ми можемо звернутись навіть з curl.

Для доступу до кластеру можемо використати kubectl proxy, який візьме параметри з ~/.kube/config з адресою API Server та токеном, і створить тунель до нього.

В мене є налаштований доступ до AWS EKS – тому підключення піде до нього:

$ kubectl proxy --port=8080
Starting to serve on 127.0.0.1:8080

І звертаємось до API:

$ curl -s localhost:8080 | jq
{
  "paths": [
    "/.well-known/openid-configuration",
    "/api",
    "/api/v1",
    "/apis",
    ...
    "/version"
  ]
}

Власне, що ми бачимо – це список API endpoints, які підтримує Kubernetes API:

  • /api/: інформація по самому Kubernetes API та точка входу до core API Groups (див. далі)
  • /api/v1: core API group з Pods, ConfigMaps, Services, etc
  • /apis/: APIGroupList – решта API Groups в системі та їх версії, в тому числі і API Groups, створені з різних CRD
    • наприклад, для API Group operator.victoriametrics.com можемо бачити підтримку двох версій – “operator.victoriametrics.com/v1″ “operator.victoriametrics.com/v1beta1
  • /version: інформація по версії кластера

Ну і далі вже можемо піти глибше, і подивитись що всередині кожного ендпоінту, наприклад – отримати інформацію про всі Pods в кластері:

$ curl -s localhost:8080/api/v1/pods | jq
...
    {
      "metadata": {
        "name": "backend-ws-deployment-6db58cc97c-k56lm",
      ...
        "namespace": "staging-backend-api-ns"
        "labels": {
          "app": "backend-ws",
          "component": "backend",
      ...
      "spec": {
        "volumes": [
          {
            "name": "eks-pod-identity-token",
      ...
        "containers": [
          {
            "name": "backend-ws-container",
            "image": "492***148.dkr.ecr.us-east-1.amazonaws.com/challenge-backend-api:v0.171.9",
            "command": [
              "gunicorn",
              "websockets_backend.run_api:app",
      ...
            "resources": {
              "requests": {
                "cpu": "200m",
                "memory": "512Mi"
              }
            },
...

Тут бачимо інформацію про Pod з іменем “backend-ws-deployment-6db58cc97c-k56lm“, який живе в Kubernetes Namespace “staging-backend-api-ns“, і решту інформації про нього – volumes, які в цьому поді контейнери, ресурси і т.д.

Kubernetes API Groups та Kind

API Groups – це спосіб організації ресурсів у Kubernetes. Вони групуються за групами, версіями та типами ресурсів (Kind).

Тобто структура API:

  • API Group
    • versions
      • kind

Наприклад, в /api/v1 ми бачимо Kubernetes Core API Group, в /apis – API Groups apps, batch, events і так далі.

Структура буде такою:

  • /apis/<group> – сама група та її версії
  • /apis/<group>/<version> – конкретна версія групи вже з конкретними resources (Kind)
  • /apis/<group>/<version>/<resource> – доступ до конкретного ресурсу та об’єктів в ньому

Note: Kind vs resource: Kind – це назва ресурсу, яка задається в schema цього ресурсу. А resource – це ім’я, яке використовується при побудові URI при запиті до API Server.

Наприклад, для API Group apps маємо версію v1:

$ curl -s localhost:8080/apis/apps | jq
{
  "kind": "APIGroup",
  "apiVersion": "v1",
  "name": "apps",
  "versions": [
    {
      "groupVersion": "apps/v1",
      "version": "v1"
    }
  ],
...

А всередині версії – ресурси, наприклад deployments:

$ curl -s localhost:8080/apis/apps/v1 | jq
{
...
    {
      "name": "deployments",
      "singularName": "deployment",
      "namespaced": true,
      "kind": "Deployment",
      "verbs": [
        "create",
        "delete",
        "deletecollection",
        "get",
        "list",
        "patch",
        "update",
        "watch"
      ],
      "shortNames": [
        "deploy"
      ],
      "categories": [
        "all"
      ],
...

І вже використовуючи цю групу, версію та конкретний тип ресурсу (kind) – отримуємо всі об’єкти:

$ curl -s localhost:8080/apis/apps/v1/deployments/ | jq
{
  "kind": "DeploymentList",
  "apiVersion": "apps/v1",
  "metadata": {
    "resourceVersion": "1534"
  },
  "items": [
    {
      "metadata": {
        "name": "coredns",
        "namespace": "kube-system",
        "uid": "9d7f6de3-041e-4afe-84f4-e124d2cc6e8a",
        "resourceVersion": "709",
        "generation": 2,
        "creationTimestamp": "2025-07-12T10:15:33Z",
        "labels": {
          "k8s-app": "kube-dns"
        },
...

Окей, ми звернулись до API – але звідки він бере всі ті дані, що нам відображаються?

Kubernetes та etcd

Для зберігання даних в Kubernetes маємо ще один ключовий компонент Control Plane – etcd.

Власне це просто key:value база даних з усіма даними, які і формують наш кластер – всі його налаштування, вся ресурси, всі стани цих ресурсів, RBAC-групи тощо.

Коли Kubernetes API Server отримує запит, наприклад – POST /apis/apps/v1/namespaces/default/deployments – він спершу перевіряє відповідність обʼєкта до схеми ресурсу (валідація), і тільки після цього зберігає його в etcd.

База etcd складається з набору ключів. Наприклад Pod з іменем “nginx-abc” буде зберігатись в ключі з іменем /registry/pods/default/nginx-abc.

Див. документацію Operating etcd clusters for Kubernetes.

В AWS EKS ми доступу до etcd не маємо (і це добре), але можемо запустити Minikube, і трохи подивитись там:

$ minikube start
...
🏄  Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default

Перевіряємо системні поди:

$ kubectl -n kube-system get pod
NAME                               READY   STATUS              RESTARTS      AGE
coredns-674b8bbfcf-68q8p           0/1     ContainerCreating   0             57s
etcd-minikube                      1/1     Running             0             62s
...

Підключаємось в кластер:

$ minikube ssh

Якби ми використовували minikube start --driver=virtualbox – то з minikube ssh зайшли в інстанс VirtualBox.

Але так як у нас дефолтний драйвер docker – то просто заходимо в контейнер minikube.

Встановлюємо тут etcd, аби отримати etcdctl:

docker@minikube:~$ sudo apt update
docker@minikube:~$ sudo apt install etcd

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

docker@minikube:~$ etcdctl -version
etcdctl version: 3.3.25

І тепер можемо подивитись що в базі:

docker@minikube:~$ sudo ETCDCTL_API=3 etcdctl \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/var/lib/minikube/certs/etcd/ca.crt \
  --cert=/var/lib/minikube/certs/etcd/server.crt \
  --key=/var/lib/minikube/certs/etcd/server.key \
  get "" --prefix --keys-only
...
/registry/namespaces/kube-system
/registry/pods/kube-system/coredns-674b8bbfcf-68q8p
/registry/pods/kube-system/etcd-minikube
...
/registry/services/endpoints/default/kubernetes
/registry/services/endpoints/kube-system/kube-dns
...

Дані в ключах зберігаються в форматі Protobuf (Protocol Buffers), тому при звичайному etcdctl get KEY дані будуть виглядати трохи криво.

Глянемо, що є в базі про Pod самого etcd:

docker@minikube:~$ sudo ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/var/lib/minikube/certs/etcd/ca.crt --cert=/var/lib/minikube/certs/etcd/server.crt --key=/var/lib/minikube/certs/etcd/server.key get "/registry/pods/kube-system/etcd-minikube"

Результат:

Окей.

CustomResourceDefinitions та Kubernetes API

Отже, коли ми створюємо CRD – ми розширюємо Kubernetes API, створюючи власну API Group з власним ім’ям, версією та новим типом ресурсу (Kind), який описується в CRD.

Документація – Extend the Kubernetes API with CustomResourceDefinitions.

Напишемо простеньку CRD:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: myapps.mycompany.com
spec:
  group: mycompany.com
  names:
    kind: MyApp
    plural: myapps
    singular: myapp
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                image:
                  type: string

Тут ми:

  • використовуємо існуючу API Group apiextensions.k8s.io і версію v1
    • з неї беремо схему об’єкту CustomResourceDefinition
  • і на основі цієї схеми – створюємо власну API Group з ім’ям mycompany.com
    • в цій API Group описуємо єдиний тип ресурсу – kind: MyApp
    • і одну версію – v1
    • далі з openAPIV3Schema описуємо схему нашого ресурсу – які у нього поля, їхні типи, тут жеж можна задати дефолтні значення (див. OpenAPI Specification)

З цим CRD ми зможемо створювати нові Custom Resources з маніфестом, в якому передамо поля apiVersion, kind, та spec.image – зі schema.openAPIV3Schema.properties.spec.properties.image нашого CRD:

apiVersion: mycompany.com/v1
kind: MyApp
metadata:
  name: example
spec:
  image: nginx:1.25

Створюємо CRD:

$ kk apply -f test-crd.yaml 
customresourcedefinition.apiextensions.k8s.io/myapps.mycompany.com created

Перевіряємо в Kubernetes API (можна з селектором | jq '.groups[] | select(.name == "mycompany.com")'):

$ curl -s localhost:8080/apis/ | jq
...
{
  "name": "mycompany.com",
  "versions": [
    {
      "groupVersion": "mycompany.com/v1",
      "version": "v1"
    }
  ],
  ...
}
...

І саму API Group mycompany.com:

$ curl -s localhost:8080/apis/mycompany.com/v1 | jq
{
  "kind": "APIResourceList",
  "apiVersion": "v1",
  "groupVersion": "mycompany.com/v1",
  "resources": [
    {
      "name": "myapps",
      "singularName": "myapp",
      "namespaced": true,
      "kind": "MyApp",
      "verbs": [
        "delete",
        "deletecollection",
        "get",
        "list",
        "patch",
        "create",
        "update",
        "watch"
      ],
      "storageVersionHash": "MZjF6nKlCOU="
    }
  ]
}

Та в etcd:

docker@minikube:~$ sudo ETCDCTL_API=3 etcdctl   --endpoints=https://127.0.0.1:2379   --cacert=/var/lib/minikube/certs/etcd/ca.crt   --cert=/var/lib/minikube/certs/etcd/server.crt   --key=/var/lib/minikube/certs/etcd/server.key   get "" --prefix --keys-only
/registry/apiextensions.k8s.io/customresourcedefinitions/myapps.mycompany.com
...
/registry/apiregistration.k8s.io/apiservices/v1.mycompany.com
...

Тут в ключі /registry/apiextensions.k8s.io/customresourcedefinitions/myapps.mycompany.com зберігається інформація про сам новий CRD – структура CRD, її OpenAPI schema, версії, etc, а в /registry/apiregistration.k8s.io/apiservices/v1.mycompany.com – реєструється API Service для цієї групи для доступу до групи через Kubernetes API.

Ну і звісно, ми можемо побачити CRD з kubectl`:

$ kk get crd
NAME                   CREATED AT
myapps.mycompany.com   2025-07-12T11:23:19Z

Створюємо сам CustomResource з маніфесту, що бачили вище:

$ kk apply -f test-resource.yaml 
myapp.mycompany.com/example created

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

$ kk describe MyApp
Name:         example
Namespace:    default
Labels:       <none>
Annotations:  <none>
API Version:  mycompany.com/v1
Kind:         MyApp
Metadata:
  Creation Timestamp:  2025-07-12T13:34:52Z
  Generation:          1
  Resource Version:    4611
  UID:                 a88e37fd-1477-4a7e-8c00-46c925f510ac
Spec:
  Image:  nginx:1.25

Але це поки що просто дані в etcd – ніяких реальних ресурсів по типу Pods  у нас нема, бо нема контролера, який оброблює ресурси з Kind: MyApp.

Note: трохи забігаючи наперед до наступного поста: власне, Kubernetes Operator – це і є набір CRD та контролер, який “контролює” ресурси з заданими Kind

Kubernetes API Service

Колими додаємо новий CRD – Kubernetes має не тільки створити новий ключ в etcd із новою API Group та схемою відповідних ресурсів, але й додати новий ендпоінт до свої маршрутів – як ми це робимо в Python з @app.get("/") в FastAPI – для того, аби API-сервер знав, що на запит GET /apis/mycompany.com/v1/myapps повертати ресурси саме цього типу.

Відповідний API Service буде в собі містити spec з групою та версією:

$ kk get apiservice v1.mycompany.com -o yaml
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
  creationTimestamp: "2025-07-12T11:53:52Z"
  labels:
    kube-aggregator.kubernetes.io/automanaged: "true"
  name: v1.mycompany.com
  resourceVersion: "2632"
  uid: 26fc8c6b-6770-422f-8996-3f35d86be6c7
spec:
  group: mycompany.com
  groupPriorityMinimum: 1000
  version: v1
  versionPriority: 100
...

Тобто коли ми створюємо новий CRD – Kubernetes API Server створює API Service (записуючи його до /registry/apiregistration.k8s.io/apiservices/v1.mycompany.com), і додає до свого до своїх роутів в ендпоінт /apis.

І от тепер, маючи уявлення про те, як виглядає API та база даних, яка всі ресурси зберігає – ми можемо перейти до створення CRD та контролера, тобто – власне, написати сам Operator.

Але це вже в наступній частині.

Loading

VictoriaMetrics: фікс помилки “no matches for kind VMAnomaly”
0 (0)

10 Липня 2025

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

Отже, після апгрейду victoria-metrics-k8s-stack 0.55.0 => 0.56.0 в логах Operator з’явились помилки:

...
{"logger":"controller-runtime.source.EventHandler","msg":"if kind is a CRD, it should be installed before calling Start","kind":"VMAnomaly.operator.victoriametrics.com","error":"no matches for kind \"VMAnomaly\" in version \"operator.victoriametrics.com/v1\""}
...
{"logger":"setup","msg":"cannot setup manager","error":"cannot start controller manager: failed to wait for vmanomaly caches to sync kind source: *v1.VMAnomaly: timed out waiting for cache to be synced for 
Kind *v1.VMAnomaly"}
...

Скоріш за все через додавання нового ресурсу VMAnomaly в v0.60.0 оператора.

Перевіряємо наявні CRD:

$ kk get crd | grep victoriametrics
vlclusters.operator.victoriametrics.com                 2025-06-17T13:13:06Z
vlogs.operator.victoriametrics.com                      2025-06-10T09:39:22Z
vlsingles.operator.victoriametrics.com                  2025-06-17T13:13:07Z
vmagents.operator.victoriametrics.com                   2025-06-10T09:39:23Z
vmalertmanagerconfigs.operator.victoriametrics.com      2025-06-10T09:39:23Z
vmalertmanagers.operator.victoriametrics.com            2025-06-10T09:39:23Z
vmalerts.operator.victoriametrics.com                   2025-06-10T09:39:22Z
vmauths.operator.victoriametrics.com                    2025-06-10T09:39:22Z
vmclusters.operator.victoriametrics.com                 2025-06-10T09:39:23Z
vmnodescrapes.operator.victoriametrics.com              2025-06-10T09:39:22Z
vmpodscrapes.operator.victoriametrics.com               2025-06-10T09:39:23Z
vmprobes.operator.victoriametrics.com                   2025-06-10T09:39:22Z
vmrules.operator.victoriametrics.com                    2025-06-10T09:39:22Z
vmscrapeconfigs.operator.victoriametrics.com            2025-06-10T09:39:23Z
vmservicescrapes.operator.victoriametrics.com           2025-06-10T09:39:22Z
vmsingles.operator.victoriametrics.com                  2025-06-10T09:39:23Z
vmstaticscrapes.operator.victoriametrics.com            2025-06-10T09:39:23Z
vmusers.operator.victoriametrics.com                    2025-06-10T09:39:22Z

Очікувано, VMAnomaly нема.

Найкращим варіантом було б просто встановити всі CRD з окремого чарту victoriametrics-operator-crds (який під капотом просто встановлює CRD із файлу victoria-metrics-operator/charts/crds/crds/crd.yaml)

Але “так історично склалося”, що CRD у нас вже встановлені вручну (може, тоді не було окремого чарту?), тому найпростіший варіант – просто оновити їх напрямую з файлу:

$ kk apply -f https://raw.githubusercontent.com/VictoriaMetrics/helm-charts/refs/heads/master/charts/victoria-metrics-operator/charts/crds/crds/crd.yaml
...
customresourcedefinition.apiextensions.k8s.io/vmanomalies.operator.victoriametrics.com created
...

Перевіряємо CRD в кластері тепер:

$ kk get crd | grep anomal
vmanomalies.operator.victoriametrics.com                2025-07-09T14:27:43Z

Готово, все працює.

Loading