Не зважаючи на те, що про зміни було повідомлено в листах від 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.
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.
Можна глянути що саме було встановлено з останнім апдейтом:
Як саме 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:
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:
В 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) незалежно від загального навантаження системи.
Вище бачили 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.
Діапазон значень тут – від 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 – далі побачимо, як це виглядає.
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:
Складно в одному пості описати те, про що написані тисячі книжок на тисячу сторінок, але сьогодні спробуємо швиденько розглянути основи того, як відбувається комунікація між хостами в мережі.
Спочатку згадаємо про моделі 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):
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)
тут формується 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 – це більше теоретична моделі, яку спочатку описали (“як це має бути”), і потім вже під цю модель почали додавати нові протоколи.
Окей – з загальною схему передачі даних розібрались, давайте подивимось детальніше на те, що і як саме передається в 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 оголошується під час 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:
Трохи розібрались з TCP headers – тепер давайте глянемо на те, як встановлюється TCP-з’єднання.
Отже, TCP – це протокол, орієнтований на з’єднання (тобто потребує встановлення сесії до початку передачі даних), який забезпечує доставку даних, контроль потоку та виявлення помилок при передачі.
На відміну від UDP – з TCP ми або гарантовано передаємо дані, або буде виявлена помилка і з’єднання буде розірване.
TCP handshake
Як і в TLS, встановлення TCP-з’єднання відбувається за стандартним процесом – “3-way handshake“.
TCP handshake складається з трьох етапів (власне, тому і назва “3-way handshake”):
SYN: клієнт відправляє пакет з флагом SYN, вказуючи свій Initial Sequence Number
SYN-ACK: сервер відповідає пакетом з флагами SYN та ACK, цим він:
підтверджує отримання SYN від клієнта (встановлюючи поле Acknowledgment Number)
відправляє свій власний Initial Sequence Number
ACK: клієнт відправляє ACK, чим підтверджує отримання SYN-ACK від серверу
На цьому сесія вважається встановленою, і починається передача даних.
Закриття сесії – “4-way FIN handshake“:
FIN: клієнт повідомляє сервер (або навпаки), що закінчив передачу, і готовий до закриття сесії
ACK від серверу: сервер підтверджує отримання FIN
FIN від серверу: сервер повідомляє, що теж готовий закрити сесію
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 – підтвердження отримання пакету.
Тобто:
Seq=0:
клієнт відправляє Inital Sequence Number, який Wireshark нам відображає як 0
Seq=0 Ack=1 Len=0:
Seq=0 – сервер теж задає свій Inital Sequence Number
Ack=1 – сервер інкрементить Seq від клієнта на +1
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 та інших.
Ядро також підтримує автоматичне масштабування буферів – файл /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-сегментів, які викликатимуть зайві накладні витрати та зниження продуктивності.
Network Interface Card отримує Ethernet frame з TCP-пакетом
ядро системи викликає драйвер карти, а драйвер викликає функцію netif_receive_skb() і передає весь отриманий кадр (в структурі skb – Socket Buffer) на обробку мережевій підсистемі ядра
Layer 3: Network layer
пакет передається до ip_rcv() (IPv4) або ipv6_rcv() (IPv6), де перевіряється заголовок IP для визначення протоколу
якщо Protocol = 6 (TCP) – пакет передається в tcp_v4_rcv()
Layer 4: Transport layer
функція tcp_v4_rcv() перевіряє контрольну суму, знаходить відповідний локальний сокет (порт), обробляє SEQ/ACK/FIN/RST/SYN флаги, і додає payload у receive buffer сокета, прив’язаного до відповідного порту (наприклад, listen(80) для веб-сервера)
після передачі даних до веб-серверу – ядро формує ACK-пакет у відповідь
дані передаються у внутрішній receive buffer сокета, і звідти вже передаються в userspace до веб-серверу (якщо ми про браузер-сервер)
Якщо заглиблюватись – то можна взяти утиліти по типу Systemtap для відстеження системних викликів.
Сокети та TCP-порти в Linux
Для роботи з TCP в Linux є концепція сокетів (sockets) – це такі собі ендпоінти, які прив’язуються до пари IP:PORT.
Власне сокет – це абстракція, яка дозволяє програмам читати/писати в мережу, як через звичайний файл, і по суті і є файловим дескриптором спеціального типу: операційна система сприймає їх як пайп (pipe), через який можна передавати дані.
Сокети можуть бути або локальними, аби мережевими:
AF_INET для IPv4 та AF_INET6 для IPv6
AF_UNIX або AF_LOCAL – для локальної роботи
AF_* в імені – це “Address Family“, бо маємо не тільки TCP/UDP-сокети, але й AF_UNIX – локальні, AF_BLUETOOTH – Bluetooth, AF_NETLINK – Netlink і т.д.
// 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, які ми можемо бачити для якихось локальних демонів, наприклад:
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.
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 у відповідь, або просто дропнути пакет (залежно від ситуації).
Є у нас 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.
Також рекомендується додати фільтр --vm-native-filter-match='{__name__!~"vm_.*"}' аби не переносити метрики, які відносяться до самої VictoriaMetrics, бо це може призвести до data collision – появи дублікатів тайм-серій.
Хоча в моєму випадку у нас через VMAgent до всіх метрик додається метрика з іменем кластеру:
Інший варіант – додати опцію 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-кластері.
Я робив з 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 на новий.
Питав розробників про варіант з 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 – vlstorage, vlinsert 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.
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:
Тепер у нас 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-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-кластері:
При створенні 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.
502 у нас виникає, якщо ALB не зміг отримати коректну відповідь від бекенда, тобто помилка на рівні самого сервісу (застосунку), application layer (“я додзвонився, але на тому кінці відповіли щось незрозуміле або кинули слухавку посеред розмови”):
у Pod не відкритий порт
Pod впав, але Readiness probe каже що він живий, і Kubernetes не відключає його від трафіку (не видаляє IP зі списку в Endpoints)
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 проходять надто довго
Інші можливі причини 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:
Тоді вирішив перевірити просто всі 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 з цим атрибутом:
Не пам’ятаю для чого додавав, але трошки вилізло боком.
Добре – це поміняли, і 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.
виконує 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
Бачимо, що старий 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
...
Але в цьому конкретному випадку помилки виникають на Dev-оточенні, де тільки один контейнер з API і нема PDB – а на Staging і Production вони є, тому там ми 503 помилки більше не отримуємо:
Terraform має два способи перенести існуючі ресурси під управління Terraform – з Terraform CLI і командою terraform import, або використовуючи ресурс import.
Для чого нам може знадобитись імпорт ресурсів?
якщо у нас вже є вручну налаштований (“clickops”) якийсь сервіс, який ми хочемо перенести під управління Terraform (робили як Proof of Concept, а потім пішло в production)
якщо у нас є ресурси, які створювались з іншою IaC системою, наприклад – з CloudFormation
якщо ми втратили наш state-файл, і треба його відновити
чи якщо ми розбиваємо один великий проект на менші і створюємо нові state-файли
Описуємо блок для 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:
$ 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 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.
Виконуємо 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
Тепер ще один цікавий момент.
Якщо ми задамо якісь додаткові атрибути нашому вже імпортованому юзеру, наприклад теги:
$ 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 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]
У нас є автоматизація для 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
}
...
Але проблема виникає не через самі індекси – а через те, як вони змінюються, якщо елемент зі списку видаляється або переміщується, особливо якщо ці індекси використовуються як ключі для 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.
...
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
А flatten() нам просто прибирає цю вкладеність list(list(object)), і перетворює результат в плаский list(object), де кожен об’єкт – це унікальна пара кластер + юзер:
Окей – з цим розібрались, йдемо далі – подивимось, як цей список буде використовувати в 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 об’єктів:
А оскільки 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.
І якщо раніше 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, і виконувати ітерацію за цим ключем:
Дуже хочеться покрутити якісь 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:
Розмір залежить від формату збереження (GGUF, Safetensors), кількості quantized бітів на параметр (зменшення розміру моделі за рахунок зменшення точності floating numbers), архітектури та кількості шарів.
Окремо потрібне буде місце під контекст – далі побачимо, як це впливає.
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.
$ 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: скільки токенів було згенеровано у відповідь
#!/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“:
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 через системні промти.
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
>>> 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.
Кожного разу, як беруся за встановлення 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 для перевірки.
Колись в 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 ~]# 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 приводили до чорного екрану.
Так як у нас є зашифрований розділ – то його треба вказати в GRUB, аби він передав ядру необхідність виконати decrypt.
Зараз це можна зробити /etc/crypttab – або “the old school way” з GRUB_CMDLINE_LINUX.
Я crypttab не користувався, якихось додаткових розділів в мене нема, тому GRUB_CMDLINE_LINUX буде достатньо.
Додаємо ще три пакети – власне grub, efibootmgr (хоча ми його вже встановили) та intel-ucode, аби мати останні апдейти для ЦПУ (це, звісно, якщо у вас Intel CPU, а не AMD).
Додаємо 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
Ну і останнє – UUID розділу, який ми задавали в GRUB – отут в мене і була помилка, бо робив blkid /dev/mapper/cryptroot, а не самого розділу /dev/sda4.
В попередньому матеріалі розібрались з тим, що таке 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 і зробити запит на кшталт такого:
Тепер давайте напишемо 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")
Робимо 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“.