
Привет, Хабр! На связи Аскар Добряков, ведущий эксперт направления защиты данных и приложений в К2 Кибербезопасность. Мы занимаемся всеми направлениями кибербезопасности, в частности защитой веб-приложений. За годы работы я насмотрелся на всякое: конфиг валиден, reload прошел, мониторинг зеленый, а на контрольном прогоне прилетает 502 на проде, auth_request сыплет пятисотками при живом бэкенде, или браузер закэшировал ошибку и упорно показывает 403 спустя часы после фикса. Причём не всегда это можно быстро исправить на уровне приложения.
Когда все приложения вокруг ведут себя не так и хотят странного, приходится придумывать нестандартные решения. К счастью, Nginx может в этом помочь.
Я собрал три таких кейса из своей практики, разобрал симптомы и диагнозы, показал рабочие конфиги. А чтобы читалось легче, завернул все в сквозную метафору. Итак, давайте смешаем коктейль.
Cache-Control, или Правильная маркировка стаканчиков
Для наглядности введу действующих лиц. Бармен — это Nginx (или любой WAF на этом движке). В роли охранника будет auth-сервис. Кассиром назову upstream-бэкенд, а клиентом пускай будет curl, браузер, кто угодно на том конце.
Вот допустим, клиент сделал запрос и получил 200 OК. Он сделал заказ, забрал у бармена бокал с коктейлем, перелил его в свою фляжку, которую засунул в карман браузера и вернется за таким же в следующую пятницу. Примерно так и работает кэширование. Но если коктейль приготовлен с ошибкой (403, 500, не важно какой), бармен обязан наклеить на бокал стикер «НЕ ТРОГАТЬ». Если этого не сделать, клиент будет страдать от похмелья, пока не придет за новой порцией.
В нашем кейсе мы решили динамически подменять заголовок Cache-Control на no-cache, если код ответа клиенту не равен 200, а при 200 передавать оригинальное значение от апстрима.
Стенд
Рассмотрим реализацию этого механизма на примере простого тестового стенда.
Наше целевое приложение работает на порту 81: отвечает 200 на все запросы, кроме /403, где возвращает 403. К каждому ответу добавляем заголовок Cache-Control: "Privet" — именно его мы будем подменять по условию (тут по идее должно быть корректное значение, но на стенде мы сделали просто заметный маркер, чтобы отработать манипуляции с заголовками). Перед приложением стоит реверс-прокси на порту 80, который проксирует трафик и подменяет заголовок.
Решение через map
Для этого кейса if не сработает, потому что Nginx обрабатывает директивы не в том порядке, в каком вы их написали, а в порядке фаз обработки запроса. Так что if внутри location, не условный оператор в привычном смысле, а отдельный контекст с известными ограничениями и ловушками, которые ломали конфиги уже не одному поколению инженеров.
Директива map объявляется на уровне http-блока и позволяет безопасно вычислять значение переменной на основе другой переменной без проблем, характерных для if в location. Так что создадим map с переменной $cc. Если $status равен 200, берем оригинальный заголовок от апстрима из $upstream_http_cache_control. В остальных случаях по дефолту принудительно ставим no-cache. Заголовки от апстрима доступны с префиксом $upstream_http_ и именем в нижнем регистре.
Полный конфиг стенда:

В такой реализации proxy_hide_header Cache-Control удаляет оригинальный заголовок от апстрима, иначе клиент получит дубликат, а add_header Cache-Control $cc always добавляет заголовок с вычисленным значением. Флаг always обязателен: без него add_header не сработает для ответов с кодами 4xx и 5xx.
$status содержит итоговый статус, который уходит клиенту. Он может отличаться от статуса апстрима, например, если по пути сработал error_page и подменил код. Мы работаем именно с финальным статусом: тем, что увидит клиент на стикере.
Проверяем

При 200 клиент получает исходное значение Cache-Control. Прокси скрывает оригинальный заголовок и добавил новый с тем же значением, так что семантика кэширования сохранена.

При 403 выдаем принудительный no-cache. Бармен клеит стикеры: успешные ответы кэшируем по правилам, а ошибки запрещаем к сохранению.
auth_request, или Нормализация контроля
Следующий проблемный кейс иного плана: не про заголовки, а про авторизацию.
Представим, что в коктейльном баре проходит закрытая вечеринка по приглашениям, и бармен просит охрану проверить документы клиента перед выдачей заказа. Но вместо понятного «Все окей» или «Не пущать» охранник бормочет что-то нечленораздельное. Бармен не знает, как это интерпретировать, и клиент получает 500 Internal Server Error вместо коктейля.
Контракт auth_request
На практике таким охранником может быть шлюз предаутентификации или антибот-система. Модуль auth_request делает подзапрос к другому серверу перед обработкой основного запроса (в нашем случае это была антибот-проверка на отдельном сервисе). У модуля жесткий контракт: 2xx — пропускаем, 401 или 403 — отказ. Все остальное модуль интерпретирует как ошибку интеграции и роняет ответ в 500.
Но что если auth-бэкенд возвращает 418 «I'm a Teapot»? Исправить ситуацию нужно сейчас, а менять поведение сервиса нельзя, например, из-за legacy-ограничений или зависимости от сторонних сервисов, API. Или, если нам нужно выполнять разные действия в зависимости от того, какой именно 4хх код пришел.
Стенд
Чтобы воспроизвести эту ситуацию, развернем три компонента на localhost:
На порту 80 — Nginx с auth_request, проксирует на бэкенд после проверки.
На порту 90 — auth-сервис с захардкоженным кодом ответа (его будем менять вручную для имитации разных сценариев).
На порту 100 — бэкенд. Он отдает 200 или 403 в зависимости от URL.
Дополнительно перехватыватим 403 основного ответа через кастомный error_page, чтобы можно было отличить отказ бэкенда от отказа авторизации.

Теперь посмотрим на поведение системы при разных ответах auth-сервиса.
Воспроизводим проблему
Меняем return в auth-сервисе (порт 90) и смотрим, что получает клиент:
Auth-сервис отдает | Результат | |
return 200 | Backend OK 200 | Все работает корректно, бэкенд ответил |
return 403 | Auth error, location @error403 | Все работает корректно, но нам сюда нельзя |
return 418 | 500 Internal Server Error | Сбой |
При 418 в error.log наблюдается такая картина:

Причина всех проблем auth request unexpected status: 418. Модуль получил код вне контракта и вернул 500.
Адаптация контракта на слое прокси
Мы не будем исправлять поведение стороннего сервиса, а вместо этого адаптируем его нестандартный ответ к контракту, который понимает auth_request. Для этого вынесем обработку во внутренний location.

Без proxy_intercept_errors on error_page не перехватит ответ от проксируемого сервиса, потому что Nginx по умолчанию передает ошибки апстрима как есть.
Знак = в error_page 418 = @auth_error позволяет заменить код ответа на тот, который вернет целевой location, а internal гарантирует, что @auth_error будет недоступен снаружи, и с ним будут работать только внутренние перенаправления.
Проверяем:

Auth-сервис по-прежнему отдает 418, но Nginx перехватывает его, нормализует до 403. В результате auth_request корректно интерпретирует это как отказ в доступе.
Fail-close vs fail-open
В @auth_error мы возвращаем return 403. То есть при нестандартном ответе auth-сервиса по умолчанию доступ закрыт. В качестве альтернативы можно отдавать return 200, то есть fail-open: при ошибке авторизации пропускаем всех.

Теперь запросы проходят до бэкенда, как будто auth-сервис дал добро. Нестандартные ответы auth-сервиса ломают auth_request, но их можно адаптировать к нужному контракту используя: proxy_intercept_errors и error_page.
Fail-open тоже иногда допустим для критичных сервисов, для которых доступность важнее безопасности; но это решение должно быть осознанным и задокументированным. По умолчанию в подавляющем большинстве случаев стоит использовать return 403.
Ошибка 502, или Слишком длинный счет
Бармен смешал коктейль, готов выдать заказ, но кассир почему-то распечатал к нему счет длиной в метр: куки, сертификаты, служебные заголовки. Бармен пытается разобраться, выписывает важное в блокнот, но место заканчивается, и процесс чтения обрывается. В результате заказ оказывается отменен.
История из прода
В access.log появилась строчка:

Выписка из log_format:

Два значения рядом: $upstream_header_time равен -, а $upstream_response_time равен 7.994. Ответ от апстрима пришел (почти 8 секунд), но Nginx не смог прочитать заголовки, и в error.log мы увидели:

Заголовок ответа не влез в буфер, соединение было прервано, так что клиент получил 502 Bad Gateway.
Прочерк в $upstream_header_time сам по себе не доказывает проблему больших заголовков. Причиной могут быть таймауты, разрыв соединения, проблемы на уровне сети. Но если вы пролистываете access.log и видите что-то подобное — это повод насторожиться и сразу броситься изучать error.log.
Почему заголовки растут
Типичные виновники этой проблемы: тяжелые куки (особенно JWT-токены, хранящиеся прямо в cookie), множество Set-Cookie от бэкенда, кастомные заголовки авторизации и трейсинга. Кроме того, иногда бэкенд стоит за другим прокси, который добавляет свои заголовки. Как бы там ни было, они накапливаются на каждом слое и в какой-то момент перестают помещаться в буфер.
Фикс
Если error.log подтверждает too big header, то одним из первых шагов стоит увеличить буферы.

proxy_buffer_size задает размер буфера для первой части ответа — статусной строки и заголовков. По умолчанию это 4k или 8k в зависимости от платформы. И если заголовки превышают это ограничение, мы получаем too big header.
proxy_buffers определяет количество и размер буферов для тела ответа (здесь четыре по 512k). proxy_busy_buffers_size ограничивает объем данных, которые Nginx отправляет клиенту, пока остальные буферы еще заполняются.
После nginx -s reload ошибка 502 из access.log исчезает. На месте прочерка в $upstream_header_time оказывается реальное значение, а error.log остается пустым.
Не стоит забывать, что увеличение буферов стоит памяти. Каждое соединение теперь потребляет больше RAM, и при тысячах одновременных соединений разница между 4k и 512k легко превращается в гигабайты. Поэтому применение увеличенных буферов оправданно только для проблемного location, а значения стоит подбирать по реальному профилю нагрузки, а не на глазок.
Вместо послесловия
Ни в одной из этих трех ситуаций Nginx не был сломан, просто работал строго по инструкции, а инженер этого не ожидал.
С Cache-Control мы пытались использовать if там, где переменные еще не существуют, потому что не думали о фазах обработки запроса. С auth_request отправляли модулю код ответа, которого нет в его контракте, и удивлялись пятисоткам. С буферами просто не заглядывали в дефолтные значения, пока заголовки не перестали влезать.
Если свести три кейса к одному чек-листу, то при любой непонятной ошибке в Nginx стоит задать себе три вопроса:
На какой фазе выполняется моя директива и доступны ли в этот момент нужные переменные?
Какой контракт ожидает принимающий модуль и не отправляю ли я ему то, что он не умеет интерпретировать?
Хватает ли ресурсов на то, что я пытаюсь обработать?
Каждый раз решение укладывается в пару строк конфига, но чтобы написать эти строки, нужно сперва понять, почему Nginx ведет себя именно так, а не иначе. Документация у него хорошая, но между «прочитать man-страницу» и «понять, как модули разговаривают друг с другом» лежит целая пропасть, которую заполняет только практика.
Собственно, для этого и нужна эта статья — сохранить и передать этот опыт. Когда я разбирал эти кейсы у себя на проде, ответы приходилось собирать из обрывков на Stack Overflow, тредов в nginx-devel и экспериментов на стенде. Хочется, чтобы у вас этот путь был короче.
Если у вас есть свой кейс, который решался неочевидным способом, делитесь в комментариях.
