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

Arize Phoenix: сервіс моніторингу LLM – запуск в Kubernetes
0 (0)

23 Жовтня 2025

Прийшла задачка підняти для проекту цікавий сервіс Arize Phoenix для моніторингу і тюнингу використання LLM.

За сам сервіс багато не скажу, бо не користувався, але його запуск вийшов доволі цікавим.

Що будемо робити – спочатку з Helm запустимо тестовий варіант, подивитись як воно взагалі виглядає, потім зробимо повноцінну автоматизацію – Terraform для всяких сікретів, Helm для самого Phoenix.

Власне цей пост буде не стільки про сам Arize Phoenix, скільки просто приклад як з Terraform створити AWS Secrets, і як з Helm та External Secrets Operator ці сікрети отримати.

Тестовий запуск з Helm в Kubernetes

Phoenix підтримує різні варіанти запуску. нам цікавий Helm, документація тут – Kubernetes (helm).

Сам чарт є в Docker Hub (і далі це трохи вилізе боком), всі values є там жеж.

Можемо спулити чарт собі локально:

$ helm pull $CHART_URL 
Pulled: registry-1.docker.io/arizephoenix/phoenix-helm:4.0.4
Digest: sha256:c5692ed16ea9de346e91181c1afc2a0294af0b7f9e3dc3e13d663ee4a00ace1e

Розпаковуваємо:

$ tar xfp phoenix-helm-4.0.4.tgz

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

Або шукаємо в GitHub тут>>>.

Створюємо Kubernetes Namespace:

$ kk create ns test-phoenix-ns
namespace/test-phoenix-ns created

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

$ export CHART_URL=oci://registry-1.docker.io/arizephoenix/phoenix-helm
$ helm -n test-phoenix-ns install phoenix $CHART_URL --debug

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

$ kk get all
NAME                           READY   STATUS    RESTARTS      AGE
pod/phoenix-8677bcc44f-k8w2k   1/1     Running   1 (49s ago)   70s
pod/phoenix-postgresql-0       1/1     Running   0             70s

NAME                         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)                                        AGE
service/phoenix-postgresql   ClusterIP   172.20.11.177   <none>        5432/TCP                                       70s
service/phoenix-svc          NodePort    172.20.85.64    <none>        4317:31314/TCP,6006:31180/TCP,9090:31897/TCP   70s

NAME                      READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/phoenix   1/1     1            1           70s

NAME                                 DESIRED   CURRENT   READY   AGE
replicaset.apps/phoenix-8677bcc44f   1         1         1       70s

NAME                                  READY   AGE
statefulset.apps/phoenix-postgresql   1/1     71s

По дефолту використовує власний контейнер з PostgreSQL, для Production будемо робити в AWS RDS.

Відкриваємо доступ до порту для WebUI:

$ kk port-forward service/phoenix-svc 6006

Переходимо в браузері на http://localhost:6006, логінимось.

Дефолтний логін – admin@localhost, пароль – admin.

Документація по аутентифікація – тут>>>, і там є цікаві моменти. наприклад, змінити пошту для адміна (і для Member? тобто для звичайних юзерів? не пробував) не міжна:

Neither an Admin nor Member is permitted to change email addresses.

ОК, воно працює – давайте думати про продакшен сетап.

AWS та Terraform

Що нам треба буде з ресурсів в AWS:

  • запис Route 53 з доменом для доступу юзерів
  • TLS сертифікат в AWS Certificate Manager
  • AWS Secrets Manager:
    • пароль для доступу до Postgres
    • два паролі для самого Phoenix
    • пароль для SMTP – навіть якщо він не використовується

Готуємо файл backend.tf:

terraform {
  backend "s3" {
    bucket       = "tf-state-backend-atlas-phoenix"
    use_lockfile = true
    region       = "us-east-1"
    encrypt      = true
  }
}

Готуємо файли variables.tf, providers.tf, versions.tf, outputs.tf.

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

$ tree .
.
├── Makefile
├── backend.tf
├── data.tf
├── envs
│   └── ops
│       └── ops-1-33.tfvars
├── outputs.tf
├── providers.tf
├── variables.tf
└── versions.tf

Тут “ops” – це ім’я AWS-оточення, а в ops-1-33.tfvars значення специфічні для поточного кластеру AWS Elasctic Kubernetes Service.

Запис в AWS Route 53

Додаємо нову змінну:

variable "dns_zone" {
  description = "AWS Route 53 zone for the AWS Ops environment"
  type        = string
  default = "ops.example.co"
}

В файл data.tf додаємо отримання інформації про зону:

data "aws_route53_zone" "ops" {
  name = var.dns_zone
}

Для запису в Route 53 треба буде створити CNAME на AWS Application Load Balancer.

У нас використовується один external ALB для всіх сервісів в Kubernetes, див. Kubernetes: єдиний AWS Load Balancer для різних Kubernetes Ingress.

Тому просто отримаємо інформацію по ньому з ще одним ресурсом data.

Додаємо змінну з іменем ALB:

variable "aws_alb_name" {
  description = "AWS EKS Shared Load Balancer name specific to EKS Environment"
  type        = string
}

Додаємо значення в ops-1-33.tfvars:

aws_alb_name = "k8s-ops133externalalb-***"

І додаємо data:

data "aws_lb" "shared_alb" {
  name = var.aws_alb_name
}

В файлі locals.tf створимо нову local з повним іменем:

locals {
  # 'phoenix.ops.example.co'
  phoenix_domain_name = "phoenix.${var.dns_zone}"
}

І тепер можемо описати новий record в Route 53:

resource "aws_route53_record" "phoenix_dns" {
  zone_id = data.aws_route53_zone.ops.zone_id
  name    = local.phoenix_domain_name
  type    = "CNAME"
  ttl     = 300
  records = [ 
    data.aws_lb.shared_alb.dns_name
  ]
}

Виконуємо terraform init та terraform plan, перевіряємо, що все ок:

...
Terraform will perform the following actions:

  # aws_route53_record.phoenix_dns will be created
  + resource "aws_route53_record" "phoenix_dns" {
      + allow_overwrite = (known after apply)
      + fqdn            = (known after apply)
      + id              = (known after apply)
      + name            = "phoenix.ops.example.co"
      + records         = [
          + "k8s-ops133externalalb-***.us-east-1.elb.amazonaws.com",
        ]
      + ttl             = 300
      + type            = "CNAME"
      + zone_id         = "Z02***OYY"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Сертифікат в AWS ACM

Далі для Ingress та ALB нам потрібно створити сертифікат під цей DNS:

module "ops_phoenix_acm" {
  source  = "terraform-aws-modules/acm/aws"
  version = "~> 6.0"

  # 'phoenix.ops.example.co'
  domain_name = local.phoenix_domain_name
  zone_id     = data.aws_route53_zone.ops.zone_id

  validation_method = "DNS"
  wait_for_validation = true

  tags = {
    Name = local.phoenix_domain_name
  }
}

Записи в AWS Secrets Manager

Сікретів для Phoenix нас буде кілька:

  • PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD: пароль при сетапі
  • PHOENIX_ADMIN_SECRET: пароль після сетапу
    • чесно тут не дуже зрозумів, бо навіть якщо відразу створити і передати PHOENIX_ADMIN_SECRET – то перший логін все одно буде з PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD
  • PHOENIX_SECRET: для підпису JWT-токенів (писав давно, але все ще актуально – Kubernetes: ServiceAccounts, JWT-tokens, authentication, and RBAC authorization)
  • PHOENIX_POSTGRES_PASSWORD: пароль доступу до сервера баз даних

Сікрети в AWS будемо робити з ephemeral та write-only, див. Terraform: використання Ephemeral resources та Write-only attributes.

Описуємо перший сікрет:

# auth.defaultAdminPassword or PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD
# PHOENIX_ADMIN_SECRET
# PHOENIX_SECRET: A long string value that is used to sign JWTs for your deployment.
# PHOENIX_POSTGRES_PASSWORD
# PHOENIX_SMTP_PASSWORD

##############################################
### PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD ###
##############################################

# generate a random password
ephemeral "random_password" "ops_phoenix_default_admin_initail_secret_random_password" {
  length  = 12
  special = true
}
# create an AWS Secret resource
resource "aws_secretsmanager_secret" "ops_phoenix_default_admin_initial_secret" {
  name                    = "/ops/phoenix/phoenix_default_admin_initial_secret"
  description             = "Default Phoenix admin username and password"
  recovery_window_in_days = 0
}
# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "ops_phoenix_default_admin_initial_secret_version" {
  secret_id                = aws_secretsmanager_secret.ops_phoenix_default_admin_initial_secret.id
  secret_string_wo         = ephemeral.random_password.ops_phoenix_default_admin_initail_secret_random_password.result
  secret_string_wo_version = 1
}

Деплоїмо, перевіряємо Route 53, ACM та Secrets Manager:

Повторюємо для решти – вони всі більш-менш однакові, тільки в деяких просто пароль, в деяких логін:пароль в JSON, і різна довжина.

Бо, наприклад, для PHOENIX_ADMIN_SECRET є перевірка на кількість символів:

...
atlas-phoenix-6865f69ffc-k7hwl:phoenix   File "/phoenix/env/phoenix/config.py", line 772, in get_env_phoenix_admin_secret
atlas-phoenix-6865f69ffc-k7hwl:phoenix     REQUIREMENTS_FOR_PHOENIX_SECRET.validate(phoenix_admin_secret, "Phoenix secret")
atlas-phoenix-6865f69ffc-k7hwl:phoenix   File "/phoenix/env/phoenix/auth.py", line 255, in validate
atlas-phoenix-6865f69ffc-k7hwl:phoenix     raise ValueError(err_text)
atlas-phoenix-6865f69ffc-k7hwl:phoenix ValueError: Phoenix secret must be at least 32 characters long
....

Описуємо ресурси:

...

############################
### PHOENIX_ADMIN_SECRET ###
############################

# generate a random password
ephemeral "random_password" "ops_phoenix_admin_secret_random_password" {
  length  = 32
  special = true
}
# create an AWS Secret resource
resource "aws_secretsmanager_secret" "ops_phoenix_admin_secret" {
  name                    = "/ops/phoenix/phoenix_admin_secret"
  description             = "Phoenix admin username and password"
  recovery_window_in_days = 0
}
# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "ops_phoenix_admin_secret_version" {
  secret_id = aws_secretsmanager_secret.ops_phoenix_admin_secret.id
  secret_string_wo = jsonencode({
    login    = "admin@localhost"
    password = ephemeral.random_password.ops_phoenix_admin_secret_random_password.result
  })
  secret_string_wo_version = 3
}

######################
### PHOENIX_SECRET ###
######################

# generate a random password
ephemeral "random_password" "ops_phoenix_secret_random_password" {
  length  = 65
  special = false
}
# create an AWS Secret resource
resource "aws_secretsmanager_secret" "ops_phoenix_secret" {
  name                    = "/ops/phoenix/phoenix_secret"
  description             = "Phoenix secret string used to sign JWTs"
  recovery_window_in_days = 0
}
# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "ops_phoenix_secret_version" {
  secret_id                = aws_secretsmanager_secret.ops_phoenix_secret.id
  secret_string_wo         = ephemeral.random_password.ops_phoenix_secret_random_password.result
  secret_string_wo_version = 1
}

#################################
### PHOENIX_POSTGRES_PASSWORD ###
#################################

# generate a random password
ephemeral "random_password" "ops_phoenix_postgres_random_password" {
  length  = 12
  special = false
}
# create an AWS Secret resource
resource "aws_secretsmanager_secret" "ops_phoenix_postgres_credentials" {
  name                    = "/ops/phoenix/phoenix_postgres_credentials"
  description             = "Phoenix PostgreSQL username and password"
  recovery_window_in_days = 0
}
# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "ops_phoenix_postgres_credentials_version" {
  secret_id                = aws_secretsmanager_secret.ops_phoenix_postgres_credentials.id
  secret_string_wo         = ephemeral.random_password.ops_phoenix_postgres_random_password.result
  secret_string_wo_version = 3
}

#############################
### PHOENIX_SMTP_PASSWORD ###
#############################

# generate a random password
ephemeral "random_password" "ops_phoenix_smtp_password_random_password" {
  length  = 12
  special = false
}
# create an AWS Secret resource
resource "aws_secretsmanager_secret" "ops_phoenix_smtp_password" {
  name                    = "/ops/phoenix/ops_phoenix_smtp_password"
  description             = "Phoenix secret string used to sign JWTs"
  recovery_window_in_days = 0
}
# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "ops_phoenix_smtp_password_version" {
  secret_id                = aws_secretsmanager_secret.ops_phoenix_smtp_password.id
  secret_string_wo         = ephemeral.random_password.ops_phoenix_smtp_password_random_password.result
  secret_string_wo_version = 2
}

З Terraform все, можемо готувати базу Postgres.

PostgreSQL user and database

Сервер у нас вже є, тому зараз просто створити базу і юзера.

Підключаємось до RDS:

$ export PGPASSWORD='***'
$ psql -h db.monitoring.ops.example.co -U ops_monitoring_user -d ops_grafana_db
psql (17.6, server 16.8)
...
ops_grafana_db=> 

Створюємо юзера, базу, даємо повний доступ до цієї бази:

ops_grafana_db=> CREATE USER ops_phoenix_user WITH PASSWORD '***';
CREATE ROLE
ops_grafana_db=> CREATE DATABASE ops_phoenix_db OWNER ops_phoenix_user;
CREATE DATABASE
ops_grafana_db=> GRANT ALL PRIVILEGES ON DATABASE ops_phoenix_db TO ops_phoenix_user;
GRANT

І тепер саме цікаве – Helm.

Деплой Helm

Для отримання паролів з AWS Secrets Manager будемо використовувати External Secrets Operator (див. AWS: Kubernetes та External Secrets Operator для AWS Secrets Manager), для цього нам треба буде в чарт додати власні файли.

Тому робимо новий чарт в якому через Helm Dependency використовуємо чарт Arize Phoenix.

Описуємо Chart.yaml – і отут буде проблема з Docker Hub, див. далі.

Пишемо файл:

apiVersion: v2
name: atlas-phoenix
description: A Helm chart for Arize Phoenix stack
type: application
version: 0.1.1
appVersion: "1.17.0"
dependencies:
- name: phoenix
  version: ~4.0
  repository: oci://registry-1.docker.io/arizephoenix/phoenix-helm

Тепер робимо helm dependency update, і ловимо “response status code 401” від Docker Hub:

...
Update Complete. ⎈Happy Helming!⎈
Error: could not retrieve list of tags for repository oci://registry-1.docker.io/arizephoenix/phoenix-helm: GET "https://registry-1.docker.io/v2/arizephoenix/phoenix-helm/phoenix/tags/list": response status code 401: unauthorized: authentication required: [map[Action:pull Class: Name:arizephoenix/phoenix-helm/phoenix Type:repository]]

Тому що Helm при dependency update намагається отримати всі доступні теги з tags/list, а в Docker Hub для цього потрібно залогінитись.

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

Пишемо Makefile в якому додаємо таргет на helm pull oci://:

helm-oci-pull:
  mkdir -p charts/ && cd charts/ && helm pull oci://registry-1.docker.io/arizephoenix/phoenix-helm \
  --version 4.0.4 \
  --untar

helm-template-ops-1-33:
  helm -n ops-phoenix-ns template .

Редагуємо Chart.yml, в repository задаємо значення з file://charts/:

apiVersion: v2
name: atlas-phoenix
description: A Helm chart for Arize Phoenix stack
type: application
version: 0.1.1
appVersion: "1.17.0"
dependencies:
- name: phoenix-helm
  repository: file://charts/phoenix

Пулимо чарт:

$ make helm-oci-pull 
mkdir -p charts/ && cd charts/ && helm pull oci://registry-1.docker.io/arizephoenix/phoenix-helm \
--version 4.0.4 \
--untar
Pulled: registry-1.docker.io/arizephoenix/phoenix-helm:4.0.4
Digest: sha256:c5692ed16ea9de346e91181c1afc2a0294af0b7f9e3dc3e13d663ee4a00ace1e

І перевіримо, що все нормально рендериться:

$ make helm-template-ops-1-33 
helm -n ops-phoenix-ns template .
---
# Source: atlas-phoenix/charts/phoenix-helm/charts/postgresql/templates/secureconfig.yaml
apiVersion: v1
kind: Secret
metadata:
  name: release-name-postgresql
  labels:
    helm.sh/chart: postgresql-1.5.8
    app.kubernetes.io/name: postgresql
    app.kubernetes.io/instance: release-name
    app.kubernetes.io/version: "17.6"
    app.kubernetes.io/managed-by: Helm
...

Додавання values

Створюємо директорії і файл з параметрами для поточного кластеру EKS 1.33:

$ mkdir -p values/ops
$ touch values/ops/atlas-phoenix-ops-1-33-values.yaml

Заносимо значення – і власні, далі їх будемо використовувати, і для phoenix-helm:

aws:
  region: "us-east-1"

config:
  env: "ops"

phoenix-helm:
  auth:
    # Kubernetes Secret name
    name: phoenix-secret

Kubernetes Secrets з External Secrets Operator

Створюємо каталог для власних файлів і файл для ESO SecretStore:

$ mkdir templates/
$ touch templates/secretstore.yaml

Описуємо SecretStore та ExternalSecret, який створить Kubernetes Secret з ім’ям phoenix-secret:

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: phoenix-secret-store
spec:
  provider:
    aws:
      service: SecretsManager
      region: {{ .Values.aws.region }}
---
# the ExternalSecret resource is used to:
# 1. authentificate in AWS using the SecretStore defined above
# 2. get data from the AWS ParameterStore
# 3. create a Kubernetes Secret
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: phoenix-external-secret
spec:
  refreshInterval: 5m
  secretStoreRef:
    name: phoenix-secret-store
    kind: SecretStore
  target:
    # Kubernetes Secret name
    # will be mounted to Poenix Pods
    # .Values.phoenix.auth.name
    name: phoenix-secret
    creationPolicy: Owner
    deletionPolicy: Delete
  data:
    # key in the Kubernetes Secret
    # i.e. the variable name in a Pod

    - secretKey: PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD
      remoteRef:
        key: "/{{ .Values.config.env }}/phoenix/phoenix_default_admin_initial_secret"

    - secretKey: PHOENIX_ADMIN_SECRET
      remoteRef:
        key: "/{{ .Values.config.env }}/phoenix/phoenix_admin_secret"
        property: password

    - secretKey: PHOENIX_SECRET
      remoteRef:
        key: "/{{ .Values.config.env }}/phoenix/phoenix_secret"

    - secretKey: PHOENIX_POSTGRES_PASSWORD
      remoteRef:
        key: "/{{ .Values.config.env }}/phoenix/phoenix_postgres_credentials"

    # make it empty
    - secretKey: PHOENIX_SMTP_PASSWORD
      remoteRef:
        key: "/{{ .Values.config.env }}/phoenix/ops_phoenix_smtp_password"

Створюємо Kubernetes Namespace:

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

Деплоїмо чарт і перевіряємо ресурси – SecretStore:

 $ kk get SecretStore phoenix-secret-store
NAME                   AGE   STATUS   CAPABILITIES   READY
phoenix-secret-store   14m   Valid    ReadWrite      True

ExternalSecret:

$ kk get externalsecret
NAME                      STORE                  REFRESH INTERVAL   STATUS         READY
phoenix-external-secret   phoenix-secret-store   5m                 SecretSynced   True

Та Kubernetes Secret:

$ kk get secret
NAME                                  TYPE                 DATA   AGE
phoenix-secret                        Opaque               1      2m15s

Перевіряємо дані в ньому:

$ kk get secret phoenix-secret -o yaml
apiVersion: v1
data:
  PHOENIX_ADMIN_SECRET: RnB***lY=
  PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD: P0Z***Tp6
  PHOENIX_POSTGRES_PASSWORD: TWo***Uty
  PHOENIX_SECRET: OVJ***FI=
  PHOENIX_SMTP_PASSWORD: NXR***VlK
kind: Secret
...

Отримуємо реальні значення з base64 -d:

$ echo NXR***VlK | base64 -d
5tgdKoDr9YYJ

Звіряємо з даними в AWS Secrets Manager.

Підключення до PostgreSQL

В values додаємо параметри для Postgres:

...
phoenix-helm:
  auth:
    # Kubernetes Secret name
    name: phoenix-secret
  # use AWS RDS instead of deploying local
  postgresql:
    enabled: false

  database:
    postgres:
      host: db.monitoring.ops.example.co
      user: ops_phoenix_user
      db: ops_phoenix_db
...

Деплоїмо, перевіряємо:

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

Сам Ingress enabled by default, тому нам треба тільки додати атрибути, через які він “замапиться” на наш загальний AWS Application Load Balancer через анотацію alb.ingress.kubernetes.io/group.name.

Але і тут є нюанс: в чарті нема можливості задати spec.ingressClassName="alb".

Тому робимо трохи deprecated way, теж через annotations:

...
  ingress:
    enabled: true
    host: phoenix.ops.example.co
    tls:
      enabled: true
    annotations:
      alb.ingress.kubernetes.io/group.name: ops-1-33-external-alb
      alb.ingress.kubernetes.io/target-type: ip
      alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:492***148:certificate/e7145895-9506-4683-a56a-ba6bf98596c5
      alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}, {"HTTP":80}]'
      alb.ingress.kubernetes.io/actions.ssl-redirect: '{"Type": "redirect", "RedirectConfig": { "Protocol": "HTTPS", "Port": "443", "StatusCode": "HTTP_301"}}'
      kubernetes.io/ingress.class: alb
...

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

Все завелось, все (поки що) працює.

Перший логін робимо з паролем PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD, далі Phoenix запросить його змінити – задаємо наш із PHOENIX_ADMIN_SECRET, віддаємо девелоперам на погратись:

Готово.

Loading

Kubernetes: моніторинг процесів з process-exporter
0 (0)

22 Вересня 2025

Дебажимо одну проблему з використанням пам’яті в Kubernetes Pods, і вирішили подивитись на пам’ять і кількість процесів на нодах.

Сама проблема полягає в тому, що зазвичай Kubernetes Pod з Livekit споживає близько 2 гігабайт пам’яті, але іноді бувають спайки до 10-11 гіг, через що под вбивається:

Що ми хочемо визначити: це один процес починає стільки пам’яті “їсти” – чи просто створюється багато процесів в контейнері?

Самий простий варіант тут – використати Prometheus Process Exporter, який запускається у вигляді DaemonSet, на кожній WorkerNode створює власний контейнер, і для всіх чи обраних процесів на EC2 збирає статистику з /proc.

Є непоганий (і працюючий) Helm-чарт kir4h/process-exporter, візьмемо його.

Запуск Process Exporter

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

$ helm repo add kir4h https://kir4h.github.io/charts
$ helm install my-process-exporter kir4h/process-exporter

Або в нашому випадку – встановлюємо через Helm dependency – додаємо чарт до Chart.yaml чарту нашого стеку моніторинга:

...
- name: process-exporter
  version: ~1.0
  repository: https://kir4h.github.io/charts
  condition: process-exporter.enabled

Додаємо values для нього:

...
process-exporter:
  enabled: true
  tolerations:
  - effect: NoSchedule
    operator: Exists
  - key: CriticalAddonsOnly
    operator: Exists
    effect: NoSchedule
  - key: CriticalAddonsOnly

Деплоїмо, перевіряємо DaemonSet:

$ kk get ds
NAME                                             DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR            AGE
atlas-victoriametrics-process-exporter           9         9         9       9            9           <none>                   76m
...

І перевіряємо ServiceMonitor:

$ kk get serviceMonitor | grep process
atlas-victoriametrics-process-exporter                   3d3h

Для VictoriaMetrcis автоматично створюється VMServiceScrape:

$ kk get VMServiceScrape | grep process
atlas-victoriametrics-process-exporter                   3d3h   operational

Перевіряємо чи є метрики, наприклад по namedprocess_namegroup_memory_bytes:

Створення Name Groups

Зараз маємо дані по взагалі всім процесам – нам це не треба.

Конкретно в нашому випадку нас цікавить статистика по процесам нашого Backend API, процеси Python.

У нас їх три основних – сам Backend API, Celery Workers, та власне Livekit, і кожен сервіс запускається у власних Pods з окремих Deployments.

Знаходимо процеси  в подах, дивимось як саме вони запущені.

Backend API:

root@backend-api-deployment-5695989cb5-rjhv9:/app# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.2  40348 34712 ?        Ss   07:59   0:02 /usr/local/bin/python /usr/local/bin/gunicorn challenge_backend.run_api:app [...]
root           7  1.2  2.5 2075368 414564 ?      Sl   07:59   1:32 /usr/local/bin/python /usr/local/bin/gunicorn challenge_backend.run_api:app [...]
root           8  1.1  2.6 1999384 422228 ?      Sl   07:59   1:23 /usr/local/bin/python /usr/local/bin/gunicorn challenge_backend.run_api:app [...]
root           9  1.2  2.6 2002492 429192 ?      Sl   07:59   1:30 /usr/local/bin/python /usr/local/bin/gunicorn challenge_backend.run_api:app [...]
...

Celery workers:

root@backend-celery-workers-deployment-5bc64557c8-zbq2j:/app# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.2  1.4 544832 236720 ?       Ss   07:27   0:24 /usr/local/bin/python /usr/local/bin/celery -A celery_app.app worker [...]
...

Та Livekit:

root@backend-livekit-agent-deployment-7d9bf86564-qgjzb:/app# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.4  1.8 2112944 294772 ?      Ssl  07:06   0:46 python -m cortex.livekit_agent.main start
root          24  0.0  0.0  15788 12860 ?        S    07:06   0:00 /usr/local/bin/python -c from multiprocessing.resource_tracker import main;main(34)
root          25  0.0  0.6 342976 102852 ?       S    07:06   0:02 /usr/local/bin/python -c from multiprocessing.forkserver import main [...]
...

Додаємо конфіг для process-exporter – описуємо nameMatchers:

...
process-exporter:
  enabled: true
  tolerations:
    operator: Exists
    effect: NoSchedule
  - key: CriticalAddonsOnly
  config:
    # metrics will be broken down by thread name as well as group name
    threads: true
    # any process that otherwise isn't part of its own group becomes part of the first group found (if any) when walking the process tree upwards
    children: true
    # means that on each scrape the process names are re-evaluated
    recheck: false
    # remove_empty_groups drop empty groups if no processes found
    remove_empty_groups: true
    nameMatchers: 
      # gunicorn (python + uvicorn workers)
      - name: "gunicorn"
        exe:
          - /usr/local/bin/python
        cmdline:
          - ".*gunicorn.*"

      # celery worker
      - name: "celery-worker"
        exe:
          - /usr/local/bin/python
        cmdline:
          - ".*celery.*worker.*"

      # livekit agent
      - name: "livekit-agent"
        exe:
          - python
          - /usr/local/bin/python
        cmdline:
          - ".*cortex.livekit_agent.main.*"

      # livekit multiprocessing helpers
      - name: "livekit-multiproc"
        exe:
          - /usr/local/bin/python
        cmdline:
          - ".*multiprocessing.*"

Тут в exe – список самого executable (можна кілька), а в cmdline – аргументи, з якими процес запущено.

Тобто для Livekit у нас exe – “/usr/local/bin/python“, а cmdline – це “-c from multiprocessing.resource_tracker [...]” або “-c from multiprocessing.forkserver [...]“.

Деплоїмо, і тепер залишилось тільки три групи:

 

Але є нюанси.

Перше – статистика збирається з кожної ноди по всій групі процесів.

Тобто, якщо ми зробимо:

sum(namedprocess_namegroup_memory_bytes{memtype="resident", groupname="celery-worker"}) by (groupname, instance, pod)

То отримаємо суму всіх RSS всіх Celery-воркерів на ноді, де запущений відповідний process-exporter Pod:

А друга проблема – що Process Exporter не має лейбли з іменем WorkerNode, з якої зібрані метрики.

Тому тут тільки шукати вручну – по Pod IP (лейбла instance) можемо знайти його Node:

$ kk get pod -o wide | grep 10.0.45.166
atlas-victoriametrics-process-exporter-4zdzl                      1/1     Running     0              6m51s   10.0.45.166   ip-10-0-40-195.ec2.internal   <none>           <none>

А потім вже дивитись які поди на цій ноді:

$ kk describe node ip-10-0-40-195.ec2.internal | grep celery
  dev-backend-api-ns          backend-celery-workers-deployment-5bc64557c8-hqhz4                 200m (5%)     0 (0%)      1500Mi (10%)     0 (0%)         3h28m
  dev-backend-api-ns          backend-celery-workers-long-running-deployment-57d7cb9984-nlfs4    200m (5%)     0 (0%)      1500Mi (10%)     0 (0%)         3h12m
  prod-backend-api-ns         backend-celery-workers-deployment-5597dfd875-m7c2n                 500m (12%)    0 (0%)      1500Mi (10%)     0 (0%)         99m
  staging-backend-api-ns      backend-celery-workers-long-running-deployment-5bb44795b7-pcmj2    200m (5%)     0 (0%)      1500Mi (10%)     0 (0%)         103m

І на ноді глянемо процеси і їхній RSS:

[root@ip-10-0-40-195 ec2-user]# ps -eo rss,cmd | grep celery
232888 /usr/local/bin/python /usr/local/bin/celery -A celery_app.app worker --loglevel=info -Q default
241656 /usr/local/bin/python /usr/local/bin/celery -A celery_app.app worker --loglevel=info -Q default
...
239232 /usr/local/bin/python /usr/local/bin/celery -A celery_app.app worker --loglevel=info -Q default
252240 /usr/local/bin/python /usr/local/bin/celery -A celery_app.app worker --loglevel=info -Q default
 2416 grep --color=auto celery

На графіку у нас тут 4,604,280,832 байт:

Рахуємо самі:

[root@ip-10-0-40-195 ec2-user]# ps -eo rss,cmd | grep celery | grep -v grep | awk '{sum += $1} END {print sum*1024 " bytes"}'
4608430080 bytes

Повертаючись до питання того, що немає інформації по кожному процесу: ми можемо отримати середнє значення по кожному, бо у нас є метрика namedprocess_namegroup_num_procs:

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

[root@ip-10-0-40-195 ec2-user]# ps aux | grep celery | grep -v grep | wc -l
20

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

sum(namedprocess_namegroup_memory_bytes{memtype="resident", groupname="celery-worker", instance="10.0.45.166:9256"}) by (groupname, instance, pod)
/
sum(namedprocess_namegroup_num_procs{groupname="celery-worker", instance="10.0.45.166:9256"}) by (groupname, instance, pod)

Результат ~230 MB:

Як ми і бачили в ps -eo rss,cmd.

Name Group Template variables та інформація по кожному процесу

Або, якщо нам прям дуже хочеться бачити статистику по кожному процесу – ми можемо використати динамічні імена для groupname з {{.PID}} – тоді для кожного процесу буде формуватись окрема група, див. Using a config file: group name:

...
    nameMatchers: 
      # gunicorn (python + uvicorn workers)
      - name: "gunicorn-{{.Comm}}-{{.PID}}"
        exe:
          - python
          - /usr/bin/python
          - /usr/local/bin/python
        cmdline:
          - ".*gunicorn.*"

      # celery worker
      - name: "celery-worker-{{.Comm}}-{{.PID}}"
        exe:
          - python
          - /usr/bin/python
          - /usr/local/bin/python
        cmdline:
          - ".*celery.*worker.*"

      # livekit agent
      - name: "livekit-agent-{{.Comm}}-{{.PID}}"
        exe:
          - python
          - /usr/bin/python
          - /usr/local/bin/python
        cmdline:
          - ".*livekit_agent.*"

      # livekit multiprocessing helpers
      - name: "livekit-multiproc-{{.Comm}}-{{.PID}}"
        exe:
          - python
          - /usr/bin/python
          - /usr/local/bin/python
        cmdline:
          - ".*multiprocessing.*"

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

Але цей варіант ОК тільки для якщо вам треба щось подебажити, і відключити, бо призведе до High cardinality issue.

Результат нашого дебагу

Власне, що нам потрібно було дізнатись – пам’ять “утікає” в якомусь одному процесі, чи просто створюється багато процесів в одному Pod?

Для цього в Grafana зробили графік із запитом:

sum(namedprocess_namegroup_memory_bytes{memtype="resident", groupname=~"livekit-multiproc-.*"}) by (groupname, instance)

До нього додали графіки з метриками самого Livekit – lk_agents_active_job_count та lk_agents_child_process_count, і окремо – графік з VictoriaLogs, де виводимо кількість API-запитів кожного юзера по полю token_email:

namespace: "prod-backend-api-ns" "GET /cortex/livekit-token" | unpack_json fields (token_email) | stats by (token_email) count()

І в результаті маємо таку картину:

Де і бачимо, що один і той же юзер починає робити пачку запитів для підключення до Livekit, через що в Livekit Pod створюється пачка процесів (по новій Livekit Job на кожен запит), і в результаті загальна кількість пам’яті в поді зашкалює, бо 40 процесів по ~380 MB це ~15 гігабайт пам’яті.

Але в кожному конкретному процесі пам’ять тримається на рівні 300-400 мегабайт.

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

Loading

AWS: моніторинг AWS OpenSearch Service кластеру з CloudWatch
0 (0)

18 Вересня 2025

Продовжуємо нашу подорож з AWS OpenSearch Service.

Що ми маємо – це маленький кластер AWS OpenSearch Service, 3 трьома data nodes, використовується в ролі vector store для AWS Bedrock Knowledge Bases.

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

  1. AWS: знайомство з OpenSearch Service в ролі vector store
  2. AWS: створення OpenSearch Service cluster та налаштування аутентифікації і авторизації
  3. Terraform: створення AWS OpenSearch Service cluster та юзерів

Вже мали перший production incident 🙂

Запустили якийсь пошук без фільтрів, і наші t3.small.search вмерли через CPU.

Тому давайте глянемо що у нас є з моніторингу всього цього щастя.

Зараз зробимо щось базове, просто з метриками CloudWatch, але в плані моніторингу OpenSearch є кілька рішень:

  • метрики CloudWatch самого OpenSearchService – дані по CPU, Memory, JVM, які ми можемо збирати до VictoriaMetrics і генерити алерти або використати в Grafana dashboard, див. Monitoring OpenSearch cluster metrics with Amazon CloudWatch
  • CloudWatch Events, які генерить OpenSearch Service – див. Monitoring OpenSearch Service events with Amazon EventBridge, можемо їх через SNS відправляти до Opsgenie, а звідти до Slack
  • логи в CloudWatch Logs – можемо збирати в VictoriaLogs, і генерити якісь метрики і алерти, але я під час нашого production incent нічого цікавого в логах не побачив, див. Monitoring OpenSearch logs with Amazon CloudWatch Logs
  • Monitors самого OpenSearch – вміє в Anomaly Detection та власний Alerting, є навіть окремий Terraform resource opensearch_monitor, див. також Configuring alerts in Amazon OpenSearch Service
  • і є Prometheus Exporter Plugin, який відкриває ендпоінт для збору метрик з Prometheus/VictoriaMetrics (але в AWS OpenSearch Managed його додати не можна, хоча сапорт обіцяє, ще feature request є – може колись додадуть)

CloudWatch метрики

Метрик досить багато, але з того, що може бути цікавим нам – враховуючи те, що у нас нема виділених master та coordinator nodes, і ми не використовуємо ultra-warm та cold інстанси.

Cluster metrics:

  • ClusterStatus: green/yellow/red – основний показник стану кластеру, контроль активності шардів даних
  • Shards: active/unassigned/delayedUnassigned/activePrimary/initializing/relocating – більш детальна інформація по стану шардів, але тут просто загальна кількість, без деталізації по конкретним індексам
  • Nodes: кількість нод в кластері – знаючи, скільки має бути живих нод – можемо алертити, коли якась нода відвалиться
  • SearchableDocuments: не те щоб саме для нас було дуже цікаво, але можливо буде корисним потім, аби бачити що взагалі твориться в індексах
  • CPUUtilization: відсоток використання CPU разом на всіх нодах, і це прям must-have
  • FreeStorageSpace: теж корисно моніторити
  • ClusterIndexWritesBlocked: чи все ОК із записами в індекс
  • JVMMemoryPressure та OldGenJVMMemoryPressure: відсоток використання пам’яті JVM heap – далі окремо копнемо в моніторинг JVM, бо це прям окремий геморой
  • AutomatedSnapshotFailure: мабуть, good to know, якщо бекап сфейлиться
  • CPUCreditBalance: нам корисно, бо ми на t3 інстансах (але у нас в CloudWatch її нема)
  • 2xx, 3xx, 4xx, 5xx`: дані по HTTP-запитам і помилкам
    • я тут збираю тільки 5хх для алертів
  • ThroughputThrottle і IopsThrottle: в RDS ми стикались з проблемами доступу до диску, тому варто помоніторити і тут, див. PostgreSQL: AWS RDS Performance and monitoring
    • тут треба буде дивитись на метрики з EBS volume metrics, але для початку можна просто додати алерти на Throttle взагалі
  • HighSwapUsage: аналогічно до попередніх метрик – колись мали біду в RDS, тому краще помоніторити і тут

EBS volume metrics – тут в принципі стандартні метрики EBS, як і для EC2 або RDS:

  • ReadLatency та WriteLatency: затримки читання/запису
    • іноді бувають спайки, тому можна додати
  • ReadThroughput та WriteThroughput: “пропускна здатність”? загальне навантаження на диск, давайте скажемо так
  • DiskQueueDepth: черга I/O операцій
    • у нас в CloudWatch пуста (поки що?), тому скіпаємо
  • ReadIOPS та WriteIOPS: кількість операцій читання/запису на секунду

Instance metrics – тут метрики по кожному OpenSearch інстансу (не серверу, EC2, а самого OpenSearch) на кожній ноді:

  • FetchLatency та FetchRate: як швидко отримуємо дані з шардів (але в CloudWatch теж не знайшов)
  • ThreadCount: кількість потоків в операційній системі, які були створені JVM (Garbadge Collector threads, search threads, write/index threads, etc)
    • в CloudWatch значення стабільне, але в Grafana для загальної картини поки можна додати, подивимось, чи буде там щось цікаве
  • ShardReactivateCount: як часто шарди зі станів cold/inactive переводяться в активні, що потребує ресурсів операційної системи і CPU та пам’яті; ну… може бути, треба глянути чи воно взагалі у нас має якісь значення
    • але в CloudWatch теж нічого – “did not match any metrics
  • ConcurrentSearchRate та ConcurrentSearchLatency: кількість і швидкість одночасних запитів на пошук – може бути цікавим, якщо довго висять багато паралельних запитів
    • але у нас (поки що?) ці значення постійно на нулі, тому скіпаємо
  • SearchRate: кількість пошукових запитів на хвилину, корисно для загальної картини
  • SearchLatency: швидкість виконання пошукових запитів, мабуть, дуже корисно, можна навіть алерт прикрутити
  • IndexingRate та IndexingLatency: аналогічно, але для індексації нових документів
  • SysMemoryUtilization: відсоток використання пам’яті на дата-ноді, але це не дасть повноцінної картини, треба дивитись на пам’ять JVM
  • JVMGCYoungCollectionCount та JVMGCOldCollectionCount: кількість запусків Garbage Collectors, корисно разом з даними по JVM memory, поговоримо далі детальніше
  • SearchTaskCancelled та SearchShardTaskCancelled: про погані новини 🙂 якщо задачі канселяються – щось явно йде не так (або юзер сам перервав виконання запиту, або HTTP connection reset, або таймаути, чи навантаження на кластер)
    • але у нас завжди по нулях, навіть коли кластер падав, тому поки сенсу збору цих метрик не бачу
  • ThreadpoolIndexQueue та ThreadpoolSearchQueue: кількість задач на індексацію та пошук в черзі, коли їх забагато – маємо ThreadpoolIndexRejected та ThreadpoolSearchRejected
    • ThreadpoolIndexQueue в CloudWatch нема взагалі, а ThreadpoolSearchQueue є, але теж постійно в нулях, тому поки скіпаємо
  • ThreadpoolIndexRejected та ThreadpoolSearchRejected: власне, вище
    • в CloudWatch картина аналогічна – ThreadpoolIndexRejected нема взагалі, ThreadpoolSearchRejected в нулях
  • ThreadpoolIndexThreads та ThreadpoolSearchThreads: максимальна кількість потоків операційної системи для індексації та пошуку, якщо всі зайняті – то запити підуть в ThreadpoolIndexQueue/ThreadpoolSearchQueue
    • в OpenSearch є кілька типів пулів для потоків – search, index, write і т.д., і для кожного пулу є показник threads (скільки виділено), queue – черга, rejected – відхилено, бо черга переповнена, див. OpenSearch Threadpool
    • в Node Stats API (GET _nodes/stats/thread_pool) є показник active threads, але в CloudWatch такого не бачу
    • ThreadpoolIndexThreads у нас в CloudWatch взагалі нема, а ThreadpoolSearchThreads статична, поки, думаю, можна скіпнути їхній моніторинг
  • PrimaryWriteRejected: відхилені операції записи в primary-шарди через проблеми в thread pool write або index, чи навантаження на дата-ноді
    • в CloudWatch поки пусті, але додамо збір і алерт
  • ReplicaWriteRejected: відхилені операції записи в replica-шарди – в primary документ додано, але не може записати в репліку
    • в CloudWatch поки пусті, але додамо збір і алерт

k-NN metrics – нам корисно, бо у нас vector store з k-NN:

  • KNNCacheCapacityReached: коли кеш повністю зайнятий (див. далі)
  • KNNEvictionCount: як часто дані з кешу видаляються – ознака, що пам’яті не вистачає
  • KNNGraphMemoryUsage: використання off-heap пам’яті під графи самого вектору
  • KNNGraphQueryErrors: кількість помилок при пошуку в векторах
    • в CloudWatch поки пусті, але додамо збір і алерт
  • KNNGraphQueryRequests: загальна кількість запитів до k-NN graphs
  • KNNHitCount та KNNMissCount: скільки результатів було повернуто з кешу, а скільки довелось зчитувати з диску
  • KNNTotalLoadTime: швидкість завантаження з диску в кеш (великі графи або завантажений EBS – буде рости час)

Моніторинг Memory

Давайте подумаємо як нам основнім показники помоніторити, і першим – пам’ять, бо це ж Java.

Що у нас є по пам’яті з метрик?

  • SysMemoryUtilization: відсоток використання пам’яті на сервері (дата-ноді) взагалі
  • JVMMemoryPressure: загальний відсоток використання JVM Heap; JVM Heap по дефолту виділяється в 50% від пам’яті серверу, але не більше 32 гіг
  • OldGenJVMMemoryPressure: див. далі
  • KNNGraphMemoryUsage: про це говорили в першому пості – AWS: знайомство з OpenSearch Service в ролі vector store
    • в CloudWatch ще є метрика KNNGraphMemoryUsagePercentage – але в документації її нема

kNN Memory usage

Спершу коротенько про пам’ять під k-NN.

Отже, на EC2 у нас виділяється пам’ять під JVM Heap (50% доступної на сервері), і окремо – off-heap для OpenSearch vector store, де він тримає графи та кеш  vectore store – див. Approximate k-NN search, плюс під саму операційну систему і її файловий кеш.

Якоїсь метрики типу “KNNGraphMemoryAvailable” у нас нема, але маючи KNNGraphMemoryUsagePercentage та KNNGraphMemoryUsage можемо її порахувати:

  • KNNGraphMemoryUsage: у нас зараз 662 мегабайти
  • KNNGraphMemoryUsagePercentage: 60%

Значить, під k-NN graphs виділяється 1 гігабайт поза JVM Heap memory (це на t3.medium.search).

З документації k-Nearest Neighbor (k-NN) search in Amazon OpenSearch Service:

OpenSearch Service uses half of an instance’s RAM for the Java heap (up to a heap size of 32 GiB). By default, k-NN uses up to 50% of the remaining half

Знаючи, що у нас зараз t3.medium.search, на яких видається 4 гігабайти пам’яті – 2 GB йде під JVM Heap, і 1 гігабайт – під k-NN графи.

Основну частину KNNGraphMemory використовує k-NN cache, тобто частина оперативної пам’яті системи, в якій OpenSearch тримає HNSW-графи з векторних індексів, аби не зчитувати їх кожного разу з диску (див.  k-NN clear cache).

Тому корисно мати графіки по EBS IOPS та використанню k-NN cache.

JVM Memory usage

Окей, давайте згадувати що там в Java взагалі відбувається, див. What Is Java Heap Memory?, OpenSearch Heap Size Usage and JVM Garbage Collection та Understanding the JVMMemoryPressure metric changes in Amazon OpenSearch Service.

Якщо дуже спрощено, то:

  • Stack Memory: окрім JVM Heap маємо Stack, який виділяється кожному потоку, де він тримає свої змінні, посилання, параметри запуску
    • задається через -Xss, дефолтне значення від 256 кілобайт до 1 мегабайту, див. Understanding Threads and Locks (не знайшов, як подивитись в OpenSearch Service)
    • якщо маємо багато threads – буде багато пам’яті під їхні стеки
    • очищується, коли thread вмирає
  • Heap Space:
    • використовується для виділення пам’яті, яка доступна всім потокам
    • керується Garbage Collectors (GC)
    • в контексті OpenSearch у нас тут будуть кеши пошуку і індексацій

В Heap memory у нас є:

  • Young Generation: свіженькі дані, усі нові об’єкти
    • дані звідси або видаляються зовсім, або переміщаються в Old Generation
  • Old Generation: сам код процесу OpenSearch, кеші, індексні структури Lucene, великі масиви

Якщо OldGenJVMMemoryPressure забитий – значить, Garbage Collector не може його почистити, бо на дані є посилання, і тоді маємо проблему – бо в Heap нема місця для нових даних, і JVM може впасти з помилкою OutOfMemoryError.

Взагалі “heap pressure” – це коли в Young Gen і Old Gen мало вільної пам’яті, і нема де розмістити нові дані, аби відповісти клієнтам.

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

В результаті latency зростає, індексація нових документів гальмує, або взагалі отримуємо ClusterIndexWritesBlocked – аби уникнути Java OutOfMemoryError, бо при індексації OpenSearch спочатку пише дані в Heap, а потім “скидається” на диск.

Див. Key JVM Metrics to Monitor for Peak Java Application Performance.

Отже – для картини використання пам’яті моніторимо:

  • SysMemoryUtilization – для загальної картини по стану EC2
    • в нашому випадку тут буде стабільно близько 90%, але це ОК
  • JVMMemoryPressure – для загальної картини по JVM
    • має регулярно чиститись з Garbage Collector (GC)
    • якщо постійно вище 80-90% – є проблеми з запуском GC
  • OldGenJVMMemoryPressure – для даних по Old Generation Heap
    • має бути на рівні 30-40%, якщо вище і не вичищається – то проблеми або з кодом, або з GC
  • KNNGraphMemoryUsage – в нашому випадку треба для загальної картини

І варто додати алерти на HighSwapUsage – у нас вже відбувався активний swapping, коли запустились на t3.small.search, і це показник того, що пам’яті недостатньо.

Збір метрик до VictoriaMetrics

Власне, як вибрати метрики?

Спершу шукаємо їх в CloudWatch Metrics, і дивимось чи взагалі метрика є, і чи вона повертає якісь цікаві дані.

Наприклад, SysMemoryUtilization дає інфу.

Отуто у нас на t3.small.search був спайк, після якого кластер впав:

А ось метрика HighSwapUsage – теж до переїзду на t3.medium.search:

ClusterStatus є:

Shards є, але це по всім індексам, і нема можливості фільтрувати по окремим:

Ну і треба мати на увазі, що збір метрик з CloudWatch теж коштує грошей за API-запити, тому все підряд збирати не варто.

Взагалі для збору метрик з CloudWatch ми користуємось YACE (Yet Another CloudWatch Exporter), але він не підтримує OpenSearch Managed cluser – див. Features.

Тому беремо звичайний експортер – CloudWatch Exporter.

У нас він деплоїться з Helm-чарту моніторингу (див. VictoriaMetrics: створення Kubernetes monitoring stack з власним Helm-чартом), додаємо йому новий конфіг:

...

prometheus-cloudwatch-exporter:
  enabled: true
  serviceAccount:
    name: "cloudwatch-sa"
    annotations:
      eks.amazonaws.com/sts-regional-endpoints: "true"
  serviceMonitor:
    enabled: true
  config: |-
    region: us-east-1
    metrics:

    - aws_namespace: AWS/ES
      aws_metric_name: KNNGraphMemoryUsage
      aws_dimensions: [ClientId, DomainName, NodeId]
      aws_statistics: [Average]

    - aws_namespace: AWS/ES
      aws_metric_name: SysMemoryUtilization
      aws_dimensions: [ClientId, DomainName, NodeId]
      aws_statistics: [Average]

    - aws_namespace: AWS/ES
      aws_metric_name: JVMMemoryPressure
      aws_dimensions: [ClientId, DomainName, NodeId]
      aws_statistics: [Average]

    - aws_namespace: AWS/ES
      aws_metric_name: OldGenJVMMemoryPressure
      aws_dimensions: [ClientId, DomainName, NodeId]
      aws_statistics: [Average]

Зверніть увагу, що для різних метрик можуть бути різні Dimenstions – перевіряємо їх в CloudWatch:

Деплоїмо, перевіряємо:

І навіть цифри вийшли такі, як ми рахували в першому пості – маємо ~130000 документів в production index, по формулі num_vectors * 1.1 * (4*1024 + 8*16) виходить 604032000 байт, або 604.032 мегабайт.

А на графіку маємо 662261 kilobytes – це 662 мегабайти, але по всім індексам разом.

Тепер у VictoriaMetrics у нас є метрики aws_es_knngraph_memory_usage_average, aws_es_sys_memory_utilization_average, aws_es_jvmmemory_pressure_average, aws_es_old_gen_jvmmemory_pressure_average.

Аналогічно додаємо решту.

Для пошуку того, як саме метрики називаються в VictoriaMetrics/Prometheus – відкриваємо порт до CloudWatch Exporter:

$ kk port-forward svc/atlas-victoriametrics-prometheus-cloudwatch-exporter 9106

І з curl та grep шукаємо метрики:

$ curl -s localhost:9106/metrics | grep aws_es
# HELP aws_es_cluster_status_green_maximum CloudWatch metric AWS/ES ClusterStatus.green Dimensions: [ClientId, DomainName] Statistic: Maximum Unit: Count
# TYPE aws_es_cluster_status_green_maximum gauge
aws_es_cluster_status_green_maximum{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",client_id="492***148",} 1.0 1758014700000
# HELP aws_es_cluster_status_yellow_maximum CloudWatch metric AWS/ES ClusterStatus.yellow Dimensions: [ClientId, DomainName] Statistic: Maximum Unit: Count
# TYPE aws_es_cluster_status_yellow_maximum gauge
aws_es_cluster_status_yellow_maximum{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",client_id="492***148",} 0.0 1758014700000
# HELP aws_es_cluster_status_red_maximum CloudWatch metric AWS/ES ClusterStatus.red Dimensions: [ClientId, DomainName] Statistic: Maximum Unit: Count
# TYPE aws_es_cluster_status_red_maximum gauge
aws_es_cluster_status_red_maximum{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",client_id="492***148",} 0.0 1758014700000
...

Створення Grafana dahsboard

ОК, метрики з CloudWatch маємо – їх поки вистачить.

Подумаємо, що ми хочемо бачити в Grafana.

Загальна ідея – така собі “overview” дашборда, де на одній борді будуть відображатись всі головні дані по кластеру.

Які метрики зараз є, і як ми їх можемо використати в Grafana – я їх тут собі виписував, аби не заплутатись, бо їх вийшло багатенько:

  • aws_es_cluster_status_green_maximum, aws_es_cluster_status_yellow_maximum, aws_es_cluster_status_red_maximum: можна зробити одну Stats панель
  • aws_es_nodes_maximum: теж якусь Stats панель – знаємо, скільки має бути, і будемо робити червоним, коли Data Nodes менше, ніж має бути
  • aws_es_searchable_documents_maximum: просто інтересу заради – графіком покажемо кількість документів разом в усіх індексах
  • aws_es_cpuutilization_average: одним графіком по кожній ноді, і якусь Stats з загальною інформацією і різними кольорами
  • aws_es_free_storage_space_maximum: просто Stats
  • aws_es_cluster_index_writes_blocked_maximum: не став додавати в Grafana, тільки алерт
  • aws_es_jvmmemory_pressure_average: графік і Stats
  • aws_es_old_gen_jvmmemory_pressure_average: десь поруч, теж графіком + Stats
  • aws_es_automated_snapshot_failure_maximum: це просто для алерта
  • aws_es_5xx_maximum: і графік, і Stats
  • aws_es_iops_throttle_maximum: графік, аби бачити в порівнянні з іншими даними типу CPU/Mem usage
  • aws_es_throughput_throttle_maximum: графік
  • aws_es_high_swap_usage_maximum: і графік, і Stats – графік, аби бачити в порівнянні з CPU/дисками
  • aws_es_read_latency_average: графік
  • aws_es_write_latency_average: графік
  • aws_es_read_throughput_average: не став додавати, бо забагато графіків
  • aws_es_write_throughput_average: не став додавати, бо забагато графіків
  • aws_es_read_iops_average: графік, корисно, аби розуміти роботу кешу k-NN – якщо його мало (а ми тестили на t3.small.search з 2 гігабайтами загальної пам’яті) – то читання з диску буде багато
  • aws_es_write_iops_average: аналогічно
  • aws_es_thread_count_average: не став додавати, бо воно доволі статичне і якось сильно корисної інформації не побачив
  • aws_es_search_rate_average: теж просто графік
  • aws_es_search_latency_average: аналогічно, десь поруч
  • aws_es_sys_memory_utilization_average: ну, воно постійно буде десь під 90%, поки прибрав з Grafana, але додав в алерти
  • aws_es_jvmgcyoung_collection_count_average: графік, бачити як часто викликається
  • aws_es_jvmgcold_collection_count_average: графік, бачити як часто викликається
  • aws_es_primary_write_rejected_average: графік, але поки не став додавати, бо забагато графіків – тільки алерт
  • aws_es_replica_write_rejected_average: графік, але поки не став додавати, бо забагато графіків – тільки алерт
  • k-NN:
    • aws_es_knncache_capacity_reached_maximum: тільки для warning-алерту
    • aws_es_knneviction_count_average: не став додавати, хоча може бути цікавим
    • aws_es_knngraph_memory_usage_average: не став додавати
    • aws_es_knngraph_memory_usage_percentage_maximum: графік, замість aws_es_knngraph_memory_usage_average
    • aws_es_knngraph_query_errors_maximum: тільки алерт
    • aws_es_knngraph_query_requests_sum: графік
    • aws_es_knnhit_count_maximum: графік
    • aws_es_knnmiss_count_maximum:  графік
    • aws_es_knntotal_load_time_sum: було непогано мати графік, але нема місця на борді

VictoriaMetrics/Prometheus sum(), avg() та max()

Спершу давайте згадаємо які у нас є функції для агрегації даних.

З CloudWatch для OpenSearch ми будемо отримувати два основні типи – counter та gauge:

$ curl -s localhost:9106/metrics | grep cpuutil
# HELP aws_es_cpuutilization_average CloudWatch metric AWS/ES CPUUtilization Dimensions: [ClientId, DomainName, NodeId] Statistic: Average Unit: Percent
# TYPE aws_es_cpuutilization_average gauge
aws_es_cpuutilization_average{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",node_id="BzX51PLwSRCJ7GrbgB4VyA",client_id="492***148",} 10.0 1758099600000
...

Різниця між ними:

  • counter: значення може тільки збільшувати значення
  • gauge: значення може збільшуватись і зменшуватись

Тут у нас “TYPE aws_es_cpuutilization_average gauge“, бо використання CPU може і збільшуватись, і зменшуватись.

Див. чудово документацію VictoriaMetrics – Prometheus Metrics Explained: Counters, Gauges, Histograms & Summaries:

Як ми його можемо використати в графіках?

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

  • aws_es_cpuutilization_average{node_id="BzX51PLwSRCJ7GrbgB4VyA"} == 9
  • aws_es_cpuutilization_average{node_id="IIEcajw5SfmWCXe_AZMIpA"} == 28
  • aws_es_cpuutilization_average{node_id="lrsnwK1CQgumpiXfhGq06g"} == 8

З sum() без лейбл ми просто отримаємо суму всіх значень:

Якщо зробимо sum by (node_id) – то отримаємо значення для конкретної тайм-серії, яка тут буде збігатись з вибіркою без sum by ():

(значення міняється, поки пишу і роблю запити)

З max() без фільтрів – отримаємо просто максимальне значення, вибране з усіх отриманих тайм-серій:

А з avg() – середнє значення всіх значень, тобто сума всіх значень поділена на кількість тайм-серій:

Порахуємо самі:

(41+46+12)/3
33

Власне, чому я про це став писати окремо – бо з sum() навіть із by (node_id) іноді можна отримати такі во спайки:

Хоча без sum() їх нема:

А траплялись вони через те, що в цей момент перестворювався Pod з CloudWatch Exporter:

І в цей момент ми отримували дані зі старого поду, і з нового.

Тому тут варіант або використовувати max(), або просто avg(). Хоча max() все ж, мабуть, краще, бо нам цікаві “найгірші” показники.

Окей – з цим розібрались, погнали робити дашборду.

Cluster status

Тут хочеться на одній Stats панелі бачити всі три значення – Green, Yellow, Red.

Але так як в Grafana у нас нема if/else, то зробимо “костиль”.

Збираємо всі три метрики, і результат кожної множимо на 1, 2, чи 3:

sum(aws_es_cluster_status_green_maximum) by (domain_name) * 1 +
sum(aws_es_cluster_status_yellow_maximum) by (domain_name) * 2 +
sum(aws_es_cluster_status_red_maximum) by (domain_name) * 3

Відповідно, якщо aws_es_cluster_status_green_maximum == 1, то 1 * 1 == 1, а aws_es_cluster_status_yellow_maximum == 0 і aws_es_cluster_status_red_maximum будуть == 0 – то і множення поверне 0.

А якщо aws_es_cluster_status_green_maximum стане 0, але aws_es_cluster_status_red_maximum буде 1 – то 1 * 2 отримаємо 3, і по значенню 3 будемо міняти показник в Stats-панелі

І додаємо Value mappings з текстом і кольорами:

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

Nodes status

Тут все просто – знаємо потрібну кількість, поточну отримуємо з aws_es_nodes_maximum:

sum(aws_es_nodes_maximum) by (domain_name)

І знов через Value mappings задаємо значення і кольори:

На випадок, якщо колись збільшимо кількість нод, і забудемо оновити тут значення для “ОК” – то додаємо третій статус, ERR:

CPUUtilization: Stats

Тут зробимо кросивенько – з типом візуалізації Gauge:

avg(aws_es_cpuutilization_average) by (domain_name)

Задаємо Text size та Unit:

І Thresholds:

Description непогано генерить ChatGPT – корисно і девелоперам, і нам самим через півроку, або просто беремо опис з документації AWS:

The percentage of CPU usage for data nodes in the cluster. Maximum shows the node with the highest CPU usage. Average represents all nodes in the cluster.

Додаємо решту Stats:

CPUUtilization: Graph

Тут виведемо графік по CPU кожної ноди – середнє за 5 хвилин:

max(avg_over_time(aws_es_cpuutilization_average[5m])) by (node_id)

І ось теж приклад того, як з sum() з’являлись спайки, яких не було насправді:

Тому робимо max().

Задамо Gradient mode == Opacity, і Unit == percent:

Задаємо Color scheme і Thresholds, включаємо Show thresholds:

В Data links можна задати лінку на сторінку DataNode Health в AWS Console:

https://us-east-1.console.aws.amazon.com/aos/home?region=us-east-1#opensearch/domains/atlas-kb-prod-cluster/data_Node/${__field.labels.node_id}

Всі доступні поля – по Ctrl+Space:

Actions, мабуть, не так давно з’явилось, ще не використовував, але виглядає цікаво – можна щось пушнути:

JVMMemoryPressure: Graph

Тут нам цікаво бачити чи не “залипає” використання пам’яті, і як часто запускається Garbage Collector.

Запит простий – можна зробити max by (node_id), але я зробив просто загальну картину по кластеру:

max(aws_es_jvmmemory_pressure_average)

І графік аналогічно попередньому:

В Desription додаємо пояснення “коли хвилюватись”:

Represents the percentage of JVM heap in use (young + old generation).
Values below 75% are normal. Sustained pressure above 80% indicates frequent GC and potential performance degradation.
Values consistently > 85–90% mean heap exhaustion risk and may trigger ClusterIndexWritesBlocked – investigate immediately.

JVMGCYoungCollectionCount and JVMGCOldCollectionCount

Дуже корисний графік, аби бачити як часто зпускаються Garbage Collects.

В запиті використаємо increase[1m] – побачити як змінилось значення за хвилину:

max(increase(aws_es_jvmgcyoung_collection_count_average[1m])) by (domain_name)

І для Old Gen:

max(increase(aws_es_jvmgcold_collection_count_average[1m])) by (domain_name)

Unit – ops/sec, Decimals задаємо 0, аби мати тільки цілі значення:

KNNHitCount vs KNNMissCount

Тут зробимо дані на секунду – rate():

sum(rate(aws_es_knnhit_count_average[5m]))

І для Cache Miss:

sum(rate(aws_es_knnmiss_count_average[5m]))

Unit ops/s, кольори можемо задати через Overrides:

Статистика тут, до речі, дуже так собі – стабільно багато Cache missed, але чому – поки не розібрались.

Фінальний результат

Збираємо всі графіки, і отримуємо щось таке:

t3.small.search vs t3.medium.search на графіках

І приклад того, як нестача ресурсів, в першу чергу пам’яті, відображається на графіках: у нас були t3.medium.search, потім ми повернули t3.small.search, аби подивитись як воно на перформанс вплине.

t3.small.search – це лише 2 гігабайти пам’яті і 2 ядра CPU.

З цих 2 гіг пам’яті 1 гіг під JVM Heap, 500 мегабайт під k-NN memory, і 500 залишалось на решту процесів.

Ну і результати, цілком очікувані:

  • Garbage Collectors стали запускатись постійно, бо треба було чистити пам’ять, якої не вистачало
  • Read IOPS виріс, бо постійно з диска завантажувались дані до JVM Heap Young і k-NN
  • Search Latency виріс, бо не всі дані були в кеші, і чекали I/O-операцій з диску
  • і CPU utilization підскочив – бо CPU був завантажений і Garbage Collectors, і читанням з диску

Створення Alerts

Ще можна глянути рекомендації від AWS – Recommended CloudWatch alarms for Amazon OpenSearch Service.

OpenSearch ClusterStatus Yellow та OpenSearch ClusterStatus Red: тут просто якщо більше ніж 0:

...
      - alert: OpenSearch ClusterStatus Yellow
        expr: sum(aws_es_cluster_status_yellow_maximum) by (domain_name, node_id) > 0
        for: 1s
        labels:
          severity: warning
          component: backend
          environment: prod
        annotations:
          summary: 'OpenSearch ClusterStatus Yellow status detected'
          description: |-
            The primary shards for all indexes are allocated to nodes in the cluster, but replica shards for at least one index are not
            *OpenSearch Doman*: `{{ "{{" }} $labels.domain_name }}`
          grafana_opensearch_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/b2d2dabd-a6b4-4a8a-b795-270b3e200a2e/aws-opensearch-cluster-cloudwatch'

      - alert: OpenSearch ClusterStatus Red
        expr: sum(aws_es_cluster_status_red_maximum) by (domain_name, node_id) > 0
        for: 1s
        labels:
          severity: critical
          component: backend
          environment: prod
        annotations:
          summary: 'OpenSearch ClusterStatus RED status detected!'
          description: |-
            The primary and replica shards for at least one index are not allocated to nodes in the cluster
            *OpenSearch Doman*: `{{ "{{" }} $labels.domain_name }}`
          grafana_opensearch_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/b2d2dabd-a6b4-4a8a-b795-270b3e200a2e/aws-opensearch-cluster-cloudwatch'
...

Через labels у нас реалізований роутинг алертів в Opsgenie до потрібних каналів Slack, а анотація grafana_opensearch_overview_url використовуються для додавання лінки на Grafana в повідомленні в Slack:

OpenSearch CPUHigh – якщо більше 20% протягом 10 хвилин:
      - alert: OpenSearch CPUHigh
        expr: sum(aws_es_cpuutilization_average) by (domain_name, node_id) > 20
        for: 10m
...

OpenSearch Data Node down – якщо нода впала:

      - alert: OpenSearch Data Node down
        expr: sum(aws_es_nodes_maximum) by (domain_name) < 3
        for: 1s
        labels:
          severity: critical
...

aws_es_free_storage_space_maximum – нам поки сенсу нема.

OpenSearch Blocking Write – алертимо, якщо почались блоки на write:
...
      - alert: OpenSearch Blocking Write
        expr: sum(aws_es_cluster_index_writes_blocked_maximum) by (domain_name) >= 1
        for: 1s
        labels:
          severity: critical
...

Ну і решта алертів, які я поки що додав:

...
      - alert: OpenSearch AutomatedSnapshotFailure 
        expr: sum(aws_es_automated_snapshot_failure_maximum) by (domain_name) >= 1
        for: 1s
        labels:
          severity: critical
...
      - alert: OpenSearch 5xx Errors 
        expr: sum(aws_es_5xx_maximum) by (domain_name) >= 1
        for: 1s
        labels:
          severity: critical
...
      - alert: OpenSearch IopsThrottled
        expr: sum(aws_es_iops_throttle_maximum) by (domain_name) >= 1
        for: 1s
        labels:
          severity: warning
...
      - alert: OpenSearch ThroughputThrottled
        expr: sum(aws_es_throughput_throttle_maximum) by (domain_name) >= 1
        for: 1s
        labels:
          severity: warning
...
      - alert: OpenSearch SysMemoryUtilization High Warning
        expr: avg(aws_es_sys_memory_utilization_average) by (domain_name) >= 95
        for: 5m
        labels:
          severity: warning
...
      - alert: OpenSearch PrimaryWriteRejected High
        expr: sum(aws_es_primary_write_rejected_maximum) by (domain_name) >= 1
        for: 1s
        labels:
          severity: critical
...
      - alert: OpenSearch KNNGraphQueryErrors High
        expr: sum(aws_es_knngraph_query_errors_maximum) by (domain_name) >= 1
        for: 1s
        labels:
          severity: critical
...
      - alert: OpenSearch KNNCacheCapacityReached
        expr: sum(aws_es_knngraph_query_errors_maximum) by (domain_name) >= 1
        for: 1s
        labels:
          severity: warning
...

По ходу використання подивимось, що ще можна додати.

Loading