FreeBSD: Home NAS, part 1 – налаштування ZFS mirror (RAID1)
5 (1)

7 Грудня 2025

Є в мене ідея підняти собі вдома NAS на FreeBSD.

Для цього купив машинку Lenovo ThinkCentre M720s SFF – тиха, компактна, є можливість встановити 2 SATA III SSD + окремо M.2 слот під NVMe SSD.

Що планується:

  • на NVMe SSD: UFS і FreeBSD
  • на SATA SSD: ZFS з RAID1

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

Встановлювати будемо FreeBSD 14.3, хоча вже вийшла 15, але там є цікаві зміни, з нею окремо пограюсь.

Звісно, можна було взяти TrueNAS, яка базується на FreeBSD – але хочеться “ванільної” FreeBSD і все поробити ручками.

Всі частини серії по налаштуванню домашнього NAS на FreeBSD:

Установка FreeBSD через SSH

Установку будемо робити по SSH з bsdinstall – завантажимо систему в режимі LiveCD, включимо SSH, і далі вже з робочого ноута виконаємо установку системи.

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

Вибираємо Live System:

Логінимось з root:

Піднімаємо мережу:

# ifconfig em0 up
# dhclient em0

Налаштування SSH на FreeBSD LiveCD

Для SSH нам треба буде задати пароль root і робити зміни в /etc/ssh/sshd_config, але зараз це все не працює, бо система змонтована в read-only:

Дивимось, які розділи є зараз:

І робимо “dirty hack:

  • монтуємо нову файлову систему tmpfs в RAM на /mnt
  • копіюємо туди вміст /etc з LiveCD
  • монтуємо tmpfs поверх /etc (перекриваючи read-only каталог з ISO)
  • копіюємо підготовлені файли з /mnt назад у новий /etc

Виконуємо:

# mount -t tmpfs tmpfs /mnt
# cp -a /etc/* /mnt/
# mount -t tmpfs tmpfs /etc
# cp -a /mnt/* /etc/

Синтаксис mount для tmptfsmount -t <fstype> <source> <mountpoint>, в source значення обов’язкове, тому ще раз вказуємо tmpfs.

Тепер задаємо пароль з passwd і стартуємо sshd з onestart:

# passwd
# service sshd onestart

Але SSH все ще не пустить, бо по дефолту логін root заборонений:

$ ssh [email protected]
([email protected]) Password for root@:
([email protected]) Password for root@:
([email protected]) Password for root@:

Задаємо PermitRootLogin yes в /etc/ssh/sshd_config, ще раз рестартимо sshd:

# echo "PermitRootLogin yes" >> /etc/ssh/sshd_config
# service sshd onerestart

Тепер можемо залогінитись:

$ ssh [email protected]
([email protected]) Password for root@:
Last login: Sun Dec  7 12:19:25 2025
FreeBSD 14.3-RELEASE (GENERIC) releng/14.3-n271432-8c9ce319fef7

Welcome to FreeBSD!
...

root@:~ # 

Установка з bsdinstall

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

# bsdinstall

Вибираємо що будемо додавати в систему – ports треба, src опціонально, на реальному NAS точно варте:

Disk partitioning

Будемо робити мінімальну розбивку диска, тому вибираємо Manual:

Встановлювати систему будемо на ada0, вибираємо його і Create:

Далі треба вибрати схему, тут, в принципів, стандартно для 2025 року – GPT:

Підтверджуємо зміни, і маємо нову partition table на системному ada0:

Розділ freebsd-boot

Далі треба створити самі розділи.

Ще раз вибираємо ada0, Create, і створюємо розділ під freebsd-boot.

Це тільки зараз на віртуальній машині, на самому ThinkCentre тут будемо робити тип efi з розміром мегабайт у 200-500.

Зараз задаємо:

  • Type: freebsd-boot
  • Size: 512K
  • Mountpoint: empty
  • Label: empty

Підтверджуємо, йдемо до наступного розділу.

Розділ freebsd-swap

Ще раз Create, додаємо Swap.

Враховуючи що у нас на ThninkCentre будуть:

  • 8 – 16 GB RAM
  • без sleep/hibernate
  • буде UFS і ZFS

То 2 гігабайти вистачить.

Задаємо:

  • Type: freebsd-swap
  • Size: 2GB
  • Mountpoint: empty
  • Label: empty

Розділ root з UFS

Основна система буде на UFS, бо вона дуже стабільна, не потребує для роботи RAM, швидко монтується, проста у відновленні, без складних механізмів кешування.

Задаємо:

  • Type: freebsd-ufs
  • Size: 14GB
  • Mountpoint: /
  • Label: rootfs – просто ім’я для нас

Решту дисків налаштуємо пізніше, зараз вибираємо Finish і Commit:

Finihsing installation

Чекаємо завершення копіювання:

Налаштовуємо мережу:

Таймзону:

В System Configuration – sshd, без мишки, включаємо ntpd і powerd:

System Hardening – враховуючи що це буде домашній NAS, але буду, мабуть, відкривати доступ ззовні, хоч і за фаєрволом – то є сенс трохи затюнити security:

  • read_msgbuf: дозволяємо dmesg тільки root
  • proc_debug: дозволяємо ptrace тільки root
  • random_pid: номера PID в більш рандомному порядку
  • clear_tmp: чистимо /tmp при ребутах
  • secure_console: вимагаємо пароль root при логіні з фізичної консолі

Додаємо юзера:

Все готово – ребутаємо машину:

Створення ZFS RAID

Логінимось вже під звичайним юзером:

$ ssh [email protected]
...
FreeBSD 14.3-RELEASE (GENERIC) releng/14.3-n271432-8c9ce319fef7
Welcome to FreeBSD!
...
setevoy@test-nas-1:~ $ 

Встановлюємо vim 🙂

# pkg install vim

Перевіряємо що у нас з дисками.

З geom disk – інформація по фізичним девайсам, з gpart show – подивитись розділи на дисках.

Перевіряємо диски – три штуки:

root@test-nas-1:/home/setevoy # geom disk list
Geom name: ada0
Providers:
1. Name: ada0
   Mediasize: 17179869184 (16G)
   Sectorsize: 512
   Mode: r2w2e3
   descr: VBOX HARDDISK
   ident: VB262b53f7-adc5cd2c
   rotationrate: unknown
   fwsectors: 63
   fwheads: 16

Geom name: ada1
Providers:
1. Name: ada1
   Mediasize: 17179869184 (16G)
   Sectorsize: 512
   Mode: r0w0e0
   descr: VBOX HARDDISK
   ident: VB059f9d08-4b0e1f56
   rotationrate: unknown
   fwsectors: 63
   fwheads: 16

Geom name: ada2
Providers:
1. Name: ada2
   Mediasize: 17179869184 (16G)
   Sectorsize: 512
   Mode: r0w0e0
   descr: VBOX HARDDISK
   ident: VB3941028c-3ea0d485
   rotationrate: unknown
   fwsectors: 63
   fwheads: 16

І з gpart – поточний ada0, на який встановлювали систему:

root@test-nas-1:/home/setevoy # gpart show
=>      40  33554352  ada0  GPT  (16G)
        40      1024     1  freebsd-boot  (512K)
      1064   4194304     2  freebsd-swap  (2.0G)
   4195368  29359024     3  freebsd-ufs  (14G)

Диски ada1 та ada2 будуть під ZFS і його mirror (RAID1).

Якщо там щось було – все видаляємо:

root@test-nas-1:/home/setevoy # gpart destroy -F ada1
gpart: arg0 'ada1': Invalid argument
root@test-nas-1:/home/setevoy # gpart destroy -F ada2
gpart: arg0 'ada2': Invalid argument

Але так як це віртуалка, і диски пусті – то маємо “Invalid argument”, зараз це ОК.

Створюємо GPT розмітку на ada1 та ada2:

root@test-nas-1:/home/setevoy # gpart create -s gpt ada1
ada1 created
root@test-nas-1:/home/setevoy # gpart create -s gpt ada2
ada2 created

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

root@test-nas-1:/home/setevoy # gpart show ada1
=>      40  33554352  ada1  GPT  (16G)
        40  33554352        - free -  (16G)

Створюємо розділи під ZFS:

root@test-nas-1:/home/setevoy # gpart add -t freebsd-zfs ada1
ada1p1 added
root@test-nas-1:/home/setevoy # gpart add -t freebsd-zfs ada2
ada2p1 added

Ще раз перевіряємо:

root@test-nas-1:/home/setevoy # gpart show ada1
=>      40  33554352  ada1  GPT  (16G)
        40  33554352     1  freebsd-zfs  (16G)

Створення ZFS mirror із zpool

Вся “магія” ZFS – що в ньому все йде “з коробки” – не треба окремий LVM і його групи, не треба mdadm для RAID.

Для роботи з дисками в ZFS основна утиліта – zpool, для роботи з даними (datasets, файловими системами, снапшотами) – zfs.

Для об’єднання одного або кількох дисків у єдиний логічний storage ZFS використовує pool – в Linux LVM аналог volume group.

Створюємо пул:

root@test-nas-1:/home/setevoy # zpool create tank mirror ada1p1 ada2p1

Тут tank – ім’я пула, в mirror вказуємо, що це буде RAID1, і передаємо список розділів, які в цей пул включаються.

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

root@test-nas-1:/home/setevoy # zpool status
  pool: tank
 state: ONLINE
config:

        NAME        STATE     READ WRITE CKSUM
        tank        ONLINE       0     0     0
          mirror-0  ONLINE       0     0     0
            ada1p1  ONLINE       0     0     0
            ada2p1  ONLINE       0     0     0

errors: No known data errors

ZFS відразу монтує цей пул в /tank:

root@test-nas-1:/home/setevoy # mount
/dev/ada0p3 on / (ufs, local, soft-updates, journaled soft-updates)
devfs on /dev (devfs)
tank on /tank (zfs, local, nfsv4acls)

Перевіряємо розділи зараз:

root@test-nas-1:/home/setevoy # gpart show
=>      40  33554352  ada0  GPT  (16G)
        40      1024     1  freebsd-boot  (512K)
      1064   4194304     2  freebsd-swap  (2.0G)
   4195368  29359024     3  freebsd-ufs  (14G)

=>      40  33554352  ada1  GPT  (16G)
        40  33554352     1  freebsd-zfs  (16G)

=>      40  33554352  ada2  GPT  (16G)
        40  33554352     1  freebsd-zfs  (16G)

Якщо хочемо змінити маунтпоінт – виконуємо zfs set mountpoint:

root@test-nas-1:/home/setevoy # zfs set mountpoint=/data tank

І він відразу монтується в новий каталог:

root@test-nas-1:/home/setevoy # mount
/dev/ada0p3 on / (ufs, local, soft-updates, journaled soft-updates)
devfs on /dev (devfs)
tank on /data (zfs, local, nfsv4acls)

Налаштовуємо компресію даних – корисно для NAS, див. Compression і Compressing ZFS File Systems.

lz4 – дефолтний варіант зараз, включаємо її:

root@test-nas-1:/home/setevoy # zfs set compression=lz4 tank

Так як саму систему ми встановлювали з UFS, то для роботи ZFS треба додати пару параметрів в автозапуск.

Налаштування boot loader в /boot/loader.conf аби завантажити модулі ядра:

zfs_load="YES"

Або, щоб не лазити руками – sysrc і з -f ім’я файлу:

root@test-nas-1:/home/setevoy # sysrc -f /boot/loader.conf zfs_load="YES"

І до /etc/rc.conf, аби запустити демон zfsd і змонтувати файлові системи:

root@test-nas-1:/home/setevoy # sysrc zfs_enable="YES"
zfs_enable: NO -> YES

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

root@test-nas-1:/home/setevoy # zpool status
  pool: tank
 state: ONLINE
config:

        NAME        STATE     READ WRITE CKSUM
        tank        ONLINE       0     0     0
          mirror-0  ONLINE       0     0     0
            ada1p1  ONLINE       0     0     0
            ada2p1  ONLINE       0     0     0

Все нам місці.

Тепер можна поробити всякий тюнінг – налаштувати окремі datasets, snapshots тощо.

Для Web UI можна буде спробувати Seafile чи FileBrowser.

Loading

VictoriaMetrics: Recording Rules для логів AWS Load Balancer
0 (0)

5 Грудня 2025

В продовження теми логів AWS Load Balancer: в попередньому пості  Golang: запис логів AWS Loab Balancer до VictoriaLogs зробили збір логів з власним logs collector на Golang, тепер треба з цих логів отримати щось корисне.

Раніше, коли у нас на проекті була Loki, ми з її RecordingRules створювали метрики, з яких потім малювали дашборди в Grafana і мали алерти з Alertmanager.

Тепер треба це перенести до VictoriaMetrics, у якої є власні RecordingRules, з якими VMAlert може виконувати запити до VictoriaLogs.

Колись про це писав у VictoriaLogs: створення Recording Rules з VMAlert, але там більше просто ознайомлення і налаштування – а сьогодні будемо робити конкретну задачу.

Що хочеться бачити в метриках з логів:

  • помилки 4хх
  • помилки 5хх
  • загальна кількість реквестів на ALB
  • час відповіді загальна
  • час target response time на конкретних ендпоінтах нашого Backend API

VictoriaLogs LogsQL ALB logs parsing

ALB access logs має чітко визначений формат полів (який, правда, іноді змінюється), див. документацію Access log entries.

Знаючи ці поля – з LogsQL pipes можемо їх розпарсити і створити потрібні fields, які потім будемо використовувати для обчислень.

extract pipe

Перший pipe, який використаємо – extract, з яким читаємо дані з логу і створюємо поля.

Всі поля нам не потрібні, деякі поля створюються ще на етапі збору логів в Go (описаний у Створення Log parser), тому задаємо <_> на ті поля, які хочемо скіпнути, і тепер маємо таку конструкцію:

{stream="alb"}
| extract "<_> <_> <elb_id> <_> <_> <request_processing_time> <target_processing_time> <response_processing_time> <elb_status_code> <target_status_code> <received_bytes> <sent_bytes> <request_line> <user_agent> <_> <_> <_> <trace_id> <domain_name> <_> <_> <_> <_> <_> <error_reason> <_> <_> <_> <_> <conn_trace_id> <_> <_> <_>"

На початку використовуємо Stream filter, який задається в нашому колекторі логів, а далі описуємо сам патерн полів.

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

{stream="alb"}
| extract "<_> <_> <elb_id> <_> <_> <request_processing_time> <target_processing_time> <response_processing_time> <elb_status_code> <target_status_code> <received_bytes> <sent_bytes> <request_line> <user_agent> <_> <_> <_> <trace_id> <domain_name> <_> <_> <_> <_> <_> <error_reason> <_> <_> <_> <_> <conn_trace_id>"

extract_regexp pipe

Нам потрібно буде робити час відповіді і помилки по конкретним ендпоінтам нашого Backend API, і нам вистачить першої частини URI.

Тобто зі строки “https://staging.api.challenge.example.co:443/roadmap/media/123 HTTP/2.0” треба отримати тільки “/roadmap”.

Сам запит йде в полі request_line:

Тут можемо використати extract_regexp pipe з яким отримуємо першу частину після “/” і зберігаємо результат в regex named capture group з ім’ям uri_path:

{stream="alb"}
| extract "<_> <_> <elb_id> <_> <_> <request_processing_time> <target_processing_time> <response_processing_time> <elb_status_code> <target_status_code> <received_bytes> <sent_bytes> <request_line> <user_agent> <_> <_> <_> <trace_id> <domain_name> <_> <_> <_> <_> <_> <error_reason> <_> <_> <_> <_> <conn_trace_id>"
| extract_regexp `.*:443(?P<uri_path>/[^/?]+).* HTTP` from request_line

Для extract_regexp задаємо поле, яке він буде парсити – from request_line, аби не парсити весь зміст _msg.

Отримуємо такий результат:

filter pipe

Якщо хочемо відфільтрувати дані – використовуємо filter, в який можемо передати регулярку.

Наприклад, аби вибрати тільки деякі домени – робимо filter domain_name :~ "regex". Або без регулярки – з := "example.com", що буде швидше.

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

{stream="alb"}
| extract "<_> <_> <elb_id> <_> <_> <request_processing_time> <target_processing_time> <response_processing_time> <elb_status_code> <target_status_code> <received_bytes> <sent_bytes> <request_line> <user_agent> <_> <_> <_> <trace_id> <domain_name> <_> <_> <_> <_> <_> <error_reason> <_> <_> <_> <_> <conn_trace_id>"
| filter domain_name :~ ".+challenge.example.co|lightdash.example.co"
| extract_regexp `.*:443(?P<uri_path>/[^/?]+).* HTTP` from request_line

Або можемо прибрати якісь результати, наприклад видалити всі домени, в яких є тире – робимо filter domain_name :!~ "-":

{stream="alb"}
| extract "<_> ... "
| filter domain_name :!~ "-"
| extract_regexp `.*:443(?P<uri_path>/[^/?]+).* HTTP` from request_line

stats pipe та stats pipe functions

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

Для цього у нас є stats pipe і її функції, див. всі в документації stats pipe functions.

Наприклад, avg():

{stream="alb"}
| extract "<_> <_> <elb_id> <_> <_> <request_processing_time> <target_processing_time> <response_processing_time> <elb_status_code> <target_status_code> <received_bytes> <sent_bytes> <request_line> <user_agent> <_> <_> <_> <trace_id> <domain_name> <_> <_> <_> <_> <_> <error_reason> <_> <_> <_> <_> <conn_trace_id>"
| filter domain_name :!~ "^-$"
| extract_regexp `.*:443(?P<uri_path>/[^/?]+).* HTTP` from request_line
|stats by (domain_name) avg()

Результат:

Але зараз avg() рахує значення зі всіх numeric log fields:

avg returns the average value over the given numeric log fields .

Тому користі з цього нуль.

Натомість можемо вказати яке саме поле використовувати, вказуємо його аргументом до avg() –  avg(target_processing_time):

{stream="alb"}
...
|stats by (domain_name) avg(target_processing_time) as avg_target_processing_time

Результат:

math pipe

Ще хочеться бачити загальний час виконання запитів, але в полях ALB у нас три окремі значення – request_processing_time, target_processing_time та response_processing_time.

Робити три окремі метрики, а потім рахувати в Grafana не хочеться, тому можемо зробити простіше – використати LogsQL math(): спочатку рахуємо суму по всім трьом полям, а потім отриманий результат передаємо до avg():

{stream="alb"}
...
| math (request_processing_time + target_processing_time + response_processing_time) as total_processing_time
|stats by (domain_name) avg(total_processing_time) as avg_total_processing_time

Результат:

Хоча насправді сенсу особо  нема, бо в 99.9% випадків 99.9% часу все одно займає target_processing_time.

AWS Athena та AWS ALB logs

“Довіряй – але перевіряй”.

Тому зробимо табличку в AWS Athena, і потім з неї будемо робити запити для перевірки.

Документація – Query Application Load Balancer logs.

Виконуємо запит:

CREATE EXTERNAL TABLE IF NOT EXISTS alb_access_logs (
    type string,
    time string,
    elb string,
    client_ip string,
    client_port int,
    target_ip string,
    target_port int,
    request_processing_time double,
    target_processing_time double,
    response_processing_time double,
    elb_status_code int,
    target_status_code string,
    received_bytes bigint,
    sent_bytes bigint,
    request_verb string,
    request_url string,
    request_proto string,
    user_agent string,
    ssl_cipher string,
    ssl_protocol string,
    target_group_arn string,
    trace_id string,
    domain_name string,
    chosen_cert_arn string,
    matched_rule_priority string,
    request_creation_time string,
    actions_executed string,
    redirect_url string,
    lambda_error_reason string,
    target_port_list string,
    target_status_code_list string,
    classification string,
    classification_reason string,
    conn_trace_id string
)
ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.RegexSerDe'
WITH SERDEPROPERTIES (
    'serialization.format' = '1',
    'input.regex' =
'([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*):([0-9]*) ([^ ]*)[:-]([0-9]*) ([-.0-9]*) ([-.0-9]*) ([-.0-9]*) (|[-0-9]*) (-|[-0-9]*) ([-0-9]*) ([-0-9]*) \"([^ ]*) (.*) (- |[^ ]*)\" \"([^\"]*)\" ([A-Z0-9-_]+) ([A-Za-z0-9.-]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^\"]*)\" ([-.0-9]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^ ]*)\" \"([^\\s]+?)\" \"([^\\s]+)\" \"([^ ]*)\" \"([^ ]*)\" ?([^ ]*)? ?( .*)?'
)
LOCATION 's3://ops-1-33-devops-ingress-ops-alb-loki-logs/AWSLogs/492***148/elasticloadbalancing/us-east-1/';

Тепер маємо таблицю, яка дозволяє виконувати SQL-запити до логів, що зберігаються в S3:

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

SELECT elb_status_code, count(*)
FROM alb_access_logs
WHERE date(from_iso8601_timestamp(time)) = date('2025-12-04')
GROUP BY elb_status_code
ORDER BY elb_status_code;

Результат:

Ну і для додатково користуємось метриками з CloudWatch.

Recording Rules з VictoriaLogs

Розібрались з тим, як нам отримати поля, сформували поля – можна переходити до Recording Rules та метрик.

Документація – Recording rules.

AWS ALB logs 5-minute delay issue

Але перед тим, як почати писати rules – є момент, який треба уточнити:

  • у нас логи з S3 збираються раз на 5 хвилин (“ELB publishes a log file for each load balancer node every 5 minutes” – документація Access log files)
  • дефолтний Recording Rules evaluation time – 1 хвилина (див. Configuration)
  • і якщо момент виконання rule не співпаде з моментом запуску log parser і у VictoriaLogs логів на цей момент не буде – то і наш rule не спрацює, і нічого не створить

Але для таких випадків ми в налаштуваннях Rules group можемо використати eval_delay, наприклад:

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
  name: vmlogs-alert-rules
spec:
  groups:
    - name: VM-ALB-logs-Test
      type: vlogs
      eval_delay: 15m
...

Тоді задача, яка мала запуститись у, наприклад, 12:00:00 – буде запущена в 12:15:00, але вона буде шукати логи в проміжок часу 12:00:00 – 12:01:00.

Ну а в логах, в нашому конкретному випадку, timestamp парситься з самих логів, тому в VictoriaLogs час буде такий, як було збережено самим лоад-балансером.

Планування Recording Rules

Повернемось до початку – що хочеться бачити:

  • помилки 4хх – ALB та target
  • помилки 5хх – ALB та target
  • загальна кількість реквестів на ALB та по конкретних ендпоінтах нашого Backend API
  • час відповіді загальна
  • час target response time на конкретних ендпоінтах

Тобто задача, фактично, зводиться к трьом показникам – кількість помилок, кількість реквестів, час відповіді.

Як ми це можемо зробити з Recording Rules і метриками:

  • 4хх і 5хх помилки по полям elb_status_code:
    • з фільтром filter elb_status_code :~ "40[1-4]|50[0-4]" отримуємо всі логи, в яких є elb_status_code з 4хх або 5хх
    • потім зі stats і rate() рахуємо кількість знайдених log records, в яких були 4хх або 5хх помилки
    • потім в дашбордах і алертах робимо фільтри по 4хх або 5хх (хоча можна і окремі метрики робити)
  • target_status_code – аналогічно
  • загальна кількість реквестів на секунду по домену:
    • кожен запис в логах == окремий HTTP query
    • тому можемо просто робити stats by (elb_id, domain_name) rate() – групуємо по elb_id, domain_name, аби потім мати фільтри в Grafana і алертах
  • загальна кількість реквестів на ендпоінт
    • аналогічно, тільки додаємо uri_pathstats by (elb_id, domain_name, uri_path) rate()
    • в uri_path у нас тільки перша частина запиту, їх не так багато різних, тому проблеми з high cardinality у VictoriaLogs не буде
  • ALB total response time і на конкретних ендпоінтах – тут є варіанти 🙂 див далі

Пам’ятаємо про high cardinality і в VictoriaMetrics: все, що ми використаємо у stats by () потім буде збережено в labels метрик, а тому робити групування по всяким всякі trace_id не варто – при потребі це можна буде перевіряти в самих логах, а в алертах і Grafana воно нам не треба.

Див. також VictoriaMetrics: Churn Rate, High cardinality, метрики та IndexDB.

Поїхали.

ALB Requests Rate rule

Почнемо з самої простої – просто рахуємо скільки записів було отримано за останній evalution interval, тобто 1 хвилину, потім з rate() отримуємо кількість змін на секунду:

apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
  name: vmlogs-alert-rules
spec:
  groups:
    - name: VM-ALB-logs
      type: vlogs
      eval_delay: 15m
      interval: 1m
        - record: vmlogs:alb:stats:elb_requests_total:rate
          debug: true
          expr: |
            {stream="alb"}
            | extract "<_> <_> <elb_id> <_> <_> <request_processing_time> <target_processing_time> <response_processing_time> <elb_status_code> <target_status_code> <received_bytes> <sent_bytes> <request_line> <user_agent> <_> <_> <_> <trace_id> <domain_name> <_> <_> <_> <_> <_> <error_reason> <_> <_> <_> <_> <conn_trace_id> <_> <_> <_>"
            | stats by (elb_id, domain_name) rate() as elb_requests_rate

Деплоїмо, чекаємо 5-10 хвилин, перевіряємо в VictoriaMetrics:

О 10:13 маємо значення 4.83.

Перевіряємо в CloudWatch, де з math просто робимо “sum requests in 1 minute /60”:

Ті ж 4.83, правда о 10:12.

Можемо ще перевірити з Athena, яку підготували раніше, і з таким запитом:

SELECT
  date_trunc('minute', from_iso8601_timestamp(time)) AS minute,
  count(*) / 60.0 AS rps_estimated
FROM alb_access_logs
WHERE from_iso8601_timestamp(time) >= now() - interval '1' hour
GROUP BY 1
ORDER BY 1 DESC;

Ті ж цифри, тільки теж о 10:12:

ALB 4xx/5xx Errors rule

Далі робимо per second rate помилок – та ж сама логіка, тільки додаємо filter elb_status_code :~ "40[1-4]":

- record: vmlogs:alb:stats:elb_errors:4xx:rate
  debug: false
  expr: |
    {stream="alb"}
    | extract "<_> <_> <elb_id> <_> <_> <request_processing_time> <target_processing_time> <response_processing_time> <elb_status_code> <target_status_code> <received_bytes> <sent_bytes> <request_line> <user_agent> <_> <_> <_> <trace_id> <domain_name> <_> <_> <_> <_> <_> <error_reason> <_> <_> <_> <_> <conn_trace_id> <_> <_> <_>"
    | filter elb_status_code :~ "40[1-4]"
    | extract_regexp `.*:443(?P<uri_path>/[^/?]+).* HTTP` from request_line
    | stats by (elb_id, elb_status_code, domain_name, uri_path) rate() as elb_4xx_errors_rate

Деплоїмо, чекаємо, перевіряємо у VictoriaMetrics:

Маємо значення 0.91 об 11:07.

В CloudWtach – те саме, тільки знов на 1 хвилину раніше:

ALB Target Processing Time rule

Наступним хочеться отримати те, що в CloudWatch у нас приходить в метриці TargetResponseTime, але мати можливість отримати і загальний час по всьому LoadBalancer – і по окремим URI.

Проблема зі stats by () і avg()

Перше рішення, яке прийшло в голову – це просто взяти середнє по кожному ендпоінту, потім взяти середнє по всім отриманням значенням – і має бути +/- те самий результат, що в CloudWatch Target Response Time з Average.

Але якщо зробити метрику так:

- record: vmlogs:alb:stats:elb_target_processing_time:avg
  debug: false
  expr: |
    {stream="alb"}
    ...
    | stats by (elb_id, domain_name, uri_path) avg(target_processing_time) as avg_target_processing_time

І потім для отримання загального середнього значення у VictoriaMetrics виконувати avg():

avg(vmlogs:alb:stats:elb_target_processing_time:avg)

То дані в VictoriaMetrics і CloudWatch будуть різні.

Наприклад, в 12:52 у VictoriaMetrics маємо значення 0.06:

Але в CloudWatch – Target Response Time і Average за 1 хвилину у 12:21 буде 0.11:

Або 0.15 в 12:52:

Зовсім не схоже на те, що бачимо у VictoriaMetrics.

Окей – тоді, може, просто взяти суму від всіх наших avg(by uri_path)?

sum(vmlogs:alb:stats:elb_target_processing_time:avg)

Але тоді о 12:52 результат буде взагалі 1.23:

Чому так виходить?

Бо у нас в stats() результати рахуються для кожної групи (elb_id, domain_name, uri_path).

Тобто, якщо взяти такі приклади і використати тільки uri_path:

  • /uri-1: 200 requests по 0.1 секунді:
    • sum: 200*0.1       == 20 sec
    • avg: 200*0.1/200 == 0.1 sec
  • /uri-2: 10 requests по 2 секунди:
    • sum: 10*2       == 20 sec
    • avg: 10*2/10  == 2 sec

Якщо ми будемо рахувати результат як avg(vmlogs:alb:stats:elb_target_processing_time:avg) – то для отримання avg() ми візьмемо значення з кожної метрики:

  • vmlogs:alb:stats:elb_target_processing_time:avg{uri_path="/uri-1"} == 0.1 sec
  • vmlogs:alb:stats:elb_target_processing_time:avg{uri_path="/uri-2"} == 2 sec

І поділимо на кількість отриманих метрик, тобто на 2, що дасть нам:

  • (0.1 sec + 2 sec) / 2 == 1.05 sec

Якщо ж ми будемо рахувати як sum(vmlogs:alb:stats:elb_target_processing_time:avg) – то для тої ж пари:

  • vmlogs:alb:stats:elb_target_processing_time:avg{uri_path="/uri-1"} == 0.1 sec
  • vmlogs:alb:stats:elb_target_processing_time:avg{uri_path="/uri-2"} == 2 sec

Ми отримаємо:

  • (0.1 sec + 2 sec) = 2.1 sec

Average values in CloudWatch

А як CloudWatch обчислює Average взагалі, і по TargetResponseTime?

Він бере кількість отриманих метрик, Sample count – о 12:52 це було 143:

Потім бере суму по всім отриманим метрикам, Sum – в 12:52 це було 22:

І ділить суму на кількість – 22/143 == 0.1538:

Або якщо повернутись до нашого прикладу вище – то це сума часу по обом ендпоінтам поділена на загальну кількість запитів: (/uri-1 sum + /uri-2 sum) / (/uri-1 requests + /uri-1 requests):

  • (20+20)/210 == 0.19

Тобто реальний середній час відповіді нашого умовного ALB – 0.19 секунди, а не 1.05 у випадку з avg() або тим більш не 2.1 sec у випадку з sum().

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

  • або в stats() треба брати суму, а не avg(), а потім отриманий результат ділити на загальну кількість запитів
  • або мати окрему метрику на загальний середній час – і окрему на середній час по кожному ендпоінту

При першому варіанті – це може виглядати так:

- record: vmlogs:alb:stats:elb_target_processing_time:sum
  debug: false
  expr: |
    {stream="alb"}
    | extract "<_> <_> <elb_id> <_> <_> <request_processing_time> <target_processing_time> <response_processing_time> <elb_status_code> <target_status_code> <received_bytes> <sent_bytes> <request_line> <user_agent> <_> <_> <_> <trace_id> <domain_name> <_> <_> <_> <_> <_> <error_reason> <_> <_> <_> <_> <conn_trace_id> <_> <_> <_>"
    | extract_regexp `.*:443(?P<uri_path>/[^/?]+).* HTTP` from request_line
    | filter target_processing_time :! "-1"
    | stats by (elb_id, domain_name, uri_path) sum(target_processing_time) as sum_target_processing_time

- record: vmlogs:alb:stats:elb_target_processing_time:count
  debug: false
  expr: |
    {stream="alb"}
    | extract "<_> <_> <elb_id> <_> <_> <request_processing_time> <target_processing_time> <response_processing_time> <elb_status_code> <target_status_code> <received_bytes> <sent_bytes> <request_line> <user_agent> <_> <_> <_> <trace_id> <domain_name> <_> <_> <_> <_> <_> <error_reason> <_> <_> <_> <_> <conn_trace_id> <_> <_> <_>"
    | extract_regexp `.*:443(?P<uri_path>/[^/?]+).* HTTP` from request_line
    | filter target_processing_time :! "-1"
    | stats by (elb_id, domain_name, uri_path) count() as count_requests

Замість vmlogs:alb:stats:elb_target_processing_time:count можна було б взяти vmlogs:alb:stats:elb_requests_total:count, яку ми робили вище – але в метриках target_processing_time ми прибираємо значення “-1”, яке буває в target_processing_time якщо підключення розірване чи завершилось помилкою, а в vmlogs:alb:stats:elb_requests_total:count у нас всі реквести без виключення.

І з цими двома метриками ми можемо отримати середнє значення часу відповіді по кожному ендпоінту так:

sum(
  sum(vmlogs:alb:stats:elb_target_processing_time:sum) by (uri_path)
  / 
  sum(vmlogs:alb:stats:elb_target_processing_time:count) by (uri_path)
)
by (uri_path)

Тут у нас в 12:47 ендпоінт /chats відповідав 0.54 секунди.

І те саме значення буде в самому першому варіанті:

- record: vmlogs:alb:stats:elb_target_processing_time:avg
  debug: false
  expr: |
    {stream="alb"}
    | extract "<_> <_> <elb_id> <_> <_> <request_processing_time> <target_processing_time> <response_processing_time> <elb_status_code> <target_status_code> <received_bytes> <sent_bytes> <request_line> <user_agent> <_> <_> <_> <trace_id> <domain_name> <_> <_> <_> <_> <_> <error_reason> <_> <_> <_> <_> <conn_trace_id> <_> <_> <_>"
    | extract_regexp `.*:443(?P<uri_path>/[^/?]+).* HTTP` from request_line
    | filter target_processing_time :! "-1"
    | stats by (elb_id, domain_name, uri_path) avg(target_processing_time) as avg_target_processing_time

Маємо о 12:47 ті самі 0.54 на /chats:

А аби отримати середній загальний час відповіді по всьому лоад-балансеру – можемо або використати метрики vmlogs:alb:stats:elb_target_processing_time:sum і vmlogs:alb:stats:elb_target_processing_time:count так:

sum(
  sum(vmlogs:alb:stats:elb_target_processing_time:sum)
  / 
  sum(vmlogs:alb:stats:elb_target_processing_time:count)
)

Отримаємо 0.22 у 12:58:

Або просто зробити окрему метрику зі stats by (elb_id) avg():

- record: vmlogs:alb:stats:elb_target_processing_time_total:avg
  debug: false
  expr: |
    {stream="alb"}
    | extract "<_> <_> <elb_id> <_> <_> <request_processing_time> <target_processing_time> <response_processing_time> <elb_status_code> <target_status_code> <received_bytes> <sent_bytes> <request_line> <user_agent> <_> <_> <_> <trace_id> <domain_name> <_> <_> <_> <_> <_> <error_reason> <_> <_> <_> <_> <conn_trace_id> <_> <_> <_>"
    | extract_regexp `.*:443(?P<uri_path>/[^/?]+).* HTTP` from request_line
    | filter target_processing_time :! "-1"
    | stats by (elb_id) avg(target_processing_time) as avg_target_processing_time

Яка поверне ті самі 0.22 в 12:58:

І те саме значення буде в CloudWatch:

Власне, на цьому і все.

Тепер можна додавати інші метрики, алерти, і тюнити Grafana dashboard.

Loading

FreeBSD: установка на ThinkPad X200 Tablet у 2025 році
0 (0)

30 Листопада 2025

Я давно фанат ThinkPad, дуже люблю всю їхню лінійку.

Нещодавно десь зустрів модель X200, які випускались з 2008 року – просто десь побачив картинку, і дуже захотів собі в “колекцію”. Неочікувано – але він навіть знайшовся в продажу на OLX, тому купив собі цей чудо-девайс.

ThinkPad X200 overview

Він… Ну – це просто бімба 🙂

Перше, що відрізняє цей ноутбук від інших моделей – це поворотний екран на 360 градусів, і це “фірмова фішка” моделі саме X200 Tablet, бо є X200 без цього.

Друге – це тачскрін.

Зовнішній вигляд

Виглядає ThinkPad X200 Tablet так:

На борту навіть є dial-up модем!

Ну і, звісно, ніяких HDMI – для зовнішнього монітору тільки VGA.

Хоча HDMI 1.0 з’явився ще у 2003, але тоді він використовувався для DVD-плеєрів та телевізорах, а ноутбуки в ті роки ще йшли переважно з VGA.

Замовив перехідник VGA на HDMI, бо в моєму моніторі DELL U3421WE ніяких VGA, само собою, вже нема – подивимось, чи спрацює.

Справа є фізичний перемикач для відключення WiFi/Bluetooth, хоча в моїй моделі блютузу нема:

В комплекті йде стилус:

ThinkPad X200 Hardware

Звісно, залізо вже стареньке:

  • дисплей: 12.1 дюймів, 1440×900 точок
  • процесор: Intel Core 2 Duo SU9400, 1.4 GHz
  • відео: Integrated card Intel 4500MHD
  • оперативна пам’ять: DDR3-1066 MHz
    • в офіційній документації Lenovo завжди писали, що максимум пам’яті 4 гігабайти, але гугол каже, що 8 гіг працює без проблем
    • поки що в мене 4 GB, замовив нові 2 планки по 4 ГБ, подивимось чи запрацює
    • зараз (2025 рік) DDR3-1066 MHz на 4 ГБ коштує 440 гривень 🙂
  • диск: SATA, але сам диск HDD, 250 ГБ, 5400 RPM
    • начебто є можливість замінити диск на SSD, але корпус ще не розбирав, потім подивлюся, і якщо можна – то поставлю якийсь SSD
  • мережа:
    • Ethernet 1000 bmps
    • WiFi: 802.11a, 802.11g, 802.11n

Встановлення FreeBSD

Чому FreeBSD? Бо це моя перша UNIX-система, на якій я вперше збирав ядро, ще у 2006 чи 2007 році, і потім до 2012 чи 2013 це була моя основна система на моїх перших сервера.

Моя перша версія FreeBSD була… не пам’ятаю точно, чи то 5, чи то 6. Але згодом, після виходу FreeBSD 9 перейшов на Linux (CentOS). Правда чому саме – теж не пам’ятаю. Щось пов’язане зі змінами в роботі з пакетами і FreeBSD ports.

І навіть в цьому блозі одним із перших матеріалів були пости саме по FreeBSD – FreeBSD: установка коллекции портов (Ports Collection), 14 серпня 2011.

Ну і раз вже налаштовуємо “раритетний” ноутбук – то чому б не спробувати і “раритетну” систему? 🙂

Трохи ностальгії по тим часам, коли вчився працювати з портами FreeBSD, коли вперше розбирався з runlevels в Linux часів SysV init і їхніми аналогами у FreeBSD – режимами завантаження системи.

Хоча, звісно, встановлювати будемо актуальну версію FreeBSD, 14.3 на сьогодняшній день.

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

Тому я все ж встановлю FreeBSD, пограюсь і опишу якісь деталі, але потім на цьому ноутбуці буде Arch Linux – там хоч і довелось трохи попрацювати напильником, але тачскрін працює відмінно.

Вибір образу FreeBSD

Для FreeBSD існує три гілки системи:

  • RELEASE: основна стабільна версія, апдейти тільки security + critical fixes – максимум стабільності, мінімум сюрпризів
  • STABLE: гілка розробки, з якої формується майбутній RELEASE – вже достатньо стабільна, але все ще можуть бути баги
  • CURRENT: гілка активної розробки з усіма новими фічами і плюшками – мінімум стабільності, максимум нових експерементальних штук

FreeBSD має окремі версії для майже всіх платформ:

Для нашого ThinkPad X200 вибираємо amd64, завантажуємо образ зі сторінки Get FreeBSD, і починаємо установку.

Встановлення FreeBSD на віртуальну машину

Аби не робити 100500 фотографій екрану – процес установки FreeBSD покажу на віртуалці з QEMU/KVM, бо принципової різниці нема.

По віртуалізації QEMU/KVM в Linux є чорнетка, допишу якось, давно лежить.

Офіційна документація – Chapter 2. Installing FreeBSD.

У FreeBSD дуже приємний інсталятор bsdinstall, з яким можна зробити все і відразу. І, як на мене – він зручніший за archintsall, з яким я взагалі не подружився і Arch Linux завжди встановлюю руками:

Вибираємо 1, далі маємо вибір – або запустити установку, або використати ISO як live-cd:

Задаємо ім’я хоста:

Далі вибір компонентів.

Нам тут потрібні тільки порти, “ports – Ports tree“, і можна додати “src – System source tree” – тут весь код FreeBSD, корисно, якщо хочеться зібрати власне ядро і для установку апдейдів з утилітою freebsd-update, про неї трохи далі:

Наступний крок – розбивка диску:

  • ZFS: файлова система з підтримкою snapshots, RAID і можливістю зміни розміру розділів
    • з’явилась у 2005 році для операційної системи Solaris від Sun Microsystems, і на той час була революційною системою
  • UFS: класична для FreeBSD файлова система – дуже стабільна, мінімум використання RAM, але і не має всіх фіч ZFS

Залишаємо дефолтну опцію “Auto (ZFS)”:

Забігаючи наперед – ось так потім виглядають розділи вже на самому ноутбуку:

# gpart show
=>       40  312581728  ada0  GPT  (149G)
         40       1024     1  freebsd-boot  (512K)
       1064        984        - free -  (492K)
       2048    4194304     2  freebsd-swap  (2.0G)
    4196352  308383744     3  freebsd-zfs  (147G)
  312580096       1672        - free -  (836K)

І навіть пропонується відразу налаштувати RAID – але це явно не наш кейс:

Вибираємо диск для установки:

Чекаємо завершення установки:

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

Вибираємо мережевий інтерфейс:

Це установка на віртуалку, WiFi тут нема.

Але на самому ноуті можна відразу налаштувати і його, хоча в моєму випадку з ThinkPad X200 потім довелось трохи робити руками, далі покажу.

Залишаємо DHCP:

Вибираємо таймзону:

Після чого налаштовуємо базові параметри системи:

  • local_unbound: локальний DNS-кеш + DNSSEC валідація, для домашнього ноута можна не включати
  • sshd: включаємо
  • moused: підтримка миші в консолі – користі мало, але виглядає прикольно 🙂
  • ntpd: синхронізація часу
  • powerd: динамічне керування частотою CPU, дуже корисно, особливо для ноутбука – зберігати час роботи батареї
  • dumpdev: допомагає дебажити kernel panic

Далі налаштування для security – цікаво, але для домашнього ноута можна пропустити, має сенс для серверів:

Детально можна почитати в документації Section 2.8.4, “Enabling Hardening Security Options”, але коротко пройдемось по опціях, бо тут проявляються перші (якщо не говорити про файлу систему) відмінності від Linux:

  • hide_uids і hide_gids: ховає процеси інших користувачів системи (root, звісно, бачить все)
  • hide_jail: ховає процеси, які запущені в jail
    • FreeBSD Jails – аналог “класичних” контейнерів в Linux, але з більше жорсткою ізоляцією
    • і з’явились вони ще до того, як в Linux ядрі взагалі з’явилась підтримка чогось схожого:
      • jails були додані у FreeBSD ще у 2000 році, з FreeBSD 4.0
      • в Linux на той час був тільки chroot, де ізоляція була дуже слабка, і можна було легко вийти за межі “контейнера” і тримати доступ до всієї системи
      • і тільки у 2006-2008 в Linux з’явились cgroups, аж в 2008-2013 були додані namespaces, і тільки після цього, у 2013 році, з’явився Docker і контейнери в Linux, якими ми їх знаємо зараз
  • read_msgbuf: заборона читання dmesg звичайним юзерам
  • proc_debug: вимикає для звичайних користувачів можливість дебажити чужі процеси і обмежує частину інформації з /proc
  • random_pid: якщо включено, то PID задається з рандомним зміщенням номеру, а не по черзі
  • clear_tmp: у FreeBSD каталог /tmp по дефолту не очищається при ребуті, з clear_tmp це можна включити
    • в Linux залежить від дистрибутива і того, як саме монтується /tmp, бо зазвичай це tmpfs в RAM, який, звісно, очищається
    • FreeBSD свідомо не очищає його, бо політика FreeBSD – “адміністратор сам вирішує, що треба робити системі
  • disable_syslogd: заборона відкриття мережевого сокету для демона syslogd – на ноуті можна включити
    • але сам syslogd продовжує використання локального Unix socket /var/run/log для роботи
  • secure_console: блокує root-вхід з консолі без пароля при завантаженні системи в single-user mode (див. Chapter 15. The FreeBSD Booting Process – у FreeBSD дуже класна документація)
  • disable_dtrace: DTrace у FreeBSD – аналог strace/eBPF в Linux, для трасування процесів, системних викликів і роботи ядра, і має можливість вносити “на льоту” зміни в пам’ять та ядро; disable_dtrace блокує цю можливість навіть для root

Окей – залишаємо тут все по дефолту, і переходимо до user management:

Додаємо юзера:

У FreeBSD є можливість вказати Login class, і це фішка чисто FreeBSD, див. Configuring Login Classes.

Ну і власне на цьому установка завершена.

В останньому вікні є можливість щось дотюнити:

Ребутаємось, і маємо нову систему:

І екран загрузки вже на самому ноуті:

FreeBSD load options

Коротко по доступних опціях:

  • Boot Multi user (Enter): стандартний запуск, монтуються файлові системи, запускаються сервіси із /etc/rc.conf
    • аналог в Linux – звичайний boot у runlevel/systemd multi-user.target
  • Boot Single user: коли система зламалась, і треба щось пофіксити
    • запускається тільки ядро і мінімальний shell
    • /” монтується в read-only (можна перемонтувати в r/w)
    • аналог в Linux – init=/bin/bash або systemd.unit=rescue.target
  • Escape to loader prompt: консоль у FreeBSD loader, де можна змінити параметри ядра, вибрати інше ядро, змінити ZFS Boot Environments
    • аналог в Linux – GRUB console, але FreeBSD loader глибше інтегрований з системою
  • Reboot: reboot 🙂
  • Cons: Video: фішка FreeBSD, задає тип для system console – video console (звичайний екран) або serial console
  • Kernel: default/kernel: вибір ядра для завантаження
    • при апгрейдах попередня версія ядра зберігається у /boot/kernel.old/, і при проблемах можна завантажити інші ядра
  • Boot Options: додаткові параметри для завантаження – відключити ACPI, DMA, ZFS cache, etc

Повертаємось до ноутбука.

Налаштування WiFi

При встановленні на ноутбук побачило карту:

І мережі:

Але після налаштування і завершення установки інтерфейсу нема:

Робимо вручну.

З pciconf -lv перевіряємо пристрої на PCI/PCI-Express шині:

Нас цікавить device iwn – “Intel WiFi Link 5300“:

The Intel® WiFi Link 5300 Series is a family of IEEE 802.11a/b/g/Draft-N1 wireless network adapters that operate in both the 2.4 GHz and 5.0 GHz spectra

Intel® WiFi Link 5300 Series.

Значить, нам потрібен драйвер iwn і модуль iwn5000fw.

Додаємо їх завантаження у файл /boot/loader.conf:

if_iwn_load="YES"
iwn5000fw_load="YES"

Можна додати без ребуту з kldload:

# kldload if_iwn
# kldload iwn5000fw

Створюємо новий віртуальний Wi-Fi інтерфейс з іменем wlan0 на основі апаратного пристрою iwn0, який ми побачили в pciconf:

# ifconfig wlan0 create wlandev iwn0

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

Звісно, мережі ще нема – додаємо конфіг для неї.

Для роботи з WiFi у FreeBSD є утиліта wpa_supplicant, яка відповідає за авторизацію, підтримку підключення, перепідключення при втраті сигналу.

Редагуємо файл /etc/wpa_supplicant.conf, в кінець додаємо параметри:

network={
    ssid="MyWiFi"
    psk="qwerty12345"
}

Додаємо загрузку конфігу WiFi у /etc/rc.conf, який відповідає за autostart:

wlans_iwn0="wlan0"
ifconfig_wlan0="WPA DHCP"

Рестартимо мережу:

# service netif restart

І wlan0 має підключитись і отримати IP:

root@setevoy-x200:/home/setevoy # ifconfig wlan0
wlan0: flags=8843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST> metric 0 mtu 1500
        options=0
        ether 00:21:6a:b5:32:4c
        inet 192.168.0.172 netmask 0xffffff00 broadcast 192.168.0.255
        groups: wlan
        ssid setevoy-tp-link-21-5 channel 36 (5180 MHz 11a ht/40+) bssid 30:68:93:80:ef:73
        regdomain FCC country US authmode WPA2/802.11i privacy ON
        deftxkey UNDEF AES-CCM 2:128-bit txpower 17 bmiss 10 mcastrate 6
        mgmtrate 6 scanvalid 60 ampdulimit 64k -amsdutx amsdurx shortgi -stbc
        -ldpc -uapsd wme roaming MANUAL
        parent interface: iwn0
        media: IEEE 802.11 Wireless Ethernet MCS mode 11na
        status: associated
        nd6 options=29<PERFORMNUD,IFDISABLED,AUTO_LINKLOCAL>

Може бути із затримкою в 10-30 секунд, але в результаті завелось.

При потребі можна запустити wpa_supplicant в дебаг-режимі:

# wpa_supplicant -i wlan0 -c /etc/wpa_supplicant.conf -dd

FreeBSD Ports Collection та pkg

У FreeBSD є два незалежні механізми встановлення програм – з утилітою pkg, або збирати самому.

pkg – аналог apt або pacman в Linux: отримує і встановлює вже зібрані і готові до встановлення пакети з офіційного репозиторію:

# cat /etc/pkg/FreeBSD.conf
FreeBSD: {
  url: "pkg+https://pkg.FreeBSD.org/${ABI}/quarterly",
  mirror_type: "srv",
  signature_type: "fingerprints",
  fingerprints: "/usr/share/keys/pkg",
  enabled: yes
}
FreeBSD-kmods: {
  url: "pkg+https://pkg.FreeBSD.org/${ABI}/kmods_quarterly_${VERSION_MINOR}",
  mirror_type: "srv",
  signature_type: "fingerprints",
  fingerprints: "/usr/share/keys/pkg",
  enabled: yes
}

Тут FreeBSD – це user-facing пакети, а FreeBSD-kmods – репозиторій для додаткових модулів ядра (nvidia-driver, virtualbox-kmod тощо).

FreeBSD Ports Collection – каталог із source code пакетів та Makefiles, розбиті по категоріям, наприклад порт для xfce4-conf буде в директорії /usr/ports/x11/:

root@setevoy-x200:/home/setevoy # ls -1 /usr/ports/x11/xfce4-conf/
Makefile
distinfo
files
pkg-descr
pkg-plist

Використовувати порти варто, якщо потрібні якісь особливі параметри для зборки та установки, але здебільшого все можна встановлювати з pkg.

Апгрейд системи і пакетів

Тут у нас є дві окремі частини – сама система, і пакети, які ми встановлюємо з pkg.

Документація – Chapter 26. Updating and Upgrading FreeBSD і freebsd-update man.

Для апгрейду системи у FreeBSD є класна утиліта freebsd-update – дозволяє і отримувати останні апдейти, і виконувати апгрейд самої системи.

Аби отримати і встановити вручну – робимо:

# freebsd-update fetch
# freebsd-update install

fetch – завантажити останні апдейти, install – встановити їх.

Або можна відразу додати в cron:

@daily    root    freebsd-update cron

freebsd-update fetch та freebsd-update install виконають апгрейд ядра, системних утиліт в /bin, /sbin, /usr/bin, бібліотеки в /lib, /usr/lib і драйверів.

Для апгрейду пакетів, які ми встановлюємо з pkg – використовуємо аналогічні команди:

# pkg update
# pkg upgrade

Аналогічно до freebsd-update – з update отримуємо оновлення, з upgrade встановлюємо їх.

Установка X.Org та Desktop Environment

Про X.Org і вибір Desktop Environment писав у пості Arch Linux: установка і налаштування KDE Plasma у 2025, зараз нам треба просто вибрати яке саме оточення собі взяти.

Враховуючи обмежені ресурси – старенький CPU та всього 4 GB RAM – треба щось легке.

Можна XFCE – легкий, стабільний, з коробки є всі необхідні інструменти.

LXQT – трохи важчий за XFCE, але більш сучасний.

Ну або просто взяти чистий Windows Manager типу Fluxbox або Openbox.

Давайте візьмемо XFCE.

Встановлюємо X.Org та драйвери для відео – Direct Rendering Manager GPU drivers.

X.org потягне велику пачку залежностей

# pkg install xorg drm-kmod
...
Number of packages to be installed: 318

The process will require 3 GiB more space.
431 MiB to be downloaded.
...

Встановлюємо сам XFCE – теж немаленький:

# pkg install xfce xfce4-goodies
...
Number of packages to be installed: 317

The process will require 1 GiB more space.
248 MiB to be downloaded.
...

Але році у 2010 встановлював KDE з ports – то процес зборки зайняв багато годин, бо pkg тоді ще не було, і в ті часи майже все встановлювалось із ports collection.

Була і тоді система pkg_tools, але в ній не було системи залежностей, пакети качались по FTP, а апгрейд пакетів виконувався фактично через видалення і встановлення заново.

Повертаємось на нашої системи.

Встановили XFCE – додаємо login manager, slim (Simple Log In Manager):

# pkg install slim slim-themes

До /etc/rc.conf додаємо запуск DBus (потрібен для XFCE) та Slim:

sysrc dbus_enable="YES"
sysrc slim_enable="YES"

Створюємо файл ~/.xinitrc в домашній директорії юзера, аби Slim знав що запускати:

# echo "exec startxfce4" > /home/setevoy/.xinitrc

Ребутаємо машинку:

# reboot

І вуаля – все готово:

Можна перевірити з яким драйвером працює відео:

# cat /var/log/Xorg.0.log | grep -E "Driver|scfb|vesa"
[   145.813]    X.Org Video Driver: 25.2
[   145.862] (==) Matched scfb as autoconfigured driver 2
[   145.862] (==) Matched vesa as autoconfigured driver 3
[   145.864]    Module class: X.Org Video Driver
[   145.864]    ABI class: X.Org Video Driver, version 25.2
[   145.864] (II) LoadModule: "scfb"
[   145.864] (II) Loading /usr/local/lib/xorg/modules/drivers/scfb_drv.so
[   145.864] (II) Module scfb: vendor="X.Org Foundation"
[   145.864]    ABI class: X.Org Video Driver, version 25.2
[   145.864] (II) LoadModule: "vesa"
[   145.864] (II) Loading /usr/local/lib/xorg/modules/drivers/vesa_drv.so
[   145.864] (II) Module vesa: vendor="X.Org Foundation"
[   145.864]    Module class: X.Org Video Driver
[   145.865]    ABI class: X.Org Video Driver, version 25.2
[   145.865] (II) modesetting: Driver for Modesetting Kernel Drivers: kms
[   145.865] (II) scfb: driver for wsdisplay framebuffer: scfb
[   145.865] (II) VESA: driver for VESA chipsets: vesa
[   145.873] (WW) Falling back to old probe method for scfb
[   145.873] scfb trace: probe start
[   145.873] scfb trace: probe done
[   145.874]    ABI class: X.Org Video Driver, version 25.2
[   145.875]    ABI class: X.Org Video Driver, version 25.2
[   146.006] (II) UnloadModule: "scfb"
[   146.006] (II) Unloading scfb

scfb чимось не сподобався, буде з vesa, але на цьому залізі взагалі не принципово.

Додаємо інші корисні пакети:

# pkg install vim sudo bash

Я вже звик до bash, тому встановлюю і його.

Аби змінити юзеру його shell – використовуємо chsh:

# chsh -s /usr/local/bin/bash setevoy
chsh: user information updated

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

# grep setevoy /etc/passwd
setevoy:*:1001:1001:setevoy:/home/setevoy:/usr/local/bin/bash

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

Система працює, можна користуватись.

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

Loading

Golang: запис логів AWS Loab Balancer до VictoriaLogs
0 (0)

26 Листопада 2025

Наступна задача, яку хочеться вирішити з Go – це написати власний logs collector для збору логів AWS Load Balancer з AWS S3 і запису їх до VictoriaLogs.

Це, звісно, можна було б вирішити просто з Vector.dev, як це робив для AWS VPC Flow Logs, див. Vector.dev: знайомство, логи з AWS S3 та інтеграція з VictoriaLogs, але є можливість трохи попрактикуватись в Go, тому будемо робити власний колектор.

Тож основна ідея зараз така:

  • Load Balancer пише логи до S3
  • в S3 створюємо нотифікацію до AWS SQS
  • наш колектор опитує SQS, отримує інформацію про нові об’єкти в S3
  • робить запит до S3, отримує gz-архів
  • розпаковує, парсить дані, і відправляє до VictoriaLogs

Поїхали.

Налаштування S3 та SQS notifications

Коли продумував ідею, то головне питання було – як знати, які об’єкти в S3 ми вже обробили, а які ні?

Мати якусь базу, в яку писати інформацію про вже оброблені об’єкти – перший варіант. Але з часом записів про такі обєкти буде все більше, плюс не дуже хочеться тягнути якийсь stateful-сервіс в Kubernetes, де потім буде запускатись наш колектор.

Тому робимо простіше і надійніше, так само як це зроблено з Vector.dev та VPC Flow Logs – створимо SQS чергу, в яку будуть приходити повідомлення про нові S3 objects.

Читаємо повідомлення з SQS, отримуємо з них інформацію про нові файли, оброблюємо файли, видаляємо меседж із SQS queue.

Див. документацію AWS – Walkthrough: Configuring a bucket for notifications (SNS topic or SQS queue).

Створення SQS queue

Створюємо нову queue, поки руками, потім зробимо нормально, з Terraform.

Тип queue лишаємо Standart, бо:

Amazon Simple Queue Service FIFO (First-In-First-Out) queues aren’t supported as an Amazon S3 event notification destination

Див. Amazon S3 Event Notifications.

Крім того, нам порядок нам не важливий, бо в логах є власний timestamp, який ми і будемо парсити.

Переходимо до SQS, створюємо нову чергу:

Дал, в Access policy описуємо політику.

Писати в чергу у нас буде S3, а читати – Kubernetes Pod із ServiceAccount.

ServiceAccount і IAM Role для нього будемо робити вже потім, потім просто даємо право читати всім з нашого AWS Account.

А для S3 додаємо дозвіл на SQS:SendMessage:

{
    "Version": "2012-10-17",
    "Id": "loggerID",
    "Statement": [
        {
            "Sid": "sendFromS3LogsAllow",
            "Effect": "Allow",
            "Principal": {
                "Service": "s3.amazonaws.com"
            },
            "Action": [
                "SQS:SendMessage"
            ],
            "Resource": "arn:aws:sqs:us-east-1:492***148:testing-alb-logs-s3-notifier",
            "Condition": {
                "ArnLike": {
                    "aws:SourceArn": "arn:aws:s3:*:*:ops-1-33-devops-ingress-ops-alb-loki-logs"
                }
            }
        },
        {
            "Sid": "receiveFromQueueAllowSameAccount",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::492***148:root"
            },
            "Action": [
                "SQS:ReceiveMessage",
                "SQS:DeleteMessage",
                "SQS:GetQueueAttributes",
                "SQS:GetQueueUrl"
            ],
            "Resource": "arn:aws:sqs:us-east-1:492***148:testing-alb-logs-s3-notifier"
        }
    ]
}

Решту залишаємо дефолтним.

Налаштування S3 Event notifications

Переходимо до S3 з логами, Properties > Event notifications:

Створюємо новий Event notification – відправляти повідомлення про всі операції s3:ObjectCreated:

В Destination вибираємо SQS і нашу чергу:

Чекаємо кілька хвилин, перевіряємо Monitoring в SQS:

Окей – є повідомлення, тепер їх треба зібрати і прочитати.

Переходимо до Go.

AWS SDK for Go

Для роботи з AWS нам буде потрібен AWS SDK for Go, з яким можемо виконати всі потрібні операції.

Для отримання даних доступу використовуємо aws-sdk-go-v2/config і функцію LoadDefaultConfig(), яка виконує стандартний пошук credentials – в змінних оточення, файлах ~/.aws/credentials та ~/.aws/config, або використовує EC2 IAM Roles.

До LoadDefaultConfig() першим аргументом потрібно передати context, писав про нього у Golang: створення OpenAI Exporter для VictoriaMetrics, поки запускаємо з context.Background(), потім напишемо обробку і завершення роботи:

package main

import (
  "context"
  "log"

  "github.com/aws/aws-sdk-go-v2/config"
)

func main() {
  // TODO: add exit handler
  ctx := context.Background()
  // Load the Shared AWS Configuration (~/.aws/config)
  cfg, err := config.LoadDefaultConfig(ctx)
  if err != nil {
    log.Fatal(err)
  }
}

Go AWS SDK SQS

Для роботи з SQS в AWS SDK є окремий пакет sqs.

SQS client

Додаємо його імпорт, додаємо створення клієнту з NewFromConfig(), якому передаємо AWS config, який створили вище.

Для роботи з SQS queue треба мати URL – отримуємо його з GetQueueUrl().

Додаємо отримання QueueName зі змінних оточення, бо потім в Kubernetes будемо передавати із Helm chart values:

package main

import (
  "context"
  "fmt"
  "log"
  "os"

  "github.com/aws/aws-sdk-go-v2/aws"
  "github.com/aws/aws-sdk-go-v2/config"
  "github.com/aws/aws-sdk-go-v2/service/sqs"
)

func main() {
  // TODO: add exit handler
  ctx := context.Background()

  // Load the Shared AWS Configuration (~/.aws/config)
  cfg, err := config.LoadDefaultConfig(ctx)
  if err != nil {
    log.Fatal(err)
  }

  // create SQS client using the shared AWS config
  sqsClient := sqs.NewFromConfig(cfg)

  // read queue name from environment
  queueName := os.Getenv("ALB_LOGS_QUEUE")
  if queueName == "" {
    log.Fatal("environment variable ALB_LOGS_QUEUE is not set")
  }

  // request queue URL by name
  getURLResp, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
    QueueName: aws.String(queueName),
  })
  if err != nil {
    log.Fatal(err)
  }

  queueURL := getURLResp.QueueUrl
  fmt.Println("Queue URL:", *queueURL)
}

Виконуємо go mod init:

$ go mod init alb-logs-collector-poc
$ go mod tidy

Задаємо змінну з іменем черги:

$ export ALB_LOGS_QUEUE="testing-alb-logs-s3-notifier"

Запускаємо наш код:

$ go run main.go 
Queue URL: https://sqs.us-east-1.amazonaws.com/492***148/testing-alb-logs-s3-notifier

ОК, тепер можемо отримати повідомлення.

SQS ReceiveMessage()

Читаємо нові меседжи в queue з ReceiveMessage(), куди передаємо конфіг ReceiveMessageInput:

...
  // receive a single message from SQS
  msgResp, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
    QueueUrl:            queueURL, // URL returned by the GetQueueUrl()
    MaxNumberOfMessages: 1,        // receive only one message
    WaitTimeSeconds:     10,       // enable long polling
  })
  if err != nil {
    log.Fatal(err)
  }

  if len(msgResp.Messages) == 0 {
    fmt.Println("no messages received")
    return
  }
  // print the received message body
  fmt.Println("Received message:", aws.ToString(msgResp.Messages[0].Body))
...

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

$ go run main.go | jq
{
  "Records": [
    {
      "eventVersion": "2.1",
      "eventSource": "aws:s3",
      ...
      "eventName": "ObjectCreated:Put",
      ...
      "s3": {
        ...
        "bucket": {
          "name": "ops-1-33-devops-ingress-ops-alb-loki-logs",
        ...
        "object": {
          "key": "AWSLogs/492***148/elasticloadbalancing/us-east-1/2025/11/25/492***148_elasticloadbalancing_us-east-1_app.k8s-ops133externalalb-***.336cddd33c043f33_20251125T0935Z_34.***.***.15_25hsfvwt.log.gz",
          ...
        }
      }
    }
  ]
}

Нас тут цікавлять два поля – bucket.name та object.key.

Створюємо struct і з json.Unmarshal() заносимо в неї дані, парсинг JSON розбирав в тому ж пості про OpenAI Exporter в частині Створення Go struct для JSON Unmarshall:

...
  // define a struct to unmarshal S3 event message
  type S3Event struct {
    Records []struct {
      S3 struct {
        Bucket struct {
          Name string `json:"name"`
        } `json:"bucket"`
        Object struct {
          Key string `json:"key"`
        } `json:"object"`
      } `json:"s3"`
    } `json:"Records"`
  }

  // take SQS message body
  msgBody := aws.ToString(msgResp.Messages[0].Body)

  // decode JSON into the struct
  var event S3Event
  if err := json.Unmarshal([]byte(msgBody), &event); err != nil {
    log.Fatal("failed to parse S3 event:", err)
  }

  // extract bucket and key
  // we always have 1 message, so use [0]
  bucket := event.Records[0].S3.Bucket.Name
  key := event.Records[0].S3.Object.Key

  fmt.Println("bucket:", bucket)
  fmt.Println("key:", key)
...

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

$ go run main.go 
bucket: ops-1-33-devops-ingress-ops-alb-loki-logs
key: AWSLogs/492***148/elasticloadbalancing/us-east-1/2025/11/25/492***148_elasticloadbalancing_us-east-1_app.k8s-ops133externalalb-***.336cddd33c043f33_20251125T0935Z_52.***.***.213_60mjhvf6.log.gz

Можемо переходити до S3.

Go AWS SDK S3

Аналогічно до SQS – використовуємо пакет s3.

S3 client

Створюємо клієнт з NewFromConfig():

...
  // create S3 client using the shared AWS config
  s3Client := s3.NewFromConfig(cfg)
...

S3 GetObject()

Додаємо читання файлу з GetObject().

Теж аналогічно з SQS – передаємо GetObjectInput:

...
  // fetch the S3 object and return its streaming body
  objResp, err := s3Client.GetObject(ctx, &s3.GetObjectInput{
    Bucket: aws.String(bucket),
    Key:    aws.String(key),
  })
  if err != nil {
    log.Fatal("failed to download object:", err)
  }
  defer objResp.Body.Close()

  fmt.Println("S3 object stream opened:", bucket, key)
...

GetObject() повертає *GetObjectOutput, в якому є поле Body з типом io.ReadCloser, а io.ReadCloser – це інтерфейс, який визначає два методи – Reader та Closer.

Читання файлу з gzip

Логи в S3 зберігаються в gz, тому додаємо пакет gzip, і з NewReader() читаємо дані:

...
  // create gzip reader from S3 object stream
  gzReader, err := gzip.NewReader(objResp.Body)
  if err != nil {
    log.Fatal("failed to create gzip reader:", err)
  }
  defer gzReader.Close()

  fmt.Println("gzip stream opened")
...

NewReader() приймає аргумент з типом io.Reader interface, а тому ми можемо до нього передати objResp.Body.

Але сам по собі gzip.NewReader() дані нікуди не повертає – він тільки відкриє буфер, в який буде писати розархівовані строки.

Тому далі додаємо bufio.

Читання gzip output з bufio

Додаємо bufio, і з NewScanner() читаємо дані від gzip.NewReader() до буферу, з якого далі зі Scan() та Text() формуємо строки:

...
  // create scanner to read decompressed log lines
  scanner := bufio.NewScanner(gzReader)
  
  // increase buffer size if ALB logs have long lines
  // default scanner buffer = 64 KB
  buf := make([]byte, 0, 1024*1024) // 1 MB
  scanner.Buffer(buf, 1024*1024)
  
  // iterate over every line in the decompressed file
  for scanner.Scan() {
    line := scanner.Text()
    fmt.Println("log line:", line)
  }
  if err := scanner.Err(); err != nil {
    log.Fatal("scanner error:", err)
  }
...

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

$ go run main.go  | head
S3 object stream opened: ops-1-33-devops-ingress-ops-alb-loki-logs AWSLogs/492***148/elasticloadbalancing/us-east-1/2025/11/25/492***148_elasticloadbalancing_us-east-1_app.k8s-ops133externalalb-***.336cddd33c043f33_20251125T1005Z_52.***.***.213_2nbanv4o.log.gz

log line: h2 2025-11-25T10:00:02.271561Z app/k8s-ops133externalalb-***/336cddd33c043f33 52.***.***.183:60978 10.0.47.34:8080 0.001 0.005 0.001 200 200 38 5492 "GET https://lightdash.example.co:443/ HTTP/2.0" "Blackbox Exporter/0.27.0" ECDHE-RSA-AES128-GCM-SHA256 TLSv1.2 arn:aws:elasticloadbalancing:us-east-1:492***148:targetgroup/k8s-proddata-lightdas-fa7b1ce474/c400fe91849c401a "Root=1-69257e22-4b16fe8d62d2f8955edc6da8" "lightdash.example.co" "arn:aws:acm:us-east-1:492***148:certificate/77230c5f-d0c2-4e58-b579-8b8422686986" 15 2025-11-25T10:00:02.264000Z "forward" "-" "-" "10.0.47.34:8080" "200" "-" "-" TID_48df8c3791b69144b4ae0f6084e015d6 "-" "-" "-"
...

Розбиття main() на функції

У нас вже доволі великий main(), і основні операції зробили, все працює.

Давайте наведемо трохи красоти.

Весь код зараз:

package main

import (
  "bufio"
  "compress/gzip"
  "context"
  "encoding/json"
  "fmt"
  "log"
  "os"

  "github.com/aws/aws-sdk-go-v2/aws"
  "github.com/aws/aws-sdk-go-v2/config"
  "github.com/aws/aws-sdk-go-v2/service/s3"
  "github.com/aws/aws-sdk-go-v2/service/sqs"
)

func main() {
  // TODO: add exit handler
  ctx := context.Background()

  // Load the Shared AWS Configuration (~/.aws/config)
  cfg, err := config.LoadDefaultConfig(ctx)
  if err != nil {
    log.Fatal(err)
  }

  // create SQS client using the shared AWS config
  sqsClient := sqs.NewFromConfig(cfg)

  // read queue name from environment
  queueName := os.Getenv("ALB_LOGS_QUEUE")
  if queueName == "" {
    log.Fatal("environment variable ALB_LOGS_QUEUE is not set")
  }

  // request queue URL by name
  getURLResp, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
    QueueName: aws.String(queueName),
  })
  if err != nil {
    log.Fatal(err)
  }

  queueURL := getURLResp.QueueUrl
  //fmt.Println("Queue URL:", *queueURL)

  // receive a single message from SQS
  msgResp, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
    QueueUrl:            queueURL, // URL який ми отримали раніше
    MaxNumberOfMessages: 1,        // receive only one message
    WaitTimeSeconds:     10,       // enable long polling (recommended)
  })
  if err != nil {
    log.Fatal(err)
  }

  if len(msgResp.Messages) == 0 {
    fmt.Println("no messages received")
    return
  }
  // print the received message body
  //fmt.Println(aws.ToString(msgResp.Messages[0].Body))

  // define a struct to unmarshal S3 event message
  type S3Event struct {
    Records []struct {
      S3 struct {
        Bucket struct {
          Name string `json:"name"`
        } `json:"bucket"`
        Object struct {
          Key string `json:"key"`
        } `json:"object"`
      } `json:"s3"`
    } `json:"Records"`
  }

  // take SQS message body
  msgBody := aws.ToString(msgResp.Messages[0].Body)

  // decode JSON into the struct
  var event S3Event
  if err := json.Unmarshal([]byte(msgBody), &event); err != nil {
    log.Fatal("failed to parse S3 event:", err)
  }

  // extract bucket and key
  // we always have 1 message, so use [0]
  bucket := event.Records[0].S3.Bucket.Name
  key := event.Records[0].S3.Object.Key

  //fmt.Println("bucket:", bucket)
  //fmt.Println("key:", key)

  // create S3 client using the shared AWS config
  s3Client := s3.NewFromConfig(cfg)

  // fetch the S3 object and return its streaming body
  objResp, err := s3Client.GetObject(ctx, &s3.GetObjectInput{
    Bucket: aws.String(bucket),
    Key:    aws.String(key),
  })
  if err != nil {
    log.Fatal("failed to download object:", err)
  }
  defer objResp.Body.Close()

  fmt.Println("S3 object stream opened:", bucket, key)

  // create gzip reader from S3 object stream
  gzReader, err := gzip.NewReader(objResp.Body)
  if err != nil {
    log.Fatal("failed to create gzip reader:", err)
  }
  defer gzReader.Close()
  //fmt.Println("gzip stream opened")

  // create scanner to read decompressed log lines
  scanner := bufio.NewScanner(gzReader)

  // increase buffer size if ALB logs have long lines
  // default scanner buffer = 64 KB
  buf := make([]byte, 0, 1024*1024) // 1 MB
  scanner.Buffer(buf, 1024*1024)

  // iterate over every line in the decompressed file
  for scanner.Scan() {
    line := scanner.Text()
    fmt.Println("log line:", line)
  }
  if err := scanner.Err(); err != nil {
    log.Fatal("scanner error:", err)
  }
}

Як можемо організувати процес?

В main() виконуємо всякі ініціалізації, а потім в циклі будемо опитувати SQS:

  • створюємо context
  • створюємо AWS config
  • створюємо клієнти sqsClient та s3Client
  • зчитуємо змінні середовища (ALB_LOGS_QUEUE, потім додамо ще)
  • і потім в циклі:
    • викликаємо функцію receiveFromSQS() – перевіряємо, чи з’явились нові меседжи
    • викликаємо функцію getS3Object() – якщо меседжи є, то йдемо до S3 і читаємо звідти новий архів
    • викликаємо функцію processLogFile() – зчитуємо строки з кожного отриманого логу

Які функції для цього знадобляться?

  • функція ReceiveFromSQS()
    • сюди будемо передавати context, SQS client, SQS queue URL, і записувати в структуру S3Event ім’я бакету та key – ім’я файлу
    • для подальшого видалення меседжів після успішної обробки – потрібно буде повертати receiptHandle
  • функція GetS3Object()
    • отримує context, AWS Config, bucket, key
    • виконує GetObject() і повертає GetObjectOutput
  • функція GzipReader()
    • читає дані від GetS3Object(), розпаковує, повертає строки
  • функція ScanLines()
    • отримує дані від GzipReader(), зчитує з Text(), і поки що просто виводить на консоль

Package collector

Для зручності і аби все було структуровано – розділимо все по окремим файлам:

collector/
    sqs.go
    s3.go
    gzip.go
    scan.go
main.go

Файл collector/sqs.go

Коли тестив, то зустрів таку помилку:

$ go run main.go | head 
panic: runtime error: index out of range [0] with length 0

goroutine 1 [running]:
alb-logs-collector/collector.ReceiveFromSQS({0xa83530, 0xdda060}, 0xc00021d888, {0xc000160050, 0x4d})
        /home/setevoy/Projects/Go/alb-logs-collector/collector/sqs.go:52 +0x25d
main.main()
        /home/setevoy/Projects/Go/alb-logs-collector/main.go:46 +0x305
exit status 2

Виникає через, то AWS пише тестові повідомлення до черги:

{"Service":"Amazon S3","Event":"s3:TestEvent","Time":"2025-11-24T11:10:31.573Z","Bucket":"ops-1-33-devops-ingress-ops-alb-loki-logs", ...}

Тому до ReceiveFromSQS() додамо перевірку.

Функція ReceiveFromSQS()

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

package collector

import (
  "context"
  "encoding/json"
  "fmt"
  "strings"

  "github.com/aws/aws-sdk-go-v2/aws"
  "github.com/aws/aws-sdk-go-v2/service/sqs"
)

// S3Event for SQS message
type S3Event struct {
  Records []struct {
    S3 struct {
      Bucket struct {
        Name string `json:"name"`
      } `json:"bucket"`
      Object struct {
        Key string `json:"key"`
      } `json:"object"`
    } `json:"s3"`
  } `json:"Records"`
}

// ReceiveFromSQS reads one message and returns bucket, key, receiptHandle, queueURL
func ReceiveFromSQS(ctx context.Context, client *sqs.Client, queueName string) (bucket, key, receiptHandle, queueURL string, err error) {

  // get real queue URL
  getURL, err := client.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
    QueueName: aws.String(queueName),
  })
  if err != nil {
    return "", "", "", "", err
  }

  queueURL = *getURL.QueueUrl

  // receive one message
  resp, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
    QueueUrl:            aws.String(queueURL),
    MaxNumberOfMessages: 1,
    WaitTimeSeconds:     10,
  })
  if err != nil {
    return "", "", "", "", err
  }

  if len(resp.Messages) == 0 {
    return "", "", "", "", fmt.Errorf("no messages")
  }

  // receive one message
  msg := resp.Messages[0]
  // create ReceiptHandle to return to the delete function
  receiptHandle = *msg.ReceiptHandle

  raw := aws.ToString(msg.Body)
  fmt.Println("SQS RAW:", raw)

  // skip AWS test event
  if strings.Contains(raw, `"Event":"s3:TestEvent"`) {
    fmt.Println("Skipping AWS S3 test event")
    return "", "", "", queueURL, fmt.Errorf("test event skipped")
  }

  // parse S3 event message
  var event S3Event
  if err := json.Unmarshal([]byte(raw), &event); err != nil {
    return "", "", "", "", err
  }

  bucket = event.Records[0].S3.Bucket.Name
  key = event.Records[0].S3.Object.Key

  return bucket, key, receiptHandle, queueURL, nil
}

Функція DeleteFromSQS()

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

// DeleteFromSQS deletes message after successful processing
func DeleteFromSQS(ctx context.Context, client *sqs.Client, queueURL, receiptHandle string) error {
  _, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{
    QueueUrl:      aws.String(queueURL),
    ReceiptHandle: aws.String(receiptHandle),
  })
  return err
}

Файл collector/s3.go та функція GetS3Object()

package collector

import (
  "context"

  "github.com/aws/aws-sdk-go-v2/aws"
  "github.com/aws/aws-sdk-go-v2/service/s3"
)

// GetS3Object returns stream reader of S3 file
func GetS3Object(ctx context.Context, client *s3.Client, bucket, key string) (*s3.GetObjectOutput, error) {
  return client.GetObject(ctx, &s3.GetObjectInput{
    Bucket: aws.String(bucket),
    Key:    aws.String(key),
  })
}

Файл collector/gzip.go та функція GzipReader()

Виносимо теж окремо, аби весь код був більш логічним.

Крім того, потім, можливо, потрібно буде додати якісь перевірки:

package collector

import (
  "compress/gzip"
  "io"
)

// GzipReader wraps S3 body
func GzipReader(r io.Reader) (*gzip.Reader, error) {
  return gzip.NewReader(r)
}

Файл collector/scan.go та функція ScanLines()

package collector

import (
  "bufio"
  "fmt"
  "io"
)

// ScanLines reads log lines from reader
func ScanLines(r io.Reader) error {
  scanner := bufio.NewScanner(r)

  // increase max line size
  buf := make([]byte, 0, 1024*1024)
  scanner.Buffer(buf, 1024*1024)

  // iterate over every line in the decompressed file
  for scanner.Scan() {
    line := scanner.Text()
    fmt.Println("log line:", line)
  }

  return scanner.Err()
}

Зміни в main.go

Додаємо імпорт нашого пакету collector, і викликаємо нові функції.

Тепер весь файл main.go виглядає так:

package main

import (
  "alb-logs-collector-poc/collector"
  "context"
  "fmt"
  "log"
  "os"

  "github.com/aws/aws-sdk-go-v2/config"
  "github.com/aws/aws-sdk-go-v2/service/s3"
  "github.com/aws/aws-sdk-go-v2/service/sqs"
)

func main() {
  // TODO: add exit handler
  ctx := context.Background()

  // Load the Shared AWS Configuration (~/.aws/config)
  cfg, err := config.LoadDefaultConfig(ctx)
  if err != nil {
    log.Fatal(err)
  }

  // create SQS client using the shared AWS config
  sqsClient := sqs.NewFromConfig(cfg)
  // create S3 client using the shared AWS config
  s3Client := s3.NewFromConfig(cfg)

  // read queue name from environment
  queueName := os.Getenv("ALB_LOGS_QUEUE")
  if queueName == "" {
    log.Fatal("environment variable ALB_LOGS_QUEUE is not set")
  }

  // 1. get message
  //bucket, key, receiptHandle, queueURL, err := collector.ReceiveFromSQS(ctx, sqsClient, queue)
  bucket, key, _, _, err := collector.ReceiveFromSQS(ctx, sqsClient, queueName)
  if err != nil {
    if err.Error() == "no messages" {
      fmt.Println("NO MESSAGES")
      return
    }
    fmt.Println("ERROR receiving message:", err)
    return
  }

  fmt.Println("BUCKET:", bucket)
  fmt.Println("KEY:", key)

  // 2. get S3 object
  s3Obj, err := collector.GetS3Object(ctx, s3Client, bucket, key)
  if err != nil {
    fmt.Println("S3 error:", err)
    return
  }
  defer s3Obj.Body.Close()

  fmt.Println("S3 object stream opened:", bucket, key)

  // 3. gzip reader
  gzReader, err := collector.GzipReader(s3Obj.Body)
  if err != nil {
    fmt.Println("gzip error:", err)
    return
  }
  defer gzReader.Close()

  // 4. scan all lines in log file
  err = collector.ScanLines(gzReader)
  if err != nil {
    log.Fatal("scanner error:", err)
  }
}

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

$ go run main.go
SQS RAW: {"Records":[{"eventVersion":"2.1","eventSource":"aws:s3","awsRegion":"us-east-1","eventTime":"2025-11-25T11:15:01.559Z","eventName":"ObjectCreated:Put","userIdentity":{"principalId":"AWS:***.***:elblogdelivery-session"},"requestParameters":{"sourceIPAddress":"2600:***.***:c0ef"},"responseElements":{"x-amz-request-id":"Y6523DEXMF2DS6FG","x-amz-id-2":"tWgdxqzRbKrjUGDMLLkrOtKQW6S6aWT31VHomgaNm0UAIlzeshheXEGgZN3yRH4pMlWdzfBLqBlZuh3BO0QghDkTVU2WllFDKmh22/B1Cqk="},"s3":{"s3SchemaVersion":"1.0","configurationId":"objectCreatedEventSqs","bucket":{"name":"ops-1-33-devops-ingress-ops-alb-loki-logs","ownerIdentity":{"principalId":"***.***"},"arn":"arn:aws:s3:::ops-1-33-devops-ingress-ops-alb-loki-logs"},"object":{"key":"AWSLogs/492***148/elasticloadbalancing/us-east-1/2025/11/25/492***148_elasticloadbalancing_us-east-1_app.k8s-ops133externalalb-***.336cddd33c043f33_20251125T1115Z_34***.***.15_44qtqgut.log.gz","size":17823,"eTag":"36d1243edd2a7a98546e7af645c36068","sequencer":"0069258FB5806A2506"}}}]}
BUCKET: ops-1-33-devops-ingress-ops-alb-loki-logs
KEY: AWSLogs/492***148/elasticloadbalancing/us-east-1/2025/11/25/492***148_elasticloadbalancing_us-east-1_app.k8s-ops133externalalb-***.336cddd33c043f33_20251125T1115Z_34.225.155.15_44qtqgut.log.gz
S3 object stream opened: ops-1-33-devops-ingress-ops-alb-loki-logs AWSLogs/492***148/elasticloadbalancing/us-east-1/2025/11/25/492***148_elasticloadbalancing_us-east-1_app.k8s-ops133externalalb-***.336cddd33c043f33_20251125T1115Z_34.***.***.15_44qtqgut.log.gz
log line: h2 2025-11-25T11:10:06.230595Z app/k8s-ops133externalalb-***/336cddd33c043f33 62.***.***.83:32026 10.0.44.225:8000 0.000 0.034 0.000 200 200 887 1165 "GET https://staging.api.challenge.example.co:443/admin/users/list?limit=1&user=test_thread_1_ci_ios_ui_participant2%40test.example.co HTTP/2.0" "Challenge App UI Tests-Runner/1.0 (com.challengeapp.uitests.Challenge-App-UI-Tests.xctrunner; build:1; iOS 18.5.0) Alamofire/5.9.0" ECDHE-RSA-AES128-GCM-SHA256 TLSv1.2 arn:aws:elasticloadbalancing:us-east-1:492***148:targetgroup/k8s-stagingb-backenda-410aa6288b/0d639f3b859bc8fb "Root=1-69258e8e-04c4384c05934a37738043b0" "staging.api.challenge.example.co" "arn:aws:acm:us-east-1:492***148:certificate/c3b6ec41-50a0-488e-93bb-b03967405f8c" 24 2025-11-25T11:10:06.195000Z "forward" "-" "-" "10.0.44.225:8000" "200" "-" "-" TID_d1e2c09f4fe6cf46b7c573f42b97889d "-" "-" "-"
...

Запис логів до VictoriaLogs

VictoriaLogs підтримує різні формати запису, ми будемо робити через JSON stream API.

Спершу спробуємо руками.

Відкриваємо порт до VictoriaLogs Kubernetes Service:

$ kk -n ops-monitoring-ns port-forward svc/atlas-victoriametrics-victoria-logs-single-server 9428

І робимо запит з curl:

$ echo '{ "log": { "level": "info", "message": "TEST" }, "date": "0", "stream": "alb" }
' | curl -X POST -H 'Content-Type: application/stream+json' --data-binary @- \
 'http://localhost:9428/insert/jsonline?_stream_fields=stream&_time_field=date&_msg_field=log.message'

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

Створення файлу collector/victoria.go

Створюємо мінімальний файл для тесту.

В ньому:

  • JSONLogRecord struct: тут будемо формувати JSON для передачі на VictoriaLogs HTTP API
  • заповнюємо його даними, в поле Timestamp можна передавати “Unix timestamp in seconds, milliseconds, microseconds or nanoseconds” – ми робимо в time.UnixMilli()
  • і задаємо значення для полів stream та message
  • формуємо HTTP request
  • відправляємо стандартним http.Client.Do()
package collector

import (
  "bytes"
  "encoding/json"
  "fmt"
  "net/http"
  "time"
)

// JSONLogRecord is the minimal structure for VictoriaLogs JSONLine API
type JSONLogRecord struct {
  Timestamp int64  `json:"date"`
  Stream    string `json:"stream"`
  Message   string `json:"message"`
}

// SendTestRecord sends simple test log record to VictoriaLogs
func SendTestRecord(url string) error {
  rec := JSONLogRecord{
    Timestamp: time.Now().UTC().UnixMilli(),
    Stream:    "test",
    Message:   "TEST VICTORIA",
  }

  body, err := json.Marshal(rec)
  if err != nil {
    return fmt.Errorf("marshal error: %w", err)
  }

  req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
  if err != nil {
    return fmt.Errorf("request error: %w", err)
  }

  req.Header.Set("Content-Type", "application/stream+json")

  resp, err := http.DefaultClient.Do(req)
  if err != nil {
    return fmt.Errorf("http error: %w", err)
  }
  defer resp.Body.Close()

  if resp.StatusCode != 200 {
    return fmt.Errorf("victoria returned %d", resp.StatusCode)
  }

  fmt.Println("TEST RECORD SENT TO VICTORIA")
  return nil
}

До main.go додаємо отримання VictoriaLogs ендпоінта зі змінних оточення і з ним будуємо повний URL, в якому вказуємо які поля треба вважати як _msg, де буде _time, в якому форматі час, і за яким полем створювати Log stream:

...
  // "http://localhost:9428/insert/jsonline"
  vmLogsEp := os.Getenv("VICTORIA_LOGS_URL")
  if vmLogsEp == "" {
    log.Fatal("environment variable VICTORIA_LOGS_URL is not set")
  }

  // VictoriaLogs endpoint
  vmLogsURL := vmLogsEp +
    "?_msg_field=message" +
    "&_time_field=date" +
    "&_time_format=unix_ms" +
    "&_stream_fields=stream"

  // 5. send test record to VictoriaLogs
  err = collector.SendVlTestRecord(vmLogsURL)
  if err != nil {
    fmt.Println("ERROR sending test record:", err)
    return
  }
...

Задаємо змінну:

$ export VICTORIA_LOGS_URL="http://localhost:9428/insert/jsonline"

Запсукаємо:

$ go run main.go 
...
TEST RECORD SENT TO VICTORIA

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

ОК, працює.

Створення Log parser

Тепер оновлюємо наш код – додаємо нові функції:

  • collector/scan.go буде читати дані від gzip і писати в канал у вигляді готових strings
  • collector/parser.go – формує SimpleLog з полями Timestamp і Message

Редагуємо scan.go – замість створення буферу створюємо channel, і scanner.Text() тепер буде писати в канал замість буферу:

package collector

import (
  "bufio"
  "io"
)

// ScanLines reads lines from an io.Reader and sends them into a channel
// caller must range over the returned channel
func ScanLines(r io.Reader) <-chan string {
  ch := make(chan string)

  go func() {
    defer close(ch)

    scanner := bufio.NewScanner(r)

    // - bufio.Scanner reads decompressed bytes from GzipReader()
    // - it splits input by '\n' and returns each line as a complete string
    // - each line is sent into the channel for further processing
    for scanner.Scan() {
      ch <- scanner.Text()
    }
  }()

  return ch
}

Створюємо parser.go зі структурою SimpleLog.

В структурі будемо тримати час, отриманий із запису в log record, а в Message будемо заносити весь текст:

package collector

import (
  "fmt"
  "strings"
  "time"
)

// SimpleLog contains parsed timestamp and original line
type SimpleLog struct {
  Timestamp time.Time
  Message   string
}

// ParseRawLine parses ALB log timestamp from the 2nd field.
// Everything else we keep raw.
func ParseRawLine(line string) (*SimpleLog, error) {
  fields := strings.Fields(line)
  if len(fields) < 2 {
    return nil, fmt.Errorf("invalid ALB log line")
  }

  fmt.Println("--- PARSER SEND ---")
  fmt.Println(line)
  fmt.Println("--- PARSER DEBUG ---")

  ts, err := time.Parse(time.RFC3339Nano, fields[1])
  if err != nil {
    return nil, fmt.Errorf("timestamp parse error: %w", err)
  }

  return &SimpleLog{
    Timestamp: ts.UTC(),
    Message:   line,
  }, nil
}

Оновлюємо victoria.go – тепер не вона буде заповнювати дані у JSONLogRecord, а буде приймати їх аргументом:

package collector

import (
  "bytes"
  "encoding/json"
  "fmt"
  "net/http"
  "time"
)

// JSONLogRecord is the minimal structure for VictoriaLogs JSONLine API
type JSONLogRecord struct {
  Timestamp int64  `json:"date"`
  Stream    string `json:"stream"`
  Message   string `json:"message"`
}

// SendToVictoria sends one JSON LINE into VictoriaLogs JSON Stream API.
func SendToVictoria(url string, rec *JSONLogRecord) error {
  body, err := json.Marshal(rec)
  if err != nil {
    return err
  }

  fmt.Println("--- VMLOGS SEND ---")
  fmt.Println(string(body))
  fmt.Println("--- VMLOGS DEBUG ---")

  req, err := http.NewRequest("POST", url, bytes.NewReader(body))
  if err != nil {
    return err
  }

  req.Header.Set("Content-Type", "application/stream+json")

  resp, err := http.DefaultClient.Do(req)
  if err != nil {
    return err
  }
  defer resp.Body.Close()

  if resp.StatusCode >= 300 {
    return fmt.Errorf("victoria response status: %s", resp.Status)
  }

  return nil
}

В main.go() робимо цикл і вже додаємо видалення повідомлень з SQS викликом DeleteFromSQS(), яку створювали раніше:

...
  // 4. Pipeline: read lines → parse → send
  for line := range collector.ScanLines(gzReader) {

    rec, err := collector.ParseRawLine(line)
    if err != nil {
      continue
    }

    out := &collector.JSONLogRecord{
      Timestamp: rec.Timestamp.UnixMilli(),
      Stream:    "alb",
      Message:   rec.Message,
    }

    if err := collector.SendToVictoria(vmLogsURL, out); err != nil {
      fmt.Println("send error:", err)
    }
  }

  // 5. Delete SQS message
  if err := collector.DeleteFromSQS(ctx, sqsClient, queueURL, receiptHandle); err != nil {
    fmt.Println("delete error:", err)
  }
}

Поки collector.ScanLines() повертає дані – передаємо їх до ParseRawLine(), який заповнює SimpleLog з Timestamp і Message.

Потім заповнюємо JSONLogRecord і передаємо до VictoriaLogs.

Зараз можна очистити SQS-чергу: поки я писав код, там назбирались старі повідомлення, і експортер почав тягнути старі логи. Я довго шукав, чому у VictoriaLogs час не збігається з очікуваним, але проблема виявилась банальною – я дивився дані за останні 15 хвилин, а імпортувались ранкові записи.

Але тоді доведеться почекати до 5 хвилин, поки з’явиться новий меседж.

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

$ go run main.go 
...
--- PARSER SEND ---
https 2025-11-25T12:36:50.859909Z app/k8s-ops133externalalb-***/336cddd33c043f33 3.***.***.78:3883 10.0.46.229:8000 0.000 0.010 0.000 200 200 2023 574 "POST https://api.challenge.example.co:443/auth/auth0-webhooks/post-login HTTP/1.1" "axios/1.6.5" ECDHE-RSA-AES128-GCM-SHA256 TLSv1.2 arn:aws:elasticloadbalancing:us-east-1:492***148:targetgroup/k8s-prodback-backenda-47ba3e0f35/9ec763ecd48352da "Root=1-6925a2e2-6e4507dd3a23de072c2f6ae9" "api.challenge.example.co" "arn:aws:acm:us-east-1:492***148:certificate/beeb3714-511e-414b-b1f3-5440746bb5ea" 12 2025-11-25T12:36:50.837000Z "forward" "-" "-" "10.0.46.229:8000" "200" "-" "-" TID_5ed365cd6c6f57409a2566d1dcaf049c "-" "-" "-"
--- PARSER DEBUG ---
--- VMLOGS SEND ---
{"date":1764074210859,"stream":"alb","message":"https 2025-11-25T12:36:50.859909Z app/k8s-ops133externalalb-***/336cddd33c043f33 3.***.***.78:3883 10.0.46.229:8000 0.000 0.010 0.000 200 200 2023 574 \"POST https://api.challenge.example.co:443/auth/auth0-webhooks/post-login HTTP/1.1\" \"axios/1.6.5\" ECDHE-RSA-AES128-GCM-SHA256 TLSv1.2 arn:aws:elasticloadbalancing:us-east-1:492***148:targetgroup/k8s-prodback-backenda-47ba3e0f35/9ec763ecd48352da \"Root=1-6925a2e2-6e4507dd3a23de072c2f6ae9\" \"api.challenge.example.co\" \"arn:aws:acm:us-east-1:492***148:certificate/beeb3714-511e-414b-b1f3-5440746bb5ea\" 12 2025-11-25T12:36:50.837000Z \"forward\" \"-\" \"-\" \"10.0.46.229:8000\" \"200\" \"-\" \"-\" TID_5ed365cd6c6f57409a2566d1dcaf049c \"-\" \"-\" \"-\""}
--- VMLOGS DEBUG ---
...

І у VictoriaLogs:

Все тут.

Що нам залишилось:

  • формувати поля
  • додати gocron

Запуск циклу з gocron

Додаємо запуск кожну хвилину з gocron.

Тепер весь main.go виглядає так:

func main() {
  // TODO: add exit handler
  ctx := context.Background()

  // Load the Shared AWS Configuration (~/.aws/config)
  cfg, err := config.LoadDefaultConfig(ctx)
  if err != nil {
    log.Fatal(err)
  }

  // create SQS client using the shared AWS config
  sqsClient := sqs.NewFromConfig(cfg)
  // create S3 client using the shared AWS config
  s3Client := s3.NewFromConfig(cfg)

  // read queue name from environment
  queueName := os.Getenv("ALB_LOGS_QUEUE")
  if queueName == "" {
    log.Fatal("environment variable ALB_LOGS_QUEUE is not set")
  }

  // scheduler
  s := gocron.NewScheduler(time.UTC)

  // job: check SQS every minute
  s.Every(1).Minute().Do(func() {
    fmt.Println("CHECKING SQS...")

    // 1. get message
    bucket, key, receiptHandle, queueURL, err := collector.ReceiveFromSQS(ctx, sqsClient, queueName)
    //bucket, key, _, _, err := collector.ReceiveFromSQS(ctx, sqsClient, queueName)
    if err != nil {
      if err.Error() == "no messages" {
        fmt.Println("NO MESSAGES")
        return
      }
      fmt.Println("ERROR receiving message:", err)
      return
    }

    fmt.Println("BUCKET:", bucket)
    fmt.Println("KEY:", key)

    // 2. get S3 object
    s3Obj, err := collector.GetS3Object(ctx, s3Client, bucket, key)
    if err != nil {
      fmt.Println("S3 error:", err)
      return
    }
    defer s3Obj.Body.Close()

    fmt.Println("S3 object stream opened:", bucket, key)

    // 3. gzip reader
    gzReader, err := collector.GzipReader(s3Obj.Body)
    if err != nil {
      fmt.Println("gzip error:", err)
      return
    }
    defer gzReader.Close()

    // "http://localhost:9428/insert/jsonline"
    vmLogsEp := os.Getenv("VICTORIA_LOGS_URL")
    if vmLogsEp == "" {
      log.Fatal("environment variable VICTORIA_LOGS_URL is not set")
    }

    // VictoriaLogs endpoint
    vmLogsURL := vmLogsEp +
      "?_msg_field=message" +
      "&_time_field=date" +
      "&_time_format=unix_ms" +
      "&_stream_fields=stream"

    // 4. Pipeline: read lines → parse → send
    for line := range collector.ScanLines(gzReader) {

      rec, err := collector.ParseRawLine(line)
      if err != nil {
        continue
      }

      out := &collector.JSONLogRecord{
        Timestamp: rec.Timestamp.UnixMilli(),
        Stream:    "alb",
        Message:   rec.Message,
      }

      if err := collector.SendToVictoria(vmLogsURL, out); err != nil {
        fmt.Println("send error:", err)
      }
    }

    // 5. delete message from SQS
    if err := collector.DeleteFromSQS(ctx, sqsClient, queueURL, receiptHandle); err != nil {
      fmt.Println("FAILED TO DELETE SQS MESSAGE:", err)
    } else {
      fmt.Println("SQS MESSAGE DELETED")
    }
  })

  // start async
  s.StartBlocking()
}

Додавання fields до VictoriaLogs message 

Документація по полям AWS ALB – Access log entries.

Що зараз можемо додати до логів у VictoriaLogs – client_ip з client:port, target_ip (Kubernetes Pod) із target:port, elb_status_code – код відповіді ALB, аби потім простіше робити алерти, і target_status_code – аналогічно.

Оновлюємо структуру SimpleLog – додаємо нові поля:

type SimpleLog struct {
    Timestamp    time.Time
    Message      string
    ClientIP     string
    TargetIP     string
    ELBStatus    int
    TargetStatus int
}

До ParseRawLine() додаємо запис в ці поля, використовуючи array index зі строки fields:

...
    elbStatus, _ := strconv.Atoi(fields[8])
    targetStatus, _ := strconv.Atoi(fields[9])

    return &SimpleLog{
        Timestamp:    ts.UTC(),
        Message:      line,
        ClientIP:     fields[3],
        TargetIP:     fields[4],
        ELBStatus:    elbStatus,
        TargetStatus: targetStatus,
    }, nil
...

Додаємо ці поля до JSONLogRecord:

type JSONLogRecord struct {
    Timestamp    int64  `json:"date"`
    Message      string `json:"message"`
    Stream       string `json:"stream"`
    ClientIP     string `json:"client_ip"`
    TargetIP     string `json:"target_ip"`
    ELBStatus    int    `json:"elb_status"`
    TargetStatus int    `json:"target_status"`
}

І додаємо їх до main.go:

...
      out := &collector.JSONLogRecord{
        Timestamp:    rec.Timestamp.UnixMilli(),
        Message:      rec.Message,
        Stream:       "alb",
        ClientIP:     rec.ClientIP,
        TargetIP:     rec.TargetIP,
        ELBStatus:    rec.ELBStatus,
        TargetStatus: rec.TargetStatus,
      }
...

Тепер у VictoriaLogs маємо нові fields:

Порти в client_ip та target_ip тут явно зайві, можемо їх вирізати під час парсингу.

До parser.go додаємо функцію зі strings.SplitN():

// cut port from "ip:port"
func stripPort(s string) string {
  parts := strings.SplitN(s, ":", 2)
  return parts[0]
}

І використовуємо її при заповненні SimpleLog:

...
  return &SimpleLog{
    Timestamp:    ts.UTC(),
    Message:      line,
    ClientIP:     stripPort(fields[3]),
    TargetIP:     stripPort(fields[4]),
    ELBStatus:    elbStatus,
    TargetStatus: targetStatus,
  }, nil
...

Тепер маємо поля без зайвих портів:

The final result

Весь код разом.

Коменти попросив написати AI, щоб детально по всім функціям було пояснення.

Файл main.go

package main

import (
  "alb-logs-collector/collector"
  "context"
  "fmt"
  "log"
  "os"
  "time"

  "github.com/aws/aws-sdk-go-v2/config"
  "github.com/aws/aws-sdk-go-v2/service/s3"
  "github.com/aws/aws-sdk-go-v2/service/sqs"
  "github.com/go-co-op/gocron"
)

func main() {
  // create a base context for all AWS operations
  // this context is passed into SQS/S3 functions
  ctx := context.Background()

  // load shared AWS config from ~/.aws/config and ~/.aws/credentials
  // this provides region, credentials, retry settings, etc.
  cfg, err := config.LoadDefaultConfig(ctx)
  if err != nil {
    log.Fatal(err)
  }

  // create SQS client using the shared AWS config
  // used later for reading and deleting queue messages
  sqsClient := sqs.NewFromConfig(cfg)

  // create S3 client for reading S3 objects
  s3Client := s3.NewFromConfig(cfg)

  // read queue name from environment
  // this avoids hardcoding queue names in code
  queueName := os.Getenv("ALB_LOGS_QUEUE")
  if queueName == "" {
    log.Fatal("environment variable ALB_LOGS_QUEUE is not set")
  }

  // create the scheduler that runs periodic jobs
  // gocron automatically creates goroutines for scheduled tasks
  s := gocron.NewScheduler(time.UTC)

  // schedule: run the function every minute
  s.Every(1).Minute().Do(func() {
    fmt.Println("CHECKING SQS...")

    // 1. read one message from SQS
    // ReceiveFromSQS is implemented in collector/sqs.go
    bucket, key, receiptHandle, queueURL, err := collector.ReceiveFromSQS(ctx, sqsClient, queueName)
    if err != nil {
      // "no messages" is not an error — just no new logs
      if err.Error() == "no messages" {
        fmt.Println("NO MESSAGES")
        return
      }
      fmt.Println("ERROR receiving message:", err)
      return
    }

    fmt.Println("BUCKET:", bucket)
    fmt.Println("KEY:", key)

    // 2. download the S3 object stream
    // GetS3Object located in collector/s3.go
    s3Obj, err := collector.GetS3Object(ctx, s3Client, bucket, key)
    if err != nil {
      fmt.Println("S3 error:", err)
      return
    }
    defer s3Obj.Body.Close()

    fmt.Println("S3 object stream opened:", bucket, key)

    // 3. wrap the S3 stream into gzip reader
    // GzipReader implemented in collector/gzip.go
    gzReader, err := collector.GzipReader(s3Obj.Body)
    if err != nil {
      fmt.Println("gzip error:", err)
      return
    }
    defer gzReader.Close()

    // read VictoriaLogs endpoint
    vmLogsEp := os.Getenv("VICTORIA_LOGS_URL")
    if vmLogsEp == "" {
      log.Fatal("environment variable VICTORIA_LOGS_URL is not set")
    }

    // final URL with parameters for jsonline ingestion
    vmLogsURL := vmLogsEp +
      "?_msg_field=message" + // which JSON field contains the log message
      "&_time_field=date" + // which JSON field contains timestamp
      "&_time_format=unix_ms" + // tell VictoriaLogs that the timestamp is unix milliseconds
      "&_stream_fields=stream" // which field defines the stream name

    // 4. process the log file line-by-line
    // ScanLines implemented in collector/scan.go
    // It returns a channel of RAW log lines (already ungzipped)
    for line := range collector.ScanLines(gzReader) {

      // parse timestamp + extract fields
      // ParseRawLine implemented in collector/parser.go
      rec, err := collector.ParseRawLine(line)
      if err != nil {
        continue // skip invalid ALB lines
      }

      // prepare the JSON record for VictoriaLogs
      // JSONLogRecord defined in collector/victoria.go
      out := &collector.JSONLogRecord{
        Timestamp:    rec.Timestamp.UnixMilli(), // ALB timestamp in unix ms
        Message:      rec.Message,               // full raw ALB log line
        Stream:       "alb",                     // log stream name
        ClientIP:     rec.ClientIP,              // extracted from ALB log
        TargetIP:     rec.TargetIP,              // extracted from ALB log
        ELBStatus:    rec.ELBStatus,             // HTTP status from ALB
        TargetStatus: rec.TargetStatus,          // backend response status
      }

      // send it to VictoriaLogs
      // SendToVictoria implemented in collector/victoria.go
      if err := collector.SendToVictoria(vmLogsURL, out); err != nil {
        fmt.Println("send error:", err)
      }
    }

    // 5. delete message from SQS after successful processing
    // DeleteFromSQS implemented in collector/sqs.go
    if err := collector.DeleteFromSQS(ctx, sqsClient, queueURL, receiptHandle); err != nil {
      fmt.Println("FAILED TO DELETE SQS MESSAGE:", err)
    } else {
      fmt.Println("SQS MESSAGE DELETED")
    }
  })

  // start the scheduler and block main goroutine forever
  s.StartBlocking()
}

Файл sqs.go

package collector

import (
  "context"
  "encoding/json"
  "fmt"
  "strings"

  "github.com/aws/aws-sdk-go-v2/aws"
  "github.com/aws/aws-sdk-go-v2/service/sqs"
)

// S3Event describes the JSON format sent by S3 to SQS.
// It matches the structure of the S3 event notification:
// "Records" → "s3" → "bucket.name" and "object.key".
// This struct is used in ReceiveFromSQS() to extract bucket/key.
type S3Event struct {
  Records []struct {
    S3 struct {
      Bucket struct {
        Name string `json:"name"` // S3 bucketName
      } `json:"bucket"`
      Object struct {
        Key string `json:"key"` // S3 object key (log filename)
      } `json:"object"`
    } `json:"s3"`
  } `json:"Records"`
}

// ReceiveFromSQS reads a single SQS message.
// It returns:
// - bucket: the S3 bucket from event
// - key: the object key (filename in S3)
// - receiptHandle: required later to delete the message
// - queueURL: actual AWS queue URL (needed for deletion)
// - err: error, or "no messages" if queue is empty
//
// This function is called from main.go inside the scheduler loop.
// After processing the file from S3, the caller must call DeleteFromSQS().
func ReceiveFromSQS(ctx context.Context, client *sqs.Client, queueName string) (bucket, key, receiptHandle, queueURL string, err error) {

  // resolve real SQS queue URL from queue name
  // SQS APIs always operate on queueURL, not the name
  getURL, err := client.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
    QueueName: aws.String(queueName),
  })
  if err != nil {
    return "", "", "", "", err
  }

  queueURL = *getURL.QueueUrl

  // receive exactly one message
  // WaitTimeSeconds enables long polling for up to 10s
  resp, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
    QueueUrl:            aws.String(queueURL),
    MaxNumberOfMessages: 1,
    WaitTimeSeconds:     10,
  })
  if err != nil {
    return "", "", "", "", err
  }

  // queue empty → no new logs
  if len(resp.Messages) == 0 {
    return "", "", "", "", fmt.Errorf("no messages")
  }

  // take the first (and only) message
  msg := resp.Messages[0]

  // ReceiptHandle is mandatory for deletion
  receiptHandle = *msg.ReceiptHandle

  // raw JSON body received from S3 → SQS notification
  raw := aws.ToString(msg.Body)
  fmt.Println("SQS RAW:", raw)

  // filter out AWS TestEvent generated when enabling notifications
  // Test events do NOT contain real logs and must be ignored
  if strings.Contains(raw, `"Event":"s3:TestEvent"`) {
    fmt.Println("Skipping AWS S3 test event")
    return "", "", "", queueURL, fmt.Errorf("test event skipped")
  }

  // unmarshal JSON into S3Event struct
  var event S3Event
  if err := json.Unmarshal([]byte(raw), &event); err != nil {
    return "", "", "", "", err
  }

  // extract bucket and key from the event
  bucket = event.Records[0].S3.Bucket.Name
  key = event.Records[0].S3.Object.Key

  return bucket, key, receiptHandle, queueURL, nil
}

// DeleteFromSQS permanently removes the processed message.
// Must be called only after successful S3 → gzip → parsing → VictoriaLogs ingestion.
// If not deleted, SQS will retry delivery (visibility timeout expires).
// Implemented in collector/sqs.go, called from main.go.
func DeleteFromSQS(ctx context.Context, client *sqs.Client, queueURL, receiptHandle string) error {
  _, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{
    QueueUrl:      aws.String(queueURL),
    ReceiptHandle: aws.String(receiptHandle),
  })
  return err
}

Файл s3.go

package collector

import (
  "context"

  "github.com/aws/aws-sdk-go-v2/aws"
  "github.com/aws/aws-sdk-go-v2/service/s3"
)

// GetS3Object retrieves an S3 object and returns a streaming reader.
//
// This function does NOT download the file to disk.
// It returns a network stream (`GetObjectOutput.Body`), which allows the caller
// to read the object lazily, byte-by-byte, directly from S3.
//
// In our pipeline, the call chain looks like:
//
//	main.go
//	  → ReceiveFromSQS() to get bucket/key      (collector/sqs.go)
//	  → GetS3Object() to open S3 object stream  (this file)
//	  → GzipReader() to decompress gzip data    (collector/gzip.go)
//	  → ScanLines() to iterate log lines        (collector/scan.go)
//
// Notes:
// - The returned object must be closed by the caller: `defer obj.Body.Close()`
// - S3 GetObject supports range requests, but here we read the whole file.
// - The body is read sequentially; this is efficient for log ingestion patterns.
func GetS3Object(ctx context.Context, client *s3.Client, bucket, key string) (*s3.GetObjectOutput, error) {
  return client.GetObject(ctx, &s3.GetObjectInput{
    Bucket: aws.String(bucket), // name of S3 bucket with ALB logs
    Key:    aws.String(key),    // key (path/filename) of the gzip log file
  })
}

Файл gzip.go

package collector

import (
  "compress/gzip"
  "io"
)

// GzipReader wraps an existing io.Reader with a gzip decompressor.
//
// This function takes the S3 object body (returned by GetS3Object in
// collector/s3.go) and turns it into a gzip.Reader capable of producing
// the decompressed content of the ALB log file.
//
// Typical pipeline:
//
//	S3Object.Body (io.ReadCloser)
//	    ↓ passed into
//	GzipReader() → *gzip.Reader (still a stream)
//	    ↓ passed into
//	ScanLines() → yields plain-text log lines
//
// Notes:
// - gzip.NewReader expects the input stream to be in .gz format.
// - Caller must close the returned reader: `defer gz.Close()`.
// - No buffering or scanning is done here; this function only wraps the stream.
func GzipReader(r io.Reader) (*gzip.Reader, error) {
  return gzip.NewReader(r)
}

Файл scan.go

package collector

import (
  "bufio"
  "io"
)

// ScanLines reads an arbitrary io.Reader line-by-line and returns a channel
// that produces each extracted line as a string.
//
// This function is used in main.go to iterate through the contents of an S3
// Gzip file. The call chain is:
//
//	main.go → GetS3Object() (collector/s3.go)
//	        → GzipReader() (collector/gzip.go)
//	        → ScanLines()  (this file)
//
// Because ScanLines() uses a goroutine, the caller can process lines
// asynchronously using: `for line := range ScanLines(r) { ... }`.
//
// Notes:
//   - The returned channel is *unbuffered*, so each send blocks until the caller
//     receives the value. This naturally rate-controls the goroutine.
//   - bufio.Scanner splits input by '\n', automatically handling different line
//     lengths (up to scanner’s max buffer).
//   - When the reader is fully consumed, the goroutine closes the channel.
func ScanLines(r io.Reader) <-chan string {
  ch := make(chan string)

  // launch a goroutine that streams lines out of the reader
  go func() {
    // ensure channel is closed when scanning finishes
    defer close(ch)

    // bufio.Scanner provides efficient, line-oriented reading of text streams
    scanner := bufio.NewScanner(r)

    // loop until EOF or error. Each iteration reads the next line
    for scanner.Scan() {
      // push extracted line into the channel
      // (blocks until caller receives it)
      ch <- scanner.Text()
    }

    // scanner.Err() is intentionally ignored here, because error handling
    // is performed by the caller when needed, and the channel-based design
    // treats EOF as natural termination.
  }()

  // return read-only channel of strings
  return ch
}

Файл parser.go

package collector

import (
  "fmt"
  "strconv"
  "strings"
  "time"
)

// SimpleLog represents a minimal, lightweight structure containing:
//
// - Timestamp: parsed ALB timestamp in UTC
// - Message:   the full raw log line (used as message in VictoriaLogs)
// - ClientIP:  extracted client IP (port removed)
// - TargetIP:  extracted backend target IP (port removed)
// - ELBStatus: HTTP status returned by ALB to the client
// - TargetStatus: HTTP status returned by the backend to ALB
//
// This struct is designed for the simplified ingestion phase,
// before implementing full ALB field parsing.
type SimpleLog struct {
  Timestamp    time.Time
  Message      string
  ClientIP     string
  TargetIP     string
  ELBStatus    int
  TargetStatus int
}

// stripPort removes the ":port" suffix from IP strings like "1.2.3.4:5678".
// This keeps VictoriaLogs cardinality low, avoiding creation of thousands
// of separate series due to ephemeral client ports.
func stripPort(s string) string {
  parts := strings.SplitN(s, ":", 2)
  return parts[0]
}

// ParseRawLine parses the essential ALB log fields:
//
// - Timestamp from the 2nd field
// - Client IP (without port)
// - Target IP (without port)
// - ALB HTTP status
// - Target HTTP status
//
// Everything else is kept untouched in the Message field.
//
// ALB logs are space-delimited, except for quoted sections
// (like the request line and user agent). At this simplified stage,
// we do *not* parse quoted fields — we only extract the mandatory parts.
func ParseRawLine(line string) (*SimpleLog, error) {
  fields := strings.Fields(line)

  // we expect at least: protocol, timestamp, elb, client, target, ... status codes
  // ALB format is consistent — <10 fields means corrupted input
  if len(fields) < 10 {
    return nil, fmt.Errorf("invalid ALB log line")
  }

  fmt.Println("--- PARSER SEND ---")
  fmt.Println(line)
  fmt.Println("--- PARSER DEBUG ---")

  // parse timestamp in RFC3339Nano format
  // ALB always emits timestamps in UTC
  ts, err := time.Parse(time.RFC3339Nano, fields[1])
  if err != nil {
    return nil, fmt.Errorf("timestamp parse error: %w", err)
  }

  // convert status codes from string → int
  elbStatus, _ := strconv.Atoi(fields[8])
  targetStatus, _ := strconv.Atoi(fields[9])

  return &SimpleLog{
    Timestamp:    ts.UTC(), // ensure strict UTC normalization
    Message:      line,     // pass full raw line to VictoriaLogs
    ClientIP:     stripPort(fields[3]),
    TargetIP:     stripPort(fields[4]),
    ELBStatus:    elbStatus,
    TargetStatus: targetStatus,
  }, nil
}

Файл victoria.go

package collector

import (
  "bytes"
  "encoding/json"
  "fmt"
  "net/http"
)

// JSONLogRecord represents a single log entry formatted for
// VictoriaLogs JSONLine ingestion API.
//
// Field mapping:
//
//   - Timestamp → "date"
//     UNIX milliseconds timestamp of the log event.
//     This must match ?_time_field=date&_time_format=unix_ms in ingestion URL.
//
//   - Message → "message"
//     The complete raw ALB log line. Used as the primary log message.
//
//   - Stream → "stream"
//     Logical log stream identifier. Used in ingestion via ?_stream_fields=stream.
//
//   - ClientIP / TargetIP / ELBStatus / TargetStatus
//     Additional parsed metadata fields. These become searchable log fields.
//
// The structure intentionally avoids nested JSON — VictoriaLogs processes
// flat objects more efficiently and without ambiguity in field extraction.
type JSONLogRecord struct {
  Timestamp    int64  `json:"date"`
  Message      string `json:"message"`
  Stream       string `json:"stream"`
  ClientIP     string `json:"client_ip"`
  TargetIP     string `json:"target_ip"`
  ELBStatus    int    `json:"elb_status"`
  TargetStatus int    `json:"target_status"`
}

// SendToVictoria sends exactly ONE JSON record to the VictoriaLogs JSONLine API.
//
// This function is called from main.go inside the ingestion loop.
// It performs the following steps:
//
//  1. Marshal the JSONLogRecord into a compact JSON object
//  2. Build an HTTP POST request with Content-Type: application/stream+json
//  3. Send the request to VictoriaLogs
//  4. Check for non-2xx HTTP status codes
//
// Notes:
//   - VictoriaLogs expects a "streaming JSON" format, where each POST body
//     contains a single JSON object (or multiple lines if needed).
//   - We send logs one-by-one for simplicity, but batching can be added later.
//   - The caller controls the ingestion endpoint URL, including query params:
//     ?_msg_field=message&_time_field=date&_time_format=unix_ms&_stream_fields=stream
//
// Errors returned from this function are logged in main.go, and do not
// interrupt the ingestion pipeline.
func SendToVictoria(url string, rec *JSONLogRecord) error {
  // serialize record into JSON line
  body, err := json.Marshal(rec)
  if err != nil {
    return err
  }

  // debug output for transparency
  fmt.Println("--- VMLOGS SEND ---")
  fmt.Println(string(body))
  fmt.Println("--- VMLOGS DEBUG ---")

  // create POST request with streaming JSON payload
  req, err := http.NewRequest("POST", url, bytes.NewReader(body))
  if err != nil {
    return err
  }

  // JSONLine ingestion requires this content type
  req.Header.Set("Content-Type", "application/stream+json")

  // send HTTP request using default HTTP client
  resp, err := http.DefaultClient.Do(req)
  if err != nil {
    return err
  }
  defer resp.Body.Close()

  // check server-side errors
  if resp.StatusCode >= 300 {
    return fmt.Errorf("victoria response status: %s", resp.Status)
  }

  return nil
}

Вже запустив в Kubernetes, все працює.

Єдиний момент, що SQS та S3 операції не падають при access denied, треба додати перевірку помилок.

Loading

Golang: інтерфейси, типи та методи на прикладі io.Copy()
0 (0)

22 Листопада 2025

Почав писати log collector з S3 до VictoriaLogs з використанням AWS GO SDK, і в коді достатньо багато використовуються різні Input/Ouput операції, бо треба отримати лог, розпарсити, записати дані.

В попередньому пості по інтерфейсам – Golang: interfaces – “магія” виклику методів через інтерфейси – вже трохи торкався теми того, що таке інтерфейси і як саме вони працюють, але там не було прикладів того, як саме вони використовуються.

Тож цього разу подивимось на дуже класний приклад використання інтерфейсів при роботі з функцією io.Copy() і ще раз трохи зазирнемо під капот внутрішньої реалізації інтерфейсів в Go.

Basic I/O example – os.Open(), os.Create() та io.Copy()

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

package main

import (
  "fmt"
  "io"
  "os"
)

func main() {
  // open the source file for reading
  // returns pointer to os.File:
  // 'func os.Open(name string) (*os.File, error)':
  //
  // os.File represents an open file descriptor:
  // type File struct {
  //	// contains filtered or unexported fields
  // }
  sourceFile, err := os.Open("source.txt")
  if err != nil {
    panic(err)
  }
  // always close files when done
  defer sourceFile.Close()

  // create destination file for writing
  // 'func os.Create(name string) (*os.File, error)'
  destFile, err := os.Create("dest.txt")
  if err != nil {
    panic(err)
  }
  defer destFile.Close()

  // copy data from source to destination
  // io.Copy() pulls bytes from any Reader and pushes them into any Writer
  // 'func io.Copy(dst io.Writer, src io.Reader) (written int64, err error)'
  bytesWritten, err := io.Copy(destFile, sourceFile)
  if err != nil {
    panic(err)
  }

  fmt.Println("Copied bytes:", bytesWritten)
}

Тут ми:

  • з os.Open("source.txt") відкриваємо файл на читання
  • з os.Create("dest.txt") створюємо файл, в який будемо копіювати дані
  • і з io.Copy() копіюємо дані з “source.txt” до “dest.txt

Створюємо тестовий файл source.txt:

$ echo source > source.txt

Запускаємо нашу програму:

$ go run main.go
Copied bytes: 7

Що для нас важливо зараз – що виклики os.Open() і os.Create() повертають об’єкти типу *os.File struct – посилання на структуру.

А *os.File struct має набір методів, які далі використовуються для запуску процесу копіювання файлу з io.Copy().

Методи інтерфейсів на прикладі io.Copy()

Отже, до функції io.Copy() передаються destFile та sourceFile, які є об’єктами з типом *os.File:

...
  bytesWritten, err := io.Copy(destFile, sourceFile)
...

Функція io.Copy() приймає два аргументи – dst Writer та src Reader, які далі передає до функції copyBuffer():

// Copy copies from src to dst until either EOF is reached
func Copy(dst Writer, src Reader) (written int64, err error) {
  return copyBuffer(dst, src, nil)
}

copyBuffer() приймає такі самі аргументи та повертає такі самі дані:

func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
  ...
}

І для обох функцій – і Copy(), і copyBuffer() – аргументи є interface type:

А type Writer interface описує вимоги до типу, що може бути використаний через цей інтерфейс: такий тип повинен мати метод Write(), приймати аргумент з типом slice of bytes, і повертати значення int та err:

// Writer is the interface that wraps the basic Write method.
type Writer interface {
  Write(p []byte) (n int, err error)
}

Тобто в нашому коді:

...
    bytesWritten, err := io.Copy(destFile, sourceFile)
...

Об’єкт destFile повинен мати метод Write() – який в нього є, бо destFile – це *os.File struct, в якої є набір методів, в тому числі як раз Read() та Write():

$ go doc os.File
package os // import "os"

type File struct {
        // Has unexported fields.
}
    File represents an open file descriptor.

...

func (f *File) Read(b []byte) (n int, err error)
...
func (f *File) Write(b []byte) (n int, err error)

Де метод Write() приймає слайс байтів []byte:

func (f *File) Write(b []byte) (n int, err error) {
    ...
}

А отже, маючи об’єкт з типом *os.File – ми через відповідні інтерфейси можемо викликати *os.File.Write():

  • func Copy(dst Writer, ...) каже – “dst повинен мати метод Write([]byte) (int, error)
  • тип *os.File має метод Write() – а значить він задовольняє Writer interface

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

І коли ми пишемо і запускаємо io.Copy(destFile, ...) – під капотом Go під час компіляції програми:

  • перевіряє, який тип приймає io.Copy() – це interface type
  • аби задовільнити конкретно цей interface type – об’єкт (тип), який передається аргументом до io.Copy(), повинен мати метод Write()
  • Go перевіряє, чи є у переданого типу такий метод – чи є для об’єкту *os.File метод Write()

Далі – “магія”, описана в попередньому пості: ще раз глянемо на те, як працюють інтерфейси, як через них викликаються методи, і що саме знаходиться в аргументах io.Copy() та copyBuffer() при роботі програми.

Структури iface та itab

Коли ми передаємо обʼєкт (pointer на *os.File)  у параметр із типом інтерфейсу (dst Writer) – то Go формує дві внутрішні структури, які передаються до функцій як interface value.

Перша, type iface structiface, має два поля – tab і data:

type iface struct {
  // Pointer to the 'itab' (interface table)
  tab unsafe.Pointer
  // Pointer to the actual data (our *os.File struct)
  data unsafe.Pointer
}

Де:

  • tab unsafe.Pointer: pointer на другий тип type itab, який описаний в type ITab struct
    • раніше було type itab struct, зараз перенесли в Go ABI, про ABI в наступному пості
  • data unsafe.Pointer: pointer на наш об’єкт з типом os.File struct, який має метод Write()

Друга структура, ITab struct, має свої три поля:

type ITab struct {
   // pointer to the 'type Writer interface'
   Inter *InterfaceType
   // pointer to the 'type File struct'
   Type *Type
   // in our case if we have 1 method, thus '[N]uintptr' == [1]uintptr
   // and in the 'fun[0]' will be the address of the method 'Write()' of the 'os.File' struct
   Fun [1]uintptr // will have '[1]uintptr', and
}

Тут:

  • Inter *InterfaceType: pointer на опис type Writer interface
    • який інтерфейс треба задовольнити
  • Type: pointer на опис конкретного типу значення (у нашому випадку тип *os.File)
    • який тип ми передаємо
  • 'Fun[0]': буде посиланням на метод Write() структури os.File
    • ось адреси методів, які цей тип використовує для реалізації цього інтерфейсу

І коли ми в коді передаємо значення типу *os.File в параметр інтерфейсного типу (dst Writer) – то Go створює ці структури, і передає структура iface з полями tab і data до виклику io.Copy(), а потім далі – до copyBuffer():

io.Copy(iface):
- iface.tab => вказівник на структуру itab
- iface.data => вказівник на *os.File

В itab struct маємо таблицю методів, пов’язаних з цим інтерфейсом (або – які імплементують цей інтерфейс), а в полі fun структури itab знаходиться масив з pointers, де кожен елемент містить адресу функції, яка реалізує відповідний метод інтерфейсу для конкретного типу.

І у випадку з інтерфейсом Writer – це буде масив fun[0] зі значенням, наприклад, 0xc000014070, де за адресою 0xc000014070 буде розташований метод Write() типу *os.File.

І коли в copyBuffer(dst Writer) виконується виклик Write(), який описаний як:

func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
    ...
      nw, ew := dst.Write(buf[0:nr])
...

То фактично це перетворюється на виклик itab.fun[0]:

copyBuffer(iface struct) => itab struct => fun[0] field => 0xc000014070 => os.File.Write()

Повертаючись до твердження “при виклику io.Copy(*os.File) – викликається copyBuffer(), якому першим аргументом передається структура iface” – давайте подивимось на аргументи, з якими ми працюємо.

Перевірка типів інтерфейсних значень в аргументах

Аби побачити все своїми очима – повторимо “хак” з попереднього поста – створимо власну структуру, яка аналогічна до iface, бо напряму до iface ми звернутись не можемо – але можемо прочитати її памʼять через unsafe.Pointer.

І на додачу створимо власну функцію myCopy(), яка буде мати в параметрах наші власні інтерфейси – аналогічно тому, як це зроблено для io.Copy().

Тобто – ми повністю повторюємо поведінку оригінального io.Copy(), але замість справжніх io.Reader та io.Writer використовуємо свої інтерфейси і власну структуру myIfaceStruct, аби подивитись, як Go зберігає інтерфейс у памʼяті:

  • створюємо два об’єкти sourceFile та destFile, які є pointers на *os.File
  • описуємо власну функцію myCopy(), яка в параметрах описує отримання інтерфейсних типів
  • наші інтерфейси myReaderInterface та myWriterInterface вимагають методів Read() та Write(), які є у sourceFile та destFile

Код виходить такий:

package main

import (
  "fmt"
  "io"
  "os"
  "unsafe"
)

type myIfaceStruct struct {
  tab  unsafe.Pointer
  data unsafe.Pointer
}

// Writer is the interface that wraps the basic Write method.
type myWriterInterface interface {
  // define Write method to satisfy the myWriterInterface interface
  Write(p []byte) (n int, err error)
}

// Reader is the interface that wraps the basic Read method.
type myReaderInterface interface {
  // define Read method to satisfy the myReaderInterface interface
  Read(p []byte) (n int, err error)
}

// accept any type which has Read and Write methods
func myCopy(src myReaderInterface, dst myWriterInterface) (int64, error) {
  // '&src' gives us the address of the interface variable 'src'
  // 'unsafe.Pointer(&src)' allows us to reinterpret that memory as a different type
  // the interface value occupies 16 bytes:
  //   - first 8 bytes: pointer to the method/type table ('tab')
  //   - next 8 bytes: pointer to the actual value ('data')
  // '(*myIfaceStruct)(...)' tells Go to treat those bytes as a 'myIfaceStruct'
  // '*(*myIfaceStruct)(...)' finally copies those bytes into the 'rawIface' variable
  rawIface := *(*myIfaceStruct)(unsafe.Pointer(&src))

  fmt.Println()

  // Print diagnostic messages
  //
  // we intentionally use '%p' modifier with a non-pointer value argument
  // this causes a formatting error, and 'fmt' prints a diagnostic message
  // that includes the full content of 'rawIface' (its type and both fields)
  fmt.Printf("'rawIface' data: %p\n", rawIface)
  // same idea for %s: &src is a *myReaderInterface, not a string
  // so fmt prints a diagnostic message showing the type and value
  fmt.Printf("'src' data: %s\n", &src)

  fmt.Println()

  // Print addresses from the 'iface' struct
  //
  // 'tab' field is a pointer to the interface's method table (the 'itab' struct)
  // this value is copied from the real interface value stored in 'src'
  fmt.Printf("Copy of the 'iface.tab': address stored inside 'rawIface.tab': %p\n", rawIface.tab)
  // 'data' field is a pointer to the underlying object (the *os.File struct)
  // also copied directly from the actual interface storage
  fmt.Printf("Copy of the 'iface.data': address stored inside 'rawIface.data': %p\n", rawIface.data)
  // print the address of the real underlying object (*os.File)
  // this should match the value stored in rawIface.data
  fmt.Printf("The 'src' (*os.File) actual object address: %p\n", src)

  fmt.Println()

  // Test sizes
  //
  // 'src' will have 16 bytes
  // because 'iface' has two fields: 'tab' and 'data'
  // they are pointers, each of 8 bytes
  fmt.Println("sizeof the 'src' (size of 'iface' with two pointers):", unsafe.Sizeof(src))
  // but pointer to the '*os.File' object size will be 8 bytes
  testSource, _ := os.Open("source.txt")
  fmt.Println("sizeof the 'testSource' (size of '*os.File' with one pointer):", unsafe.Sizeof(testSource))

  fmt.Println()

  // demonstrate "dynamic types"
  //
  // - Printf '%T' modifier will print the type of the variable
  // - Printf '%p' modifier will print the address pointed to by '&'
  fmt.Printf("'src' type: %T\n", src)
  // address of the the 'src'
  fmt.Printf("'src' address: %p\n", &src)

  fmt.Println()

  return io.Copy(dst, src)
}

func main() {
  // sourceFile is *os.File
  sourceFile, _ := os.Open("source.txt")
  defer sourceFile.Close()

  // destFile is *os.File
  destFile, _ := os.Create("dest.txt")
  defer destFile.Close()

  myCopy(sourceFile, destFile)
}

Запускаємо:

$ go run test-int.go

'rawIface' data: %!p(main.myIfaceStruct={0x4eee38 0xc000062030})
'src' data: %!s(*main.myReaderInterface=0xc000014070)

Copy of the 'iface.tab': address stored inside 'rawIface.tab': 0x4eee38
Copy of the 'iface.data': address stored inside 'rawIface.data': 0xc000062030
The 'src' (*os.File) actual object address: 0xc000062030

sizeof the 'src' (size of 'iface' with two pointers): 16
sizeof the 'testSource' (size of '*os.File' with one pointer): 8

'src' type: *os.File
'src' address: 0xc000014070

І розбираємо результат.

Перші два – зовсім “грязний хак”, випадково на нього натрапив: якщо до модифікатора в fmt.Printf() передати не той тип даних, який він очікує – він виводить повідомлення з деталями по помилці, де можемо побачити, що саме повністю передавалось (хоча як виявилось, під капотом просто викликається (reflect.TypeOf(p.arg).String())).

Перший блок:

  • rawIface є типом main.myIfaceStruct, яка містить два вказівники на адреси 0x4eee38 та 0xc000062030 – див. далі про зміст rawIface
  • src є поінтером на *main.myReaderInterface – структуру, яка знаходиться за адресою 0xc000014070

Далі – виводимо адреси, які зберігаються в полях iface (і які ми отримали через нашу власну структуру):

  • 'rawIface.tab': 0x4eee38 – тут адреса розміщення itab struct
  • 'rawIface.data': 0xc000062030 – тут адреса переданого через src об’єкту os.File
  • і ту саму адресу ми бачимо в наступному рядку – src є pointer на *os.File, з Printf(%p) отримуємо адресу, на яку src  вказує

Найбільш явний доказ того, що насправді myCopy() у (src myReaderInterface) працює з інтерфейсом, а не *os.File – це розмір:

  • з unsafe.Sizeof(src) отримуємо розмір самого інтерфейсного значення (iface), яке складається з двох pointers – tab і data, по 8 байт кожен
  • а testSource := os.Open("source.txt") має розмір 8 байт, бо це один поінтер

Інтерфейси Go та “dynamic type”

А далі ми бачимо те, що називають “динамічними типами”: в результатах unsafe.Sizeof(src)) ми побачили, що там 2 поінтери, тобто це 100% тип interface value з двома pointers.

Але в fmt.Printf("'src' type: %T\n", src) ми отримуємо тип *os.File – бо це pointer на структуру os.File:

$ go run test-int.go
...
'src' data: %!s(*main.myReaderInterface=0xc000014070)
...
'src' type: *os.File
'src' address: 0xc000014070

То чому не якийсь myReaderInterface{}?

Дивимось документацію по Variables:

The static type (or just type) of a variable is the type given in its declaration, the type provided in the new call or composite literal, or the type of an element of a structured variable. Variables of interface type also have a distinct dynamic type, which is the (non-interface) type of the value assigned to the variable at run time (unless the value is the predeclared identifier nil, which has no type). The dynamic type may vary during execution but values stored in interface variables are always assignable to the static type of the variable.

Отже, змінна має static type, коли:

  • змінна оголошується (var i int)
  • тип заданий під час присвоювання даних при виклику функцій (x := new(int))
  • при використанні composite literal (y := []string{"a", "b"})

Проте змінні інтерфейсного типу завжди мають фіксований статичний тип (сам інтерфейс) – але реальний об’єкт всередині неї має окремий dynamic type – це конкретний тип значення, присвоєного під час виконання.

І в нашому прикладі вище – iface.data як раз і є тою змінною, яка визначає dynamic type, і тому ми в результаті fmt.Printf("'src' type: %T\n", src) бачимо саме *os.File.

Додаємо до нашого коду ще трохи дебагу:

...
  // show the static type of the interface itself
  // - (*myReaderInterface)(nil) creates a nil pointer to the interface type
  // - reflect.TypeOf(...) gives the type of that pointer
  // - Elem() gives the type the pointer points to (the interface type)
  // this demonstrates that the static type is 'myReaderInterface'
  fmt.Println("static type of the 'myReaderInterface':", reflect.TypeOf((*myReaderInterface)(nil)).Elem())

  // show the dynamic type stored inside the interface variable 'src'
  // - 'src' is an interface value (16-byte iface: tab + data)
  // - reflect.TypeOf(src) reads the real type stored in iface.data
  // this prints the actual type, '*os.File' in our case
  //
  // and this is exactly the same information that 'fmt.Printf("%T", src)' prints:
  // both reflect.TypeOf(src) and %T reveal the dynamic type stored in the interface
  fmt.Printf("'src' dynamic type: %v\n", reflect.TypeOf(src))

  // show the type of the variable 'src' itself, not the value stored inside it
  // this is exactly what the myCopy() function "sees" when receiving its argument
  // - '&src' is a pointer to the interface variable
  // - reflect.TypeOf(&src) therefore reports: "*myReaderInterface"
  // this confirms that 'src' is an interface-typed variable, not a concrete value
  fmt.Println("'src' variable type: ", reflect.TypeOf(&src))
...

Результат:

$ go run test-int.go
...
static type of the 'myReaderInterface': main.myReaderInterface
'src' dynamic type: *os.File
'src' variable type:  *main.myReaderInterface

Тут ми:

  • в першій перевірці просто створюємо вказівник на інтерфейсний тип (але без створення самого об’єкту): результат є main.myReaderInterface
  • другий результат – “прочитай значення інтерфейсної змінної src, і скажи, який там тип” – саме тут ми бачимо, що в iface.data зберігається pointer на об’єкт типу – *os.File
  • третя перевірка – “сходи за адресою, де зберігається змінна src, і скажи який за цією адресою тип даних” – отримуємо pointer на *main.myReaderInterface

Використання інтерфейсів на прикладі io.Copy()

То що це все значить для нас?

А значить, що використовуючи інтерфейси, ми можемо передати будь-які значення (типи), які реалізують інтерфейс.

Якщо повернутись до нашого першого коду, то в io.Copy() першим параметром ми можемо передати будь-який тип, який має метод Write([]byte) (int, error), а в другий – аналогічно, тільки Read(), бо під капотом Copy() викликає copyBuffer(), а той просто створює буфер розміром в 32 кілобайти, чей який “переливає” з одного “каналу” в інший:

func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
        ...
  if buf == nil {
    size := 32 * 1024
                ...
    buf = make([]byte, size)
  }
  for {
    nr, er := src.Read(buf)
    if nr > 0 {
      nw, ew := dst.Write(buf[0:nr])
        ...

А значить – ми можемо у Writer передати os.Stdout, тобто просто вивести на консоль:

...

func main() {
  // open the source file for reading
  // returs pointer to os.File:
  // 'func os.Open(name string) (*os.File, error)':
  //
  // os.File represents an open file descriptor:
  // type File struct {
  //	// contains filtered or unexported fields
  // }
  sourceFile, err := os.Open("source.txt")
  if err != nil {
    panic(err)
  }
  // always close files when done
  defer sourceFile.Close()

  // printto console instead
  // actualy, os.Stdout is also *os.File
  // thus it also has Write() method
  bytesWritten, err := io.Copy(os.Stdout, sourceFile)
  if err != nil {
    panic(err)
  }

  fmt.Println("Copied bytes:", bytesWritten)
}

Результат:

$ go run main.go
source
Copied bytes: 7

Або можемо створити власний буфер в пам’яті, і писати в нього, бо bytes.Buffer теж має метод Write():

$ go doc bytes.Buffer | grep "Write("
func (b *Buffer) Write(p []byte) (n int, err error)

А тому ми можемо передати його до io.Copy():

...
  buf := &bytes.Buffer{}
  io.Copy(buf, sourceFile)
  fmt.Println("Buffer content:", buf.String())
...

Результат:

$ go run main.go
Buffer content: source

Або більш цікавий приклад – з викликом одного інтерфейсу через інший.

Такий код:

...
  // create a pointer to bytes.Buffer (it implements io.Writer)
  buf := &bytes.Buffer{}

  // var resp *http.Response
  resp, _ := http.Get("https://example.com")
  defer resp.Body.Close()

  fmt.Printf("resp.Body %T\n", resp.Body)

  io.Copy(buf, resp.Body)

  // print the buffer content
  fmt.Println("Buffer content:", buf.String())
...

Результат:

$ go run main.go
resp.Body type: *http.http2gzipReader
Buffer content: <!doctype html><html lang="en"><head><title>Example Domain</title>
...

Тут:

  • http.Get() повернув *http.Response, тобто структуру Response
  • у структурі Response є поле Body з типом io.ReadCloser interface
  • інтерфейс io.ReadCloser визначає два методи – Read() і Close()
  • сервер надіслав відповідь у gzip, тому HTTP-клієнт Go автоматично обгорнув Body у декомпресор – тип *http.http2gzipReader
  • тип *http.http2gzipReader реалізує метод Read(), тому він задовольняє інтерфейс io.Reader
  • також у нього є метод Close(), тому він задовольняє і io.ReadCloser
  • оскільки resp.Body має динамічний тип *http.http2gzipReader, то виклик io.Copy(buf, resp.Body) фактично викликає (*http.http2gzipReader).Read()
  • тому io.Copy() може прийняти resp.Body як io.Reader і прочитати через gzip-декодер

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

Далі треба вчитись писати код з інтерфейсами самому.

Loading

Golang: створення OpenAI Exporter для VictoriaMetrics
0 (0)

17 Листопада 2025

Є задачка на моніторинг костів на OpenAI – бачити скільки за добу витрачено кожним проектом, і слати алерти в Slack, коли витрати завеликі.

Потикав кілька готових експортерів для OpenAI, але не побачив там метрик саме по костам, тому просто напишемо свій.

Писати будемо на Golang, ідея дуже проста – з OpenAI API отримуємо дані, генеруємо метрику, відправляємо її до VictoriaMetrics.

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

Поїхали.

OpenAI API

Документація по OpenAI API – Costs та повертаєме значення – Costs object.

Для доступу до Costs потрібен окремий ключ – робимо на platform.openai.com в Admin keys:

Для отримання Costs треба задавати параметр start_time в Unix форматі – створюємо змінну:

$ TODAY=$(date -d "$(date +%Y-%m-%d) 00:00:00" +%s)
$ echo $TODAY
1762898400

І перевіряємо доступ з curl:

$ curl -s -H "Content-Type: application/json" -H "Authorization: Bearer $OPENAI_ADMIN_KEY" "https://api.openai.com/v1/organization/costs?start_time=$TODAY"
{
  "object": "page",
  "has_more": false,
  "next_page": null,
  "data": [
    {
      "object": "bucket",
      "start_time": 1762819200,
      "end_time": 1762905600,
      "results": [
        {
          "object": "organization.costs.result",
          "amount": {
            "value": 5.65750295,
            "currency": "usd"
...

ОК, доступ працює – поїхали до Golang.

Golang gore REPL

Для швидкого тестування функцій можна встановити gore:

$ go install github.com/x-motemen/gore/cmd/gore@latest

Запускаємо (не забуваємо про $GOPATH/bin в $PATH) і перевіряємо отримання поточної дати і часу:

$ gore
gore version 0.6.1  :help for help
gore> :import time
gore> time.Now()
%!t(int64=1763025027)2025-11-13 11:10:27 Local

Ну або просто користуватись Go Playground.

Створення Golang API client

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

Що нам треба для запитів:

  • мати URL
  • мати час
  • отриманий результат поки просто виводимо на консоль

Створюємо каталог проекту, виконуємо ініціалізацію:

$ mkdir ~/Work/atlas-monitoring/exporters/openai-exporter
$ go mod init openai-exporter

Для API-клієнту можемо використати стандартну бібліотеку net/http, або більш спеціалізовані типу resty  або sling.

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

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

Вже є версія 3, але вона ще в beta, тому беремо другу.

Спробуємо з resty спочатку в gore:

gore> :import "github.com/go-resty/resty/v2"
gore> client := resty.New()
...
gore> resp, err := client.R().Get("https://httpbin.org/get")
...
gore> fmt.Println(resp, err)
...
313
nil

Для виконання API-запитів спочатку викликом методу New() створюємо об’єкт type Client struct, а далі з методом R() (request) робимо виклики.

Документація по New() тут>>>, її код тут>>>.

Не дуже зручно, що типи в Golang не описують пов’язаних методів – але їх можна побачити в go doc:

$ go doc github.com/go-resty/resty/v2.Client | grep "New()\|R()"
func New() *Client
func (c *Client) R() *Request

В розділі Usage є такий приклад:

...
resp, err := client.R().
    EnableTrace().
    Get("https://httpbin.org/get")
...

Де resty використовує method chaining, коли методи якогось типу повертають той самий тип.

Як це виглядає:

  • з функцією resty.New() ми створюємо клієнт – New() повертає *Client struct з його пов’язаними методами
  • для Client struct є метод R(), який повертає *Request struct
  • для структури Request маємо метод EnableTrace() який теж повертає Request
  • і для того ж Request маємо метод Get(), який теж повертає Request плюс error

І це дозволяє нам будувати ланцюжки запитів – Client => R() => Request => EnableTrace() => Request => Get().

Окей, давайте до коду.

Створення resty клієнта

Пишемо main.go:

package main

import (
  "fmt"

  "github.com/go-resty/resty/v2"
)

// set global const as ay be used in other packages
const (
  baseURL   = "https://api.openai.com/v1"
  costsPath = "/organization/costs"
)

func main() {
  client := resty.New()

  // build 'https://api.openai.com/v1/organization/costs'
  response, err := client.R().Get(baseURL + costsPath)
  if err != nil {
    panic(err)
  }

  fmt.Println(response)
}

Запускаємо:

$ go run main.go
{
  "error": {
    "message": "You didn't provide an API key. You need to provide your API key in an Authorization header using Bearer auth (i.e. Authorization: Bearer YOUR_KEY). You can obtain an API key from https://platform.openai.com/account/api-keys."
...

Супер, працює.

Тепер додамо отримання API ключа зі змінної.

Використовуємо os:

...
import (
  "fmt"
  "os"
...
func main() {
  client := resty.New()

  apiKey = os.Getenv("OPENAI_ADMIN_KEY")
...

Далі нам треба додати auth header до нашого запиту – використовуємо метод func (*Client) SetAuthToken, який просто додає значення до поля Token в об’єкті Client.

Ще є окремий метод func (r *Request) SetAuthToken, який задає токен на конкретні реквести, а не на весь клієнт, але в нашому випадку робимо простіше, через загальний Client.

Робимо method chaining із прикладу вище – для Client викликаємо SetAuthToken(), який задає токен, наступним викликаємо R() для створення request, і наступним викликаємо Get(), в який передаємо URL:

...
  apiKey := os.Getenv("OPENAI_ADMIN_KEY")

  // build 'https://api.openai.com/v1/organization/costs'
  response, err := client.SetAuthToken(apiKey).R().Get(baseURL + costsPath)
...

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

$ go run main.go
{
  "error": {
    "message": "Missing query parameter 'start_time'",
...

ОК – аутентифікацію ми пройшли, тепер треба додати параметри.

Тут у нас є цілих чотири варіанти:

Зараз нам треба тільки start_time, але потім будемо додавати ще, тому можна їх відразу записати в map, який потім передамо до SetQueryParams().

Для start_time нам треба передати час – робимо з time.Now(), і передавати дату до OpenAI API нам треба в Unix форматі, тому використовуємо функцію Unix().

Перевіряємо як воно буде виглядати:

gore> :import time
gore> timeNow := time.Now().Unix()
1762956432

Додаємо в код створення змінної timeNow з часом, створення setQueryParams map of strings зі списком параметрів теж в strings, і додаємо виклик SetQueryParams() до client:

...
  timeNow := time.Now().Unix()

  setQueryParams := map[string]string{
    "start_time": timeNow,
  }

  // build 'https://api.openai.com/v1/organization/costs'
  response, err := client.SetAuthToken(apiKey).
    R().SetQueryParams(setQueryParams).
    Get(baseURL + costsPath)
...

Але якщо викликати цей код зараз, то буде помилка, бо timeNow := time.Now().Unix() повертається в int64:

gore> fmt.Printf("%t", timeNow)
%!t(int64=1762957173)21
nil

А в setQueryParams() нам треба передати string, бо SetQueryParams() приймає map зі string:

func (r *Request) SetQueryParams(params map[string]string) *Request

Тому конвертуємо нашу змінну timeNow в string зі strconv.FormatInt():

gore> :import strconv
gore> s := strconv.FormatInt(timeNow, 10)
gore> fmt.Printf("%t", s)
%!t(string=1763371451)22

І можемо зробити нашу змінну timeNow так:

...
timeNow := strconv.FormatInt(time.Now().Unix(), 10)
...

Запускаємо, перевіряємо:

$ go run main.go
{
  "object": "page",
  "has_more": false,
  "next_page": null,
  "data": [
    {
      "object": "bucket",
      "start_time": 1762905600,
      "end_time": 1762992000,
      "results": [
        {
          "object": "organization.costs.result",
          "amount": {
            "value": 6.442440250000003,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": null,
          "organization_id": "org-ORG"
...

Чудово, маємо потрібні дані.

Тепер треба додати ще один параметр – group_by=project_id:

...
  setQueryParams := map[string]string{
    "start_time": timeNow,
    "group_by":   "project_id",
  }
...

І тепер в результатах маємо дані по кожному project_id:

$ go run main.go
{
  "object": "page",
  "has_more": false,
  "next_page": null,
  "data": [
    {
      "object": "bucket",
      "start_time": 1762905600,
      "end_time": 1762992000,
      "results": [
        {
          "object": "organization.costs.result",
          "amount": {
            "value": 1.76643575,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": "proj_1",
          "organization_id": "org-ORG"
        },
        {
          "object": "organization.costs.result",
          "amount": {
            "value": 0.47790999999999995,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": "proj_2",
          "organization_id": "org-ORG"
        },
...

Далі нам треба отриманий результат зберігти в якусь змінну для подальшої роботи.

resty JSON Unmarshall

resty підтримує автоматичний JSON unmarshalling через метод SetResult():

func (r *Request) SetResult(res interface{}) *Request {
  if res != nil {
    r.Result = getPointer(res)
  }
  return r
}

Він приймає аргументом тип any (interface{}), передає його до своєї функції getPointer(), де виконується перевірка – чи це тип pointer:

func getPointer(v interface{}) interface{} {
  vv := valueOf(v)
  if vv.Kind() == reflect.Ptr {
    return v
  }
  ...
}

І тоді SetResult() викликаючи parseResponseBody() записує значення з Request.Result до об’єкта, який був переданий аргументом до SetResult():

...
  // default after response middlewares
  c.afterResponse = []ResponseMiddleware{
    parseResponseBody,
    saveResponseIntoFile,
  }
...

А в функції parseResponseBody() викликається метод Unmarshalc, який в свою чергу викликає Client.JSONUnmarshal(), а поле JSONUnmarshal містить функцію json.Unmarshal():

...
func createClient(hc *http.Client) *Client {
  if hc.Transport == nil {
    hc.Transport = createTransport(nil)
  }

  c := &Client{ // not setting lang default values
                ...
    JSONUnmarshal:          json.Unmarshal,
...

Див. код resty/v2/client.go.

Отже, ми отримуємо результат в JSON, і через SetResult() можемо зберігти потрібні поля в якийсь об’єкт.

Створення Go struct для JSON Unmarshall

Давайте подумаємо над тим, як ми хочемо сформувати дані.

У нас є project_id та amount – скільки цей проект витратив, це ми отримуємо з OpenAI API /organization/costs.

У нас також є Project Names, які ми можемо отримати з /organization/projects, але про це трохи далі.

В результаті ми можемо побудувати щось таке:

[
  {
    "project_id": "Id1",
    "project_name": "Name1",
    "project_spend": 100
  },
  {
    "project_id": "Id2",
    "project_name": "Name2",
    "project_spend": 200
  }
]

Що для цього є в Go?

  • array, масив: фіксована довжина, індексований тип, всі об’єкти того самого типу – [3]int{1,2,3}
  • slice: аналогічний до array, але не фіксованої довжини – []int{1,2,3}
  • maps: набір key:value елементів змінної довжини одного типу – map[string]string{"key_name": "value_value"}
  • structs: комплексний тип, який може включати в себе інші типи – struct{ Name string; Age int }{ Name: "Nino", Age: 35 }

Так як ми знаємо, які типи ми отримуємо з API та всі поля в них – то нам підійде slice of structs, де кожен елемент slice буде структурою з полями, в яких ми будемо зберігати project_id, amount та project_name.

Структура для Project ID та Amount

Структура може виглядати так:

type ProjectSpend struct {
  ProjectID    string
  ProjectSpend int
}

А потім створимо slice з цією структурою:

data := []ProjectSpend{}

Тепер давайте подивимось на те, що нам повертає OpenAI API.

Від /organization/costs:

{
  "object": "page",
  "has_more": false,
  "next_page": null,
  "data": [
    {
      "object": "bucket",
      "start_time": 1763078400,
      "end_time": 1763164800,
      "results": [
        {
          "object": "organization.costs.result",
          "amount": {
            "value": 2.16911625,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": "proj_1",
          "organization_id": "org-ORG"
        },
        {
          "object": "organization.costs.result",
          "amount": {
            "value": 0.1846203,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": "proj_2",
          "organization_id": "org-ORG"
        },
        ...
      ]
    }
  ]
}

Тут у нас виходить така структура:

  • починається з JSON object {}
    • має кілька JSON properties –  "object": "page", etc
    • далі йде масив data []
      • який містить в собі інший object {}
        •  який починається з properties "object": "bucket", etc
        • і в якому є інший масив results []
          • який включає в себе ще один object {}
            • який починається із property "object": "organization.costs.result"
            • за яким слідує property amount, який містить в собі вкладений object {}
              • з двома property – value та value

Якщо ми хочемо це відобразити в Go struct – то нам потрібно створити кілька структур, які будуть передавати дані одна до одної:

  • перша структура “захоплює” data[]
    • друга структура – отримує results[]
      • третя – отримує значення поля project_id
        • а четверта – зчитує amount

Як це може виглядати в коді – з використання structs composition, коли одна структура містить в собі поле, яке є іншою структурою:

type ResponceAmount struct {
  Value float64
}

type ResponceProjectID struct {
  ProjectID string `json:"project_id"`
  Amount    ResponceAmount
}

type ResponseResults struct {
  Results []ResponceProjectID
}

type ResponseData struct {
  Data []ResponseResults
}

res := &ResponseData{}

І тепер можемо виконати json.Unmarshall через виклик SetResult(), в який ме передаємо pointer – res := &ResponseData{}:

...
  _, err := client.SetAuthToken(apiKey).
    R().SetQueryParams(setQueryParams).
    SetResult(res).
    Get(baseURL + costsPath)

fmt.Println("Result: ", res)
...

Отримуємо такий результат:

$ go run main.go
...
Result:  &{[{[{proj_1 {2.16911625}} {proj_Agtar0XzJdXXLhGt8YCRNZMY {0.1846203}} {proj_2 {0.1531728}} {proj_3 {0.19788874999999997}}]}]}

Або можемо зробити більш лаконічно – використовуючи nested anonymous structs:

...
  // catch data[] and pass to nested struct
  // catch results[] and pass to next nested struct
  // catch 'project_id' property to the 'ProjectID' field, and pass to next nested struct
  // catch 'amount' property to the 'Amount' field, and pass to next nested struct
  // finally, catch 'value' property to the 'Value' field
  type ResponseData struct {
    Data []struct {
      Results []struct {
        ProjectID string `json:"project_id"`
        Amount    struct {
          Value float64
        }
      }
    }
  }
...

І отримаємо той самий результат.

А далі нам потрібно буде згенерувати метрики з лейблами.

Робимо це у два цикли for, в яких перебираємо поля кожної структури:

...
  // catch each item from the 'Response.Data[]'
  for _, dataItem := range res.Data {
    // catch each iteam from the 'Response.Data[].Results[]'
    for _, result := range dataItem.Results {

      project := result.ProjectID
      amount := result.Amount.Value

      // print in VictoriaMetrics gauge format
      fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
    }
  }
...

Результат:

$ go run main.go
openai_stats{type="costs", project="proj_1"} 2.170784
openai_stats{type="costs", project="proj_2"} 0.241411
openai_stats{type="costs", project="proj_3"} 0.213558
openai_stats{type="costs", project="proj_4"} 0.198619

А тепер зробимо аналогічно, але для імен проектів, бо мати в лейблах метрик значення у вигляді “proj_123” зовсім незручно, хочеться вивести нормальні імена.

Структура для Project Names

Додаємо другий ендпоінт, див. документацію List projects:

...
const (
  baseURL      = "https://api.openai.com/v1"
  costsPath    = "/organization/costs"
  projectsPath = "/organization/projects"
)
...

А виконання запитів до OpenAI виносимо в окрему функцію:

...
func getOpenAi(client *resty.Client, path string, out any) error {
  _, err := client.R().
    SetResult(out).
    Get(path)
  return err
}
...

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

...
func main() {
  //client := resty.New()

  apiKey := os.Getenv("OPENAI_ADMIN_KEY")
  timeNow := strconv.FormatInt(time.Now().Unix(), 10)

  setQueryParams := map[string]string{
    "start_time": timeNow,
    "group_by":   "project_id",
  }

  // use pointer to ResponseData struct
  // as 'json.Unmarshal' requires a pointer to write results
  costsRes := &CostsResponseData{}

  client := resty.New().
    SetAuthToken(apiKey).
    SetQueryParams(setQueryParams)

  getOpenAi(client, baseURL+costsPath, costsRes)

  fmt.Println("Result: ", costsRes)
...

Запускаємо:

$ go run main.go
Result:  &{[{[{proj_1 {2.1707842499999996}} {proj_2 {0.24141089999999998}} {proj_3 {0.21355799999999994}} {proj_4 {0.46123659999999994}}]}]}
openai_stats{type="costs", project="proj_1"} 2.170784
openai_stats{type="costs", project="proj_2"} 0.241411
...

Тепер переходимо до отримання імен проектів.

Запит до api.openai.com/v1/organization/projects нам поверне дані в такому форматі:

{
    "object": "list",
    "data": [
        {
            "id": "proj_abc",
            "object": "organization.project",
            "name": "Project example",
            "created_at": 1711471533,
            "archived_at": null,
            "status": "active"
        }
    ],
    "first_id": "proj-abc",
    "last_id": "proj-xyz",
    "has_more": false
}

Робимо аналогічно до отримання костів – створюємо структуру:

...
type ProjectsResponse struct {
  Data []struct {
    ID   string
    Name string
  }
}
...

І в main() додаємо другий виклик getOpenAi() та обробку помилок:

...
  // use pointer to ResponseData struct
  // as 'json.Unmarshal' requires a pointer to write results
  costsRes := &CostsResponseData{}
  if err := getOpenAi(client, baseURL+costsPath, costsRes); err != nil {
    panic(err)
  }

  projectsRes := &ProjectsResponse{}
  if err := getOpenAi(client, baseURL+projectsPath, projectsRes); err != nil {
    panic(err)
  }

  fmt.Println("Costs Result: ", costsRes)
  fmt.Println("Projects Result: ", projectsRes)
...

Маємо такий результат:

$ go run main.go
Costs Result:  &{[{[{proj_1 {2.1707842499999996}} {proj_2 {0.24141089999999998}} {proj_3 {0.21355799999999994}} {proj_4 {0.46123659999999994}}]}]}
Projects Result:  &{[{proj_1 Default project} {proj_2 Assistant Test/Eval} {proj_3 Kraken Production} {proj_4 Knowledge Base}]}
...

sanitize імен – форматування даних зі strings.Replace()

Але в іменах у нас є пробіли та символи “/”, і імена проектів містять заглавні букви – а нам в лейблах метрик треба мати вид “my_project_name“.

Додамо функцію, яка буде виконувати нормалізацію використовуючи методи ToLower() та ReplaceAll() із пакету strings:

...
func normalizeLabel(s string) string {
  s = strings.ToLower(s)
  s = strings.ReplaceAll(s, " ", "_")
  s = strings.ReplaceAll(s, "/", "_")
  return s
}
...

Наступний крок – побудувати map, в якій ми будемо мати project_id та project_names:

...
  projectNames := make(map[string]string)

  // get each 'ProjectsResponse.Data[].ID'
  // get each 'ProjectsResponse.Data[].Name'
  // populate the projectNames map with:
  // 'project_id' = 'project_name'
  for _, p := range projectsRes.Data {
    projectNames[p.ID] = normalizeLabel(p.Name)
  }

  fmt.Println("Projects Names: ", projectNames)
...

В результаті маємо:

$ go run main.go
Projects Names:  map[proj_1:kraken_production proj_2:assistant_test_eval proj_3:knowledge_base proj_4:default_project]

І тепер оновлюємо наші два цикли – використовуємо в лейблі імена замість ID:

...
  // catch each item from the 'Response.Data[]'
  for _, dataItem := range costsRes.Data {
    // catch each item from the 'Response.Data[].Results[]'
    for _, result := range dataItem.Results {

      // get ''Response.Data[].Results[].ProjectID'
      id := result.ProjectID

      // get ''Response.Data[].Results[].Amount.Value'
      amount := result.Amount.Value

      // use the 'id' to get the project name from the projectNames map
      project := projectNames[id]
      if project == "" {
        project = "unknown"
      }

      // print in VictoriaMetrics gauge format
      fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
    }
  }
...

І результат:

$ go run main.go
openai_stats{type="costs", project="knowledge_base"} 2.170784
openai_stats{type="costs", project="kraken_production"} 0.241411
openai_stats{type="costs", project="assistant_test_eval"} 1.083077
openai_stats{type="costs", project="default_project"} 0.461237

Зараз весь код у нас такий:

package main

import (
  "fmt"
  "os"
  "strconv"
  "strings"
  "time"

  "github.com/go-resty/resty/v2"
)

// set global const as ay be used in other packages
const (
  baseURL      = "https://api.openai.com/v1"
  costsPath    = "/organization/costs"
  projectsPath = "/organization/projects"
)

// catch data[] and pass to nested struct
// catch results[] and pass to next nested struct
// catch 'project_id' property to the 'ProjectID' field, and pass to next nested struct
// catch 'amount' property to the 'Amount' field, and pass to next nested struct
// finally, catch 'value' property to the 'Value' field
type CostsResponseData struct {
  Data []struct {
    Results []struct {
      ProjectID string `json:"project_id"`
      Amount    struct {
        Value float64
      }
    }
  }
}

type ProjectsResponse struct {
  Data []struct {
    ID   string
    Name string
  }
}

func getOpenAi(client *resty.Client, path string, out any) error {
  _, err := client.R().
    SetResult(out).
    Get(path)
  return err
}

func normalizeLabel(s string) string {
  s = strings.ToLower(s)
  s = strings.ReplaceAll(s, " ", "_")
  s = strings.ReplaceAll(s, "/", "_")
  return s
}

func main() {
  //client := resty.New()

  apiKey := os.Getenv("OPENAI_ADMIN_KEY")
  timeNow := strconv.FormatInt(time.Now().Unix(), 10)

  setQueryParams := map[string]string{
    "start_time": timeNow,
    "group_by":   "project_id",
  }

  client := resty.New().
    SetAuthToken(apiKey).
    SetQueryParams(setQueryParams)

  // use pointer to ResponseData struct
  // as 'json.Unmarshal' requires a pointer to write results
  costsRes := &CostsResponseData{}
  if err := getOpenAi(client, baseURL+costsPath, costsRes); err != nil {
    panic(err)
  }

  projectsRes := &ProjectsResponse{}
  if err := getOpenAi(client, baseURL+projectsPath, projectsRes); err != nil {
    panic(err)
  }

  projectNames := make(map[string]string)

  // get each 'ProjectsResponse.Data[].ID'
  // get each 'ProjectsResponse.Data[].Name'
  // populate the projectNames map with:
  // 'project_id' = 'project_name'
  for _, p := range projectsRes.Data {
    projectNames[p.ID] = normalizeLabel(p.Name)
  }


  // catch each item from the 'Response.Data[]'
  for _, dataItem := range costsRes.Data {
    // catch each item from the 'Response.Data[].Results[]'
    for _, result := range dataItem.Results {

      // get ''Response.Data[].Results[].ProjectID'
      id := result.ProjectID

      // get ''Response.Data[].Results[].Amount.Value'
      amount := result.Amount.Value

      // use the 'id' to get the project name from the projectNames map
      project := projectNames[id]
      if project == "" {
        project = "unknown"
      }

      // print in VictoriaMetrics gauge format
      fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
    }
  }
}

Тепер можемо переходити до формування реальних метрик та записати їх до VictoriaMetrics.

Планування метрик для VictoriaMetrics

Отже, метрики у нас будуть у вигляді openai_stats{type="costs", project="prodject_id"} 5.55.

А що сказано в задачі, що треба в результаті?

якщо денний спендінг на опенаі перевищує середній за останні дні (з певним трешолдом) – кричати в слак

Значить, нам потрібна буде сума за добу, і маючи її, ми можемо робити порівняння з попередніми періодами часу.

А що нам повертається в API?

Дивимось Costs object:

The aggregated costs details of the specific time bucket.

А що ми маємо, коли робимо запит тільки зі start_time без end_time?

Дивимось час в отриманому response:

...
      "start_time": 1762905600,
      "end_time": 1762992000,
...

Тут start_time буде:

$ date -d @1762905600
Wed Nov 12 02:00:00 EET 2025

А end_time:

$ date -d @1762992000
Thu Nov 13 02:00:00 EET 2025

Це 00:00 в UTC.

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

Отже тут:

...
      "start_time": 1762905600,
      "end_time": 1762992000,
      ...
            "value": 1.76643575,
            "currency": "usd"
          },
          "line_item": null,
          "project_id": "proj_1",
...

Бачимо, що за сьогодні проект з ID “proj_1” витратив 1.76643575 бакси.

Окей…

Як ми можемо це зберігати в метрику? Зробити тип Counter, який постійно збільшується, і кожну годину його оновлювати?

Тоді тайм-серія (див. Що таке Time Series?) по цій метриці буде виглядати якось так:

openai_stats{type="costs", project="prodject_id"}
  1762960223 1.76
  1762960237 1.80
  1762960249 1.95

І потім можемо для алерту створити запит на кшталт такого:

if 
avg_over_time(openai_stats{type="costs", project="prodject_id"}[1d)
> 
avg_over_time(openai_stats{type="costs", project="prodject_id"}[3d)
then send alert

Але з Counter є нюанс – він обнуляється, якщо експортер перезапуститься – див. counter reset.

Крім того, якщо ми отримуємо дані починаючи з 00:00 – то з наступного дня значення буде починатись з 0,00 USD.

А значить, у нас значення в метриці може і збільшуватись, і зменшуватись, а значить – нам потрібен не Counter, а Gauge.

VictoriaMetrics Go client

Є бібліотека для Prometheus, але так як у нас VictoriaMetrics – то беремо їхній пакет, який до того ж має функцію PushMetrics(), з якою ми можемо відразу пушити метрики до VictoriaMetrics.

Дивимось документацію по type Gauge, там є приклад створення об’єкта метрики.

Функція NewGauge() приймає два аргументи – ім’я метрики з лейблами та функцію, яка виконує оновлення значення для цієї метрики, див. gauge.go:

func NewGauge(name string, f func() float64) *Gauge {
  return defaultSet.NewGauge(name, f)
}

Але якщо ми хочемо задавати дані самі – то замість передачі другого аргументу f func() – можемо передати просто nil, а потім використати метод Set().

Пробуємо, як це працює з nil та Set():

gore> :import "github.com/VictoriaMetrics/metrics"
gore> g := metrics.NewGauge(`test_gauge`, nil)
gore> g.Set(9.00)
gore> :import fmt
gore> fmt.Println(g.Get())
9

Супер.

Тепер думає як ми будемо все це діло робити.

Нам потрібно:

  • створити metrics.NewGauge() на кожну метрику
  • потім в циклі раз на годину отримувати дані з API
  • для кожної метрики виконувати Set()

Тобто метрики генеруємо кожну зі своїм значенням лейбли project:

project_1 := metrics.NewGauge(openai_stats{type="costs", project="prodject_1"}) 
project_2 := metrics.NewGauge(openai_stats{type="costs", project="prodject_2"}) 
project_3 := metrics.NewGauge(openai_stats{type="costs", project="prodject_3"})

А потім для кожного project_N виконуємо Set().

У нас зараз є цикл, який заповнює fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount).

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

Для виводу на консоль використовуємо функцію metrics.WritePrometheus(), яка пише в Prometheus-форматі в канал, який задається першим аргументом.

Після циклів додаємо:

...
      // print in VictoriaMetrics gauge format
      //fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
      metricName := fmt.Sprintf(`test_openai_stats{type="costs", project="%s"}`, project)
      gauge := metrics.NewGauge(metricName, nil)
      gauge.Set(amount)

    }
  }

  metrics.WritePrometheus(os.Stdout, false)
...

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

$ go run main.go
test_openai_stats{type="costs", project="assistant_test_eval"} 4.9838991
test_openai_stats{type="costs", project="default_project"} 0.5281144000000001
test_openai_stats{type="costs", project="knowledge_base"} 2.17244425
test_openai_stats{type="costs", project="kraken_production"} 0.5510669499999999

Супер.

А тепер подумаємо над всією логікою виконання.

Що у нас є зараз:

  1. створення resty.Client
  2. ініціалізація структури costsRes := &CostsResponseData{}
  3. виклик getOpenAi() з аргументами (client, baseURL+costsPath, costsRes), де ми заповнюємо дані в структурі CostsResponseData
  4. ініціалізація projectsRes := &ProjectsResponse{}
  5. виклик getOpenAi() з аргументами (client, baseURL+projectsPath, projectsRes), де ми заповнюємо дані в структурі ProjectsResponse
  6. ініціалізація мапи projectNames
  7. заповнення її з даними "project_id": "project_name"
  8. далі цикли, в яких:
    1. отримуємо project_id
    2. отримуємо amount
    3. по project_id отримуємо ім’я проекту, записуємо в змінну project
    4. генеруємо ім’я метрики і лейблу з project в metricName
    5. з metrics.NewGauge генеруємо нову метрику
    6. з gauge.Set(amount) записуємо в неї значення
  9. з metrics.WritePrometheus() всі згенеровані метрики виводимо на консоль

І все це зараз виконується при виклику main().

Натомість нам при виклику main(), тобто при старті експортера, треба:

  1. створити resty.Client
  2.  далі періодично виконувати оновлення даних та записувати дані до VictoriaMetrics:
    1. з getOpenAi() заповнити структуру ProjectsResponse
    2. з getOpenAi() заповнити структуру CostsResponseData
    3. заповнити projectNames
    4. запустити цикли для генерації метрик і виконання Set()
    5. в кінці циклу виконати WritePrometheus()

Правда, при такому підході ми кожну годину будемо перезаписувати поля в ProjectsResponse, CostsResponseData та projectNames, що наче не дуже ОК з точки зору перформансу – але якщо у нас з’явиться новий проект, то ми його відразу “спіймаємо”, і додамо нову метрику для нього.

Отже, що треба зробити – це винести нашу логіку в окрему функцію, раз на годину викликати її, а потім виконувати WritePrometheus().

Пишемо цю функцію, тільки  міняємо NewGauge() на GetOrCreateGauge(), бо при наступному виклику нашої функції метрики вже будуть створені:

...
func fetchAndPush(client *resty.Client, costsRes *CostsResponseData, projectsRes *ProjectsResponse, projectNames map[string]string) {
  if err := getOpenAi(client, baseURL+costsPath, costsRes); err != nil {
    panic(err)
  }
  if err := getOpenAi(client, baseURL+projectsPath, projectsRes); err != nil {
    panic(err)
  }
  // get each 'ProjectsResponse.Data[].ID'
  // get each 'ProjectsResponse.Data[].Name'
  // populate the projectNames map with:
  // 'project_id' = 'project_name'
  for _, p := range projectsRes.Data {
    projectNames[p.ID] = normalizeLabel(p.Name)
  }

  // catch each item from the 'Response.Data[]'
  for _, dataItem := range costsRes.Data {
    // catch each item from the 'Response.Data[].Results[]'
    for _, result := range dataItem.Results {

      // get 'Response.Data[].Results[].ProjectID'
      // i.e. 'proj_123'
      id := result.ProjectID

      // get 'Response.Data[].Results[].Amount.Value'
      amount := result.Amount.Value

      // use the 'id' to get the project name from the projectNames map
      project := projectNames[id]
      if project == "" {
        project = "unknown"
      }

      // print in VictoriaMetrics gauge format
      //fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
      metricName := fmt.Sprintf(`test_openai_stats{type="costs", project="%s"}`, project)
      gauge := metrics.GetOrCreateGauge(metricName, nil)
      gauge.Set(amount)

    }
  }

  metrics.WritePrometheus(os.Stdout, false)
}
...

Тепер в main() у нас залишається:

...
func main() {
  //client := resty.New()

  apiKey := os.Getenv("OPENAI_ADMIN_KEY")
  timeNow := strconv.FormatInt(time.Now().Unix(), 10)

  setQueryParams := map[string]string{
    "start_time": timeNow,
    "group_by":   "project_id",
  }

  client := resty.New().
    SetAuthToken(apiKey).
    SetQueryParams(setQueryParams)

  // use pointer to ResponseData struct
  // as 'json.Unmarshal' requires a pointer to write results
  costsRes := &CostsResponseData{}

  projectsRes := &ProjectsResponse{}

  // will be populated with key:value pairs:
  // 'proj_123' = 'kraken_production'
  projectNames := make(map[string]string)

  fetchAndPush(client, costsRes, projectsRes, projectNames)
}

Запускаємо для перевірки:

$ go run main.go
test_openai_stats{type="costs", project="assistant_test_eval"} 6.3417053
test_openai_stats{type="costs", project="default_project"} 0.6592560500000001
test_openai_stats{type="costs", project="knowledge_base"} 2.17244425
test_openai_stats{type="costs", project="kraken_production"} 0.6170747

Тепер нам треба замість простого виводу на консоль записати дані до VictoriaMetrics.

Запис метрик до VictoriaMetrics з InitPush() та PushMetrics()

Для запису метрик до VictoriaMetrics маємо дві основні функції – InitPush() та PushMetrics().

Функція InitPush()

Функція InitPush() дозволяє виконувати періодичні записи із заданим interval, а PushMetrics() – просто разово записати всі метрики, які є в Set struct. Про Set трохи далі.

Тепер просто інтересу заради розберемо, як саме VictoriaMetrics клієнт виконує запис.

Знаходимо код InitPush():

func InitPush(pushURL string, interval time.Duration, extraLabels string, pushProcessMetrics bool) error {
  writeMetrics := func(w io.Writer) {
    WritePrometheus(w, pushProcessMetrics)
  }
  return InitPushExt(pushURL, interval, extraLabels, writeMetrics)
}

Тут:

  • ми в нашому коді викликаємо InitPush(), передаємо до цієї функції URL та інтервал
  • InitPush() створює змінну writeMetrics – анонімну функцію, яка приймає аргумент типу io.Writer, і яка потім буде викликати функцію WritePrometheus(), в яку передається цей io.Writer
  • далі викликається функція InitPushExt(), якій передається pushURL, interval, та об’єкт writeMetrics

Дивимось на InitPushExt():

func InitPushExt(pushURL string, interval time.Duration, extraLabels string, writeMetrics func(w io.Writer)) error {
  opts := &PushOptions{
    ExtraLabels: extraLabels,
  }
  return InitPushExtWithOptions(context.Background(), pushURL, interval, writeMetrics, opts)
}

Тут просто додаються параметри зі структури PushOptions, в яку можемо передати параметри типу extraLabels, і потім викликається InitPushExtWithOptions(), в яку передається наш writeMetrics.

Дивимось InitPushExtWithOptions(): тут створюється goroutine, яка із заданим interval викликає pushMetrics(), в яку передається наш об’єкт writeMetrics (тобто та анонімна функція, яка буде викликати WritePrometheus()):

func InitPushExtWithOptions(ctx context.Context, pushURL string, interval time.Duration, writeMetrics func(w io.Writer), opts *PushOptions) error {
  pc, err := newPushContext(pushURL, opts)
        ...
  go func() {
    ticker := time.NewTicker(interval)
                ...
        ctxLocal, cancel := context.WithTimeout(ctx, interval+time.Second)
        err := pc.pushMetrics(ctxLocal, writeMetrics)

В свою чергу pushMetrics() створює буфер bytes.Buffer, передає його до writeMetrics(), writeMetrics() викликає WritePrometheus(), яка отримує цей буфер:

func (pc *pushContext) pushMetrics(ctx context.Context, writeMetrics func(w io.Writer)) error {
  bb := getBytesBuffer()
  defer putBytesBuffer(bb)

  writeMetrics(bb)
...

І потім WritePrometheus() записує зібрані метрики в цей буфер:

// WritePrometheus writes all the metrics from s to w in Prometheus format.
func (s *Set) WritePrometheus(w io.Writer) {
...

А далі з цього буферу (все ще в pushMetrics() створюється request body, задаються headers:

І потім виконується відправка даних до переданого URL:

 

Тепер повернемось до “WritePrometheus() записує зібрані метрики в цей буфер“.

WritePrometheus() – це метод структури Set:

func (s *Set) WritePrometheus(w io.Writer) {
  ...
}

А Set створюється, коли ми викликаємо NetGauge():

func NewGauge(name string, f func() float64) *Gauge {
  return defaultSet.NewGauge(name, f)
}

defaultSet – це виклик NewSet():

var defaultSet = NewSet()

А NewSet() заповнює структуру Set:

// NewSet creates new set of metrics.
//
// Pass the set to RegisterSet() function in order to export its metrics via global WritePrometheus() call.
func NewSet() *Set {
  return &Set{
    m: make(map[string]*namedMetric),
  }
}

Тобто, при виклику NetGauge() ми передаємо аргумент з іменем метрики, NetGauge() викликає NewSet(), передає цю метрику, а NewSet() виконує ініціалізацію структури Set, в поле namedMetric задаючи нашу метрику.

Функція PushMetrics()

Ну а з PushMetrics() все майже аналогічно – створюється writeMetrics, викликається PushMetricsExt():

func PushMetrics(ctx context.Context, pushURL string, pushProcessMetrics bool, opts *PushOptions) error {
  writeMetrics := func(w io.Writer) {
    WritePrometheus(w, pushProcessMetrics)
  }
  return PushMetricsExt(ctx, pushURL, writeMetrics, opts)
}

А PushMetricsExt() викликає pushMetrics(), але тільки один раз, а не в циклі:

func PushMetricsExt(ctx context.Context, pushURL string, writeMetrics func(w io.Writer), opts *PushOptions) error {
  pc, err := newPushContext(pushURL, opts)
  if err != nil {
    return err
  }
  return pc.pushMetrics(ctx, writeMetrics)
}

Окей – повертаємось до нашого коду.

Отже, що нам треба зробити зараз – це замість WritePrometheus() викликати PushMetrics().

Створення context та виклик PushMetrics()

Для PushMetrics() потрібно передати context, який керує goroutines і завершує їх або по таймауту, або якщо сама програма отримала від системи сигнали SIGTERM чи SIGKILL.

Детальніше про context трохи далі, поки просто додаємо import "context", в main() створюємо пустий контекст з Background():

...
import (
  "context"
...
func main() {
        ...
  // will be populated with key:value pairs:
  // 'proj_123' = 'kraken_production'
  projectNames := make(map[string]string)

  ctx := context.Background()
...

В нашій функції fetchAndPush() додаємо параметр з типом context.Context:

...
func fetchAndPush(ctx context.Context, ...) {
   ...
}

Додаємо передачу контексту до виклику fetchAndPush():

...
       fetchAndPush(ctx, client, costsRes, projectsRes, projectNames)
...

Задаємо змінну з URL VictoriaMetrics, міняємо metrics.WritePrometheus() на metrics.PushMetrics(), до якої передаємо отриманий з main() context:

...

  //metrics.WritePrometheus(os.Stdout, false)
  pushURL := "http://localhost:8428/api/v1/import/prometheus"

  if err := metrics.PushMetrics(ctx, pushURL, false, nil); err != nil {
    panic(err)
  }
}

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

gocron – запуск задач за розкладом

Є приємний пакет gocron, додаємо його, поки тестуємо – ставимо запуск кожну хвилину:

import (
        ...
  "github.com/go-co-op/gocron"
        ...
)

...
func main() {
  s := gocron.NewScheduler(time.Local)

  s.Every(1).Minute().Do(func() {
    fetchAndPush(client, costsRes, projectsRes, projectNames)
  })

  s.StartBlocking()
}

Потім можна переробити на виклик раз на годину – s.Every(1).Hour().Do( ... ), або на початку кожної години – s.Cron("0 * * * *").Do( ... ).

І в кінці запускаємо крон зі StartBlocking(), який блокує завершення самої функції main().

Відкриваємо доступ до VictoriaMetrics в Kubernetes:

$ kk -n ops-monitoring-ns port-forward svc/vmsingle-vm-k8s-stack 8428

Запускаємо наш експортер:

$ go run main.go
test_openai_stats{type="costs", project="assistant_test_eval"} 6.501765299999999
test_openai_stats{type="costs", project="default_project"} 0.6592560500000001
test_openai_stats{type="costs", project="knowledge_base"} 2.17411225
test_openai_stats{type="costs", project="kraken_production"} 0.6471627999999999
^Csignal: interrupt

І перевіряємо дані вже у VictoriaMetrics:

Правда, тут з’явився якийсь “unknown” проект, треба буде додати логування.

Що ще треба поправити:

  • зараз ініціалізація структур CostsResponseData та ProjectsResponse виконується в main(), і потім при кожному виклику fetchAndPush() в них записуються дані
    • якщо проект видалиться з OpenAI – він залишиться в структурах, і ми будемо продовжувати писати метрики для проекту, якого вже нема
    • треба винести в саму fetchAndPush() і просто кожного разу заповнювати їх з нуля
  • аналогічно з projectNames – перенести ініціалізацію в саму fetchAndPush()
  • SetQueryParams – зараз передається однаково для обох викликів getOpenAi(), але в /organization/projects нема параметра group_by
  • в метриці лейблу type="" краще замінити на category=""
  • додати external lablels – щось типу “job="openai-exporter"
  • замість використання panic(err) – записувати в лог, повертати помилку до викликаючої функції і обробляти там
  • додати коректну обробку сигналів SIGTERM та SIGINT
  • resty.client вміє виконувати retry при помилках, треба додати SetRetryCount() і SetRetryWaitTime()
  • ну і додати логи виконання і помилок

Створення Golang context

Під час роботи в нашому коді запускається кілька одночасних операцій – з gocron.NewScheduler() ми запускаємо виконання нашої функції fetchAndPush(), в ній у нас запускаються HTTP-запити з resty.Client.Get(), у VictoriaMetrics запускаються виклики для запису до VictoriaMetrics endpoint.

Аби все це діло коректно завершити, а не просто “вбити” під час отримання SIGINT або SIGTERM – Go дозволяє нам керувати процесом завершення наших функцій і goroutines через context виконання.

Інший приклад, коли нам треба керувати виконанням операції – це задати ліміт на час виконання, як це, наприклад, зроблено в VictoriaMetrics у функції InitPushExtWithOptions():

...
  go func() {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
    stopCh := ctx.Done()
    for {
      select {
      case <-ticker.C:
        ctxLocal, cancel := context.WithTimeout(ctx, interval+time.Second)
        err := pc.pushMetrics(ctxLocal, writeMetrics)
...

Тут виконання pc.pushMetrics() обмежено interval, який передається при виклику InitPush().

При цьому context виконання включає в себе не тільки обробку сигналів і керування життєвим циклом функцій і goroutine, но і всю пов’язану з цим виконанням інформацію:

Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes

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

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

  • створити context
  • створити “перехоплювач сигналів” SIGINT (Ctrl+C) та SIGTERM (сигнал від операційної системи, коли виконання програми завершується, наприклад – коли kubelet зупиняє контейнер)
  • відправити сигнал зупинки всім дочірнім функціям і goroutines
  • завершити виконання main()

Для цього замість виклику context.Background() в main() використовуємо signal.NotifyContext(), який отримує потрібні системні виклики і відправляє сигнал про зупинку всім пов’язаним задачам (як саме – див. далі у Bonus: як працює контроль виконання в context):

...
  rootCtx, rootCancel := signal.NotifyContext(
    context.Background(),
    os.Interrupt,
    syscall.SIGTERM,
  )
  defer rootCancel()
...

Далі описуємо запуск gocron.NewScheduler(), а в кінці main() запускаємо створення та читання з каналу:

...
  // block until Ctrl+C cancels rootCtx
  <-rootCtx.Done()
}

Як тільки NotifyContext() отримає SIGTERM – він закриє канал rootCtx.Done(), після чого каскадно закриються канали всіх дочірніх контекстів, потім всі дочірні goroutines, що слухають ці контексти, завершать роботу, і main() зможе коректно завершитись.

resty.client теж вміє працювати з context через SetContext(), йому передаємо наш rootCtx при виклику if err := getOpenAI(ctx, ... ) {...}.

Фінальний результат

Після всіх правок весь код експортеру тепер виглядає так:

package main

import (
  "context"
  "fmt"
  "log"
  "os"
  "os/signal"
  "strconv"
  "strings"
  "syscall"
  "time"

  "github.com/VictoriaMetrics/metrics"
  "github.com/go-co-op/gocron"
  "github.com/go-resty/resty/v2"
)

const (
  // base URL of the OpenAI Admin API
  baseURL = "https://api.openai.com/v1"

  // endpoints that we call
  costsPath    = "/organization/costs"
  projectsPath = "/organization/projects"

  // VictoriaMetrics push endpoint (Prometheus remote write format)
  pushURL = "http://localhost:8428/api/v1/import/prometheus"
)

// structure describing the JSON for costs API
// resty will unmarshal into this struct automatically
type CostsResponseData struct {
  Data []struct {
    Results []struct {
      ProjectID string `json:"project_id"`
      Amount    struct {
        Value float64 `json:"value"`
      } `json:"amount"`
    } `json:"results"`
  } `json:"data"`
}

// structure describing the JSON for projects API
// used to map project_id → readable project name
type ProjectsResponse struct {
  Data []struct {
    ID   string `json:"id"`
    Name string `json:"name"`
  } `json:"data"`
}

// normalizeLabel converts a project name into a Prometheus-safe label
// - lowercases
// - replaces spaces with underscores
// - replaces slashes to avoid label parser issues
func normalizeLabel(s string) string {
  s = strings.ToLower(s)
  s = strings.ReplaceAll(s, " ", "_")
  s = strings.ReplaceAll(s, "/", "_")
  return s
}

// getOpenAI performs a GET request to the OpenAI Admin API
// and unmarshals the returned JSON into the 'out' structure.
//
// ctx: allows cancellation (we pass rootCtx so Ctrl+C cancels requests)
// client: the resty client with authentication
// path: "/organization/costs" or "/organization/projects"
// params: optional query parameters
func getOpenAI(ctx context.Context, client *resty.Client, path string, params map[string]string, out any) error {
  // create HTTP request object
  req := client.R().
    SetContext(ctx). // attach context so cancellation works
    SetResult(out)   // register target structure for unmarshalling JSON

  // set optional query parameters
  if params != nil {
    req.SetQueryParams(params)
  }

  // execute HTTP GET request
  if _, err := req.Get(baseURL + path); err != nil {
    return fmt.Errorf("request to %s failed: %w", path, err)
  }

  return nil
}

// fetchAndPush performs one exporter cycle:
//
// 1. fetch costs grouped by project_id
// 2. fetch readable project names
// 3. build project_id → normalized_name map
// 4. create/update Prometheus gauges
// 5. push all metrics to VictoriaMetrics
//
// ctx: the root context (cancelled when Ctrl+C is pressed)
func fetchAndPush(ctx context.Context, client *resty.Client) error {
  // create fresh response holders for every iteration
  costsRes := &CostsResponseData{}
  projectsRes := &ProjectsResponse{}
  projectNames := make(map[string]string)

  // build query parameters for costs API
  // start_time: current timestamp (Unix)
  // group_by: instruct API to group costs per project_id
  timeNow := strconv.FormatInt(time.Now().Unix(), 10)
  costParams := map[string]string{
    "start_time": timeNow,
    "group_by":   "project_id",
  }

  // fetch costs data
  if err := getOpenAI(ctx, client, costsPath, costParams, costsRes); err != nil {
    return fmt.Errorf("fetch costs: %w", err)
  }

  // fetch project definitions
  if err := getOpenAI(ctx, client, projectsPath, nil, projectsRes); err != nil {
    return fmt.Errorf("fetch projects: %w", err)
  }

  // fill map: project_id → normalized_label
  for _, p := range projectsRes.Data {
    projectNames[p.ID] = normalizeLabel(p.Name)
  }

  // process returned costs
  for _, dataItem := range costsRes.Data {
    for _, result := range dataItem.Results {
      id := result.ProjectID
      amount := result.Amount.Value

      // resolve project readable name
      project := projectNames[id]
      if project == "" {
        project = "unknown"
      }

      metricName := fmt.Sprintf(
        `openai_stats{project="%s",category="costs"}`,
        project,
      )

      // get or create gauge
      gauge := metrics.GetOrCreateGauge(metricName, nil)

      // update gauge value
      gauge.Set(amount)

      // log written metric
      log.Printf("metric updated: name=%s value=%f", metricName, amount)
    }
  }

  // push metrics with job="openai_exporter"
  pushOpts := &metrics.PushOptions{
    ExtraLabels: `job="openai_exporter"`,
  }

  // push all collected metrics
  if err := metrics.PushMetrics(ctx, pushURL, false, pushOpts); err != nil {
    return fmt.Errorf("push metrics: %w", err)
  }

  return nil
}

func main() {
  // create a context that automatically cancels on OS signals (Ctrl+C, kill, SIGTERM)
  //
  // how it works:
  // - signal.NotifyContext wraps the parent context and subscribes it to OS signals
  // - when the program receives Ctrl+C (SIGINT) or SIGTERM:
  //       Go internally calls rootCancel()
  //       the context's Done() channel is closed
  // - all goroutines waiting on <-rootCtx.Done() are instantly unblocked
  // - any operation bound to this context (HTTP requests, timeouts, jobs)
  //       receives ctx.Err()==context.Canceled and stops gracefully
  //
  // practically:
  // - main goroutine waits for <-rootCtx.Done()
  // - when Ctrl+C arrives => rootCtx.Done() closes => program starts graceful shutdown
  //
  // 'defer rootCancel()' is used to clean up internal signal resources when main() exits normally
  rootCtx, rootCancel := signal.NotifyContext(
    context.Background(),
    os.Interrupt,
    syscall.SIGTERM,
  )
  defer rootCancel()

  // load OpenAI admin API key
  apiKey := os.Getenv("OPENAI_ADMIN_KEY")
  if apiKey == "" {
    log.Fatal("OPENAI_ADMIN_KEY is not set")
  }

  // create resty client with:
  // - bearer token
  // - automatic retries (3 attempts)
  client := resty.New().
    SetAuthToken(apiKey).
    SetRetryCount(3).
    SetRetryWaitTime(2 * time.Second)

  // create scheduler using local timezone
  s := gocron.NewScheduler(time.Local)

  // register a job that runs every 1 minute
  s.Every(1).Minute().Do(func() {
    start := time.Now()

    log.Println("starting fetch-and-push cycle")

    // run our exporter cycle
    if err := fetchAndPush(rootCtx, client); err != nil {
      log.Println("ERROR during fetchAndPush:", err)
      return
    }

    log.Println("fetch-and-push completed in", time.Since(start))
  })

  log.Println("starting scheduler...")

  // run scheduler in background goroutine
  s.StartAsync()

  // block until Ctrl+C cancels rootCtx
  <-rootCtx.Done()

  log.Println("received Ctrl+C, stopping scheduler...")

  // shutdown scheduler gracefully
  s.Stop()

  log.Println("scheduler stopped, exiting")
}

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

І порівняємо з даними в самому OpenAI на сторінці platform.openai.com/settings/organization/usage:

Ті самі 6.95 долари, що ми бачимо у VictoriaMetrics від нашого експортеру.

Можна б ще покращити код, наприклад розбити велику функцію fetchAndPush(), і треба додати передачу URL до VictoriaMetrics зі змінних оточення, але поки поживемо з таким варіантом.

Bonus: як працює контроль виконання через Golang context

Ми в нашій функції fetchAndPush() використовуємо metrics.PushMetrics(), передавши йому контекст.

Але для кращої картини – давайте знову повернемося до InitPush(), бо там використання context більш явне.

Отже, InitPush() викликає InitPushExt(), а InitPushExt() викликає InitPushExtWithOptions(), якому передає пустий context.Background()return InitPushExtWithOptions(context.Background() ...).

В InitPushExtWithOptions() запускається goroutine, go func() {}, в якій створюється локальний context :

...
        ctxLocal, cancel := context.WithTimeout(ctx, interval+time.Second)
        err := pc.pushMetrics(ctxLocal, writeMetrics)
...

Закриття каналу з cancel()

При виклику функції context.WithTimeout() відбувається наступний процес:

  • WithTimeout() викликає WithDeadline()
  • WithDeadline() викликає WithDeadlineCause():
    • де створюється об’єкт структури c := &timerCtx{}
      • структура timerCtx містить в собі embedding структуру cancelCtx
      • таким чином timerCtx тепер має доступ до всіх методів структури cancelCtx
    • далі WithDeadlineCause() перевіряє умову if dur <= 0 і, і якщо час виконання завершився, то:
      • викликає c.cancel(true, DeadlineExceeded, cause)
      • повертає “return c, func() { c.cancel(false, Canceled, nil) }“, яка повертається до InitPushExtWithOptions() в частині ctxLocal, cancel := context.WithTimeout() і "func() { c.cancel() }" і стає cancel()
        • c.cancel() – це метод структури timerCtxfunc (c *timerCtx) cancel(), який викликає c.cancelCtx.cancel()
        • а c.cancelCtx.cancel() – це метод структури cancelCtxfunc (c *cancelCtx) cancel(), який викликає d, _ := c.done.Load().(chan struct{})
          • і викликає close(d)

Ось тут:

func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
...
  d, _ := c.done.Load().(chan struct{})
  if d == nil {
    ...
  } else {
    close(d)
  }
...

c.done.Load() – викликається з поля done структури cancelCtx:

type cancelCtx struct {
        ...
  done     atomic.Value          // of chan struct{}, created lazily, closed by first cancel call
        ...
}

Де Load() це метод структури Value:

func (v *Value) Load() (val any)

Тобто, в d, _ := c.done.Load().(chan struct{}) викликається Load(), в (chan struct{}) виконується type assertion, тобто перевіряється, що це тип chan struct{}, після чого d стає chan struct{}, після чого виконується close(channel).

А close() – це вбудована функція Go, яка закриває отриманий аргументом канал.

Як тільки канал Done() закривається – всі goroutines, які виконують <-ctx.Done(), миттєво пробуджуються і можуть коректно завершити свою роботу.

В InitPushExtWithOptions() це виконується тут:

go func() {
    ...
    stopCh := ctx.Done()
    ...
    case <-stopCh:
                              ...
      return
    }
  }
}()

Закриття каналу – це читання нульового значення, що призводить до спрацювання умови case => що призводить до завершення циклу через виклик return => що призводить до завершення всієї go func() {}.

Окей.

А звідки канал взявся?

Відкриття каналу

Для того, аби функція чи рутина постійно “слухали” цей канал в очікуванні його закриття – ми викликаємо Done():

...
  go func() {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
    stopCh := ctx.Done()
      ...
      case <-stopCh:
        if wg != nil {
          wg.Done()
        }
        return
      }
...

А ctx.Done() – це метод інтерфейсу Context interface:

type Context interface {
...
  Done() <-chan struct{}
}

В якій і створюється сам канал:

...
func (c *cancelCtx) Done() <-chan struct{} {
        ...
  if d == nil {
    d = make(chan struct{})
    c.done.Store(d)
  }
  return d.(chan struct{})
}
...

Отже, коли викликається:

  1. context.WithTimeout()  =>
    1. WithDeadline() =>
      1. WithDeadlineCause() =>
        1. c := &timerCtx{} яка має cancelCtx
          1. а cancelCtx{} має метод Done()

І коли ми викликаємо

...
  rootCtx, rootCancel := signal.NotifyContext(
    context.Background(),
    os.Interrupt,
    syscall.SIGTERM,
  )
...

То в signal.NotifyContext() ми передаємо пустий контекст, а signal.NotifyContext() створює і повертає власний контекст з context.WithCancel():

...
func NotifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) {
  ctx, cancel := context.WithCancel(parent)
  c := &signalCtx{
           ...
  }
        ...
  return c, c.stop
}
...

Тому в кінці нашої main() ми можемо викликати читання з каналу:

...
  // block until Ctrl+C cancels rootCtx
  <-rootCtx.Done()
...

А як тільки канал закриється – управління повертається до main(), виконується gocron.Stop(), і виконання програми завершується.

Loading

Golang: interfaces – “магія” виклику методів через інтерфейси
0 (0)

11 Листопада 2025

Інтерфейси в Go дозволяють описати доступ до даних або методів без створення самих реалізацій в цих інтерфейсах.

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

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

Взагалі хотів просто написати пост при використання інтерфейсів, але натомість вийшов пост про те, як інтерфейси реалізовані взагалі, і про “магію” того, як через них відбувається виклик даних.

Насправді спочатку було б добре написати про pointers та методи в Go, бо в цьому матеріалі саме на них побудоване все пояснення роботи інтерфейсів, але це вже іншим разом.

Див. доповнення у Golang: інтерфейси, типи та методи на прикладі io.Copy().

Empty Interfaces

Найпростіший приклад інтерфейсу – коли в ньому не задається ані метод, ані тип даних, який цей метод повертає.

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

package main

import "fmt"

// define an empty interface
// it can hold a value of any type
type Any interface{}

func printValue(v Any) {
  // print the value
  fmt.Println("Value:", v)
}

func main() {
  // pass int
  printValue(42)
  // pass string
  printValue("hello")
  // pass float
  printValue(3.14)
  // pass slice
  printValue([]int{1, 2, 3})
}

Без використання інтерфейсу – нам довелось би створювати окремі функції для кожного типу, який ми хочемо передати, або, як альтернатива – використовувати generics.

Інший варіант створення порожнього інтерфейсу – це використання типу any, який по факту є аліасом на interface{}:

...
type MyAny any

func printValue(v Any) {
  // print the value
  fmt.Println("Value:", v)
}
...

Interfaces та Methods

Якщо “порожній” інтерфейс any каже “я приймаю будь-яке значення”, то “класичний” інтерфейс каже: “мене цікавить лише певна поведінка”.

Ця поведінка описується через набір сигнатур методів (method signatures), які тип має реалізувати, щоб відповідати інтерфейсу

Коли ми створюємо змінну інтерфейсного типу або передаємо значення у функцію, що приймає інтерфейс, Go перевіряє, чи тип реалізує всі методи цього інтерфейсу, і створює зв’язок між ними.

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

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

Наприклад:

package main

import "fmt"

// define an interface 'MyInterface' with a single method 'MyMethod' returning a string
type MyInterface interface {
  MyMethod() string
}

// define 'MyStruct' struct with a 'MyField' field
type MyStruct struct {
  MyField string
}

// define 'MyMethod' method for the 'MyStruct' struct
// this makes 'MyStruct' implicitly implement 'MyInterface'
// 'MyMethod' method uses 'MyStruct' as the receiver, so this method is tied to the 'MyStruct' type
func (receiver MyStruct) MyMethod() string {
  return "Executing " + receiver.MyField
}

// define a function 'sayHello()' which accepts any type that implements 'MyInterface'
// and prints the value returned by its 'MyMethod'
func sayHello(g MyInterface) {
  fmt.Println(g.MyMethod())
}

func main() {
  // create an instance of MyStruct
  myObj := MyStruct{MyField: "Hello, Interface!"}

  // pass the MyStruct instance to the function.
  // this works because MyStruct implements MyInterface.
  sayHello(myObj)
}

Тут ми:

  • оголошуємо власний інтерфейсний тип з іменем MyInterface
  • цей інтерфейс описує одну сигнатуру методу – MyMethod(), і цей метод має повертати дані з типом string
  • створюємо власний тип даних MyStruct з типом struct, в якому є одне поле MyField з типом string
  • до цієї структури “прив’язуємо” функцію MyMethod() – через вказання ресивера (receiver MyStruct), завдяки чому MyStruct реалізує інтерфейс MyInterface
  • описуємо нашу “основному робочу” функцію sayHello(), яка аргументом приймає інтерфейс і викликає метод MyMethod(), який є в цьому інтерфейсі
  • створюємо інстанс нашого типу даних MyStruct, якому в поле MyField записуємо значення “Hello, Interface!
  • і викликаємо нашу робочу функцію, передаючи аргументом цю структуру

Постарався відобразити зв’язки між всіма об’єктами, бо вони дуже не явні, вийшло щось таке:

  1. створюємо об’єкт myObj з типом MyStruct
  2. викликаємо sayHello(), передаючи аргументом myObj, який всередині функції sayHello() стає змінною g, яка пов’язується з нашим інтерфейсом MyInterface, який надає доступ до методу MyMethod()
  3. в функції sayHello() через виклик g.MyMethod() ми звертаємось до інтерфейсу MyInterface, кажучи “мені потрібен твій метод MyMethod()
  4. інтерфейс MyInterface “бачить”, що всередині нього зараз схований об’єкт myObj (типу MyStruct), тому він перенаправляє цей виклик саме до методу цієї конкретної структури

Окей – тепер картина стає більш зрозумілою.

Окрім одного моменту – як саме інтерфейс “бачить”, що “в ньому” є об’єкт myObj з методом MyMethod()?

The interface’s “magic”: type iface struct

Для того, аби розібратись з цим – трохи зануримось в магію вказівників (pointers), а саме – створимо власну структуру, яка буде копіювати те, як в type MyInterface interface структуровані дані.

А потім через вказівники – подивимось на адреси і зміст даних.

“Трохи” перепишемо наш код:

package main

import (
  "fmt"
  "unsafe"
)

// define MyInterface interface
// (same as before)
type MyInterface interface {
  MyMethod() string
}

// define MyStruct struct
// (same as before)
type MyStruct struct {
  MyField string
}

// define MyMethod method with a POINTER receiver
// - before was func (p MyStruct) MyMethod() ... - by value
// - now is func (p *MyStruct) MyMethod() ... - by pointer
// This means the method operates on the original data.
func (p *MyStruct) MyMethod() string {
  return "Executing " + p.MyField
}

// This is the helper struct to inspect an interface
// It represents the internal memory layout of an interface variable
// the 'tab' has a table with information about the interface's type and methods
//
//	 type iface struct {
//		  // pointer to the 'itab' struct, see below
//		  tab  *itab
//		  // here will be a pointer to the 'myObj' struct
//		  data unsafe.Pointer
//		}
//
//		type itab struct {
//		  // pointer to the 'type MyInterface interface'
//		  inter *interfacetype
//	          // pointer to the 'type MyStruct struct'
//		  typ   *rtype
//		  // in our case we have 1 method, thus '[N]uintptr' == [1]uintptr
//		  // and in the 'fun[0]' will be the address of the method 'MyMethod'
//		  fun   [N]uintptr // will have '[1]uintptr', and
//		}
type ifaceStruct struct {
  // Pointer to type/method info table
  tab unsafe.Pointer
  // Pointer to the actual data
  // in our case, here will be a pointer to the 'myObj' struct
  data unsafe.Pointer
}

// HERE IS THE "MAGIC"
// We modify sayHello to inspect the `g` it receives.
//
// 'g' is a new, local variable of the 'MyInterface' type.
// When the function is called, `myObj` is assigned to `g`.
//
// Because 'g' is an interface, it internally consists of two pointers:
//  1. tab:  A pointer to the "interface table" (itab) that links
//     the interface type (MyInterface) to the concrete type (*MyStruct)
//     and stores pointers to the methods that satisfy the interface
//  2. data: A pointer to the actual data. In our case, this will be
//     the pointer we passed in (`myObj`).
func sayHello(g MyInterface) {
  fmt.Println("Inside sayHello()")

  // Get the address of `g` and cast it to our helper struct 'ifaceStruct'

  // This line does three things in one go:
  // 1. &g                  - takes the memory address of our interface variable `g`
  // 2. unsafe.Pointer(&g)  - casts that address to a raw, untyped pointer
  // 3. (*ifaceStruct)(...) - re-interprets that raw pointer as a pointer to our helper struct

  // As a result, `g_internal` is now a `*ifaceStruct` that points to
  // the exact same memory location as `g`, letting us access its .tab and .data fields.
  g_internal := (*ifaceStruct)(unsafe.Pointer(&g))

  fmt.Printf("Internal 'Type' pointer (tab):  %p\n", g_internal.tab)
  fmt.Printf("Internal 'Data' pointer (data): %p\n", g_internal.data)
  fmt.Println("Result:", g.MyMethod())
}

func main() {
  // Create the object and get a pointer to it
  // 'myObj' now holds a pointer to a MyStruct instance in memory
  myObj := &MyStruct{MyField: "Hello, Interface!"}

  // Print location of the 'myObj' struct
  fmt.Println("Inside main()")
  fmt.Printf("Address of the original 'myObj' in main(): %p\n", myObj)

  // Pass the pointer to the function
  // i.e. we pass an address of the 'myObj' struct location
  sayHello(myObj)
}

Запускаємо:

$ go run interface-details.go
Inside main()
Address of the original 'myObj' in main(): 0xc000014070
Inside sayHello()
Internal 'Type' pointer (tab):  0x4e5a28
Internal 'Data' pointer (data): 0xc000014070
Result: Executing Hello, Interface!

В коментах розписав все детально, але по суті коротко ми:

  1. у виклику sayHello(myObj) до функції sayHello() передаємо адресу “0xc000014070” – посилання на структуру MyStruct з полем MyField, в яке записане значення “Hello, Interface!
  2. функція sayHello() приймає аргумент типу інтерфейс, і змінна g містить два вказівники – tab (на структуру itab, яка зберігає інформацію про тип і методи), та data (на значення типу MyStruct)

Можна спробувати візуалізувати так:

MyInterface (variable 'g')
+----------------------------------------+
| tab  =>  itab(MyInterface, *MyStruct)
| data =>  &MyStruct{"Hello, Interface!"}
+----------------------------------------+

А сама цікава магія відбувається під час компіляції програми і створення структури itab:

  1. Go перевіряє методи в коді, знаходить структуру MyStruct з методом MyMethod()
  2. перевіряє інтерфейси, і знаходить MyInterface, який вимагає метод MyMethod() string
  3. перевіряє, що MyStruct.MyMethod() та MyInterface.MyMethod() збігаються
  4. створює таблицю інтерфейсу (itabinterface table), яка пов’язує MyStruct з MyInterface і зберігає адреси методів, що реалізують інтерфейс

І далі під час виконання програми під час виклику sayHello(myObj) Go створює нову змінну g типу iface, у якій ці два вказівники (tab та data) поєднуються:

  • вказівник на itab (яку компілятор створив для пари MyStruct + MyInterface) буде поміщено в g.tab
  • вказівник на myObj (тобто адреса типу “0xc000014070“) буде поміщено в g.data

В результаті в g.tab у нас буде структура itab – в полі fun[0] якої буде адреса функції MyMethod(), а в g.data – буде вказівник на екземпляр MyStruct з полем MyField.

І тоді при виклику:

...
fmt.Println("\nResult:", g.MyMethod())
...

Ми запускаємо:

...
return "Executing " + *MyStruct.MyField
...

Наостанок – можна ще вивести і саму itab, аналогічно тому, як зробили для самого інтерфейсу, через створення власної структури type itabStruct struct:

package main

import (
  "fmt"
  "unsafe"
)

// define MyInterface interface
type MyInterface interface {
  MyMethod() string
}

// define MyStruct struct
type MyStruct struct {
  MyField string
}

// define MyMethod method with a POINTER receiver
func (p *MyStruct) MyMethod() string {
  return "Executing " + p.MyField
}

// This helper represents the interface value itself (the 2-word struct)
type ifaceStruct struct {
  // Pointer to the 'itab' (interface table)
  tab unsafe.Pointer
  // Pointer to the actual data (our *MyStruct)
  data unsafe.Pointer
}

// NEW
// This helper represents the internal 'runtime.itab' struct
type itabStruct struct {
  // inter: Pointer to the interface type's definition (MyInterface)
  inter unsafe.Pointer
  // typ: Pointer to the concrete type's definition (*MyStruct)
  typ unsafe.Pointer
  // hash: Hash of the concrete type, used for lookups
  hash uint32
  // _ [4]byte: Padding (on 64-bit systems)
  _ [4]byte
  // fun: The method dispatch table - an array of function pointers
  // Each entry corresponds to a method defined in the interface
  // Here we have one entry: the address of MyStruct.MyMethod()
  fun [1]uintptr
}

// HERE IS THE "MAGIC"
func sayHello(g MyInterface) {
  fmt.Println("--- Inside sayHello() ---")

  // 1. Get the address of `g` and cast it to our helper struct
  g_internal := (*ifaceStruct)(unsafe.Pointer(&g))

  // Print the two main pointers
  fmt.Printf("g.tab (pointer to itab):  %p\n", g_internal.tab)
  fmt.Printf("g.data (pointer to myObj): %p\n", g_internal.data)

  // NEW - 2. DE-REFERENCE THE 'tab' POINTER
  // Cast the 'tab' pointer to our itabStruct pointer
  itab_ptr := (*itabStruct)(g_internal.tab)

  // NEW - 3. PRINT THE CONTENTS OF THE 'itab'
  fmt.Println("\n--- Inspecting the 'itab' (at address g.tab) ---")
  fmt.Printf("itab.inter (ptr to MyInterface info): %p\n", itab_ptr.inter)
  fmt.Printf("itab.typ (ptr to *MyStruct info):   %p\n", itab_ptr.typ)
  fmt.Printf("itab.hash (hash of *MyStruct type): %x\n", itab_ptr.hash)

  // This is the final link!
  // This is the actual memory address of the function to be called, i.e. the 'g.MyMethod()' in this case
  fmt.Printf("itab.fun[0] (ADDRESS OF THE METHOD):  0x%x\n", itab_ptr.fun[0])

  // 4. Call the method as usual
  fmt.Println("\nResult:", g.MyMethod())
}

func main() {
  // Create the object and get a pointer to it
  myObj := &MyStruct{MyField: "Hello, Interface!"}

  fmt.Println("--- Inside main() ---")
  fmt.Printf("Address of original 'myObj' in main(): %p\n", myObj)

  // Pass the pointer to the function - Go will create an 'iface' value
  // linking the interface 'MyInterface' with the concrete type *MyStruct.
  sayHello(myObj)
}

Результат:

$ go run interface-details-3.go
--- Inside main() ---
Address of original 'myObj' in main(): 0xc00019a020
--- Inside sayHello() ---
g.tab (pointer to itab):  0x4e6c08
g.data (pointer to myObj): 0xc00019a020

--- Inspecting the 'itab' (at address g.tab) ---
itab.inter (ptr to MyInterface info): 0x4a9d80
itab.typ (ptr to *MyStruct info):   0x4a86e0
itab.hash (hash of *MyStruct type): 1ac3179f
itab.fun[0] (ADDRESS OF THE METHOD):  0x499c40

Result: Executing Hello, Interface!

Тобто, коли ми викликаємо g.MyMethod(), Go бере адресу функції з itab.fun[0] і викликає її, передаючи їй як аргумент вказівник з g.data – от і вся “магія” динамічного виклику методів через інтерфейс.

Ну і тепер можна використовувати інтерфейси, вже маючи уявлення про те, як саме вони працюють.

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

 

Loading

VictoriaMetrics: Churn Rate, High cardinality, метрики та IndexDB
0 (0)

1 Листопада 2025

З’явився цей пост в принципі випадково.

Прилетів мені один з дефолтних алертів VictoriaMetrics, які створюються під час деплою Helm-чарту victoria-metrics-k8s-stack:

Думав написати коротенький пост типу “що таке Churn Rate і як його пофіксати”, але в результаті вийшло доволі глибоко зануритись в те, як взагалі VictoriaMetrics працює з даними – і це виявилось дуже цікавою темою.

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

Metric vs Time Series vs Sample

Всі ми маємо справу з метриками в моніторингу – будь то Prometheus, чи VictoriaMetrics, чи InfluxDB, і ці метрики ми потім використовуємо в наших дашбордах Grafana або в алерт-рулах VMAlert.

Але що таке власне “метрика”? А що таке тайм-серія, sample чи data point? І як кількість різних значень однієї label для метрики впливає на використання диску та пам’яті?

Бо, наприклад, я в постах зазвичай просто використовую слово “метрика”, бо в 99% цього достатньо, аби описати об’єкт, про який йде мова.

Але для повноцінної роботи з системами моніторингу треба добре уявляти різницю між цими поняттями.

Що таке Metric?

Метрика (Metric): що вимірюється

Наприклад – cpu_usage, memory_free, http_requests_total, database_connections.

В документації VictoriaMetrics є дуже точний вираз – це як імена змінних, через які ми передаємо дані, див. Structure of a metric.

Метрика має власне ім’я, та опціонально набір labels (лейбл або тегів), які дозволяють додати більше контексту для конкретного вимірювання – але без значень цих лейбл.

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

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

Приклад:

Metric: "cpu_usage{server, core}"

Тут:

  • ім’я метрики: cpu_usage
    • ім’я label: server
    • ім’я label: core

Що таке Time Series?

Таймсерія (Time Series): послідовність даних

Це повна послідовність записів, які згруповані для конкретної метрики та її labels зі значеннями – тобто набору metric_name{label_name="label_value"}, і які впорядковані за часом.

Приклад:

Metric: "cpu_usage{server, core}"
├── Time series: cpu_usage{server="web01", core="0"}
│   ├── 1753857852, 75.5
│   ├── 1753857912, 76.2
│   ├── 1753857972, 74.8
│   └── 1753858032, 73.1
├── Time series: cpu_usage{server="web01", core="1"}
│   ├── 1753857852, 82.3
│   ├── 1753857912, 81.7
│   └── ...
└── Time series: cpu_usage{server="web02", core="0"}
    ├── 1753857852, 45.2
    ├── 1753857912, 47.8
    └── ...

Тут для метрики cpu_usage{server, core} ми маємо три різні таймсерії:

  1. cpu_usage{server="web01", core="0"}
    • в час 1753857852 (Wed Jul 30 2025 06:44:12 GMT) значення було 75.5
    • в час 1753857912 (Wed Jul 30 2025 06:45:12 GMT) значення було 76.2
  2. cpu_usage{server="web01", core="1"}
    1. в час 1753857852 значення було 82.3
  3. cpu_usage{server="web02", core="0"}
    1. в час 1753857852 значення було 45.2

Що таке Sample та Data Points?

Семпл (Sample): конкретний запис у послідовності даних (таймсерії).

Sample і Data Point – синоніми, і являють собою окреме значення метрики у певний момент часу.

Має вигляд (timestamp, value), наприклад “1753857852 75.5” – тобто, в Unix timestamp 1753857852 значення було 75.5%.

Приклад:

Metric: "cpu_usage{server, core}"
├── Time series: cpu_usage{server="web01", core="0"}
│   ├── Sample: 1753857852, 75.5
│   ├── Sample: 1753857912, 76.2
│   ├── Sample: 1753857972, 74.8
│   └── Sample: 1753858032, 73.1
├── Time series: cpu_usage{server="web01", core="1"}
│   ├── Sample: 1753857852, 82.3
│   ├── Sample: 1753857912, 81.7
│   └── ...
└── Time series: cpu_usage{server="web02", core="0"}
    ├── Sample: 1753857852, 45.2
    ├── Sample: 1753857912, 47.8
    └── ...

Тут:

  • для таймсерії cpu_usage{server="web01", core="0"} маємо чотири семпла:
    • 1753857852, 75.5
    • 1753857912, 76.2
    • 1753857972, 74.8
    • 1753858032, 73.1

І дані за весь період спостережень по кожній унікальній комбінації cpu_usage{server="some_server", core="some_core"} будуть формувати одну і ту ж таймсерію, навіть якщо ці дані збираються роками – допоки не зміниться значення або в server, або в core.

High Cardinality vs High Churn rate

Обидві проблеми мають однакове “походження”, але трохи відрізняються по суті.

High cardinality – це “persistent проблема”, яка впливає на зберігання, індексацію та пошук даних.

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

Це призводить до великої кількості живих та неактивних серій, що збільшує розмір IndexDB, використання памʼяті та час пошуку. Про IndexDB детальніше будемо говорити далі.

Див. Cardinality explorer в блогах VictoriaMetrics.

High churn rate – це “online проблема”, коли у нас постійно створюються нові тайм-серії через зміну значень лейблів, особливо короткоживучих або динамічних (як у Kubernetes – pod_name, container_id, job_id, або щось типу client_ip).

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

“Життя метрики”

Є дуже класне відео, яке побачив багато років тому – The Inner Life of the Cell, чомусь воно тут згадалось.

Аби зрозуміти як кількість лейбл (точніше – значення в них) впливають на розмір даних в системі і на використання CPU та пам’яті – давайте подивимось як у VictoriaMetrics взагалі відбувається весь процес “під капотом”.

Допоможе нам в цьому чудова серія постів від Phuong LeHow vmagent Collects and Ships Metrics Fast with Aggregation, Deduplication, and More.

Там 7 частин, і для дійсно “глибокого занурення” у внутрішню архітектуру VictoriaMetrics дуже рекомендую їх прочитати.

Але зараз ми відносно швидко пройдемося по процесу додавання нових даних і їхньому пошуку, і більше сконцентруємось саме на питанні Churn Rate.

“Write-path”: vminsert та vmstorage

Отже – почнемо з початку: vmagent збирає метрики з експортерів, і далі ці дані через vminsert треба записати до vmstorage.

У випадку vmsingle у на всі компоненти працюють в одному процесі, але для кращої картини – давайте їх розділяти.

vminsert збирає дані до себе в пам’ять, після чого відправляє до vmstorage блоками до 100 мегабайт.

На початку кожного блоку від vminsert задається загальний розмір блоку, після чого vmstorage починає зчитувати дані в ньому блоками по 24+n байт, строкам (row):

  • в перших 8 байтах вказується розмір n – розмір наступного сектору, який містить в собі ім’я метрики та її лейбли
  • другий сектор – ці n байт з іменем метрики і лейблами
  • третій сектор розміром 8 байт містить в собі значення семпла (“75.5” з прикладів вище)
  • четвертий містить Timestamp, ще 8 байт

В результаті формується row із 8*3 байт (24) + n байт, де n – це довжина імені метрики і її лейбл.

vmstorage формує власні блоки, в кожному максимум 10,000 строк:

Reading and Parsing Data

vmstorage, IndexDB та TSID

Після чого починає сама цікава магія – це Time Series ID, або TSID.

Для кожної унікальної комбінації метрика+лейбли+значення лейбл VictoriaMetrics має власний унікальний ідентифікатор, який використовується для збереження даних та при подальшому пошуку даних.

Сам TSID це ідентифікатор (див type TSID struct), суто внутрішній механізм самої VictoriaMetrisc, який ми, нажаль, ніде побачити не можемо:

// TSID is unique id for a time series.
//
// Time series blocks are sorted by TSID.
type TSID struct {
  MetricGroupID uint64

  JobID uint32

  InstanceID uint32

  // MetricID is the unique id of the metric (time series).
  //
  // All the other TSID fields may be obtained by MetricID.
  MetricID uint64
}

Маючи набір з імені метрики та її тегів (лейбл), vmstorage спершу перевіряє свій TSID Cache. Якщо для ції комбінації ми вже маємо згенерований TSID – використовуємо його.

Якщо в кеші даних нема (значення vm_slow_row_inserts_total росте) – vmstorage звертається до IndexDB, і починає пошук TSID там.

Якщо в IndexDB знайдений TSID – він додається в кеш vmstorage, і процес йде далі:

 

Cache miss triggers IndexDB lookup

Якщо ж це абсолютно нові імена метрики і лейбл з їхніми значеннями – генерується новий TSID, який реєструється в кеші vmstorage.

IndexDB зберігає два індекси, в кожному кілька мапінгів між полями та ID, описано в частині How IndexDB is Structured:

  • 1 – Tag to metric IDs (Global index): кожен тег (лейбла) мапиться на ім’я метрики (її ID)
  • 2 – Metric ID to TSID (Global index): ID кожної метрики мапиться на TSID
  • 3 – Metric ID to metric name (Global index): мапінг власне імені метрики на її ID
  • 4 – Deleted metric ID: трекер видалених metric IDs.
  • 5 – Date to metric ID (Per-day index): мапінг дат на metric ID для швидкого пошуку по датам (“чи є за цей день дані по цій метриці”)
  • 6 – Date with tag to metric IDs (Per-day index): аналогічний до першого Tag to metric IDs мапінгу, але по датам
  • 7 – Date with metric name to TSID (Per-day index): схожий на другого Metric ID to TSID мапінгу, але по іменам метрик і датам

Ці індекси тримаються як в пам’яті, і періодично записуються на диск (flush) в persistant storage IndexDB в каталог indexdb/, де – як і в каталозі data/, в якому зберігають самі тайм-серії – виконується merge даних для оптимізації зберігання та пошуку.

Детальніше див. в 3 частині в блогах VictoriaMetrcis – How vmstorage Processes Data: Retention, Merging, Deduplication.

І повертаючись до питання Churn Rate та High cardinality – кожна окрема метрика+лейбли створюють окремі TSID, для кожної лейбли створюються мапінги в індексах, при великій кількості нових даних, які постійно записуються з пам’яті в диск – частіше викликаються дискові операції – маємо навантаження на CPU, пам’ять, I/O операції диска.

vmstorage та збереження даних на диску

В принципі, саме цікаве ми вже побачили – ролі IndexDB та TSID, але давайте пройдемось по решті процесу.

З отриманих від vminsert даних прочитали дані, сформували власні block з rows.

В кожній row vmstorage зберігає вже не ім’я метрики – а її TSID, а для кожного TSID містить записи з values та часом (власне, тайм-серії):

vmstorage’s main storage

Далі вони записуються в пам’яті в “raw-row shards”, після чого формують in-memory LSM parts (див. Log-structured merge-tree і LSM tree and Sorted string tables (SST)):

How vmstorage handles data ingestionЯкі потім записуються на диск:

How vmstorage organizes data within a partition

І на диску, як і для даних IndexDB, аналогічно відбуваються Merge Process, Deduplication та Downsampling.

Але з того, що нам цікаво – це як воно виглядає на диску:

$ kk exec -ti vmsingle-vm-k8s-stack-ff6f9bf4c-qt2mj -- tree victoria-metrics-data/data
victoria-metrics-data/data
├── big
│   ├── 2025_09
│   │   └── 18688A4D78E7FBFB
│   │       ├── index.bin
│   │       ├── metadata.json
│   │       ├── metaindex.bin
│   │       ├── timestamps.bin
│   │       └── values.bin
│   ├── 2025_10
│   │   ├── 186A34EE1061F960
│   │   │   ├── index.bin
│   │   │   ├── metadata.json
│   │   │   ├── metaindex.bin
│   │   │   ├── timestamps.bin
│   │   │   └── values.bin
│   │   ├── 186CDDD43EA4892F
...
── small
    ├── 2025_09
    │   ├── 18688A4D78E8044E
    │   │   ├── index.bin
    │   │   ├── metadata.json
    │   │   ├── metaindex.bin
    │   │   ├── timestamps.bin
    │   │   └── values.bin
    │   ├── 18688A4D78E80B8F
    │   │   ├── index.bin
    │   │   ├── metadata.json
    │   │   ├── metaindex.bin
    │   │   ├── timestamps.bin
    │   │   └── values.bin
...

Тут в small “скидаються” дані з in-memory parts, і small потім merge в big parts.

Кожен part містить в собі власний індекс, який відповідає за мапінг даних на timestamps та values:

LSM parts organized into columnar data files

“Read-path”: пошук даних з vmselect та vmstorage

Коли ж ми робимо пошук по даним – то vmselect передає до vmstorage запит з метрикою, лейблами (тегами) та датою, за яку треба виконати пошук.

vmstorage в IndexDB по tag to metric IDs знаходить відповідні MetricIDs – для всіх метрик, які має цей тег.

Далі по Metric ID IndexDB в записах metric ID to TSID знаходить відповідні TSID, які повертає до vmstorage.

Маючи TSID – vmtorage вже перевіряє in-memory, small та big parts, шукаючи потрібний TSID в файлах metaindex.bin.

А знайшовши потрібний metadata.bin – він читає відповідний index.bin, який вже каже в яких строках timestamp.bin та values.bin знайти потрібні дані, які потім повертаються до vmselect.

Практичний приклад: запис 10,000 метрик і 10,000 labels

Це все цікаво почитати в теорії – але давайте трохи практики, бо завжди ж цікаво подивитись як воно виглядає в реальності.

Що будемо робити:

  • запустимо два контейнери з VictoriaMetrics
  • в кожен через API запишемо 10,000 метрик, але:
    • в один інстанс для всіх метрик лейбла буде мати однакове значення
    • в другий інстанс значення label буде постійно змінюватись

А потім глянемо як це вплинуло на розмір даних.

Створюємо директорії:

$ mkdir vm-data-light
$ mkdir vm-data-heavy

Запускаємо два контейнери – vm-light та vm-heavy, кожному підключаємо відповідний каталог – ./vm-data-light та ./vm-data-heavy, кожен слухає власний TCP-порт:

$ docker run --rm --name vm-light -p 8428:8428 -v ./vm-data-light:/victoria-metrics-data victoriametrics/victoria-metrics
$ docker run --rm --name vm-heavy -p 8429:8428 -v ./vm-data-heavy:/victoria-metrics-data victoriametrics/victoria-metrics

Перевіряємо розмір каталогів зараз:

$ du -sh vm-data-light/
76K     vm-data-light/

$ du -sh vm-data-heavy/
76K     vm-data-heavy/

І кількість файлів в них:

$ find vm-data-light/ -type f | wc -l
5

$ find vm-data-heavy/ -type f | wc -l
5

Всюди все однаково.

Тепер пишемо два скрипти – теж “light” та “heavy”.

Спочатку “light” версія:

#!/usr/bin/env bash

for i in $(seq 1 10000); do
  echo "my_metric{label=\"value-1\"} $i" | curl -s \
    --data-binary @- \
    http://localhost:8428/api/v1/import/prometheus
done

echo "DONE: stable series sent"

Тут в циклі від 1 до 10000 виконуємо запис метрики my_metric{label="value-1"}, але з кожним разом просто збільшуємо значення, яке зберігаємо.

Другий скрипт – “heavy” версія:

#!/usr/bin/env bash

for i in $(seq 1 10000); do
  echo "my_metric{label=\"value-$i\"} $i" | curl -s \
    --data-binary @- \
    http://localhost:8429/api/v1/import/prometheus
done

echo "DONE: high churn series sent"

Він аналогічний, але тут значення змінної $i використовуємо ще і для зміни значення в label – my_metric{label="value-$i"} $i.

Запускаємо тести:

$ bash light.sh

$ bash heavy.sh

І порівнюємо дані.

Розмір даних в data/:

$ du -sh vm-data-light/data/
152K    vm-data-light/data/

$ du -sh vm-data-heavy/data/
372K    vm-data-heavy/data/

Розмір даних в indexdb/:

$ du -sh vm-data-light/indexdb/
56K     vm-data-light/indexdb/

$ du -sh vm-data-heavy/indexdb/
764K    vm-data-heavy/indexdb/

Кількість файлів в data/:

$ find vm-data-light/data/ -type f | wc -l
26

$ find vm-data-heavy/data/ -type f | wc -l
26

Кількість файлів в indexdb/:

$ find vm-data-light/indexdb/ -type f | wc -l
8

$ find vm-data-heavy/indexdb/ -type f | wc -l
53

8 vs 53!

Дерево каталогів і файлів в vm-data-light/data/ і vm-data-heavy/data/ буде однаковим, але давайте глянемо на IndexDB.

У vm-data-light/indexdb/:

$ tree vm-data-light/indexdb/
vm-data-light/indexdb/
├── 1872FB055ACC4FF8
│   └── parts.json
├── 1872FB055ACC4FF9
│   ├── 1872FB055C5E523F
│   │   ├── index.bin
│   │   ├── items.bin
│   │   ├── lens.bin
│   │   ├── metadata.json
│   │   └── metaindex.bin
│   └── parts.json
├── 1872FB055ACC4FFA
│   └── parts.json
└── snapshots

6 directories, 8 files

Тоді як у vm-data-heavy/indexdb/ картина вже зовсім інша:

$ tree vm-data-heavy/indexdb/
vm-data-heavy/indexdb/
├── 1872FB05F8C559B2
│   └── parts.json
├── 1872FB05F8C559B3
│   ├── 1872FB05FA9633D4
│   │   ├── index.bin
│   │   ├── items.bin
│   │   ├── lens.bin
│   │   ├── metadata.json
│   │   └── metaindex.bin
│   ├── 1872FB05FA9633D5
│   │   ├── index.bin
│   │   ├── items.bin
│   │   ├── lens.bin
│   │   ├── metadata.json
│   │   └── metaindex.bin
│   ├── 1872FB05FA9633D6
│   │   ├── index.bin
│   │   ├── items.bin
│   │   ├── lens.bin
│   │   ├── metadata.json
│   │   └── metaindex.bin
│   ├── 1872FB05FA9633D8
│   │   ├── index.bin
│   │   ├── items.bin
│   │   ├── lens.bin
│   │   ├── metadata.json
│   │   └── metaindex.bin
│   ├── 1872FB05FA9633DA
│   │   ├── index.bin
│   │   ├── items.bin
│   │   ├── lens.bin
│   │   ├── metadata.json
│   │   └── metaindex.bin
│   ├── 1872FB05FA9633DB
│   │   ├── index.bin
│   │   ├── items.bin
│   │   ├── lens.bin
│   │   ├── metadata.json
│   │   └── metaindex.bin
│   ├── 1872FB05FA9633DC
│   │   ├── index.bin
│   │   ├── items.bin
│   │   ├── lens.bin
│   │   ├── metadata.json
│   │   └── metaindex.bin
│   ├── 1872FB05FA9633DD
│   │   ├── index.bin
│   │   ├── items.bin
│   │   ├── lens.bin
│   │   ├── metadata.json
│   │   └── metaindex.bin
│   ├── 1872FB05FA9633DE
│   │   ├── index.bin
│   │   ├── items.bin
│   │   ├── lens.bin
│   │   ├── metadata.json
│   │   └── metaindex.bin
│   ├── 1872FB05FA9633DF
│   │   ├── index.bin
│   │   ├── items.bin
│   │   ├── lens.bin
│   │   ├── metadata.json
│   │   └── metaindex.bin
│   └── parts.json
├── 1872FB05F8C559B4
│   └── parts.json
└── snapshots

15 directories, 53 files

Тобто:

  • vm-data-light/indexdb: 6 directories, 8 files
  • vm-data-heavy/indexdb: 15 directories, 53 files

І на додачу можемо порівняти статистику з /api/v1/status/tsdb.

Light-версія:

$ curl -s http://localhost:8428/prometheus/api/v1/status/tsdb | jq
{
  "status": "success",
  "data": {
    "totalSeries": 1,
    "totalLabelValuePairs": 2,
    "seriesCountByMetricName": [
      {
        "name": "my_metric",
        "value": 1,
        "requestsCount": 0,
        "lastRequestTimestamp": 0
      }
    ],
    "seriesCountByLabelName": [
      {
        "name": "__name__",
        "value": 1
      },
      {
        "name": "label",
        "value": 1
      }
    ],
    "seriesCountByFocusLabelValue": [],
    "seriesCountByLabelValuePair": [
      {
        "name": "__name__=my_metric",
        "value": 1
      },
      {
        "name": "label=value-1",
        "value": 1
      }
    ],
    "labelValueCountByLabelName": [
      {
        "name": "__name__",
        "value": 1
      },
      {
        "name": "label",
        "value": 1
      }
    ]
  }
}

Тоді як в “heavy-версії” просто всього більше:

$ curl -s http://localhost:8429/prometheus/api/v1/status/tsdb | jq
{
  "status": "success",
  "data": {
    "totalSeries": 10000,
    "totalLabelValuePairs": 20000,
    "seriesCountByMetricName": [
      {
        "name": "my_metric",
        "value": 10000,
        "requestsCount": 0,
        "lastRequestTimestamp": 0
      }
    ],
    "seriesCountByLabelName": [
      {
        "name": "__name__",
        "value": 10000
      },
      {
        "name": "label",
        "value": 10000
      }
    ],
    "seriesCountByFocusLabelValue": [],
    "seriesCountByLabelValuePair": [
      {
        "name": "__name__=my_metric",
        "value": 10000
      },
      ...
      {
        "name": "label=value-1003",
        "value": 1
      },
      {
        "name": "label=value-1004",
        "value": 1
      }
    ],
    "labelValueCountByLabelName": [
      {
        "name": "label",
        "value": 10000
      },
      {
        "name": "__name__",
        "value": 1
      }
    ]
  }
}

Власне, на цьому все.

Піду переписувати конфіги для vmagent, аби дропати частину лейбл, особливо від Karpenter (див. Karpenter: моніторинг та Grafana dashboard для Kubernetes WorkerNodes) – бо там їх просто десятки на кожну метрику. Див. Relabeling cookbook.

Loading

InfluxDB: запуск на Debian з NGINX і підключення Grafana
0 (0)

28 Жовтня 2025

Отже, продовження попереднього посту InfluxDB: знайомство і основні можливості.

Там познайомились з InfluxDB в цілому, тепер час будувати з ним реальні рішення.

Що будемо робити – запустимо InfluxDB на Debian, налаштуємо NGINX, імпортуємо дані з Google Sheets в .csv, а потім мігруємо їх до InfluxDB та підключимо Grafana. І додатково трохи пограємось з Python Falsk для створення веб-форми.

Мій “self-monitoring” проект

Власне, для чого я все це роблю: я веду такий собі “self human monitoring” – кожного дня записую в Google Sheets різні показники – як добре спав, який був настрій, наскільки добре голова працювала і багато іншого, загалом там 23 метрики.

Далі це все прямо в Google Sheets виводиться в графіки, де я в будь-який момент можу глянути в який період яке в мене було самопочуття.

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

Минулого року для візуалізації підключав Google Looker Studio, який нативно вміє інтеграцію з Google Sheets – але з ним постійно виникали якісь проблеми, особливо якщо змінювався формат в таблиці типу перейменування колонок, тому згодом я Looker Studio закинув.

І врешті-решт прийшла ідея того, що, камон! Девопс я, ілі тварь дрожащая?

Чому б не використати мої знання в моніторингу інфраструктури в цій справі теж?

Тому вирішив побудувати власний стек моніторингу, де дані будуть зберігатись в InfluxDB.

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

Тому поки що InfluxDB залишиться як база, а до неї додамо Grafana.

А вже пізніше, мабуть, все ж мігрую дані до VictoriaMetrics.

Втім, цей пост, звісно, не про цей селф-мониторинг, а просто непоганий приклад того, як запустити Influx з NGINX і Grafana, як імпортувати дані, і як створити веб-сторінку з Flask для додавання нових метрик в InfluxDB.

Поточні дані в Google Sheets

На прикладі таблиці Sleep:

Тут Sleep_rate – суб’єктивна оцінка якості сну, Sleepy_day – наскільки сильна була сонливість цього дня, Wake_ups – скільки раз за ніч прокидався, і Mults – наскільки яскраві і насичені були сни, бо іноді вони бувають дійсно “мультфільмами” – наче всю ніч в кінотеатрі просидів 🙂

План дій

Робитись все буде на тому самому сервері з Debian, де зараз хоститься сам блог RTFM.

Що будемо робити:

  • запустимо InfluxDB в Docker
  • налаштуємо vitrtualhost в NGINX
  • імпортуємо існуючі дані з Google Sheets в InfluxDB
  • подивимось, які дашборди можемо зробити в InfluxDB
  • додамо форму для введення нових даних
  • додамо Grafana для повноцінної візуалізації

Окремо треба буде зробити бекап і підтюнити InfluxDB та Grafana, бо сервер маленький, лише 2 гігабайти пам’яті, але це вже іншим разом.

Поїхали.

Запуск InfluxDB з Docker Compose

Простіше всього зробити з docker-compose, аби потім легше було переносити на інший сервер.

Встановлення Docker та Docker Compose на Debian

Встановлюємо Docker та Docker Compose, документація тут>>>:

root@setevoy-do-2023-09-02:~# apt-get update
root@setevoy-do-2023-09-02:~# apt-get install ca-certificates curl
root@setevoy-do-2023-09-02:~# install -m 0755 -d /etc/apt/keyrings
root@setevoy-do-2023-09-02:~# curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
root@setevoy-do-2023-09-02:~# chmod a+r /etc/apt/keyrings/docker.asc

root@setevoy-do-2023-09-02:~# echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

root@setevoy-do-2023-09-02:~# cat /etc/apt/sources.list.d/docker.list
deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian   bookworm stable

root@setevoy-do-2023-09-02:~# apt-get update

root@setevoy-do-2023-09-02:~# apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Запускаємо сервіс, додаємо в автостарт:

root@setevoy-do-2023-09-02:~# systemctl start docker
root@setevoy-do-2023-09-02:~# systemctl enable docker

В Debian пакет docker-compose-plugin якось дивно встановлює docker-compose executable, довелось шукати по системі:

root@setevoy-do-2023-09-02:/opt/influx# find / -name docker-compose -type f
/usr/libexec/docker/cli-plugins/docker-compose

Додаємо собі в $PATH:

root@setevoy-do-2023-09-02:/opt/influx# echo 'export PATH=$PATH:/usr/libexec/docker/cli-plugins/' >> ~/.bashrc
root@setevoy-do-2023-09-02:/opt/influx# . ~/.bashrc

Docker Compose для InfluxDB та performance tuning

Я тут не особо копався, але на майбутнє можна буде подивитись.

Всі доступні опції – InfluxDB configuration options.

Змінні, які можна використати:

  • INFLUXD_REPORTING_DISABLED: телеметрія в InfluxData (О.о)
  • INFLUXD_TASKS_ENABLED: користуватись поки не планую
  • INFLUXD_FLUX_LOG_ENABLED: детальні логи Flux queries, поки логи нехай будуть, але потім можна буде відключити
  • INFLUXD_QUERY_MEMORY_BYTES: можна задати ліміт по пам’яті на кожен запит, але з моїм об’ємом даних – не варте
  • INFLUXD_UI_DISABLED: можна відключити веб-інтерфейс і працювати тільки з API, поки нехай буде, як повністю на Grafana переключусь – можна буде відключити

Для даних буду робити каталог в /data, там в мене зараз живуть сайти, це окремий Digtical Ocean volume, який автоматом бекапиться самим Digtical Ocean:

root@setevoy-do-2023-09-02:~# ls -l /data/www/
total 8
drwxr-xr-x 4 root    root    4096 Sep  2  2023 rtfm
drwxr-xr-x 4 setevoy setevoy 4096 Sep  2  2023 setevoy

А для InfluxDB зробимо новий:

root@setevoy-do-2023-09-02:~# mkdir -p /data/influx/influxdb-data

Для файлу docker-compose.yaml робимо окремий каталог в /opt, в мене там всякі мої скрипти:

root@setevoy-do-2023-09-02:/opt# mkdir -p /opt/influx

Пишемо сам файл:

services:
  influxdb:
    image: influxdb:2.7
    container_name: influxdb
    restart: unless-stopped
    ports:
      - "8086:8086"
    environment:
      # disable telemetry reporting
      - INFLUXD_REPORTING_DISABLED=true
      # disable background Flux task scheduler
      - INFLUXD_TASKS_ENABLED=false
      # reduce Flux logging noise
      #- INFLUXD_FLUX_LOG_ENABLED=false
      # default retention
      - DOCKER_INFLUXDB_INIT_RETENTION=infinite
    volumes:
      - /data/influx/influxdb-data:/var/lib/influxdb2

Запускаємо:

root@setevoy-do-2023-09-02:/opt/influx# docker-compose up

Не Kubernetes – port-forward не зробиш 🙁

Можна ssh-тунель, звісно, але будемо вже відразу робити через NGINX.

NGINX Setup

Додаємо новий рекорд в DNS:

SSL з Let’s Encrypt

Отримуємо сертифікат.

Треба зробити автоматизацію, але мені все лінь – OpenVPN: Let’s Encrypt DNS verification с certbot и AWS Route53 и обновление сертификата в OpenVPN Access Server.

Робимо максимально просто:

root@setevoy-do-2023-09-02:~# certbot certonly --preferred-challenges dns -d monitoring.example.org.ua --manual --email [email protected] --agree-tos
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Requesting a certificate for monitoring.example.org.ua

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Please deploy a DNS TXT record under the name:

_acme-challenge.monitoring.example.org.ua.

with the following value:

UlWc0fwbYvdNuylzbxwnSfjyHgBIcFXQqByNBeQIFD0
...

Додаємо нову TXT, перевіряємо з домашнього компа, що вона вже з’явилась:

$ dig _acme-challenge.monitoring.example.org.ua txt +short
"UlWc0fwbYvdNuylzbxwnSfjyHgBIcFXQqByNBeQIFD0"

Тицаємо Enter, сертифікат готовий:

...
Press Enter to Continue

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/monitoring.example.org.ua/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/monitoring.example.org.ua/privkey.pem
This certificate expires on 2026-01-24.

Додавання NGINX virtualhost

В файлі /etc/nginx/conf.d/monitoring.example.org.ua.conf описуємо новий server і location:

server {
    listen 80;
    server_name monitoring.example.org.ua;

    root /data/www/setevoy/monitoring.example.org.ua;
    server_tokens off;

    location ~ /.well-known {
        allow all;
    }

    location / {
        allow 62.***.***.83;    # office
        deny all;

        return 301 https://monitoring.example.org.ua$request_uri;
    }
}

server {
    listen 443 ssl;
    server_name monitoring.example.org.ua;

    add_header Strict-Transport-Security "max-age=31536000; includeSubdomains" always;
    server_tokens off;

    access_log /var/log/nginx/monitoring.example.org.ua-access.log;
    error_log  /var/log/nginx/monitoring.example.org.ua-error.log warn;

    ssl_certificate     /etc/letsencrypt/live/monitoring.example.org.ua/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/monitoring.example.org.ua/privkey.pem;

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on;
    ssl_dhparam /etc/nginx/dhparams.pem;
    ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:ECDHE-RSA-AES128-GCM-SHA256:AES256+EECDH:DHE-RSA-AES128-GCM-SHA256:AES256+EDH:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:50m;
    #ssl_stapling on;
    #ssl_stapling_verify on;

    client_max_body_size 300M;

    location / {
        # allow from home and office only
        allow 62.***.***.83;    # office
        deny all;

        # proxy to InfluxDB container
        proxy_pass http://127.0.0.1:8086;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # to tune if need
        proxy_read_timeout 300s;
        proxy_connect_timeout 60s;
        proxy_send_timeout 300s;
    }
}

Перевіряємо синтаксис:

root@setevoy-do-2023-09-02:/opt/influx# nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Перезавантажуємо конфіги:

root@setevoy-do-2023-09-02:/opt/influx# systemctl reload nginx

І відкриваємо наш InfluxDB:

Готово. База є – можна переносити дані.

Імпорт даних з Google Sheets – .csv в InfluxDB

Тепер сама весела частина 🙂

Точніше – перша весела.

Треба імпортувати вже існуючі дані з Google Sheets в InfluxDB і згенерувати метрики. Благо в мене з попередніх років в Таблицях все структуровано, InfluxDB вміє приймати .csv, тому тут проблем (майже) не було.

Завантажуємо таблицю собі на машину в .csv:

Отримуємо такий документ:

$ head 2025-Daily-Sleep-self.csv 
Date,Sleep_rate_my_day,Sleepy_day,Wake_ups,Mults
2025-01-01,7,1,,
2025-01-02,7,1,,
2025-01-03,7,2,,
2025-01-04,5,3,,

Таблиць в мене кілька:

Для кожної зробимо окрему метрику, а в тегах використаємо імена колонок:

Найпростіший спосіб завантажити csv – через UI:

Але в даному випадку він не спрацює, бо не той формат дати – в мене 2025-01-09, а InfluxDB хоче повний rfc3339, тобто 2025-01-09T00:00:00Z.

Згадуємо, що колись вміли в awk Йдемо до ChatGPT, отримуємо команду для форматування дати:

root@setevoy-do-2023-09-02:/data/influx/import# awk -F, 'NR==1{print;next} {printf "%sT00:00:00Z,%s,%s,%s,%s\n", $1, $2, $3, $4, $5}' 2025-Daily-Sleep-self.csv > 2025-Daily-Sleep-self-rfc3339.csv

Тепер маємо нормальну дату:

$ head 2025-Daily-Sleep-self-rfc3339.csv 
Date,Sleep_rate_my_day,Sleepy_day,Wake_ups,Mults
2025-01-01T00:00:00Z,7,1,,
2025-01-02T00:00:00Z,7,1,,
2025-01-03T00:00:00Z,7,2,,

Копіюємо файл на сервер:

$ scp -i /home/setevoy/.ssh/setevoy-do-2023-09-02 2025-Daily-Sleep-self.csv [email protected]:/data/influx/import
2025-Daily-Sleep-self.csv

Встановлюємо InfluxDB CLI:

root@setevoy-do-2023-09-02:/opt/influx# wget https://dl.influxdata.com/influxdb/releases/influxdb2-client-2.7.5-linux-amd64.tar.gz
root@setevoy-do-2023-09-02:/opt/influx# tar xvzf ./influxdb2-client-2.7.5-linux-amd64.tar.gz
./
./LICENSE
./README.md
./influx

Додаємо собі $PATH:/usr/libexec/docker/cli-plugins/:/opt/influx, налаштовуємо підключення:

root@setevoy-do-2023-09-02:/opt/influx# influx config create --config-name local --host-url http://localhost:8086 --org setevoy --token $INFLUX_TOKEN  --active
Active  Name    URL                     Org
*       local   http://localhost:8086   setevoy

І завантажуємо дані – додаємо --header, бо формат InfluxDB вимагає цих анотацій, див. Extended annotated CSV:

root@setevoy-do-2023-09-02:/data/influx/import# influx write --bucket self-monitoring-1 --file 2025-Daily-Sleep-self-rfc3339.csv --format csv --header "#constant measurement,sleep_daily" --header "#datatype dateTime:RFC3339,double,double,double,double"
2025/10/26 11:32:24 line 303: no field data found
2025/10/26 11:32:24 line 304: no field data found
2025/10/26 11:32:24 Unable to batcher to error-file: invalid argument
2025/10/26 11:32:24 line 305: no field data found
2025/10/26 11:32:24 Unable to batcher to error-file: invalid argument
2025/10/26 11:32:24 line 306: no field data found
...

Таблиці за 2023 і 204 в мене окремими документами, аналогічно додаємо їх – і тепер маємо всі дані в одному місці:

Всі дані за 2.5 роки на одній дашборді.

Офігєть.

Веб-форма з Flask для внесення даних

Наступна задача – додати можливість вносити нові дані.

Перший варіант – продовжити писати в Google Sheets, на сервері скриптом отримувати їх, фіксити дату і пушити в базу, а скрипт запускати по крону.

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

Мінуси – буде проблема з тим, як в скрипті перевіряти які дані в базі вже є, аби не дублювати старі записи, і нові дані з Google Sheets в базі з’являться не відразу, а коли відпрацює крон.

Другий варіант – повністю нова схема: написати простеньку веб-сторінку, яка через InfluxDB клієнт буде записувати нові дані.

Мінуси – доведеться налаштовувати додатковий location в NGINX і запускати якийсь сервіс, який це скрипт буде оброблювати.

Врешті-решт все ж зупинився на другому варіанті.

Як це буде працювати:

  • gunicorn для запуску Flask app
  • index.html шаблон
  • metrics.json з описом метрик і їхніх тегів
  • app.py, який отримує дані з форми вводу в HTML і виконує операції в InfluxDB

Шаблон для метрик

Аби спростити життя далі – щоб простіше було додавати нові метрики – створимо JSON, який буде використовуватись в app.py аби формувати список метрик і їхніх тегів.

Отримуємо доступні метрики:

root@setevoy-do-2023-09-02:/data/influx/import# influx query '
import "influxdata/influxdb/schema"
schema.measurements(bucket: "self-monitoring-1")
'
Result: _result
Table: keys: []
            _value:string
-------------------------
energy_productivity_daily
         mood_smile_daily
              sleep_daily
              times_daily
             weight_daily

І теги для кожної метрики:

root@setevoy-do-2023-09-02:/data/influx/import# influx query '
import "influxdata/influxdb/schema"
schema.measurementFieldKeys(bucket: "self-monitoring-1", measurement: "sleep_daily")
'
Result: _result
Table: keys: []
         _value:string
----------------------
                 Mults
     Sleep_rate_my_day
            Sleepy_day
              Wake_ups

Пишемо JSON:

{
  "energy_productivity_daily": [
    "Energy_day",
    "Productivity_work",
    "Productivity_home",
    "Kognit_day",
    "Prosperity_day",
    "Study_day"
  ],
  "mood_smile_daily": [
    "Mood_day",
    "Smile_day",
    "Depression_day",
    "Anxiety_day",
    "Agression",
    "Sickness",
    "Kitty_index"
  ],
  "sleep_daily": [
    "Sleep_rate_my_day",
    "Sleepy_day",
    "Wake_ups",
    "Mults"
  ],
  "times_daily": [
    "Sleep",
    "Work",
    "Rest",
    "Study",
    "Self"
  ],
  "weight_daily": [
    "Weight"
  ],
  "testing_metric": [
    "Testing_tag"
  ]
}

Flask і InfluxDBClient

Навайбокодив 🙂

Але працює.

Файл app.py:

import os
import json
from datetime import date, datetime, time, timezone
from flask import Flask, render_template, request, jsonify
from influxdb_client import InfluxDBClient, Point, WritePrecision
from influxdb_client.client.write_api import SYNCHRONOUS

app = Flask(__name__)

# === InfluxDB config ===
INFLUX_URL = "http://localhost:8086"
INFLUX_TOKEN = "tOx***iuw=="
INFLUX_ORG = "setevoy"

# default bucket, if user doesn't choose one from the html form
DEFAULT_BUCKET = "self-monitoring-1"

# load metrics from the 'metrics.json'
METRICS_FILE = os.path.join(os.path.dirname(__file__), "metrics.json")
with open(METRICS_FILE, "r") as f:
    METRICS = json.load(f)


@app.get("/set")
@app.get("/set/")
def index():
    """Render HTML form with today's date pre-filled"""
    return render_template(
        "index.html",
        metrics=METRICS,
        today_date=date.today().isoformat()
    )


@app.post("/set/submit")
def submit():
    """Handle form submission and write data to InfluxDB"""
    form = request.form

    # --- 1) Date from form or today ---
    date_str = form.get("date")
    if date_str:
        try:
            selected_date = datetime.fromisoformat(date_str).date()
        except ValueError:
            return jsonify({"ok": False, "error": "Bad date format, expected YYYY-MM-DD"}), 400
    else:
        selected_date = date.today()

    # --- 2) Fixed time: 03:00 UTC ---
    ts = datetime.combine(selected_date, time(3, 0, 0), tzinfo=timezone.utc)

    wrote, errors = [], []

    # --- 3) Get bucket from form or use default ---
    bucket = form.get("bucket", DEFAULT_BUCKET)

    try:
        with InfluxDBClient(url=INFLUX_URL, token=INFLUX_TOKEN, org=INFLUX_ORG) as client:
            write_api = client.write_api(write_options=SYNCHRONOUS)

            for measurement, fields in METRICS.items():
                for field in fields:
                    raw = form.get(field)
                    if raw is None or raw == "":
                        continue
                    try:
                        val = float(raw)
                    except ValueError:
                        errors.append(f"{measurement}.{field}: not a number: {raw!r}")
                        continue

                    point = (
                        Point(measurement)
                        .field(field, val)
                        .time(ts, WritePrecision.NS)
                    )

                    # write to selected bucket
                    write_api.write(bucket=bucket, record=point)
                    wrote.append(f"{bucket}: {measurement}.{field}={val}")

    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500

    html = f"""
    <html>
      <body style="font-family:Arial;margin:40px;">
        <h3>Data successfully written</h3>
        <p><b>Date:</b> {selected_date.isoformat()}</p>
        <ul>
          {''.join(f'<li>{w}</li>' for w in wrote)}
        </ul>
        <p><a href="/set"><button>Return to main page</button></a></p>
      </body>
    </html>
    """
    return html


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, debug=True)

В принципі, тут доволі простий скрипт:

  • @app.get("/set/"): роут, де буде наша форма, генерує сторінку з файлу index.html
  • @app.post("/set/submit") і функція submit(): де логіка виконання – є можливість задати дату, вибрати корзину в InfluxDB, в яку будемо писати, бере список метрик і тегів з metrics.json, і через InfluxDBClient вносить дані в InfluxDB
  • в кінці виводиться ще одна форма з інформацією про те, що саме було записано, і малює кнопку “повернутись назад”

Файл templates/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Self Monitoring</title>
  <style>
    body { font-family: Arial; margin: 40px; }
    .metric-block { margin-bottom: 30px; }
    label { display: inline-block; width: 180px; }
    input { width: 80px; }
  </style>
  <script>
    // set date input to yesterday in local time (YYYY-MM-DD)
    function setYesterday() {
      const d = new Date();
      d.setDate(d.getDate() - 1);
      const y = d.getFullYear();
      const m = String(d.getMonth() + 1).padStart(2, '0');
      const day = String(d.getDate()).padStart(2, '0');
      document.getElementById('date').value = `${y}-${m}-${day}`;
    }
  </script>
</head>
<body>
  <h2>Self Monitoring</h2>

  <form action="/set/submit" method="post">
    <!-- Bucket selector -->
    <div style="margin-bottom:16px;">
      <label for="bucket">Bucket:</label>
      <select id="bucket" name="bucket" required>
        <option value="self-monitoring-1">self-monitoring-1</option>
        <option value="self-monitoring-test">self-monitoring-test</option>
      </select>
    </div>

    <!-- Date picker -->
    <div style="margin-bottom:16px;">
      <label for="date">Date:</label>
      <input type="date" id="date" name="date" value="{{ today_date }}" required>
      <button type="button" onclick="setYesterday()">Yesterday</button>
      <small>UTC midnight will be used</small>
    </div>

    {% for measurement, fields in metrics.items() %}
      <div class="metric-block">
        <h3>{{ measurement }}</h3>
        {% for field in fields %}
          <div>
            <label for="{{ field }}">{{ field }}:</label>
            <input type="number" step="any" name="{{ field }}" id="{{ field }}">
          </div>
        {% endfor %}
      </div>
    {% endfor %}
    <input type="submit" value="Submit">
  </form>
</body>
</html>

Додаємо новий location в NGINX:

...
    location /set/ {

        auth_basic "Self Monitoring Access";
        auth_basic_user_file /data/www/setevoy/.htpasswd_blog;

        proxy_pass http://127.0.0.1:8080;

        allow 62.***.***.83;
        deny all;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
...

Встановлюємо пакет для Python virtualhost:

root@setevoy-do-2023-09-02:/data/influx/self-monitoring-form# apt install python3.11-venv
root@setevoy-do-2023-09-02:/data/influx/self-monitoring-form# python3 -m venv venv

Встановлюємо залежності (старовєр з pip замість uv):

(venv) root@setevoy-do-2023-09-02:/data/influx/self-monitoring-form# pip install -r requirements.txt

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

(venv) root@setevoy-do-2023-09-02:/data/influx/self-monitoring-form# gunicorn -w 1 -b 127.0.0.1:8080 app:app
[2025-10-27 10:31:35 +0000] [488395] [INFO] Starting gunicorn 23.0.0
[2025-10-27 10:31:35 +0000] [488395] [INFO] Listening at: http://127.0.0.1:8080 (488395)

Заходимо на https://monitoring.example.org.ua/set, і маємо зручну форму:

Додаємо новий запис, перевіряємо в InfluxDB:

from(bucket: "self-monitoring-test")
  |> range(start: 2025-10-25T00:00:00Z, stop: 2025-10-28T00:00:00Z)
  |> filter(fn: (r) => r._measurement == "testing_metric")
  |> keep(columns: ["_time", "_field", "_value"])

Якщо треба видалити дані – робимо через CLI:

root@setevoy-do-2023-09-02:/data/influx# influx delete \
  --bucket self-monitoring-1 \
  --org setevoy \
  --start '2025-10-27T00:00:00Z' \
  --stop '2025-10-28T00:00:00Z' \
  --predicate '_measurement="times_daily"' \
  --host 'http://localhost:8086'

Запуск Grafana з NGINX

Додаємо контейнер з Grafana в наш docker-compose.yaml, відразу встановлюємо grafana-influxdb-flux-datasource:

...

  grafana:
    image: grafana/grafana
    container_name: grafana
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - GF_SERVER_SERVE_FROM_SUB_PATH=true
      - GF_SECURITY_COOKIE_SAMESITE=none
      - GF_SECURITY_COOKIE_SECURE=true
      - GF_SECURITY_COOKIE_NAME=grafana_session
      - GF_SECURITY_COOKIE_REMEMBER_NAME=grafana_remember
      - GF_SECURITY_COOKIE_LIFETIME=86400
      - GF_SECURITY_ADMIN_USER=setevoy
      - GF_SECURITY_ADMIN_PASSWORD=password
      - GF_INSTALL_PLUGINS=grafana-influxdb-flux-datasource
      # optional: disable telemetry
      - GF_ANALYTICS_REPORTING_ENABLED=false
      - GF_SERVER_ROOT_URL=https://monitoring.example.org.ua/grafana/
      - GF_SERVER_SERVE_FROM_SUB_PATH=true
    volumes:
      - /data/influx/grafana:/var/lib/grafana
    depends_on:
      - influxdb

Налаштовуємо ще один location в NGINX:

...
location /grafana/ {

    proxy_pass http://127.0.0.1:3000;

    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    proxy_redirect off;

    allow 62.***.***.83;
    deny all;
}

...

Логінимось в Grafana, налаштовуємо InfluxDB, в password вносимо наш токен з InfluxDB:

І створюємо дашборду:

І вся борда з усіма даними з жовтня 2023 року виглядає так:

Запуск gunicorn з Docker Compose

Зараз він запускається вручну, це не зручно, винесемо теж в docker-compose.

...
  self-monitoring:
    build: /data/influx/self-monitoring-form
    container_name: self-monitoring
    ports:
      - "8080:8080"
    environment:
      - INFLUX_URL=http://influxdb:8086
      - INFLUX_TOKEN="tOx***iuw=="
      - INFLUX_ORG=setevoy
    depends_on:
      - influxdb

В /data/influx/self-monitoring-form додаємо Dockerfile:

FROM python:3.12-slim

WORKDIR /app
COPY . .

RUN pip install --no-cache-dir -r requirements.txt

EXPOSE 8080
CMD ["gunicorn", "-b", "0.0.0.0:8080", "app:app"]

Запускаємо docker-compose:

root@setevoy-do-2023-09-02:/opt/influx# docker-compose up
[+] Building 23.9s (11/11) FINISHED                                                                                                                                                                                                                                       
 => [internal] load local bake definitions                                                                                                                                                                                                                           0.0s
 => => reading from stdin 553B                                                                                                                                                                                                                                       0.0s
 => [internal] load build definition from Dockerfile                                                                                                                                                                                                                 0.0s
 => => transferring dockerfile: 200B                                                                                                                                                                                                                                 0.0s
 => [internal] load metadata for docker.io/library/python:3.12-slim    
...
[+] Running 5/5                                                                                                                                                                                                                                                           
 ✔ influx-self-monitoring     Built                                                                                                                                                                                                                                  0.0s 
 ✔ Network influx_default     Created                                                                                                                                                                                                                                0.1s 
 ✔ Container influxdb         Created                                                                                                                                                                                                                                0.1s 
 ✔ Container self-monitoring  Created                                                                                                                                                                                                                                0.1s 
 ✔ Container grafana          Created 
...

Створення systemd service

Спростимо запуск цього всього щастя – зробимо через systemd.

Додаємо файл /etc/systemd/system/self-monitoring.service:

[Unit]
Description=Self-monitoring stack
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
WorkingDirectory=/opt/influx
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
RemainAfterExit=yes
TimeoutStartSec=0

[Install]
WantedBy=multi-user.target

Запускаємо його:

root@setevoy-do-2023-09-02:/opt/influx# systemctl start self-monitoring
root@setevoy-do-2023-09-02:/opt/influx# systemctl enable self-monitoring
Created symlink /etc/systemd/system/multi-user.target.wants/self-monitoring.service → /etc/systemd/system/self-monitoring.service.

bash скрипт для бекапу InfluxDB

Що ще треба буде зробити – це бекапи.

Я трохи повозився з influx backup, але постійно ловив 401, не став заморачуватись, бо дані оновлюються рідко, тому просто навайбокодив простенький скрипт на bash:

#!/bin/bash
# backup InfluxDB data directory and upload to S3

# set vars
SRC_DIR="/opt/influx"
BACKUP_DIR="/backups/influx"
DATE=$(date +%Y-%m-%d)
ARCHIVE_NAME="${DATE}-influx.tar.gz"
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}"
S3_BUCKET="s3://setevoy-influx-backups"

# create backup directory if not exists
mkdir -p "$BACKUP_DIR"

# create tar.gz archive
tar -czf "$ARCHIVE_PATH" -C "$SRC_DIR" .

# check that archive was created
if [ ! -f "$ARCHIVE_PATH" ]; then
  echo "❌ Failed to create backup archive!"
  exit 1
fi

# upload to S3
aws s3 cp "$ARCHIVE_PATH" "$S3_BUCKET/$ARCHIVE_NAME"

# check upload result
if [ $? -eq 0 ]; then
  echo "✅ Uploaded to S3: $S3_BUCKET/$ARCHIVE_NAME"
  # remove local archive after successful upload
  rm -f "$ARCHIVE_PATH"
  echo "🧹 Local archive removed: $ARCHIVE_PATH"
else
  echo "⚠️ Upload to S3 failed, keeping local copy."
  exit 1
fi

Запускаємо для перевірки:

root@setevoy-do-2023-09-02:~# chmod +x /opt/influx/backup_data.sh
root@setevoy-do-2023-09-02:~# /opt/influx/backup_data.sh
upload: ../backups/influx/2025-10-27-influx.tar.gz to s3://setevoy-influx-backups/2025-10-27-influx.tar.gz
✅ Uploaded to S3: s3://setevoy-influx-backups/2025-10-27-influx.tar.gz
🧹 Local archive removed: /backups/influx/2025-10-27-influx.tar.gz

Додаємо в cron:

0 3 * * * /usr/local/bin/backup-influx.sh >> /var/log/backup-influx.log 2>&1

Готово.

Loading

InfluxDB: знайомство і основні можливості
0 (0)

25 Жовтня 2025

Є в мене давня ідея self-monitoring, яку, сподіваюсь, я такі почну робити і про яку напишу окремо.

Але суть її така сама, як і в етіх ваших моніторингах – збирати метрики, і відображати графіки.

Почав під цю систему вибирати базу даних, і хоча там частота запису метрик невелика, 1 метрика на день, але хочу її робити у звичному мені time series форматі – як ми це робимо в VictoriaMetrics/Prometheus.

А в рамках написання іншого поста, про структуру TSDB та метрики (все ще в чернетках), я торкнувся InfluxDB, про яку згадав і цього разу.

Саму InfluxDB я трохи використовував ще років п’ять тому, але зовсім трохи – вона просто була одним з бекендів для Grafana, коли ми будували автоматичний load testing з JMeter в Kubernetes (колись до цього знов дійде, і напишу теж, бо там дуже класний сетап).

Але так, щоб самому використовувати InfluxDB – досвіду не було. І коли я зараз глянув на неї – то система прям дуже сподобалась, а тому для свого self-monitoring буду використовувати її.

Ну якщо що – то з InfluxDB завжди можна мігранути дані у VictoriaMetircs, див. Migrate from InfluxDB to VictoriaMetrics.

Тож що сьогодні будемо робити:

  • запустимо InfluxDB локально на Linux-хості
  • розберемо основні концепти і поняття
  • подивимось на інтерфейс, на основні компоненти
  • додамо метрику вручну
  • додамо збір метрик з Telegraf
  • додамо збір логів з Telegraf

VictoriaMetrics vs InfluxDB

Якщо дуже коротко – то для повноцінного моніторингу, для відносно великого проекту я все ж взяв би саме VictoriaMetrics, бо на великих об’ємах вона буде набагато краща в плані CPU/Memory.

Але для якогось pet project – InfluxDB можливо підійде краще за рахунок того, що в ній “з коробки” є можливість будувати дашборди з графіками, є власний alertmanager, є цікаві штуки для різних автоматизацій.

Втім, у InfluxDB є (відносний) недолік – це більш складна мова запитів, яких до того цілих дві – Flux та InfluxQL. Але можливості query builder для простого використання цілком достатньо.

InfluxDB overview

Власне InfluxDB – ще одна Time Series Database, як вже згадувані VictoriaMetrics або Prometheus.

Головна різниця – VictoriaMetrics та Prometheus працюють по pull-моделі (збирають дані з експортерів), а InfluxDB – це push-модель, коли експортери самі, власне, пушать дані в базу.

Різні і мови запитів – в VictoriaMetrics MetricsQL та PromQL в Prometheus маємо звичні нам функції типу rate() і sum by (), тоді як в InfluxDB це мова Flux (“functional data scripting language“), яка по суті являється повноцінною мовою програмування, та InfluxQL – яка більше схожа на SQL, але в InfluxDB v2 вмикається через костиль, і дефолтна мова саме Flux (але в InfluxDB v3 наче знову буде InfluxQL).

VictoriaMetrics/Prometheus – це частина CNCF-екосистеми і LGPT (Loki + Grafana + Prometheus + Tempo) або PLG (Prometheus + Loki + Grafana) стеків, а InfluxDB – це про TICK stack (Telegraf + InfluxDB + Chronograf + Kapacitor).

При цьому в InfluxDB v2 Chronograf та Kapacitor вже вбудовані в саму систему, окремо запускати не треба.

Ну і дані – VictoriaMetrics та Prometheus заточені під зберігання і роботу саме з “класичними” метриками, тоді як в InfluxDB можна збирати логи, дані з IoT девайсів, events, дані від Telegraf-плагінів тощо.

Крім того, InfluxDB наче краще підходить для довготривалого зберігання даних – і за рахунок самої моделі зберігання даних, і за рахунок вбудованих механізмів для data retention.

Ну і можливості візуалізації даних – якщо в VictoriaMetrics та Prometheus у нас “з коробки” є тільки базові графіки, бо це всеж більше бази даних, то в InfluxDB у нас є повноцінний інтерфейс, через який ми можемо робити всі потрібні налаштування і візуалізації

Запуск InfluxDB з Docker

Для “погратись” просто запустимо локально з Docker:

$ docker run -d \
  --name influxdb \
  -p 8086:8086 \
  -v $PWD/influxdb_data:/var/lib/influxdb2 \
  influxdb:2

Використаємо InfluxDB v2.7, хоча вже є версія 3.

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

Note: по ходу гуглінга знайшов цікавий матеріал – What InfluxDB Got Wrong, де як раз говориться про те, що команда InfluxData робить нові версії несумісні з попередніми, і це, звісно, не дуже гуд

Відкриваємо в браузері http://localhost:8086, налаштовуємо юзера, організацію, і дефолтний бакет (про бакети і інші концепти далі):

Відразу отримуємо пропозицію налаштування – “погратись”, advanced, або просто перейти в базу:

Клікаємо Quick start аби отримати якісь базові дані, де нам відразу автоматично налаштовується збір власних метрик InfluxDB і створюється дашборда:

Key concepts

Коротко пройдемось по основних поняттях.

  • Bucket: на відміну від VictoriaMetrics/Prometheus, в InfluxDB дані організовані в такі собі “корзини” або “бази даних”
  • Measurement: це по факту звичні нам з VictoriaMetrics/Prometheus метрики, і метрики (я їх буду назвати саме так, хоча, мабуть, це не дуже коректно з технічної точки зору) складаються з:
    • Tags: labels для метрик, індексуються для швидкого пошуку
    • Fields: поля зі значеннями, не індексуються
    • Timestamp: час додавання метрики
  • Point: конкретний запис (метрика + теги + значення + час), аналог Sample або data points в термінах VictoriaMetircs/Prometheus
  • Series: група записів (метрика + теги + значення), аналог Time Series в термінах VictoriaMetircs/Prometheus

Формат метрик відрізняється від VictoriaMetrics/Prometheus і записується в форматі line protocol.

Наприклад, у VictoriaMetircs запис може виглядати так:

node_cpu_seconds_total{cpu="0", mode="user"}  120.5

А в InfluxDB він буде таким:

node_cpu_seconds_total,cpu=0,mode=user value=120.5 1735156800000000000

Тут в InfluxDB метриці маємо власне ім’я метрики node_cpu_seconds_total, два теги зі значеннями – cpu=0,mode=user, поле value зі значенням, і timestamp.

Timestamp можна задавати в UNIX epoch, можна в ISO 8601, тобто 2025-10-25T12:00:00Z, але рекомендований і дефолтний формат – саме UNIX.

Доступ до InfluxDB

Тут маємо на вибір сам UI і Data Exporter, CLI-утиліту influx, та InfluxDB HTTP API для всякої автоматизації.

influx CLI

Документація – influx – InfluxDB command line interface.

З influx можемо працювати з контейнера:

$ docker exec -ti influxdb influx --help
NAME:
   influx - Influx Client

USAGE:
   influx [command]

HINT: If you are looking for the InfluxQL shell from 1.x, run "influx v1 shell"

COMMANDS:
   version              Print the influx CLI version
   write                Write points to InfluxDB
   bucket               Bucket management commands
...

Або встановити локально:

$ sudo pacman -S influx-cli

Створюємо токен:

Задаємо його в змінні:

$ export INFLUX_TOKEN="0S_4Co9XTA73SzwUQvbXsEUGKcjhhGWiBLobEOnH-kcmtOwMpbe-kyMrs2vFUcbg27WtneYhmILL7paWAuc8Ow=="

Налаштовуємо підключення:

$ influx config create --config-name test-local --host-url http://localhost:8086 --org setevoy --token $INFLUX_TOKEN  --active
Active  Name            URL                     Org
*       test-local      http://localhost:8086   setevoy

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

$ influx bucket list
ID                      Name            Retention       Shard group duration    Organization ID         Schema Type
88e39083ae738103        _monitoring     168h0m0s        24h0m0s                 7f284740b8e4ebfa        implicit
f7e383b1a2366840        _tasks          72h0m0s         24h0m0s                 7f284740b8e4ebfa        implicit
0d1b2da0ccaea8cf        testing_bucket  infinite        168h0m0s                7f284740b8e4ebfa        implicit

HTTP API

Документація – InfluxDB HTTP API.

Тут можна просто з curl, передавши токен:

$ curl -s --request GET "http://localhost:8086/api/v2/buckets" --header "Authorization: Token $INFLUX_TOKEN"

Результат:

Інтерфейс

Зліва маємо основне меню:

Load Data

В Load Data: все про дані:

  • Sources: завантажити з файлів або CLI, записати з клієнтів тощо
  • Buckets: менеджмент “баз даних”
  • Telegraf: створення конфігурації для агенту (“експортеру”) для збору метрик  (тільки конфіг, сам Telegraf запускаємо окремо)
  • Scrapers: InfluxDB з другої версії додала можливість самій отримувати дані із зовнішніх ресурсів, фактично як ми це маємо з VictoriaMetircs/Prometheus
  • API Tokens: вже бачили – менеджмент токенів

Data Explorer

Дуже нагадує Kibana – зручний інтерфейс для простої побудови запитів і візуалізації даних:

Notebooks

Документація – Overview of notebooks.

Дуже цікава фішка, аналог Jupyter Notebook – “жива” аналітика, експерименти із запитами, автоматизація запитів:

Дозволяє зберігати послідовності, які потім можна використати в InfluxDB Tasks.

Кожен Notebook розбитий на кілька cell, які можуть бути data source для отримання даних, visualization для графіків, і action – створити алерт або Task.

Dashboards

Дашборди 🙂

Тут вже з коробки маємо одну готову:

Де можемо редагувати візуалізації:

І де я перший раз побачив Flux:

Виглядає… Складно 🙂

Але можемо переключитись на Query builder:

А потім знов повернутись до коду:

Tasks

Документація – Get started with InfluxDB tasks.

Такі собі ETL-джоби по крону.

Приймають дані, виконують модифікацію, зберігають в корзині.

Наприклад, код (ChatGPT непогано генерить):

option task = {name: "copy_http_api_metrics", every: 5s}

data =
    from(bucket: "testing_bucket")
        |> range(start: -1h)
        |> filter(fn: (r) => r._measurement == "http_api_requests_total")
        |> set(key: "example_tag", value: "demo")

data |> to(bucket: "new_bucket", org: "setevoy")

Тут:

  • реєструємо таску з ім’ям copy_http_api_metrics
  • яка зчитує дані з бакету testing_bucket
  • звідки вибирає метрику http_api_requests_total
  • додає до кожного запису новий тег example_tag="demo"
  • і зберігає результат в інший бакет – new_bucket

Зацініть сам редактор! Навіть помилки показує:

Таска пошла виконуватись:

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

Alerts

Вбудована система алертів:

Цікаво, що відразу є алерти двох типів – Threshold для “стандартних” алертів, і Deadman – якщо сервіс перестає надсилати дані.

В Cheks на першому етапі задаються самі умови перевірки:

А на другому – значення, при яких алерт буде спрацьовувати, при чому відразу різні severity:

В Notification Enpoints можна задати куди відправляти:

А в Notification Rules задаємо куди відправляти, як часто повторювати тощо – але в мене нема ендпоінтів, тому пропустимо.

Виглядає прям дуже круто.

Settings

Тут можемо задати глобальні змінні для використання у своїх запитах чи дашбордах:

Створити шаблони:

При чому шаблони – це не тільки про дашборди і візуалізації, а буквально будь-що, що ми налаштовуємо в InfluxDB.

І Secrets – як змінні, тільки їх значення не буде видно:

Додавання даних

ОК, з інтерфейсом розібрались – давайте запишемо щось в базу.

Додавання і читання метрик з influx CLI

З CLI – influx write:

$ influx write \
  --bucket testing_bucket \
  --org setevoy \
  --precision s \
  "example_requests_total,handler=platform,method=GET value=42 $(date +%s)"

Отримуємо її обратно з influx query:

influx query '
from(bucket: "testing_bucket")
  |> range(start: -1h)
  |> filter(fn: (r) => r._measurement == "example_requests_total")
'

Результат:

Додавання метрик через HTTP API

Робимо з curl:

$ curl -X POST "http://localhost:8086/api/v2/write?org=setevoy&bucket=testing_bucket&precision=s" \
  -H "Authorization: Token $INFLUX_TOKEN" \
  --data-raw "api_example_requests_total,handler=platform,method=GET value=42 $(date +%s)"

Отримуємо значення в JSON:

$ curl -X POST "http://localhost:8086/api/v2/query?org=setevoy" \
  -H "Authorization: Token $INFLUX_TOKEN" \
  -H "Content-Type: application/vnd.flux" \
  -H "Accept: application/json" \
  --data-binary 'from(bucket: "testing_bucket")
    |> range(start: -1h)
    |> filter(fn: (r) => r._measurement == "api_example_requests_total")'

Результат:

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

Metrics

Насправді доволі потужний інструмент з купою плагінів, але для прикладу зберемо метрики CPU з хоста:

Зберігаємо:

І навіть отримуємо інструкції як запустити:

Прямо при запуску ми в Telegraf передаємо URL з конфігом – і він отримає саме ті налаштування, які ми робили на попередньому екрані, тобто нам взагалі не треба писати локальний telegraf.conf.

Це прям якась кілер-фіча.

Встановлюємо клієнт:

$ yay -S telegraf

Запускаємо:

$ export INFLUX_TOKEN=CMmL9cSOiukwFpWF0hNuVoCOML9XC80mQxUukMhOO8XIM8vOGxCneUYpM-2wuOXonSx9gbZKc73pq-SqRn59_w==
$ telegraf --config http://localhost:8086/api/v2/telegrafs/0fb2cd69daf77000
2025-10-25T12:00:12Z I! Loading config: http://localhost:8086/api/v2/telegrafs/0fb2cd69daf77000
2025-10-25T12:00:12Z I! Starting Telegraf unknown brought to you by InfluxData the makers of InfluxDB
2025-10-25T12:00:12Z I! Available plugins: 239 inputs, 9 aggregators, 35 processors, 26 parsers, 65 outputs, 6 secret-stores
2025-10-25T12:00:12Z I! Loaded inputs: linux_cpu
2025-10-25T12:00:12Z I! Loaded aggregators:
2025-10-25T12:00:12Z I! Loaded processors:
2025-10-25T12:00:12Z I! Loaded secretstores:
2025-10-25T12:00:12Z I! Loaded outputs: influxdb_v2
2025-10-25T12:00:12Z I! Tags enabled: host=setevoy-work
2025-10-25T12:00:12Z I! [agent] Config: Interval:10s, Quiet:false, Hostname:"setevoy-work", Flush Interval:10s
...

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

Можемо звідси відразу зберегти в нову дашборду:

Logs

Аналогічно можемо збирати логи з Telegraf inputs.tail:

Задаємо файл, формат і власні теги:

...
# Parse the new lines appended to a file
[[inputs.tail]]
  files = ["/var/log/firewalld"]
  from_beginning = true

  data_format = "grok"
  grok_patterns = ["%{GREEDYDATA:message}"]

  [inputs.tail.tags]
    source = "firewalld"
    env = "testing"
    example_tag = "demo"

Запускаємо від рута, бо /var/log/firewalld недоступний від звичайного юзера:

[root@setevoy-work Influx]# export INFLUX_TOKEN=-uYQA4L2F7EnT5dcaYKkN7o5aF-mnjTBfTf7gHV-LgDuRguOkO8yL_w6liJY8y5HG8eATCg7MxZrrRGS2035fA==
[root@setevoy-work Influx]# telegraf --config http://localhost:8086/api/v2/telegrafs/0fb2d88892777000 --debug
2025-10-25T12:43:09Z I! Loading config: http://localhost:8086/api/v2/telegrafs/0fb2d88892777000
2025-10-25T12:43:09Z I! Starting Telegraf unknown brought to you by InfluxData the makers of InfluxDB
2025-10-25T12:43:09Z I! Available plugins: 239 inputs, 9 aggregators, 35 processors, 26 parsers, 65 outputs, 6 secret-stores
2025-10-25T12:43:09Z I! Loaded inputs: tail
...
2025-10-25T12:43:19Z D! [outputs.influxdb_v2] Wrote batch of 146 metrics in 20.649008ms
...

І перевіряємо – включаємо Agregate function == sort, і View raw data:

Власне, на цьому поки все.

Щось якось дійсно в захваті 🙂

Для когось self-monitoring – класна система, де не треба нічого зайвого.

Але треба все ж дивитись на ресурси, бо навіть оцей мінімальний сетап вже єсть 250 метрів пам’яті:

Ну і 100% є якісь підводні камені, які можна побачити вже в продакшені.

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

 

Loading