Проксирование, ресайз и кеширование картинок с помощью NGINX

9 комментариев

Есть некий удаленный сервис (далее images.service.com), по функционалу похож на каталог товаров. У него есть АПИ благодаря которому, я могу выводить этот список товаров на своем проекте (далее site.com). Но вот незадача, удаленный ресурс частенько падает. И как следствие — в каталоге пропадают абсолютно все товары, и вы сами представляете, наверняка, что это такое.

Пришлось прикрутить кеширование данных всех товаров через АПИ. По сути, сам проект уже работает с кешированными данными. Но вся беда в картинках каждого товара. Приблизительная схема работы проекта:
Кеширование товаровКак только падает удаленный сервис, пропадают картинки товаров, что очень сильно сказывается на юзабилити проекта, и вообще выглядит убого.

Товаров в каталоге около 10 000. Картинок у каждого в среднем 30. Плюс 3 размера каждой картинки (large, medium, small), итого около 900 000 различных изображений (это около 80ГБ). Товары в каталоге динамичны — появляются, удаляются. Т.е. и за актуальностью картинок тоже надо бы следить.

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

Подытожим наши требования:

  • проксировать картинки с удаленного сервиса и сохранять их локально
  • если есть уже сохраненная картинка, то отдавать ее
  • автоматически ресайзить картинки, пересохранять их в progressive JPEG
  • минимизировать обращения к удаленному серверу, а именно:
    • если удаленная картинка не найдена, то такой ответ сервиса кешировать
    • если сервис упал, то такие ответы тоже кешировать
    • если есть уже сохраненная картинка бОльшего размера, то использовать ее (т.е. взять ее, отресайзить, сохранить, и отдать пользователю), без обращения к удаленному сервису
  • в случае ошибки отдавать картинку-заглушку

Использовать PHP или любые другие скриптовые средства не хотелось, из-за их тормознутости/отжирания памяти. Все это удалось решить средствами NGINX.

Сразу скажу, для меня это было в новинку, с nginx я работал мало, в основном только на уровне базовых правил и не более. Но за время того как делал эту задачу, можно сказать выучил документацию по nginx чуть ли не наизусть)

Забегу вперед и покажу схему архитектуры, которая получилась в итоге:
Схема работы NGINX из двух инстансов

Как видно из схемы, пришлось создавать два инстанса nginx (две разные секции server), и разделять задачи между ними. В принципе можно было бы обойтись и одним, но тогда кешировать запросы с ошибками не удалось бы. А если удаленный сервис вдруг упадет, то этого кеша очень сильно не хватало бы. Поэтому решено было остановится на такой схеме.

Для всего этого дела был приобретен отдельный сервер (далее image.site.com), который и стал у нас в дальнейшем сервером картинок.

NGINX

Для описанного выше, нам нужен модуль ngx_http_image_filter_module, который в общем то и позволяет ресайзить картинки. Но, к сожалению, по дефолту nginx собирается без него. Поэтому пришлось пересобрать nginx, с подключением этой библиотеки. Так же заодно добавил поддержку PCRE JIT, в мануале написано, что

использование PCRE JIT способно существенно ускорить обработку регулярных выражений.

А в нашем случае это будет довольно полезно. Полный процесс сборки описывать не буду, он хорошо описан в официальной документации. В моем случае добавились лишь следующие аргументы при сборке:
./configure ... --with-http_image_filter_module --with-pcre=[путь до pcre] --with-pcre-jit
В будущем еще планирую подключить HTTP/2. Будет полезно.

Основной инстанс

Собственно это тот инстанс, что открыт на 80 и 443 портах и доступен снаружи.

В этой секции основной упор был сделан на директиву proxy_store. Что она дает? Она сохраняет проксируемый файл в указанном каталоге, как есть, с сохранением структуры каталогов. Для нашей задачи подходит, как нельзя лучше. Конфиг nginx для этой секции:

Показать конфиг основного инстанса NGINX
Разберем по функциональным блокам.

Картинка-заглушка

По дефолту, на любой ошибочный запрос отдается картинка /nophoto/nophoto.jpg (мало ли кто набрал вручную имя домена где у нас лежат картинки или пытается скачать какую нибудь другую страницу).

Но если к серверу обратились за картинкой, а ее нет, мы отдаем картинку в нужном формате (т.е. хотят PNG но ее на сервере нету, вернется /nophoto/nophoto.png и т.д.). Для этого в каталоге /nophoto/ лежат картинки всех перечисленных форматов.

Существующие картинки

Тут все просто:

Если файл в каталоге /imdata/ есть, отдаем его, если нет, то переходим в секцию @proxy_local.

Запрос к внутреннему инстансу

Здесь мы уже видим ту самую диррективу proxy_store:

которая сохраняет результат запроса в каталог /imdata/. Здесь происходит внутренний запрос к 0.0.0.0:8085, а точнее ко внутреннему инстансу NGINX о котором говорилось выше.

Причем если внутренний запрос вернет ошибку, например 404, то этот ответ сохранен не будет, а обработается в соответствии с указанной ранее директивой error_page, см. proxy_intercept_errors.

При этом обратите внимание на то что proxy_pass_request_body и proxy_pass_request_headers установлены в off. Зачем? Затем чтобы минимизировать трафик внутри между инстансами NGINX. Предполагается что клиент запрашивает картинку (т.е. обычный статичный файл), а это значит, что никаких заголовков, типа User-Agent, Referer и прочих, для запроса не нужно. И уж тем более не нужно тело запроса (параметры POST, PUT и т.д), так как таких запросов тут быть не должно вообще, и в конфиге я целенаправленно заблокировал все кроме GET и HEAD (см. limit_except).

Внутренний инстанс

Этот инстанс висит на 8085 порту, но доступ к нему извне закрыт фаерволом, т.к. по сути он нужен только для внутренних запросов, и светить наружу ему попросту не за чем.

Показать конфиг внутреннего инстанса NGINX
С этой секцией уже посложнее. Сразу поясню по размерам картинок:
Оригинальный размер Новый размер
full — 940px * ?
large — 940px * ? large — 625px * ?
medium — 611px * ? medium — 380px * ?
small — 282px * ? small — ? * 100px
(? — пропорциональное изменение размера)

Как видно, практически все используемые размеры нам не подходят (единственный спорный момент — использование старых картинок типа medium вместо новых large, т.к. у них разница составляет всего 14px, и можно было бы переверстать оригинальный шаблон, но было принято решение подгонять все в точности как надо нам).

При этом старые полнозамерные картинки large теперь пересохраняются под новым именем full, на тот случай если нам однажды понадобится добавить новый размер.

Покажу то, как работает этот инстанс на примере запроса картинки medium в виде блок-схемы:
Блок схема ресайзинга проксируемых картинок
(Вообще на самом деле немного не так. На деле кеш проверяется перед запросом на удаленный сервер. Но это не принципиально, а такая схема более наглядна.)

Кеширование

Как видно тут, кешируются все ответы удаленного ресурса: ошибки на 1 час, все валидные ответы на 1 минуту.

Зачем кешировать валидный ответ? В данном случае это делается для снижения количества обращений к удаленному сервису. Т.е. если в один момент два разных человека запросят одну и ту же картинку, то выполнится не два запроса на удаленный сервис, а один. Второй запрос будет стоять в очереди (proxy_cache_lock) ожидания завершения точно такого же предыдущего запроса.

Работа с картинкой

Проверка существования картинок осуществляется следующим образом:

Т.е. если найдена картинка по адресу ${root_path}/imdata${request_1}full${request_2} или ${root_path}/imdata${request_1}large${request_2} (где ${request_1} и ${request_2} — части URL запроса), то управление передается в отдельную секцию через код ошибки 482:

(да есть и другие способы передачи обработки запроса, например через rewrite, try_files и прочее, но такой способ через error_page мне показался наиболее лаконичным)

Если же сохранной ранее картинки бОльшего размера нету, то обращаемся за ней на удаленный сервис, и ресайзим ее:

и полученный результат отдаем.

Честно сказать, очень печалит подход к секции if в nginx, а точнее ее неявное преобразование в еще один location. И следующее отсюда отсутствие возможности реализовывать вложенные if, отсутствие конструкции else. Хотя с другой стороны, это все таки конфигурационный файл, а не язык программирования.

Обновление картинок

Как помним из вступления, нам нужна была еще динамика. Т.е. добавление/удаление картинок.

В принципе добавление картинок на сервер происходит постепенно и прозрачно, при каждом запросе к новой картинке она сохраняется у нас, и можно было бы не заморачиваться. Рано или поздно все картинки оказались бы на нашем сервере. А точнее в нашем случае это будет скорее «поздно», так как это произойдет после того как юзеры просмотрят все товары внутри каталога, и пролистают все фотографии. А так как удаленный сервис падает довольно часто, то вероятность того, что юзер попытается просмотреть еще не сохраненную у нас картинку будет очень велика.

Было решено приделать систему поддерживающую актуальность сохраненных картинок. Работает очень просто. На сервере проекта site.com раз в сутки по крону парсится список всех товаров, вытаскиваются все ссылки на картинки, и составляется такой список всех картинок:
http://images.service.com/images/item123/123/123/large_123.jpg
http://images.service.com/images/item234/234/234/large_234.jpg
...
[и еще 300 000 линков на прочие картинки]

Затем на сервере картинок images.site.com (тот самый про который и написан этот пост), так же раз в сутки этот файл скачивается wget-ом, после чего стартует скрипт который и обрабатывает данный список.

Алгоритм очень прост. Проверяем существование этого файла в каталоге /imdata/, если файла нет, то делаем HEAD запрос к локальному серверу NGINX. Можно было бы и внутри скрипта CURL-ом напрямую скачивать картинку с удаленного сервиса, но зачем? У нас же это уже реализовано, пусть и средствами веб сервера, но этого вполне достаточно.

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

Скрипт для скачивания новых картинок

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

Итог

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

Как видите NGINX — очень гибкий веб сервер :)

  1. Radeon

    Я бы все таки сделал проще. С первого Nginx отдавал бы картинку если она уже у нас есть, если нету, то делал бы реврайт на php скрипт, который бы всем этим и занимался.

    1. Дмитрий Амиров Автор

      Возможно, но как оказалось на практике, php здесь абсолютно не нужен, и все это можно решить итак с помощью только лишь nginx

        1. Дмитрий Амиров Автор

          Да, только текущая реализация куда более надежна, да хотя бы только потому что в ней меньше звеньев.

          Ну и не маловажный факт — тут мы отказываемся от PHP вообще как такового, значительно экономя память. В случае ДДОС атаки тут все упрется в ширину канала сервера. В вашем же случае процессами PHP выжрется вся память, и это произойдет куда раньше, чем забьется канал сервера.

          1. Radeon

            Так а причем тут это? PHP будет выполнятся только при первом скачивании картинки, а после ее же уже будет отдавать Nginx.

            1. Дмитрий Амиров Автор

              Запросы, например, к несуществующим картинкам будут все равно проходить до PHP. И DDOS-ить по таким URL-ам может оказаться вполне действенно.

              1. Radeon

                Ну так то тот же ресайз картинки Nginx-ом как ни крути вешает свой воркер на время ресайза.

                1. Дмитрий Амиров Автор

                  Мы с вами спорим как то неправильно) Давайте я подытожу:

                  1) На PHP. Плюсы — проще, шире возможности.
                  2) Только средствами NGINX. Плюсы — надежность, потребление ресурсов, скорость.

                  Своим задачам свои методики. Это можно было бы реализовать на PHP и это было проще. Но упор делался на скорость и надежность. Поэтому и выбран был Nginx. Да и просто хотя бы потому что «я могу»)

                  Да и кстати вопрос спорный, так ли уж проще это было делать. Ведь весь этот функционал надо реализовывать на php самостоятельно: ресайз, запрос на удаленный сервис и т.д. Тогда как в nginx это уже все реализовано, и дело за малым — правильно собрать конфиг.

Добавить комментарий

Прочли запись? Понравилась? Не стесняйтесь, оставьте, пожалуйста, свой комментарий. Мне очень интересно, что вы думаете об этом. Кстати в комментарии вы можете задать мне любой вопрос. Я обязательно отвечу.

Вы можете оставить коментарий анонимно, для этого можно не указывать Имя и email. Все комментарии проходят модерацию, поэтому ваш комментарий появится не сразу.