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


  1. build_your_web
    29.01.2022 21:56
    +1

    Почему выбор пал на ffmpeg? Это явный overkill для простого ресайза картинок.


    1. okosynskyi Автор
      29.01.2022 22:18
      -1

      Возможно вы правы. Какую либу вы бы посоветовали?


    1. Rive
      29.01.2022 22:29
      +2

      Похоже, что-нибудь вроде https://github.com/serverlesspub/imagemagick-aws-lambda-2 будет прямым serverless аналогом этого решения.


  1. vedmak3
    29.01.2022 22:14
    +1

    Дешево сердито. Не благодарите. https://github.com/vedmak3/clipImage


    1. okosynskyi Автор
      29.01.2022 22:16
      -2

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


      1. Rive
        29.01.2022 22:32

        То есть злоумышленнику достаточно перебирать разные размеры кропа, чтобы съесть бесплатный лимит вызовов и превратить приложение в тыкву, при этом решение не предусматривает обычные методы борьбы с таким DDoS'ом (асимметричное шифрование параметров url картинки, кэширование и т. п.)


        1. okosynskyi Автор
          29.01.2022 22:39
          +1

          Загружать в бакет могут только те, кому разрешили. А так он закрыт. Поэтому DDOS отменяется


        1. okosynskyi Автор
          29.01.2022 22:42

          Вы наверно не так поняли. Нарезка происходит в момент upload'а, а не download'a


          1. Tihon_V
            31.01.2022 10:39

            Это тоже можно ограничить с помощью rate-limit'a, cors и авторизации для использования лямбды, а еще закрыть все CF сверху…


  1. funca
    29.01.2022 23:35
    +3

    AWS предлагают солюшен немного на других технологиях https://github.com/aws-solutions/serverless-image-handler. В чем преимущество вашего решения?


  1. random1st
    30.01.2022 10:29

    Слои использовать уже не актуально, Lambda поддерживает Docker images. На предмет использования ffmpeg для нарезки картинок ничего сказать не могу, обычно он используется для работы с видео - интересно было бы посмотреть на бенчмарки. Еще с правами вопросы - они слишкрм широкие. Ну и почему serverless, а не AWS SAM или Terraform/Cloudformation - тоже вопрос.


    1. okosynskyi Автор
      30.01.2022 14:16

      В предыдущей своей статье я использовал Terraform. В целом, не принципиально, чем именно будет развертываться облако. По моему опыту для более простых проектов легче использовать Serverless Framework. Вы можете сравнить сложность конфигурации Terraform и Serverless Framework. Docker images уже давно есть. Но почему не актуально использовать слои? Слой же можно переиспользовать в различных лямбдах. Слои и докер имеджы разные вещи.


      1. random1st
        30.01.2022 17:22
        +1

        Serverless саппортится только авторами фреймворка, каноничным является использование SAM. Что касается использования слоев - Docker дает возможность собирать имейджи до 10Гб размером, опять же - проще становится локальная отладка и сборка. Терраформ ваш глянул, прослезился - очень "порадовало" наличие стейта в репозитории, токена и до кучи account_id как параметр - при том, что он может быть получен самим терраформом. Вообще рекомендую использовать модули из реестра.


  1. kawena54
    30.01.2022 14:11

    Интересно есть какой сейчас TOP 1 солюшен для своих серверов ? или все пишут свои враперы над imagemagic ? просто часто вижу при запросах одни и тоже паттерны урлов для выбора и обрезки картинок


    1. 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/



  1. caballero
    31.01.2022 20:09

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