2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-05-11

はじめに

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

技術スタック

  • 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

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

image.png

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

image.png

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

image.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に以下を追加します

  before_action :merge_params!

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

次にハンドラーであるlambda.rbに以下を記載します

      env = {
        'rack.input' => StringIO.new(body),
      }

DynamoDBのテーブル名の設定

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

  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に以下を記載します。

  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
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"]
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

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とお友達になりたいです。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?