Лагіраваньне: ад хаосу да сыстэмы
Кожны распрацоўшчык дадае логі. Мала хто думае пра тое, як іх дадаваць. У выніку атрымліваецца прыкладна наступнае: логі ёсьць, але калі здараецца інцыдэнт на продзе і трэба зразумець, дзе ўсё пайшло не так, — ты глядзіш вачыма ў бясконцыя "something failed" і "error occurred" і разумееш, што яны табе нічога не кажуць.
Гэты тэкст — спроба сфармуляваць падыход да лагіраваньня як да інжынернай дысцыпліны. Не набор правілаў дзеля правілаў, а практыка, якая акупляецца ў самы патрэбны момант.
Чаму логі — гэта частка дызайну
Распрацоўшчыкі часта ставяцца да лагіраваньня як да апцыянальнага аздабленьня: напісаў код, здаецца ўсё працуе — дадаць пару logger.info() дзесьці ў фінале. Гэта памылка.
Добра прадуманыя логі — гэта фактычна дакументацыя сыстэмы, якая самаабнаўляецца. Калі я чытаю незнаёмы сэрвіс і бачу, якія падзеі ён лагуе, у якіх кантэкстах і на якіх узроўнях — я разумею яго паводзіны хутчэй, чым з каментароў у кодзе. Каментарыі пратухаюць. Логі — гэта тое, як сыстэма апісвае сябе ў рэальным часе.
Калі лагіраваньне закладзена правільна, яно дае тры рэчы:
- Дыягностыку — калі нешта сламалася, ты ведаеш дзе і чаму
- Назіральнасьць — ты бачыш, як сыстэма паводзіцца нават калі ніхто не скардзіцца
- Алерты — ты можаш наладзіць абвестку па error rate крытычнай аперацыі і даведацца пра праблему раней за карыстальніка
Усе тры рэчы становяцца недасяжнымі, калі логі дадаюцца хаатычна.
Структураваныя логі — не бяздумны трэнд, а неабходнасьць
Ёсьць два фармату логаў: тэкставы і структураваны (JSON).
Тэкставы выглядае так:
[2025-05-21 14:32:01] ERROR: Failed to process payment for user 42, amount 99.90, reason: timeout
Структураваны — так (можа быць варыянт зь вынясеньнем кантэкставых палёў ва ўкладзены аб'ект, але гэта проста дэталі рэалізацыі):
{
"timestamp": "2025-05-21T14:32:01.234Z",
"level": "error",
"message": "payment processing failed",
"user_id": 42,
"amount": 99.90,
"reason": "timeout",
"service": "payment-service",
"trace_id": "abc123"
}
З першым фарматам ты можаш чытаць вачыма. З другім — можаш рабіць усё: фільтраваць па user_id, будаваць графікі па amount, наладжваць алерты па reason, аб'ядноўваць лог-запісы па trace_id праз некалькі сэрвісаў.
Простае правіла: у продакшне — заўсёды JSON. Тэкставы фармат — толькі для лакальнай распрацоўкі, і толькі калі логер умее пераключацца аўтаматычна праз канфігурацыю.
Стандарты і рэалізацыі
Існуе некалькі базавых фармалізацый падыходаў да лагірваньня:
- OpenTelemetry Logs — вендар-незалежная спецыфікацыя, якая вызначае структуру
LogRecord: timestamp, TraceId, SpanId, SeverityNumber, Body, Attributes. Гэта сёньня лепшы агульны знамянальнік - Elastic Common Schema (ECS) — дэ-факта стандарт для ELK-стэку, вызначае назовы палёў
- PSR-3 (PHP) — інтэрфейс з 8 узроўнямі,
Monologяк рэферэнсная рэалізацыя slog(Go, stdlib 1.21+),zap,zerolog— усе тры прасоўваюць structured logging з палямі замест інтэрпаляцыі радкоў- SLF4J + Logback/Log4j2 (Java) — MDC (Mapped Diagnostic Context) для захаваньня кантэксту ў шматструменным асяродку
structlog(Python) — надбудова над stdlibloggingдля структураваных запісаў
Канкрэтны выбар інструменту другасны. Галоўнае — каб ён падтрымліваў структураваны вывад і кантэкстныя палі.
Два слаі кожнага лог-запісу: паведамленьне і кантэкст
Гэта самае важнае разуменьне, якое адразу мяняе якасьць логаў.
Паведамленьне (поле message) — статычны радок, які апісвае тып падзеі. Яно ніколі не зьмяняецца ад выкліку да выкліку.
Кантэкст — усё зьменнае: ідэнтыфікатары, значэньні, даўжыні, статусы — гэта палі.
Правільна:
{
"message": "payment processing failed",
"user_id": 42,
"amount": 99.90,
"reason": "timeout"
}
Няправільна:
{
"message": "payment processing failed for user 42, amount 99.90: timeout"
}
Чаму гэта важна? Таму што message — гэта ключ для групоўкі. У Loki, Elasticsearch, Datadog — усюды ты можаш узяць статычны паведамленьне і паглядзець, колькі разоў яно ўзьнікла, па якіх карыстальніках, з якімі значэньнямі. Калі ж ты ўставіш у паведамленьне зьменныя — кожны запіс становіцца ўнікальным, індэкс разрастаецца, а алерты на ўзроўні message стануць немагчымымі.
Мінімальны набор палёў кожнага запісу
timestamp — RFC3339Nano, UTC, заўсёды
level — радком: "debug", "info", "warn", "error"
message — статычны апісальны радок
service — назва сэрвісу
version — вэрсія дэплою (вельмі дапамагае пры посткем разборы)
trace_id — ідэнтыфікатар запыту/аперацыі для сувязі запісаў
trace_id варта прадаваць праз усе міжсэрвісныя выклікі — праз HTTP-загалоўкі, gRPC metadata, атрыбуты паведамленьняў у чарзе. Менавіта ён дазваляе сабраць поўны ланцужок падзей па адным інцыдэнце праз дзесятак сэрвісаў. Звычайна гэты ідэнтыфікатар фармулюецца на баку traefik ці nginx для знешніх запытаў.
Узроўні лагіраваньня: за кожным стаіць сэнс
Узроўні — гэта кантракт паміж кодам і тым, хто будзе чытаць логі (альбо наладжваць на іх алерты).
| Узровень | Калі выкарыстоўваць |
|---|---|
| TRACE | Дэтальны стан знутры алгарытму. Толькі для лакальнай адладкі канкрэтнай праблемы альбо для dev-асяродку. На продзе можа быць ненадоўга актывізаваны ў самым крайнім выпадку |
| DEBUG | Дыягнастычная інфармацыя, карысная пры разборы інцыдэнту. На прод — апцыянальна, па свічу |
| INFO | Значная бізнэс-падзея: запыт прыняты, задача запушчана, карыстальнік аўтэнтыфікаваны |
| WARN | Аномалія, якая не перашкаджае зараз, але патрабуе ўвагі: паўторная спроба прайшла, выкарыстоўваецца састарэлы API, значэньне блізка да ліміту |
| ERROR | Аперацыя правалілася, але сэрвіс працягвае працаваць. Патрабуе рэакцыі |
| FATAL/CRITICAL | Сэрвіс не можа працягваць працу. Звычайна вядзе да завяршэньня працэсу |
Частыя памылкі
ERROR на кожны 4xx. Калі карыстальнік адправіў няправільны запыт і атрымаў 400, 401 ці наогул 404 — гэта не памылка сэрвісу. Гэта нармальная бізнэс-сітуацыя. ERROR тут засмечвае алерты і прыводзіць да таго, што рэальныя памылкі губляюцца ў шуме. Выключэньнем можа быць калі не знойдзена тое, што павінна быць абавязкова і гэта ўплывае на карэктную працу бізнэс-лёгікі.
WARN на тое, што ўжо патрабуе рэакцыі. Калі сытуацыя патрабуе рэакцыі дзяжурнага — гэта ERROR.
INFO на ўнутраныя тэхнічныя дэталі. Вось тып паведамленьня, якое я бачыў часьцей за ўсё: logger.info("Processing item " + id) у сярэдзіне ітэрацыі цыклу. Гэта не INFO, гэта DEBUG у лепшым выпадку. На прод такі запіс генеруе кіламетры бессэнсоўнага шуму.
Дзе абавязкова мець логі і што ў іх
Кропкі ўваходу: HTTP-хэндлеры, кансюмеры, cron-задачы
// Пачатак апрацоўкі
{ "message": "request received", "method": "POST", "path": "/payments", "request_id": "...", "user_id": 42 }
// Завяршэньне
{ "message": "request completed", "method": "POST", "path": "/payments", "status": 200, "duration_ms": 145 }
Абодва запісы важныя. Калі сэрвіс завіс і не адказвае — у логах будзе "request received" без "request completed". Гэта адразу паказвае, дзе шукаць.
Зьнешнія выклікі: HTTP да іншага сэрвісу, БД, чаргу
{ "message": "external call failed", "target": "payment-gateway", "operation": "charge", "duration_ms": 5001, "error": "connection timeout" }
Гэта самае вартае месца для DEBUG/INFO пра паспяховыя выклікі і ERROR пра памылкі. Менавіта тут ты даведаешся, калі зьнешні сэрвіс пачаў тармазіць ці сыпацца.
Старт і стоп сэрвісу
{ "message": "service starting", "version": "1.4.2", "port": 8080 }
{ "message": "service ready" }
{ "message": "shutdown initiated", "reason": "SIGTERM" }
{ "message": "graceful shutdown complete", "duration_ms": 2300 }
Гэта здаецца банальным, але лог завяршэньня з reason і duration_ms неаднойчы дапамагаў зразумець, ці сэрвіс завяршыўся штатна ці быў забіты.
Бізнэс-падзеі
Залежаць ад дамэну, але логіка адна: лагаваць значныя зьмены стану.
{ "message": "order created", "order_id": "ord_123", "user_id": 42, "amount": 99.90 }
{ "message": "payment retry", "payment_id": "pay_456", "attempt": 2, "reason": "gateway_timeout" }
{ "message": "payment failed", "payment_id": "pay_456", "reason": "card_declined" }
Як фармуляваць паведамленьні
Некалькі прынцыпаў, якія спрашчаюць чытаньне логаў:
Завершаны час для завершаных падзей. "user authenticated", не "authenticating user". "cache miss", не "checking cache". Калі ты бачыш запіс — падзея ўжо адбылася.
Не дублюй узровень у паведамленьні. "Error: payment failed" — дрэнна, бо ўзровень і так error. Проста "payment failed".
Кароткі апісальны радок. "connection pool exhausted" лепш за "Could not get connection from pool because all connections are in use".
Адзінства стылю ў праекце. Альбо "cache_miss" (snake_case), альбо "cache miss" (натуральная мова) — выбраць і трымацца. Калі кожны распрацоўшчык піша як хоча, пошук па паведамленьнях ператвараецца ў лотэрэю.
Антыпатэрны:
"Something went wrong"— бескарысна"ok"— што акурат ok?"!!!"— гэта не лог"Entering function processPayment"— гэта трэйсінг, не лагіраваньне
Для якіх сэрвісных слаёў лагірваньне будзе лішнім
Гэта пытаньне, пра якое рэдка думаюць загадзя. А дарма.
Дамэнавая логіка
Тут усё не так адназначна, як часам прадстаўляецца ў тэарэтычных матэрыялах.
Строгі падыход "чысты дамэн не лагуе нічога" мае сэнс у кантэксьце архітэктур, дзе дамэн ізаляваны ад любых інфраструктурных залежнасьцяў і адтуль толькі вяртаюцца вынікі працы ці памылка. На практыцы ж лагіраваньне, размешчанае блізка да месца здарэньня, часта карыснейшае: яно захоўвае поўны лакальны кантэкст, які на ўзроўні вышэй ужо можа быць недасяжным або размытым.
Таму прынцып тут хутчэй такі: лагуй там, дзе ёсьць поўны кантэкст - максімальна блізка да месца падзеі, пры магчымасьці прытрымліваючыся рэкамендацый да выкарыстаньня інфраструктурных механізмаў ў слаёвай мадэлі.
func (o *Order) ApplyDiscount(code string) error {
if !o.isValid(code) {
// Тут ёсьць усё: і сам аб'ект, і код, і прычына адмовы
// На ўзроўні вышэй гэтага кантэксту можа ўжо не быць
logger.Warn("discount code invalid", "order_id", o.ID, "code", code, "status", o.Status)
return ErrInvalidCode
}
...
}
Але ёсьць адна пастка, якую варта пазьбягаць: дубліраваньне. Калі дамэнавы метад залагаваў памылку, а application layer яе перахапіў і залагаваў зноў — у логах адна памылка зьяўляецца двойчы, з розным кантэкстам, і пры аналізе гэта блытае. Таму лагіраваньне блізка да здарэньня патрабуе дамоўленасьці ў камандзе.
Утылітарныя функцыі і хэлперы
Функцыя тыпу parseDate(s string) не павінна нічога лагаваць. Яна вяртае памылку. Хто яе выклікае — той і вырашае, гэта ERROR ці WARN, ці можна проігнараваць.
Рэпазіторыі / Data Access Layer
Спрэчны момант, але ў большасьці выпадкаў — не. Рэпазіторый вяртае памылку. Application layer вырашае, наколькі гэта сур'ёзна. Калі рэпазіторый лагуе сам — атрымліваем дубліраваньне: адна памылка лагуецца двойчы на розных узроўнях.
Выключэньне: slow query log — гэта інфраструктурны аспект, які можна лагаваць прама ў рэпазіторыі з узроўнем WARN, бо гэта не бізнэс-памылка, а паказчык прадукцыйнасьці.
Прыкметы праблемы праектаваньня
- Адна і тая ж памылка лагуецца на некалькіх узроўнях стэку — дубліраваньне
- Лагіраваньне ўнутры цыклю без сэмплінгу
logger.Info("entering function X")на кожны метад — гэта трэйсінг, не лагіраваньне; для гэтага ёсьць OpenTelemetry Traces
Збор логаў: сэрвісу не істотна куды яны пішуцца
Ёсьць два падыходы:
Push: сэрвіс сам шле ў агрэгатар (Loki push API, Fluentd, OTLP exporter).
Pull: агент чытае stdout/файлы (Promtail, Vector, Filebeat).
У кантэйнерным асяродку правільны адказ амаль заўсёды — stdout/stderr >> агент зьбірае. Сэрвіс ня ведае, куды ідуць яго логі. Гэта разьвязвае інфраструктурную канфігурацыю ад кода.
Антыпатэрн: пісаць у файл унутры кантэйнеру, потым асобна збіраць. Двайны запіс, ускладненьне, галаўны боль з rotation.
Зь чым сутыкаўся раней і надзейна запомніў
Ёсьць рэчы, якія разумееш толькі пасьля таго, як яны адбыліся ў продзе.
Інцыдэнт, калі толькі загадзя закладзены дадзеныя дапамаглі. Сыстэма пачала губляць дадзеныя ціхаматна — без памылак, без крашаў. Знайсьці крыніцу дазволіла толькі тое, што на ўваходзе ў кожны сэрвіс лагаваліся ключавыя ідэнтыфікатары з трэйс-айдышнікам. Мы прасьледавалі ланцужок і ўбачылі, на якім этапе аб'ект перастаў зьяўляцца. Без гэтых запісаў — толькі гіпотэзы. Нажаль гэтыя логі былі дададзены ўжо пасьля таго, як у балансах разыходжаньні сталі досыць заўважнымі. Затое гэта надзейна замацавала ў галаве - падсцілаць месца, дзе можаш упасьці, загадзі і саломкі лепей не шкадаваць.
Алерты па error rate. Калі ERROR-узровень выкарыстоўваецца правільна (а не на кожны 400-ты адказ), можна зрабіць просты алерт: error_rate > 1% за апошнія 5 хвілін па крытычнай аперацыі. Гэта дае апавяшчэньне раней, чым карыстальнікі напішуць у падтрымку. Для гэтага патрэбна толькі каб ERROR сапраўды азначаў памылку. Сумую па тым часам, калі такое наладзіць было досыць проста.
Пошук, дзе губляюцца дадзеныя, калі сэрвіс спыніць нельга. Такая сытуацыя здаралася не аднойчы: нешта ня так, але сэрвіс у мінімальна жывым стане, прадакшн, спыняць нельга. Дадаць дадатковы лог у палёце і задэплоіць без рэстарту (калі архітэктура дазваляе), альбо адсачыць ход падзей па ўжо існуючых трэйс-айдышніках — гэта і ёсьць разьніца паміж "ляцім наўздагад" і "бачым, куды глядзець".
Логі як дакументацыя. Калі я заходжу ў незнаёмы сэрвіс — першае, на што гляджу, гэта логі на продзе і ключавыя кропкі лагіраваньня ў кодзе. Гэта дае разуменьне паводзін сыстэмы хутчэй, чым любы README.
Аб чым яшчэ варта памятаць
Сакрэтныя ці ўласныя дадзеныя. Ніколі не лагаваць паролі, токены, поўныя нумары картак, персанальныя дадзеныя ў адкрытым выглядзе. Маскіраваньне — не апцыя, а патрабаваньне. Памылкі тут каштуюць дорага.
Сэмплінг. Лагаваць кожны запыт на высоканагружаным сэрвісе дорага. Стратэгіі: лагаваць заўсёды памылкі + N% паспяховых, альбо tail-based sampling (вырашаць пасьля завяршэньня запыту, ведаючы яго вынік).
Лагіраваньне ўнутры цыклаў. Калі цыкл выконваецца мільёны разоў — лог унутры яго ператварае прод у пажар. Альбо семплінг, альбо лагаваць вынік пасьля завяршэньня цыклу, а не кожную ітэрацыю. Дарэчы гэта ж можа выявіць укладзеныя цыклы, што само па сабе той яшчэ red flag.
Замест высновы
Добрыя логі — гэта не тое, пра што думаеш у момант напісаньня кода. Гэта тое, пра што думаеш у 3 ночы, калі ёсьць інцыдэнт на продзе. Розьніца паміж "мы ня бачым нічога" і "вось дакладна, дзе праблема" — гэта рашэньні, прынятыя загадзя.
Лагіраваньне — гэта інжынерная дысцыпліна, якую варта закласьці ў стандарты каманды, правіць на code review і ня дазваляць дэградаваць да "дадаём дзесьці, калі памятаем".
На гэтым усё. Шануйце свой час і "не шкадуйце паперы" для логаў, дыскі зараз вялікія, а вы зь будучыні не аднойчы скажаце сабе "дзякуй".
Да сустрэчы.