Компьютерные сети CDN и edge: как работает раздача, инвалидация, географическая маршрутизация
0%

CDN и edge: как работает раздача, инвалидация, географическая маршрутизация

CDN и edge: как работает раздача, инвалидация, географическая маршрутизация

Возьмите линейку и посчитайте. Свет в одномодовом волокне идёт со скоростью примерно 200 000 км/с — на треть медленнее, чем в вакууме, из-за коэффициента преломления стекла. Франкфурт и Сан-Паулу разделяет 9800 км по большому кругу, но кабель не летит по прямой: реальная длина трассы через подводные системы — около 14 000 км. Односторонняя задержка — 70 мс, RTT — 140 мс. На практике измеряется 180–210 мс: добавляются регенераторы, оптические кроссы, очереди в маршрутизаторах и последняя миля.

Теперь сложите бюджет одного холодного HTTPS-запроса к серверу во Франкфурте из Бразилии:

Шаг RTT Накопленное время
DNS-резолв (не в кэше) 1 × 190 мс 190 мс
TCP-рукопожатие 1 × 190 мс 380 мс
TLS 1.3 1 × 190 мс 570 мс
Запрос → первый байт ответа 1 × 190 мс 760 мс

Три четверти секунды до первого байта — и мы ещё не начали качать данные. Никакая оптимизация кода на origin это не исправит: вы упёрлись в физику, а не в CPU. Единственный способ — не ходить через океан. Поставить копию контента в 12 мс от пользователя, и те же четыре RTT превращаются в 48 мс.

Это и есть вся идея CDN в одном предложении: перенести данные ближе к пользователю, чтобы сократить не пропускную способность, а число и длину круговых обменов. Всё остальное в этой статье — инженерные следствия из этой идеи и цены, которые за неё платят.

Предполагается, что вы уже прочитали про HTTP и его кэширование, TLS, DNS и прокси с балансировкой — CDN собран ровно из этих кирпичей.

Что CDN на самом деле продаёт

Наивное определение «CDN — это кэш картинок» описывает 1998 год. Сегодня клиент покупает четыре разных товара, и путать их вредно, потому что за каждый платят отдельную цену.

Что решает Механизм Чем платите
Задержка edge PoP в 5–20 мс от пользователя согласованность: копия отстаёт от origin
Полоса и нагрузка на origin кэш + иерархия shield сложность инвалидации, отладка «на чьём кэше»
Устойчивость к всплескам и DDoS сотни PoP, anycast, огромный суммарный аплинк вендор-лок, вы отдаёте TLS-ключи или используете keyless
Скорость динамики (некэшируемого) тёплые соединения и оптимизированный маршрут edge → origin плата за запрос, а не за гигабайт

Четвёртый пункт удивляет тех, кто считает CDN только кэшем. Даже для полностью персонализированного POST /api/checkout, который нельзя закэшировать в принципе, CDN даёт выигрыш: пользователь делает TCP+TLS-рукопожатие с edge в 10 мс, а edge переиспользует давно установленное соединение к origin. Из четырёх RTT через океан остаётся один — и то по приватному backbone провайдера, где нет перегруженных публичных пиринговых точек. Типичный выигрыш на динамике — 30–50% TTFB без единого закэшированного байта.

И чего CDN не делает, вопреки надеждам:

  • Не чинит медленный origin. Если генерация страницы занимает 900 мс, MISS будет стоить 900 мс плюс дорога. CDN лишь уменьшает долю таких запросов.
  • Не делает приложение доступным. При Cache-Control: private и 100% MISS падение origin роняет всё. Отказоустойчивость даёт stale-if-error, а не сам факт покупки CDN.
  • Не заменяет геораспределённую базу. Запись всегда идёт в один регион; это проблема репликации, а не раздачи.

Как индустрия сюда пришла

Ключевой перелом произошёл дважды. Первый — когда поняли, что близость важнее полосы. Второй — когда поняли, что раз уж у нас есть 300 точек присутствия с процессорами, глупо использовать их только для отдачи файлов.

Анатомия: путь запроса сверху вниз

Иерархия кэшей CDN и судьба 1000 запросов

Разберём каждый уровень.

Edge PoP (L1). Физически — стойка или несколько стоек в дата-центре или прямо в сети провайдера (embedded PoP внутри AS оператора — так работают Netflix Open Connect и Google GGC). Здесь терминируется TLS, здесь живёт кэш в RAM и на NVMe. Внутри PoP десятки серверов, и запрос должен попасть на тот, где лежит нужный объект, — про это ниже раздел о хешировании.

Shield / mid-tier (L2). Один-два PoP, назначенные «представителями» перед origin. Все промахи edge идут не напрямую к origin, а через shield. Зачем: если у вас 300 PoP и объект запросили в каждом — origin получит 300 запросов на один файл. Shield превращает их в один. Он же держит постоянные тёплые TCP+TLS-соединения к origin, поэтому cache fill не платит за рукопожатие.

Origin. Ваш балансировщик и приложение. Для CDN это просто HTTP-бэкенд, и все правила из статьи про прокси работают здесь: health checks, таймауты, ретраи. Часто в роли origin выступает объектное хранилище — тогда стоит прочитать про S3-совместимые хранилища.

Обратите внимание на арифметику в нижней части схемы: hit ratio на edge и offload origin — это разные метрики, и путают их постоянно. 85% edge-hit сами по себе оставляют origin 15% трафика. Shield превращает 15% в 6%. При обсуждении с вендором всегда уточняйте, какую из двух цифр вам называют.

Что происходит на промахе: схлопывание запросов

Самая интересная механика — не HIT, а MISS под нагрузкой. Представьте: истёк TTL популярного объекта, и в эту миллисекунду его запрашивают 4000 клиентов.

Три механизма здесь работают вместе, и все три надо включать явно:

  1. Request collapsing (он же coalescing, в nginx — proxy_cache_lock). Без него 4000 клиентов дадут 4000 запросов вниз. Это классический cache stampede, самая частая причина «origin лёг ровно в момент истечения TTL».
  2. Условная валидация. Клиентом для origin выступает не браузер, а сам CDN, и он посылает If-None-Match/If-Modified-Since. Ответ 304 стоит один RTT и ноль байт тела — при большом объекте это разница между 84 КБ и 200 байтами.
  3. Иерархия. Схлопывание работает внутри одного PoP. Между PoP его делает shield.

Ключ кэша — то, из-за чего всё ломается

Кэш — это хеш-таблица. Всё поведение CDN определяется тем, что именно вы кладёте в ключ. По умолчанию у большинства провайдеров ключ выглядит так:

scheme + host + путь + строка запроса (полностью) + значения заголовков из Vary

Каждый элемент — источник инцидентов.

Строка запроса. Ссылка с рекламной меткой ?utm_source=telegram&utm_campaign=july создаёт отдельный объект в кэше. Одна страница, разошедшаяся по десяти каналам с уникальными метками, — это десять MISS вместо одного HIT. Хуже: атакующий может генерировать бесконечные ?x=1, ?x=2 и вымывать ваш кэш (cache-busting DoS). Лечение — нормализация ключа: явный allow-list параметров, влияющих на ответ, и сортировка их по алфавиту.

Vary. Заголовок говорит кэшу «ответ зависит от вот этого заголовка запроса». Vary: Accept-Encoding — правильно и обязательно: gzip и brotli версии разные. Vary: User-Agent — катастрофа: уникальных UA в дикой природе миллионы, hit ratio падает до нуля. Vary: Cookie на странице каталога означает, что каждый посетитель со своей сессионной кукой получает личную копию — кэш становится дорогим генератором мусора.

Чего в ключе нет — и это опасно. Заголовки, которые влияют на ответ, но не входят в ключ, называются unkeyed input. Классика: приложение строит абсолютные ссылки из X-Forwarded-Host, а CDN этот заголовок в ключ не кладёт. Атакующий шлёт один запрос с X-Forwarded-Host: evil.example, получает 200, ответ ложится в кэш — и все следующие посетители получают страницу со скриптами с чужого домена. Это не теория, это отработанный класс атак: Practical Web Cache Poisoning и Web Cache Entanglement Джеймса Кеттла. Разбор защиты — в статье про безопасность API.

Практическое правило звучит так: всё, что влияет на тело ответа, обязано быть либо в ключе, либо запрещено на входе. Третьего не дано.

Нормализация ключа на nginx:

# Оставляем в ключе только значимые параметры, отбрасывая рекламные метки.
# $arg_page и $arg_sort — реально влияют на ответ; utm_* — нет.
map $args $normalized_args {
    default        "";
    "~*(^|&)page=(?<p>[0-9]{1,4})" "page=$p";
}

proxy_cache_key "$scheme$proxy_host$uri?$normalized_args";

# Схлопывание запросов: только один запрос к origin на ключ.
proxy_cache_lock          on;
proxy_cache_lock_timeout  5s;
proxy_cache_lock_age      5s;

# Отдаём протухшее, пока обновляем в фоне, и при аварии origin.
proxy_cache_use_stale     updating error timeout http_500 http_502 http_503 http_504;
proxy_cache_background_update on;

# Ретраить безопасно только идемпотентное.
proxy_next_upstream       error timeout http_502 http_503;

Полное описание директив — в документации ngx_http_proxy_module. Вопрос «а можно ли ретраить» разбирается отдельно в идемпотентности и семантике доставки.

Жизненный цикл объекта в кэше

Объект в CDN не просто «есть или нет». У него полноценный автомат состояний, и понимание этого автомата экономит часы отладки.

Разница между Stale и Absent — принципиальная. Протухший объект всё ещё лежит на диске, и это ваш страховой полис: при недоступности origin его можно отдать. Удалённый объект не спасёт никого. Отсюда практический вывод: purge all — это не «обновить сайт», это «выключить страховку для всего каталога и одновременно позвать stampede».

Разница между hard purge и soft purge — та же логика, но управляемая. Soft purge помечает объект протухшим, не удаляя: следующий запрос вызовет ревалидацию, а если origin молчит — отдастся старая версия. В подавляющем большинстве сценариев нужен именно soft purge.

Заголовки: кто кем управляет

На пути ответа стоят минимум три кэша с разными правилами: браузер, CDN и, возможно, корпоративный прокси. Управлять ими одним Cache-Control неудобно — и для этого придумали таргетированные директивы.

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
ETag: "b7c29fa1"
Cache-Control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400
CDN-Cache-Control: max-age=3600, stale-while-revalidate=86400
Surrogate-Control: max-age=3600
Surrogate-Key: product-42 category-shoes homepage
Vary: Accept-Encoding

Читается так: браузер держит копию минуту, CDN — час. Логика очевидна: инвалидировать CDN вы можете, а браузеры пользователей — нет. Поэтому браузерный TTL всегда короткий (или ноль плюс ETag), а CDN-овский — длинный.

Разбор директив:

Директива Кому Что делает Типичная ошибка
max-age=N всем свежесть N секунд ставят большой на HTML и не могут выкатить фикс
s-maxage=N только общим кэшам перекрывает max-age для CDN путают с max-age, ждут влияния на браузер
CDN-Cache-Control только CDN (RFC 9213) перекрывает s-maxage не поддерживается старыми конфигурациями
Surrogate-Control Varnish, Fastly, Akamai то же, но старее стандарта CDN его съедает и не передаёт дальше
stale-while-revalidate=N всем (RFC 5861) отдавать протухшее N секунд, обновляя в фоне не включают — и получают всплеск задержки на каждый TTL
stale-if-error=N всем отдавать протухшее при 5xx не включают — и падение origin роняет статику
private всем только браузер, CDN не хранит забывают на персонализированном HTML — и утекают чужие корзины
no-store всем не хранить вообще ставят на всё «на всякий случай», убивая смысл CDN
immutable браузеру не ревалидировать даже по F5 ставят на файл без хеша в имени

Отдельно про Age: это счётчик секунд, которые ответ провёл в кэшах. Age: 0 — только что с origin, Age: 48210 — лежит 13 часов. Первое, что нужно смотреть при жалобе «показывает старое».

Формальные определения — RFC 9111 «HTTP Caching»; прикладной пересказ — MDN Caching и web.dev HTTP Cache.

Отладочные заголовки на практике

curl -sSI https://static.example-shop.com/assets/app.4f1c8b.js
HTTP/2 200
date: Thu, 16 Jul 2026 09:12:44 GMT
content-type: application/javascript; charset=utf-8
content-length: 184320
cache-control: public, max-age=31536000, immutable
etag: "a3f19c2e-2d000"
age: 48210
vary: accept-encoding
cf-cache-status: HIT
cf-ray: 8b2f1a9c4d3e77a1-FRA
server: cloudflare

Что тут видно за пять секунд: файл лежит в кэше 13,4 часа (age), обслужен из Франкфурта (суффикс -FRA в cf-ray), это HIT, имя файла содержит хеш содержимого — значит, immutable тут уместен.

У каждого провайдера свой заголовок статуса: cf-cache-status (Cloudflare), x-cache: HIT/MISS from cloudfront (CloudFront), x-cache: HIT, HIT двумя значениями по числу уровней (Fastly). Именно этот зоопарк стандартизирует RFC 9211 «Cache-Status»:

Cache-Status: cdn-edge-fra; hit; ttl=3120, cdn-shield-ams; hit; ttl=71400

Читается справа налево по ходу движения ответа. Если провайдер это поддерживает — включайте, отладка становится в разы приятнее.

Проверить конкретный PoP, не меняя DNS:

# Идём принудительно на франкфуртский адрес, но с правильным SNI и Host
curl -sSI --resolve static.example-shop.com:443:104.18.24.181 \
     https://static.example-shop.com/assets/app.4f1c8b.js | grep -Ei 'cf-ray|age|cache'
age: 48210
cache-control: public, max-age=31536000, immutable
cf-cache-status: HIT
cf-ray: 8b2f1a9c4d3e77a1-FRA

А заодно посмотреть, где вы вообще находитесь с точки зрения сети:

curl -s https://www.cloudflare.com/cdn-cgi/trace
fl=421f34
h=www.cloudflare.com
ip=91.0.0.42
ts=1784541164.223
visit_scheme=https
uag=curl/8.5.0
colo=FRA
sliver=none
http=http/2
loc=DE
tls=TLSv1.3
sni=plaintext
kex=X25519MLKEM768

colo=FRA — вы на франкфуртском PoP. sni=plaintextECH не используется, имя хоста видно на проводе. kex показывает согласованный обмен ключами.

Инвалидация: два трудных вопроса информатики

Известная шутка гласит, что в информатике два трудных вопроса: инвалидация кэша, именование вещей и ошибки на единицу. В CDN эта шутка перестаёт быть смешной, потому что кэш распределён по 300 точкам в разных юрисдикциях.

Стратегия 1: версионные URL (и почему она лучшая)

Не инвалидируйте то, что можно не инвалидировать. Если имя файла содержит хеш содержимого — app.4f1c8b.js — то новая версия это другой URL, и проблемы инвалидации просто не существует. Ставьте max-age=31536000, immutable и забудьте. Сборщики фронтенда делают это из коробки; подробности — в производительности фронтенда.

Ограничение очевидно: работает только там, где ссылку на ресурс контролирует ваш код. HTML по адресу /catalog/shoes переименовать нельзя — пользователь ходит именно туда.

Стратегия 2: TTL и его джиттер

Для контента, который «должен обновляться примерно раз в N», короткий TTL — нормальное решение. Но есть ловушка: если 50 000 объектов положили в кэш в момент прогрева и всем дали max-age=3600, они протухнут одновременно. Через час origin получит синхронный залп. Это тот же эффект, что и «громкий сосед» в распределённых системах, лечится так же — джиттером.

import hashlib
import random


def jittered_ttl(base_ttl: int, key: str, spread: float = 0.2) -> int:
    """TTL с детерминированным разбросом ±spread вокруг базового.

    Детерминированность по ключу важна: все PoP независимо посчитают
    одинаковый TTL для одного объекта, и объект не будет то и дело
    протухать раньше времени на одном узле и позже на другом.
    """
    digest = hashlib.blake2b(key.encode(), digest_size=8).digest()
    # Преобразуем хеш в число в диапазоне [-1, 1]
    unit = int.from_bytes(digest, "big") / (2 ** 64 - 1) * 2 - 1
    return max(1, int(base_ttl * (1 + spread * unit)))


def should_refresh_early(age: float, ttl: float, beta: float = 1.0) -> bool:
    """Вероятностное досрочное обновление (XFetch).

    Чем ближе объект к истечению, тем выше шанс, что именно этот запрос
    пойдёт обновлять его в фоне. Классический приём против stampede:
    залп размазывается по времени вместо резкого фронта.
    """
    remaining = ttl - age
    if remaining <= 0:
        return True
    # -log(random) даёт экспоненциальное распределение
    import math
    return remaining < beta * (-math.log(random.random()))

Сложность обеих функций — O(1) по времени и памяти. jittered_ttl считает один хеш от короткой строки; should_refresh_early — один логарифм. Это важно: код исполняется на каждом запросе на edge, где бюджет измеряется микросекундами.

Стратегия 3: purge по surrogate key (теги)

Самый мощный инструмент промышленных CDN. Origin помечает каждый ответ набором тегов, а инвалидация идёт по тегу, а не по URL.

Surrogate-Key: product-42 category-shoes brand-nike homepage sitemap

Товар 42 отображается на карточке товара, в трёх листингах, в поиске, на главной, в sitemap и в двух виджетах «похожие». Перечислять все эти URL при обновлении цены — невозможно (их тысячи с учётом пагинации и фильтров). Одна команда решает всё:

# Fastly: инвалидируем всё, что помечено тегом product-42
curl -sS -X POST \
     -H "Fastly-Key: $FASTLY_API_TOKEN" \
     -H "Fastly-Soft-Purge: 1" \
     "https://api.fastly.com/service/$SERVICE_ID/purge/product-42"
{"status":"ok","id":"81-1784541201-9271443"}
# Cloudflare: purge по тегам (Enterprise) — до 30 тегов за вызов
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"tags":["product-42","category-shoes"]}'
{"result":{"id":"9a7b1c..."},"success":true,"errors":[],"messages":[]}

Заголовок Fastly-Soft-Purge: 1 — это ровно тот переход Purged → Stale из автомата выше. Без него получите Purged → Absent и все вытекающие радости.

Дисциплина тегирования — единственная сложная часть. Правило: тег вешает тот, кто знает о зависимости. Рендерер карточки товара знает, что использовал товар 42 → добавляет product-42. Рендерер листинга знает, что показал двадцать товаров → добавляет двадцать тегов. Ошибка в тегировании даёт либо устаревшие данные, либо (что хуже для нагрузки) избыточную инвалидацию.

Как purge физически доезжает до 300 точек

Инвалидация — это распределённая рассылка, и её задержка не равна нулю. Схема у всех примерно одна: API принимает команду, кладёт её в надёжную шину, шина реплицируется по регионам, каждый PoP применяет локально.

Практические следствия, которые надо закладывать в архитектуру:

  • Purge асинхронен. Между «API ответил ok» и «во всех PoP применено» проходит от 150 мс до нескольких секунд, при аварии — минуты. Никогда не пишите код вида «сделали purge, сразу читаем и проверяем».
  • Порядок операций важен. Сначала запись в БД, потом purge. Иначе между purge и коммитом успеет прилететь запрос, который закэширует старую версию заново, — и вы получите устаревший кэш с новым TTL.
  • Purge — это ресурс с лимитом. У всех провайдеров есть квоты (обычно тысячи операций в час). Инвалидация из цикла по 100 000 товаров упрётся в лимит и/или в счёт.
  • Purge all — аварийный рубильник. Он снимает страховку stale-if-error со всего каталога и приглашает stampede. Запускать его следует руками, осознанно и в непиковое время.

Географическая маршрутизация: кто решает, куда вы попадёте

Anycast против DNS GSLB: два способа выбора PoP

Два подхода, оба живут в проде, часто одновременно.

Anycast

Один и тот же IP-префикс анонсируется по BGP из всех PoP. Пакет пользователя доходит туда, куда его отнесёт маршрутизация. Никакого «выбора» в приложении нет вообще — за вас всё решил интернет.

traceroute -n 1.1.1.1
traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 60 byte packets
 1  192.168.1.1        0.512 ms   0.481 ms   0.470 ms
 2  10.132.0.1         4.118 ms   4.092 ms   4.201 ms
 3  62.115.45.190      6.740 ms   6.688 ms   6.702 ms
 4  80.81.192.108      7.905 ms   7.881 ms   7.912 ms
 5  1.1.1.1            8.043 ms   7.996 ms   8.011 ms

Пять хопов и 8 мс до сервиса, который «находится везде». Из Сингапура тот же адрес даст такие же 5–8 мс — но это будет физически другая машина.

Плюсы: мгновенное переключение при отказе (маршрут просто отзывается), автоматическое размазывание DDoS по всей сети, ничего не зависит от поведения резолверов.

Минусы, которые кусают:

  • BGP не знает про километры. Он оптимизирует длину AS-пути и локальные политики оператора. Провайдер, который купил дешёвый транзит, может отправить вас из Варшавы в Лондон, хотя PoP есть в Варшаве.
  • Маршрут может смениться посреди TCP-сессии. Тогда следующий сегмент приезжает на другой PoP, у которого нет вашего состояния, и он отвечает RST. Для коротких HTTP-ответов это редкость, для длинных загрузок — реальная проблема. Именно поэтому CDN так любят QUIC: connection ID позволяет опознать соединение независимо от адреса.

DNS GSLB

Авторитетный сервер CDN отдаёт разные адреса разным клиентам. Классическая цепочка выглядит так:

dig www.example-shop.com
;; ANSWER SECTION:
www.example-shop.com.   300  IN  CNAME  www.example-shop.com.cdn.example-cdn.net.
www.example-shop.com.cdn.example-cdn.net. 20 IN A 198.51.100.20
www.example-shop.com.cdn.example-cdn.net. 20 IN A 198.51.100.21

;; Query time: 24 msec
;; SERVER: 192.168.1.1#53(192.168.1.1) (UDP)

Обратите внимание на TTL: 300 у вашего CNAME и 20 у A-записей CDN. Короткий TTL — это ручка управления: чем он меньше, тем быстрее CDN может увести трафик с проблемного PoP. Платите за это нагрузкой на DNS и лишними резолвами.

Главная проблема схемы: авторитетный сервер видит не клиента, а резолвер. Если вы в Мюнхене, а ваш резолвер — публичный 8.8.8.8, географическая логика легко промахнётся. Лечит это EDNS Client Subnet, RFC 7871 — резолвер добавляет к запросу усечённую подсеть клиента:

# Смотрим, что ответит GSLB для клиента из немецкой подсети
dig @ns1.cdn.example-cdn.net www.example-shop.com.cdn.example-cdn.net A \
    +subnet=91.0.0.0/24 +short
198.51.100.20
# И для клиента из бразильской
dig @ns1.cdn.example-cdn.net www.example-shop.com.cdn.example-cdn.net A \
    +subnet=177.99.0.0/24 +short
198.51.100.88

Разные ответы на один вопрос — это и есть GSLB в действии. Проверить, доехал ли ECS до авторитетного сервера, можно так:

dig @8.8.8.8 www.example-shop.com A +subnet=91.0.0.0/24 | grep -A2 'CLIENT-SUBNET'
; CLIENT-SUBNET: 91.0.0.0/24/24

Третье число — SCOPE: насколько специфичен ответ. /24 означает «этот ответ годится только для этой /24». /0 означает «ответ универсальный, кэшируй для всех». Чем больше scope, тем сильнее дробится кэш резолвера: вместо одной записи он хранит тысячи. Это прямая цена точной геолокации, и она бьёт по производительности DNS. Механику резолвинга разбирает статья про DNS.

Важное для 2020-х: DoH, VPN и корпоративные резолверы ломают всю эту логику. Пользователь через VPN-выход в Нидерландах будет обслужен из Амстердама, что бы вы ни настроили. Не стройте бизнес-логику (право доступа, ценообразование, юрисдикцию) на том, какой PoP обслужил запрос, — это оценка, а не факт.

Что выбирают на практике

Крупные сети используют гибрид: anycast для входа (быстрое переключение и защита от DDoS) плюс внутренняя логика для выбора конкретного сервера и, при необходимости, переброс на другой PoP через HTTP-редирект или альтернативные адреса в Alt-Svc. GSLB остаётся там, где нужна тонкая политика — например, разведение трафика по стоимости транзита или соблюдение требований к локализации данных.

Как объект находит свой сервер внутри PoP

Внутри одного PoP стоят десятки кэширующих серверов. Балансировщик должен направить запрос на тот же самый сервер, что и в прошлый раз, иначе объект придётся качать с origin заново на каждую машину — и эффективный размер кэша упадёт в N раз.

Обычный hash(key) % N не годится: при добавлении или выпадении одного сервера меняются почти все отображения, и кэш обнуляется целиком. Ответ — консистентное хеширование (Karger et al., STOC 1997) или ещё более простое rendezvous hashing (HRW, Thaler & Ravishankar, 1998).

Псевдокод HRW:

функция ВЫБРАТЬ_УЗЕЛ(ключ, живые_узлы):
    лучший_узел ← ∅
    лучший_вес ← −∞
    для каждого узла N из живые_узлы:
        вес ← ХЕШ(ключ ‖ идентификатор N)
        если вес > лучший_вес:
            лучший_вес ← вес
            лучший_узел ← N
    вернуть лучший_узел

Реализация с учётом разной ёмкости серверов:

import hashlib
from typing import Iterable, NamedTuple


class Node(NamedTuple):
    name: str
    weight: float = 1.0  # относительная ёмкость: NVMe-узел «тяжелее» обычного


def _score(key: str, node: Node) -> float:
    """Псевдослучайный вес пары (ключ, узел), масштабированный ёмкостью."""
    digest = hashlib.blake2b(f"{key}\x00{node.name}".encode(), digest_size=8).digest()
    # Нормализуем хеш в (0, 1)
    h = (int.from_bytes(digest, "big") + 1) / (2 ** 64 + 1)
    # Приём Rendezvous Hashing with Weights: -weight / ln(h)
    import math
    return -node.weight / math.log(h)


def pick_node(key: str, nodes: Iterable[Node]) -> Node:
    """Выбирает узел для ключа. Детерминированно на всех балансировщиках."""
    return max(nodes, key=lambda n: _score(key, n))


if __name__ == "__main__":
    cluster = [Node(f"cache-{i:02d}", weight=2.0 if i < 3 else 1.0) for i in range(12)]
    key = "https://static.example-shop.com/assets/app.4f1c8b.js"
    print(pick_node(key, cluster).name)          # cache-09

    # Выводим один узел из строя — переезжает только его доля ключей
    degraded = [n for n in cluster if n.name != "cache-09"]
    print(pick_node(key, degraded).name)         # cache-08

Анализ. Время — O(N) на выбор, где N — число узлов в PoP; при N порядка 50 это десятки наносекунд на современном CPU, что для сетевого пути пренебрежимо. Память — O(N), никаких колец с виртуальными узлами хранить не нужно (у консистентного хеширования это O(N · V), где V — 100–200 виртуальных точек на узел, зато выбор за O(log(N·V)) по отсортированному кольцу).

Ключевое свойство обоих алгоритмов — минимальное возмущение: при выпадении одного узла из N переезжает ровно 1/N ключей, а не почти все. Именно поэтому вывод сервера на обслуживание не роняет hit ratio PoP с 90% до 5%. Подробнее про класс задач — в статье о шардировании.

Что видно в Wireshark и tcpdump

Теория теорией, но признать проблему нужно на проводе.

Cache fill глазами tcpdump на origin. Запускаем на origin-сервере и смотрим, кто и как к нам ходит:

sudo tcpdump -i eth0 -nn 'tcp port 443 and (tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst)) != 0'
09:14:02.118441 IP 198.51.100.20.51422 > 10.0.3.7.443: Flags [S], seq 2913884371, win 62727, options [mss 1460,sackOK,TS val 118342 ecr 0,nop,wscale 7], length 0
09:14:02.118512 IP 10.0.3.7.443 > 198.51.100.20.51422: Flags [S.], seq 3874113981, ack 2913884372, win 65160, options [mss 1460,sackOK,TS val 9938211 ecr 118342,nop,wscale 7], length 0
09:14:47.902118 IP 198.51.100.20.51422 > 10.0.3.7.443: Flags [F.], seq 84213, ack 1, win 502, length 0

Здоровая картина: мало SYN, длинные соединения, много запросов внутри каждого. Если вы видите шквал SYN с адресов CDN — соединения не переиспользуются, и вы платите рукопожатием за каждый cache fill. Причины обычно две: Connection: close в ответах origin или слишком агрессивный keepalive_timeout.

Проверить состояние живых соединений от CDN:

ss -tin state established '( sport = :443 )' | head -8
ESTAB 0 0 10.0.3.7:443 198.51.100.20:51422
	 cubic wscale:7,7 rto:248 rtt:44.812/1.203 mss:1448 pmtu:1500 rcvmss:536
	 bytes_sent:84213904 bytes_acked:84213904 segs_out:58221 segs_in:19402
	 send 258.4Mbps lastsnd:1204 lastrcv:1204 lastack:1204 delivery_rate 191.2Mbps
	 reordering:3 rcv_space:14480 retrans:0/11 dsack_dups:2

rtt:44.812 — это дорога до shield. retrans:0/11 при 58 тысячах сегментов — норма. delivery_rate заметно ниже send — типичный признак ограничения окном перегрузки, детали в статье про TCP.

Что искать в Wireshark. Полезные фильтры при разборе дампа с клиента:

  • tls.handshake.extensions_server_name == "static.example-shop.com" — найти ClientHello и убедиться, что SNI правильный (типичная ошибка при --resolve и кастомных Host).
  • http.response.code == 304 — все ревалидации. Если их много от браузера, значит max-age слишком мал.
  • tcp.analysis.retransmission || tcp.analysis.fast_retransmission — потери. На пути к близкому edge их быть почти не должно; массовые ретрансмиссии до edge означают проблему на последней миле, а не в CDN.
  • quic.long.packet_type == 0 — Initial-пакеты QUIC; если их много без последующего трафика, значит UDP/443 где-то режется и клиент откатывается на TCP.
  • Statistics → Conversations, сортировка по Duration — быстро видно, переиспользуются ли соединения.

Как выглядит потеря на графике. В Wireshark откройте Statistics → TCP Stream Graphs → Time Sequence (tcptrace). Здоровая передача — ровная восходящая «лесенка». Потеря выглядит как плато (отправитель ждёт), затем вертикальный скачок ретрансмиссии и заново набирающая наклон линия — окно перегрузки урезали. Если такие пилы регулярны и совпадают с границей объектов — вы упёрлись в policer у оператора, и CDN тут не поможет.

Edge compute: когда PoP умеет не только отдавать байты

Раз на 300 площадках стоят серверы, логично исполнять на них код. Так появились Cloudflare Workers, Lambda@Edge/CloudFront Functions, Fastly Compute. Модель везде схожая: короткоживущий изолят (V8 isolate или WASM-модуль) с жёсткими лимитами.

Четыре точки врезки — не декоративная деталь, а самое важное в edge compute. Код до кэша (viewer request) выполняется на каждом запросе, включая HIT: сюда кладут дешёвые вещи — нормализацию ключа, редиректы, гео-логику. Код после origin (origin response) выполняется только на MISS: сюда кладут дорогое — правку заголовков кэширования, простановку Surrogate-Key. Перепутать эти две точки — значит либо платить за исполнение на каждом HIT, либо не суметь повлиять на кэширование вовсе.

Пример нормализации ключа и защиты от cache-busting:

// Cloudflare Worker: нормализуем URL до похода в кэш.
const ALLOWED_PARAMS = new Set(["page", "sort", "size"]);

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);

    // Оставляем только значимые параметры и сортируем — иначе ?a=1&b=2
    // и ?b=2&a=1 создадут два разных объекта в кэше.
    const kept = [...url.searchParams.entries()]
      .filter(([k]) => ALLOWED_PARAMS.has(k))
      .sort(([a], [b]) => a.localeCompare(b));
    url.search = new URLSearchParams(kept).toString();

    // Явно строим ключ кэша: HEAD и GET делят один объект.
    const cacheKey = new Request(url.toString(), { method: "GET" });
    const cache = caches.default;

    let response = await cache.match(cacheKey);
    if (response) {
      return new Response(response.body, response); // HIT, ничего не считаем
    }

    response = await fetch(url.toString(), request);
    response = new Response(response.body, response);
    response.headers.set("CDN-Cache-Control", "max-age=3600, stale-while-revalidate=86400");

    // waitUntil: запись в кэш не задерживает ответ клиенту.
    ctx.waitUntil(cache.put(cacheKey, response.clone()));
    return response;
  },
};

Ограничения, о которых узнают поздно:

  • CPU-бюджет измеряется миллисекундами (10–50 мс на запрос типично). Криптография, разбор мегабайтного JSON, синхронные циклы по большим массивам — не сюда.
  • Нет произвольных TCP-сокетов в большинстве рантаймов: только HTTP-фетчи и специальные привязки. Подключиться напрямую к PostgreSQL из воркера классическим драйвером обычно нельзя.
  • Состояние на edge согласовано слабо. Edge-KV — это eventual consistency с задержкой распространения от секунд до минут. Читать оттуда фича-флаги отлично, вести счётчик остатков товара — путь к overselling. Разбор гарантий — в моделях согласованности.
  • Отладка тяжелее. Локальный эмулятор не воспроизводит гео, конкурентность и реальные тайминги. Логи с 300 PoP надо агрегировать — см. наблюдаемость.

TLS на краю: где лежат ваши ключи

CDN терминирует TLS — значит, у него есть приватный ключ для вашего домена. Это ровно тот компромисс, который многие подписывают не читая.

Варианты, от простого к параноидальному:

  1. Сертификат выпускает CDN (обычно бесплатно, ACME под капотом). Ключ живёт у провайдера на всех PoP. Просто, работает, но провайдер технически может читать ваш трафик.
  2. Вы загружаете свой сертификат. Ничего не меняет по сути — ключ всё равно у провайдера.
  3. Keyless SSL. На PoP лежит только сертификат; операция подписи в рукопожатии выполняется удалённым сервером в вашем периметре. Ключ не покидает ваш HSM, но каждое полное рукопожатие стоит дополнительного round-trip до вашего ключевого сервера. С TLS 1.3 и возобновлением сессий это терпимо.
  4. Сквозное шифрование без терминации — CDN проксирует TCP, не разбирая TLS. Тогда это не CDN, а L4-балансировщик: кэшировать нечего, ведь содержимое зашифровано. См. L4 против L7.

Проверить, чей сертификат отдаёт edge и по какой цепочке:

openssl s_client -connect static.example-shop.com:443 \
                 -servername static.example-shop.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
subject=CN = static.example-shop.com
issuer=C = US, O = Let's Encrypt, CN = E6
notBefore=Jun 28 04:11:19 2026 GMT
notAfter=Sep 26 04:11:18 2026 GMT
X509v3 Subject Alternative Name:
    DNS:static.example-shop.com, DNS:*.static.example-shop.com

Если в issuer вы видите УЦ, с которым не подписывали договор, — сертификат выпустил CDN. Это нормально при варианте 1, но должно быть осознанным решением. Детали проверки цепочек — в статье про TLS и в треке безопасности, безопасность транспорта.

Отдельная ловушка: origin тоже должен быть за TLS, и CDN должен проверять его сертификат. Режим «шифруем клиента, к origin ходим по HTTP» (у Cloudflare он называется Flexible) даёт замочек в браузере при полностью открытом канале между CDN и вашим сервером — и при этом приложение видит X-Forwarded-Proto: https и не подозревает о проблеме.

Типичные аварии и как их узнавать

«Выкатили релиз, у части пользователей старая версия». Смотрим age и cf-cache-status на HTML. Почти всегда причина — длинный max-age на HTML или забытый purge. Правильная конфигурация: HTML — max-age=0, s-maxage=60, stale-while-revalidate=600; ассеты с хешем — год и immutable.

«Origin лёг ровно в 03:00». Синхронное истечение TTL после ночного прогрева. Лечится джиттером и stale-while-revalidate.

«Пользователь видит чужую корзину». Персонализированный ответ уехал в общий кэш. Обычно потому, что origin вернул Cache-Control: public (или вообще ничего, а CDN применил дефолтный TTL) на странице с сессией. Проверять надо тем, что ответ с Set-Cookie никогда не должен быть кэшируемым в общем кэше — это правило стоит вбить в тесты. Полезный приём: интеграционный тест, который делает два запроса с разными куками и сравнивает тела.

«В одной стране сайт быстрый, в другой — нет». Сравниваем cf-ray/x-served-by из разных мест, смотрим traceroute. Частая причина — плохой anycast-маршрут у конкретного оператора или отсутствие ECS у популярного в стране резолвера.

«После purge всё легло». purge all вместо тегов. Смотрите графики: провал hit ratio до нуля с одновременным пиком RPS на origin — подпись этого события.

«Скачивание больших файлов рвётся». Проверьте поддержку Range и заголовок Accept-Ranges: bytes. Некоторые конфигурации кэшируют диапазоны как отдельные объекты, раздувая хранилище; в nginx это лечится proxy_cache_slice или slice-модулем.

«Бесконечная петля между CDN». При мультивендорной схеме (CDN A как origin для CDN B) возможен цикл. Для этого существует RFC 8586 и заголовок CDN-Loop — каждый CDN дописывает себя, увидев своё имя, обрывает петлю.

Методичный подход к таким разборам — в статье про диагностику сети.

Экономика: за что вы платите

Три модели тарификации, часто смешанные:

  • За гигабайт исходящего трафика. Основная статья для медиа. Цена сильно зависит от региона: раздача в Северной Америке и Европе кратно дешевле, чем в Южной Америке, Индии или Австралии — там дороже транзит.
  • За запрос. Основная статья для API и мелких файлов. Миллион мелких иконок по 2 КБ обойдётся дороже, чем один файл на 2 ГБ, при одинаковом объёме.
  • За вызов edge-функции и за CPU-миллисекунды. Здесь легко получить сюрприз, если код исполняется на каждом запросе, включая HIT.

Оценка выгоды считается элементарно. Пусть h — hit ratio, L_edge и L_origin — задержки. Тогда:

Средняя задержка = h · L_edge + (1 − h) · L_origin
Трафик на origin = (1 − h) · Общий трафик

При h = 0.94, L_edge = 12 мс, L_origin = 165 мс средняя задержка равна 0.94 · 12 + 0.06 · 165 ≈ 21 мс. Обратите внимание на нелинейность: рост hit ratio с 90% до 95% сокращает нагрузку на origin вдвое (10% → 5%), а с 95% до 97.5% — снова вдвое. Каждый следующий процент дороже предыдущего, но и ценнее. Отсюда практический приоритет: сначала чините ключ кэша и Vary, и только потом покупайте больше PoP. Про подсчёт совокупной стоимости — облачные затраты и компромиссы.

Ещё одна экономия, о которой забывают: у большинства облаков исходящий трафик из региона в интернет стоит дорого, а из региона в CDN того же вендора — дёшево или бесплатно. Правильно настроенный shield снижает счёт за egress сильнее, чем за сам CDN.

Чеклист внедрения

  1. Ассеты — с хешем в имени, Cache-Control: public, max-age=31536000, immutable. Никаких purge.
  2. HTML — max-age=0 для браузера, s-maxage/CDN-Cache-Control для CDN, обязательно stale-while-revalidate и stale-if-error.
  3. Vary — только Accept-Encoding и, если правда нужно, Accept-Language. Никогда User-Agent и Cookie на кэшируемом.
  4. Ключ кэша нормализован: allow-list query-параметров, сортировка, отброс рекламных меток.
  5. Все заголовки, влияющие на ответ, — в ключе. Проверить X-Forwarded-Host, X-Original-URL, X-Rewrite-URL.
  6. Ответ с Set-Cookie не кэшируется. Покрыть тестом.
  7. Включены proxy_cache_lock/request collapsing и shield.
  8. Инвалидация — по surrogate key, soft purge. purge all — только руками.
  9. Порядок: коммит в БД → purge. Не наоборот.
  10. TTL с джиттером. Прогрев не создаёт синхронного фронта истечения.
  11. Мониторинг: hit ratio по классам контента, origin RPS, Age в перцентилях, доля 5xx от origin, доля ответов из stale.
  12. Origin защищён: доступ только с адресов CDN плюс общий секрет в заголовке — иначе вас обойдут напрямую и вся защита от DDoS бесполезна.
  13. Проверен план на случай отказа CDN: как переключить DNS на origin и выдержит ли origin такой трафик.

Мини-итог

CDN — это не «ускоритель», а управляемая рассинхронизация: вы соглашаетесь, что копии данных временно расходятся, и получаете взамен физически недостижимую иначе задержку и на порядок меньшую нагрузку на origin. Вся сложность профессии сосредоточена в двух точках: ключ кэша (что считается одним и тем же объектом) и инвалидация (когда копия перестаёт быть правдой). Географическая маршрутизация — anycast или DNS GSLB — определяет только, к какому кэшу вы обратитесь; она важна, но чинится настройками провайдера, а ключ и инвалидация чинятся вашим кодом.

Практический порядок работ, если система уже в проде: измерьте hit ratio по классам контента → почините Vary и нормализацию ключа → включите stale-while-revalidate и stale-if-error → внедрите surrogate keys → и только затем обсуждайте с вендором количество PoP.

Источники

Что дальше

Мы разобрали, как контент доезжает до пользователя по кратчайшему пути. Дальше — про то, почему у самого пользователя этот путь бывает перекрыт: трансляция адресов, фильтры и туннели, из-за которых рождается фраза «у меня работает».

NAT, фаерволы и VPN: почему «у меня работает, а у клиента нет»

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов