1. 1
  2. 2
  3. 3
  4. 4
  5. 5

# теория · шаг 2 из 5

Свой формат: время, бэкенд, request_id

Коротко

Свой формат пишут ради одного вопроса, на который combined не отвечает: «тормозит - это мы или сеть до клиента?» Отвечает на него пара переменных времени, а рядом с ними появляются ещё три вещи, которых иначе не добыть.

  • $request_time против $upstream_response_time разводит «медленный бэкенд» и «медленный канал».
  • В строке может стоять us=502, а в $status - 200: это повторная попытка, и пользователь ошибки не видел.
  • $request_id сшивает лог nginx с логами приложения по одному значению.
  • log_format живёт только в http, а access_log подчиняется правилу «свои директивы вытесняют унаследованные».
  • Для JSON обязателен escape=json, а поля upstream_* обязаны быть в кавычках - иначе строка ломается на первом же запросе без бэкенда.

Если пара rt/urt и escape=json знакомы - листай до «Теперь сам».

Сначала ответь сам

Строка из лога, формат свой:

↳  выводgrep pair /var/log/nginx/main.log
127.0.0.1 - - [10/Sep/2026:19:58:52 +0000] "GET /pair/report HTTP/1.1" 200 3 "-" "curl/8.22.0" host=localhost rt=0.000 upstream=127.0.0.1:9, 127.0.0.1:8080 us=502, 200 urt=0.001, 0.000 uct=-, 0.000 uht=-, 0.000 rid=a308ceb1dbf4e5caad5e76c0415d5766

Формат этой строки разобран ниже в уроке. Два адреса в upstream= взялись из группы серверов, куда nginx отправляет запрос (группы разбираются в разделе про балансировкуРазбирается в разделе 8, глава «Методы балансировки»: Группа серверов вместо адреса). В логе есть 502. Что увидел пользователь?

Успех. 502 стоит в $upstream_status - это ответ первой попытки: сервер 127.0.0.1:9 соединение отверг, nginx пошёл на второй адрес, тот ответил, и клиенту ушёл код 200 из $status. Запятая в этих полях всегда значит одно: попыток было больше одной.

Считать 502 по grep " 502 " в таком логе - значит считать поломки, которых пользователь не заметил. И наоборот: если считать только по $status, скрытые отказы бэкендов не увидит никто.

На что это похоже

Ты заказал еду и ждал сорок минут. Сорок минут - это $request_time. Кухня готовила восемь минут - это $upstream_response_time. Остальные тридцать две курьер вёз.

Пока у тебя одно число «сорок», разговор с рестораном бессмысленный: они клянутся, что готовят быстро, и по своим записям правы. Как только рядом встают два числа, спорить не о чем - видно, чья это половина.

Вся настройка формата сводится к тому, чтобы в каждой строке было два числа вместо одного.

Механизм: где кончается одно время и начинается другое

тело запроса соединение бэкенд думает бэкенд отдаёт отдача клиенту uct uht - сколько бэкенд думал urt - весь ответ бэкенда rt - всё целиком
Три отрезка с приставкой upstream_ начинаются в одной точке и вложены друг в друга. Нижний шире всех: в него входит и чтение запроса, и отдача ответа клиенту, чего бэкенд не видит вовсе.
Переменная Что меряет
$request_time от первого байта запроса до последнего записанного байта ответа
$upstream_connect_time сколько заняло установить соединение с бэкендом
$upstream_header_time до первого байта ответа бэкенда
$upstream_response_time пока бэкенд отдавал ответ целиком

Правило

Читай эти числа парами, а не по одному. Разность rt и urt - это всё, что не бэкенд: чтение запроса, ожидание в очереди, отдача клиенту.

  • rt большой, urt маленький - канал до клиента или медленная загрузка тела;
  • оба большие - тормозит бэкенд;
  • заметный uct при быстром остальном - бэкенду не хватает воркеров, соединения ждут приёма;
  • uht почти равен urt - бэкенд долго думает и быстро отдаёт: тяжёлый запрос к базе.

Разбор: клиент на медленном канале

Замер на стенде. Бэкенд отдаёт 20 000 000 байт, клиент читает их через curl --limit-rate 2M - это 2 МиБ, 2 097 152 байта в секунду. Со стороны клиента загрузка заняла 9,5 секунды, ровно 20 000 000 / 2 097 152. В логе:

↳  выводgrep huge /var/log/nginx/main.log
127.0.0.1 - - [10/Sep/2026:19:58:56 +0000] "GET /huge HTTP/1.1" 200 20000000 "-" "curl/8.22.0" host=localhost rt=4.239 upstream=127.0.0.1:8080 us=200 urt=0.064 uct=0.000 uht=0.001 rid=986cd9438ba973a33aeb627d8034fe05

Бэкенд отработал за 64 миллисекунды, nginx потратил 4,2 секунды. Виноват канал - и это видно из одной строки, не заходя ни на один сервер.

Но обрати внимание: 4,2 против 9,5 у клиента. $request_time заканчивается, когда nginx записал последний байт в сокет, а не когда клиент его получил. Часть данных лежала в буферах ядра, пока nginx уже считал запрос завершённым. На том же стенде ответ в 2 000 000 байт целиком уместился в буфер, и rt вышел 0.003 при почти секунде загрузки у клиента.

Практический вывод: rt - нижняя оценка того, сколько ждал человек. Если он большой, канал точно плохой; если маленький, это ещё ничего не доказывает.

Разбор: где наивное правило подводит

Прочерк - это не ноль. У запроса, не дошедшего до бэкенда, все upstream_* пусты: в обычном формате пустое значение печатается прочерком -, а с escape=json - пустотой. У 499 картина хитрее: us=- и uht=- (заголовков не было), а urt=1.001 - столько nginx прождал до того, как клиент ушёл. Складывать такие значения в среднее нельзя.

Кавычки в JSON - не косметика. Вот формат, где $upstream_response_time записан числом без кавычек:

/etc/nginx/nginx.confhttp
log_format jsonbad escape=json '{"uri":"$uri","urt":$upstream_response_time,"ua":"$http_user_agent"}';

Он ломает строку на каждом запросе, который не ушёл на бэкенд, - на статике, на редиректе, на 404, - и на каждой повторной попытке:

↳  выводcat /var/log/nginx/jsonbad.log
{"uri":"/pair/report","urt":0.001, 0.000,"ua":"curl/8.22.0"}
{"uri":"/logo.png","urt":,"ua":"curl/8.22.0"}
{"uri":"/huge","urt":0.064,"ua":"curl/8.22.0"}

Первые две строки не разберёт ни один парсер. То же с $upstream_addr и $upstream_status. С кавычками вокруг значения те же запросы дают "urt":"0.001, 0.000" и "urt":"" - корректный JSON.

escape=json обязателен. Без него nginx экранирует по своим правилам, а не по правилам JSON, и User-Agent с кавычкой превращается в \x22:

↳  вывододно и то же значение при двух режимах экранирования
escape=json:     "ua":"Mozilla/5.0 \"hacked\" \\back"
по умолчанию:    "ua":"Mozilla/5.0 \x22hacked\x22 \x5Cback"

Вторая строка формально похожа на JSON и ломается на \x: такой последовательности в стандарте нет. Приёмная сторона начинает ронять записи пачками, причём именно те, что интереснее всего.

Формат под разбор инцидентов

/etc/nginx/nginx.confhttp
log_format main '$remote_addr - $remote_user [$time_local] '
                '"$request" $status $body_bytes_sent '
                '"$http_referer" "$http_user_agent" '
                'host=$host rt=$request_time '
                'upstream=$upstream_addr us=$upstream_status '
                'urt=$upstream_response_time uct=$upstream_connect_time '
                'uht=$upstream_header_time rid=$request_id';

log_format json escape=json '{"uri":"$uri","urt":"$upstream_response_time","ua":"$http_user_agent"}';

Куски в кавычках склеиваются в одну строку формата, поэтому пробел в конце каждого нужен, а отступы между ними - нет. В официальном образе Docker в nginx.conf уже объявлен свой log_format main, и этот блок пишется вместо него: такой же в conf.d даст duplicate "log_format" name "main" - проверено.

log_format объявляется только в http. Попытка положить его в server даёт отказ ещё до старта:

↳  выводnginx -t
nginx: [emerg] "log_format" directive is not allowed here in /etc/nginx/nginx.conf:32

:32 - номер строки в файле.

$request_id - 32 шестнадцатеричных символа, свои на каждый запрос. Передай их бэкенду, и строка nginx сошьётся с записями приложения:

/etc/nginx/conf.d/shop.local.confhttp server location /api/
proxy_set_header X-Request-Id $request_id;

proxy_set_header добавляет заголовок к запросу, уходящему бэкенду; имя заголовка выбираешь сам, X-Request-Id - общепринятое.

↳  выводчто увидел бэкенд и что записал nginx
бэкенд:  X-Request-Id: 7ea37b4954250561d6d41a0ab9a3366f
в логе:  rid=7ea37b4954250561d6d41a0ab9a3366f

Дальше в разборе ты берёшь rid из строки с ошибкой и находишь по нему трассу в приложении. Без этого приходится сопоставлять по времени и URL, а под нагрузкой это гадание.

Что ломается без этого

Спор «виноват бэкенд» против «виноват nginx» идёт неделями, потому что у сторон разные числа и ни одно из них не общее. Появление rt и urt в одной строке заканчивает такой спор за минуту.

Второе: лог в JSON, который разваливается на трети записей. Сборщик молча отбрасывает нечитаемое, графики выглядят нормально, и никто не замечает, что пропали именно ошибки.

Зачем это в работе

Куда писать - решается тут же, рядом с форматом:

/etc/nginx/conf.d/shop.local.confhttp
server {
    listen 80;
    server_name shop.local;
    access_log /var/log/nginx/shop.access.log main;

    # мониторинг стучится раз в секунду - в логе от этого только шум
    location = /health { access_log off; return 200 "ok\n"; }

    # трафик API уезжает в аналитику отдельным потоком
    location /api/ {
        access_log /var/log/nginx/api.access.log json;
        proxy_pass http://127.0.0.1:8080;
    }
}

На одном уровне можно объявить несколько access_log - запись уйдёт в каждый. \n в return - перевод строки в конце ответа. А access_log на уровне вытесняет унаследованный: объявишь в server свой формат, и общий access_log из http в этот сервер писать перестанет. Проверяется это за минуту и удивляет ровно один раз в жизни.

Ещё два параметра для нагруженного сайта:

/etc/nginx/conf.d/shop.local.confhttp server
access_log /var/log/nginx/shop.access.log main buffer=64k flush=5s;

Эту строку пишут вместо прежнего access_log, а не рядом - иначе запись уйдёт в оба. buffer копит строки в памяти, flush не даёт последним залежаться. Плата - твой запрос появится в логе не сразу: на стенде с flush=5s строка ждала своих пять секунд. При отладке это выглядит как «лог не пишется», а при жёстком падении процесса последние секунды теряются насовсем.

Ротация: логи кончились ровно в полночь

logrotate - системная программа, которая раз в сутки переименовывает и сжимает логи (её правила для nginx лежат в /etc/logrotate.d/nginx). Она переименовывает файл, а nginx продолжает писать в старый дескриптор - номер уже открытого файла, по которому процесс пишет, не глядя на имя: новый файл не появляется вовсе, а записи уходят в access.log.1. Проверено на стенде: после mv строки продолжили копиться в переименованном файле, и только nginx -s reopen создал новый. Поэтому в конфиге ротации обязателен postrotate с nginx -s reopen или kill -USR1 - оба велят nginx заново открыть файлы логов, не перечитывая конфиг, как reload.

Теперь сам

В формате стоит rt=$request_time, и на графике 95-й процентиль по /api/search (время, быстрее которого обслужены 95% запросов) вырос с 0,3 до 2,5 секунды. urt при этом стоит на месте, около 0,2. Куда идти?

Не на бэкенд. Разность выросла на два с лишним секунды, а бэкенд отвечает как раньше, значит время уходит либо на чтение запроса, либо на отдачу ответа. Стоит посмотреть, не выросли ли ответы в размере ($body_bytes_sent) и не пришла ли новая аудитория с плохой связью. uct тут не поможет: он входит в urt, а urt стоит на месте.

Главное

$request_time и $upstream_response_time в одной строке разводят «медленный бэкенд» и «медленный канал»; разность - это всё, что бэкенда не касается. Запятая в upstream-полях означает повторную попытку, и us=502 при $status=200 значит, что пользователь ошибки не видел. log_format объявляется только в http; access_log на уровне вытесняет унаследованный, off выключает логирование уровня, несколько директив пишут во все назначения. Для JSON нужен escape=json, а upstream-поля обязаны быть в кавычках - иначе строка ломается на каждом запросе без бэкенда.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.