AWS Lambda Ruby & Serverless Framework & FFmpeg によるアップロード動画からのサムネ画像生成

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

# この記事の主なトピック

- AWS Lambda (Ruby runtime)
  - https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-ruby.html
- Serverless Framework
  - https://serverless.com/
- AWS Lambda Layer
  - https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-layers.html
- FFmpeg
  - https://ffmpeg.org/

# 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`が必要なので、`Gemfile`と`Gemfile.lock`が、まず必要になります。

```Gemfile
# 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の基本的な仕組みを確認する](https://dev.classmethod.jp/cloud/aws/lambda-layer-basics-how-it-works/ ) より引用

- https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-layers.html
- https://dev.classmethod.jp/cloud/aws/lambda-layer-basics-how-it-works/

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コンソールの方にも、関数が追加される。

<img width="1783" alt="スクリーンショット 2019-05-11 19.10.40.png (126.6 kB)" src="https://img.esa.io/uploads/production/attachments/10011/2019/05/11/48947/c8ee7c62-43c3-4d17-84d6-16ea845c618e.png">

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

<img width="1627" alt="スクリーンショット 2019-05-11 19.14.02.png (266.2 kB)" src="https://img.esa.io/uploads/production/attachments/10011/2019/05/11/48947/a05f8d3e-94c3-406f-a732-fdb3947ec120.png">

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

```diff
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)](https://img.esa.io/uploads/production/attachments/10011/2019/05/11/48947/e5b20bda-7da5-4cb1-9fc7-accc96ad62da.png)


これで、トリガー対象の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)](https://img.esa.io/uploads/production/attachments/10011/2019/05/11/48947/c0d087f2-650c-4523-8055-a5f592a2a8f4.png)

# まとめ

- Serverless Framework を使うと、AWSのデプロイ周りが楽
- YAML形式なので、公式ドキュメントとにらめっこしながら、なんとなく設定できるので、学習コストはそこまで高くない印象
- 各機能の説明はわかりやすいが、複数の機能を組み合わせた例などが、ドキュメントだけだと不足気味な印象。。。

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