Разберём механику, а не рецепты, чтобы ты понимал, ПОЧЕМУ конфиг работает именно так, и мог чинить чужие конфиги по симптому.
Где лежат файлы: nginx root и alias
Вся nginx раздача файлов начинается с того, как директива маппит URI в путь на диске. Есть ровно два способа: root и alias. Они выглядят похоже, но склеивают путь по-разному, и в этом весь подвох.
root - это база, к которой URI приклеивается целиком. Полный путь к файлу = root плюс весь $uri.
Код: Выделить всё
server {
root /var/www/site;
location /static/ {
# запрос /static/css/app.css
# ищется файл /var/www/site/static/css/app.css
# root + ПОЛНЫЙ uri (включая /static/)
}
}
Код: Выделить всё
location /static/ {
alias /var/www/assets/;
# запрос /static/css/app.css
# /static/ заменяется на /var/www/assets/
# ищется файл /var/www/assets/css/app.css
}

Классическая грабля nginx root alias: слеш в конце
Это самая частая ошибка во всём nginx, и она стоит часов отладки. Правило простое, но контринтуитивное:
Смотри, как ломается. Location со слешем, alias без слеша:Если в location есть завершающий слеш, то и в alias он ОБЯЗАН быть. Несоответствие слешей ломает склейку пути.
Код: Выделить всё
# СЛОМАНО
location /static/ {
alias /var/www/assets; # нет слеша на конце!
}
# запрос /static/app.css
# nginx отбрасывает "/static/" и подставляет "/var/www/assets"
# к остатку "app.css" -> получается "/var/www/assetsapp.css"
# файла нет -> 404, хотя руками всё на месте
Код: Выделить всё
# ПРАВИЛЬНО
location /static/ {
alias /var/www/assets/;
}
# /static/app.css -> /var/www/assets/app.css
Вторая мина alias - его нельзя смешивать с регэксп-location без захвата группы, и про неё ниже, в разделе про try_files.
index и try_files: как nginx ищет файл
index срабатывает, когда запрос приходит на каталог (URI заканчивается слешем). Директива перебирает имена по списку и отдаёт первый существующий файл:
Код: Выделить всё
index index.html index.htm;
# запрос на /docs/ -> пробуем /docs/index.html, потом /docs/index.htm
Код: Выделить всё
location / {
try_files $uri $uri/ =404;
}
# 1. есть файл по $uri? отдаём его
# 2. есть каталог $uri/ ? обрабатываем как каталог (сработает index)
# 3. ничего нет -> отдаём 404
- =404 (или любой код) - честно вернуть ошибку. Для каталогов со статикой это правильно: если файла нет, его и нет.
- /index.html - внутренний редирект на конкретный URI. Это и есть паттерн SPA.
- @fallback - передать в именованную локацию, где обычно стоит proxy_pass на бэкенд. Так строят гибрид "статика с диска, иначе в приложение".
Код: Выделить всё
location / {
try_files $uri $uri/ @backend;
}
location @backend {
proxy_pass http://app_upstream;
}
Паттерн nginx SPA: try_files и /index.html
Одностраничные приложения (React, Vue, Angular, Svelte) роутятся на клиенте. Сервер физически не имеет файла /users/42/profile - этот путь существует только в JS-роутере браузера. Если пользователь обновит страницу на таком URL, nginx по умолчанию вернёт 404. Лечится это одной строкой:
Код: Выделить всё
location / {
try_files $uri $uri/ /index.html;
}
Но в этом паттерне есть тонкая ловушка с кэшем. Хэшированные ассеты (main.a3f2c1.js) можно и нужно кэшировать агрессивно - их имя меняется при каждой сборке. А вот сам index.html кэшировать в браузере НЕЛЬЗЯ, иначе пользователь застрянет на старой версии приложения и будет тянуть несуществующие бандлы. Поэтому ассеты и index.html разводят по разным location:
Код: Выделить всё
# хэшированная статика - кэшируем надолго (как expires 30d в эталоне)
location ~* \.(?:js|css|woff2|png|jpe?g|svg|webp|ico)$ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404; # тут НЕ фолбэк на index.html!
access_log off;
}
# всё остальное - в SPA-фолбэк
location / {
try_files $uri $uri/ /index.html;
}
# index.html - запретить кэш браузера
location = /index.html {
add_header Cache-Control "no-cache";
}
autoindex: листинг каталога
Когда запрос пришёл на каталог, а ни одного index-файла нет, по умолчанию nginx вернёт 403 Forbidden. Директива autoindex on включает генерацию HTML-листинга содержимого:
Код: Выделить всё
location /downloads/ {
autoindex on;
autoindex_exact_size off; # размеры в КБ/МБ, а не в байтах
autoindex_localtime on; # время файлов в локальной зоне
}
Эффективная nginx раздача файлов: sendfile, tcp_nopush, tcp_nodelay
Теперь про скорость. Эти три директивы из эталонного global_tcp.conf отвечают за то, как байты файла уходят в сеть. Работают они в связке, и понимать их надо вместе.
sendfile on - ключевая оптимизация nginx sendfile. Обычная отдача файла - это цикл: ядро читает блок файла в буфер nginx (копия 1), nginx копирует его в сокет (копия 2), ядро отправляет. sendfile - это системный вызов, который передаёт данные из файла напрямую в сокет внутри ядра, минуя пользовательское пространство. Меньше копирований, меньше переключений контекста, ниже нагрузка на CPU. На статике это даёт ощутимый прирост.
tcp_nopush on (работает ТОЛЬКО при sendfile on) - включает опцию TCP_CORK. Она говорит ядру: "не отправляй пакеты, пока не наберётся полный сегмент". Без неё HTTP-заголовки уходят в одном маленьком пакете, а тело файла - следующими. С tcp_nopush заголовки и начало файла склеиваются в один полный пакет - меньше пакетов в сети, выше эффективность.
tcp_nodelay on - отключает алгоритм Нейгла (TCP_NODELAY). Нейгл придерживает мелкие пакеты, копя их в более крупный, и добавляет задержку до 200 мс. Для интерактива (API, keepalive) эта задержка - зло.
Кажется, что nopush (придержи) и nodelay (не придерживай) противоречат друг другу. Но nginx умён: он применяет CORK на основной массе файла, а на ПОСЛЕДНЕМ пакете снимает CORK и включает nodelay, чтобы хвост ушёл немедленно без 200 мс ожидания. Поэтому канонический набор - все три включены, как в эталоне:
Код: Выделить всё
# global_tcp.conf
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_time 1h;
types_hash_max_size 4096;
При каждом запросе nginx делает open(), stat() и в конце close() для файла. Под нагрузкой на одни и те же популярные файлы это тысячи лишних системных вызовов в секунду. open_file_cache (из эталонного global_cache.conf) кэширует метаданные: открытые дескрипторы, размеры, время модификации, и даже факт отсутствия файла и ошибки доступа.
Код: Выделить всё
# global_cache.conf
open_file_cache max=2000 inactive=20s;
open_file_cache_valid 60s;
open_file_cache_min_uses 5;
open_file_cache_errors on;
- max=2000 - максимум элементов в кэше; при переполнении вытесняются наименее используемые (LRU).
- inactive=20s - если к закэшированному файлу не обращались 20 секунд, элемент выкидывается. Это НЕ срок валидности, а таймаут простоя.
- open_file_cache_valid 60s - раз в 60 секунд nginx перепроверяет на диске, актуальна ли запись (не изменился ли, не удалён ли файл).
- open_file_cache_min_uses 5 - файл попадает в кэш только после 5 обращений за период inactive. Защита от засорения кэша одноразовыми запросами.
- open_file_cache_errors on - кэшировать и негативный результат: если файла нет, запоминаем это и не дёргаем диск повторно. Сильно помогает при try_files, где много заведомо несуществующих путей (тот же SPA, где почти каждый $uri - промах).
Заголовки кэша браузера: expires и Cache-Control
Самый дешёвый способ ускорить сайт - заставить браузер не запрашивать статику повторно. Директива expires проставляет заголовки Expires и Cache-Control с нужным TTL. Эталонный паттерн - expires 30d плюс public для статики:
Код: Выделить всё
location ~* \.(?:css|js|woff2|png|jpe?g|gif|svg|webp|ico)$ {
expires 30d;
add_header Cache-Control "public";
access_log off;
log_not_found off;
}
Range-запросы и большие файлы
Когда отдаёшь видео, дистрибутивы, большие архивы - в дело вступают Range-запросы (HTTP 206 Partial Content). Браузер или плеер просит "дай байты с 1000000 по 2000000", чтобы перематывать видео и докачивать прерванное. nginx поддерживает Range для статики из коробки - он анонсирует Accept-Ranges: bytes и корректно отдаёт куски через sendfile. Тебе ничего настраивать не надо, главное - не сломать это самому: некоторые цепочки фильтров (например, gzip на лету) убивают Range, поэтому большие бинарники не сжимай gzip-ом (они и не сжимаются толком), оставь Range работать. Для совсем больших файлов следи за тем, чтобы отдача шла именно с диска через sendfile, а не буферизовалась в память.
Грабли try_files и alias вместе
Отдельная мина - сочетание try_files с alias. Исторически try_files внутри location с alias склеивает путь некорректно (баг тянется годами, см. nginx trac ticket 97). Симптом: фолбэк-аргумент резолвится не от alias, а от root, и ты получаешь 404 на ровном месте. Лечение - либо вообще не мешать try_files с alias, а перейти на root, либо в регэксп-location использовать alias с захватом группы:
Код: Выделить всё
# рабочий обход для regex-location + alias
location ~ ^/files/(?<rest>.*)$ {
alias /var/www/storage/$rest;
try_files $uri =404;
}
Мини-лаба: собери раздачу статики и SPA руками
Повтори по шагам на тестовом nginx (или в Docker openresty/openresty, как в эталоне).
- 1. Создай каталог: mkdir -p /srv/spa/assets, положи туда index.html (с любым текстом) и assets/app.js.
- 2. Напиши server с глобальными sendfile/tcp_nopush/tcp_nodelay и open_file_cache:
Код: Выделить всё
server {
listen 8081;
root /srv/spa;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
open_file_cache max=1000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_errors on;
location ~* \.(?:js|css|png|svg|woff2)$ {
expires 30d;
add_header Cache-Control "public";
try_files $uri =404;
access_log off;
}
location / {
try_files $uri $uri/ /index.html;
}
}
- 3. Проверь конфиг: nginx -t, перезагрузи: nginx -s reload.
- 4. curl -i http://127.0.0.1:8081/assets/app.js - убедись, что есть Expires и Cache-Control: public, Accept-Ranges: bytes.
- 5. curl -i http://127.0.0.1:8081/users/42 - это виртуальный SPA-маршрут, файла нет. Должен вернуться index.html с кодом 200 (а не 404). Это работает фолбэк.
- 6. curl -i http://127.0.0.1:8081/assets/missing.js - несуществующий ассет. Должен быть честный 404, а НЕ index.html (проверка, что =404 на месте).
- 7. Сломай намеренно: добавь location /static/ { alias /srv/spa; } (без слеша), запроси /static/index.html, поймай 404 и почувствуй грабли склейки на своей шкуре. Потом добавь слеш и убедись, что починилось.
- 1. В чём разница склейки пути у root и alias и почему alias без завершающего слеша ломает раздачу при location со слешем?
- 2. Что вернёт try_files $uri $uri/ /index.html на запрос к несуществующему виртуальному маршруту SPA, и почему для location со статикой фолбэк должен быть =404, а не /index.html?
- 3. Почему sendfile, tcp_nopush и tcp_nodelay включают вместе, хотя nopush и nodelay звучат противоположно? Что nginx делает с последним пакетом?
- 4. Что именно кэширует open_file_cache, зачем нужен open_file_cache_errors on и почему это особенно полезно с try_files?
Раздача статики - это фундамент, и он держится на четырёх вещах. Где искать файл (root по умолчанию, alias осознанно и со слешами), как искать (try_files с правильным последним аргументом: =404 для ассетов, /index.html для SPA, @location для гибрида), как отдавать быстро (sendfile + tcp_nopush + tcp_nodelay + open_file_cache) и как разгрузить себя за счёт браузера (expires 30d, Cache-Control public для хэшированной статики). Запомни две главные грабли - слеш у alias и фолбэк на index.html там, где должен быть 404 - и ты обойдёшь 90 процентов чужих ошибок в nginx-конфигах статики.