JSON-логи в Nginx — это не «модно», а удобно: их легко парсить, индексировать, склеивать с логами приложения и быстро отвечать на вопросы вроде «почему выросли 5xx?» или «где теряем время: на Nginx или на апстриме?». Главная ошибка — пытаться «склеить JSON руками» без корректного экранирования: рано или поздно кавычка в User-Agent или управляющий символ в заголовке превратят строку в невалидный JSON.
Ниже — практичная схема: корректный log_format escape=json, единый request_id (в том числе через X-Request-ID), а также метрики апстрима (upstream_response_time, upstream_connect_time, upstream_header_time) для диагностики задержек и 5xx.
Зачем JSON-логи и почему combined уже не хватает
Классический combined-лог хорош глазами, но неудобен для машин: регулярки, «плавающие» поля, спецсимволы. Как только вы собираете логи централизованно или расследуете инциденты по нескольким сервисам, обычно хочется:
- фиксированный набор ключей, который не меняется от строки к строке;
- надёжное экранирование кавычек и управляющих символов;
- корреляцию запроса через
request_idмежду Nginx, приложением и апстримами; - явные числовые поля времени, чтобы быстро строить разрезы по latency;
- понимание природы 5xx: Nginx не смог сходить к апстриму или апстрим вернул 500.
Структурированный формат решает это «в лоб»: одна строка — один JSON-объект. Но только если вы используете escape=json и не делаете ручное экранирование в строках формата.
База: безопасный JSON через log_format escape=json
Ключевой момент: без log_format ... escape=json вы рано или поздно получите невалидный JSON. Достаточно одного заголовка с кавычкой или непечатным символом, чтобы парсер на стороне сбора логов начал «сыпаться».
Пример базового формата (обычно размещают в контексте http):
log_format json_main escape=json
'{'
'"ts":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"xff":"$http_x_forwarded_for",'
'"request_id":"$request_id",'
'"request_method":"$request_method",'
'"scheme":"$scheme",'
'"host":"$host",'
'"uri":"$uri",'
'"args":"$args",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_length":$request_length,'
'"request_time":$request_time,'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_response_time":"$upstream_response_time",'
'"upstream_connect_time":"$upstream_connect_time",'
'"upstream_header_time":"$upstream_header_time",'
'"http_referer":"$http_referer",'
'"http_user_agent":"$http_user_agent"'
'}';
И подключение формата к файлу:
access_log /var/log/nginx/access.json json_main;
Почему часть полей строковые, а часть числовые
$status, $body_bytes_sent, $request_time и похожие поля удобнее хранить числами: фильтры и агрегации проще и быстрее. Но есть нюанс с upstream-полями: они могут быть пустыми или содержать несколько значений через запятую (ретраи, failover). Поэтому $upstream_response_time и родственников часто разумнее оставлять строками — так вы не потеряете исходную картину, а нормализацию сделаете уже на стороне системы логирования.
Если вы храните проекты на виртуальном хостинге или на VDS, JSON-логи особенно полезны при миграциях/масштабировании: вы быстрее увидите, что именно изменилось — сетевые задержки до апстрима, рост очередей в приложении или проблемы на уровне фронта.

request_id и X-Request-ID: единая корреляция по всей цепочке
Цель простая: один и тот же идентификатор запроса должен проходить через Nginx, приложение и любые промежуточные сервисы. Иначе вы увидите в логах приложения одно, в Nginx другое, а в логах апстрима третье — и расследование превращается в ручной «пазл».
Часто клиент, CDN или балансировщик уже присылает X-Request-ID. Рабочая практика:
- если заголовок
X-Request-IDпришёл — использовать его; - если не пришёл — сгенерировать ID на Nginx (через
$request_id) и проставить дальше; - всегда возвращать
X-Request-IDклиенту в ответе, чтобы саппорт мог попросить «ID запроса».
Как взять входящий X-Request-ID, иначе использовать $request_id
Удобнее всего сделать «нормализованную» переменную через map:
map $http_x_request_id $req_id {
default $http_x_request_id;
"" $request_id;
}
Теперь в JSON-логе пишем не $request_id, а $req_id:
log_format json_main escape=json
'{'
'"ts":"$time_iso8601",'
'"request_id":"$req_id",'
'"status":$status,'
'"request_time":$request_time,'
'"upstream_response_time":"$upstream_response_time"'
'}';
Проброс request id в апстримы и возврат клиенту
Для reverse proxy:
proxy_set_header X-Request-ID $req_id;
add_header X-Request-ID $req_id always;
Для FastCGI (PHP-FPM):
fastcgi_param HTTP_X_REQUEST_ID $req_id;
add_header X-Request-ID $req_id always;
Если Nginx стоит за CDN/балансировщиком, часто именно edge генерирует X-Request-ID. Задача Nginx — не «перепридумать» ID, а аккуратно принять и прокинуть его дальше.
В теме миграций и «разрезания» монолита на сервисы сильно помогает дисциплина с request-id. Если планируете переезды без простоев, пригодится чеклист из статьи про миграцию сайта с минимальным downtime.
upstream_time: какие поля реально помогают и как их читать
Когда говорят «upstream_time», обычно имеют в виду $upstream_response_time. Но для диагностики полезно держать три поля:
$upstream_connect_time— время установления соединения до апстрима;$upstream_header_time— время до получения заголовков ответа от апстрима;$upstream_response_time— полное время получения ответа от апстрима (включая тело).
Вместе с $request_time (общее время обработки на стороне Nginx) вы получаете понятную картину:
- если
request_timeзаметно большеupstream_response_time, ищите задержки на стороне Nginx: медленный клиент, буферизация, запись на диск, лимиты, TLS и т.д.; - если растёт
upstream_connect_time, часто это сеть, исчерпание соединений, перегруз апстрима или проблемы маршрутизации; - если большой
upstream_header_time, апстрим долго «думает» перед началом ответа (очереди, блокировки, медленная БД); - если большой
upstream_response_time, апстрим долго отдаёт тело (генерация, стриминг, большие ответы, медленный диск).
Почему upstream_* может быть списком значений
Nginx может сходить к нескольким апстримам в рамках одного запроса: ретраи при ошибках, несколько upstream-серверов, failover. Тогда переменные вроде $upstream_response_time содержат значения через запятую (например, 0.120, 0.004). Это не помеха, а полезная диагностика: видно, что первый апстрим «подвис», а второй ответил быстро.
Как ловить и объяснять 5xx по JSON-логам
5xx — это не всегда «приложение упало». В связке Nginx + апстрим есть минимум два класса ситуаций:
- апстрим вернул 5xx, и Nginx это проксировал (смотрите
upstream_status); - Nginx сам сгенерировал ошибку (таймауты, connect error, некорректный ответ апстрима) — при этом upstream-поля могут быть пустыми.
Чтобы быстро отличать одно от другого, логируйте одновременно status (клиентский) и upstream_status (ответ апстрима). Тогда типовые сценарии читаются проще:
status=502иupstream_statusпустой — Nginx не получил ответа от апстрима (часто connect error/timeout);status=504иupstream_response_timeблизко к таймауту — типичный upstream timeout;status=499— клиент закрыл соединение; это не «ошибка сервера», но полезный сигнал для UX и нагрузки.
Минимальный набор полей для расследования инцидентов
Если не хотите логировать «всё подряд», но хотите разбирать 5xx быстро, оставьте хотя бы:
ts,host,uri,args(илиrequest_uri),request_method;status,request_time;upstream_addr,upstream_status,upstream_response_time,upstream_connect_time,upstream_header_time;request_id(через$req_id);remote_addrи/илиxff.
Готовый пример конфига: JSON + request_id + upstream timings
Ниже «скелет», который обычно достаточно положить в http, а дальше подключать в нужные server. По желанию разнесите map и log_format в отдельный include-файл, чтобы переиспользовать между проектами.
http {
map $http_x_request_id $req_id {
default $http_x_request_id;
"" $request_id;
}
log_format json_main escape=json
'{'
'"ts":"$time_iso8601",'
'"request_id":"$req_id",'
'"remote_addr":"$remote_addr",'
'"xff":"$http_x_forwarded_for",'
'"host":"$host",'
'"request":"$request",'
'"uri":"$uri",'
'"args":"$args",'
'"status":$status,'
'"request_time":$request_time,'
'"bytes_sent":$bytes_sent,'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_response_time":"$upstream_response_time",'
'"upstream_connect_time":"$upstream_connect_time",'
'"upstream_header_time":"$upstream_header_time",'
'"http_user_agent":"$http_user_agent"'
'}';
access_log /var/log/nginx/access.json json_main;
server {
listen 80;
add_header X-Request-ID $req_id always;
location / {
proxy_set_header X-Request-ID $req_id;
proxy_set_header Host $host;
proxy_pass http://app_upstream;
}
}
}
Проверка: валидность JSON и быстрый поиск проблемных запросов
После изменений сделайте две проверки: синтаксис Nginx и «что реально пишется в лог». Начинаем с конфигурации:
nginx -t
Если на сервере есть jq, он моментально покажет, валидный ли JSON (и где именно ломается):
tail -n 50 /var/log/nginx/access.json | jq -c . > /dev/null
Быстрый поиск 5xx (без внешней системы логирования):
grep '"status":5' /var/log/nginx/access.json | tail -n 50
Найти конкретный запрос по ID (особенно полезно, когда ID прислал пользователь или саппорт):
grep '"request_id":"YOUR_ID"' /var/log/nginx/access.json
Если хотите быстро отобрать именно «пограничные» случаи проксирования, смотрите связку status и upstream_status в найденных строках.

Типовые грабли и как их обходить
1) Невалидный JSON из-за отсутствия escape=json
Это главный «скрытый» баг. Даже если сейчас всё выглядит нормально, одна кавычка в http_user_agent или неожиданный символ в http_referer сломают формат. Лечится включением escape=json и отказом от ручного экранирования.
2) request_id не совпадает между сервисами
Если приложение само генерирует ID, а Nginx — свой, получится два независимых мира. Выберите «источник истины» (входящий X-Request-ID на edge или генерация на Nginx) и строго прокидывайте дальше. Переменная $req_id через map — самый простой способ.
3) upstream_* пустые, и это нормально
Если запрос обслужен локально (статика) или Nginx отдал ошибку до проксирования, upstream-поля будут пустыми. Не пытайтесь насильно превращать их в числа на стороне Nginx: это только усложняет формат и ухудшает читаемость.
4) Несколько значений upstream_time ломают агрегации
Если вы строите метрики по upstream_response_time как по числу, нужно заранее решить, что считать: первое значение, последнее, максимум или сумму. На уровне Nginx это неудобно. Правильнее разбирать поле на стороне системы логирования (где есть нормальный парсинг и функции агрегации).
Что добавить дальше (когда базовый формат прижился)
Когда схема начнёт работать «на автомате», обычно хочется расширить JSON-лог ещё парой полей:
- маркер сервиса/vhost (например, отдельный ключ
serviceчерезmapпо$host); $limit_req_statusи$limit_conn_status, если используете лимиты;$gzip_ratio, если важно понимать компрессию;- отдельные поля для
$request_uriи$uri, чтобы различать «как пришло» и «как нормализовалось»; - маскирование чувствительных параметров в
argsна этапе логирования или обработки (токены, e-mail, телефоны).
Если параллельно закручиваете безопасность домена (HSTS, редиректы, миграции сертификатов), держите под рукой разбор по теме: редиректы 301, HSTS и SSL при переезде домена.
База, которая даёт максимум пользы при минимуме сложности: надёжный JSON с escape=json, единый request_id и upstream timings. Дальше всё упирается не в «как логировать», а в то, какие вопросы вы хотите задавать логам и как быстро получать ответы.


