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 і порти
5 (1)

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

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

26 Травня 2025

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

# iwctl

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

[iwd]# station wlan0 get-networks

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

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

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

[iwd]# station wlan0 show

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

Отримуємо IP:

# ip a s wlan0

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

# passwd root

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

$ ssh [email protected]

archinstall vs manual installation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Тут:

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

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

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

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

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

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

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

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

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

Partition 3 has been deleted.

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

Partition 2 has been deleted.

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

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

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

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

root@archiso ~ # fdisk /dev/sda

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

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

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

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

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

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

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

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

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

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

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

The signature will be removed by a write command.

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

Command (m for help): t

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

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

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

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

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

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

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

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

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

...

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

І Swap:

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

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

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

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

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

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

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

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

Виконуємо:

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

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

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

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

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

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

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

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

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

Iris Xe Graphics

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

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

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

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

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

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

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

Створення fstab

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

GRUB

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

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

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

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

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

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

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

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

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

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

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

Додавання cryptdevice

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

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

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

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

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

Додавання resume

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

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

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

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

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

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

Тут все.

Пароль root

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

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

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

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

Готово.

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

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

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

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

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

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

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

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

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

Виконуємо chroot:

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

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

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

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

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

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

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

Все є.

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

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

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

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

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

# systemctl start iwd
# systemctl enable iwd

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

# systemctl start dhcpcd
# systemctl enable dhcpcd

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


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

Loading

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

10 Травня 2025

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

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

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

VictoriaLogs API

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

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

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

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

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

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

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

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

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

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

import requests

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

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

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

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

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

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

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

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

#!/usr/bin/env python3

from fastmcp import FastMCP
import requests

mcp = FastMCP("VictoriaLogs MCP Server")

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

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

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

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

$ pip install fastmcp

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

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

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

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

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

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

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

Офігєть 🙂

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Loading