Проєктування стабільної телеметрії в Rust. Будуємо NEXUS, частина 2

💡 Усі статті, обговорення, новини про DevOps — в одному місці. Приєднуйтесь до DevOps спільноти!

Мене звати Анатолій Шляхто — начальник відділу дилінгу АТ «Банк Альянс» та розробник ПЗ у ЗСУ. Ця стаття буде максимально корисною для Rust-розробників, системних архітекторів та DevOps-інженерів, які стикалися з хаосом у логах розподілених систем. Ми розберемо, як проектувати підсистему спостережуваності, що працює як суворий контракт, гарантуючи чистоту даних та нульові алокації на гарячих шляхах виконання.

Проєктування уніфікованої підсистеми логування без зайвих алокацій із жорсткою схемою, що об’єднує WebAssembly, нативні мікросервіси та дашборди ELK/Loki.

У минулій статті я розповідав, чому в екосистемі NEXUS я почав розробку з крейта nx-error, ще до того, як написав хоча б рядок коду для мережі чи бізнес-логіки. Помилки визначають детерміновані межі збоїв між розподіленими компонентами.

Але чіткий контракт помилок — це лише половина справи. Щойно розподілена система виходить у продакшн (у вигляді ізольованих мікросервісів, CLI-утиліт та фронтенду), її внутрішня поведінка перетворюється на повний чорний ящик, якщо ми не забезпечимо ії безперервну та передбачувану спостережуваність observability.

Якщо пустити це на самоплив, логування неминуче деградує в хаотичний «зоопарк» несумісних форматів, розрізнених часових міток та плаваючих схем. Дозвіл різним крейтам чи модулям диктувати власні структури логів перекладає величезний технічний борг на плечі DevOps. Замість того, щоб аналізувати стан системи під час інциденту, інженери витрачають критично важливий час на ремонт пайплайнів, виправлення помилок мапінгу в Elasticsearch та пошук втраченої телеметрії.

Щоб зупинити цей архітектурний дрифт ще до його початку, я розробив другий фундаментальний крейт екосистеми: nx-logger.

diagram

nx-logger — це не спроба винайти велосипед. Це оптимізована структурна обгортка та шар конфігурації навколо стандартної екосистеми tracing та tracing-subscriber. Його головна місія — примусово забезпечити стабільний контракт даних для систем моніторингу в кардинально різних середовищах виконання.

Головне обмеження: єдиний контракт для кожного рантайму

У сучасних розподілених платформах код не живе в єдиному інкубаторі. У рамках NEXUS компоненти повинні працювати у трьох фундаментально різних середовищах:

  • Високонавантажені нативні мікросервіси: Багатопотокові бекенди на базі tokio, які вимагають блискавичного паралельного запису на диск або в мережеві сокети.
  • CLI-утиліти: Легковагові локальні бінарники, що виконують робочі процеси і відправляють вивід прямо в stdout.
  • Гостьові модулі WebAssembly: Пісочниці (wasm32-unknown-unknown або WASI), що виконуються всередині серверних рантаймів на кшталт Wasmtime або Spin. Ці середовища не мають прямого доступу до файлової системи хоста, системного годинника, моделі потоків або сирих мережевих сокетів.

Коли ці різнорідні системи відправляють логи до централізованого агрегатора, SRE/DevOps команда повинна мати змогу парсити їх, використовуючи єдиний, залізобетонний контракт схеми.

Якщо асинхронний шлюз логує user_id як ціле число (42), а ізольований WASM-модуль аналітики логує його як рядок ("42"), індексатори на зразок Elasticsearch миттєво відкинуть цей лог через конфлікт мапінгу. У продакшені такі конфлікти — це справжній кошмар. Вони виникають не лише через невідповідність long та text; вони спрацьовують при зміні форми вкладених об’єктів, колізіях типів масивів та розбіжностях у парсингу дат. Коли виникає конфлікт схеми, пайплайн може відкинути весь батч логів, знищуючи видимість системи саме тоді, коли вона потрібна найбільше.

nx-logger вирішує цю проблему, фіксуючи межі серіалізації всередині шару трейсингу застосунку. Він приховує відмінності середовищ, жорстко гарантуючи стабільну структуру вихідного формату: текст чи JSON для консольного/файлового виводу, або пряме вивантаження в стек телеметрії через gRPC/tonic.

API: обмеження під час компіляції через Typestate

Будь-який інфраструктурний контракт не має сенсу, якщо розробники можуть його неправильно налаштувати або забути ініціалізувати критичний компонент. Щоб запобігти потраплянню невалідних конфігурацій у продакшн, API використовує патерн Typestate. Відстежуючи стани ініціалізації безпосередньо в системі типів Rust, помилки конфігурації відловлюються на етапі компіляції, а не падають у рантаймі.

Розробники по всьому воркспейсі взаємодіють з уніфікованим флоу ініціалізації:

Адаптації архітектури під капотом:

  • Нативне середовище проти WASM: На нативних платформах nx-logger інтегрується з неблокуючим апендером tracing_appender::non_blocking::NonBlocking для перенесення операцій вводу/виводу у виділений фоновий потік із підтримкою ротації файлів. При компіляції під target_arch = "wasm32" код ротації та фонових потоків вирізається за допомогою умовної компіляції (#[cfg]). Залежно від специфічного WebAssembly-таргету, білдер прозоро перенаправляє події: напряму у web_sys::console::log для браузерів, або безпосередньо в нативну телеметрію хоста через стандартні інтерфейси wasi:cli/stdout чи wasi:logging для компонентів WASI P2. При цьому точне розташування полів зберігається у всіх середовищах.
  • RAII-охоронець (worker guard): Метод init() повертає критично важливий guard для управління ресурсами. Поки він живе у головній функції main(), логи обробляються асинхронно. Коли застосунок завершує роботу, guard видаляється, викликаючи блокуючий скид, який гарантує, що жодні буферизовані логи не будуть втрачені під час раптової зупинки.

Оптимізація гарячого шляху: тримаємо купу heap в чистоті

Кожен виклик макросів трейсінгу info! або error! на критичних ділянках, наприклад, у циклах високочастотного трейдингу або рушіях маршрутизації, потенційно може викликати блокування глобального алокатора пам’яті. Якщо утиліта логування виділяє пам’ять у купі (алокація пам’яті) для кожного атрибута, ключа чи динамічного рядка, це швидко призводить до конкуренції між потоками та деградації продуктивності.

Щоб мінімізувати цей оверхед, nx-logger уникає серіалізаторів загального призначення на кшталт serde_json на гарячому шляху виконання, покладаючись натомість на попередньо виділені масиви на стеку та жорсткі ліміти.

1. Оптимізація з SmallVec та SmolStr

Коли застосунок ініціює подію логування, tracing передає метадані через свій внутрішній патерн Visitor. Замість того, щоб алокувати карту BTreeMap або динамічний масив Vec у купі для збору цих полів, nx-logger тимчасово зберігає їх на стеку за допомогою SmallVec, ініціалізованого статичною ємністю у 16 полів:

  • Компроміс переповнення масиву (spillover): Для 99% звичайних логів 16 слотів під метадані більше ніж достатньо. У таких стандартних сценаріях збір полів відбувається з нульовими алокаціями в купі. Однак реальна телеметрія непередбачувана. Якщо глибокий контекст виконання перевищує 16 полів, SmallVec автоматично переповнюється і динамічно виділяє пам’ять у купі. Цей архітектурний трейд-офф ставить абсолютну стабільність застосунку вище за суворі ліміти продуктивності в екстремальних умовах.
  • Вбудовані рядки (inline strings): Динамічні рядкові значення фіксуються за допомогою SmolStr. Будь-які рядкові дані розміром до 22 байт пакуються безпосередньо у внутрішню структуру на стеку, повністю оминаючи глобальний алокатор. При перевищенні цього розміру також відбувається автоматична алокація рядка у купі.

2. Прямий потоковий запис через std::fmt::Write

Замість створення проміжних структур даних для представлення події логування, nx-logger форматує та екранує фінальний payload у форматі JSON безпосередньо в цільовий буфер байтів за один прохід, використовуючи швидкий кастомний цикл обробки символів:

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

Емпіричний аналіз: бенчмарки та профілювання пам’яті

Щоб перевірити архітектуру, я протестував підсистему під великими навантаженнями, використовуючи criterion для вимірювання затримок у режимі release та dhat для точного відстеження алокацій у купі.

1. Затримка під навантаженням (Criterion)

Бенчмарки виконувались у режимі release, щоб виміряти загальний час, витрачений на захоплення структурованих подій, що містять суміш статичних літералів та динамічних полів. Я тестував систему при різних рівнях конкурентності потоків (1, 4 та 8 воркерів):

Аналіз продуктивності: в однопотоковому середовищі (workers_1) обробка та відправка структурованої події займає приблизно 2.97 мкс. При масштабуванні до конкурентного багатопотокового виконання (workers_4 та workers_8) затримка зростає до 7.96 мкс та 11.35 мкс. Це очікуваний компроміс архітектури: затримка зростає через конкуренцію потоків за доступ до спільних неблокуючих буферів tracing_appender та внутрішніх механізмів синхронізації шардів базового реєстру sharded_slab.

2. Відстеження алокацій через DHAT

Запуск навантажувальних тестів всередині профайлера пам’яті dhat показує точні обсяги глобальних алокацій:

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

Вердикт профайлера: Усі 17 блоків алокацій (~137 КБ) відбуваються виключно на етапі запуску системи (LoggerBuilder::init).

Що найважливіше: на гарячому шляху виконання виклик info! або error! у межах лімітів полів SmallVec призводить до відсутності нових алокацій у купі, повністю ізолюючи систему від блокувань алокатора під час стрибків навантаження у продакшені.

Інженерні компроміси та межі системи

Проєктування високопродуктивної інфраструктури вимагає свідомих компромісів. Отримання тонкого контролю над макетами серіалізації та споживанням пам’яті вносить специфічні операційні ризики.

1. Ручне формування JSON проти зручності підтримки

Головна ціна відмови від стандартних фреймворків на кшталт serde_json — це крихкість коду. Під час ранньої розробки дрібні баги у форматуванні легко генерували невалідні JSON-записи:

Invalid JSON line: {... "spans":[user_login] ...}: Error("expected value")

Оскільки вивід логу будується вручну через сирий запис рядків, пропущена лапка або зайва кома зробить невалідним увесь JSON-рядок. Даунстрім-системи збору логів, такі як Grafana Loki або Logstash, відхилять увесь пошкоджений пакет.

Стратегія пом’якшення: Щоб боротися з цим, не додаючи оверхеду в рантаймі, я використовую property-based тестування. Я інтегрував крейт proptest для генерації випадкових, дуже нестандартних Unicode-рядків. Ці рядки проганяються через функцію write_escaped_json_str і валідуються на відповідність еталонній моделі serde_json::from_str, що дозволяє відловлювати регресії форматування ще до релізної компіляції.

2. Межі безпеки fxhash

Щоб відповідати вимогам комплаєнсу щодо чутливих даних користувачів, nx-logger автоматично звіряє поля логів із блок-листом для фільтрації, який зчитується зі змінної середовища LOG_IGNORE_FIELDS:

  • Компроміс HashDoS: за замовчуванням стандартний HashSet у Rust використовує алгоритм SipHash для захисту від атак HashDoS (де зловмисник може спеціально підібрати вхідні дані, щоб викликати колізії хешів та знизити продуктивність до O(N^2)). У nx-logger я свідомо замінив його на FxHashSet (працює на базі fxhash), який орієнтований виключно на швидкість виконання.
  • Обґрунтування безпеки: Хоча fxhash не є криптографічно стійким, блок-лист формується виключно під час запуску застосунку з довіреного шару середовища, яким керує DevOps. Оскільки довільний користувацький ввід не може динамічно вводити нові ключі в цей статичний набір для оцінки, вектор атаки HashDoS повністю нівелюється, що робить виграш у продуктивності абсолютно виправданим.

3. Зворотний тиск (Backpressure) та політика скидання

Під час роботи під високими навантаженнями, tracing використовує обмежений канал у пам’яті для передачі записів логів фоновому I/O потоку. Ця межа захищає пам’ять застосунку від неконтрольованого зростання, якщо диски хост-системи перенавантажуються або виникають тимчасові стрибки затримок.

  • Політика скидання (drop policy): Коли цей внутрішній буфер заповнюється повністю, система активує політику drop-head. Найстаріші необроблені записи логів витісняються з черги та назавжди видаляються, щоб звільнити місце для нових подій.
  • Операційний вплив: Цей дизайн ставить доступність та чуйність ядра застосунку вище за історичну телеметрію. Сервіс ніколи не зависне і не впаде через дискові затримки, але SRE/DevOps команда має прийняти той факт, що частина історії логів може бути втрачена під час серйозних вузьких місць вводу/виводу.

Приклад практичного застосування обох крейтів

Коли така архітектура — це оверінжиніринг

Цей дизайн створювався для кросплатформних корпоративних середовищ, де обов’язковість схем та максимально передбачувана поведінка пам’яті мають першорядне значення. Але така система, швидше за все, буде надлишковою, якщо ви розробляєте:

  1. Прості інструменти командного рядка або скрипти автоматизації для локального використання.
  2. Застосунки, які не агрегують неструктуровану телеметрію у високонавантажені рушії індексування.

Для менших кодових баз стандартні базові конфігурації tracing-subscriber забезпечують чудову продуктивність і вимагають значно менше зусиль на підтримку коду.

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

Серія статей про архітектуру NEXUS: Читати повний індекс серії

Попередня стаття: Проєктування спільного контракту помилок у Rust. Будуємо NEXUS, частина 1

Головний репозиторій: Вихідний код NEXUS

Крейт: nx-logger

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі