🦔

Serverless Framework & DockerによるローカルフレンドリーなLambda開発・運用

2021/05/08に公開

会社のチームメンバーにLambdaの開発・デプロイ方法について共有するために書いた記事ですが、Lambdaを触ったことない人にも有益そうな内容なので、Zennに投稿してみました🙏

今回作りたいLambda

  • 指定したS3バケットに画像がアップロードされたら、リサイズして同じまたは別のS3バケットにアップロードする

完成リポジトリ

Runtime

  • Ruby2.7

前準備

AWSアカウントが作成済み、AWS CLIインストール済み

  • アクセスキーIDとシークレットアクセスキーを事前に設定する必要あり
$ aws configure
AWS Access Key ID [****************]:

Dockerのインストール

  • 筆者がインストールしているバージョンがたまたまこれなので、必ずしも一致している必要はありません。
$ docker --version
Docker version 20.10.5, build 55c4c88

Serverless Framworkについて

Lambdaをデプロイするまでに必要な各設定を簡単にできるツールです。
https://www.serverless.com/

他にも、AWS公式のSAMやTerraformなど他のlaCでも代用は可能です。
https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/what-is-sam.html

おそらくLambdaにフォーカスするなら、Serverless FrameworkかSAMの2択になるかなと思います。
これら2つの詳細な比較は今回割愛しますが、以下の条件いずれかに該当するならServerless Frameworkを使ったほうが良いかと考えます。

  • 既存のS3バケットをLambda実行のイベントトリガーとして登録したい
  • AWS Lambdaの他にCloud Functions(GCP)なども使っておりマルチクラウドな環境
  • 手慣れている

一番上の条件が自分は出くわすことが多いので、Serverless Frameworkを使いがちです。
どうやらSAMはデプロイコマンド実行時にツール内部で、CloudFormationを使ってリソースを新たに構築する関係上、イベントトリガーで指定したS3バケットを新規作成しようと試みるが、S3のバケット名はグローバルにユニークでないといけないため、既存のS3バケットと名前が重複し失敗します。
そのため、新規にAWS環境を構築するような初期フェーズなら良いのでしょうが、すでに既存のAWSリソースが構築済みのような状況でLambdaを追加したいという場合だと、Serverless Frameworkの1択になるかなと思います。

※補足: SAMとServerless Frameworkの既存S3バケットのイベント通知について

SAMのS3イベントトリガー関連のロードマップissueを見ていると、2021年5月時点ですとまだ既存のS3をトリガーとして登録することはできなさそうです。
https://github.com/aws-cloudformation/aws-cloudformation-coverage-roadmap/issues/79

一方のServerless Frameworkでは既存のS3をイベントトリガーにどうやって登録しているかと言うと、Serverless Framework自身がデプロイコマンド実行時に、既存S3バケットのイベントをpub/subするEventBridgeなどを作成するためにCloudFormationのカスタムリソースで実現しています。

Serverless Framwrokを実行できるdocker-compose環境を構築する

  • ローカルでServerless Framworkをインストールしてもいいのですが、チーム開発を想定してdocker-compose化しておくことを推奨します。

まずは、以下のようなディレクトリとファイルを作成

$ mkdir resize-lambda-serverless
$ cd resize-lambda-serverless
$ tree -a -I .git
.
├── .gitignore
├── Dockerfile.dev
├── docker-compose.yml
├── docker.env
└── docker.env.sample

Dockerfile.dev
使用するベースイメージはLambdaの環境に限りなく近いdocker-lambdaのイメージを使用します。

FROM lambci/lambda:build-ruby2.7

# serverless frameworkのインストール
RUN curl -sL https://rpm.nodesource.com/setup_12.x | bash - \
  && yum -y install nodejs && yum -y clean all \
  && npm install -g serverless

WORKDIR /var/task/
CMD ["/bin/bash"]

docker-compose.yml

version: '3'
services:
  serverless:
    build:
      context: ./
      dockerfile: Dockerfile.dev
    volumes:
      - .:/var/task
    env_file:
      - docker.env

docker.env

  • docker-compose内でAWSの各クレデンシャル情報が使えるように設定しておきます。
    • 💣Gitでremote pushする際はこのファイルは必ず gitignore対象にしておきましょう!
AWS_ACCESS_KEY_ID=xxx
AWS_DEFAULT_REGION=ap-northeast-1
AWS_SECRET_ACCESS_KEY=xxx

.gitignore

docker.env

### Serverless ###
# Ignore build directory
.serverless

Serverless FrameworkのDocker環境を起動

$ docker-compose run --rm serverless sh
sh-4.2# ruby -v
ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-linux]

sh-4.2# sls -version
Framework Core: 2.39.2
Plugin: 4.5.3
SDK: 4.2.2
Components: 3.9.2

Ruby Runtime Lambdaのテンプレート作成

sh-4.2# serverless create --template aws-ruby --name resize-lambda-serverless
sh-4.2# yum -y install tree
sh-4.2# tree -a -I .git
.
├── docker-compose.yml
├── docker.env
├── docker.env.sample
├── Dockerfile.dev
├── .gitignore
├── handler.rb
├── .npmignore
└── serverless.yml

動作確認用に serverless.yml を以下のように変更。

service: resize-lambda-serverless
frameworkVersion: '2'
provider:
  name: aws
  runtime: ruby2.7
  region: ap-northeast-1
  lambdaHashingVersion: 20201221
functions:
  hello:
    handler: handler.hello
    events:
      - httpApi:
          path: /hello
          method: get	
package:
  exclude:
    - .git
    - .gitignore
    - docker.env
    - docker.env.sample
    - docker-compose.yml
    - Dockerfile.dev

これで最低限の準備は整ったので、試しにデプロイ

sh-4.2# sls deploy
...省略
Serverless: Stack update finished...
Service Information
service: resize-lambda-serverless
stage: dev
region: ap-northeast-1
stack: resize-lambda-serverless-dev
resources: 11
api keys:
  None
endpoints:
  GET - https://xxx.execute-api.ap-northeast-1.amazonaws.com/hello
functions:
  hello: resize-lambda-serverless-dev-hello
layers:
  None

Native extensionsに依存するgemを使用したRubyの開発環境のセットアップ

今回のユースケースであるリサイズ処理を実装したい場合、何からのGemが必要になるのでbundlerまわりを整えていきます。

sh-4.2# bundler init
Writing new Gemfile to /var/task/Gemfile

Gemfileにリサイズ処理に必要なgemを追加します。
今回は、ImageMagickではなく libvipsを使うので image_processing gemを追加
https://github.com/janko/image_processing

Gemfile

# frozen_string_literal: true

source "https://rubygems.org"

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

gem "aws-sdk-s3"
gem "image_processing"

group :development do
  gem "rake"
end

group :test do
  gem "pry"
  gem "minitest"
end

Gemfile変更後に、Lambda内にgemを含めるための設定を行います。

sh-4.2# bundle config set path "vendor/bundle"
sh-4.2# bundle install

これで準備万端と思いきや、image_processing gemを使ったリサイズ処理を実行することはできません。

例えばhandler.rb内で、require image_processing/vipsをして、bundle exec ruby handler.rbを実行すると、エラーになります。

sh-4.2# bundle exec ruby handler.rb
...
Could not open library 'libvips.so.42': libvips.so.42: cannot open shared object file: No such file or directory

これはimage_processing/vips がlibvipsに依存しているためで現在のDocker環境にlibvipsをインストールする必要があります。

Lambdaでのnative extensionsの取り扱いについて

今回のケースのようにC言語などに依存するライブラリを使用する場合、Lambdaでは以下のどちらかの対応が必要になります。

  • Dockerを使う
  • Lambda Layerを使う

Docker を使う

2020年12月からLambdaは、Dockerをサポートしたのでnative extensionsなライブラリを含んだDockerイメージを作ってしまうという方法があります。
https://aws.amazon.com/jp/blogs/news/new-for-aws-lambda-container-image-support/

例えば今回のケースであれば、以下のような流れで対応できそうです。

  1. libvipsをインストールしたLambda実行用のDockerイメージを作成
  2. ECRに作成したDockerイメージをpush
  3. ECRからイメージをpullしてLambdaを実行

使用するDockerイメージはAWS公式がAmazonLinux2ベースのLambda用イメージを提供しているのでそれを使うのが良さそうです。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/images-create.html#images-create-1

ただし、libvips及びその他諸々の周辺ミドルウェア(libpngなど)も自分で Dockerfile にインストールの定義を追加していくかつLambdaで実行可能な設定を追加する必要があります。
個人的には、自分で必要なミドルウェアを毎度定義していくのはしんどいので後述するLambda Layerを使うほうが良いと思います。

Lambda Layer を使う

詳細な説明は、公式ドキュメントやクラスメソッドさんの記事に譲りますが、ざっくりいうとメインロジックの実装とは別に共通して使用する依存ライブラリなどをlayerという形で別にデプロイして、他のLambdaからlayerにあるライブラリなどを使用することができるというものです。

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

Lambda Layerが素晴らしいのは、Lambda自体のサイズ肥大化を抑えられるだけでなく既にAWS公式やいろんな人がDockerイメージやOSSリポジトリなどで様々な依存ライブラリをまとめたLayerを公開しているなどエコシステムが整っていることです。

目当てのlibvipsもありますし、その他Layerもまとめられています。
https://github.com/customink/ruby-vips-lambda
https://serverlessrepo.aws.amazon.com/applications
https://github.com/mthenw/awesome-layers

customink/ruby-vips-lambda を使えば、libvipsを含んだLambda Layerのデプロイもすぐにできます。

$ git clone git@github.com:customink/ruby-vips-lambda.git
$ cd customink/ruby-vips-lambda

東京リージョンにデプロイするため、bin/deploy を少しいじります。

#!/bin/bash

set -e

./bin/build

export VIPS_VERSION=$(cat share/VIPS_VERSION)
export LAYER_NAME="rubyvips${VIPS_VERSION//./}-27"
- export AWS_REGION=${AWS_REGION:=us-east-1}
+ export AWS_REGION=${AWS_REGION:=ap-northeast-1}

aws lambda publish-layer-version \
  --region $AWS_REGION \
  --layer-name $LAYER_NAME \
  --description "Libvips for Ruby FFI." \
  --zip-file "fileb://share/libvips.zip"

そして、Layerをデプロイ

$ bin/deploy

Layerを使ったリサイズ処理のLambda

handler.rb

require_relative "./lib/image_service"

def handler(event:, context:)
  key     = event["Records"][0].dig("s3", "object", "key")
  resizer = ImageService::Resizer.new(key, 280)

  resizer.resize!
end

リサイズ処理自体は、POROで別に分けることでテストがしやすくなります。
https://github.com/samuraikun/image-resizer-lambda-serverless/commit/79a9f413bb486771777d83c15cfd3287beafbfa6

serverless.yml

  • S3への操作権限付与
  • 使用するlayerの指定
  • イベントトリガーに登録するS3バケットの指定
 service: resize-lambda-serverless
 frameworkVersion: '2'
 provider:
   name: aws
   runtime: ruby2.7
   region: ap-northeast-1
   lambdaHashingVersion: 20201221
+  iamRoleStatements:
+    - Effect: "Allow"
+      Action:
+        - "s3:*"
+      Resource:
+        - "arn:aws:s3:::${指定したバケット名}/*"
 functions:
   image_resizer:
     handler: handler.handler
+    layers:
+      - arn:aws:lambda:ap-northeast-1:アカウントID:layer:デプロイしたlayerのarn
     events:
+      - s3:
+          bucket: ${指定したバケット名}
+          events:
+            - s3:ObjectCreated:*
+          rules:
+            - prefix: ${指定したディレクトリ名}
+          existing: true
 package:
   exclude:
     - .git
     - .gitignore
     - docker.env
     - docker.env.sample
     - docker-compose.yml
     - Dockerfile.dev

これでsls deployを実行した後に指定したS3バケットに画像をアップロードすれば、リサイズされた画像が追加されます。

ローカルフレンドリーなLambda開発

customink/ruby-vips-lambda を使って native extensionsなライブラリをLayerで外だしできましたが、ローカル上で依存ライブラリを含んだ状態でLambda上の処理をデバッグがしたい場合は、LayerそのものをDockerイメージ化しておくのがおすすめです。

幸い、customink/ruby-vips-lambdaDockerfileがあるので、それを手元でプロジェクトのDockerfileにコピペしてもよいかもしれません。

今回であれば、Dockerfile.devcustomink/ruby-vips-lambdaのDockerfile にあるlibvipsのインストールなどの定義を追加します。
https://github.com/samuraikun/image-resizer-lambda-serverless/commit/a9a32aa222afb49bc77116f3ce14a80115e0eb49

そして、docker-compose環境内で以下のようにデバッグするなどが一番手軽かもしれません。

# 手元でデバッグするために一時的にコメントアウト
# def handler(event:, context:)
def handler
  binding.irb
end
sh-4.2# bundle exec ruby handler.rb

docker-lambdaのREADMEにあるように、Dockerコンテナーを起動してcurlでLambdaのエンドポイントを叩いて動作確認もした方が良いかもしれませんが、docker-composeでnative extentions含めた環境が再現できればひとまず十分ではないかと個人的には考えます。

Dockerを活用したLambdaの開発には、docker-lambdaの他に yumda もあり、こちらはAmazonLinux2ベースのイメージにyum経由で簡単に依存ライブラリを追加できるのでLambda LayerのDocker化が簡単にできそうです。
https://github.com/lambci/yumda

まとめると以下のアプローチが考えられるかなと思います。

  1. lambci/build-xxxのDockerイメージを使ってLambda, Layer環境のdocker-compose化
  2. docker-lambda のコンテナーを起動させて動作確認
  3. Lambda本体をdocker-lambdaのイメージを使い、layerはyumdaで別にイメージ作成する

Discussion