Почему ваш CDN ничего не кеширует

Каждый статический файл agentready.md возвращался с одной и той же строкой:

cf-cache-status: BYPASS

Шрифты, стили, скрипты, картинки. Не MISS — MISS означал бы, что край ещё не видел файл и оставит у себя только что забранную копию. BYPASS означает, что край прочитал ответ, решил, что не имеет права его хранить, и завтра решит так же. Каждый посетитель тянул все байты с origin и тянул бы их вечно.

Четыре настройки, которые были ни при чём

  • Development Mode — выключен. Он полностью гасит кеш на краю на три часа, и его часто забывают выключить, так что смотреть его нужно первым.
  • Caching Level — Standard.
  • Page Rules — ни одно не ставило Cache Level в Bypass.
  • Browser Cache TTL — вменяемое значение.

Всё, что панель говорила про кеш, говорило храни это. Край не хранил ничего.

Причина была в ответе, а не в панели

Она видна сразу, если прочитать весь блок заголовков, а не ту единственную строку, за которой пришли:

curl -sI https://agentready.md/fonts/ibm-plex-sans-latin.woff2
HTTP/2 200
content-type: font/woff2
cache-control: public, max-age=31536000, immutable
set-cookie: _csrf=8f3c…; Path=/; SameSite=Strict
cf-cache-status: BYPASS

Третья строка просит год. Четвёртая объясняет, почему его никто не даёт.

Ответ с Set-Cookie нельзя хранить в общем кеше. Кука принадлежит одному посетителю; сохранить такой ответ и отдать его следующему — значит вручить ему чужую куку. Отказывается любой CDN. Cloudflare даёт отказу имя BYPASS, остальные просто молча не сохраняют объект.

Кука была наша. Мидлвара генерировала CSRF-токен на каждый запрос и прикладывала его, чтобы форма, отрисованная в любом месте сайта, нашла токен готовым. В «каждый запрос» попадал и запрос шрифта woff2 — файла, который не рисует форм, не читает токенов и не выполняет кода.

Исправление

CSRF остаётся как был. Просто токен перестаёт выдаваться тому, кто не может им воспользоваться:

const ASSET_PREFIXES = ['/css/', '/js/', '/fonts/', '/images/'];
const COOKIELESS_FILES = new Set([
  '/favicon.ico', '/badge.js', '/robots.txt', '/sitemap.xml', '/llms.txt',
]);

function servesWithoutCookie(path) {
  return COOKIELESS_FILES.has(path)
    || ASSET_PREFIXES.some((prefix) => path.startsWith(prefix))
    || path.endsWith('.md');
}

Сравнивайте пути, а не расширения. Часть ответов, которым кеш нужнее всего, не читается с диска, а собирается шаблоном: markdown-двойник каждой страницы, наши SVG-бейджи. Форм они тоже не рисуют.

Если куку ставили не вы, посмотрите на слой сессий. Немало фреймворков открывают сессию и отправляют её куку на любой запрос, дошедший до мидлвары, даже если внутрь ничего так и не положили.

Как это диагностировать на любом стеке

Смотрите ассет, а не главную. Главная часто некешируема по законным причинам и ничего не говорит о тридцати файлах, которые она за собой тянет.

for u in / /css/app.css /js/app.js /fonts/ibm-plex-sans-latin.woff2; do
  echo "== $u"
  curl -sI "https://example.com$u" \
    | grep -iE 'cache-control|set-cookie|cf-cache-status|x-cache|^age'
done

Запросите один и тот же URL дважды. Второй раз должен быть попаданием: cf-cache-status: HIT, x-cache: Hit from cloudfront или растущий заголовок age: в Varnish и nginx. Второй промах означает, что объект не сохраняется, и причина — в заголовках, которые вы только что вывели.

Остальные обычные подозреваемые

Cache-Control: private — только браузеры, общий кеш никогда. no-store — никто, даже браузер. no-cache значит не «не кешируй», а «перепроверяй перед каждым использованием», то есть по одному круговому запросу на файл.

Vary: * делает ответ некешируемым сразу. Vary: Cookie почти так же плох: в ключ кеша попадает значение, своё у каждого посетителя, и общего не остаётся ничего. Vary: User-Agent дробит один файл на тысячи вариантов. Уместен здесь Accept-Encoding.

Строки запроса. Большинство кешей ключуется по полному URL, поэтому каждый вариант с ?utm_source= или ?fbclid= — отдельный объект, забираемый с origin. Срезайте или нормализуйте рекламные параметры на краю.

И дефолт фреймворка: не bypass, но обходится почти так же дорого. max-age=0 на каждом статическом файле означает по запросу на перепроверку для каждого ассета при каждом переходе.

Политика кеширования, которая держится

Правило про то, что обещает URL, а не про то, чем является файл.

URL с хешем содержимого (app.css?v=9f2ac41b, app.9f2ac41b.css) — это адрес содержимого: изменился файл, изменился и URL. Таким ставим public, max-age=31536000, immutable, и очищать никогда ничего не придётся. Тот же файл без токена получает час: токен и есть обещание, а кешировать на основании обещания, которого никто не давал, незачем. Логотип или обложку для соцсетей, которые меняют на том же месте, — на неделю.

Остаётся исключение, на котором ошибаются чаще всего, потому что файл выглядит самым кешируемым из всего, что у вас есть: то, что встроено в чужие страницы по URL, которому вы не можете дать версию. Мы отдаём badge.js на час ровно поэтому. Он работает внутри сайтов, где мы не управляем ни HTML, ни URL, так что год immutable означал бы баг, до которого год не дотянуться.

Если вы раздаёте файлы через @fastify/static

Две частности, стоившие нам половины дня. По умолчанию отправляется Cache-Control: public, max-age=0. И если заголовок ставите вы сами через setHeaders, передайте заодно cacheControl: false, иначе библиотека соберёт свой из дефолтного maxAge и перезапишет ваш — молча, оставив то, что выглядит как осознанное решение:

app.register(fastifyStatic, {
  root: join(__dirname, 'public'),
  cacheControl: false,           // иначе setHeaders будет перезаписан
  setHeaders: setStaticCacheHeaders,
});

Прежде чем трогать хоть одну настройку кеша, прочитайте один ответ целиком. Заголовок, который ломает кеширование, редко тот, в имени которого есть слово cache.

Проверить сайт · Чек-лист готовности к ИИ · Пускают ли ботов?