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 точек присутствия с процессорами, глупо использовать их только для отдачи файлов.
Анатомия: путь запроса сверху вниз
Разберём каждый уровень.
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 клиентов.
берём блокировку на ключ кэша E->>S: GET /catalog/page-1
If-None-Match a3f1 C2->>E: GET /catalog/page-1 (×3999) Note over E: ключ занят → ставим в очередь,
наружу НИ ОДНОГО запроса S->>O: GET /catalog/page-1
If-None-Match a3f1 alt Контент не менялся O-->>S: 304 Not Modified (0 байт тела) Note over S: обновляем Age и TTL,
тело берём из своего кэша S-->>E: 200 OK (из кэша shield) else Контент изменился O-->>S: 200 OK, 84 КБ, новый ETag b7c2 S-->>E: 200 OK, 84 КБ end E-->>C1: 200 OK, Age: 0 E-->>C2: 200 OK, Age: 0 (×3999 из свежего кэша) Note over E,O: origin увидел ОДИН запрос вместо 4000
Три механизма здесь работают вместе, и все три надо включать явно:
- Request collapsing (он же coalescing, в nginx —
proxy_cache_lock). Без него 4000 клиентов дадут 4000 запросов вниз. Это классический cache stampede, самая частая причина «origin лёг ровно в момент истечения TTL». - Условная валидация. Клиентом для origin выступает не браузер, а сам CDN, и он посылает
If-None-Match/If-Modified-Since. Ответ304стоит один RTT и ноль байт тела — при большом объекте это разница между 84 КБ и 200 байтами. - Иерархия. Схлопывание работает внутри одного 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=plaintext — ECH не используется, имя хоста видно на проводе. 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 применяет локально.
(реплицированный лог с порядком)"] C --> D1["Регион EU: broadcast"] C --> D2["Регион APAC: broadcast"] C --> D3["Регион AMER: broadcast"] D1 --> E1["PoP FRA: пометить ключи"] D1 --> E2["PoP AMS: пометить ключи"] D2 --> E3["PoP SIN: пометить ключи"] D3 --> E4["PoP GRU: пометить ключи"] E1 --> F{"PoP был офлайн?"} F -- да --> G["догоняет лог при возврате"] F -- нет --> H["готово за 150–500 мс"] G --> H style C fill:#7d6bb5,fill-opacity:0.15,stroke:#7d6bb5 style H fill:#3f9d6b,fill-opacity:0.15,stroke:#3f9d6b
Практические следствия, которые надо закладывать в архитектуру:
- Purge асинхронен. Между «API ответил ok» и «во всех PoP применено» проходит от 150 мс до нескольких секунд, при аварии — минуты. Никогда не пишите код вида «сделали purge, сразу читаем и проверяем».
- Порядок операций важен. Сначала запись в БД, потом purge. Иначе между purge и коммитом успеет прилететь запрос, который закэширует старую версию заново, — и вы получите устаревший кэш с новым TTL.
- Purge — это ресурс с лимитом. У всех провайдеров есть квоты (обычно тысячи операций в час). Инвалидация из цикла по 100 000 товаров упрётся в лимит и/или в счёт.
- Purge all — аварийный рубильник. Он снимает страховку
stale-if-errorсо всего каталога и приглашает stampede. Запускать его следует руками, осознанно и в непиковое время.
Географическая маршрутизация: кто решает, куда вы попадёте
Два подхода, оба живут в проде, часто одновременно.
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-модуль) с жёсткими лимитами.
нормализация ключа,
A/B, гео, редиректы"] B --> C{"Кэш PoP"} C -- HIT --> G["viewer response
заголовки, CSP, куки"] C -- MISS --> D["origin request
подпись, смена бэкенда"] D --> E["Origin или shield"] E --> F["origin response
правка Cache-Control,
простановка тегов"] F --> C C --> G G --> H["Ответ клиенту"] style B fill:#4b83d4,fill-opacity:0.15,stroke:#4b83d4 style F fill:#d08a2e,fill-opacity:0.15,stroke:#d08a2e
Четыре точки врезки — не декоративная деталь, а самое важное в 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 — значит, у него есть приватный ключ для вашего домена. Это ровно тот компромисс, который многие подписывают не читая.
Варианты, от простого к параноидальному:
- Сертификат выпускает CDN (обычно бесплатно, ACME под капотом). Ключ живёт у провайдера на всех PoP. Просто, работает, но провайдер технически может читать ваш трафик.
- Вы загружаете свой сертификат. Ничего не меняет по сути — ключ всё равно у провайдера.
- Keyless SSL. На PoP лежит только сертификат; операция подписи в рукопожатии выполняется удалённым сервером в вашем периметре. Ключ не покидает ваш HSM, но каждое полное рукопожатие стоит дополнительного round-trip до вашего ключевого сервера. С TLS 1.3 и возобновлением сессий это терпимо.
- Сквозное шифрование без терминации — 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.
Чеклист внедрения
- Ассеты — с хешем в имени,
Cache-Control: public, max-age=31536000, immutable. Никаких purge. - HTML —
max-age=0для браузера,s-maxage/CDN-Cache-Controlдля CDN, обязательноstale-while-revalidateиstale-if-error. Vary— толькоAccept-Encodingи, если правда нужно,Accept-Language. НикогдаUser-AgentиCookieна кэшируемом.- Ключ кэша нормализован: allow-list query-параметров, сортировка, отброс рекламных меток.
- Все заголовки, влияющие на ответ, — в ключе. Проверить
X-Forwarded-Host,X-Original-URL,X-Rewrite-URL. - Ответ с
Set-Cookieне кэшируется. Покрыть тестом. - Включены
proxy_cache_lock/request collapsing и shield. - Инвалидация — по surrogate key, soft purge.
purge all— только руками. - Порядок: коммит в БД → purge. Не наоборот.
- TTL с джиттером. Прогрев не создаёт синхронного фронта истечения.
- Мониторинг: hit ratio по классам контента, origin RPS,
Ageв перцентилях, доля 5xx от origin, доля ответов из stale. - Origin защищён: доступ только с адресов CDN плюс общий секрет в заголовке — иначе вас обойдут напрямую и вся защита от DDoS бесполезна.
- Проверен план на случай отказа CDN: как переключить DNS на origin и выдержит ли origin такой трафик.
Мини-итог
CDN — это не «ускоритель», а управляемая рассинхронизация: вы соглашаетесь, что копии данных временно расходятся, и получаете взамен физически недостижимую иначе задержку и на порядок меньшую нагрузку на origin. Вся сложность профессии сосредоточена в двух точках: ключ кэша (что считается одним и тем же объектом) и инвалидация (когда копия перестаёт быть правдой). Географическая маршрутизация — anycast или DNS GSLB — определяет только, к какому кэшу вы обратитесь; она важна, но чинится настройками провайдера, а ключ и инвалидация чинятся вашим кодом.
Практический порядок работ, если система уже в проде: измерьте hit ratio по классам контента → почините Vary и нормализацию ключа → включите stale-while-revalidate и stale-if-error → внедрите surrogate keys → и только затем обсуждайте с вендором количество PoP.
Источники
- RFC 9111 — HTTP Caching: нормативная семантика свежести, валидации и общих кэшей.
- RFC 5861 — HTTP Cache-Control Extensions for Stale Content:
stale-while-revalidateиstale-if-error. - RFC 9213 — Targeted HTTP Cache Control:
CDN-Cache-Controlи адресные директивы. - RFC 9211 — The Cache-Status HTTP Response Header Field: стандартная замена зоопарку
X-Cache. - RFC 7871 — Client Subnet in DNS Queries: ECS, scope и цена точной геолокации.
- RFC 8586 — Loop Detection in Content Delivery Networks: заголовок
CDN-Loop. - RFC 6707 — CDN Interconnection Problem Statement: зачем и как связывают CDN разных вендоров.
- MDN: HTTP caching и web.dev: HTTP Cache: прикладные руководства.
- ngx_http_proxy_module и документация Varnish: как это настраивается своими руками.
- Practical Web Cache Poisoning и Web Cache Entanglement, James Kettle: обязательное чтение про unkeyed input.
- Karger et al. «Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web», STOC 1997 — математическая основа распределения объектов по кэшам.
- Thaler, Ravishankar «Using Name-Based Mappings to Increase Hit Rates», IEEE/ACM Transactions on Networking, 1998 — rendezvous hashing.
- Nygren, Sitaraman, Sun «The Akamai Network: A Platform for High-Performance Internet Applications», ACM SIGOPS Operating Systems Review, 2010 — как устроена промышленная сеть изнутри.
Что дальше
Мы разобрали, как контент доезжает до пользователя по кратчайшему пути. Дальше — про то, почему у самого пользователя этот путь бывает перекрыт: трансляция адресов, фильтры и туннели, из-за которых рождается фраза «у меня работает».
NAT, фаерволы и VPN: почему «у меня работает, а у клиента нет»