Есть соблазн думать о CDN как о чём-то монолитном и дорогом, но если отойти от мифов, окажется, что для большинства проектов достаточно лёгкой многоузловой схемы: кромочные VPS в нескольких регионах, на каждой — Nginx завершающий TLS и говорящий HTTP/2/3, за ним — Varnish, отвечающий за кэш, уровень логики и graceful деградацию, а за Varnish — защищённый origin. Такая топология даёт почти все приятные свойства «большого» CDN: латентность из-за географии падает, нагрузка на origin исчезает, хвостовые задержки сглаживаются grace-механизмами, а стоимость предсказуема, потому что вы контролируете и железо, и каналы. В независимом облаке это особенно хорошо: мы ставим узлы ближе к пользователю, не платим за загадочные «выходы из зоны» и получаем честные гарантии по vCPU/IOPS. Ниже — как именно собрать приватный CDN на Nginx+Varnish, не превратив всё в музей костылей; с конкретными конфигами, аккуратными сетевыми настройками и фокусом на экономику.
Прежде чем строить, договоримся о цели. Нам нужны кромочные узлы, которые умеют принимать HTTPS по HTTP/2 и HTTP/3 (QUIC), раздавать кэш, обслуживать фильтрованные PURGE/ban-запросы для инвалидации и падать в “grace/stale-while-revalidate”, когда origin занят или недоступен. Нам нужен origin, доступный только этим кромкам, а не всему миру. И нам нужна управляемая маршрутизация: в простом варианте — GeoDNS с короткими TTL, в зрелом — anycast /24 с BGP-анонсами, чтобы один и тот же адрес был «везде рядом». Всё остальное — детали.
Начнём с одного узла, потому что один узел — это просто три процесса в одном флаконе. Возьмём Ubuntu 24.04 или Debian 12 — у них свежие ядра и пакеты, ставим Nginx из репозитория nginx.org, чтобы сразу получить http_v3, ставим Varnish 7.x с поддержкой vcl 4.1, открываем в файрволе 80/443 TCP и 443 UDP под QUIC и сразу включаем NTP, иначе в логах будет параллельная вселенная. Сервисы поднимем «в правильном порядке»: Nginx слушает 443, принимает TLS и HTTP/3, проксирует «вниз» на 127.0.0.1:6081, где ждёт Varnish, а тот тянет контент с origin по приватному адресу, храня копии в памяти и на диске.
sudo apt update && sudo apt -y full-upgrade
sudo apt -y install curl gnupg2 ca-certificates lsb-release ufw jq
# Репозиторий nginx.org (Ubuntu/Debian)
curl -fsSL https://nginx.org/keys/nginx_signing.key | sudo gpg --dearmor -o /usr/share/keyrings/nginx.gpg
echo "deb [signed-by=/usr/share/keyrings/nginx.gpg] http://nginx.org/packages/$(. /etc/os-release; echo $ID) $(lsb_release -sc) nginx" | sudo tee /etc/apt/sources.list.d/nginx.list >/dev/null
sudo apt update && sudo apt -y install nginx
# Varnish 7.x (официальный репозиторий)
curl -fsSL https://packagecloud.io/varnishcache/varnish74/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/varnish.gpg
echo "deb [signed-by=/usr/share/keyrings/varnish.gpg] https://packagecloud.io/varnishcache/varnish74/$(. /etc/os-release; echo $ID)/ $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/varnish.list >/dev/null
sudo apt update && sudo apt -y install varnish
# Файрвол: HTTP/HTTPS + QUIC
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw --force enable
Теперь сделаем Nginx терминатором TLS с HTTP/2 и HTTP/3. Он будет принимать трафик на 443, отдавать HSTS, правильные шифры, Alt-Svc и проксировать клиентские запросы на Varnish через loopback; Varnish в ответе вернёт кэшированный объект или обратится к origin. Чтобы QUIC действительно работал, Nginx должен слушать UDP/443 и рекламировать Alt-Svc — иначе браузер останется на TCP.
# /etc/nginx/conf.d/cdn.conf
server {
listen 80;
server_name cdn.example.com;
return 301 https://cdn.example.com$request_uri;
}
server {
# HTTP/3 (QUIC) + HTTP/2
listen 443 quic reuseport;
listen 443 ssl http2;
server_name cdn.example.com;
ssl_certificate /etc/letsencrypt/live/cdn.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cdn.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;
ssl_prefer_server_ciphers off;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy same-origin always;
add_header Strict-Transport-Security "max-age=31536000" always;
# Сжатие на кромке: gzip и brotli
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# Если установлен модуль brotli (в сборке nginx.org может быть)
brotli on;
brotli_types text/plain text/css application/javascript application/json image/svg+xml;
# Проксирование в Varnish
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:6081;
proxy_read_timeout 120s;
}
# Безопасный PURGE через Nginx (проксируем только с доверённых IP)
location /__purge__ {
allow 127.0.0.1;
allow 198.51.100.25; # ваша админ-подсеть
allow 10.66.66.0/24; # VPN
deny all;
proxy_method PURGE;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:6081;
}
}
Путь сертификата указан реальный, поэтому перед запуском удобно выпустить Let’s Encrypt через standalone на время или через webroot, если уже есть Nginx. QUIC и HTTP/2 активированы, заголовки безопасности включены, Alt-Svc рекламе сообщает клиенту о наличии HTTP/3. Теперь оживим Varnish. По умолчанию он слушает клиентов на 6081 и админ-порт 6082, а backend не настроен; в нашем случае backend — это origin, доступный по приватной сети или по внешнему адресу, но закрыт всем, кроме кромок. Мы сразу включим TTL для статических расширений, строгую нормализацию Accept-Encoding, отключим кэширование для приватных ответов и банально избавимся от Set-Cookie в статике, чтобы не разбивать кеш по кукам. Также отметим, что для изображений компрессия не нужна, а для текстов будем кешировать gzip/br и уважать Vary.
# /etc/varnish/default.vcl
vcl 4.1;
backend origin {
.host = "192.0.2.10"; # приватный адрес origin
.port = "80";
.connect_timeout = 1s;
.first_byte_timeout = 5s;
.between_bytes_timeout = 2s;
}
acl purge {
"127.0.0.1";
"198.51.100.25";
"10.66.66.0"/24;
}
sub vcl_recv {
if (req.method == "PURGE") {
if (client.ip ~ purge) { return (purge); }
return (synth(405, "Not allowed"));
}
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# Пробрасываем реальный клиентский IP вниз по цепочке
if (req.http.X-Forwarded-For) {
set req.http.X-Forwarded-For = req.http.X-Forwarded-For + ", " + client.ip;
} else {
set req.http.X-Forwarded-For = client.ip;
}
# Нормализуем Accept-Encoding, чтобы кэш не взрывался в варианты
if (req.http.Accept-Encoding) {
if (req.url ~ "\.(jpg|jpeg|png|gif|webp|avif|ico)(\?.*)?$") {
unset req.http.Accept-Encoding;
} elseif (req.http.Accept-Encoding ~ "br") {
set req.http.Accept-Encoding = "br";
} elseif (req.http.Accept-Encoding ~ "gzip") {
set req.http.Accept-Encoding = "gzip";
} else {
unset req.http.Accept-Encoding;
}
}
# Приватные запросы не кэшируем
if (req.http.Authorization) { return (pass); }
# Куки убивают кэш; для статики игнорируем, для всего остального — пропускаем
if (req.http.Cookie) {
if (req.url ~ "\.(css|js|woff|woff2|ttf|eot|jpg|jpeg|png|gif|webp|svg|mp4|webm)(\?.*)?$") {
unset req.http.Cookie;
} else {
return (pass);
}
}
return (hash);
}
sub vcl_backend_response {
# Умолчания TTL для ответов без Cache-Control
if (beresp.ttl <= 0s || beresp.ttl == -1s) {
set beresp.ttl = 5m;
}
# Статика живёт дольше, отключаем Set-Cookie и включаем grace
if (bereq.url ~ "\.(css|js|jpg|jpeg|png|gif|webp|svg|woff|woff2|ttf|eot|mp4|webm)(\?.*)?$") {
set beresp.ttl = 7d;
set beresp.grace = 24h;
unset beresp.http.Set-Cookie;
}
# Если origin просит не кэшировать — уважим
if (beresp.http.Cache-Control ~ "private" || beresp.http.Cache-Control ~ "no-cache" || beresp.http.Cache-Control ~ "no-store") {
set beresp.ttl = 0s;
return (pass);
}
set beresp.http.X-Cache-TTL = beresp.ttl;
return (deliver);
}
sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
set resp.http.X-Served-By = server.hostname;
return (deliver);
}
Чтобы Varnish выступал как локальный «клиентский» сервер за Nginx, имеет смысл зафиксировать параметры запуска через systemd-override: адрес прослушивания, память под хранилище и окраску потоков. Мы берём malloc-хранилище на 2 ГБ под горячий набор, статике позволяем жить долго, а бОльшее «холодное» отдадим файловой системе и page cache ядра, если понадобится.
# /etc/systemd/system/varnish.service.d/override.conf
[Service]
ExecStart=
ExecStart=/usr/sbin/varnishd \
-a 127.0.0.1:6081 \
-T 127.0.0.1:6082 \
-s malloc,2G \
-p thread_pools=2 \
-p thread_pool_min=100 \
-p thread_pool_max=2000 \
-f /etc/varnish/default.vcl
После этого перезагружаем юниты и стартуем всё связно. Важно не забыть про сертификаты и QUIC: если Alt-Svc есть, но UDP/443 закрыт или nginx собран без http_v3, браузер вежливо останется на TCP/HTTP/2, и вы потеряете преимущества в хвостовых задержках.
sudo systemctl daemon-reload
sudo systemctl enable --now varnish
sudo nginx -t && sudo systemctl reload nginx
Теперь к «правильной» стороне — origin. У origin должно быть минимум два свойства: он недоступен из Интернета напрямую и отдаёт корректные кэширующие заголовки. Это может быть Nginx или Apache за приватным адресом; важно выставить Cache-Control для статики на недели вперёд и дать ETag/Last-Modified для нормальной валидации. Ещё важнее — закрыть origin на периметре, разрешив доступ только из IP кромочных узлов. Если у вас UFW, правило «deny all, allow только адреса edge» решает вопрос за две строки; в облаке добавляется уровень Security Group.
Когда один узел ожил, масштабирование становится скучной работой. Разворачиваем кромки в двух-трёх регионах, дублируем одинаковую конфигурацию Ansible’ом или скриптами, включаем централизованную инвалидацию. В простейшем варианте инвалидация — это PURGE по URL или ban по маске; в зрелых схемах — ban по xkey (теги), когда один продукт инвалидирует десятки файлов одной командой. Мы уже добавили приватный маршрут /__purge__ в Nginx и ACL в Varnish; этого достаточно, чтобы шлёпнуть curl из CI при выкладке. Важно фильтровать доступ по IP и вовсе не экспонировать админ-порт 6082 наружу.
# Пример инвалидации «точки» через Nginx -> Varnish
curl -X PURGE -H "Host: cdn.example.com" "https://cdn.example.com/__purge__/assets/app.css"
# Пример ban по маске (VCL 4.1, varnishadm)
echo "ban req.http.host == cdn.example.com && req.url ~ ^/assets/" | sudo varnishadm -T 127.0.0.1:6082
Маршрутизация — отдельное удовольствие. Самый экономичный способ — GeoDNS с короткими TTL: пользователи из Европы попадут на узел в NL/DE/UK, пользователи из Азии — в SG, пользователи из США — в US. В независимом облаке вы контролируете TTL, зоны и политику. Когда захотите «как у взрослых», у нас есть BGP и anycast: можно арендовать /24 и анонсировать его в двух-трёх точках, тогда один и тот же адрес окажется рядом для всех; Varnish и Nginx к этому равнодушны, а пользователи получат латентность «из коробки». Anycast дороже в сетевой части, но для высоких SLA окупается уже на этапе борьбы с отказами: один узел падает — маршрут гаснет, трафик переливается без танцев с DNS.
Наблюдаемость даёт половину успешности. На стороне Varnish держите varnishstat и varnishncsa, метрику hit-ratio, объём grace и количество pass. На стороне Nginx активируйте логирование HTTP/3 и отдачу сжатых ресурсов, добавьте короткую метку региона в X-Served-By. Централизованно складывайте логи на NVMe и периодически выгружайте в S3-совместимое хранилище — стоимость копеечная, а польза огромная: по запросу безопасности можно поднять фактическую историю ответов, в инженерии — отловить редкие 5xx по времени. Простейший ориентир здоровья — X-Cache: HIT/MISS и время ответа; хороший кромочный узел держит hit-ratio выше 80% на статике и выше 50–60% на API-ответах с коротким TTL при корректной валидации.
Безопасность — не только про TLS. Закрывайте порт 6082 отовсюду, кроме loopback, не экспонируйте 6081 в Интернет, держите список доверенных адресов для PURGE и банов, на origin разрешайте вход только с кромок; для админового доступа используйте VPN. На Nginx включайте HSTS и строгую политику заголовков, на Varnish не бойтесь отбрасывать подозрительные заголовки с километрическими значениями. При волновой нагрузке полезен «стабилизатор» Varnish: grace и saintmode позволят продолжать обслуживать пользователей из кэша даже при кратковременных ошибках origin. Если хотите бонус к экономике — отдавайте крупные статики напрямую из Nginx на кромке, минуя Varnish, а всё, что мутирует и зависит от валидации, держите через VCL; разница в системных вызовах покажется мелочью, но на хвостовых задержках она заметна.
Технически любопытный момент связан с HTTP/3. QUIC живёт по UDP и заметно улучшает хвостовые задержки на мобильных и перегруженных сетях. Он толерантен к потере пакетов и не теряет весь поток из-за одного повреждённого сегмента, как TCP. В частных CDN это даёт ожидаемый плюс к UX без фокусов: Nginx принимает UDP/443, а Varnish видит обычный HTTP/1.1 от локального Nginx и прекрасно кэширует, даже если половина клиентов говорит с кромкой на h3. Никакой специальной магии для Varnish не нужно — всё интересное происходит «снаружи».
Кому-то может показаться, что мы зарываемся в детали, но именно детали экономят бюджет. Нормализованный Accept-Encoding снижает «взрыв вариантов» кэша и экономит память. Удалённый Set-Cookie в статике сохраняет общий объект для всех. Длинные TTL для статики снимают нагрузку с origin навсегда, а короткие TTL с корректной валидацией позволяют API ощущаться «как локальный», даже когда origin далёк. Отдельная папка для логов на NVMe предотвращает чудесное явление «пропал трафик из-за fsync». И ещё одна важная мелочь — ulimit -n: когда файловых дескрипторов мало, эффект снежного кома поймаете быстрее, чем успеете открыть мониторинг.
На операционной стороне держите один «источник правды» для конфигов. Обычный Ansible-плейбук, который раскатывает Nginx-конфиг, VCL и systemd-override, спасает от «у Вани сборка с brotli, у Маши без». Храните версии в git, в релизных заметках фиксируйте только два-три факта: когда меняли политику кэша, когда меняли список доверенных IP для PURGE и когда меняли TTL. Эти заметки потом отлично коррелируют с метриками и помогают быстро отвечать на вопрос «почему вдруг стало лучше/хуже».
На десерт — минимальная проверка живучести. Для Varnish достаточно отправить кэшируемый статический объект дважды и увидеть X-Cache: HIT во второй раз; для grace — на время «положить» origin (файрволом закрыть его от кромки) и убедиться, что пользователи всё ещё получают ответ из grace. Для HTTP/3 проверьте Alt-Svc и --http3 в curl — если вы видите HTTP/3 200 и нормальный тайминг, значит, UDP/443 открыт, а Nginx действительно собран с http_v3.
# Быстрая самопроверка узла
curl -I https://cdn.example.com/asset/logo.svg
curl -I https://cdn.example.com/asset/logo.svg | grep X-Cache
curl -I --http3 https://cdn.example.com/
Когда узлы по регионам заведены, можно подумать о маршрутизации. Начните с GeoDNS: задайте короткий TTL, укажите адреса узлов для каждой страны/континента, проверяйте маршруты и задержки периодическими запросами curl -w из площадок мониторинга. Если хочется стабильности «как у телекомов», а проект растёт, любой независимый провайдер с BGP подскажет, как собрать anycast на /24 и одновременно оставить GeoDNS как канареечную ветку. Это ровно тот случай, когда «independent cloud» чувствуется руками: вы управляете тем, что на самом деле важно, а не покупаете «чёрный ящик» с неизвестной экономикой.
И да, приватный CDN — это не только про «сайты». Точно так же кэшируются артефакты CI, статические каталоги пакетов, медиа для мобильных приложений, HLS/DASH-фрагменты, публичные API-ответы с короткими TTL. Везде, где повторяемость высока, Varnish экономит деньги и стрессы, а Nginx довозит современный транспорт без боли. В нашей базе знаний есть сопутствующие how-to: UFW для аккуратного периметра, HTTP/3/QUIC на Nginx, резервные копии и обвязка мониторинга; они помогают «довести до ума» детали и не держать всё в голове.
Если важно запуститься быстро и без сюрпризов, мы готовы снять операционную скуку. Можно заказать VPS с преднастройкой «Private CDN (Nginx+Varnish)», получить готовые кромочные образы в выбранных регионах и закрытый origin с бэкендом. Мы бесплатно мигрируем сайт и статику, настроим PURGE/ban и централизованные логи, включим HTTP/3, проверим хиты на тестовом трафике и передадим плейбук для дальнейших раскаток. Ещё один полезный шаг — проверить доступность локаций и выбрать точки присутствия по вашим аудиториям; если нужно anycast, поможем с арендой /24 и анонсом через наш BGP без лишней бюрократии.
В результате получается вещь почти скучная — и именно поэтому надёжная. Лёгкие VPS в трёх-четырёх местах, «правильный» Nginx на входе, Varnish с терпеливым кэшем, закрытый origin, дисциплина с TTL и инвалидациями, прозрачные логи и простые метрики. С точки зрения бизнеса это независимость от чужих ценообразований, предсказуемая стоимость, гибкость ближе к продукту и удобная безопасность. С точки зрения инженерии — понятные флаги, честные тесты и отсутствие азартных «сюрпризов» по ночам. Именно так и должен выглядеть приватный CDN в независимом облаке: меньше магии, больше управляемости и простая математика экономики. Если нужна площадка — подготовим узлы, доведём нагрузочные тесты и останемся рядом до «зелёного» статуса на графиках.