Щось всі навколо тільки і говорять що про море про MCP – тож прийшов час і самому розібратись в темі.
Отже, сьогодні розберемося з основними поняттями – “що воно взагалі таке”, потім напишемо власний “мікро-MCP сервер”, а в наступному пості – щось більш реальне, про роботу з VictoriaLogs.
Обмеження LLM
Будь-яка Large Language Model – це система “сама в собі”: в неї немає доступу до зовнішніх ресурсів і вона не може виконати якісь дії в реальному світі, в реальному оточенні – наприклад, виконати shell-команду на вашому ноутбуці чи надіслати API-запит до GitHub або до AWS.
З часом з’явилась концепція “агентів” – локальних сервісів, які модель може викликати або такі дії виконати.
Але тут постала нова проблема: зовнішній світ і кількість сервісів в ньому безмежні, і такі агенти не можуть знати наперед усе про всі сервіси та як з ними взаємодіяти.
Тому врешті-решт з’явився новий стандарт, протокол – Model Context Protocol (MCP), який як раз дозволив розширити можливості LLM та агентів через єдиний і стандартний спосіб описати контекст того, з чим модель буде мати справу.
And so… The MCP?
Отже, Model Context Protocol – це протокол, “схема”, яка описує стандарт, за яким модель може взаємодіяти із зовнішнім середовищем.
Саму специфікацію можна почитати тут – Specification.
Якщо дуже просто, то MCP – це “інтерфейс”, через який LLM може виконувати якісь дії.
Схема роботи MCP – клієнт-серверна:
MCP-клієнт, через який ми передаємо запит у вигляді natural language – “створи новий Pull Request в моєму GitHub-репозиторії”
та MCP-сервер, до якого LLM звертається, аби цей запит транслювати у формат “сходити на такий-то URL, пройти аутентифікацію з таким-то токеном, виконати такий-то API-запит”
В ролі клієнта може виступити будь-яке рішення, яке вміє говорити з LLM – Cursor IDE, мобільний застосунок, або навіть просто CLI-утиліта.
А в ролі сервера – сервіс, до якого наш клієнт може звернутись із запитом.
Архітектура MCP
Якщо говорити більш детально, то у нас є кілька компонентів:
MCP Host: наприклад, Cursor або Windsurf – приймає запит від юзера, формує структурований MCP-запит (виклик функції або tool), і надсилає його до MCP-клієнта
MCP Client: це сама LLM (або AI-агент) + її інтерпретатор (interpreter, runtime, tool router), і вони разом приймають запит від MCP Host, визначає, який саме tool треба використати, виконують цей виклик (через MCP Server), та повертають результат
MCP Server: сервіс, який надає один чи декілька tools для MCP Client, та виконує запити від MCP Client, наприклад – запускає shell-команди
Data Sources та Remote Services: власне те, з чим напряму буде комунікувати MCP-сервер – логи, бази даних, API-сервери
Сам flow виконання запиту можна визначити так:
User -> MCP Host
MCP Host -> MCP Client (LLM/агент обробляє запит, визначає інструмент)
MCP Client -> MCP Server (використовує tool)
MCP Server -> Data Sources та Remote Services (отримує дані, формує відповідь)
Отже, MCP використовує модель клієнт-сервер, і описує три ключові компоненти (або примітиви):
Resources: дані, до яких треба звернутись – логи, метрики, база даних, API-відповіді (docs)
Prompts: шаблони або форми подачі запитів до LLM – визначають, як саме ми формулюємо питання, щоб модель краще зрозуміла, яку функцію (tool) викликати (docs)
Tools: функції, які доступні на MCP-сервері для виклику MCP-клієнтом, і які викликає модель чи агент після аналізу запиту користувача (docs)
такими функціями тут можуть бути як функції, наприклад на Python, так і API-ендпоінти або shell-команди
Транспорти MCP
Для комунікації між клієнтом та сервером MCP визначає Transports – канали зв’язку, через які передаються запити та відповіді.
Наразі є три основних типи (MCP все ще активно розроблюється, тому можливо будуть нові):
stdio: стандартні потоки stdin/stdout, що використовується коли клієнт та сервер працюють локально
SSE (Server-Sent Events): односторонній канал від сервера до клієнта для передачі даних з результатами виконання запиту у вигляді подій
SSE може бути реалізований як stream-like даних передача – тобто, передача великих відповідей невеликими chunks (частинами), або як повернення однієї події одним повідомленням
в такому разі клієнт використовує стандартний HTTP POST для відправки самого запиту
Streamable HTTP: двосторонній канал, у якому клієнт отримує відповідь від сервера через HTTP streaming
#!/usr/bin/env python3
from mcp.server.fastmcp import FastMCP
# instantiate an MCP server client
mcp = FastMCP("My MCP Tools")
# Register a tool
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers and return the result"""
return a + b
if __name__ == "__main__":
mcp.run(transport="stdio")
Тут:
FastMCP: бібліотека для створення MCP-серверів, яка реалізує специфікацію Model Context Protocol
from mcp.server.fastmcp import – входить у Python SDK
tools: функції, які зможе використовувати LLM – див. Tools
Є дуже прикольно штука для дебагу MCP-серверів – Inspector. Див. документацію тут>>>. Потребує в системі NodeJS >= 18.
Запускаємо:
$ npx @modelcontextprotocol/inspector python3 mcp_server.py
Starting MCP inspector...
⚙ Proxy server listening on port 6277
🔍 MCP Inspector is up and running at http://127.0.0.1:6274
...
І відкриваємо в браузері http://127.0.0.1:6274, де в Tools можемо побачити наш tool add:
Додавання MCP Server до Windsurf
Так як у нас бібліотека mcp встановлена в Python virtual environment, то для її використання в IDE нам потрібен повний шлях.
В терміналі, в якому у нас активований venv виконуємо:
Повертаємо до Settings, клікаємо Refresh – і маємо отримати наш новий сервер, у якого є один tool – add:
І він жеж має з’явитись у вікні чату:
Пробуємо його використати:
Йой! It works!
LLM (у випадку з Windsurf дефолтна буде Cascade) сама визначила, що в неї є доступ до MCP-серверу, який може виконати математичну операцію add, і використала його.
Найс.
В наступному пості – напишемо власний MCP-сервер для роботи з VictoriaLogs – просто, аби детальніше подивитись як воно працює, бо команда VictoriaMetrics вже робить власний сервер (ще не випустили, але я вже помацаю 🙂 ).
Якщо дуже просто, то Celery – це щось, за допомогою чого ми можемо виконувати задачі поза нашим основним сервісом.
Наприклад, є Backend API, який має якийсь ендпоінт, на який мобілочки відправляють інформацію про те, що юзер створив новий whatever в застосунку. Задача бекенда – додати whatever в базі даних.
Можна це виконати прямо в інстансі самого API відразу при отриманні івента на ендпоінт, а можна, якщо нам не горить виконання whatever в базі даних, створити паралельну відкладену задачу, яка буде виконана через 1-5-20-60 секунд.
Власне, це і робить Celery:
основний код сервісу створює task
Celery-клієнт в коді цю задачу відправляє в MQ Broker (Message Queue Broker, як-от RabbitMQ, Redis або мать його єті AWS SQS, див повний список на Broker Overview)
Celery Worker отримує меседж з черги
Worker запускає якусь функцію, як робить whatever в базі даних
…
profit!
Власне в цьому пості ми не будемо заглиблюватись в деталі реалізації всього цього щастя.
Все, що мені цікаво – це як з цим працювати, тобто – як я можу створити новий task, аби Worker цю задачу виконав.
В ідеалі – ще й перевірити, що задача дійсно була виконана – але тут є проблеми з AWS SQS. Далі подивимось на це детальніше.
Запуск Celery
Зробимо все швиденько локально з Python PiP та Docker, потім ще глянемо на AWS SQS, Kubernetes та моніторинг.
Створення проекту
Встановлюємо сам Celery та залежності для роботи з Redis:
IDK чому в документації параметр називається “broker” а не “broker_url“, бо “broker” наче депрікейтед, а документація “describes the current stable version of Celery (5.4)” (с). Чи, може, --broker – це параметр для командної строки, а broker_url – для конфігу?
Весь код можна просто робити в одному файлі типу tasks.py, як це описано в Getting Started документації, але я відразу розіб’ю на кілька окремих модулів, аби було більше “production way”, тим більше воно вже так зроблено у нас в Production, тому хочеться і під час тестування мати більш схожий сетап.
Створюємо основний модуль для Селері – celery_app.py:
from celery import Celery
app = Celery(__name__,
broker_url='redis://localhost:6379/0',
include=["celery_tasks"]
)
Тут, власне, broker_url – адреса Redis, а в include ми підключаємо наші майбутні таски. Також є опція autodiscover_tasks, але не пробував і у нас не використовується.
Якщо таски не заімпорчені – то будуть помилки типу:
Received unregistered task of type ‘celery_tasks.test_task’.
Створюємо модуль для Celery Tasks – celery_tasks.py:
#!/usr/bin/env python
from celery_tasks import test_task
test_task.delay("Hello, comrade!")
Запускаємо вже власне Celery Worker – це окремий процес Python (в Kubernetes у нас для цього окремі Pods).
Тобто, Celery-клієнт – це інстанс Celery, який створюється під час запуску API і через який ми створюємо нові задачі в брокері, а Worker – це окремий процес, який займається збором повідомлень і виконанням задач.
Так як у нас Redis, то це будуть саме ключі. Але для Celery – це черги, і ключі створюються з типом SET, тобто можуть мати послідовність. Хоча нам зараз такі нюанси не дуже важливі.
Запускаємо наш “API” аби він викликав створення задачі (не забуваємо про activate venv, якщо робимо в окремому терміналі, бо треба імпортити Celery libs):
...
[2025-03-19 12:48:06,285: INFO/MainProcess] Task celery_tasks.test_task[edbdc0aa-673c-490f-a18f-0b7665db2ff7] received
[2025-03-19 12:48:06,287: INFO/ForkPoolWorker-15] Task celery_tasks.test_task[edbdc0aa-673c-490f-a18f-0b7665db2ff7] succeeded in 0.0003573799040168524s: 'OK: Hello, comrade!'
Default Celery Broker keys
Швиденько глянемо розберемо що Celery створює в чергах і для чого, бо в SQS будемо мати проблеми з деякими з них, тому треба розуміти чому і від чого.
Як вже говорили вище, в Redis це KEYS, але з типом SET (в SQS були б окремі черги):
root@a0c65a5e7bfb:/data# redis-cli type _kombu.binding.celeryev
set
Тут:
_kombu.binding.celeryev: використовується Celery Events для надсилання подій про стан воркерів (наприклад, коли воркер запускається, виконує задачу або завершує роботу)
використовується для моніторингу через celery events або Flower, ми їх далі подивимось
_kombu.binding.celery: головна черга завдань Celery за замовчуванням
сюди надсилаються задачі, які потім обробляються Celery Workers
_kombu.binding.celery.pidbox: використовується для pidbox messaging, тобто обміну командами між воркерами, наприклад, celery inspect, celery control – і в SQS це теж працювати не буде
через нього Celery може надсилати команди воркерам, щоб перевірити їхній стан, змінити рівень логування тощо
Чому _kombu в іменах – бо під капотом Celery використовує бібліотеку Kombu.
Глянемо, що в ключах.
В дефолтній черзі – виконуємо SMEMBERS, бо це тип SET:
А далі ми спробуємо додати трохи води моніторингу.
Моніторинг Celery в AWS SQS
Власне, як я дійшов до жизні такой до цього посту: є Kubernetes Pod, для якого хочеться мати простий Liveness Probe.
Я за 10 хвилин нагуглив пару методів Celery, оновив Deployment, і вже хотів мержити PR, як виявилось, що…
Отже, в чому зараз проблема: SQS не підтримує кілька корисних нам речей:
SQS doesn’t yet support worker remote control commands.
SQS doesn’t yet support events, and so cannot be used with celery events, celerymon, or the Django Admin monitor.
Тобто, якщо ми спробуємо виконати celery inspect ping – то отримаємо помилки. Як мінімум тому, бо для цього потрібна черга pidbox, яка не підтримується в SQS.
Давайте спочатку на ці помилки глянемо.
get() та “No result backend is configured”
Помилку з get() ми вже бачили – якщо спробувати зробити таке без result_backend:
result = test_task.delay("Hello, comrade!")
print(result.get())
То отримаємо NotImplementedError(E_NO_BACKEND.strip()):
...
File "/home/setevoy/Scripts/Python/celery/.venv/lib/python3.13/site-packages/celery/backends/base.py", line 1104, in _is_disabled
raise NotImplementedError(E_NO_BACKEND.strip())
NotImplementedError: No result backend is configured.
Окей…
Але ж мабуть така серйозна бібліотека як Celery має власні механізми для перевірки воркерів?
Так – має.
Але…
Celery control inspect
Взагалі, celery.app.control класна штука, і якщо у вас RabbitMQ чи Redis – то з нею можна отримати багато корисної інформації.
Але у нас SQS, тому control працювати не буде.
Пробуємо перевірити таски для воркерів:
$ celery -A celery_app inspect registered
...
File "/home/setevoy/Scripts/Python/celery/.venv/lib/python3.13/site-packages/kombu/transport/SQS.py", line 381, in _resolve_queue_url
raise UndefinedQueueException((
...<2 lines>...
).format(sqs_qname))
kombu.transport.SQS.UndefinedQueueException: Queue with name '0f41e5d5-49e1-38bc-bc9b-c1efbc4f9a3e-reply-celery-pidbox' must be defined in 'predefined_queues'.
Або можемо спробувати пінганути воркери з app.control.ping() – отримаємо ту ж саму помилку з “pidbox must be defined in ‘predefined_queues’“:
І варіанти того, як цю проблему обійти в Kubernetes Liveness probes – просто… Відключити перевірки взагалі. Наприклад – тут>>>.
Можливе рішення: окремий result_backend
Отже, що я зараз намагаюсь зробити – це просто додати result_backend з другим контейнером Redis до Kubernetes Pod з Celery. Ресурсів Redis потребує копійки, тому в принципі якимось оверхедом це не буде.
Тобто, ми маємо:
SQS для меседжів
Redis для зберігання статусів обробки цих меседжів
Тоді з get() можемо отримати результат виконання тестової таски, і впевнитись, що воркери працюють.
Задаємо result_backend з Redis знов:
...
result_backend='redis://localhost:6379/0'
...
Додаємо нову таску в celery_tasks.py:
@app.task
def celery_health_check():
return "OK"
Додаємо “моніторинг” в наш “API”, my_api_app.py:
#!/usr/bin/env python
import sys
from celery_tasks import test_task, celery_health_check_task
def celery_health_check():
try:
result = celery_health_check_task.apply_async()
response = result.get(timeout=5)
print ("Result:", result)
print ("Result state:", result.state)
print ("Respose:", response)
if response != "OK":
raise RuntimeError("Celery health check task returned unexpected response!")
print("Celery is running")
except Exception as e:
print("Celery health check failed")
print({"status": "error", "message": str(e)})
sys.exit(1)
celery_health_check()
delay() – самий простий метод без додаткових параметрів, apply_async() вміє в різні опції. Нам зараз в принципі не важливо, але delay() ми вже використовували, тут давайте з apply_async().
Перезапускаємо Celery Worker, запускаємо наш головний скрипт:
$ ./my_api_app.py
Result: 2d32805b-3c55-412d-8fc4-4b893f222202
Result state: SUCCESS
Respose: OK
Celery is running
Гуд.
Що ми можемо ще?
Celery та FastAPI
Окрім виклику Celery через імпорти, ми можемо створити FastAPI сервіс, і все робити через TCP.
В останнє декоратори в Python трогав ще років 10 тому, в Python 2, хочеться трохи оновити пам’ять, бо зараз почав доволі активно ними користуватись, ну і ще раз подивитись як жеж воно працює під капотом, і що воно таке взагалі.
Пост вийшов трохи… дивний? Бо перша половина – в стилі “у нас є одне яблуко, і ми до нього додаємо ще одне”, а друга половина – якісь інтеграли. Але anyway – особисто в мене в голові картинка склалась, розуміння з’явилось, тому най буде так.
Отже, якщо коротко – Python decorator являє собою просто функцію, яка в аргументах приймає іншу функцію, і “додає” до неї якийсь новий функціонал.
Спочатку зробимо власний декоратор, подивимось як все це діло виглядає в пам’яті системи, а потім розберемо FaspAPI та його додавання роутів через app.get("/path").
В кінці будуть кілька корисних посилань, де більше детально розглядається теорія про функції і декоратори в Python, а тут буде суто практична частина.
Простий приклад Python decorator
Описуємо функцію, яка буде нашим декоратором, і нашу “робочу” функцію:
#!/usr/bin/env python
# a decorator function, which accetps another function as an argument
def decorator(func):
# extend the gotten function with a new feature
def do_domething():
print("I'm sothing")
# execute the function, passed in the argument
func()
# return the "featured" functionality
return do_domething
# just a common function
def just_a_func():
print("Just a text")
# run it
just_a_func()
Тут функція decorator() приймає аргументом будь-яку іншу функцію, а just_a_func() – наша “основна” функція, яка робить для нас якісь дії:
$ ./example_decorators.py
Just a text
Тепер ми можемо зробити такий фінт – створимо змінну $decorated, яка буде посиланням на decorator(), аргументом до decorator() передамо нашу just_a_func(), і викличемо $decorated як функцію:
...
# run it
#just_a_func()
# create a variable pointed to the decorator(), and pass the just_a_func() in the argument
decorated = decorator(just_a_func)
# call the function from the 'decorated' object
decorated()
Результат – у нас виконається і “внутрішня” функція do_domething(), бо вона є в return функції decorator(), і функція just_a_func(), яку ми передали в аргументах – бо в decorator.do_domething() є її виклик:
$ ./example_decorators.py
I'm sothing
Just a text
А тепер замість того аби створювати змінну і їй призначати функцію decorator() з аргументом – ми можемо зробити те саме, але через виклик декоратора як @decorator перед нашою робочою функцією:
#!/usr/bin/env python
# a decorator function, which accetps another function as an argument
def decorator(func):
# extend the gotten function with a new feature
def do_domething():
print("I'm sothing")
# execute the function, passed in the argument
func()
# return the "featured" functionality
return do_domething
# just a common function
#def just_a_func():
# print("Just a text")
# run it
#just_a_func()
# create a variable pointed to the decorator(), and pass the just_a_func() in the argument
#decorated = decorator(just_a_func)
# call the function from the 'decorated' object
#decorated()
@decorator
def just_a_func():
print("Just a text")
just_a_func()
І отримаємо той самий результат:
$ ./example_decorators.py
I'm sothing
Just a text
Як працюють декоратори?
Знаєте, чому з інфраструктурою простіше, ніж з програмуванням? Бо при роботі з серверами-мережами-кубернетесом у нас є якісь умовно-фізичні об’єкти, які ми можемо помацати руками і побачити очима. А в програмуванні – це все треба тримати в голові. Але є дуже дієвий лайф-хак: просто дивись на карту пам’яті процесу.
Давайте розберемо, що відбувається “під капотом”, коли ми використовуємо декоратори:
def decorator(func): в пам’яті створюється об’єкт функції decorator()
def just_a_func(): аналогічно, створюється об’єкт для функції just_a_func()
decorated = decorator(just_a_func): створюється третій об’єкт – змінна decorated:
decorated в собі містить посилання на функцію decorator()
аргументом до decorator() передається посилання на адресу, де знаходиться just_a_func()
функція decorator() створює новий об’єкт – do_domething(), бо вона є в return у decorator()
do_domething() виконує якісь додаткові дії, і викликає функцію, яка передана в func
В результаті, при виклику decorated як функції (тобто, з ()) – виконається функція do_domething(), а потім функція, яку передали аргументом, бо в аргументі func є посилання на функцію just_a_func().
Все це можна побачити в консолі:
>>> from example_decorators import *
>>> decorator # check the decorator() address
<function decorator at 0x7668b8eef2e0>
>>> just_a_func # check the just_a_func() address
<function just_a_func at 0x7668b8eef380>
>>> decorated # check the decorated variable address
<function decorator.<locals>.do_domething at 0x7668b8eef420>
Так як в decorated = decorator() ми створили посилання на функцію decorator() яка повертає свою внутрішню функцію do_domething(), то тепер decorated – це функція decorator.do_domething().
А у func ми будемо мати адресу just_a_func.
Для кращого розуміння – давайте просто глянемо на адреси пам’яті з функцією id():
#!/usr/bin/env python
# a decorator function, which accetps another function as an argument
def decorator(func):
# extend the gotten function with a new feature
def do_domething():
#print("I'm sothing")
print(f"Address of the do_domething() function: {id(do_domething)}")
# execute the function, passed in the argument
func()
print(f"Address of the 'func' argument: {id(func)}")
# return the "featured" functionality
return do_domething
# just a common function
def just_a_func():
return None
#print("Just a text")
print(f"Address of the decorator() function object: {id(decorator)}")
print(f"Address of the just_a_func() function object (before decoration): {id(just_a_func)}")
# run it
#just_a_func()
# create a variable pointed to the decorator(), and pass the just_a_func() in the argument
decorated = decorator(just_a_func)
decorated()
print(f"Address of the just_a_func() function object (after decoration): {id(just_a_func)}")
print(f"Address of the 'decorated' variable: {id(decorated)}")
Виконуємо скрипт, і маємо такий результат:
$ ./example_decorators.py
Address of the decorator() function object: 130166777561632
Address of the just_a_func() function object (before decoration): 130166777574272
Address of the do_domething() function: 130166777574432
Address of the 'func' argument: 130166777574272
Address of the just_a_func() function object (after decoration): 130166777574272
Address of the 'decorated' variable: 130166777574432
Тут:
decorator(): об’єкт функції за адресою 130166777561632 (створюється під час запуску програми)
just_a_func(): другий об’єкт функції за адресою 130166777574272 (створюється під час запуску програми)
виклик decorator() в decorated() створює об’єкт функцій do_domething(), який знаходиться за адресою 130166777574432 (створюється під час виконання decorator())
в аргументі func передається адреса об’єкту just_a_func() – 130166777574272
сама функція just_a_func() не змінюється, і знаходиться там жеж – 130166777574272
і змінна decorated тепер “відправляє” нас до функції do_domething() за адресою 130166777574432, бо decorator() виконує retrun значення do_domething()
Реальний приклад з FastAPI
Ну і давайте глянемо як це використовується в реальному житті.
Наприклад, я до цього посту прийшов, бо робив нові роути для FastAPI, і мені стало цікаво – як жеж FastAPI app.get("/path") додає роути?
Створимо файл fastapi_routes.py з двома роутами:
#!/usr/bin/env python
from fastapi import FastAPI
app = FastAPI()
# main route
@app.get("/")
def home():
return {"message": "default route"}
# new route
@app.get("/ping")
def new_route():
return {"message": "pong"}
Що тут відбувається:
створюємо інстанс класу FastAPI()
через декоратор @app.get("/") додаємо запуск функції home() при виклику path “/“
аналогічно робимо для запиту при виклику app з path “/ping“
$ uvicorn fastapi_routes:app --reload --port 8082
INFO: Will watch for changes in these directories: ['/home/setevoy/Scripts/Python/decorators']
INFO: Uvicorn running on http://127.0.0.1:8082 (Press CTRL+C to quit)
INFO: Started reloader process [2700158] using StatReload
INFO: Started server process [2700161]
INFO: Waiting for application startup.
INFO: Application startup complete.
...
->: return type annotation (анотація типу поверненого значення), тобто get() повертає якийсь тип даних
Callable[...]: повертається тип Callable (функція)
Callable[[DecoratedCallable], DecoratedCallable]: функція, яка повертається, приймає аргументом тип DecoratedCallable, і повертає теж тип DecoratedCallable
тип DecoratedCallable описаний в types.py: DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any]):
bound=Callable вказує, що типом даних може бути тільки функція (callable-об’єкт)
ця функція може приймати будь-які аргументи – ...,
і може повертати будь-які дані – Any
виклик app.get() повертає метод self.router.get()
а self.router.get() – це метод APIRouter, який описаний в routing.py#:1366, і який повертає метод self.api_route():
а функція api_route(), яка описана в тому ж routing.py#L963 повертає функцію-декоратор decorator(func: DecoratedCallable)
а функція decorator() викликає метод add_api_route() – в тому ж routing.py#L994:
а add_api_route першим аргументом приймає path, а другим – функцію func, яку треба зв’язати з цим роутом
Ми могли б переписати цей код так – залишимо додавання “/” через app.get(), а для “/ping” зробимо аналогічно тому, як робили в нашому першому прикладі – через створення змінної.
Тільки тут треба робити два об’єкти – спершу для app.get(), а потім вже викликати decorator() і передавати нашу функцію:
#!/usr/bin/env python
from fastapi import FastAPI
app = FastAPI()
# main route
@app.get("/")
def home():
return {"message": "default route"}
# new route
def new_route():
return {"message": "pong"}
# create 'decorator' variable pointed to the app.get() function
# the 'decorator' then will return another function, the decorator() itself
decorator = app.get("/ping")
# create another variable using the decorator() returned by the get() above, and pass our function
decorated = decorator(new_route)
Результат буде аналогічним в обох випадках – і для “/“, і для “/ping“.
Для більшої ясності – давайте це знову зробимо в консолі:
[root@ip-10-0-46-247 ec2-user]# crictl
NAME:
crictl - client for CRI
USAGE:
crictl [global options] command [command options] [arguments...]
COMMANDS:
attach Attach to a running container
create Create a new container
exec Run a command in a running container
version Display runtime version information
images, image, img List images
inspect Display the status of one or more containers
inspecti Return the status of one or more images
...
Варіант 1: використання crictl inspect та hostPath
Використовуючи Container ID із kubectl describe pod отримаємо інформацію про цей контейнер на хості і про його mounts:
[root@ip-10-0-46-247 ec2-user]# ll /var/lib/kubelet/pods/7b2b0205-8c7e-430f-995b-a45cd79ecb9f/volumes/kubernetes.io~csi/pvc-ed3831bc-56a2-4660-9aef-b47cd252edac/mount
total 40
drwxr-xr-x 6 root root 4096 Oct 1 00:51 cache
drwxr-xr-x 4 root root 4096 Dec 4 2023 data
-rw-r--r-- 1 root root 0 Oct 1 00:52 flock.lock
drwxr-xr-x 6 root root 4096 Sep 29 04:00 indexdb
drwx------ 2 root root 16384 Dec 4 2023 lost+found
drwxr-xr-x 2 root root 4096 Dec 4 2023 metadata
drwxr-xr-x 2 root root 4096 Dec 4 2023 snapshots
drwxr-xr-x 3 root root 4096 Oct 1 00:52 tmp
Варіант 2: з /proc/PID/root
Інший варіант – через PID процесу який ми теж бачили в crictl inspect – в данному випадку це буде 57398c3184cd229be564b140f32a9214b38a507137522904eab6ae38b676432a:
Робити будемо як завжди – спочатку вручну локально на робочій машині, подивимось, як воно працює, а потім додамо конфіг для Helm-чарту, задеплоїмо в Kubernetes, і подивимось, як налаштувати ContainerD для використання цього mirror.
Запуск Sonatype Nexus локально з Docker
Створюємо локальний каталог для nexus data, аби дані зберігались при рестарті Docker, міняємо юзера, бо будемо ловити помилки типу:
Заходимо в браузері на http://localhost:8080, логінимось в систему:
Setup wizard можна пропустити, або швиденько проклікати “Next” і задати новий пароль адміну.
Створення Docker cache repository
Переходимо в Administration > Repository > Repositories:
Клікаємо Create repository:
Вибираємо тип docker (proxy):
Задаємо ім’я, HTTP-порт, на якому будуть прийматись конекти від docker-daemon, і дозволяємо anonymous docker pull:
Далі задаємо адресу, з якої будемо пулити образи – https://registry-1.docker.io, решту параметрів поки можна залишити без змін:
Окремо варто згадати можливість створення docker (group), де налаштовується єдиний конектор для кількох репозиторіїв в Nexus. Але мені це поки не потрібно, хоча в майбутньому – можливо.
Рестартимо локальний Docker, виконуємо docker pull і дивимось логи с journalctl -u docker:
$ sudo journalctl -u docker --no-pager -f
...
level=debug msg="Trying to pull rabbitmq from http://172.17.0.2:8092/"
level=info msg="Attempting next endpoint for pull after error: Head \"http://172.17.0.2:8092/v2/library/rabbitmq/manifests/latest\": unauthorized: "
level=debug msg="Trying to pull rabbitmq from https://registry-1.docker.io"
...
Першого разу я забув включити Docker Bearer Token Realm.
А другого разу в мене в ~/.docker/config.json був збережений токен для https://index.docker.io, і Docker намагався використати його. В такому випадку можна просто видалити/перемістити config.json, і виконати pull ще раз.
Окей.
А що там с ContainerD? Бо в AWS Elastic Kubenretes Service у нас не Docker.
ContainerD та registry mirrors
Чесно кажучи, з containerd болі було більше, ніж з Nexus. Тут і його TOML для конфігів, і різні версії самого ContainerD та конфігурації, і deprecated параметри.
додамо окремий blob store: підключимо окремий persistentVolume, бо в дефолтному лише 8 гіг, і якщо для PyPi цього більш-менш достатньо, то для Docker images буде замало
додамо additionalPorts: тут задаємо порт, на якому буде Docker cache
В мене деплоїться з Terraform під час налаштування Kubernetes-кластеру.
Все разом, з PyPi, в мене зараз виглядає так:
resource "helm_release" "nexus" {
namespace = "ops-nexus-ns"
create_namespace = true
name = "nexus3"
repository = "https://stevehipwell.github.io/helm-charts/"
#repository_username = data.aws_ecrpublic_authorization_token.token.user_name
#repository_password = data.aws_ecrpublic_authorization_token.token.password
chart = "nexus3"
version = "5.7.2"
# also:
# Environment:
# INSTALL4J_ADD_VM_PARAMS: -Djava.util.prefs.userRoot=${NEXUS_DATA}/javaprefs -Xms1024m -Xmx1024m -XX:MaxDirectMemorySize=2048m
values = [
<<-EOT
# use existing Kubernetes Secret with admin's password
rootPassword:
secret: nexus-root-password
key: password
# enable storage
persistence:
enabled: true
storageClass: gp2-retain
# create additional PersistentVolume to store Docker cached data
extraVolumes:
- name: nexus-docker-volume
persistentVolumeClaim:
claimName: nexus-docker-pvc
# mount the PersistentVolume into the Nexus' Pod
extraVolumeMounts:
- name: nexus-docker-volume
mountPath: /data/nexus/docker-cache
resources:
requests:
cpu: 100m
memory: 2000Mi
limits:
cpu: 500m
memory: 3000Mi
# enable to collect Nexus metrics to VictoriaMetrics/Prometheus
metrics:
enabled: true
serviceMonitor:
enabled: true
# use dedicated ServiceAccount
# still, EKS Pod Identity isn't working (yet?)
serviceAccount:
create: true
name: nexus3
automountToken: true
# add additional TCP port for the Docker caching listener
service:
additionalPorts:
- port: 8082
name: docker-proxy
containerPort: 8082
hosts:
- nexus-docker.ops.example.co
# to be able to connect from Kubernetes WorkerNodes, we have to have a dedicated AWS LoadBalancer, not only Kubernetes Service with ClusterIP
ingress:
enabled: true
annotations:
alb.ingress.kubernetes.io/group.name: ops-1-30-internal-alb
alb.ingress.kubernetes.io/target-type: ip
ingressClassName: alb
hosts:
- nexus.ops.example.co
# define the Nexus configuration
config:
enabled: true
anonymous:
enabled: true
blobStores:
# local EBS storage; 8 GB total default size ('persistence' config above)
# is attached to a repository in the 'repos.pip-cache' below
- name: default
type: file
path: /nexus-data/blobs/default
softQuota:
type: spaceRemainingQuota
limit: 500
# dedicated sorage for PyPi caching
- name: PyPILocalStore
type: file
path: /nexus-data/blobs/pypi
softQuota:
type: spaceRemainingQuota
limit: 500
# dedicated sorage for Docker caching
- name: DockerCacheLocalStore
type: file
path: /data/nexus/docker-cache
softQuota:
type: spaceRemainingQuota
limit: 500
# enable Docker Bearer Token Realm
realms:
enabled: true
values:
- NexusAuthenticatingRealm
- DockerToken
# cleanup policies for Blob Storages
# is attached to epositories below
cleanup:
- name: CleanupAll
notes: "Cleanup content that hasn't been updated in 14 days downloaded in 28 days."
format: ALL_FORMATS
mode: delete
criteria:
isPrerelease:
lastBlobUpdated: "1209600"
lastDownloaded: "2419200"
repos:
- name: pip-cache
format: pypi
type: proxy
online: true
negativeCache:
enabled: true
timeToLive: 1440
proxy:
remoteUrl: https://pypi.org
metadataMaxAge: 1440
contentMaxAge: 1440
httpClient:
blocked: false
autoBlock: true
connection:
retries: 0
useTrustStore: false
storage:
blobStoreName: default
strictContentTypeValidation: false
cleanup:
policyNames:
- CleanupAll
- name: docker-cache
format: docker
type: proxy
online: true
negativeCache:
enabled: true
timeToLive: 1440
proxy:
remoteUrl: https://registry-1.docker.io
metadataMaxAge: 1440
contentMaxAge: 1440
httpClient:
blocked: false
autoBlock: true
connection:
retries: 0
useTrustStore: false
storage:
blobStoreName: DockerCacheLocalStore
strictContentTypeValidation: false
cleanup:
policyNames:
- CleanupAll
docker:
v1Enabled: false
forceBasicAuth: false
httpPort: 8082
dockerProxy:
indexType: "REGISTRY"
cacheForeignLayers: "true"
EOT
]
}
Деплоїмо, відкриваємо порт:
$ kk -n ops-nexus-ns port-forward svc/nexus3 8081
Перевіряємо Realm:
Перевіряємо сам Docker repository:
Гуд.
Ingress/ALB для Nexus Docker cache
Так як ContainerD на EC2 не відноситься до Kubernetes, то і доступу до Kubernetes Service з ClusterIP в нього нема. Відповідно, він не зможе виконати pull образів з порта 8082 на nexus3.ops-nexus-ns.svc.cluster.local.
В Helm chart є можливість створити окремий Ingress, в якому задаємо всі параметри:
...
# to be able to connect from Kubernetes WorkerNodes, we have to have a dedicated AWS LoadBalancer, not only Kubernetes Service with ClusterIP
ingress:
enabled: true
annotations:
alb.ingress.kubernetes.io/group.name: ops-1-30-internal-alb
alb.ingress.kubernetes.io/target-type: ip
ingressClassName: alb
hosts:
- nexus.ops.example.co
...
Але пам’ятаєте, як в тому анекдоті – “А тепер забудьте все, чому вас вчили в університеті”?
Ну, от – забудьте все, що ми робили з конфігами containerd вище, бо це вже deprecated way. Тепер стільно-модно-молодьожно робити з Registry Host Namespace.
Ідея в тому, що в /etc/containerd/certs.d створюється каталог для registry, в ньому файл hosts.toml, а вже в ньому – описується налаштування registry.
В нашому випадку виглядати це буде так:
[root@ip-10-0-45-117 ec2-user]# tree /etc/containerd/certs.d/
/etc/containerd/certs.d/
└── docker.io
└── hosts.toml
І в hosts.toml:
server = "https://docker.io"
[host."http://nexus-docker.ops.example.co"]
capabilities = ["pull", "resolve"]
Окей. Описуємо це все діло в UserData нашого тестового EC2NodeClass.
Так тут наш “улюблений” YAML – то приведу весь конфіг, аби не мати проблем з відступами, бо трохи погемороївся:
Не часто, але іноді виникає потреба завантажити систему з usb, і перезібрати initramfs-linux.img.
Цей пост – скоріш просто невеликий нотаток для себе як, що, і куди маунтити на робочому ноуті, аби запустити mkinitcpio, бо в мене є розділи LVM, є окремі розділи на диску під /boot та swap.
iwctl та WiFi
Отже, завантажуємось з флешки, і налаштовуємо WiFi, аби далі всі команди робити з іншого компа, де можна копіпастити з RTFM:
[root@archiso ~]# iwctl
# station wlan0 connect setevoy-linksys-5-0
Задаємо пароль root:
[root@archiso ~]# passwd root
Отримуємо IP:
[root@archiso ~]# ip a s wlan0
3: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
inet 192.168.3.114/24 brd 192.168.3.255 scope global dynamic noprefixroute wlan0
До того ж, в AWS RDS для PostgreSQL є можливість включити логування Execution Plans з EXPLAIN, що теж корисно для моніторингу і дебагу, тому подивимось як це включити і які параметри для цього є.
Explain відобразить нам Execution Plan. А аби краще розуміти що таке Execution Plan – глянемо на те, як взагалі виглядає процес виконання запитів.
Що таке Execution Plan в PostgreSQL
Коли ми відправляємо запит до PostgreSQL, то:
запит проходить через лексичний (Lexing) та синтаксичний (Parsing) аналіз
Lexing: розділення на токени (оператори) – SELECT, FROM, WHERE тощо
Parsing: перевірка синтаксису та створення дерева синтаксичного аналізу (Parse Tree)
далі відбувається аналіз (Analysis/Binding)
перевірка існування таблиць, колонок та функцій, прав доступу
створюється схематична інформація (schema information) – мета-дані про структуру таблиць, індекси, привілегії тощо
створюється логічний план (Logical Plan Generation)
на основі дерева синтаксичного аналізу (Parse Tree) формується логічний план запиту, в якому визначаються операції, які треба виконати – JOIN, AGREGATE etc
виконується оптимізація запиту (Query Optimization)
визначаються найкращі алгоритми JOIN (Nested Loop, Hash Join, Merge Join)
вирішується, чи використовувати індекси (Index Scan, Bitmap Index Scan) або повний перегляд таблиці (Seq Scan)
генерується фізичний план (Execution Plan Generation)
логічний план перетворюється у конкретний набір операцій для виконання (Seq Scan, Index Scan, Hash Join тощо)
визначається, як буде використовуватися оперативна пам’ять, кеш і тимчасові файли на диску (саме тому фізичний план – бо вже враховуються фізичні ресурси системи)
і нарешті відбувається виконання запиту (Execution)
отримуємо дані з диску, змінюємо дані в пам’яті тощо
якщо це SELECT – дані відправляються клієнту
якщо виконується INSERT/UPDATE/DELETE – дані фізично змінюються на диску
завершення операції (Commit або Rollback)
COMMIT (підтвердження транзакції) – зміни фіксуються в базі даних, і стають доступні іншим клієнтам
ROLLBACK (скасування транзакції) – зміни повертаються до стану, який був до BEGIN
Використання EXPLAIN
Отже, з EXPLAIN ми можемо отримати Execution Plan – тобто, побачити те, які операції будуть виконуватись, і скільки даних і ресурсів вони торкнуться.
Найпростіший приклад може виглядати так:
EXPLAIN SELECT * FROM table_name;
Execution Plan являє собою дерево фізичних операцій, де Nodes – це окрема операція, така як Seq Scan, Index Scan, Sort, Join, Aggregate тощо, а стрілочки – зв’язки між вузлами і передача даних між операціями.
ANALYZE: коли ми просто викликаємо EXPLAIN, то реальні дії не виконуються; додавання ANALYZE запускає виконання запиту, і потім відображає статистику
default: FALSE
VERBOSE: включає в результат додаткову інформацію (імена колонок, функцій тощо)
default: FALSE
COSTS: відображення “вартості” виконання операції – умовна одиниця вимірювання витрат на виконання запиту з врахування затрат CPU та IO
default: TRUE
BUFFERS: відображення використання буферів пам’яті (тільки якщо ANALYZE=TRUE) – скільки даних знайдено в кеші, скільки буде зчитано з диску, записано на диск тощо
default: FALSE
FORMAT: в якому форматі вивести результат – TEXT, XML, JSON, YAML
default: TEXT
Результати EXPLAIN
Давайте глянемо, що у нас було в результаті виконання такого запиту:
EXPLAIN (ANALYZE, VERBOSE, BUFFERS, FORMAT YAML) SELECT * FROM foster_home;
Що цікавого ми тут маємо:
основний план і загальна інформація:
Node Type: "Seq Scan": операція послідовного сканування (Sequential Scan), без використання індексів
Parallel Aware: false: запит буде виконано без паралелізації
Async Capable: false: запит не може бути виконано асинхронно
Relation Name: "foster_home": ім’я таблиці, над якою виконуються дії
Schema: "public": ім’я схеми, в якій знаходиться таблиця
оцінка вартості (COSTS):
Startup Cost: 0.00: операція починається миттєво, оскільки це послідовне читання
Total Cost: 16242.01: оцінка вартості всієї операції (не час, а умовні одиниці)
Plan Rows: 35801: планується повернути в результаті запита 35801 рядків
Plan Width: 1330: середній очікуваний розмір рядка в байтах
фактичне виконання запиту (ANALYZE):
Actual Startup Time: 0.018: час на початок виконання запиту
Actual Total Time: 638.911: скільки всього часу зайняло виконання
Actual Rows: 35801: скільки рядків було повернуто в результаті виконання
вихідні дані (Output):
список колонок, які були повернуті в результаті запиту (всі, бо виконуємо SELECT *)
буфери пам’яті (BUFFERS):
Shared Hit Blocks: 455: в shared_buffers було знайдено 455 блоків з існуючими даними
Shared Read Blocks: 15429: 15429 блоків довелось читати з диску
Shared Dirtied Blocks: 0 та Shared Written Blocks: 0: в результаті виконання запиту ніякі дані не змінювались
Temp Read Blocks: 0 та Temp Written Blocks: 0 – тимчасові файли не використовувались (temp_blks_read в pg_stat_statements)
операції введення/виведення (I/O)
I/O Read Time: 599.188: скільки часу витратили на читання з диску
I/O Write Time: 0.000: нічого не писали
Temp I/O Read Time: 0.000 та Temp I/O Write Time: 0.000: знов-таки, тимчасові файли не використовувались
EXPLAIN та використання пам’яті
Що найбільш цікаве для мене, бо до цього всього ми прийшли саме тому, що розбирались з питанням “що з’їло всю пам’ять на сервері” – це скільки пам’яті буде використано при виконання різних запитів.
Використання shared_buffers
Отже, в результатах EXPLAIN ми бачили, що оцінка була:
Plan Rows: 35801: планується повернути в результаті запита 35801 строк
Plan Width: 1330: і кожен рядок займе 1330 байт у пам’яті
Тобто, в результаті виконання запиту SELECT * FROM foster_home очікується отримати 35801 рядків, в середньому кожен розміром в 1330 байт.
Відповідно, в shared_buffers на час виконання запиту планується мати:
35801*1330/1024/1024
45
45 мегабайт.
Але реальний розмір після виконання вказано в Shared Hit Blocks + Shared Read Blocks: 455 блоків даних по 8 КБ вже було в пам’яті, і 15429 блоків було зчитано з диску.
Отже, в результаті виконання SELECT * FROM foster_home ми в shared_buffers будемо мати 124 мегабайти (3640 байти там вже були, і 123432 буде додатково зчитано з диску).
При тому, що під shared_buffers на Dev-сервері всього виділено:
dev_kraken_db=> SHOW shared_buffers;
shared_buffers
----------------
190496kB
~186 мегабайт.
Різниця в Plan Width і Shared Read Blocks
Чому в плані ми бачили ~45 мегабайт, а після виконання – 124 MB?
Бо Plan Width відображає дані по середньому розміру рядка, а Shared Read Blocks – скільки фізичних блоків даних було прочитано з диску.
Для значень в Plan Width PostgreSQL використовує дані з view pg_stats.avg_width.
Колонки в нашій таблиці:
dev_kraken_db=> \d foster_home
Table "public.foster_home"
Column | Type | Collation | Nullable | Default
---------------------+-------+-----------+----------+---------
challenge_id | text | | |
type | text | | |
parent_challenge_id | text | | |
source_challenge_id | text | | |
base_challenge_id | text | | |
bio_parent_id | text | | |
matched_by | text | | |
title | text | | |
challenge | jsonb | | |
user_id | text | | |
Indexes:
"foster_home_challenge_id" btree (challenge_id)
Перевірити, скільки work_mem споживає запит, і чи достатньо дефолтних 4 мегабайт можемо так:
dev_kraken_db=> EXPLAIN (ANALYZE, VERBOSE, BUFFERS, FORMAT YAML)
dev_kraken_db-> SELECT * FROM foster_home ORDER BY title;
QUERY PLAN
--------------------------------------------
- Plan: +
Node Type: "Gather Merge" +
...
Temp Read Blocks: 5497 +
Temp Written Blocks: 5507 +
...
Temp I/O Read Time: 192.337 +
Temp I/O Write Time: 117.445 +
...
Plans: +
- Node Type: "Sort"
...
Sort Key: +
- "foster_home.title" +
Sort Method: "external merge" +
Sort Space Used: 17048 +
Sort Space Type: "Disk" +
...
Тут ми бачимо, що:
Temp Read та Written Blocks: використовувались тимчасові блоки
Temp I/O Read та Write Time: був витрачений час на роботу з диском
Sort Method: "external merge" та Sort Space Type: "Disk": для сортування використовувався диск, а не "Memory", бо в work_mem весь результат не вмістився
Sort Space Used: 17048: для сортування знадобилось 17048 кілобайт (16 мегабайт)
Перевірити поточне значення для work_mem можемо так:
dev_kraken_db=> SHOW work_mem;
work_mem
----------
4MB
Саме тому для сортування знадобилось створювати temp files.
Якщо ж ми збільшимо work_mem (через SET для поточної сесії, або через Parameters Group для RDS аби зміни були постійними):
Отже, як резюме: з EXPLAIN ANALYZE ми можемо отримати інформацію про те, скільки даних при виконанні запиту буде зчитано з диску в shared_buffers, і скільки даних буде використано в work_mem або temp files
Як нам це може допомогти моніторити “важкі” запити в AWS RDS?
AWS RDS PostgreSQL та auto_explain
В RDS ми можемо включити auto_explain, який буде записувати в лог результат EXPLAIN для подальшого аналізу.
Мігруємо наш Backend API з DynamoDB на AWS RDS PostgreSQL, і кілька раз RDS падав.
Власне, враховуючи те, що ми задля економії взяли db.t3.small з двома vCPU і двома гігабайтами пам’яті – то доволі очікувано, але стало цікаво чому ж саме все падало.
Через кілька днів почав цю тему дебажити, і хоча причини поки не знайшли – але з’явилась непогана чернетка того, як можна поінвестигейтити проблеми з перформансом в RDS PostgreSQL.
Пост – не звичайний “як зробити”, а скоріше просто записати для себе – куди і на що наступного разу дивитись, і які зміни в моніторингу можна зробити, аби наступного разу побачити проблему раніше, ніж вона стане критичною.
The Issue
Отже, як все починалось.
Backend API запущений в AWS Elastic Kubernetes Service, і в якийсь момент посипались алерти по 503 помилкам:
З’явились алерти на використання Swap на Production RDS:
В Sentry з’явились помилки про проблеми з підключенням до серверу баз даних:
Починаємо перевіряти моніторинг RDS, і бачимо, що в якийсь момент Freeable Memory впала до 50 мегабайт:
Коли сервер впав, ми його перезапустили – але проблема тут же виникла знов.
Тому вирішили поки що переїхати на db.m5.large – на графіку видно, як вільна пам’ять стала 7.25 GB.
Ну і давайте глянемо, що цікавого ми можемо побачити з всієї цієї історії.
Або виконувати прямо з коду при ініціалізації підключень:
with engine.connect() as conn:
conn.execute("SET application_name TO 'MyApp'")
Другий варіант виглядає привабливішим, бо connection string до Backend API передається змінною оточення з AWS Secret Store, і робити окремий URL тільки заради одного параметру application_name для кожного сервісу API виглядає трохи костильно.
Тому краще в кожній апці бекенду задавати власний параметр при створенні підключення.
Корисні PostgreSQL Extentions
По ходу діла додавав кілька PostgreSQL Extentions, які прям дуже корисні в таких справах для моніторингу і інвестігейту.
Включення pg_stat_statements
Теж на жаль не було включено на момент проблеми, але в цілому прямо must have штука.
В RDS PostgreSQL версій 11 і вище бібліотека включена по дефолту, тому все, що треба зробити – це створити EXTENSION, див. CREATE EXTENSION.
Перевіряємо, чи є extention зараз:
dev_kraken_db=> SELECT *
FROM pg_available_extensions
WHERE
name = 'pg_stat_statements' and
installed_version is not null;
name | default_version | installed_version | comment
------+-----------------+-------------------+---------
(0 rows)
dev_kraken_db=> SELECT *
FROM pg_available_extensions
WHERE
name = 'pg_stat_statements' and
installed_version is not null;
name | default_version | installed_version | comment
--------------------+-----------------+-------------------+------------------------------------------------------------------------
pg_stat_statements | 1.10 | 1.10 | track planning and execution statistics of all SQL statements executed
(1 row)
І спробуємо отримати якусь інформацію з pg_stat_statements і таким запитом:
SELECT query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
Далі в цьому пості будуть ще приклади того, яку інформацію з pg_stat_statements можемо отримати.
Включення pg_stat_activity
Окрім pg_stat_statements, корисну інформацію по поточній активності можна отримати з pg_stat_activity, включена по дефолту.
Обидві являють собою views, хоча в різних схемах бази:
dev_kraken_db=> \dv *.pg_stat_(statements|activity)
List of relations
Schema | Name | Type | Owner
------------+--------------------+------+----------
pg_catalog | pg_stat_activity | view | rdsadmin
public | pg_stat_statements | view | rdsadmi
Різниця між pg_stat_activity та pg_stat_statements у PostgreSQL
Обидві допомагають аналізувати запити, але pg_stat_activity – це поточна активність, а pg_stat_statements – “історична”:
Параметр
pg_stat_activity
pg_stat_statements
Що показує?
Поточні активні сесії та їхній стан.
Історія виконаних SQL-запитів зі статистикою.
Дані в режимі реального часу?
Так, тільки активні процеси.
Ні, це накопичена статистика по всіх запитах.
Які запити видно?
Тільки ті, що виконуються прямо зараз.
Запити, які виконувались раніше (навіть якщо вже завершилися).
Чи зберігає історію?
Ні, дані зникають після завершення запиту.
Так, PostgreSQL збирає та агрегує статистику.
Що можна дізнатися?
Який запит зараз працює, скільки він триває, на що він чекає (CPU, I/O, Locks).
Середній, мінімальний, максимальний час виконання запитів, кількість викликів.
Основне використання
Аналіз продуктивності в режимі реального часу, пошук проблемних запитів зараз.
Пошук “важких” запитів, які створюють навантаження в довгостроковій перспективі.
Включення pg_buffercache
Ще один корисний extension – це pg_buffercache, який може відобразити інформацію по стану пам’яті в PosgtreSQL.
Включається аналогічно до pg_stat_statements:
CREATE EXTENSION IF NOT EXISTS pg_buffercache;
Далі теж подивимось на цікаві запити для перевірки стану пам’яті в PostgreSQL.
Окей. Повертаємось на нашої проблеми.
CPU utilization та DBLoad
Перше, на що звернули увагу – це навантаження на CPU.
У PostgreSQL кожна клієнтська сесія створює окремий процес (backend process).
DBLoad – це метрика AWS RDS PostgreSQL, яка відображає значення активних сесій, які виконуються або очікують на ресурси – CPU, disk I/O, Locks. Не враховуються сесії в статусі idle, але враховуються сесії в статусі active, idle in transaction або waiting.
DBLoad схожий на Load Average у Linux, але враховує тільки PostgreSQL-сесії:
У Linux Load Average показує кількість процесів на Linux-сервері, які або використовують CPU, або чекають на нього чи на I/O
У RDS DBLoad відображає середню кількість активних сесій на сервері PostgreSQL, які або працюють, або чекають ресурси
Тобто в ідеалі кожен backend-процес, який виконує запити від підключеного клієнта, має мати доступ до “власного” ядра vCPU, отже DBLoad має бути ~= кількості vCPU або менше.
Якщо ж DBLoad значно перевищує кількість доступних ядер – то це показник, що система перевантажена і процеси (сесії) очікують в черзі на CPU або інші ресурси.
DBLoad включає в себе ще два показники:
DBLoadCPU: сесії, які знаходяться саме в очікуванні вільного CPU
DBLoadNonCPU: сесії, які знаходяться в очікуванні диску, database table locks, networking, etc
Перевірити сесії, які будуть вважатись активними і будуть включені в DBLoad можемо так:
SELECT pid, usename, state, wait_event, backend_type, query
FROM pg_stat_activity
WHERE state != 'idle'
Нормальне значення для DBLoad
DBLoad має бути приблизно рівним або нижче кількості доступних vCPU.
DBLoad vs CPU Utilization
Чому на першому скріні ми бачимо “100%”, а на другому просто кількість в 17.5?
CPU Utilization: відсоток використання CPU від загальної доступної потужності
DBLoad: кількість активних сесій
Враховуючи, що на сервері в той момент було 2 доступних vCPU, і при цьому 17 активних сесій – то маємо 100% використання процесорного часу.
Окремо варто завернути увагу на DBLoadRelativeToNumVCPUs – це DBLoad поділений на кількість доступних vCPU, тобто середнє навантаження на кожне ядро CPU.
DBLoadCPU (Database Load on CPU Wait)
DBLoadCPU відображає кількість активних сесій, які очікують на CPU, тобто процеси, які не можуть виконуватись, бо всі доступні CPU зайняті.
В ідеалі має бути близько нуля – тобто, на сервері не має бути процесів, які очікують CPU.
Якщо DBLoadCPU має значення близько DBLoad, то RDS не встигає обробити всі запити – не вистачає CPU time, і вони стають в чергу.
Перевірити можемо тим самим запитом з pg_stat_activity, як вище: якщо в wait_event = "CPU", то це процеси, які чекають вільного CPU.
Нормальне значення для DBLoadCPU
DBLoadCPU має бути якнайнижчим (близьким до нуля).
Якщо DBLoadCPU майже дорівнює DBLoad, то:
основне навантаження саме на процесор
сесії не блокуються через Table Locks або повільний диск (I/O), а просто чекають CPU
DBLoadNonCPU (Database Load on Non-CPU)
DBLoadNonCPU, власне, відображає інформацію очікування ресурсів, не пов’язаних з CPU.
Це можуть бути:
блокування (Locks): очікування доступу до таблиці або рядка
I/O очікування (I/O Wait): повільне читання або запис через дискові обмеження
Network Wait: затримки через мережеві операції (наприклад, реплікація або передача даних)
Other Wait Events: інші очікування, такі як процеси фонового обслуговування
Перевірити такі сесії можемо аналогічно до попередніх запитів з pg_stat_activity, але додамо виборку wait_event_type та wait_event:
SELECT pid, usename, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start;
Тут wait_event_type вказує на тип ресурсу, на який очікує процес (CPU, IO, Lock, WAL, Client), а wait_event деталізує яку конкретно операцію процес очікує.
Наприклад, wait_event_type може бути “IO“, тоді в wait_event_type можуть бути значення “DataFileRead” (очікування читання з диска) або “DataFileWrite” – очікування запису на диск.
Або, якщо wait_event_type == Client, тоді wait_event_type може бути “ClientRead“, “ClientWrite“, “ClientSocket“.
DBLoad17.5: при двох vCPU – маємо багато активних сесій, процесор не встигає обробити всі запити
DBLoadCPU13.9: багато сесій очікують на доступний CPU
DBLoadNonCPU3.59: частина запитів очікували диск, блокування, або якісь мережеві запити
Operating System CPU utilization
Окрім метрик DBLoad, які відносяться саме до RDS та PostgreSQL, у нас ще є інформація по самій операційній системі, де маємо інформацію і по диску, і по пам’яті, і по CPU.
Власне в CPU utilization ми маємо графік у відсотках з використання CPU, який складається з кількох метрик, кожна з яких відображає окремий режим:
os.cpuUtilization.steal.avg (Steal Time): очікування фізичного CPU, якщо AWS виділила його іншому інстансу на цьому фізичному сервері, або якщо CPU Credits використано, і AWS обмежує ваш інстанс
os.cpuUtilization.guest.avg (Guest Time): CPU, який “з’їла” гостьова операційна система – якщо на сервері є Virtual Machine або Docker, але не про RDS
os.cpuUtilization.irq.avg (Interrupt Requests, IRQ Time): очікування Interrupt Requests, IRQ Time – обробка апаратних переривань (мережеві запити або диск), може бути пов’язана з високим IOPS на EBS
os.cpuUtilization.wait.avg (I/O Wait Time): час I/O Wait Time, дискові операції, наприклад – зчитування файлів
os.cpuUtilization.user.avg (User Time): час на юзер-процеси, в даному випадку це можуть бути обробка запитів PostgreSQL
os.cpuUtilization.system.avg (System Time): робота ядра операційної системи (обробка процесів з user space, дискові операції, операції з пам’яттю)
os.cpuUtilization.nice.avg (Nice Time): час на процеси з пріоритетом nice – низькопріоритетні фонові завдання
Тут us – user, sy – system, ni – nice, id – idle і так далі.
З нашого графіку в RDS Performance Insights ми маємо найбільшу частину саме по wait – і в той час є спайки по EBS IO operations:
Тобто, “прилетів” якийсь запит, який почав активно вичитувати з диска:
І поки CPU чекав на завершення операцій з диском – решта запитів виконувались повільніше.
В той самий час маємо “провал” по Freeable memory – бо дані з диска записувались в пам’ять.
І хоча саме значення в 460 IOPS не виглядає якимось зависоким, але схоже, що саме в цей момент ми “з’їли” доступну пам’ять.
На що нам в даному випадку може вказувати високий os.cpuUtilization.wait.avg?
повільний EBS: все ж не наш кейс, бо маємо швидкість до 3000 IOPS; хоча очікування читання з диску в пам’ять могло спричинити ріст I/O Waits на CPU
блокування/Locks: як варіант, але у нас є метрика db.Transactions.blocked_transactions.avg – і там все було добре, тобто PostgreSQL не чекав на звільнення locks – на транзакції теж зараз глянемо
мало оперативної пам’яті: читання нових даних з диску в пам’ять витіснило існуючі там дані в Swap, і потім вичитувало їх звідти назад, при цьому скидуючи дані з пам’яті обратно в Swap, аби завантажити зі swap нові (swap storm)
Використання Swap в цей час теж виросло:
До Swap і ReadIOPS зараз перейдемо, але спочатку давайте глянемо на транзакції.
Transactions
Ще з цікавого – активність транзакцій:
Бачимо, що як тільки почались проблеми з CPU – у нас xact_commit та xact_rollback (графік зліва) впали до нуля, і в той же час кількість active_transactions виросла до ~20, але при цьому blocked_transactions було 0.
Вже не можу зробити скрін, але ще був спайк по “idle in transaction” – тобто, транзакції починались (BEGIN), але не завершувались (не виконали COMMIT або ROLLBACK).
Але як так може бути? Навіть при високому CPU Waits хоча б частина транзакцій мали б завершитись.
зависокий Read IOPS: система не могла отримати дані з диску?
ні – ReadIOPS виріс, але не прям настільки критично
однак через те, що FreeableMemory був занизьким, дані з shared_buffers могли бути скинуті до swap, що викликало ще більші затримки у процесів, які ці дані очікували
зависокий Write IOPS: система не могла виконати запис WAL (Write Ahead Logs, будемо розбирати далі), якого потребує завершення транзакцій
але ми бачили, що Write IOPS був в нормі
багато table locks, і процеси очікували вивільнення ресурсів?
теж ні, бо ми бачили, що blocked_transactions було на нулі
робота autovacuum або ANALYZE, які могли заблокувати транзакції?
але знов-таки – db.Transactions.blocked_transactions.avg був на нулі
Swap storm: оце вже більше схоже на правду:
читання з диску витіснило активні дані на Swap (впав показник FreeableMemory)
Swap Usage виріс майже до 3-х гігабайт
PostgreSQL не міг отримати сторінки з shared_buffers, бо вони були в SWAP (про пам’ять теж далі буде)
через це транзакції “зависли” в очікуванні читання з диска, замість того щоб працювати у RAM
Що ми можемо перевірити в таких випадках?
I/O Waits або Blocks
SELECT pid, usename, query, wait_event_type, wait_event, state
FROM pg_stat_activity
WHERE state = 'active';
Якщо в wait_event маємо “I/O” або “Locks” – то причина може бути тут.
WAL – Write Ahead Logs
при кожній операції DML (Data Manipulation Language), наприклад при INSERT, UPDATE або DELETE, дані спочатку змінюються в пам’яті (shared_buffers – будемо далі про них говорити), де створюється “контекст операції”
одночасно ця операція заноситься у WAL-буфер (wal_buffers – буфер пам’яті)
коли wal_buffers заповнюється, або коли транзакція завершена, PostgreSQL-процес wal_writer за допомогою системного виклику fsync() записує дані з буфера у wal-файл (директорія pg_wal/) – це журнал всіх змін, що відбулися перед COMMIT
клієнт, який запустив виконання запиту отримує повідомлення COMMIT – операція успішно завершена
якщо параметр synchronous_commit = on, PostgreSQL чекає завершення fsync() перед відправкою COMMIT
якщо synchronous_commit = off, PostgreSQL не чекає fsync() і COMMIT відбувається швидше, але з ризиком втрати даних
при неможливості виконати транзакцію – клієнт отримає помилку “could not commit transaction“
дані з shared_buffers записуються до файлів самої бази даних (каталог base/) – цим займається процес checkpointer, який записує модифіковані в пам’яті дані (dirty pages) на диск
це відбувається за допомогою процесу CHECKPOINT не одразу після COMMIT, а періодично
після виконання CHECKPOINT – PostgreSQL виконує архівацію або видалення WAL-файлів
Отже, якщо EBS був перенавантажений з Write IOPS – то WAL міг перестати писатись, і це могло призвести до зупинки виконання транзакцій.
Але в нашому випадку ми бачимо, що і db.Transactions.xact_rollback.avg був на нулі, а він не залежить від WAL і Write-операцій на диску.
В PostgreSQL Exporter є кілька корисних метрик, які відображаються активність WAL:
pg_stat_archiver_archived_count: загальна кількість успішно заархівованих WAL-файлів (що скаже нам, що WAL працює коректно)
pg_stat_archiver_failed_count: кількість невдалих спроб архівування WAL-файлів
pg_stat_bgwriter_checkpoint_time: час, витрачений на виконання CHECKPOINTs
Ще можна зробити такий запит:
SELECT * FROM pg_stat_wal;
Якщо wal_buffers_full високий і росте, то, можливо, транзакції чекають на виконання fsync(), або що значення wal_buffers замале, і його треба збільшити аби зменшити частоту примусових записів WAL на диск.
В PostgreSQL такої метрики наче нема, але можемо зробити власну з custom.yaml:
pg_stat_wal:
query: "SELECT wal_buffers_full FROM pg_stat_wal;"
metrics:
- wal_buffers_full:
usage: "COUNTER"
description: "Number of times the WAL buffers were completely full, causing WAL data to be written to disk."
Read IOPS та Swap
Добре.
Давайте повернемось до питання з Read IOPS та Swap.
Що тут могло відбутись:
якийсь запит почав активно зчитувати дані з диску
вони заносились в shared_buffers, в пам’яті не вистачило місця, і дані, які там були до цього були винесені в Swap
запити в PostgreSQL продовжують виконуватись, але тепер замість того, аби просто взяти дані з пам’яті – PostgreSQL має йти до Swap, і тому маємо високий ReadIOPS та CPU I/O Waits – тобто CPU чекає, поки дані будуть зчитані з диску
Але тоді наче мало б бути спайк по db.IO.blks_read.avg, раз читаємо з диска?
Але ні, бо db.IO.blks_read – це запити від самого PostgreSQL на читання данних.
Коли ж він оперує зі свапом – він все одно вважає, що працює з оперативною пам’яттю.
А от метрика ReadIOPS – це вже від самої операційної системи/EBS, і вона як раз показує всі операції читання, а не тільки від процесів PostgreSQL.
Що цікаво, що в момент проблеми у нас db.Cache.blks_hit впав до нуля. Про що це каже? Зазвичай, що backend-процеси (сесії) не знаходили дані в shared_buffers.
Але знаючи, що у нас взагалі всі транзакції зупинились, а db.IO.blks_read теж впав до нуля – то скоріш PostgreSQL просто перестав звертатись до кешу взагалі, бо всі чекали на вільний CPU.
Втім, хоча ми не можемо отримати метрики з Enhanced monitoring напряму, але – сюрпрайз! вони пишуться до CloudWatch Logs! А вже з логів ми можемо нагенерити собі будь-які метрики з VictoriaLogs або Loki:
І вже в логах бачимо, що Swap In/Out таки відбувався. Тільки простих графіків вже не побачити. Але в майбутньому зробити собі якихось метрик з цих логів було б корисно.
В Log Groups шукаємо RDSOSMetrics, а потім вибираємо лог по RDS ID:
Operating system process list
Ще дуже корисним може бути список процесів:
Якщо починає падати вільна пам’ять – йдемо сюди, дивимось Resident memory, знаходимо PID процесу який жере пам’ять – і дивимось, що саме там за запит:
prod_kraken_db=> SELECT user, pid, query FROM pg_stat_activity WHERE pid = '26421';
user | pid | query
------------------+-------+----------
prod_kraken_user | 26421 | ROLLBACK
Всі ці процеси ми також маємо в логах, про які згадував вище – але це краще перевіряти в момент, коли виникає проблема, аби знайти який саме виконувався.
Бо так, ми можемо включити slow queries logs – але в тих логах ми не побачимо PID, і не зможемо дізнатись скільки пам’яті цей запит використав.
Пам’ять в PostgreSQL
Давайте трохи копнемо в те, що взагалі в пам’яті PostgreSQL.
Пам’ять в PostgreSQL ділиться на два основні типи – це “shared memory“, та “local memory” – пам’ять кожного бекенд-процесу (сесії).
В shared memory ми маємо:
shared_buffers: основна пам’ять, де PostgreSQL тримає кеш даних, які він зчитує з диску при обробці запитів – кешування сторінок таблиць та індексів
аналог Heap Memory (Java Heap)
shared_buffers за замовчуванням становить 25% від загальної RAM, але можна змінити
wal_buffers: вже бачили вище – використовується для тимчасового зберігання WAL-записів для буферизації транзакції перед записом у WAL-файл
Із shared_buffers змінені дані (dirty pages) записуються на диск двома процесами:
Background Writer (bgwriter): працює в фоні, поступово записує дані на диск
Checkpointer (checkpoint): примусово записує всі сторінки під час CHECKPOINT
Пам’ять процесів має:
work_mem: виділяється запитам, які виконують сортувань (ORDER BY), хеш-операцій (HASH JOIN) та агрегацій
кожен запит отримує свою копію work_mem, тому при великій кількості одночасних запитів пам’ять може швидко закінчитись
якщо work_mem процесу не вистачає – PostgreSQL починає записувати тимчасові файли на диск (temp_blks_written), що уповільнює виконання запитів
maintenance_work_mem: власне, maintenance operations – операції по vacuuming, створення індексів, додавання foreign keys
temp_buffers: виділяється для тимчасових таблиць (CREATE TEMP TABLE).
Ми можемо отримати всі дані з pg_settings так:
SELECT
name,
setting,
unit,
CASE
WHEN unit = '8kB' THEN setting::bigint * 8
WHEN unit = 'kB' THEN setting::bigint
ELSE NULL
END AS total_kb,
pg_size_pretty(
CASE
WHEN unit = '8kB' THEN setting::bigint * 8 * 1024
WHEN unit = 'kB' THEN setting::bigint * 1024
ELSE NULL
END
) AS total_pretty
FROM pg_settings
WHERE name IN ('shared_buffers', 'work_mem', 'temp_buffers', 'wal_buffers');
Маємо 238967 shared_buffers, кожен по 8КБ, разом ~1.9 GB.
Але це вже зараз, на db.m5.large.
Перевірка shared_buffers
Cache hit ratio покаже скільки даних було отримано з пам’яті, а скільки з самого диску – хоча у нас це є в метриках db.IO.blks_read.avg та db.Cache.blks_hit.avg (або метрики pg_stat_database_blks_hit та pg_stat_database_blks_read в PostgreSQL Exporter):
SELECT
blks_read, blks_hit,
ROUND(blks_hit::numeric / NULLIF(blks_hit + blks_read, 0), 4) AS cache_hit_ratio
FROM pg_stat_database
WHERE datname = current_database();
Якщо cache_hit_ratio < 0.9, значить, кеш PostgreSQL не ефективний, і забагато даних читається з диска замість кеша.
Побачити скільки з виділених shared_buffers зараз використані (активні), а скільки вільні – тут нам знадобиться extention pg_buffercache.
Запит:
SELECT
COUNT(*) AS total_buffers,
SUM(CASE WHEN isdirty THEN 1 ELSE 0 END) AS dirty_buffers,
SUM(CASE WHEN relfilenode IS NULL THEN 1 ELSE 0 END) AS free_buffers,
SUM(CASE WHEN relfilenode IS NOT NULL THEN 1 ELSE 0 END) AS used_buffers,
ROUND(100.0 * SUM(CASE WHEN relfilenode IS NOT NULL THEN 1 ELSE 0 END) / COUNT(*), 2) AS used_percent,
ROUND(100.0 * SUM(CASE WHEN relfilenode IS NULL THEN 1 ELSE 0 END) / COUNT(*), 2) AS free_percent
FROM pg_buffercache;
Маємо 238967 буферів загалом, з яких використано лише 12280, або 5%.
Або інший варіант – подивитись, скільки всього сторінок зараз в shared_buffers:
prod_kraken_db=> SELECT
count(*) AS cached_pages,
pg_size_pretty(count(*) * 8192) AS cached_size
FROM pg_buffercache;
cached_pages | cached_size
--------------+-------------
117495 | 918 MB
При тому, що всього під shared_buffers виділено:
prod_kraken_db=> SHOW shared_buffers;
shared_buffers
----------------
939960kB
918 мегабайт.
Але чому тоді в попередньому запиті ми бачили, що “зайнято 5%”?
Бо в результаті з pg_buffercache в полях used_buffers та used_percent враховуються тільки активні сторінки (used), тобто ті, які або мають прив’язку до файлу (relfilenode), або були нещодавно використані.
SELECT
c.relname, count(*) AS buffers
FROM
pg_buffercache b
INNER JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
AND
b.reldatabase IN (0, (SELECT oid FROM pg_database
WHERE
datname = current_database()))
GROUP BY c.relname
ORDER BY 2 DESC
LIMIT 10;
Тут для challenge_progress використано 1636 буферів, що дає нам:
Якщо у нас падає FreeableMemory, то, можливо, використовується забагато work_mem.
Перевірити скільки виділяється на кожен процес:
dev_kraken_db=> SHOW work_mem;
work_mem
----------
4MB
Перевірити чи вистачає процесам цього значення work_mem можна зі значення temp_blks_written, бо коли пам’ять в work_mem закінчується, то процес починає виносити дані в тимчасові таблиці:
Ну і, власне, на цьому, мабуть, все.
Якісь висновки? Складно робити. Ясно, що db.t3.small з двома гігабайтами нам було замало.
Є підозра який саме запит тоді викликав цю “цепну реакцію”, в slow queries logs побачили “некрасивий” SELECT, і девелопери його наче оптимізували.
Спробуємо зменшити тип інстансу до 4 гігабайт пам’яті, і подивимось, чи виникне проблема знов.
Monitoring summary
Замість висновків – кілька ідей того, що треба моніторити, і що в моніторингу можна покращити.
Наші алерти
Накидаю трохи алертів, які у нас вже є зараз.
CloudWatch метрики
Метрики CloudWatch. Збираємо до VictoriaMetrics з YACE-експортером.
CPUUtilization
Алерт:
- alert: HighCPUUtilization
expr: avg(aws_rds_cpuutilization_average{dimension_DBInstanceIdentifier!="", dimension_DBInstanceIdentifier!~"kraken-ops-rds-.*"}[5m]) by (dimension_DBInstanceIdentifier) > 80
for: 5m
labels:
severity: warning
component: devops
environment: ops
annotations:
summary: "High CPU utilization on RDS instance"
description: "CPU utilization is above 80% for more than 5 minutes on RDS instance {{ "{{" }} $labels.instance }}."
DBLoadRelativeToNumVCPUs
Алерт:
- alert: HighCPULoadPerVCPUWarningAll
expr: avg(aws_rds_dbload_relative_to_num_vcpus_average{dimension_DBInstanceIdentifier!="", dimension_DBInstanceIdentifier!~"kraken-ops-rds-.*"}[5m]) by (dimension_DBInstanceIdentifier) > 0.8
for: 5m
labels:
severity: warning
component: devops
environment: ops
annotations:
summary: "High per-core CPU utilization on RDS instance"
description: |
CPU utilization is above 80% for more than 5 minutes on RDS instance {{ "{{" }} $labels.instance }}
*DB instance*: `{{ "{{" }} $labels.dimension_DBInstanceIdentifier }}`
*Per-vCPU load*: `{{ "{{" }} $value | humanize }}`
FreeStorageSpace
Не дуже актуально, якщо маємо динамічний storage, але може бути корисним.
Алерт:
- record: aws:rds:free_storage:gigabytes
expr: sum(aws_rds_free_storage_space_average{dimension_DBInstanceIdentifier!=""}) by (dimension_DBInstanceIdentifier) / 1073741824
# ALL
- alert: LowFreeStorageSpaceCriticalAll
expr: aws:rds:free_storage:gigabytes < 5
for: 5m
labels:
severity: warning
component: devops
environment: ops
annotations:
summary: "Low Disk Space on an RDS instance"
description: |-
Free storage below 5 GB
*DB instance*: `{{ "{{" }} $labels.dimension_DBInstanceIdentifier }}`
*Free storage*: `{{ "{{" }} $value | humanize }}`
FreeableMemory
Алерт:
- alert: LowFreeableMemoryDev
expr: avg(aws_rds_freeable_memory_average{dimension_DBInstanceIdentifier="kraken-ops-rds-dev"}[5m]*0.000001) by (dimension_DBInstanceIdentifier) < 20
for: 5m
labels:
severity: warning
component: backend
environment: dev
annotations:
summary: "High memory usage on RDS instance"
description: |-
Freeable memory is less than 100mb
*DB instance*: `{{ "{{" }} $labels.dimension_DBInstanceIdentifier }}`
*Free memory*: `{{ "{{" }} $value | humanize }}`
ReadLatency, ReadIOPS та WriteLatency і WriteIOPS
Схожі метрики, корисно моніторити.
Алерт:
- alert: HighDiskReadLatencyKrakenStaging
expr: sum(aws_rds_read_latency_average{dimension_DBInstanceIdentifier="kraken-ops-rds-dev"}) by (dimension_DBInstanceIdentifier) > 0.1
for: 1s
labels:
severity: warning
component: backend
environment: dev
annotations:
summary: "High Disk Read Latency on RDS instance"
description: |-
Reads from a storage are too slow
*DB instance*: `{{ "{{" }} $labels.dimension_DBInstanceIdentifier }}`
*Read Latency*: `{{ "{{" }} $value | humanize }}`
SwapUsage
Теж must have метрика.
Алерт:
- record: aws:rds:swap_used:gigabytes
expr: sum(aws_rds_swap_usage_average{dimension_DBInstanceIdentifier!=""}) by (dimension_DBInstanceIdentifier) / 1073741824
# ALL
- alert: SwapUsedAllWarning
expr: sum(aws:rds:swap_used:gigabytes{dimension_DBInstanceIdentifier!="", dimension_DBInstanceIdentifier!~"kraken-ops-rds-.*"}) by (dimension_DBInstanceIdentifier) > 0.8
for: 1s
labels:
severity: warning
component: devops
environment: ops
annotations:
summary: "Swap space use is too high on an RDS instance"
description: |-
The RDS instance is using more than *0.8 GB* of swap space
*DB instance*: `{{ "{{" }} $labels.dimension_DBInstanceIdentifier }}`
*Swap used GB*: `{{ "{{" }} $value | humanize }}`
DatabaseConnections
Є метрика CloudWatch, але вона нам повертає просто кількість конектів – а ліміт може бути різним для різних типів інстансів.
Тому приклад алерта тут покажу, але далі буде інший – з метрик PostgreSQL Exporter:
# db.t3.micro - 112 max_connections (Backend Dev)
# db.t3.small - 225 max_connections (Backend Prod)
# db.t3.medium - 450 max_connections
# db.t3.large - 901 max_connections
# ALL
- alert: HighConnectionCountWarning
expr: avg(aws_rds_database_connections_average{dimension_DBInstanceIdentifier!="", dimension_DBInstanceIdentifier!~".*kraken.*"}[5m]) by (dimension_DBInstanceIdentifier) > 50
for: 1m
labels:
severity: warning
component: devops
environment: ops
annotations:
summary: "High number of connections on RDS instance"
description: |-
An RDS instance Connections Pool is almost full. New connections may be rejected.
*DB instance*: `{{ "{{" }} $labels.dimension_DBInstanceIdentifier }}`
*Instance type*: `db.t3.micro`
*Max connections*: `112`
*Current connections*: `{{ "{{" }} $value | humanize }}`
Loki Recording Rules metrics
Пару метрик генеримо з логів нашого Backend API, наприклад:
- record: aws:rds:backend:connection_failed:sum:rate:5m
expr: |
sum(
rate(
{app=~"backend-.*"}
!= "token_email"
|= "sqlalchemy.exc.OperationalError"
| regexp `.*OperationalError\) (?P<message>connection to server at "(?P<db_server>[^"]+)".*$)`
[5m]
)
) by (message, db_server)
І потім з них створюємо алерти:
- alert: BackendRDSConnectionFailed
expr: sum(aws:rds:backend:connection_failed:sum:rate:5m{db_server="dev.db.kraken.ops.example.co"}) by (db_server, message) > 0
for: 1s
labels:
severity: critical
component: backend
environment: dev
annotations:
summary: "Connection to RDS server failed"
description: |-
Backend Pods can't connect to an RDS instance
*Database server:*: {{ "{{" }} $labels.db_server }}
*Error message*: {{ "{{" }} $labels.message }}
PostgreSQL Exporter metrcis
pg_stat_database_numbackends
Тут як раз про Connections: в експортері ми маємо метрику pg_settings_max_connections, яка вказує на максимальну кількість конектів в залежності від типу інстансу, і pg_stat_database_numbackends – кількість активних сесій (конектів).
Відповідно можемо порахувати % від max connections.
Єдина проблема, що ці метрики мають різні лейбли, і я забив робити якісь label_replace – тому просто додав три record, на кожен environemnt:
# 'pg_stat_database_numbackends' and 'pg_settings_max_connections' have no common labels
# don't want to waste time with 'label_replace' or similar
# thus just create different 'records' for Prod and Staging
- record: aws:rds:kraken_dev:max_connections_used:percent
expr: |
(
sum(pg_stat_database_numbackends{datname=~"dev_kraken_db", job="atlas-victoriametrics-postgres-exporter-kraken-dev"})
/
sum(pg_settings_max_connections{container=~".*kraken-dev"})
) * 100
- alert: ExporterHighConnectionPercentBackendDevWarning
expr: aws:rds:kraken_dev:max_connections_used:percent > 40
for: 1s
labels:
severity: warning
component: backend
environment: dev
annotations:
summary: "High number of connections on the Backend RDS instance"
description: |-
RDS instance Connections Pool is almost full. New connections may be rejected.
*DB instance*: `kraken-ops-rds-dev`
*Connections pool use*: `{{ "{{" }} $value | humanize }}%`
grafana_rds_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/ceao6muzwos1sa/kraken-rds?orgId=1&from=now-1h&to=now&timezone=browser&var-query0=&var-db_name=kraken-ops-rds-dev'
pg_stat_activity_max_tx_duration
Алерти, коли якісь транзакції виконуються надто довго.
Не сказати, що дуже корисна метрика, бо не маємо PID і кількості пам’яті, але поки що хоч так.
Потім можна буде подумати над кастомними метриками.
Зараз алерт такий:
- alert: ExporterTransactionExecutionTimeBackendDevWarning
expr: sum(rate(pg_stat_activity_max_tx_duration{datname="dev_kraken_db"}[5m])) by (state, datname) > 0.1
for: 1m
labels:
severity: warning
component: backend
environment: dev
annotations:
summary: "RDS transactions running too long"
description: |-
Too long duration in seconds active transaction has been running
*Database name*: `{{ "{{" }} $labels.datname }}`
*State*: `{{ "{{" }} $labels.state }}`
*Duration*: `{{ "{{" }} printf "%.2f" $value }}` seconds
grafana_rds_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/ceao6muzwos1sa/kraken-rds?orgId=1&from=now-1h&to=now&timezone=browser&var-query0=&var-db_name={{ "{{" }} $labels.datname }}'
Варто додати
Ну, тут прям дуже багато всього.
PostgreSQL Exporter custom metrics
Основне, десь вище вже згадував – в PostgreSQL Exporter ми можемо створювати кастомні метрики з результатами запитів до PostgreSQL, використовуючи config.queries.
Але можливо додам окремими алертами – по кількості active transactions, або по idle in transaction.
Основні метрики з PostgreSQL Exporter:
pg_stat_database_xact_commit та pg_stat_database_xact_rollback: як бачили в нашому випадку – якщо значення падає, то маємо проблеми – запити не завершуються
pg_stat_activity: по лейблі state маємо два основні:
active: загальна кількість активних запитів
idle in transaction: теж бачили в нашому випадку, що багато запитів зависли в очікуванні завершення
Теж згадував кілька метрик, які є в PotsgreSQL Exporter, можливо, додам по ним або алертів, або графіків до Grafana:
pg_stat_archiver_archived_count: загальна кількість успішно заархівованих WAL-файлів (що скаже нам, що WAL працює коректно)
pg_stat_archiver_failed_count: кількість невдалих спроб архівування WAL-файлів
pg_stat_bgwriter_checkpoint_time: час, витрачений на виконання CHECKPOINT
В самому сервері можемо перевірити з view pg_stat_wal:
SELECT * FROM pg_stat_wal;
Основні тут:
wal_records: кількість записаних WAL-записів (операцій INSERT, UPDATE, DELETE)
wal_bytes: загальний обсяг даних (у байтах), записаних у WAL
wal_buffers_full: скільки разів WAL-буфери були повністю заповнені, змушуючи бекенд-процеси писати напряму в WAL-файл
wal_write: кількість разів, коли PostgreSQL записував WAL у файл
wal_write_time: загальний час у мілісекундах, витрачений на записи WAL
wal_sync_time: загальний час (у мілісекундах), витрачений на fsync() (гарантований запис на диск)
Моніторинг shared_buffers
Тут треба ще подумати, які б метрики можна було генерити, і які графіки або алерти придумати.
З того, що приходить в голову:
моніторити shared hit та read: скільки даних було знайдено в кеші, а скільки довелось зчитувати з диску
buffers_backend: скільки буферів записали безпосередньо бекенд-процеси
в нормальній ситуації всі дані з dirty pages мають записуватись bgwriter або checkpoint
якщо shared_buffers зайняті, а bgwriter, wal_writer або checkpointer не встигає переносити з них дані на диск – то backend-процеси клієнтів змушені переносити дані самі, що уповільнює виконання їх запитів
Перевіряємо з:
SELECT buffers_backend, buffers_checkpoint, buffers_alloc FROM pg_stat_bgwriter;
Тут:
buffers_backend: скільки буферів записали безпосередньо бекенд-процеси
buffers_checkpoint: скільки буферів записано під час CHECKPOINT
якщо маємо високе значення:
то чекпоінти відбуваються рідко, і одразу записують багато сторінок
або bgwriter не встигає виконувати записи, і CHECKPOINT записує все відразу
buffers_alloc: скільки нових буферів виділено у shared_buffers
якщо маємо високе значення – то кеш постійно витісняється, і PostgreSQL змушений завантажувати сторінки з диска
Моніторинг Checkpointer
Також сенс приглядати за Checkpointer:
SELECT checkpoint_write_time, checkpoint_sync_time FROM pg_stat_bgwriter;
Тут:
checkpoint_write_time: час, витрачений на запис змінених сторінок (dirty pages) з shared_buffers у файлову систему; якщо значення велике – то:
занадто великий shared_buffers – при чекпоінті доводиться записувати забагато сторінок одразу
багато операцій (UPDATE, DELETE), що призводить до великої кількості “брудних” сторінок (dirty pages).
або checkpoint_timeout занадто великий, тому при чекпоінті записується багато змін одразу.
checkpoint_sync_time: час, витрачений на примусовий запис (виконання fsync()) змінених сторінок на фізичний диск; якщо значення велике – то:
можливі проблеми з диском – повільно записуються дані
Моніторинг work_mem
Теж є сенс дивитись сюди.
Якщо work_mem недостатньо – то процеси починають писати temp_blks_written, що, по-перше, уповільнює виконання запитів, по-друге – створює додаткове навантаження на диск.
Перевіряємо з:
SELECT temp_files, temp_bytes FROM pg_stat_database WHERE datname = current_database();
Продовжуємо міграцію з Grafana Loki на VictoriaLogs, і наступна задача – це перенести Recording Rules з Loki до VictoriaLogs, і оновити алерти.
Recording Rules та інтеграцію з VMAlert до VictoriaLogs завезли відносно недавно, і цю схему ще не тестував.
Тому спершу все зробимо руками, подивимось як це працює, які є нюанси, а потім будемо оновлювати Helm chart, яким деплоїться мій Monitoring Stack, і додавати туди нові Recording Rules.
Тож, що сьогодні:
встановимо VMAlert з Helm чарту в Kubernetes
перепишемо запит Loki LogQL на VictoriaLogs LogsQL
створимо VMAlert Recording Rule для генерації метрик з логів
протестуємо, як робити алерти з логів та Recording Rules
і подивимось, як цю схему можна інтегрувати в існуючий стек VictoriaMetrics
в цих запитах він виконує якісь expr – як в звичайних алертах
по результатам цих запитів VMAlert або генерує метрику – якщо це Recording Rule – і записує її в VictoriaMetrics чи Prometheus, або генерує алерт – якщо це Alert
Тобто тут та ж сама схема, як і в Loki, і метрики з Recording Rule ми можемо використовувати не тільки для алертів, а і в Grafana dashboards.
Як завжди – у VictoriaMetrics є чудова документація:
В мене вже є повністю задеплоєний стек VictoriaMetrics і решта всього моніторингу власним чартом, але зараз VMAlert запустимо окремо від нього, бо є момент з тим, як VMAlert робить запити до VictoriaMetrics та VictoriaLogs – далі з цим розберемось.
Тут вичитуються логи з Kubernetes Pods нашого Backend API, з кожного запису створюється нове поле domain, і використовуються існуючі в логах поля path та duration.
А потім для кожного domain, path, node_name обчислюється average duration на виконання запиту.
Аби зробити аналогічний запит з VictoriaLogs LogsQL, нам потрібно:
вибрати логи з app:="backend-api"
створити поле domain
отримати значення path та duration
обчислити mean (average) за 5 хвилин по полю duration
згрупувати результат по полям domain, path, node_name
Знайдемо логи з VMLogs:
Далі:
додамо unpack_json, бо логи пишуться в JSON – парсимо його, і створюємо нові поля
додамо фільтр по полю http.url, бо частина записів в логах або не мають URL взагалі, або там адреса Kubernetes Pods у вигляді http://10.0.32.14:8080/ping – всякі Liveness && Readiness Probes, які нам не цікаві
використовуємо extract_regexp, аби з поля _msg створити нове поле domain
полів у нас тут забагато, всі вони нам не потрібні – використаємо fields pipe, і залишимо тільки ті, які будемо використовувати
можемо додати фільтр path:~".+", аби скіпнути всі записи з пустим path
Замість фільтра http.url:~"example.co" можемо використати Sequence filter у формі http.url:seq("example.co") – але різниці у швидкості виконання запита не побачив:
Насправді для перформансу фільтр http.url:~"example.co" краще перенести на початок запиту, відразу за stream selector app:="backend-api", і спростити просто до Word filter"example.co" – але вже поробив скріни, тому ОК, тут нехай буде так, потім зробимо, як треба.
Тепер маємо потрібні записи, маємо потрібні поля – йдемо далі.
Далі нам потрібен stats pipe зі stats pipe function avg() за 5 хвилин зі значення в полі duration.
Додаємо в запит | stats by (_time:5m, path, node_name, domain) avg(duration) avg_duration.
Тут вже краще використати Time series візуалізацію в Grafana dashboard:
І давайте порівняємо результат з Loki.
Візьмемо якийсь домен, ноду, та URI, наприклад в Loki результат буде таким:
Тепер можемо власне переходити до Recording Rules.
Створення VictoriaLogs Recording Rules та Alerts
Для додавання Recording Rules в values чарту VMAlert є блок config.alerts.groups, в якому ми можемо з типом record описати або власне Recording Rule, або з типом alert – описати алерт.
Створення Recording Rule
Спочатку спробуємо Recording Rule.
Додаємо record: vmlogs:eks:pod:backend:api:path_duration:avg в наш файл vmalert-test-values.yaml:
Перевіряємо метрику vmlogs:eks:pods:backend:api:path_duration:avg в VMSingle:
Yay!
It works!
Створення Alert
Алерти можемо додати двома шляхами:
можемо описати новий алерт прямо в values чарту нового VMAlert, який буде виконувати запити напряму до VictoriaLogs
або, оскільки у нас є Recording Rule, який створює метрику – то ми можемо створити звичайний VMRule, який буде опрацьований оператором, і переданий до “дефолтного” VMAlert
Давайте спробуємо і так, і так.
Спочатку додамо алерт до файлу vmalert-test-values.yaml, поруч з нашим Recording Rule, в імені алерту вкажемо “Raw“:
А далі в values.yaml для кожного сабчарту задаються параметри.
VMAlert: datasource.url, VictoriaMetrics та VictoriaLogs
Що нам треба – це додати інтеграцію VMAlert з VictoriaLogs сюди, але є нюанс: VMAlert може мати тільки один параметр datasource.url, в якому зараз заданий Kubernetes Service з VMSingle – звідки VMAlert бере метрики для обчислення умов існуючих алертів:
$ kk -n ops-monitoring-ns describe pod vmalert-vm-k8s-stack-7d5bd6f955-m6mz4
...
Containers:
vmalert:
...
Args:
-datasource.url=http://vmsingle-vm-k8s-stack.ops-monitoring-ns.svc.cluster.local.:8429
...
Але ж нам треба задати адресу VictoriaLogs, і при цьому залишити можливість запитів до VMSingle.
або просто мати два окремих інстанси VMAlert – один для метрик з VictoriaLogs, другий – для роботи з VictoriaLogs
або використати VMAuth, і в залежності від URI запиту від VMAlert роутити запити на потрібний бекенд – або VictoriaMetrics/VMSingle, або VictoriaLogs
Опція 1: два інстанси VMAlert
Перший варіант – запускати два VMAlert, і кожному передати власний datasource.url.
Але є питання – як в різні VMAlert передавати Recording Rules та власне Алерти?
Бо в мене Алерти описуються через ресурси VMRules, які з VictoriaMetrics Operator записуються в ConfigMap, який потім підключається до мого “дефолтного” VMAlert:
$ kk -n ops-monitoring-ns describe pod vmalert-vm-k8s-stack-7d5bd6f955-m6mz4
...
Volumes:
...
vm-vm-k8s-stack-rulefiles-0:
Type: ConfigMap (a volume populated by a ConfigMap)
Name: vm-vm-k8s-stack-rulefiles-0
...
Якщо робити схему з двома інстансами VMAlert з різними datasource.url – то для інстансу, який буде робити запити до VictoriaLogs нам потрібно створювати власний ConfigMap, і маунтити його з вальюсів цього інстансу VMAlert, без VMRules і участі VM Operator.
Хоча технічно, мабуть, можливо мати VMRules з Recording Rules та Alerts і два інстанси VMAlert, де в кожен інстанс будуть мапитись один і той самий ConfigMap і з RecordingRules, і з Alerts – але тоді один VMAlert буде постійно писати про помилки запитів до VictroriaMetrcis, а другий – про помилки запитів до VictoriaLogs.
Тому тут бачу тільки варіант з окремим ConfgiMap для RecordingRules, і окремо мати VMRules для алертів, як воно є зараз.
Мені така схема якось не дуже подобається, бо я хотів би і RecordingRules, і Алерти описувати через VMRules.
Другий варіант – редіректити запити від єдиного інстансу VMAlert до VictoriaLogs та VictoriaMetrics/VMSingle через VMAuth.
В мене VMAuth вже є, писав про нього в пості VictoriaMetrics: VMAuth – проксі, аутентифікація та авторизація, де налаштована аутентифікація і вже є роути – я ним користуюсь для доступу до деяких внутрішніх ресурсів, коли мені ліньки робити kubectl port-forward.
Що нам треба – це додати ще пару src_paths:
/api/v1/query.* – для запитів до VictoriaMetrics/VMSingle
/select/logsql/.* – для запитів до VictoriaLogs
Тоді в моєму випадку все разом буде виглядати так:
$ kk get vmrule | grep vmlogs
vmlogs-alert-rules 4s
Логи VMAlert – нова група створена:
$ ktail -l app.kubernetes.io/name=vmalert
...
vmalert-vm-k8s-stack-6c5cb6d76d-dxpbf:vmalert 2025-01-08T13:30:43.609Z info VictoriaMetrics/app/vmalert/rule/group.go:486 group "VM-Logs-Backend-Pods-Logs" will start in 1.540718685s; interval=15s; eval_offset=<nil>; concurrency=1
vmalert-vm-k8s-stack-6c5cb6d76d-dxpbf:vmalert 2025-01-08T13:30:45.151Z info VictoriaMetrics/app/vmalert/rule/group.go:486 group "VM-Logs-Backend-Pods-Logs" started; interval=15s; eval_offset=<nil>; concurrency=1
...
І перевіряємо нову метрику в VMSingle:
Готово.
Тепер можна мігрувати решту Recording Rules з Loki до VictoriaLogs.
Але там є маленький недолік – всі дані будуються з raw logs, які пишуться з VPC Flow Logs в AWS S3, з S3 їх збирає Promtail в AWS Lambda, і потім пише до VictoriaLogs.
Проблема: перформанс з raw logs
В цій Grafana dashboard з VictoriaLogs виконуються запити типу:
Де з extract отримуємо значення для нових полів прямо із логу.
І все це більш-менш працює, але максимальний період, за який вдається побудувати графіки – 24 години (з Loki було взагалі 30 хвилин).
Але є інший варіант роботи з логами: замість того, аби парсити поля прямо під час виконання запиту з використанням exctract – ми можемо створювати ці поля ще на етапі збору логів з S3, і далі в запитах використовувати вже їх.
В принципі, це можна було б зробити прямо з поточним сетапом – через Promtail. Щось схоже я робив в Grafana Loki: alerts from Ruler and labels from logs, але – ну не хочеться мені мати справу з Lambda Promtail від Grafana, бо мені навіть не вдалося оновити версію Promtail в моєму Docker image – а я не пам’ятаю, як робив перший. Тому в мене Promtail в Lambda досі той, який я створив ще у 2023 році – див. Loki: збір логів з CloudWatch Logs з використанням Lambda Promtail.
Тому замість Promtail вирішив спробувати Vector.dev. Він трохи складний в налаштуванні, але має просто безліч можливостей.
Власне, чим більше можливостей – тим більш складно налаштувати систему. Втім, мені все ж вдалось зробити те, чого я хотів, і вийшло навіть доволі просто, тому можна пробувати робити це для Production.
Тож сьогодні зробимо простенький Proof of Concept з Flow Logs, Vector.dev та VictoriaLogs:
встановимо Helm-чарт з Vector
створимо новий AWS S3, налаштуємо VPC Flow Logs з custom format для запису в цей бакет
подивимось, як ми можемо збирати логи з S3 до Vector.dev і додавати нові поля
і порівняємо швидкість роботи з raw logs vs логи з Vector з полями
Vector is a high-performance observability data pipeline that puts organizations in control of their observability data. Collect, transform, and route all your logs, metrics, and traces to any vendors
Тобто основна ідея – збирати будь-які дані моніторингу, будь то метрики або логи, виконувати над ними якісь дії, і потім кудись писати.
В моєму випадку мені треба взяти запис лога, додати до нього якісь поля, і записати до VictoriaLogs.
В нашому випадку Sources буде AWS S3, в Transforms – будемо парсити логи VPC FLow logs і створювати нові fields, а в Sinks – використаємо Elasticsearch Sink для VictoriaLogs, див. документацію по Vector setup в VictoriaLogs docs.
Взагалі, Vector має окремий Loki Sink, але з ним більше проблем, ніж користі, а з Elasticsearch (або HTTP) все запрацювало без проблем.
$ helm repo add vector https://helm.vector.dev
"vector" has been added to your repositories
$ helm repo update
Встановлюємо Vector – поки з дефолтними параметрами, потім створимо власний values.yaml:
$ helm install vector vector/vector
NAME: vector
LAST DEPLOYED: Mon Dec 2 15:13:30 2024
...
Переходимо до VPC Flow Logs.
Налаштування AWS VPC Flow Logs до S3
Далі, нам потрібна S3 корзина, в яку ми будемо писати VPC Flow Logs, і SQS, в яку будуть відправлятись повідомлення, коли в S3 створюються нові об’єкти, тобто логи.
Потім Vector буде читати повідомлення з цієї SQS, і забирати логи з S3.
Або робимо руками – переходимо в VPC, вкладка Flow logs, клікаємо Create flow log – тут я вже маю два Flow Logs для Promtail Lambda:
В Destination задаємо Send to an Amazon S3 bucket, і вказуємо ARN нашого бакета:
Я завжди використовую Custom format з додатковими полями:
Зберігаємо, і перевіряємо статус:
Все зелененьке, працює.
Можна зачекати 10 хвилин (дефолтний період доставки логів), і перевірити дані в самій S3:
І вкладку Monitoring в SQS:
Налаштування Vector.dev
Ну а тепер саме цікаве.
Отже, що нам треба:
додати Source S3 з параметром SQS – звідки будемо збирати логи
додати трансформацію – створення нових fields
і додати Sink для VictoriaLogs – куди будемо писати
Тобто створюється такий собі pipeline – Source збирає дані, Transform їх трансформує, а Sink – передає оброблені дані далі, в нашому випадку до VictoriaLogs.
З документацією розібрались – поїхали конфігуряти.
Vector.dev: Sources – S3
Першим налаштуємо збір логів з AWS S3 бакету. Для цього нам потрібні такі параметри:
type: aws_s3
auth: як будемо виконувати аутентифікацію
поки зробимо банальним Access/Secret ключами, коли будемо це запускати в Production – то додамо EKS Pod Identity з IAM Role, яка буде дозволяти доступ Kubernetes Pod з Vector до S3 та SQS
sqs.queue_url: звідки Vector буде отримувати інформацію, що в S3 з’явились нові логи
Задавати параметри будемо через Helm chart values і параметр customConfig, до якого є важливий коментар:
# customConfig — Override Vector’s default configs, if used **all** options need to be specified.
Тобто, нам потрібно буде задати всі параметри.
Тому зараз конфіг буде таким:
image:
repository: timberio/vector
pullPolicy: IfNotPresent
replicas: 1
service:
enabled: false
customConfig:
sources:
s3-vector-vmlogs-flow-logs-bucket: # source name to be used later in Transforms
type: aws_s3
region: us-east-1
compression: gzip
auth:
region: us-east-1
access_key_id: AKI***B7A
secret_access_key: pAu***2gW
sqs:
queue_url: https://sqs.us-east-1.amazonaws.com/492***148/s3-vector-vmlogs-queue
Vector.dev: Transforms – remap та VRL
Transforms є багато, але нам зараз цікавий remap, в якому з Vector Remap Language (VRL) ми можемо виконувати прям безліч всяких операцій.
VRL – це domain-specific language (DSL) для самого Vector.dev, в якому є різні функції для роботи з даними.
Є навіть VRL Playground, де можна спробувати що і як працює.
З того, що може бути цікавим нам – це Parse functions, а саме – функція parse_aws_vpc_flow_log. А для роботи з AWS Load Balancer logs – є функція parse_aws_alb_log.
Тут ми створюємо власні поля region, vpc_id etc, приводимо поля packets та bytes до типу integer, і в кінці видаляємо весь message з .parsed викликом Path functiondel().
Але в данному випадку все чудово працює і без цього, просто експерементував з різними варіантами.
Vector.dev: Sinks – Elasticsearch та VictoriaLogs
І останнім нам потрібно задати Sink.
Я пробував це робити з Loki Sink, але з ним так і не вийшло правильно оформити нові поля, тому по рекомендації розробників VictoriaLogs просто взяв Elasticsearch Sink.
Описуємо наш конфіг:
...
sinks:
s3-flow-logs-to-victorialogs:
inputs:
- s3-vector-vmlogs-flow-logs-transform # a Transform name to get processed data from
type: elasticsearch
endpoints:
- http://atlas-victoriametrics-victoria-logs-single-server:9428/insert/elasticsearch/ # VictoriaLogs Kubernetes Service URL and Elasticsearch endpoint
api_version: v8
compression: gzip
healthcheck:
enabled: false
query: # HTTP query params
extra_fields: source=vector # add a custom label
# _msg_field: message # ommited here, as we have everything in the fields from the Transform, but may be used for other data
_time_field: timestamp # set the '_time' field for the VictoriaLogs
_stream_fields: source,vpc_id,az_id # create Stream fields for the VictoriaLogs to save data in a dedicated Stream; specify fields without spaces
Власне, я тут наче все додав в коменти, але пройдемось ще:
inputs: задаємо ім’я Transform, з якого беремо дані
endpoints: передаємо адресу VictoriaLogs в нашому Kubernetes кластері
healthcheck: відключаємо, бо VictoriaLogs поки не підтримує /ping ендпоінт
в _stream_fields описуємо по яким полям VictoriaLogs буде створювати log stream – див. Stream fields
Весь values тепер виглядає так:
image:
repository: timberio/vector
pullPolicy: IfNotPresent
replicas: 1
service:
enabled: false
customConfig:
sources:
s3-vector-vmlogs-flow-logs-bucket: # source name to be used later in Transforms
type: aws_s3
region: us-east-1
compression: gzip
auth:
region: us-east-1
access_key_id: AKI***B7A
secret_access_key: pAu***2gW
sqs:
queue_url: https://sqs.us-east-1.amazonaws.com/492***148/s3-vector-vmlogs-queue
transforms:
s3-vector-vmlogs-flow-logs-transform: # a name from the 'sources', can have several Inputs
type: remap
inputs:
- s3-vector-vmlogs-flow-logs-bucket
source: |
. = parse_aws_vpc_flow_log!(
.message,
format: "region vpc_id az_id subnet_id instance_id interface_id flow_direction srcaddr dstaddr srcport dstport pkt_srcaddr pkt_dstaddr pkt_src_aws_service pkt_dst_aws_service traffic_path packets bytes action"
)
sinks:
s3-flow-logs-to-victorialogs:
inputs:
- s3-vector-vmlogs-flow-logs-transform # a Transform name to get processed data from
type: elasticsearch
endpoints:
- http://atlas-victoriametrics-victoria-logs-single-server:9428/insert/elasticsearch/ # VictoriaLogs Kubernetes Service URL and Elasticsearch endpoint
api_version: v8
compression: gzip
healthcheck:
enabled: false
query: # HTTP query params
extra_fields: source=vector # add a custom label
# _msg_field: message # ommited here, as we have everything in the fields from the Transform, but may be used for other data
_time_field: timestamp # set the '_time' field for the VictoriaLogs
_stream_fields: source,vpc_id,az_id # create Stream fields for the VictoriaLogs to save data in a dedicated Stream; specify fields without spaces
В логах чомусь помилка обробки поля srcport з Flow Logs:
ERROR transform{component_kind="transform" component_id=s3-vector-vmlogs-flow-logs-transform component_type=remap}: vector::internal_events::remap: Mapping failed with event. error="function call error for \"parse_aws_vpc_flow_log\" at (4:254): failed to parse value as i64 (key: `srcport`): `srcport`" error_type="conversion_failed" stage="processing" internal_log_rate_limit=true
Чому – не знаю, бо поле таке саме і в Flow Logs, і в нашому custom format. Але воно наче ні на що не впливає, пізніше зроблю GitHub Issue, спитаю.
Чекаємо, коли з S3 прийдуть дані, і перевіряємо в нашій VictoriaLogs, використовуючи _stream: {source="vector", vpc_id="vpc-0fbaffe234c0d81ea", az_id="use1-az2"} – поля, які ми задавали в _stream_fields: