Сценарий: статика, SPA и кэширование за CDN

Рейтинг: 70.1% · 9 голосов
Самый подробный курс по Nginx - от первого конфига до production API-gateway. Архитектура и event loop, контексты и location, виртуальные хосты, статика, reverse proxy и upstream-балансировка, кэширование, HTTPS и TLS (post-quantum), HTTP/2 и HTTP/3 QUIC, безопасность и rate limiting, realip, JSON-логи и сквозная трассировка, метрики, тюнинг и траблшутинг. Плюс OpenResty (Lua, фазы, JWT, динамика) и Angie (нативные Prometheus, ACME, dynamic upstream). На основе эталонного gateway и лучших практик. Для новичков и SRE/DevOps. Актуально на 2026 (nginx 1.30/1.31, Angie, ingress-nginx EOL -> Gateway API).
Ответить
Аватара пользователя
Igor_NgINX
Сообщения: 44
Зарегистрирован: 11 май 2026, 05:31

Сценарий: статика, SPA и кэширование за CDN

Сообщение Igor_NgINX »

Оглавление курса (44)
  1. Что такое Nginx и почему он захватил веб
  2. Установка Nginx, Angie и OpenResty: пакеты, версии, Docker
  3. Первый конфиг с нуля: минимальный сервер за 5 минут
  4. Архитектура процессов: master, worker и event loop
  5. Анатомия конфигурации: контексты, наследование и модульность
  6. Виртуальные хосты: server и server_name
  7. Директива location: матчинг и приоритеты по шагам
  8. Раздача статики: root, alias, try_files, sendfile
  9. Переменные, map и блок if
  10. rewrite, return и канонизация URL
  11. Работа с заголовками: add_header и proxy_set_header
  12. Модель доверия прокси-заголовков: realip и защита от спуфинга
  13. Сжатие: gzip, brotli и zstd
  14. Reverse proxy: proxy_pass и проброс запроса
  15. upstream и балансировка нагрузки
  16. Таймауты, повторы и устойчивость прокси
  17. FastCGI и PHP: nginx + PHP-FPM (и uwsgi/scgi)
  18. WebSocket, gRPC и стриминг через Nginx
  19. Кэширование ответов: proxy_cache и микрокэш
  20. HTTPS и TLS: сертификаты, протоколы, шифры (2026)
  21. Let's Encrypt и ACME: certbot, acme.sh, нативный ACME Angie
  22. HTTP/2 и HTTP/3 (QUIC) в 2026
  23. Безопасность: заголовки, скрытие версии, ограничения
  24. Rate limiting и защита от перегрузки
  25. Контроль доступа: allow/deny, auth_basic, auth_request и mTLS
  26. Логирование: access_log, форматы и структурированный JSON
  27. Сквозная трассировка запросов и OpenTelemetry
  28. Метрики и мониторинг Nginx
  29. Производительность и тюнинг под нагрузку
  30. Траблшутинг: 502, 504, 403, 404 и debug-лог
  31. OpenResty: Nginx как платформа на LuaJIT
  32. Фазы обработки запроса и Lua-хуки
  33. Lua API: ngx.*, shared dict, cosocket и lua-resty-core
  34. Практика OpenResty: авторизация, JWT, кэш, Redis
  35. Angie: современный форк Nginx 2026
  36. Динамическая конфигурация и service discovery
  37. Nginx как API-gateway и Kubernetes Gateway API
  38. Динамические модули, njs и WAF
  39. Stream-модуль: проксирование TCP и UDP
  40. Деплой, перезагрузка и конфиг как код
  41. Сценарий: статика, SPA и кэширование за CDN (вы здесь)
  42. Сценарий: микросервисный gateway по образцу ngx-trace-gateway
  43. Капстоун: собираем production-gateway с нуля
  44. Чек-лист безопасности, CVE 2026 и аудит конфигурации
Боль: SPA, который то не открывается по прямой ссылке, то показывает старую версию

Ты собрал React или Vue, выкатил в продакшен, и начинается. Зашёл на главную - всё работает. Нажал F5 на /dashboard/orders/42 - белый экран и 404 от nginx. Выкатил новый билд - часть пользователей неделю видит старый, ловит ошибки на несовпадающих чанках. А когда поставил перед origin CDN, в логах вместо реальных IP клиентов один и тот же адрес edge-ноды, rate-limit и geo сломались, а origin при этом открыт всему интернету в обход CDN.

Все эти грабли - не про React и не про Cloudflare. Это про то, как nginx отдаёт статику и проставляет кэш-заголовки. Тема узкая, но именно тут собрана половина инцидентов фронтенда. Разберём механику: как nginx раздаёт собранный SPA, почему хешированные ассеты надо кэшировать на год, а index.html нельзя кэшировать вообще, как отдавать предсжатые brotli-файлы, чем заменили server push, и как поставить nginx как статический сайт за CDN так, чтобы origin был защищён, а в логах был настоящий IP клиента.

Изображение

Механика SPA-роутинга: try_files и почему index.html - точка входа

Собранный SPA - это набор статических файлов: один index.html, кучка JS/CSS-чанков с хешами в имени (main.4f8a1c.js) и медиа. Роутинг внутри приложения делает JS в браузере через History API. Сервер про маршруты ничего не знает: для него /dashboard/orders/42 - это просто путь, под которым на диске нет файла.

Когда юзер переходит по внутренней ссылке - всё хорошо, навигацию рисует JS без обращения к серверу. Проблема ровно одна: первый запрос или F5 на глубоком URL. Браузер честно идёт на сервер за /dashboard/orders/42, файла там нет, nginx отдаёт 404. Лечение - directive try_files: попробовать отдать файл по пути, потом каталог, а если не нашли - вернуть index.html, чтобы приложение само разобрало маршрут.

Код: Выделить всё

location / {
    root /var/www/app/dist;
    try_files $uri $uri/ /index.html;
}
Разберём по шагам. $uri - нормализованный путь запроса. Для /assets/main.4f8a1c.js файл существует - nginx отдаёт его и на этом останавливается. Для /dashboard/orders/42 файла нет, каталога нет, значит nginx делает внутренний редирект на последний аргумент /index.html и отдаёт его с кодом 200. Браузер получает HTML, грузит JS, роутер видит в location.pathname путь /dashboard/orders/42 и рисует нужный экран. Важно: последний аргумент try_files с ведущим слешем - это внутренний редирект на location, а не имя файла; код ответа будет 200, а не 404, и это правильно для SPA.

Краевой случай: не пиши последним аргументом =404 - это вернёт 404 и сломает deep links. И не путай try_files $uri $uri/ /index.html с try_files $uri /index.html =404 - вторая форма с двумя fallback некорректна, у директивы последний аргумент один.

Кэш-заголовки: immutable на год для ассетов, no-store для index.html

Тут кроется главная причина "у юзеров старая версия". Современные сборщики (Vite, webpack, esbuild) дают ассетам имя с хешем содержимого: main.4f8a1c.js. Поменялся хоть байт - меняется хеш, меняется имя файла. Значит старый и новый файл физически разные URL и не могут конфликтовать в кэше. Такой файл можно кэшировать вечно.

Код: Выделить всё

location /assets/ {
    root /var/www/app/dist;
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
    access_log off;
}
max-age=31536000 - это год в секундах. immutable - сигнал браузеру: даже при F5 не делай условный запрос (If-None-Match), файл не меняется. Без immutable Chrome при перезагрузке всё равно дёргает сервер за 304, а с immutable - берёт из дискового кэша молча. Для хешированных ассетов это безопасно и резко снижает число запросов к origin.

А вот index.html кэшировать нельзя ни в коем случае. Это единственный нехешированный файл, и именно он содержит ссылки на свежие хеши ассетов. Закэшируешь index.html - браузер будет грузить старый HTML, тянуть из него ссылки на чанки, которых после деплоя уже нет на диске, и приложение упадёт с ошибкой загрузки модуля.

Код: Выделить всё

location = /index.html {
    root /var/www/app/dist;
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    expires -1;
    add_header X-Frame-Options SAMEORIGIN always;
}
Тонкость с add_header: эта директива наследуется в дочерний location только если в нём НЕТ своих add_header. Как только ты добавил хоть один add_header в location = /index.html, все add_header из родителя (server, http) в этом блоке перестают действовать - их надо продублировать. Это классические грабли: думаешь, HSTS и X-Content-Type-Options наследуются, а они молча отвалились. Решение - либо не плодить add_header в дочерних блоках, либо дублировать нужные с флагом always.

Предсжатие из сборки: brotli_static и gzip_static

Сжимать текстовые ассеты на лету директивой gzip - значит жечь CPU на каждый запрос. Для статики это глупо: файлы не меняются, сожми их один раз на этапе сборки и положи рядом готовые .br и .gz. nginx отдаст предсжатую версию без затрат CPU.

Директива gzip_static встроена в nginx (нужен модуль ngx_http_gzip_static_module, собирается с --with-http_gzip_static_module, в стандартных пакетах есть). С brotli сложнее: нативного brotli в ванильном nginx нет, ngx_brotli - это сторонний динамический модуль, его надо подгрузить через load_module. В Angie ситуация мягче: там нативно есть Zstd (zstd_static), но Brotli всё равно сторонний.

Код: Выделить всё

# в начале nginx.conf, если модуль динамический:
# load_module modules/ngx_http_brotli_static_module.so;

location ~* \.(js|css|svg|json)$ {
    root /var/www/app/dist;
    brotli_static on;
    gzip_static on;
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
}
Как это работает на запросе main.4f8a1c.js. Браузер шлёт Accept-Encoding: gzip, br. brotli_static on проверяет, есть ли на диске main.4f8a1c.js.br - если есть и клиент поддерживает br, отдаёт его с Content-Encoding: br. Нет .br или клиент его не понимает - в дело вступает gzip_static, ищет .js.gz. Нет и его - отдаётся оригинал как есть. Сам nginx ничего не сжимает, только выбирает готовый файл.

Грабли: не забудь gzip_vary on (или эквивалент), чтобы в ответе был Vary: Accept-Encoding - иначе промежуточный кэш или CDN может отдать brotli-версию клиенту, который brotli не понимает. И следи, чтобы сборка реально клала .br/.gz в dist - без них директивы тихо отдают оригинал, и ты будешь думать, что сжатие работает, а трафик раздут.

Server push мёртв: preload-заголовки и 103 Early Hints

Раньше для ускорения первой загрузки использовали HTTP/2 Server Push: сервер сам пушил клиенту критичные ассеты, не дожидаясь запроса. В 2026 это история. Server push удалён из nginx - директив http2_push и http2_push_preload больше нет, Chrome отключил push ещё в 106-й версии, реальное использование было около 0.04% сессий. Не пиши про push в новых конфигах вообще.

Замена номер один - обычный заголовок Link с rel=preload. Он говорит браузеру: начни тянуть этот ресурс прямо сейчас, не дожидаясь, пока распарсишь HTML.

Код: Выделить всё

location = /index.html {
    root /var/www/app/dist;
    add_header Cache-Control "no-store";
    add_header Link "</assets/main.4f8a1c.js>; rel=preload; as=script" always;
    add_header Link "</assets/main.4f8a1c.css>; rel=preload; as=style" always;
}
Замена номер два, более продвинутая - 103 Early Hints. Это промежуточный HTTP-ответ: сервер сразу отдаёт статус 103 с заголовками Link, пока бэкенд ещё думает над основным ответом. Браузер начинает грузить ассеты в это "время раздумий". В nginx нативная поддержка появилась в 1.29.0 директивой early_hints - она управляет тем, при каких условиях Early Hints от бэкенда уходят клиенту. Для чисто статической раздачи Early Hints обычно избыточны (HTML и так отдаётся мгновенно), они дают выигрыш, когда между nginx и медленным upstream есть задержка. Для SPA за origin достаточно preload-заголовка в index.html.

Nginx как origin за CDN: real_ip, правильный Cache-Control и защита

Теперь главный продакшен-сценарий: nginx + Cloudflare (или любой CDN) перед ним. CDN держит на edge закэшированную статику и принимает основной трафик, а origin - твой nginx - отдаёт контент только когда у CDN кэш-промах. Тут три задачи, и каждую часто заваливают.

Задача 1. Реальный IP клиента (см. урок 11). Когда перед тобой CDN, на TCP-уровне к origin подключается edge-нода. Без модуля realip переменная $remote_addr - это адрес этой edge-ноды, а не клиента. Сломается всё, что завязано на IP: логи, rate-limit, geo, бан по IP. Cloudflare кладёт настоящий IP в заголовок CF-Connecting-IP (и дублирует в X-Forwarded-For). Включаем realip, но - внимание - доверяем заголовку ТОЛЬКО если запрос пришёл с сетей CDN.

Код: Выделить всё

# доверяем только диапазонам Cloudflare (список с их сайта, обновлять!):
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 2400:cb00::/32;
# ... полный актуальный список IPv4+IPv6 диапазонов CF

real_ip_header CF-Connecting-IP;
real_ip_recursive on;
Почему именно "только сети CDN" - вопрос безопасности. real_ip_header заставляет nginx верить заголовку и подменять $remote_addr его значением. Если разрешить это для всех (set_real_ip_from 0.0.0.0/0), любой клиент пошлёт CF-Connecting-IP: 1.2.3.4 и подделает свой адрес - сломает твой rate-limit и обойдёт IP-баны. Доверяй заголовку ровно тем, кто его реально проставляет: диапазонам Cloudflare. real_ip_recursive on заставляет nginx идти по цепочке X-Forwarded-For справа налево, отбрасывая доверенные адреса, пока не дойдёт до первого недоверенного - это и есть клиент.

Задача 2. Правильные Cache-Control и Vary для CDN. CDN кэширует по тем же заголовкам, что и браузер. Логика immutable для ассетов и no-store для index.html работает и на edge: хешированные чанки CDN держит на год, а HTML не кэширует или кэширует на секунды. Обязательно отдавай Vary: Accept-Encoding, иначе CDN может закэшировать brotli-версию и отдать её клиенту без поддержки brotli. Если контент зависит от языка или авторизации - добавь это в Vary, но осторожно: широкий Vary убивает hit rate кэша.

Задача 3. Защита origin (deny all, кроме CDN). Если origin открыт всему интернету, атакующий узнает его реальный IP (через старые DNS-записи, утечки, сканеры) и ударит мимо CDN - в обход всей защиты, WAF и rate-limit edge. Закрываем origin на уровне сети файрволом, а в nginx дублируем правилом: пускать только сети CDN.

Код: Выделить всё

location / {
    # пускаем только Cloudflare, остальных - в 403
    allow 173.245.48.0/20;
    allow 103.21.244.0/22;
    allow 104.16.0.0/13;
    allow 2400:cb00::/32;
    # ... полный список CF
    deny all;

    root /var/www/app/dist;
    try_files $uri $uri/ /index.html;
}
Грабли с порядком: allow/deny из модуля ngx_http_access_module работают по первому совпадению сверху вниз. Все allow для сетей CDN должны идти ДО deny all. И помни: эта защита бьётся ровно по тому же списку, что и set_real_ip_from - держи их синхронными. Лучше вынести список диапазонов в отдельный include-файл и обновлять его одной командой плюс nginx -s reload, потому что диапазоны CDN периодически меняются.

Картинки и медиа: range-запросы и immutable

Медиа отдаётся как обычная статика, но с нюансами. Видео и крупные файлы браузер тянет range-запросами (Range: bytes=...) - nginx поддерживает их из коробки для статики, ничего включать не надо, он сам отдаёт 206 Partial Content. Если файлы версионируются (имя с хешем или ?v=) - ставь immutable на год. Если имена стабильные, но содержимое может смениться (avatar.jpg) - короткий max-age плюс ETag, чтобы браузер ревалидировал условным запросом, а не тянул заново.

Код: Выделить всё

location ~* \.(png|jpg|jpeg|webp|avif|mp4|woff2)$ {
    root /var/www/app/dist;
    expires 30d;
    add_header Cache-Control "public, max-age=2592000";
    access_log off;
}
Типичные грабли
  • F5 на deep link даёт 404 - забыл try_files ... /index.html, либо последним аргументом стоит =404.
  • Юзеры видят старую версию после деплоя - закэширован index.html. Ему нужен no-store/no-cache, кэшируется только хешированная статика.
  • Сломались add_header при добавлении одного нового - add_header не наследуется в location, где есть свой add_header. Дублируй нужные.
  • Brotli "включён", а трафик не падает - сборка не кладёт .br рядом с файлами, либо нет Vary: Accept-Encoding.
  • За CDN в логах один IP - нет модуля realip или неверный real_ip_header. Лечится set_real_ip_from на сети CDN + real_ip_header CF-Connecting-IP.
  • set_real_ip_from 0.0.0.0/0 - дыра: клиент подделает свой IP. Доверяй только сетям CDN.
  • Origin открыт всем - бьют мимо CDN. Закрывай allow сети CDN; deny all плюс файрвол.
  • Пишешь http2_push - директивы больше нет, конфиг не пройдёт nginx -t. Используй Link rel=preload или 103 Early Hints.
Мини-лаба: production-конфиг SPA за CDN своими руками
  • Шаг 1. Собери любой Vite/CRA проект (npm run build), получи каталог dist с index.html и assets/. Положи его в /var/www/app/dist.
  • Шаг 2. Создай server-блок: listen 80; root /var/www/app/dist; добавь location / с try_files $uri $uri/ /index.html.
  • Шаг 3. Добавь location /assets/ с expires 1y и Cache-Control public, max-age=31536000, immutable.
  • Шаг 4. Добавь location = /index.html с Cache-Control no-store и заголовком Link rel=preload на главный JS-чанк.
  • Шаг 5. Сожми ассеты на сборке (find dist -name '*.js' -exec gzip -k {} +, и brotli аналогично), включи gzip_static on и brotli_static on в location ассетов. Проверь curl -H 'Accept-Encoding: br' -I и ищи Content-Encoding: br.
  • Шаг 6. Проверь deep link: curl -i http://localhost/some/deep/route - должен прийти 200 и тело index.html, не 404.
  • Шаг 7. Сэмулируй CDN: добавь set_real_ip_from 127.0.0.1; real_ip_header CF-Connecting-IP; и пошли curl -H 'CF-Connecting-IP: 8.8.8.8' - убедись по логу, что записался 8.8.8.8.
  • Шаг 8. Закрой origin: allow 127.0.0.1; deny all; - запрос с локалхоста проходит, "чужой" получает 403. Проверь nginx -t перед каждым reload.
Контрольные вопросы
  • Почему хешированный main.4f8a1c.js можно отдавать с immutable max-age=31536000, а index.html - нельзя кэшировать вообще?
  • Что делает последний аргумент в try_files $uri $uri/ /index.html и почему ответ будет 200, а не 404?
  • Зачем set_real_ip_from обязан перечислять только сети CDN, и что сломается при set_real_ip_from 0.0.0.0/0?
  • Чем заменили HTTP/2 Server Push в 2026 и в какой версии nginx появился 103 Early Hints?
Итог

Быстрый и надёжный фронтенд за CDN держится на четырёх вещах. try_files ... /index.html чинит deep links SPA. Жёсткое разделение кэша: хешированные ассеты - immutable на год, index.html - no-store, иначе юзеры застрянут на старом билде. Предсжатие brotli_static/gzip_static снимает CPU и трафик, не забудь Vary. И, наконец, контур за CDN: realip с доверием только сетям CDN возвращает настоящий IP клиента (урок 11), а allow сети CDN + deny all закрывают origin от ударов в обход. Server push забудь - есть Link rel=preload и 103 Early Hints. Этот шаблон закрывает 90% задач раздачи статического сайта и SPA в проде.
👍2 ❤️ 🔥 😄 🤔5
Аватара пользователя
porta
Сообщения: 1
Зарегистрирован: 25 май 2026, 03:51

Re: Сценарий: статика, SPA и кэширование за CDN

Сообщение porta »

Вот про add_header не знал - реально словил, что HSTS отвалился когда добавил Link в index.html. Спасибо, теперь дублирую always
👍1 ❤️ 🔥1 😄 🤔
Аватара пользователя
seemee
Сообщения: 1
Зарегистрирован: 02 июн 2026, 16:46

Re: Сценарий: статика, SPA и кэширование за CDN

Сообщение seemee »

Вопрос: список диапазонов Cloudflare же меняется. Кто-нибудь автоматизировал обновление и set_real_ip_from и allow одним include, чтобы reload по cron? Руками лень два места править
👍 ❤️ 🔥 😄 🤔
Ответить
← Предыдущая глава
Деплой, перезагрузка и конфиг как код
Следующая глава →
Сценарий: микросервисный gateway по образцу ngx-trace-gateway

Все главы курса «Nginx профессионально: от первого конфига до API-gateway на OpenResty и Angie»

Поделиться темой: ✈ Telegram VK
  • Похожие темы
Похожие запросы: Тюнинг и производительность nginxРаздача статики и SPA на nginxnginx и PHP-FPM: настройка FastCGIКэширование ответов в nginx (proxy_cache)Сжатие в nginx: gzip и brotli

Вернуться в «Nginx профессионально: от первого конфига до API-gateway на OpenResty и Angie»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 2 гостя