Представь типичную картину. У тебя бэкенд на PHP или Node, он на каждый запрос лезет в базу, рендерит шаблон, тратит 200-400 мс и кусок CPU. Пока запросов десяток в секунду - живёшь. А потом тебя постит крупный канал, и на главную одновременно валит 5000 человек. Бэкенд встаёт колом, база захлёбывается, ты смотришь в Grafana и видишь, как 504 заливают всю выдачу. При этом 90 процентов этих 5000 запросов абсолютно одинаковы: один и тот же URL, один и тот же ответ. Зачем рендерить его пять тысяч раз?
Вот тут и включается nginx кэширование. Идея простая: Nginx один раз сходил на бэкенд, забрал ответ, положил его на диск и в память, а дальше тысячи клиентов получают копию напрямую от Nginx, не трогая бэкенд вообще. Это nginx cache в самом чистом виде - reverse proxy кэш. Разница в цифрах не косметическая: закэшированный ответ отдаётся за доли миллисекунды против сотен у бэкенда, и под пиком ты держишь нагрузку, которую без кэша держать физически нечем.
В этом уроке разберём nginx proxy_cache от и до: как объявляется зона через nginx proxy_cache_path, из чего собирается ключ, как читать X-Cache-Status, единый production-паттерн защиты бэкенда (lock + background_update + revalidate + use_stale), отдельно nginx микрокэш для динамики, второй слой через open_file_cache и - очень важно - где кэш стреляет тебе в ногу приватностью. Это не теория: всё, что покажу, лежит в global_cache.conf и global_proxy.conf эталонного gateway, на нём и разберём.

proxy_cache_path: где и как Nginx хранит nginx кэш
Кэш начинается с зоны. Объявляется она в контексте http, один раз на весь сервер:
Код: Выделить всё
proxy_cache_path /var/cache/nginx/gate levels=1:2 keys_zone=appcache:128m inactive=60m max_size=10g use_temp_path=off;
/var/cache/nginx/gate - каталог на диске, куда лягут сами тела ответов. Каталог должен существовать и быть доступен пользователю, под которым крутится worker (обычно nginx или www-data), иначе при старте получишь ошибку прав.
levels=1:2 - схема вложенных подкаталогов. Имя файла кэша - это MD5 от ключа. levels=1:2 говорит: возьми последний символ хеша как имя каталога первого уровня, два предыдущих - второго. То есть файл с хешем ...c3d8a окажется в /var/cache/nginx/gate/a/d8/. Зачем? Если свалить миллион файлов в один каталог, многие файловые системы начинают тормозить на листинге и поиске inode. Раскладка по 256 на 256 подкаталогов держит в каждом каталоге сотни файлов вместо миллионов.
keys_zone=appcache:128m - вот это ключевой и часто недопонимаемый момент. Это зона в разделяемой памяти (shared memory) под имя appcache, и в ней лежат НЕ тела ответов, а только индекс: ключи, метаданные, флаги, таймеры. Сами тела - на диске. 128m памяти под индекс - это очень много: одна запись занимает примерно 0.5 КБ, значит 128m держат под рукой порядка 250 тысяч ключей одновременно. Имя appcache - это то, чем ты будешь ссылаться на зону в proxy_cache.
inactive=60m - если к объекту не обращались 60 минут, его выкинут из кэша, даже если по сроку жизни он ещё валиден. Это про вытеснение по неиспользуемости, не путать с TTL.
max_size=10g - потолок размера кэша на диске. При превышении специальный процесс cache manager начинает вычищать самые старые/невостребованные записи по LRU. Без max_size кэш будет расти, пока не забьёт диск.
use_temp_path=off - принципиально для I/O. По умолчанию Nginx сначала пишет тело во временный каталог (proxy_temp_path), а потом переносит в каталог кэша. Если temp и cache на разных файловых системах, перенос превращается в полное копирование. use_temp_path=off заставляет писать сразу в каталог кэша - убираем лишнее копирование. Ставь off почти всегда.
Зона объявлена, но ещё ничего не кэшируется. Чтобы location реально использовал кэш, нужна директива proxy_cache:
Код: Выделить всё
location /api/ {
proxy_cache appcache;
proxy_cache_valid 200 1h;
proxy_pass http://backend_upstream;
}
Ключ кэша: из чего складывается nginx proxy_cache_key и где приватная мина
Ключ - это то, по чему Nginx решает "это тот же самый ответ или другой". По умолчанию ключ равен "$scheme$proxy_host$request_uri". В эталонном gateway он расширен:
Код: Выделить всё
proxy_cache_key "$scheme$request_method$host$request_uri$http_authorization";
Замысел был такой: "пусть у разных токенов будут разные записи кэша, чтобы Алисин ответ не утёк Бобу". Звучит логично, но это ловушка. Смотри, что происходит на деле. Алиса с токеном A дёргает /api/profile - Nginx кэширует её приватный профиль под ключом, куда входит токен A. Пока всё честно: Боб с токеном B получит свой ключ и свой запрос на бэкенд. Но проблема в другом - ты вообще закэшировал приватный персональный ответ на диске Nginx. Если Алиса дёрнет /api/profile дважды с одним токеном, второй раз ей отдаст Nginx из кэша, минуя любую проверку прав на бэкенде. Токен протух, доступ отозвали, юзера забанили - а Nginx всё ещё час отдаёт его старый профиль из appcache всем, кто придёт с тем же значением заголовка. Это дыра в авторизации и приватности.
Правильный подход - НЕ городить токен в ключ, а вообще не кэшировать то, что приватно. Для этого есть две директивы:
Код: Выделить всё
# Не сохранять ответ в кэш, если есть Authorization или сессионная кука
proxy_no_cache $http_authorization $cookie_sessionid;
# Не отдавать из кэша (идти на бэкенд), при тех же условиях
proxy_cache_bypass $http_authorization $cookie_sessionid;
Запомни как мантру: кэш-ключ задаёт уникальность, а не приватность. Приватность решается через no_cache/bypass, а не запихиванием токена в ключ.
Единый паттерн устойчивости: lock, background_update, revalidate, use_stale
Теперь сердцевина урока - набор директив, который превращает наивный кэш в надёжный щит для бэкенда. В эталонном global_proxy.conf он собран в один блок, разберём по порядку.
proxy_cache_lock - защита от thundering herd (cache stampede). Представь: популярный объект протух (EXPIRED), и в эту секунду на него прилетело 1000 запросов. Без блокировки все 1000 обнаружат, что в кэше пусто/устарело, и ВСЕ разом ломанутся на бэкенд за свежей копией. Бэкенд, который ты кэшем как раз и защищал, получает залп в 1000 одинаковых тяжёлых запросов и падает. Это и есть стампед.
Код: Выделить всё
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_background_update - обновление в фоне.
Код: Выделить всё
proxy_cache_background_update on;
proxy_cache_revalidate on;
proxy_cache_revalidate - дешёвое обновление через условные запросы. Если бэкенд отдаёт ETag или Last-Modified, при обновлении протухшей записи Nginx пошлёт на бэкенд не обычный GET, а условный - с If-None-Match / If-Modified-Since. Если контент не менялся, бэкенд ответит 304 Not Modified без тела - Nginx просто продлит TTL существующей записи, не перекачивая мегабайты. Экономия трафика и времени на ровном месте.
proxy_cache_use_stale - отдавать устаревшее при сбоях бэкенда. Это директива устойчивости. В эталоне она закомментирована (по умолчанию выключена), но включать её в проде стоит почти всегда:
Код: Выделить всё
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504 http_429;
Собранный воедино боевой блок (то, что и крутится в эталонном gateway):
Код: Выделить всё
location /api/ {
proxy_cache appcache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 302 1h;
proxy_cache_valid 404 1m;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_background_update on;
proxy_cache_revalidate on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504 http_429;
proxy_no_cache $http_authorization $cookie_sessionid;
proxy_cache_bypass $http_authorization $cookie_sessionid;
add_header X-Cache-Status $upstream_cache_status always;
proxy_pass http://backend_upstream;
}
X-Cache-Status: читаем, что происходит с nginx cache
Переменная $upstream_cache_status - твой главный диагностический инструмент. Выкидывай её в заголовок и в access-лог, без неё ты слепой. Значения:
- MISS - в кэше не было, сходили на бэкенд, положили.
- HIT - отдали из кэша, бэкенд не трогали. То, ради чего всё затевалось.
- EXPIRED - запись была, но протухла по TTL; сходили на бэкенд за свежей.
- UPDATING - запись протухла, отдаём старую, а свежую тянем в фоне (это background_update + use_stale updating в действии).
- STALE - бэкенд недоступен/ошибся, отдали заведомо протухшую копию по proxy_cache_use_stale. Видишь STALE - значит бэкенду плохо, иди смотри.
- BYPASS - кэш сознательно обойдён по proxy_cache_bypass (например, был Authorization).
Код: Выделить всё
$ curl -sI http://localhost/api/news | grep -i x-cache
X-Cache-Status: MISS
$ curl -sI http://localhost/api/news | grep -i x-cache
X-Cache-Status: HIT
Код: Выделить всё
proxy_ignore_headers Expires Cache-Control Set-Cookie Vary;
Nginx микрокэш: кэшируем динамику на одну секунду
"Но у меня контент динамический, его нельзя кэшировать на час!" - частое возражение. И тут на сцену выходит nginx микрокэш (micro-caching). Трюк гениально простой: кэшируем динамический ответ всего на 1 секунду.
Считаем. Главная обновляется псевдо-реально, и под пиком на неё летит 1000 запросов в секунду. Без кэша - 1000 рендеров в секунду, бэкенд мёртв. С микрокэшем на 1 секунду - бэкенд рендерит страницу РОВНО ОДИН раз в секунду, остальные 999 запросов получают ту же копию из nginx cache. Свежесть данных - максимум секунда задержки, что для 99 процентов контента незаметно. А нагрузка на бэкенд падает в тысячу раз. Это самый дешёвый способ пережить хабраэффект.
Код: Выделить всё
location / {
proxy_cache appcache;
proxy_cache_valid 200 1s; # вот он, микрокэш
proxy_cache_lock on; # без лока смысл теряется
proxy_cache_use_stale updating error timeout;
proxy_cache_background_update on;
proxy_no_cache $cookie_sessionid;
proxy_cache_bypass $cookie_sessionid;
add_header X-Cache-Status $upstream_cache_status always;
proxy_pass http://backend_upstream;
}
open_file_cache: второй слой кэша для статики
proxy_cache закрывает ответы бэкенда. А что со статикой, которую Nginx отдаёт сам с диска - css, js, картинки? Тут другой слой: open_file_cache. Он кэширует не содержимое файлов, а метаданные дескрипторов: результаты open(), stat(), существование файла, права, размер, mtime.
Зачем? Под нагрузкой на каждый запрос статики Nginx делает системные вызовы stat() и open(). Это дёшево поодиночке, но при 50k rps превращается в заметную нагрузку на ядро и диск. open_file_cache держит эти метаданные в памяти worker-а:
Код: Выделить всё
open_file_cache max=2000 inactive=20s;
open_file_cache_valid 60s;
open_file_cache_min_uses 5;
open_file_cache_errors on;
Важно понимать разницу: proxy_cache и open_file_cache - это два РАЗНЫХ кэша про разные вещи. Первый - про тела ответов бэкенда (динамика, проксирование). Второй - про дескрипторы файлов, которые Nginx отдаёт сам. На боевом шлюзе работают оба слоя одновременно.
Типичные грабли
- Токен/кука в proxy_cache_key вместо no_cache. Разобрали выше - это утечка приватных ответов. Ключ - про уникальность, приватность - через proxy_no_cache/proxy_cache_bypass.
- POST в кэше. По умолчанию Nginx кэширует только GET и HEAD (proxy_cache_methods). Не пытайся кэшировать POST - это почти всегда логическая ошибка.
- Микрокэш без proxy_cache_lock. Каждую секунду на границе TTL - стампед. Лок обязателен.
- background_update без use_stale updating. Без флага updating фоновое обновление не включится, и клиент будет ждать обновления синхронно. Эти две директивы ходят парой.
- Кэширование ответов с Set-Cookie. Если proxy_ignore_headers Set-Cookie включён, а no_cache по сессии нет - закэшируешь чужую персонализацию. Классика утечки сессий.
- use_temp_path не off на разных ФС. Temp и cache на разных файловых системах превращают каждую запись в полное копирование файла. Ставь use_temp_path=off.
- Слишком короткий lock_timeout. Если лидер обновляет дольше lock_timeout, ждущие всё равно пойдут на бэкенд - стампед возвращается. Соизмеряй с реальным временем рендера.
Рано или поздно понадобится выбить конкретную запись из кэша - например, контент обновили вручную и ждать TTL нельзя. В open-source Nginx директива proxy_cache_purge живёт в стороннем модуле ngx_cache_purge (в Nginx Plus и в Angie есть встроенный механизм purge). Идея - выделить специальный метод/локацию, которая по ключу удаляет запись:
Код: Выделить всё
map $request_method $purge_method {
PURGE 1;
default 0;
}
location /api/ {
proxy_cache appcache;
proxy_cache_purge $purge_method; # из модуля ngx_cache_purge
proxy_pass http://backend_upstream;
}
Мини-лаба: собери кэш руками за 6 шагов
Понадобится Nginx (или OpenResty) и любой бэкенд, который умно отвечает - подойдёт даже python -m http.server или маленький echo-сервис на :9000.
- Шаг 1. Создай каталог кэша: mkdir -p /var/cache/nginx/gate и дай права worker-у (chown).
- Шаг 2. В http добавь зону: proxy_cache_path /var/cache/nginx/gate levels=1:2 keys_zone=appcache:128m inactive=60m max_size=10g use_temp_path=off;
- Шаг 3. В location пропиши proxy_cache appcache; proxy_cache_valid 200 30s; proxy_pass на свой бэкенд; и add_header X-Cache-Status $upstream_cache_status always;
- Шаг 4. nginx -t && nginx -s reload. Дёрни дважды: curl -sI localhost/ | grep -i x-cache. Первый MISS, второй HIT. Загляни в /var/cache/nginx/gate - там появились файлы в подкаталогах a/bc/.
- Шаг 5. Добавь устойчивый блок: proxy_cache_lock on; proxy_cache_lock_timeout 5s; proxy_cache_background_update on; proxy_cache_revalidate on; proxy_cache_use_stale error timeout updating http_502 http_503; Останови бэкенд и снова сделай запрос - вместо 502 получишь STALE из кэша. Это и есть устойчивость.
- Шаг 6. Поставь proxy_cache_valid 200 1s; и прогони нагрузку: while true; do curl -s -o /dev/null -w "%{http_code} " localhost/; done в одном терминале, а в логе бэкенда считай реальные хиты - увидишь примерно один запрос в секунду вместо шквала. Это микрокэш в работе.
- Что хранится в зоне keys_zone (shared memory), а что - на диске в каталоге кэша? Почему 128m под индекс - это очень много записей?
- Почему добавлять $http_authorization в proxy_cache_key - плохая идея, и через какие две директивы правильно исключать приватные ответы из кэша?
- Зачем нужен proxy_cache_lock и что произойдёт с микрокэшем на 1 секунду, если лок выключить?
- В каких случаях ты увидишь в X-Cache-Status значения UPDATING и STALE, и какие директивы за каждое из них отвечают?
nginx proxy_cache - это не "включил и забыл", а связка из ключа, TTL и набора директив устойчивости. Запомни главное: зона объявляется в nginx proxy_cache_path (индекс в памяти, тела на диске, use_temp_path=off); приватность решается через proxy_no_cache/proxy_cache_bypass, а НЕ токеном в ключе; production-щит бэкенда - это proxy_cache_lock + background_update + revalidate + use_stale в одном флаконе; nginx микрокэш на 1 секунду спасает динамику под пиком; open_file_cache - отдельный слой для статики; X-Cache-Status - твои глаза. Соберёшь эту комбинацию правильно - твой бэкенд переживёт нагрузку, которую без кэша не пережил бы никогда.