Хабрахабр

Еще один вариант генерации превьюшек для изображений с использованием AWS Lambda & golang + nodejs + nginx

Здравствуйте уважаемые пользователи Хабра!

Наконец у меня появилась действительно нетривиальная и достаточно интересная задача решением которой хочу поделиться с Вами. Меня зовут Никита, в текущий момент времени я тружусь backend-разработчиком в стартапе мобильного приложения.

В разрабатываемом мобильном приложении имеется работа с изображениями. О чем собственно пойдет разговор? Еще одно условие, практически первая общая задача которая была мне поставлена: сделать чтобы все это работало и масштабировалось в облаке на Амазоне. Как можно нетрудно догадаться: где есть картинки, там скорее всего появятся превьюхи. Ну ок, уходим так уходим. Если немного лирики: был телефонный разговор со знакомым партнера по бизнесу в режиме громкой связи, где я получил пачку ценных указаний главная мысль которых звучит просто: уходите от серверного мышления.

Этот участок бэкэнда предсказуемо плохо показал себя на таком, своего рода «нагрузочном тестировании», которое я проводил на очень дохлой VDS-ке при практически дефолтных настройках LAMP, по крайней мере без дополнительного тюнинга, где все неоптимизированные места вылезут сразу и гарантированно. Генерация изображений это достаточно дорогая операция в плане ресурсов. Пусть он занимается тем что дает более-менее однородную нагрузку, а именно запросы к БД, логика приложения и JSON-ответы и тому подобная малоинтересная API-шная рутина. По этой причине я принял решение данную задачу убрать подальше от пхп-бэкэнда. Почему нельзя настроить масштабирование EC2 инстансов в автоматическом режиме и оставить на PHP эту задачу? Те, кто знаком с Амазоном скажут: а в чем проблема? А если серьезно — есть масса нюансов в контексте архитектуры бэкэнда, выходящих за пределы данной статьи, по этому оставлю данный вопрос без ответа. Отвечаю: «так микросервиснее». Я всего лишь хочу предложить решение и милости прошу под кат.
Вводная: изображения хранятся в условном s3 bucket.mydomain, далее по тексту везде упоминается как bucket. Каждый на него ответит сам в контексте своей архитектуры, если он возникнет. Содержимое bucket считается статическим и общедоступным, но листинг запрещен, по этому каждый объект имеет ACL «public-read», при том что сам bucket non public read, файловая структура внутри bucket имеет вид folder/subfolder/filename.ext.

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

Хотя был опыт наложения watermark`ов динамически, (т.е. Я сторонник решений когда картинка нужного размера предварительно сгенерирована и просто отдается с файловой системы. Не стоит прям сильно бояться делать их динамически, в такой подход тоже имеет право на жизнь, но в целом считаю самым оптимальным решением считаю когда превью генерируется 1 раз по какому-то событию и далее отдается из файловой системы, если оно там присутствует, в случае если нет — производится попытка его сгенерировать снова. изображение по-новой генерировалось всегда) который показал достаточно неплохие результаты (я ожидал большей нагрузки чем оказалось). Данный подход и был реализован в текущей задаче. Это дает достаточно неплохую управляемость и может быть полезено если вдруг изменились требования к размерам превью. В моем случае (опять же повторюсь упрощенно) это выглядит так: Но тут есть один важный момент — необходимо «договориться» (возможно с самим собой) о uri-схеме.

  • /photo/some/file.jpg — отдать исходный файл
  • /prew/preset/some/file.jpg — отдать превью для file.jpg

Появилось новое слово preset, что это? В процессе реализации я подумал, а если парсить второй сегмент uri на предмет ширины/высоты то это получается можно самому себе вырыть яму. А что будет если какой нибудь умник захочет от 1 до over9000 перебрать значения второго сегмента uri? По этому договорился с остальными участниками процесса разработки на тему какого размера нужны превьюшки. Получилось несколько «пресетов» разного размера имя которого передается в качестве второго сегмента uri. Опять же возвращаясь к вопросу управляемости, в случае если по какой-то причине понадобится изменить размер превью, достаточно будет поправить переменные окружения в prewmanager, о котором пойдет речь чуть позже и удалить неактуальные файлы с файловой системы.

В общем виде схема работы выглядит как на рисунке:

image

В принципе он то же самое делает и в запросе 2 поскольку сами файлы и превью хранятся в одном bucket т.к. Что здесь происходит:
В запросе 1, который /photo/ nginx проксирует запрос на s3. Но есть одно отличие, на схеме указан if. следуя официальной документации по AWS, количество объектов внутри bucket неограничено. Кстати про 403 ответ. Занимается он тем, что меняет способ обработки 403/404 ответа от s3. фактически имея доступ ТОЛЬКО к public-read объектам, то из-за отсутствия права на листинг (амазон вместо 404 отдаст 403, этим обусловлена запись в конфиге: error_page 403 404 =404 /404.jpg; Кусок конфига где описывается данная работа выглядит вот так: Дело все в том, что если обращаться к хранилищу БЕЗ credentials (мой случай) т.е.

location / proxy_http_version 1.1; proxy_set_header Authorization ''; proxy_set_header Host $s3_bucket; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_hide_header x-amz-id-2; proxy_hide_header x-amz-request-id; proxy_hide_header Set-Cookie; proxy_ignore_headers "Set-Cookie"; proxy_buffering off; proxy_intercept_errors on; proxy_pass http://$req_proxy_str; } location /404.jpg { root /var/www/error/; internal; } location @prewmanager { proxy_pass http://prewnamager_host:8180; proxy_redirect http://prewnamager_host:8180 /; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; access_log off ; }

Как вы могли заметить prewmanager-е проксируется на какой-то сетевой сервис. Вот в нем и есть вся соль данной статьи. Этот сервис, написан на nodejs, он запускает aws lambda, написанную на go, «блокирует» дальнейшие вызовы для обрабатываемого uri до завершения работы lambda-функции и отдает результат работы aws lambda всем ожидающим. К сожалению целиком код prewmanager-а привести не могу, по этому попробую проиллюстрировать отдельными участками (уж простите) первой полнофункциональной версии скрипта. В продакшене более красивая версия, но увы. Однако тем не менее в качестве «понять логику работы» и возможно использовать как скетч этот код на мой взгляд вполне сгодится.

// тут были requre, process.env.* и т.д. const lambda = new AWS.Lambda({...});
const rc = redis.createClient(...);
const getAsync = promisify(rc.get).bind(rc); function make404Response(response) {
// тут берем с файловой системы картинку и отдаем с 404 кодом -- типовая задача
} function makeErrorResponse(response) {
// аналогично функции выше только картинка другая
} // AWS Lambda возвращает в base64 данные картинки и content-type
function makeResultResponse(response, response_payload) { let buff = new Buffer(response_payload.data, 'base64'); response.statusCode = 200; response.setHeader('Content-Type', response_payload.content_type); response.end(buff); return;
} http.createServer(async function(request, response) { // тут был разбор uri, генерация строкового ключа для редиса и т.д. // для redis, если ключа нет (null) значит необходимо запускать работу AWS lambda // и устанавливаем блокировку чтобы не запускалась функция дважды и более раз на данный запрос // если ключ есть -- дожидаемся ответа через функцию let reply = false; try { reply = await getAsync(redis_key); } catch (err) { } if(reply === null) { // ставим блокировку на 30 секунд rc.set(redis_key, 'blocked', 'EX', 30); // и выполняем операции в ламбде // пресеты, если требуемого пресета нет -- 404 switch (preset) { case "preset_name_1": var request_payload = { src_key: "photo/" + aws_ob_key, src_bucket: src_bucket, dst_bucket: dst_bucket, root_folder: dst_root, preset_name: preset, rewrite_part: "photo", width: 1440 }; var params = { FunctionName: "my_lambda_function_name", InvocationType: "RequestResponse", LogType: "Tail", Payload: JSON.stringify(request_payload), }; lambda.invoke(params, function(err, data) { if (err) { makeErrorResponse(response); } else { rc.set(redis_key, data.Payload, 'EX', 30); let response_payload = JSON.parse(data.Payload); if(response_payload.status == true) { makeResultResponse(response, response_payload); } else { console.log(response_payload.error); makeErrorResponse(response); } } }); break; ... default: make404Response(response); } } else if (reply === false) { // это если редис не отзывается makeErrorResponse(response); } else { // тут в нормальной ситуации возможны 2 варианта // когда уже запрос выполняется -- blocked // когда он уже выполнился, т.е. есть данные if(reply == 'blocked') { let res; let i = 0; const intervalId = setInterval(async function() { try { res = await getAsync(redis_key); } catch (err) { } if (res != null && res != 'blocked') { let response_payload = JSON.parse(res); if(response_payload.status == true) { makeResultResponse(response, response_payload); } else { console.log(response_payload.error); makeErrorResponse(response); } clearInterval(intervalId); } else { i++; // вечно это продолжаться не должно if(i > 100) { makeErrorResponse(response); clearInterval(intervalId); } } }, 500); } }
}).listen(port);

Откуда взялся редис и зачем? В этой задаче я так рассудил: поскольку мы в облаке где инстансы с редисом я могу масштабировать сколь душе угодно с одной стороны, а с другой когда встал вопрос о блокировке повторных вызовов функции с теми же параметрами ну что если не редис, который к тому же уже используется в проекте? Локально держать в памяти и писать наколеночный «гарбадж коллектор»? Зачем когда можно просто сунуть эти данные (или флаг блокировки в редис) с определенным временем жизни и обо всем этом позаботится этот замечательный инструмент. Ну логично-же.

Прошу больно не пинать поскольку это третий бинарь после «hello world» и еще там по-мелочи, который был мной написан и скомпилирован. Ну и напоследок приведу целиком код функции для AWS Lambda который был написан на Go. Но в целом все работает, но как говорится нет предела совершенству. Вот ссылка на гитхаб где он выложен, прошу пулл-реквесты если что-то не так. Для работы функции необходим JSON-payload, если поступят просьбы, добавлю на гитхаб инструкцию как тестировать функцию, пример JSON-payload`a и т.д.

Создать функцию, прописать enviroment-ы, максимальное время и выделение памяти. Пару слов о настройке AWS Lambda: там все просто. Но есть нюанс, который выходит за рамки данной статьи: IAM имя ему. Залить архив и пользоваться. Пользователя, роль, права тоже придется настроить, без этого боюсь ничего не выйдет.

В контексте текущей политической ситуации: да мы одни из первых попали под блокировку Амазона. В заключение хочу сказать что данная система уже протестирована в продакшен, правда хайлоадными нагрузками похвастаться не могу, но в целом вообще никаких проблем не было. Но шум поднимать не стали и отвлекать от работы юристов, а настроили nginx на российском хостинге. Буквально в первый же день. И вот приведенный выше конфиг nginx, поскольку вся статика у меня на поддомене размещена, почти строчка в строчку с минимальными изменениями был перенесен на сервер в РФ и втечение рабочего дня все об этом и забыли. Вообще я считаю что Amazon s3 это настолько удобное, хорошо документированное и поддерживаемое хранилище, что из-за лысых из браззерс советников по мемасам и прочих хирургов-нехирургов отказываться от него как минимум не стоит.

Всех благодарю за внимание.

Теги
Показать больше

Похожие статьи

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

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть