Представь типичный продакшен. У тебя десяток сервисов: auth, billing, catalog, search, admin-ui. Каждый слушает свой порт, у каждого свои таймауты, свои заголовки, свой формат логов. Клиент стучится напрямую, CORS течёт, rate-limit размазан по приложениям, а когда в 3 часа ночи падает один запрос - ты не можешь проследить его путь от мобильного клиента до базы, потому что у каждого хопа свой непонятный id. Знакомо?
Решение - единая точка входа. nginx gateway (он же reverse proxy, он же edge) забирает на себя TLS, маршрутизацию, кэш, лимиты, безопасность и трассировку. Приложения за ним становятся тупыми и счастливыми: они принимают чистый HTTP, доверяют заголовкам от шлюза и не думают про внешний мир. Это и есть классический паттерн nginx микросервисы: шлюз снаружи держит политику, сервисы внутри держат бизнес-логику.
Беда в том, что наивный шлюз быстро превращается в один файл на 2000 строк, который никто не решается трогать. В этом уроке мы разбираем готовый эталон - проект ngx-trace-gateway. Это рабочий nginx production конфиг на OpenResty, собранный по уму: модульный, с насквозь прошитой трассировкой, JSON-логами на 50+ полей, метриками, семью зонами rate-limit и слоем безопасности. Цель урока - не просто полюбоваться, а понять механику каждого решения настолько, чтобы развернуть такой шлюз под свои бэкенды и осознанно его править.

Модульная структура: nginx модульный конфиг вместо монолита
Первое, что отличает эталон от поделки - это структура. Точка входа
Код: Выделить всё
nginx.confКод: Выделить всё
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
pcre_jit on;
include /etc/nginx/conf.d/events.conf;
include /etc/nginx/conf.d/http.conf;
events.conf тоже минималистичен и осмыслен:
Код: Выделить всё
events {
worker_connections 10240;
multi_accept on;
use epoll;
}
Код: Выделить всё
ulimit -nА вот http.conf - это диспетчер. Он задаёт базу (mime.types, charset, карту WebSocket-апгрейда) и подключает ровно 14 файлов политик из globals/ строго по порядку, а в конце - виртуальные хосты:
Код: Выделить всё
http {
include mime.types;
default_type application/octet-stream;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
charset utf-8;
recursive_error_pages on;
include /etc/nginx/conf.d/globals/global_hop_name.conf;
include /etc/nginx/conf.d/globals/global_tcp.conf;
include /etc/nginx/conf.d/globals/global_proxy.conf;
include /etc/nginx/conf.d/globals/global_fastcgi.conf;
include /etc/nginx/conf.d/globals/global_uwsgi.conf;
include /etc/nginx/conf.d/globals/global_scgi.conf;
include /etc/nginx/conf.d/globals/global_rate_limit.conf;
include /etc/nginx/conf.d/globals/global_trace.conf;
include /etc/nginx/conf.d/globals/global_gzip.conf;
include /etc/nginx/conf.d/globals/global_logging.conf;
include /etc/nginx/conf.d/globals/global_cache.conf;
include /etc/nginx/conf.d/globals/global_ssl.conf;
include /etc/nginx/conf.d/globals/global_security.conf;
include /etc/nginx/conf.d/globals/global_error_pages.conf;
include /etc/nginx/sites-enabled/*.conf;
}
Маленькая, но важная деталь: vhost-ы лежат в sites-available, а http.conf подключает sites-enabled. Включение хоста - это симлинк, выключение - удаление симлинка. Конфиг хоста не трогаешь, состояние храним отдельно. Это nginx эталон обращения с виртуальными хостами, унаследованный из Debian-практики.
Сквозная трассировка: главная фишка nginx reverse proxy конфига
Это сердце проекта и причина, по которой он называется trace-gateway. Задача: каждый запрос должен иметь единый сквозной id, который живёт через всю цепочку сервисов, плюс цепочку хопов, по которой видно, через какие узлы он прошёл. И всё это - без единой строчки Lua, чистыми map.
Начнём с имени узла. global_hop_name превращает технический hostname в логическую роль:
Код: Выделить всё
map $hostname $hop_physical {
default $hostname;
}
map $hostname $hop_name {
api-01.prod.local api-nginx-01;
edge-01.prod.local edge-01;
internal-01.prod internal-nginx-01;
default $hop_physical;
}
Теперь сама трассировка - global_trace. Это цепочка из map, где выход одного становится входом другого:
Код: Выделить всё
map $http_x_request_id $trace_request_id {
"" $request_id;
default $http_x_request_id;
}
map $request_id $local_request_id {
default $request_id;
}
map "$hop_name:$local_request_id" $current_hop {
default "$hop_name:$local_request_id";
}
map $http_x_request_chain $incoming_chain {
"" "";
default $http_x_request_chain;
}
map $incoming_chain $request_hops_chain {
"" $current_hop;
default "$incoming_chain, $current_hop";
}
Куда это всё уезжает? В global_proxy все три идентификатора одновременно и отдаются клиенту через add_header, и пробрасываются в бэкенд через proxy_set_header:
Код: Выделить всё
add_header X-Request-ID $trace_request_id always;
proxy_set_header X-Request-ID $trace_request_id;
add_header X-Request-Chain $request_hops_chain always;
proxy_set_header X-Request-Chain $request_hops_chain;
proxy_set_header X-Prev-Request-ID $local_request_id;
add_header X-HOP-Name $hop_name always;
proxy_set_header X-HOP-Name $hop_name;
Структурированные JSON-логи и метрики
Текстовый access.log в микросервисах бесполезен - его не распарсить нормально. Эталон логирует в JSON через escape=json, и это меняет всё: лог сразу льётся в ELK/Loki/ClickHouse как структурированное событие. Severity не выдумывается, а выводится из статуса картой:
Код: Выделить всё
map $status $log_level {
~^2 "info";
~^3 "info";
~^4 "warn";
~^5 "error";
default "info";
}
log_format structured_log escape=json
'{'
'"timestamp":"$time_iso8601",'
'"timestamp_msec":"$msec",'
'"severity":"$log_level",'
'"trace":{'
'"hop_name":"$hop_name",'
'"trace_request_id":"$trace_request_id",'
'"hops_chain":"$request_hops_chain"'
'},'
'"http":{"method":"$request_method","uri":"$uri","status":"$status"},'
'"timings":{'
'"request_time":"$request_time",'
'"upstream_response_time":"$upstream_response_time"'
'}'
'}';
Метрики живут отдельно. global_metrics поднимает служебный сервер на внутреннем порту 8080, отдаёт stub_status и закрыт ACL-ом:
Код: Выделить всё
server {
listen 0.0.0.0:8080;
access_log off;
location = /nginx_status {
stub_status;
allow 127.0.0.1;
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
deny all;
}
}
Переиспользуемые политики: proxy, cache, security, 7 зон rate-limit
Теперь то, ради чего вся модульность - набор политик, которые пишутся один раз и работают для всех хостов.
global_proxy - дефолты проксирования: таймауты (connect 5s, read/send 10s), буферизация (proxy_buffers 16 16k, busy 32k), proxy_next_upstream error timeout с tries 3 - то есть на сетевой ошибке или таймауте шлюз перепробует до трёх апстримов. Тут же proxy_http_version 1.1. Маленькая ремарка по 2026: начиная с nginx 1.29.7 (вошло в stable 1.30) соединение к бэкенду по умолчанию уже HTTP/1.1 с keepalive, раньше был HTTP/1.0 с close. Но явная директива 1.1 в эталоне - правильная страховка совместимости со старыми версиями и обязательна для проброса Upgrade (WebSocket).
global_cache + кэш-политика в proxy - вот где эталон показывает зрелость. Зона объявляется отдельно:
Код: Выделить всё
proxy_cache_path /var/cache/nginx/gate levels=1:2 keys_zone=appcache:128m
inactive=60m max_size=10g use_temp_path=off;
Код: Выделить всё
proxy_cache_key "$scheme$request_method$host$request_uri$http_authorization";
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_background_update on;
proxy_cache_revalidate on;
proxy_cache_valid 200 1h;
add_header X-Proxy-Cache $upstream_cache_status always;
global_rate_limit - семь зон. Это не перебор, это разделение по характеру трафика:
Код: Выделить всё
limit_req_zone $binary_remote_addr zone=app_login:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=security_sensitive:5m rate=1r/s;
limit_req_zone $binary_remote_addr zone=public_api_ip:20m rate=60r/m;
limit_req_zone $api_consumer_id zone=public_api_user:20m rate=120r/m;
limit_req_zone $rl_api_key zone=public_api_key:20m rate=10r/s;
limit_req_zone $binary_remote_addr zone=admin_area:5m rate=10r/s;
limit_req_zone $binary_remote_addr zone=internal_clients:10m rate=200r/s;
Код: Выделить всё
limit_req zone=app_login burst=5 nodelay;global_security + app_redirects_security - два слоя защиты. Глобальный шлёт безопасные заголовки на все ответы:
Код: Выделить всё
server_tokens off;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
Код: Выделить всё
add_header X-XSS-Protection "1; mode=block";Второй слой - app_redirects_security, подключается внутри vhost и делает канонизацию плюс грубую фильтрацию. Канонизация убирает дубли для SEO и чистит логи:
Код: Выделить всё
if ($request_uri ~ "^(.*/)(index\.php|index\.html)$") { return 301 $scheme://$host$1; }
if ($request_uri ~ "^[^?]*\?$") { return 301 $scheme://$host$uri; }
if ($request_uri ~ "//+") { return 301 $scheme://$host$uri; }
if ($needs_slash = 1) { return 301 $scheme://$host$uri/; }
upstream и vhost-ы замыкают картину. Группа апстримов с весами, пассивным контролем здоровья и keepalive:
Код: Выделить всё
upstream backend_upstream {
server 10.0.0.1:9000 weight=5 max_fails=3 fail_timeout=30s;
server 10.0.0.2:9000 max_fails=3 fail_timeout=30s;
server 10.0.0.4:9000 backup;
keepalive 64;
}
Типичные грабли при работе с таким шлюзом
- Порядок include. Переставил global_logging выше global_trace - и в логи поедут пустые $trace_request_id, потому что map ещё не объявлен в момент использования. Зависимости map нужно держать сверху: hop_name, потом trace, потом всё остальное.
- add_header не наследуется при переопределении. Если в location ты добавишь хоть один свой add_header, все глобальные add_header в этом location пропадут - таково правило наследования директивы. Лечится модулем headers-more (more_set_headers) либо дублированием. Это самая частая причина "куда делись security-заголовки на /api".
- if внутри location - зло. Знаменитое "if is evil": в app_redirects_security if используется на уровне server для return 301/403 - это допустимый и безопасный кейс. Но if с try_files или proxy_pass внутри location ведёт себя непредсказуемо. Правило: if только для return/rewrite на уровне server.
- $remote_addr без realip - это адрес ближайшего прокси, а не клиента. Если перед шлюзом стоит CDN или балансировщик, то и rate-limit по $binary_remote_addr, и логи, и geo посчитают IP прокси. Нужен модуль realip с set_real_ip_from, доверяющий ТОЛЬКО известным сетям CDN - иначе клиент подделает X-Forwarded-For и обойдёт лимиты.
- proxy_buffering и стриминг. Буферизация включена глобально и это правильно для обычного API, но для SSE/стримов/долгих ответов её надо выключать в конкретном location (proxy_buffering off), иначе клиент будет ждать, пока nginx добуферит весь ответ.
- Держи nginx и модули обновлёнными. В 2026 актуальны CVE в rewrite-модуле и HTTP/3 - чем меньше у тебя самописных rewrite/if-правил и чем свежее версия, тем спокойнее. Минимализм в правилах канонизации - это ещё и безопасность.
Повтори руками по шагам.
- Шаг 1. Подними эталон в docker-compose на образе openresty/openresty. Healthcheck должен гонять - это проверка синтаксиса до старта.
Код: Выделить всё
nginx -t - Шаг 2. Проверь конфиг руками: . Должно быть "syntax is ok" и "test is successful". Сломай нарочно - убери одну закрывающую скобку в http.conf и повтори, посмотри как nginx показывает файл и строку.
Код: Выделить всё
docker exec -it openresty openresty -t - Шаг 3. Заведи свой апстрим. В upstream.conf добавь и в http_server-секции в location ^~ /myapi/ поставь
Код: Выделить всё
upstream my_api { server 10.0.0.50:8000 max_fails=3 fail_timeout=30s; keepalive 32; }. Перечитай конфиг без даунтайма:Код: Выделить всё
proxy_pass http://my_api;.Код: Выделить всё
docker exec openresty openresty -s reload - Шаг 4. Проверь трассировку. - в ответе найди X-Request-ID, X-Request-Chain и X-HOP-Name. Теперь повтори с заголовком:
Код: Выделить всё
curl -i http://localhost/myapi/ping- в ответе X-Request-ID должен стать my-trace-123, а не сгенерированный. Это значит сквозной id работает.Код: Выделить всё
curl -i -H "X-Request-ID: my-trace-123" http://localhost/myapi/ping - Шаг 5. Проверь лимиты. Повесь на свой location , reload, и пульни
Код: Выделить всё
limit_req zone=public_api_ip burst=5 nodelay; limit_req_status 429;- часть ответов должна стать 429.Код: Выделить всё
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost/myapi/ping; done - Шаг 6. Проверь метрики и логи. - увидишь счётчики stub_status. Потом
Код: Выделить всё
docker exec openresty curl -s http://127.0.0.1:8080/nginx_status- убедись, что это валидный JSON с полями trace и timings.Код: Выделить всё
docker exec openresty tail -n1 /var/log/nginx/access.log
- Почему global_hop_name и global_trace обязаны подключаться в http.conf раньше, чем global_proxy и global_logging? Что попадёт в логи, если перепутать порядок?
- Чем по смыслу отличаются три идентификатора - $trace_request_id, $local_request_id и $prev_request_id? Какой из них рождается на первом хопе, а какой меняется на каждом узле?
- Что делают вместе proxy_cache_lock, proxy_cache_background_update и proxy_cache_revalidate и какую конкретную проблему решает каждая под нагрузкой?
- Почему в эталоне семь отдельных зон rate-limit, а не одна общая? Приведи пример, когда $binary_remote_addr - плохой ключ, и чем его заменить.
ngx-trace-gateway - это не магия, а дисциплина. Пустой nginx.conf, события, http-диспетчер, 14 файлов политик в правильном порядке, vhost-ы в конце. Сквозная трассировка собрана из обычных map без Lua и проброшена через add_header/proxy_set_header. Логи - JSON с severity из статуса и разделением request_time против upstream_response_time. Метрики - изолированный stub_status за ACL. Политики proxy/cache/security/rate-limit написаны один раз и работают для всех. Возьми этот скелет, замени апстримы и домены на свои, выкинь устаревший X-XSS-Protection, добавь realip под свой CDN - и у тебя готовый, прозрачный и поддерживаемый nginx gateway, который не страшно открывать через год.