【Lambda+API Gateway】Lambda関数をコンテナイメージでserverless deployしたい

# はじめに
serverless deployする機会があったので、学んだところなども踏まえて簡単にまとめてみます

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/ruby-image.html

# ディレクトリ構成
```
sample-project
┣ backend(railsプロジェクト)
┣ Dockerfile
┣ serverless.yml
┣ entrypoint.sh
┣ docker-compose.yml
```

# ファイル準備

Dockerfileは開発環境用と本番環境用に分けています

```:sample-project/Dockerfile
# 開発用
ARG DOCKER_TAG
ARG NODE_TAG
ARG RUBY_TAG

FROM public.ecr.aws/docker/library/node:${NODE_TAG} as node
FROM public.ecr.aws/docker/library/docker:${DOCKER_TAG} as docker
FROM public.ecr.aws/lambda/ruby:${RUBY_TAG}

WORKDIR /app
RUN yum update -y && yum -y groupinstall "Development tools" --setopt=group_package_types=mandatory,default,optional && \
  yum install -y less tzdata unzip python3 && \
  cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
  curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \
  unzip awscliv2.zip && \
  ./aws/install && \
  yum clean all

ARG YARN_VER

COPY --from=node /opt/yarn-${YARN_VER} /opt/yarn
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules/ /usr/local/lib/node_modules/
RUN ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \
  && ln -s /opt/yarn/bin/yarn /usr/local/bin/yarnpkg \
  && ln -s /usr/local/bin/node /usr/local/bin/nodejs \
  && ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
  && ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npx

# Docker binary copy
COPY --from=docker /usr/local/bin/docker /usr/local/bin/

ARG SLS_VER

RUN yarn global add serverless@${SLS_VER} aws-sso-credentials-getter @vue/cli @vue/cli-service \
  && yarn cache clean

ARG BUNDLE_VER
ENV BUNDLE_VER ${BUNDLE_VER}

ARG RUBY_TAG
ENV RUBY_TAG ${RUBY_TAG}

RUN gem install bundler -v ${BUNDLE_VER}

RUN bundle config path /usr/local/bundle

ENTRYPOINT ["./entrypoint.sh"]
```

```:backend/Dockerfile
# 本番用
ARG RUBY_TAG

FROM public.ecr.aws/lambda/ruby:${RUBY_TAG}

ENV LANG C.UTF-8

RUN yum update -y && yum groupinstall -y "Development Tools"

WORKDIR /var/task

ARG BUNDLE_VER

RUN gem install bundler -v ${BUNDLE_VER}

COPY lambda.rb /var/task
COPY ./Gemfile /var/task
COPY ./Gemfile.lock /var/task
ENV GEM_HOME=/var/task
RUN bundle install

ADD . /var/task/

CMD ["lambda.App::Handler.process"]
```

- lambda関数の実体は/var/taskに存在しているようなので、ハンドラーのlambda.rb(後述)やGemfileなどをマウントしています
- CMDでイメージ実行時にハンドラー(後述)が実行されるようにしています

```:docker-compose.yml
services:
  qiita:
    build:
      context: .
      args:
        DOCKER_TAG: "20.10"
        NODE_TAG: "17.1.0"
        RUBY_TAG: "2.7.2022.02.01.09"
        YARN_VER: "v1.22.15"
        SLS_VER: "3.23.0"
        BUNDLE_VER: "2.3.5"
    tty: true
    init: true
    ports:
      - 8080:8080
    volumes:
      - ./:/app
      - ~/.aws:/root/.aws
      - /var/run/docker.sock:/var/run/docker.sock
      - node_modules:/app/node_modules
      - bundle:/usr/local/bundle
    container_name: qiita

volumes:
  node_modules:
  bundle:
```
```sample-project/entrypoint.sh
bundle _${BUNDLE_VER}_ install --gemfile backend/Gemfile && yarn install && sh
```

serverless deployを実行する際の設定をserverless.ymlに記載します


```sample-project/serverless.yml
service: qiita-api

provider:
  name: aws
  runtime: ruby2.7
  timeout: 300
  region: ap-northeast-1
  deploymentBucket:
    name: qiita-rails-api-dev-slsdeploymentbucket
  ecr:
    scanOnPush: true
    images:
      lambda_docker_qiita:
        path: ./backend
        buildArgs:
          BUNDLE_VER: ${env:BUNDLE_VER, '2.3.5'}
          RUBY_TAG: ${env:RUBY_TAG, '2.7'}
custom:
  defaultStage: dev
  deploymentBucket:
    # s3バケットオブジェクトへのパブリックアクセスをすべてブロック
    blockPublicAccess: true

functions:
  api:
    timeout: 25
    image:
      name: lambda_docker_qiita
    events:
      - http:
          path: /
          method: ANY
      - http:
          path: /{proxy+}
          method: ANY
plugins:
  - serverless-deployment-bucket
```

- serverless deploy時に自動で作成されるs3バケットがパブリックアクセスオンの状態なので、
serverless-deployment-bucketというプラグインを用いてパブリックアクセスをオフにしています

- ecrにpushされたイメージを元にlambdaが関数を作ってくれます

- eventsでエンドポイントを2つ(rootとroot以下全て)指定しています
/{proxy+}がワイルドカードのようなふるまいをしてくれているようです

- このeventsがトリガーとなってlambda関数が実行されます。
エンドポイントにアクセスする度に本番環境用のDockerイメージが作成され、CMDで指定したハンドラーが実行されます

プラグインはsls deploy時に使用するので、開発環境用のコンテナに入ってインストールします

```
docker exec -it qiita sh
yarn add serverless-deployment-bucket
```

成功するとsample-project/pacage.jsonが以下のようになると思います

```:sample-project/pacage.json
{
  "dependencies": {
    "serverless-deployment-bucket": "^1.6.0"
  }
}
```

次に動作確認用として以下のファイルを作成します

```backend/app/controllers/test_controller.rb
class TestController < ApplicationController
    def index
      render json: {status: :ok, message: "hello! world!"}
    end
  end
```

```backend/config/routes.rb
Rails.application.routes.draw do
  resources :test
end
```

```backend/config.ru
# This file is used by Rack-based servers to start the application.

require_relative 'config/environment'

run Rails.application
```

```backend/lambda.rb
require 'json'
require 'rack'
require 'base64'

$app ||= Rack::Builder.parse_file("#{__dir__}/config.ru").first

module App
  class Handler
    def self.process(event:, context:)
      body = if event['isBase64Encoded']
        Base64.decode64 event['body']
      else
        event['body']
      end || ''
      puts body if body.present? && body.exclude?('password')

      headers = event.fetch 'headers', {}

      env = {
        'REQUEST_METHOD' => event.fetch('httpMethod'),
        'SCRIPT_NAME' => '',
        'PATH_INFO' => event.fetch('path', ''),
        'QUERY_STRING' => Rack::Utils.build_query(event['queryStringParameters'] || {}),
        'SERVER_NAME' => headers.fetch('Host', 'localhost'),
        'SERVER_PORT' => headers.fetch('X-Forwarded-Port', 443).to_s,

        'rack.version' => Rack::VERSION,
        'rack.url_scheme' => headers.fetch('CloudFront-Forwarded-Proto') { headers.fetch('X-Forwarded-Proto', 'https') },
        'rack.input' => StringIO.new(body),
        'rack.errors' => $stderr,
      }

      headers.each_pair do |key, value|
        name = key.upcase.gsub '-', '_'
        header = case name
          when 'CONTENT_TYPE', 'CONTENT_LENGTH'
            name
          else
            "HTTP_#{name}"
        end
        env[header] = value.to_s
      end

      begin
        status, headers, body = $app.call env

        headers.merge!({
          'Access-Control-Allow-Credentials': true,
          'Access-Control-Expose-Headers': 'Content-Disposition,X-Suggested-Filename',
          'Content-Security-Policy': "default-src 'self'"
        })

        body_content = ""
        body.each do |item|
          body_content += item.to_s
        end

        response = {
          'statusCode' => status,
          'headers' => headers,
          'body' => body_content
        }
      rescue Exception => exception
        response = {
          'statusCode' => 500,
          'body' => exception.message
        }
      end

      response
    end
  end
end
```


- API GatewayがLambdaにeventを渡した後、LambdaがRailsを実行するためのHandlerとしてlambda.rbが必要になります

- ECR接続などでaws認証が必要になったので、今回はコマンドから設定しておきます

```
# docker exec -it qiita sh
serverless config credentials --provider aws --key hogehoge --secret fugafuga
```

# 動作確認

デプロイを実行します
```
# docker exec -it qiita sh
sls deploy
```

デプロイに成功すると以下の表示がされると思います
ここでは伏せていますが、endpointsやfunctionsなども表示されていると思います

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2889192/8219ba3c-807e-6343-3458-6ec49c1955f7.png)

動作確認用に設定した/testにアクセスして、hello worldが画面に表示されることを確認します
(//○○.ap-northeast-1.amazonaws.com/dev/test)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2889192/e96476f7-eed7-64fd-d16b-bb8582352f7e.png)

上手くいきました！


# おわりに
とりあえずdeployできるところまで行きたかったので駆け足になってしまった部分があります。
これからAPI GatewayとLambdaの詳しい動きやRack部分の理解を深めていきます。