Serverless FrameworkでGemをLayersに切り出す


環境

  • Mac OS X 10.14.3
  • Serverless Framework 1.36.1
  • Ruby 2.5

はじめに

Serverless FrameworkでRubyを使用する場合にパッケージ容量をもっとも圧迫するvendor(gemの内包ディレクトリ)を切り出す方法について書きます。

こちらの記事を読んで出来るようになること

  • Serverless Framework x RubyでGemをLayerに切り出す
  • Layersのバージョンアップにも対応可能

Lambda Layersとは

ざっくりと、各Lambda関数で共通化した処理をLambda関数の中に内包するのではなく、外に切り出してしまう仕組みのこと。

1. サンプルアプリケーションの作成

この記事ではserverless frameworkの使い方などは書きません

以下のコマンドでサンプルアプリケーションを作成します。

$ sls create -t aws-ruby -p sample

上記を実行すると、以下のようなディレクトリが作成されます。

sample/
- serverless.yml
- handler.rb

2. bundle init & gem install

$ cd sample

上記で、sampleディレクトリに移動しておきます。
sampleディレクトリ内で、以下を実行していきます。

$ bundle init

上記で、Gemfileが作成されるので、エディタでGemfileを編集していきます。

# frozen_string_literal: true

source "https://rubygems.org"

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

# 以下を追加
gem "business_time"

適当なGemをGemfileに追加して、以下を実行します。

$ bundle install --path vendor/bundle

3. serverless.ymlを編集

service: sample

provider:
  name: aws
  runtime: ruby2.5
  region: ap-northeast-1
  role: arn:aws:iam::ロールのarn

package:
 exclude:
   - vendor/**
   - Gemfile
   - Gemfile.lock

functions:
  hello:
    handler: handler.hello

layers:
  gems:
    path: vendor

この時点で一度デプロイします。

$ sls deploy

デプロイを実行すると、layersが作成されるので、再度serverless.ymlを編集します。

同一のserverless.yml内であれば、REFで参照をすることが出来ます。
公式ドキュメント

service: sample

provider:
  name: aws
  runtime: ruby2.5
  region: ap-northeast-1
  role: arn:aws:iam::ロールのarn

package:
 exclude:
   - vendor/**
   - Gemfile
   - Gemfile.lock

functions:
  hello:
    handler: handler.hello
    layers:
      - {Ref: GemsLambdaLayer}

layers:
  gems:
    path: vendor

functionlayersを追加して、Refで参照させるのですが、この時に参照方法のルールとして、Layer名をTitleCaseで記載し、LambdaLayerを末尾に結合させる必要があります。
今回のLayer名はgemsなので、GemsLambdaLayerとなっています。

Layer名がhogeだったら、HogeLambdaLayerとなるかと思います。

これで再度デプロイします。

$ sls deploy

これでAWSのLambda関数ページを見ても、まだLayerが反映されません。

どこが問題かと言うと、serverless.ymlには記載する順番を気をつける必要があるようで、以下のように、functionsより上にlayersを記載する必要があります。

上記を修正したserverless.ymlが以下

service: sample

provider:
  name: aws
  runtime: ruby2.5
  region: ap-northeast-1
  role: arn:aws:iam::ロールのARN

package:
 exclude:
   - vendor/**
   - Gemfile
   - Gemfile.lock

layers:
  gems:
    path: vendor

functions:
  hello:
    handler: handler.hello
    layers:
      - {Ref: GemsLambdaLayer}

これで再度デプロイすることでLambda関数にLayerが適用されます。

4. Lambda関数でgemを使用する

handler.rbを以下のようにします

require 'json'
require 'business_time'

def hello(event:, context:)
  puts Date.today.workday?

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

これで、適当にテストを作成し、「テスト」ボタンを押下すると、以下のようなエラーが発生します。

{
  "errorMessage": "cannot load such file -- business_time",
  "errorType": "Init<LoadError>",
  "stackTrace": [
    "/var/lang/lib/ruby/2.5.0/rubygems/core_ext/kernel_require.rb:59:in `require'",
    "/var/lang/lib/ruby/2.5.0/rubygems/core_ext/kernel_require.rb:59:in `require'",
    "/var/task/handler.rb:2:in `<top (required)>'",
    "/var/lang/lib/ruby/2.5.0/rubygems/core_ext/kernel_require.rb:59:in `require'",
    "/var/lang/lib/ruby/2.5.0/rubygems/core_ext/kernel_require.rb:59:in `require'"
  ]
}

bussiness_timeを参照出来なくなっています。
Lambda Layersを使用する際、格納したファイルやディレクトリは、/optディレクトリに格納される仕様になっています。なので、こちらの対応が必要になります。

対応方法は以下の二つです。
* gemの参照先を絶対パスで記述する
* LOAD_PATHを変更する

後者で対応することにしました。
以下のように、handler.rbを変更します。

load_path = Dir["/opt/bundle/ruby/2.5.0/gems/**/lib"]
$LOAD_PATH.unshift(*load_path)

require 'json'
require 'business_time'

def hello(event:, context:)
  puts Date.today.workday?

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

ちなみに、/opt/bundle/ruby/2.5.0/gems/**/libのパスはlayersの作成方法によって変わってしまうため、適宜変更してください。

これでテストを実行すると、200が返却され、ログにtrueが返ってくるかと思います。

5. sls invokeを使用出来るようにする

上記までだと、LOAD_PATHを変更してしまうため、ローカルでsls invokeが使用できなくなってしまうため、対応したいと思います。

serverless frameworkのsls invokeコマンドを実行すると、IS_LOCALという環境変数が作成され、trueがセットされます。※ こちらを参照ください

なので、これをフラグにLambda関数に条件分岐を設定します。

handler.rbを以下のように変更します。

if ENV['IS_LOCAL'].nil?
  load_paths = Dir["/opt/bundle/ruby/2.5.0/gems/**/lib"]
  $LOAD_PATH.unshift(*load_paths)
end

require 'json'
require 'business_time'

def hello(event:, context:)
  puts Date.today.workday?

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

これで、sls invokeだった時は、LOAD_PATHが参照されなくなるので、sls invokeが使えるようになるかと思います。
当然この方法だと、関数が増えた時に全てに分岐を加えなければいけないので、対応策としては微妙ですが...

もっといい方法があればコメントいただけますと幸いです。