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`を編集していきます。

```yaml
# 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を編集

```yaml
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で参照をすることが出来ます。
[公式ドキュメント](https://serverless.com/framework/docs/providers/aws/guide/layers#using-your-layers)

```yaml
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
```

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

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

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

```
$ sls deploy
```

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


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

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

```yaml
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`を以下のようにします

```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
```

![image.png](https://qiita-image-store.s3.amazonaws.com/0/19716/6e8ffff3-2324-0891-956d-8eb3bee01952.png)


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

```json
{
  "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`を変更します。


```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`がセットされます。※ [こちらを参照ください](https://serverless.com/framework/docs/providers/aws/cli-reference/invoke-local#environment)

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

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

```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`が使えるようになるかと思います。
当然この方法だと、関数が増えた時に全てに分岐を加えなければいけないので、対応策としては微妙ですが...

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