Эта небольшая история — живое свидетельство того, как самые простые решения (иногда) могут оказаться очень эффективными. В одном из проектов руководство взяло курс на оптимизацию бюджета на инфраструктуру. В результате анализа всех статей расходов стало очевидным, что заметно выдаются счета за сетевой трафик из Amazon S3-бакета, где хранится публичная статика веб-приложения. Так появилась задача найти и реализовать максимально недорогой и решающий бизнес-задачу способ.
Проблема и как её решать
Имеются следующие вводные:
Объем статики — около 1 ТБ;
Исходящий трафик — около 15 ТБ в месяц;
Текущий ежемесячный счет — около 2000 USD.
Как известно, в случае с S3 главная статья расходов — это исходящий трафик, а не сам хранимый объем. Соответственно, задача состоит в том, чтобы перевести этот трафик в более дешевое место.
Что с этим можно сделать?
воспользоваться услугами различных CDN-провайдеров;
реализовать собственный кэширующий прокси.
От CDN’ов отказались по двум причинам:
В CDN как таковом не было потребности: у проекта единственный целевой регион пользователей. К тому же, точек присутствия известных CDN’ов в данном регионе не было (и даже близко).
Стоимость. Все популярные решения выходили в среднем от 300 USD до тысяч у крупных провайдеров.
Примерная стоимость услуг популярных CDN для нашего случая получалась такая:
Selectel — 250 USD;
Akamai — 350 USD;
Amazon CloudFront — 1000+ USD;
Google CDN — 1000+ USD.
По этим причинам оптимальным выходом виделся self-hosted кэширующий прокси, т.к. он целиком удовлетворяет потребности с многократным выигрышем в стоимости решения.
Реализация
Архитектура
Основная проблема, которая возникала при локальном кэширующим прокси, — отказоустойчивость. Так как данных много и отдавать их надо быстро, под хранение требуется сервер на быстрых NVMe-дисках с достаточным объемом. Главная забота в таком случае — потеря самого сервера: ведь тогда мы полностью теряем статику, что никуда не годится.
Очевидное решение — докупить второй сервер и сделать балансировку между ними, а отказоустойчивость на основе VRRP. Так получилось бы нормальное решение и в плане отказоустойчивости, и в плане масштабируемости. Однако при обсуждении вопроса с клиентом пришли к выводу, что для нас это избыточно, поскольку объем трафика не требует масштабирования и в обозримом будущем не планируется существенного увеличения (т.е. мы бы получили существенное увеличение стоимости без явной на то потребности). В итоге остановились на минимальном варианте: в случае потери кэширующего сервера достаточно делать автопереключение обратно на публичный S3-бакет. С таким подходом получаем действительно экономное и в достаточной мере отказоустойчивое решение (повторюсь, что в нашем частном случае).
Итоговая схема выглядит так:
Детали
Весь трафик мы получаем в единую точку входа: nginx-балансировщики. После этого запросы уходят в Kubernetes и попадают по назначению в nginx на выделенном сервере-хранилище. Если по какой-то причине существуют проблемы в получении ответа от нашего прокси, включается backup upstream — в его роли origin.
Могут появиться резонные вопросы: «Зачем делать этот прокси узлом Kubernetes? Это же не приносит никакой пользы в данной схеме!». Всё довольно просто: мы сторонники подхода IaC и для нас есть вполне ощутимая польза: унификация конфига и управления новой сущностью. В случае, когда это отдельный хост (живущий сам по себе), им надо как-то управлять, ставить туда nginx, конфигурировать его и т.д. А потом забудут о нём и о том, из какого репозитория он управляется (если он вообще появится)… Kubernetes в наших реалиях автоматически решает все эти боли и не приносит проблем или накладных расходов, а управляется IaC-манифестами из Git-репозитория.
Как выглядят конфиги для этой схемы?
Вот что в Nginx LB:
##################
server {
listen 80;
server_name MAIN.IMG.DOMAIN;
location / {
proxy_set_header Host $http_host;
proxy_pass http://s3-upstream;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
}
}
##################
upstream s3-upstream {
server k8s-ingress-ip-1:30080 max_fails=1 fail_timeout=5s weight=100;
server k8s-ingress-ip-2:30080 max_fails=1 fail_timeout=5s weight=100;
server k8s-ingress-ip-3:30080 max_fails=1 fail_timeout=5s weight=100;
server 127.0.0.1:8888 backup;
}
###################
server {
listen 127.0.0.1:8888 default_server;
server_name DOMAIN.NAME;
location / {
resolver 8.8.8.8 valid=10s;
set $bucket "BUCKET_NAME.s3.eu-central-1.amazonaws.com";
proxy_set_header Host $bucket;
proxy_pass http://$bucket;
}
}
Здесь есть пара очень важных моментов, на которые стоит обратить внимание:
Конструкция из нескольких
server
'ов. В чем её смысл? Если в качестве backup upstream указать напрямую адрес бакета, то рано или поздно мы столкнёмся с проблемой того, что адрес будет преобразован в IP во время загрузки конфигурации nginx и закэширован. Но IP в URL'е бакета — динамический, а следовательно, nginx ничего не узнает о том, когда адрес изменится, поэтому в момент переключения на backup upstream начнет отправлять трафик на неправильный IP.Чтобы избежать этой фатальной ошибки, приходится делать промежуточный internal
server_name
, в котором указываем resolver и время жизни полученного IP. Также стоит сделать егоdefault_server
, чтобы не получить проблем с Host-заголовком, что пробрасывается из origin'а.Директива
proxy_next_upstream
. Если вы её не укажете, переключение на backup upstream будет происходить почти наверняка не так, как вы того хотите.
Далее конфиг самого прокси-кэша (точнее, только значимые его части):
proxy_cache_path /var/cache/nginx/s3-cache levels=1:2 keys_zone=s3-cache:1280m max_size=1050g inactive=43200m;
location / {
add_header 'Access-Control-Allow-Origin' '*';
proxy_cache s3-cache;
proxy_cache_key $scheme$request_method$host$request_uri;
proxy_cache_valid 200 301 302 60d;
proxy_cache_valid 403 404 1m;
proxy_cache_revalidate on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504 http_403 http_404 http_429;
proxy_cache_background_update on;
proxy_cache_lock on;
expires 60d;
proxy_http_version 1.1;
proxy_hide_header x-amz-id-2;
proxy_hide_header x-amz-request-id;
proxy_hide_header x-amz-version-id;
proxy_ignore_headers "Set-Cookie" "Expires" "Cache-Control";
proxy_pass http://BUCKET_NAME.s3.eu-central-1.amazonaws.com/;
}
Тут всё должно быть понятно.
NB: Кстати, существует более сложный и неочевидный вариант конфигурации nginx при использовании приватного S3-бакета — мы его уже описывали в другой статье.
Ingress-ресурс не прилагаю, т.к. он выглядит совершенно буднично.
Результат
После реализации этой схемы и переключения на неё, трафик на балансировщиках изменился следующим образом:
Получается, что вся связка стоила нам лишь добавления одного сервера стоимостью 60 евро и бесплатного трафика, который отдается с него, в текущих объемах. При этом есть ещё двукратный запас по росту трафика до того момента, когда он может начать оплачиваться. А про прежний счёт в 2000 USD можно забыть благодаря тому, что с разовым «прогревом» кэша S3-трафик пропадает (а стоимость хранения данных в S3 минимальна).
Выводы
Что мы поняли после эксплуатации подобного решения на протяжении некоторого времени? Эта реализация имеет простую схему отказоустойчивости и подходит даже для больших объемов и нагрузок. Однако надо понимать ее потенциальные недостатки и что стоит учесть перед боевым внедрением.
Во-первых (напомню еще раз), отсутствует распределенная раздача и кэширование файлов между регионами/странами. Для нас это не имело значения, однако все будет иначе для тех, кто нуждается в одинаково быстрой отдаче файлов для пользователей из разных концов континента.
Второй момент — объем трафика. Учитывайте особенности тарификации своего провайдера. Поскольку точка раздачи по сути единственная, объемы могут быть значительными и «неожиданно» могут привести к немалой трате средств. Тогда уж лучше задуматься про подключение CDN.
В-третьих, отказоустойчивость. Вам почти наверняка не удастся воспроизвести отказоустойчивость, которую сможет предложить популярный CDN. Если вы сейчас работаете в рамках одного ЦОДа, то точек отказа — великое множество. Даже если вы продумаете хорошую схему с механизмом failover’а входных балансировщиков, в машинном зале ЦОДа может отказать БП, а трактор перед ЦОДом обязательно перекопает магистральный канал… Очевидно, что ради отказоустойчивости одной только раздачи статики нет смысла городить межЦОДовую архитектуру — тогда тоже проще подключить CDN. Однако если у вас уже такая инфраструктура, почему бы и нет?.. (И опять же, не забудьте про регион доставки контента.)
Такие потенциальные проблемы, их следствия и пересечения разнятся от ситуации к ситуации, поэтому критично учитывать узкие места архитектуры в целом и, конечно, понимать, какую цель вы хотите достичь и технически, и для бизнеса.
P.S.
Читайте также в нашем блоге: