はじめに
バックエンドのサーバレスデプロイに挑戦したので簡単にまとめてみます。
技術スタック
- 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でアクセス
- エンドポイントにPOSTでアクセス
- エンドポイントにPUTでアクセス
- エンドポイントにDELETEでアクセス
ポイント(詰まったところ)
デプロイ後にエンドポイントにアクセスすると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/*
ファイル(一部)
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
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
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
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"]
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'
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
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とお友達になりたいです。