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

12 Липня 2025

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

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

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

Kubernetes API

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Kubernetes API Groups та Kind

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

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

  • API Group
    • versions
      • kind

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

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

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

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

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

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

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

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

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

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

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

Kubernetes та etcd

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

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

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

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

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

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

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

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

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

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

$ minikube ssh

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

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

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

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

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

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

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

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

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

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

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

Результат:

Окей.

CustomResourceDefinitions та Kubernetes API

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

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

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

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

Тут ми:

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

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

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

Створюємо CRD:

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

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

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

І саму API Group mycompany.com:

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

Та в etcd:

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

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

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

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

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

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

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

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

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

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

Kubernetes API Service

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

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

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

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

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

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

Loading

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

10 Липня 2025

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

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

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

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

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

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

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

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

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

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

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

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

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

Loading

Arch Linux: linux-firmware-nvidia: /usr/lib/firmware/nvidia/ exists in filesystem
0 (0)

9 Липня 2025

Не зважаючи на те, що про зміни було повідомлено в листах від Arch Linux – чомусь дуже у багатьох виникли проблеми з останнім апдейтом: в сабредітах по Arch Linux на Reddit прям через один топік питають “Аааа, в мене все зламалось, що робити?!?”.

Глянемо як все ж завершити апгрейд, і що саме мінялось.

The issue: linux-firmware exists in filesystem

Власне, помилка виглядає так:

$ sudo pacman -Syu
...
(363/363) checking for file conflicts                                                                                                                  [############################################################################################] 100%
error: failed to commit transaction (conflicting files)
linux-firmware-nvidia: /usr/lib/firmware/nvidia/ad103 exists in filesystem
linux-firmware-nvidia: /usr/lib/firmware/nvidia/ad104 exists in filesystem
linux-firmware-nvidia: /usr/lib/firmware/nvidia/ad106 exists in filesystem
linux-firmware-nvidia: /usr/lib/firmware/nvidia/ad107 exists in filesystem
Errors occurred, no packages were upgraded.

Лист від Arch Linux виглядав так:

Посилання – тут>>>.

В повідомленні говориться, що:

With 20250613.12fe085f-5, we split our firmware into several vendor-focused packages. linux-firmware is now an empty package depending on our default set of firmware.

І явно вказано, що треба зробити – просто перевстановити пакет linux-firmware.

Видаляємо існуючий:

$ sudo pacman -Rdd linux-firmware

І встановлюємо заново:

$ sudo pacman -Syu linux-firmware

The cause: what was changed?

А що в новому пакеті? Що змінилось?

Сам пакет linux-firmware містить необхідні файли для роботи hardware, але які не включені в ядро Linux чи в ISO-образ Arch Linux.

Як говориться в листі – тепер замість єдиного пакета з усіма файлами – він буде розбитий на кілька різних, “vendor-focused packages“.

Версія (чи “тег”?) нового пакету – 20250627, а старого – 20250508.

Можна глянути що саме було встановлено з останнім апдейтом:

$ grep linux-firmware /var/log/pacman.log
[2025-05-27T15:03:52+0000] [PACMAN] Running 'pacman -r /mnt -Sy --config=/tmp/pacman.conf.2hxk --disable-sandbox --cachedir=/mnt/var/cache/pacman/pkg --noconfirm base linux linux-firmware grub efibootmgr networkmanager sudo vim iwd dhcpcd openssh'
[2025-05-27T15:04:18+0000] [ALPM] installed linux-firmware-whence (20250508.788aadc8-2)
[2025-05-27T15:04:18+0000] [ALPM] installed linux-firmware (20250508.788aadc8-2)
[2025-07-05T15:24:51+0300] [PACMAN] Running 'pacman -Rdd linux-firmware'
[2025-07-05T15:24:52+0300] [ALPM] removed linux-firmware (20250508.788aadc8-2)
[2025-07-05T15:25:10+0300] [PACMAN] Running 'pacman -Syu linux-firmware'
[2025-07-05T15:25:26+0300] [ALPM] upgraded linux-firmware-whence (20250508.788aadc8-2 -> 20250627-1)
[2025-07-05T15:25:26+0300] [ALPM] installed linux-firmware-amdgpu (20250627-1)
[2025-07-05T15:25:26+0300] [ALPM] installed linux-firmware-atheros (20250627-1)
[2025-07-05T15:25:26+0300] [ALPM] installed linux-firmware-broadcom (20250627-1)
[2025-07-05T15:25:26+0300] [ALPM] installed linux-firmware-cirrus (20250627-1)
[2025-07-05T15:25:26+0300] [ALPM] installed linux-firmware-intel (20250627-1)
[2025-07-05T15:25:26+0300] [ALPM] installed linux-firmware-mediatek (20250627-1)
[2025-07-05T15:25:26+0300] [ALPM] installed linux-firmware-nvidia (20250627-1)
[2025-07-05T15:25:26+0300] [ALPM] installed linux-firmware-other (20250627-1)
[2025-07-05T15:25:26+0300] [ALPM] installed linux-firmware-radeon (20250627-1)
[2025-07-05T15:25:26+0300] [ALPM] installed linux-firmware-realtek (20250627-1)
[2025-07-05T15:25:26+0300] [ALPM] installed linux-firmware (20250627-1)

Власне і бачимо, що тепер у нас є пачка пакетів типу linux-firmware-nvidia, який містить в собі:

$ bsdtar -tf /var/cache/pacman/pkg/linux-firmware-nvidia-20250627-1-any.pkg.tar.zst | head
.BUILDINFO
.MTREE
.PKGINFO
usr/
usr/lib/
usr/lib/firmware/
usr/lib/firmware/nvidia/
usr/lib/firmware/nvidia/ad102/
usr/lib/firmware/nvidia/ad102/gsp/
usr/lib/firmware/nvidia/ad102/gsp/booter_load-535.113.01.bin.zst

Або з pacman:

$ pacman -Ql linux-firmware-nvidia | head
linux-firmware-nvidia /usr/
linux-firmware-nvidia /usr/lib/
linux-firmware-nvidia /usr/lib/firmware/
linux-firmware-nvidia /usr/lib/firmware/nvidia/
linux-firmware-nvidia /usr/lib/firmware/nvidia/ad102/
linux-firmware-nvidia /usr/lib/firmware/nvidia/ad102/gsp/
linux-firmware-nvidia /usr/lib/firmware/nvidia/ad102/gsp/booter_load-535.113.01.bin.zst
linux-firmware-nvidia /usr/lib/firmware/nvidia/ad102/gsp/booter_load-570.144.bin.zst
linux-firmware-nvidia /usr/lib/firmware/nvidia/ad102/gsp/booter_unload-535.113.01.bin.zst
linux-firmware-nvidia /usr/lib/firmware/nvidia/ad102/gsp/booter_unload-570.144.bin.zst

А старий можна подивитись в кеші:

$ ls -lt /var/cache/pacman/pkg/linux-firmware-20250508*
-rw-r--r-- 1 root root       310 May 24 08:41 /var/cache/pacman/pkg/linux-firmware-20250508.788aadc8-2-any.pkg.tar.zst.sig
-rw-r--r-- 1 root root 287519592 May 24 08:40 /var/cache/pacman/pkg/linux-firmware-20250508.788aadc8-2-any.pkg.tar.zst

І що саме було в ньому:

$ bsdtar -tf /var/cache/pacman/pkg/linux-firmware-20250508.788aadc8-2-any.pkg.tar.zst | grep nvidia | head
usr/lib/firmware/brcm/brcmfmac4354-sdio.nvidia,p2371-2180.txt.zst
usr/lib/firmware/nvidia/
usr/lib/firmware/nvidia/ad102/
usr/lib/firmware/nvidia/ad102/gsp/
usr/lib/firmware/nvidia/ad102/gsp/booter_load-535.113.01.bin.zst
usr/lib/firmware/nvidia/ad102/gsp/booter_unload-535.113.01.bin.zst
usr/lib/firmware/nvidia/ad102/gsp/bootloader-535.113.01.bin.zst
usr/lib/firmware/nvidia/ad102/gsp/gsp-535.113.01.bin.zst
usr/lib/firmware/nvidia/ad103/
usr/lib/firmware/nvidia/ad103/gsp

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

What to do in the future?

  1. підписатись на розсилку: https://lists.archlinux.org/mailman3/lists/arch-announce.lists.archlinux.org/
  2. встановити пакет informant – він перевіряє Arch News і додає hook для pacman, який зупинить апгрейд, якщо є якісь not resolved issues

Loading

Kubernetes: Pod resources.requests, resources.limits та Linux cgroups
0 (0)

2 Липня 2025

Як саме resources.requests та resources.limits в Kubernetes manifest працюють “під капотом”, і як саме Linux буде виділяти та обмежувати ресурси для контейнерів?

Отже, Kubernetes для Pod ми можемо задати два основні параметри для CPU та Memory – spec.containers.resources.requests та spec.containers.resources.limits:

  • resources.requests: впливає на те, як і де Pod буде створено, і скільки ресурсів гарантовано він отримає
  • resources.limits: впливає на те, скільки ресурсів максимум він може споживати
    • якщо resources.limits.memory більше ліміту – под може бути вбито з OOMKiller, якщо на WorkerNode недостатньо вільної пам’яті (Node memory pressure)
    • якщо resources.limits.cpu більше ліміту – то включиться режим CPU throttling

Якщо з Memory все доволі зрозуміло – задаємо кількість байт, то з CPU все трохи цікавіше.

Тож спершу давайте глянемо на те, як ядро Linux взагалі планує те, скільки CPU time буде приділятись кожному процесу завдяки механізму Control Groups.

Linux cgroups

Linux Control Groups (cgroups) – один з двох основних механізмів ядра, які забезпечують ізоляцію і контроль над процесами:

  • Linux namespaces: створюють ізольований простір імен з власним деревом процесів (PID Namespace), мережевими інтерфейсами (net namespace), User IDs (User namespace) і так далі – див. What is: Linux namespaces, примеры PID и Network namespaces
  • Linux cgroups: механізм контролю ресурсів процесами – скільки пам’яті, CPU, мережевих ресурсів та дискових I/O операцій буде доступно процесу

Groups в назві – бо всі процеси об’єднані в групи у вигляді дерева – parent-child tree.

Тому якщо для parent-процесу заданий ліміт в 512 мегабайт – то сума доступної пам’яті його і його потомків не може бути вища за 512 МБ.

Всі групи задані в каталозі /sys/fs/cgroup/, який підключається окремим типом файлової системи – cgroup2:

$ mount | grep cgro
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot)

cgroups є більш старої версії 1, та нової – 2, див. man cgroups.

По факту, cgroups v2 вже є новим стандартом, тому говорити будемо про неї – але cgroups v1 все ще присутній, коли ми говоримо про Kubernetes.

Перевірити версію можна за допомогою stat і каталога /sys/fs/cgroup/:

$ stat -fc %T /sys/fs/cgroup/
cgroup2fs

Якщо тут буде tmpfs – то це cgroups v1.

Каталог /sys/fs/cgroup/

Типовий вигляд директорії на Linux-хості – тут приклад з мого домашнього ноутбука з Arch Linux:

$ tree /sys/fs/cgroup/ -d -L 2
/sys/fs/cgroup/
├── dev-hugepages.mount
├── dev-mqueue.mount
├── init.scope
...
├── system.slice
│   ├── NetworkManager.service
│   ├── bluetooth.service
│   ├── bolt.service
    ...
└── user.slice
    └── user-1000.slice

Ту ж ієрархію можна побачити з systemctl status або systemd-cgls.

В systemctl status дерево виглядає так:

$ systemctl status
● setevoy-work
    State: running
    ...
    Since: Mon 2025-06-09 12:21:11 EEST; 3 weeks 1 day ago
  systemd: 257.6-1-arch
   CGroup: /
           ├─init.scope
           │ └─1 /sbin/init
           ├─system.slice
           │ ├─NetworkManager.service
           │ │ └─858 /usr/bin/NetworkManager --no-daemon
           ...
           │ └─wpa_supplicant.service
           │   └─1989 /usr/bin/wpa_supplicant -u -s -O /run/wpa_supplicant
           └─user.slice

Тут всі процеси згруповані по типам:

  • system.slice: всі systemd-сервіси (nginx.service, docker.service, і т.д.)
  • user.slice: user-процеси
  • machine.slice: віртуальні машини, контейнери

Де slice – це абстракція systemd, за якою він групує різні процеси – див. man systemd.slice

До якої саме cgroup належить процес можна побачити в його /proc/<PID>/cgroup, наприклад NetworkManager з PID “858”:

$ cat /proc/858/cgroup 
0::/system.slice/NetworkManager.service

Також cgroup slice може бути задана в systemd-файлі сервісу:

$ cat /usr/lib/systemd/system/[email protected] | grep Slice
Slice=system.slice

CPU та Memory в cgroups, та cgroups v1 vs cgroups v2

Отже, в cgroup для всього слайсу задаються параметри того, скільки CPU та Memory процеси цієї групи можуть використовувати (тут і далі будемо говорити тільки про CPU та Memory).

Наприклад, для мого юзера setevoy (з ID 1000) маємо файли cpu.max та memory.max:

$ cat /sys/fs/cgroup/user.slice/user-1000.slice/cpu.max
max 100000

$ cat /sys/fs/cgroup/user.slice/user-1000.slice/memory.max 
max

cpu.max в cgroups v2 замінив cpu.cfs_quota_us і cpu.cfs_period_us з cgroup v1

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

Формат файлу – <quota> <period>, де <quota> – це доступний процесу (чи групі) час, а <period> – тривалість одного періоду в мікросекундах (100.000 мкс = 100 мс).

В cgroups v1 ці значення задавались в cpu.cfs_quota – для <quota> в v2, та cpu.cfs_period_us – для <period> у v2.

Тобто в файлі вище бачимо:

  • max: доступний весь час
  • 100000 мкс = 100 мс, один CPU period

CPU period тут – це інтервал часу, протягом якого Linux ядро перевіряє, скільки процеси у cgroup використали CPU: якщо групі заданий ліміт (quota), і процеси його вичерпали – то вони будуть призупинені до завершення поточного period (CPU throttling).

Тобто якщо для процесу заданий ліміт в 50.000 (50 мс) при period в 100.000 мікросекунд (100 мс) – то процеси можуть використати тільки 50 мс у кожному 100 мс “вікні”.

Використання пам’яті можна побачити у файлі memory.current:

$ cat /sys/fs/cgroup/user.slice/user-1000.slice/memory.current 
47336714240

Що дає нам:

$ echo "47336714240 / 1024 / 1024" | bc
45143

45 гігабайт зайнятої пам’яті процесами юзера 1000.

Також перевірити поточне використання ресурсів кожною групою можемо з systemd-cgtop:

Або передавши ім’я слайсу:

Для CPU є загальна статистика по групі від початку створення процесів в цій групі – cpu.stat:

$ cat /sys/fs/cgroup/user.slice/user-1000.slice/cpu.stat
usage_usec 2863938974603
...

В Kubernetes показники cpu.max та memory.max будуть визначатись, коли ми задаємо resources.limits.cpu та resources.limits.memory.

Чому “Kubernetes CPU Limits – погана ідея”?

Дуже часто можна зустріти згадку про те, що задавати ліміти на CPU в Kubernetes – погана ідея.

Чому так?

Бо якщо ми задаємо ліміт (тобто значення != max в cpu.max) – то коли група процесів використає свій час в поточному вікні CPU Time – то ці процеси будуть обмежені навіть попри те, що загалом CPU має можливість виконати запити.

Тобто, навіть якщо в системі є вільні ядра, але cgroup уже вичерпала свій cpu.max у поточному періоді – процеси цієї групи будуть призупинені до завершення періоду (CPU throttling) незалежно від загального навантаження системи.

Див. For the Love of God, Stop Using CPU Limits on Kubernetes та Making Sense of Kubernetes CPU Requests And Limits.

Linux CFS та cpu.weight

Вище бачили cpu.max, де для мого юзера дозволяється використовувати весь доступний CPU час за кожен CPU-період.

Але якщо обмеження не встановлено (тобто max), і кілька груп процесів хочуть доступ до CPU одночасно – то ядро має вирішити кому виділити більше CPU time.

Для цього в cgroups задається ще один параметр – cpu.weight (у cgroups v2) або cpu.shares (у cgroups v1): це відносний пріоритет групи процесів при визначенні черги доступу до CPU.

Значення cpu.weight враховується Linux CFS (Completely Fair Scheduler), щоб розподілити CPU пропорційно між кількома cgroup. – див. CFS Scheduler та Process Scheduling in Linux.

$ cat /sys/fs/cgroup/user.slice/user-1000.slice/cpu.weight
100

Діапазон значень тут – від 1 до 10.000, де 1 – мінімальний пріорітет, а 10.000 – максимальний. Значення 100 – дефолтне.

Чи пріорітет вищий – тим більше часу CFS буде виділяти процесам цієї групи.

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

В Kubernetes показник cpu.weight буде визначатись із resources.requests.cpu.

А от значення resources.requests.memory впливає тільки на Kubernetes Scheduler для вибору Kubernetes WorkerNode для пошуку ноди, на якій є достатньо вільної пам’яті.

cpu.weight vs process nice

Окрім cpu.weight/cpu.shares маємо ще process nice, який задає пріорітет задачі.

Різниця між ними в тому, що cpu.weight задаються на рівні cgroup – а nice на рівні конкретного процесу всередині однієї групи.

І якщо більше значення в cpu.weight вказує на більший пріорітет, то з nice навпаки – чим нижче значення nice (від -19 до 20 максимум) – тим більше часу буде виділено процесу.

Якщо обидва процеси в одній cgroup, але з різним nice – то буде враховуватись nice.

А якщо це різні cgroup – то буде враховуватись саме cpu.weight.

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

Linux cgroups Summary

Отже, кожна Control Group визначає те, скільки CPU та пам’яті буде виділятись процесу.

  • cpu.max: визначає скільки часу від кожного CPU period група процесів може витратити
    • в Kubernetes manifest значення в resources.limits.cpu та resources.limits.memory впливають на налаштування cpu.max та memory.max для cgroup відповідних контейнерів
  • memory.max: скільки пам’яті можна використати без ризику потрапити під Oout of Memory Killer
    • в Kubernetes manifest значення resources.requests.memory впливає тільки на Kubernetes Scheduler для вибору Kubernetes WorkerNode
  • cpu.weight: визначає пріорітет групи процесів при навантаженому CPU
    • в Kubernetes manifest значення в resources.requests.cpu впливає на налаштування cpu.weight для cgroup відповідних контейнерів

Kubernetes Pod resources та Linux cgroups

ОК, тепер, як розібрались з cgroups в Linux – давайте детальніше глянемо на те, як значення в Kubernetes resources.requests та resources.limits впливають на контейнери.

Коли ми задаємо spec.container.resources в Deployment чи Pod, і Pod створюються на WorkerNode – то kubelet на цій ноді отримує значення зі PodSpec, і передає їх до Container Runtime Interface (CRI) (ContainerD чи CRI-O).

CRI перетворює їх в специфікацію контейнера в JSON, в якому вказує відповідні значення для cgroup цього контейнеру.

Kubernetes CPU Unit vs cgroup CPU share

В маніфестах Kubernetes ресурси CPU ми задаємо в CPU units: 1 юніт == 1 повне CPU ядро – фізичне чи віртуальне, див. CPU resource units.

1 millicpu або millicores – це 1/1000 від одного ядра CPU.

Один Kubernetes CPU Unit – це 1024 CPU shares у відповідній Linux cgroup.

Тобто: 1 Kubernetes CPU Unit == 1000 millicpu == 1024 CPU shares у cgroup.

Крім того, є нюанс з тим, як саме Kubernetes рахує cpu.weight для Pods – бо Kubernetes використовує CPU shares, які потім переводить в cpu.weight для cgroup v2 – далі побачимо, як це виглядає.

Перевірка Kubernetes Pod resources в cgroup

Створимо тестовий Pod:

apiVersion: v1
kind: Pod
metadata:
  name: nginx-test
  namespace: default
spec:
  containers:
    - name: nginx
      image: nginx
      resources:
        requests:
          cpu: "1"
          memory: "1Gi"
        limits:
          cpu: "1"
          memory: "1Gi"

Запускаємо його, і знаходимо відповідну WorkerNode:

$ kk describe pod nginx-test
Name:             nginx-test
Namespace:        default
Priority:         0
Service Account:  default
Node:             ip-10-0-32-142.ec2.internal/10.0.32.142
...

Підключаємось по SSH, і глянемо на параметри cgroups.

Kubernetes kubepods.slice cgroup

Всі параметри для Kubernetes Pods задані в каталозі /sys/fs/cgroup/kubepods.slice/:

[root@ip-10-0-32-142 ec2-user]# ls -l /sys/fs/cgroup/kubepods.slice/
...
drwxr-xr-x. 5 root root 0 Jul  2 12:30 kubepods-besteffort.slice
drwxr-xr-x. 6 root root 0 Jul  2 12:30 kubepods-burstable.slice
drwxr-xr-x. 4 root root 0 Jul  2 12:31 kubepods-pod32075da9_3540_4960_8677_e3837e04d69f.slice
...

Аби знайти який сам cgroup slice відповідає за наш контейнер – перевіримо запущені поди в namespace k8s.io:

[root@ip-10-0-32-142 ec2-user]# ctr -n k8s.io containers ls
CONTAINER                                                           IMAGE                                                                                             RUNTIME                  
00d432ee10181ce579af7f0d02a3a04167ced45f8438167f3922e385ed9ab58f    602401143452.dkr.ecr.us-east-1.amazonaws.com/eks/eks-pod-identity-agent:v0.1.29                   io.containerd.runc.v2    
...
987bb39fa50532a89842fe1b7a21d1a5829cdf10949a11ac2d4f30ce4afcca2f    docker.io/library/nginx:latest                                                                    io.containerd.runc.v2
...

Note: namespace в ctr – це неймспейси самого containerd, а не Linux, див. containerd namespaces for Docker, Kubernetes, and beyond

Наш контейнер – “987bb39fa50532a89842fe1b7a21d1a5829cdf10949a11ac2d4f30ce4afcca2f“.

Перевіряємо всю інформацію по ньому:

[root@ip-10-0-32-142 ec2-user]# ctr -n k8s.io containers info 987bb39fa50532a89842fe1b7a21d1a5829cdf10949a11ac2d4f30ce4afcca2f
...
        "linux": {
            "resources": {
                "devices": [
                    {
                        "allow": false,
                        "access": "rwm"
                    }
                ],
                "memory": {
                    "limit": 1073741824,
                    "swap": 1073741824
                },
                "cpu": {
                    "shares": 1024,
                    "quota": 100000,
                    "period": 100000
                },
                "unified": {
                    "memory.oom.group": "1",
                    "memory.swap.max": "0"
                }
            },
            "cgroupsPath": "kubepods-pod32075da9_3540_4960_8677_e3837e04d69f.slice:cri-containerd:987bb39fa50532a89842fe1b7a21d1a5829cdf10949a11ac2d4f30ce4afcca2f",
...

Тут ми бачимо resources.memory та resources.cpu.

З memory все ясно, а в resources.cpu маємо три поля:

  • shares: це наші requests із Pod manifest (PodSpec)
  • quota: це наші limits
  • period: CPU період, про який говорили вище – “вікно обліку” для CFS

В cgroupsPath бачимо який саме cgroup slice містить інформацію по цьому контейнеру:

[root@ip-10-0-32-142 ec2-user]# ls -l /sys/fs/cgroup/kubepods.slice/kubepods-pod32075da9_3540_4960_8677_e3837e04d69f.slice/cri-containerd-987bb39fa50532a89842fe1b7a21d1a5829cdf10949a11ac2d4f30ce4afcca2f.scope/
...
-rw-r--r--. 1 root root 0 Jul  2 12:31 cpu.idle
-rw-r--r--. 1 root root 0 Jul  2 12:31 cpu.max
...
-rw-r--r--. 1 root root 0 Jul  2 12:31 cpu.weight
...
-rw-r--r--. 1 root root 0 Jul  2 12:31 memory.max
...

І відповідні значення в них:

[root@ip-10-0-32-142 ec2-user]# cat /sys/fs/cgroup/kubepods.slice/kubepods-pod[...]cca2f.scope/cpu.max
100000 100000

Тобто максимум 100.000 мікросекунд від кожного вікна у 100.000 мікросекунд – бо ми задали resources.limits.cpu == “1”, тобто “повне ядро”.

Kubernetes, cpu.weight та cgroups v2

А от якщо ми поглянемо на файл cpu.weight – то тут картина буде така:

[root@ip-10-0-32-142 ec2-user]# cat /sys/fs/cgroup/kubepods.slice/kubepods-pod[...]cca2f.scope/cpu.weight
39

Звідки взялось значення “39”?

В опису контейнера ми бачили shares == 1024:

...
        "linux": {
            "resources": {
                ...
                "cpu": {
                    "shares": 1024,
...

cpu.shares 1024 – це значення, яке ми задали в Kubernetes, коли вказали resources.requests.cpu == “1”, бо, як говорилось вище – “Один Kubernetes CPU Unit – це 1024 CPU shares”.

Тобто, для cgroups v1 – у файлі cpu.shares ми б мали значення 1024.

Але з cgroup v 2 трохи цікавіше.

“Під капотом” в Kubernetes все одно рахується CPU Shares у форматі 1 ядро == 1024 shares, які потім транслюються в формат cgroups v2.

Якщо ми глянемо загальний cpu.weights для всього слайсу kubepods.slice – то там буде значення 76:

[root@ip-10-0-32-142 ec2-user]# cat /sys/fs/cgroup/kubepods.slice/cpu.weight
76

А звідки взялося “76”?

Перевіряємо скільки ядер на цьому інстансі:

[root@ip-10-0-32-142 ec2-user]# lscpu | grep -E '^CPU\('
CPU(s):                                  2

Формула, за якою рахується cpu.weight описана у файлі group_manager_linux.go#L566:

...
func CpuSharesToCpuWeight(cpuShares uint64) uint64 {
  return uint64((((cpuShares - 2) * 9999) / 262142) + 1)
}
...

Маючи 2 ядра == 2048 CPU shares для v1 – рахуємо:

((((2048 - 2) * 9999) / 262142) + 1)
79

Тобто на весь kubepods.slice визначається “вага” в 79 cpu.weight.

Але це ми рахували взагалі всі CPU shares – а на ділі частина CPU резервується під систему і контролери типу kubelet.

Kubernetes Quality of Service Classes

Див. Kubernetes: Evicted поды и Quality of Service для подов та Pod Quality of Service Classes.

В каталозі /sys/fs/cgroup/kubepods.slice/ ми маємо:

  • kubepods-besteffort.slice: BestEffort QoS: якщо requests та limits задані, але limits менші за requests
  • kubepods-burstable.slice: для Burstable QoS – коли задані тільки requests
  • kubepods-pod32075da9_3540_4960_8677_e3837e04d69f.slice: Quarateed QoS – коли requests та limits задані, і дорівнюють один одному

Наш Pod – саме Quarateed QoS:

$ kk describe pod nginx-test | grep QoS
QoS Class:                   Guaranteed

Бо ми задали requests == limits.

А так як ми задали 1 повне ядро в requests – то Kubernetes через cgroups виділяє йому половину всіє cpu.weight доступної для kubepods.slice:

38*2
76

Саме те значення, яке ми бачили в kubepods.slice/cpu.weight.

І тому Linux CFS зажди буде видавати нашому контейнеру половину всього доступного CPU time на обох ядрах – або “одне ціле ядро”.

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

Loading

TCP/IP: моделі OSI та TCP/IP, TCP-пакети, Linux sockets і порти
0 (0)

29 Червня 2025

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

Спочатку згадаємо про моделі OSI та TCP/IP, потім про структуру пакетів, встановлення підключень, і в кінці – заглянемо “під капот” Linux – подивимось на сокети і Linux TCP stack.

В основному увага буде саме TCP, бо це те, з чим ми найчастіше маємо справу.

What is: The TCP/IP

Отже, TCP/IP включає в себе два основних поняття:

  • по-перше, це стек протоколів (стандартів, наборів правил)  комунікації – TCP (Transmisstion Control Protocol) Та IP (Internet Protocol): вони описують те, яким чином встановлюються з’єднання між хостами та сервісами в Інтернеті. Ці протоколи стандартизовані і описані у відповідних RFC (TCP – RFC: 793, IP – RFC: 791)
  • крім того, TCP/IP – це модель комунікації, яка включає в себе кілька рівнів – подібно до моделі OSI (і який теж описаний в Informational RFC – RFC 1180)

Загальна модель OSI

Модель OSI (Open Systems Interconnection model), розроблена та стандартизована ISO (International Organization for Standardization) у 1984 році – див. ISO/IEC 35.100.

Колись писав про це у схожому пості – What is: модель OSI, але то було давно, і без детального опису TCP/IP.

Отже, основна ідея моделі: будь-яке з’єднання в мережі проходить через кілька рівнів комунікації, де кожен рівень відповідає лише за свою задачу і не має доступу до логіки роботи інших рівнів – це називається Layer isolation.

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

Якщо дуже спрощено, то процес можна відобразити так:

  • коли наш браузер формує якийсь HTTP-запит – це відбувається на самому верхньому рівні, Application layer
  • цей запит передається нижче, до Transport layer, де дані від браузера інкапсулюються (encapsulation): до даних з Application layer додаються заголовки з Transport layer – TCP headers
  • потім, ще нижче – на Network layer додається IP-заголовок, що вказує адресу відправника й одержувача
  • і нарешті, на Link layer формується Ethernet frame, який і передається через Physical layer

На приймаючій стороні (наприклад, EC2 інстанс з NGINX) все відбувається у зворотньому напрямку – decapsulation: кожен рівень знімає свою обгортку й передає payload вищому рівню.

Непогана діаграма є в OSI Model Explained (хоча тут HTTP header відображено навпроти Session layer – але це відбувається на Application layer):

PDU (Protocol Data Unit)

Крім того, коли ми говоримо “пакет” маючи на увазі дані – то технічно на кожному рівні моделі OSI ці дані називаються по-різному, а загальна назва для них – PDU (Protocol Data Unit):

Protocols layer classification based on OSI Reference Model

Note: хоча на цій схемі теж є неточності, наприклад SQL – це мова запитів на Application layer

Тобто:

  • Application, Presentation, Session рівні – це просто Data
  • Transport layer (рівень 4) – це Segment (в TCP) або Datagram (UDP)
  • Network layer – Packet (наприклад, IP-пакет, в якому може бути TCP-сегмент)
  • Data Link layer – оперує Frames
  • Physical layer – це вже біти, 0 та 1

Процес передачі даних від браузера до web-серверу з TLS

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

  • Layer 7 – Application layer:
    • браузеру треба відправити дані – payload
    • він оперує на Application layer, і формується HTTP-запит з HTTP headers (аутентифікація, кешування, сам запит до потрібного ресурсу – URI, та куди запит йде – URL)
    • тут встановлюється HTTP-сесія – через cookies, JWT-токен, параметри URL тощо
    • сформовані дані передаються до Presentation Layer
  • Layer 6 – Presentation Layer:
    • якщо використовується шифрування, то тут підключаться бібліотеки SSL/TLS і шифрують дані
    • додаються TLS headers
    • при потребі виконується перетворення даних, наприклад кодування символів (ASCII, UTF-8)
  • Layer 5 – Session Layer:
    • тут відбувається процес TLS handshake – встановлення методів і ключів шифрування (класний матеріал на цю тему – The Illustrated TLS 1.2 Connection, і колись писав я – What is: SSL/TLS в деталях)
    • створюється TLS-сесія між клієнтом та сервером
  • Layer 4 – Transport Layer:
    • тут формується TCP-сегмент (або datagram для UDP, втім тут ми розглядаємо браузер та HTTP, тому TCP): до даних від вищих рівнів (наприклад, браузера) додаються TCP headers – Source port, Destination port, порядковий Sequence number – номер пакету (див Multiplexing and Demultiplexing in Transport Layer)
    • задаються TCP flags – ACK, etc
    • розмір TCP-сегменту обмежується MSS (Maximum Segment Size) – будемо дивитись далі
  • Layer 3 – Network Layer:
    • тут до TCP-сегменту додаються IP headers – звідки пакет відправлений (source IP), куди він йде (destination IP) і формується IP пакет
    • додаються дані про TTL (Time To Live) пакту, checksum пакету для перевірки отримувачем – чи пакет дійшов неушкодженим
    • розмір IP-адреси – 32 біти в IPv4, та 128 біт в IPv6
    • від IP-адреси залежить тип передачі даних – unicast (одному адресату), multicast (кільком адресатам), там broadcast – всім хостам в заданій мережі
    • на цьому ж рівні працює ICMP для обміну інформацією стан мережі і про помилки
      • на Network layer ICMP packets можуть створюватися автоматично при проблемах на рівні маршрутизації
      • але можуть формуватись і на Application layer, наприклад утилітами ping або traceroute
  • Layer 2 – Data Link Layer:
    • Ethernet, Wi-Fi – драйвери мережевої карти формують frame, додаючи MAC-адресу відправника і отримувача і власну перевірку цілісності пакету – CRC (cyclic redundancy check)
    • MAC адреси визначаються за допомогою протоколу ARP (Adress Resolution Protocol)
  • Layer 1 – Physical Layer:
    • фізичне з’єднання, електричні або оптичні сигнали

DNS в моделі OSI

Окремо давайте розглянемо питання про DNS:

  • Layer 7 – Application layer:
    • браузеру потрібно відправити запит на “google.com”
    • браузер виконує функцію getaddrinfo() або gethostbyname() (застаріла) з бібліотеки glibc
      • glibc перевіряє параметри /etc/nsswitch.conf:
        • при необхідності виконати зовнішній DNS-запит (наприклад, якщо нема запису в /etc/hosts) – перевіряє параметри в /etc/resolv/conf
        • формується DNS-запит, відкривається UPD- або TCP-сокет
  • Layer 4 – Transport Layer:
    • на транспортному рівні до PDU (сегменту TCP або датаграми UDP)  додається заголовок з destination port 53 (зазвичай UDP, але може бути TCP – якщо відповідь більша за 512 байт або включено режим DNS-over-TCP, DoT – див. RFC 7766)
  • Layer 3 – Network Layer:
    • до пакету додаються IP headers – куди саме відправити запит
  • Layer 2 – Data Link Layer та Layer 1 – Physical Layer: передача даних

Модель OSI ISO vs модель TCP/IP

Модель TCP/IP (або Internet Protocol Suite) була розроблена у 1970-х, ще до появи OSI і лягла  в основу Інтернету (і його попередника – ARPANET).

Моделі OSI ISO та TCP/IP створені для уніфікації зв’язку між пристроями, але мають ключові відмінності:

  • OSI описує 7 рівнів, тоді як TCP/IP – 4
    • Application Layer в TCP/IP включає в себе Application, Presentation та Session layers моделі OSI
    • а Network Access Layer в TCP/IP включає в себе Data Link та Physical layers моделі OSI (іноді називають Link Layer)
  • модель OSI це більш “академічна модель”, яка використовується для пояснення принципів роботи мережі, а TCP/IP – “прикладна модель”, на якій власне побудована комунікація в Інтернеті

Головна різниця в тому, що:

  • модель TCP/IP створювалась для опису вже існуючих протоколів (TCP, IP, FTP, SMTP тощо), які використовувались в ARPANET – тобто спочатку технологія, а потім її опис у вигляді моделі.
  • натомість модель OSI – це більше теоретична моделі, яку спочатку описали (“як це має бути”), і потім вже під цю модель почали додавати нові протоколи.

Непогана ілюстрація в рівнях моделей є в A Refresher Course on OSI & TCP/IP:

TCP headers та payload

Окей – з загальною схему передачі даних розібрались, давайте подивимось детальніше на те, що і як саме передається в TCP/IP.

Оскільки ми вже згадували про TCP headers – то давайте почнемо з них.

TCP header має однакову структуру незалежно від того, чи він передається всередині IPv4 або IPv6. Його мінімальний розмір 20 байт, а максимальний, за рахунок використання поля Options – 60 байт.

IPv4 headers має змінну довжину – від 20 до 60 байт, а от заголовки IPv6 фіксовані в 40 байт.

MTU, MSS та TCP Payload

Максимальний розмір даних, які можна передати в одному IP-пакеті та TCP-сегменті, і залежить від розміру Ethernet frame, MTU (Maximum Transmission Unit), який по дефолту заданий в 1500 байт:

$ ifconfig wlan0 | grep -i MTU
wlan0: flags=4099<UP,BROADCAST,MULTICAST>  mtu 1500

Від цих 1500 байт віднімається місце під TCP та IP заголовки, і в результаті це дає нам MSS (Maximum Segment Size) – максимальний розмір корисних даних у TCP-сегменті:

MSS = (MTU) - (IP header) - (TCP header)
    = 1500 - 20 - 20
    = 1460 байт

MSS оголошується під час TCP-handshake через TCP option MSS в пакеті SYN, і обидві сторони узгоджують значення, що дозволяє відправнику враховувати цей розмір, аби уникнути IP-фрагментації.

Якщо TCP payload перевищує MSS – то стек TCP виконує сегментацію, тобто розбиває цей потік на кілька окремих TCP-сегментів.

Наприклад, браузер відправляє POST-запит розміром 3000 байт – тоді TCP поділить цей запит на:

  • сегмент 1 з розміром даних 1460 байт
  • сегмент 2 з розміром даних 1460 байт
  • сегмент 3 з 80 байтами

У випадку з TCP-сегментації – кожен пакет матиме власні заголовки, а його Sequence Number буде вказувати на позицію першого байта даних цього сегмента в загальному потоці даних. При цьому розмір payload кожного сегмента не перевищуватиме MSS.

IP-фрагментація – виняткова ситуація, який трапляється тільки якщо TCP-сегмент (або інший IP-пакет) вже перевищив MTU, і в ідеалі не має відбуватись.

Структура TCP headers

Отже, після отримання даних від Application layer формується TCP-сегмент, до якого додається набір TCP headers:

Note:  далі все ж буду використовувати слово “флаги”, а не “прапорці”, бо в контексті TCP якось більш коректно звучить

Тут:

  • Source port: поле 16 біт в якому вказується порт відправника
  • Destination port: поле 16 біт в якому вказується порт призначення
  • Sequence number: поле 32 біти, яке вказує на перший байт даних (payload) кожного TCP-сегменту
  • Acknowledgment number: поле 32 біти, яке передається отримувачем для запиту наступного TCP-сегменту – це буде Sequence Number + 1
  • DO (data offset): поле 4 біти яке вказує де закінчується TCP header і починаються дані (payload)
  • RSV (reserved field): 3 биті, не використовується, і завжди пусте
  • Flags: 9 біт, також називаються “control bits” – використовуються для передачі флагів, які контролюють встановлення підключення, передачу даних та закриття підключення
    • URG: urgent pointer – якщо флаг заданий, то сегмент має термінові дані, які на стороні операційної системи отримувача можуть бути передані окремим системним викликом минуючи загальний TCP-буфер (див. TCP – Urgent pointer field)
      • не впливає на маршрутизацію чи доставку пакета мережею і використовується тільки локально стеком ядра операційної системи отримувача
    • ACK: acknowledgment підтвердження отримання сегменту
    • PSH: push function – передати дані негайно, не очікуючи наповнення TCP-буферу
    • RST: reset – примусове закриття з’єднання при помилках
    • SYN: початок з’єднання (в TCP 3-way handshake, далі подивимось), задає Initial Sequence Number – див. трохи ниже
    • FIN: нормальне закриття з’єднання, відправляється як клієнтом, так і сервером
  • Window: 16 біт, вказується максимальна кількість байт, які відправник (і клієнт, і сервер) може відправити без очікування наступного ACK від серверу (контроль TCP-буферу ядра на стороні серверу)
  • Checksum: 16 біт, контрольна сума TCP-заголовка + даних, використовується для перевірки, чи не пошкоджено сегмент під час передачі
  • Urgent pointer: 16 біт, якщо заданий URG, то тут вказується де завершуються термінові дані
  • Options: 0 – 320 біт, використовується для передачі MSS, timestamps тощо

Sequence Number

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

TCP – це потоковий протокол, який передає послідовність байт, а не окремі повідомлення.

Під час передачі даних в TCP-сесії:

  • клієнт відправляє SYN з Initial Sequence Number, який спочатку задається у вигляді рандомного числа, наприклад 100000
  • сервер відповідає SYN-ACK, підтверджуючи отримання запиту на з’єднання (SYN)
    • в полі Acknowledgment Number вказує 100001
  • далі, при початку передачі першого сегменту даних – клієнт в першому сегменті вкаже Sequence Number 100001, а в наступному – 101461 (при MSS 1460 байт)

Тобто, кожен наступний сегмент збільшує Sequence Number на довжину payload.

Перегляд TCP headers в Wireshark

Самий простий спосіб – з wireshark (або wireshark-qt).

Запускаємо від root, вибираємо інтерфейс:

Наприклад, подивитись трафік до RTFM – знаходимо IP:

$ dig rtfm.co.ua +short
104.26.3.188
104.26.2.188
172.67.68.115

Задаємо фільтр:

ip.addr==104.26.3.188 || ip.addr==104.26.2.188 || ip.addr==172.67.68.115

І отримуємо дані:

TCP connection

Трохи розібрались з TCP headers – тепер давайте глянемо на те, як встановлюється TCP-з’єднання.

Отже, TCP – це протокол, орієнтований на з’єднання (тобто потребує встановлення сесії до початку передачі даних), який забезпечує доставку даних, контроль потоку та виявлення помилок при передачі.

На відміну від UDP – з TCP ми або гарантовано передаємо дані, або буде виявлена помилка і з’єднання буде розірване.

TCP handshake

Як і в TLS, встановлення TCP-з’єднання відбувається за стандартним процесом – “3-way handshake“.

По TLS – див. What is: SSL/TLS в деталях.

TCP handshake складається з трьох етапів (власне, тому і назва “3-way handshake”):

  1. SYN: клієнт відправляє пакет з флагом SYN, вказуючи свій Initial Sequence Number
  2. SYN-ACK: сервер відповідає пакетом з флагами SYN та ACK, цим він:
    1. підтверджує отримання SYN від клієнта (встановлюючи поле Acknowledgment Number)
    2. відправляє свій власний Initial Sequence Number
  3. ACK: клієнт відправляє ACK, чим підтверджує отримання SYN-ACK від серверу

На цьому сесія вважається встановленою, і починається передача даних.

Закриття сесії – “4-way FIN handshake“:

  1. FIN: клієнт повідомляє сервер (або навпаки), що закінчив передачу, і готовий до закриття сесії
  2. ACK від серверу: сервер підтверджує отримання FIN
  3. FIN від серверу: сервер повідомляє, що теж готовий закрити сесію
  4. ACK від клієнта: клієнт відповідає фінальним ACK, підтверджуючи отримання FIN від сервера

Після цього з’єднання повністю закривається.

Аналіз сесії з Wireshark

Можна писати відразу в Wireshark, можемо спочатку створити файл, а потім його вже аналізувати.

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

$ sudo tcpdump host 104.26.3.188 or host 104.26.2.188 or host 172.67.68.115 -w tcp.pcap

В іншому вікні виконуємо запит до RTFM:

$ curl https://rtfm.co.ua

Відкриваємо сформований дамп у Wireshark:

$ sudo wireshark tcp.pcap

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

Власне тут ми першими і бачимо 3-way handshake – початок з’єднання:

  • SYN (клієнт 50556 => сервер 443):
    • Seq=0, Len=0
    • клієнт (192.168.0.116) відкриває з’єднання маючи локальний порт 50556 до серверу RTFM на до 172.67.68.115 і порт 443
  • SYN, ACK (443 → 50556):
    • Seq=0 Ack=1, Len=0
    • відповідь від серверу 172.67.68.115 , що він підтвердив отримання SYN від клієнту 192.168.0.116 (флаг `ACK), і задає свій флаг SYN` з Initial Sequence Number, встановлюючи початкову точку для свого потоку даних
  • ACK (50556 → 443):
    • Seq=1 Ack=1, Len=0
    • Клієнт підтверджує SYN від сервера

Четвертим вже починається передача даних – Len-1388 (насправді, це вже початок TLS handshake – наступним, п’ятим пакетом бачимо TLSv1.3).

Seq=0 – це як раз Sequence Number, про який говорили вище.

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

Len=0 в перших трьох пакетах нуль, бо це тільки встановлення з’єднання, ще до передачі даних, і пакети містять тільки TCP-заголовки для встановлення з’єднання, без даних.

Ack=N – підтвердження отримання пакету.

Тобто:

  1. Seq=0:
    • клієнт відправляє Inital Sequence Number, який Wireshark нам відображає як 0
  2. Seq=0 Ack=1 Len=0:
    • Seq=0 – сервер теж задає свій  Inital Sequence Number
    • Ack=1 – сервер інкрементить Seq від клієнта на +1
  3. Seq=1 Ack=1 Len=0:
    • Seq=1 – тепер клієнт збільшує свій Sequence Number
    • Ack=1 – клієнт підтверджує отримання SYN від серверу

TCP та ядро Linux

В ядрі операційної системи для передачі даних за TCP-протоколом реалізована своя система – TCP stack.

Вона відповідає за:

  • відкриття та завершення TCP-сесій
  • контроль доставки (ACK, SEQ)
  • повторну передачу втрачених пакетів
  • розпізнавання флагів (SYN, FIN, RST тощо)
  • збирання даних з кількох сегментів у правильному порядку

По факту, це набір функцій в ядрі, які опрацьовують TCP-пакети.

А сам TCP-стек – це частина мережевого стеку ядра, разом з обробкою Ethernet, ARP, IP, UDP та інших.

Див. документацію на  kernel_flow.

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

  • rmem_*: receive buffer (для вхідного трафіку)
  • wmem_*: write buffer (для вихідного трафіку)

Дефолтні значення задаються в /proc/sys/net/ipv4/tcp_rmem та /proc/sys/net/ipv4/tcp_wmem відповідно.

А перевизначити їх можна з sysctl:

$ sudo sysctl -w net.ipv4.tcp_rmem="4096 87380 6291456"
$ sudo sysctl -w net.ipv4.tcp_wmem="4096 65536 6291456"

Ядро також підтримує автоматичне масштабування буферів – файл /proc/sys/net/ipv4/tcp_moderate_rcvbuf:

$ cat /proc/sys/net/ipv4/tcp_moderate_rcvbuf
1

1 – включено, 0 – виключено.

Мінімальний Maximum segment size (MSS) задається у файлі /proc/sys/net/ipv4/tcp_min_snd_mss:

$ cat /proc/sys/net/ipv4/tcp_min_snd_mss
48

48 байт == 384 біти.

Мінімальний розмір задається для запобіганню відправки надто маленьких TCP-сегментів, які викликатимуть зайві накладні витрати та зниження продуктивності.

Процес отримання TCP-пакету ядром

Як в системі виглядає процес отримання даних?

Див. kernel_flow.

Якщо спрощено, то:

  1. Layer 2: Link layer
    1. Network Interface Card отримує Ethernet frame з TCP-пакетом
    2. ядро системи викликає драйвер карти, а драйвер викликає функцію netif_receive_skb() і передає весь отриманий кадр (в структурі skbSocket Buffer) на обробку мережевій підсистемі ядра
  2. Layer 3: Network layer
    1. пакет передається до ip_rcv() (IPv4) або ipv6_rcv() (IPv6), де перевіряється заголовок IP для визначення протоколу
    2. якщо Protocol = 6 (TCP) – пакет передається в tcp_v4_rcv()
  3. Layer 4: Transport layer
    1. функція tcp_v4_rcv() перевіряє контрольну суму, знаходить відповідний локальний сокет (порт), обробляє SEQ/ACK/FIN/RST/SYN флаги, і додає payload у receive buffer сокета, прив’язаного до відповідного порту (наприклад, listen(80) для веб-сервера)
    2. після передачі даних до веб-серверу – ядро формує ACK-пакет у відповідь
    3. дані передаються у внутрішній receive buffer сокета, і звідти вже передаються в userspace до веб-серверу (якщо ми про браузер-сервер)

Якщо заглиблюватись – то можна взяти утиліти по типу Systemtap для відстеження системних викликів.

Сокети та TCP-порти в Linux

Для роботи з TCP в Linux є концепція сокетів (sockets) – це такі собі ендпоінти, які прив’язуються до пари IP:PORT.

Непоганий текст з діаграмами – TCP handling in Linux.

Власне сокет – це абстракція, яка дозволяє програмам читати/писати в мережу, як через звичайний файл, і по суті і є файловим дескриптором спеціального типу: операційна система сприймає їх як пайп (pipe), через який можна передавати дані.

Сокети можуть бути або локальними, аби мережевими:

  • AF_INET для IPv4 та AF_INET6 для IPv6
  • AF_UNIX або AF_LOCAL – для локальної роботи

AF_* в імені – це “Address Family“, бо маємо не тільки TCP/UDP-сокети, але й AF_UNIX – локальні, AF_BLUETOOTH – Bluetooth, AF_NETLINK – Netlink і т.д.

При створенні сокету задається його тип:

socket(AF_INET, SOCK_STREAM, 0);  // TCP
socket(AF_INET, SOCK_DGRAM, 0);   // UDP

А потім з функцією bind() виконується прив’язка до IP та порту.

C programming: UNIX Socket

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

Приклад роботи сокетів писав в C: сокеты и пример модели client-server (2017 рік, боже…).

Простий приклад локального сокету:

// Create a UNIX domain socket file at /tmp/mysocket.sock

#include <sys/socket.h>   // import socket(), bind() functions
#include <sys/un.h>       // import C struct sockaddr_un
#include <unistd.h>       // import close() function
#include <stdio.h>        // import input/output functions like print()
#include <string.h>       // import strings/memory functions like strlen()

// def main C function
int main() {

   // define a variable with the 'int' type
   // it will store the socket's file descriptor ID returned by the socket() function
   // a file descriptor is just an integer index into the per-process open file table
   // the actual 'file' struct exists in kernel space, user space only sees the integer
    int sockfd;

    // define a variable named 'addr' with the 'struct sockaddr_un' type
    // this structure is used to specify socket address for AF_UNIX sockets
    struct sockaddr_un addr;

    // Step 1: create socket 
    // socket(domain, type, protocol)
    // AF_UNIX: UNIX domain socket
    // SOCK_STREAM: stream-oriented (like TCP)
    // '0': protocal, set to 0 as AF_UNIX + SOCK_STREAM have no protocal
    sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket");
        return 1;
    }

    // Step 2: set up address structure
    memset(&addr, 0, sizeof(addr));              // zero out the memory for safety
    addr.sun_family = AF_UNIX;                   // set socket family (UNIX domain)
    strcpy(addr.sun_path, "/tmp/mysocket.sock"); // set path for the socket file in the 'addr' structure

    // Step 3: remove old socket file if it exists
    // unlink removes a file; important to avoid "Address already in use" error
    unlink("/tmp/mysocket.sock");

    // Step 4: bind
    // 
    // bind the socket to a local address (path in the filesystem) - bind(sockfd, addr, size_of_addr)
    // 'sockfd': socket file descriptor returned by socket()
    // '&addr': pointer to the sockaddr_un struct that contains:
    // - family (AF_UNIX)
    // - path (filesystem path to the socket file)
    // '(struct sockaddr*)': cast required because bind() expects a generic sockaddr*
    // 'sizeof(addr)': size of the sockaddr_un structure
    //
    // after this call, the socket is associated with a specific name (path),
    // so other processes can connect to it via this path.
    if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind");
        return 1;
    }

    printf("UNIX socket created at /tmp/mysocket.sock\n");

    // Step 5: keep socket alive for inspection
    sleep(60);

    // Step 6: cleanup
    close(sockfd);                 // close the socket file descriptor
    unlink("/tmp/mysocket.sock");  // remove the socket file from filesystem
    return 0;
}

Збираємо з gcc:

$ gcc unix_socket.c -o unix_socket

Запускаємо:

$ ./unix_socket 
UNIX socket created at /tmp/mysocket.sock

І маємо відкритий сокет:

$ file /tmp/mysocket.sock
/tmp/mysocket.sock: socket

$ ls -l /tmp/mysocket.sock
srwxr-xr-x 1 setevoy setevoy 0 Jun 29 10:30 /tmp/mysocket.sock

В ls -l бачимо флаг “s” на початку – показує, що це тип socket.

Власне саме так і створюються сокети в Linux, які ми можемо бачити для якихось локальних демонів, наприклад:

$ sudo find / -type s 2>/dev/null
...
/run/docker.sock
/run/dbus/system_bus_socket
...
/run/dhcpcd/sock
...

C programming: AF_INET Socket

AF_INET та AF_INET6 створюються і працюють аналогічно до локальних UNIX-сокетів – тільки замість “адреси” у вигляді імені локального файлу використовують пару IP:PORT.

Також їх називають “BSD sockets” або “Berkeley sockets”, бо вперше вони були реалізовані Berkeley Software Distribution (BSD) Unix у 1983 році.

Код в принципі схожий на створення UNIX-сокету:

#include <stdio.h>              // for printf(), perror()
#include <stdlib.h>             // for exit()
#include <string.h>             // for memset()
#include <unistd.h>             // for close()
#include <sys/types.h>          // for socket types
#include <sys/socket.h>         // for socket(), bind()
#include <netinet/in.h>         // for sockaddr_in
#include <arpa/inet.h>          // for inet_addr()

int main() {
    // will store the socket's file descriptor (int)
    int sockfd;  

    // Step 1: create a new socket
    // AF_INET    = IPv4 address family
    // SOCK_STREAM = TCP (reliable byte stream)
    // 0          = default protocol (IPPROTO_TCP for AF_INET)
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    // define the address to bind the socket to
    struct sockaddr_in server_addr;

    // Step 2: fill the 'server_addr' structure with zeros to avoid undefined or leftover data
    memset(&server_addr, 0, sizeof(server_addr));

    // AF_INET for IPv4 
    server_addr.sin_family = AF_INET;
    // port number
    server_addr.sin_port = htons(8080);
    // bind to th 'localhost' (127.0.0.1)
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

    // Step 3: bind the socket to the given IP address and port
    // this makes the socket listen for incoming connections on the '127.0.0.1:8080'
    if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind failed");
        close(sockfd);  // close socket before exiting
        exit(EXIT_FAILURE);
    }

    // Step 4: listen for incoming connections
    // the socket is now ready to accept connections
    // function: int listen(int sockfd, int backlog);
    // 'sockfd' is the socket file descriptor
    // 'backlog' is the maximum number of pending connections
    // if the backlog is exceeded, new connections will be refused
    // here we set it to 5, meaning up to 5 connections can be queued
    if (listen(sockfd, 5) < 0) {
        perror("listen");
        return 1;
    }

    printf("Socket successfully created and bound to 127.0.0.1:8080\n");
    printf("Press Enter to close the socket...\n");
    
    // keep the socket open for inspection
    getchar();

    // Step 5: close the socket after use
    close(sockfd);
    return 0;
}

Але тут:

  • задаємо тип AF_INET
  • задаємо TCP-порт
  • задаємо IP, на якому слухати
  • з системним викликом listen() переводимо сокет в стан очікування з’єднань

Збираємо:

$ gcc inet_socket.c -o inet_socket

Запускаємо:

 $ ./inet_socket 
Socket successfully created and bound to 127.0.0.1:8080

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

$ netstat -anp | grep 8080
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
tcp        0      0 127.0.0.1:8080          0.0.0.0:*               LISTEN      2724448/./inet_sock 

TCP ports

TCP-порт – це просто число від 0 до 65535, які дозволяють мати різні сервіси і підключення при одному IP-адресі, і використовується виключно для адресації сокету по парі IP:PORT.

Діапазон портів поділений на три частини:

  • 0 – 1023: well-known ports (SSH: 22, HTTP: 80…)
  • 1024 – 49151: Registered ports (можуть використовуватись додатками)
  • 49152 – 65535: Ephemeral ports (системою для клієнтів)

Тобто з’єднання формується з пари <client_IP>:<client_port> => <server_IP>:<server_port>, і пара IP:PORT задається при створенні сокету (через виклик bind()).

Коли ядро отримує TCP-пакет, то викликає tcp_v4_rcv(), яка в свою чергу по IP-адресі:порт шукає відповідний сокет (через виклик inet_lookup() або __inet_lookup_established()), і якщо сокет знайдено – то через нього передається payload пакету.

Якщо сокет не знайдено, або сервіс повернув помилку – то ядро може повернути RST у відповідь, або просто дропнути пакет (залежно від ситуації).

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

Loading

VictoriaMetrics: міграція даних VMSingle та VictoriaLogs між кластерами Kubernetes
0 (0)

27 Червня 2025

Є у нас VictoriaMetrics і VictoriaLogs, працюють на AWS Elastic Kubernetes Service.

Мажорні апгрейди EKS ми робимо через створення нового кластеру, а тому з’явилась задача перенесення даних моніторингу зі старого інстансу VMSingle на новий.

Для VictoriaMetrics можемо використати vmctl, яка через API старого і нового інстансу може мігрувати дані працюючи в ролі проксі між двома інстансами.

З VictoriaLogs ситуація поки що дещо складніша і наразі є два варіанти – далі їх подивимось.

Отже, що маємо:

  • старий Kubernetes cluster EKS 1.30
  • новий Kubernetes cluster EKS 1.33

Деплоїться нашим власним Helm-чартом, який через dependencies встановлює victoria-metrics-k8s-stack та victoria-logs-single, плюс пачка різних додаткових сервісів типу PostgreSQL Exporter.

Міграція метрик VictoriaMetrics

Запуск vmctl

vmctl підтримує міграцію як з VMSinlge на VMClutser, так і навпаки, або просто між інстансами VMSinlge => VMSinlge, або VMClutser => VMClutser.

В нашому випадку це просто два інстанси VMSingle.

Встановити vmctl можна встановити локально у поді з VMSingle, див. How to build, але так як CLI все одно працює через API – то простіше створити окремий Pod, і все робити з нього. Docker-образ доступний тут – victoriametrics/vmctl.

Так як entrypoint для цього образу заданий в /vmctl-prod, то аби просто зайти в контейнер – передамо --command, запустимо в циклі ping та sleep, і далі спокійно з консолі будемо робити все, що нам треба:

$ kubectl run vmctl-pod --image=victoriametrics/vmctl --restart=Never --command -- /bin/sh -c "while true; echo ping; do sleep 5; done"
pod/vmctl-pod created

На якому саме кластері запускати в принципі різниці нема.

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

$ kk exec -ti vmctl-pod -- sh  
/ # 

Перевіримо:

/ # /vmctl-prod vm-native --help
NAME:
   vmctl vm-native - Migrate time series between VictoriaMetrics installations via native binary format

USAGE:
   vmctl vm-native [command options] [arguments...]

OPTIONS:
   -s                              Whether to run in silent mode. If set to true no confirmation prompts will appear. (default: false)
   ...

Запуск міграції

Kubernetes Pod з vmctl буде працювати в ролі проксі між source та destination, тому повинен мати стабільний нетворк.

Крім того, якщо мігруєте великий об’єм даних – то подивіться в бік опції --vm-concurrency для запуску міграції в кілька паралельних потоків, при цьому кожен воркер буде додатково використовувати CPU/Memory.

В документації також описані можливі проблеми з лімітами – див. Migrating data from VictoriaMetrics, і корисно глянути розділ Migration tips.

Також рекомендується додати фільтр --vm-native-filter-match='{__name__!~"vm_.*"}' аби не переносити метрики, які відносяться до самої VictoriaMetrics, бо це може призвести до data collision – появи дублікатів тайм-серій.

Хоча в моєму випадку у нас через VMAgent до всіх метрик додається метрика з іменем кластеру:

...
  vmagent:
    enabled: true
    spec:
      externalLabels:
        cluster: "eks-ops-1-33"
...

Якщо для VMSingle задані resources.limits – краще їх відключити або збільшити, і збільшити реквести, бо я ловив 504 та Pod Eviction.

І можливо є сенс винести VMSingle на окрему WorkerNode, бо в нашому випадку для моніторингу використовуються t3, ще й Spot.

Що і куди мігруємо:

  • source: VMSingle на EKS 1.30
    • ендпоінт vmsingle.monitoring.1-30.ops.example.co
  • destination: VMSingle на EKS 1.33
    • ендпоінт vmsingle.monitoring.1-33.ops.example.co

З нашого поду з vmctl перевіряємо доступ до обох ендпоінтів:

/ # apk add curl

/ # curl -X GET -I https://vmsingle.monitoring.1-30.ops.example.co
HTTP/2 400

/ # curl -X GET -I https://vmsingle.monitoring.1-33.ops.example.co
HTTP/2 200

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

/ # /vmctl-prod vm-native \
> --vm-native-src-addr=https://vmsingle.monitoring.1-30.ops.example.co/ \
> --vm-native-dst-addr=https://vmsingle.monitoring.1-33.ops.example.co \
> --vm-native-filter-match='{__name__!~"vm_.*"}' \
> --vm-native-filter-time-start='2023-01-01'
VictoriaMetrics Native import mode
...

Процес пішов:

Ресурси на source – память до 5-6 гігабайт піднімалась:

На destination було трохи більше CPU, але менше пам’яті:

І завершення – зайняло 6 годин, але це я робив без --vm-concurrency:

...
2025/06/23 19:07:29 Import finished!
2025/06/23 19:07:29 VictoriaMetrics importer stats:
  time spent while importing: 6h30m8.537582366s;
  total bytes: 16.5 GB;
  bytes/s: 705.9 kB;
  requests: 6882;
  requests retries: 405;
2025/06/23 19:07:29 Total time: 6h30m8.541808518s

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

Якщо міграція зафейлилась

Спочатку перевіряємо запит – треба знайти старі метрики на новому кластері.

Перевіряємо на новому кластері використовуючи лейблу cluster – корисна штука:

$ curl -s 'http://localhost:8429/prometheus/api/v1/series' -d 'match[]={cluster="eks-ops-1-30"}' | jq
...
    {
      "__name__": "yace_cloudwatch_targetgroupapi_requests_total",
      "cluster": "eks-ops-1-30",
      "job": "yace-exporter",
      "instance": "yace-service:5000",
      "prometheus": "ops-monitoring-ns/vm-k8s-stack"
    }
...

Документація з видалення метрик і взагалі роботі з VictoriaMetrics API – How to delete or replace metrics in VictoriaMetrics і Deletes time series from VictoriaMetrics.

Виконуємо запит на /api/v1/admin/tsdb/delete_series:

$ curl -s 'http://localhost:8429/api/v1/admin/tsdb/delete_series' -d 'match[]={cluster="eks-ops-1-30"}

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

$ curl -s 'http://localhost:8429/prometheus/api/v1/series' -d 'match[]={cluster="eks-ops-1-30"}' | jq
{
  "status": "success",
  "data": []
}

Тепер можна повторити міграцію.

Інший варіант – додати опцію dedup.minScrapeInterval=1ms, тоді VictoriaMetrics сама видалить дублікати, але я цей варіант не тестив.

Міграція VictoriaLogs

З VictoriaLogs ситуація трохи складніша, бо vlogscli поки що (сподіваюсь, додадуть) не має якоїсь опції для переносу даних як в vmctl.

І тут є проблема:

  • якщо в VictoriaLogs на новому кластері ще нема ніяких даних – то можна просто скопіювати старі даних з rsync в PVC нового інстансу VictoriaLogs
    • аналогічно, якщо дані є, але нема overlapping днів, бо дані в VictoriaLogs storage зберігаються в каталогах по дням, і їм можна спокійно переносити
  • але якщо дані є, та/або дні дублюються – то поки що єдиний варіант це запускати два інстанси VictoriaLogs: один зі старими даними, один з новими, а перед ними мати інстанс vlselect

Коли додадуть Object Storage – то буде простіше, і це вже є в Roadmap. Тоді можна буде просто все тримати в AWS S3, як це у нас зараз в Grafana Loki.

Варіант 1: копіювання даних з rsync

Отже, перший варіант – якщо в новому інстансі VictoriaLogs даних нема, або нема записів в одні і ті ж  дні на обох інстансах – старому і новому.

Тут ми можемо просто скопіювати дані, і вони будуть доступні на новому Kubernetes-кластері.

Див. документацію VictoriaLogs – Backup and restore.

Я робив з rsync, але можна спробувати зробити з утилітами типу korb.

Перевіримо де зберігаються логи в VictoriaLogs Pod:

$ kk describe pod atlas-victoriametrics-vmlogs-new-server-0
Name:             atlas-victoriametrics-vmlogs-new-server-0
...
Containers:
  vlogs:
    ...
    Args:
      --storageDataPath=/storage
    ...
    Mounts:
      /storage from server-volume (rw)
    ...
Volumes:
  server-volume:
    Type:       PersistentVolumeClaim (a reference to a PersistentVolumeClaim in the same namespace)
    ClaimName:  server-volume-atlas-victoriametrics-vmlogs-new-server-0
    ReadOnly:   false
...

І зміст директорії /storage:

~ $ ls -l /storage/partitions/
total 32
drwxrwsr-x    4 1000     2000          4096 Jun 16 00:00 20250616
drwxrwsr-x    4 1000     2000          4096 Jun 17 00:00 20250617
drwxrwsr-x    4 1000     2000          4096 Jun 18 00:00 20250618
drwxrwsr-x    4 1000     2000          4096 Jun 19 00:00 20250619
drwxrwsr-x    4 1000     2000          4096 Jun 20 00:00 20250620
drwxr-sr-x    4 1000     2000          4096 Jun 21 00:00 20250621
drwxr-sr-x    4 1000     2000          4096 Jun 22 00:00 20250622
drwxr-sr-x    4 1000     2000          4096 Jun 23 00:00 20250623

Але в самому поді нема ані rsync, ані SSH, і ми навіть не можемо їх встановити:

~ $ rsync
sh: rsync: not found
~ $ apk add rsync
ERROR: Unable to lock database: Permission denied
ERROR: Failed to open apk database: Permission denied
~ $ su
su: must be suid to work properly
~ $ sudo -s
sh: sudo: not found
~ $ ssh
sh: ssh: not found

Тому просто зробимо rsync зі старого EC2 на новий.

Як знайти потрібний каталог на хості – писав в Kubernetes: знайти каталог з mounted volume в Pod на хості.

Налаштування доступу SSH на EC2 в EKS – AWS: Karpenter та SSH для Kubernetes WorkerNodes.

Перевіряємо Pod на старому кластері – знаходимо його EC2 та Container ID:

$ kk describe pod atlas-victoriametrics-vmlogs-new-server-0 | grep 'Node\|Container'
Node:             ip-10-0-39-190.ec2.internal/10.0.39.190
Containers:
    Container ID:  containerd://db9fa73a4d37045b0338ae48438f9815e4f6f92c3fd6546604ca5d1338f19844
...

Підключаємось на WorkerNode:

$ ssh -i ~/.ssh/eks_ec2 [email protected]

В mounts[] знаходимо каталог для /storage:

[root@ip-10-0-39-190 ec2-user]# crictl inspect  db9fa73a4d37045b0338ae48438f9815e4f6f92c3fd6546604ca5d1338f19844 | jq
...
    "mounts": [
      {
        "containerPath": "/storage",
        "gidMappings": [],
        "hostPath": "/var/lib/kubelet/pods/5192e1f9-20ea-49c6-99ed-775af5e44183/volumes/kubernetes.io~csi/pvc-43c427fa-b05c-45b8-8bdb-92b00bff3496/mount",
...

Перевіряємо зміст:

[root@ip-10-0-39-190 ec2-user]# ll /var/lib/kubelet/pods/5192e1f9-20ea-49c6-99ed-775af5e44183/volumes/kubernetes.io~csi/pvc-43c427fa-b05c-45b8-8bdb-92b00bff3496/mount
total 24
drwxrwsr-x  3 ec2-user 2000  4096 Nov 19  2024 cache
-rw-rw-r--  1 ec2-user 2000     0 Jun 20 19:20 flock.lock
drwxrws---  2 root     2000 16384 Sep  4  2024 lost+found
drwxrwsr-x 10 ec2-user 2000  4096 Jun 25 00:25 partitions

Нам тут треба тільки дані з каталогу partitions.

Повторюємо для VictoriaLogs на новому кластері, правда Amazon Linux 2023 не має critctl – втім, є ctr.

Перевіряємо ContainerD Namespaces для контейнерів:

[root@ip-10-0-41-247 ec2-user]# ctr ns ls
NAME   LABELS 
k8s.io

Перевіряємо контейнер з ctr containers info:

[root@ip-10-0-41-247 ec2-user]# ctr -n k8s.io containers info 9fd6fefaec92ab76093651239f6e177686e7c7dd012d53d4bf2e6820260aa884
...
            {
                "destination": "/storage",
                "type": "bind",
                "source": "/var/lib/kubelet/pods/4b2f179d-9ada-403e-9680-b76e3507563f/volumes/kubernetes.io~csi/pvc-da384ead-50e8-425f-b3b0-47c35f3a5155/mount",
...

І зміст /var/lib/kubelet/pods/4b2f179d-9ada-403e-9680-b76e3507563f/volumes/kubernetes.io~csi/pvc-da384ead-50e8-425f-b3b0-47c35f3a5155/mount:

[root@ip-10-0-41-247 ec2-user]# ll /var/lib/kubelet/pods/4b2f179d-9ada-403e-9680-b76e3507563f/volumes/kubernetes.io~csi/pvc-da384ead-50e8-425f-b3b0-47c35f3a5155/mount
total 20
-rw-rw-r--.  1 ec2-user 2000     0 Jun 25 12:18 flock.lock
drwxrws---.  2 root     2000 16384 Jun 10 09:41 lost+found
drwxrwsr-x. 10 ec2-user 2000  4096 Jun 25 00:32 partitions

Зверніть увагу на UID юзерів та групи даних, мають бути однакові – ec2-user(1000) та група 2000 на обох EC2 інстансах.

На старому кластері створюємо SSH-ключ і перевіряємо підключення на EC2 нового кластеру:

[root@ip-10-0-39-190 ec2-user]# ssh -i .ssh/eks [email protected]
...
[ec2-user@ip-10-0-41-247 ~]$

ОК, є.

Тепер на обох інстансах встановлюємо rsync:

[root@ip-10-0-39-190 ec2-user]# yum -y install rsync

Про всяк випадок – можна забекапити дані на новому інстансі – або снапшот EBS, або з tar.

І ще нюанс – з retention period, добре, що згадав – він у нас всього 7 днів. Тому якщо скопіювати дані зараз – то старі логи видаляться.

Міняємо:

...
retentionPeriod: 30d
...

На новому інстансі зробимо каталог, куди будемо переносити дані (можна і відразу в каталог PVC):

[root@ip-10-0-41-247 ec2-user]# mkdir vmlogs

І зі старого запускаємо rsync на новий інстанс в $HOME/vmlogs:

[root@ip-10-0-39-190 ec2-user]# rsync -avz --progress --delete -e "ssh -i .ssh/eks" \
> /var/lib/kubelet/pods/5192e1f9-20ea-49c6-99ed-775af5e44183/volumes/kubernetes.io~csi/pvc-43c427fa-b05c-45b8-8bdb-92b00bff3496/mount/partitions/ \
> [email protected]:/home/ec2-user/vmlogs/
...

Тут:

  • -a: архівний режим (зберігає права, час create/modify та структуру)
  • -v: verbose режим
  • -z: компресія даних
  • --progress: відобразити прогрес
  • --delete: видаляти дані з destination якщо вони видалені в source
  • -e: команда з ssh-ключем

Першим аргументом задаємо локальну директорію, другим – куди копіюємо.

І для source вказуємо “/” в кінці .../mount/partitions/ – скопіювати зміст, а не саму папку.

Якщо витикають помилки з permission denied – додаємо --rsync-path="sudo rsync".

Передача завершена:

...
sent 2,483,902,797 bytes  received 189,037 bytes  20,614,869.99 bytes/sec
total size is 2,553,861,458  speedup is 1.03

Перевіряємо дані на новому інстансі:

[root@ip-10-0-41-247 ec2-user]# ll vmlogs/
total 0
drwxrwsr-x. 4 ec2-user ec2-user 35 Jun 18 00:00 20250618
drwxrwsr-x. 4 ec2-user ec2-user 35 Jun 19 00:00 20250619
drwxrwsr-x. 4 ec2-user ec2-user 35 Jun 20 00:00 20250620
drwxr-sr-x. 4 ec2-user ec2-user 35 Jun 21 00:00 20250621
drwxr-sr-x. 4 ec2-user ec2-user 35 Jun 22 00:00 20250622
drwxr-sr-x. 4 ec2-user ec2-user 35 Jun 23 00:00 20250623
drwxr-sr-x. 4 ec2-user ec2-user 35 Jun 24 00:00 20250624
drwxr-sr-x. 4 ec2-user ec2-user 35 Jun 25 00:00 20250625

Ну і тут власне я зіткнувся з проблемою overlapping data:

[root@ip-10-0-41-247 ec2-user]# cp -r vmlogs/* /var/lib/kubelet/pods/84a4ecd3-21a0-4eec-bebc-078a5105bf86/volumes/kubernetes.io~csi/pvc-da384ead-50e8-425f-b3b0-47c35f3a5155/mount/partitions/
cp: overwrite '/var/lib/kubelet/pods/84a4ecd3-21a0-4eec-bebc-078a5105bf86/volumes/kubernetes.io~csi/pvc-da384ead-50e8-425f-b3b0-47c35f3a5155/mount/partitions/20250618/datadb/parts.json'?

Питав розробників про варіант з JSON merge – але це не спрацює.

Якщо ж дані не перетинаються – то просто копіюємо дані і рестартимо Pod з VictoriaLogs.

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

Варіант 2: запуск двох VMLogs + vlselect

Отже, якщо у нас є дані за одні і ті ж дні на старому і новому інстансах VictoriaLogs – то робимо таким чином:

  • на новому EKS-кластері створюємо другий інстанс VMLogs
  • в його PVC копіюємо дані зі старого кластеру
  • додаємо Pod з vlselect
  • для vlselect вказуємо два source – обидва інстанси VMLogs
  • і потім для Grafana VictoriaLogs data source використовуємо URL сервісу vlselect

Можна було б просто додати vlselect, і роутити запити на старий кластер – але нам треба старий кластер видаляти.

vlselect vs VMLogs

Фактично vlselect це той самий бінарний файл, що і VictoriaLogs, що дуже спрощую нам весь сетап – див. документацію VictoriaLogs cluster:

Note that all the VictoriaLogs cluster components – vlstoragevlinsert and vlselect – share the same executable – victoria-logs-prod.

Тому ми просто можемо взяти ще один чарт victoria-logs-single, і все запускати з нього.

І насправді ми будемо будувати такий собі “VictoriaLogs cluster на мінімалках”:

  • наш поточний інстанс VictoriaLogs буде грати роль vlinsert та vlstorage – туди пишуться нові логи нового кластеру
  • новий інстанс VictoriaLogs буде грати роль vlstorage – в ньому ми будемо зберігати дані зі старого кластеру
  • третій інстанс VictoriaLogs буде грати роль vlselect – він буде ендпоінтом для Grafana, і буде робити API-запити з пошуком логів з обох інстансів VictoriaLogs

Helm chart update

Я поки не готовий запускати повноцінну версію VictoriaLogs cluster, тому просто додамо ще парочку dependencies в наш поточний Helm chart.

Редагуємо Chart.yaml:

...
dependencies:
...
- name: victoria-logs-single
  version: ~0.11.2
  repository: https://victoriametrics.github.io/helm-charts
  alias: vmlogs-new
- name: victoria-logs-single
  version: ~0.11.2
  repository: https://victoriametrics.github.io/helm-charts
  alias: vmlogs-old
- name: victoria-logs-single
  version: ~0.11.2
  repository: https://victoriametrics.github.io/helm-charts
  alias: vlselect
...

Тут деплоїмо три чарти (точніше, чарт один – просто з різними values, див. Helm: multiple деплой одного чарта з Chart’s dependency), і кожному задаємо власний alias:

  • vmlogs-new: поточний інстанс VMLogs на новому EKS кластері
  • vmlogs-old: новий інстанс, в який будемо переносити дані зі старого EKS кластеру
  • vlselect: буде нашим новим ендпоінтом для пошуку логів

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

...
Pod "atlas-victoriametrics-victoria-logs-single-old-server-0" is invalid: metadata.labels: Invalid value: "atlas-victoriametrics-victoria-logs-single-old-server-77cf9cd79d": must be no more than 63 characters
...

Що треба перевірити в дефолтному values.yaml чарту victoria-logs-single:

...
  persistentVolume:
    # -- Create/use Persistent Volume Claim for server component. Use empty dir if set to false
    enabled: true
    size: 10Gi
...
  ingress:
    # -- Enable deployment of ingress for server component
    enabled: false
...

Для інстансу vlselect додаємо storageNode, де через кому вказуємо ендпоінти обох VictoriaLogs, і при потребі задаємо параметри для persistentVolume:

...
vmlogs-new:
  server:
    persistentVolume:
      enabled: true
      storageClassName: gp2-retain
      size: 30Gi
    retentionPeriod: 14d

vmlogs-old:
  server:
    persistentVolume:
      enabled: true
      storageClassName: gp2-retain
      size: 30Gi
    retentionPeriod: 14d

vlselect:
  server:
    extraArgs:
      storageNode: atlas-victoriametrics-vmlogs-new-server:9428,atlas-victoriametrics-vmlogs-old-server:9428
...

Деплоїмо стек, і перевіряємо Pods:

$ kk get pod | grep 'vmlogs\|vlselect'
atlas-victoriametrics-vlselect-server-0                           1/1     Running     0              19h
atlas-victoriametrics-vmlogs-new-server-0                         1/1     Running     0              76s
atlas-victoriametrics-vmlogs-old-server-0                         1/1     Running     0              76s

Сервіси:

$ kk get svc | grep 'vmlogs\|vlselect'
atlas-victoriametrics-vlselect-server                    ClusterIP   None             <none>        9428/TCP                     22h
atlas-victoriametrics-vmlogs-new-server                  ClusterIP   None             <none>        9428/TCP                     42s
atlas-victoriametrics-vmlogs-old-server                  ClusterIP   None             <none>        9428/TCP                     42s

Тепер у нас Promtail на новому кластері продовжує писати в atlas-victoriametrics-vmlogs-new-server, а atlas-victoriametrics-vmlogs-old-server у нас пустий інстанс VMLogs.

Можемо перевірити доступ до логів через інстанс vlselect:

$ kk port-forward svc/atlas-victoriametrics-vlselect-server 9428

Перенос даних зі старого кластеру

Далі, власне, просто повторюємо те, що робили вище – знаходимо каталог PVC, і копіюємо туди дані зі старого кластеру.

Цього разу спочатку собі на робочу машину, а потім звідси вже в Kubernetes:

[setevoy@setevoy-work ~] $ mkdir vmlogs_back

Поки писав, VictoriaLogs на старому кластері вже переїхав на новий EC2, тому шукаємо дані заново.

Переключаємо kubectl на старий кластер, і шукаємо Pod:

$ kk describe pod atlas-victoriametrics-victoria-logs-single-server-0 | grep 'Node\|Container'
Node:             ip-10-0-38-72.ec2.internal/10.0.38.72
Containers:
    Container ID:  containerd://c168d4487282dd7d868aadcfcd1840e4e15cfd360f56f542a98b77978f91e252
...

Підключаємось, знаходимо директорію:

[root@ip-10-0-38-72 ec2-user]# crictl inspect c168d4487282dd7d868aadcfcd1840e4e15cfd360f56f542a98b77978f91e252
...
    "mounts": [
      {
        "containerPath": "/storage",
        "gidMappings": [],
        "hostPath": "/var/lib/kubelet/pods/f84ef4b9-272f-437e-9f98-649e1707ed09/volumes/kubernetes.io~csi/pvc-43c427fa-b05c-45b8-8bdb-92b00bff3496/mount",
...

Встановлюємо там rsync, і копіюємо дані на робочу машину:

$ rsync -avz --progress -e "ssh -i .ssh/eks_ec2" \
> --rsync-path="sudo rsync" \
> [email protected]:/var/lib/kubelet/pods/f84ef4b9-272f-437e-9f98-649e1707ed09/volumes/kubernetes.io~csi/pvc-43c427fa-b05c-45b8-8bdb-92b00bff3496/mount/partitions/ \
> /home/setevoy/vmlogs_back/
...

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

$ ll ~/vmlogs_back/
total 32
drwxrwsr-x 4 setevoy setevoy 4096 Jun 19 03:00 20250619
drwxrwsr-x 4 setevoy setevoy 4096 Jun 20 03:00 20250620
drwxrwsr-x 4 setevoy setevoy 4096 Jun 21 03:00 20250621
...

Тепер переносимо все на новий кластер, там де Pod atlas-victoriametrics-vmlogs-old-server-0.

Переключаємо kubectl на новий кластер, знаходимо WorkerNode і Container ID:

$ kd atlas-victoriametrics-vmlogs-old-server-0 | grep 'Node\|Container'
Node:             ip-10-0-36-143.ec2.internal/10.0.36.143
Containers:
    Container ID:  containerd://f10118b10afab75c43e03adcc0644af5caa8654687cd81e59cdf15bd8c32cb31
...

Логінимось і знаходимо директорію:

[root@ip-10-0-36-143 ec2-user]# ctr -n k8s.io containers info f10118b10afab75c43e03adcc0644af5caa8654687cd81e59cdf15bd8c32cb31
...
            {
                "destination": "/storage",
                "type": "bind",
                "source": "/var/lib/kubelet/pods/297b75ec-63fa-4061-bb23-7a6a120da939/volumes/kubernetes.io~csi/pvc-c7373468-f247-4596-b2e2-87852aad71bb/mount",
...

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

drwxr-sr-x. 2 ec2-user 2000  4096 Jun 26 13:14 partitions
[root@ip-10-0-36-143 ec2-user]# ls -l /var/lib/kubelet/pods/297b75ec-63fa-4061-bb23-7a6a120da939/volumes/kubernetes.io~csi/pvc-c7373468-f247-4596-b2e2-87852aad71bb/mount/partitions/
total 0

Встановлюємо там rsync і копіюємо дані з локальної директорії /home/setevoy/vmlogs_back/ на новий EKS кластер:

$ rsync -avz --progress -e "ssh -i .ssh/eks_ec2" --rsync-path="sudo rsync" \
> /home/setevoy/vmlogs_back/ \
> [email protected]:/var/lib/kubelet/pods/297b75ec-63fa-4061-bb23-7a6a120da939/volumes/kubernetes.io~csi/pvc-c7373468-f247-4596-b2e2-87852aad71bb/mount/partitions/
...

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

[root@ip-10-0-36-143 ec2-user]# ls -l /var/lib/kubelet/pods/297b75ec-63fa-4061-bb23-7a6a120da939/volumes/kubernetes.io~csi/pvc-c7373468-f247-4596-b2e2-87852aad71bb/mount/partitions/
total 32
drwxrwsr-x. 4 ec2-user ec2-user 4096 Jun 19 00:00 20250619
drwx--S---. 2 root     ec2-user 4096 Jun 26 13:39 20250620
drwx--S---. 2 root     ec2-user 4096 Jun 26 13:39 20250621
drwx--S---. 2 root     ec2-user 4096 Jun 26 13:39 20250622
drwx--S---. 2 root     ec2-user 4096 Jun 26 13:39 20250623
...

Міняємо юзера і групу:

[root@ip-10-0-36-143 ec2-user]# chown -R ec2-user:2000 /var/lib/kubelet/pods/297b75ec-63fa-4061-bb23-7a6a120da939/volumes/kubernetes.io~csi/pvc-c7373468-f247-4596-b2e2-87852aad71bb/mount/partitions/
[root@ip-10-0-36-143 ec2-user]# ls -l /var/lib/kubelet/pods/297b75ec-63fa-4061-bb23-7a6a120da939/volumes/kubernetes.io~csi/pvc-c7373468-f247-4596-b2e2-87852aad71bb/mount/partitions/
total 32
drwxrwsr-x. 4 ec2-user 2000 4096 Jun 19 00:00 20250619
drwxrwsr-x. 4 ec2-user 2000 4096 Jun 20 00:00 20250620
...

Перезапускаємо Pod atlas-victoriametrics-vmlogs-old-server-0.

Перевірка даних

І пошукаємо щось.

Спочатку щось про ноду hostname: "ip-10-0-36-143.ec2.internal" на новому кластері – це має прийти з інстансу atlas-victoriametrics-vmlogs-new-server-0, тобто зі старого інстансу на новому Kubernetes-кластері:

А тепер якусь ноду зі старого кластера:

Все є.

Готово.

Loading

Kubernetes: помилки 503 з AWS ALB – можливі причини та рішення
0 (0)

25 Червня 2025

Після міграції на новий Kubernetes Cluster на Backend API почали виникати помилки 503.

Чому з’явились саме на 1.33 – так і не зрозумів, бо в параметрах AWS ALB та Kubernetes Ingress нічого не мінялось, а на 1.30 їх не було.

Може спрацювали деякі мої фікси в моніторингу – або це щось пов’язане новим AMI чи версією VPC CNI.

503 виникали у нас в трьох випадках:

  • іноді без всякою деплою, коли всі поди були Running && Ready
  • іноді під час деплою – але тільки на Dev, бо там один Pod для API
  • і під час Karpenter Consolidation

Давайте покопаємо можливі причини.

Трохи контексту: наш сетап

Маємо AWS EKS, в якому запущені Pods з Backend API.

Для доступу до них маємо Kubernetes Service з типом ClusterIP та Ingress resource з атрибутом alb.ingress.kubernetes.io/target-type: ip.

Маємо AWS LoadBalancer Controller, який створює AWS Application LoadBalancer.

При створенні Kubernetes Service – Endpoints controller створює ресурс Endpoints зі списком Pod IPs, який потім використовується ALB Controller для додавання таргетів в Target Group.

При деплої Backend API Ingress – ALB Controller створює ALB з Listeners на кожен hostname з Ingress з власним SSL, і з Target Group для кожного Listener, в якій задаються IP Pods зі списку адрес в Endpoints, на які треба слати трафік від клієнтів.

Тобто схема виглядає так: client => ALB => Listener => Target Group => Pod IP.

Більше – Kubernetes: что такое Endpoints та Kubernetes: Service, балансировка нагрузки, kube-proxy и iptables.

Правда, в Kubernetes 1.33 Endpoints вже deprecated, див. Kubernetes v1.33: Continuing the transition from Endpoints to EndpointSlices.

ALB та EKS: 502 vs 503 vs 504

Для початку розглянемо різницю між помилками.

502 у нас виникає, якщо ALB не зміг отримати коректну відповідь від бекенда, тобто помилка на рівні самого сервісу (застосунку), application layer (“я додзвонився, але на тому кінці відповіли щось незрозуміле або кинули слухавку посеред розмови”):

  • у Pod не відкритий порт
  • Pod впав, але Readiness probe каже що він живий, і Kubernetes не відключає його від трафіку (не видаляє IP зі списку в Endpoints)
  • Pod повертає помилку від сервісу при підключенні

По темі:

503 виникає, якщо ALB не має жодного Healthy target, або Ingress не зміг знайти Pod (“абонент не може прийняти ваш дзвінок”):

  • Pods проходять readinessProbe, додаються в список Endpoints і в ALB targets, але не проходять Health checks в TargetGroup – тоді ALB шле трафік на всі Pods (targets), див. Health checks for Application Load Balancer target groups
  • Pods не проходять readinessProbe, Kubernetes видаляє їх з Endpoints, і Target Group стає порожньою – ALB нема куди слати запити
  • Kubernetes Service має помилки в конфігурації (наприклад – неправильний Pod selector)
  • Kubernetes Service налаштований коректно, але кількість запущених подів == 0
  • ALB встановив підключення до Pod, але підключення розірване на рівні TCP (наприклад, через різні keep-alive таймаути на бекенді та ALB), і ALB отримав TCP RST (reset)

504 виникає коли ALB відправив запит, але не отримав відповідь у встановлений таймаут (дефолт 60 секунд на ALB) (“я додзвонився, але мені надто довго не відповідають, і я кладу слухавку”)

  • процес в Pod надто довго обробляє запит
  • мережеві проблеми в AWS VPC або EKS кластері, і пакети від ALB до Pod проходять надто довго

Див. також Troubleshoot your Application Load Balancers.

Можливі причин 503

Інші можливі причини 503, і чи пов’язані вони з нашим випадком:

  • неправильні правила Security Groups:
    • при створенні TargetGroups, AWS Load Balancer Controller має створити або оновити SecurityGroups, і може, наприклад, не мати доступу до AWS API на редагування SecurityGroup
    • але не наш кейс, бо помилка виникає періодично, а в такому випадку була б відразу і постійно
  • unhealthy targets в Target Group:
    • якщо всі Pods (ALB targets) періодично “відвалюються” – то будемо мати 503, бо ALB починає слати трафік на всі наявні таргети
    • теж не наш кейс – перевіряємо метрику UnHealthyHostCount в CloudWatch – вона показує, що проблем з таргетами не було
  • некоректні теги на VPC Subnets:
    • ALB Контролер шукає Subnets за тегом kubernetes.io/cluster/<cluster-name>: owned аби знайти в них Pods і зареєструвати targets
    • знов-таки не наш кейс, бо помилка виникає періодично, а не є постійною
  • затримка при connection draining:
    • при деплойменті або скейлінгу старі Pods видаляються, і їхні IP видаляються з TargegtGroup, але це може виконуватись з затримкою – тобто Pod вже мертвий, а його IP в Targets ще є
    • але в першому випадку рестартів чи деплоїв не було, див. далі
  • ліміти пакетів в секунду або трафіку в секунду на Worker Node:
  • неузгодженість таймаутів Keep-Alive:
    • на Load Balancer idle timeout вищий, ніж на бекенді – і ALB може відправити запит по конекту, який вже закритий на бекенді

Ну а тепер до наших проблем.

Problem 1: різні keep-alive timeouts

ALB idle timeout 600 секунд, Backend – 75 секунд

На нашому ALB маємо Connection idle timeout в 600 seconds:

 

А на Backend API – 75 секунд:

...
CMD ["gunicorn", "challenge_backend.run_api:app", "--bind", "0.0.0.0:8000", "-w", "3", "-k", "uvicorn.workers.UvicornWorker", "--log-level", "debug", "--keep-alive", "75"]
...

Тобто через 75 секунд Backend шле сигнал TCP FIN, і закриває підключення – але ALB в цей час ще може відправити запит.

Як можна подебажити?

Я цього разу не робив, але на майбутнє – можна перевірити трафік з tcpdump:

$ sudo tcpdump -i any -nn -C 100 -W 5 -w /tmp/alb_capture.pcap 'port 8080 and host <ALB_IP_1> or host <ALB_IP_2>)'

Що може відбуватись:

  1. якщо connection не активний протягом 75 секунд, то Pod закриває зєднання – відправляє пакет з [FIN, ACK]
  2. ALB намагається через те саме з’єднання відправити пакет: <ALB_IP> => <POD_IP> | TCP | [PSH, ACK] Seq=1 Ack=2 Len=...
  3. а Pod відповідає пакетом з [RST] (reset): <POD_IP> => <ALB_IP> | TCP | [RST] Seq=2

ALB в такому випадку клієнту поверне 503 помилку.

Тому для початку – просто на ALB задаємо дефолтні 60 секунд (менше, ніж на бекенді). Ну, або збільшуємо на бекенді.

А звідки взявся 600 секунд на Ingress?

Тут я трохи покопався, бо не відразу знайшов звідки ж задавалось 600 секунд на ALB.

Дефолтний таймаут в AWS ALB 60 секунд, див. Configure the idle connection timeout for your Classic Load Balancer.

У нас використовується єдиний Ingress, який створює AWS ALB, і до якого потім через анотацію alb.ingress.kubernetes.io/group.name підключаються інші Ingres (див. Kubernetes: єдиний AWS Load Balancer для різних Kubernetes Ingress).

В цьому основному Ingress у нас нема ніяких атрибутів для зміни idle timeout:

...
    alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}'
    alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket={{ .Values.alb_aws_logs_s3_bucket }}
    alb.ingress.kubernetes.io/actions.default-action: >
      {"Type":"fixed-response","FixedResponseConfig":{"ContentType":"text/plain","StatusCode":"200","MessageBody":"It works!"}}
...

Хоча це можна зробити через Custom atributes – там є приклад як задати 600 секунд, але це custom, а не дефолт для ALB Ingress Controller.

Тобто дефолт має бути 60.

Спробував просто через цей Custom задати 60 секунд – і отримав помилку “conflicting load balancer attributes idle_timeout.timeout_seconds“:

...
aws-load-balancer-controller-6f7576c58b-5nmp7:aws-load-balancer-controller {"level":"error","ts":"2025-06-20T10:39:51Z","msg":"Reconciler error","controller":"ingress","object":{"name":"ops-1-33-external-alb"},"namespace":"","name":"ops-1-33-external-alb","reconcileID":"24bf8a2e-72ca-4008-8308-0f3b5595649c","error":"conflicting load balancer attributes idle_timeout.timeout_seconds: 60 | 600"}
...

Тоді вирішив перевірити просто всі Ingress в усіх Namespaces:

$ kk get ingress -A -o yaml | grep idle_timeout
      alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=600
      alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=600

І потім вже без grep просто знайшов імя Ingress з цим атрибутом:

...
- apiVersion: networking.k8s.io/v1
  kind: Ingress
  metadata:
    annotations:
      ...
      alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=600
      ...
    name: atlas-victoriametrics-victoria-metrics-auth
...

Не пам’ятаю для чого додавав, але трошки вилізло боком.

Добре – це поміняли, і 503 стало набагато менше – але все ще іноді бували.

Problem 2: 503 під час деплою

Знов прилетіла 503.

Тепер бачимо, що дійсно – в цей час був деплой:

Хоча Deployment має maxSurge 100% і maxUnavailable: 0 – див. Rolling Update:

...
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 100% # run all replicas before stop old Pods
      maxUnavailable: 0 # keep all old Pods until new Pods will be Running (passed the readinessProbe)
...

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

Але ми при деплої на Dev все одно отримуємо 503.

Kubernetes, AWS TargetGroup та targets registration

Як виглядає процес додавання в TargetGroups?

  • створюємо новий Pod
  • kubelet перевіряє readinessProbe
    • поки readinessProbe не пройдений – Pod в статусі Ready == False
  • коли readinessProbe пройдена – Kubernetes оновлює Endpoints і додає Pod IP в список адрес
  • ALB Controller бачить новий IP в Endpoints, і починає процес додавання цього IP в TargetGroup
  • після реєстрації в TargetGroup цей IP не відразу отримує трафік, а переходить в статус Initial
  • ALB починає виконувати свої Health checks
  • коли Health check пройдено – target стає Healthy, і на нього роутиться трафік

Kubernetes, AWS TargetGroup та targets deregistration

Але є нюанс в тому, як targets видаляються з Target Group:

  • під час деплою з Rolling Update – Kubernetes створює новий Pod, і чекає поки він стане Ready (пройде readinessProbe)
  • після цього Kubernetes починає видаляти старий под – він переходить в статус Terminating
  • в це жеж час новий IP нового Pod, який було додано в ALB Target Group ще може проходити Health checks, тобто бути в статусі Initial
  • а target того Pod, який вже почав Terminating – переходить в статус Draining і потім видаляється з Target Group

І ось тут ми і можемо ловити 503.

Перевірка

Спробуємо зарепроьюсити цю проблему:

  • робимо деплой
  • слідкуємо за статусом Pod
    • він стане Ready, і почне вбиватись старий Pod
    • в цей час глянемо ALB – чи пройшов новий таргет Initial, і який статус старого target

Що маємо перед деплоєм:

Сам Pod:

$ kk get pod -l app=backend-api -o wide
NAME                                      READY   STATUS    RESTARTS   AGE   IP           NODE                          NOMINATED NODE   READINESS GATES
backend-api-deployment-849557c54b-jmkz4   1/1     Running   0          44m   10.0.47.48   ip-10-0-38-184.ec2.internal   <none>           <none>

І Edpoints:

$ kk get endpoints backend-api-service
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME                  ENDPOINTS         AGE
backend-api-service   10.0.47.48:8000   7d3h

Таргети в TargetGroup – зараз один:

Запускаємо деплой – і маємо новий в Ready та Running, а старий в Terminating:

І в цей в TargetGroup маємо новий таргет в статусі Initial, а старий вже в Draining:

Правда, з curl в циклі по 1 секунді не вийшло відловити 503 – надто швидко проходять Health checks.

Рішення: Pod readiness gate

Pod readiness gate додає ще одну перевірку до Pod: поки відповідний Target в ALB не пройде Helth check – старий Pod не буде видалятись:

The condition status on a pod will be set to True only when the corresponding target in the ALB/NLB target group shows a health state of »Healthy«.

Але це працює тільки якщо у вас target_type == IP.

Включаємо Readiness gate в Kubernetes Namespace:

$ kubectl label namespace dev-backend-api-ns elbv2.k8s.aws/pod-readiness-gate-inject=enabled
namespace/dev-backend-api-ns labeled

Деплоїмо ще раз, і дивимось.

Pod перейшов в Ready, але в Readiness Gates він ще не готовий – 0/1:

Старий target не дрейниться, поки новий в Initial:

Бо старий Pod ще не почав видалятись:

Як тільки новий target став Healthy:

То і старий Pod почав видалятись:

Problem 3: Karpenter consolidation

Ще кілька раз 503 виникала в момент, коли Karpeter видаляв WorkerNode.

Перевіримо поди і ноди:

sum(kube_pod_info{namespace="dev-backend-api-ns", pod=~"backend-api.*"}) by (pod, node)

  • старий под на ip-10-0-32-209.ec2.internal
  • новий на ip-10-0-36-145.ec2.internal

і в цей відбувався ребаланс WorkerNodes – інстанс ip-10-0-32-209.ec2.internal видалявся через "reason":"underutilized":

Давайте згадаємо як виглядає процес Karpenter consolidation – див. Karpenter Disruption Flow в пості Kubernetes: забезпечення High Availability для Pods:

  • Karpenter бачить underutilized EC2
  • він додає Node taint з NoSchedule, аби нові поди не створювались на цій WorkerNode (див. Kubernetes: Pods та WorkerNodes – контроль розміщення подів на нодах)
  • виконує Pod Eviction, аби видалити з цієї ноди існуючі Pods
  • Kubernetes отримує Eviction event, і починає процес видалення контейнерів – переводить їх в стан Ternimating через відправку сигналу SIGTERM
  • ALB Controller бачить, що Pod в статусі Terminating – і починає процес видалення target – переводить його в стан Draining
  • в цей жеж час Kubernetes бачить, що кількість replicas в Deployment не дорівнює desired state, і створює новий Pod на заміну старому
    • новий Pod не може бути запущеним на старій WorkerNode, бо та має taint NoSchedule, і якщо на існуючих WorkerNodes нема місця – Karpenter створює новий інстанс EC2, що займає пару хвилин – це ще додатково збільшить вікно для 503
  • новий Pod в цей час в статусі Pending або ContainerCreating, і не додається до LoadBalancer TargetGroup – тоді як старий target вже видаляється
    • насправді не видаляється відразу, бо Draining state – це коли ALB перестає створювати нові з’єднання з target, але дає час на завершення існуючих – див. Registered targets
  • відповідно в цей час ALB просто нема куди слати трафік
  • отримуємо 503

Перевірка

Можемо спробувати зарепродьюсити помилку:

  • виконаємо node drain там де Dev Pod
  • подивимось на статус подів
  • подивимось на ALB Targets

Знаходимо ноду Pod з Backend API в Dev Namespace:

$ kk -n dev-backend-api-ns get pod -l app=backend-api -o wide
NAME                                      READY   STATUS    RESTARTS   AGE     IP          NODE                          NOMINATED NODE   READINESS GATES
backend-api-deployment-7b5fd6bb9b-mrm2m   1/1     Running   0          7m56s   10.0.42.9   ip-10-0-40-204.ec2.internal   <none>           1/1

Виконуємо drain цієї WrorkerNode:

$ kubectl drain --ignore-daemonsets ip-10-0-40-204.ec2.internal

Бачимо, що старий Pod в статусі Terminating, а новий – ContainerCreating:

І в цей час в TargetGroup маємо тільки один target, і він вже в статусі Draining – бо старий Pod в Terminating, а новий ще не додано в TargetGroup – бо Ednpoints оновиться тільки тоді, коли новий Pod стане Ready:

І в цей час ловимо купу 503:

$ while true; curl -X GET -s -o /dev/null -w "%{http_code}\n" https://dev.api.example.co/ping; do sleep 1; done | grep 503
503
503
503
...

Рішення: PodDisruptionBudget

Як цьому запобігти?

Мати PodDisruptionBudget, детальніше в тому ж пості Kubernetes: забезпечення High Availability для Pods.

Але в цьому конкретному випадку помилки виникають на Dev-оточенні, де тільки один контейнер з API і нема PDB – а на Staging і Production вони є, тому там ми 503 помилки більше не отримуємо:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: backend-api-pdb
spec:
  minAvailable: {{ .Values.deployment_api.poddisruptionbudget.min_avail }}
  selector:
    matchLabels:
      app: backend-api

Власне, остання 503 помилка вирішена теж.

Loading

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