【API Gateway+Lambda+DynamoDB+ServerlessFramework】Railsをサーバレスデプロイしたい

# はじめに
バックエンドのサーバレスデプロイに挑戦したので簡単にまとめてみます。

# 技術スタック
- AWS DynamoDB
- AWS API Gateway
- AWS Lambda
- Ruby on Rails

# 動き
- ユーザーからのリクエストはAPI Gatewayを通じてLambdaに送信される
- リクエストを受け取ったLambdaはRailsアプリケーションを実行する
- データ操作はDynamoDBを使用
- レスポンスはLambdaに返され、API Gatewayを通じてユーザーに返される
- リクエスト/レスポンスはPostmanを使用

# ファイル構成(一部)
```
sample-project
┣ backend
┃  ┗ app
┃     ┗ controllers
┃           ┣ application_controller.rb
┃           ┗ todos_controller.rb
┃     ┗ models
┃         ┗ todo.rb
┃  ┗ config
┃       ┗ environments
┃              ┗ production.rb
┃  ┗ Dockerfile
┃  ┗ Gemfile
┃  ┗ lambda.rb
┣ serverless.yml
```


# 動作確認
- エンドポイントにGETでアクセス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2889192/4f48ee75-6575-4f9c-4c2e-28e6bb5d1f90.png)

- エンドポイントにPOSTでアクセス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2889192/a655cacb-e056-dab2-fdde-c8184b39e0e4.png)

- エンドポイントにPUTでアクセス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2889192/917108f2-5c83-37de-7685-d5edee534792.png)


- エンドポイントにDELETEでアクセス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2889192/cc16802b-00df-5681-3220-d584b741af29.png)


# ポイント(詰まったところ)
## デプロイ後にエンドポイントにアクセスするとBlocked hostとなる
```
[ActionDispatch::HostAuthorization::DefaultResponseApp] Blocked host: hogehoge.amazonaws.com
```

これは production.rbに以下を記載することで解決しました
```
config.hosts << "hogehoge.amazonaws.com"
```


## DynamoDBに対してレコードの取得はできるが、作成ができない
ここで沼りました。聞いたところDockerイメージをlambdaにデプロイしている場合はAPI Gatewayで受け取ったリクエストパラメータをRails側で取得するためにrackが必要とのことでした。

application.controller.rbに以下を追加します
```ruby:
  before_action :merge_params!

  def merge_params!
    params.merge! JSON.parse(request.env['rack.input'].read)
  rescue JSON::ParserError
    true
  end
```
次にハンドラーであるlambda.rbに以下を記載します
```ruby:
      env = {
        'rack.input' => StringIO.new(body),
      }
```

## DynamoDBのテーブル名の設定
詰まったというほどではないですが改めて整理しておきます。
todo.rbに以下を追加することでLambda実行時にDynamoDBにテーブルを作成してくれます。

```ruby:
  include Dynamoid::Document

  table name: :todos, key: :id, capacity_mode: :on_demand
  field :content, :string
  field :status, :number

  create_table
```

create_tableはdynamoidのメソッドで、テーブルが存在しなければ作成してくれるようです。
テーブル名は「dynamoid__」+「環境名_」+「モデル名」+「s」となります。
ここではRAILS_ENV が "production"で、モデル名がtodoであるため、dynamoid__production_todosというテーブル名が作成されます。


また、このテーブルに対してLambdaがアクセスできるように権限を付与します。
serverless.ymlに以下を記載します。

```yaml:
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:*
      Resource:
        # ここではAWSアカウントで作成した全てのDynamoDBテーブルにアクセスできる権限を付与しています
        - arn:aws:dynamodb:ap-northeast-1:${aws:accountId}:table/*
```

# ファイル(一部)
```application_controller.rb
class ApplicationController < ActionController::API
  before_action :merge_params!

  def merge_params!
    params.merge! JSON.parse(request.env['rack.input'].read)
  rescue JSON::ParserError
    true
  end
end
```

```todos_controller.rb
class TodosController < ApplicationController
    def index
      @todos = Todo.all
      render json: { status: 200, todos: @todos }
    end
  
    def new
      @todo = Todo.new
    end
  
    def create
      todo = Todo.create_todo(todo_params)
      if todo.errors.messages.present?
        render_error(500, todo.errors.messages)
      else
        render json: { status: 200 }
      end
    end
  
    def edit
      @todo = Todo.find(params[:id])
    end

    def show
      @todo = Todo.find(params[:id])
    end
  
    def update
      todo =Todo.update_todo(params[:id], todo_params)
      if todo[:status] == 200
        render json: { status: 200, todo: todo[:value] }
      else
        render_error(todo[:status], todo[:value])
      end  
    end
  
    def destroy
      @todo = Todo.find(params[:id])
      if @todo.destroy
        render json: { status: 200 }
      else
        render json: { status: 500 }
      end
    end    
  
    private
  
      def todo_params
        params.require(:todo).permit(:content, :status)
      end
  end
```

```todo.rb
class Todo
  include Dynamoid::Document

  table name: :todos, key: :id, capacity_mode: :on_demand
  field :content, :string
  field :status, :number

  create_table

  def self.create_todo(todo_value)
    Todo.create(content: todo_value[:content], status: todo_value[:status])
  end

  def self.update_todo(todo_id, todo_value)
    return { status: 400, value: "can't be blank" } if todo_value.to_h.any? { |_key, value| value.blank? }
    todo = Todo.update(todo_id, todo_value)
    { status: 200, value: todo }
  rescue StandardError, Aws::DynamoDB::Errors::ServiceError => e
    { status: 500, value: e.message }
  end
end
```

```production.rb
require "active_support/core_ext/integer/time"

Rails.application.configure do
  config.hosts << "hogehoge.amazonaws.com"

  if ENV["RAILS_LOG_TO_STDOUT"].present?
    logger           = ActiveSupport::Logger.new(STDOUT)
    logger.formatter = config.log_formatter
    config.logger    = ActiveSupport::TaggedLogging.new(logger)
  end

  config.log_level = :debug
end
```

```Dockerfile
FROM public.ecr.aws/lambda/ruby:2.7 as builder

ENV LANG C.UTF-8

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

RUN yum install -y libxml2 libxml2-devel libxslt libxslt-devel gcc-c++ mysql-devel

WORKDIR  /var/task
ENV RAILS_ENV=production
ENV RACK_ENV=production

RUN gem install bundler -v 2.3.5
RUN gem update --system

COPY ./Gemfile  /var/task
COPY ./Gemfile.lock  /var/task

RUN bundle config --local ruby 2.7.7
RUN mkdir -p  /var/task/vendor
RUN bundle install --path  /var/task/vendor/bundle --clean

ENV RAILS_LOG_TO_STDOUT=1
ADD .  /var/task/

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

```ruby:Gemfile
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby "2.7.7"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 7.0.4", ">= 7.0.4.3"
gem 'rack'
gem 'mysql2', '~>0.5.5'
gem "dynamoid"
gem 'sqlite3'
```

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

$app ||= Rack::Builder.parse_file("#{__dir__}/config.ru").first
ENV['RACK_ENV'] ||= 'production'

module App
  class Handler
    def self.process(event:, context:)
      puts event
      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-Origin': ENV['CLOUDFRONT_ADDRESS'],
          '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

```


```yaml:serverless.yml
service: qiita-test

frameworkVersion: '3'

provider:
  name: aws
  runtime: ruby2.7
  timeout: 300

  region: ap-northeast-1
  deploymentBucket:
    name: qiita-test-bucket
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:*
      Resource:
        - arn:aws:dynamodb:ap-northeast-1:${aws:accountId}:table/*
  ecr:
    scanOnPush: true
    images:
      lambda_docker:
        path: ./backend

custom:
  deploymentBucket:
    blockPublicAccess: true

functions:
  app:
    timeout: 30
    image:
      name: lambda_docker
    events:
      - http:
          path: /
          method: ANY
      - http:
          path: /{proxy+}
          method: ANY

plugins:
  - serverless-deployment-bucket
```


# おわりに
サーバレスデプロイして動きが確認できるところまでどうしてもやり切りたかったので、簡易的な構成で挑戦しました。
Lambdaのアップロード制限に対応することを考えると、RailsよりPythonやGoの方が楽なので次はそちらを試したいです。色々詰まりましたが、AWSサポートやチームの方々に助けられました。まだ知識が浅い部分が多いので引き続き頑張ります。AWSとお友達になりたいです。