# теория · шаг 2 из 6
expires, immutable и always
Коротко
Умолчание заменяется политикой: разным типам файлов - разный срок.
expires 30dставит сразу два поля:Expiresс абсолютной датой иCache-Control: max-age=2592000.expiresне действует на коды ошибок - на 404 в том же блоке не уйдёт ничего.immutableдирективаexpiresне умеет, его пишут черезadd_header. И комбинировать их не надо: в ответ уедут два поляCache-Control.immutableставится только на адреса с хешем в имени. Дляlogo.png- никогда.- Заголовкам безопасности (вроде
X-Content-Type-Options) нуженalways, а ловушку «свойadd_headerвытесняет внешние» с 1.29.3 снимаетadd_header_inherit merge.
Если политика «html не кешируем, ассеты навсегда» уже настроена - листай до «Теперь сам».
Сначала ответь сам
Популярный рецепт для версионированных ассетов, который встречается в половине инструкций:
location /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
Что увидит клиент в заголовках ответа?
Инстинктивный ответ - одну строку Cache-Control с immutable. Настоящий -
две строки Cache-Control, и в первой никакого immutable нет.
На что это похоже
Кеш-политика - это сроки годности на разных полках склада.
На консервах ставят год: состав не меняется, проверять нечего. На хлебе - день, и его пересматривают каждое утро. А на витрине с описанием ассортимента срока нет вовсе: её читают заново каждый раз, иначе покупатель придёт за товаром, которого уже нет.
index.html - это витрина. Она перечисляет, какие файлы сейчас актуальны.
Ассеты с хешем в имени - консервы: содержимое по этому адресу не изменится
никогда. Ошибка почти всех - поставить один срок на весь склад.
Механизм: что именно пишет expires
Директива принимает срок, а в ответ кладёт два поля. Проверено на nginx 1.31.5.
location /kesh/ { expires 30d; }
curl -sI localhost/kesh/f.css
HTTP/1.1 200 OK
Server: nginx/1.31.5
Date: Thu, 10 Sep 2026 19:53:00 GMT
Content-Type: text/css
Content-Length: 7
Last-Modified: Thu, 10 Sep 2026 19:52:58 GMT
Connection: keep-alive
ETag: "6aa30a9a-7"
Expires: Sat, 10 Oct 2026 19:53:00 GMT
Cache-Control: max-age=2592000
Accept-Ranges: bytes
Expires - абсолютной датой, ради HTTP/1.0, и отсчитывается она от момента
запроса: Date 10 сентября, Expires 10 октября. Cache-Control - в
секундах, и при конфликте главнее он. Единицы срока: ms, s, m, h, d,
w, M (месяц), y (год); expires 1y даёт max-age=31536000 - ровно 365
суток. Обычное значение и три особых:
| Запись | Что в ответе |
|---|---|
expires 30d; |
Expires через 30 дней от запроса и Cache-Control: max-age=2592000 |
expires -1; |
Cache-Control: no-cache, Expires с текущим временем |
expires epoch; |
Cache-Control: no-cache, Expires с 1970 годом |
expires off; |
ничего не добавляется |
-1 и epoch дают одинаковый Cache-Control и различаются только датой в
Expires - то есть на практике равнозначны.
expires не действует на ошибки
Тот же блок, запрос несуществующего файла:
curl -sI localhost/kesh/netu.css
HTTP/1.1 404 Not Found
Server: nginx/1.31.5
Date: Thu, 10 Sep 2026 19:53:00 GMT
Content-Type: text/html
Content-Length: 153
Connection: keep-alive
Ни Cache-Control, ни Expires.
expires работает на «безопасных» кодах, как и add_header без always.
Для кеш-заголовков это обычно и правильно - кешировать 404 незачем, - но
знать стоит: политика, написанная через expires, на ошибках не действует.
Правило
Срок ставят по типу содержимого, а не на весь сайт: точка входа - перепроверять всегда, версионированное - навсегда, остальное - на разумный срок.
Разбор: почему два Cache-Control
Вернёмся к конфигу из начала урока.
curl -sI localhost/assets/f.css | grep -E 'Expires|Cache-Control'
Expires: Fri, 10 Sep 2027 19:53:00 GMT
Cache-Control: max-age=31536000
Cache-Control: public, max-age=31536000, immutable
Первую строку положила expires 1y, вторую - add_header. Они не заменяют
друг друга, а складываются: это разные директивы, и каждая делает своё.
Работать это будет - по спецификации получатель объединяет одноимённые поля
через запятую, - но читается плохо, а при правке одного из двух значений сайт
получит противоречивую пару. Выбирают что-то одно, и для immutable выбор
очевиден: expires его не умеет.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
try_files $uri =404 здесь ради того же, что в уроке про SPA: пропавший ассет
получает честный 404, а не страницу приложения.
immutable только для адресов с версией
immutable - обещание браузеру: «содержимое по этому URL никогда не
изменится». Нарушишь - и у пользователя год провисит старый файл, а сделать с
этим ты уже ничего не сможешь: запроса не будет, значит и повлиять не на что.
Поэтому ставят его только там, где имя файла содержит хеш содержимого:
app.4f2c1b.js, style.9a71e0.css. Для logo.png - никогда.
Разбор: где наивное правило подводит
Наивное правило: «поставил add_header Cache-Control в блок - политика
готова».
Готова, но заодно ты выключил всё, что было задано выше. Помнишь правило из
второго раздела: если на уровне есть свой add_header, заголовки внешних
уровней отбрасываются целиком.
add_header X-Content-Type-Options nosniff always;
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
# nosniff отсюда исчез
}
X-Content-Type-Options: nosniff запрещает браузеру угадывать тип файла по
содержимому - это один из заголовков безопасности.
Годами это лечили дублированием. С версии 1.29.3 есть строка, которая говорит это прямо:
add_header_inherit merge;
add_header X-Content-Type-Options nosniff always;
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
# теперь в ответе оба заголовка
}
И вторая половина того же правила: add_header без always не срабатывает на
кодах ошибок. Для кеш-заголовков это неважно, для заголовков безопасности -
важно: страница 404 останется без nosniff. Отсюда простое разделение - у
заголовков безопасности always, у кеш-заголовков не нужен.
Проверь, что версия свежая
add_header_inherit нет в 1.28 и более ранних версиях - конфиг просто не соберётся с
unknown directive. Посмотри nginx -v перед тем, как закладываться на неё:
в сборках из репозиториев дистрибутивов версия часто отстаёт.
Что ломается без этого
- Две строки
Cache-Controlв ответе. Смешаныexpiresиadd_header. Оставь что-то одно. - Пользователи сидят на старой версии приложения.
index.htmlзакеширован надолго; ему нуженexpires -1. - У пользователя год висит сломанный файл.
immutableпоставлен на адрес без хеша в имени. - Заголовки безопасности пропали на части адресов. Свой
add_headerвытеснил внешние; лечитсяadd_header_inherit merge. - Заголовки безопасности пропали на страницах ошибок. Забыт
always.
Зачем это в работе
Политика кеша - это три решения, и принимают их именно в таком порядке:
server {
listen 80;
server_name shop.local;
root /var/www/shop/dist;
index index.html;
add_header_inherit merge;
add_header X-Content-Type-Options nosniff always;
# 1. точка входа: всегда перепроверять, иначе люди застрянут на старой версии
location = /index.html { expires -1; }
# 2. имена с хешем: версия зашита в адрес, можно на год
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# 3. картинки без версии в имени: месяц, компромисс
location /images/ { expires 30d; }
location / { try_files $uri /index.html; }
}
Читается как список решений, а не как набор директив. И проверяется он тоже списком - по одному запросу на каждое решение:
curl -sI https://сайт/ | grep -i cache-control # ждём no-cache
curl -sI https://сайт/assets/app.4f2c1b.js | grep -i cache-control # ждём immutable
curl -sI https://сайт/images/logo.png | grep -i cache-control # ждём max-age на месяц
Проверено: / получает no-cache - запрос на каталог уходит в index.html и
попадает в location = /index.html. Три строки в скрипте выкладки ловят
потерянный блок раньше, чем это заметит
пользователь, застрявший на прошлой версии.
Теперь сам
1. Почему index.html кешировать нельзя, а app.4f2c1b.js - можно на год?
Потому что имя ассета содержит хеш содержимого: новая сборка - новое имя,
новый адрес, старый кеш просто перестаёт использоваться. У index.html адрес
один и тот же всегда, и именно он перечисляет, какие ассеты сейчас актуальны.
Закешировал витрину - человек ходит по старому списку.
2. В блоке стоит expires 30d, а на 404 из этого блока заголовков кеша
нет. Это поломка?
Нет, так и задумано: expires работает на успешных кодах. Кешировать ошибку
незачем. Знать об этом стоит только затем, чтобы не искать несуществующую
проблему.
3. Сайт за CDN. Что изменится в политике?
Добавится осмысленность у public и private: public разрешает хранить
копию и промежуточным узлам, private - только браузеру. Ассеты с хешем -
public, персональные ответы - private или вовсе no-store. Ошибка в эту
сторону дороже обычной: чужой персональный ответ, попавший в общий кеш, увидит
следующий посетитель.
Главное
expires ставит Cache-Control: max-age и Expires, но не работает на кодах
ошибок и не умеет immutable - его пишут через add_header, и смешивать их не
надо. immutable ставится только на адреса с хешем в имени. Заголовкам
безопасности нужен always, а ловушку «свой add_header вытесняет внешние» с
1.29.3 снимает add_header_inherit merge.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий