Files

8 lines
18 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 310,
"slug": "editorial-2019-05-field-http-caching",
"title": "Один URL, два ответа: как не склеить варианты в HTTP-кэше",
"excerpt": "После включения CDN пользователь получает не тот язык или старую версию страницы. Разбираем cache key, Vary и ETag на контролируемом примере и проверяем результат через origin и edge.",
"contentHtml": "<p>В учебном сценарии утром после релиза дежурный инженер открыл <code>/catalog</code> и заметил странный результат: запрос с <code>Accept-Language: en</code> получил русский HTML. На origin уже была новая подпись, но публичный адрес иногда отдавал старую. Инженер сначала решил очистить CDN, затем сравнил ответы по слоям. Цена ошибки — неверный интерфейс и часы на спор между frontend, backend и CDN; если в ответе есть персональные данные, ошибка кэша становится утечкой.</p>\n<p>Этот разбор отвечает на один вопрос: как доказать, что два запроса с одним URL получают свои представления. Сначала проверяем, что именно создаёт origin (источник ответа), затем смотрим, как edge (пограничный кэш CDN) выбирает сохранённый объект, и только после этого проверяем свежесть через <code>ETag</code>. Очистка кэша может скрыть симптом, но не подтверждает правильность контракта.</p>\n<h2>Разбор ситуации: один URL, две версии</h2>\n<p>Представим публичный URL <code>https://www.example.test/catalog</code>. Тело ответа зависит от одного входа — <code>Accept-Language</code>. Для <code>ru</code> и <code>en</code> это два разных представления одного ресурса. Они могут иметь одинаковый путь, но кэш не должен считать их взаимозаменяемыми.</p>\n<p>Первое предположение дежурного — в CDN осталась старая запись. Проверка origin изменила картину: origin действительно различал языки, а edge возвращал один и тот же digest тела. В этот момент purge перестал быть диагностикой. Нужно было проверить, объявляет ли ответ зависимость через <code>Vary</code> и совпадает ли это правило с фактическим cache key провайдера.</p>\n<h2>Как кэш выбирает сохранённый ответ</h2>\n<p>Минимальный cache key включает метод и целевой URI. Для согласования содержимого кэш может хранить несколько ответов на один URI. Заголовок <code>Vary</code> сообщает, какие request headers исходного запроса участвуют в сопоставлении: если ответ содержит <code>Vary: Accept-Language</code>, значение этого поля должно совпасть с запросом, который кэш хочет обслужить без повторной проверки.</p>\n<p><code>Vary</code> — часть HTTP-контракта ответа. Настройка CDN может дополнительно включать в ключ cookie, query-параметр или нормализованное значение заголовка. Поэтому наличие <code>Vary</code> не доказывает, что конкретный edge настроен верно: нужно сравнить фактические тела и заголовки на origin и публичном адресе. Если тело не зависит от языка, добавлять <code>Vary</code> «на всякий случай» не следует — это создаст лишние варианты.</p>\n<p>Свежесть — отдельная координата. <code>max-age</code> задаёт срок свежести для обычного кэша, а <code>s-maxage</code> может задать отдельный срок для shared cache. Директива <code>private</code> ограничивает хранение ответа общим кэшем, а <code>no-store</code> запрещает его хранить. Ни одна из этих директив не выбирает язык и не исправляет неполный ключ.</p>\n<table><thead><tr><th scope='col'>Что наблюдаем</th><th scope='col'>Что это означает</th><th scope='col'>Что проверить</th></tr></thead><tbody><tr><td>Origin возвращает разные тела</td><td>Представление зависит от входа</td><td>Записать вход, digest тела и response headers</td></tr><tr><td>В ответе нет <code>Vary</code></td><td>Зависимость от request header не объявлена</td><td>Сверить фактические входы генератора</td></tr><tr><td>Origin различает, edge склеивает</td><td>Правило cache key не соответствует варианту</td><td>Сравнить холодные запросы и настройки CDN</td></tr><tr><td>Тело верное, но старое</td><td>Ответ ещё свежий или revalidation не работает</td><td>Проверить <code>Age</code>, TTL, <code>ETag</code> и <code>304</code></td></tr><tr><td>В ответе есть пользовательские данные</td><td>Shared cache может отдать объект другому пользователю</td><td>Проверить <code>Authorization</code>, cookie и <code>Cache-Control</code></td></tr></tbody></table>\n<pre><code>GET /catalog HTTP/1.1\nHost: www.example.test\nAccept-Language: en\n\nHTTP/1.1 200 OK\nCache-Control: public, max-age=60\nVary: Accept-Language\nETag: \"catalog-en-v18\"\n\n&lt;h1&gt;Catalog&lt;/h1&gt;</code></pre>\n<p>Обмен выше учебный: домен, тело и значение <code>ETag</code> придуманы и не являются результатом измерения. В реальной системе значение тега непрозрачно для клиента; важно, что оно идентифицирует выбранное представление, а не то, как именно сервер его вычисляет.</p>\n<h2>Сначала докажите различие на origin</h2>\n<p>Начните с безопасного тестового домена без персональных данных. Меняйте только язык, сохраняйте заголовки и тела отдельно. Не добавляйте одновременно cookie, User-Agent и экспериментальные параметры: иначе после этого нельзя будет сказать, какой вход изменил результат.</p>\n<pre><code>curl -sS -L -D /tmp/catalog-ru.headers \\\n -o /tmp/catalog-ru.html \\\n -H \"Accept-Language: ru\" \\\n https://origin.example.test/catalog\n\ncurl -sS -L -D /tmp/catalog-en.headers \\\n -o /tmp/catalog-en.html \\\n -H \"Accept-Language: en\" \\\n https://origin.example.test/catalog\n\nsha256sum /tmp/catalog-ru.html /tmp/catalog-en.html\ndiff -u /tmp/catalog-ru.html /tmp/catalog-en.html || true\ngrep -Ei \"^(cache-control|vary|etag|last-modified):\" /tmp/catalog-ru.headers\ngrep -Ei \"^(cache-control|vary|etag|last-modified):\" /tmp/catalog-en.headers</code></pre>\n<p>Команды предназначены для стенда и здесь не выполнялись. Разные digest и осмысленный diff подтверждают, что origin сформировал разные тела. В обоих ответах ищите <code>Vary: Accept-Language</code>. Если origin уже отдаёт неверный язык, не начинайте с purge: исправьте выбор представления или передачу заголовка.</p>\n<figure><img src='/assets/editorial/2019/http-cache-variant-check-2019.svg' alt='Проверка вариантов HTTP-кэша через origin и CDN с разными языками и ETag' loading='lazy' /><figcaption>Сначала сравниваются два ответа origin, затем те же варианты на edge. Digest тела показывает склейку даже тогда, когда статус обоих запросов равен 200.</figcaption></figure>\n<h2>Поворот расследования: origin и edge расходятся</h2>\n<p>После проверки origin инженер повторяет те же запросы через публичный адрес. Статус <code>200</code> сам по себе ничего не доказывает: сервер мог вернуть HTML от другого варианта. Нужны тело, итоговый URL после redirect, <code>Content-Type</code>, <code>Cache-Control</code>, <code>Vary</code>, <code>ETag</code> и, если его добавляет shared cache, <code>Age</code>. Отсутствие <code>Age</code> не доказывает обращение к origin.</p>\n<pre><code>for lang in ru en; do\n curl -sS -L -D \"/tmp/edge-$lang.headers\" \\\n -o \"/tmp/edge-$lang.html\" \\\n -H \"Accept-Language: $lang\" \\\n https://www.example.test/catalog\ndone\n\nsha256sum /tmp/edge-ru.html /tmp/edge-en.html\ngrep -Ei \"^(cache-control|vary|etag|age):\" /tmp/edge-ru.headers\ngrep -Ei \"^(cache-control|vary|etag|age):\" /tmp/edge-en.headers</code></pre>\n<p>Выполните каждую пару на холодном тестовом ключе или явно зафиксируйте состояние кэша. Если origin различает тела, а edge отдаёт один digest, причина находится между ними: cache key, нормализация заголовка, proxy или правило провайдера. RFC описывает поведение HTTP-кэша, но конкретный CDN может иметь собственные настройки ключа; их нужно сверить с документацией провайдера и подтвердить запросами.</p>\n<p>Если публичный ответ содержит персональные данные, не пытайтесь лечить это добавлением всех cookie в ключ. Такой ключ может раздробить кэш и всё равно оставить ошибку в политике хранения. Для личного HTML безопаснее отказаться от shared cache и проверить директивы <code>private</code> или <code>no-store</code> согласно модели данных.</p>\n<h2>ETag и условный запрос</h2>\n<p><code>ETag</code> — непрозрачный валидатор выбранного представления. Он может различать версии ресурса во времени и одновременно существующие варианты content negotiation. При <code>If-None-Match</code> сервер сравнивает тег с выбранным представлением запроса; для GET при совпадении возвращается <code>304 Not Modified</code> без тела. Поэтому язык в условном запросе должен выбирать тот же вариант, для которого был снят тег.</p>\n<p>Сначала получите тег для одного языка, затем повторите запрос с тем же <code>Accept-Language</code>. После контролируемого изменения представления ожидайте новый тег и <code>200</code>. Случайно меняющийся тег ломает повторное использование, а неизменный тег после изменения позволяет отдать устаревшее представление. Сравнивать тег от <code>ru</code> с запросом <code>en</code> нельзя: это уже другая выбранная репрезентация, даже если URL совпадает.</p>\n<pre><code>curl -sS -L -D /tmp/catalog-ru-first.headers -o /tmp/catalog-ru-first.html \\\n -H \"Accept-Language: ru\" \\\n https://www.example.test/catalog\n\nETAG=$(awk 'BEGIN{IGNORECASE=1} /^etag:/ {sub(/^etag:[[:space:]]*/, \"\"); print; exit}' \\\n /tmp/catalog-ru-first.headers)\n\ncurl -sS -L -D - -o /dev/null \\\n -H \"Accept-Language: ru\" \\\n -H \"If-None-Match: $ETAG\" \\\n https://www.example.test/catalog</code></pre>\n<p>Ожидаемый <code>304</code> означает, что выбранное представление не изменилось и валидатор совпал; это не доказательство попадания в CDN, потому что условный запрос может обработать origin. Для проверки edge отдельно фиксируйте публичный URL и доступные признаки кэша. После изменения только русской версии повторите оба шага и убедитесь, что английский тег и тело не изменились случайно.</p>\n<h2>Порядок проверки</h2>\n<ol><li>Выберите тестовый URL без персональных данных и назовите единственный вход, который должен менять тело.</li><li>Снимите два ответа origin, меняя только <code>Accept-Language</code>; сохраните входы, заголовки, тела и digest.</li><li>Сравните тела. Если они различаются, проверьте, что каждый response header содержит согласованный <code>Vary</code>.</li><li>Повторите те же запросы через публичный edge и запишите итоговый URL, статус, заголовки и digest каждого тела.</li><li>Если ответы склеились, проверьте cache key, нормализацию заголовка и правило провайдера; не начинайте с полной очистки кэша.</li><li>Для одного языка снимите <code>ETag</code> и выполните условный GET с тем же языком и <code>If-None-Match</code>.</li><li>Измените только выбранное представление и повторите запрос: ожидайте новый тег и <code>200</code> с новым телом, если сервер применяет этот контракт.</li><li>Запишите команды и ожидаемые признаки в runbook или автоматическую проверку, а затем удалите тестовые персональные данные.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Один <code>Vary</code> не описывает всю конфигурацию CDN. Тело может зависеть от cookie, <code>Authorization</code>, географии, устройства, query-параметра или эксперимента. Каждый реальный вход нужно включить в модель варианта либо исключить shared caching. Redirect, CORS, service worker и серверная нормализация могут изменить наблюдаемую загрузку, поэтому проверяйте итоговый ответ.</p>\n<p>Сценарий не сообщает production-результаты: домены, языки, теги и digest выше учебные. Работа готова, когда origin отдаёт ожидаемые тела, заголовки объявляют реальные входы, edge не смешивает варианты, а условный запрос проверен для того же выбранного представления. Вернувшись к утренней сцене, инженер оставляет purge аварийным действием, а доказательством считает повторяемую пару запросов и зафиксированный результат.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://www.rfc-editor.org/rfc/rfc9111.html' target='_blank' rel='noopener'>RFC 9111: HTTP Caching</a> — состав cache key, сопоставление вариантов через <code>Vary</code>, свежесть, validation и правила shared cache.</li><li><a href='https://www.rfc-editor.org/rfc/rfc9110.html' target='_blank' rel='noopener'>RFC 9110: HTTP Semantics</a> — selected representation, <code>ETag</code>, <code>If-None-Match</code>, weak comparison и ответ <code>304 Not Modified</code>.</li></ul>"
}