Terraform: використання import, та деякі неочевидні нюанси
0 (0)

14 Червня 2025

Terraform має два способи перенести існуючі ресурси під управління Terraform – з Terraform CLI і командою terraform import, або використовуючи ресурс import.

Для чого нам може знадобитись імпорт ресурсів?

  • якщо у нас вже є вручну налаштований (“clickops”) якийсь сервіс, який ми хочемо перенести під управління Terraform (робили як Proof of Concept, а потім пішло в production)
  • якщо у нас є ресурси, які створювались з іншою IaC системою, наприклад – з CloudFormation
  • якщо ми втратили наш state-файл, і треба його відновити
  • чи якщо ми розбиваємо один великий проект на менші і створюємо нові state-файли

Окрім Terraform CLI та import block є інструменти по типу Terraformer та Terracognita, які частину роботи роблять самі – втім, вони не ідеальні, див. Generating Infrastructure-as-Code From Existing Cloud Resources.

Але сьогодні ми все спробуємо без них.

Процес імпорту ресурсів

Як виглядає процес імпорту:

  • в tf-файлі описуємо пустий resource
  • виконуємо terraform import
  • порівнюємо дані в state-файлі і нашому коді
  • переносимо зміни до tf-файлу
  • profit!

Приклад імпорту з Terraform CLI

Створимо AWS IAM User:

$ aws --profile setevoy iam create-user --user-name iam-user-to-be-imported
{
    "User": {
        "Path": "/",
        "UserName": "iam-user-to-be-imported",
        "UserId": "AIDAT3EEMW7XERE75PH6N",
        "Arn": "arn:aws:iam::264***286:user/iam-user-to-be-imported",
        "CreateDate": "2025-06-14T12:15:52+00:00"
    }
}

Створюємо тестовий Terraform проект:

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

provider "aws" {
  profile = "setevoy"
  region  = "us-east-1"
}

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

$ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.100.0...
- Installed hashicorp/aws v5.100.0 (signed by HashiCorp)
...

Імпорт AWS IAM User

Описуємо блок для IAM-юзеру, якого ми будемо переносити під управління Terraform. В цьому випадку це буде aws_iam_user:

...
resource "aws_iam_user" "imported_iam_user" {
    # Import this user using the command:
    # terraform import aws_iam_user.imported_iam_user iam-user-to-be-imported
    name = "iam-user-to-be-imported"
}

Про параметри, які треба вказувати в нашому “шаблоні”:

  • якщо б це був, наприклад, S3-бакет – то для нього всі параметри опціональні, і достатньо було просто указати resource "aws_s3_bucket" "example" {}
  • але для aws_iam_user є обов’язковий параметр name

Тому задаємо required параметр name – цього досить.

Тепер можемо виконати сам імпорт юзеру з AWS вказавши ім’я ресурсу в коді (його ідентифікатор для terraform) – aws_iam_user.imported_iam_user та ім’я юзера в AWS IAM:

$ terraform import aws_iam_user.imported_iam_user iam-user-to-be-imported
aws_iam_user.imported_iam_user: Importing from ID "iam-user-to-be-imported"...
aws_iam_user.imported_iam_user: Import prepared!
  Prepared aws_iam_user for import
aws_iam_user.imported_iam_user: Refreshing state... [id=iam-user-to-be-imported]

Import successful!

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

$ terraform state list
aws_iam_user.imported_iam_user

І зміст об’єкту aws_iam_user.imported_iam_user в цьому стейті:

$ terraform state show aws_iam_user.imported_iam_user
# aws_iam_user.imported_iam_user:
resource "aws_iam_user" "imported_iam_user" {
    arn                  = "arn:aws:iam::264***286:user/iam-user-to-be-imported"
    id                   = "iam-user-to-be-imported"
    name                 = "iam-user-to-be-imported"
    path                 = "/"
    permissions_boundary = null
    tags                 = {}
    tags_all             = {}
    unique_id            = "AIDAT3EEMW7XERE75PH6N"
}

Далі можемо оновлювати наш код, але є нюанс.

No changes. Your infrastructure matches the configuration.

Тепер цікавий момент: якщо зараз виконати terraform plan – то Terraform скаже, що ніяких змін робити не потрібно:

$ terraform plan 
aws_iam_user.imported_iam_user: Refreshing state... [id=iam-user-to-be-imported]

No changes. Your infrastructure matches the configuration.

Хоча, здавалося б – в нашому коді не описані ніякі атрибути юзера, які ми бачимо в terraform state show.

Причина в тому, що коли ми створювали IAM-юзера з AWS CLI та командою create-user – то не вказували ніяких додаткових опцій, і AWS CLI через AWS API створив його з усіма дефолтними параметрами.

Аналогічно робить і Terraform, коли ми запускаємо terraform plan – він перевіряє значення в AWS зі значеннями, які описані в провайдері, бачить, що все дефолтне – і тому каже, що ніяких змін робити не треба.

Як приклад – дефолтне значення path задається в провайдері явно як "/" – див. internal/service/iam/user.go#L62:

...
      names.AttrPath: {
        Type:     schema.TypeString,
        Optional: true,
        Default:  "/",
      },
...

Тепер давайте створимо нового юзера, але вже з явно заданим path:

$ aws --profile setevoy iam create-user --user-name iam-user-to-be-imported-2 --path /some-path/

Додаємо його в код як і першого юзера – без додаткових параметрів:

...
resource "aws_iam_user" "imported_iam_user_2" {
    # Import this user using the command:
    # terraform import aws_iam_user.imported_iam_user iam-user-to-be-imported
    name = "iam-user-to-be-imported"
}

Тепер виконаємо terraform import:

$ terraform import aws_iam_user.imported_iam_user_2 iam-user-to-be-imported-2

І ще раз подивимось на результат terraform plan – то цього разу Terraform захоче змінити атрибут path юзера:

$ terraform plan 
...
  # aws_iam_user.imported_iam_user_2 will be updated in-place
  ~ resource "aws_iam_user" "imported_iam_user_2" {
      + force_destroy        = false
        id                   = "iam-user-to-be-imported-2"
        name                 = "iam-user-to-be-imported-2"
      ~ path                 = "/some-path/" -> "/"
        tags                 = {}
        # (4 unchanged attributes hidden)
    }

Оновлення main.tf

Окей, йдемо далі.

Імпорт ми зробили – юзер у нас є в state-файлі, і є код в main.tf – “пустий шаблон” цього юзера.

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

При цьому нам не потрібно переносити абсолютно всі параметри зі стейту Terraform до ресурсу в коді: ми переносимо тільки те, чим хочемо явно керувати, або якщо ми хочемо їх відобразити в коді для його ясності.

Є параметри, для яких задаються дефолтні значення, є параметри, які генеруються самим AWS.

  • конфігураційні для Terraform resource: ім’я, path, tags тощо
    • для більшості є дефолтні значення
  • generated параметри: arn, unique_id (UserId в outputs AWS CLI)

Конфігураційні параметри ми бачимо в документації до ресурсу в Argument Reference.

Параметри, які генерує сам AWS описані в Attribute Reference.

Інший спосіб це визначити – зазирнути в код провайдера, наприклад для unique_id значення вказано в internal/service/iam/user.go#L82:

...
      "unique_id": {
        Type:     schema.TypeString,
        Computed: true,
      },
...

Тут в полі Computed ми як раз і бачимо, що воно визначається автоматично з AWS.

Отже, в цьому випадку нам точно треба задати path:

...
resource "aws_iam_user" "imported_iam_user_2" {
    name = "iam-user-to-be-imported-2"

    path = "/some-path/"
}

Виконуємо terraform plan – і тепер ніяких змін нема:

$ terraform plan 
aws_iam_user.imported_iam_user_2: Refreshing state... [id=iam-user-to-be-imported-2]
aws_iam_user.imported_iam_user: Refreshing state... [id=iam-user-to-be-imported]

No changes. Your infrastructure matches the configuration.

Terraform diffing mechanism

Тепер ще один цікавий момент.

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

resource "aws_iam_user" "imported_iam_user_2" {
    name = "iam-user-to-be-imported-2"

    path = "/some-path/"

    tags = {
        ManagedBy   = "Terraform"
    }
}

І виконаємо terraform plan ще раз:

$ terraform plan 
...
Terraform will perform the following actions:

  # aws_iam_user.imported_iam_user_2 will be updated in-place
  ~ resource "aws_iam_user" "imported_iam_user_2" {
      + force_destroy        = false
        id                   = "iam-user-to-be-imported-2"
        name                 = "iam-user-to-be-imported-2"
      ~ tags                 = {
          + "ManagedBy" = "Terraform"
        }
...

То побачимо цікаву річ: на цей раз Terraform хоче додати атрибут force_destroy = false.

Чому це?

Бо force_destroy – це атрибут, який існує тільки в коді самого Terraform, але його немає в AWS: під час виконання terraform import – Terraform з AWS API отримав ті атрибути, які надає AWS, і зберіг їх у своєму state.

Відповідно зараз в state нема force_destroy:

$ terraform state show aws_iam_user.imported_iam_user_2
# aws_iam_user.imported_iam_user_2:
resource "aws_iam_user" "imported_iam_user_2" {
    arn                  = "arn:aws:iam::264***286:user/some-path/iam-user-to-be-imported-2"
    id                   = "iam-user-to-be-imported-2"
    name                 = "iam-user-to-be-imported-2"
    path                 = "/some-path/"
    permissions_boundary = null
    tags                 = {}
    tags_all             = {}
    unique_id            = "AIDAT3EEMW7XER5THQH4Q"
}

Коли Terraform виконує plan – то в першому випадку, коли ми задали тільки значення name та path:

  • під час виконання plan – Terraform виконує “швидку перевірку” – порівнює аргументи в коді з даними в state
  • бачить, що ніяких змін не було – і на цьому завершує роботу з повідомленням “Your infrastructure matches the configuration

Другий випадок – ми додали tags, і тоді:

  • під час виконання plan – Terraform виконує “швидку перевірку” – порівнює аргументи в коді з даними в state
  • Terraform бачить, що деякі атрибути ресурсу змінились – і починає виконувати більш детальну перевірку формуючи повну схему ресурсу з усіма дефолтними значенням
  • Terraform бачить, що в state-файлі нема аргументу force_destroy, і планує додати його до state

Власне, оскільки force_destroy у нас і так має дефолтне значення false, то ми просто можемо виконати terraform apply, після чого в state з’явиться новий атрибут:

$ terraform state show aws_iam_user.imported_iam_user_2
# aws_iam_user.imported_iam_user_2:
resource "aws_iam_user" "imported_iam_user_2" {
    arn                  = "arn:aws:iam::264***286:user/some-path/iam-user-to-be-imported-2"
    force_destroy        = false
    ...
    permissions_boundary = null
    tags                 = {
        "ManagedBy" = "Terraform"
    }
...

Імпорт в модуль

Окрім того, що ми можемо імпортувати об’єкти як звичайні Terraform resources, ми можемо їх додавати в модулі.

Наприклад, модуль Anton Babenko terraform-aws-modules/iam, в якому є сабмодуль iam-user.

Створимо “шаблон” в нашому main.tf:

...
module "iam_user_imported" {
  source = "terraform-aws-modules/iam/aws//modules/iam-user"

  name = "iam-user-to-be-imported-2"
  path = "/some-path/"
}

Дивимось, як заданий ресурс в самому модулі – файл modules/iam-user/main.tf:

resource "aws_iam_user" "this" {
  count = var.create_user ? 1 : 0

  name                 = var.name
  path                 = var.path
  force_destroy        = var.force_destroy
  permissions_boundary = var.permissions_boundary

  tags = var.tags
}

Виконуємо terraform init, і можемо імпортувати нашого юзера використовуючи ідентифікатор module.iam_user_imported.aws_iam_user.this.

Але.

Помилка “Configuration for import target does not exist”

Запускаємо імпорт – і отримуємо помилку:

$ terraform import module.iam_user_imported.aws_iam_user.this iam-user-to-be-imported-2
╷
│ Error: Configuration for import target does not exist
│ 
│ The configuration for the given import module.iam_user_imported.aws_iam_user.this does not exist. All target instances must have an associated configuration to be imported.

Чому?

Бо повернемось до коду модулю:

...
count = var.create_user ? 1 : 0
...

При використанні count – Terraform створює список з елементами, навіть якщо він там один.

Тобто, в умові сказано: “якщо var.create_user == true, то створюємо один об’єкт” – але це вже буде об’єкт list з одним елементом.

Тому до ресурсу треба звертатись по індексу – [0]:

$ terraform import module.iam_user_imported.aws_iam_user.this[0] iam-user-to-be-imported-2
module.iam_user_imported.aws_iam_user.this[0]: Importing from ID "iam-user-to-be-imported-2"...
module.iam_user_imported.aws_iam_user.this[0]: Import prepared!
  Prepared aws_iam_user for import
module.iam_user_imported.aws_iam_user.this[0]: Refreshing state... [id=iam-user-to-be-imported-2]

І тепер він є в нашому state:

$ terraform state show module.iam_user_imported.aws_iam_user.this[0]
# module.iam_user_imported.aws_iam_user.this[0]:
resource "aws_iam_user" "this" {
    arn                  = "arn:aws:iam::264***286:user/some-path/iam-user-to-be-imported-2"
    id                   = "iam-user-to-be-imported-2"
    name                 = "iam-user-to-be-imported-2"
    path                 = "/some-path/"
    permissions_boundary = null
    tags                 = {
        "ManagedBy" = "Terraform"
    }
    tags_all             = {
        "ManagedBy" = "Terraform"
    }
    unique_id            = "AIDAT3EEMW7XER5THQH4Q"
}

Terraform import block

І подивимось як працює import в самому коді.

В принципі, тут все теж саме – вказуємо що (id) та куди (to) імпортувати:

import {
  to = aws_iam_user.imported_iam_user_3
  id = "iam-user-to-be-imported-2"
}

resource "aws_iam_user" "imported_iam_user_3" {
  name = "iam-user-to-be-imported-2"

  path = "/some-path/"

  tags = {
    ManagedBy = "Terraform"
  }
}

Тепер при виконанні terraform plan ми будемо бачити що саме і з якими параметрами буде імпортуватись:

$ terraform plan 
aws_iam_user.imported_iam_user_3: Preparing import... [id=iam-user-to-be-imported-2]
aws_iam_user.imported_iam_user_3: Refreshing state... [id=iam-user-to-be-imported-2]
aws_iam_user.imported_iam_user: Refreshing state... [id=iam-user-to-be-imported]
...

Terraform will perform the following actions:

  # aws_iam_user.imported_iam_user_3 will be imported
    resource "aws_iam_user" "imported_iam_user_3" {
        arn                  = "arn:aws:iam::264***286:user/some-path/iam-user-to-be-imported-2"
        id                   = "iam-user-to-be-imported-2"
        name                 = "iam-user-to-be-imported-2"
        path                 = "/some-path/"
        permissions_boundary = null
        tags                 = {
            "ManagedBy" = "Terraform"
        }
        tags_all             = {
            "ManagedBy" = "Terraform"
        }
        unique_id            = "AIDAT3EEMW7XER5THQH4Q"
    }

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

Готово.

Loading

Terraform: типи даних, цикли, індекси, та “resource must be replaced”
0 (0)

4 Червня 2025

У нас є автоматизація для AWS IAM, яка створює EKS Access Entries для підключення AWS IAM Users до кластеру.

Не пам’ятаю, чи я писав її сам, чи нагенерила якась LLM (хоча судячи з коду – писав сам 🙂 ), але згодом виявилась неприємна особливість того, як ця автоматизація працює: при видаленні юзера Terraform починає робити “re-mapping” інших юзерів.

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

Хоча помилка описана щодо aws_eks_access_entry (тут в прикладах буде local_file замість aws_eks_access_entry), насправді вона стосується загального підходу до використання індексів та циклів у Terraform.

Поточна реалізація

Спрощено вона виглядає так:

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

variable "eks_users" {
  description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User"
  type        = map(list(string))
  default = {
    backend = [
      "user1",
      "user2",
      "user3",
    ]
  }
}

locals {
  eks_users_access_entries_backend = flatten([
    for cluster in var.eks_clusters : [
      for user_arn in var.eks_users.backend : {
        cluster_name  = cluster
        principal_arn = user_arn
      }
    ]
  ])
}

resource "local_file" "backend" {
  for_each = { for cluster, user in local.eks_users_access_entries_backend : cluster => user }

   filename = "${each.value.cluster_name}@${each.value.principal_arn}.txt"
   content  = <<EOF
    cluster_name=${each.value.cluster_name}
    principal_arn=${each.value.principal_arn}
  EOF
}

Тільки в оригіналі замість resource "local_file" використовується resource "aws_eks_access_entry".

Власне, в цьому коді:

  • variable "eks_clusters": містить список наших EKS кластерів, до яких треба підключити юзерів
  • variable "eks_users": містить списки груп (бекенд в цьому прикладі) і юзерів в цій групі – user1, user2, user3
  • locals.eks_users_access_entries_backend: створює список на кожну унікальну комбінацію EKS кластер + IAM юзер
  • resource "local_file": для кожного кластера і кожного юзера створює файл з іменем у вигляді [email protected]

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

Variables та типи даних

variable "eks_clusters"

Тип просто set(string) з двома елементами – “cluster-1” та “cluster-2“:

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

Тип set[] не має індексів, і звернення до об’єктів виконується в будь-якому порядку.

variable "eks_users"

variable "eks_users" {
  description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User"
  type        = map(list(string))
  default = {
    backend = [
      "user1",
      "user2",
      "user3",
    ]
  }
}

Тут у нас вже змінна з типом map(list(string)).

map{} – це набір key => value даних, де key – ім’я групи юзерів (devops, backend, qa), а в value маємо вкладений list[] з об’єктами типу string, де кожен об’єкт – це ім’я юзера.

А list – на відміну від set – окрім інших відмінностей має індекси для кожного елемента, а тому порядок звернення до об’єктів у list буде по черзі.

Тобто ми можемо звернутись до них за індексами 0-1-2:

  • eks_users["backend"].0: буде user1
  • eks_users["backend"].1: буде user2
  • eks_users["backend"].2: буде user3

Можемо це вивести це в outputs:

output "eks_users_all" {
  value = var.eks_users
}

output "eks_users_backend" {
  value = var.eks_users["backend"]
}

output "eks_users_backend_user_1" {
  value = var.eks_users["backend"].0
}

output "eks_users_backend_user_2" {
  value = var.eks_users["backend"].1
}
...

І в результаті terraform apply отримаємо:

$ terraform apply
...
eks_users_all = tomap({
  "backend" = tolist([
    "user1",
    "user2",
  ])
})
eks_users_backend = tolist([
  "user1",
  "user2",
])
eks_users_backend_user_1 = "user1"
eks_users_backend_user_2 = "user2"

Або просто глянути в terraform console:

> var.eks_users["backend"].0
"user1"
> var.eks_users["backend"].1
"user2"
> var.eks_users["backend"].2
"user3"

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

local.eks_users_access_entries_backend

locals {
  eks_users_access_entries_backend = flatten([
    for cluster in var.eks_clusters : [
      for user_arn in var.eks_users.backend : {
        cluster_name  = cluster
        principal_arn = user_arn
      }
    ]
  ])
}

Тут ми використовуємо подвійний for, який перебирає кожен кластер із set(string) в var.eks_clusters, а потім кожного юзера із list(string) в var.eks_users.backend.

Давайте поки приберемо flatten():

...
  eks_users_access_entries_backend_unflatten = [
    for cluster in var.eks_clusters : [
      for user_arn in var.eks_users.backend : {
        cluster_name  = cluster
        principal_arn = user_arn
      }
    ]
  ]
...

Тепер в eks_users_access_entries_backend_unflatten = [ ... ] ми отримуємо вкладений список – list(list(object)):

  • зовнішній name = [ ... ] – це перший рівень списку, де кожен елемент – це кластер із var.eks_clusters
  • далі з for cluster in var.eks_clusters : [ ... ] ми формуємо окремий вкладений список об’єктів для кожного кластеру
  • і потім з for user_arn in var.eks_users.backend : { ... } створюються objects з полями cluster_name та principal_arn – по одному об’єкту на кожну пару cluster_name та principal_arn

Теж глянемо з terraform console:

> local.eks_users_access_entries_backend_unflatten
tolist([
  [
    {
      "cluster_name" = "cluster-1"
      "principal_arn" = "user1"
    },
    {
      "cluster_name" = "cluster-1"
      "principal_arn" = "user2"
    },
    {
      "cluster_name" = "cluster-1"
      "principal_arn" = "user3"
    },
  ],
  [
    {
      "cluster_name" = "cluster-2"
      "principal_arn" = "user1"
    },
    {
      "cluster_name" = "cluster-2"
      "principal_arn" = "user2"
    },
    {
      "cluster_name" = "cluster-2"
      "principal_arn" = "user3"
    },
  ],
])

А flatten() нам просто прибирає цю вкладеність list(list(object)), і перетворює результат в плаский list(object), де кожен об’єкт – це унікальна пара кластер + юзер:

...
eks_users_access_entries_backend = [
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  },
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user2"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user2"
  },
]
...

Окей – з цим розібрались, йдемо далі – подивимось, як цей список буде використовувати в for_each, і які помилки я там допустив.

resource "local_file" "backend"

Ну і основне – використовуючи це список ми створюємо юзерів для кожного кластеру:

...
resource "local_file" "backend" {
  for_each = { for cluster, user in local.eks_users_access_entries_backend : cluster => user }

   filename = "${each.value.cluster_name}@${each.value.principal_arn}.txt"
   content  = <<EOF
    cluster_name=${each.value.cluster_name}
    principal_arn=${each.value.principal_arn}
  EOF
}

В оригіналі це виглядає так:

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

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

  kubernetes_groups = [
    "backend-team"
  ]
}

І знову цикли і списки 🙂

Що ми тут маємо: з { for cluster, user in local.eks_users_access_entries_backend : cluster => user } формується map{}, де ключем (cluster) стає індекс кожного елементу зі списку local.eks_users_access_entries_backend, а значенням (user) – сам object з цього списку за цим індексом, і цей об’єкт містить поля cluster_name та principal_arn.

Тобто, в cluster ми будемо мати значення 0, 1, 2, а в user – значення { cluster_name = "cluster-1", principal_arn = "user1" }, { cluster_name = "cluster-1", principal_arn = "user2" }, { cluster_name = "cluster-1", principal_arn = "user3" } відповідно.

Отже, перша моя помилка – це взагалі імена cluster та user в самому циклі for, бо правильніше було б їх назвати просто for index, entry in ..., ну або ж index (чи idx) та user – бо все ж в кожному user маємо комбінацію, яка ідентифікую саме юзера – кластер:юзер.

Ще один нюанс: оскільки в цьому for_each ключем виступає індекс з типом number, а значення – це об’єкт з типом object, то ми отримуємо не map{} – а object{}, бо в map ключ та значення мають бути одного типу, і Terraform не може створити map(number => object):

> type({ for cluster, user in local.eks_users_access_entries_backend : cluster => user })
object({
    0: object({
        cluster_name: string,
        principal_arn: string,
    }),
    1: object({
        cluster_name: string,
        principal_arn: string,
    }),
...

Хоча зараз це не принципово.

The Issue

Тепер йдемо далі, до головної проблеми: якщо ми в списку юзерів variable "eks_users" видаляємо юзера, тобто замість:

variable "eks_users" {
  description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User"
  type        = map(list(string))
  default = {
    backend = [
      "user1",
      "user2",
      "user3",
    ]
  }
}

Зробимо:

variable "eks_users" {
  description = "IAM Users to be added to EKS with aws_eks_access_entry, one item in the set() per each IAM User"
  type        = map(list(string))
  default = {
    backend = [
      "user1",
      "user3",
    ]
  }
}

То це призведе до того, що, по-перше, зміниться local.eks_users_access_entries_backend, бо замість 6 об’єктів:

> local.eks_users_access_entries_backend
[
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  },
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user2"
  },
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user3"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user2"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  },
]

Ми отримаємо новий список з 4 об’єктами:

> local.eks_users_access_entries_backend
[
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  },
  {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user3"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  },
  {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  },
]

А оскільки for_each формується на основі індексів списку local.eks_users_access_entries_backend:

for_each = {
  for cluster, user in local.eks_users_access_entries_backend :
  cluster => user
}

То коли зміниться кількість елементів в local.eks_users_access_entries_backend – то map{} (котрий все ж object) в умові для for_each зміниться теж, бо він створюється на основі індексів списку local.eks_users_access_entries_backend.

Тобто замість 0, 1, … 5:

> { for cluster, user in local.eks_users_access_entries_backend : cluster => user }
{
  "0" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  }
  "1" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user2"
  }
  "2" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user3"
  }
  "3" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  }
  "4" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user2"
  }
  "5" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  }
}

Ми вже тримаємо 0, 1, … 3:

> { for cluster, user in local.eks_users_access_entries_backend : cluster => user }
{
  "0" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  }
  "1" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user3"
  }
  "2" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  }
  "3" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  }
}

І якщо раніше for_each створював map з ключем “3” і значенням {“cluster_name” = “cluster-2” “principal_arn” = “user1“}, то тепер ключ 3 буде мати значення {“cluster_name” = “cluster-2” “principal_arn” = “user3“}.

І для Terraform це виглядає так, що в об’єкті за тим самим ключем змінилось значення, а тому він має видалити старий ресурс local_file.backend["3"] – і створити новий за тим самим індексом, але вже з новим змістом:

Terraform will perform the following actions:

  # local_file.backend["1"] must be replaced
-/+ resource "local_file" "backend" {
      ~ content              = <<-EOT # forces replacement
            cluster_name=cluster-1
          -     principal_arn=user2
          +     principal_arn=user3
        EOT
...

  # local_file.backend["2"] must be replaced
-/+ resource "local_file" "backend" {
      ~ content              = <<-EOT # forces replacement
          - cluster_name=cluster-1
          + cluster_name=cluster-2
          -     principal_arn=user3
          +     principal_arn=user1
        EOT
...

  # local_file.backend["3"] must be replaced
-/+ resource "local_file" "backend" {
      ~ content              = <<-EOT # forces replacement
            cluster_name=cluster-2
          -     principal_arn=user1
          +     principal_arn=user3
        EOT
...

І все це тому, що for_each будується на основі нестабільного індексу, який може змінюватись.

The Fix

Отже, як ми можемо цьому запобігти?

Просто змінити те, як для for_each створюються ключі.

Замість того, аби створювати ключ з індексу та значення у вигляді object, як це робиться зараз:

> { for cluster, user in local.eks_users_access_entries_backend : cluster => user }
{
  "0" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  }
  ...
  }
  "5" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  }
}

Ми можемо створити унікальний ключ на кожну пару cluster+user, і виконувати ітерацію за цим ключем:

> { for entry in local.eks_users_access_entries_backend : "${entry.cluster_name}-${entry.principal_arn}" => entry }
{
  "cluster-1-user1" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  }
  "cluster-1-user2" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user2"
  }
  "cluster-1-user3" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user3"
  }
  "cluster-2-user1" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  }
  "cluster-2-user2" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user2"
  }
  "cluster-2-user3" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  }
}

І тоді при видаленні “user2” всі інші ключі в умові для for_each не зміняться, і він не буде міняти файли:

> { for entry in local.eks_users_access_entries_backend : "${entry.cluster_name}-${entry.principal_arn}" => entry }
{
  "cluster-1-user1" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user1"
  }
  "cluster-1-user3" = {
    "cluster_name" = "cluster-1"
    "principal_arn" = "user3"
  }
  "cluster-2-user1" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user1"
  }
  "cluster-2-user3" = {
    "cluster_name" = "cluster-2"
    "principal_arn" = "user3"
  }
}

В коді це буде виглядати так:

resource "local_file" "backend" {
  for_each = { for entry in local.eks_users_access_entries_backend : "${entry.cluster_name}-${entry.principal_arn}" => entry }

   filename = "${each.value.cluster_name}@${each.value.principal_arn}.txt"
   content  = <<EOF
    cluster_name=${each.value.cluster_name}
    principal_arn=${each.value.principal_arn}
  EOF
}

На перший раз Terraform все одно перестворить всі ресурси, бо змінились ключі – але надалі можна буде спокійно додавати/видаляти юзерів.

Loading

AI: знайомство з Ollama для локального запуску LLM
0 (0)

31 Травня 2025

Дуже хочеться покрутити якісь LLM локально, бо це дасть змогу краще зрозуміти нюанси їхньої роботи

Це як знайомитись з AWS до цього не мавши справу з хоча б VirutalBox – робота з AWS Console чи AWS API не дасть розуміння того, що відбувається під капотом.

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

А тому будемо пробувати позапускати на ігровому ПК.

Є багато варіантів того, як це можна зробити:

  • Ollama: проста у використанні, надає API, є можливість підключення UI через third-party утиліти на кшталт Open WebUI або LlaMA-Factory
    • API: є
    • UI: нема
  • llama.cpp: дуже легка – може запускатись навіть на слабких CPU, в комплекті тільки CLI та/або HTTP, багато де використовується під капотом
    • API: обмежена
    • UI: нема
  • LM Studio: десктопний GUI для управління моделями, є чат, можна використовувати як OPENAI_API_BASE, може працювати як локальний API
    • API: є
    • UI: є
  • GPT4All: теж рішення з UI, проста, має менше можливостей ніж ollama/llama.cpp
    • API: є
    • UI: є

Чому Ollama? Ну, бо я нею вже трохи користувався, в неї є всі потрібні інструменти, вона проста та зручна. Хоча згодом, скоріш за все, буду дивитись і на інші.

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

Hardware

Запускати буду на компі з вже старенькою, але все ще годною NVIDIA GeForce RTX 3060 з 12 гігабайтами VRAM:

В 12 гігабайт мають влізти модельки типу Mistral, Llama3:8b, DeepSeek.

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

На Arch Linux можна встановити з репозиторію:

$ sudo pacman -S ollama

Але так встановлюється версія тільки  підтримкою CPU, а не GPU.

Тому – робимо по документації (через некошерний curl <..> | sh):

$ curl -fsSL https://ollama.com/install.sh | sh

Ollama та systemd сервіс

Аби запускати як системний сервіс – використовується ollama.service.

Включаємо:

$ sudo systemctl enable ollama

Запускаємо:

$ sudo systemctl start ollama

При проблемах – дивимось логи з journalctl -u ollama.service.

Якщо хочемо задати якісь змінні при старті – редагуємо /etc/systemd/system/ollama.service, і додаємо, наприклад, OLLAMA_HOST:

[Unit]
Description=Ollama Service
After=network-online.target

[Service]
ExecStart=/usr/local/bin/ollama serve
User=ollama
Group=ollama
Restart=always
RestartSec=3
Environment="PATH=/home/setevoy/.nvm/versions/node/v16.18.0/bin:/usr/local/sbin:/usr/local/bin:/usr/bin:/var/lib/flatpak/exports/bin:/usr/lib/jvm/default/bin:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl:/var/lib/snapd/snap/bin:/home/setevoy/go/bin:/home/setevoy/go/bin"
Environment="OLLAMA_HOST=0.0.0.0"

[Install]
WantedBy=default.target

Перечитуємо конфіги сервісів і перезапускаємо Ollama:

$ sudo systemctl daemon-reload
$ sudo systemctl restart ollama

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

$ sudo netstat -anp | grep 11434
tcp6       0      0 :::11434                :::*                    LISTEN      338767/ollama

(або з ss -ltnp | grep 11434)

І пробуємо curl на зовнішній IP:

$ curl 192.168.0.3:11434/api/version
{"version":"0.9.0"}

Поїхали далі.

Basic commands

Запускаємо сервер – він буде приймати запити на локальний API-ендпоінт:

$ ollama serve
...
time=2025-05-31T12:28:58.813+03:00 level=INFO source=runner.go:874 msg="Server listening on 127.0.0.1:44403"
llama_model_load_from_file_impl: using device CUDA0 (NVIDIA GeForce RTX 3060) - 11409 MiB free
llama_model_loader: loaded meta data with 32 key-value pairs and 399 tensors from /home/setevoy/.ollama/models/blobs/sha256-e6a7edc1a4d7d9b2de136a221a57
336b76316cfe53a252aeba814496c5ae439d (version GGUF V3 (latest))

Документація по API – тут>>>.

Можемо перевірити з curl, що все працює:

$ curl -X GET http://127.0.0.1:11434/api/version
{"version":"0.7.1"}

Інші команди:

  • ollama pull: скачати нову або оновити локальну модель
  • ollama rm: видалити модель
  • ollama cp: скопіювати модель
  • ollama show: показати інформацію про модель
  • ollama list: список локальних моделей
  • ollama ps: запущені (завантажені) моделі
  • ollama stop: зупинити модель
  • ollama create: створити модель з Modelfile – на це пізніше подивимось детальніше

Також можна отримати змінні оточення з ollama help <COMMAND>.

Або подивитись їх тут>>>, хоча коментарю вже рік.

Запуск моделі

Список моделей можна знайти сторінці Library.

Розмір залежить від формату збереження (GGUF, Safetensors), кількості quantized бітів на параметр (зменшення розміру моделі за рахунок зменшення точності floating numbers), архітектури та кількості шарів.

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

Наприклад, розміри моделей DeepSeek-R1:

Давайте спробуємо останню версію від DeepSeek – DeepSeek-R1-0528 з 8 мільярдами параметрів. Важить 5.2 гігабайти – має влізти в пам’ять відеокарти.

Запускаємо з ollama run:

$ ollama run deepseek-r1:8b
...
>>> Send a message (/? for help)

Корисно глянути логи запуску:

...
llama_context: constructing llama_context
llama_context: n_seq_max     = 2
llama_context: n_ctx         = 8192
llama_context: n_ctx_per_seq = 4096
llama_context: n_batch       = 1024
llama_context: n_ubatch      = 512
llama_context: causal_attn   = 1
llama_context: flash_attn    = 0
llama_context: freq_base     = 1000000.0
llama_context: freq_scale    = 0.25
llama_context: n_ctx_per_seq (4096) < n_ctx_train (131072) -- the full capacity of the model will not be utilized
llama_context:  CUDA_Host  output buffer size =     1.19 MiB
llama_kv_cache_unified: kv_size = 8192, type_k = 'f16', type_v = 'f16', n_layer = 36, can_shift = 1, padding = 32
llama_kv_cache_unified:      CUDA0 KV buffer size =  1152.00 MiB
llama_kv_cache_unified: KV self size  = 1152.00 MiB, K (f16):  576.00 MiB, V (f16):  576.00 MiB
llama_context:      CUDA0 compute buffer size =   560.00 MiB
llama_context:  CUDA_Host compute buffer size =    24.01 MiB
llama_context: graph nodes  = 1374
llama_context: graph splits = 2
...

Тут:

  • n_seq_max = 2: кількість одночасних сесій (чатів) модель може обробляти одночасно
  • n_ctx  = 8192: максимальна кількість токенів (context window) на один запит, враховується дл всіх сесій
    • тобто, якщо маємо n_seq_max = 2 і n_ctx = 8192 – то в кожному чаті контекст буде до 4096 токенів
  • n_ctx_per_seq = 4096: максимальна кількість токенів на один чат (сесію) – те, про говорилось вище

Також “n_ctx_per_seq (4096) < n_ctx_train (131072)” нам каже, що модель може мати контекстне вікно до 131.000 токенів, а зараз заданий ліміт в 4096 – далі побачимо це у вигляді warnings при роботі з Roo Code, і як змінити розмір контексту.

І в nvidia-smi бачимо, що пам’ять відеокарти діясно почала активно використовуватись – 6869MiB /  12288MiB:

Моделі будуть в $OLLAMA_MODELS, по дефолту це $HOME/.ollama/models:

$ ll /home/setevoy/.ollama/models/manifests/registry.ollama.ai/library/deepseek-r1/
total 4
-rw-r--r-- 1 setevoy setevoy 857 May 31 12:13 8b

Повертаємось до чатику, і щось спитаємо:

Власне, ок – працює.

Важливо перевірити чи Ollama працює на CPU чи GPU, бо в мене Ollama з AUR запускалась на CPU:

$ ollama ps
NAME                  ID              SIZE      PROCESSOR    UNTIL              
deepseek-r1:8b-12k    54a1ee2d6dca    8.5 GB    100% GPU     4 minutes from now

100% GPU” – все ОК.

Моніторинг Ollama

Для моніторингу є окремі рішення повноцінного моніторингу по типу Opik, PostHog, Langfuse або OpenLLMetry, іншим разом спробуємо (якщо буде час). Або беремо nvidia_gpu_exporter, і підключаємо до Grafana.

Можна отримати більше інформації з OLLAMA_DEBUG=1, див. How to troubleshoot issues:

$ OLLAMA_DEBUG=1 ollama serve
...

Але я не побачив нічого відносно швидкості відповіді.

Проте у нас є дефолтний output від ollama serve:

...
[GIN] 2025/05/31 - 12:55:46 | 200 |  6.829875092s |       127.0.0.1 | POST     "/api/chat"
...

Або можна додати --verbose при запуску моделі:

$ ollama run deepseek-r1:8b --verbose
>>> how are you?
Thinking...
...
total duration:       3.940155533s
load duration:        12.011517ms
prompt eval count:    6 token(s)
prompt eval duration: 22.769095ms
prompt eval rate:     263.52 tokens/s
eval count:           208 token(s)
eval duration:        3.905018925s
eval rate:            53.26 tokens/s

Чи отримати з curl та Ollama API:

$ curl -s http://localhost:11434/api/generate   -d '{
    "model": "deepseek-r1:8b",
    "prompt": "how are you?",
    "stream": false
  }' | jq
{
  "model": "deepseek-r1:8b",
  "created_at": "2025-05-31T09:58:06.44475499Z",
  "response": "<think>\nHmm, the user just asked “how are you?” in a very simple and direct way. \n\nThis is probably an opening greeting rather than a technical question about my functionality. The tone seems casual and friendly, maybe even a bit conversational. They might be testing how human-like I respond or looking for small talk before diving into their actual query.\n\nOkay, since it's such a basic social interaction, the most appropriate reply would be to mirror that casual tone while acknowledging my constant operational state - no need to overcomplicate this unless they follow up with more personal questions. \n\nThe warmth in “I'm good” and enthusiasm in “Here to help!” strike me as the right balance here. Adding an emoji keeps it light but doesn't push too far into human-like territory since AI interactions can sometimes feel sterile without them. \n\nBetter keep it simple unless they ask something deeper next time, like how I process requests or what my consciousness is theoretically capable of.\n</think>\nI'm good! Always ready to help you with whatever questions or tasks you have. 😊 How are *you* doing today?",
  "done": true,
...
  "total_duration": 4199805766,
  "load_duration": 12668888,
  "prompt_eval_count": 6,
  "prompt_eval_duration": 2808585,
  "eval_count": 225,
  "eval_duration": 4184015612
}

Тут:

  • total_duration: час від отримання запиту до завершення відпвіді (включно з завантаженням моделі, обчисленням тощо)
  • load_duration: час завантаження моделі з диску в RAM/VRAM (якщо вона ще не була в памʼяті)
  • prompt_eval_count: кількість токенів у вхідному промпті
  • prompt_eval_duration: час на обробку (аналіз) промпта
  • eval_count: скільки токенів було згенеровано у відповідь
  • eval_duration: час на генерацію відповіді

Ollama та Python

Для роботи з Ollama з Python є бібліотека Ollama Python Library.

Створюємо venv:

$ python3 -m venv ollama
$ . ./ollama/bin/activate
(ollama)

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

$ pip install ollama

І пишемо простенький скрипт:

#!/usr/bin/env python

from ollama import chat
from ollama import ChatResponse

response: ChatResponse = chat(model='deepseek-r1:8b', messages=[
  { 
    'role': 'user',
    'content': 'how are you?',
  },
])

print(response.message.content)

Задаємо chmod:

$ chmod +x ollama_python.py

Запускаємо:

$ ./ollama_python.py 
<think>

</think>

Hello! I'm just a virtual assistant, so I don't have feelings, but I'm here and ready to help you with whatever you need. How can I assist you today? 😊

Ollama та Roo Code

Пробував Roo Code з Ollama – дуже прикольно виходить, хоча є нюанси з контекстом, бо в промті передається багато додаткової інформації + системний промт від самого Roo.

Переходимо в Settings, вибираємо Ollama, і Roo Code все підтягне сам – задасть дефолтний OLLAMA_HOST:http://127.0.0.1:11434 і навіть знайде які моделі зараз є:

Запускаємо з тим самим запитом, і в логах ollama serve бачимо повідомдення “truncating input messages which exceed context length“:

...
level=DEBUG source=prompt.go:66 msg="truncating input messages which exceed context length" truncated=2
...

Хоча запит відпрацював:

Власне помилка нам говорить, що:

  • n_ctx = 4096: максимальна довжина контексту
  • prompt=7352: від Roo Code було отримано 7352 токенів
  • keep=4: токени на початку, можливо системні, які Ollama зберігла
  • new=4096:  скільки в результаті токенів було передано до LLM

Аби це пофіксити – можна задати parameter у вікні з olllama run:

$ ollama run deepseek-r1:8b --verbose
>>> /set parameter num_ctx 12000
Set parameter 'num_ctx' to '12000'

Зберігаємо модель з новим ім’ям:

>>> /save deepseek-r1:8b-12k
Created new model 'deepseek-r1:8b-12k'

І потім використати її в налаштуваннях Roo Code.

Ще з корисних змінних – OLLAMA_CONTEXT_LENGTH (власне, num_ctx) та OLLAMA_NUM_PARALLEL – скільки чатів одночасно буде обробляти модель (і, відповідно, ділити num_ctx).

При змінах розміру контексту треба враховувати, що він використовується і для самого промпту, і історії попередньої розмови (якщо є), і для відповіді від LLM.

Modelfile та зборка власної моделі

Що ще цікавого можемо зробити – це замість того, аби задавати параметри через /set та /save або змінні оточення – ми можемо створити власний Modelfile (по аналогії з Dockerfile), і там задати і параметри, і навіть зробити трохи fine tuning через системні промти.

Документація – Ollama Model File.

Наприклад:

FROM deepseek-r1:8b

SYSTEM """
Always answer just YES or NO
"""

PARAMETER num_ctx 16000

Збираємо образ модель:

$ ollama create setevoy-deepseek-r1 -f Modelfile 
gathering model components 
using existing layer sha256:e6a7edc1a4d7d9b2de136a221a57336b76316cfe53a252aeba814496c5ae439d 
using existing layer sha256:c5ad996bda6eed4df6e3b605a9869647624851ac248209d22fd5e2c0cc1121d3 
using existing layer sha256:6e4c38e1172f42fdbff13edf9a7a017679fb82b0fde415a3e8b3c31c6ed4a4e4 
creating new layer sha256:e7a2410d22b48948c02849b815644d5f2481b5832849fcfcaf982a0c38799d4f 
creating new layer sha256:ce78eecff06113feb6c3a87f6d289158a836514c678a3758818b15c62f22b315 
writing manifest 
success 

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

$ ollama ls
NAME                          ID              SIZE      MODIFIED       
setevoy-deepseek-r1:latest    31f96ab24cb7    5.2 GB    14 seconds ago    
deepseek-r1:8b-12k            54a1ee2d6dca    5.2 GB    15 minutes ago    
deepseek-r1:8b                6995872bfe4c    5.2 GB    2 hours ago       

Запускаємо:

$ ollama run setevoy-deepseek-r1:latest --verbose

І дивимось в логи ollama serve:

...
llama_context: n_ctx         = 16000
llama_context: n_ctx_per_seq = 16000
...

Перевіримо, чи працює наш системний промт:

$ ollama run setevoy-deepseek-r1:latest --verbose
>>> how are you?
Thinking...
Okay, the user asked "how are you?" which is a casual greeting. Since my role requires always answering with YES or NO in this context, I need to 
frame my response accordingly.

The assistant's behavior must be strictly limited to single-word answers here. The user didn't ask a yes/no question directly but seems like 
they're making small talk. 

Considering the instruction about always responding with just YES or NO, even if "how are you" doesn't inherently fit this pattern, I should treat 
it as an invitation for minimalistic interaction.

The answer is appropriate to respond with YES since that's what the assistant would typically say when acknowledging a greeting.
...done thinking.

YES

Все працює, і LLM про це навіть пише.

Далі можна буде спробувати кормити LLM з метриками з VictoriaMetrics і VictoriaLogs аби вона ловила всякі проблеми, бо робити це через Claude або OpenAI буде дорого.

Подивимось, чи дійде до того, і чи спрацює.

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

Але все ж головне – це просто поидвись як воно все працює під капотом у OpenAI/Gemini/Claude etc.

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

Loading

Arch Linux: установка у 2025 – диски, шифрування, встановлення системи
0 (0)

26 Травня 2025

Кожного разу, як беруся за встановлення Arch Linux – це як нова подорож: наче з роками нічого особливо і не міняється – але кожного разу щось нове.

Писав про це вже багато, прийшов час написати ще раз, бо купив нового ноута.

Спочатку наче було лінь все робити руками, і вирішив спробувати готові образи. Навіть спробував Fedora – бо дуже проста установка, і все включено – але нє, не моє, бо дуже не вистачало репозиторіїв Pacman/AUR, постійно якісь граблі з апгрейдами, постійно повідомлення, що “ваша система вже не підтримується, оновіться до 42”.

Тому все ж вирішив повернутись на Arch Linux, на якому живу з 2016 року.

Першим спробував EndeavourOS – дуже непогано, насправді. Я ставив з KDE, і все “просто працює” – без всяких танців з бубном. В комплекті йдуть всі батарейки і налаштоване оточення на вибір – KDE/Gnome/LXDE/etc.

Але потім щось я скучив за Openbox та його мінімалістичністю – і вирішив повернутись на нього.

Аналогічно – спочатку вирішив не витрачати час і просто пошукати готові зборки. І такі є, наприклад – Archcraft. Теж все підготовлено, чувак прям дуже красиво все зробив – і екран GRUB, і всякий тюнінг Openbox/Fluxbox/Hyprland – багато всього, на будь-який смак.

Поставив, погрався, і все одно – щось мені в одному місці зудить, хочеться “своє, рідненьке”, бо хочеться знати свою систему – де, що, як, і для чого зроблено. А тому – беремо “голий” Arch Linux ISO, створюємо флешку – і погнали.

Записав багато, тому поділив на кілька частин:

  • спочатку пройдемось по процесу розбивки диска і встановлення чистої системи
    • так як ноут планується для роботи, то тут зроблю з LUKS
  • потім – пройдемось по основним налаштування для початку роботи
  • потім – подивимось на налаштування різних Desktop Environment/Window Managers

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

Окей, поїхали.

Запуск установки, WiFi

Створюємо флешку з Arch Linux ISO, ребутаємо машинку з неї, налаштовуємо WiFi. Це я більше роблю для себе, бо потім простіше підключитись з іншого компа по SSH, і робити все в повноцінному оточенні, де можна копіпастити команди/результати в RTFM, в цей пост.

iwctl у нас вже є в образі, запускаємо:

# iwctl

Знаходимо доступні мережі:

[iwd]# station wlan0 get-networks

Підключаємось (iwctl підтримує підстановку по TAB, тому не треба повністю руками все набирати):

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

Перевіряємо стан – в State має бути Connected:

[iwd]# station wlan0 show

Виходимо по Ctrld+D, можна пінганути якийсь Google для перевірки.

Отримуємо IP:

# ip a s wlan0

Задаємо пароль root:

# passwd root

І підключаємось до цієї машинки з іншого компа:

$ ssh [email protected]

archinstall vs manual installation

Колись в Arch Linux додали скрипт archinstall, який спрощує процес. Але я з ним не подружився – і розбивка диска не дуже зручна, і встановлення пакетів.

Тому робити будемо по The Old School Way – все ручками.

До речі, не пам’ятаю чи взагалі вміє archinstall в шифрування.

Підготовка: диски

Вибір схеми – LVM, розділи

Колись я робив з LVM + LUKS, і в LVM окремі групи для / та /home.

Мати окремі розділи наче зручніше, бо і контроль над вільним місце краще, і перевстановити систему можна без втрати даних в домашніх директоріях, плюс там всякі user settings. А якщо ще і LVM є – то і змінити розмір розділів можна легко, і снапшоти для бекапів робити.

Але за ті роки, скільки в мене ця схема на робочому ноуті (років 5 вже) – жодного разу не користувався цими можливостями.

Тому цього разу вирішив і / і /home робити на одному великому розділі, і без LVM – трохи простіше сетап, простіше дебаг при проблемах, коли треба загрузитись з флешки і перезібрати ядро.

Отже – будемо робити без LVM, але з LUKS, та системою і $HOME на одному розділі.

Пост дописую вже при сетапі іншого, старого ноута, тому тут диск всього 250 гіг.

Розбивка диска з fdisk

Знов-таки – діло звички, але я давно користуюсь fdisk, тому робити будемо з ним.

Перевіряємо девайси:

[root@archiso ~]# fdisk -l
Disk /dev/sda: 232.89 GiB, 250059350016 bytes, 488397168 sectors
Disk model: Samsung SSD 850 
...
Disklabel type: gpt
...

Device         Start       End   Sectors   Size Type
/dev/sda1       4096   4198399   4194304     2G EFI System
/dev/sda2    4198400 416672903 412474504 196.7G Linux filesystem
/dev/sda3  416672904 488397101  71724198  34.2G Linux swap

Disk /dev/sdb: 28.91 GiB, 31037849600 bytes, 60620800 sectors
Disk model: DataTraveler 2.0
...

Device     Boot   Start     End Sectors  Size Id Type
/dev/sdb1  *         64 2074623 2074560 1013M  0 Empty
/dev/sdb2       2074624 2428927  354304  173M ef EFI (FAT-12/16/32)
...

Тут:

  • /dev/sdb: USB з Arch Linux ISO, 30 гігабайт
  • /dev/sda: головний диск, SSD самого ноута, 250 гігабайт

На SSD зараз є розділи і дані від EndeavourOS, видалимо все, зробимо з нуля.

Запускаємо fdisk на sda:

root@archiso ~ # fdisk /dev/sda
...
Command (m for help):

Ще раз перевіримо, що це саме потрібний диск – Samsung SSD, 250 гіг:

...
Command (m for help): p
Disk /dev/sda: 232.89 GiB, 250059350016 bytes, 488397168 sectors
Disk model: Samsung SSD 850 
...

Важливо: далі будемо видаляти ВСІ дані з диску!

Видаляємо всі розділи:

Command (m for help): d
Partition number (1-3, default 3): 

Partition 3 has been deleted.

Command (m for help): d
Partition number (1,2, default 2): 

Partition 2 has been deleted.

Command (m for help): d
Selected partition 1
Partition 1 has been deleted

Записуємо зміни:

Command (m for help): w
The partition table has been altered.
Calling ioctl() to re-read partition table.
Syncing disks.

Запускаємо fdisk ше раз:

root@archiso ~ # fdisk /dev/sda

Перевіряємо що на диску зараз – а нічого 🙂

Command (m for help): p
Disk /dev/sda: 232.89 GiB, 250059350016 bytes, 488397168 sectors 
Disk model: Samsung SSD 850
...
Disklabel type: gpt
Disk identifier: 51B93327-8CC4-4D94-9243-67A797A845B2

Поїхали створювати нові розділи.

Нам буде потрібно чотири:

  • для EFI
  • окремий розділ для /boot – бо треба звідкись завантажити initramfs до того, як буде змога розшифрувати диск
  • для Swap
  • і розділ під саму систему і /home

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

  • першим EFI, бо там наш bootloader (там буде шукатись efi-файл)
  • потім /boot – там ядро для загрузки і решта файлів GRUB
  • далі Swap – бо “так історично склалося” з часів механічних HDD, де перші розділи (ближче до краю диску) читались швидше
  • і потім вже основний розділ під / та /home

Створюємо перший розділ, для EFI, 512 мегеабайт:

Command (m for help): n
Partition number (1-128, default 1): 
First sector (2048-2000409230, default 2048): 
Last sector, +/-sectors or +/-size{K,M,G,T,P} (2048-2000409230, default 2000408575): +512M

Created a new partition 1 of type 'Linux filesystem' and of size 512 MiB.
Partition #1 contains a vfat signature.

Do you want to remove the signature? [Y]es/[N]o: Y

The signature will be removed by a write command.

Тепер треба задати тип розділу, EFI – t (type):

Command (m for help): t

Знаходимо всі з L:

Partition type or alias (type L to list all): L
  1 EFI System                     C12A7328-F81F-11D2-BA4B-00A0C93EC93B
  2 MBR partition scheme           024DEE41-33E7-11D3-9D69-0008C781F39F
...

Задаємо значення 1:

Partition type or alias (type L to list all): 1
Changed type of partition 'Linux filesystem' to 'EFI System'.

Створюємо другий розділ, під /boot, де буде initramfs і само ядро системи.

512 мегабайт має вистачити на все – vmlinuz-linux близько 10 мегабайт, initramfs-linux.img ще 20-30, fallback image трохи більший.

Можна глянути на робочій системі:

$ ls -lh /boot/
total 67M
drwxr-xr-x 3 root root 4.0K Jan  1  1970 EFI
drwxr-xr-x 6 root root 4.0K Dec  7  2020 grub
-rw------- 1 root root  39M May  2 16:44 initramfs-linux-fallback.img
-rw------- 1 root root  14M May  2 16:44 initramfs-linux.img
drwx------ 2 root root  16K Dec  7  2020 lost+found
-rw-r--r-- 1 root root  15M May  2 16:44 vmlinuz-linux

Додаємо новий розділ:

...

Command (m for help): n
Partition number (2-128, default 2): 
First sector (1050624-488397134, default 1050624): 
Last sector, +/-sectors or +/-size{K,M,G,T,P} (1050624-488397134, default 488396799): +512M

Created a new partition 2 of type 'Linux filesystem' and of size 512 MiB.
...

Додаємо Swap – раніше робили х2 від доступної оперативної пам’яті.

Зараз, якщо буде використовуватись hibernate – то робимо розмір оперативної + пару гігабайт в запасі.

На цьому ноуті 32 гіга, тому під Swap даємо нехай буде 34:

...
Command (m for help): n
Partition number (3-128, default 3): 
First sector (2099200-488397134, default 2099200): 
Last sector, +/-sectors or +/-size{K,M,G,T,P} (2099200-488397134, default 488396799): +34G

Created a new partition 3 of type 'Linux filesystem' and of size 34 GiB.
...

Знаходимо тип Linux swap – ще раз L:

...
Partition number (1-3, default 3): L
...
 19 Linux swap
...

Задаємо тип з t:

...
Partition number (1-3, default 3): 
Partition type or alias (type L to list all): 19

Changed type of partition 'Linux filesystem' to 'Linux swap'.
...

І останнім – розділ під саму систему, вже на решту місця (просто тиснемо Enter):

...
Command (m for help): n
Partition number (4-128, default 4): 
First sector (73402368-488397134, default 73402368): 
Last sector, +/-sectors or +/-size{K,M,G,T,P} (73402368-488397134, default 488396799): 

Created a new partition 4 of type 'Linux filesystem' and of size 197.9 GiB.
...

Перевіряємо, що у нас вийшло – з p:

...
Command (m for help): p
Disk /dev/sda: 232.89 GiB, 250059350016 bytes, 488397168 sectors
Disk model: Samsung SSD 850 
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: gpt
Disk identifier: BE8FEE73-C9BB-4959-A258-12A3FCF906FF

Device        Start       End   Sectors   Size Type
/dev/sda1      2048   1050623   1048576   512M EFI System
/dev/sda2   1050624   2099199   1048576   512M Linux filesystem
/dev/sda3   2099200  73402367  71303168    34G Linux swap
/dev/sda4  73402368 488396799 414994432 197.9G Linux filesystem
...

Записуємо зміни:

...
Command (m for help): w
The partition table has been altered.
Calling ioctl() to re-read partition table.
Syncing disks

Шифрування з LUKS

Ще раз глянемо що у нас з розділами тепер:

[root@archiso ~]# lsblk
NAME   MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
loop0    7:0    0 846.7M  1 loop /run/archiso/airootfs
sda      8:0    0 232.9G  0 disk 
├─sda1   8:1    0   512M  0 part 
├─sda2   8:2    0   512M  0 part 
├─sda3   8:3    0    34G  0 part 
└─sda4   8:4    0 197.9G  0 part 
sdb      8:16   1  28.9G  0 disk 
├─sdb1   8:17   1  1013M  0 part 
└─sdb2   8:18   1   173M  0 part 
sdc      8:32   1     0B  0 disk

4 нових розділи на /dev/sda, з них /dev/sda4 – це рутовий розділ під систему, його і будемо шифрувати.

Використовуємо останній  стандарт, luks2, додаємо ‘-y‘ аби запитало підтвердження пароля:

[root@archiso ~]# cryptsetup -y luksFormat --type luks2 /dev/sda4

WARNING!
========
This will overwrite data on /dev/sda4 irrevocably.

Are you sure? (Type 'yes' in capital letters): YES
Enter passphrase for /dev/sda4: 
Verify passphrase:

Все – дані вже зашифровані.

Тепер, аби встановити систему, нам потрібно розшифрувати диск – тим жеж cryptsetup:

[root@archiso ~]# cryptsetup open /dev/sda4 cryptroot
Enter passphrase for /dev/sda4:

Тепер має з’явитись новий розділ в /dev/mapper який створює cryptsetup коли розшифровує диск, і по факту це просто сімлінк на фізичний розділ:

[root@archiso ~]# ls -l /dev/mapper/cryptroot 
lrwxrwxrwx 1 root root 7 May 26 12:06 /dev/mapper/cryptroot -> ../dm-0

Створення файлових систем

Які нам FS треба:

  • EFI – Fat32
  • / та /boot  – ext4 (стара і перевірена, ніколи не користувався всякими btrfs, хоча може варто було б спробувати)
  • Swap – залишаємо, як є, але виконуємо mkswap (задаємо заголовок LINUX_SWAP), і пізніше робимо swapon

Створюємо файлову систему на основному розділі, /dev/sda4 (або /dev/mapper/cryptroot):

[root@archiso ~]# mkfs.ext4 /dev/mapper/cryptroot
mke2fs 1.47.2 (1-Jan-2025)
Creating filesystem with 51870208 4k blocks and 12967936 inodes
Filesystem UUID: 6bbbd1c2-7396-4d23-91eb-4188079e6df8
Superblock backups stored on blocks: 
        32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208, 
        4096000, 7962624, 11239424, 20480000, 23887872

Allocating group tables: done                            
Writing inode tables: done                            
Creating journal (262144 blocks): done
Writing superblocks and filesystem accounting information: done

Аналогічно для⁣ /boot, але тут вже через /dev/sda2:

root@archiso ~ # mkfs.ext4 /dev/sda2

Тепер Fat32 для EFI, /dev/sda1:

[root@archiso ~]# mkfs.fat -F32 /dev/sda1
mkfs.fat 4.2 (2021-01-31)

І Swap:

[root@archiso ~]# mkswap /dev/sda3
Setting up swapspace version 1, size = 34 GiB (36507217920 bytes)
no label, UUID=9132a23f-bb87-43d1-9ac5-33b75bf17f35

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

[root@archiso ~]# fdisk -l /dev/sda
Disk /dev/sda: 232.89 GiB, 250059350016 bytes, 488397168 sectors
Disk model: Samsung SSD 850 
...

Device        Start       End   Sectors   Size Type
/dev/sda1      2048   1050623   1048576   512M EFI System
/dev/sda2   1050624   2099199   1048576   512M Linux filesystem
/dev/sda3   2099200  73402367  71303168    34G Linux swap
/dev/sda4  73402368 488396799 414994432 197.9G Linux filesystem

Окей, поїхали далі.

Підключення розділів

Монтуємо розділи:

  • sda4 (/dev/mapper/cryptroot), корневий розділ – в /mnt
  • sda2, boot – в /mnt/boot
  • sda1, EFI – в /mnt/boot/efi
  • і swapon для /dev/sda3

Виконуємо:

[root@archiso ~]# mount /dev/mapper/cryptroot /mnt
[root@archiso ~]# mkdir /mnt/boot
[root@archiso ~]# mount /dev/sda2 /mnt/boot
[root@archiso ~]# mkdir /mnt/boot/efi 
[root@archiso ~]# mount /dev/sda1 /mnt/boot/efi
[root@archiso ~]# swapon /dev/sda3

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

[root@archiso ~]# swapon --show
NAME      TYPE      SIZE USED PRIO
/dev/sda3 partition  34G   0B   -2

І решту розділів:

[root@archiso ~]# lsblk /dev/sda
NAME          MAJ:MIN RM   SIZE RO TYPE  MOUNTPOINTS
sda             8:0    0 232.9G  0 disk  
├─sda1          8:1    0   512M  0 part  /mnt/boot/efi
├─sda2          8:2    0   512M  0 part  /mnt/boot
├─sda3          8:3    0    34G  0 part  [SWAP]
└─sda4          8:4    0 197.9G  0 part  
  └─cryptroot 253:0    0 197.9G  0 crypt /mnt

Встановлення системи

З pacstrap встановлюємо необхідні пакети.

Краще тут відразу додати все для WiFi, в тому числі dhcpd аби потім не мати геморою з підключенням, і встановити openssh:

root@archiso ~ # pacstrap -K /mnt base linux linux-firmware grub efibootmgr networkmanager sudo vim iwd dhcpcd openssh
==> Creating install root at /mnt
...
(12/13) Updating linux initcpios...
==> Building image from preset: /etc/mkinitcpio.d/linux.preset: 'default'
==> Using default configuration file: '/etc/mkinitcpio.conf'
  -> -k /boot/vmlinuz-linux -g /boot/initramfs-linux.img
==> Starting build: '6.14.7-arch2-1'
...
==> Initcpio image generation successful
(13/13) Reloading system bus configuration...
  Skipped: Running in chroot.
pacstrap -K /mnt base linux linux-firmware grub efibootmgr networkmanager sud  23.20s user 13.51s system 84% cpu 43.626 total

Iris Xe Graphics

На новому ноуті (Lenovo ThinkPad T14 Gen 5) в мене відеокарта Iris Xe Graphics, і для неї треба трохи танців, бо перші спроби запуску Archcraft приводили до чорного екрану.

Вирішувалось додавання nomodeset в загрузку ядра.

Встановлюємо додаткові пакети:

root@archiso ~ # pacstrap -K /mnt mesa vulkan-intel intel-media-driver libva libva-utils

Переключаємось в нову систему, і якщо є – то видаляємо xf86-video-intel:

[root@archiso /]# pacman -Rns xf86-video-intel
error: target not found: xf86-video-intel

Виходимо зі chroot-оточення, йдемо далі.

Створення fstab

Генеруємо /etc/fstab з розділами:

[root@archiso ~]# genfstab -U /mnt >> /mnt/etc/fstab

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

[root@archiso ~]# cat /mnt/etc/fstab
# Static information about the filesystems.
# See fstab(5) for details.

# <file system> <dir> <type> <options> <dump> <pass>
# /dev/mapper/cryptroot
UUID=6bbbd1c2-7396-4d23-91eb-4188079e6df8       /               ext4            rw,relatime     0 1

# /dev/sda2
UUID=93dade4c-1579-4256-8491-4e560fc563c4       /boot           ext4            rw,relatime     0 2

# /dev/sda1
UUID=BBE3-903E          /boot/efi       vfat            rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=ascii,shortname=mixed,utf8,errors=remount-ro   0 2

# /dev/sda3
UUID=9132a23f-bb87-43d1-9ac5-33b75bf17f35       none            swap            defaults        0 0

Виглядає наче ОК.

Зборка ядра з mkinitcpio

Так як у нас LUKS, то треба додати дію (власне, “hook“) encrypt, аби ядро при старті системи виконало decrypt розділу.

keyboard і keymap вже мають бути, але краще перевірити – вони використовується, аби можна було ввести сам пароль.

Редагуємо файл /mnt/etc/mkinitcpio.conf (/mnt! – або робимо chroot), заходимо строку з HOOKS:

...
HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block filesystems fsck)
...

Додаємо encrypt після block і перед filesystems – порядок важливий:

HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block encrypt resume filesystems fsck)

(якщо будемо використовувати hibernation – то перед filesystem ще додаємо resume)

Чому порядок важливий:

  • block – знаходить диски
  • encrypt – декриптить розділ
  • filesystems – монтує розділи

Якщо не буде encrypt – то initramfs не запитає пароль, і не зможе підключити розділ.

Тут, до речі, важливий нюанс: якщо ми не шифруємо Swap, і крадуть ноут, який був в гібернації – то можуть отримати всю інформацію, яка була в пам’яті.

Тому, якщо вже робити все секьюрно – то Swap теж треба шифрувати. Або використовувати swapfile на рутовому розділі, який шифрується.

Виконуємо arch-chroot:

[root@archiso ~]# arch-chroot /mnt/

Збираємо ядро:

[root@archiso /]# mkinitcpio -P
==> Building image from preset: /etc/mkinitcpio.d/linux.preset: 'default'
==> Using default configuration file: '/etc/mkinitcpio.conf'
  -> -k /boot/vmlinuz-linux -g /boot/initramfs-linux.img
...
  -> Running build hook: [encrypt]
  -> Running build hook: [resume]
  -> Running build hook: [filesystems]
  -> Running build hook: [fsck]
==> Generating module dependencies
==> Creating zstd-compressed initcpio image: '/boot/initramfs-linux-fallback.img'
  -> Early uncompressed CPIO image generation successful
==> Initcpio image generation successful

GRUB

Так як у нас є зашифрований розділ – то його треба вказати в GRUB, аби він передав ядру необхідність виконати decrypt.

Зараз це можна зробити /etc/crypttab – або “the old school way” з GRUB_CMDLINE_LINUX.

Я crypttab не користувався, якихось додаткових розділів в мене нема, тому GRUB_CMDLINE_LINUX буде достатньо.

Додаємо ще три пакети – власне grub, efibootmgr (хоча ми його вже встановили) та intel-ucode, аби мати останні апдейти для ЦПУ (це, звісно, якщо у вас Intel CPU, а не AMD).

Перевірити можна так:

[root@archiso /]# lscpu | grep 'Vendor'
Vendor ID:                            GenuineInte

Якщо AMD – То використовуємо пакет amd-ucode.

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

[root@archiso /]# pacman -S grub efibootmgr intel-ucode

Встановлюємо сам GRUB в розділ EFI:

[root@archiso /]# grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=GRUB
Installing for x86_64-efi platform.
Installation finished. No error reported

Додавання cryptdevice

Знаходимо UUID зашифрованого розділу:

[root@archiso /]# blkid | grep LUKS
/dev/sda4: UUID="738d2f9b-7ffa-4d4e-93be-e00af0a64d5b" TYPE="crypto_LUKS" PARTUUID="32835823-6901-4bbf-9520-1876e64ba109"

(тут я як раз помилився – взяв не той UUID, і система не грузилась, див. в кінці)

Редагуємо /etc/default/grub, знаходимо строку GRUB_CMDLINE_LINUX="", і додаємо параметр cryptdevice:

...
GRUB_CMDLINE_LINUX="cryptdevice=UUID=738d2f9b-7ffa-4d4e-93be-e00af0a64d5b:cryptroot root=/dev/mapper/cryptroot"
...

Додавання resume

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

Знаходимо UUID розділу Swap:

[root@archiso /]# blkid | grep swap
/dev/sda3: UUID="9132a23f-bb87-43d1-9ac5-33b75bf17f35" TYPE="swap" PARTUUID="d7d38c7c-b8c6-49d5-81c3-0ef34cbd7d52"

Додаємо resume=UUID=9132a23f-bb87-43d1-9ac5-33b75bf17f35 в GRUB_CMDLINE_LINUX:

Генеруємо конфіг для GRUB:

[root@archiso /]# grub-mkconfig -o /boot/grub/grub.cfg
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-linux
Found initrd image: /boot/intel-ucode.img /boot/initramfs-linux.img
Found fallback initrd image(s) in /boot:  intel-ucode.img initramfs-linux-fallback.img
Warning: os-prober will not be executed to detect other bootable partitions.
Systems on them will not be added to the GRUB boot configuration.
Check GRUB_DISABLE_OS_PROBER documentation entry.
Adding boot menu entry for UEFI Firmware Settings ...
done

Тут все.

Пароль root

Задамо пароль root, аби потім залогінитись в нову систему:

[root@archiso /]# passwd root
New password: 
Retype new password: 
passwd: password updated successfully

Виходимо зі chroot, і ребутаємось:

[root@archiso /]# 
exit
root@archiso ~ # reboot

Готово.

Проблеми: “Failed to mount <device> on real root”

З першої спроби не вийшло 🙂

Хоча до цього сетапив на новому ноуті – все пройшло ОК.

Зараз маю помилку:

ERROR: device '/dev/mapper/cryptroot' not found
...
Failed to mount '/dev/mapper/cryptroot' on real root

Ну, давайте розбиратись.

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

Завантажуємось з флешки, монтуємо диски:

[root@archiso ~]# cryptsetup open /dev/sda4 cryptroot
Enter passphrase for /dev/sda4: 
[root@archiso ~]# mount /dev/mapper/cryptroot /mnt
[root@archiso ~]# mount /dev/sda2 /mnt/boot
[root@archiso ~]# mount /dev/sda1 /mnt/boot/efi
[root@archiso ~]# swapon /dev/sda3

Виконуємо chroot:

[root@archiso ~]# arch-chroot /mnt/
[root@archiso /]#

Перевіряємо конфіг для ядра:

[root@archiso /]# cat /etc/mkinitcpio.conf | grep HOOKS | tail -1
HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block encrypt resume filesystems fsck)

Наче все вірно.

Про всяк випадок – можна ще раз зібрати ядро з mkinitcpio -P.

З lsinitcpio перевіримо що в ядрі – мають бути модулі cryptsetup та crypttab:

[root@archiso /]# lsinitcpio /boot/initramfs-linux.img | grep 'cryptsetup\|crypttab'
usr/bin/cryptsetup
usr/lib/libcryptsetup.so.12
usr/lib/libcryptsetup.so.12.10.0

Все є.

Ну і останнє – UUID розділу, який ми задавали в GRUB – отут в мене і була помилка, бо робив blkid /dev/mapper/cryptroot, а не самого розділу /dev/sda4.

Перевіряємо ID розділу з типом crypto_LUKS:

[root@archiso /]# blkid | grep LUKS
/dev/sda4: UUID="738d2f9b-7ffa-4d4e-93be-e00af0a64d5b" TYPE="crypto_LUKS" PARTUUID="32835823-6901-4bbf-9520-1876e64ba109"

Редагуємо /etc/default/grub, вказуємо коректний UUID для cryptdevice, виконуємо grub-mkconfig -o /boot/grub/grub.cfg – готово:

Після старту системи запускаємо idw:

# systemctl start iwd
# systemctl enable iwd

Запускаємо dhcpcd:

# systemctl start dhcpcd
# systemctl enable dhcpcd

І підключаємось до WiFi з iwctl:


Ну а в другій частині саме цікаве – налаштування робочого оточення 🙂

Loading

AI: пишемо MCP-сервер для VictoriaLogs
0 (0)

10 Травня 2025

В попередньому матеріалі розібрались з тим, що таке MCP взагалі, і створили дуже простенький сервер, який підключили до Windsurf – див. AI: що таке той MCP?

Тепер – давайте спробуємо створити щось більш корисне, наприклад – MCP-сервер, який буде підключатись до VictoriaLogs та отримувати якісь дані.

Насправді команда VictoriaMetrcis вже робить власний, тому тут ми “просто пограємось”, аби подивитись на MCP на більш реальному прикладі.

VictoriaLogs API

Спершу спробуємо зробити запит руками, а потім “загорнемо” його в код на Python.

Відкриваємо локальний порт на VictoriaLogs:

$ kubectl port-forward svc/atlas-victoriametrics-victoria-logs-single-server 9428
Forwarding from 127.0.0.1:9428 -> 9428
Forwarding from [::1]:9428 -> 9428

Для роботи з VictoriaLogs ми можемо використати її HTTP API і зробити запит на кшталт такого:

$ curl -X POST http://localhost:9428/select/logsql/query -d 'query=error' -d 'limit=1'
{"_time":"2025-05-08T11:13:44.36173829Z","_stream_id":"0000000000000000ae9096a01bcfdf58cd1159fc206f3aea","_stream":"{namespace=\"ops-monitoring-ns\"}","_msg":"2025-05-08T11:13:44.361Z\twarn\tVictoriaMetrics/lib/promscrape/scrapework.go:383\tcannot scrape target \"http://atlas-victoriametrics-prometheus-blackbox-exporter.ops-monitoring-ns.svc:9115
...

Окей, ту все працює.

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

Можемо навіть попросити Cascade у Windsurf це зробити за нас:

Правда, pip install -r requirements.txt все ж довелось виконувати самому в консолі, бо робимо в Python virtual env (хоча пізніше він все ж запропонував виконати python -m venv venv).

В результаті маємо такий код:

import requests

def query_victoria_logs(query: str, limit: int = 1, host: str = "localhost", port: int = 9428) -> dict:
    """
    Query VictoriaLogs endpoint using the logsql/query endpoint.
    
    Args:
        query: The search query to execute
        limit: Maximum number of results to return
        host: VictoriaLogs host (default: localhost)
        port: VictoriaLogs port (default: 9428)
        
    Returns:
        Dictionary containing the response from VictoriaLogs
    """
    url = f"http://{host}:{port}/select/logsql/query"
    params = {
        'query': query,
        'limit': limit
    }
    
    try:
        response = requests.post(url, data=params)
        response.raise_for_status()  # Raise an exception for bad status codes
        return response.json()
    except requests.exceptions.RequestException as e:
        print(f"Error querying VictoriaLogs: {e}")
        return None

if __name__ == "__main__":
    # Example usage
    result = query_victoria_logs(query="error", limit=1)
    if result:
        print("Query results:")
        print(result)
    else:
        print("No results or error occurred")

В цілому – нормально. Достатньо просто, і працює:

$ python victoria_logs_client.py 
Query results:
{'_time': '2025-05-10T11:43:43.694956594Z', '_stream_id': '0000000000000000ae9096a01bcfdf58cd1159fc206f3aea', '_stream': '{namespace="ops-monitoring-ns"}', '_msg': '2025-05-10T11:43:43.694Z\twarn\tVictoriaMetrics/lib/promscrape/scrapework.go:383\tcannot scrape target "http://atlas-victoriametrics-prometheus-blackbox-exporter.ops-monitoring-ns.svc:9115/probe
...

Але далі, коли я попросив Cascade створити MCP-сервер і додати @tool – то він трохи впав в безкінечний цикл 🙂

Окей, anyway – vibe-кодінг це не про нас, ми любимо все робити власними руками.

Створення MCP-серверу для VictoriaLogs

Використовуємо той самий FastMCP, і трохи спростимо функцію:

#!/usr/bin/env python3

from fastmcp import FastMCP
import requests

mcp = FastMCP("VictoriaLogs MCP Server")

@mcp.tool()
def query_victorialogs(query: str, limit: int = 1) -> str:
    """
    Run a LogsQL query against VictoriaLogs and return raw result.
    """
    url = "http://localhost:9428/select/logsql/query"
    data = {
        "query": query,
        "limit": str(limit)
    }

    try:
        response = requests.post(url, data=data)
        response.raise_for_status()
        return response.text
    except requests.RequestException as e:
        return f"Error: {e}"

if __name__ == "__main__":
    mcp.run(transport="stdio")

Встановлюємо fastmcp пакет напряму:

$ pip install fastmcp

Пробуємо запустити:

$ ./victoria_logs_client.py 
[05/10/25 14:56:43] INFO     Starting server "VictoriaLogs MCP Server"...

Наче навіть працює…

Пробуємо додати до Windsufr – my-mcp-server це старий, з попереднього поста, додаємо новий – victoria-logs-mcp.

Редагуємо файл ~/.codeium/windsurf/mcp_config.json:

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "/home/setevoy/Scripts/Python/MCP/my-mcp-server/.venv/bin/mcp",
      "args": [
        "run",
        "/home/setevoy/Scripts/Python/MCP/my-mcp-server/mcp_server.py"
      ]
    },
    "victoria-logs-mcp": {
      "command": "/home/setevoy/Scripts/Python/MCP/my-mcp-server/.venv/bin/python",
      "args": [
        "/home/setevoy/Scripts/Python/MCP/my-mcp-server/victoria_logs_client.py"
      ]
    }
  }
}

Робимо Refresh у Windsurf Settings > MCP, і спробуємо викликати наш новий сервер з запитом “find first log record woth error“:

Офігєть 🙂

І навіть є пояснення помилки з логу – “I found a warning log from VictoriaMetrics about a failed target scrape. The system couldn’t reach the blackbox exporter service, which is causing the issue“.

@tool: аналіз помилок 5хх

Спочатку давайте знову спробуємо з curl:

$ curl -X POST http://localhost:9428/select/logsql/query -d 'query=_msg:~"status=50[024]"' -d 'limit=100' -d 'format=json'
...
{"_time":"2025-05-09T13:59:40.41620395Z","_stream_id":"0000000000000000ae9096a01bcfdf58cd1159fc206f3aea","_stream":"{namespace=\"ops-monitoring-ns\"}","_msg":"level=warn ...
...

Тепер додаємо новий tool в наш код:

...
@mcp.tool()
def analyze_5xx_logs(limit: int = 100) -> str:
    """
    Analyze recent logs with 5xx HTTP status codes by parsing NDJSON response from VictoriaLogs.
    """
    url = "http://localhost:9428/select/logsql/query"
    query = '_msg:~"status=50[0234]"'

    data = {
        "query": query,
        "limit": str(limit),
        "format": "json"
    }

    try:
        response = requests.post(url, data=data)
        response.raise_for_status()

        # NDJSON: parse each line as a separate JSON object
        entries = []
        for line in response.text.strip().splitlines():
            try:
                entry = json.loads(line)
                entries.append(entry)
            except json.JSONDecodeError:
                continue

    except requests.RequestException as e:
        return f"Request error: {e}"

    if not entries:
        return f"No logs found matching: {query}"

    messages = [entry.get("_msg", "") for entry in entries]
    combined = "\n".join(messages[:limit])
    return f"Found {len(messages)} logs matching `{query}`:\n\n{combined}"
...

Зберігаємо зміни в коді, робимо Reload – і для нашого MCP-серверу з’явився новий tool analyze_5xx_logs:

Питаємо Cascade “Analyze the last 10 logs with 502 or 504 errors“:

Моделька навіть сама вирішила уточнити запит – замість “status=50[0234]” зробити просто “status=50[24]“.

І видала свій вердикт по знайденим помилкам:

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

Чекаємо офіційного релізу MCP-серверу від VictoriaMetrics.

Loading

AI: що таке той MCP?
0 (0)

10 Травня 2025

Щось всі навколо тільки і говорять що про море про MCP – тож прийшов час і самому розібратись в темі.

Отже, сьогодні розберемося з основними поняттями – “що воно взагалі таке”, потім напишемо власний “мікро-MCP сервер”, а в наступному пості – щось більш реальне, про роботу з VictoriaLogs.

Обмеження LLM

Будь-яка Large Language Model – це система “сама в собі”: в неї немає доступу до зовнішніх ресурсів і вона не може виконати якісь дії в реальному світі, в реальному оточенні – наприклад, виконати shell-команду на вашому ноутбуці чи надіслати API-запит до GitHub або до AWS.

З часом з’явилась концепція “агентів” – локальних сервісів, які модель може викликати або такі дії виконати.

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

Тому врешті-решт з’явився новий стандарт, протокол – Model Context Protocol (MCP), який як раз дозволив розширити можливості LLM та агентів через єдиний і стандартний спосіб описати контекст того, з чим модель буде мати справу.

And so… The MCP?

Отже, Model Context Protocol – це протокол, “схема”, яка описує стандарт, за яким модель може взаємодіяти із зовнішнім середовищем.

Саму специфікацію можна почитати тут – Specification.

Якщо дуже просто, то MCP – це “інтерфейс”, через який LLM може виконувати якісь дії.

Схема роботи MCP – клієнт-серверна:

  • MCP-клієнт, через який ми передаємо запит у вигляді natural language – “створи новий Pull Request в моєму GitHub-репозиторії”
  • та MCP-сервер, до якого LLM звертається, аби цей запит транслювати у формат “сходити на такий-то URL, пройти аутентифікацію з таким-то токеном, виконати такий-то API-запит”

В ролі клієнта може виступити будь-яке рішення, яке вміє говорити з LLM – Cursor IDE, мобільний застосунок, або навіть просто CLI-утиліта.

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

Архітектура MCP

Якщо говорити більш детально, то у нас є кілька компонентів:

  • MCP Host: наприклад, Cursor або Windsurf – приймає запит від юзера, формує структурований MCP-запит (виклик функції або tool), і надсилає його до MCP-клієнта
  • MCP Client: це сама LLM (або AI-агент) + її інтерпретатор (interpreter, runtime, tool router), і вони разом приймають запит від MCP Host, визначає, який саме tool треба використати, виконують цей виклик (через MCP Server), та повертають результат
  • MCP Server: сервіс, який надає один чи декілька tools для MCP Client, та виконує запити від MCP Client, наприклад – запускає shell-команди
  • Data Sources та Remote Services: власне те, з чим напряму буде комунікувати MCP-сервер – логи, бази даних, API-сервери

Сам flow виконання запиту можна визначити так:

  • User -> MCP Host
    • MCP Host -> MCP Client (LLM/агент обробляє запит, визначає інструмент)
      • MCP Client -> MCP Server (використовує tool)
        • MCP Server -> Data Sources та Remote Services (отримує дані, формує відповідь)
      • MCP Server -> MCP Client
    • MCP Client -> MCP Host
  • MCP Host -> User

Документація – Core architecture.

Компоненти MCP

Отже, MCP використовує модель клієнт-сервер, і описує три ключові компоненти (або примітиви):

  • Resources: дані, до яких треба звернутись – логи, метрики, база даних, API-відповіді (docs)
  • Prompts: шаблони або форми подачі запитів до LLM – визначають, як саме ми формулюємо питання, щоб модель краще зрозуміла, яку функцію (tool) викликати (docs)
  • Tools: функції, які доступні на MCP-сервері для виклику MCP-клієнтом, і які викликає модель чи агент після аналізу запиту користувача (docs)
    • такими функціями тут можуть бути як функції, наприклад на Python, так і API-ендпоінти або shell-команди

Транспорти MCP

Для комунікації між клієнтом та сервером MCP визначає Transports – канали зв’язку, через які передаються запити та відповіді.

Наразі є три основних типи (MCP все ще активно розроблюється, тому можливо будуть нові):

  • stdio: стандартні потоки stdin/stdout, що використовується коли клієнт та сервер працюють локально
  • SSE (Server-Sent Events): односторонній канал від сервера до клієнта для передачі даних з результатами виконання запиту у вигляді подій
    • SSE може бути реалізований як stream-like даних передача – тобто, передача великих відповідей невеликими chunks (частинами), або як повернення однієї події одним повідомленням
    • в такому разі клієнт використовує стандартний HTTP POST для відправки самого запиту
  • Streamable HTTP: двосторонній канал, у якому клієнт отримує відповідь від сервера через HTTP streaming

Документація – Transport layer.

RAG vs MCP

Але ж у нас вже є Retrieval-Augmented Generation, RAG? Нашо нам новий інструмент?

Бо RAG виконує пошук інформації в зовнішніх середовищах, і повертає до моделі контекст, дані.

А з MCP – модель виконує саме дії: пошук інформації, запуск Docker-контейнеру на ноутбуці тощо.

Створення MCP Server

Окей – з основними поняттями розібрались, тепер давайте спробуємо створити власний MCP і підключити до якогось IDE.

Я буду використовувати Windsurf, бо в ньому все якось вийшло простіше, і він дуже наглядно відображає використання MCP.

MCP Server на Python

Писати будемо на Python з використанням Python SDK.

Створюємо директорію, активуємо virtual environment:

$ mkdir -p MCP/my-mcp-server
$ cd MCP/my-mcp-server
$ python -m venv .venv
$ . .venv/bin/activate

Встановлюємо бібліотеки:

$ pip install mcp mcp[cli] requests

Пишемо сам код:

#!/usr/bin/env python3

from mcp.server.fastmcp import FastMCP

# instantiate an MCP server client
mcp = FastMCP("My MCP Tools")


# Register a tool
@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers and return the result"""
    return a + b


if __name__ == "__main__":
    mcp.run(transport="stdio")

Тут:

  • FastMCP: бібліотека для створення MCP-серверів, яка реалізує специфікацію Model Context Protocol
    • from mcp.server.fastmcp import – входить у Python SDK
  • tools: функції, які зможе використовувати LLM – див. Tools
  • run: метод для запуску FastMCP-серверу – див. Running Your FastMCP Server

Використання MCP Inspector

Є дуже прикольно штука для дебагу MCP-серверів – Inspector. Див. документацію тут>>>. Потребує в системі NodeJS >= 18.

Запускаємо:

$ npx @modelcontextprotocol/inspector python3 mcp_server.py
Starting MCP inspector...
⚙ Proxy server listening on port 6277
🔍 MCP Inspector is up and running at http://127.0.0.1:6274 
...

І відкриваємо в браузері http://127.0.0.1:6274, де в Tools можемо побачити наш tool add:

Додавання MCP Server до Windsurf

Так як у нас бібліотека mcp встановлена в Python virtual environment, то для її використання в IDE нам потрібен повний шлях.

В терміналі, в якому у нас активований venv виконуємо:

$ realpath .venv/bin/mcp
/home/setevoy/Scripts/Python/MCP/my-mcp-server/.venv/bin/mcp

Файл налаштувань MCP для Windsurf – ~/.codeium/windsurf/mcp_config.json.

Або просто відкриваємо Windsurf Settings:

Клікаємо Add Server > Add custom server:

І нам відкриється файл mcp_config.json з прикладом додавання серверу:

Додаємо наш:

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "/home/setevoy/Scripts/Python/MCP/my-mcp-server/.venv/bin/mcp",
      "args": [
        "run",
        "/home/setevoy/Scripts/Python/MCP/my-mcp-server/mcp_server.py"
      ]
    }
  }
}

Повертаємо до Settings, клікаємо Refresh – і маємо отримати наш новий сервер, у якого є один tool – add:

І він жеж має з’явитись у вікні чату:

Пробуємо його використати:

Йой! It works!

LLM (у випадку з Windsurf дефолтна буде Cascade) сама визначила, що в неї є доступ до MCP-серверу, який може виконати математичну операцію add, і використала його.

Найс.

В наступному пості – напишемо власний MCP-сервер для роботи з VictoriaLogs – просто, аби детальніше подивитись як воно працює, бо команда VictoriaMetrics вже робить власний сервер (ще не випустили, але я вже помацаю 🙂 ).

Див. наступну частину по MCP – AI: пишемо MCP-сервер для VictoriaLogs.

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

Loading

Python: знайомство з Celery та його моніторинг
0 (0)

15 Квітня 2025

Якщо дуже просто, то Celery – це щось, за допомогою чого ми можемо виконувати задачі поза нашим основним сервісом.

Наприклад, є Backend API, який має якийсь ендпоінт, на який мобілочки відправляють інформацію про те, що юзер створив новий whatever в застосунку. Задача бекенда – додати whatever в базі даних.

Можна це виконати прямо в інстансі самого API відразу при отриманні івента на ендпоінт, а можна, якщо нам не горить виконання whatever в базі даних, створити паралельну відкладену задачу, яка буде виконана через 1-5-20-60 секунд.

Власне, це і робить Celery:

  • основний код сервісу створює task
  • Celery-клієнт в коді цю задачу відправляє в MQ Broker (Message Queue Broker, як-от RabbitMQ, Redis або мать його єті AWS SQS, див повний список на Broker Overview)
  • Celery Worker отримує меседж з черги
  • Worker запускає якусь функцію, як робить whatever в базі даних
  • profit!

Власне в цьому пості ми не будемо заглиблюватись в деталі реалізації всього цього щастя.

Все, що мені цікаво – це як з цим працювати, тобто – як я можу створити новий task, аби Worker цю задачу виконав.

В ідеалі – ще й перевірити, що задача дійсно була виконана – але тут є проблеми з AWS SQS. Далі подивимось на це детальніше.

Запуск Celery

Зробимо все швиденько локально з Python PiP та Docker, потім ще глянемо на AWS SQS, Kubernetes та моніторинг.

Створення проекту

Встановлюємо сам Celery та залежності для роботи з Redis:

$ mkdir celery
$ cd celery/
$ python -m venv .venv
$ . ./.venv/bin/activate
$ pip install celery
$ pip install -U "celery[redis]"

Запуск Redis

В ролі MQ буде Redis, бо його легко запустити локально і він легенький в плані ресурсів.

Для results backend – теж Redis, але це розглянемо трохи пізніше.

Запускаємо контейнер з Redis:

$ docker run --rm -p 6379:6379 --name redis -e REDIS_ARGS="--bind 0.0.0.0" redis

Заходимо в нього:

$ docker exec -ti redis bash
root@78326cab3d4b:/data#

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

root@78326cab3d4b:/data# redis-cli ping
PONG

Запуск Celery

Параметри для брокера – див. Broker Settings.

IDK чому в документації параметр називається “broker” а не “broker_url“, бо “broker” наче депрікейтед, а документація “describes the current stable version of Celery (5.4)” (с). Чи, може, --broker – це параметр для командної строки, а broker_url – для конфігу?

Весь код можна просто робити в одному файлі типу tasks.py, як це описано в Getting Started документації, але я відразу розіб’ю на кілька окремих модулів, аби було більше “production way”, тим більше воно вже так зроблено у нас в Production, тому хочеться і під час тестування мати більш схожий сетап.

Створюємо основний модуль для Селері – celery_app.py:

from celery import Celery

app = Celery(__name__, 
                 broker_url='redis://localhost:6379/0',
                 include=["celery_tasks"]
             )

Тут, власне, broker_url – адреса Redis, а в include ми підключаємо наші майбутні таски. Також є опція autodiscover_tasks, але не пробував і у нас не використовується.

Якщо таски не заімпорчені – то будуть помилки типу:

Received unregistered task of type ‘celery_tasks.test_task’.

Створюємо модуль для Celery Tasks – celery_tasks.py:

from celery_app import app

@app.task
def test_task(arg):
    return "OK: " + arg

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

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

Наш API через Celery створює таску, яка їде в RDS, і оновлює табличку з цим юзером, додаючи якісь нові records.

Пишемо наш “API сервіс” – основний код, який буде викликати Celery.

Через метод delay() додаємо створення задачі:

#!/usr/bin/env python

from celery_tasks import test_task
    
test_task.delay("Hello, comrade!")

Запускаємо вже власне Celery Worker – це окремий процес Python (в Kubernetes у нас для цього окремі Pods).

Тобто, Celery-клієнт – це інстанс Celery, який створюється під час запуску API і через який ми створюємо нові задачі в брокері, а Worker – це окремий процес, який займається збором повідомлень і виконанням задач.

Запустити можна просто з термінала:

$ celery -A celery_app worker --loglevel=INFO
...
- ** ---------- [config]
- ** ---------- .> app:         celery_app:0x7d2bb7830980
- ** ---------- .> transport:   redis://localhost:6379/0
- ** ---------- .> results:     disabled://
...
 -------------- [queues]
                .> celery           exchange=celery(direct) key=celery
...

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

root@61eff8635cb1:/data# redis-cli keys *
1) "_kombu.binding.celery.pidbox"
2) "_kombu.binding.celery"
3) "_kombu.binding.celeryev"

Так як у нас Redis, то це будуть саме ключі. Але для Celery – це черги, і ключі створюються з типом SET, тобто можуть мати послідовність. Хоча нам зараз такі нюанси не дуже важливі.

Запускаємо наш “API” аби він викликав створення задачі (не забуваємо про activate venv, якщо робимо в окремому терміналі, бо треба імпортити Celery libs):

$ chmod +x my_api_app.py
$ ./my_api_app.py 

Перевіряємо логи Celery Worker – таска отримана, таска оброблена:

...
[2025-03-19 12:48:06,285: INFO/MainProcess] Task celery_tasks.test_task[edbdc0aa-673c-490f-a18f-0b7665db2ff7] received
[2025-03-19 12:48:06,287: INFO/ForkPoolWorker-15] Task celery_tasks.test_task[edbdc0aa-673c-490f-a18f-0b7665db2ff7] succeeded in 0.0003573799040168524s: 'OK: Hello, comrade!'

Default Celery Broker keys

Швиденько глянемо розберемо що Celery створює в чергах і для чого, бо в SQS будемо мати проблеми з деякими з них, тому треба розуміти чому і від чого.

Як вже говорили вище, в Redis це KEYS, але з типом SET (в SQS були б окремі черги):

root@a0c65a5e7bfb:/data# redis-cli type _kombu.binding.celeryev
set

Тут:

  • _kombu.binding.celeryev: використовується Celery Events для надсилання подій про стан воркерів (наприклад, коли воркер запускається, виконує задачу або завершує роботу)
    • використовується для моніторингу через celery events або Flower, ми їх далі подивимось
  • _kombu.binding.celery: головна черга завдань Celery за замовчуванням
    • сюди надсилаються задачі, які потім обробляються Celery Workers
  • _kombu.binding.celery.pidbox: використовується для pidbox messaging, тобто обміну командами між воркерами, наприклад, celery inspect, celery control – і в SQS це теж працювати не буде
    • через нього Celery може надсилати команди воркерам, щоб перевірити їхній стан, змінити рівень логування тощо

Чому _kombu в іменах – бо під капотом Celery використовує бібліотеку Kombu.

Глянемо, що в ключах.

В дефолтній черзі – виконуємо SMEMBERS, бо це тип SET:

root@a06f25448034:/data# redis-cli SMEMBERS _kombu.binding.celery
1) "celery\x06\x16\x06\x16celery"

\x06 та \x16 – це ACK та SYN, які додаються Kombo.

Так як наша задача була виконана – то у нас в _kombu.binding.celery пусто.

Ну і pidbox – інформація про наявний воркер celery@setevoy-wrk-laptop:

root@8d4de6fb1bc6:/data# redis-cli SMEMBERS _kombu.binding.celery.pidbox
1) "\x06\x16\x06\[email protected]"

Можна запустити redis-cli monitor, і побачити все, що відбувається в Redis.

Окей.

Все наче працює.

Що далі?

Додавання result_backend

Без наявного result_backend ми не можемо перевіряти статус виконання тасок, бо Celery просто ніде не зберігає цю інформацію. Див. Keeping Results.

Тобто, якщо ми потім захочемо отримати стан задачі з response = result.get() (далі це зробимо) – то без result_backend отримаємо помилки типу:

│ File “/usr/local/lib/python3.12/site-packages/celery/backends/base.py”, line 1104, in _is_disabled │
│ raise NotImplementedError(E_NO_BACKEND.strip())

Давайте конфіг Celery винесемо теж окремим модулем, celery_config.py, і додамо параметр result_backend з Redis:

broker_url='redis://localhost:6379/0'
result_backend='redis://localhost:6379/0'

include=[
    "celery_tasks"
]

Оновлюємо код celery_app.py – додаємо імпорт конфігу і виклик config_from_object():

import celery_config

from celery import Celery

#app = Celery(__name__, 
#                 broker_url='redis://localhost:6379/0',
#                 include=["celery_tasks"]
#             )

app = Celery(__name__)
app.config_from_object("celery_config", force=True)

Перезапускаємо Celery Worker, і тепер в “results” замість Disabled маємо адресу нашого Redis:

$ celery -A celery_app:app worker --loglevel=INFO
...
- ** ---------- [config]
- ** ---------- .> app:         celery_app:0x78f451ae8980
- ** ---------- .> transport:   redis://localhost:6379/0
- ** ---------- .> results:     redis://localhost:6379/0
...

Оновлюємо код основного сервісу – додамо отримання результату виконання задачі через get():

#!/usr/bin/env python

from celery_tasks import test_task

    
result = test_task.delay("Hello, comrade!")

print(result.get())

Запускаємо наш “API”:

$ ./my_api_app.py 
OK: Hello, comrade!

І в print(result.get()) маємо значення, яке повертає функція test_task() – тобто return "OK" + переданий аргумент.

Окей.

Виглядає, наче тут все класно працює.

А тепер давайте спробуємо використати AWS SQS.

Використання Celery з AWS SQS

Документація – Using Amazon SQS.

Встановлюємо залежності:

$ pip install "celery[sqs]"

Створення SQS

Залишаємо дефолтний тип, Standart:

DLQ нам зараз не потрібна.

Зберігаємо, копіюємо URL – він нам буде потрібний для Celery:

Налаштування Celery з SQS

Редагуємо наш celery_app.py – додаємо AWS ACCESS/SECRET ключі і чергу.

Але спочатку давайте глянемо на доступні опції:

  • broker_url: тут міняємо на sqs
  • broker_transport_options:
    • predefined_queues: варто додати, бо інакше Celery буде шукати доступні SQS з ListQueues, що довго і може бути дорого
      • дефолтна черга може бути задана з task_default_queue
        • якщо task_default_queue не задана, то Celery (мабуть) буде шукати чергу за URL https://sqs.us-east-1.amazonaws.com/492***148/celery
      • чергу також можна передати під час створення таски – @app.task(queue="my_custom_queue")
  • task_create_missing_queues: якщо потрібна черга не знайдена, то Celery спробує її створити – що в SQS нам точно не треба

Тепер конфіг може виглядати так – result_backend поки відключаємо, бо SQS його не підтримує, див. Results:

from kombu.utils.url import safequote

#broker_url='redis://localhost:6379/0'
#result_backend='redis://localhost:6379/0'

aws_access_key = safequote("AKI***B7A")
aws_secret_key = safequote("pAu***2gW")

broker_url = "sqs://{aws_access_key}:{aws_secret_key}@".format(
    aws_access_key=aws_access_key, aws_secret_key=aws_secret_key,
)
broker_transport_options = {
    "region": "us-east-1",
    "predefined_queues": {
        "arseny_test": {
            "url": "https://sqs.us-east-1.amazonaws.com/492***148/arseny-celery-test",
        }
    }
}

task_create_missing_queues = False
task_default_queue = "arseny_test"

include=[
    "celery_tasks"
]

Перезапускаємо воркер:

$ celery -A celery_app worker --loglevel=INFO
...
- ** ---------- [config]
- ** ---------- .> app:         celery_app:0x7d9631ef8980
- ** ---------- .> transport:   sqs://AKI***B7A:**@localhost//
- ** ---------- .> results:     disabled://
- *** --- * --- .> concurrency: 16 (prefork)
-- ******* ---- .> task events: OFF (enable -E to monitor tasks in this worker)
--- ***** ----- 
 -------------- [queues]
                .> arseny_test      exchange=arseny_test(direct) key=arseny_test

[tasks]
  . celery_tasks.test_task
...

Цікаво, що transport @localhost… Але ок.

Перевіряємо вкладку Моніторинг в SQS:

Меседжі пройшли, ОК.

Що далі?

А далі ми спробуємо додати трохи води моніторингу.

Моніторинг Celery в AWS SQS

Власне, як я дійшов до жизні такой до цього посту: є Kubernetes Pod, для якого хочеться мати простий Liveness Probe.

Я за 10 хвилин нагуглив пару методів Celery, оновив Deployment, і вже хотів мержити PR, як виявилось, що…

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

  • SQS doesn’t yet support worker remote control commands.
  • SQS doesn’t yet support events, and so cannot be used with celery eventscelerymon, or the Django Admin monitor.

Тобто, якщо ми спробуємо виконати celery inspect ping – то отримаємо помилки. Як мінімум тому, бо для цього потрібна черга pidbox, яка не підтримується в SQS.

Давайте спочатку на ці помилки глянемо.

get() та “No result backend is configured”

Помилку з get() ми вже бачили – якщо спробувати зробити таке без result_backend:

result = test_task.delay("Hello, comrade!")
print(result.get())

То отримаємо NotImplementedError(E_NO_BACKEND.strip()):

...
  File "/home/setevoy/Scripts/Python/celery/.venv/lib/python3.13/site-packages/celery/backends/base.py", line 1104, in _is_disabled
    raise NotImplementedError(E_NO_BACKEND.strip())
NotImplementedError: No result backend is configured.

Окей…

Але ж мабуть така серйозна бібліотека як Celery має власні механізми для перевірки воркерів?

Так – має.

Але…

Celery control inspect

Взагалі, celery.app.control класна штука, і якщо у вас RabbitMQ чи Redis – то з нею можна отримати багато корисної інформації.

Але у нас SQS, тому control працювати не буде.

Пробуємо перевірити таски для воркерів:

$ celery -A celery_app inspect registered
...
  File "/home/setevoy/Scripts/Python/celery/.venv/lib/python3.13/site-packages/kombu/transport/SQS.py", line 381, in _resolve_queue_url
    raise UndefinedQueueException((
    ...<2 lines>...
    ).format(sqs_qname))
kombu.transport.SQS.UndefinedQueueException: Queue with name '0f41e5d5-49e1-38bc-bc9b-c1efbc4f9a3e-reply-celery-pidbox' must be defined in 'predefined_queues'.

Або можемо спробувати пінганути воркери з app.control.ping() – отримаємо ту ж саму помилку з “pidbox must be defined in ‘predefined_queues’“:

@app.task
def celery_health_check():
    response = app.control.ping(timeout=2)
    return response

Ну і теж саме для celery_app.control.inspect().

Є дуже стара GitHub issue – celery ping doesn’t work when using SQS.

І варіанти того, як цю проблему обійти в Kubernetes Liveness probes – просто… Відключити перевірки взагалі. Наприклад – тут>>>.

Можливе рішення: окремий result_backend

Отже, що я зараз намагаюсь зробити – це просто додати result_backend з другим контейнером Redis до Kubernetes Pod з Celery. Ресурсів Redis потребує копійки, тому в принципі якимось оверхедом це не буде.

Тобто, ми маємо:

  • SQS для меседжів
  • Redis для зберігання статусів обробки цих меседжів

Тоді з get() можемо отримати результат виконання тестової таски, і впевнитись, що воркери працюють.

Задаємо result_backend з Redis знов:

...
result_backend='redis://localhost:6379/0'
...

Додаємо нову таску в celery_tasks.py:

@app.task
def celery_health_check():
    return "OK"

Додаємо “моніторинг” в наш “API”, my_api_app.py:

#!/usr/bin/env python

import sys

from celery_tasks import test_task, celery_health_check_task

def celery_health_check():
    try:
        result = celery_health_check_task.apply_async()
        response = result.get(timeout=5)

        print ("Result:", result)
        print ("Result state:", result.state)
        print ("Respose:", response)

        if response != "OK":
            raise RuntimeError("Celery health check task returned unexpected response!")

        print("Celery is running")

    except Exception as e:
        print("Celery health check failed")
        print({"status": "error", "message": str(e)})
        sys.exit(1)

celery_health_check()

delay() – самий простий метод без додаткових параметрів, apply_async() вміє в різні опції. Нам зараз в принципі не важливо, але delay() ми вже використовували, тут давайте з apply_async().

Перезапускаємо Celery Worker, запускаємо наш головний скрипт:

$ ./my_api_app.py 
Result: 2d32805b-3c55-412d-8fc4-4b893f222202
Result state: SUCCESS
Respose: OK
Celery is running

Гуд.

Що ми можемо ще?

Celery та FastAPI

Окрім виклику Celery через імпорти, ми можемо створити FastAPI сервіс, і все робити через TCP.

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

$ pip install fastapi uvicorn

Створюємо файл celery_api.py, описуємо FastAPI app:

from fastapi import FastAPI, HTTPException

from celery_tasks import celery_health_check_task

app = FastAPI()


@app.get("/celery-healthz")
def celery_healthcheck():
    try:
        result = celery_health_check_task.apply_async()
        response = result.get(timeout=5)

        if response != "OK":
            raise RuntimeError("Celery health check task returned unexpected response!")

        return {"status": "success", "message": "Celery is running"}

    except Exception as e:
        raise HTTPException(status_code=500, detail={"status": "error", "message": str(e)})

Запускаємо з uvicorn:

$ uvicorn celery_api:app --host 0.0.0.0 --port 8000 --reload

І перевіряємо:

$ curl localhost:8000/celery-healthz
{"status":"success","message":"Celery is running"}

Looks good…

Flower для моніторингу Celery

Flower – популярне рішення для моніторингу Celery, але не буде працювати з SQS з тих самих причин:

  • Flower використовує celery events (celeryev), в SQS не працює
  • список воркерів у Flower використовує Celery inspect(), що, як ми бачили вище, теж не буде працювати з SQS

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

Тому, аби побачити можливості Flower – давайте повернемо Redis в celery_config.py:

broker_url='redis://localhost:6379/0'
result_backend='redis://localhost:6379/0'
...

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

$ pip install flower

Запускаємо Flower з нашим інстансом Celery та його конфігом:

$ celery -A celery_app flower
[I 250320 13:49:48 command:168] Visit me at http://0.0.0.0:5555
[I 250320 13:49:48 command:176] Broker: redis://localhost:6379/0
[I 250320 13:49:48 command:177] Registered tasks: 
    ['celery.accumulate',
     'celery.backend_cleanup',
     'celery.chain',
     'celery.chord',
     'celery.chord_unlock',
     'celery.chunks',
     'celery.group',
     'celery.map',
     'celery.starmap',
     'celery_tasks.celery_health_check_task',
     'celery_tasks.test_task']
[I 250320 13:49:48 mixins:228] Connected to redis://localhost:6379/0

І відкриваємо в браузері http://localhost:5555:

Можемо використати Flower API для моніторингу:

$ curl -s localhost:5555/api/workers | jq
{
  "celery@setevoy-wrk-laptop": {
    "scheduled": [],
    "timestamp": 1742471544.7675385,
    "active": [],
    "reserved": [],
    ...

Документація по API – API Reference.

Власне, це все.

Celery запустили, як створити таски – подивились, як можна моніторити їх виконання – розібрались.

Залишилось це реалізувати в Production.

Loading

Python: знайомство з декораторами на прикладі FastAPI
0 (0)

18 Березня 2025

В останнє декоратори в Python трогав ще років 10 тому, в Python 2, хочеться трохи оновити пам’ять, бо зараз почав доволі активно ними користуватись, ну і ще раз подивитись як жеж воно працює під капотом, і що воно таке взагалі.

Пост вийшов трохи… дивний? Бо перша половина – в стилі “у нас є одне яблуко, і ми до нього додаємо ще одне”, а друга половина – якісь інтеграли. Але anyway – особисто в мене в голові картинка склалась, розуміння з’явилось, тому най буде так.

Отже, якщо коротко – Python decorator являє собою просто функцію, яка в аргументах приймає іншу функцію, і “додає” до неї якийсь новий функціонал.

Спочатку зробимо власний декоратор, подивимось як все це діло виглядає в пам’яті системи, а потім розберемо FaspAPI та його додавання роутів через app.get("/path").

В кінці будуть кілька корисних посилань, де більше детально розглядається теорія про функції і декоратори в Python, а тут буде суто практична частина.

Простий приклад Python decorator

Описуємо функцію, яка буде нашим декоратором, і нашу “робочу” функцію:

#!/usr/bin/env python

# a decorator function, which accetps another function as an argument
def decorator(func):
  # extend the gotten function with a new feature
  def do_domething():
    print("I'm sothing")
    # execute the function, passed in the argument
    func()
  # return the "featured" functionality
  return do_domething


# just a common function 
def just_a_func():
  print("Just a text")

# run it
just_a_func()

Тут функція decorator() приймає аргументом будь-яку іншу функцію, а just_a_func() – наша “основна” функція, яка робить для нас якісь дії:

$ ./example_decorators.py 
Just a text

Тепер ми можемо зробити такий фінт – створимо змінну $decorated, яка буде посиланням на decorator(), аргументом до decorator() передамо нашу just_a_func(), і викличемо $decorated як функцію:

...
# run it
#just_a_func()

# create a variable pointed to the decorator(), and pass the just_a_func() in the argument
decorated = decorator(just_a_func)
# call the function from the 'decorated' object
decorated()

Результат – у нас виконається і “внутрішня” функція do_domething(), бо вона є в return функції decorator(), і функція just_a_func(), яку ми передали в аргументах – бо в decorator.do_domething() є її виклик:

$ ./example_decorators.py 
I'm sothing
Just a text

А тепер замість того аби створювати змінну і їй призначати функцію decorator() з аргументом – ми можемо зробити те саме, але через виклик декоратора як @decorator перед нашою робочою функцією:

#!/usr/bin/env python

# a decorator function, which accetps another function as an argument
def decorator(func):
  # extend the gotten function with a new feature
  def do_domething():
    print("I'm sothing")
    # execute the function, passed in the argument
    func()
  # return the "featured" functionality
  return do_domething


# just a common function 
#def just_a_func():
#  print("Just a text")

# run it
#just_a_func()

# create a variable pointed to the decorator(), and pass the just_a_func() in the argument
#decorated = decorator(just_a_func)
# call the function from the 'decorated' object
#decorated()

@decorator
def just_a_func():
  print("Just a text")

just_a_func()

І отримаємо той самий результат:

$ ./example_decorators.py 
I'm sothing
Just a text

Як працюють декоратори?

Знаєте, чому з інфраструктурою простіше, ніж з програмуванням? Бо при роботі з серверами-мережами-кубернетесом у нас є якісь умовно-фізичні об’єкти, які ми можемо помацати руками і побачити очима. А в програмуванні – це все треба тримати в голові. Але є дуже дієвий лайф-хак: просто дивись на карту пам’яті процесу.

Давайте розберемо, що відбувається “під капотом”, коли ми використовуємо декоратори:

  • def decorator(func): в пам’яті створюється об’єкт функції decorator()
  • def just_a_func(): аналогічно, створюється об’єкт для функції just_a_func()
  • decorated = decorator(just_a_func): створюється третій об’єкт – змінна decorated:
    • decorated в собі містить посилання на функцію decorator()
    • аргументом до decorator() передається посилання на адресу, де знаходиться just_a_func()
    • функція decorator() створює новий об’єкт – do_domething(), бо вона є в return у decorator()
      • do_domething() виконує якісь додаткові дії, і викликає функцію, яка передана в func

В результаті, при виклику decorated як функції (тобто, з ()) – виконається функція do_domething(), а потім функція, яку передали аргументом, бо в аргументі func є посилання на функцію just_a_func().

Все це можна побачити в консолі:

>>> from example_decorators import *
>>> decorator # check the decorator() address
<function decorator at 0x7668b8eef2e0>
>>> just_a_func # check the just_a_func() address
<function just_a_func at 0x7668b8eef380>
>>> decorated # check the decorated variable address
<function decorator.<locals>.do_domething at 0x7668b8eef420>

Так як в decorated = decorator() ми створили посилання на функцію decorator() яка повертає свою внутрішню функцію do_domething(), то тепер decorated – це функція decorator.do_domething().

А у func ми будемо мати адресу just_a_func.

Для кращого розуміння – давайте просто глянемо на адреси пам’яті з функцією id():

#!/usr/bin/env python

# a decorator function, which accetps another function as an argument
def decorator(func):
  # extend the gotten function with a new feature
  def do_domething():
    #print("I'm sothing")
    print(f"Address of the do_domething() function: {id(do_domething)}")
    # execute the function, passed in the argument
    func()
    print(f"Address of the 'func' argument: {id(func)}")
  # return the "featured" functionality
  return do_domething


# just a common function 
def just_a_func():
  return None
  #print("Just a text")

print(f"Address of the decorator() function object: {id(decorator)}")
print(f"Address of the just_a_func() function object (before decoration): {id(just_a_func)}")

# run it
#just_a_func()

# create a variable pointed to the decorator(), and pass the just_a_func() in the argument
decorated = decorator(just_a_func)
decorated()

print(f"Address of the just_a_func() function object (after decoration): {id(just_a_func)}")
print(f"Address of the 'decorated' variable: {id(decorated)}")

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

$ ./example_decorators.py 
Address of the decorator() function object: 130166777561632
Address of the just_a_func() function object (before decoration): 130166777574272
Address of the do_domething() function: 130166777574432
Address of the 'func' argument: 130166777574272
Address of the just_a_func() function object (after decoration): 130166777574272
Address of the 'decorated' variable: 130166777574432

Тут:

  • decorator(): об’єкт функції за адресою 130166777561632 (створюється під час запуску програми)
  • just_a_func(): другий об’єкт функції за адресою 130166777574272 (створюється під час запуску програми)
  • виклик decorator() в decorated() створює об’єкт функцій do_domething(), який знаходиться за адресою 130166777574432 (створюється під час виконання decorator())
  • в аргументі func передається адреса об’єкту just_a_func()130166777574272
  • сама функція just_a_func() не змінюється, і знаходиться там жеж – 130166777574272
  • і змінна decorated тепер “відправляє” нас до функції do_domething() за адресою 130166777574432, бо decorator() виконує retrun значення do_domething()

Реальний приклад з FastAPI

Ну і давайте глянемо як це використовується в реальному житті.

Наприклад, я до цього посту прийшов, бо робив нові роути для FastAPI, і мені стало цікаво – як жеж FastAPI app.get("/path") додає роути?

Створимо файл fastapi_routes.py з двома роутами:

#!/usr/bin/env python

from fastapi import FastAPI

app = FastAPI()

# main route
@app.get("/")
def home():
    return {"message": "default route"}

# new route
@app.get("/ping")
def new_route():
    return {"message": "pong"}

Що тут відбувається:

  • створюємо інстанс класу FastAPI()
  • через декоратор @app.get("/") додаємо запуск функції home() при виклику path “/
  • аналогічно робимо для запиту при виклику app з path/ping

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

$ python3 -m venv .venv
$ . ./.venv/bin/activate
$ pip install fastapi uvicorn

Запускаємо нашу програму з uvicorn:

$ uvicorn fastapi_routes:app --reload --port 8082
INFO:     Will watch for changes in these directories: ['/home/setevoy/Scripts/Python/decorators']
INFO:     Uvicorn running on http://127.0.0.1:8082 (Press CTRL+C to quit)
INFO:     Started reloader process [2700158] using StatReload
INFO:     Started server process [2700161]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
...

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

$ curl localhost:8082/    
{"message":"default route"}

$ curl localhost:8082/ping
{"message":"pong"}

Як працює FastAPI get()?

Як це працює?

Сама функція get() не є декоратором, але вона повертає декоратор – див. applications.py#L1460:

..
-> Callable[[DecoratedCallable], DecoratedCallable]:
...
  return self.router.get(...)

Тут:

  • ->: return type annotation (анотація типу поверненого значення), тобто get() повертає якийсь тип даних
    • Callable[...]: повертається тип Callable (функція)
      • Callable[[DecoratedCallable], DecoratedCallable]: функція, яка повертається, приймає аргументом тип DecoratedCallable, і повертає теж тип DecoratedCallable
        • тип DecoratedCallable описаний в types.py: DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any]):
          • bound=Callable вказує, що типом даних може бути тільки функція (callable-об’єкт)
          • ця функція може приймати будь-які аргументи – ...,
          • і може повертати будь-які дані – Any
  • виклик app.get() повертає метод self.router.get()
    • а self.router.get() – це метод APIRouter, який описаний в routing.py#:1366, і який повертає метод self.api_route():
      • а функція  api_route(), яка описана в тому ж routing.py#L963 повертає функцію-декоратор decorator(func: DecoratedCallable)
        • а функція decorator() викликає метод add_api_route() – в тому ж routing.py#L994:
          • а add_api_route першим аргументом приймає path, а другим – функцію func, яку треба зв’язати з цим роутом
          • потім add_api_route() повертає func
        • а api_route() повертає decorator()
      • router.get() повертає api_route()
    • app.get() повертає router.get()
...
    def api_route(
        self,
        path: str,
        ...
        ),
    ) -> Callable[[DecoratedCallable], DecoratedCallable]:
        def decorator(func: DecoratedCallable) -> DecoratedCallable:
            self.add_api_route(
                path,
                func,
                ...
            )
            return func

        return decorator
...

Ми могли б переписати цей код так – залишимо додавання “/” через app.get(), а для “/ping” зробимо аналогічно тому, як робили в нашому першому прикладі – через створення змінної.

Тільки тут треба робити два об’єкти – спершу для app.get(), а потім вже викликати decorator() і передавати нашу функцію:

#!/usr/bin/env python

from fastapi import FastAPI

app = FastAPI()
  
# main route
@app.get("/")
def home():
    return {"message": "default route"}

# new route
def new_route():
    return {"message": "pong"}

# create 'decorator' variable pointed to the app.get() function
# the 'decorator' then will return another function, the decorator() itself
decorator = app.get("/ping")

# create another variable using the decorator() returned by the get() above, and pass our function
decorated = decorator(new_route)

Результат буде аналогічним в обох випадках – і для “/“, і для “/ping“.

Для більшої ясності – давайте це знову зробимо в консолі:

>>> from fastapi import FastAPI
>>> app = FastAPI()
>>> def new_route():
...     return {"message": "pong"}
...     
>>> decorator = app.get("/ping")
>>> decorated = decorator(new_route)

І перевіримо типи об’єктів та адреси пам’яті:

>>> app
<fastapi.applications.FastAPI object at 0x7381bb521940>
>>> app.get("/ping")
<function APIRouter.api_route.<locals>.decorator at 0x7381bb5a2480>
>>> decorator
<function APIRouter.api_route.<locals>.decorator at 0x7381bb5a2200>
>>> new_route
<function new_route at 0x7381bb5a22a0>
>>> decorated
<function new_route at 0x7381bb5a22a0>

Або навіть можемо просто використати метод add_api_route() напряму, прибравши виклик @app.get:

#!/usr/bin/env python

from fastapi import FastAPI

app = FastAPI()
  
# main route
#@app.get("/")
def home():
    return {"message": "default route"}
    
app.add_api_route("/", home)

# new route 
#@app.get("/ping")
def new_route():
    return {"message": "pong"}

app.add_api_route("/ping", new_route)

Власне, це все, що треба знати про використання get() як декоратор в FastAPI.

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

Loading

Kubernetes: знайти каталог з mounted volume в Pod на хості
0 (0)

10 Березня 2025

Маємо AWS Elastic Kubernetes Service, на якому розгорнуто стек VictoriaMetrics (див. VictoriaMetrics: створення Kubernetes monitoring stack з власним Helm-чартом).

Треба перенести дані зі старого поду VMSingle на новий, на новому кластері, а для цього треба знайти ці дані на EC2.

Note: щодо міграції даних VMSingle, то для неї є утиліта vmctl, десь в чернетках лежить пост, як буду робити наступну міграцію – то допишу

Перевірка VMSingle Kubernetes Pod

Перевіряємо дані VMSingle Kubernetes Pod – знаходимо його EC2-інстанс, відповідний Container ID та Mounts:

$ kk describe pod vmsingle-vm-k8s-stack-67d585c9cd-jt4f7
Name:             vmsingle-vm-k8s-stack-67d585c9cd-jt4f7
...
Node:             ip-10-0-46-247.ec2.internal/10.0.46.247
...
Containers:
  vmsingle:
    Container ID:  containerd://57398c3184cd229be564b140f32a9214b38a507137522904eab6ae38b676432a
...
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-5xmmq (ro)
      /victoria-metrics-data from data (rw)
...

Підключаємось на сервер (див. AWS: Karpenter та SSH для Kubernetes WorkerNodes):

$ ssh -i .ssh/hOS/atlas-eks-ec2 [email protected]
[ec2-user@ip-10-0-46-247 ~]$ sudo -s
[root@ip-10-0-46-247 ec2-user]#

Але звичного docker CLI тут нема, бо з версії 1.24 у вже ContainerD замість dockershim (див. All you need to know about moving to containerd on Amazon EKS):

[root@ip-10-0-46-247 ec2-user]# docker
bash: docker: command not found

Для роботи з containerd маємо утиліту crictl (Container Runtime Interface CTL):

[root@ip-10-0-46-247 ec2-user]# crictl 
NAME:
   crictl - client for CRI

USAGE:
   crictl [global options] command [command options] [arguments...]

COMMANDS:
   attach              Attach to a running container
   create              Create a new container
   exec                Run a command in a running container
   version             Display runtime version information
   images, image, img  List images
   inspect             Display the status of one or more containers
   inspecti            Return the status of one or more images
   ...

Варіант 1: використання crictl inspect та hostPath

Використовуючи Container ID із kubectl describe pod отримаємо інформацію про цей контейнер на хості і про його mounts:

[root@ip-10-0-46-247 ec2-user]# crictl inspect 57398c3184cd229be564b140f32a9214b38a507137522904eab6ae38b676432a
...
    "mounts": [
      {
        "containerPath": "/victoria-metrics-data",
        "gidMappings": [],
        "hostPath": "/var/lib/kubelet/pods/7b2b0205-8c7e-430f-995b-a45cd79ecb9f/volumes/kubernetes.io~csi/pvc-ed3831bc-56a2-4660-9aef-b47cd252edac/mount",
...

Перевіряємо каталог з hostPath за EC2:

[root@ip-10-0-46-247 ec2-user]# ll /var/lib/kubelet/pods/7b2b0205-8c7e-430f-995b-a45cd79ecb9f/volumes/kubernetes.io~csi/pvc-ed3831bc-56a2-4660-9aef-b47cd252edac/mount
total 40
drwxr-xr-x 6 root root  4096 Oct  1 00:51 cache
drwxr-xr-x 4 root root  4096 Dec  4  2023 data
-rw-r--r-- 1 root root     0 Oct  1 00:52 flock.lock
drwxr-xr-x 6 root root  4096 Sep 29 04:00 indexdb
drwx------ 2 root root 16384 Dec  4  2023 lost+found
drwxr-xr-x 2 root root  4096 Dec  4  2023 metadata
drwxr-xr-x 2 root root  4096 Dec  4  2023 snapshots
drwxr-xr-x 3 root root  4096 Oct  1 00:52 tmp

Варіант 2: з /proc/PID/root

Інший варіант – через PID процесу який ми теж бачили в crictl inspect – в данному випадку це буде 57398c3184cd229be564b140f32a9214b38a507137522904eab6ae38b676432a:

...
  "info": {
    ...
    "pid": 6933,
    ...
    "config": {
      "metadata": {
        "name": "vmsingle"
      },
...

Тоді на хості можемо перевірити /proc/<PID>/root:

[root@ip-10-0-46-247 ec2-user]# ll /proc/6933/root/victoria-metrics-data/
total 40
drwxr-xr-x 6 root root  4096 Oct  1 00:51 cache
drwxr-xr-x 4 root root  4096 Dec  4  2023 data
-rw-r--r-- 1 root root     0 Oct  1 00:52 flock.lock
drwxr-xr-x 6 root root  4096 Sep 29 04:00 indexdb
drwx------ 2 root root 16384 Dec  4  2023 lost+found
drwxr-xr-x 2 root root  4096 Dec  4  2023 metadata
drwxr-xr-x 2 root root  4096 Dec  4  2023 snapshots
drwxr-xr-x 3 root root  4096 Oct  1 00:52 tmp

Готово. Тепер можна зробити rsync цього каталогу на новий Kubernetes WorkerNode.

Loading

Nexus: налаштування Docker proxy repository та ContainerD в Kubernetes
0 (0)

5 Березня 2025

Про запуск Nexus писав в пості Nexus: запуск в Kubernetes та налаштування PyPi caching repository, тепер до PyPi хочеться додати кешування Docker images, тим більш Docker Hub з 1-го квітня 2025 вводить нові ліміти – див. Docker Hub usage and limits (дяка @Anatolii).

Робити будемо як завжди – спочатку вручну локально на робочій машині, подивимось, як воно працює, а потім додамо конфіг для Helm-чарту, задеплоїмо в Kubernetes, і подивимось, як налаштувати ContainerD для використання цього mirror.

Запуск Sonatype Nexus локально з Docker

Створюємо локальний каталог для nexus data, аби дані зберігались при рестарті Docker, міняємо юзера, бо будемо ловити помилки типу:

...
mkdir: cannot create directory '../sonatype-work/nexus3/log': Permission denied
mkdir: cannot create directory '../sonatype-work/nexus3/tmp': Permission denied
...

Виконуємо:

$ mkdir /home/setevoy/Temp/nexus3-data/
$ sudo chown -R 200:200 /home/setevoy/Temp/nexus3-data/

Запускаємо Nexus з цією директорією, і додаємо порти для доступу до самого Nexus та для Docker registry, який створимо далі:

$ docker run  -p 8080:8081 -p 8092:8092 --name nexus3 --restart=always -v /home/setevoy/Temp/nexus3-data:/nexus-data sonatype/nexus3

Чекаємо, поки все запуститься, і отримуємо пароль для admin:

$ docker exec -ti nexus3 cat /nexus-data/admin.password
d549658c-f57a-4339-a589-1f244d4dd21b

Заходимо в браузері на http://localhost:8080, логінимось в систему:

Setup wizard можна пропустити, або швиденько проклікати “Next” і задати новий пароль адміну.

Створення Docker cache repository

Переходимо в Administration > Repository > Repositories:

Клікаємо Create repository:

Вибираємо тип docker (proxy):

Задаємо ім’я, HTTP-порт, на якому будуть прийматись конекти від docker-daemon, і дозволяємо anonymous docker pull:

Далі задаємо адресу, з якої будемо пулити образи – https://registry-1.docker.io, решту параметрів поки можна залишити без змін:

Окремо варто згадати можливість створення docker (group), де налаштовується єдиний конектор для кількох репозиторіїв в Nexus. Але мені це поки не потрібно, хоча в майбутньому – можливо.

Див. Grouping Docker Repositories та Using Nexus OSS as a proxy/cache for Docker images.

Включення Docker Security Realm

Хоча ми і не використовуємо аутентифікацію, але реалм треба включити.

Переходимо в Security > Realms, додаємо Docker Bearer Token Realm:

Перевірка Docker mirror

Знаходимо IP контейнера, в якому запущено Nexus:

$ docker inspect nexus3
...
        "NetworkSettings": {
           ..
            "Networks": {
              ...
                    "IPAddress": "172.17.0.2",
...

Порт для Docker cache в Nexus ми задавали 8092.

На робочій машині редагуємо файл /etc/docker/daemon.json, задаємо registry-mirrors та insecure-registries, бо у нас нема SSL:

{
        "insecure-registries": ["http://172.17.0.2:8092"],
        "registry-mirrors": ["http://172.17.0.2:8092"]
}

Ребутаємо локальний Docker service:

$ systemctl restart docker

Виконуємо docker info, перевіряємо, що зміни застосовані:

$ docker info
...
 Insecure Registries:
  172.17.0.2:8092
  ::1/128
  127.0.0.0/8
 Registry Mirrors:
  http://172.17.0.2:8092/

Виконуємо docker pull nginx – запит має піти через Nexus, і там зберегти копії даних:

Якщо дані не з’являються – то скоріш за все проблема з аутентифікацією.

Для перевірки до /etc/docker/daemon.json додаємо debug=true:

{
        "insecure-registries": ["http://172.17.0.2:8092"],
        "registry-mirrors": ["http://172.17.0.2:8092"],
        "debug": true
}

Рестартимо локальний Docker, виконуємо docker pull і дивимось логи с journalctl -u docker:

$ sudo journalctl -u docker --no-pager -f
...
level=debug msg="Trying to pull rabbitmq from http://172.17.0.2:8092/"
level=info msg="Attempting next endpoint for pull after error: Head \"http://172.17.0.2:8092/v2/library/rabbitmq/manifests/latest\": unauthorized: "
level=debug msg="Trying to pull rabbitmq from https://registry-1.docker.io"
...

Першого разу я забув включити Docker Bearer Token Realm.

А другого разу в мене в ~/.docker/config.json був збережений токен для https://index.docker.io, і Docker намагався використати його. В такому випадку можна просто видалити/перемістити config.json, і виконати pull ще раз.

Окей.

А що там с ContainerD? Бо в AWS Elastic Kubenretes Service у нас не Docker.

ContainerD та registry mirrors

Чесно кажучи, з containerd болі було більше, ніж з Nexus. Тут і його TOML для конфігів, і різні версії самого ContainerD та конфігурації, і deprecated параметри.

В мене просто очі болять від такого формату:

[plugins]
  [plugins.'io.containerd.cri.v1.runtime']
    [plugins.'io.containerd.cri.v1.runtime'.containerd]
      [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes]
        [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc]
          [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options]

Anyway, воно все ж запрацювало, тому поїхали.

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

На Arch Linux встановлюємо з pacman:

$ sudo pacman -S containerd crictl

Генеруємо дефолтний конфіг для containerd:

$ sudo mkdir /etc/containerd/
$ containerd config default | sudo tee /etc/containerd/config.toml

В файл /etc/containerd/config.toml додаємо параметри для mirrors:

...
[plugins]
  [plugins.'io.containerd.cri.v1.images']
...
    [plugins.'io.containerd.cri.v1.images'.registry]
      [plugins.'io.containerd.cri.v1.images'.registry.mirrors."docker.io"]
        endpoint = ["http://172.17.0.2:8092"]
...

Тут:

  • plugins.'io.containerd.cri.v1.images: параметри для image service, управління образами
    • registry: налаштування registry для images service
      • mirrors."docker.io": дзеркала для docker.io
        • endpoint: куди звертатись, коли треба спулити образ з docker.io

Перезапускаємо containerd:

$ sudo systemctl restart containerd

Перевіряємо, що нові параметри застосовані:

$ containerd config dump | grep -A 1 mirrors
      [plugins.'io.containerd.cri.v1.images'.registry.mirrors]
        [plugins.'io.containerd.cri.v1.images'.registry.mirrors.'docker.io']
          endpoint = ['http://172.17.0.2:8092']

Виконуємо crictl pull:

$ sudo crictl pull ubuntu

І перевіряємо Nexus:

З’явився образ Ubuntu.

Тут наче все працює – давайте спробуємо це все діло налаштувати в Kubernetes.

Nexus Helm chart values та Kubernetes

В частині Додавання репозиторію в Nexus через Helm chart values трохи писав про те, як і які values додавались для запуску з Nexus Helm chart в Kubernetes для кешу PyPi.

Трохи їх оновимо:

  • додамо окремий blob store: підключимо окремий persistentVolume, бо в дефолтному лише 8 гіг, і якщо для PyPi цього більш-менш достатньо, то для Docker images буде замало
  • додамо additionalPorts: тут задаємо порт, на якому буде Docker cache
  • включимо Ingress

Всі values – values.yaml.

В мене деплоїться з Terraform під час налаштування Kubernetes-кластеру.

Все разом, з PyPi, в мене зараз виглядає так:

resource "helm_release" "nexus" {
  namespace        = "ops-nexus-ns"
  create_namespace = true

  name                = "nexus3"
  repository          = "https://stevehipwell.github.io/helm-charts/"
  #repository_username = data.aws_ecrpublic_authorization_token.token.user_name
  #repository_password = data.aws_ecrpublic_authorization_token.token.password
  chart               = "nexus3"
  version             = "5.7.2"
  # also:
  #  Environment:
  #  INSTALL4J_ADD_VM_PARAMS:          -Djava.util.prefs.userRoot=${NEXUS_DATA}/javaprefs -Xms1024m -Xmx1024m -XX:MaxDirectMemorySize=2048m
  values = [
    <<-EOT
      # use existing Kubernetes Secret with admin's password
      rootPassword:
        secret: nexus-root-password
        key: password

      # enable storage
      persistence:
        enabled: true
        storageClass: gp2-retain

      # create additional PersistentVolume to store Docker cached data
      extraVolumes:
        - name: nexus-docker-volume
          persistentVolumeClaim:
            claimName: nexus-docker-pvc

      # mount the PersistentVolume into the Nexus' Pod
      extraVolumeMounts:
        - name: nexus-docker-volume
          mountPath: /data/nexus/docker-cache

      resources:
        requests:
          cpu: 100m
          memory: 2000Mi
        limits:
          cpu: 500m
          memory: 3000Mi

      # enable to collect Nexus metrics to VictoriaMetrics/Prometheus
      metrics:
        enabled: true
        serviceMonitor:
          enabled: true

      # use dedicated ServiceAccount
      # still, EKS Pod Identity isn't working (yet?)
      serviceAccount:
        create: true
        name: nexus3
        automountToken: true

      # add additional TCP port for the Docker caching listener
      service:
        additionalPorts:
          - port: 8082
            name: docker-proxy
            containerPort: 8082
            hosts:
              - nexus-docker.ops.example.co

      # to be able to connect from Kubernetes WorkerNodes, we have to have a dedicated AWS LoadBalancer, not only Kubernetes Service with ClusterIP
      ingress:
        enabled: true
        annotations:
          alb.ingress.kubernetes.io/group.name: ops-1-30-internal-alb
          alb.ingress.kubernetes.io/target-type: ip
        ingressClassName: alb
        hosts:
        - nexus.ops.example.co

      # define the Nexus configuration
      config:
        enabled: true
        anonymous:
          enabled: true

        blobStores:

          # local EBS storage; 8 GB total default size ('persistence' config above)
          # is attached to a repository in the 'repos.pip-cache' below
          - name: default
            type: file
            path: /nexus-data/blobs/default
            softQuota:
              type: spaceRemainingQuota
              limit: 500

          # dedicated sorage for PyPi caching
          - name: PyPILocalStore
            type: file
            path: /nexus-data/blobs/pypi
            softQuota:
              type: spaceRemainingQuota
              limit: 500              

          # dedicated sorage for Docker caching
          - name: DockerCacheLocalStore
            type: file
            path: /data/nexus/docker-cache
            softQuota:
              type: spaceRemainingQuota
              limit: 500

        # enable Docker Bearer Token Realm
        realms:
          enabled: true
          values:
            - NexusAuthenticatingRealm
            - DockerToken

        # cleanup policies for Blob Storages
        # is attached to epositories below
        cleanup:
          - name: CleanupAll
            notes: "Cleanup content that hasn't been updated in 14 days downloaded in 28 days."
            format: ALL_FORMATS
            mode: delete
            criteria:
              isPrerelease:
              lastBlobUpdated: "1209600"
              lastDownloaded: "2419200"

        repos:

          - name: pip-cache
            format: pypi
            type: proxy
            online: true
            negativeCache:
              enabled: true
              timeToLive: 1440
            proxy:
              remoteUrl: https://pypi.org
              metadataMaxAge: 1440
              contentMaxAge: 1440
            httpClient:
              blocked: false
              autoBlock: true
              connection:
              retries: 0
              useTrustStore: false
            storage:
              blobStoreName: default
              strictContentTypeValidation: false
            cleanup:
              policyNames:
                - CleanupAll

          - name: docker-cache
            format: docker
            type: proxy
            online: true
            negativeCache:
              enabled: true
              timeToLive: 1440
            proxy:
              remoteUrl: https://registry-1.docker.io
              metadataMaxAge: 1440
              contentMaxAge: 1440
            httpClient:
              blocked: false
              autoBlock: true
              connection:
              retries: 0
              useTrustStore: false
            storage:
              blobStoreName: DockerCacheLocalStore
              strictContentTypeValidation: false
            cleanup:
              policyNames:
                - CleanupAll
            docker:
              v1Enabled: false
              forceBasicAuth: false
              httpPort: 8082
            dockerProxy:
              indexType: "REGISTRY"
              cacheForeignLayers: "true"
    EOT
  ]
}

Деплоїмо, відкриваємо порт:

$ kk -n ops-nexus-ns port-forward svc/nexus3 8081

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

Перевіряємо сам Docker repository:

Гуд.

Ingress/ALB для Nexus Docker cache

Так як ContainerD на EC2 не відноситься до Kubernetes, то і доступу до Kubernetes Service з ClusterIP в нього нема. Відповідно, він не зможе виконати pull образів з порта 8082 на nexus3.ops-nexus-ns.svc.cluster.local.

В Helm chart є можливість створити окремий Ingress, в якому задаємо всі параметри:

...
      # to be able to connect from Kubernetes WorkerNodes, we have to have a dedicated AWS LoadBalancer, not only Kubernetes Service with ClusterIP
      ingress:
        enabled: true
        annotations:
          alb.ingress.kubernetes.io/group.name: ops-1-30-internal-alb
          alb.ingress.kubernetes.io/target-type: ip
        ingressClassName: alb
        hosts:
        - nexus.ops.example.co
...

В мене використовується анотація alb.ingress.kubernetes.io/group.name для обєднання кількох Kubernetes Ingress через один AWS LoadBalancer, див. Kubernetes: єдиний AWS Load Balancer для різних Kubernetes Ingress.

Важливий нюанс тут: в параметрах Ingress не треба додавати порти і хости, які задані в Service.

Тобто, для:

...
      # add additional TCP port for the Docker caching listener
      service:
        additionalPorts:
          - port: 8082
            name: docker-proxy
            containerPort: 8082
            hosts:
              - nexus-docker.ops.example.co
...

В Helm-чарті автоматично створиться роут на Ingress:

$ kk -n ops-nexus-ns get ingress nexus3 -o yaml
...
spec:
  ingressClassName: alb
  rules:
  - host: nexus.ops.example.co
    http:
      paths:
      - backend:
          service:
            name: nexus3
            port:
              name: http
        path: /
        pathType: Prefix
  - host: nexus-docker.ops.example.co
    http:
      paths:
      - backend:
          service:
            name: nexus3
            port:
              name: docker-proxy
        path: /
        pathType: Prefix

Див. ingress.yaml.

Го далі.

Налаштування ContainerD mirror на Kubernetes WorkerNode

Вже бачили локально, в AWS EKS в принципі все теж саме.

Єдине, що локально у нас версія v2.0.3, а в AWS EKS – 1.7.25, тому формат конфігу буде трохи різний.

На AWS EKS WorkerNode/EC2 перевіряємо файл /etc/containerd/config.toml:

...

[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d:/etc/docker/certs.d"
...

Поки руками додаємо сюди нове дзеркало – тут і є трохи відмінності від того, що ми робили локально. Див. приклади в Configure Registry Credentials Example – GCR with Service Account Key Authentication.

Тобто, для containerd версії 1 – версія конфігу == 2, а для containerd версії 2 – версія конфігу == 3…? Okay, man… 

На EC2 конфіг буде виглядати так:

[plugins."io.containerd.grpc.v1.cri".registry]
  [plugins."io.containerd.grpc.v1.cri".registry.mirrors]
    [plugins."io.containerd.grpc.v1.cri".registry.mirrors."docker.io"]
      endpoint = ["http://nexus-docker.ops.example.co"]

Порт не задаємо, бо на AWS ALB це розрулиться через hostname, який зароутить запит на потрібний Listener.

Рестартимо сервіс:

[root@ip-10-0-32-218 ec2-user]# systemctl restart containerd

Виконуємо pull якогось образу:

[root@ip-10-0-46-186 ec2-user]# crictl pull nginx

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

Все є.

Залишилось додати налаштування ContainerD при створенні сервері з Karpenter.

Karpenter та конфіг для ContainerD

В мене EC2NodeClass для Karpenter створюються в Terraform, див. Terraform: створення EKS, частина 3 – установка Karpenter.

Звістно, всі ці операції краще виконувати на тестовому оточенні, або створити окремий NodeClass та NodePool.

Зараз там через AWS EC2 UserData конфігуриться ~ec2-user/.ssh/authorized_keys для SSH (див. AWS: Karpenter та SSH для Kubernetes WorkerNodes), і сюди ж можемо додати створення файлу для ContainerD mirror.

В дефолтному варіанті ми бачили, що containerd буде перевіряти такі каталоги:

...
[plugins."io.containerd.grpc.v1.cri".registry] 
  config_path = "/etc/containerd/certs.d:/etc/docker/certs.d"
...

Отже, в них можемо додати новий файл.

Але пам’ятаєте, як в тому анекдоті – “А тепер забудьте все, чому вас вчили в університеті”?

Ну, от – забудьте все, що ми робили з конфігами containerd вище, бо це вже deprecated way. Тепер стільно-модно-молодьожно робити з Registry Host Namespace.

Ідея в тому, що в /etc/containerd/certs.d створюється каталог для registry, в ньому файл hosts.toml, а вже в ньому – описується налаштування registry.

В нашому випадку виглядати це буде так:

[root@ip-10-0-45-117 ec2-user]# tree /etc/containerd/certs.d/
/etc/containerd/certs.d/
└── docker.io
    └── hosts.toml

І в hosts.toml:

server = "https://docker.io"

[host."http://nexus-docker.ops.example.co"]
  capabilities = ["pull", "resolve"]

Окей. Описуємо це все діло в UserData нашого тестового EC2NodeClass.

Так тут наш “улюблений” YAML – то приведу весь конфіг, аби не мати проблем з відступами, бо трохи погемороївся:

resource "kubectl_manifest" "karpenter_node_class_test_latest" {
  yaml_body = <<-YAML
    apiVersion: karpenter.k8s.aws/v1
    kind: EC2NodeClass
    metadata:
      name: class-test-latest
    spec:
      kubelet:
        maxPods: 110
      blockDeviceMappings:
        - deviceName: /dev/xvda
          ebs:
            volumeSize: 40Gi
            volumeType: gp3
      amiSelectorTerms:
        - alias: al2@latest
      role: ${module.eks.eks_managed_node_groups["${local.env_name_short}-default"].iam_role_name}
      subnetSelectorTerms:
        - tags:
            karpenter.sh/discovery: "atlas-vpc-${var.aws_environment}-private"
      securityGroupSelectorTerms:
        - tags:
            karpenter.sh/discovery: ${var.env_name}
      tags:
        Name: ${local.env_name_short}-karpenter
        nodeclass: test
        environment: ${var.eks_environment}
        created-by: "karpenter"
        karpenter.sh/discovery: ${module.eks.cluster_name}
      userData: |
        #!/bin/bash
        set -e

        mkdir -p ~ec2-user/.ssh/
        touch ~ec2-user/.ssh/authorized_keys
        echo "${var.karpenter_nodeclass_ssh}" >> ~ec2-user/.ssh/authorized_keys
        chmod 600 ~ec2-user/.ssh/authorized_keys
        chown -R ec2-user:ec2-user ~ec2-user/.ssh/

        mkdir -p /etc/containerd/certs.d/docker.io

        cat <<EOF | tee /etc/containerd/certs.d/docker.io/hosts.toml

        server = "https://docker.io"

        [host."http://nexus-docker.ops.example.co"]
          capabilities = ["pull", "resolve"]
        EOF

        systemctl restart containerd
  YAML

  depends_on = [
    helm_release.karpenter
  ]
}

Для створення EC2 маю окремий тестовий Pod, який має tolerations і nodeAffinity (див. Kubernetes: Pods та WorkerNodes – контроль розміщення подів на нодах), через які Karpenter має створити EC2 саме з “class-test-latest” EC2NodeClass:

apiVersion: v1
kind: Pod
metadata:
  name: test-pod
spec:
  containers:
    - name: nginx
      image: nginx
      command: ['sleep', '36000']
  restartPolicy: Never
  tolerations:
    - key: "TestOnly"
      effect: "NoSchedule"  
      operator: "Exists"
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - key: component
                operator: In
                values:
                  - test

Деплоїмо зміни, деплоїмо Pod, логінимось на EC2 та перевіряємо конфіг:

[root@ip-10-0-45-117 ec2-user]# cat /etc/containerd/certs.d/docker.io/hosts.toml 

server = "https://docker.io"

[host."http://nexus-docker.ops.example.co"]
  capabilities = ["pull", "resolve"]

Ще раз виконуємо pull якого-небудь PHP:

[root@ip-10-0-43-82 ec2-user]# crictl pull php

І перевіряємо Nexus:

Готово.

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

Loading