CDN и сжатие

Кеш убирает запросы. Всё, что осталось, надо доставить: с ближайшего сервера, минимальным числом байтов и по нормальному протоколу.

CDN (Content Delivery Network) — сеть серверов по всему миру, которые кешируют вашу статику и отдают её пользователю с ближайшей к нему точки, вместо того чтобы гонять байты через полпланеты к вашему origin-серверу.

Зачем это нужно

Скорость света — единственное ограничение в вебе, которое нельзя оптимизировать кодом. Свет в оптоволокне идёт примерно 200 000 км/с, и путь Москва — Вирджиния туда-обратно даёт около 120 мс чистой физики. Установка соединения — это 3 таких круга (TCP, потом TLS), то есть треть секунды до того, как поедет первый байт вашего CSS.

Вы не сделаете физику быстрее. Но вы можете сократить расстояние — и именно этим занимается CDN. Плюс, как бонус, он снимает с вашего сервера почти весь трафик статики и переживает всплески нагрузки, от которых один origin-сервер лёг бы.

CDN: география и разгрузка

CDN хранит копию вашей статики на сотнях edge-серверов. Пользователь из Новосибирска получает файл из Новосибирска, из Берлина — из Берлина. Соединение (TCP + TLS) устанавливается с ближайшим узлом, и RTT падает со 120 мс до 5–20 мс. Дальше edge либо отдаёт файл из своего кеша, либо один раз сходит за ним к вашему origin и закеширует.

Важно понимать: CDN подчиняется тем же заголовкам, что и браузер. Из прошлого урока: public разрешает общим кешам хранить ответ, private — запрещает. Есть отдельная директива s-maxage: она задаёт время жизни именно для общих кешей (CDN), не трогая браузерное max-age.

# Браузер держит 5 минут, CDN — сутки, и ещё неделю может отдавать
# протухшую копию, пока фоном обновляет её у origin
Cache-Control: public, max-age=300, s-maxage=86400, stale-while-revalidate=604800

Эта комбинация — рабочая лошадка для HTML на контентных сайтах: origin получает считанные запросы, а пользователь почти всегда получает ответ мгновенно с edge.

gzip против brotli

Текст сжимается отлично: HTML, CSS, JS, JSON, SVG теряют 70–85% веса. Работает это прозрачно — браузер сообщает, что умеет распаковывать, сервер выбирает алгоритм и помечает ответ:

# Браузер:
Accept-Encoding: gzip, deflate, br, zstd

# Сервер:
Content-Encoding: br
Vary: Accept-Encoding

gzip — старый добрый стандарт, понимают все. brotli (br) — алгоритм от Google со встроенным словарём типовых веб-конструкций; на текстовых ресурсах он даёт в среднем на 15–20% меньше байтов, чем gzip, и поддерживается всеми актуальными браузерами. Заголовок Vary: Accept-Encoding обязателен: он говорит кешам хранить отдельные версии ответа под разные кодировки, иначе клиент с gzip рискует получить brotli-поток и не распаковать его.

Ключевая тонкость, о которой забывают: у brotli есть уровни сжатия 0–11, и brotli_comp_level 11 на лету — это очень дорого по CPU. Правильная стратегия разная для статики и динамики:

  • Статика (файлы сборки) — сжимайте заранее, на этапе build, максимальным уровнем. Рядом с index-4f2a9c1e.js кладётся index-4f2a9c1e.js.br, и nginx с директивой brotli_static on отдаёт готовый файл, не тратя ни такта CPU в рантайме.
  • Динамика (ответы API, SSR-HTML) — сжимайте на лету, но умеренным уровнем (brotli 4–5 примерно соответствует gzip 6 по скорости и лучше по размеру).
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;

brotli on;
brotli_comp_level 5;
brotli_static on;          # отдавать готовые .br файлы, если они лежат рядом
brotli_types text/plain text/css application/javascript application/json image/svg+xml;

И главное правило: не сжимайте уже сжатое. JPEG, PNG, WebP, AVIF, MP4, woff2 — внутри уже сжаты. Прогон их через gzip не даст выигрыша (иногда даже добавит байтов), но потратит CPU на каждом запросе. Списки gzip_types и brotli_types должны содержать только текстовые MIME-типы.

Проверить, что сжатие включилось, можно так:

curl -sI -H "Accept-Encoding: br" https://example.com/assets/index-4f2a9c1e.js \
  | grep -iE "content-encoding|content-length|vary"
# content-encoding: br
# content-length: 41208
# vary: Accept-Encoding

HTTP/2 и HTTP/3: что реально меняется

Вокруг версий протокола много магического мышления, поэтому по существу.

HTTP/2

Главное новшество — мультиплексирование: все запросы к одному домену идут по одному TCP-соединению параллельно, без очереди. В HTTP/1.1 браузер держал 6 соединений на домен, и седьмой запрос ждал. Плюс HTTP/2 сжимает заголовки (HPACK) — на сайте с жирными cookie это заметная экономия.

Практическое следствие, которое многие пропустили: старые приёмы HTTP/1.1 стали вредны. Доменный шардинг (раскидать статику по static1., static2.) теперь только плодит лишние рукопожатия. Склейка всех иконок в один спрайт и всего JS в один гигантский бандл больше не обязательна — много мелких файлов кешируются гранулярнее (поменяли одну страницу — не инвалидировали весь бандл). А server push, который активно рекламировали, оказался неудачной идеей: он слал файлы, которые уже лежали в кеше клиента, и в 2022 году его убрали из Chrome. Вместо него — rel="preload".

HTTP/3

HTTP/2 решил очередь на уровне HTTP, но под ним остался TCP, а у TCP есть своя очередь: потеря одного пакета останавливает доставку всех потоков в соединении, пока пакет не переотправят. Это head-of-line blocking на транспортном уровне, и на мобильной сети с потерями он бьёт больно.

HTTP/3 работает поверх QUIC (протокол на базе UDP), где потоки независимы: потерялся пакет одного потока — остальные едут дальше. Плюс QUIC объединяет транспортное и криптографическое рукопожатие в одно (соединение устанавливается за 1 RTT вместо 3, а при повторном визите — за 0 RTT) и переживает смену сети: пользователь ушёл с Wi-Fi в LTE — соединение не рвётся, а мигрирует.

Итог трезво: на быстром проводном интернете разница между HTTP/2 и HTTP/3 близка к нулю. На мобильной сети с потерями пакетов и на дальних дистанциях выигрыш вполне реальный. Хорошая новость в том, что включать это самому обычно не надо — HTTP/3 просто включается галочкой у CDN, и браузер сам переключится, увидев заголовок Alt-Svc.

preconnect и dns-prefetch

Пока браузер не встретил в HTML ссылку на сторонний домен, он ничего о нём не знает. А как только встретил — начинается тот самый долгий путь: DNS → TCP → TLS. Подсказки в <head> позволяют начать этот путь заранее, параллельно с парсингом HTML:

<!-- Полное рукопожатие заранее: DNS + TCP + TLS. Дорого, но эффективно -->
<link rel="preconnect" href="https://cdn.example.com">

<!-- Для шрифтов crossorigin ОБЯЗАТЕЛЕН: они грузятся в анонимном режиме,
     и без этого атрибута откроется второе, отдельное соединение -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

<!-- Только DNS-резолв. Дёшево, для доменов, которые понадобятся «может быть» -->
<link rel="dns-prefetch" href="https://analytics.example.com">

<!-- preload — это не про соединение, а про сам файл: «начни качать прямо сейчас» -->
<link rel="preload" href="/fonts/Inter-var-c3d90b21.woff2" as="font" type="font/woff2" crossorigin>
ПодсказкаЧто делаетКогда применять
dns-prefetchТолько DNS-запрос. Дёшево, экономит 20–120 мсДоменов много, или они нужны не наверняка
preconnectDNS + TCP + TLS. Экономит 100–300 мс, но держит соединение1–4 критичных домена, откуда ресурсы точно поедут
preloadСкачивает конкретный файл с высоким приоритетомLCP-картинка, критичный шрифт

Как это работает

Соберём всё в одну картину — жизнь одного запроса за шрифтом со стороннего домена и то, кто из инструментов какой кусок съедает:

ЭтапЦенаЧем сокращается
DNS-резолв20–120 мсdns-prefetch, preconnect
TCP-рукопожатие1 RTTpreconnect; CDN (RTT меньше); HTTP/3 (сливается с TLS)
TLS-рукопожатие1–2 RTTpreconnect; HTTP/3 (0-RTT при повторном визите)
Ожидание ответа (TTFB)1 RTT + время сервераCDN (edge вместо origin)
Скачивание теларазмер / полосаbrotli, оптимизация ассетов
Очередь за другими запросамиот 0 до секундHTTP/2 (мультиплексирование), HTTP/3 (нет HOL на транспорте)

Обратите внимание: сжатие бьёт только по последней строчке. Если ваш JS весит 40 КБ, а сайт тормозит — проблема не в сжатии, а в четырёх строках выше. Это очень частая ошибка приоритизации.

Частые ошибки

  • Десяток preconnect «на всякий случай». Каждое соединение — это память, CPU на TLS и конкуренция за полосу в самый нежный момент загрузки. Больше 4–6 preconnect обычно вредят. Оставьте те домены, откуда ресурсы поедут гарантированно и сразу.
  • preconnect к шрифтам без crossorigin. Шрифты запрашиваются в анонимном CORS-режиме, и соединение без crossorigin им не подойдёт — браузер откроет второе. Вы заплатили за рукопожатие дважды и не сэкономили ничего.
  • gzip на картинках и шрифтах. JPEG/PNG/WebP/woff2 уже сжаты. Прогон через gzip тратит CPU сервера впустую. Держите gzip_types чистым.
  • brotli уровня 11 на динамике. Под нагрузкой сервер уходит в CPU-полку, а TTFB растёт сильнее, чем вы сэкономили на байтах. Максимальный уровень — только для предсжатия на этапе сборки.
  • Забытый Vary: Accept-Encoding. Кеш (CDN или прокси) сохранит brotli-версию и отдаст её клиенту, который brotli не поддерживает. Пользователь получит бинарный мусор вместо страницы.
  • CDN подключили, но Cache-Control не настроили. Без явного заголовка edge либо не кеширует ничего и просто проксирует всё к origin (вы платите за CDN, но не получаете кеша), либо кеширует по своим умолчаниям — включая персонализированный HTML с чужой сессией. Заголовки задаёте вы, а не CDN.
  • Вера в то, что HTTP/3 «ускорит сайт». Он ускорит доставку по плохой сети. Тяжёлый JS, который блокирует главный поток на 3 секунды, не ускорит никакой протокол.

Итоги

  • CDN сокращает физическое расстояние: RTT падает с сотни миллисекунд до единиц. Он подчиняется вашим заголовкам — public, s-maxage, stale-while-revalidate.
  • brotli на тексте выигрывает у gzip 15–20%. Статику сжимайте заранее максимальным уровнем (brotli_static on), динамику — на лету уровнем 4–5.
  • Не сжимайте уже сжатое: картинки, видео, woff2. И никогда не забывайте Vary: Accept-Encoding.
  • HTTP/2 дал мультиплексирование и отменил доменный шардинг и обязательную склейку бандлов. Server push мёртв — используйте preload.
  • HTTP/3 (QUIC) убирает head-of-line blocking на транспорте и ускоряет рукопожатие. Реальный выигрыш — на мобильных и дальних соединениях; включается на стороне CDN.
  • preconnect — для 1–4 критичных доменов (шрифтам обязателен crossorigin), dns-prefetch — для остальных, preload — для конкретного критичного файла.
Проверьте себя
1. Почему для preconnect к домену со шрифтами обязателен атрибут crossorigin?
AОн разрешает CDN кешировать шрифт на edge-сервере
BШрифты грузятся в анонимном CORS-режиме, и без crossorigin браузер откроет второе, отдельное соединение — экономия обнулится
CБез него шрифт не пройдёт brotli-сжатие
DОн нужен только для HTTP/3, на HTTP/2 не влияет
2. Какой заголовок обязателен, если сервер отдаёт brotli-сжатые ответы, а перед ним стоит CDN или прокси?
ACache-Control: immutable
BContent-Length
CVary: Accept-Encoding
DAlt-Svc: h3