Как мы сэкономили 2000 USD на трафике из Amazon S3 с помощью nginx-кэша

78d11767bc3077e46d5bd2eaac614f29.png

Эта небольшая история — живое свидетельство того, как самые простые решения (иногда) могут оказаться очень эффективными. В одном из проектов руководство взяло курс на оптимизацию бюджета на инфраструктуру. В результате анализа всех статей расходов стало очевидным, что заметно выдаются счета за сетевой трафик из Amazon S3-бакета, где хранится публичная статика веб-приложения. Так появилась задача найти и реализовать максимально недорогой и решающий бизнес-задачу способ.

Проблема и как её решать

Имеются следующие вводные:

  • Объем статики — около 1 ТБ;

  • Исходящий трафик — около 15 ТБ в месяц;

  • Текущий ежемесячный счет — около 2000 USD.

Как известно, в случае с S3 главная статья расходов — это исходящий трафик, а не сам хранимый объем. Соответственно, задача состоит в том, чтобы перевести этот трафик в более дешевое место.

Что с этим можно сделать?

  1. воспользоваться услугами различных CDN-провайдеров;

  2. реализовать собственный кэширующий прокси.

От 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-бакет. С таким подходом получаем действительно экономное и в достаточной мере отказоустойчивое решение (повторюсь, что в нашем частном случае).

Итоговая схема выглядит так:

ffa56b1e51b9f2f8b6a9f7c50ba8bef4

Детали

Весь трафик мы получаем в единую точку входа: 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;
    }
}

Здесь есть пара очень важных моментов, на которые стоит обратить внимание:

  1. Конструкция из нескольких server'ов. В чем её смысл? Если в качестве backup upstream указать напрямую адрес бакета, то рано или поздно мы столкнёмся с проблемой того, что адрес будет преобразован в IP во время загрузки конфигурации nginx и закэширован. Но IP в URL’е бакета — динамический, а следовательно, nginx ничего не узнает о том, когда адрес изменится, поэтому в момент переключения на backup upstream начнет отправлять трафик на неправильный IP.

    Чтобы избежать этой фатальной ошибки, приходится делать промежуточный internal server_name, в котором указываем resolver и время жизни полученного IP. Также стоит сделать его default_server, чтобы не получить проблем с Host-заголовком, что пробрасывается из origin’а.

  2. Директива 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-ресурс не прилагаю, т.к. он выглядит совершенно буднично.

Результат

После реализации этой схемы и переключения на неё трафик на балансировщиках изменился следующим образом:

15015a50df47f8013a3da56eb1530ce9

Получается, что вся связка стоила нам лишь добавления одного сервера стоимостью 60 евро и бесплатного трафика, который отдается с него, в текущих объемах. При этом есть еще двукратный запас по росту трафика до того момента, когда он может начать оплачиваться.

Выводы

Что мы поняли после эксплуатации подобного решения на протяжении некоторого времени? Эта реализация имеет простую схему отказоустойчивости и подходит даже для больших объемов и нагрузок. Однако надо понимать ее потенциальные недостатки и что стоит учесть перед боевым внедрением.

Во-первых (напомню еще раз), отсутствует распределенная раздача и кэширование файлов между регионами/странами. Для нас это не имело значения, однако все будет иначе для тех, кто нуждается в одинаково быстрой отдаче файлов для пользователей из разных концов континента.

Второй момент — объем трафика. Учитывайте особенности тарификации своего провайдера. Поскольку точка раздачи по сути единственная, объемы могут быть значительными и «неожиданно» могут привести к немалой трате средств. Тогда уж лучше задуматься про подключение CDN.

В-третьих, отказоустойчивость. Вам почти наверняка не удастся воспроизвести отказоустойчивость, которую сможет предложить популярный CDN. Если вы сейчас работаете в рамках одного ЦОДа, то точек отказа — великое множество. Даже если вы продумаете хорошую схему с механизмом failover«а входных балансировщиков, в машинном зале ЦОДа может отказать БП, а трактор перед ЦОДом обязательно перекопает магистральный канал… Очевидно, что ради отказоустойчивости одной только раздачи статики нет смысла городить межЦОДовую архитектуру — тогда тоже проще подключить CDN. Однако если у вас уже такая инфраструктура, почему бы и нет?… (И опять же, не забудьте про регион доставки контента.)

Такие потенциальные проблемы, их следствия и пересечения разнятся от ситуации к ситуации, поэтому критично учитывать узкие места архитектуры в целом и, конечно, понимать, какую цель вы хотите достичь и технически, и для бизнеса.

P.S.

Читайте также в нашем блоге:

© Habrahabr.ru