Данило (Dayfing)
Назад до публікацій
2 586 слів13 хв

Кешування статики в Nginx і Cloudflare: заголовки та очищення кешу

Віддавайте ассети з хешем в імені із заголовком Cache-Control: public, max-age=31536000, immutable, а HTML віддавайте браузерам з no-cache і задавайте для Cloudflare окремий короткий edge TTL. Зробіть HTML придатним до кешування через Cache Rule, залиште Browser Cache TTL у режимі Respect Existing Headers і очищуйте HTML останнім кроком кожного деплою. Тоді браузер під час кожного візиту перевіряє невеликий документ, edge сам відповідає на більшість таких перевірок, а новий реліз видно одразу після відповіді на запит purge.

Дві політики кешу для двох видів файлів

Статична збірка створює файли двох видів. Файли з відбитком, наприклад /_astro/index.3f9c1a.js, містять хеш вмісту в імені. Коли вміст змінюється, змінюється й URL, тому копія старого URL може рік лежати в будь-якому кеші. HTML, фіди, sitemap і robots.txt мають сталі URL, тож будь-яка їхня кешована копія може застаріти.

Тому хешовані файли отримують найдовший термін життя і ніколи не потребують очищення. Сталі URL отримують браузерну політику з повторною перевіркою та edge-політику, яку можна скинути. Основний виграш у швидкості дає перша група: повторний відвідувач завантажує невеликий HTML-документ, часто як відповідь 304, а всі скрипти, стилі та шрифти бере з локального кешу. Саме тому кешування є одним із найдешевших способів покращити Time to First Byte і Largest Contentful Paint, як пояснює посібник з Core Web Vitals.

Два правила деплою роблять цю схему безпечною. Завантажуйте нові хешовані файли до того, як HTML перемкнеться на новий реліз. Зберігайте хешовані файли попереднього релізу, поки HTML, що на них посилається, може лишатися в якомусь кеші, адже вкладка, відкрита до деплою, запитає старі імена.

Browser TTL і edge TTL: два різні годинники

Кеш браузера належить відвідувачу, і жодні ваші дії після відповіді не видалять із нього запис. Edge-кеш належить Cloudflare, і purge API очищує його за секунди. Тому довгий browser TTL припустимий лише для URL, вміст яких ніколи не змінюється. Для HTML директива no-cache дозволяє браузеру зберегти сторінку, але вимагає умовного запиту перед кожним повторним використанням.

Edge TTL можна задати через s-maxage, через Cloudflare-CDN-Cache-Control або CDN-Cache-Control чи через Cache Rule. Оскільки його можна скинути, edge TTL для HTML є страховкою, а не механізмом оновлення. Якщо purge не спрацював, найгірша затримка на edge дорівнює терміну свіжості плюс вікно stale-while-revalidate: для max-age=300, stale-while-revalidate=60 це 360 секунд.

Спочатку перевірте одне налаштування зони. Browser Cache TTL за замовчуванням дорівнює чотирьом годинам на всіх тарифах, і Cloudflare перевизначає терміни origin, менші за це значення. HTML, який має перевірятися щоразу, тоді може годинами жити в браузерах, куди не дістане жоден purge. Встановіть Browser Cache TTL у Respect Existing Headers, як описано в документації щодо edge і browser TTL.

Як Cloudflare читає s-maxage, stale-while-revalidate і stale-if-error

Браузери ігнорують s-maxage, тому Cache-Control: public, max-age=0, s-maxage=300 здається очевидним заголовком для HTML, і Cloudflare справді використовує його як edge TTL. Підступ описано в RFC 9111: s-maxage також несе семантику proxy-revalidate, тобто спільний кеш не повинен віддавати застарілу відповідь без попередньої перевірки.

На тарифах Free, Pro і Business функція Origin Cache Control увімкнена завжди, і Cloudflare дотримується цього правила. Його документація з ревалідації називає s-maxage, must-revalidate, proxy-revalidate і no-cache директивами, що вимикають видачу застарілих копій. Поруч зі stale-while-revalidate вони перетворюють UPDATING на EXPIRED, і відвідувач чекає на origin. Ці ж директиви змушують Cloudflare ігнорувати stale-if-error. Заголовок на кшталт max-age=0, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400 дає лише edge TTL у 300 секунд і нічого більше.

Коли stale-while-revalidate діє, ревалідація асинхронна: перший запит після закінчення терміну отримує застарілу копію з cf-cache-status: UPDATING, а Cloudflare оновлює її у фоні. stale-if-error спрацьовує лише на відповіді 5xx від origin. Always Online вимикає обидві директиви, а значення TTL мають бути цілими числами.

Щоб задати браузерам і edge різні політики без s-maxage, використовуйте адресний заголовок. Cloudflare перевіряє спершу Cloudflare-CDN-Cache-Control, потім CDN-Cache-Control, потім Cache-Control. Якщо CDN-заголовок є, Cache-Control доходить до браузера без змін і не впливає на edge, а Cloudflare-CDN-Cache-Control Cloudflare далі не передає. Для HTML:

Cache-Control: no-cache
Cloudflare-CDN-Cache-Control: max-age=300, stale-while-revalidate=60, stale-if-error=86400

Браузер перевіряє сторінку щоразу. Cloudflare зберігає її п'ять хвилин, до хвилини віддає застарілу копію під час оновлення і добу віддає останню робочу копію, якщо origin падає. Правила пріоритету описано в документації щодо CDN-Cache-Control.

Cache Rules: довіряти origin чи перевизначати

За замовчуванням Cloudflare вирішує, чи кешувати відповідь, за розширенням файлу і не кешує HTML та JSON. URL на кшталт /writing/post/ не має розширення, тому без правила кожен запит сторінки отримує DYNAMIC і йде на origin.

Cache Rule з Eligible for cache має три режими Edge TTL. respect_origin виконує ваші заголовки, а без них бере значення за замовчуванням, наприклад 120 хвилин для відповіді 200. bypass_by_default виконує заголовки, а без них не кешує. override_origin ігнорує заголовки й задає TTL примусово, причому мінімум залежить від тарифу: 2 години на Free, 1 година на Pro і 1 секунда на Business та Enterprise. Browser TTL можна взяти з origin, перевизначити або вимкнути.

Якщо origin віддає продумані заголовки, довіряйте їм. Цей набір правил для фази http_request_cache_settings робить хост придатним до кешування, а потім виключає динамічні шляхи. Для налаштувань кешу перемагає останнє правило, що збіглося, тому правило bypass стоїть останнім.

{
  "rules": [
    {
      "description": "Cache the site according to origin headers",
      "expression": "http.host eq \"example.com\"",
      "action": "set_cache_settings",
      "action_parameters": {
        "cache": true,
        "edge_ttl": { "mode": "respect_origin" },
        "browser_ttl": { "mode": "respect_origin" }
      }
    },
    {
      "description": "Never cache API routes and previews",
      "expression": "http.host eq \"example.com\" and (starts_with(http.request.uri.path, \"/api/\") or starts_with(http.request.uri.path, \"/preview/\"))",
      "action": "set_cache_settings",
      "action_parameters": { "cache": false }
    }
  ]
}

Не додавайте http.request.method eq "GET" до правила кешування. Cloudflare попереджає, що очищення за окремим URL може не спрацювати, якщо правило збігається лише з GET, бо запити purge всередині використовують інший метод. Повний перелік параметрів наведено в налаштуваннях Cache Rules.

Заголовки Nginx без пастки успадкування add_header

Nginx успадковує директиви add_header з верхнього рівня, лише якщо на поточному рівні їх немає. Щойно location додає власний Cache-Control, усі заголовки рівня server у ньому мовчки зникають, зокрема заголовки безпеки.

server {
    add_header X-Content-Type-Options "nosniff" always;

    location ^~ /_astro/ {
        # X-Content-Type-Options is no longer sent for this location.
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
}

Переносне рішення: винести спільні заголовки в сніпет і підключати його через include у кожному блоці з власним add_header. У Nginx 1.29.3 і новіших, зокрема в стабільній гілці 1.30, директива add_header_inherit merge натомість дописує успадковані заголовки. З merge не тримайте Cache-Control у блоці server, інакше location надсилатимуть його двічі.

Згідно з документацією модуля headers, add_header без always застосовується лише до відповідей 200, 201, 204, 206, 301, 302, 303, 304, 307 і 308. Використовуйте always для заголовків безпеки та для no-store на сторінках помилок, але ніколи для довготривалих заголовків кешу, інакше 404 на помилку в імені ассета стане immutable на рік. Також не змішуйте expires з add_header Cache-Control, бо це дає два поля заголовка. Блок server для статичної збірки, без TLS і журналювання:

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    root /srv/example.com/current;
    index index.html;

    gzip on;
    gzip_vary on;
    gzip_static on;
    gzip_types text/css application/javascript application/json
               application/xml application/rss+xml image/svg+xml text/plain;

    location ^~ /_astro/ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    location ~* \.(?:avif|webp|png|jpe?g|gif|svg|ico|woff2)$ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=86400";
        try_files $uri =404;
    }

    location ~* \.(?:xml|txt)$ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=300";
        add_header Cloudflare-CDN-Cache-Control "max-age=3600, stale-if-error=86400";
        add_header Cache-Tag "html";
        try_files $uri =404;
    }

    location / {
        include snippets/security-headers.conf;
        add_header Cache-Control "no-cache";
        add_header Cloudflare-CDN-Cache-Control "max-age=300, stale-while-revalidate=60, stale-if-error=86400";
        add_header Cache-Tag "html";
        try_files $uri $uri/ =404;
    }

    error_page 404 /404.html;
    location = /404.html {
        internal;
        include snippets/security-headers.conf;
        add_header Cache-Control "no-store" always;
    }
}

Для gzip_static потрібна збірка з --with-http_gzip_static_module, це видно у виводі nginx -V. Origin має приймати з'єднання лише від Cloudflare, щоб ніхто не обходив кеш. Налаштування файрвола для цього розбирає посібник із захисту Linux VPS.

ETag, Last-Modified і ревалідація

З etag on, що ввімкнено за замовчуванням, Nginx віддає для статичних файлів ETag і Last-Modified та відповідає на умовні запити кодом 304. Його ETag будується з часу зміни й розміру файлу, а не з хешу вмісту. Тому деплой, що перезаписує всі файли, змінює всі ETag, і перша ревалідація після релізу повертає повну відповідь 200. Якщо реліз роздають кілька серверів origin, час файлів на них має збігатися, наприклад під час копіювання через rsync -a, інакше результат ревалідації залежатиме від того, який сервер відповів.

Поки копія свіжа, Cloudflare сам відповідає на ревалідації браузерів. Після закінчення терміну він надсилає в Nginx умовний запит, і відповідь 304 подовжує TTL без передавання тіла. Коли Cloudflare змінює кодування, він перетворює сильний ETag на слабкий W/"...". Це безпечно, бо If-None-Match використовує слабке порівняння. Вмикайте Respect Strong ETags, лише якщо клієнту справді потрібні побайтові валідатори.

Стиснення: gzip на origin, Brotli і Zstandard на edge

Cloudflare запитує в origin accept-encoding: br, gzip і може перекодувати отриману відповідь. Відвідувачам він віддає gzip, Brotli або Zstandard залежно від Accept-Encoding, тарифу та Compression Rules. За замовчуванням зони Free надають перевагу Zstandard, Pro і Business обирають Brotli, а Enterprise використовує gzip. Стискаються лише відповіді 200, 403 і 404, як зазначено в документації щодо стиснення.

Для origin за Cloudflare достатньо gzip. Стискайте текстові файли заздалегідь під час збірки для gzip_static або ввімкніть стиснення на льоту через gzip on. За замовчуванням gzip_types містить лише text/html, тому перелічіть інші текстові типи й залиште gzip_vary on. Brotli і Zstandard не входять до набору модулів nginx.org і потребують сторонніх модулів. Не ставте Cache-Control: no-transform на текстові відповіді, бо тоді Cloudflare не стисне нестиснену відповідь.

Cookies, query string і ключ кешу

Ключ кешу Cloudflare за замовчуванням містить схему, хост, шлях і повний query string, а також кілька заголовків запиту, наприклад Origin. Cookies до нього не входять, тому кешований URL ніколи не повинен залежати від cookie. Сторінки для авторизованих користувачів, кошики, API чи бекенд агента виводьте через правило bypass з private або no-store чи на окремий хост. Як зазвичай виглядає такий динамічний шлях запиту, показує посібник з архітектури AI-агента для production.

Заголовок відповіді Set-Cookie ламає кешування. За Eligible for cache Cloudflare зберігає cookie і не кладе відповідь у кеш, тож кожен запит отримує MISS. Перевірте, що ні модуль, ні балансувальник не додають cookies до статичних файлів, або видаляйте їх через Cache Response Rule.

Кожен окремий query string створює окремий запис, тому посилання з ?utm_source= починають із промаху. Це знижує частку влучань, але не коректність. Рівень кешування Ignore Query String діє лише для статичних розширень файлів, а власні ключі за параметрами query string залежать від тарифу. Sort query string доступна на всіх тарифах.

Стратегії purge і очищення з CI

З квітня 2025 року всі методи purge доступні на будь-якому тарифі:

  • Single URL видаляє точні URL, до 100 за запит (500 на Enterprise), коли збірка знає, що змінилося.
  • Prefix видаляє все під шляхом на кшталт example.com/writing/, включно з варіантами з query string.
  • Tag видаляє всі об'єкти, у відповіді яких був відповідний заголовок Cache-Tag. Cloudflare прибирає цей заголовок, перш ніж відповідь побачить відвідувач. Позначте HTML, фіди й sitemap тегом html, і один виклик очистить усі сталі URL, а хешовані ассети залишаться в кеші.
  • Hostname видаляє все для одного хоста.
  • Everything очищує всю зону і спрямовує весь трафік на origin, доки кеш не заповниться знову, тому залиште його на крайній випадок.

Запити за hostname, tag, prefix і purge everything ділять ліміт акаунта: 5 запитів на хвилину на Free, 5 на секунду на Pro, 10 на секунду на Business і 50 на секунду на Enterprise, згідно з документацією щодо purge. Використовуйте API-токен лише з правом Cache Purge на одну зону і зберігайте його як секрет CI:

#!/usr/bin/env bash
set -euo pipefail
: "${CF_API_TOKEN:?}" "${CF_ZONE_ID:?}" "${ORIGIN_HOST:?}"

# 1. Hashed assets first, without --delete, so old pages keep working.
rsync -a dist/_astro/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/_astro/"

# 2. Everything else. Excluded paths are not deleted.
rsync -a --delete --exclude '/_astro/' dist/ "deploy@${ORIGIN_HOST}:/srv/example.com/current/"

# 3. Purge HTML, feeds and sitemaps at the edge.
curl -fsS --max-time 30 -X POST \
  "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"tags":["html"]}' </dev/null \
  | jq -e '.success == true' >/dev/null

Великим сайтам краще завантажувати збірку в каталог релізу й атомарно перемикати symlink. Успішна відповідь purge означає лише те, що запит прийнято, тому завершуйте задачу завантаженням зміненої сторінки й перевіркою маркера збірки, наприклад хешу коміту.

Перевірка через curl -I, cf-cache-status і Age

curl -sSI -H 'Accept-Encoding: zstd, br, gzip' https://example.com/writing/some-post/ \
  | grep -iE '^(cache-control|cf-cache-status|age|etag|content-encoding):'

Для кешованих запитів Cloudflare перетворює HEAD на GET, тому curl -I теж наповнює кеш. Перша відповідь має показати MISS, друга HIT з Age, тобто кількістю секунд від кешування або ревалідації об'єкта. Кожен статус визначено в документації щодо відповідей кешу:

  • DYNAMIC: запит не придатний до кешування, зазвичай тому що HTML не покрито правилом або ввімкнено Development Mode.
  • BYPASS: запит придатний, але відповідь не кешується через no-store, private, Set-Cookie або Vary: *.
  • UPDATING: віддано застарілу копію, поки триває фонова ревалідація.
  • EXPIRED: застарілу копію заново завантажено синхронно. Якщо ви чекали UPDATING, шукайте s-maxage, must-revalidate або no-cache.
  • REVALIDATED: origin підтвердив копію відповіддю 304, поки запит чекав.
  • STALE: origin не відповів, і було віддано стару копію.

Після purge очікуйте MISS або EXPIRED при Tiered Cache і після purge everything. Щоб перевірити лише Nginx, зверніться до origin з дозволеного хоста командою curl -sSI --resolve example.com:443:203.0.113.10 https://example.com/, потім додайте -H 'If-None-Match: "<etag>"' і очікуйте 304.

Рекомендовані заголовки за типами файлів

Тип файлу Приклад Cache-Control Політика edge Після деплою
JS, CSS, шрифти, зображення з відбитком /_astro/app.3f9c1a.js public, max-age=31536000, immutable З origin Нічого
HTML-сторінки /writing/post/ no-cache max-age=300, stale-while-revalidate=60, stale-if-error=86400 Purge тегу html
Фіди, sitemap, robots.txt /rss.xml public, max-age=300 max-age=3600, stale-if-error=86400 Purge тегу html
Зображення та іконки без хешу /favicon.ico public, max-age=86400 З origin Purge URL або нове ім'я
JSON, що змінюється між деплоями /data/stats.json public, max-age=60 З origin або bypass Purge URL
Сторінка 404 будь-який відсутній URL no-store з always Не кешується Нічого
API, прев'ю, адмінка /api/ private, no-store Правило bypass Нічого

Значення edge з max-age записуються в Cloudflare-CDN-Cache-Control. Якщо ви кешуєте відповіді 404 на edge, позначайте їх тегом html, інакше сторінка, опублікована пізніше, лишиться схованою за закешованим 404.

Чек-лист деплою

  • Імена ассетів містять хеші вмісту, і ніщо зі сталим URL не позначено immutable.
  • Кожен блок Nginx з add_header підключає заголовки безпеки, а always стоїть лише на заголовках безпеки й відповідях з помилками.
  • HTML віддає no-cache разом із Cloudflare-CDN-Cache-Control, без s-maxage поруч зі stale-while-revalidate.
  • gzip_types охоплює текстові типи, а статичні відповіді не містять Set-Cookie.
  • Browser Cache TTL враховує наявні заголовки, а правило bypass стоїть останнім і не обмежене лише GET.
  • CI завантажує ассети раніше за HTML, зберігає старі хешовані файли, очищує тег html і падає, якщо success не дорівнює true.
  • Сторінка показує MISS, потім HIT з Age, потім UPDATING після закінчення edge TTL.
  • Origin приймає трафік лише від Cloudflare.

Інші публікації