Ruby
ffmpeg
lambda
ServerlessFramework

AWS Lambda Ruby & Serverless Framework & FFmpeg による動画からのサムネ画像生成

AWS Lambda で、動画からサムネ画像作成は、割とポピュラーなユースケースだと思いますが、Ruby runtime かつ Serverless Framework を組み合わせてのまとまった情報がなかったので、ここに記す。


この記事の主なトピック


Serverless Framework とは?


  • AWS Lambda などのデプロイやらLambda自体の設定等を楽に設定できるフレームワーク

  • https://serverless.com/

  • 以降は、単にServerlessと簡略して呼びます

基本的な開発からデプロイの流れは、


  1. Lambda 用のプロジェクトを作成

  2. YAML形式の設定ファイルを記述

  3. 関数そのものの実装

  4. 各(AWS, GCP, Azure, etc)サービスにデプロイ


Serverless によるLambda 関数のプロジェクト作成

まずは、serverlessをインストールします。

現時点(2019年05月10日時点)で、最新版の1.41.1を使います。

ほんとは、グローバルにインストールする必要は必ずしもないのですが、簡略化のため。

$ npm i -g serverless

$ sls --version
1.41.1


Lambda関数のプロジェクトを作成



  • sls createコマンドで、プロジェクトの雛形を作成します。

  • 今回は、ランタイムがRuby(2.5)で、動画から、サムネ画像を作成するLambda関数を作成します。


  • --templateは、Serverless側が用意したテンプレートを使用します

  • https://serverless.com/framework/docs/providers/aws/guide/quick-start/

$ sls create --template aws-ruby --path lambda/capture_thumbnail_by_ruby

Serverless: Generating boilerplate...
Serverless: Generating boilerplate in "/Users/y.kojima/Desktop/lambda/capture_thumbnail_by_ruby"
_______ __
| _ .-----.----.--.--.-----.----| .-----.-----.-----.
| |___| -__| _| | | -__| _| | -__|__ --|__ --|
|____ |_____|__| \___/|_____|__| |__|_____|_____|_____|
| | | The Serverless Application Framework
| | serverless.com, v1.41.1
-------'

Serverless: Successfully generated boilerplate for template: "aws-ruby"

$ ls
handler.rb serverless.yml

自動生成された各ファイルの中身はこんな感じ👇


handler.rb

require 'json'

def hello(event:, context:)
{ statusCode: 200, body: JSON.generate('Go Serverless v1.0! Your function executed successfully!') }
end



serverless.yml

# Welcome to Serverless!

#
# This file is the main config file for your service.
# It's very minimal at this point and uses default values.
# You can always add more config options for more control.
# We've included some commented out config examples here.
# Just uncomment any of them to get that config option.
#
# For full config options, check the docs:
# docs.serverless.com
#
# Happy Coding!

service: capture_thumbnail_by_ruby # NOTE: update this with your service name

# You can pin your service to only deploy with a specific Serverless version
# Check out our docs for more details
# frameworkVersion: "=X.X.X"

provider:
name: aws
runtime: ruby2.5

# you can overwrite defaults here
# stage: dev
# region: us-east-1

# you can add statements to the Lambda function's IAM Role here
# iamRoleStatements:
# - Effect: "Allow"
# Action:
# - "s3:ListBucket"
# Resource: { "Fn::Join" : ["", ["arn:aws:s3:::", { "Ref" : "ServerlessDeploymentBucket" } ] ] }
# - Effect: "Allow"
# Action:
# - "s3:PutObject"
# Resource:
# Fn::Join:
# - ""
# - - "arn:aws:s3:::"
# - "Ref" : "ServerlessDeploymentBucket"
# - "/*"

# you can define service wide environment variables here
# environment:
# variable1: value1

# you can add packaging information here
#package:
# include:
# - include-me.py
# - include-me-dir/**
# exclude:
# - exclude-me.py
# - exclude-me-dir/**

functions:
hello:
handler: handler.hello

# The following are a few example events you can configure
# NOTE: Please make sure to change your handler code to work with those events
# Check the event documentation for details
# events:
# - http:
# path: users/create
# method: get
# - websocket: $connect
# - s3: ${env:BUCKET}
# - schedule: rate(10 minutes)
# - sns: greeter-topic
# - stream: arn:aws:dynamodb:region:XXXXXX:table/foo/stream/1970-01-01T00:00:00.000
# - alexaSkill: amzn1.ask.skill.xx-xx-xx-xx
# - alexaSmartHome: amzn1.ask.skill.xx-xx-xx-xx
# - iot:
# sql: "SELECT * FROM 'some_topic'"
# - cloudwatchEvent:
# event:
# source:
# - "aws.ec2"
# detail-type:
# - "EC2 Instance State-change Notification"
# detail:
# state:
# - pending
# - cloudwatchLog: '/aws/lambda/hello'
# - cognitoUserPool:
# pool: MyUserPool
# trigger: PreSignUp

# Define function environment variables here
# environment:
# variable2: value2

# you can add CloudFormation resource templates here
#resources:
# Resources:
# NewResource:
# Type: AWS::S3::Bucket
# Properties:
# BucketName: my-new-bucket
# Outputs:
# NewOutput:
# Description: "Description for the output"
# Value: "Some output value"



設定ファイルの編集

設定ファイル(serverless.yml)で、まず設定したいことは、以下の2点。ほかは、後述します。


  • LambdaからS3へ各操作を許可するためのIAMロールの設定

  • どのS3バケットをトリガー対象として登録するか

まずは、不要なコメントを削除


serverless.yml

service: capture_thumbnail_by_ruby

provider:
name: aws
runtime: ruby2.5

functions:
hello:
handler: handler.hello


IAMロールの設定と、Lambda自身のマシンリソースの設定を追記・変更します。


serverless.yml

service: capture_thumbnail_by_ruby

provider:
name: aws
runtime: ruby2.5
region: ap-northeast-1
memorySize: 256
timeout: 180
# 開発、ステージング、本番環境で、デプロイを分ける際に、
# 'sls deploy --stage production'のように実行できる
# --stage を特に指定しない場合は、デフォルトで、開発環境に対してデプロイされる
stage: ${opt:stage, 'dev'}
iamRoleStatements:
- Effect: "Allow"
Action:
- "s3:GetObject"
- "s3:PutObject"
- "s3:PutObjectAcl"
- "s3:GetBucketTagging"
- "s3:GetObjectTagging"
- "s3:PutObjectTagging"
- "s3:PutBucketNotification"
Resource:
- "arn:aws:s3:::*"

functions:
captureThumbnail:
handler: handler.capture_thumbnail
description: Capture thumbnail image from video file
events:
- s3:
bucket: test
event: s3:ObjectCreated:*
rules:
- prefix: cache/test/
- suffix: .mp4



サムネ画像生成の関数の実装


  • 外部ライブラリのaws-sdk-s3が必要なので、GemfileGemfile.lockが、まず必要になります。

# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "aws-sdk-s3", "~> 1"

Gemfileを作成したら、bundle installを実行


サムネ画像を作成するための関数の実装


handler.rb

require 'json'

require 'aws-sdk-s3'
require 'logger'
require 'open3'

def capture_thumbnail(event:, context:)
s3 = Aws::S3::Client.new
bucket = event['Records'][0]['s3']['bucket']['name']
key = event['Records'][0]['s3']['object']['key']

logger.info("Start to capture thumbnail image for s3://#{bucket}/#{key}")

basename = File.basename(key)
signer = Aws::S3::Presigner.new
url = signer.presigned_url(:get_object, bucket: bucket, key: key)

# 後述する AWS Lambda Layer を使用して、FFmpegを Lambda 環境にインストールしているので、
# 'opt/'以下に格納されたffmpegの実行ファイルを使用する
capture_cmd = "/opt/ffmpeg/ffmpeg -i \"#{url}\" -ss 3 -vframes 1 -f image2 /tmp/#{basename}.jpg"
log_output, error, status = Open3.capture3(capture_cmd)

logger.info("Capture thumbnail is completed")
logger.info(log_output)
logger.info(status)

unless status.success?
logger.error(error)
raise
end

s3.put_object({
acl: 'public-read',
body: File.open("/tmp/#{basename}.jpg"),
bucket: bucket,
key: "#{key.sub('movie', 'thumbnail')}.jpg",
content_type: 'image/jpeg'
})

logger.info("Upload is completed!")
rescue => e
logger.error("#{e.class}:#{e.message}")
raise e
ensure
clear_cache
end

private

def clear_cache
logger.info(Open3.capture3('whoami')[0])
logger.info(Open3.capture3('rm -vfr /tmp/*')[0])
logger.info(Open3.capture3('ls -l /tmp/')[0])
end

def logger
@logger ||= Logger.new(STDOUT)
end


FFmpegの実行自体は、外部コマンドで実行するため、実行前に、Lambda内に、FFmpegの実行ファイルをデプロイしておく必要があります。

単純な方法としては、一度手元のローカル環境で、FFmpegをインストールして、それをLambdaのデプロイに含める方法があります。

しかし、この方法は、FFmpeg本体をインストールする必要があるので、Lambdaのファイルサイズ増加につながるため、デプロイ時間に悪影響を与える。

また、他の Lambda関数でも、FFmpegを利用したい場合に、各関数毎に、FFmpegをインストールする必要があるので、DRYではない。

そこで、AWS Lambda Layer が問題を解決する。


AWS Lambda Layer とは?


一言で言えば、複数のLambda関数でライブラリを共有できる仕組みです。


Lambda Layerの基本的な仕組みを確認する より引用

FFmpegを含んだ AWS Lambda Layer を Lambda関数とは、別でデプロイしておくことで、必要な時に Lambda関数が、Layerを利用するといったことが可能となる。

もちろん、Serverless は、AWS Lambda Layer をサポートしているので、serverless.ymlにLayer の設定を追加する。


serverless.yml

service: capture_thumbnail_by_ruby

provider:
name: aws
runtime: ruby2.5
region: ap-northeast-1
memorySize: 256
timeout: 180
# 開発、ステージング、本番環境で、デプロイを分ける際に、
# 'sls deploy --stage production'のように実行できる
# --stage を特に指定しない場合は、デフォルトで、開発環境に対してデプロイされる
stage: ${opt:stage, 'dev'}
iamRoleStatements:
- Effect: "Allow"
Action:
- "s3:GetObject"
- "s3:PutObject"
- "s3:PutObjectAcl"
- "s3:GetBucketTagging"
- "s3:GetObjectTagging"
- "s3:PutObjectTagging"
- "s3:PutBucketNotification"
Resource:
- "arn:aws:s3:::*"

functions:
captureThumbnail:
handler: handler.capture_thumbnail
description: Capture thumbnail image from video file
events:
- s3:
bucket: test
event: s3:ObjectCreated:*
rules:
- prefix: cache/test/
- suffix: .mp4

layers:
ffmpeg:
path: layer


layerというディレクトリを作成しておきます。

mkdir layer

このままだと、AWS Lambda Layer のスペースができただけなので、Layerをデプロイする前のフック処理として、Layer内に FFmpeg をインストールする前処理が、必要になります。

デプロイ前のフック処理を行うために、Serverlessの機能であるPluginsを使用します。


Plugins について



  • Serverlessのコマンドとして使える、自前のカスタムコマンドを作成するための機能です。

  • 今回で言えば、「デプロイ前に FFmpeg をインストールして、layer以下に配置する」 というカスタムコマンドを作る必要があります。

  • Pluginsの実装自体は、JavaScript でのみ作ることができます。

  • https://serverless.com/framework/docs/providers/aws/guide/plugins/

Plugins用のディレクトリとファイルを作成します。

mkdir plugins

touch InstallFFmpeg.js

Pluginsの実装内容はこちら👇

重要なのは、this.hooksの部分で、ここでデプロイ前に FFmpeg をダウンロードするフック処理を設定します。


InstallFFmpeg.js

'use strict';

const execSync = require('child_process').execSync

class InstallFFmpeg {
constructor() {
this.commands = {
deploy: {
lifecycleEvents: [
'resources'
]
},
};

this.hooks = {
'before:deploy:resources': this.beforeDeployResources
};
}

beforeDeployResources() {
console.log('execute hook!')

const ffmpegURL = 'https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz'

// 既に Layer が存在している場合は、一旦削除する
execSync('rm -fr layer/*', (error, stdout, stderr) => {
if (error) { console.log(stderr); }
console.log(stdout)
})

// FFmpegをインストール
execSync(`curl ${ffmpegURL} | tar zxv -C layer && cd layer && mv ffmpeg-*-*-static ffmpeg`, (error, stdout, stderr) => {
if (error) { console.log(stderr); }
console.log(stdout)
})

console.log('finished hook!')
};
}

module.exports = InstallFFmpeg;


Plugin ができたので、作成した Plugin を使用するようserverless.ymlに変更を加えます。


serverless.yml

service: capture_thumbnail_by_ruby

provider:
name: aws
runtime: ruby2.5
region: ap-northeast-1
memorySize: 256
timeout: 180
# 開発、ステージング、本番環境で、デプロイを分ける際に、
# 'sls deploy --stage production'のように実行できる
# --stage を特に指定しない場合は、デフォルトで、開発環境に対してデプロイされる
stage: ${opt:stage, 'dev'}
iamRoleStatements:
- Effect: "Allow"
Action:
- "s3:GetObject"
- "s3:PutObject"
- "s3:PutObjectAcl"
- "s3:GetBucketTagging"
- "s3:GetObjectTagging"
- "s3:PutObjectTagging"
- "s3:PutBucketNotification"
Resource:
- "arn:aws:s3:::*"

functions:
captureThumbnail:
handler: handler.capture_thumbnail
description: Capture thumbnail image from video file
events:
- s3:
bucket: test
event: s3:ObjectCreated:*
rules:
- prefix: cache/test/
- suffix: .mp4

layers:
ffmpeg:
path: layer

plugins:
localPath: './plugins'
modules:
- InstallFFmpeg


これで、デプロイまでの準備が完成🚀


デプロイ

デプロイ作業としては、以下の作業が必要になります。


  • Lambda 本体のデプロイ

  • Lambda の実行トリガーの登録


Lambda 本体のデプロイ

sls deploy

// ステージング用のLambdaをデプロイする場合
sls deploy --stage staging

// 本番用のLambdaをデプロイする場合
sls deploy --stage production


※ 💣既存のS3バケットをトリガー対象にする場合


  • そのままでは、デプロイコマンドが失敗します


    • 理由としては、Serverlessは、デフォルトでは、トリガー対象のS3バケットを指定する場合、新規のS3バケットを作成しようとするためです。



$ sls deploy

Serverless Error ---------------------------------------

Serverless plugin "serverless-plugin-existing-s3" not found. Make sure it's installed and listed in the "plugins" section of your serverless config file.

Get Support --------------------------------------------
Docs: docs.serverless.com
Bugs: github.com/serverless/serverless/issues
Issues: forum.serverless.com

Your Environment Information ---------------------------
OS: darwin
Node Version: 8.15.0
Serverless Version: 1.41.1

新規のS3バケットではなく、既存のS3バケットをトリガー対象にしたい場合は、serverless-plugin-existing-s3というPluginsが必要になります。

このライブラリは、npmパッケージなので、package.jsonを作成します。

yarn init or npm init

そして、serverless-plugin-existing-s3をインストール

yarn add serverless-plugin-existing-s3

再度、sls deployします

$ sls deploy

execute hook!
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0x ffmpeg-4.1.1-amd64-static/
x ffmpeg-4.1.1-amd64-static/GPLv3.txt
x ffmpeg-4.1.1-amd64-static/manpages/

......................

finished hook!
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Excluding development dependencies...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
.....
Serverless: Stack create finished...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service captureThumbnailRubyForTest.zip file to S3 (13.49 KB)...
Serverless: Uploading service ffmpeg.zip file to S3 (51.32 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..................
Serverless: Stack update finished...
Service Information
service: captureThumbnailRubyForTest
stage: dev
region: ap-northeast-1
stack: captureThumbnailRubyForTest-dev
resources: 6
api keys:
None
endpoints:
None
functions:
captureThumbnail: captureThumbnailRubyForTest-dev-captureThumbnail
layers:
ffmpeg: arn:aws:lambda:ap-northeast-1:0123456:layer:ffmpeg:1

デプロイが完了すると、AWSコンソールの方にも、関数が追加される。

スクリーンショット 2019-05-11 19.10.40.png (126.6 kB)

トリガー対象のS3バケットはまだ登録されていないので、sls s3deployで、トリガー対象を登録します。

スクリーンショット 2019-05-11 19.14.02.png (266.2 kB)

先程、serverless-plugin-existing-s3をインストールしたので、既存のS3バケットをserverless.ymlにトリガー対象として登録するために、設定内容を少し変更させます。

service: capture_thumbnail_by_ruby

provider:
name: aws
runtime: ruby2.5
region: ap-northeast-1
memorySize: 256
timeout: 180
stage: ${opt:stage, 'dev'}
iamRoleStatements:
- Effect: "Allow"
Action:
- "s3:GetObject"
- "s3:PutObject"
- "s3:PutObjectAcl"
- "s3:GetBucketTagging"
- "s3:GetObjectTagging"
- "s3:PutObjectTagging"
- "s3:PutBucketNotification"
Resource:
- "arn:aws:s3:::*"

functions:
captureThumbnail:
handler: handler.capture_thumbnail
description: Capture thumbnail image from video file
events:
+ - existingS3:
+ bucket: ${file(./config.${self:provider.stage}.yml):trigger.bucket}
+ events:
+ - s3:ObjectCreated:*
+ rules:
+ - prefix: cache/test/
+ - suffix: .mp4

layers:
ffmpeg:
path: layer

plugins:
localPath: './plugins'
modules:
- InstallFFmpeg
+ - serverless-plugin-existing-s3

+ package:
+ exclude:
+ - yarn-error.log
+ - yarn.lock
+ - package.json

本番、ステージング、開発環境毎に、S3バケットを分けている場合は、bucketの部分が動的に指定できるように、各環境毎に、バケット名を設定したYAMLファイルを Lambdaプロジェクトのルートディレクトリに、用意します。


  • config.dev.yml

  • config.staging.yml

  • config.production.yml

設定内容は、各環境毎のバケット名


config.xxx.yml

trigger:

# bucket: dev or staging or production
bucket: production

再度、sls s3deployを実行

$ sls s3deploy

Serverless: beforeFunctions --> building ...
Serverless: beforeFunctions <-- Complete, built 1 events.
Serverless: functions --> prepare to be executed by s3 buckets ...
policyId exS3-v2-captureThumbnailRubyForTest-dev-captureThumbnail-crevo-test
Serverless: functions <-- built 0 events across 1 buckets.
Serverless: beforeS3 -->
Serverless: beforeS3 <--
Serverless: s3 --> initiate requests ...
Serverless: s3 <-- Complete 1 updates.

成功した場合、指定したS3バケットがトリガー対象として登録されます。

image.png (315.9 kB)

これで、トリガー対象のS3バケットに、mp4などの動画をアップロードすると、Lambdaが実行して、サムネ画像が作成されます。

(CloudWatch で確認してみるでもよいでしょう!)


※ 💣もし、以下のようなエラーが出た場合

$ sls s3deploy

Serverless: beforeFunctions --> building ...
Serverless: beforeFunctions <-- Complete, built 1 events.
Serverless: functions --> prepare to be executed by s3 buckets ...
policyId exS3-v2-captureThumbnailRubyForTest-dev-captureThumbnail-test-bucket
Serverless: functions <-- built 0 events across 1 buckets.
Serverless: beforeS3 -->
Serverless: beforeS3 <--
Serverless: s3 --> initiate requests ...

Error --------------------------------------------------

test-bucket Configuration is ambiguously defined. Cannot have overlapping suffixes in two rules if the prefixes are overlapping for the same event type.

For debugging logs, run again after setting the "SLS_DEBUG=*" environment variable.

Get Support --------------------------------------------
Docs: docs.serverless.com
Bugs: github.com/serverless/serverless/issues
Issues: forum.serverless.com

Your Environment Information ---------------------------
OS: darwin
Node Version: 8.15.0
Serverless Version: 1.41.1

他の Lambda 関数で、同じS3バケットがトリガーとして登録されているので、登録を無効にする必要があります。

image.png (341.8 kB)


まとめ


  • Serverless Framework を使うと、AWSのデプロイ周りが楽

  • YAML形式なので、公式ドキュメントとにらめっこしながら、なんとなく設定できるので、学習コストはそこまで高くない印象

  • 各機能の説明はわかりやすいが、複数の機能を組み合わせた例などが、ドキュメントだけだと不足気味な印象。。。

AWS Lambda を Rubyで書けるの、やっぱ楽しいな✨