Почему nginx не кэширует ответ 200 даже с proxy_cache_valid — и чем заканчивается «просто игнорировать Set-Cookie»
Знакомая картина: в конфиг добавлен proxy_cache_path, прописан proxy_cache_valid 200 10m, а в логе по-прежнему сплошной MISS и бэкенд лежит под той же нагрузкой. Дальше обычно происходит одно и то же — человек находит в интернете строчку proxy_ignore_headers Cache-Control Set-Cookie, вставляет её, кэш «начинает работать», и через сутки пользователи видят чужие личные кабинеты. Ниже — почему nginx отказывается кэшировать, что именно ему мешает, как я разделяю кэшируемое и персональное на боевых стендах и как это проверить до того, как проверит клиент.
proxy_cache_valid — это не приказ, а значение по умолчанию
Первое, что надо принять: в nginx решение «кэшировать или нет» принимает не ваш конфиг, а ответ бэкенда. В русской документации ngx_http_proxy_module это написано прямым текстом в описании proxy_cache_valid: «Параметры кэширования могут также быть заданы непосредственно в заголовке ответа. Такой способ приоритетнее, чем задание времени кэширования с помощью директивы». Вы написали 10 минут — приложение прислало Cache-Control: no-store — победило приложение.
Из-за этого весь популярный способ отладки «а давайте увеличим срок кэша» бессмысленен по построению. Если ответ в принципе не признан кэшируемым, то что 10 минут, что 10 часов — разницы ноль, вы всё равно получите MISS на каждый запрос. Я видел конфиг, где человек последовательно поднял proxy_cache_valid с 5m до 12h, потом добавил proxy_cache_valid any 12h, потом ещё proxy_cache_min_uses 1 — и всё это на приложении, которое на каждый ответ ставит сессионную куку. Ни один из шагов не мог помочь.
Правильный порядок отладки обратный: сначала смотрим, что реально отдаёт бэкенд, и только потом трогаем директивы nginx. Одна команда решает 80 % таких вопросов — берём ответ напрямую с апстрима, минуя nginx, и читаем заголовки.
Ещё один момент, который экономит часы: nginx принимает решение один раз, в момент получения ответа от апстрима. Он не «пересматривает» кэшируемость задним числом и не логирует причину отказа — в error_log вы ничего про это не увидите даже на уровне warn. Единственный внятный сигнал — значение $upstream_cache_status, и то оно скажет MISS, но не объяснит, почему. Поэтому диагностика всегда начинается с сырых заголовков апстрима, а не с чтения логов nginx.
- curl -sSD- -o /dev/null http://127.0.0.1:8080/services/ — смотрим Set-Cookie, Cache-Control, Expires, X-Accel-Expires, Vary;
- если в ответе есть Set-Cookie — nginx не закэширует его при любом proxy_cache_valid, пока обработка этого поля не отключена через proxy_ignore_headers (о цене такого отключения — ниже);
- если есть Cache-Control: private / no-cache / no-store — тоже не закэширует;
- если есть Vary: * — тоже не закэширует;
- если есть X-Accel-Expires: 0 — кэширование этого ответа выключено принудительно самим приложением.
Полный список того, что останавливает nginx
Правила разбросаны по документации, поэтому соберу их в одном месте. Часть из них прямо перечислена на nginx.org/ru в описании proxy_cache_valid (Set-Cookie, Vary: *, X-Accel-Expires: 0), разбор директив Cache-Control описан в NGINX Admin Guide и блоге NGINX. nginx считает ответ некэшируемым, если: в ответе есть поле Set-Cookie; в Cache-Control встретились private, no-cache или no-store; есть Vary со специальным значением «*»; X-Accel-Expires равен нулю; истёкшее значение Expires в прошлом; метод запроса не входит в proxy_cache_methods (по умолчанию там только GET и HEAD, причём GET и HEAD кэшируются всегда); срок жизни не задан заголовками ответа, а код ответа не перечислен в proxy_cache_valid; сработало условие proxy_no_cache. Плюс есть отдельная тонкость: если запрос отправлен методом, который не кэшируется, nginx честно сходит на бэкенд.
Отдельно про Vary. С версии 1.7.7 nginx умеет учитывать Vary: если бэкенд прислал Vary: Accept-Encoding, ответ будет закэширован с учётом соответствующего заголовка запроса. Звучит хорошо, но на практике я регулярно вижу Vary: Cookie от фреймворков — и тогда каждый уникальный набор кук порождает свой вариант в кэше. Формально кэш работает, HIT есть, но hit ratio уезжает в единицы процентов, а keys_zone пухнет. Это не ошибка nginx, это ошибка бэкенда, который на всякий случай варьирует по кукам.
И про no-cache стоит сказать честно, потому что это место, где nginx расходится с RFC 9111. По стандарту no-cache означает «храни, но перед выдачей ревалидируй», а не «не храни». nginx в базовой обработке заголовков трактует no-cache как запрет на кэширование ответа. Формально это упрощение, практически — оно ловит больше проблем, чем создаёт, но знать о расхождении надо: приложение, которое ставит no-cache в надежде на условные запросы, у вас за nginx просто перестанет кэшироваться.
Про X-Accel-Expires добавлю отдельно, потому что это самый недооценённый заголовок в связке. Он задаёт время кэширования в секундах и имеет приоритет над Expires и Cache-Control, а значение с префиксом @ трактуется как абсолютное время в секундах от Epoch. Нулевое значение выключает кэширование конкретного ответа. Для приложения это идеальный способ управлять кэшем фронта, ничего не ломая в браузерных кэшах: X-Accel-Expires nginx съедает сам и наружу не отдаёт, потому что все заголовки X-Accel-* по умолчанию скрываются от клиента. Я почти всегда предлагаю разработчикам именно этот механизм вместо игр с Cache-Control, когда нужно поведение «для nginx одно, для браузера другое».
- Set-Cookie в ответе — стоп;
- Cache-Control: private | no-cache | no-store — стоп;
- Vary: * — стоп; Vary: <любой заголовок> — кэш с учётом этого заголовка запроса;
- X-Accel-Expires: 0 — стоп (а положительное значение задаёт срок в секундах и перебивает Expires и Cache-Control);
- срок не задан заголовками (X-Accel-Expires, Expires, Cache-Control: max-age), а код ответа не описан в proxy_cache_valid — стоп (без списка кодов директива распространяется только на 200, 301 и 302, параметр any — на все коды);
- метод не в proxy_cache_methods (по умолчанию GET HEAD) — стоп.
Разбор: как одна строка конфига выдала чужие личные кабинеты
Стенд — груминг-салон «Пушистый стиль» на 16 рабочих мест: сайт с онлайн-записью и личным кабинетом клиента, где хранятся карточки питомцев, история стрижек, бонусный счёт и телефон владельца. Внутри — PHP-приложение на Laravel за nginx 1.28.3 из репозитория nginx.org, Debian 12, один VPS с php-fpm и MySQL. Раздел услуг и прайс с галереей работ открыт всем, а под кабинетом — персональные данные клиентов. Задача была банальная: после рассылки акции страницы прайса и витрины зоокосметики (около 1 800 позиций) генерились по 700–900 мс, и в первые часы после письма php-fpm вставал в очередь, а администраторы на ресепшене не могли открыть расписание мастеров.
Фрилансер, который делал сайт салона, включил кэш вот таким блоком — привожу как было, это ровно тот сниппет, который гуляет по статьям про nginx caching:
proxy_cache_path /var/cache/nginx/salon levels=1:2 keys_zone=salon:16m
max_size=2g inactive=60m use_temp_path=off;
location / {
proxy_pass http://salon_upstream;
proxy_cache salon;
proxy_ignore_headers Cache-Control Set-Cookie;
proxy_cache_valid any 30m;
}Что произошло дальше. Laravel на каждый ответ ставит две куки — XSRF-TOKEN и сессионную laravel_session. Директива proxy_ignore_headers Set-Cookie отключила обработку этого поля, то есть nginx перестал видеть в нём причину не кэшировать. Но она не удаляет заголовок из ответа: Set-Cookie как лежал в теле кэшируемого ответа, так и лёг в файл кэша вместе с ним. Ключ кэша при этом остался дефолтным — $scheme$proxy_host$request_uri, никакого идентификатора пользователя в нём нет. Итог: первая клиентка, открывшая кабинет, положила в кэш свой ответ вместе со своей сессионной кукой, а следующие 30 минут все остальные получали эту куку из кэша и оказывались в её кабинете — с её телефоном, кличками её собак и записью на субботу. Симптом, с которым нас позвали, звучал как «клиенты звонят и говорят, что видят чужих питомцев».
Разбирали по логам: в access_log добавили $upstream_cache_status, за сутки 1 940 запросов на /cabinet/*, из них 1 810 HIT — то есть кабинет отдавался из кэша практически всегда. Дальше просто: вернули location для кабинета в режим без кэша, вычистили /var/cache/nginx/salon, перезапустили nginx и инвалидировали все сессии на стороне приложения. Готовой artisan-команды «сбросить все сессии» в Laravel нет, поэтому действовали по драйверу сессий: при драйвере file очистили storage/framework/sessions, при database — таблицу sessions; дополнительно поменяли имя сессионной куки (SESSION_COOKIE), чтобы старые куки из кэша стали бесполезны. Всем клиентам пришлось войти заново, но иначе утёкшие куки оставались бы валидными. Потом уже спокойно собрали нормальную схему кэширования, о ней ниже. Прайс и витрина после переделки отдавались за 8–14 мс с hit ratio около 90 %, php-fpm перестал упираться в очередь даже в день рассылки. Отдельно владелице салона пришлось решать вопрос уведомления клиентов: телефоны и имена — это персональные данные.
Отдельно проговорю, почему подрядчик вообще так сделал, — это не глупость, а типовая ловушка. Он честно проверил конфиг: открыл каталог в анонимном браузере, увидел X-Cache-Status: HIT, время ответа упало с 800 мс до 10 мс, задача закрыта. Кабинет он не проверял, потому что «кабинет же за авторизацией». А ловушка в том, что location / накрывает и кабинет тоже, и первый же авторизованный клиент, зашедший на /cabinet/, положил свой ответ в общую запись. Проверка «работает ли кэш» и проверка «не течёт ли кэш» — это два разных теста, и второй почти никогда не делают.
- proxy_ignore_headers Set-Cookie — снимает запрет на кэширование, но не убирает заголовок из ответа;
- дефолтный ключ кэша не содержит ничего пользовательского — все клиенты делят одну запись;
- эффект «кэш заработал» появляется мгновенно, а утечка проявляется только когда в кэш попал ответ авторизованного пользователя;
- в логах это видно как аномально высокий HIT на URL личного кабинета.
Как я это делаю на боевых стендах: разделяй, а не игнорируй
Моя позиция простая: proxy_ignore_headers — не инструмент включения кэша, а инструмент точечного обхода кривого бэкенда, и применять его надо в максимально узком location. Кэш включается не «на весь сайт», а на конкретные, заведомо анонимные маршруты. Всё остальное идёт на бэкенд без кэша, и это нормально — попытка закэшировать вообще всё почти всегда заканчивается либо нулевым hit ratio, либо утечкой.
Рабочая схема, к которой я прихожу практически на каждом проекте, выглядит так. Отдельная зона кэша, отдельный location под анонимный контент, явное перечисление кодов ответа, и обязательный аварийный выключатель по куке авторизации.
proxy_cache_path /var/cache/nginx/anon levels=1:2 keys_zone=anon:16m
max_size=2g inactive=24h use_temp_path=off;
map $http_cookie $is_logged_in {
default 0;
"~*laravel_session" 1;
"~*PHPSESSID" 1;
"~*BITRIX_SM_LOGIN" 1;
}
location ^~ /services/ {
proxy_pass http://salon_upstream;
proxy_cache anon;
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_valid 200 301 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_min_uses 2;
proxy_cache_bypass $is_logged_in $http_authorization;
proxy_no_cache $is_logged_in $http_authorization;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_revalidate on;
proxy_cache_background_update on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
add_header X-Cache-Status $upstream_cache_status always;
}Разберу неочевидное. proxy_cache_key я почти всегда переопределяю: дефолтный $scheme$proxy_host$request_uri содержит имя апстрима, а не хост запроса, и на мультидоменной конфигурации два разных сайта, ходящих в один upstream, схлопнутся в одну запись кэша. Это ещё один способ выдать чужой контент, только уже между доменами. proxy_cache_min_uses 2 отсекает мусорный длинный хвост URL, который спрашивают ровно один раз, — он экономит и место, и записи в keys_zone. Связка proxy_cache_use_stale updating + proxy_cache_background_update + proxy_cache_lock — то, что реально держит бэкенд живым в момент истечения популярной записи: без них истечение горячего URL превращается в залп одновременных запросов на апстрим.
Про размер зоны. keys_zone задаётся в мегабайтах, и, по документации, зоны размером в 1 мегабайт достаточно для хранения около 8 тысяч ключей (в коммерческой версии — около 4 тысяч, там в зоне хранится расширенная информация). На сайте салона уникальных URL прайса и витрины было около 4 тысяч с учётом фильтров и пагинации — формально хватило бы и мегабайта, взяли 16 МБ с большим запасом, память это не экономит заметно. Если keys_zone всё же кончится, nginx начнёт принудительно вытеснять наименее востребованные записи независимо от того, сколько места осталось на диске, а в error_log появятся сообщения уровня alert вида «could not allocate node in cache keys zone». Hit ratio при этом просядет, и если error_log никто не читает, это пройдёт незамеченным. Это, кстати, ещё одна причина не сваливать всё в одну зону: переполнение зоны статикой выбьет из кэша прайс.
- одна зона кэша = одна логическая группа контента, не мешайте статику, каталог и API в одном keys_zone;
- keys_zone: 1 МБ ≈ 8000 ключей, считайте по реальному числу URL, а не наугад;
- use_temp_path=off — временные файлы пишутся прямо в каталог кэша, без копирования между файловыми системами;
- inactive задаёт время без обращений, после которого запись удаляется независимо от свежести (по умолчанию 10 минут) — это не то же самое, что proxy_cache_valid, их постоянно путают;
- add_header X-Cache-Status с флагом always — чтобы статус кэша был виден и на ошибочных ответах.
proxy_no_cache и proxy_cache_bypass: две разные кнопки
Их путают чаще всего, хотя разница ровно в одном слове. proxy_cache_bypass запрещает брать ответ из кэша — запрос уходит на бэкенд, а результат при этом всё равно может быть сохранён. proxy_no_cache запрещает сохранять ответ в кэш — но если подходящая запись уже есть, она будет отдана. Поэтому для «этот пользователь не должен ни получать чужое, ни класть своё» нужны обе директивы с одинаковым условием. Одна без другой — половина решения, причём в обе стороны дырявая.
Условие я почти всегда строю через map, а не через голую переменную. Голый $cookie_PHPSESSID даёт срабатывание на любой сессионной куке, включая ту, что фреймворк выдаёт анонимному гостю, — и кэш выключается для всех, hit ratio падает в ноль, а вы неделю не понимаете, почему. map по regexp на куку именно авторизации (или на отдельную куку-флаг, которую приложение ставит только после логина) даёт предсказуемое поведение. Если приложение такой куки не ставит — попросите разработчиков её добавить, это пять строк и самый дешёвый способ сделать кэш безопасным.
Второй частый случай — принудительный обход кэша для отладки. Классический рецепт из документации — proxy_cache_bypass $cookie_nocache $arg_nocache$arg_comment — рабочий, но добавлю предостережение: параметр запроса типа ?nocache=1 в проде оставлять нельзя, иначе любой бот сможет прогревать ваш бэкенд напрямую. Оставляйте либо куку, которую ставите себе руками, либо ограничение по IP.
# обход кэша по служебной куке, которую ставлю себе руками
proxy_cache_bypass $cookie_nocache;
proxy_no_cache $cookie_nocache;
# запросы с авторизацией не читаем из кэша и не пишем в него
proxy_cache_bypass $http_authorization;
proxy_no_cache $http_authorization;И про метод запроса, раз уж речь о кнопках. proxy_cache_methods по умолчанию содержит GET и HEAD, причём эти два метода кэшируются всегда и убрать их из списка нельзя. Добавить туда POST технически можно, и иногда это предлагают для тяжёлых поисковых запросов, но я так не делаю: ключ кэша по умолчанию не содержит тела запроса, а значит два разных POST на один URL схлопнутся в одну запись. Если очень нужно — придётся включать тело запроса в proxy_cache_key, что само по себе хрупко, или переписывать поиск на GET. Второе честнее и обычно быстрее по трудозатратам.
- proxy_cache_bypass — «не читать из кэша»;
- proxy_no_cache — «не писать в кэш»;
- срабатывание считается, если хотя бы один параметр непустой и не равен «0»;
- $upstream_cache_status при срабатывании bypass показывает BYPASS — по нему и проверяйте, что условие вообще срабатывает;
- proxy_cache_bypass $http_authorization — минимальная гигиена для любого API с токенами.
Диагностика: как за пять минут понять, что происходит
Без $upstream_cache_status в логе разговор о кэше — это гадание. Первым делом я добавляю его в лог-формат и отдельным заголовком в ответ. Значений всего семь: MISS, BYPASS, EXPIRED, STALE, UPDATING, REVALIDATED, HIT. Уже по их распределению видно диагноз: сплошной MISS — ответ не признан кэшируемым; сплошной BYPASS — переусердствовали с условиями; много EXPIRED при живом трафике — слишком короткий proxy_cache_valid или нет background update.
log_format cachelog '$remote_addr $status $upstream_cache_status '
'$request_time "$request" "$http_user_agent"';
access_log /var/log/nginx/cache.log cachelog;Дальше — быстрый разбор по логу. Эти три команды я выполняю на каждом стенде, где жалуются на кэш:
# распределение статусов кэша за файл целиком
awk '{print $3}' /var/log/nginx/cache.log | sort | uniq -c | sort -rn
# топ URL, которые никогда не попадают в кэш
awk '$3=="MISS"{print $5,$6}' /var/log/nginx/cache.log | sort | uniq -c | sort -rn | head -20
# что реально отдаёт бэкенд, минуя nginx
curl -sSD- -o /dev/null -H 'Host: salon.example.com' http://127.0.0.1:8080/catalog/И проверка на ту самую утечку, ради которой всё затевалось. Файл кэша хранит заголовки ответа бэкенда как есть, поэтому grep по каталогу кэша быстро показывает, попадали ли в кэш ответы с Set-Cookie. Если grep что-то нашёл и рядом нет proxy_hide_header Set-Cookie — куки из этих записей раздаются клиентам, и лечится это только очисткой кэша плюс инвалидацией сессий на бэкенде. Если proxy_hide_header стоит, находка в файлах ожидаема, и решающая проверка другая: запрос через nginx на закэшированный URL (статус HIT) не должен содержать Set-Cookie в ответе.
Ещё один тест, который я обязательно прогоняю перед сдачей, — параллельная нагрузка на холодный кэш. Смысл в том, чтобы убедиться, что proxy_cache_lock реально работает и десять одновременных запросов на непрогретый URL дают один поход на бэкенд, а не десять. Проверяется в одну строку: очищаем кэш, делаем reload, запускаем десяток параллельных curl на один URL и смотрим по логам апстрима, сколько запросов до него доехало. Если доехали все десять — либо proxy_cache_lock выключен, либо у вас разные ключи кэша из-за переменной, о которой вы забыли.
- grep -rl 'Set-Cookie' /var/cache/nginx/ | head — если пусто, ответы с куками в кэш не попадали; если не пусто — curl -sSD- -o /dev/null на этот URL через nginx и проверить, что в HIT-ответе нет Set-Cookie;
- два разных браузера (или curl с разными куками) на один URL кабинета — ответы должны отличаться;
- запрос без кук на приватный URL должен давать редирект на логин, а не готовую страницу из кэша;
- после правки конфига: nginx -t, затем systemctl reload nginx — reload не сбрасывает кэш: файлы остаются на диске, а зона keys_zone в разделяемой памяти переживает перезагрузку конфигурации.
Что делать в первую очередь, а на что можно забить
Приоритет номер один — убедиться, что в кэш не попадает ничего персонального. Это единственный пункт из всей темы, который стоит денег и репутации: утечка чужого кабинета — это уже разговор с юристами и, при персональных данных, с регулятором. Всё остальное — вопрос производительности, а её отсутствие никого не разоряет за один день. Поэтому порядок такой: сначала proxy_no_cache и proxy_cache_bypass по признаку авторизации, потом сужение location до анонимных маршрутов, и только потом тюнинг сроков и hit ratio.
Приоритет номер два — заставить бэкенд отдавать корректные заголовки. Это скучно, требует разработчиков и почти всегда откладывается, но именно здесь лежит правильное решение. Если приложение аккуратно ставит Cache-Control: public, max-age=600 на каталог и private, no-store на кабинет, то в nginx вам вообще не нужен ни один proxy_ignore_headers: он сам всё разложит правильно, а вы получите бонусом корректное поведение браузерных кэшей и CDN. Я всегда предлагаю сначала этот путь и только при отказе клиента строю обвязку на стороне nginx.
А вот на что можно забить спокойно. Не надо гнаться за hit ratio выше 90 % — на сайте с большим каталогом и длинным хвостом это недостижимо и не нужно, эффект даёт кэширование верхних 5 % URL. Не надо ставить огромные max_size «на вырост»: cache manager честно обходит записи, и на десятках миллионов файлов вы получите заметный I/O там, где его не ждали. Не надо ставить proxy_cache_min_uses больше 2–3 — при агрессивных значениях горячие страницы просто не успевают попасть в кэш между инвалидациями. И не надо изобретать своё сложное значение proxy_cache_key: чем больше в нём переменных, тем больше вариантов, и тем реже HIT.
И последнее, спорное. Многие включают proxy_cache_use_stale с широким списком http_5xx и оставляют навсегда. Я так делаю осознанно и считаю правильным для витрин и каталогов: отдать посетителю страницу пятиминутной давности лучше, чем 502. Но для API, где ответ — это данные, на которых принимаются решения, устаревший ответ хуже честной ошибки, потому что клиентское приложение не отличит их. Здесь я оставляю только error timeout updating и никаких http_5xx.
Если сводить всё к одному абзацу: nginx ведёт себя ровно так, как ему сказал бэкенд, и почти все «странности кэша» — это отражение заголовков, которые никто не смотрел. Соблазн заглушить эти заголовки одной директивой очень велик, потому что результат виден мгновенно. Но proxy_ignore_headers — это выключение защиты, а не включение производительности, и цена ошибки здесь несимметрична: выигрыш измеряется миллисекундами, а проигрыш — чужими персональными данными в чужом браузере. Начинайте с заголовков приложения, кэшируйте узко и обязательно тестируйте кэш двумя разными пользователями.
- сделать сразу: proxy_no_cache/proxy_cache_bypass по куке авторизации, узкий location, проверка grep по каталогу кэша;
- сделать в спринт: корректные Cache-Control на стороне приложения, отдельная кука-флаг авторизации;
- сделать потом: proxy_cache_lock, background update, revalidate, тюнинг keys_zone и max_size;
- не делать: proxy_ignore_headers на весь сайт, гигантские max_size, самодельные многосоставные ключи кэша.
Частые вопросы
Я поставил proxy_cache_valid 200 1h, а в логе всё равно MISS. Что смотреть первым?
Заголовки ответа бэкенда, а не конфиг nginx. Выполните curl -sSD- -o /dev/null напрямую к апстриму и ищите Set-Cookie, Cache-Control: private/no-cache/no-store, Vary: *, X-Accel-Expires: 0. Любого из них достаточно, чтобы nginx отказался кэшировать — параметры кэширования из заголовков ответа имеют приоритет над proxy_cache_valid.
Можно ли просто написать proxy_ignore_headers Cache-Control Set-Cookie и не мучиться?
Технически можно, практически — это самый частый способ выдать одному пользователю сессию другого. Директива отключает обработку заголовка, но не удаляет его из ответа: Set-Cookie сохраняется в кэше вместе с телом и раздаётся всем, кто попал в ту же запись по ключу. Если без игнорирования никак — обязательно добавляйте рядом proxy_hide_header Set-Cookie и применяйте это только в узком location с заведомо анонимным контентом.
Чем отличаются proxy_no_cache и proxy_cache_bypass?
proxy_cache_bypass запрещает брать ответ из кэша (запрос уходит на бэкенд), proxy_no_cache запрещает сохранять ответ в кэш. Это независимые кнопки, и для авторизованных пользователей нужны обе с одинаковым условием. Обе срабатывают, если хотя бы один их параметр непустой и не равен строке «0».
Почему после proxy_ignore_headers Cache-Control кэш всё равно не заработал?
Потому что вместе с обработкой Cache-Control вы отключили и источник информации о времени жизни ответа. Если Expires и X-Accel-Expires тоже нет, nginx не знает, на сколько кэшировать, и не кэширует. Ровно об этом писал Максим Дунин в рассылке nginx: после игнорирования Cache-Control нужно явно задать proxy_cache_valid.
Как включить кэш только для неавторизованных, если приложение всем ставит сессионную куку?
Через map по куке, которая появляется только после логина, и связку proxy_cache_bypass/proxy_no_cache с этой переменной. Если такой куки нет — попросите разработчиков добавить отдельную куку-флаг после успешной аутентификации, это несколько строк кода и самый надёжный способ разделить контуры. Ключ кэша при этом персонализировать не надо: это раздувает кэш и почти не даёт HIT.
Как проверить, что в кэш не утекли чужие куки?
grep -rl 'Set-Cookie' /var/cache/nginx/ — если вывод пуст, ответы с куками в кэш не попадали. Если grep что-то нашёл, проверьте через nginx закэшированный URL: в HIT-ответе не должно быть Set-Cookie (при proxy_hide_header Set-Cookie поле остаётся в файле, но клиенту не передаётся). Если кука уходит клиентам — уберите кэш с этого location, очистите каталог кэша, перезапустите nginx и инвалидируйте сессии на стороне приложения: закэшированные куки остаются валидными до истечения сессии, поэтому одной очисткой кэша дело не заканчивается.
Какая версия nginx актуальна в 2026 году?
На сентябрь 2026 stable — 1.30.4, mainline — 1.31.5; ветки 1.28.x и 1.26.x переведены в legacy. Логика кэширования, описанная в статье, одинакова для всех этих веток: обработка Vary работает с 1.7.7, min_free в proxy_cache_path — с 1.19.1. Дистрибутивные пакеты обычно сильно старше, но на поведение кэша это не влияет.
Источники
- nginx: ngx_http_proxy_module — Директивы proxy_cache_valid, proxy_cache_key (по умолчанию $scheme$proxy_host$request_uri), proxy_ignore_headers (X-Accel-Redirect, X-Accel-Expires, X-Accel-Limit-Rate, X-Accel-Buffering, X-Accel-Charset, Expires, Cache-Control, Set-Cookie, Vary), proxy_no_cache, proxy_cache_bypass, proxy_cache_path, proxy_cache_methods (по умолчанию GET HEAD), proxy_hide_header. Проверено по русской версии документации 14.09.2026 (mainline 1.31.5, stable 1.30.4). https://nginx.org/ru/docs/http/ngx_http_proxy_module.html Там же: приоритет заголовков ответа над proxy_cache_valid, X-Accel-Expires (0 и префикс @), 1 МБ keys_zone ≈ 8 тысяч ключей, условие «непустое и не равно 0» для proxy_cache_bypass/proxy_no_cache.
- nginx: ngx_http_upstream_module — Переменная $upstream_cache_status и её семь значений: MISS, BYPASS, EXPIRED, STALE, UPDATING, REVALIDATED, HIT. https://nginx.org/ru/docs/http/ngx_http_upstream_module.html
- NGINX Admin Guide — Content Caching — Раздел «Limiting or Disabling Caching»: примеры proxy_cache_bypass $cookie_nocache $arg_nocache$arg_comment и proxy_no_cache $http_pragma $http_authorization; кэширование byte-range через ngx_http_slice_module. https://docs.nginx.com/nginx/admin-guide/content-cache/content-caching/
- NGINX Blog — A Guide to Caching with NGINX — Подтверждает, что по умолчанию nginx не кэширует ответы с Set-Cookie и с Cache-Control private/no-cache/no-store, и приводит связку proxy_cache_revalidate / proxy_cache_min_uses / proxy_cache_use_stale / proxy_cache_background_update / proxy_cache_lock. https://blog.nginx.org/blog/nginx-caching-guide
- nginx mailing list, ответ Максима Дунина (сентябрь 2013) — «Ignoring the Cache-Control header likely results in no cache time information available. You have to set proxy_cache_valid then for a cache to work» — почему proxy_ignore_headers Cache-Control без proxy_cache_valid не включает кэш. https://mailman.nginx.org/pipermail/nginx/2013-September/040370.html
- nginx: download (актуальные версии) — На 14.09.2026 mainline — 1.31.5, stable — 1.30.4; ветки 1.28.x (1.28.3) и 1.26.x (1.26.3) в разделе legacy. https://nginx.org/ru/download.html
