Достаточно частая задача веб разработчика - нарезать картинки. Предлагаю вашему вниманию готовое решение, используя Serverless framework + Lambda + S3.
Лямбда функция слушает событие загрузки файла в S3 и запускается. На входе функции мы можем извлечь имя S3 и ключ файла. Далее алгоритм такой: скачиваем файл в лямбду, нарезаем изображения и загружаем их в другой S3. Очень важно загружать в другой, потому что при загрузке в тот же S3 вызовется лямбда опять и будет нарезать уже нарезанные картинки (бесконечная рекурсия, за которую нужно будет заплатить). Это все в теории. Переходим к практике.
Для нарезки изображений будем использовать ffmpeg. Вот пример как можно сделать изображение уменьшенное и обрезанное до 300х300px:
ffmpeg -i source.jpg -filter:v "scale=300:-1,crop=300:300:0:0" -y crop300x300.jpg
Таким образом мы получили первый thumb:
Все замечательно работает локально, но в лямбде нет ffmpeg. Поэтому нужен бинарник для лямбды, который положим в лямбда слой. Чтобы запустить консольную команду из JavaScript воспользуемся child_process:
const childProcess = require('child_process')
spawnPromise(command, argsarray, envOptions) {
return new Promise((resolve, reject) => {
const childProc = childProcess.spawn(command, argsarray, envOptions || { env: process.env, cwd: process.cwd() }),
resultBuffers = []
childProc.stdout.on('data', buffer => {
resultBuffers.push(buffer)
})
childProc.stderr.on('data', buffer => console.log(buffer.toString()))
childProc.on('exit', (code, signal) => {
if (code || signal) {
reject(`${command} failed with ${code || signal}`)
} else {
resolve(Buffer.concat(resultBuffers).toString().trim())
}
})
})
}
Также нам надо будет взять мета информацию из картинки: ширину, высоту, угол поворота (необходим, для правильного поворота картинки если делать снимок на телефон). Для этого воспользуемся ffprobe. Также, разместим бинарник в лямбда слое. И сделаем функцию, которая будет доставать эти данные:
async getResolution(inputFile) {
try {
let rotate = await this.spawnPromise(
config.FFPROBE,
[
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries',
'stream_tags=rotate',
'-of', 'default=nw=1:nk=1',
inputFile],
)
rotate = rotate || 0
const str = await this.spawnPromise(
config.FFPROBE,
[
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries',
'stream=width,height',
'-of', 'csv=s=x:p=0',
inputFile],
)
let [width, height] = String(str).split('x')
if (String(rotate) === '90' || String(rotate) === '-90' || String(rotate) === '270' || String(rotate) ===
'-270') {
[width, height] = [height, width]
}
return { width, height, rotate }
} catch (e) {
console.log('ffprobe error', e)
return { width: 1080, height: 1920 }
}
}
Теперь, зная мета информацию о файле, можно сделать основную функцию конвертации:
async thumb(inputFile, outputFile, filterData, imageData = {}) {
try {
const isVertical = Number(imageData.width) < Number(imageData.height)
let filter
if (filterData.crop) {
const newImageWidth = isVertical ? filterData.height : filterData.width
const newImageHeight = isVertical ? filterData.width : filterData.height
const resizeK = newImageWidth / imageData.width
const x = parseInt(isVertical ? 0 : (resizeK * imageData.width / 2 - newImageWidth / 2))
const y = parseInt(isVertical ? (resizeK * imageData.height / 2 - newImageHeight / 2) : 0)
if (newImageWidth === newImageHeight) {
// square
filter = `scale=${isVertical
? `${newImageWidth}:-1`
: `-1:${newImageWidth}`},crop=${newImageWidth}:${newImageHeight}:${x}:${y}`
} else {
filter = `scale=${isVertical
? `${newImageWidth}:-1`
: `-1:${newImageWidth}`},crop=${newImageWidth}:${newImageHeight}:${x}:${y}`
}
} else {
const newImageWidth = isVertical ? filterData.height : filterData.width
filter = `scale=${newImageWidth}:-1`
}
if (Number(imageData.rotate) !== 0) {
const times = Number(imageData.rotate) / 90
for (let i = 0; i < times; i++) {
filter += `,transpose=1`
}
}
const callData = [
'-v',
'error',
'-noautorotate',
'-i',
inputFile,
'-filter:v',
filter,
'-y',
]
return await this.spawnPromise(
config.FFMPEG,
[
...callData,
outputFile],
)
} catch (e) {
console.log('ffmpeg error', e)
}
Я это делал для одного из своих проектов и первая проблема, с которой столкнулись пользователи, это загрузка картинок из iPhone. Так как Apple сделали свой формат изображений, который еще не все поддерживают. Пока единственное решение, которое я нашел это скомпилировать tifig бинарник и добавить его в слой для лямбды. К сожалению, все бинарники я компилировал для лямбды давно и о том, как скомпилировать tifig для лямбды не расскажу, но зато поделюсь готовыми бинарниками.
И добавим функцию конвертации heic в jpg:
heicToJpg(filePath, newFilePath) {
return new Promise((resolve, reject) => {
childProcess.exec(se([config.TIFIG, filePath, newFilePath]), { encoding: 'utf8' },
function (error, stdout, stderr) {
if (error) {
console.error(stderr)
return reject(error)
}
fs.unlinkSync(filePath)
return resolve(true)
})
})
}
Итак, основной код лямбды на самом высоком уровне выглядит вот так:
module.exports.handler = async (event) => {
const s3Record = event.Records[0].s3
const sourceKey = s3Record.object.key
if (!file.isPhoto(sourceKey)) {
return true
}
await file.download(s3Record.bucket.name, sourceKey,file.getDownloadName(sourceKey))
const ext = file.getExt(sourceKey)
const isHeic = file.isHeic(sourceKey)
// convert iphone photo format to jpg
if (isHeic) {
await convert.heicToJpg(file.getDownloadName(sourceKey), file.getDownloadNameIfHeic(sourceKey, ext))
}
// create thumbs by config.RESOLUTIONS
const { width, height, rotate } = await convert.getResolution(file.getDownloadNameIfHeic(sourceKey, ext))
await Promise.all(config.RESOLUTIONS.map(r => createThumbAndUpload(s3Record, r, { width, height, rotate, ext })))
// remove unnecessary files
await file.remove(file.getDownloadName(sourceKey))
if (isHeic) {
await file.remove(file.getDownloadNameIfHeic(sourceKey, ext))
}
return true
}
При этом всем config.RESOLUTIONS это константа настройки, при помощи которой можно задать в какие форматы необходимо конвертировать изображения. Для примера я сделал несколько вариантов:
RESOLUTIONS: [
{ name: '1920x1080', width: 1920, height: 1080 },
{ name: '1280x720', width: 1280, height: 720 },
{ name: 'crop600x400', crop: true, width: 600, height: 400 },
{ name: 'crop300x300', crop: true, width: 300, height: 300 },
]
Развернем все в облаке. Итак подготовим конфиг деплоя Serverless Framework с поясняющими коментариями:
service: service-lambda-thumb-tifig
frameworkVersion: '2 || 3'
app: lambda-thumb-tifig
package:
# исключаем лишние файлы из архива лямбды, чтоб уменьшить ее размер
patterns:
- '!node_modules/aws-sdk'
- '!node_modules/@aws-cdk'
- '!node_modules/serverless'
- '!node_modules/serverless-lift'
- '!.idea'
- '!yarn.lock'
- '!yarn.error.log'
- '!README.md'
- '!.gitignore'
- '!package.json'
- '!lib'
- '!.git'
custom:
name: 'lambda-thumb-tifig'
environment: 'prod'
region: 'us-east-1'
lambda_prefix: ${self:custom.environment}-${self:custom.name}
# Отмечу что я использовал авто создание S3 для демо.
# Но в реальности можно подключить уже раннее созданные S3.
# этот конфиг создает новые S3
constructs:
sourceBucket:
type: storage
destinationBucket:
type: storage
provider:
name: aws
lambdaHashingVersion: '20201221'
environment:
DESTINATION_BUCKET: ${construct:destinationBucket.bucketName}
REGION: ${self:custom.region}
region: ${self:custom.region}
runtime: nodejs14.x
iamRoleStatements:
- Effect: "Allow"
Action:
- "s3:PutBucketNotification"
- "s3:GetObject"
- "s3:PutObject"
Resource:
Fn::Join:
- ""
- - "arn:aws:s3:::*"
- Effect: "Allow"
Action:
- "rds:*"
Resource: "*"
plugins:
- serverless-lift
functions:
# создаем лямбда-функцию
ffmpeg-tifig:
# Увеличиваем память и процессор лямбда функции
# для увеличения быстродействия
# такие лямбды будут дороже, но так как в этом проекте
# я не выхожу за бесплатные лимиты в месяц - буду использовать
# самый быстрый вариант
memorySize: 10240
name: ${self:custom.lambda_prefix}
handler: src/index.handler
# Увеличиваем максимальное время выполнения функции
timeout: 300
# Подключим функцию к собитию загрузки файла в S3 Source
events:
- s3:
bucket: ${construct:sourceBucket.bucketName}
existing: true
# Подключим слой Lib к лямбда-функции
layers:
- { Ref: LibLambdaLayer }
# Создадим лямбда слой и поместим в него все содержимое папки lib.
layers:
lib:
path: lib
Развернем это все в AWS:
sls deploy
В результате получаем лямбда функцию, подключенную к S3:
Проверим это все в AWS Console, загрузив несколько картинок в Source S3. И посмотрим в Destination S3:
Весь код доступен тут. Если у вас возникло желание каким-либо образом улучшить код - прошу сделать PR в этот репозиторий. Сделаем мир лучше вместе.
Комментарии (17)
vedmak3
29.01.2022 22:14+1Дешево сердито. Не благодарите. https://github.com/vedmak3/clipImage
okosynskyi Автор
29.01.2022 22:16-2Спасибо за решение. Оно тоже имеет место быть. Но в данном случае я рассказал об облачном решении, которое бесплатно до определенного лимита вызовов в месяц
Rive
29.01.2022 22:32То есть злоумышленнику достаточно перебирать разные размеры кропа, чтобы съесть бесплатный лимит вызовов и превратить приложение в тыкву, при этом решение не предусматривает обычные методы борьбы с таким DDoS'ом (асимметричное шифрование параметров url картинки, кэширование и т. п.)
okosynskyi Автор
29.01.2022 22:39+1Загружать в бакет могут только те, кому разрешили. А так он закрыт. Поэтому DDOS отменяется
okosynskyi Автор
29.01.2022 22:42Вы наверно не так поняли. Нарезка происходит в момент upload'а, а не download'a
Tihon_V
31.01.2022 10:39Это тоже можно ограничить с помощью rate-limit'a, cors и авторизации для использования лямбды, а еще закрыть все CF сверху…
funca
29.01.2022 23:35+3AWS предлагают солюшен немного на других технологиях https://github.com/aws-solutions/serverless-image-handler. В чем преимущество вашего решения?
random1st
30.01.2022 10:29Слои использовать уже не актуально, Lambda поддерживает Docker images. На предмет использования ffmpeg для нарезки картинок ничего сказать не могу, обычно он используется для работы с видео - интересно было бы посмотреть на бенчмарки. Еще с правами вопросы - они слишкрм широкие. Ну и почему serverless, а не AWS SAM или Terraform/Cloudformation - тоже вопрос.
okosynskyi Автор
30.01.2022 14:16В предыдущей своей статье я использовал Terraform. В целом, не принципиально, чем именно будет развертываться облако. По моему опыту для более простых проектов легче использовать Serverless Framework. Вы можете сравнить сложность конфигурации Terraform и Serverless Framework. Docker images уже давно есть. Но почему не актуально использовать слои? Слой же можно переиспользовать в различных лямбдах. Слои и докер имеджы разные вещи.
random1st
30.01.2022 17:22+1Serverless саппортится только авторами фреймворка, каноничным является использование SAM. Что касается использования слоев - Docker дает возможность собирать имейджи до 10Гб размером, опять же - проще становится локальная отладка и сборка. Терраформ ваш глянул, прослезился - очень "порадовало" наличие стейта в репозитории, токена и до кучи account_id как параметр - при том, что он может быть получен самим терраформом. Вообще рекомендую использовать модули из реестра.
kawena54
30.01.2022 14:11Интересно есть какой сейчас TOP 1 солюшен для своих серверов ? или все пишут свои враперы над imagemagic ? просто часто вижу при запросах одни и тоже паттерны урлов для выбора и обрезки картинок
random1st
30.01.2022 17:24Не знаю как для своих серверов - там кажется логичным использование некоего прокси для ресайза на лету и накрытие этого CDN. В AWS кажется перспективным использование Lambda@Edge вот таким образом https://aws.amazon.com/blogs/networking-and-content-delivery/resizing-images-with-amazon-cloudfront-lambdaedge-aws-cdn-blog/
Balling
31.01.2022 18:33Heic контейнер для HEIF/AVIF https://trac.ffmpeg.org/ticket/7621#comment:10
https://patchwork.ffmpeg.org/project/ffmpeg/patch/20220107090111.243853-6-leo.izen@gmail.com/ JPEG XL все же лучше.
caballero
31.01.2022 20:09делал такое для конвертации видео. Пришлось отказаться - у лямбды ограниченый таймаут. На больших видосах просто отваливалось
build_your_web
Почему выбор пал на ffmpeg? Это явный overkill для простого ресайза картинок.
okosynskyi Автор
Возможно вы правы. Какую либу вы бы посоветовали?
Rive
Похоже, что-нибудь вроде https://github.com/serverlesspub/imagemagick-aws-lambda-2 будет прямым serverless аналогом этого решения.