Дебажимо одну проблему з використанням пам’яті в Kubernetes Pods, і вирішили подивитись на пам’ять і кількість процесів на нодах.
Сама проблема полягає в тому, що зазвичай Kubernetes Pod з Livekit споживає близько 2 гігабайт пам’яті, але іноді бувають спайки до 10-11 гіг, через що под вбивається:
Що ми хочемо визначити: це один процес починає стільки пам’яті “їсти” – чи просто створюється багато процесів в контейнері?
Самий простий варіант тут – використати Prometheus Process Exporter, який запускається у вигляді DaemonSet, на кожній WorkerNode створює власний контейнер, і для всіх чи обраних процесів на EC2 збирає статистику з /proc.
root@backend-celery-workers-deployment-5bc64557c8-zbq2j:/app# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.2 1.4 544832 236720 ? Ss 07:27 0:24 /usr/local/bin/python /usr/local/bin/celery -A celery_app.app worker [...]
...
Та Livekit:
root@backend-livekit-agent-deployment-7d9bf86564-qgjzb:/app# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.4 1.8 2112944 294772 ? Ssl 07:06 0:46 python -m cortex.livekit_agent.main start
root 24 0.0 0.0 15788 12860 ? S 07:06 0:00 /usr/local/bin/python -c from multiprocessing.resource_tracker import main;main(34)
root 25 0.0 0.6 342976 102852 ? S 07:06 0:02 /usr/local/bin/python -c from multiprocessing.forkserver import main [...]
...
Додаємо конфіг для process-exporter – описуємо nameMatchers:
...
process-exporter:
enabled: true
tolerations:
operator: Exists
effect: NoSchedule
- key: CriticalAddonsOnly
config:
# metrics will be broken down by thread name as well as group name
threads: true
# any process that otherwise isn't part of its own group becomes part of the first group found (if any) when walking the process tree upwards
children: true
# means that on each scrape the process names are re-evaluated
recheck: false
# remove_empty_groups drop empty groups if no processes found
remove_empty_groups: true
nameMatchers:
# gunicorn (python + uvicorn workers)
- name: "gunicorn"
exe:
- /usr/local/bin/python
cmdline:
- ".*gunicorn.*"
# celery worker
- name: "celery-worker"
exe:
- /usr/local/bin/python
cmdline:
- ".*celery.*worker.*"
# livekit agent
- name: "livekit-agent"
exe:
- python
- /usr/local/bin/python
cmdline:
- ".*cortex.livekit_agent.main.*"
# livekit multiprocessing helpers
- name: "livekit-multiproc"
exe:
- /usr/local/bin/python
cmdline:
- ".*multiprocessing.*"
Тут в exe – список самого executable (можна кілька), а в cmdline – аргументи, з якими процес запущено.
Тобто для Livekit у нас exe – “/usr/local/bin/python“, а cmdline – це “-c from multiprocessing.resource_tracker [...]” або “-c from multiprocessing.forkserver [...]“.
Деплоїмо, і тепер залишилось тільки три групи:
Але є нюанси.
Перше – статистика збирається з кожної ноди по всій групі процесів.
Тобто, якщо ми зробимо:
sum(namedprocess_namegroup_memory_bytes{memtype="resident", groupname="celery-worker"}) by (groupname, instance, pod)
То отримаємо суму всіх RSS всіх Celery-воркерів на ноді, де запущений відповідний process-exporter Pod:
А друга проблема – що Process Exporter не має лейбли з іменем WorkerNode, з якої зібрані метрики.
Тому тут тільки шукати вручну – по Pod IP (лейбла instance) можемо знайти його Node:
$ kk get pod -o wide | grep 10.0.45.166
atlas-victoriametrics-process-exporter-4zdzl 1/1 Running 0 6m51s 10.0.45.166 ip-10-0-40-195.ec2.internal <none> <none>
Повертаючись до питання того, що немає інформації по кожному процесу: ми можемо отримати середнє значення по кожному, бо у нас є метрика namedprocess_namegroup_num_procs:
sum(namedprocess_namegroup_memory_bytes{memtype="resident", groupname="celery-worker", instance="10.0.45.166:9256"}) by (groupname, instance, pod)
/
sum(namedprocess_namegroup_num_procs{groupname="celery-worker", instance="10.0.45.166:9256"}) by (groupname, instance, pod)
Результат ~230 MB:
Як ми і бачили в ps -eo rss,cmd.
Name Group Template variables та інформація по кожному процесу
Або, якщо нам прям дуже хочеться бачити статистику по кожному процесу – ми можемо використати динамічні імена для groupname з {{.PID}} – тоді для кожного процесу буде формуватись окрема група, див. Using a config file: group name:
Але цей варіант ОК тільки для якщо вам треба щось подебажити, і відключити, бо призведе до High cardinality issue.
Результат нашого дебагу
Власне, що нам потрібно було дізнатись – пам’ять “утікає” в якомусь одному процесі, чи просто створюється багато процесів в одному Pod?
Для цього в Grafana зробили графік із запитом:
sum(namedprocess_namegroup_memory_bytes{memtype="resident", groupname=~"livekit-multiproc-.*"}) by (groupname, instance)
До нього додали графіки з метриками самого Livekit – lk_agents_active_job_count та lk_agents_child_process_count, і окремо – графік з VictoriaLogs, де виводимо кількість API-запитів кожного юзера по полю token_email:
Де і бачимо, що один і той же юзер починає робити пачку запитів для підключення до Livekit, через що в Livekit Pod створюється пачка процесів (по новій Livekit Job на кожен запит), і в результаті загальна кількість пам’яті в поді зашкалює, бо 40 процесів по ~380 MB це ~15 гігабайт пам’яті.
Але в кожному конкретному процесі пам’ять тримається на рівні 300-400 мегабайт.
Залишилось розібратись чому саме спавняться процеси, але то вже задачка девелоперам.
логи в CloudWatch Logs – можемо збирати в VictoriaLogs, і генерити якісь метрики і алерти, але я під час нашого production incent нічого цікавого в логах не побачив, див. Monitoring OpenSearch logs with Amazon CloudWatch Logs
і є Prometheus Exporter Plugin, який відкриває ендпоінт для збору метрик з Prometheus/VictoriaMetrics (але в AWS OpenSearch Managed його додати не можна, хоча сапорт обіцяє, ще feature request є – може колись додадуть)
CloudWatch метрики
Метрик досить багато, але з того, що може бути цікавим нам – враховуючи те, що у нас нема виділених master та coordinator nodes, і ми не використовуємо ultra-warm та cold інстанси.
Cluster metrics:
ClusterStatus: green/yellow/red – основний показник стану кластеру, контроль активності шардів даних
Shards: active/unassigned/delayedUnassigned/activePrimary/initializing/relocating – більш детальна інформація по стану шардів, але тут просто загальна кількість, без деталізації по конкретним індексам
Nodes: кількість нод в кластері – знаючи, скільки має бути живих нод – можемо алертити, коли якась нода відвалиться
SearchableDocuments: не те щоб саме для нас було дуже цікаво, але можливо буде корисним потім, аби бачити що взагалі твориться в індексах
CPUUtilization: відсоток використання CPU разом на всіх нодах, і це прям must-have
FreeStorageSpace: теж корисно моніторити
ClusterIndexWritesBlocked: чи все ОК із записами в індекс
JVMMemoryPressure та OldGenJVMMemoryPressure: відсоток використання пам’яті JVM heap – далі окремо копнемо в моніторинг JVM, бо це прям окремий геморой
AutomatedSnapshotFailure: мабуть, good to know, якщо бекап сфейлиться
CPUCreditBalance: нам корисно, бо ми на t3 інстансах (але у нас в CloudWatch її нема)
2xx, 3xx, 4xx, 5xx`: дані по HTTP-запитам і помилкам
тут треба буде дивитись на метрики з EBS volume metrics, але для початку можна просто додати алерти на Throttle взагалі
HighSwapUsage: аналогічно до попередніх метрик – колись мали біду в RDS, тому краще помоніторити і тут
EBS volume metrics – тут в принципі стандартні метрики EBS, як і для EC2 або RDS:
ReadLatency та WriteLatency: затримки читання/запису
іноді бувають спайки, тому можна додати
ReadThroughput та WriteThroughput: “пропускна здатність”? загальне навантаження на диск, давайте скажемо так
DiskQueueDepth: черга I/O операцій
у нас в CloudWatch пуста (поки що?), тому скіпаємо
ReadIOPS та WriteIOPS: кількість операцій читання/запису на секунду
Instance metrics – тут метрики по кожному OpenSearch інстансу (не серверу, EC2, а самого OpenSearch) на кожній ноді:
FetchLatency та FetchRate: як швидко отримуємо дані з шардів (але в CloudWatch теж не знайшов)
ThreadCount: кількість потоків в операційній системі, які були створені JVM (Garbadge Collector threads, search threads, write/index threads, etc)
в CloudWatch значення стабільне, але в Grafana для загальної картини поки можна додати, подивимось, чи буде там щось цікаве
ShardReactivateCount: як часто шарди зі станів cold/inactive переводяться в активні, що потребує ресурсів операційної системи і CPU та пам’яті; ну… може бути, треба глянути чи воно взагалі у нас має якісь значення
але в CloudWatch теж нічого – “did not match any metrics“
ConcurrentSearchRate та ConcurrentSearchLatency: кількість і швидкість одночасних запитів на пошук – може бути цікавим, якщо довго висять багато паралельних запитів
але у нас (поки що?) ці значення постійно на нулі, тому скіпаємо
SearchRate: кількість пошукових запитів на хвилину, корисно для загальної картини
SearchLatency: швидкість виконання пошукових запитів, мабуть, дуже корисно, можна навіть алерт прикрутити
IndexingRate та IndexingLatency: аналогічно, але для індексації нових документів
SysMemoryUtilization: відсоток використання пам’яті на дата-ноді, але це не дасть повноцінної картини, треба дивитись на пам’ять JVM
JVMGCYoungCollectionCount та JVMGCOldCollectionCount: кількість запусків Garbage Collectors, корисно разом з даними по JVM memory, поговоримо далі детальніше
SearchTaskCancelled та SearchShardTaskCancelled: про погані новини 🙂 якщо задачі канселяються – щось явно йде не так (або юзер сам перервав виконання запиту, або HTTP connection reset, або таймаути, чи навантаження на кластер)
але у нас завжди по нулях, навіть коли кластер падав, тому поки сенсу збору цих метрик не бачу
ThreadpoolIndexQueue та ThreadpoolSearchQueue: кількість задач на індексацію та пошук в черзі, коли їх забагато – маємо ThreadpoolIndexRejected та ThreadpoolSearchRejected
ThreadpoolIndexQueue в CloudWatch нема взагалі, а ThreadpoolSearchQueue є, але теж постійно в нулях, тому поки скіпаємо
ThreadpoolIndexRejected та ThreadpoolSearchRejected: власне, вище
в CloudWatch картина аналогічна – ThreadpoolIndexRejected нема взагалі, ThreadpoolSearchRejected в нулях
ThreadpoolIndexThreads та ThreadpoolSearchThreads: максимальна кількість потоків операційної системи для індексації та пошуку, якщо всі зайняті – то запити підуть в ThreadpoolIndexQueue/ThreadpoolSearchQueue
в OpenSearch є кілька типів пулів для потоків – search, index, write і т.д., і для кожного пулу є показник threads (скільки виділено), queue – черга, rejected – відхилено, бо черга переповнена, див. OpenSearch Threadpool
в Node Stats API (GET _nodes/stats/thread_pool) є показник active threads, але в CloudWatch такого не бачу
ThreadpoolIndexThreads у нас в CloudWatch взагалі нема, а ThreadpoolSearchThreads статична, поки, думаю, можна скіпнути їхній моніторинг
PrimaryWriteRejected: відхилені операції записи в primary-шарди через проблеми в thread pool write або index, чи навантаження на дата-ноді
в CloudWatch поки пусті, але додамо збір і алерт
ReplicaWriteRejected: відхилені операції записи в replica-шарди – в primary документ додано, але не може записати в репліку
в CloudWatch поки пусті, але додамо збір і алерт
k-NN metrics – нам корисно, бо у нас vector store з k-NN:
KNNCacheCapacityReached: коли кеш повністю зайнятий (див. далі)
KNNEvictionCount: як часто дані з кешу видаляються – ознака, що пам’яті не вистачає
KNNGraphMemoryUsage: використання off-heap пам’яті під графи самого вектору
KNNGraphQueryErrors: кількість помилок при пошуку в векторах
в CloudWatch поки пусті, але додамо збір і алерт
KNNGraphQueryRequests: загальна кількість запитів до k-NN graphs
KNNHitCount та KNNMissCount: скільки результатів було повернуто з кешу, а скільки довелось зчитувати з диску
KNNTotalLoadTime: швидкість завантаження з диску в кеш (великі графи або завантажений EBS – буде рости час)
Моніторинг Memory
Давайте подумаємо як нам основнім показники помоніторити, і першим – пам’ять, бо це ж Java.
Що у нас є по пам’яті з метрик?
SysMemoryUtilization: відсоток використання пам’яті на сервері (дата-ноді) взагалі
JVMMemoryPressure: загальний відсоток використання JVM Heap; JVM Heap по дефолту виділяється в 50% від пам’яті серверу, але не більше 32 гіг
в CloudWatch ще є метрика KNNGraphMemoryUsagePercentage – але в документації її нема
kNN Memory usage
Спершу коротенько про пам’ять під k-NN.
Отже, на EC2 у нас виділяється пам’ять під JVM Heap (50% доступної на сервері), і окремо – off-heap для OpenSearch vector store, де він тримає графи та кеш vectore store – див. Approximate k-NN search, плюс під саму операційну систему і її файловий кеш.
Якоїсь метрики типу “KNNGraphMemoryAvailable” у нас нема, але маючи KNNGraphMemoryUsagePercentage та KNNGraphMemoryUsage можемо її порахувати:
KNNGraphMemoryUsage: у нас зараз 662 мегабайти
KNNGraphMemoryUsagePercentage: 60%
Значить, під k-NN graphs виділяється 1 гігабайт поза JVM Heap memory (це на t3.medium.search).
OpenSearch Service uses half of an instance’s RAM for the Java heap (up to a heap size of 32 GiB). By default, k-NN uses up to 50% of the remaining half
Знаючи, що у нас зараз t3.medium.search, на яких видається 4 гігабайти пам’яті – 2 GB йде під JVM Heap, і 1 гігабайт – під k-NN графи.
Основну частину KNNGraphMemory використовує k-NN cache, тобто частина оперативної пам’яті системи, в якій OpenSearch тримає HNSW-графи з векторних індексів, аби не зчитувати їх кожного разу з диску (див. k-NN clear cache).
Тому корисно мати графіки по EBS IOPS та використанню k-NN cache.
Stack Memory: окрім JVM Heap маємо Stack, який виділяється кожному потоку, де він тримає свої змінні, посилання, параметри запуску
задається через -Xss, дефолтне значення від 256 кілобайт до 1 мегабайту, див. Understanding Threads and Locks (не знайшов, як подивитись в OpenSearch Service)
якщо маємо багато threads – буде багато пам’яті під їхні стеки
очищується, коли thread вмирає
Heap Space:
використовується для виділення пам’яті, яка доступна всім потокам
керується Garbage Collectors (GC)
в контексті OpenSearch у нас тут будуть кеши пошуку і індексацій
В Heap memory у нас є:
Young Generation: свіженькі дані, усі нові об’єкти
дані звідси або видаляються зовсім, або переміщаються в Old Generation
Old Generation: сам код процесу OpenSearch, кеші, індексні структури Lucene, великі масиви
Якщо OldGenJVMMemoryPressure забитий – значить, Garbage Collector не може його почистити, бо на дані є посилання, і тоді маємо проблему – бо в Heap нема місця для нових даних, і JVM може впасти з помилкою OutOfMemoryError.
Взагалі “heap pressure” – це коли в Young Gen і Old Gen мало вільної пам’яті, і нема де розмістити нові дані, аби відповісти клієнтам.
Це призводить до частого запуску Garbage Collector, що займає час та ресурси системи – замість обробки запитів від клієнтів.
В результаті latency зростає, індексація нових документів гальмує, або взагалі отримуємо ClusterIndexWritesBlocked – аби уникнути Java OutOfMemoryError, бо при індексації OpenSearch спочатку пише дані в Heap, а потім “скидається” на диск.
Отже – для картини використання пам’яті моніторимо:
SysMemoryUtilization – для загальної картини по стану EC2
в нашому випадку тут буде стабільно близько 90%, але це ОК
JVMMemoryPressure – для загальної картини по JVM
має регулярно чиститись з Garbage Collector (GC)
якщо постійно вище 80-90% – є проблеми з запуском GC
OldGenJVMMemoryPressure – для даних по Old Generation Heap
має бути на рівні 30-40%, якщо вище і не вичищається – то проблеми або з кодом, або з GC
KNNGraphMemoryUsage – в нашому випадку треба для загальної картини
І варто додати алерти на HighSwapUsage – у нас вже відбувався активний swapping, коли запустились на t3.small.search, і це показник того, що пам’яті недостатньо.
Збір метрик до VictoriaMetrics
Власне, як вибрати метрики?
Спершу шукаємо їх в CloudWatch Metrics, і дивимось чи взагалі метрика є, і чи вона повертає якісь цікаві дані.
Наприклад, SysMemoryUtilization дає інфу.
Отуто у нас на t3.small.search був спайк, після якого кластер впав:
А ось метрика HighSwapUsage – теж до переїзду на t3.medium.search:
ClusterStatus є:
Shards є, але це по всім індексам, і нема можливості фільтрувати по окремим:
Ну і треба мати на увазі, що збір метрик з CloudWatch теж коштує грошей за API-запити, тому все підряд збирати не варто.
Взагалі для збору метрик з CloudWatch ми користуємось YACE (Yet Another CloudWatch Exporter), але він не підтримує OpenSearch Managed cluser – див. Features.
Зверніть увагу, що для різних метрик можуть бути різні Dimenstions – перевіряємо їх в CloudWatch:
Деплоїмо, перевіряємо:
І навіть цифри вийшли такі, як ми рахували в першому пості – маємо ~130000 документів в production index, по формулі num_vectors * 1.1 * (4*1024 + 8*16) виходить 604032000 байт, або 604.032 мегабайт.
А на графіку маємо 662261 kilobytes – це 662 мегабайти, але по всім індексам разом.
Тепер у VictoriaMetrics у нас є метрики aws_es_knngraph_memory_usage_average, aws_es_sys_memory_utilization_average, aws_es_jvmmemory_pressure_average, aws_es_old_gen_jvmmemory_pressure_average.
Аналогічно додаємо решту.
Для пошуку того, як саме метрики називаються в VictoriaMetrics/Prometheus – відкриваємо порт до CloudWatch Exporter:
$ kk port-forward svc/atlas-victoriametrics-prometheus-cloudwatch-exporter 9106
І з curl та grep шукаємо метрики:
$ curl -s localhost:9106/metrics | grep aws_es
# HELP aws_es_cluster_status_green_maximum CloudWatch metric AWS/ES ClusterStatus.green Dimensions: [ClientId, DomainName] Statistic: Maximum Unit: Count
# TYPE aws_es_cluster_status_green_maximum gauge
aws_es_cluster_status_green_maximum{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",client_id="492***148",} 1.0 1758014700000
# HELP aws_es_cluster_status_yellow_maximum CloudWatch metric AWS/ES ClusterStatus.yellow Dimensions: [ClientId, DomainName] Statistic: Maximum Unit: Count
# TYPE aws_es_cluster_status_yellow_maximum gauge
aws_es_cluster_status_yellow_maximum{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",client_id="492***148",} 0.0 1758014700000
# HELP aws_es_cluster_status_red_maximum CloudWatch metric AWS/ES ClusterStatus.red Dimensions: [ClientId, DomainName] Statistic: Maximum Unit: Count
# TYPE aws_es_cluster_status_red_maximum gauge
aws_es_cluster_status_red_maximum{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",client_id="492***148",} 0.0 1758014700000
...
Створення Grafana dahsboard
ОК, метрики з CloudWatch маємо – їх поки вистачить.
Подумаємо, що ми хочемо бачити в Grafana.
Загальна ідея – така собі “overview” дашборда, де на одній борді будуть відображатись всі головні дані по кластеру.
Які метрики зараз є, і як ми їх можемо використати в Grafana – я їх тут собі виписував, аби не заплутатись, бо їх вийшло багатенько:
aws_es_cluster_status_green_maximum, aws_es_cluster_status_yellow_maximum, aws_es_cluster_status_red_maximum: можна зробити одну Stats панель
aws_es_nodes_maximum: теж якусь Stats панель – знаємо, скільки має бути, і будемо робити червоним, коли Data Nodes менше, ніж має бути
aws_es_searchable_documents_maximum: просто інтересу заради – графіком покажемо кількість документів разом в усіх індексах
aws_es_cpuutilization_average: одним графіком по кожній ноді, і якусь Stats з загальною інформацією і різними кольорами
aws_es_free_storage_space_maximum: просто Stats
aws_es_cluster_index_writes_blocked_maximum: не став додавати в Grafana, тільки алерт
aws_es_jvmmemory_pressure_average: графік і Stats
aws_es_old_gen_jvmmemory_pressure_average: десь поруч, теж графіком + Stats
aws_es_automated_snapshot_failure_maximum: це просто для алерта
aws_es_5xx_maximum: і графік, і Stats
aws_es_iops_throttle_maximum: графік, аби бачити в порівнянні з іншими даними типу CPU/Mem usage
aws_es_throughput_throttle_maximum: графік
aws_es_high_swap_usage_maximum: і графік, і Stats – графік, аби бачити в порівнянні з CPU/дисками
aws_es_read_latency_average: графік
aws_es_write_latency_average: графік
aws_es_read_throughput_average: не став додавати, бо забагато графіків
aws_es_write_throughput_average: не став додавати, бо забагато графіків
aws_es_read_iops_average: графік, корисно, аби розуміти роботу кешу k-NN – якщо його мало (а ми тестили на t3.small.search з 2 гігабайтами загальної пам’яті) – то читання з диску буде багато
aws_es_write_iops_average: аналогічно
aws_es_thread_count_average: не став додавати, бо воно доволі статичне і якось сильно корисної інформації не побачив
aws_es_search_rate_average: теж просто графік
aws_es_search_latency_average: аналогічно, десь поруч
aws_es_sys_memory_utilization_average: ну, воно постійно буде десь під 90%, поки прибрав з Grafana, але додав в алерти
aws_es_jvmgcyoung_collection_count_average: графік, бачити як часто викликається
aws_es_jvmgcold_collection_count_average: графік, бачити як часто викликається
aws_es_primary_write_rejected_average: графік, але поки не став додавати, бо забагато графіків – тільки алерт
aws_es_replica_write_rejected_average: графік, але поки не став додавати, бо забагато графіків – тільки алерт
k-NN:
aws_es_knncache_capacity_reached_maximum: тільки для warning-алерту
aws_es_knneviction_count_average: не став додавати, хоча може бути цікавим
aws_es_knngraph_memory_usage_average: не став додавати
aws_es_knngraph_memory_usage_percentage_maximum: графік, замість aws_es_knngraph_memory_usage_average
aws_es_knngraph_query_errors_maximum: тільки алерт
aws_es_knngraph_query_requests_sum: графік
aws_es_knnhit_count_maximum: графік
aws_es_knnmiss_count_maximum: графік
aws_es_knntotal_load_time_sum: було непогано мати графік, але нема місця на борді
VictoriaMetrics/Prometheus sum(), avg() та max()
Спершу давайте згадаємо які у нас є функції для агрегації даних.
З CloudWatch для OpenSearch ми будемо отримувати два основні типи – counter та gauge:
$ curl -s localhost:9106/metrics | grep cpuutil
# HELP aws_es_cpuutilization_average CloudWatch metric AWS/ES CPUUtilization Dimensions: [ClientId, DomainName, NodeId] Statistic: Average Unit: Percent
# TYPE aws_es_cpuutilization_average gauge
aws_es_cpuutilization_average{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",node_id="BzX51PLwSRCJ7GrbgB4VyA",client_id="492***148",} 10.0 1758099600000
...
Різниця між ними:
counter: значення може тільки збільшувати значення
gauge: значення може збільшуватись і зменшуватись
Тут у нас “TYPE aws_es_cpuutilization_average gauge“, бо використання CPU може і збільшуватись, і зменшуватись.
З sum() без лейбл ми просто отримаємо суму всіх значень:
Якщо зробимо sum by (node_id) – то отримаємо значення для конкретної тайм-серії, яка тут буде збігатись з вибіркою без sum by ():
(значення міняється, поки пишу і роблю запити)
З max() без фільтрів – отримаємо просто максимальне значення, вибране з усіх отриманих тайм-серій:
А з avg() – середнє значення всіх значень, тобто сума всіх значень поділена на кількість тайм-серій:
Порахуємо самі:
(41+46+12)/3
33
Власне, чому я про це став писати окремо – бо з sum() навіть із by (node_id) іноді можна отримати такі во спайки:
Хоча без sum() їх нема:
А траплялись вони через те, що в цей момент перестворювався Pod з CloudWatch Exporter:
І в цей момент ми отримували дані зі старого поду, і з нового.
Тому тут варіант або використовувати max(), або просто avg(). Хоча max() все ж, мабуть, краще, бо нам цікаві “найгірші” показники.
Окей – з цим розібрались, погнали робити дашборду.
Cluster status
Тут хочеться на одній Stats панелі бачити всі три значення – Green, Yellow, Red.
Але так як в Grafana у нас нема if/else, то зробимо “костиль”.
Збираємо всі три метрики, і результат кожної множимо на 1, 2, чи 3:
sum(aws_es_cluster_status_green_maximum) by (domain_name) * 1 +
sum(aws_es_cluster_status_yellow_maximum) by (domain_name) * 2 +
sum(aws_es_cluster_status_red_maximum) by (domain_name) * 3
Відповідно, якщо aws_es_cluster_status_green_maximum == 1, то 1 * 1 == 1, а aws_es_cluster_status_yellow_maximum == 0 і aws_es_cluster_status_red_maximum будуть == 0 – то і множення поверне 0.
А якщо aws_es_cluster_status_green_maximum стане 0, але aws_es_cluster_status_red_maximum буде 1 – то 1 * 2 отримаємо 3, і по значенню 3 будемо міняти показник в Stats-панелі
І додаємо Value mappings з текстом і кольорами:
Отримуємо такий результат:
Nodes status
Тут все просто – знаємо потрібну кількість, поточну отримуємо з aws_es_nodes_maximum:
sum(aws_es_nodes_maximum) by (domain_name)
І знов через Value mappings задаємо значення і кольори:
На випадок, якщо колись збільшимо кількість нод, і забудемо оновити тут значення для “ОК” – то додаємо третій статус, ERR:
CPUUtilization: Stats
Тут зробимо кросивенько – з типом візуалізації Gauge:
avg(aws_es_cpuutilization_average) by (domain_name)
Задаємо Text size та Unit:
І Thresholds:
Description непогано генерить ChatGPT – корисно і девелоперам, і нам самим через півроку, або просто беремо опис з документації AWS:
The percentage of CPU usage for data nodes in the cluster. Maximum shows the node with the highest CPU usage. Average represents all nodes in the cluster.
Додаємо решту Stats:
CPUUtilization: Graph
Тут виведемо графік по CPU кожної ноди – середнє за 5 хвилин:
max(avg_over_time(aws_es_cpuutilization_average[5m])) by (node_id)
І ось теж приклад того, як з sum() з’являлись спайки, яких не було насправді:
Тому робимо max().
Задамо Gradient mode == Opacity, і Unit == percent:
Задаємо Color scheme і Thresholds, включаємо Show thresholds:
В Data links можна задати лінку на сторінку DataNode Health в AWS Console:
Actions, мабуть, не так давно з’явилось, ще не використовував, але виглядає цікаво – можна щось пушнути:
JVMMemoryPressure: Graph
Тут нам цікаво бачити чи не “залипає” використання пам’яті, і як часто запускається Garbage Collector.
Запит простий – можна зробити max by (node_id), але я зробив просто загальну картину по кластеру:
max(aws_es_jvmmemory_pressure_average)
І графік аналогічно попередньому:
В Desription додаємо пояснення “коли хвилюватись”:
Represents the percentage of JVM heap in use (young + old generation).
Values below 75% are normal. Sustained pressure above 80% indicates frequent GC and potential performance degradation.
Values consistently > 85–90% mean heap exhaustion risk and may trigger ClusterIndexWritesBlocked – investigate immediately.
JVMGCYoungCollectionCount and JVMGCOldCollectionCount
Дуже корисний графік, аби бачити як часто зпускаються Garbage Collects.
В запиті використаємо increase[1m] – побачити як змінилось значення за хвилину:
max(increase(aws_es_jvmgcyoung_collection_count_average[1m])) by (domain_name)
І для Old Gen:
max(increase(aws_es_jvmgcold_collection_count_average[1m])) by (domain_name)
Unit – ops/sec, Decimals задаємо 0, аби мати тільки цілі значення:
KNNHitCount vs KNNMissCount
Тут зробимо дані на секунду – rate():
sum(rate(aws_es_knnhit_count_average[5m]))
І для Cache Miss:
sum(rate(aws_es_knnmiss_count_average[5m]))
Unit ops/s, кольори можемо задати через Overrides:
Статистика тут, до речі, дуже так собі – стабільно багато Cache missed, але чому – поки не розібрались.
Фінальний результат
Збираємо всі графіки, і отримуємо щось таке:
t3.small.search vs t3.medium.search на графіках
І приклад того, як нестача ресурсів, в першу чергу пам’яті, відображається на графіках: у нас були t3.medium.search, потім ми повернули t3.small.search, аби подивитись як воно на перформанс вплине.
t3.small.search – це лише 2 гігабайти пам’яті і 2 ядра CPU.
З цих 2 гіг пам’яті 1 гіг під JVM Heap, 500 мегабайт під k-NN memory, і 500 залишалось на решту процесів.
Ну і результати, цілком очікувані:
Garbage Collectors стали запускатись постійно, бо треба було чистити пам’ять, якої не вистачало
Read IOPS виріс, бо постійно з диска завантажувались дані до JVM Heap Young і k-NN
Search Latency виріс, бо не всі дані були в кеші, і чекали I/O-операцій з диску
і CPU utilization підскочив – бо CPU був завантажений і Garbage Collectors, і читанням з диску
OpenSearch ClusterStatus Yellow та OpenSearch ClusterStatus Red: тут просто якщо більше ніж 0:
...
- alert: OpenSearch ClusterStatus Yellow
expr: sum(aws_es_cluster_status_yellow_maximum) by (domain_name, node_id) > 0
for: 1s
labels:
severity: warning
component: backend
environment: prod
annotations:
summary: 'OpenSearch ClusterStatus Yellow status detected'
description: |-
The primary shards for all indexes are allocated to nodes in the cluster, but replica shards for at least one index are not
*OpenSearch Doman*: `{{ "{{" }} $labels.domain_name }}`
grafana_opensearch_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/b2d2dabd-a6b4-4a8a-b795-270b3e200a2e/aws-opensearch-cluster-cloudwatch'
- alert: OpenSearch ClusterStatus Red
expr: sum(aws_es_cluster_status_red_maximum) by (domain_name, node_id) > 0
for: 1s
labels:
severity: critical
component: backend
environment: prod
annotations:
summary: 'OpenSearch ClusterStatus RED status detected!'
description: |-
The primary and replica shards for at least one index are not allocated to nodes in the cluster
*OpenSearch Doman*: `{{ "{{" }} $labels.domain_name }}`
grafana_opensearch_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/b2d2dabd-a6b4-4a8a-b795-270b3e200a2e/aws-opensearch-cluster-cloudwatch'
...
Через labels у нас реалізований роутинг алертів в Opsgenie до потрібних каналів Slack, а анотація grafana_opensearch_overview_url використовуються для додавання лінки на Grafana в повідомленні в Slack:
OpenSearch CPUHigh – якщо більше 20% протягом 10 хвилин:
Тепер напишемо Terraform code для створення кластера, юзерів та індексів.
Створювати кластер будемо в VPC, для аутентифікації використаємо internal user database.
А в VPC не можна… Бо – suprize! – AWS Bedrock вимагає OpenSeach Managed кластер саме Public, а не в VPC.
The OpenSearch Managed Cluster you provided is not supported because it is VPC protected. Your cluster must be behind a public network.
Писав в сапорт, сказали, що:
However, there is an ongoing product feature request (PFR) to have Bedrock KnowledgeBases support provisioned Open Search clusters in VPC.
І пропонують використати Amazon OpenSearch Serverless, з якого ми власне і тікаємо, бо ціни дурні.
Друга проблема, яка виявилась, коли я почав писати ресурси bedrockagent_knowledge_base – це те, що він не підтримує storage_configuration з type == OPENSEARCH_MANAGED, тільки Serverless.
Отже, будемо робити OpenSearch Managed Service кластер, кластер буде один, з трьома індексами – Dev/Staging/Prod.
В кластері буде три маленькі дата-ноди, а в кожному індексі – 1 primary shard та 1 репліка, бо проект маленький, даних в нашому Production індексі на AWS OpenSearch Serverless, з якого ми хочемо переїхати на AWS OpenSearch Service – зараз всього 2 GiB, і навряд чи в майбутньому буде дуже багато.
Було б добре кластер зробити у власному Terraform модулі аби простіше створювати якісь тестові оточення, як в мене це зроблено для AWS EKS – але поки не дуже є на це час, тому робимо просто tf-файлами з окремим prod.tfvars для змінних.
Може, потім напишу окремо по переносу у власний модуль, бо це дійсно зручно.
І в наступній частині – поговоримо про моніторинг, бо наш Production вже разок падав 🙂
В data.tf збираємо дані AWS Account ID, Availability Zones, VPC та приватні subnets, в яких будемо створювати кластер в яких колись потім будемо створювати кластер:
data "aws_caller_identity" "current" {}
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_vpc" "eks_vpc" {
id = var.vpc_id
}
data "aws_subnets" "private" {
filter {
name = "vpc-id"
values = [var.vpc_id]
}
tags = {
subnet-type = "private"
}
}
Файл variables.tf з нашими дефолтними змінними, потім будемо додавати нові:
variable "aws_region" {
type = string
}
variable "project_name" {
description = "A project name to be used in resources"
type = string
}
variable "component" {
description = "A team using this project (backend, web, ios, data, devops)"
type = string
}
variable "environment" {
description = "Dev/Prod, will be used in AWS resources Name tag, and resources names"
type = string
}
variable "vpc_id" {
type = string
description = "A VPC ID to be used to create OpenSearch cluster and its Nodes"
}
Значення змінних передаємо через окремий prod.tfvars, потім, при потребі, можна буде створити нове оточення через файл типу envs/test/test.tfvars:
У нас тут ще буде AWS Bedrock, якому треба буде налаштувати доступ – аде це зробимо через його IAM Role, і про Bedrock тут писати не буду – бо і тема окрема, і в Terraform поки що нема підтримки OPENSEARCH_MANAGED, тому ми зробили його руками, а потім виконаємо terraform import.
Індекси, юзерів для нашого Backend API та Bedrock IAM Role mappings будемо робити в internal database самого OpenSearch через Terraform OpenSearch Provider аби не морочитись з доступами до дашборди.
Більша частина locals буде саме тут, але деякі, які зовсім вже “локальні” до якогось коду – будуть у файлах з кодом ресурсів.
Додаємо файл opensearcth_users.tf – поки тут тільки рутовий юзер, пароль зберігаємо в AWS Parameter Store (замість AWS Secrets Manager – “так історично склалося”):
############
### ROOT ###
############
# generate root password
# waiting for write-only: https://github.com/hashicorp/terraform-provider-aws/pull/43621
# then will update it with the ephemeral type
resource "random_password" "os_master_password" {
length = 16
special = true
}
# store the root password in AWS Parameter Store
resource "aws_ssm_parameter" "os_master_password" {
name = "/${var.environment}/${local.env_name}-root-password"
description = "OpenSearch cluster master password"
type = "SecureString"
value = random_password.os_master_password.result
overwrite = true
tier = "Standard"
lifecycle {
ignore_changes = [value] # to prevent diff every time password is regenerated
}
}
data "aws_ssm_parameter" "os_master_password" {
name = "/${var.environment}/${local.env_name}-root-password"
with_decryption = true
depends_on = [aws_ssm_parameter.os_master_password]
}
Пишемо файл opensearch_cluster.tf.
Я тут залишив конфіг для VPC, і на майбутнє, і просто для прикладу, хоча перенести вже створений кластер у VPC не можна буде – доведеться створювати новий, див. Limitations в документації Launching your Amazon OpenSearch Service domains within a VPC:
module "opensearch" {
source = "terraform-aws-modules/opensearch/aws"
version = "~> 2.0.0"
# enable Fine-grained access control
# by using the internal user database, we'll simply access to the Dashboards
# for backend API Kubernetes Pods, will use Kubernetes Secrets with username:password from AWS Parameter Store
advanced_security_options = {
enabled = true
anonymous_auth_enabled = false
internal_user_database_enabled = true
master_user_options = {
master_user_name = "os_root"
master_user_password = data.aws_ssm_parameter.os_master_password.value
}
}
# can't be used with t3 instances
auto_tune_options = {
desired_state = "DISABLED"
}
# have three data nodes - t3.small.search nodes in two AZs
# will use 3 indexes - dev/stage/prod with 1 shard and 1 replica each
cluster_config = {
instance_count = var.cluser_options.instance_count
dedicated_master_enabled = false
instance_type = var.cluser_options.instance_type
# put both data-nodes in different AZs
zone_awareness_config = {
availability_zone_count = 2
}
zone_awareness_enabled = true
}
# the cluster's name
# 'atlas-kb-prod'
domain_name = "${local.env_name}-cluster"
# 50 GiB for each Data Node
ebs_options = {
ebs_enabled = true
volume_type = var.cluser_options.volume_type
volume_size = var.cluser_options.volume_size
}
encrypt_at_rest = {
enabled = true
}
# latest for today:
# https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html#choosing-version
engine_version = var.cluser_options.engine_version
# enable CloudWatch logs for Index and Search slow logs
# TODO: collect to VictoriaLogs or Loki, and create metrics and alerts
log_publishing_options = [
{ log_type = "INDEX_SLOW_LOGS" },
{ log_type = "SEARCH_SLOW_LOGS" },
]
ip_address_type = "ipv4"
node_to_node_encryption = {
enabled = true
}
# allow minor version updates automatically
# will be performed during off-peak windows
software_update_options = {
auto_software_update_enabled = var.cluser_options.auto_software_update_enabled
}
# DO NOT use 'atlas-vpc-ops' VPC and its private subnets
# > "The OpenSearch Managed Cluster you provided is not supported because it is VPC protected. Your cluster must be behind a public network."
# vpc_options = {
# subnet_ids = data.aws_subnets.private.ids
# }
# # VPC endpoint to access from Kubernetes Pods
# vpc_endpoints = {
# one = {
# subnet_ids = data.aws_subnets.private.ids
# }
# }
# Security Group rules to allow access from the VPC only
# security_group_rules = {
# ingress_443 = {
# type = "ingress"
# description = "HTTPS access from VPC"
# from_port = 443
# to_port = 443
# ip_protocol = "tcp"
# cidr_ipv4 = data.aws_vpc.ops_vpc.cidr_block
# }
# }
# Access policy
# necessary to allow access for AWS user to the Dashboards
access_policy_statements = [
{
effect = "Allow"
principals = [{
type = "*"
identifiers = ["*"]
}]
actions = ["es:*"]
}
]
# 'atlas-kb-ops-os-cluster'
tags = {
Name = "${var.project_name}-${var.environment}-os-cluster"
}
}
В принципі, тут все в коментах описано, але кратко:
три дата-ноди, кожна з 50 гіг дисків, в різних Availability Zones
включаємо логи в CloudWatch
кластер робимо в приватних сабнетах
в Domain Access Policy дозволяємо доступ для всіх
ну – поки так… Security Groups ми використати не можемо, бо не в VPC, а створити IP-Based policy – як? ми ж не знаємо CIDR Bedrock
в принципі, тут в principals.identifiers можна додати ліміт на наших IAM Users + Bedrock AIM Role, бо вона буде одна
Запускаємо створення кластера і йдемо пити чай.
Налаштування Custom endpoint
Після створення кластеру перевіряємо доступ до дашборди, якщо все ОК – то додаємо Custom endpoint.
Note: з Custom endpoint свої приколи: в Terraform OpenSearch Provider треба використовувати саме Custom endpoint URL, але в AWS Bedrock Knowledge Base – дефолтний URL кластеру
Для цього нам треба зробити сертифікат в AWS Certificate Manager і додати новий запис в Route53.
Я тут очікував можливу проблему куриця і яйця, бо налаштування Custom Endpoint залежать від AWS ACM і запису в AWS Route53, а запис в AWS Route53 буде залежати від кластеру – бо використовує його ендпоінт.
Але ні, якщо робити новий кластер з налаштуваннями, які описав нижче – все нормально створюється: спочатку сертифікат в AWS ACM, потім кластер з Custom Endpoint, потім запис в Route53 з CNAME на cluster default URL.
output "vpc_id" {
value = var.vpc_id
}
output "cluster_arn" {
value = module.opensearch.domain_arn
}
output "opensearch_domain_endpoint_cluster" {
value = "https://${module.opensearch.domain_endpoint}"
}
output "opensearch_domain_endpoint_custom" {
value = "https://${local.os_custom_domain_name}"
}
output "opensearch_root_username" {
value = "os_root"
}
output "opensearch_root_user_password_secret_name" {
value = "/${var.environment}/${local.env_name}-root-password"
}
Створення OpenSearch Users
Власне, що нам залишилось – це користувачі і індекси.
Юзерів у нас буде два типи:
звичайні юзери з OpenSearch internal database – для нашого Backend API в Kubernetes (насправді, потім ми все ж перейшли на IAM Roles, які мапляться в поди Backend через EKS Pod Identities)
і юзери (IAM Role) для Bedrock – там буде три Knowledge Bases, кожна зі своєю IAM Role, для якої треба буде додати OpenSearch Role і зробити mapping на IAM-ролі
Почнемо зі звичайних юзерів.
Додаємо провайдера, в мене це у файлі versions.tf:
Якщо кластер всеж створений в VPC – то потрібен підключений VPN, аби провайдер зміг підключитись до кластеру.
До variables.tf додаємо list() зі списком оточень:
...
variable "app_environments" {
type = list(string)
description = "The Application's environments, to be used to created Dev/Staging/Prod DynamoDB tables, etc"
}
Спершу я планував просто використовувати локальних юзерів, і в цей пост записав такий варіант – нехай буде. Далі покажу, як все ж зробили потім – з IAM Users та IAM Roles.
У файлі opensearch_users.tf додаємо в циклах три паролі, трьох юзерів, і три ролі, на які мапимо юзерів – кожна роль з доступом до власного індексу:
...
##############
### KRAKEN ###
##############
resource "random_password" "os_kraken_password" {
for_each = toset(var.app_environments)
length = 16
special = true
}
# store the root password in AWS Parameter Store
resource "aws_ssm_parameter" "os_kraken_password" {
for_each = toset(var.app_environments)
name = "/${var.environment}/${local.env_name}-kraken-${each.key}-password"
description = "OpenSearch cluster Backend Dev password"
type = "SecureString"
value = random_password.os_kraken_password[each.key].result
overwrite = true
tier = "Standard"
lifecycle {
ignore_changes = [value] # to prevent diff every time password is regenerated
}
}
# Create a user
resource "opensearch_user" "os_kraken_user" {
for_each = toset(var.app_environments)
username = "os_kraken_${each.key}"
password = random_password.os_kraken_password[each.key].result
description = "Backend EKS ${each.key} user"
depends_on = [module.opensearch]
}
# And a full user, role and role mapping example:
resource "opensearch_role" "os_kraken_role" {
for_each = toset(var.app_environments)
role_name = "os_kraken_${each.key}_role"
description = "Backend EKS ${each.key} role"
cluster_permissions = [
"indices:data/read/msearch",
"indices:data/write/bulk*",
"indices:data/read/mget*"
]
index_permissions {
index_patterns = ["kraken-kb-index-${each.key}"]
allowed_actions = ["*"]
}
depends_on = [module.opensearch]
}
В cluster_permissions додаємо дозволи, які потрібні і для index level, і для cluster level, бо Bedrock без них не працював, див. Cluster wide index permissions.
Деплоїмо, перевіряємо в Dashboards:
Додавання IAM Users
Тут ідея така сама, просто замість звичайних юзерів з логіном:паролем для аутентифікації використовується IAM та його Users && Roles.
Про роль для Bedrock далі, а зараз додамо мапінг юзерів.
Що нам треба – це взяти список наших Backend team юзерів, дати їм IAM Policy з доступом до OpenSearch, а потім в OpnSearch internal users database додати мапінг на локальну роль.
Note: ChatGPT вперто казав додавати IAM Users в Backend Roles, але ні, і це явно вказано в документації – додавати треба в Users, див. Additional master users.
І всім IAM Users треба додати IAM-політику з доступом.
Створення AWS Bedrock IAM Roles та OpenSearch Role mappings
Bedrock у нас вже є, треба просто створити нові IAM Roles і замапити їх до OpenSeach Roles.
Додаємо файл iam.tf – описуємо IAM Role та IAM Policy (Identity-based Policy для доступу до OpenSearch), тут також в циклі по кожному з var.app_environmetns:
#####################################
### MAIN ROLE FOR KNOWLEDGE BASE ###
#####################################
# grants permissions for AWS Bedrock to interact with other AWS services
resource "aws_iam_role" "knowledge_base_role" {
for_each = toset(var.app_environments)
name = "${var.project_name}-role-${each.key}-managed"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "bedrock.amazonaws.com"
}
Condition = {
StringEquals = {
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
}
ArnLike = {
# restricts the role to be assumed only by Bedrock knowledge base in the specified region
"aws:SourceArn" = "arn:aws:bedrock:${var.aws_region}:${data.aws_caller_identity.current.account_id}:knowledge-base/*"
}
}
}
]
})
}
# IAM policy for Knowledge Base to access OpenSearch Managed
resource "aws_iam_policy" "knowledge_base_opensearch_policy" {
for_each = toset(var.app_environments)
name = "${var.project_name}-kb-opensearch-policy-${each.key}-managed"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"es:*",
]
Resource = [
module.opensearch.domain_arn,
"${module.opensearch.domain_arn}/*"
]
}
]
})
}
resource "aws_iam_role_policy_attachment" "knowledge_base_opensearch" {
for_each = toset(var.app_environments)
role = aws_iam_role.knowledge_base_role[each.key].name
policy_arn = aws_iam_policy.knowledge_base_opensearch_policy[each.key].arn
}
Далі в opensearch_users.tf створимо:
opensearch_role: з cluster_permissions та index_permissions на кожен індекс
locals з усіма IAM Roles, які створили вище
і opensearch_roles_mapping для кожної opensearch_role.os_bedrock_roles, які через backend_roles додаємо до кожної opensearch_role
Власне, саме тут зіткнулись з помилками доступу Bedrock, через які довелось додавати cluster_permissions:
The knowledge base storage configuration provided is invalid… Request failed: [security_exception] no permissions for [indices:data/read/msearch] and User [name=arn:aws:iam::492***148:role/kraken-kb-role-dev, backend_roles=[arn:aws:iam::492***148:role/kraken-kb-role-dev], requestedTenant=null]
Створюємо файл opensearch_indexes.tf. І додаємо сам індекси – тут я все ж вирішив без циклу, прямо створити окремі Dev/Staging/Prod:
# Dev Index
resource "opensearch_index" "kb_vector_index_dev" {
name = "kraken-kb-index-dev"
# enable approximate nearest neighbor search by setting index_knn to true
index_knn = true
index_knn_algo_param_ef_search = "512"
number_of_shards = "1"
number_of_replicas = "1"
mappings = local.os_index_mappings
# When new documents are ingested into the Knowledge Base,
# OpenSearch automatically creates field mappings for new metadata fields under
# AMAZON_BEDROCK_METADATA. Since these fields are created outside of TF resource definitions,
# TF detects them as configuration drift and attempts to recreate the index to match its
# known state.
#
# This lifecycle rule prevents unnecessary index recreation by ignoring mapping changes
# that occur after initial deployment.
lifecycle {
ignore_changes = [mappings]
}
}
...
В Terraform ephemeral resources та write-only arguments з’явились давно, ще у версії 1.10, але не було нагоди про них написати детальніше.
Основна ідея їх – не залишати “слідів” в state-файлі, що особливо корисно для паролів або токенів, бо дані існують тільки під час виконання apply самого Terraform в його пам’яті.
Втім, для їх використання є певні обмеження – далі на них глянемо, але спочатку подивимось на все в дії.
Приклад без ephemeral values та write-only arguments
Почнемо зі старої схеми, без використання ephemeral resources та write-only arguments – створимо рандомний пароль, ресурс aws_secretsmanager_secret, в ньому збережемо цей пароль, і отримаємо його з data:
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
component = "devops"
created-by = "terraform"
environment = "test"
}
}
}
### RESOURCES ###
# generate a random password
resource "random_password" "test_random_password" {
length = 8
special = false
}
# create an AWS Secret resource
resource "aws_secretsmanager_secret" "test_aws_secret" {
name = "db_password"
description = "database passsword"
recovery_window_in_days = 0
}
# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
secret_id = aws_secretsmanager_secret.test_aws_secret.id
secret_string = random_password.test_random_password.result
}
### DATA SOURCES ###
# retrieve the AWS Secret value
data "aws_secretsmanager_secret_version" "test_aws_secret_data" {
secret_id = aws_secretsmanager_secret.test_aws_secret.id
depends_on = [aws_secretsmanager_secret_version.test_aws_secret_version]
}
### OUTPUTS ###
# get the random password value
output test_random_password {
value = random_password.test_random_password.result
sensitive = true
}
# get the AWS Secret value
output "test_aws_secret" {
value = data.aws_secretsmanager_secret_version.test_aws_secret_data.secret_string
sensitive = true
}
Тут ми:
resource "random_password": генеруємо сам пароль
resource "aws_secretsmanager_secret": створюємо новий запис в AWS Secrets Manager
resource "aws_secretsmanager_secret_version": записуємо в цей Secret значення із resource "random_password"
data "aws_secretsmanager_secret_version": отримуємо значення з AWS Secrets Manager
output "test_random_password": виводимо значення із resource "random_password"
output "test_aws_secret": виводимо значення, отримане з AWS Secrets Manager
Атрибути ресурсів, які мають суфікс _wo є “write-only” даними, тобто Terraform їх тримає в пам’яті під час виконання операцій, але ніде в себе не зберігає.
Втім, таки атрибути підтримуються далеко не всіма ресурсами. Наприклад, в AWS RDS через ресурс aws_db_instance можна передати пароль через атрибут password_wo, а в aws_opensearch_domain і його master_user_password для створення root-юзера в internal user database – (поки що) ні.
aws_secretsmanager_secret_version теж підтримує write-only attributes – secret_string_wo замість secret_string, і secret_string_wo_version замість secret_string_version.
Використання secret_string_wo_version обов’язкове при secret_string_wo, бо так як Terraform не зберігає інформацію про пароль – то він не буде знати, коли його треба оновити. Для цього задаємо версію, яку інкрементимо кожен раз, коли хочемо оновити пароль.
Редагуємо наш код, тільки resource "aws_secretsmanager_secret_version" – задаємо secret_string_wo і secret_string_wo_version, решту залишаємо без змін:
Тепер у нас в managed.aws_secretsmanager_secret_version.test_aws_secret_version немає значень для secret_string та secret_string_wo.
Використання Ephemeral resources
Ідея “ефемерних” ресурсів така ж, як і з write-only arguments – ці ресурси існують тільки в пам’яті Terraform під час виконання terraform apply і не зберігаються в state file.
Редагуємо наш код і міняємо resource "random_password" на ephemeral "random_password", resource "aws_secretsmanager_secret_version" залишаємо – він пароль запише в AWS Secrets Manager, але не зберігає значення в state, і додаємо новий ресурс – ephemeral "aws_secretsmanager_secret_version", через який ми цей пароль отримаємо назад в Terraform.
При цьому в secret_string_wo і в output "test_random_password" ми тепер посилаємось на пароль через ephemeral – ephemeral.random_password.test_random_password.result.
І в output "test_aws_secret" теж використовуємо ephemeral.aws_secretsmanager_secret_version.test_aws_secret_data.secret_string.
data "aws_secretsmanager_secret_version" можемо прибирати, бо пароль ми тепер отримаємо саме з ephemeral "aws_secretsmanager_secret_version":
...
### RESOURCES ###
# generate a random password
ephemeral "random_password" "test_random_password" {
length = 8
special = false
}
# create an AWS Secret resource
resource "aws_secretsmanager_secret" "test_aws_secret" {
name = "db_password"
description = "database passsword"
recovery_window_in_days = 0
}
# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
secret_id = aws_secretsmanager_secret.test_aws_secret.id
#secret_string = random_password.test_random_password.result
secret_string_wo = ephemeral.random_password.test_random_password.result
secret_string_wo_version = 1
}
### DATA SOURCES ###
# Retrieve the password from Secrets Manager (ephemeral)
ephemeral "aws_secretsmanager_secret_version" "test_aws_secret_version_ephemeral" {
secret_id = aws_secretsmanager_secret.test_aws_secret.id
}
# retrieve the AWS Secret value
# data "aws_secretsmanager_secret_version" "test_aws_secret_data" {
# secret_id = aws_secretsmanager_secret.test_aws_secret.id
# depends_on = [aws_secretsmanager_secret_version.test_aws_secret_version]
# }
### OUTPUTS ###
# get the random password value
output test_random_password {
value = ephemeral.random_password.test_random_password.result
sensitive = true
}
# get the AWS Secret value
output "test_aws_secret" {
value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
sensitive = true
}
Помилка “This output value is not declared as returning an ephemeral value”
Виконуємо terraform apply, і ловимо першу помилку:
...
│ Error: Ephemeral value not allowed
│
│ on main.tf line 53, in output "test_random_password":
│ 53: value = ephemeral.random_password.test_random_password.result
│
│ This output value is not declared as returning an ephemeral value, so it cannot be set to a result derived from an ephemeral value.
╵
╷
│ Error: Ephemeral value not allowed
│
│ on main.tf line 59, in output "test_aws_secret":
│ 59: value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
│
│ This output value is not declared as returning an ephemeral value, so it cannot be set to a result derived from an ephemeral value.
Але навіть якщо ми додамо параметр ephemeral = true:
...
### OUTPUTS ###
# get the random password value
output test_random_password {
value = ephemeral.random_password.test_random_password.result
sensitive = true
ephemeral = true
}
# get the AWS Secret value
output "test_aws_secret" {
value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
sensitive = true
ephemeral = true
}
То це все одно працювати не буде.
Помилка “Ephemeral outputs are not allowed in context of a root module”
Тепер помилка буде виглядати так:
...
╷
│ Error: Ephemeral output not allowed
│
│ on main.tf line 52:
│ 52: output test_random_password {
│
│ Ephemeral outputs are not allowed in context of a root module
╵
╷
│ Error: Ephemeral output not allowed
│
│ on main.tf line 59:
│ 59: output "test_aws_secret" {
│
│ Ephemeral outputs are not allowed in context of a root module
Бо використання Ephemeral outputs можливе тільки в модулях – далі глянемо, як саме.
ОК – поки просто приберемо Outputs, і тепер terraform apply проходить без проблем:
Вище ми пробували використати output "test_aws_secret" з ephemeral = true, але отримали помилку “Ephemeral outputs are not allowed in context of a root module“.
В попередній частині – AWS: знайомство з OpenSearch Service в ролі vector store – подивились на AWS OpenSearch Service взагалі, трохи розібрались з тим, як в ньому організовані дані, що таке shards та nodes, і які нам власне типи інстансів для data nodes треба.
Наступний крок – створити кластер і подивитись на аутентифікацію, яка, як на мене, в чомусь навіть складніша за AWS EKS. Хоча, можливо, просто діло звички.
Що будемо робити сьогодні – вручну створимо кластер AWS OpenSearch Service, глянемо на основні опції при створенні кластеру, а потім копнемо в налаштування доступу до кластеру і до OpenSearch Dashboards з AWS IAM та Fine-grained access control самого OpenSearch і його Security plugin.
Переходимо в Amazon OpenSearch Service > Domains, клікаємо “Create domain”.
Задаємо ім’я, вибираємо “Standart create”, аби мати доступ до всіх опцій:
В “Templates” вибираємо “Dev/”test – тоді можна буде вибрати конфіг без Master Nodes і можна буде деплоїти в одній Availability Zone.
В “Deployment option(s)” вибираємо “Domain without standby” – тоді нам будуть доступні інстанси t3:
Справа нам зручненько відразу показує весь сетап.
Storage
Питання кількості шардів на кластер розбирали в попередньому пості, будемо вважати, що у нас планується даних максимум 20-30 GiB, тому будемо створювати 1 primary шард та 1 replica. Але шарди налаштовуються пізніше, коли будемо робити індекси з Terraform і opensearch_index_template.
І для цих двох шардів будемо робити дві Data Nodes – одна для primary шарду, одна для репліки.
“Instance family” вибираємо “General puprose”, в “Instance type” – t3.small.search.
“EBS storage size per node” візьмемо 50 GiB – 20-30 гігабайт під дані, і трохи запасу для самої операційної системи:
Nodes
“Number of master nodes” та “Dedicated coordinator nodes” залишаємо без змін, тобто без них:
Network
В “Custom endpoint” поки теж нічого не міняємо, але потім тут можна додати який власний домен із Route53 з сертифікатом з AWS Certificate Manager для доступу до кластеру, див. Creating a custom endpoint for Amazon OpenSearch Service.
В “Network” – поки робимо найпростіший варіант, з “Public access”, але для Production будемо робити всередині VPC:
Але треба буде потестити доступ до Dashboards, бо якщо кластер створюється в сабнетах VPC, то до нього не можна застосувати IP-based policies, див. About access policies on VPC domains. Про IP-based policies будемо говорити тут далі.
Access && permissions
“Fine-grained access control” (FGAC) – поки відключаємо, далі детальніше подивимось на цей механізм. Хоча я не впевнений, що він буде потрібен, бо розділити доступ до різних індексів в одному кластері можна і просто з IAM.
SAML, JWT та IAM Identity Center залежать від FGAC, тому теж скіпаємо, і надалі я їх використовувати не планую, не наш кейс.
Cognito теж мимо – ми ним не користуємось (хоча пізніше, можливо, подивлюсь в сторону інтеграції з Auth0 чи Cognito для Dashboards):
“Access policy” можна порівняти з S3 Access Policy, або з IAM Policy для EKS яка дозволяє IAM-юзеру доступ до кластеру.
Детальніше поговоримо в частині про аутентифікацію, поки просто залишаємо дефолтний “Do not set domain level access policy”:
“Off-peak window” – час найменшого навантаження для встановлення апдейтів і виконання Auto-tune операцій.
У нас off-peak буде вночі по США, тому в Production тут буде Central Time (CT) 05:00 UTC.
Але так як зараз тестовий PoC – то теж скіпаємо.
Auto-Tune власне теж нормально описана, і недоступна для наших інстансів t3.
Automatic software update – корисна штука для Production, і буде виконуватись в час, заданий в Off-peak window:
В “Advanced cluster settings” можна відключити rest.action.multi.allow_explicit_index, але не знаю, як у нас будуть будуватись запити, і начебто десь зустрічав, що може поламати Dashboard – тому нехай залишиться дефолтне enabled:
Ну і все, в результаті маємо такий сетап:
Клікаємо “Create”, і йдемо пити чай, бо створюється кластер довго – довше, ніж EKS, і створення OpenSearch зайняло хвилин 20.
Аутентифікація та авторизація
Тепер, мабуть, саме цікаве – про юзерів і доступи.
Після створення кластера по дефолту ми маємо обмежені права доступу до самого OpenSearch API:
Бо в “Security Configuration” у нас є явний Deny:
Доступ до AWS OpenSearch Service має три таких собі “рівня” – мережа, IAM, та Security Plugin самого OpenSearch.
При цьому в IAM у нас є дві сутності – Domain Access Policy, який ми бачимо в Security Configuration > Access Policy (атрибут access_policies в Terraform), та Identity-based policies – які є звичайними AWS IAM Policies.
Якщо говорити про ці рівні більш детально, то вони виглядають якось так:
або, якщо брати аналогію з EKS – То це Public та Private API endpoint, або з RDS – створювати інстанс в публічних чи приватних сабнетах
AWS IAM:
Domain Access Policies:
Resource-based policies: політики, які описуються безпосередньо в налаштуваннях самого кластеру
доступ задається для IAM Role, IAM User, AWS Accounts до конкретного OpenSearch domain
IP-based policies: фактично ті самі Resource-based policies, але з можливістю дозволити доступ без аутентифікації для конкретних IP (тільки якщо тип доступу Public, див. VPC versus public domains)
Identity-based policies: якщо Resource-based policies є частиною налаштувань security-політик кластера – то Identity-based policies є звичайними AWS IAM Policies, які додаються конкретному юзеру чи ролі
якщо в Resource-based policies і Identity-based policies ми задаємо правила на рівні кластеру (домену) і індексів, то в FGAC можна додатково описати обмеження на конкретні документи або поля
і навіть якщо в Resource-based policies і Identity-based policies дозволено доступ до ресурсу в кластері – через Fine-grained access control його можна “обрізати”
Тобто authentification та authorization flow буде таким:
AWS API отримує запит від юзера, наприклад es:ESHttpGet
AWS IAM виконує аутентифікацію – перевіряє ACCESS:SECRET ключі або Session token
AWS IAM виконує авторизацію:
перевіряє IAM Policy юзера (Identity-based policy), якщо тут є явний дозвіл – пропускаємо
перевіряє Domain Access Policy (Resource-based policy) кластеру, якщо тут явний дозвіл – пропускаємо
запит приходить до самого OpenSearch
якщо Fine-grained access control не включений – дозволяємо
якщо є налаштований Fine-grained access control – перевіряємо внутрішні ролі, і якщо юзеру дозволено – то виконуємо запит
Давайте робити доступи, подивимось, як воно все працює.
Налаштування Domain Access policy
Базовий варіант – додати IAM User доступ до кластеру.
Resource-based policy
Редагуємо “Access policy”, і вказуємо свого юзера, типи API-операцій, та домен:
IP-based policies та доступ до OpenSearch Dashboards
Аналогічно, через Domain Access Policy можемо відкрити доступ до Dashboards – самий простий варіант, але працює тільки з Public domains. Якщо кластер буде в VPC – то треба буде робити додаткову аутентифікацію, див. Controlling access to Dashboards.
Тобто тепер у нас є Domain Acces Policy – яка дозволяє доступ конкретно моєму юзеру, і є окрема IAM Ploicy – Identity-based policy – яка дозволяє доступ тестовому юзеру.
Але тут є один важливий момент: в IAM Policy ми вказуємо або весь домен – або тільки його subresources.
Тобто, якщо замість політики AmazonOpenSearchServiceFullAccess ми створимо власну полісі, в якій вкажемо "Resource":***:domain/test/*":
То ми зможемо виконати es:ESHttpGet (GET _cluster/health) – але не зможемо виконати cluster-level операції, наприклад – es:AddTags, навіть при тому, що в Actions IAM-політики маємо дозвіл на всі виклики – es:*:
$ aws --profile test-os opensearch add-tags --arn arn:aws:es:us-east-1:492***148:domain/test --tag-list Key=environment,Value=test
An error occurred (AccessDeniedException) when calling the AddTags operation: User: arn:aws:iam::492***148:user/test-opesearch-identity-based-policy is not authorized to perform: es:AddTags on resource: arn:aws:es:us-east-1:492***148:domain/test because no identity-based policy allows the es:AddTags action
Якщо ж ми хочемо дозволити взагалі всі операції з кластером – то "Resource" задаємо як "arn:aws:es:us-east-1:492***148:domain/test", і тоді можемо додати теги.
Юзери можуть бути як з AWS IAM, так і з внутрішньої бази OpenSearch.
Як і в Kubernetes, в OpenSearch є набір дефолтних ролей – див. Predefined roles.
При цьому ролі, як і в Kubernetes, можуть бути cluster-wide або index-specific – аналог ClusterRoleBinding та просто namespaced RoleBinding в Kubernetes, плюс в OpenSearch FGAC можна додатково мати document level або field level permissions.
Налаштування Fine-grained access control
Важливий момент: після включення FGAC не можна буде повернутись на стару схему. Але всі доступи з IAM залишаться, навіть якщо переключитись на internal database.
Спершу тут нам треба задати Master user, якого можна вказати з IAM – або створити локально в OpenSearch.
Якщо ми створюємо юзера через опцію “Create master user” – то вказуємо звичайний логін:пароль, і в такому випадку OpenSearch підключить internal user database (internal_user_database_enabled в Terraform).
Має сенс, якщо не хочеться крутити Cognito чи SAML, і якщо налаштування юзерів у кожного кластеру будуть власні.
Якщо задавати IAM-юзера, то схема буде схожою з AIM аутентифікацією для RDS і IAM database authentication – доступ до кластеру контролюється AWS IAM, але внутрішні першмішени до схем та баз – ролями PostgreSQL чи MariaDB, див. AWS: RDS з IAM database authentication, EKS Pod Identities та Terraform.
Тобто в такому випадку AWS IAM буде виконувати виключно аутентифікацію юзера, а авторизація (перевірка прав доступу) вже через Security plugin та ролі самого OpenSearch.
Спробуємо локальну базу, і, думаю, в Production ми теж візьмемо цю схему:
І зайняло це прям багато часу – більше години, при тому, що в кластері нема ніяких наших даних.
Після того як зміни застосовані – в Dashboards у нас тепер буде просити логін і пароль, використовуємо нашого Master user:
Master user отримує дві підключені ролі – all_access та security_manager.
І саме security_manager дає доступ до розділу Security та Users в дашборді:
При цьому у нас залишається доступ наших AIM-юзерів, і ми можемо далі використовувати curl: IAM users будуть мапитись на роль default_role, яка дозволяє виконувати GET/PUT на всі індекси – див. About the default_role:
Ми зараз використовуємо AWS OpenSearch Service як vector store для нашого RAG з AWS Bedrock Knowledge Base.
Про RAG і Bedrock детальніше поговоримо іншим разом, а сьогодні давайте подивимось на AWS OpenSearch Service.
Власне, задача – мігрувати наш AWS OpenSearch Service Serverless на Managed, в першу чергу через (сюрпрайз) питання вартості – бо з Serverless у нас постійно неочікувані спайки у використанні OpenSearch Compute Units (OCU – процесор, пам’ять та диск) – навіть коли нема ніяких змін у даних.
Головна задача – це спланувати розмір кластеру: диски, CPU та пам’ять, і підібрати під це типи інстансів.
Elasticsearch vs OpenSearch vs AWS OpenSearch Service
Власне, OpenSearch – це по суті той самий Elasticsearch: коли Elasticsearch у, здається, 2021 змінив умови своєї ліцензії – AWS запустила власний форк, назвавши його OpenSearch.
OpenSearch сумісний з Elasticsearch до версії 7.10, але на відміну від Elasticsearch – у OpenSearch повністю вільна ліцензія.
Про запуск Elasticsearch як частину ELK-стеку для логів колись писав тут – Elastic Stack: обзор и установка ELK на Ubuntu, але там більше про self-hosted і взагалі роботу з індексами, а тепер ми подивимось саме на рішення від AWS.
AWS OpenSearch Service – це повністю AWS-managed сервіс: як і у випадку з Kubernetes – AWS бере на себе всі задачі по деплою, апдейтам, бекапам, має тісну інтеграцію з іншими AWS-сервісами – IAM, VPC, S3, ну і Bedrock, з яким ми його і використовуємо.
AWS OpenSearch Service: знайомство
Тут і далі буду говорити в основному за Managed OpenSearch Service.
Основні концепти AWS OpenSearch Service – це домен, ноди, індекси (“бази”) та шарди (shards).
Домен – це сам кластер, який ми налаштовуємо на потрібну кількість і тип Nodes, а індекси – поділені на shards (блоки даних), які розподілені між Nodes:
Самі Nodes в кластері – по суті звичайні EC2 (як і в тому ж RDS чи навіть AWS Load Balancer), де під капотом працюють ті самі звичайні compute-інстанси.
Для кластеру AWS OpenSearch Service як і з Elastic Kubernetes Service створюються окремі control nodes (master nodes), тільки на відміну від EKS тут нам не треба окремо менеджити Data Plane та WorkerNodes.
Як і в RDS – для OpenSearch-кластеру можемо налаштувати автоматичні бекапи.
Для розуміння того, які типи інстансів нам вибрати для нашого кластеру – давайте розберемось з тим, що таке індекси в OpenSearch (або Elasticsearch, бо суть одна).
Отже, індекс – це колекція документів, які мають якісь загальні риси. У кожного індексу є унікальне ім’я – як у бази даних в RDS PostgreSQL чи MariaDB.
Хоча індекс часто порівнюють з базою даних, на практиці зручніше думати про індекс як про таблицю, а “база” – це весь кластер.
Документ – JSON-об’єкт в індексі, і являє собою базовий юніт зберігання даних. Якщо брати аналогію с тими ж базами даних – то це як рядок в таблиці.
Кожен документ має набір key-value полів, де value можуть бути string, integer, date або більш складними структурами типу масивів або object.
Індекси діляться на частини – шарди, задля кращого перформансу, де кожен шард містить частину даних індексу. Кожен документ зберігається тільки в одному шарді, а пошук може виконуватись паралельно в кілько шардах.
Хоча технічно це не дуже коректно, але про шарди можна уявляти собі як окремі міні-індекси, міні-бази.
Shards можуть бути primary, або replica: primary приймає всі write-операції і може обробляти select, а репліка – тільки для read-only операцій.
При цьому репліка завжди створюється на іншій data node – задля fault tolerance, і репліка може стати primary, якщо нода з primary-шардом впала.
Дефолтне значення кількості шард на кожен індексів в AWS OpenSearch Service – 5, але може налаштовуватись окремо (тобто, при 5 primary shards – будемо мати 10 шардів загалом, бо ще будуть репліки). А розмір шардів рекомендується мати від 10 до 50 гігабайт: кожен шард потребує CPU та пам’яті для роботи з ним, тому велика кількість маленьких шардів збільшить потребу в ресурсах, тоді як занадто великі шарди – сповільнять операції над ними.
В Open Source OpenSearch (та Elasticsearch) – primary shards по дефолту 1.
Нові документи розподіляються рівномірно між всіма наявними шардами.
Data Nodes – зберігають дані і шарди, і виконуються запити пошуку і агрегацій. Основні “робочі юніти” кластеру.
Master Nodes – зберігають metadata про індекси, mapping, стан кластеру, керують primary/replica shard-ами, виконують rebalancing – але не займаються обробкою пошукових запитів. Тобто їхня задача – виключно контроль кластера.
Coordinator nodes (client nodes) – не зберігають ніяких даних і не приймають участі в їхній обробці, роль цих нод – такий собі “проксі” між клієнтом та data nodes – приймають запит від клієнта, ділять його на підзапити (scatter), відправляють їх до відповідних data nodes, потім збирають результат (gather) і повертають його клієнту. Але окремі ноди під Coordinators бажано мати на великих кластерах, аби зняти навантаження з Master та Data nodes.
Pricing
Як і з більшістю аналогічних сервісів AWS – платимо за compute-ресурси (CPU, RAM) за диск (EBS), і за трафік – хоча трафік з нюансами (в кращу сторону) – бо для multi-AZ деплойментів ми не платимо за трафік між нодами в різних Availability Zones (в RDS, здається, також), а також не платимо за трафік між UltraWarm/Cold Nodes та AWS S3.
General Purpose SSD (gp3) EBS: $0.122 per GB / month (звичайний EBS для EC2 – $0.08/GB-month)
Аналогічно до AWS EKS – в OpenSearch Service є два типи підтримки оновлень – Standart та Extended, і, звісно, Extended буде дорожчий.
Hot, UltraWarm, Cold storage в OpenSearch Service
Зберігання даних (індексів) в OpenSearch Service може бути організовано або на EBS на самій дата-ноді (Hot), аде закешовано на ноді з “бекендом” в S3 (UltraWarm), або тільки в S3 (Cold):
Hot storage: звичайні data-nodes на звичайних EC2 з EBS – для найбільш актуальних даних, дає швидкий доступ до даних
UltraWarm storage: для все ще актуальних, але не часто потрібних даних – дані зберігаються в S3, а на нодах зберігається їхній кеш, при цьому самі ноди – окремий тип інстансів типу ultrawarm1.medium.search
швидкий доступ до даних, які є в кеші, повільніший до даних, до яких довго не звертались
самі ноди дорожчі (ultrawarm1.medium.search буде коштувати $0.238), але економія за рахунок збереження даних в S3 замість EBS
дані read-only
недоступне, якщо в кластері T2 або T3 інстанси 🙁
Cold storage: ці дані зберігаються виключно в S3, а доступ до них можливий через API OpenSerach Service
повільний доступ, але тут платимо тільки за S3
для використання треба мати налаштований Warm storage
аналогічно – недоступне, якщо в кластері T2 або T3 інстанси 🙁
Автоматичні бекапи – безкоштовні, зберігаються 14 днів.
Ручні – платимо за S3, але не платимо за трафік для їх збереження.
Планування AWS OpenSearch Service domain
ОК, з основними деталями наче розібрались – давайте подумаємо про те, як ми будемо робити кластер – його capacity plainning і вибір типів інстансів для Data Nodes.
Storage
Вибір розміру дисків
Дуже важливий момент, з якого треба починати – це визначити скільки місця буде займати ваш індекс чи індекси.
Наприклад, у нас буде 3 дата-ноди, зберігати будемо якісь логи.
На день записуємо 10 GiB логів, які зберігаємо 30 днів – в результаті отримуємо 300 гігабайт зайнятого місця. Маючи три ноди – це 100 гіг на кожну ноду.
Але при цьому нам треба враховувати:є
Number of replicas: кожна replica shard – це копія primary shard, відповідно буде займати приблизно стільки ж місця
OpenSearch indexing overhead: OpenSarch займає додаткове місце під власні індекси: це ще +10% від розміру самих даних
Operating system reserved space: 5% місця на EBS резервується операційною системою
OpenSearch Service overhead: і ще 20% – але не більше 20 гігабайт – резервується на кожній ноді самим OpenSearch Service для власної роботи
По останньому пункту в документації є цікаве уточнення:
якщо маємо 3 ноди, у кожної 500 гіг диск – то разом будемо мати 1.5 терабайти, при цьому загальний максимальний розмір зарезервованого місця для OpenSearch буде 60 ГБ – по 20 на кожну ноду
якщо маємо 10 нод і у кожної буде 100 гіг диск – то разом буде 1 Терабайт, але при цьому максимальний розмір зарезервованого місця для OpenSearch буде 200 ГБ – по 20 на кожну ноду
Формула розрахунку місця виглядає так:
Source data * (1 + number of replicas) * (1 + indexing overhead) / (1 - Linux reserved space) / (1 - OpenSearch Service overhead) = minimum storage requirement
Тобто, маючи потребу зберігати 300 ГБ логів – рахуємо:
Source data: 300 GiB
1 primary + 1 replica
1 + indexing overhead = 1.1 (+10% від 1)
1 – Linux reserved space = 0.95 (5%)
1 – OpenSearch Service overhead = 0.8 (але це вірно якщо диски менше ніж 100 ГБ)
В такому випадку для наших 300 GiB логів нам потрібно:
300*2*1.1/0.95/0.8
867
867 GiB загального місця.
Або там жеж є простіша формула – просто використати коефіцієнт 1.45:
Source data * (1 + number of replicas) * 1.45 = minimum storage requirement
В чому суть: в AWS OpenSearch Service індекс по дефолту розбивається на 5 primary-шардів без реплік (в self-hosted Elasticsearch/OpenSearch дефолт 1 primary та 1 replica).
Після створення індексу просто так змінити кількість шард не можна, бо роутинг запитів до документів прив’язаний саме до конкретних shards (ось тут непогано описано – Distributing Documents across Shards (Routing)).
При цьому рекомендований розмір шардів – 10-30 GiB для даних, де більше пошуку, і 30-50 – для індексів, де більше wrtie-операцій.
До розміру самого індексу ще треба додавати indexing overhead, про який говорили вище – 10%.
Якщо брати до уваги кейс, де ми пишемо логи (тобто, write intesive workload), і максимальний розмір індексу буде 300 GiB + 10% == 330 GiB.
Якщо ми хочемо мати primary шарди скажімо в 30 гігабайт – то отримуємо 11 primary shards.
Зміна кількості primary shards потребує створення нового індексу і виконання reindex – копіювання даних зі старого індексу в новий, див. Optimize OpenSearch index shard sizes.
Якщо індекс планується маленьким – то краще мати один шард + 1 репліка, інакше кластер буде створювати зайві порожні shard-и, які все одно споживають ресурси.
При цьому все одно рекомендується мати три ноди: на одній буде primary-шард, на другій – replica, а третя буде резервною:
якщо нода-1 з primary впаде – то нода-2 зробить replica новим primary
а нода-3 отримає нову replica
Вибір типу Data Nodes
Ще один важливий момент – як вибрати правильний тип data-нод?
Що нам треба розуміти для вибору ноди – це потреби в CPU, в RAM, та диск.
А от тепер саме цікаве – як порахувати потрібну пам’ять?
Тут розрахунки будуть дуже залежати від того, який саме індекс, дані будуть – просто документи у вигляді логів, або, як в нашому випадку, це буде vector store.
Перш ніж будемо рахувати потреби – кратко подивимось як взагалі розподіляється пам’ять на інстансі:
JVM Heap Size: по дефолту задається у 50% RAM (але не більше 32 гігабайт): в JVM Heap у нас будуть різні власні дані OpenSearch – метадані та керування шардом/індексом (мапінги, routing, стан кластера), об’єкти запитів і відповідей, координація пошуку, різні внутрішні кеши та буфери – тобто, чисто внутрішні потреби самого OpenSeach
off-heap memory (пам’ять самої операційної системи):
у випадку використання індексу як vector-store – графи HNSW (k-NN search) + Linux page cache для даних, які з диску завантажуються в пам’ять ОС для швидкого доступу
у випадку простих логів – тільки Linux page cache для даних, які з диску завантажуються в пам’ять ОС
Розрахунок RAM для логів
Плануємо JVM Heap в 16 гіг, пам’ятаючи, що це буде 50%. Ну, або взяти хоча б 8, і потім прослідкувати за JVMMemoryPressure.
Далі прикидуємо пам’ять під off-heap – Linux буде робити mmap актуальних для обробки запитів даних (зчитувати блоки даних в диску в пам’ять, коли процес їх запросить).
Тут у нас будуть “гарячі дані” – тобто дані, які часто потрібні клієнтам. Наприклад, знаємо, що найчастіше шукати в логах будемо за останні 24 години, і на добу пишемо 10 гігабайт логів разом.
До цих 10 ГБ варто додати 10-50 відсотків на структури самого OpenSearch, тож в результаті індекс буде рости на 11-15 ГБ в день.
З цих 11-12 гігабайт нехай 50% будуть активно використовуватись для результатів пошуку – записуємо собі 5-6 GiB RAM під “гарячий OS page cache”.
Розрахунок RAM для vector store
Якщо ж ми використовуємо OpenSearch як векторну базу, то нам треба враховувати потребу в пам’яті під кожен граф для пошуку даних.
Для того, аби прикинути скільки пам’яті буде займати структура HNSW – нам треба знати кількість векторів в індексу, їхній dimension (розмірність ембедінгу), та кількість зв’язків між кожною нодою в графі (скільки сусідів зберігати для кожної точки в цьому графі).
Що взагалі у нас у “векторі”?
набір чисел, заданий в dimension embedding-моделі ([0.12, -0.88, ...])
metadata: різні key_value з інформацію до якого документа цей вектор належить, source, і так далі
опціонально – сам оригінальний текст (поле _source – не впливає на граф, але збільшує розмір індексу)
id: "doc1-chunk1"
knn_vector: [0.12, -0.33, ...] // number set by dimension parameter
metadata: {doc_id: "doc1", chunk: 1, text: "some text"}
RAG, AWS Bedrock Knowlege Base, дані, та створення векторів
Як виглядає процес роботи RAG в цілому, і місце векторної бази в ньому:
клієнт (наприклад, мобільна апка) робить запит до нашого Backend API, який працює в Kubernetes
Backend API отримує його, і генерує запит RetrieveAndGenerate до Bedrock, в якому передається Knowledge Base ID та текст запиту від клієнта
Bedrock запускає RAG pipeline, в якому:
відправляє запит до embedding-моделі, аби перетворити його на вектор(и)
сам виконує k-NN пошук в OpenSearch-індексі, аби знайти максимально релевантні дані
формує розширений промпт, який містить в собі оригінальний запит + дані, які йому повернув OpenSearch
викликає GenAI модель, якій передає цей розширений промпт
отримує від неї відповідь
повертає її у вигляді JSON до нашого Backend API
Backend API відправляє отриманий результат клієнту
Як виглядає процес перетворення тексту у вектори в AWS Bedrock Knowledge Base:
маємо якийсь source – наприклад, txt-файл в S3
Bedrock його зчитує, і якщо він великий – ділить його на chunks з розміром, заданим в параметрах Bedrock
Bedrock кожен чанк тексту передається до embdedding LLM-model, яка перетворює цей чанк у вектор фіксованої довжини (dimension), і повертає до Bedrock pipeline
Bedrock відправляє цей вектор разом з метаданими до AWS OpenSearch vector store, де він індексується для k-NN пошуку
Кількість векторів
Кількість векторів в індексі в першу чергу залежить від корпусу даних (розмір всіх вхідних даних, з якими ми працюємо), і на скільки чанків вони будуть поділені.
Що варто розуміти: вектори створюються не для окремих токенів, а для частин тексту, для цілих фраз.
У кожної ембедінг-моделі є ліміт на кількість токенів, які вона може обробити за раз (максимальна “довжина входу”).
Якщо текст довгий – то він розбивається на частини (chunks), і для кожного такого чанку створюється власний вектор.
Якщо візьмемо для прикладу ембедінг-модель з лімітом в 512 токенів і розмірністю (dimnestion, d) в 1024 чисел – то:
фраза “hello, world” – влазить в одне “вікно” для ембедінгу, буде створений 1 вектор
абзац англійськими текстом в 300 слів дасть приблизно 400 токенів – це теж поміщається у вікно, і теж буде створений 1 ембедінг-вектор
стаття в 1.000 слів дасть вже приблизно 1300-1400 токенів, а тому вона буде поділена на три чанки, і для них будуть створені окремі вектори:
chunk_1 => [vector_1 with 1024 numbers]
chunk_2 => [vector_2 with 1024 numbers]
chunk_3 => [vector_3 with 1024 numbers]
d (dimension) – задається embedding-моделлю, яка перетворює дані у вектори для зберігання в vector-store. Наприклад, в Amazon Titan Embeddings dimension=1024. І цей жеж параметр вказується при створенні індексу.
m (Maximum number of bi-directional links) – кількість зв’язків між кожною нодою в графі, це параметр HNSW-графа, задається, коли ми створюємо індекс, наприклад:
Тепер, знаючи всі ці дані – ми можемо порахувати скільки пам’яті буде потрібно для побудови графа в пам’яті, наприклад:
кількість векторів: 1 000 000
d=1024
m=16
Формула:
num_vectors * 1.1 * (4 * d + 8 * m)
Тут:
1.1: додається 10% запасу під службові структури HNSW
4: кожна координата (число у векторі) зберігається як float32 = 4 байти
8: кількість байт на зберігання id кожного “сусіда” (64-bit int) (кількість яких дається через m)
Отже, рахуємо:
1.000.000 * 1.1 * (4*1024 + 8*16)
4646400000.0 байт, або 4.64 гігабайт – це обсяг для графа HNSW по всіх векторах (без урахування реплік і шард, про них трохи далі).
Тепер враховуємо розподіл на чанки і дата-ноди:
якщо у нас весь індекс 100 гігабайт
поділений на 3 primary shards, і для кожної primary маємо 1 replica shards – разом 6 шардів
маємо 3 дата-ноди – на кожній ноді буде по 2 шарди
Для кожного шарду буде побудований окремий граф, а тому 4.64 гігабайт множимо на 2.
Але так як індекс розподілений на 3 ноди – то ділимо результат на 3.
Тож розрахунок буде таким:
graph_total: наші 4.64 гігабайти, загальний обсяг для графу
graph_cluster: graph_total * (1 + replicas) (primary + всі репліки)
graph_per_node = graph_cluster / кількість дата-нод в кластері
Формула буде такою:
graph_total * (1 + replicas) / num_data_nodes
Маючи 1 primary shard + 1 replicas shard виходить:
4.64 гігабайт * 2 / 3 data nodes
~ 3.1 GiB пам’яті на кожну ноду чисто під графи.
k-NN-графи зберігаються в off-heap пам’яті, тому вже можемо прикинути:
8 (краще 16) гігабайт під JVM Heap для самого OpenSearch
3 GiB під графи
Ліміт для k-NN графів задається в knn.memory.circuit_breaker.limit, і зазвичай має значення в 50: off-heap пам’яті – див. k-NN differences, tuning, and limitations.
Метрика в CloudWatch – KNNGraphMemoryUsage, див. k-NN metrics.
Або в API самого OpenSearch – _plugins/_knn/stats та _nodes/stats/indices,os,break (див. Nodes Stats API).
І до цього треба додати OS page cache для “гарячих” даних – векторів/метаданих/тексту, які з диску мапляться в пам’ять для швидкого доступу – як ми це рахували для індексу з логами.
Для OS page cache можемо накинути ще 20-50% від повного розміру індексу на ноді, хоча тут залежить від того, які операції будуть виконуватись. В ідеалі, якщо грошей не жалко – то можна докинути ще 100% від розміру індексу * 2 (на кожну репліку кожного шарду) / кількість нод.
Отже, якщо візьмемо 1 000 000 векторів в базі, і саму базу в умовних 30 гігабайт, 3 primary shards і для кожної 1 репліка, і 3 data-node – то отримуємо:
8 (краще 16) гігабайт під JVM Heap для самого OpenSearch
3 GB під графи
30 * 2 / 3 * 0.5 (50% для OS page cache) == 10 ГБ
І ще додати відсотків 10-15 на роботу самої операційної системи – отримуємо (16 + 3 + 10) * 1.15 == ~34 GB RAM.
В наступних (сподіваюсь, напишу) постах – вже насетапимо кластер, може відразу з Terraform, створимо індекс, подивимось на аутентифікацію та доступ до OpenSearch Dashboard (бо трохи через одне місце), і подумаємо про моніторинг.
Пройдемось по загальним налаштуванням Arch linux (точніше, будь-якого Linux), потім поговоримо про вибір Desktop Environments, і власне встановимо та налаштуємо KDE.
Я собі цього разу основною вибрав KDE Plasma, але далі трохи поговоримо про різні, бо за 10 років активного використання Linux/Arch Linux пробував майже всі основні.
В цьому пості буду описувати встановлення і налаштування на “чистому” Arch Linux, але якщо цікаво просто поекспериментувати з Arch Linux based системами та KDE – то подивіться в сторону EndeavourOS, бо там все готове з коробки, є вибір різних оточень (KDE, Gnome, Mate, Openbox, etc), зручний графічний інсталятор, активне комьюніті на форумі та Reddit.
Налаштування системи
Систему встановили, вона завантажується, починаємо її готувати до використання.
Якщо ще не запускали – то стартуємо сервіси для WiFi, SSH, Bluetooth:
Якщо при спробі виконати sudo все ще каже, що “username is not in the sudoers file” – перевірте наявність файлу /etc/sudoers.d/10-installer, бо в ньому свої правила, які мають перевагу над /etc/sudoers. Якщо є – то можна його просто видалити.
Встановлення Yay
Під звичайним юзером додаємо пакети, встановлюємо yay для менеджменту пакетів з AUR (Arch User Repository):
Додаємо pipewire та pavucontrol для роботи звукової системи – взагалі, якщо буде KDE, то там це в комплекті буде встановлено, але я бавився з іншими менеджерами, тому встановлював окремо:
[setevoy@archlinux ~]$ systemctl --user status pipewire-pulse
● pipewire-pulse.service - PipeWire PulseAudio
Loaded: loaded (/usr/lib/systemd/user/pipewire-pulse.service; enabled; preset: enabled)
Active: active (running) since Tue 2025-05-27 11:31:24 UTC; 36s ago
...
З базових налаштувань це наче все – можемо переходити до графічного оточення.
Desktop Environments vs Window Managers
Desktop Environment (DE) – це повноцінне оточення з “all batteries included” – не тільки сама графічна оболонка, але і всякі утиліти для комфортної роботи і керування системою – network manager, набір системних пакетів типу поштового клієнта, контроль bluetooth-девайсів, різні панельки, віджети, і так далі.
Desktop Environment як правило включає в себе Window Manager.
Приклади DE – KDE, Gnome, Xfce, Mate.
Window Manager (WM) – це система, яка відповідає (майже) виключно за, власне, windows – вікна. Їх розміщення, їхній вигляд, оформлення. Деякі WM мають власні панелі типу System Tray та Task Manager, в деяких їх треба встановлювати і налаштовувати окремо.
Приклади WM – Openbox, Fluxbox, bspwm.
Тобто, можна встановити тільки Window Manager, без DE – і все зробити власноруч. Менше споживання ресурсів (за рахунок відсутності додаткових систем), але більше часу на налаштування.
А можна просто взяти готовий Desktop Environment, де всі утиліти будуть “з коробки”.
В KDE Plasma може мати Openbox як Window Manager, а можна користуватись дефолтним KWin.
Wayland vs X.Org
Наразі існують дві основні системи, які забезпечують роботу Window Managers (WM) і Desktop Environments (DE) – X.org та Wayland. Вони відповідають за те, як будуть відображатись вікна на екрані, як їх можна переміщати, які дії з ними виконувати, оброблюють дії мишки та клавіатури.
Працюють за моделлю клієнт-сервер:
клієнти – це додатки (файловий менеджер, поштовий клієнт тощо), які відправляють команди серверу
сервер – отримує від клієнта команди, та виконує їх – “перемістити вікно Х на інший екран”, і передає картинку на екран
У класичному X.Org сервер не вміє сам по собі малювати ефекти чи поєднувати кілька вікон в одну картинку, і йому для цього потрібен окремий компонент – compositor.
Compositor “збирає” кадр: бере зображення від усіх вікон, накладає їх одне на одне, додає ефекти (прозорість, тіні), і відправляє готовий результат на GPU.
Приклади compositor-ів для X.Org – compton (застарілий), picom (активно підтримується).
В KDE Plasma на X.Org ми можемо встановити окремий compositor типу Picom, може якось напишу про нього, є в чернетках. А можна користуватись дефолтним KWin.
Wayland поєднує функції сервера та композитора в одному процесі. Тобто кожен WM чи DE під Wayland сам є і сервером, і композитором.
Приклади композиторів для Wayland – Mutter (GNOME), KWin (KDE), а також окремі реалізації на кшталт Sway, Wayfire.
Wayland, як на мене, все ще трохи “сирий”, тоді як X.Org – хоч і “древні мамонт”, але дуже стабільний і має підтримку всього і всюди.
Отже, основні компоненти графічної системи:
графічний сервер (X.Org Server або Wayland): отримує від програм команди та події введення, керує відображенням вікон і передає підготовлені дані до відеосистеми Linux
compositor: формує фінальне зображення для екрана – розташовує вікна, накладає їх одне на одне, застосовує ефекти (тіні, прозорість, анімації) та передає результат на GPU
Qt vs GTK
Обидва являють собою фреймворки для розробки графічних інтерфейсів.
Обидва виконують одну роль – надають програмісту готові елементи інтерфейсу (кнопки, меню, поля вводу) і засоби для їх відображення. Різниця переважно у філософії, API та зовнішньому вигляді за замовчуванням.
Головна різниця для нас, як юзерів – це зовнішній вигляд: GTK більш мінімалістичний, суворий.
GNOME – це GTK, KDE Plasma – це Qt.
Приклад GTK-based з Thunar file manager:
Тоді як Qt – більш сучасний, приклад з Dolphin file manager:
Велика проблема в деяких DE/WM – це зробити так, аби різні застосунки виглядали однаково, бо теми оформлення для GTK-based та Qt-based відрізняються.
Тому, якщо користуватись різними – то іноді треба витратити на це час, аби підібрати теми.
Наприклад я користуюсь поштовим клієнтом Thunderbird – який зроблений на GTK, а файловим менеджером Dolphin – який є Qt-based.
ОК – з цим розібрались, тепер можемо переходити власне до налаштувань.
Login screen: SDDM
Для вікна логіну в систему, вибору DE/ME та її запуску використовуємо Desktop Display Manager.
Колись я це робив вручну через логів з термінала і потім запуску startx, але ми живемо у 2025 – давайте робити нормально 🙂
Їх теж багато, але SDDM стабільний, легко налаштовується, має різні теми оформлення.
Хоча sddm буде встановлений з KDE – але я робив окремо, тому най буде тут.
В цілому це чудове рішення – дуже швидке, мінімум використання ресурсів, мінімалістичний вигляд.
З мінусів – це прям нормально так часу на перше налаштування, бо треба самому все встановити і, головне, писати всі файли конфігурації.
Цього разу, як купив новий ноутбук, спочатку теж думав просто скопіювати всі файли зі старого ноута, але потім вирішив спробувати щось відносно нове.
Перепробував прям багато всього – і GNOME, і MATE, і LXQt, і, звісно, сам Openbox.
І в цілому всі (окрім GNOME, від якого в мене прям дико пригорає) працюють нормально.
Всі не ідеальні – але всі цілком робочі. далі трохи опишу свої враження від кожної.
Що я, власне, взагалі хочу від робочого оточення?
верхня панель – інформаційна по навантаженню на CPU та використання пам’яті і дисків, погода, керування звуком, стан батареї ноутбука тощо
нижня панель – класичний таскбар з активними вікнами, запуск нових додатків, системний трей з повідомленнями тощо
підтримка (і наявність!) тем оформлення
зручне керування моніторами, живленням і так далі
Ну і, звістно, це все має працювати стабільно.
Що у нас є на вибір?
З основних, і тих, що я пробував колись або зараз:
Openbox (WM): невмируща класика – дуже легкий, мінімалістичний, але це тільки Window Manager – панельки, керування моніторами, навіть переключення мов на клавіатурі треба робити окремими додатками
в ту ж серію йдуть Fluxbox, Blackbox
KDE Plasma (DE): “batteries included” – просто ставимо, і має все готове з коробки – але займає більше місця на диску, в пам’яті, більше часу CPU
GNOME (DE): ну… теж, як KDE – все з коробки, але має великі проблеми, якщо хочемо налаштувати і Qt-apps, і GTK-додатки в одному DE
Xfce (DE): ще один олдскул як і Openbox – але повноцінний Desktop Enviroment з усіма готовими додатками і панельками
LXDE (DE): ще більший олдскул) GTK2, давно не розвивається, але все ще можна зустріти
LXQt (DE): це XFCE, але на стероїдах – сучасна реалізація на Qt, швидкий, мінімалістичний DE
Cinnamon/MATE/Budgie (DE): “класичний GNOME”:
Cinnamon: це “Windows для Linux”
Mate: GNOME 2.0, як він був раніше
Budgie: красивий, простий – але з розвитком і стабільністю в нього дуже так собі
Окремо можна згадати про тайлінг-менеджери типу i3 або Hyprland – але це на любителя. Я пару раз пробував, і все ж не зайшло.
Власне, вибір робочого оточення.
З того, що спробував цього разу, поки не вирішив зупинитись на KDE:
GNOME – це жах. Найбільший головний біль – це зробити так, аби всі вікна виглядали хоча б приблизно однаково.
Openbox: чудово, швидко, зручно. З мінусів – це все ж Windows Manager, а не Desktop Environment, і багато чого треба додавати руками. Головна біль – це нижня панелька з taskbar: є, звісно, Tint2 – але він не вміє відмальовувати деякі іконки (хоча там більше проблема не самого Tint, але anyway). З плюсів – гнучкість, дуже багато тем, плюс в мене вже є купа конфігів зі старої машини.
Xfce: майже все, що треба – з коробки. Управління моніторами, живленням, панельки і інші свістопєрдєлкі. З мінусів – іноді треба поламати голову, аби зрозуміти як щось налаштувати. Хоча в цілому – дуже проста і приємна система.
LXQt: це як Xfce, але на Qt – приємний вигляд, доволі мінімалістичний Desktop Environment, але при цьому з коробки має всі необхідні утиліти
Пробував і MATE – не пам’ятаю, що не зайшло, хоча в цілому наче норм.
Пробував Budgie – блін… Пробував його років 10 тому, він постійно падав – падає і досі 🙂 Видалив через півгодини.
Спробував GNOME – це жах. Найбільший головний біль – це зробити так, аби всі вікна виглядали хоча б приблизно однаково. Ну і по можливостям кастомізації далеко до KDE Plasma.
Ну і, власне – KDE Plasma. Все красиво, все працює, купа готових утиліт для роботи, і прямо безмежні можливості по кастомізації.
Не завжди стабільно, іноді Plasma може падати, але в порівняні з тим, як це було років 10 тому – система дуже стабільна.
Якщо є вільна пам’ять і процесор на Intel Core i2 – то відмінна система для життя.
KDE vs Plasma
Окремо кілька слів про KDE та Plasma: KDE – це комьюніті і екосистема проектів, які розроблюються. Dolphin, Konsole, Okular, Krita, Kdenlive – прикладі таким проектів.
KDE займається розробкою Plasma, а вже Plasma – це як раз і є Desktop Environment. Хоча всі просто говорять “в мене KDE”.
На проекті користуємось двома системами для збору логів – Grafana Loki та VictoriaLogs, в які Promtail одночасно пише всі зібрані логи.
Loki ніяк не випиляємо: хоча девелопери вже давно перейшли на VictoriaLogs, але деякі алерти все ще створюються з метрик, які генерить Loki, тож ще присутня в системі.
І в якийсь момент почались у нас дві проблеми:
на VictoriaLogs забивається диск – довелось і retenation зменшувати, і диск збільшувати – хоча раніше вистачало
в Loki почали дропатись логи з помилкою “Ingestion rate limit exceeded“
Давайте копнемо – що саме і чому забиває всі логи, і як це діло помоніторити.
The issue: Loki Ingestion rate limit exceeded
Копати почав саме з помилки “Ingestion rate limit exceeded” в Loki, бо зайняте місце на диску VictoriaLogs було з тих самих причин – просто писалось забагато логів.
...
- alert: Loki Logs Dropped
expr: sum by (cluster, job, reason) (increase(loki_discarded_samples_total[5m])) > 0
for: 1s
...
Для VictoriaLogs в мене алерта не було, але в неї є схожа метрика – vl_rows_dropped_total.
Коли Loki почала дропати логи отримані від Promatil – почав перевіряти власні логи Loki, де і знайшов помилки з rate limit:
...
path=write msg="write operation failed" details="Ingestion rate limit exceeded for user fake (limit: 4194304 bytes/sec) while attempting to ingest '141' lines totaling '1040783' bytes, reduce log volume or contact your Loki administrator to see if the limit can be increased" org_id=fake
...
Проте є sum_len(), де ми можемо отримати статистику наприклад так:
*
| stats by (app) sum_len() as bytes_used
| sort (bytes_used) desc
| limit 10
Або per-second rate:
*
| stats by (app) sum_len() as rows_len
| stats by (app) rate_sum(rows_len) as bytes_used_rate
| sort (bytes_used_rate) desc
| limit 10
Причина
Тут все виявилось просто.
Достатньо було просто заглянути в логи самої VictoriaLogs, і побачити там що вона логує всі записи, які отримала від Promtail – “new log entry“:
Йдемо дивитись опції для VictoriaLogs в документації List of command-line flags, і там знаходимо “-logIngestedRows“:
-logIngestedRows
Whether to log all the ingested log entries; this can be useful for debugging of data ingestion; see https://docs.victoriametrics.com/victorialogs/data-ingestion/ ; see also -logNewStreams
Дефолтне значення тут не вказане, і я спочатку подумав, що воно просто включене в “true“, тож пішов у values нашого чарту, аби виставити “false“, де і побачив:
Власне – переключаємо його в false (або просто видаляємо, бо по дефолту воно і так false), деплоїмо – проблема вирішена.
Заодно можна переключити loggerLevel, який по дефолту має INFO.
І тут, до речі, могла б бути цікава картина: якщо б і Loki і VictoriaLogs писали лог про кожен log record, який вони отримали – то…
Loki отримує будь-який запис від Promtail
записує цю подію у власний лог
Promtail бачить новий запис від контейнеру з Loki, і знов передає його і до Loki, і до VictoriaLogs
VictoriaLogs записує у свій лог, що отримала цей запис
Promtail бачить новий запис від контейнеру з VictoriaLogs і передає його і до Loki, і до VictoriaLogs
Loki отримує цей запис від Promtail
записує цю подію у власний лог
…
Такий собі “fork logs bomb”.
Моніторинг логів на майбутнє
Тут теж все просто або користуємось дефолтними метриками від Loki та VictoriaLogs, або генеримо власні.
Метрики Loki
В чарті Loki є опція monitoring.serviceMonitor.enabled, можна просто включити її – тоді VictoriaMetrics Opeartor створить VMServiceScrape і почне збирати метрики.
Для Loki можуть бути цікавими:
loki_log_messages_total: Total number of messages logged by Loki
loki_distributor_bytes_received_total: The total number of uncompressed bytes received per tenant
loki_distributor_lines_received_total: The total number of lines received per tenant
loki_discarded_samples_total: The total number of samples that were dropped
loki_discarded_bytes_total: The total number of bytes that were dropped
Або можемо створити власні метрики з інформацією по кожній app:
vl_bytes_ingested_total: Cumulative estimated bytes of logs accepted by the ingesters, split by protocol via labels
vl_rows_ingested_total: Cumulative number of log entries successfully accepted by the ingesters, split by ingestion protocol via labels in the raw series
vl_rows_dropped_total: Cumulative rows dropped by the server during ingestion, with labeled reasons (e.g. debug mode, too many fields, timestamp out of bounds)
vl_too_long_lines_skipped_total: Number of over‑size lines skipped because they exceed the configured maximum line size
vl_free_disk_space_bytes: Current free space available on the filesystem hosting the storage path
І додати алерт на кшталт такого:
...
- alert: VictoriaLogs Logs Dropped Rows Too High
expr: sum by (reason) (vl_rows_dropped_total) > 0
for: 1s
labels:
severity: warning
component: devops
environment: ops
annotations:
summary: 'VictoriaLogs Logs Dropped Rows Too High'
description: |-
VictoriaLogs dropped too many log rows
*Reason*: `{{ "{{" }} $labels.app }}`
*Value*: `{{ "{{" }} $value | humanize }}` records dropped
tags: devops
...
Але знов-таки – vl_rows_ingested_total не скаже нам яка саме апка пише забагато логів.
В мене для цього є окремий “Testing” environment, який я викатую спочатку з поточними версіями модулів/провайдерів, потім оновлюю код, деплою апгрейд, і коли все пофікшено – то вже роблю апгрейд EKS Production (бо у нас один кластер на dev/staging/prod).
В Helm-чарті самого Karpenter наче без особливих змін, хоча вже вийшла версія 1.6 – можна заодно теж оновити, але це вже іншим разом.
В цілому апгрейд пройшов без пригод, але були два моменти, де довелось подебажити – це проблема з EC2 metadata для AWS Load Balancer Controller під час апгрейду, та з EKS Add-ons при створенні нового кластеру з AWS EKS Terraform module v21.x.
Upgrade AWS EKS Terraform module
Upgrade AWS Provider Version 6
Першим міняємо версію AWS Provider – нарешті, бо відкриті пул-реквести від Renovate муляли очі, а закрити не міг.
Поїхали робити terraform plan і дивитись що буде “ламатись”.
Renamed variables в terraform-aws-modules/eks/aws
Першим, очікувано, помилки про відсутні змінні, бо вони були перейменовані в модулі:
$ terraform plan -var-file=test-1-33.tfvars
...
│ Error: Unsupported argument
│
│ on ../../modules/atlas-eks/eks.tf line 34, in module "eks":
│ 34: cluster_name = "${var.env_name}-cluster"
│
│ An argument named "cluster_name" is not expected here.
╵
╷
│ Error: Unsupported argument
│
│ on ../../modules/atlas-eks/eks.tf line 38, in module "eks":
│ 38: cluster_version = var.eks_version
│
│ An argument named "cluster_version" is not expected here.
╵
╷
│ Error: Unsupported argument
│
│ on ../../modules/atlas-eks/eks.tf line 42, in module "eks":
│ 42: cluster_endpoint_public_access = var.eks_params.cluster_endpoint_public_access
│
│ An argument named "cluster_endpoint_public_access" is not expected here.
╵
╷
│ Error: Unsupported argument
│
│ on ../../modules/atlas-eks/eks.tf line 46, in module "eks":
│ 46: cluster_enabled_log_types = var.eks_params.cluster_enabled_log_types
│
│ An argument named "cluster_enabled_log_types" is not expected here.
╵
╷
│ Error: Unsupported argument
│
│ on ../../modules/atlas-eks/eks.tf line 50, in module "eks":
│ 50: cluster_addons = {
│
│ An argument named "cluster_addons" is not expected here.
╵
╷
│ Error: Unsupported argument
│
│ on ../../modules/atlas-eks/eks.tf line 148, in module "eks":
│ 148: cluster_security_group_name = "${var.env_name}-cluster-sg"
│
│ An argument named "cluster_security_group_name" is not expected here.
...
Йдемо в документацію по апгрейду – і по одній знаходимо як тепер називаються змінні:
Хоча, як на мене – то з префіксом cluster_* було краще, бо у нас є node_security_group_name, і була cluster_security_group_name – чітко видно який параметр для чого.
А тепер є node_security_group_name і “якась” security_group_name.
Removed variables в terraform-aws-modules/eks/aws//modules/karpenter
ОК – редагуємо імена змінних в коді основного модулю, виконуємо terraform plan ще раз – тепер маємо помилки по змінам в модулі karpenter:
...
Error: Unsupported argument
│
│ on ../../modules/atlas-eks/karpenter.tf line 7, in module "karpenter":
│ 7: irsa_oidc_provider_arn = module.eks.oidc_provider_arn
│
│ An argument named "irsa_oidc_provider_arn" is not expected here.
╵
╷
│ Error: Unsupported argument
│
│ on ../../modules/atlas-eks/karpenter.tf line 8, in module "karpenter":
│ 8: irsa_namespace_service_accounts = ["karpenter:karpenter"]
│
│ An argument named "irsa_namespace_service_accounts" is not expected here.
╵
╷
│ Error: Unsupported argument
│
│ on ../../modules/atlas-eks/karpenter.tf line 14, in module "karpenter":
│ 14: enable_irsa = true
│
│ An argument named "enable_irsa" is not expected here.
...
Вони були видалені, бо більше немає IRSA – тепер для Karpenter буде створено EKS Pod Identity, див. main.tf#L92.
Бо інакше Karpenter “відвалиться”, і апгрейд WorkerNode Group сфейлиться – бо нода буде чекати на под Karpenter, а він буде в CrashLoopbackoff і апгрейд групи сфейлиться.
eks_managed_node_groups: attribute “taints”: map of object required
Тепер помилка з тегами нод-групи:
...
│ The given value is not suitable for module.atlas_eks.module.eks.var.eks_managed_node_groups declared at .terraform/modules/atlas_eks.eks/variables.tf:1205,1-35: element "test-1-33-default": attribute "taints": map of object required.
...
Чому – бо:
Variable definitions now contain detailed object types in place of the previously used any type.
...
type = map(object({
key = string
value = optional(string)
effect = string
}))
...
А в мене taints зараз передаються зі змінної з об’єктом set(map(string)):
...
variable "eks_managed_node_group_params" {
description = "EKS Managed NodeGroups setting, one item in the map() per each dedicated NodeGroup"
type = map(object({
min_size = number
max_size = number
desired_size = number
instance_types = list(string)
capacity_type = string
taints = set(map(string))
max_unavailable_percentage = number
}))
}
...
Виконуємо terraform plan ще раз – і тепер все проходить без помилок.
Деплоїмо апдейти.
Deploying changes
Виконуємо terraform apply, і ось де маємо новий ресурс з EKS Pod Identity Association для Karpenter – module.atlas_eks.module.karpenter.aws_eks_pod_identity_association.karpenter:
В старому кластері цього нема.
ALB Controller error: “failed to fetch VPC ID from instance metadata”
...
{"level":"error","ts":"2025-08-06T07:25:40Z","logger":"setup","msg":"unable to initialize AWS cloud","error":"failed to get VPC ID: failed to fetch VPC ID from instance metadata: error in fetching vpc id through ec2 metadata: get mac metadata: operation error ec2imds: GetMetadata, canceled, context deadline exceeded"}
...
Тут опишу проблему, яка виникала тільки при створенні нового EKS кластеру з модулем v21 – апгрейд існуючого проходить без цих складностей.
Власне, в чому ця проблема полягає: кластер створився, все наче ОК, але довго висить на створенні Node Group, і потім падає з помилкою “unexpected state ‘CREATE_FAILED’“:
...
╷
│ Error: waiting for EKS Node Group (atlas-eks-test-1-33-cluster:test-1-33-default-20250801112636765600000014) create: unexpected state 'CREATE_FAILED', wanted target 'ACTIVE'. last error: i-03f2c73c7211880f7: NodeCreationFailure: Unhealthy nodes in the kubernetes cluster
...
Хоча EC2 Auto Scaling Group є, і EC2 в ній теж.
Чому?
Тобто проблема в тому, що WorkerNode створена, але не може приєднатись до Kubernetes.
Першим про що думається – це перевірити Security Group, але тут наче все правильно – всі правила прописані. Порівнював з поточним EKS кластером, який робився ще з AWS EKS Terraform module v20.x – все аналогічно.
Проблема з IAM? У EC2 нема пермішенів достукатись до кластеру? Аналогічно – порівнюємо зі старим кластером, все ОК.
А проблема виникла в “дефолтній” NodeGroup, де запускаються різні контролери.
Тому підключаємось через AWS Console – вибираємо Connect:
Потім в EC2 Instance Connect вибираємо “Connect using a Private IP” і вибираємо існуючий або руками швиденько створюємо новий EC2 Instance Connect Endpoint.
Задаємо ім’я юзера – для Amazon Linux це ec2-user:
І дивимось логи:
“Container runtime network not ready – cni plugin not initialized”
Власне:
Aug 01 13:26:04 ip-10-0-48-198.ec2.internal kubelet[1619]: E0801 13:26:04.989799 1619 kubelet.go:3126] "Container runtime network not ready" networkReady="NetworkReady=false reason:NetworkPluginNotReady message:Network plugin returns error: cni plugin not initialized"
Вау…
Окей – а що у нас там з VPC CNI?
Йдемо подивитись EKS Add-ons, і…
Взагалі пусто.
Дивимось лог terraform apply – і бачимо “Read complete“, але нема “Creating…“:
...
module.atlas_eks.module.eks.data.aws_eks_addon_version.this["vpc-cni"]: Read complete after 0s [id=vpc-cni]
...
Давайте ще глянемо чи взагалі є контейнери на ноді – може, там якісь помилки є?
Власне, да – проблема виникла через відсутність параметра before_compute.
Хоча трохи дивно, бо він був доданий ще в версії v19.9, я останній раз кластер з нуля деплоїв вже з v20 – і цієї проблеми не було.
Ба більше – створення тестового кластеру з мастер-бранча, де нема описаних тут апдейтів і версія модуля v20 все ще працює без проблем.
І в diff 20 vs 21 значних змін пов’язаних з before_compute не бачу.
Втім, так як це стосується тільки створення нового кластеру – то при просто апгрейді before_compute можна не додавати. Але якщо все ж додавати – то адони будуть перестворені.
Сама before_compute була додана аби дати можливість вказати які адони створювати до WorkerNodes, а які після. Див. main.tf#L797 та коменти до PR #2478.
Доволі часта помилка при апгрейді версій модулів, коли маємо обмеження на версії модулів чи провайдерів, і вони не співпадають між собою.
The Issue
В цьому випадку я змержив Pull Requests від Renovate і не звернув увагу на те, що terraform-aws-modules/terraform-aws-lambda потребує hashicorp/aws provider версії 6:
І змержив спочатку апгрейд Lambda до 8 версії.
Після цього під час виконання terraform init отримав помилку “no available releases match the given constraints“:
$ terraform init
Initializing the backend...
Upgrading modules...
...
│ Error: Failed to query available provider packages
│
│ Could not retrieve the list of available versions for provider hashicorp/aws: no available releases match the given constraints >= 3.29.0, ~> 5.14, >= 5.92.0, >= 6.0.0
...
The cause
Аби побачити в яких саме модулях які версії провайдерів використовуються – виконуємо terraform providers, і отримуємо дерево залежностей з усіма версіями:
А помилка нам каже “given constraints >= 3.29.0, ~> 5.14, >= 5.92.0, >= 6.0.0“, тобто:
перша умова – версії вище 3.29.0
в третій умові маємо pessimistic constraint (“песимістичне обмеження”) – в “~> 5.14” дозволяємо будь-які версії від 5.14.0 до, але не включно 6.0.0, тобто патчі для 5.14, або версії 5.15.x і вище (див. Version Constraints)
а остання умова потребує >= 6.0.0 – версії 6 і вище
Умова ~> 5.14 у нас задана в головному модулі atlas_monitoring в versions.tf:
Тут варіант або апгрейднути hashicorp/aws в atlas_monitoring до 6 версії – але там були якісь breaking changes (і на які звернув увагу 🙂 ) і які треба було перевірити, тому на той момент не поспішав.
Інше рішення – просто виконати revert Pull Request merge з апгрейдом terraform-aws-modules/terraform-aws-lambda:
А потім вже спочатку оновити hashicorp/aws до 6 версії, а вже після нього – модуль з Lambda.