Arch Linux: підключення розділів LVM та запуск mkinitcpio
0 (0)

4 Березня 2025

Не часто, але іноді виникає потреба завантажити систему з usb, і перезібрати initramfs-linux.img.

Цей пост – скоріш просто невеликий нотаток для себе як, що, і куди маунтити на робочому ноуті, аби запустити mkinitcpio, бо в мене є розділи LVM, є окремі розділи на диску під /boot та swap.

iwctl та WiFi 

Отже, завантажуємось з флешки, і налаштовуємо WiFi, аби далі всі команди робити з іншого компа, де можна копіпастити з RTFM:

[root@archiso ~]# iwctl
# station wlan0 connect setevoy-linksys-5-0

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

[root@archiso ~]# passwd root

Отримуємо IP:

[root@archiso ~]# ip a s wlan0
3: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    inet 192.168.3.114/24 brd 192.168.3.255 scope global dynamic noprefixroute wlan0

І підключаємо з іншого ноута:

[setevoy@setevoy-home-laptop ~] $ ssh [email protected]

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

Знаходимо volume group:

[root@archiso ~]# vgscan
  Found volume group "vg_arch" using metadata type lvm2

Активуємо його:

[root@archiso ~]# vgchange -ay
  2 logical volume(s) in volume group "vg_arch" now active

Згадуємо, які в системі є logical volumes, в мене їх два – один корневий під систему, інший під $HOME:

[root@archiso ~]# lvdisplay
  --- Logical volume ---
  LV Path                /dev/vg_arch/root
...
   
  --- Logical volume ---
  LV Path                /dev/vg_arch/home
...

Маунтимо /dev/vg_arch/root до /mnt:

[root@archiso ~]# mount /dev/vg_arch/root /mnt/

Підключення /boot

Глянемо решту дисків і розділів:

[root@archiso ~]# lsblk 
NAME             MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
loop0              7:0    0 820.6M  1 loop /run/archiso/airootfs
sda                8:0    1  28.9G  0 disk 
└─sda1             8:1    1  28.9G  0 part 
nvme0n1          259:0    0 465.8G  0 disk 
├─nvme0n1p1      259:1    0   512M  0 part 
├─nvme0n1p2      259:2    0     1G  0 part 
├─nvme0n1p3      259:3    0    32G  0 part 
└─nvme0n1p4      259:4    0 432.3G  0 part 
  ├─vg_arch-root 254:0    0   100G  0 lvm  /mnt
  └─vg_arch-home 254:1    0 332.3G  0 lvm

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

Наприклад, я колись витратив багато часу перезбираючи initramfs-образ, але система все одно не завантажувалась.

І лише через деякий час я згадав, що в мене і /boot на окремому розділі:

[root@archiso ~]# cat /mnt/etc/fstab 
# /dev/mapper/vg_arch-root
UUID=8569a65c-d848-427d-bcd0-5046d0e07f2b       /               ext4            rw,relatime     0 1

# /dev/mapper/vg_arch-home
UUID=0ae5ba43-3bb3-4826-8570-8918a82bf27f       /home           ext4            rw,relatime     0 2

# /dev/nvme0n1p1
UUID=54C4-1990          /boot/EFI       vfat            rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=iso8859-1,shortname=mixed,utf8,errors=remount-ro       0 2

# /dev/nvme0n1p2
UUID=6a42ffb4-6811-4176-b26e-873f49aea3b0       /boot           ext2            rw,relatime     0 2

# /dev/nvme0n1p3
UUID=08d2ae9d-28fa-4d20-9d06-72d1b7fdc21d       none            swap            defaults        0 0

Маунтимо /dev/nvme0n1p2 в /mnt/boot/:

[root@archiso ~]# mount /dev/nvme0n1p2 /mnt/boot/

Глянемо, що там зараз – а зараз нічого нема, бо під час апдейту не вистачило місця в /tmp, і не зібрався новий initramfs-linux.img:

[root@archiso ~]# ls -l /mnt/boot/
total 24
drwxr-xr-x 2 root root  4096 Dec  7  2020 EFI
drwxr-xr-x 6 root root  4096 Dec  7  2020 grub
drwx------ 2 root root 16384 Dec  7  2020 lost+found

Ну і власне тепер можна збирати систему.

Запуск mkinitcpio

Тут вже все просто – встановлюємо пакети в /mnt:

[root@archiso ~]# pacstrap /mnt base linux linux-firmware

Виконуємо chroot, і запускаємо mkinitcpio:

[root@archiso ~]# arch-chroot /mnt/
11:27:22 [root@archiso /]  # mkinitcpio -p linux
==> Building image from preset: /etc/mkinitcpio.d/linux.preset: 'default'
==> Using configuration file: '/etc/mkinitcpio.conf'
  -> -k /boot/vmlinuz-linux -c /etc/mkinitcpio.conf -g /boot/initramfs-linux.img
...

Перевіряємо /boot ще раз – тепер все на місці:

11:28:48 [root@archiso /]  # ls -l /boot/
total 68000
drwxr-xr-x 2 root root     4096 Dec  7  2020 EFI
drwxr-xr-x 6 root root     4096 Dec  7  2020 grub
-rw------- 1 root root 41323222 Mar  3 11:28 initramfs-linux-fallback.img
-rw------- 1 root root 14137751 Mar  3 11:28 initramfs-linux.img
drwx------ 2 root root    16384 Dec  7  2020 lost+found
-rw-r--r-- 1 root root 14053888 Mar  3 11:26 vmlinuz-linux

Готово – перезавантажуємось в основну систему.

Loading

PostgreSQL: використання EXPLAIN та налаштування “auto_explain” в AWS RDS
0 (0)

12 Лютого 2025

Вже згадував про можливості EXPLAIN в пості PostgreSQL: AWS RDS Performance and monitoring, але це настільки цікава і корисна штука, що варто про неї поговорити окремо.

До того ж, в AWS RDS для PostgreSQL є можливість включити логування Execution Plans з EXPLAIN, що теж корисно для моніторингу і дебагу, тому подивимось як це включити і які параметри для цього є.

PostgreSQL EXPLAIN

Отже, що таке EXPLAIN? див. також Using EXPLAIN.

Explain відобразить нам Execution Plan. А аби краще розуміти що таке Execution Plan – глянемо на те, як взагалі виглядає процес виконання запитів.

Що таке Execution Plan в PostgreSQL

Коли ми відправляємо запит до PostgreSQL, то:

  • запит проходить через лексичний (Lexing) та синтаксичний (Parsing) аналіз
    • Lexing: розділення на токени (оператори) – SELECT, FROM, WHERE тощо
    • Parsing: перевірка синтаксису та створення дерева синтаксичного аналізу (Parse Tree)
  • далі відбувається аналіз (Analysis/Binding)
    • перевірка існування таблиць, колонок та функцій, прав доступу
    • створюється схематична інформація (schema information) – мета-дані про структуру таблиць, індекси, привілегії тощо
  • створюється логічний план (Logical Plan Generation)
    • на основі дерева синтаксичного аналізу (Parse Tree) формується логічний план запиту, в якому визначаються операції, які треба виконати – JOIN, AGREGATE etc
  • виконується оптимізація запиту (Query Optimization)
    • визначаються найкращі алгоритми JOIN (Nested Loop, Hash Join, Merge Join)
    • вирішується, чи використовувати індекси (Index Scan, Bitmap Index Scan) або повний перегляд таблиці (Seq Scan)
  • генерується фізичний план (Execution Plan Generation)
    • логічний план перетворюється у конкретний набір операцій для виконання (Seq Scan, Index Scan, Hash Join тощо)
    • визначається, як буде використовуватися оперативна пам’ять, кеш і тимчасові файли на диску (саме тому фізичний план – бо вже враховуються фізичні ресурси системи)
  • і нарешті відбувається виконання запиту (Execution)
    • отримуємо дані з диску, змінюємо дані в пам’яті тощо
    • якщо це SELECT – дані відправляються клієнту
    • якщо виконується INSERT/UPDATE/DELETE – дані фізично змінюються на диску
  • завершення операції (Commit або Rollback)
    • COMMIT (підтвердження транзакції) – зміни фіксуються в базі даних, і стають доступні іншим клієнтам
    • ROLLBACK (скасування транзакції) – зміни повертаються до стану, який був до BEGIN

Використання EXPLAIN

Отже, з EXPLAIN ми можемо отримати Execution Plan – тобто, побачити те, які операції будуть виконуватись, і скільки даних і ресурсів вони торкнуться.

Найпростіший приклад може виглядати так:

EXPLAIN SELECT * FROM table_name;

Execution Plan являє собою дерево фізичних операцій, де Nodes – це окрема операція, така як Seq Scan, Index Scan, Sort, Join, Aggregate тощо, а стрілочки – зв’язки між вузлами і передача даних між операціями.

Візуалізація EXPLAIN

Є можливість візуалізувати результати Explain, наприклад онлайн з https://www.pgexplain.dev:

Або https://explain.depesz.com:

Опції EXPLAIN

Загальний синтаксис:

EXPLAIN (options) statement

Основні Options такі:

  • ANALYZE: коли ми просто викликаємо EXPLAIN, то реальні дії не виконуються; додавання ANALYZE запускає виконання запиту, і потім відображає статистику
    • default: FALSE
  • VERBOSE: включає в результат додаткову інформацію (імена колонок, функцій тощо)
    • default: FALSE
  • COSTS: відображення “вартості” виконання операції – умовна одиниця вимірювання витрат на виконання запиту з врахування затрат CPU та IO
    • default: TRUE
  • BUFFERS: відображення використання буферів пам’яті (тільки якщо ANALYZE=TRUE) – скільки даних знайдено в кеші, скільки буде зчитано з диску, записано на диск тощо
    • default: FALSE
  • FORMAT: в якому форматі вивести результат – TEXT, XML, JSON, YAML
    • default: TEXT

Результати EXPLAIN

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

EXPLAIN (ANALYZE, VERBOSE, BUFFERS, FORMAT YAML)  SELECT * FROM foster_home;

Що цікавого ми тут маємо:

  • основний план і загальна інформація:
    • Node Type: "Seq Scan": операція послідовного сканування (Sequential Scan), без використання індексів
    • Parallel Aware: false: запит буде виконано без паралелізації
    • Async Capable: false: запит не може бути виконано асинхронно
    • Relation Name: "foster_home": ім’я таблиці, над якою виконуються дії
    • Schema: "public": ім’я схеми, в якій знаходиться таблиця
  • оцінка вартості (COSTS):
    • Startup Cost: 0.00: операція починається миттєво, оскільки це послідовне читання
    • Total Cost: 16242.01: оцінка вартості всієї операції (не час, а умовні одиниці)
    • Plan Rows: 35801: планується повернути в результаті запита 35801 рядків
    • Plan Width: 1330: середній очікуваний розмір рядка в байтах
  • фактичне виконання запиту (ANALYZE):
    • Actual Startup Time: 0.018: час на початок виконання запиту
    • Actual Total Time: 638.911: скільки всього часу зайняло виконання
    • Actual Rows: 35801: скільки рядків було повернуто в результаті виконання
  • вихідні дані (Output):
    • список колонок, які були повернуті в результаті запиту (всі, бо виконуємо SELECT *)
  • буфери пам’яті (BUFFERS):
    • Shared Hit Blocks: 455: в shared_buffers було знайдено 455 блоків з існуючими даними
    • Shared Read Blocks: 15429: 15429 блоків довелось читати з диску
    • Shared Dirtied Blocks: 0 та Shared Written Blocks: 0: в результаті виконання запиту ніякі дані не змінювались
    • Temp Read Blocks: 0 та Temp Written Blocks: 0 – тимчасові файли не використовувались (temp_blks_read в pg_stat_statements)
  • операції введення/виведення (I/O)
    • I/O Read Time: 599.188: скільки часу витратили на читання з диску
    • I/O Write Time: 0.000: нічого не писали
    • Temp I/O Read Time: 0.000 та Temp I/O Write Time: 0.000: знов-таки, тимчасові файли не використовувались

EXPLAIN та використання пам’яті

Що найбільш цікаве для мене, бо до цього всього ми прийшли саме тому, що розбирались з питанням “що з’їло всю пам’ять на сервері” – це скільки пам’яті буде використано при виконання різних запитів.

Використання shared_buffers

Отже, в результатах EXPLAIN ми бачили, що оцінка була:

  • Plan Rows: 35801: планується повернути в результаті запита 35801 строк
  • Plan Width: 1330: і кожен рядок займе 1330 байт у пам’яті

Тобто, в результаті виконання запиту SELECT * FROM foster_home очікується отримати 35801 рядків, в середньому кожен розміром в 1330 байт.

Відповідно, в shared_buffers на час виконання запиту планується мати:

35801*1330/1024/1024
45

45 мегабайт.

Але реальний розмір після виконання вказано в Shared Hit Blocks + Shared Read Blocks: 455 блоків даних по 8 КБ вже було в пам’яті, і 15429 блоків було зчитано з диску.

Враховуючи, що кожен блок це 8КБ, то маємо:

(455+15429)*8/1024
124

Порівняємо з розміром самої таблиці:

dev_kraken_db=> SELECT pg_size_pretty(pg_relation_size('foster_home'));
 pg_size_pretty 
----------------
 124 MB

Отже, в результаті виконання SELECT * FROM foster_home ми в shared_buffers будемо мати 124 мегабайти (3640 байти там вже були, і 123432 буде додатково зчитано з диску).

При тому, що під shared_buffers на Dev-сервері всього виділено:

dev_kraken_db=> SHOW shared_buffers;
 shared_buffers 
----------------
 190496kB

~186 мегабайт.

Різниця в Plan Width і Shared Read Blocks

Чому в плані ми бачили ~45 мегабайт, а після виконання – 124 MB?

Бо Plan Width відображає дані по середньому розміру рядка, а Shared Read Blocks – скільки фізичних блоків даних було прочитано з диску.

Для значень в Plan Width PostgreSQL використовує дані з view pg_stats.avg_width.

Колонки в нашій таблиці:

dev_kraken_db=> \d foster_home
                  Table "public.foster_home"
       Column        | Type  | Collation | Nullable | Default 
---------------------+-------+-----------+----------+---------
 challenge_id        | text  |           |          | 
 type                | text  |           |          | 
 parent_challenge_id | text  |           |          | 
 source_challenge_id | text  |           |          | 
 base_challenge_id   | text  |           |          | 
 bio_parent_id       | text  |           |          | 
 matched_by          | text  |           |          | 
 title               | text  |           |          | 
 challenge           | jsonb |           |          | 
 user_id             | text  |           |          | 
Indexes:
    "foster_home_challenge_id" btree (challenge_id)

І їх розмір в pg_stats:

dev_kraken_db=> SELECT attname, avg_width 
FROM pg_stats 
WHERE tablename = 'foster_home';
       attname       | avg_width 
---------------------+-----------
 type                |         7
 bio_parent_id       |        37
 matched_by          |        18
 challenge_id        |        37
 parent_challenge_id |        37
 title               |        27
 user_id             |        37
 source_challenge_id |        37
 base_challenge_id   |        37
 challenge           |      1056

Що дає нам ті самі 1330 байт:

1056+37+37+37+27+37+37+18+37+7
1330

Отже, планується отримати 35801 строк, і кожна приблизно ~1330 байт.

Але реальний їх розмір ми можемо дізнатись з pg_column_size():

dev_kraken_db=> SELECT pg_size_pretty(avg(pg_column_size(t))) 
FROM foster_home t;
       pg_size_pretty        
-----------------------------
 1424.0065081980950253 bytes

Або:

1424*35801/1024/1024
48

48 мегабайт.

Але ж все одно не 124 мегабайти, які були прочитані з диска? Чому так?

Бо:

  • дані з диску читаються блоками по 8 КБ, а не окремими рядками:
    • тобто, навіть якщо треба отримати кілька рядків в пару кілобайт, то для їх отримання доведеться зчитати 8 КБ блоку, в якому вони знаходяться
  • таблиця може бути фрагментованою:
    • тобто рядки можуть знаходиться в різних блоках даних на диску, і читати доведеться кожен блок
  • виконується Seq Scan (повне сканування таблиці), а не використовується індекс:
    • через це з диску читається вся таблиця
  • частина даних може зберігатись поза основною таблицею в TOAST-таблиці:
    • але в SELECT pg_size_pretty(pg_relation_size('foster_home')); ми вже маємо розмір без TOAST та індексів – 124 мегабайти
    • отримати весь розмір можна з SELECT pg_size_pretty(pg_total_relation_size('foster_home'));

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

Наприклад, подивимось EXPLAIN SELECT по полю user_id, для якого нема індексу:

dev_kraken_db=> EXPLAIN (ANALYZE, VERBOSE, BUFFERS, FORMAT YAML)  SELECT user_id FROM foster_home;
...
     Node Type: "Seq Scan"               +
     ...
     Total Cost: 16242.01                +
     Plan Rows: 35801                    +
     Plan Width: 37                      +
     ...
     Actual Rows: 35801                  +
     ...
     Output:                             +
       - "user_id"                       +
     Shared Hit Blocks: 715              +
     Shared Read Blocks: 15169           +
...

Знов маємо Node Type: "Seq Scan", тобто вичитування всієї таблиці з диску і, відповідно, багато Shared Read Blocks.

Тоді як для challenge_id, для кого індекс є, це було б:

dev_kraken_db=> EXPLAIN (ANALYZE, VERBOSE, BUFFERS, FORMAT YAML) SELECT challenge_id FROM foster_home;
                 QUERY PLAN                 
--------------------------------------------
 - Plan:                                   +
     Node Type: "Index Only Scan"          +
     ...
     Total Cost: 1629.43                   +
     Plan Rows: 35801                      +
     Plan Width: 37                        +
     ...
     Actual Rows: 35801                    +
     ...
     Output:                               +
       - "challenge_id"                    +
     Heap Fetches: 0                       +
     Shared Hit Blocks: 271                +
     Shared Read Blocks: 0                 +
...

Тут всі дані з нашого індексу вже є в shared_buffers, тому з диску взагалі нічого не читалось.

Перевірити shared_bufers для індексу foster_home_challenge_id можемо так:

dev_kraken_db=> SELECT count(*) AS cached_pages,
       pg_size_pretty(count(*) * 8192) AS cached_size
FROM pg_buffercache
WHERE relfilenode = (SELECT relfilenode FROM pg_class WHERE relname = 'foster_home_challenge_id');
 cached_pages | cached_size 
--------------+-------------
          271 | 2168 kB

Окей – тут наче розібрались?

Але окрім shared_buffers під час виконання запиту може ще використовуватись і work_mem.

Використання work_mem

work_mem використовується, якщо в запиті є ORDER BY, HASH JOIN.

Див. Resource Consumption.

При цьому якщо в одному запиті є кілька таких операцій – то під кожну буде виділено свою область work_mem:

Query Plan:
   Sort (work_mem = 4MB)
     -> Nested Loop
         -> Sort (work_mem = 4MB)
         -> Hash Join (work_mem = 4MB)

Крім того, окремий work_mem виділяється кожному Worker, тобто додатковому процесу при виконанні запиту – див. How Parallel Query Works:

...
   ->  Sort  (cost=25736.77..25774.06 rows=14917 width=1330) (actual time=1286.099..1352.484 rows=11934 loops=3)
         ...
         Worker 0:  actual time=1279.232..1338.547 rows=10752 loops=1
           ...
         Worker 1:  actual time=1281.125..1358.600 rows=12929 loops=1
           ...
...

Перевірити, скільки work_mem споживає запит, і чи достатньо дефолтних 4 мегабайт можемо так:

dev_kraken_db=> EXPLAIN (ANALYZE, VERBOSE, BUFFERS, FORMAT YAML)
dev_kraken_db-> SELECT * FROM foster_home ORDER BY title;
                 QUERY PLAN                 
--------------------------------------------
 - Plan:                                   +
     Node Type: "Gather Merge"             +
     ...
     Temp Read Blocks: 5497                +
     Temp Written Blocks: 5507             +
     ...
     Temp I/O Read Time: 192.337           +
     Temp I/O Write Time: 117.445          +
     ...
     Plans:                                +
       - Node Type: "Sort" 
         ...
         Sort Key:                         +
           - "foster_home.title"           +
         Sort Method: "external merge"     +
         Sort Space Used: 17048            +
         Sort Space Type: "Disk"           +
...

Тут ми бачимо, що:

  • Temp Read та Written Blocks: використовувались тимчасові блоки
  • Temp I/O Read та Write Time: був витрачений час на роботу з диском
  • Sort Method: "external merge" та Sort Space Type: "Disk": для сортування використовувався диск, а не "Memory", бо в work_mem весь результат не вмістився
  • Sort Space Used: 17048: для сортування знадобилось 17048 кілобайт (16 мегабайт)

Перевірити поточне значення для work_mem можемо так:

dev_kraken_db=> SHOW work_mem;
 work_mem 
----------
 4MB

Саме тому для сортування знадобилось створювати temp files.

Якщо ж ми збільшимо work_mem (через SET для поточної сесії, або через Parameters Group для RDS аби зміни були постійними):

dev_kraken_db=> SET work_mem = '20MB';
SET

То тепер будемо бачити іншу картину:

...
     Temp Written Blocks: 0                 +
     ...
     Temp I/O Write Time: 0.000             +
...
         Sort Key:                          +
           - "foster_home.title"            +
         Sort Method: "quicksort"           +
         Sort Space Used: 17454             +
         Sort Space Type: "Memory"          +

...

Отже, як резюме: з EXPLAIN ANALYZE ми можемо отримати інформацію про те, скільки даних при виконанні запиту буде зчитано з диску в shared_buffers, і скільки даних буде використано в work_mem або temp files

Як нам це може допомогти моніторити “важкі” запити в AWS RDS?

AWS RDS PostgreSQL та auto_explain

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

Документація – auto_explain — log execution plans of slow queries. Код самого модуля – auto_explain.c.

Аби включити auto_explain в RDS, його треба додати в shared_preload_libraries:

Після чого ребутаємо інстанс RDS.

Далі налаштовуємо такі параметри:

  • auto_explain.log_min_duration: скільки часу має виконуватись запит, аби бути записаним в лог з EXPLAIN (в мілісекундах)
  • auto_explain.log_analyze: виконувати ANALIZE, а не просто EXPLAIN (може впливати на навантаження RDS)
  • auto_explain.log_buffers: додавати інформацію по BUFFERS
  • auto_explain.log_verbose: VERBOSE для EXPLAIN
  • auto_explain.log_format: в якому форматі пишемо лог

Зберігаємо параметри, і перевіряємо лог:

Готово.

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

Loading

PostgreSQL: AWS RDS Performance and monitoring
0 (0)

7 Лютого 2025

Мігруємо наш Backend API з DynamoDB на AWS RDS PostgreSQL, і кілька раз RDS падав.

Власне, враховуючи те, що ми задля економії взяли db.t3.small з двома vCPU і двома гігабайтами пам’яті – то доволі очікувано, але стало цікаво чому ж саме все падало.

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

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

The Issue

Отже, як все починалось.

Backend API запущений в AWS Elastic Kubernetes Service, і в якийсь момент посипались алерти по 503 помилкам:

З’явились алерти на використання Swap на Production RDS:

В Sentry з’явились помилки про проблеми з підключенням до серверу баз даних:

Починаємо перевіряти моніторинг RDS, і бачимо, що в якийсь момент Freeable Memory впала до 50 мегабайт:

Коли сервер впав, ми його перезапустили – але проблема тут же виникла знов.

Тому вирішили поки що переїхати на db.m5.large – на графіку видно, як вільна пам’ять стала 7.25 GB.

Ну і давайте глянемо, що цікавого ми можемо побачити з всієї цієї історії.

Set “Application Name”

В Performance Insights можна відобразити статистику по окремим Applications:

У нас цього зроблено не було, але, думаю, є сенс налаштувати окремі Applications для підключень експортерів моніторингу та з сервісів Backend API.

Є кілька варіантів, як це зробити – або передавати параметрами в connection strings:

"postgresql://user:password@host:port/database?application_name=MyApp"

Або виконувати прямо з коду при ініціалізації підключень:

with engine.connect() as conn: 
  conn.execute("SET application_name TO 'MyApp'")

Другий варіант виглядає привабливішим, бо connection string до Backend API передається змінною оточення з AWS Secret Store, і робити окремий URL тільки заради одного параметру application_name для кожного сервісу API виглядає трохи костильно.

Тому краще в кожній апці бекенду задавати власний параметр при створенні підключення.

Корисні PostgreSQL Extentions

По ходу діла додавав кілька PostgreSQL Extentions, які прям дуже корисні в таких справах для моніторингу і інвестігейту.

Включення pg_stat_statements

Теж на жаль не було включено на момент проблеми, але в цілому прямо must have штука.

Документація – pg_stat_statements — track statistics of SQL planning and execution та SQL statistics for RDS PostgreSQL.

В RDS PostgreSQL версій 11 і вище бібліотека включена по дефолту, тому все, що треба зробити – це створити EXTENSION, див. CREATE EXTENSION.

Перевіряємо, чи є extention зараз:

dev_kraken_db=> SELECT * 
FROM pg_available_extensions 
WHERE 
    name = 'pg_stat_statements' and 
    installed_version is not null;
 name | default_version | installed_version | comment 
------+-----------------+-------------------+---------
(0 rows)

(0 rows) – ок, пусто.

Створюємо його:

dev_kraken_db=> CREATE EXTENSION pg_stat_statements;
CREATE EXTENSION

Перевіряємо ще раз:

dev_kraken_db=> SELECT *                            
FROM pg_available_extensions 
WHERE 
    name = 'pg_stat_statements' and 
    installed_version is not null;
        name        | default_version | installed_version |                                comment                                 
--------------------+-----------------+-------------------+------------------------------------------------------------------------
 pg_stat_statements | 1.10            | 1.10              | track planning and execution statistics of all SQL statements executed
(1 row)

І спробуємо отримати якусь інформацію з pg_stat_statements і таким запитом:

SELECT query 
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

 

Далі в цьому пості будуть ще приклади того, яку інформацію з pg_stat_statements можемо отримати.

Включення pg_stat_activity

Окрім pg_stat_statements, корисну інформацію по поточній активності можна отримати з pg_stat_activity, включена по дефолту.

Обидві являють собою views, хоча в різних схемах бази:

dev_kraken_db=> \dv *.pg_stat_(statements|activity)
                 List of relations
   Schema   |        Name        | Type |  Owner   
------------+--------------------+------+----------
 pg_catalog | pg_stat_activity   | view | rdsadmin
 public     | pg_stat_statements | view | rdsadmi

Різниця між pg_stat_activity та pg_stat_statements у PostgreSQL

Обидві допомагають аналізувати запити, але pg_stat_activity – це поточна активність, а pg_stat_statements – “історична”:

Параметр pg_stat_activity pg_stat_statements
Що показує? Поточні активні сесії та їхній стан. Історія виконаних SQL-запитів зі статистикою.
Дані в режимі реального часу? Так, тільки активні процеси. Ні, це накопичена статистика по всіх запитах.
Які запити видно? Тільки ті, що виконуються прямо зараз. Запити, які виконувались раніше (навіть якщо вже завершилися).
Чи зберігає історію? Ні, дані зникають після завершення запиту. Так, PostgreSQL збирає та агрегує статистику.
Що можна дізнатися? Який запит зараз працює, скільки він триває, на що він чекає (CPU, I/O, Locks). Середній, мінімальний, максимальний час виконання запитів, кількість викликів.
Основне використання Аналіз продуктивності в режимі реального часу, пошук проблемних запитів зараз. Пошук “важких” запитів, які створюють навантаження в довгостроковій перспективі.

Включення pg_buffercache

Ще один корисний extension – це pg_buffercache, який може відобразити інформацію по стану пам’яті в PosgtreSQL.

Включається аналогічно до pg_stat_statements:

CREATE EXTENSION IF NOT EXISTS pg_buffercache;

Далі теж подивимось на цікаві запити для перевірки стану пам’яті в PostgreSQL.

Окей. Повертаємось на нашої проблеми.

CPU utilization та DBLoad

Перше, на що звернули увагу – це навантаження на CPU.

В Performance Isights це виглядало так:

А в Monitoring самого інстансу – так:

DBLoad (Database Load)

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

У PostgreSQL кожна клієнтська сесія створює окремий процес (backend process).

DBLoad – це метрика AWS RDS PostgreSQL, яка відображає значення активних сесій, які виконуються або очікують на ресурси – CPU, disk I/O, Locks. Не враховуються сесії в статусі idle, але враховуються сесії в статусі active, idle in transaction або waiting.

DBLoad схожий на Load Average у Linux, але враховує тільки PostgreSQL-сесії:

  • У Linux Load Average показує кількість процесів на Linux-сервері, які або використовують CPU, або чекають на нього чи на I/O
  • У RDS DBLoad відображає середню кількість активних сесій на сервері PostgreSQL, які або працюють, або чекають ресурси

Тобто в ідеалі кожен backend-процес, який виконує запити від підключеного клієнта, має мати доступ до “власного” ядра vCPU, отже DBLoad має бути ~= кількості vCPU або менше.

Якщо ж DBLoad значно перевищує кількість доступних ядер – то це показник, що система перевантажена і процеси (сесії) очікують в черзі на CPU або інші ресурси.

DBLoad включає в себе ще два показники:

  • DBLoadCPU: сесії, які знаходяться саме в очікуванні вільного CPU
  • DBLoadNonCPU: сесії, які знаходяться в очікуванні диску, database table locks, networking, etc

Перевірити сесії, які будуть вважатись активними і будуть включені в DBLoad можемо так:

SELECT pid, usename, state, wait_event, backend_type, query
FROM pg_stat_activity
WHERE state != 'idle'

Нормальне значення для DBLoad

DBLoad має бути приблизно рівним або нижче кількості доступних vCPU.

DBLoad vs CPU Utilization

Чому на першому скріні ми бачимо “100%”, а на другому просто кількість в 17.5?

  • CPU Utilization: відсоток використання CPU від загальної доступної потужності
  • DBLoad: кількість активних сесій

Враховуючи, що на сервері в той момент було 2 доступних vCPU, і при цьому 17 активних сесій – то маємо 100% використання процесорного часу.

Окремо варто завернути увагу на DBLoadRelativeToNumVCPUs – це DBLoad поділений на кількість доступних vCPU, тобто середнє навантаження на кожне ядро CPU.

DBLoadCPU (Database Load on CPU Wait)

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

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

Якщо DBLoadCPU має значення близько DBLoad, то RDS не встигає обробити всі запити – не вистачає CPU time, і вони стають в чергу.

Перевірити можемо тим самим запитом з pg_stat_activity, як вище: якщо в wait_event = "CPU", то це процеси, які чекають вільного CPU.

Нормальне значення для DBLoadCPU

DBLoadCPU має бути якнайнижчим (близьким до нуля).

Якщо DBLoadCPU майже дорівнює DBLoad, то:

  • основне навантаження саме на процесор
  • сесії не блокуються через Table Locks або повільний диск (I/O), а просто чекають CPU

DBLoadNonCPU (Database Load on Non-CPU)

DBLoadNonCPU, власне, відображає інформацію очікування ресурсів, не пов’язаних з CPU.

Це можуть бути:

  • блокування (Locks): очікування доступу до таблиці або рядка
  • I/O очікування (I/O Wait): повільне читання або запис через дискові обмеження
  • Network Wait: затримки через мережеві операції (наприклад, реплікація або передача даних)
  • Other Wait Events: інші очікування, такі як процеси фонового обслуговування

Перевірити такі сесії можемо аналогічно до попередніх запитів з pg_stat_activity, але додамо виборку wait_event_type та wait_event:

SELECT pid, usename, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start;

Тут wait_event_type вказує на тип ресурсу, на який очікує процес (CPU, IO, Lock, WAL, Client), а wait_event деталізує яку конкретно операцію процес очікує.

Наприклад, wait_event_type може бути “IO“, тоді в wait_event_type можуть бути значення “DataFileRead” (очікування читання з диска) або “DataFileWrite” – очікування запису на диск.

Або, якщо wait_event_type == Client, тоді wait_event_type може бути “ClientRead“, “ClientWrite“, “ClientSocket“.

Див. RDS for PostgreSQL wait events.

Повернемось до наших графіків:

  • CPU Utilization 100%: система перенавантажена
  • DBLoad 17.5: при двох vCPU – маємо багато активних сесій, процесор не встигає обробити всі запити
  • DBLoadCPU 13.9: багато сесій очікують на доступний CPU
  • DBLoadNonCPU 3.59: частина запитів очікували диск, блокування, або якісь мережеві запити

Operating System CPU utilization

Окрім метрик DBLoad, які відносяться саме до RDS та PostgreSQL, у нас ще є інформація по самій операційній системі, де маємо інформацію і по диску, і по пам’яті, і по CPU.

Власне в CPU utilization ми маємо графік у відсотках з використання CPU, який складається з кількох метрик, кожна з яких відображає окремий режим:

  • os.cpuUtilization.steal.avg (Steal Time): очікування фізичного CPU, якщо AWS виділила його іншому інстансу на цьому фізичному сервері, або якщо CPU Credits використано, і AWS обмежує ваш інстанс
  • os.cpuUtilization.guest.avg (Guest Time): CPU, який “з’їла” гостьова операційна система – якщо на сервері є Virtual Machine або Docker, але не про RDS
  • os.cpuUtilization.irq.avg (Interrupt Requests, IRQ Time): очікування Interrupt Requests, IRQ Time – обробка апаратних переривань (мережеві запити або диск), може бути пов’язана з високим IOPS на EBS
  • os.cpuUtilization.wait.avg (I/O Wait Time): час I/O Wait Time, дискові операції, наприклад – зчитування файлів
  • os.cpuUtilization.user.avg (User Time): час на юзер-процеси, в даному випадку це можуть бути обробка запитів PostgreSQL
  • os.cpuUtilization.system.avg (System Time): робота ядра операційної системи (обробка процесів з user space, дискові операції, операції з пам’яттю)
  • os.cpuUtilization.nice.avg (Nice Time): час на процеси з пріоритетом nice – низькопріоритетні фонові завдання

Схожі дані ми маємо в Linux top:

Tasks: 611 total, 7 running, 603 sleep, 1 d-sleep, 0 stopped, 0 zombie
%Cpu(s):  5.9 us,  3.6 sy,  0.0 ni, 89.8 id,  0.2 wa,  0.0 hi,  0.5 si,  0.0 st 
...

Тут ususer, sysystem, ninice, ididle і так далі.

З нашого графіку в RDS Performance Insights ми маємо найбільшу частину саме по wait – і в той час є спайки по EBS IO operations:

Тобто, “прилетів” якийсь запит, який почав активно вичитувати з диска:

І поки CPU чекав на завершення операцій з диском – решта запитів виконувались повільніше.

В той самий час маємо “провал” по Freeable memory – бо дані з диска записувались в пам’ять.

І хоча саме значення в 460 IOPS не виглядає якимось зависоким, але схоже, що саме в цей момент ми “з’їли” доступну пам’ять.

На що нам в даному випадку може вказувати високий os.cpuUtilization.wait.avg?

  • повільний EBS: все ж не наш кейс, бо маємо швидкість до 3000 IOPS; хоча очікування читання з диску в пам’ять могло спричинити ріст I/O Waits на CPU
  • блокування/Locks: як варіант, але у нас є метрика db.Transactions.blocked_transactions.avg – і там все було добре, тобто PostgreSQL не чекав на звільнення locks – на транзакції теж зараз глянемо
  • мало оперативної пам’яті: читання нових даних з диску в пам’ять витіснило існуючі там дані в Swap, і потім вичитувало їх звідти назад, при цьому скидуючи дані з пам’яті обратно в Swap, аби завантажити зі swap нові  (swap storm)

Використання Swap в цей час теж виросло:

До Swap і ReadIOPS зараз перейдемо, але спочатку давайте глянемо на транзакції.

Transactions

Ще з цікавого – активність транзакцій:


Бачимо, що як тільки почались проблеми з CPU – у нас xact_commit та xact_rollback (графік зліва) впали до нуля, і в той же час кількість active_transactions виросла до ~20, але при цьому blocked_transactions було 0.

Вже не можу зробити скрін, але ще був спайк по “idle in transaction” – тобто, транзакції починались (BEGIN), але не завершувались (не виконали COMMIT або ROLLBACK).

Але як так може бути? Навіть при високому CPU Waits хоча б частина транзакцій мали б завершитись.

  • зависокий Read IOPS: система не могла отримати дані з диску?
    • ні – ReadIOPS виріс, але не прям настільки критично
    • однак через те, що FreeableMemory був занизьким, дані з shared_buffers могли бути скинуті до swap, що викликало ще більші затримки у процесів, які ці дані очікували
  • зависокий Write IOPS: система не могла виконати запис WAL (Write Ahead Logs, будемо розбирати далі), якого потребує завершення транзакцій
    • але ми бачили, що Write IOPS був в нормі
  • багато table locks, і процеси очікували вивільнення ресурсів?
    • теж ні, бо ми бачили, що blocked_transactions було на нулі
  • робота autovacuum або ANALYZE, які могли заблокувати транзакції?
    • але знов-таки – db.Transactions.blocked_transactions.avg був на нулі
  • Swap storm: оце вже більше схоже на правду:
    • читання з диску витіснило активні дані на Swap (впав показник FreeableMemory)
    • Swap Usage виріс майже до 3-х гігабайт
    • PostgreSQL не міг отримати сторінки з shared_buffers, бо вони були в SWAP (про пам’ять теж далі буде)
    • через це транзакції “зависли” в очікуванні читання з диска, замість того щоб працювати у RAM

Що ми можемо перевірити в таких випадках?

I/O Waits або Blocks

SELECT pid, usename, query, wait_event_type, wait_event, state 
FROM pg_stat_activity 
WHERE state = 'active';

Якщо в wait_event маємо “I/O” або “Locks” – то причина може бути тут.

WAL – Write Ahead Logs

  • при кожній операції DML (Data Manipulation Language), наприклад при INSERT, UPDATE або DELETE, дані спочатку змінюються в пам’яті (shared_buffers – будемо далі про них говорити), де створюється “контекст операції”
  • одночасно ця операція заноситься у WAL-буфер (wal_buffers – буфер пам’яті)
  • коли wal_buffers заповнюється, або коли транзакція завершена, PostgreSQL-процес wal_writer за допомогою системного виклику fsync() записує дані з буфера у wal-файл (директорія pg_wal/) – це журнал всіх змін, що відбулися перед COMMIT
  • клієнт, який запустив виконання запиту отримує повідомлення COMMIT – операція успішно завершена
    • якщо параметр synchronous_commit = on, PostgreSQL чекає завершення fsync() перед відправкою COMMIT
    • якщо synchronous_commit = off, PostgreSQL не чекає fsync() і COMMIT відбувається швидше, але з ризиком втрати даних
    • при неможливості виконати транзакцію – клієнт отримає помилку “could not commit transaction
  • дані з shared_buffers записуються до файлів самої бази даних (каталог base/) – цим займається процес checkpointer, який записує модифіковані в пам’яті дані (dirty pages) на диск
    • це відбувається  за допомогою процесу CHECKPOINT не одразу після COMMIT, а періодично
    • після виконання CHECKPOINT – PostgreSQL виконує архівацію або видалення WAL-файлів

Дуже класний матеріал на тему WAL, memory та checkpoint – PostgreSQL: What is a checkpoint?

Отже, якщо EBS був перенавантажений з Write IOPS – то WAL міг перестати писатись, і це могло призвести до зупинки виконання транзакцій.

Але в нашому випадку ми бачимо, що і db.Transactions.xact_rollback.avg був на нулі, а він не залежить від WAL і Write-операцій на диску.

В PostgreSQL Exporter є кілька корисних метрик, які відображаються активність WAL:

  • pg_stat_archiver_archived_count: загальна кількість успішно заархівованих WAL-файлів (що скаже нам, що WAL працює коректно)
  • pg_stat_archiver_failed_count: кількість невдалих спроб архівування WAL-файлів
  • pg_stat_bgwriter_checkpoint_time: час, витрачений на виконання CHECKPOINTs

Ще можна зробити такий запит:

SELECT * FROM pg_stat_wal;

Якщо wal_buffers_full високий і росте, то, можливо, транзакції чекають на виконання fsync(), або що значення wal_buffers замале, і його треба збільшити аби зменшити частоту примусових записів WAL на диск.

В PostgreSQL такої метрики наче нема, але можемо зробити власну з custom.yaml:

pg_stat_wal:
  query: "SELECT wal_buffers_full FROM pg_stat_wal;"
  metrics:
    - wal_buffers_full:
        usage: "COUNTER"
        description: "Number of times the WAL buffers were completely full, causing WAL data to be written to disk."

Read IOPS та Swap

Добре.

Давайте повернемось до питання з Read IOPS та Swap.

Що тут могло відбутись:

  • якийсь запит почав активно зчитувати дані з диску
  • вони заносились в shared_buffers, в пам’яті не вистачило місця, і дані, які там були до цього були винесені в Swap
  • запити в PostgreSQL продовжують виконуватись, але тепер замість того, аби просто взяти дані з пам’яті – PostgreSQL має йти до Swap, і тому маємо високий ReadIOPS та CPU I/O Waits – тобто CPU чекає, поки дані будуть зчитані з диску

Але тоді наче мало б бути спайк по db.IO.blks_read.avg, раз читаємо з диска?

Але ні, бо db.IO.blks_read – це запити від самого PostgreSQL на читання данних.

Коли ж він оперує зі свапом – він все одно вважає, що працює з оперативною пам’яттю.

А от метрика ReadIOPS – це вже від самої операційної системи/EBS, і вона як раз показує всі операції читання, а не тільки від процесів PostgreSQL.

Що цікаво, що в момент проблеми у нас db.Cache.blks_hit впав до нуля. Про що це каже? Зазвичай, що backend-процеси (сесії) не знаходили дані в shared_buffers.

Але знаючи, що у нас взагалі всі транзакції зупинились, а db.IO.blks_read теж впав до нуля – то скоріш PostgreSQL просто перестав звертатись до кешу взагалі, бо всі чекали на вільний CPU.

Окей, гугл…

А що зі свапом?

SWAP та Enhanced monitoring

Що у нас є на графіках?

Тут нам буде корисний Enhanced monitoring:

Вибираємо там Swap (Manage graphs), і бачимо цікаву картину:

  • Free Memory падає
  • Free Swap падає
  • але Swaps in та Swaps out без змін

Тобто виглядає так, наче пам’ять закінчується – система скидає дані з RAM у Swap – але при цьому саме операції Swaps in/out “не було”.

Виглядає наче цікаво, і варто було б тут копнути далі – але AWS Console на цих графіках постійно падає:

Див. OS metrics in Enhanced Monitoring.

Втім, хоча ми не можемо отримати метрики з Enhanced monitoring напряму, але – сюрпрайз! вони пишуться до CloudWatch Logs! А вже з логів ми можемо нагенерити собі будь-які метрики з VictoriaLogs або Loki:

І вже в логах бачимо, що Swap In/Out таки відбувався. Тільки простих графіків вже не побачити. Але в майбутньому зробити собі якихось метрик з цих логів було б корисно.

В Log Groups шукаємо RDSOSMetrics, а потім вибираємо лог по RDS ID:

Operating system process list

Ще дуже корисним може бути список процесів:

Якщо починає падати вільна пам’ять – йдемо сюди, дивимось Resident memory, знаходимо PID процесу який жере пам’ять – і дивимось, що саме там за запит:

prod_kraken_db=> SELECT user, pid, query FROM pg_stat_activity WHERE pid = '26421';
       user       |  pid  |  query   
------------------+-------+----------
 prod_kraken_user | 26421 | ROLLBACK

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

Бо так, ми можемо включити slow queries logs – але в тих логах ми не побачимо PID, і не зможемо дізнатись скільки пам’яті цей запит використав.

Пам’ять в PostgreSQL

Давайте трохи копнемо в те, що взагалі в пам’яті PostgreSQL.

Див. RDS for PostgreSQL memory та Tuning memory parameters.

Пам’ять в PostgreSQL ділиться на два основні типи – це “shared memory, та “local memory” – пам’ять кожного бекенд-процесу (сесії).

В shared memory ми маємо:

  • shared_buffers: основна пам’ять, де PostgreSQL тримає кеш даних, які він зчитує з диску при обробці запитів – кешування сторінок таблиць та індексів
    • аналог Heap Memory (Java Heap)
    • shared_buffers за замовчуванням становить 25% від загальної RAM, але можна змінити
  • wal_buffers: вже бачили вище – використовується для тимчасового зберігання WAL-записів для буферизації транзакції перед записом у WAL-файл

Із shared_buffers змінені дані (dirty pages) записуються на диск двома процесами:

  • Background Writer (bgwriter): працює в фоні, поступово записує дані на диск
  • Checkpointer (checkpoint): примусово записує всі сторінки під час CHECKPOINT

Пам’ять процесів має:

  • work_mem: виділяється запитам, які виконують сортувань (ORDER BY), хеш-операцій (HASH JOIN) та агрегацій
    • кожен запит отримує свою копію work_mem, тому при великій кількості одночасних запитів пам’ять може швидко закінчитись
    • якщо work_mem процесу не вистачає – PostgreSQL починає записувати тимчасові файли на диск (temp_blks_written), що уповільнює виконання запитів
  • maintenance_work_mem: власне, maintenance operations – операції по vacuuming, створення індексів, додавання foreign keys
  • temp_buffers: виділяється для тимчасових таблиць (CREATE TEMP TABLE).

Ми можемо отримати всі дані з pg_settings так:

SELECT 
    name, 
    setting, 
    unit,
    CASE 
        WHEN unit = '8kB' THEN setting::bigint * 8 
        WHEN unit = 'kB' THEN setting::bigint 
        ELSE NULL 
    END AS total_kb,
    pg_size_pretty(
        CASE 
            WHEN unit = '8kB' THEN setting::bigint * 8 * 1024
            WHEN unit = 'kB' THEN setting::bigint * 1024
            ELSE NULL 
        END
    ) AS total_pretty
FROM pg_settings
WHERE name IN ('shared_buffers', 'work_mem', 'temp_buffers', 'wal_buffers');

Маємо 238967 shared_buffers, кожен по 8КБ, разом ~1.9 GB.

Але це вже зараз, на db.m5.large.

Перевірка shared_buffers

Cache hit ratio покаже скільки даних було отримано з пам’яті, а скільки з самого диску – хоча у нас це є в метриках db.IO.blks_read.avg та db.Cache.blks_hit.avg (або метрики pg_stat_database_blks_hit та pg_stat_database_blks_read в PostgreSQL Exporter):

SELECT 
    blks_read, blks_hit, 
    ROUND(blks_hit::numeric / NULLIF(blks_hit + blks_read, 0), 4) AS cache_hit_ratio
FROM pg_stat_database
WHERE datname = current_database();

Якщо cache_hit_ratio < 0.9, значить, кеш PostgreSQL не ефективний, і забагато даних читається з диска замість кеша.

Побачити скільки з виділених shared_buffers зараз використані (активні), а скільки вільні – тут нам знадобиться extention pg_buffercache.

Запит:

SELECT
    COUNT(*) AS total_buffers,
    SUM(CASE WHEN isdirty THEN 1 ELSE 0 END) AS dirty_buffers,
    SUM(CASE WHEN relfilenode IS NULL THEN 1 ELSE 0 END) AS free_buffers,
    SUM(CASE WHEN relfilenode IS NOT NULL THEN 1 ELSE 0 END) AS used_buffers,
    ROUND(100.0 * SUM(CASE WHEN relfilenode IS NOT NULL THEN 1 ELSE 0 END) / COUNT(*), 2) AS used_percent,
    ROUND(100.0 * SUM(CASE WHEN relfilenode IS NULL THEN 1 ELSE 0 END) / COUNT(*), 2) AS free_percent
FROM pg_buffercache;

Маємо 238967 буферів загалом, з яких використано лише 12280, або 5%.

Або інший варіант – подивитись, скільки всього сторінок зараз в shared_buffers:

prod_kraken_db=> SELECT 
    count(*) AS cached_pages,
    pg_size_pretty(count(*) * 8192) AS cached_size
FROM pg_buffercache;
 cached_pages | cached_size 
--------------+-------------
       117495 | 918 MB

При тому, що всього під shared_buffers виділено:

prod_kraken_db=> SHOW shared_buffers;
 shared_buffers 
----------------
 939960kB

918 мегабайт.

Але чому тоді в попередньому запиті ми бачили, що “зайнято 5%”?

Бо в результаті з pg_buffercache в полях used_buffers та used_percent враховуються тільки активні сторінки (used), тобто ті, які або мають прив’язку до файлу (relfilenode), або були нещодавно використані.

Використання EXPLAIN ANALYZE

Див. EXPLAIN.

EXPLAIN (ANALYZE, BUFFERS) покаже нам скільки даних в буферах зараз, скільки даних буде прочитано з диску:

Тут:

  • shared hit: скільки сторінок було прочитано з кешу (shared_buffers)
  • shared read: скільки сторінок було прочитано з диска (завантажено в shared_buffers)
  • shared dirtied: скільки сторінок було модифіковано

Тема Explain доволі цікава і дає нам багато цікавої інформації, тому написав про неї окремо – PostgreSQL: використання EXPLAIN та налаштування “auto_explain” в AWS RDS.

Подивитись зміст shared_buffers

Отримати кількість буферів по всім таблицям:

SELECT
c.relname, count(*) AS buffers
FROM
pg_buffercache b
INNER JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
AND
b.reldatabase IN (0, (SELECT oid FROM pg_database
WHERE
datname = current_database()))
GROUP BY c.relname
ORDER BY 2 DESC
LIMIT 10;

Тут для challenge_progress використано 1636 буферів, що дає нам:

1636*8/1024
12

12 мегабайт.

Або можна отримати з pg_relation_size() – див. System Administration Functions та pg_relation_size():

prod_kraken_db=> SELECT pg_size_pretty(pg_relation_size('challenge_progress'));
 pg_size_pretty 
----------------
 13 MB

Перевірка work_mem

Якщо у нас падає FreeableMemory, то, можливо, використовується забагато work_mem.

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

dev_kraken_db=> SHOW work_mem;
 work_mem 
----------
 4MB

Перевірити чи вистачає процесам цього значення work_mem можна зі значення temp_blks_written, бо коли пам’ять в work_mem закінчується, то процес починає виносити дані в тимчасові таблиці:

Ну і, власне, на цьому, мабуть, все.

Якісь висновки? Складно робити. Ясно, що db.t3.small з двома гігабайтами нам було замало.

Є підозра який саме запит тоді викликав цю “цепну реакцію”, в slow queries logs побачили “некрасивий” SELECT, і девелопери його наче оптимізували.

Спробуємо зменшити тип інстансу до 4 гігабайт пам’яті, і подивимось, чи виникне проблема знов.

Monitoring summary

Замість висновків – кілька ідей того, що треба моніторити, і що в моніторингу можна покращити.

Наші алерти

Накидаю трохи алертів, які у нас вже є зараз.

CloudWatch метрики

Метрики CloudWatch. Збираємо до VictoriaMetrics з YACE-експортером.

CPUUtilization

Алерт:

- alert: HighCPUUtilization
  expr: avg(aws_rds_cpuutilization_average{dimension_DBInstanceIdentifier!="", dimension_DBInstanceIdentifier!~"kraken-ops-rds-.*"}[5m]) by (dimension_DBInstanceIdentifier) > 80
  for: 5m
  labels:
    severity: warning
    component: devops
    environment: ops
  annotations:
    summary: "High CPU utilization on RDS instance"
    description: "CPU utilization is above 80% for more than 5 minutes on RDS instance {{ "{{" }} $labels.instance }}."

DBLoadRelativeToNumVCPUs

Алерт:

- alert: HighCPULoadPerVCPUWarningAll
  expr: avg(aws_rds_dbload_relative_to_num_vcpus_average{dimension_DBInstanceIdentifier!="", dimension_DBInstanceIdentifier!~"kraken-ops-rds-.*"}[5m]) by (dimension_DBInstanceIdentifier) > 0.8
  for: 5m
  labels:
    severity: warning
    component: devops
    environment: ops
  annotations:
    summary: "High per-core CPU utilization on RDS instance"
    description: |
      CPU utilization is above 80% for more than 5 minutes on RDS instance {{ "{{" }} $labels.instance }}
      *DB instance*: `{{ "{{" }} $labels.dimension_DBInstanceIdentifier }}`
      *Per-vCPU load*: `{{ "{{" }} $value | humanize }}`

FreeStorageSpace

Не дуже актуально, якщо маємо динамічний storage, але може бути корисним.

Алерт:

- record: aws:rds:free_storage:gigabytes
  expr: sum(aws_rds_free_storage_space_average{dimension_DBInstanceIdentifier!=""}) by (dimension_DBInstanceIdentifier) / 1073741824

# ALL
- alert: LowFreeStorageSpaceCriticalAll
  expr: aws:rds:free_storage:gigabytes < 5
  for: 5m
  labels:
    severity: warning
    component: devops
    environment: ops        
  annotations:
    summary: "Low Disk Space on an RDS instance"
    description: |-
      Free storage below 5 GB
      *DB instance*: `{{ "{{" }} $labels.dimension_DBInstanceIdentifier }}`
      *Free storage*: `{{ "{{" }} $value | humanize }}`

FreeableMemory

Алерт:

- alert: LowFreeableMemoryDev
  expr: avg(aws_rds_freeable_memory_average{dimension_DBInstanceIdentifier="kraken-ops-rds-dev"}[5m]*0.000001) by (dimension_DBInstanceIdentifier) < 20
  for: 5m
  labels:
    severity: warning
    component: backend
    environment: dev       
  annotations:
    summary: "High memory usage on RDS instance"
    description: |-
      Freeable memory is less than 100mb
      *DB instance*: `{{ "{{" }} $labels.dimension_DBInstanceIdentifier }}`
      *Free memory*: `{{ "{{" }} $value | humanize }}`

ReadLatency, ReadIOPS та WriteLatency і WriteIOPS

Схожі метрики, корисно моніторити.

Алерт:

- alert: HighDiskReadLatencyKrakenStaging
  expr: sum(aws_rds_read_latency_average{dimension_DBInstanceIdentifier="kraken-ops-rds-dev"}) by (dimension_DBInstanceIdentifier) > 0.1
  for: 1s
  labels:
    severity: warning
    component: backend
    environment: dev
  annotations:
    summary: "High Disk Read Latency on RDS instance"
    description: |-
      Reads from a storage are too slow
      *DB instance*: `{{ "{{" }} $labels.dimension_DBInstanceIdentifier }}`
      *Read Latency*: `{{ "{{" }} $value | humanize }}`

SwapUsage

Теж must have метрика.

Алерт:

- record: aws:rds:swap_used:gigabytes
  expr: sum(aws_rds_swap_usage_average{dimension_DBInstanceIdentifier!=""}) by (dimension_DBInstanceIdentifier) / 1073741824

# ALL
- alert: SwapUsedAllWarning
  expr: sum(aws:rds:swap_used:gigabytes{dimension_DBInstanceIdentifier!="", dimension_DBInstanceIdentifier!~"kraken-ops-rds-.*"}) by (dimension_DBInstanceIdentifier) > 0.8
  for: 1s
  labels:
    severity: warning
    component: devops
    environment: ops
  annotations:
    summary: "Swap space use is too high on an RDS instance"
    description: |-
      The RDS instance is using more than *0.8 GB* of swap space
      *DB instance*: `{{ "{{" }} $labels.dimension_DBInstanceIdentifier }}`
      *Swap used GB*: `{{ "{{" }} $value | humanize }}`

DatabaseConnections

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

Тому приклад алерта тут покажу, але далі буде інший – з метрик PostgreSQL Exporter:

# db.t3.micro - 112 max_connections (Backend Dev)
# db.t3.small - 225 max_connections (Backend Prod)
# db.t3.medium - 450 max_connections
# db.t3.large - 901 max_connections

# ALL
- alert: HighConnectionCountWarning
  expr: avg(aws_rds_database_connections_average{dimension_DBInstanceIdentifier!="", dimension_DBInstanceIdentifier!~".*kraken.*"}[5m]) by (dimension_DBInstanceIdentifier) > 50
  for: 1m
  labels:
    severity: warning
    component: devops
    environment: ops
  annotations:
    summary: "High number of connections on RDS instance"
    description: |-
      An RDS instance Connections Pool is almost full. New connections may be rejected.
      *DB instance*: `{{ "{{" }} $labels.dimension_DBInstanceIdentifier }}`
      *Instance type*: `db.t3.micro`
      *Max connections*: `112`
      *Current connections*: `{{ "{{" }} $value | humanize }}`

Loki Recording Rules metrics

Пару метрик генеримо з логів нашого Backend API, наприклад:

- record: aws:rds:backend:connection_failed:sum:rate:5m
  expr: |
    sum(
        rate(
            {app=~"backend-.*"} 
            != "token_email" 
            |= "sqlalchemy.exc.OperationalError" 
            | regexp `.*OperationalError\) (?P<message>connection to server at "(?P<db_server>[^"]+)".*$)`
            [5m]
        )
    ) by (message, db_server)

І потім з них створюємо алерти:

- alert: BackendRDSConnectionFailed
  expr: sum(aws:rds:backend:connection_failed:sum:rate:5m{db_server="dev.db.kraken.ops.example.co"}) by (db_server, message) > 0
  for: 1s
  labels:
    severity: critical
    component: backend
    environment: dev
  annotations:
    summary: "Connection to RDS server failed"
    description: |-
      Backend Pods can't connect to an RDS instance
      *Database server:*: {{ "{{" }} $labels.db_server }}
      *Error message*: {{ "{{" }} $labels.message }}

PostgreSQL Exporter metrcis

pg_stat_database_numbackends

Тут як раз про Connections: в експортері ми маємо метрику pg_settings_max_connections, яка вказує на максимальну кількість конектів в залежності від типу інстансу, і pg_stat_database_numbackends – кількість активних сесій (конектів).

Відповідно можемо порахувати % від max connections.

Єдина проблема, що ці метрики мають різні лейбли, і я забив робити якісь label_replace – тому просто додав три record, на кожен environemnt:

# 'pg_stat_database_numbackends' and 'pg_settings_max_connections' have no common labels
# don't want to waste time with 'label_replace' or similar
# thus just create different 'records' for Prod and Staging
- record: aws:rds:kraken_dev:max_connections_used:percent
  expr: |
    (
      sum(pg_stat_database_numbackends{datname=~"dev_kraken_db", job="atlas-victoriametrics-postgres-exporter-kraken-dev"})
      /
      sum(pg_settings_max_connections{container=~".*kraken-dev"})
    ) * 100

- alert: ExporterHighConnectionPercentBackendDevWarning
  expr: aws:rds:kraken_dev:max_connections_used:percent > 40
  for: 1s
  labels:
    severity: warning
    component: backend
    environment: dev
  annotations:
    summary: "High number of connections on the Backend RDS instance"
    description: |-
      RDS instance Connections Pool is almost full. New connections may be rejected.
      *DB instance*: `kraken-ops-rds-dev`
      *Connections pool use*: `{{ "{{" }} $value | humanize }}%`
    grafana_rds_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/ceao6muzwos1sa/kraken-rds?orgId=1&from=now-1h&to=now&timezone=browser&var-query0=&var-db_name=kraken-ops-rds-dev'

pg_stat_activity_max_tx_duration

Алерти, коли якісь транзакції виконуються надто довго.

Не сказати, що дуже корисна метрика, бо не маємо PID і кількості пам’яті, але поки що хоч так.

Потім можна буде подумати над кастомними метриками.

Зараз алерт такий:

- alert: ExporterTransactionExecutionTimeBackendDevWarning
  expr: sum(rate(pg_stat_activity_max_tx_duration{datname="dev_kraken_db"}[5m])) by (state, datname) > 0.1
  for: 1m
  labels:
    severity: warning
    component: backend
    environment: dev
  annotations:
    summary: "RDS transactions running too long"
    description: |-
      Too long duration in seconds active transaction has been running
      *Database name*: `{{ "{{" }} $labels.datname }}`
      *State*: `{{ "{{" }} $labels.state }}`
      *Duration*: `{{ "{{" }} printf "%.2f" $value }}` seconds
    grafana_rds_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/ceao6muzwos1sa/kraken-rds?orgId=1&from=now-1h&to=now&timezone=browser&var-query0=&var-db_name={{ "{{" }} $labels.datname }}'

Варто додати

Ну, тут прям дуже багато всього.

PostgreSQL Exporter custom metrics

Основне, десь вище вже згадував – в PostgreSQL Exporter ми можемо створювати кастомні метрики з результатами запитів до PostgreSQL, використовуючи config.queries.

Див. Create Prometheus integrated Postgres custom metrics.

Хоча ця фіча наче deprecated.

Але навіть якщо її вимкнуть – то можна заморочитись, і написати власний експортер. Див. Prometheus: створення Custom Prometheus Exporter на Python та Prometheus: GitHub Exporter – пишемо власний експортер для GitHub API.

CloudWatch Logs та Enhanced Monitoring

Теж вже згадував, було б дуже корисно мати власні метрики по тому ж Swap, або мати PID процесів і їхню resident memory.

How can I filter Enhanced Monitoring CloudWatch logs to generate automated custom metrics for Amazon RDS?

Моніторинг Transactions

В мене це зараз є на Grafana dashboard:

Але можливо додам окремими алертами – по кількості active transactions, або по idle in transaction.

Основні метрики з PostgreSQL Exporter:

  • pg_stat_database_xact_commit та pg_stat_database_xact_rollback: як бачили в нашому випадку – якщо значення падає, то маємо проблеми – запити не завершуються
  • pg_stat_activity: по лейблі state маємо два основні:
    • active: загальна кількість активних запитів
    • idle in transaction: теж бачили в нашому випадку, що багато запитів зависли в очікуванні завершення
  • pg_locks: кількість блоків (див. pg_locks та Concurrency Control)

Моніторинг WAL

Теж згадував кілька метрик, які є в PotsgreSQL Exporter, можливо, додам по ним або алертів, або графіків до Grafana:

  • pg_stat_archiver_archived_count: загальна кількість успішно заархівованих WAL-файлів (що скаже нам, що WAL працює коректно)
  • pg_stat_archiver_failed_count: кількість невдалих спроб архівування WAL-файлів
  • pg_stat_bgwriter_checkpoint_time: час, витрачений на виконання CHECKPOINT

В самому сервері можемо перевірити з view pg_stat_wal:

SELECT * FROM pg_stat_wal;

Основні тут:

  • wal_records: кількість записаних WAL-записів (операцій INSERT, UPDATE, DELETE)
  • wal_bytes: загальний обсяг даних (у байтах), записаних у WAL
  • wal_buffers_full: скільки разів WAL-буфери були повністю заповнені, змушуючи бекенд-процеси писати напряму в WAL-файл
  • wal_write: кількість разів, коли PostgreSQL записував WAL у файл
  • wal_write_time: загальний час у мілісекундах, витрачений на записи WAL
  • wal_sync_time: загальний час (у мілісекундах), витрачений на fsync() (гарантований запис на диск)

Моніторинг shared_buffers

Тут треба ще подумати, які б метрики можна було генерити, і які графіки або алерти придумати.

З того, що приходить в голову:

  • моніторити shared hit та read: скільки даних було знайдено в кеші, а скільки довелось зчитувати з диску
  • buffers_backend: скільки буферів записали безпосередньо бекенд-процеси
    • в нормальній ситуації всі дані з dirty pages мають записуватись bgwriter або checkpoint
    • якщо shared_buffers зайняті, а bgwriter, wal_writer або checkpointer не встигає переносити з них дані на диск – то backend-процеси клієнтів змушені переносити дані самі, що уповільнює виконання їх запитів

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

SELECT buffers_backend, buffers_checkpoint, buffers_alloc FROM pg_stat_bgwriter;

Тут:

  • buffers_backend: скільки буферів записали безпосередньо бекенд-процеси
  • buffers_checkpoint: скільки буферів записано під час CHECKPOINT
    • якщо маємо високе значення:
      • то чекпоінти відбуваються рідко, і одразу записують багато сторінок
      • або bgwriter не встигає виконувати записи, і CHECKPOINT записує все відразу
  • buffers_alloc: скільки нових буферів виділено у shared_buffers
    • якщо маємо високе значення – то кеш постійно витісняється, і PostgreSQL змушений завантажувати сторінки з диска

Моніторинг Checkpointer

Також сенс приглядати за Checkpointer:

SELECT checkpoint_write_time, checkpoint_sync_time FROM pg_stat_bgwriter;

Тут:

  • checkpoint_write_time: час, витрачений на запис змінених сторінок (dirty pages) з shared_buffers у файлову систему; якщо значення велике – то:
    • занадто великий shared_buffers – при чекпоінті доводиться записувати забагато сторінок одразу
    • багато операцій (UPDATE, DELETE), що призводить до великої кількості “брудних” сторінок (dirty pages).
    • або checkpoint_timeout занадто великий, тому при чекпоінті записується багато змін одразу.
  • checkpoint_sync_time: час, витрачений на примусовий запис (виконання fsync()) змінених сторінок на фізичний диск; якщо значення велике – то:
    • можливі проблеми з диском – повільно записуються дані

Моніторинг work_mem

Теж є сенс дивитись сюди.

Якщо work_mem недостатньо – то процеси починають писати temp_blks_written, що, по-перше, уповільнює виконання запитів, по-друге – створює додаткове навантаження на диск.

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

SELECT temp_files, temp_bytes FROM pg_stat_database WHERE datname = current_database();

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

Loading

VictoriaLogs: створення Recording Rules з VMAlert
0 (0)

8 Січня 2025

Продовжуємо міграцію з Grafana Loki на VictoriaLogs, і наступна задача – це перенести Recording Rules з Loki до VictoriaLogs, і оновити алерти.

Recording Rules та інтеграцію з VMAlert до VictoriaLogs завезли відносно недавно, і цю схему ще не тестував.

Тому спершу все зробимо руками, подивимось як це працює, які є нюанси, а потім будемо оновлювати Helm chart, яким деплоїться мій Monitoring Stack, і додавати туди нові Recording Rules.

Тож, що сьогодні:

  • встановимо VMAlert з Helm чарту в Kubernetes
  • перепишемо запит Loki LogQL на VictoriaLogs LogsQL
  • створимо VMAlert Recording Rule для генерації метрик з логів
  • протестуємо, як робити алерти з логів та Recording Rules
  • і подивимось, як цю схему можна інтегрувати в існуючий стек VictoriaMetrics

Попередні пости по VictoriaLogs:

Також див:

VictoriaLogs, Recording Rules та VMAlert

Отже, в чому полягає ідея:

  • VMAlert може робити запити до VictoriaLogs
  • в цих запитах він виконує якісь expr – як в звичайних алертах
  • по результатам цих запитів VMAlert або генерує метрику – якщо це Recording Rule – і записує її в VictoriaMetrics чи Prometheus, або генерує алерт – якщо це Alert

Тобто тут та ж сама схема, як і в Loki, і метрики з Recording Rule ми можемо використовувати не тільки для алертів, а і в Grafana dashboards.

Як завжди – у VictoriaMetrics є чудова документація:

Запуск VMAlert в Kubernetes з Helm чарту

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

Сам чарт тут – victoria-metrics-alert.

Для деплою нам знадобляться такі параметри:

  • datasource.url: адреса VictoriaLogs – до кого виконувати запити
  • notifier.url: адреса Alertmanager – куди слати алерти
  • remoteWrite.url: адреса VictoriaMetrics/Prometheus – куди записуємо метрики і стан алертів
  • remoteRead.url: адреса VictoriaMetrics/Prometheus – звідки читаємо стан алертів при рестарті VMAlert

Генеруємо values.yaml:

$ helm show values vm/victoria-metrics-alert > vmalert-test-values.yaml

Знаходимо потрібні Kubernetes Services:

$ kk -n ops-monitoring-ns get svc | grep 'alertmanager\|logs\|vmsingle'
atlas-victoriametrics-victoria-logs-single-server      ClusterIP   None             <none>        9428/TCP                     116d
vmalertmanager-vm-k8s-stack                            ClusterIP   None             <none>        9093/TCP,9094/TCP,9094/UDP   138d
vmsingle-vm-k8s-stack                                  ClusterIP   172.20.89.111    <none>        8429/TCP                     138d

Редагуємо vmalert-test-values.yaml:

...
  # VictoriaLogs Svc
  datasource:
    url: "http://atlas-victoriametrics-victoria-logs-single-server:9428"
...
  # Alertmanager Svc
  notifier:
    alertmanager:
      url: "http://vmalertmanager-vm-k8s-stack:9093"
...
  # VictoriaMetrics/Prometheus Svc
  remote:
    write:
      url: "http://vmsingle-vm-k8s-stack:8429"
...
    read:
      url: "http://vmsingle-vm-k8s-stack:8429"
...

Деплоїмо:

$ helm -n ops-monitoring-ns upgrade --install vmalert-test vm/victoria-metrics-alert -f vmalert-test-values.yaml

Перевіряємо Kubernetes Pod з VMalert:

$ kk -n ops-monitoring-ns get pod | grep vmalert-
vmalert-test-victoria-metrics-alert-server-6f485dc8b-tgcfd        1/1     Running     0             36s
vmalert-vm-k8s-stack-7d5bd6f955-dgx2r                             2/2     Running     0             47h

Тут vmalert-vm-k8s-stack-7d5bd6f955-dgx2r – це мій “дефолтний” VMAlert, а vmalert-test-victoria-metrics-alert-server – наш новий тестовий VMAlert.

Grafana Loki LogQL query => VictoriaLogs LogsQL query

В Grafana Loki в мене є такий Recording Rule:

kind: ConfigMap
apiVersion: v1
metadata:
  name: loki-alert-rules
data:
  rules.yaml: |-
    groups:
...
      - name: EKS-Pods-Metrics

        rules:

        - record: eks:pod:backend:api:path_duration:avg
          expr: |
            topk (10,
                avg_over_time (
                    {app="backend-api"} | json | regexp "https?://(?P<domain>([^/]+))" | line_format "{{.path}}: {{.duration}}"  | unwrap duration [5m]
                ) by (domain, path, node_name)
            )
...

Тут вичитуються логи з Kubernetes Pods нашого Backend API, з кожного запису створюється нове поле domain, і використовуються існуючі в логах поля path та duration.

А потім для кожного domain, path, node_name обчислюється average duration на виконання запиту.

Аби зробити аналогічний запит з VictoriaLogs LogsQL, нам потрібно:

  • вибрати логи з app:="backend-api"
  • створити поле domain
  • отримати значення path та duration
  • обчислити mean (average) за 5 хвилин по полю duration
  • згрупувати результат по полям domain, path, node_name

Знайдемо логи з VMLogs:

Далі:

  • додамо unpack_json, бо логи пишуться в JSON – парсимо його, і створюємо нові поля
  • додамо фільтр по полю http.url, бо частина записів в логах або не мають URL взагалі, або там адреса Kubernetes Pods у вигляді http://10.0.32.14:8080/ping – всякі Liveness && Readiness Probes, які нам не цікаві
  • використовуємо extract_regexp, аби з поля _msg створити нове поле domain
  • полів у нас тут забагато, всі вони нам не потрібні – використаємо fields pipe, і залишимо тільки ті, які будемо використовувати
  • можемо додати фільтр path:~".+", аби скіпнути всі записи з пустим path
app:="backend-api" | unpack_json | http.url:~"example.co" | extract_regexp "https?://(?P<domain>([^/]+))" | fields _time, path, duration, node_name, domain | path:~".+"

Замість фільтра http.url:~"example.co" можемо використати Sequence filter у формі http.url:seq("example.co") – але різниці у швидкості виконання запита не побачив:

Насправді для перформансу фільтр http.url:~"example.co" краще перенести на початок запиту, відразу за stream selector app:="backend-api", і спростити просто до Word filter "example.co" – але вже поробив скріни, тому ОК, тут нехай буде так, потім зробимо, як треба.

Тепер маємо потрібні записи, маємо потрібні поля – йдемо далі.

Далі нам потрібен stats pipe зі stats pipe function avg() за 5 хвилин зі значення в полі duration.

Додаємо в запит | stats by (_time:5m, path, node_name, domain) avg(duration) avg_duration.

Тут вже краще використати Time series візуалізацію в Grafana dashboard:

І давайте порівняємо результат з Loki.

Візьмемо якийсь домен, ноду, та URI, наприклад в Loki результат буде таким:

avg_over_time (
    {app="backend-api"} | json | regexp "https?://(?P<domain>([^/]+))" | line_format "{{.path}}: {{.duration}}" 
    | domain="api.challenge.example.co"
    | path="/coach/clients/{client_id}/accountability/groups"
    | node_name="ip-10-0-34-247.ec2.internal"
    | unwrap duration [5m]
) by (domain, path, node_name)

І в VictoriaLogs:

Значення “393” в обох випадках.

Гуд!

Тепер можемо власне переходити до Recording Rules.

Створення VictoriaLogs Recording Rules та Alerts

Для додавання Recording Rules в values чарту VMAlert є блок config.alerts.groups, в якому ми можемо з типом record описати або власне Recording Rule, або з типом alert – описати алерт.

Створення Recording Rule

Спочатку спробуємо Recording Rule.

Додаємо record: vmlogs:eks:pod:backend:api:path_duration:avg в наш файл vmalert-test-values.yaml:

...
  # -- VMAlert alert rules configuration.
  # Use existing configmap if specified
  configMap: ""
  # -- VMAlert configuration
  config:
    alerts:
      groups:
        - name: VmLogsEksPodsMetrics
          type: vlogs
          interval: 15s
          rules:
            - record: vmlogs:eks:pods:backend:api:path_duration:avg
              expr: |
                app:="backend-api" | unpack_json 
                | http.url:~"example.co" 
                | extract_regexp "https?://(?P<domain>([^/]+))" 
                | fields _time, path, duration, node_name, domain | path:~".+"
                | stats by (_time:5m, path, node_name, domain) avg(duration) avg_duration
...

Деплоїмо, глянемо логи тестового VMAlert:

$ ktail -n ops-monitoring-ns -l app.kubernetes.io/instance=vmalert-test
...
vmalert-test-victoria-metrics-alert-server-6469894c78-cmktk:vmalert {"ts":"2024-12-30T14:21:43.815Z","level":"info","caller":"VictoriaMetrics/app/vmalert/rule/group.go:486","msg":"group \"VmLogsEksPodsMetrics\" started; interval=15s; eval_offset=<nil>; concurrency=1"}
...

group \"VmLogsEksPodsMetrics\" started; – ОК.

Перевіряємо метрику vmlogs:eks:pods:backend:api:path_duration:avg в VMSingle:

Yay!

It works!

Створення Alert

Алерти можемо додати двома шляхами:

  • можемо описати новий алерт прямо в values чарту нового VMAlert, який буде виконувати запити напряму до VictoriaLogs
  • або, оскільки у нас є Recording Rule, який створює метрику – то ми можемо створити звичайний VMRule, який буде опрацьований оператором, і переданий до “дефолтного” VMAlert

Давайте спробуємо і так, і так.

Спочатку додамо алерт до файлу vmalert-test-values.yaml, поруч з нашим Recording Rule, в імені алерту вкажемо “Raw“:

...
  config:
    alerts:
      groups:
        - name: VmLogsEksPodsMetrics
          type: vlogs
          interval: 5s
          rules:

            - record: vmlogs:eks:pods:backend:api:path_duration:avg
              expr: |
                app:="backend-api" | unpack_json 
                | http.url:~"example.co" 
                | extract_regexp "https?://(?P<domain>([^/]+))" 
                | fields _time, path, duration, node_name, domain | path:~".+"
                | stats by (_time:5m, path, node_name, domain) avg(duration) avg_duration

            - alert: Test API Path duration Raw
              expr: |
                app:="backend-api" | unpack_json 
                | http.url:~"example.co" 
                | extract_regexp "https?://(?P<domain>([^/]+))" 
                | fields _time, path, duration, node_name, domain | path:~".+"
                | stats by (_time:5m, path, node_name, domain) avg(duration) as avg_duration
              for: 1s
              labels:
                severity: warning
                component: backend
                environment: dev
              annotations:
                summary: 'Test API Path duration Raw'
                description: |-
                  Request duration is too slow
                  *Domain Name*: `{{ $labels.domain }}`
                  *URI*: `{{ $labels.path }}`
                  *Duration*: `{{ $value | humanize }}`
                grafana_alb_overview_url: 'https://monitoring.ops.example.co/d/aws-alb-oveview/aws-alb-oveview?from=now-1h&to=now&var-domain={{ $labels.domain }}'
                tags: backend
...

Деплоїмо Helm з цим новим алертом:

$ helm -n ops-monitoring-ns upgrade --install vmalert-test vm/victoria-metrics-alert -f vmalert-test-values.yaml

Тепер створимо файл з VMRule з аналогічним алертом, але з метрики, яка створюється нашим Recording Rule – в ім’я алерту додаємо “VMSingle“:

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
  name: alerts-vmlogs-test
spec:

  groups:

    - name: VMAlertVMlogsTest
      rules:
        - alert: Test API Path duration VMSingle
          expr: vmlogs:eks:pods:backend:api:path_duration:avg > 0
          for: 1s
          labels:
            severity: warning
            component: backend
            environment: dev
          annotations:
            summary: 'Test API Path duration VMSigle'
            description: |-
              Request duration is too slow
              *Domain Name*: `{{ $labels.domain }}`
              *URI*: `{{ $labels.path }}`
              *Duration*: `{{ $value | humanize }}`
            grafana_alb_overview_url: 'https://monitoring.ops.example.co/d/aws-alb-oveview/aws-alb-oveview?from=now-1h&to=now&var-domain={{ $labels.domain }}'
            tags: backend

Деплоїмо його:

$ kk -n ops-monitoring-ns apply -f test-alert.yaml
vmrule.operator.victoriametrics.com/alerts-vmlogs-test created

І чекаємо повідомлення від Alertmanager в Slack:

Гуд!

Працює.

Тепер можемо переносити цей конфіг до загального Helm-чарту нашого моніторингу.

VictoriaLogs, VMAlert, та чарт victoria-metrics-k8s-stack

Отже, в моєму проекті є наш власний чарт, в якому через Helm dependencies встановлюються такі чарти:

apiVersion: v2
name: atlas-victoriametrics
description: A Helm chart for Atlas Victoria Metrics Kubernetes monitoring stack
type: application
version: 0.1.1
appVersion: "1.17.0"
dependencies:
- name: victoria-metrics-k8s-stack
  version: ~0.31.0
  repository: https://victoriametrics.github.io/helm-charts
- name: victoria-metrics-auth
  version: ~0.8.0
  repository: https://victoriametrics.github.io/helm-charts
  condition: victoria-metrics-auth.enabled
- name: victoria-logs-single
  version: ~0.8.0
  repository: https://victoriametrics.github.io/helm-charts
...

А далі в values.yaml для кожного сабчарту задаються параметри.

VMAlert: datasource.url, VictoriaMetrics та VictoriaLogs

Що нам треба – це додати інтеграцію VMAlert з VictoriaLogs сюди, але є нюанс: VMAlert може мати тільки один параметр datasource.url, в якому зараз заданий Kubernetes Service з VMSingle – звідки VMAlert бере метрики для обчислення умов існуючих алертів:

$ kk -n ops-monitoring-ns describe pod vmalert-vm-k8s-stack-7d5bd6f955-m6mz4
...
Containers:
  vmalert:
    ...
    Args:
      -datasource.url=http://vmsingle-vm-k8s-stack.ops-monitoring-ns.svc.cluster.local.:8429
...

Але ж нам треба задати адресу VictoriaLogs, і при цьому залишити можливість запитів до VMSingle.

В документації VictoriaLogs How to use one vmalert for VictoriaLogs and VictoriaMetrics rules in the same time? описуються два варіанти рішення:

  • або просто мати два окремих інстанси VMAlert – один для метрик з VictoriaLogs, другий – для роботи з VictoriaLogs
  • або використати VMAuth, і в залежності від URI запиту від VMAlert роутити запити на потрібний бекенд – або VictoriaMetrics/VMSingle, або VictoriaLogs

Опція 1: два інстанси VMAlert

Перший варіант – запускати два VMAlert, і кожному передати власний datasource.url.

Але є питання – як в різні VMAlert передавати Recording Rules та власне Алерти?

Бо в мене Алерти описуються через ресурси VMRules, які з VictoriaMetrics Operator записуються в ConfigMap, який потім підключається до мого “дефолтного” VMAlert:

$ kk -n ops-monitoring-ns describe pod vmalert-vm-k8s-stack-7d5bd6f955-m6mz4
...
Volumes:
  ...
  vm-vm-k8s-stack-rulefiles-0:
    Type:      ConfigMap (a volume populated by a ConfigMap)
    Name:      vm-vm-k8s-stack-rulefiles-0
...

І цей ConfigMap містить в собі всі алерти:

$ kk get cm vm-vm-k8s-stack-rulefiles-0 -o yaml | head -n 30
apiVersion: v1
data:
  ops-monitoring-ns-alerts-alertmanager.yaml: |
    groups:
    - name: VM.Alertmanager.rules
      rules:
      - alert: Alertmanager Failed To Send Alerts
        annotations:
          description: |-
            Alertmanager failed to send {{ $value | humanizePercentage }} of notifications
            *Kubernetes cluster*: `{{ $labels.cluster }}`
            *Pod*: `{{ $labels.pod }}`
            *Integration*:  `{{ $labels.integration }}`
          summary: Alertmanager Failed To Send Alerts
          tags: devops
        expr: |-
          sum(
            rate(alertmanager_notifications_failed_total [5m])
            /
            rate(alertmanager_notifications_total [5m])
          ) by (cluster, integration, pod)
          > 0.01
        for: 1m
        labels:
          component: devops
          environment: ops
          severity: warning
  ops-monitoring-ns-alerts-aws-alb.yaml: |
    groups:
    - name: AWS.ALB.Logs.rules

Якщо робити схему з двома інстансами VMAlert з різними datasource.url – то для інстансу, який буде робити запити до VictoriaLogs нам потрібно створювати власний ConfigMap, і маунтити його з вальюсів цього інстансу VMAlert, без VMRules і участі VM Operator.

Хоча технічно, мабуть, можливо мати VMRules з Recording Rules та Alerts і два інстанси VMAlert, де в кожен інстанс будуть мапитись один і той самий ConfigMap і з RecordingRules, і з Alerts – але тоді один VMAlert буде постійно писати про помилки запитів до VictroriaMetrcis, а другий – про помилки запитів до VictoriaLogs.

Тому тут бачу тільки варіант з окремим ConfgiMap для RecordingRules, і окремо мати VMRules для алертів, як воно є зараз.

Мені така схема якось не дуже подобається, бо я хотів би і RecordingRules, і Алерти описувати через VMRules.

ОК, тоді розглянемо інший варіант – з VMAuth.

Опція 2: VMAuth і src_paths

Другий варіант – редіректити запити від єдиного інстансу VMAlert до VictoriaLogs та VictoriaMetrics/VMSingle через VMAuth.

В мене VMAuth вже є, писав про нього в пості VictoriaMetrics: VMAuth – проксі, аутентифікація та авторизація, де налаштована аутентифікація і вже є роути – я ним користуюсь для доступу до деяких внутрішніх ресурсів, коли мені ліньки робити kubectl port-forward.

Що нам треба – це додати ще пару src_paths:

  • /api/v1/query.* – для запитів до VictoriaMetrics/VMSingle
  • /select/logsql/.* – для запитів до VictoriaLogs

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

apiVersion: v1
kind: Secret
metadata:
  name: vmauth-config-secret
stringData:
  auth.yml: |-
    users:
    - username: vmadmin
      password: {{ .Values.vmauth_password }}
      url_map:
      - src_paths:
        - /alertmanager.*
        url_prefix: http://vmalertmanager-vm-k8s-stack.ops-monitoring-ns.svc:9093
      - src_paths:
        - /vmui.*
        url_prefix: http://vmsingle-vm-k8s-stack.ops-monitoring-ns.svc:8429
      - src_paths:
        - /prometheus.*
        url_prefix: http://vmsingle-vm-k8s-stack.ops-monitoring-ns.svc:8429
      - src_paths:
        - /api/v1/query.*
        url_prefix: http://vmsingle-vm-k8s-stack:8429
      - src_paths:
        - /select/logsql/.*
        url_prefix: http://atlas-victoriametrics-victoria-logs-single-server:9428
      default_url:
        - http://vmalertmanager-vm-k8s-stack.ops-monitoring-ns.svc:9093

Цей Secret передається в values для VMAuth:

...
victoria-metrics-auth:
  ingress:
    enabled: true
  ...
  secretName: vmauth-config-secret
...

Якщо у вас VMAuth не використовується, або працює без паролю – то простіше, бо для VMAlert просто можна задати datasource.url.

Якщо ж потрібна аутентифікація – то додамо ще один Kubernetes Secret з логіном та паролем:

apiVersion: v1
kind: Secret
metadata:
  name: vmauth-password
stringData:
  username: vmadmin
  password: {{ .Values.vmauth_password }}

Далі в вальюсах для VMAlert додаємо datasource.url та datasource.basicAuth:

...
  vmalert:
    annotations: {}
    enabled: true
    spec:
      datasource:
        basicAuth:
          username:
            name: vmauth-password
            key: username
          password:
            name: vmauth-password
            key: password
        url: http://atlas-victoriametrics-victoria-metrics-auth:8427    
...

Тут:

  • поле spec для VMAlert описується в VMAlertSpec і має поле datasource
    • поле datasource описується в VMAlertDatasourceSpec і має поля basicAuth та url
      • поле basicAuth описується в basicauth і має два поля – password та username
        • поля password та username описуються в SecretKeySelector, і мають два поля – name та key
          • поле name: ім’я Kubernetes Secret
          • поле key: ключ в цьому сікреті

Деплоїмо, і тепер наш VMAlert відправляє запити для алертів на VMAuth, а VMAuth редіректить їх до url_prefix: http://vmsingle-vm-k8s-stack:8429.

Додавання VMRule з RecordingRule

Тепер додамо новий VMRule, в якому опишемо RecordingRule, в якому будемо генерити метрику vmlogs:eks:pods:backend:api:path_duration:avg:

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
  name: vmlogs-alert-rules
spec:

  groups:

    - name: VM-Logs-Backend-Pods-Logs
      # an expressions for the VictoriaLogs datasource
      type: vlogs
      rules:
        - record: vmlogs:eks:pods:backend:api:path_duration:avg
          expr: |
            app:="backend-api" "example.co" | unpack_json 
            | extract_regexp "https?://(?P<domain>([^/]+))" 
            | fields _time, path, duration, node_name, domain | path:~".+"
            | stats by (_time:5m, path, node_name, domain) avg(duration) avg_duration

Деплоїмо, перевіряємо новий VMRule:

$ kk get vmrule | grep vmlogs
vmlogs-alert-rules                                      4s              

Логи VMAlert – нова група створена:

$ ktail -l app.kubernetes.io/name=vmalert
...
vmalert-vm-k8s-stack-6c5cb6d76d-dxpbf:vmalert 2025-01-08T13:30:43.609Z  info    VictoriaMetrics/app/vmalert/rule/group.go:486   group "VM-Logs-Backend-Pods-Logs" will start in 1.540718685s; interval=15s; eval_offset=<nil>; concurrency=1
vmalert-vm-k8s-stack-6c5cb6d76d-dxpbf:vmalert 2025-01-08T13:30:45.151Z  info    VictoriaMetrics/app/vmalert/rule/group.go:486   group "VM-Logs-Backend-Pods-Logs" started; interval=15s; eval_offset=<nil>; concurrency=1
...

І перевіряємо нову метрику в VMSingle:

Готово.

Тепер можна мігрувати решту Recording Rules з Loki до VictoriaLogs.

Loading

Vector.dev: знайомство, логи з AWS S3 та інтеграція з VictoriaLogs
0 (0)

17 Грудня 2024

Отже, знов повертаємось до теми AWS VPC Flow Logs, VictoriaLogs, та Grafana dashboard.

В пості VictoriaLogs: дашборда в Grafana з AWS VPC Flow Logs – мігруємо з Grafana Loki ми створили прикольну дашборду для відображення різної статистики по трафіку AWS NAT Gateway.

Але там є маленький недолік – всі дані будуються з raw logs, які пишуться з VPC Flow Logs в AWS S3, з S3 їх збирає Promtail в AWS Lambda, і потім пише до VictoriaLogs.

Проблема: перформанс з raw logs

В цій Grafana dashboard з VictoriaLogs виконуються запити типу:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>" keep_original_fields
  | filter 
    interface_id:="eni-0352f8c82da6aa229"
    action:=ACCEPT
    pkt_dst_addr:ipv4_range("10.0.32.0/20")
    pkt_dst_addr:~"${kubernetes_pod_ip}"
    pkt_src_addr:~"${remote_svc_ip}"    
  | stats by (pkt_src_addr) sum(bytes) sum_bytes
  | sort by (sum_bytes) desc limit 10

Де з extract отримуємо значення для нових полів прямо із логу.

І все це більш-менш працює, але максимальний період, за який вдається побудувати графіки – 24 години (з Loki було взагалі 30 хвилин).

Але є інший варіант роботи з логами: замість того, аби парсити поля прямо під час виконання запиту з використанням exctract – ми можемо створювати ці поля ще на етапі збору логів з S3, і далі в запитах використовувати вже їх.

В принципі, це можна було б зробити прямо з поточним сетапом – через Promtail. Щось схоже я робив в Grafana Loki: alerts from Ruler and labels from logs, але – ну не хочеться мені мати справу з Lambda Promtail від Grafana, бо мені навіть не вдалося оновити версію Promtail в моєму Docker image – а я не пам’ятаю, як робив перший. Тому в мене Promtail в Lambda досі той, який я створив ще у 2023 році – див. Loki: збір логів з CloudWatch Logs з використанням Lambda Promtail.

Тому замість Promtail вирішив спробувати Vector.dev. Він трохи складний в налаштуванні, але має просто безліч можливостей.

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

Тож сьогодні зробимо простенький Proof of Concept з Flow Logs, Vector.dev та VictoriaLogs:

  • встановимо Helm-чарт з Vector
  • створимо новий AWS S3, налаштуємо VPC Flow Logs з custom format для запису в цей бакет
  • подивимось, як ми можемо збирати логи з S3 до Vector.dev і додавати нові поля
  • і порівняємо швидкість роботи з raw logs vs логи з Vector з полями

Vector.dev

Отже, що таке Vector.dev?

Vector is a high-performance observability data pipeline that puts organizations in control of their observability data. Collecttransform, and route all your logs, metrics, and traces to any vendors

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

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

Components

Див. Concepts.

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

  • Sources: звідки збираємо дані
  • Transforms: що ми з даними робимо
  • Sinks: куди ми оброблені дані передаємо далі

В нашому випадку Sources буде AWS S3, в Transforms – будемо парсити логи VPC FLow logs і створювати нові fields, а в Sinks – використаємо Elasticsearch Sink для VictoriaLogs, див. документацію по Vector setup в VictoriaLogs docs.

Взагалі, Vector має окремий Loki Sink, але з ним більше проблем, ніж користі, а з Elasticsearch (або HTTP) все запрацювало без проблем.

Запуск в Kubernetes з Helm

Документація по запуску з Helm – в Install Vector on Kubernetes та в самому чарті – README.md.

Додаємо собі новий репозиторій:

$ helm repo add vector https://helm.vector.dev
"vector" has been added to your repositories
$ helm repo update

Встановлюємо Vector – поки з дефолтними параметрами, потім створимо власний values.yaml:

$ helm install vector vector/vector
NAME: vector
LAST DEPLOYED: Mon Dec  2 15:13:30 2024
...

Переходимо до VPC Flow Logs.

Налаштування AWS VPC Flow Logs до S3

Далі, нам потрібна S3 корзина, в яку ми будемо писати VPC Flow Logs, і SQS, в яку будуть відправлятись повідомлення, коли в S3 створюються нові об’єкти, тобто логи.

Потім Vector буде читати повідомлення з цієї SQS, і забирати логи з S3.

Створення AWS SQS

Документація по SQS для S3 – Walkthrough: Configuring a bucket for notifications (SNS topic or SQS queue).

Створюємо нову чергу:

Тип – Standart:

Задаємо Access policy:

{
  "Version": "2012-10-17",
  "Id": "example-ID",
  "Statement": [
    {
      "Sid": "vpc-ops-flow-vmlogs-s3-allow",
      "Effect": "Allow",
      "Principal": {
        "Service": "s3.amazonaws.com"
      },
      "Action": "SQS:SendMessage",
      "c": "arn:aws:sqs:us-east-1:492***148:s3-vector-vmlogs-queue",
      "Condition": {
        "StringEquals": {
          "aws:SourceAccount": "492***148"
        },
        "ArnLike": {
          "aws:SourceArn": "arn:aws:s3:*:*:s3-vector-vmlogs-flow-logs-bucket"
        }
      }
    }
  ]
}

В Resource вказуємо ім’я нашої queue, а в Condition – дозволяємо доступ з ID нашого акаунту та S3-бакету з ім’ям s3-vector-vmlogs-flow-logs-bucket:

Тут все – параметри Dead-letter queue залишаємо дефолтні, клікаємо Create, і переходимо до S3.

Створення AWS S3

Створюємо новий S3 бакет з ім’ям s3-vector-vmlogs-flow-logs-bucket – як ми задали в SQS Access Policy.

ACL нам зараз не потрібна, але Block Public Access лишаємо в дефолтному Block All:

 

Клікаємо Create, переходимо в Properties > Event notifications:

Задаємо Event name, в Event types вибираємо s3:ObjectCreated:*:

В Destination задаємо нашу SQS:

Клікаємо Save changes, і переходимо вже до VPC Flow Logs.

Створення VPC Flow Logs до S3

Створюємо новий Flow Log.

Якщо у вас VPC створюється з Terraform – то можна використати ресурс aws_flow_log:

resource "aws_flow_log" "vpc_flow_vector" {
  vpc_id               = module.vpc.vpc_id
  log_destination      = "arn:aws:s3:::s3-vector-vmlogs-flow-logs-bucket"
  log_destination_type = "s3"
  traffic_type         = "ALL"
  log_format = "$${region} $${vpc-id} $${az-id} $${subnet-id} $${instance-id} $${interface-id} $${flow-direction} $${srcaddr} $${dstaddr} $${srcport} $${dstport} $${pkt-srcaddr} $${pkt-dstaddr} $${pkt-src-aws-service} $${pkt-dst-aws-service} $${traffic-path} $${packets} $${bytes} $${action}"
  tags = {
    "Name" = "flow-logs-s3-to-vector"
  }
}

Або робимо руками – переходимо в VPC, вкладка Flow logs, клікаємо Create flow log – тут я вже маю два Flow Logs для Promtail Lambda:

В Destination задаємо Send to an Amazon S3 bucket, і вказуємо ARN нашого бакета:

Я завжди використовую Custom format з додатковими полями:

Зберігаємо, і перевіряємо статус:

Все зелененьке, працює.

Можна зачекати 10 хвилин (дефолтний період доставки логів), і перевірити дані в самій S3:

І вкладку Monitoring в SQS:

Налаштування Vector.dev

Ну а тепер саме цікаве.

Отже, що нам треба:

  • додати Source S3 з параметром SQS – звідки будемо збирати логи
  • додати трансформацію – створення нових fields
  • і додати Sink для VictoriaLogs – куди будемо писати

Тобто створюється такий собі pipeline – Source збирає дані, Transform їх трансформує, а Sink – передає оброблені дані далі, в нашому випадку до VictoriaLogs.

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

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

Документація по всім Sinks – тут>>>, і по Loki – тут>>>, але ми будемо використовувати інший, Elasticsearch.

Документація по Elasticsearch Sink в Vector.dev – тут>>>, і документація по Elasticsearch data ingest в VictoriaLogs – тут>>>.

Також може бути цікавим – як з Vector збирати логи зі звичайних файлів – тут>>>.

І ще цікавий use case – збирати логи Kubernetes, і пушити їх в AWS S3 – див. How to Collect, Transform, and Ship Logs from AWS S3 to Codegiant Observability Using Vector.

З документацією розібрались – поїхали конфігуряти.

Vector.dev: Sources – S3

Першим налаштуємо збір логів з AWS S3 бакету. Для цього нам потрібні такі параметри:

  • type: aws_s3
  • auth: як будемо виконувати аутентифікацію
    • поки зробимо банальним Access/Secret ключами, коли будемо це запускати в Production – то додамо EKS Pod Identity з IAM Role, яка буде дозволяти доступ Kubernetes Pod з Vector до S3 та SQS
  • sqs.queue_url: звідки Vector буде отримувати інформацію, що в S3 з’явились нові логи

Задавати параметри будемо через Helm chart values і параметр customConfig, до якого є важливий коментар:

# customConfig — Override Vector’s default configs, if used **all** options need to be specified.

Тобто, нам потрібно буде задати всі параметри.

Тому зараз конфіг буде таким:

image:
  repository: timberio/vector
  pullPolicy: IfNotPresent

replicas: 1

service:
  enabled: false

customConfig:

  sources:
    s3-vector-vmlogs-flow-logs-bucket: # source name to be used later in Transforms
      type: aws_s3
      region: us-east-1
      compression: gzip
      auth:
        region: us-east-1
        access_key_id: AKI***B7A
        secret_access_key: pAu***2gW
      sqs:
        queue_url: https://sqs.us-east-1.amazonaws.com/492***148/s3-vector-vmlogs-queue

Vector.dev: Transforms – remap та VRL

Transforms є багато, але нам зараз цікавий remap, в якому з Vector Remap Language (VRL) ми можемо виконувати прям безліч всяких операцій.

VRL – це domain-specific language (DSL) для самого Vector.dev, в якому є різні функції для роботи з даними.

Є навіть VRL Playground, де можна спробувати що і як працює.

З того, що може бути цікавим нам – це Parse functions, а саме – функція parse_aws_vpc_flow_log. А для роботи з AWS Load Balancer logs – є функція parse_aws_alb_log.

Сама parse_aws_vpc_flow_log описується тут – parse_aws_vpc_flow_log.rs.

А приклади є тут – VRL example reference.

Що ми нею можемо зробити – передати їй на “вхід” дані з наших логів, і задати custom format.

Самий простий конфіг, з яким власне все працює так, як мені треба, виглядає так:

...
  transforms:

    s3-vector-vmlogs-flow-logs-transform:
      type: remap
      inputs:
        - s3-vector-vmlogs-flow-logs-bucket # a name from the 'sources', can have several Inputs
      source: |
        . = parse_aws_vpc_flow_log!(
          .message,
          format: "region vpc_id az_id subnet_id instance_id interface_id flow_direction srcaddr dstaddr srcport dstport pkt_srcaddr pkt_dstaddr pkt_src_aws_service pkt_dst_aws_service traffic_path packets bytes action"
        )

Якщо хочеться виконати якісь операції над полями – то можна оформити таким чином:

...
      source: |
        .parsed = parse_aws_vpc_flow_log!(
          .message,
          format: "region vpc_id az_id subnet_id instance_id interface_id flow_direction srcaddr dstaddr srcport dstport pkt_srcaddr pkt_dstaddr pkt_src_aws_service pkt_dst_aws_service traffic_path packets bytes action"
        )

        .region = .parsed.region
        .vpc_id = .parsed.vpc.id
        .az_id = .parsed.az_id
        .subnet_id = .parsed.subnet_id
        .instance_id = .parsed.instance_id
        .interface_id = .parsed.interface_id
        .account_id = .parsed.account_id
        .srcaddr = .parsed.srcaddr
        .dstaddr = .parsed.dstaddr
        .srcport = .parsed.srcport
        .dstport = .parsed.dstport
        .protocol = .parsed.protocol
        .packets = to_int(.parsed.packets)
        .bytes = to_int(.parsed.bytes)

        del(.parsed)
...

Тут ми створюємо власні поля region, vpc_id etc, приводимо поля packets та bytes до типу integer, і в кінці видаляємо весь message з .parsed викликом Path function del().

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

Vector.dev: Sinks – Elasticsearch та VictoriaLogs

І останнім нам потрібно задати Sink.

Я пробував це робити з Loki Sink, але з ним так і не вийшло правильно оформити нові поля, тому по рекомендації розробників VictoriaLogs просто взяв Elasticsearch Sink.

Описуємо наш конфіг:

...
  sinks:

    s3-flow-logs-to-victorialogs:
      inputs:
        - s3-vector-vmlogs-flow-logs-transform # a Transform name to get processed data from
      type: elasticsearch
      endpoints:
        - http://atlas-victoriametrics-victoria-logs-single-server:9428/insert/elasticsearch/ # VictoriaLogs Kubernetes Service URL and Elasticsearch endpoint
      api_version: v8
      compression: gzip
      healthcheck:
        enabled: false
      query: # HTTP query params
        extra_fields: source=vector # add a custom label
        # _msg_field: message # ommited here, as we have everything in the fields from the Transform, but may be used for other data
        _time_field: timestamp # set the '_time' field for the VictoriaLogs
        _stream_fields: source,vpc_id,az_id # create Stream fields for the VictoriaLogs to save data in a dedicated Stream; specify fields without spaces

Власне, я тут наче все додав в коменти, але пройдемось ще:

  • inputs: задаємо ім’я Transform, з якого беремо дані
  • endpoints: передаємо адресу VictoriaLogs в нашому Kubernetes кластері
  • healthcheck: відключаємо, бо VictoriaLogs поки не підтримує /ping ендпоінт
  • query: передаємо додаткові параметри, див. VictoriaLogs HTTP
    • в _stream_fields описуємо по яким полям VictoriaLogs буде створювати log stream – див. Stream fields

Весь values тепер виглядає так:

image:
  repository: timberio/vector
  pullPolicy: IfNotPresent

replicas: 1

service:
  enabled: false

customConfig:

  sources:
    s3-vector-vmlogs-flow-logs-bucket: # source name to be used later in Transforms
      type: aws_s3
      region: us-east-1
      compression: gzip
      auth:
        region: us-east-1
        access_key_id: AKI***B7A
        secret_access_key: pAu***2gW
      sqs:
        queue_url: https://sqs.us-east-1.amazonaws.com/492***148/s3-vector-vmlogs-queue

  transforms:

    s3-vector-vmlogs-flow-logs-transform: # a name from the 'sources', can have several Inputs
      type: remap
      inputs:
        - s3-vector-vmlogs-flow-logs-bucket
      source: |
        . = parse_aws_vpc_flow_log!(
          .message,
          format: "region vpc_id az_id subnet_id instance_id interface_id flow_direction srcaddr dstaddr srcport dstport pkt_srcaddr pkt_dstaddr pkt_src_aws_service pkt_dst_aws_service traffic_path packets bytes action"
        )

  sinks:

    s3-flow-logs-to-victorialogs:
      inputs:
        - s3-vector-vmlogs-flow-logs-transform # a Transform name to get processed data from
      type: elasticsearch
      endpoints:
        - http://atlas-victoriametrics-victoria-logs-single-server:9428/insert/elasticsearch/ # VictoriaLogs Kubernetes Service URL and Elasticsearch endpoint
      api_version: v8
      compression: gzip
      healthcheck:
        enabled: false
      query: # HTTP query params
        extra_fields: source=vector # add a custom label
        # _msg_field: message # ommited here, as we have everything in the fields from the Transform, but may be used for other data
        _time_field: timestamp # set the '_time' field for the VictoriaLogs
        _stream_fields: source,vpc_id,az_id # create Stream fields for the VictoriaLogs to save data in a dedicated Stream; specify fields without spaces

Деплоїмо наші зміни:

$ helm upgrade --install vector vector/vector -f vector-values.yaml

В логах чомусь помилка обробки поля srcport з Flow Logs:

ERROR transform{component_kind="transform" component_id=s3-vector-vmlogs-flow-logs-transform component_type=remap}: vector::internal_events::remap: Mapping failed with event. error="function call error for \"parse_aws_vpc_flow_log\" at (4:254): failed to parse value as i64 (key: `srcport`): `srcport`" error_type="conversion_failed" stage="processing" internal_log_rate_limit=true

Чому – не знаю, бо поле таке саме і в Flow Logs, і в нашому custom format. Але воно наче ні на що не впливає, пізніше зроблю GitHub Issue, спитаю.

Чекаємо, коли з S3 прийдуть дані, і перевіряємо в нашій VictoriaLogs, використовуючи _stream: {source="vector", vpc_id="vpc-0fbaffe234c0d81ea", az_id="use1-az2"} – поля, які ми задавали в _stream_fields:

Вау!

“It works!” (c)

Grafana та VictoriaLogs

Давайте глянемо, як це все працює в Grafana.

Спершу – просто перевіримо дані там:

В моїй Grafana dashboard є така панелька:

З таким запитом:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>" keep_original_fields
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      pkt_dst_addr:ipv4_range("10.0.32.0/20")
      pkt_dst_addr:~"${kubernetes_pod_ip}"
      pkt_src_addr:~"${remote_svc_ip}"
  | stats by (pkt_src_addr, src_port, pkt_dst_addr, dst_port) sum(bytes) bytes_total
  | sort by (bytes_total) desc limit 10

Перепишемо цей запит під нові дані – використовуємо новий stream, і приберемо filter, бо у там тепер є готові поля – виконуємо виборку відразу по ним:

{source="vector", vpc_id="vpc-0fbaffe234c0d81ea", az_id="use1-az2"} interface_id:="eni-0352f8c82da6aa229" action:="ACCEPT" pkt_dstaddr:ipv4_range("10.0.32.0/20")
  | stats by (pkt_srcaddr, srcport, pkt_dstaddr, dstport) sum(bytes) bytes_total 
  | sort by (bytes_total) desc

Performance: “raw logs” vs “fielded logs”

І порівняємо швидкість такого запиту із запитом з сирих логів.

Старий запит, візьмемо 3 години:

Новий запит за ті ж 3 години:

Різниця у 2 рази.

При цьому ресурси самого Vector:

$ kk top pod vector-0
NAME       CPU(cores)   MEMORY(bytes)   
vector-0   3m           104Mi           

І VictoriaLogs:

$ kk top pod atlas-victoriametrics-victoria-logs-single-server-0
atlas-victoriametrics-victoria-logs-single-server-0   12m   840Mi

Можна пробувати цю схему запускати в Production.

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

Loading

Nexus: запуск в Kubernetes та налаштування PyPi caching repository
0 (0)

11 Грудня 2024

У нас в Kubernetes запускаються GitHub Runner для білда і деплоя нашого Backend API, див. GitHub Actions: запуск Actions Runner Controller в Kubernetes.

Але з часом ми звернули увагу, що на NAT Gateway бігає якось забагато трафіку – див. VictoriaLogs: дашборда в Grafana з AWS VPC Flow Logs – мігруємо з Grafana Loki.

Проблема: трафік на AWS NAT Gateway

Коли почали перевіряти, то виявили цікаву деталь:

Тут через NAT GW пройшло 40.8 гігабайт даних за годину, з них 40.7 – Ingress.

З цих 40 GB в топі три Remote IP, кожен з яких передав нам майже по 10 GB трафіку (табличка зліва внизу на скріні вище).

В топі Remote IP у нас:

Remote IP       Value     Percent
------------------------------
20.60.6.4       10.6 GB	  28%
20.150.90.164   9.79 GB	  26%
20.60.6.100     8.30 GB	  22%
185.199.111.133 2.06 GB	  5%
185.199.108.133 1.89 GB	  5%
185.199.110.133 1.78 GB	  5%
185.199.109.133 1.40 GB	  4%
140.82.114.4    805  MB	  2%
146.75.28.223   705  MB	  2%
54.84.248.61    267  MB	  1%

А в топі по трафіку в Kubernetes – у нас чотири Kubernetes Pods IP:

Source IP        Pod IP      Value	Percent
-----------------------------------------------
20.60.6.4     => 10.0.43.98  1.54 GB	14%
20.60.6.100   => 10.0.43.98  1.49 GB	14%
20.60.6.100   => 10.0.42.194 1.09 GB	10%
20.150.90.164 => 10.0.44.162 1.08 GB	10%
20.60.6.4     => 10.0.44.208 1.03 GB	9%

І всі ці IP належать до подів з GitHub Runners, а “kraken” в імені – це як раз ті раннери для білдів і деплоїв нашого проекту “kraken“, бекенду:

Далі – цікавіше: якщо перевірити IP https://20.60.6.4 – то побачимо цікавий hostname:

*.blob.core.windows.net???

Шта? Дуже здивувався, бо у нас білдиться Python, і ніяких бібліотек від Mifcrosoft нема. Але потім з’явилась ідея: через те, що ми використовуємо кешування PiP і Docker в GitHub Actions для білдів Backend API, то скоріш за все це саме GitHub storage і є, і саме з нього ми ці кеши тягнемо в Kubernetes.

Аналогічна перевірка 185.199.111.133 та 140.82.114.4 нам показує *.github.io, а 54.84.248.61 – це вже athena.us-east-1.amazonaws.com.

Отже, що вирішили зробити – це запустити в Kubernetes локальне кешування з Sonatype Nexus, і його використовувати як проксі для PyPi.org і для Docker Hub images.

Про Docker caching поговоримо наступного разу, а сьогодні:

  • протестуємо Nexus локально з Docker на робочій машині
  • запустимо Nexus в Kubernetes з Helm-чарту
  • налаштуємо і перевіримо роботу PyPi cache для білдів
  • і подивимось на результати

Nexus: тестування локально з Docker

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

$ docker run -ti --rm --name nexus -p 8081:8081 sonatype/nexus3

Чекаємо кілька хвилин, бо Nexus на Java, тому стартує довго.

Отримуємо пароль адміна:

$ docker exec -ti nexus cat /nexus-data/admin.password
6221ad20-0196-4771-b1c7-43df355c2245

В браузері переходимо на http://localhost:8081, логінимось:

Якщо не зробили в Setup wizard, то заходимо в Security > Anonymous access, дозволяємо підключатись без аутентифікації:

Додавання репозиторію pypi (proxy)

Переходимо в Settings > Repositories, клікаємо Create repository:

Вибираємо тип pypi (proxy):

Створюємо репозиторій:

  • Name: pypi-proxy
  • Remote storage: https://pypi.org
  • Blob store: default

Знизу клікаємо Create repository.

Перевіримо які дані у нас зараз в default Blob storage – заходимо в контейнер Nexus:

$ docker exec -ti nexus bash                          
bash-4.4$

І дивимось каталог /nexus-data/blobs/default/content/ – зараз тут пусто:

bash-4.4$ ls -l /nexus-data/blobs/default/content/
total 8
drwxr-xr-x 3 nexus nexus 4096 Nov 27 11:02 directpath
drwxr-xr-x 2 nexus nexus 4096 Nov 27 11:02 tmp

Перевірка Nexus PyPi cache

Тепер перевіримо чи наш проксі-кеш працює.

Знаходимо IP контейнера з Nexus:

$ docker inspect nexus | jq '.[].NetworkSettings.IPAddress'
"172.17.0.2"

Запускаємо ще один контейнер з Python:

$ docker run -ti --rm python bash
root@addeba5d307c:/# 

І виконуємо pip install --index-url http://172.17.0.2:8081/repository/pypi-proxy/simple setuptools --trusted-host 172.17.0.2

root@addeba5d307c:/# time pip install --index-url http://172.17.0.2:8081/repository/pypi-proxy/simple  setuptools  --trusted-host 172.17.0.2
Looking in indexes: http://172.17.0.2:8081/repository/pypi-proxy/simple
Collecting setuptools
  Downloading http://172.17.0.2:8081/repository/pypi-proxy/packages/setuptools/75.6.0/setuptools-75.6.0-py3-none-any.whl (1.2 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 81.7 MB/s eta 0:00:00
Installing collected packages: setuptools
Successfully installed setuptools-75.6.0
...
real    0m2.595s
...

Бачимо, що виконався Downloading, і це зайняло 2.59 секунди.

Глянемо, що у нас тепер в default Blob storage в Nexus:

bash-4.4$ ls -l /nexus-data/blobs/default/content/
total 20
drwxr-xr-x 3 nexus nexus 4096 Nov 27 11:02 directpath
drwxr-xr-x 2 nexus nexus 4096 Nov 27 11:21 tmp
drwxr-xr-x 3 nexus nexus 4096 Nov 27 11:21 vol-05
drwxr-xr-x 3 nexus nexus 4096 Nov 27 11:21 vol-19
drwxr-xr-x 3 nexus nexus 4096 Nov 27 11:21 vol-33

Вже якісь дані з’явились, ок.

Тестуємо pip ще раз – спочатку видалимо встановлений пакет:

root@addeba5d307c:/# pip uninstall setuptools

І встановлюємо його ще раз, але тепер додаємо --no-cache-dir, аби не використовувати локальний кеш в контейнері:

root@5dc925fe254f:/# time pip install --no-cache-dir --index-url http://172.17.0.2:8081/repository/pypi-proxy/simple setuptools --trusted-host 172.17.0.2
Looking in indexes: http://172.17.0.2:8081/repository/pypi-proxy/simple
Collecting setuptools
  Downloading http://172.17.0.2:8081/repository/pypi-proxy/packages/setuptools/75.6.0/setuptools-75.6.0-py3-none-any.whl (1.2 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 942.9 MB/s eta 0:00:00
Installing collected packages: setuptools
Successfully installed setuptools-75.6.0
...
real    0m1.589s

Тепер часу зайняло 1.52 секунди замість 2.59.

Окей – наче все працює?

Давайте запустимо Nexus в Kubernetes.

Запуск Nexus в Kubernetes

Є такий чарт – stevehipwell/nexus3.

Можна написати маніфести самому, можна спробувати цей чарт.

Що нам може бути цікаво з вальюсів чарту:

  • config.anonymous.enabled: працювати Nexus буде локально в Kubernetes з доступом тільки по ClusterIP, тому поки це в PoC і чисто для кешу PiP – можна без аутентифікації
  • config.blobStores: поки можна залишити як є, але пізніше, можливо, підключити окремий EBS або AWS Elastic File System, див. також persistence.enabled
  • config.job.tolerations та nodeSelector: якщо треба ранити на окремій ноді, див. Kubernetes: Pods та WorkerNodes – контроль розміщення подів на нодах
  • config.repos: відразу через values створити репозиторії
  • ingress.enabled: не наш кейс, але можливість є
  • metrics.enabled: потім можна буде подивитись на моніторинг

Спочатку давайте встановимо з дефолтними параметрами, потім накидаємо власні values.

Додаємо репозиторій:

$ helm repo add stevehipwell https://stevehipwell.github.io/helm-charts/
"stevehipwell" has been added to your repositories

Створюємо окремий неймспейс ops-nexus-ns:

$ kk create ns ops-nexus-ns
namespace/ops-nexus-ns created

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

$ helm -n ops-nexus-ns upgrade --install nexus3 stevehipwell/nexus3

Запускався він хвилин 5 – я вже думав дропати чарт, і писати самому, але врешті-решт таки стартанув – Java, шо поробиш.

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

$ kk -n ops-nexus-ns get all
NAME           READY   STATUS    RESTARTS   AGE
pod/nexus3-0   4/4     Running   0          6m5s

NAME                TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
service/nexus3      ClusterIP   172.20.160.147   <none>        8081/TCP   6m5s
service/nexus3-hl   ClusterIP   None             <none>        8081/TCP   6m5s

NAME                      READY   AGE
statefulset.apps/nexus3   1/1     6m6s

Додавання Admin user password

Створимо Kubernetes Secret з паролем:

$ kk -n ops-nexus-ns create secret generic nexus-root-pass --from-literal=password=p@ssw0rd
secret/nexus-root-pass created

Пишемо файл nexus-values.yaml, в якому задаємо ім’я Kubernetes Secret і ключ з паролем, заодно включаємо Anonymous Access:

rootPassword:
  secret: nexus-root-password
  key: password

  config:  
    enabled: true
    anonymous:
      enabled: true

Додавання репозиторію в Nexus через Helm chart values

Тут трохи довелось робити “методом тика”, але завелось.

Отже, в values.yaml чарту сказано: “Repository configuration; based on the REST API (API reference docs require an existing Nexus installation and can be found at **Administration** under _System_ → _API_) but with `format` & `type` defined in the object.

Подивимось специфікацію Nexus API – які поля передаються в API request:

А що по формату?

Поля Format і Type можемо глянути в якомусь існуючому репозиторії:

Описуємо репозиторій і інші потрібні параметри – в мене все раз наразі виглядає так:

rootPassword:
  secret: nexus-root-password
  key: password
  
persistence:
  enabled: true
  storageClass: gp2-retain

resources:
  requests:
    cpu: 1000m
    memory: 1500Mi  

config:  
  enabled: true
  anonymous:
    enabled: true
  repos:
    - name: pip-cache
      format: pypi
      type: proxy
      online: true
      negativeCache:
        enabled: true
        timeToLive: 1440
      proxy:
        remoteUrl: https://pypi.org
        metadataMaxAge: 1440
        contentMaxAge: 1440
      httpClient:
        blocked: false
        autoBlock: true
        connection: 
        retries: 0
        useTrustStore: false
      storage:
        blobStoreName: default
        strictContentTypeValidation: false

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

Деплоїмо:

$ helm -n ops-nexus-ns upgrade --install nexus3 stevehipwell/nexus3 -f nexus-values.yml

У випадку помилок типу “Could not create repository“:

$ kk -n ops-nexus-ns logs -f nexus3-config-9-2cssf
Configuring Nexus3...
Configuring anonymous access...
Anonymous access configured.
Configuring blob stores...
Configuring scripts...
Script 'cleanup' updated.
Script 'task' updated.
Configuring cleanup policies...
Configuring repositories...
ERROR: Could not create repository 'pip-cache'.

Перевіряємо логи – Nexus хоче передачу майже всіх полів, в данному випадку не вистачало config.repos.httpClient.contentMaxAge:

nexus3-0:nexus3 2024-11-27 12:34:16,818+0000 WARN  [qtp554755438-84] admin org.sonatype.nexus.siesta.internal.resteasy.ResteasyViolationExceptionMapper - (ID af473d22-3eca-49ea-adb9-c7985add27e7) Response: [400] '[ValidationErrorXO{id='PARAMETER strictContentTypeValidation', message='must not be null'}, ValidationErrorXO{id='PARAMETER negativeCache', message='must not be null'}, ValidationErrorXO{id='PARAMETER metadataMaxAge', message='must not be null'}, ValidationErrorXO{id='PARAMETER contentMaxAge'[]ust not be null]arg0.httpClient]ntMaxAge]]TypeValidation]TER httpClient', message='must not be null'}]'; mapped from: [PARAMETER]

Під часу деплою, коли ми задаємо параметр config.enabled=true, чарт запускає ще один Kubernetes Pod, який власне виконує конфігурацію Nexus.

Перевіримо доступ і репозиторій – відкриваємо собі доступ:

$ kk -n ops-nexus-ns port-forward pod/nexus3-0 8082:8081
Forwarding from 127.0.0.1:8082 -> 8081
Forwarding from [::1]:8082 -> 8081

Заходимо на http://localhost:8082/#admin/repository/repositories:

Ресурсів, особливо Memory, Nexus хоче багато, бо знов-таки – Java:

Тому є сенс в values відразу виставити requests.

Перевірка Nexus в Kubernetes

Запускаємо Pod з Python:

$ kk run pod --rm -i --tty --image python bash
If you don't see a command prompt, try pressing enter.
root@pod:/# 

Знаходимо Kubernetes Service для Nexus:

$ kk -n ops-nexus-ns get svc
NAME        TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
nexus3      ClusterIP   172.20.160.147   <none>        8081/TCP   78m
nexus3-hl   ClusterIP   None             <none>        8081/TCP   78m

Знов запускаємо pip install:

root@pod:/# time pip install --index-url http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/simple setuptools --trusted-host nexus3.ops-nexus-ns.svc
Looking in indexes: http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/simple
Collecting setuptools
  Downloading http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/packages/setuptools/75.6.0/setuptools-75.6.0-py3-none-any.whl (1.2 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 86.3 MB/s eta 0:00:00
Installing collected packages: setuptools
Successfully installed setuptools-75.6.0
...

real    0m3.958s

Встановило setuptools-75.6.0 за 3.95 секунди.

Перевіримо в http://localhost:8082/#browse/browse:pip-cache:

Видаляємо setuptools з нашого поду:

root@pod:/# pip uninstall setuptools

І встановлюємо ще раз, знов з --no-cache-dir:

root@pod:/# time pip install --no-cache-dir --index-url http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/simple setuptools --trusted-host nexus3.ops-nexus-ns.svc
Looking in indexes: http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/simple
Collecting setuptools
  Downloading http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/packages/setuptools/75.6.0/setuptools-75.6.0-py3-none-any.whl (1.2 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.2/1.2 MB 875.9 MB/s eta 0:00:00
Installing collected packages: setuptools
Successfully installed setuptools-75.6.0
..

real    0m2.364s

Тепер це зайняло 2.364s.

Залишилось оновити GitHub Workflows – відключити там всякі кеші, і додати використання Nexus.

GitHub та результати по AWS NAT Gateway трафіку

На Workflow детально зупинятись не буду, бо це у кожного своє, але якщо кратко, то відключаємо кешування PiP:

...
    - name: "Setup: Python 3.10"
      uses: actions/setup-python@v5
      with:
        python-version: "3.10"
        # cache: 'pip'
        check-latest: "false"
        # cache-dependency-path: "**/*requirements.txt"
...

Це збереже близько 540 мегабайт на завантаженні архіву з кешем.

Далі у нас є step, який виконує pip install через виклик make:

...
    - name: "Setup: Dev Dependencies"
      id: setup_dev_dependencies
      #run: make dev-python-requirements
      run: make dev-python-requirements-nexus
      shell: bash
...

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

...
dev-python-requirements:
  python3 -m pip install --no-compile -r dev-requirements.txt

dev-python-requirements-nexus:
  python3 -m pip install --index-url http://nexus3.ops-nexus-ns.svc:8081/repository/pip-cache/simple --no-compile -r dev-requirements.txt --trusted-host nexus3.ops-nexus-ns.svc
...

У Workflow відключаємо всякі кеши типу actions/cache:

..
    # - name: "Setup: Get cached api-generator images"
    #   id: api-generator-cache
    #   uses: actions/cache@v4
    #   with:
    #     path: ~/_work/api-generator-cache
    #     key: api-generator-cache
...

Ну і порівняємо результати.

Білд зі старим конфігом, без Nexus і з кешами GitHub – трафік Kubernetes Pod раннера, який цей білд виконував:

3.55 гігабайт  трафіку, білд-деплой зайняли 4 хвилини 11 секунд часу.

І ця сама GitHub Actions джоба, але вже зі змерженими змінами і використанням Nexus і без GitHub caching.

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

Трафік:

329 мегабайт, білд-деплой зайняли 4 хвилини 20 секунд часу.

Ну і на цьому поки все.

Що буде зробити далі – це подивитись як Nexus можна моніторити, які в нього є метрики і які з них можна зробити алерти, і далі додати ще Docker кеш, бо доволі часто стикаємось з лімітами Docker Hub – “429 Too Many Requests – Server message: toomanyrequests: You have reached your pull rate limit. You may increase the limit by authenticating and upgrading“.

Loading

VictoriaLogs: дашборда в Grafana з AWS VPC Flow Logs – мігруємо з Grafana Loki
0 (0)

4 Грудня 2024

В попередньому пості – AWS: VPC Flow Logs – логи до S3 та Grafana dashboard з Loki ми створили дашборду в Grafana, яка відображає статистику використання NAT Gateway.

Що саме нас там цікавило – це які Kubernetes Pods використовують найбільше байт, бо це напряму впливає на наші AWS Costs.

І все наче добре з цією бордою, окрім одного – Loki не здатна обробити “сирі” логи і побудувати графіки більше ніж за 30 хвилин, максимум – 1 година, і то вже частина візуалізацій не прогружаються, хоча я намагався її затюнити – див. Grafana Loki: оптимізація роботи – Recording Rules, кешування та паралельні запити.

Тому я вирішив спробувати такий же підхід – з S3 для VPC Flow Logs, Lambda та Promtail – але вже з VictoriaLogs, тим більш з версії 0.8.0 VictoriaLogs Grafana data source вже завели кращу підтримку запитів, і тепер можна будувати візуалізації без Grafana Transformations.

Отже, що будемо робити:

  • швиденько покажу Terraform код, який створює S3 для VPC Flow Logs і AWS Lambda з Promtail, який шле дані до VictoriaLogs
  • створимо нову Grafana dashboard з VictoriaLogs datasource, і перенесемо запити з Loki та її LogQL до VictoriaLogs та LogsQL

Нагадаю з попереднього поста що ми маємо в нашому сетапі:

  • знаємо CIDR приватних сабнетів для Kubernetes Pods
    • у нас використовується тільки одна мережа в us-east-1a AvailabilityZone – 10.0.32.0/20
  • знаємо Elastic Network Interface ID нашого NAT Gateway – він у нас один, тому тут все просто
  • в логах маємо поля pkt_src_addr та pkt_dst_addr, по яким можемо вибирати трафік тільки з/до Kubernetes Pods

Також варто глянути інші пости по цій темі:

Terraform

S3 та Promtail Lambda

Детально тут розписувати не буду, бо в коді наче достатньо коментарів, які описують кожен ресурс. Просто приклад того, як таке можна зробити. Крім того, першу версію модуля описував в Terraform: створення модулю для збору логів AWS ALB в Grafana Loki, але тут трохи перероблений варіант аби мати можливість налаштування і Loki і VictoriaLogs, і не тільки логи ALB, але і VPC Flow Logs.

Отже, як це реалізував я:

  • репозиторій atlas-tf-modules: модулі Terraform, в якому є код для створення S3 бакетів, Lambda, нотифікацій і пермішенів
  • репозиторій atlas-monitoring: код Terraform та Helm-чарт нашого моніторинга, де створюються необхідні ресурси – RDS, різні додаткові S3-бакети, сертифікати AWS ACM, та викликається модуль з atlas-tf-modules/alb-s3-logs для налаштування збору логів з S3 бакетів

Почнемо з самого модуля alb-s3-logs для S3 та Lambda. Про Terraform та модулі писав в Terraform: модулі, Outputs та Variables.

Структура файлів в alb-s3-logs:

$ tree alb-s3-logs/
alb-s3-logs/
|-- README.md
|-- lambda.tf
|-- outputs.tf
|-- s3.tf
`-- variables.tf

Створення S3 buckets

Файл s3.tf – створення бакетів:

# define S3 bucket names from parameteres passed from a calling/root module in the 'atlas-monitoring' repository
locals {

  # ops-1-30-devops-ingress-ops-alb-loki-logs
  # "ops"     "1-30"        "devops"    "ingress"     "ops"     "alb"         "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # ops-1-30-devops-vpc-ops-flow-loki-logs
  # "ops"     "1-30"        "devops"    "vpc"         "ops"     "flow"        "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs
  logs_bucket_names = { for env in var.app_environments : env => "${var.aws_env}-${var.eks_version}-${var.component}-${var.application}-${env}-${var.aws_service}-${var.logger_type}-logs" }
}

resource "aws_s3_bucket" "s3_logs" {
  for_each = local.logs_bucket_names

  bucket = each.value

  # to drop a bucket, set to `true` first
  # run `terraform apply`
  # then remove the block
  # and run `terraform apply` again
  force_destroy = true
}

# remove logs older than 30 days
resource "aws_s3_bucket_lifecycle_configuration" "bucket_config" {
  for_each = aws_s3_bucket.s3_logs

  bucket = each.value.id

  rule {
    id     = "logs"
    status = "Enabled"

    expiration {
      days = 30
    }
  }
}

# block S3 bucket public access
resource "aws_s3_bucket_public_access_block" "s3_logs_backend_acl" {
  for_each = aws_s3_bucket.s3_logs

  bucket = each.value.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# using the 'var.aws_service == "alb"', attach the S3 bucket Policy to buckets for ALB Logs only
resource "aws_s3_bucket_policy" "s3_logs_alb" {
  for_each = {
    for key, bucket_name in aws_s3_bucket.s3_logs :
    key => bucket_name if var.aws_service == "alb"
  }

  bucket = each.value.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid = "RegionELBLogsWrite"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${var.elb_account_id}:root"
        }
        Action = "s3:PutObject"
        Resource = "arn:aws:s3:::${each.value.id}/AWSLogs/${var.aws_account_id}/*"
      },
      {
        Sid = "PromtailLambdaLogsGet"
        Effect = "Allow"
        Principal = {
          AWS = module.logs_promtail_lambda[each.key].lambda_role_arn
        }
        Action = "s3:GetObject"
        Resource = "arn:aws:s3:::${each.value.id}/*"
      }
    ]
  })
}

# using the 'var.aws_service == "flow"', attach attach the S3 bucket Policy to buckets for VPC Flow Logs only
resource "aws_s3_bucket_policy" "s3_logs_flow" {
  for_each = {
    for key, bucket_name in aws_s3_bucket.s3_logs :
    key => bucket_name if var.aws_service == "flow"
  }

  bucket = each.value.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid = "VPCFlowLogsDeliveryWrite",
        Effect = "Allow",
        Principal = {
          Service = "delivery.logs.amazonaws.com"
        },
        Action = "s3:PutObject",
        Resource = "arn:aws:s3:::${each.value.id}/AWSLogs/${var.aws_account_id}/*",
        Condition = {
          StringEquals = {
            "aws:SourceAccount": "${var.aws_account_id}",
            "s3:x-amz-acl": "bucket-owner-full-control"
          },
          ArnLike = {
            "aws:SourceArn": "arn:aws:logs:us-east-1:${var.aws_account_id}:*"
          }
        }
      },
      {
        Sid = "VPCFlowLogsAclCheck",
        Effect = "Allow",
        Principal = {
          Service = "delivery.logs.amazonaws.com"
        },
        Action = "s3:GetBucketAcl",
        Resource = "arn:aws:s3:::${each.value.id}",
        Condition = {
          StringEquals = {
            "aws:SourceAccount": "${var.aws_account_id}"
          },
          ArnLike = {
            "aws:SourceArn": "arn:aws:logs:us-east-1:${var.aws_account_id}:*"
          }
        }
      },
      {
        Sid = "PromtailLambdaLogsGet"
        Effect = "Allow"
        Principal = {
          AWS = module.logs_promtail_lambda[each.key].lambda_role_arn
        }
        Action = "s3:GetObject"
        Resource = "arn:aws:s3:::${each.value.id}/*"
      }      
    ]
  })
}

# send notifications to a Lambda function with Promtail when a new object is created in the S3 bucket
resource "aws_s3_bucket_notification" "s3_logs_lambda_notification" {
  for_each = aws_s3_bucket.s3_logs

  bucket = each.value.id

  lambda_function {
    lambda_function_arn = module.logs_promtail_lambda[each.key].lambda_function_arn
    events              = ["s3:ObjectCreated:*"]
    filter_prefix       = "AWSLogs/${var.aws_account_id}/"
  }
}

Створення Lambda функцій з Promtail

Файл lambda.tf:

# to allow network connections from S3 buckets IP range
data "aws_prefix_list" "s3" {
  filter {
    name   = "prefix-list-name"
    values = ["com.amazonaws.us-east-1.s3"]
  }
}

# allow connections from S3 and from/to VPC Private Subnets to access Loki and VictoriaLogs
module "logs_security_group_lambda" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "~> 5.2.0"

  # 'ops-1-30-loki-lambda-sg'
  name        = "${var.aws_env}-${var.eks_version}-lambda-${var.logger_type}-sg"
  description = "Security Group for Lambda Egress"

  vpc_id = var.vpc_id

  egress_cidr_blocks      = var.vpc_private_subnets_cidrs
  egress_ipv6_cidr_blocks = []
  egress_prefix_list_ids  = [data.aws_prefix_list.s3.id]

  ingress_cidr_blocks      = var.vpc_private_subnets_cidrs
  ingress_ipv6_cidr_blocks = []

  egress_rules  = ["https-443-tcp"]
  ingress_rules = ["https-443-tcp"]
}

# S3 buckets names:

  # ops-1-30-devops-ingress-ops-alb-loki-logs
  # "ops"     "1-30"        "devops"    "ingress"     "ops"     "alb"         "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # ops-1-30-devops-vpc-ops-flow-loki-logs
  # "ops"     "1-30"        "devops"    "vpc"         "ops"     "flow"        "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

module "logs_promtail_lambda" {
  source  = "terraform-aws-modules/lambda/aws"
  version = "~> 7.16.0"
  # key: 'ops'
  # value:  'ops-1-30-devops-vpc-ops-flow-loki-logs'
  for_each = aws_s3_bucket.s3_logs

  # build Lambda function name like 'ops-1-30-devops-vpc-ops-flow-loki-logs-logger'
  function_name = "${each.value.id}-${var.logger_type}-logger"
  description   = "Promtail instance to collect logs from S3"

  create_package = false
  # https://github.com/terraform-aws-modules/terraform-aws-lambda/issues/36
  publish = true

  # an error when sending logs from Flow Logs S3:
  # 'Task timed out after 3.05 seconds'
  timeout = 60

  image_uri     = var.promtail_image
  package_type  = "Image"
  architectures = ["x86_64"]

  # component=devops, logtype=alb, environment=ops, logger_type=loki
  # component=devops, logtype=flow, environment=ops, logger_type=loki
  environment_variables = {
    EXTRA_LABELS             = "component,${var.component},logtype,${var.aws_service},environment,${each.key},logger_type,${var.logger_type}"
    KEEP_STREAM              = "true"
    OMIT_EXTRA_LABELS_PREFIX = "true"
    PRINT_LOG_LINE           = "true"
    WRITE_ADDRESS            = var.logger_write_address
  }

  vpc_subnet_ids         = var.vpc_private_subnets_ids
  vpc_security_group_ids = [module.logs_security_group_lambda.security_group_id]
  attach_network_policy  = true

  # writing too many logs
  # see in CloudWatch Metrics by the 'IncomingBytes' metric
  # to save CloudWatch Logs costs, decrease the logs number
  # set to 'INFO' for debugging
  logging_application_log_level = "FATAL"
  logging_system_log_level = "WARN"
  logging_log_format = "JSON"

  # allow calling the Lambda from an S3 bucket
  # bucket name: ops-1-28-backend-api-dev-alb-logs
  allowed_triggers = {
    S3 = {
      principal  = "s3.amazonaws.com"
      source_arn = "arn:aws:s3:::${each.value.id}"
    }
  }
}

Виклик модуля atlas-tf-modules з коду моніторинга

Далі описуємо ресурси в коді Terraform в репозиторії atlas-monitoring – файл logs.tf.

Тут створюється три модулі:

  • Load Balancers logs в Loki
  • VPC Flow Logs в Loki
  • VPC Flow Logs в VictoriaLogs
/*

Collect ALB Logs to Loki module

S3:

- will create an aws_s3_bucket for each app_environments[]:
  # bucket names:
  # '<eks_env>-<eks_version>-<component>-<application>-<app_env>-alb-logs'
  # i.e:
  # 'ops-1-28-backend-api-dev-alb-logs'
- will create an aws_s3_bucket_policy with Allow for each Lambda
- will create an aws_s3_bucket_notification with Push event on each s3:ObjectCreated to each Lambda

Lambda:

- will create a security_group_lambda with Allow 443 from VPC CIDR
- will create a Lambda with Promtail for each aws_s3_bucket

*/

module "vpc_flow_logs_loki" {
  # create the module for each EKS cluster by its version
  # for_each = var.eks_versions
  for_each = toset(["1-30"])
  source = "[email protected]:ORG-NAME/atlas-tf-modules//alb-s3-logs?ref=master"
  # for local development
  # source = "/home/setevoy/Work/atlas-tf-modules//alb-s3-logs"

  # ops-1-30-devops-ingress-ops-alb-loki-logs
  # "ops"     "1-30"        "devops"    "ingress"     "ops"     "alb"         "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # ops-1-30-devops-vpc-ops-flow-loki-logs
  # "ops"     "1-30"        "devops"    "vpc"         "ops"     "flow"        "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # 'ops'
  aws_env          = var.aws_environment
  # '1-30'
  eks_version = each.value
  # by team: 'backend', 'devops'
  component        = "devops"
  application      = "vpc"
  app_environments = ["ops"]
  aws_service = "flow"
  logger_type = "loki"

  vpc_id                    = local.vpc_out.vpc_id
  vpc_private_subnets_cidrs = local.vpc_out.vpc_private_subnets_cidrs
  vpc_private_subnets_ids   = local.vpc_out.vpc_private_subnets_ids
  # 'https://loki.monitoring.1-30.ops.example.co:443/loki/api/v1/push'
  logger_write_address        = "https://loki.monitoring.${each.value}.ops.example.com:443/loki/api/v1/push"
  aws_account_id            = data.aws_caller_identity.current.account_id
}

module "vpc_flow_logs_vmlogs" {
  # create the module for each EKS cluster by its version
  # for_each = var.eks_versions
  for_each = toset(["1-30"])
  source = "[email protected]:ORG-NAME/atlas-tf-modules//alb-s3-logs?ref=master"
  # for local development
  # source = "/home/setevoy/Work/atlas-tf-modules//alb-s3-logs"

  # ops-1-30-devops-ingress-ops-alb-loki-logs
  # "ops"     "1-30"        "devops"    "ingress"     "ops"     "alb"         "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # ops-1-30-devops-vpc-ops-flow-loki-logs
  # "ops"     "1-30"        "devops"    "vpc"         "ops"     "flow"        "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # 'ops'
  aws_env          = var.aws_environment
  # '1-30'
  eks_version = each.value
  # by team: 'backend', 'devops'
  component        = "devops"
  application      = "vpc"
  app_environments = ["ops"]
  aws_service = "flow"
  logger_type = "vmlogs"

  vpc_id                    = local.vpc_out.vpc_id
  vpc_private_subnets_cidrs = local.vpc_out.vpc_private_subnets_cidrs
  vpc_private_subnets_ids   = local.vpc_out.vpc_private_subnets_ids
  # create log streams by the 'logtype,environment,logger_type' fields
  # see https://docs.victoriametrics.com/victorialogs/keyconcepts/#stream-fields
  logger_write_address        = "https://vmlogs.monitoring.${each.value}.ops.example.com:443/insert/loki/api/v1/push?_stream_fields=logtype,environment,logger_type"
  aws_account_id            = data.aws_caller_identity.current.account_id
}

# ../../atlas-load-balancers/helm/templates/external-ingress-alb.yaml:    
# alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=ops-1-30-devops-ingress-ops-alb-logs
# two ALB are using this buckets for their logs - the External, 'ops-external-ingress', and the Internal one, 'ops-internal-ingress'
# both are in the 'ops-common-alb-ns' Namespace
module "single_ingress_alb_logs_loki" {
  # create the module for each EKS cluster by its version
  # for_each = var.eks_versions
  for_each = toset(["1-30"])
  source = "[email protected]:ORG-NAME/atlas-tf-modules//alb-s3-logs?ref=master"
  # for local development
  # source = "/home/setevoy/Work/atlas-tf-modules//alb-s3-logs"

  # ops-1-30-devops-ingress-ops-alb-loki-logs
  # "ops"     "1-30"        "devops"    "ingress"     "ops"     "alb"         "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # ops-1-30-devops-vpc-ops-flow-loki-logs
  # "ops"     "1-30"        "devops"    "vpc"         "ops"     "flow"        "loki"       "logs"
  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-<aws_service>-<logger_type>-logs

  # 'ops'
  aws_env          = var.aws_environment
  # '1-30'
  eks_version = each.value
  component        = "devops"
  application      = "ingress"
  app_environments = ["ops"]
  aws_service = "alb"
  logger_type = "loki"

  vpc_id                    = local.vpc_out.vpc_id
  vpc_private_subnets_cidrs = local.vpc_out.vpc_private_subnets_cidrs
  vpc_private_subnets_ids   = local.vpc_out.vpc_private_subnets_ids
  # 'https://loki.monitoring.1-30.ops.example.co:443/loki/api/v1/push'
  logger_write_address        = "https://loki.monitoring.${each.value}.ops.example.com:443/loki/api/v1/push"
  aws_account_id            = data.aws_caller_identity.current.account_id
}

З цим наче все.

Модуль VPC та Flow Logs

В модулі terraform-aws-modules/vpc/aws є підтримка Flow Logs, але там можна задати тільки один flow_log_destination_arn, в якому в мене зараз Grafana Loki – S3-бакет ops-1-30-devops-vpc-ops-flow-loki-logs:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.16.0"

  ...

  enable_flow_log = var.vpc_params.enable_flow_log

  # Default: "cloud-watch-logs"
  flow_log_destination_type = "s3"

  # disalbe to use S3
  create_flow_log_cloudwatch_log_group = false
  create_flow_log_cloudwatch_iam_role  = false

  # ARN of the CloudWatch log group or S3 bucket
  # disable if use 'create_flow_log_cloudwatch_log_group' and the default 'flow_log_destination_type' value (cloud-watch-logs)
  flow_log_destination_arn = "arn:aws:s3:::ops-1-30-devops-vpc-ops-flow-loki-logs"

  flow_log_cloudwatch_log_group_name_prefix = "/aws/${local.env_name}-flow-logs/"
  flow_log_log_format = "$${region} $${vpc-id} $${az-id} $${subnet-id} $${instance-id} $${interface-id} $${flow-direction} $${srcaddr} $${dstaddr} $${srcport} $${dstport} $${pkt-srcaddr} $${pkt-dstaddr} $${pkt-src-aws-service} $${pkt-dst-aws-service} $${traffic-path} $${packets} $${bytes} $${action}"

  vpc_flow_log_tags = {
    "Name" = "flow-logs-s3-to-loki"
  }
}

Аби писати відразу в два S3 бакета – просто додаємо ресурс aws_flow_log.

VPC Flow Logs пишуться з custom format:

resource "aws_flow_log" "vpc_flow_vmlogs" {
  vpc_id               = module.vpc.vpc_id
  log_destination      = "arn:aws:s3:::ops-1-30-devops-vpc-ops-flow-vmlogs-logs"
  log_destination_type = "s3"
  traffic_type         = "ALL"
  log_format = "$${region} $${vpc-id} $${az-id} $${subnet-id} $${instance-id} $${interface-id} $${flow-direction} $${srcaddr} $${dstaddr} $${srcport} $${dstport} $${pkt-srcaddr} $${pkt-dstaddr} $${pkt-src-aws-service} $${pkt-dst-aws-service} $${traffic-path} $${packets} $${bytes} $${action}"
  tags = {
    "Name" = "flow-logs-s3-to-vmlogs"
  }
}

Крім того, я ще вручну створив Flow Logs з destination в CloudWatch Logs аби перевіряти дані в Loki та VictoriaLogs.

Створення Grafana dashboard

NAT Gateway Total processed

Першим у нас йде відображення загальної статистики по тому, скільки через NAT Gateway пройшло трафіку – і від Kubernetes Pods в інтернет, і з інтернету до Kubernetes Pods.

Запит в Loki

В Loki запит виглядає так:

sum (
    sum_over_time(
        ({logtype="flow", logger_type="loki"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | pkt_src_addr=ip("10.0.32.0/20") OR pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
        )[$__range]
    )
)

Тут:

  • рахуємо sum_over_time() за період, вибраний в Grafana dashboard – $__range
  • рахуємо трафік або від Kubernetes Pods – pkt_src_addr=ip("10.0.32.0/20"), або навпаки до Kubernetes Pods – pkt_dst_addr=ip("10.0.32.0/20")
  • рахуємо по полю bytesunwrap bytes

З таким запитом маємо такі дані:

kubernetes_pod_ip та remote_svc_ip – змінні в Grafana dashboard аби мати можливість перевірки даних по конкретним адресам:

Запит з VictoriaLogs

Тепер нам треба перевести цей запит в формат LogsQL для VictoriaLogs.

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

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      (pkt_src_addr:ipv4_range("10.0.32.0/20") OR pkt_dst_addr:ipv4_range("10.0.32.0/20"))
      pkt_src_addr:~"${kubernetes_pod_ip}"
      pkt_dst_addr:~"${remote_svc_ip}"   
  | stats sum(bytes) bytes_total

Підтримку змінної Grafana $__range завезли тільки вчора в версії датасорсу 0.9.0, тому оновіться.

Тут ми:

  • вибираємо дані за _time:$__range
  • в {logtype=flow, environment=ops, logger_type=vmlogs} використовуємо log stream selector з лейблами, які задаються в Lambda Promtail під час запису логів – /insert/loki/api/v1/push?_stream_fields=logtype,environment,logger_type
  • seq("eni-0352f8c82da6aa229", "ACCEPT") – використовуємо Sequence filter – вибираємо тільки записи з інтерфейсу NAT Gateway і ACCEPT аби пришвидшити виконання запиту (див. коментар від Olexandr Valialkin тут>>>)
  • з extract формуємо поля із записів в логу
  • з filter вибираємо інтерфейсу NAT Gateway, ACCEPT, і як і в запиті Loki – фільтруємо трафік або від Kubernetes Pods з IPv4 range filterpkt_src_addr:ipv4_range("10.0.32.0/20"), або навпаки до Kubernetes Pods – pkt_dst_addr:ipv4_range("10.0.32.0/20") (зверніть увагу, що умови OR заключені в дужки)
  • і в кінці з stats рахуємо суму по полю bytes, а результат пишемо в поле bytes_total

Перевірка з CloudWatch Logs Insights

Аби мати можливість перевірити дані в Loki і VictoriaLogs, VPC зараз пише ще й в CloudWatch Logs.

Зробимо такий запит в Logs Insights:

parse @message "* * * * * * * * * * * * * * * * * * *" 
| as region, vpc_id, az_id, subnet_id, instance_id, interface_id, 
| flow_direction, srcaddr, dstaddr, srcport, dstport, 
| pkt_srcaddr, pkt_dstaddr, pkt_src_aws_service, pkt_dst_aws_service, 
| traffic_path, packets, bytes, action 
| filter ((interface_id="eni-0352f8c82da6aa229") AND ((isIpv4InSubnet(pkt_srcaddr,"10.0.32.0/20") OR isIpv4InSubnet(pkt_dstaddr,"10.0.32.0/20") )) | stats sum(bytes) as bytes_total

В результаті маємо 8547192734 байт:

Що в форматі SI (див. Binary prefixes) дає нам 1.87 гігабайт – рахуємо з калькулятором:

$ bc
scale=2
8547192734/1000/1000/1000
8.54

В Loki у нас було 7.56 GiB, в VictoriaLogs – 8.66 GiB.

Інколи ті ж самі дані між Loki, VictoriaLogs та CloudWatch можуть відрізнятись, тим більш при виборках всього за 30 хвилин, бо самі Flow Logs пишуться з різницею в кілька хвилин.

Наприклад, в бакеті Loki останній об’єкт створено в 13:06:50:

А в VMLogs – в 13:05:29:

Перевірка з Cost Explorer

Ще можна перевірити дані в Cost Explorer.

Вибираємо Service == EC2-Other, Usage type == NatGateway-Bytes (GB):

За минулу добу маємо 129 гігабайт трафіку через NAT Gateway.

Якщо ми в Grafana (нарешті ми це можемо зробити, бо є VictoriaLogs) зробимо range в 24 години – то побачимо в “NAT Gateway Total processed” 135 гігабайт:

Плюс-мінус сходиться, бо Cost Explorer рахує не останні 24 години, як в Grafana, а за попередню добу, крім того, там використовується UTC (+00:00) time zone.

NAT Gateway Total OUT та IN processed

Далі, хочеться бачити розподілення трафіку – від Kubernetes Pods в інтернет, та з інтернету до Kubernetes Pods.

Згадаємо, що ми маємо в записах для пакетів, які проходять через NAT Gateway – розбирали в Трафік з Pod до External Server через NAT Gateway, та VPC Flow Logs:

  • по полю interface_id фільтруємо тільки ті записи, які були зроблені з інтерфейсу NAT Gateway
  • якщо пакет йде від Kubernetes Pod в інтернет – то в полі pkt_src_addr буде IP цього Pod
  • якщо пакет йде з інтернету до Kubernetes Pod –  то в полі pkt_dst_addr буде IP цього Pod

Запити Loki

Тому аби порахувати байти з інтернету – до Kubernetes Pods ми можемо зробити такий запит в Loki з sum_over_time() та $__range, аби вибрати дані за 30 хвилин, а в pkt_dst_addr=ip("10.0.32.0/20") вибираємо IP тільки VPC Private Subnet, яка використовується для Kubernetes Pods:

sum (
    sum_over_time(
        ({logtype="flow", logger_type="loki"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
        )[$__range]
    )
)

Аби запит швидше оброблювався, внизу в Options можна поставити Type == Instant.

І аналогічно, але рахуємо від Kubernetes Pod в інтернет:

sum (
    sum_over_time(
        ({logtype="flow", logger_type="loki"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | flow_direction="ingress"
        | pkt_src_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
        )[$__range]
    )
)

Запити VictoriaLogs

Запити для VictoriaLogs будуть виглядати так – з інтернету до Kubernetes Pods:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      pkt_dst_addr:ipv4_range("10.0.32.0/20")
      pkt_src_addr:~"${kubernetes_pod_ip}"
      pkt_dst_addr:~"${remote_svc_ip}"        
  | stats sum(bytes) bytes_total

З Kubernetes Pods в інтернет:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      pkt_src_addr:ipv4_range("10.0.32.0/20")
      pkt_src_addr:~"${kubernetes_pod_ip}"
      pkt_dst_addr:~"${remote_svc_ip}"  
  | stats sum(bytes) bytes_total

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

NAT Gateway Total processed bytes/sec

Крім Stat панелей хотілося б бачити і історичну картину – в який час як мінявся трафік.

Запит Loki

В Loki все просто – просто використовуємо функцію rate():

sum (
    rate(
        ({logtype="flow", logger_type="loki"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
        )[$__interval]
    )
)

В Options і в rate() використовуємо інтервал 5 хвилин, в Standart Options > Unit bytes/sec(SI), в результаті маємо 7.25 МБ/с в 12:20:

Запит VictoriaLogs

А з VictoriaLogs трохи цікавіше, бо з коробки вона не має функції rate() (але обіцяють скоро додати).

Крім того, є ще один нюанс:

  • Loki рахує дані “назад”, тобто – точка на графіку в 12:25 а rate() бере попередні 5 хвилин – [5m] з Options, які передаються в $__interval
  • в VictoriaLogs графік буде відображатись на момент виконання запиту

Аби порахувати per-second rate наших bytes – можемо використати  math pipe.

Отже, запит буде таким:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      pkt_dst_addr:ipv4_range("10.0.32.0/20")
      pkt_src_addr:~"${kubernetes_pod_ip}"
      pkt_dst_addr:~"${remote_svc_ip}"        
  | stats sum(bytes) sum_bytes
  | math (sum_bytes / ($__interval/1s)) as bytes_total

Тут:

  • в stats sum(bytes) рахуємо суму байт за інтервал, заданий в Options (5 хвилин), результат зберігаємо як sum_bytes
  • далі з math рахуємо суму байт з sum_bytes за кожен інтервал на графіку, і їх ділимо на кількість секунд в обраному $__interval

Тут у нас 8.30 МБ/с в 12:20. Плюс-мінус схоже. Можна вже зовсім заморочитись з перевіркою, і порахувати вручну з логів – але прям супер-точні цифри тут не дуже важливі, цікавить саме тренд, тому ОК.

Взагалі, при побудові саме графіків можна не прописувати _time:$__range, бо це виконується в самій VMLogs “під капотом”, але тут нехай буде для ясності.

Kubernetes Pods IN From IP

Наступним відобразимо топ Kubernetes Pods IP по отриманому з інтернету трафіку.

Запит Loki

Для Loki використовуємо sum_over_time() за $__range, у нас в дашборді це 30 хвилин:

topk(5,
  sum by (pkt_src_addr) (
    sum_over_time(
      (
        {logtype="flow", logger_type="loki"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${remote_svc_ip}"
        | pkt_dst_addr=~"${kubernetes_pod_ip}"
        | unwrap bytes
        | __error__=""
      )[$__range]
    )
  )
)

Див. Grafana Data links – дуже корисна штука.

Запит VictoriaLogs

І аналогічний запит для VictoriaLogs буде виглядати так:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter 
    interface_id:="eni-0352f8c82da6aa229"
    action:=ACCEPT
    pkt_dst_addr:ipv4_range("10.0.32.0/20")
    pkt_dst_addr:~"${kubernetes_pod_ip}"
    pkt_src_addr:~"${remote_svc_ip}"    
  | stats by (pkt_src_addr) sum(bytes) sum_bytes
  | sort by (sum_bytes) desc limit 10

VictoriaLogs поки не підтримує Options для Legend і повертає результат просто в JSON.

Тому, аби все було красиво і без зайвих даних – можемо додати Transformations > Rename fields by regex, в якому з регуляркою .*addr="(.*)".* “виріжемо” тільки IP-адреси:

І що ми маємо:

  • в Loki у нас в топі 20.150.90.164 з 954 МБ
  • в VictoriaLogs в топі 20.150.90.164 з 954 МБ

І цілому дані схожі, хоча в Loki трохи відрізняється сортування, знов-таки – через невелику затримку. Ну і topk() в Loki працює трохи дивно, я колись намагався покопати цей момент, але забив. В VictoriaLogs limit працює краще (хоча теж є баг, далі побачимо).

Давайте перевіримо IP 20.150.90.164 в CloudWatch Logs Insights з таким запитом:

parse @message "* * * * * * * * * * * * * * * * * * *" 
| as region, vpc_id, az_id, subnet_id, instance_id, interface_id, 
| flow_direction, srcaddr, dstaddr, srcport, dstport, 
| pkt_srcaddr, pkt_dstaddr, pkt_src_aws_service, pkt_dst_aws_service, 
| traffic_path, packets, bytes, action 
| filter ((interface_id="eni-0352f8c82da6aa229") AND (isIpv4InSubnet(pkt_dstaddr,"10.0.32.0/20"))) | stats sum(bytes) as bytes_total by pkt_srcaddr
| sort bytes_total desc

Дані в VictoriaLogs більш схожі на правду, але в цілому обидві системи виводять дані правильно.

Знов-таки, якщо брати більший проміжок часу (чого ми не можемо зробити з Loki, але можемо в VictoriaLogs) – то дані в CloudWatch Logs та VictoriaLogs будуть ще більш точні.

Kubernetes Pods IN From IP bytes/sec

Тут аналогічно тому, як ми робили для панельки “NAT Gateway Total IN processed” – аби мати історичну картину по трафіку.

Запит Loki

topk(5,
  sum by (pkt_src_addr) (
    rate(
      (
        {logtype="flow", logger_type="loki"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${remote_svc_ip}"
        | pkt_dst_addr=~"${kubernetes_pod_ip}"
        | unwrap bytes
        | __error__=""
      )[$__interval]
    )
  )
)

Запит VictoriaLogs

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      pkt_dst_addr:ipv4_range("10.0.32.0/20")
      pkt_drc_addr:~"${kubernetes_pod_ip}"
      pkt_src_addr:~"${remote_svc_ip}"
  | stats by (pkt_src_addr) sum(bytes) bytes_total
  | sort by (bytes_total) desc
  | math (bytes_total / ($__interval/1s)) as bytes_total

Теж плюс-мінус дані схожі.

Але тут знов є проблема з topk() в Loki – бо задано ліміт в топ-5 результатів, а виводить 11.

В VictoriaLogs також є проблема з limit, наприклад задамо | sort by (bytes_total) desc limit 5:

І в результаті маємо не топ-5 IP, а просто 5 точок на графіку.

Говорив з девелоперами VictoriaMetrics – кажуть, що схоже на баг, завів їм GitHub Issue, подивимось, що буде в найближчих релізах з багфіксами.

Kubernetes Pods IN by IP and Port

Залишилось відобразити інформацію по IP і портам – буває корисно при визначені сервісу, який генерує трафік – див. pkt_src_aws_service, pkt_dst_aws_service та визначення сервісу.

Запит Loki

Використовуємо тип візуалізації Table і такий запит:

topk(10,
  sum by (pkt_src_addr, src_port, pkt_dst_addr, dst_port) (
    sum_over_time(
      (
        {logtype="flow", logger_type="loki"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${remote_svc_ip}"
        | pkt_dst_addr=~"${kubernetes_pod_ip}"
        | unwrap bytes
        | __error__=""
      )[$__range]
    )
  )
)

В Fields override задаємо Unit поля Value в bytes(SI), і для кожної колонки – власний Data link.

Змінити заголовки колонок і сховати поле Time можемо в Transformations:

Запит VictoriaLogs

Запит буде таким:

_time:$__range {logtype=flow, environment=ops, logger_type=vmlogs} seq("eni-0352f8c82da6aa229", "ACCEPT")
| extract "<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>"
  | filter
      interface_id:="eni-0352f8c82da6aa229"
      action:="ACCEPT"
      pkt_dst_addr:ipv4_range("10.0.32.0/20")
      pkt_dst_addr:~"${kubernetes_pod_ip}"
      pkt_src_addr:~"${remote_svc_ip}"
  | stats by (pkt_src_addr, src_port, pkt_dst_addr, dst_port) sum(bytes) bytes_total
  | sort by (bytes_total) desc limit 10

Через те, що VictoriaLogs повертає (поки що) результати в JSON – то додамо трансформацію Extract fields.

В Filter fields by name як і для Loki – прибираємо колонку Time.

А в Organize fields by name – міняємо заголовки колонок і робимо сортування колонок:

Фінальний результат та перформанс Loki vs VictoriaLogs

Результат в VictoriaLogs за 12 (!) годин:

І ресурси:

$ kk top pod atlas-victoriametrics-victoria-logs-single-server-0 
NAME                                                  CPU(cores)   MEMORY(bytes)   
atlas-victoriametrics-victoria-logs-single-server-0   2754m        34Mi

Результат в Loki за 30 хвилин:

І ресурси:

$ kk top pod -l app.kubernetes.io/name=loki,app.kubernetes.io/component=read
NAME                        CPU(cores)   MEMORY(bytes)   
loki-read-89fbb8f5c-874xq   683m         402Mi           
loki-read-89fbb8f5c-8qpdw   952m         399Mi           
loki-read-89fbb8f5c-qh8dg   848m         413Mi

Ну і на цьому власне все.

Залишилось дочекатись мінорних апдейтів по датасорсу і самій VictoriaLogs.

Що далі?

А далі все ж хочеться мати поля з Kubernetes Pods IP та адреси зовнішніх ресурсах в полях логів, а не парсити їх в дашборді самою VictoriaLogs – тоді буде можливість взагалі робити виборки за кілька днів або може навіть тижнів.

Для цього підсказали ідею із vector.dev – збирати нею логи з S3, там виконувати трансформації і додавати поля, а потім вже писати ці логи в VictoriaLogs.

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

Loading

Підготовка до зими 2024-2025: частина 2 – готуємо оселю до блекаутів
0 (0)

29 Листопада 2024

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

Отже, на сьогодні, 29 листопада, маємо таку картину по відключенням:

Пєчалька, звісно, але я готувався заздалегіть, тому не все так погано.

Про що буде мова сьогодні:

  • електроживлення квартири: як в мене працює опалення/холодильник, освітлення, інтернет
  • безпека: як себе убезпечити від пожежі
  • вода: тут просто – тримаємо запас

Звісно, багато залежить від дому/квартири. Якщо у вас взагалі приватний будинок – то ставимо генератор, і не знаємо біди.

Але якщо це багатоквартирний будинок – то тут вже питання.

І теж саме стосується опалення і води: на щастя в мене в ЖК опалення газове, котел, плюс ще я водопостачання у ЖК власне, а не від загальної мережі.

Це вже не перший пост на цю тему:

Електроживлення квартири

Коли я готувався до цієї зими, то довго не міг вирішити – що ж брати? Зекономити, і взяти просто акумулятори + ДБЖ, чи купити зарядну станцію від “народних умільців”, або психанути – та купити EcoFlow?

Власне в пості Підготовка до зими 2024-2025: ДБЖ, інвертори, та акумулятори саме про це і йде мова, і трохи розібравшись з темою я вирішив не економити, і просто купив EcoFlow.

В мене дві квартири – одна однокімнатна, як “офіс”, і друга трикімнатна – для родини.

Вдома, тобто у “сімейній” квартирі я поставив станцію EcoFlow DELTA Pro 3600 Вт⋅год з LiFePO4:

При роботі котла опалення + холодильника споживається близько 280 Вт/годину, і цієї станції має вистачити на 12-15 годин роботи. А враховуючи швидкість зарядки EcoFlow в пару годин – цілком нормальний варіант:

В “офісі” ж в мене стоїть EcoFlow Delta Max 2000 2016Wh, і на резерв – саморобна станція, куплена на OLX (не рекомендую, але працює з 2023):

АВР – Автомат Введення Резерву

Якщо “офіс” в мене однокімнатна квартира, і там я обійшовся кабелями з балкону, які йдуть на кухню і в робочу кімнату, то в 3к квартирі це було б, по-перше, дуже не зручно – десятиметрові кабеля через всю квартиру, по-друге – там хотілося мати нормальне освітлення і роботу розеток, тим більш вдома є дитина.

Тому ще влітку я встановив там АВР, який автоматично переключає живлення квартири на EcoFlow, коли в загальній мережі пропадає напруга.

Виглядає це ось так:

Тут зверху – звичайний ЗУБР з автоматами, на нього заходить лінія живлення з будинку.

А під ним – вже сам АВР: з розетки в ньому йде кабель живлення до EcoFlow, а з EcoFlow ще один назад до АВР, і вже від нього живиться квартира.

Це просто чудова штука, рекомендую, хоча обійшлася вона мені в 16.000 гривень. Якщо комусь треба – то пишіть в Telegram, дам контакт майстра (до речі, служить в ППО Київської області).

Холодильник – режим Eco Friendly (LG)

Але при таких відключеннях, як сьогодні, коли світла не буде майже добу – доводиться вже економити, і тут я вперше спробував функцію Eco Friendly в холодильнику LG:

Вона обмежує споживання електроенергії, і дуже відчутно – замість ~120 Вт/г холодильник забирає близько 60-80.

При цьому він підвищує температуру в морозильнику з -20 до -15, і в самому холодильнику з +3 до +7.

Крім того, в холодильники купив Акумулятор холоду Кемпінг IcePack, аби довше підтримувати холод всередині.

Котел опалення: термостат

Ще одна дуже корисна штука, яка прям маст хев всім, у кого опалення газовим котлом – це термостат, я собі брав Computherm Q7 RF:

Тут на стіні приймач, і з нього йдуть команди на сам котел:

По-перше, такий термостат програмується на різні режими в різні години доби: на ніч можна поставити 18-20 градусів, на ранок – 21-22, вдень, поки нікого немає вдома – знов 18, а ввечері, перед поверненням всіх додому – знов до 21-22.

Це відчутно економить витрати електроенергії на роботу котла опалення, який теж їсть немало – 100-120 Вт/г. Ну і рахунок за газ буде трохи меншим.

Ноутбуки та павербанки

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

Наприклад, в мене ще з 2023 є станції Kseon 168 від Kseonics Technology на 160.00 mAh, до яких через Автомобільний зарядний пристрій Baseus можна підключити Type-C, а його до ноутбука:

Або використати звичайний павербанк, який зможе видати 30+ ват потужності, наприклад в мене є парочка 30000 mah 65W 6A Baseus PowerBank, або ось такі китайські:

До них ще докупав пару маленьких інверторів на 150 ват – телевізор від них працює без проблем.

Загалом моя “колекція” павербанок виглядає так:

Інтернет

Благо, в Україні вже давно багато де є GPON, і в моєму селі, на щастя, теж, тому ще в кінці 2022 я собі підключив таку оптику.

Єдине, що для неї треба – це живити сам ONU (медіаконвертор) та роутер.

Для дому я купив ДБЖ для роутеру UPS DC1018P (на AliExpress можна взяти рази в два дешевше, ніж на Rozetka, але і чекати довго, і якщо прийде Укрпоштою – то ну його до біса), тримає 8-10 годин:

А в офісі ONU живиться або від павербанки:

Або від Step4Net UPS-18W, і такий жеж стоїть в кімнаті для роутера – але його вистачає години на 4 роботи:

Освітлення

Для квартири ще в минулі роки купив лампи на акумуляторах Yeelight Xiaomi з магнітами:

Вони з датчиком руху, класна штука.

Така ж висить над дверима:

Також в квартирі і на сходах в парадному розвісив ось такі світильники – Motion Sensor LED Night Light:


Ще класний варіант LED-стрічка, наприклад – така, але я (поки що) не брав, бо вистачає того, що є.

Пожежна безпека

Я прям дуже боюся, що все це щастя загориться, тим більш в мене з 2022 досі на балконі стоять звичайні AGM-акумулятори, тому подумав про безпеку.

Вогнегасники

Першим ще в 2022 купив звичайні порошкові вогнегасники ВП-3 – брав відразу дві штуки, бо не факт, що з першого разу вийде правильно його використати:

Автоматичні вогнегасники

А в цьому році пішов далі, і поставив автоматичні вогнегасники AFO Fire Ball:

Вони всередині заповнені порошком, який розкидується навкруги, коли до вогнегасника доходить полум’я.

Ось тут є ефектне відео того, як це працює.

Датчики диму

Ще купив датчики диму CoVi Security, які вміють слати альорти на мобільний телефон:

На телефоні виглядає якось так:

Тестив, підпалюючи тряпку – працює.

Вода та водопостачання

Тут мені дуже повезло з ЖК, бо у нас власні скважини з насосами. Для них є окремий генератор, але він працює 2 рази на добу по 3-4 години, і буває, що не вмикають зовсім.

Тому в будь-якому випадку тримаю вдома запас води технічної, 2 бутилі по 20 літрів, і питної, теж 2х20 літрів.

На додачу ще в минулі роки купував ось такі рукомийники Litolan:

Ну і останнє, що теж купував ще в 2022 – це звичайний чайник на плиту, аби не садити EcoFlow з електрочайником (тим більш тоді ще EcoFlow в мене не було):

Начебто все розказав з цікавого-корисного.

Чекаю включення світла, аби зарядити станції)

Loading

Kubernetes: єдиний AWS Load Balancer для різних Kubernetes Ingress
0 (0)

19 Листопада 2024

Подивились ми на наші витрати на AWS Load Balancers, і подумали, що треба трохи це діло привести в порядок.

Чого хочеться: мати один LoadBalancer, і через нього роутити запити на різні Kubernetes Ingresses та Services в різних Namespaces.

Перше, що спало на думку – це або додавати в Kubernetes кластер якийсь Service Mesh типу Istio або Linkerd, або додавати Nginx Ingress Controller, а перед ним – AWS ALB.

Але в UkrOps Slack мені нагадали, що AWS Load Balancer Controller, який ми використовуємо в нашому кластері AWS Elastic Kubernetes Service, вже давно вміє таке робити за допомогою IngressGroup.

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

Тест Load Balancer Controller IngressGroup

Отже, ідея доволі проста: в маніфесті Kubernetes Ingress ми задаємо ще один атрибут – group.name, і по ньому Load Balancer Controller визначає до якого AWS LoadBalancer цей Ingress належить.

Потім він використовуючи spec.hosts в Ingress визначає hostnames і на LoadBalancer будує роутинг до необхідних Target Groups.

Давайте спробуємо на простому прикладі.

Спочатку створюємо звичайну схему з окремими Ingress/ALB – описуємо маніфест з Namespace, Deployment, Service та Ingress:

apiVersion: v1
kind: Namespace
metadata:
  name: test-app-1-ns
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-1-deploy
  namespace: test-app-1-ns
spec:
  replicas: 1
  selector:
    matchLabels:
      app: app-1-pod
  template:
    metadata:
      labels:
        app: app-1-pod
    spec:
      containers:
        - name: app-1-container
          image: nginx
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: app-1-service
  namespace: test-app-1-ns
spec:
  selector:
    app: app-1-pod
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-1-ingress
  namespace: test-app-1-ns
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80}]'
spec:
  ingressClassName: alb
  rules:
    - host: app-1.ops.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app-1-service
                port:
                  number: 80

І такий же, тільки з app-2.

Деплоїмо:

$ kk apply -f .
namespace/test-app-1-ns created
deployment.apps/app-1-deploy created
service/app-1-service created
ingress.networking.k8s.io/app-1-ingress created
namespace/test-app-2-ns created
deployment.apps/app-2-deploy created
service/app-2-service created
ingress.networking.k8s.io/app-2-ingress created

Перевіряємо Ingress та його LoadBalancer для app-1:

$ kk -n test-app-1-ns get ingress
NAME            CLASS   HOSTS                   ADDRESS                                                                  PORTS   AGE
app-1-ingress   alb     app-1.ops.example.com   k8s-testapp1-app1ingr-9375bc68bc-376038977.us-east-1.elb.amazonaws.com   80      33s

Тут ADDRESS – “k8s-testapp1-app1ingr-9375bc68bc-376038977“.

Перевіряємо для app-2:

$ kk -n test-app-2-ns get ingress
NAME            CLASS   HOSTS                   ADDRESS                                                                   PORTS   AGE
app-2-ingress   alb     app-2.ops.example.com   k8s-testapp2-app2ingr-0277bbb198-1743964934.us-east-1.elb.amazonaws.com   80      64s

Тут ADDRESS – “k8s-testapp2-app2ingr-0277bbb198-1743964934“.

Відповідно, в AWS маємо два Load Balancers:

Тепер до обох Ingress  додаємо анотацію alb.ingress.kubernetes.io/group.name: test-app-alb:

...
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-1-ingress
  namespace: test-app-1-ns
  annotations:
    alb.ingress.kubernetes.io/group.name: test-app-alb
    alb.ingress.kubernetes.io/scheme: internet-facing
...
...
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-2-ingress
  namespace: test-app-2-ns
  annotations:
    alb.ingress.kubernetes.io/group.name: test-app-alb
    alb.ingress.kubernetes.io/scheme: internet-facing
...

Деплоїмо ще раз:

$ kk apply -f .
namespace/test-app-1-ns unchanged
deployment.apps/app-1-deploy unchanged
service/app-1-service unchanged
ingress.networking.k8s.io/app-1-ingress configured
namespace/test-app-2-ns unchanged
deployment.apps/app-2-deploy unchanged
service/app-2-service unchanged
ingress.networking.k8s.io/app-2-ingress configured

Та перевіряємо Ingresses та їхні адреси тепер.

У app-1 – це “k8s-testappalb-95eaaef0c8-2109819642“:

$ kk -n test-app-1-ns get ingress
NAME            CLASS   HOSTS                 ADDRESS                                                            PORTS   AGE
app-1-ingress   alb     app-1.ops.example.com   k8s-testappalb-95eaaef0c8-2109819642.us-east-1.elb.amazonaws.com   80      6m19s

У app-2 – теж “k8s-testappalb-95eaaef0c8-2109819642“:

$ kk -n test-app-2-ns get ingress
NAME            CLASS   HOSTS                 ADDRESS                                                            PORTS   AGE
app-2-ingress   alb     app-2.ops.example.com   k8s-testappalb-95eaaef0c8-2109819642.us-east-1.elb.amazonaws.com   80      6m48s

І в AWS у нас тепер один Load Balancer:

Який має два Listerner Rules, які в залежності від hostname в Ingress будуть редіректити запити до потрібних Target Groups:

IngressGroups – ліміти та реалізація в Production

При використанні такої схеми треба мати на увазі, що деякі параметри LoadBalancer не можуть задаватись в різних Ingress.

Наприклад, якщо один Ingress має анотацію alb.ingress.kubernetes.io/tags: "component=devops", а другий Ingress намагається задати тег component=backend, то Load Balancer Controller не задеплоїть такі зміни, і повідомить про конфлікт, наприклад:

aws-load-balancer-controller-7647c5cbc7-2stvx:aws-load-balancer-controller {"level":"error","ts":"2024-09-25T10:50:23Z","msg":"Reconciler error","controller":"ingress","object":{"name":"ops-1-30-external-alb"},"namespace":"","name":"ops-1-30-external-alb","reconcileID":"1091979f-f349-4b96-850f-9e7203bfb8be","error":"conflicting tag component: devops | backend"}
aws-load-balancer-controller-7647c5cbc7-2stvx:aws-load-balancer-controller {"level":"error","ts":"2024-09-25T10:50:44Z","msg":"Reconciler error","controller":"ingress","object":{"name":"ops-1-30-external-alb"},"namespace":"","name":"ops-1-30-external-alb","reconcileID":"19851b0c-ea82-424c-8534-d3324f4c5e60","error":"conflicting tag environment: ops | prod"}

Аналогічно до параметрів на кшталт alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=some-bucket-name, або параметри SecurityGroups.

А от з TLS все простіше: для кожного Ingress в його annotations з alb.ingress.kubernetes.io/certificate-arn можна передати ARN сертифікату з AWS Certificates Manager, і вони будуть налаштовані у Listener certificates for SNI:

Тому я принаймні поки що зробив так:

  • створив окремий GitHub репозиторій
  • в ньому Helm- чарт
  • в цьому чарті два маніфести для двох Ingress – один з типом internal, другий – internet-facing, і задав там всякі дефолтні параметри

Наприклад:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ops-external-ingress
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/group.name: ops-1-30-external-alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:492***148:certificate/88a5ccc0-e729-4fdb-818c-c41c411a3e3e
    alb.ingress.kubernetes.io/tags: "environment=ops,component=devops,Name=ops-1-30-external-alb"
    alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}'
    alb.ingress.kubernetes.io/load-balancer-attributes: access_logs.s3.enabled=true,access_logs.s3.bucket=ops-1-30-devops-ingress-ops-alb-logs
    alb.ingress.kubernetes.io/actions.default-action: >
      {"Type":"fixed-response","FixedResponseConfig":{"ContentType":"text/plain","StatusCode":"200","MessageBody":"It works!"}}
spec:
  ingressClassName: alb
  defaultBackend:
    service:
      name: default-action
      port: 
        name: use-annotation

В defaultBackend задаємо дію, коли запит приходить на hostname, для якого нема окремого Listener – тут просто відповідаємо “It works!” з кодом 200.

А далі вже в Ingress проектів налаштовуються їхні параметри, наприклад Grafana:

$ kk -n ops-monitoring-ns get ingress atlas-victoriametrics-grafana -o yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    # the Common ALB group name
    alb.ingress.kubernetes.io/group.name: ops-1-30-external-alb
    ## TLS certificate from AWS ACM
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:492***148:certificate/88a5ccc0-e729-4fdb-818c-c41c411a3e3e
    # sucess codes for Target Group health checks
    alb.ingress.kubernetes.io/success-codes: "302"
    alb.ingress.kubernetes.io/target-type: ip
    kubernetes.io/ingress.class: alb
    ...
  name: atlas-victoriametrics-grafana
  namespace: ops-monitoring-ns
  ...
spec:
  rules:
  - host: monitoring.1-30.ops.example.com
    http:
      paths:
      - backend:
          service:
            name: atlas-victoriametrics-grafana
            port:
              number: 80
        path: /
        pathType: Prefix

І через alb.ingress.kubernetes.io/group.name: ops-1-30-external-alb вони “підключаються” до нашого “дефолтного” LoadBalancer.

Працюємо на такій схемі вже кілька тижнів – поки політ нормальний.

Моніторинг

Ще з важливих нюансів – це моніторинг, бо дефолтні метрики CloudWatch, наприклад по помилкам 502/503/504 створюються на весь LoadBalancer.

Але в нашому випадку ми взагалі відмовились від метрик CloudWatch (ще й платити за кожен запит GetData на отримання метрик в CloudWatch Exporter або Yet Another Cloudwatch Exporter).

Натомість ми всі Access логи лоад-балансерів збираємо до Loki, а далі вже з її Recording Rules генеруємо метрики, де в лейблах маємо ім’я домену при запиті на який помилка виникла:

...
        - record: aws:alb:requests:sum_by:elb_http_codes_by_uri_path:5xx:by_pod_ip:rate:1m
          expr: |
            sum by (pod_ip, domain, elb_code, uri_path, user_agent) (
                rate(
                    {logtype="alb"} 
                        | pattern `<type> <date> <elb_id> <client_ip> <pod_ip> <request_processing_time> <target_processing_time> <response_processing_time> <elb_code> <target_code> <received_bytes> <sent_bytes> "<request>" "<user_agent>" <ssl_cipher> <ssl_protocol> <target_group_arn> "<trace_id>" "<domain>" "<chosen_cert_arn>" <matched_rule_priority> <request_creation_time> "<actions_executed>" "<redirect_url>" "<error_reason>" "<target>" "<target_status_code>" "<_>" "<_>"`
                        | domain=~"(^.*api.challenge.example.co|lightdash.example.co)"
                        | elb_code=~"50[2-4]"
                        | regexp `.*:443(?P<uri_path>/[^/?]+).* HTTP`
                        [1m] offset 5m
                )
            )
...

Див. Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda та Grafana Loki: LogQL та Recoding Rules для метрик з логів AWS Load Balancer.

Ще з цікавого почитати можна тут – A deeper look at Ingress Sharing and Target Group Binding in AWS Load Balancer Controller.

Loading

AWS: VPC Flow Logs – логи до S3 та Grafana dashboard з Loki
0 (0)

16 Листопада 2024

В продовження теми AWS: VPC Flow Logs, NAT Gateways, та Kubernetes Pods – детальний обзор.

Там ми розбирали роботу з VPC Flow Logs в цілому, і дізнались, як ми можемо отримувати інформацію про трафік з/до Kubernetes Pods.

Але при використанні Flow Logs з CloudWatch Logs є одна проблема – це вартість.

Наприклад, коли ми включаємо Flow Logs і вони пишуться до CloudWatch Logs, то навіть у невеликому проекті з невеликим трафіком кости на CloudWatch Logs виглядають так – 23-го жовтня включив, 8 листопада відключив:

Тому замість використання CloudWatch Logs ми можемо зробити інакше: Flow Logs писати до AWS S3 бакета, а звідти забирати з Promtail і писати в Grafana Loki, див. Grafana Loki: збираємо логи AWS LoadBalancer з S3 за допомогою Promtail Lambda. А вже маючи логи в Loki – мати і алерти з VMAlert/Alertmanager, і дашборди в Grafana.

Головна проблема, яку ми зараз хочемо вирішити за допомогою VPC Flow Logs – це визначити, хто шле багато трафіку через NAT Gateway, бо це теж з’їдає наші гроші.

Друга задача – це надалі мати загальну картину і якісь алерти по трафіку.

Отже, що будемо робити:

  • створимо AWS S3 для логів
  • створимо Lambda-функцію з інстансом Promtail, який буде писати логи з бакета до Grafana Loki
  • подивимось що ми маємо в логах цікавого, і що корисного там може бути для нас по трафіку
  • створимо Grafana Dashboard

Наша інфраструктура

Спочатку трохи подивимось на наш проект.

Маємо:

Terraform та VPC Flow Logs до S3

Створення самих корзин та Lambda-функції описано в Terraform: створення модулю для збору логів AWS ALB в Grafana Loki, тому тут детально зупинятись не буду.

Взагалі модуль створювався для збору логів AWS Load Balancers, тому в іменах буде зустрічатись “alb” – потім треба буде його переписати аби імена бакетів та функцій передавати параметром.

Єдиний момент, який треба мати на увазі: VPC Flow Logs пише багато даних, тому варто додати більший таймаут для Lambda, бо частина записів втрачалась через помилку Lambda “Task timed out after 3.05 seconds“.

...
module "promtail_lambda" {
  source  = "terraform-aws-modules/lambda/aws"
  version = "~> 7.8.0"
  # key: dev
  # value:  ops-1-28-backend-api-dev-alb-logs
  for_each = aws_s3_bucket.alb_s3_logs

  # <aws_env>-<eks_version>-<component>-<application>-<app_env>-alb-logs-logger
  # bucket name: ops-1-28-backend-api-dev-alb-logs
  # lambda name: ops-1-28-backend-api-dev-alb-logs-loki-logger
  function_name = "${each.value.id}-loki-logger"
  description   = "Promtail instance to collect logs from ALB Logs in S3"

  create_package = false
  # https://github.com/terraform-aws-modules/terraform-aws-lambda/issues/36
  publish = true

  # an error when sending logs from Flow Logs S3:
  # 'Task timed out after 3.05 seconds'
  timeout = 60
...

Отже, ми маємо AWS S3 бакет, маємо Lambda, яка з цього бакету бути отримувати повідомлення про появу нових об’єктів, а потім Promtail з цієї Lambda-функції відправляє логи до інстансу Loki через Internal LoadBalancer:

При передачі логів до Loki Promtail додає кілька нових лейбл – component=vpc-flow-logs, logtype=alb, environment=ops. Далі ми зможемо їх використовувати в метриках та Grafana dashboards.

logtype=alb то знов-таки модуль писався під логи ALB, і це треба буде змінити

Тепер нам треба налаштувати Flow Logs для нашої VPC.

В модулі terraform-aws-modules/vpc/aws для цього є кілька параметрів:

...
  enable_flow_log = var.vpc_params.enable_flow_log

  # Default: "cloud-watch-logs"
  flow_log_destination_type = "s3"

  # disalbe to use S3
  create_flow_log_cloudwatch_log_group = false
  create_flow_log_cloudwatch_iam_role  = false

  # ARN of the a CloudWatch log group or an S3 bucket
  # disable if use 'create_flow_log_cloudwatch_log_group' and the default 'flow_log_destination_type' value (cloud-watch-logs)
  flow_log_destination_arn = "arn:aws:s3:::ops-1-30-vpc-flow-logs-devops-ops-alb-logs"

  # set 60 to use more detailed recoring
  flow_log_max_aggregation_interval         = 600
  # when use CloudWatch Logs, set this prefix
  flow_log_cloudwatch_log_group_name_prefix = "/aws/${local.env_name}-flow-logs/"

  # set custom log format for more detailed information
  flow_log_log_format = "$${region} $${vpc-id} $${az-id} $${subnet-id} $${instance-id} $${interface-id} $${flow-direction} $${srcaddr} $${dstaddr} $${srcport} $${dstport} $${pkt-srcaddr} $${pkt-dstaddr} $${pkt-src-aws-service} $${pkt-dst-aws-service} $${traffic-path} $${packets} $${bytes} $${action}"

...

Нас тут зараз цікавлять такі параметри:

  • flow_log_destination_type: замість дефолтного cloud-watch-logs задаємо s3
  • create_flow_log_cloudwatch_log_group та create_flow_log_cloudwatch_iam_role: відключаємо створення ресурсів для CloudWatch Logs
  • flow_log_destination_arn: задаємо ARN корзини, в яку будуть писатись логи
  • flow_log_log_format: створюємо власний формат, аби мати більше інформації, в тому числі з IP подів в Kubernetes, див. VPC Flow Log – Custom format

Виконуємо terraform apply, перевіряємо нашу VPC:

І через 10 хвилин перевіряємо логи в Grafana Loki:

Чудово – логи пішли.

Далі до запиту в Loki додаємо парсер pattern, аби сформувати поля в записах:

{logtype="alb", component="vpc-flow-logs"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`

Окей. Тепер, маючи логи і поля, можемо подумати про метрики з Loki Recording Rules, Grafana dashboard та алерти в Alertmanager.

Аналіз запису VPC Flow Logs з NAT Gateway

Спочатку давайте глянемо весь трафік через NAT Gateway – додаємо фільтр по interface_id="eni-0352f8c82da6aa229":

{logtype="alb", component="vpc-flow-logs"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
| interface_id="eni-0352f8c82da6aa229"

Що ми тут бачимо?

Наприклад, в першому записі:

Тут:

  • ingress: пакет вийшов до інтерфейсу NAT Gateway
  • 52.46.154.111: це src_addr – якийсь remote server
  • 10.0.5.175: це dst_addr – приватний IP нашого NAT Gateway
  • 443: це src_port – звідки прийшов пакет
  • 18779: це dst_port – куди прийшов пакет
  • 52.46.154.111 та 10.0.5.175:  це pkt_src_addr та pkt_dst_addr відповідно, значення такі ж, як і в5432 src_addr та dst_addr – тобто трафік явно “чисто NAT”, як розбирали в Від Remote Server до NAT Gateway
  • AMAZON: сервіс, від якого пакет отримано (але про це трохи далі)
  • pkt_dst_aws_service та traffic_path: пусті
  • 105: кількість пакетів
  • 113390: кількість байт
  • ACCEPT: пакет пройшов через Security Group/WAF

А в наступному запису бачимо dst_port 5432 – тут трафік явно до PostgreSQL RDS.

NAT Gateway та traffic_path

З цікавих моментів, які можна побачити в логах.

По-перше – це traffic_path. Іноді в логах, які пов’язані в NAT Gateway можна побачити “8”, тобто “Through an internet gateway” – див. Available fields.

Чому Internet Gateway? Бо трафік приходить з приватної мережі на NAT Gateway, але далі в інтернет він йде вже через Internet Gateway – див. One to Many: Evolving VPC Design.

pkt_src_aws_service, pkt_dst_aws_service та визначення сервісу

Щодо адрес не з нашої мережі, тобто якихось зовнішніх сервісів. В полях pkt_src_aws_service та pkt_dst_aws_service часто можна побачити запис типу “EC2” або “AMAZON” – але нам це нічого не каже.

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

Але тут є хак: якщо в src_port/dst_port ми бачимо порт 443 – то можна просто відкрити IP в браузері, де ми отримаємо помилку SSL, і в помилці буде ім’я сервісу, на який це сертифікат видано.

Наприклад, вище ми бачили, що pkt_src_aws_service == AMAZON. Якщо відкрити https://52.46.154.111 – то побачимо що саме на цьому IP:

Аналогічно будуть записи типу monitoring.us-east-1.amazonaws.com для AWS CloudWatch або athena.us-east-1.amazonaws.com для AWS Athena.

Створення Grafana dashboard

Тепер давайте пробувати створити Grafana dashboard.

Планування

Отже, головна мета – це мати уяву про трафік, який  проходить через AWS NAT Gateway.

Що ми знаємо та маємо?

  • знаємо CIDR приватних сабнетів для Kubernetes Pods
  • знаємо Elastic Network Interface ID, Public IP та Private IP для NAT Gateway – він у нас один, тому тут все просто
  • в логах маємо IP подів Kubernetes та якихось зовнішніх ресурсів
  • в логах маємо напрямок трафіку через інтерфейс NAT Gateway – IN/OUT (ingress/egress, або RX/TX – Recieved та Transmitted)

Що ми б хотіли бачити на дашборді?

  • загальний об’єм трафіку, який пройшов через NAT Gateway і за який ми заплатити
  • загальний об’єм трафіку NAT Gateway за напрямком – ingress/egress
  • сервіси в Kubernetes, які генерують найбільший трафік
  • AWS сервіси та зовнішні ресурси, які генерують трафік – для цього маємо поля pkt-src-aws-service та pkt-dst-aws-service
  • дія з пакетами ACCEPT та REJECT – може бути корисним, якщо є AWS Web Application Firewall, або вам цікаві спрацювання VPC Network Access List
  • Availability Zones для визначення cross-AZ трафіку – але це наразі не в нашому випадку, бо у нас все в одній зоні
  • traffic-path – може бути корисним для визначення якого типу трафік йде – всередині VPC, через VPC Endpoint тощо (хоча особисто я не став це використовувати в дашборді)

NAT Gateway total traffic processed

Отримати суму всього трафіку за період часу ми можемо таким запитом:

sum (
    sum_over_time(
        ({logtype="alb", component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | unwrap bytes
        | __error__=""
        )[$__range]
    )
)

Використовуємо [$__range], аби взяти проміжок часу, який задано в Grafana dashboard. В sum_over_time рахуємо всі bytes за цей час, і “загортаємо” все в sum(), аби мати просто цифру.

Для панелі “Total traffic processed” можна взяти тип візуалізації Stat, використати Unit з Bytes(IEC), і виглядати це буде так:

Маємо тут 5.8 GB за 15 хвилин.

Зараз маю для перевірки Flow Logs в CloudWatch, де можемо зробити такий запит для порівняння:

parse @message "* * * * * * * * * * * * * * * * * * *" 
| as region, vpc_id, az_id, subnet_id, instance_id, interface_id, 
| flow_direction, srcaddr, dstaddr, srcport, dstport, 
| pkt_srcaddr, pkt_dstaddr, pkt_src_aws_service, pkt_dst_aws_service, 
| traffic_path, packets, bytes, action 
| filter (interface_id like "eni-0352f8c82da6aa229") | stats sum(bytes) as bytesTransferred
| sort bytesTransferred desc
| limit 10

Тут у нас за ті ж 15 хвилин 4835737572 байт, тобто такі ж 4.8 гігабайта.

Окей – загальний трафік маємо.

Давайте додамо відображення Egress та Ingress.

NAT Gateway Egress та Ingress traffic processed – Stat

Тут все аналогічно, тільки додаємо фільтр | flow_direction="egress" або “ingress” відповідно:

sum (
    sum_over_time(
        ({logtype="alb", component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | flow_direction="egress"
        | unwrap bytes
        | __error__=""
        )[$__range]
    )
)

Так як панель у нас з типом Stat, де просто відображається цифра – то задля зменшення навантаження на Grafana та Loki в Options є сенс поставити Type == Instant.

Змінні дашборди – $kubernetes_pod_ip та $remote_svc_ip

По-перше – нам цікавий трафік саме з/до Kubernetes Pods, бо майже всі наші сервіси живуть там.

По-друге – хочеться мати можливість вибрати дані тільки по обраним pkt_src_addr та pkt_dst_addr – це може бути або Kubernetes Pod, або якийсь зовнішній сервіс – в залежності від ingress/egress трафіку.

Так як ми оперуємо з “сирими” записами в логах, а не метриками з лейблами – то ми не можемо просто взяти значення з полів, тому я додав дві змінні з типом Textbox, в які можна внести IP вручну:

А далі ми можемо ці змінні додати в усі наші запити з регуляркою pkt_src_addr=~"${kubernetes_pod_ip}", аби запит спрацьовував, якщо в змінній не задано жодного значення:

sum (
    sum_over_time(
        ({logtype="alb", component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | flow_direction="egress"
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"        
        | unwrap bytes
        | __error__=""
        )[$__range]
    )
)

Loki Recording Rules, поля, High Cardinality issue, та performance

Чому ми оперуємо з “сирими” записами в логах, а не метриками з лейблами?

Можна було б створити Recording Rule для Loki по типу такого:

...
        - record: aws:nat:egress_bytes:sum:15m
          expr: |
            sum (
                sum_over_time(
                    ({logtype="alb", component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
                    | interface_id="eni-0352f8c82da6aa229"
                    | flow_direction="egress"
                    | pkt_src_addr=ip("10.0.32.0/20")
                    | unwrap bytes
                    | __error__=""
                    )[15m]
                )
            )
...

Але якщо з даними типу “total processed bytes” це нормальний варіант, то далі, коли ми будемо створювати панелі з інформацією по IP, у нас буде проблема в тому, як ці IP зберігати в метриках.

Якщо ми будемо значення з pkt_src_addr та pkt_dst_addr заносити в лейбли метрики – то це призведе до того, що Loki буде створювати окремий набір блоків даних (chunks) на кожний унікальний набір лейбл.

А так як IP в VPC у нас багато, а зовнішніх IP може бути ще більше – то можемо отримати мільйони блоків даних, що вплине і на вартість зберігання даних в AWS S3, і на перформанс самої Loki та VictoriaMetrics або Prometheus, бо їм доведеться всі ці дані завантажувати про виконанні запитів в Grafana. Див. Loki Recording Rules, Prometheus/VictoriaMetrics та High Cardinality.

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

Тому тут варіант або змиритись з high cardinality issue і не дотримуватись best practices – або використовувати “сирі логи” в дашбордах.

При роботі з сирими логами в Loki та Grafana ми, звісно, обмежуємо себе, бо на запитах за відносно великий проміжок часу – наприклад, кілька годин – Loki починає жрати ресурси, як дурна, див. колонку CPU – майже 4 ядра зайняті повністю:

Можливо, я все ж спробую створити Recording Rules з IP в лейблах, подивитись як це вплине на систему. Поки у нас невеликий стартап і мало трафіку – це ще може бути варіантом. Але на великих обсягах такого краще не робити.

Іншим рішенням може бути спробувати використати VictoriaLogs (див. VictoriaLogs: знайомство, запуск в Kubernetes, LogsQL та Grafana), яка набагато краще може з цим працювати, і, скоріш за все, на наступному тижні я буду перероблювати цю схему саме з VictoriaLogs, тим більш сама VictoriaLogs вже отримала свій перший реліз, а скоро і її Grafana datasource вже буде офіційно додано до Grafana.

Крім того, до VictoriaLogs вже завели і підтримку Recording Rules, і алерти – див. vmalert.

NAT Gateway та traffic processed – графіки

На додачу до простих Stat панелей може бути корисним створити графіки – аби мати уявлення про те, як якісь зміни впливали на трафік.

Тут запит може бути аналогічним, тільки замість sum_over_time() використаємо rate(), в Options використовуємо Type == Range, а в Standart Options > Unit задаємо “bytes/sec”:

sum (
    rate(
        ({logtype="alb", component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"} | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_add> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | unwrap bytes
        | __error__=""
        )[15m]
    )
)

Для rate() беремо період 15 хвилин, бо логи у нас пишуться раз на 10 хвилин – дефолтне значення для flow_log_max_aggregation_interval в модулі terraform-aws-modules/vpc/aws.

Сервіси в Kubernetes, які генерують найбільший трафік

Наступним хочеться бачити IP з Kubernetes Pods, які генерують трафік.

Тут можемо створити візуалізацію з типом Pie chart і таким запитом:

topk (5,
  sum by (pkt_src_addr) (
    sum_over_time(
      (
        {component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_src_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
      )[$__range]
    )
  )
)

Використовуємо ip() з CIDR нашої приватної мережі, аби вибрати записи тільки з IP наших Pods (див. Matching IP addresses), і topk(5), аби відобразити тільки ті Pods, які генерують найбільше трафіку.

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

В топі у нас IP 10.0.44.66 – глянемо, що за сервіс:

$ kk get pod -A -o wide | grep -w 10.0.44.66
dev-backend-api-ns    backend-celery-workers-deployment-b68f88f74-rzq4j    ... 10.0.44.66 ...

Є такий Kubernetes Pod, окей. Тепер маємо уяву хто шле багато трафіку.

Grafana Data links

Аби швидко отримати інформацію що за IP, та до якого Kubernetes Pod він належить – можемо додати Grafana Data Links.

Наприклад, в мене є окрема дашборда, де по Pod IP можна отримати всю інформацію про нього.

Тоді можемо створити Data link з полем ${__field.labels.pkt_src_addr}:

І дашборда по IP “10.0.44.66”:

Всі доступні поля для Data links можна отримати з Ctrl+Space.

Або замість (чи на додачу) Pie chart можемо створити звичайний графік, аби мати “історичну картину”, як ми це робили для NAT Gateway Total traffic:

topk(5,
  sum by (pkt_src_addr) (
    rate(
      (
        {component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_src_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
      )[15m]
    )
  )
)

Remote services, які генерують найбільший трафік

По Kubernetes Pods інформацію отримали – давайте глянемо, звідки до нас приходить найбільше трафіку.

Тут все аналогічно, тільки фільтр робимо по pkt_dst_addr=ip("10.0.32.0/20") – тобто вибираємо всі записи, де пакет йде ззовні на NAT Gateway і потім до наших Pods:

topk(10,
  sum by (pkt_src_addr) (
    sum_over_time(
      (
        {component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_dst_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${remote_svc_ip}"
        | pkt_dst_addr=~"${kubernetes_pod_ip}"
        | unwrap bytes
        | __error__=""
      )[$__range]
    )
  )
)

А в Data Links можемо використати сервіс https://ipinfo.io і поле pkt_src_addr:

Сервіси в Kubernetes, які генерують найбільший трафік – таблиця з портами

Окремо можна додати табличку, де буде трохи більше інформації по кожному запису з логів.

Чому окремо – бо тут ми робимо запит з великою вибіркою по декільком полям, і через це Loki доведеться тягнути додаткові дані. Тому на запитах за великий проміжок часу нехай краще не прогрузиться одна табличка – але будуть графіки.

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

Створюємо візуалізацію з типом Table і таким запитом:

topk(10,
  sum by (pkt_src_addr, src_port, pkt_dst_addr, dst_port, pkt_dst_aws_service) (
    sum_over_time(
      (
        {component="vpc-flow-logs", __aws_s3_vpc_flow="fl-01e52787e4495dfee"}
        | pattern `<region> <vpc_id> <az_id> <subnet_id> <instance_id> <interface_id> <flow_direction> <src_addr> <dst_addr> <src_port> <dst_port> <pkt_src_addr> <pkt_dst_addr> <pkt_src_aws_service> <pkt_dst_aws_service> <traffic_path> <packets> <bytes> <action>`
        | interface_id="eni-0352f8c82da6aa229"
        | action="ACCEPT"
        | pkt_src_addr=ip("10.0.32.0/20")
        | pkt_src_addr=~"${kubernetes_pod_ip}"
        | pkt_dst_addr=~"${remote_svc_ip}"
        | unwrap bytes
        | __error__=""
      )[$__range]
    )
  )
)

В Options задаємо Type == Instant:

Додаємо Transformations:

  • Filter fields by name: тут прибираємо Time
  • Organize fields by name: міняємо заголовки колонок

Значення для Standard options > Unit та Data Links задаємо через Fields override, бо для кожної колонки у нас будуть власні параметри:

Grafana dashboard: фінальний результат

І все разом в мене поки що вийшло ось так:

Якщо не зважати не проблеми з перформансом Loki при використанні raw logs – то наче непогано. Вже дуже допомогло визначити зайвий трафік, наприклад – багато трафіку йшло від Athena, тому додамо VPC Endpoint для неї, аби не ганяти цей трафік через NAT Gateway.

Далі, мабуть, таки спробую варіант з Recording Rules для Loki, і точно буду пробувати писати логи до VictoriaLogs, і робити графіки та алерти через неї.

Loading