Ruby on Rails on AWS Lambda and API Gateway by Serverless Frameworkを試してみた

昨年の11月にAWS LambdaのRubyランタイムサポートが発表されSinatraを動かすデモコードが公開されました。これらを参考にしてRails APIを動かしてみたので手順をまとめておきます。

作ったもの

AWS Lambdaで動作するServerless Frameworkを使ったRails APIのデモコードです。リクエストを受け取るAPI Gatewayとデータを保管するDynamoDBも含みます。AWS Lambdaへのserverless deployはCodeBuildで行います。

f:id:nihma:20190114084616p:plain

使えるREST APIは下記です。

  • GET /todos
  • POST /todos
  • GET /todos/:id
  • PUT /todos/:id
  • DELETE /todos/:id

利用しているライブラリのバージョンは下記です。

ソースコードは下記です。この記事のタグはv0.0.0です。

github.com

デモコードの使い方

setup.shにてAWS環境を構築できるようにしました。

$ git clone https://github.com/nihemak/serverless-rails-sample.git
$ cd serverless-rails-sample
$ ./setup.sh

setup.shは下記を行います。

  1. Create CodeCommit repository
  2. Create DynamoDB table
  3. Create IAM lambda execute role
  4. Create S3 deploy bucket
  5. Create ECR build image repository
  6. Create IAM build role
  7. Create CodeBuild
  8. Create Lambda and API Gateway
  9. Test Lambda and API Gateway

具体的には下記の通りです。

#!/bin/bash

# This is the master key for this demo.
RAILS_MASTER_KEY="743c44757a18175254895f68b1369aa5"

AWS_IDENTITY=$(aws sts get-caller-identity)
AWS_ACCOUNT_ID=$(echo ${AWS_IDENTITY} | jq -r ".Account")

REGION="ap-northeast-1"
SERVICE_NAME="serverless-rails-sample"
STAGE_ENV="demo"
CODECOMMIT_REPO_NAME=${SERVICE_NAME}
CODECOMMIT_BRANCH="master"
CODEBUILD_PROJ_NAME="${SERVICE_NAME}-${STAGE_ENV}"
SERVICE_STACK_NAME="${SERVICE_NAME}-${STAGE_ENV}"
ECR_REPO_NAME=${SERVICE_NAME}
ROLE_BUILD_NAME="${SERVICE_NAME}-build"
ROLE_EXECUTE_NAME="${SERVICE_NAME}-execute"
BUCKET_DEPLOY_NAME="${SERVICE_NAME}-deploy-bucket"
DYNAMO_PREFIX="${SERVICE_NAME}-${STAGE_ENV}"

rm -rf work
mkdir work
cd ./work

## Create CodeCommit repository
aws codecommit create-repository --repository-name ${CODECOMMIT_REPO_NAME}
git clone --mirror https://github.com/nihemak/serverless-rails-sample.git
cd serverless-rails-sample
git push ssh://git-codecommit.${REGION}.amazonaws.com/v1/repos/${CODECOMMIT_REPO_NAME} --all
cd ..

## Create DynamoDB table
aws dynamodb create-table --table-name ${DYNAMO_PREFIX}-todos \
                          --attribute-definitions AttributeName=id,AttributeType=S \
                          --key-schema AttributeName=id,KeyType=HASH \
                          --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1

## Create IAM lambda execute role
cat <<EOF > Trust-Policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "lambda.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
EOF
ROLE_EXECUTE=$(aws iam create-role --role-name ${ROLE_EXECUTE_NAME} \
                                   --assume-role-policy-document file://Trust-Policy.json)
ROLE_EXECUTE_ARN=$(echo ${ROLE_EXECUTE} | jq -r ".Role.Arn")
cat <<EOF > Permissions.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:${REGION}:${AWS_ACCOUNT_ID}:log-group:/aws/lambda/${SERVICE_NAME}-*"
            ],
            "Effect": "Allow"
        },
        {
            "Action": [
                "dynamodb:DescribeTable",
                "dynamodb:Query",
                "dynamodb:Scan",
                "dynamodb:GetItem",
                "dynamodb:PutItem",
                "dynamodb:UpdateItem",
                "dynamodb:DeleteItem"
            ],
            "Resource": "arn:aws:dynamodb:${REGION}:${AWS_ACCOUNT_ID}:table/${DYNAMO_PREFIX}-*",
            "Effect": "Allow"
        },
        {
            "Action": [
                "dynamodb:ListTables"
            ],
            "Resource": "*",
            "Effect": "Allow"
        }
    ]
}
EOF
aws iam put-role-policy \
        --role-name ${ROLE_EXECUTE_NAME} \
        --policy-name ${ROLE_EXECUTE_NAME} \
        --policy-document file://Permissions.json

## Create S3 deploy bucket
aws s3 mb s3://${BUCKET_DEPLOY_NAME} --region ${REGION}

## Create ECR build image repository
ECR_LOGIN=$(aws ecr get-login --no-include-email)
echo ${ECR_LOGIN} > ecr_login.sh
chmod 755 ecr_login.sh
bash ./ecr_login.sh

ECR_REPO=$(aws ecr create-repository --repository-name ${ECR_REPO_NAME})
ECR_REPO_URL=$(echo ${ECR_REPO} | jq -r ".repository.repositoryUri")

docker pull lambci/lambda:build-ruby2.5
docker tag lambci/lambda:build-ruby2.5 ${ECR_REPO_URL}:latest
docker push ${ECR_REPO_URL}:latest

cat <<EOF > ecr_policy.json
{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Sid": "CodeBuildAccess",
            "Effect": "Allow",
            "Principal": {
                "Service": "codebuild.amazonaws.com"
            },
            "Action": [
                "ecr:GetDownloadUrlForLayer",
                "ecr:BatchGetImage",
                "ecr:BatchCheckLayerAvailability"
            ]
        }
    ]
}
EOF
aws ecr set-repository-policy --repository-name ${ECR_REPO_NAME} --policy-text file://ecr_policy.json

## Create IAM build role
cat <<EOF > Trust-Policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "codebuild.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
EOF
ROLE_BUILD=$(aws iam create-role --role-name ${ROLE_BUILD_NAME} \
                                 --assume-role-policy-document file://Trust-Policy.json)
aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/AdministratorAccess \
                           --role-name ${ROLE_BUILD_NAME}
ROLE_BUILD_ARN=$(echo ${ROLE_BUILD} |jq -r ".Role.Arn")

## Create CodeBuild
cat <<EOF > Source.json
{
  "type": "CODECOMMIT",
  "location": "https://git-codecommit.${REGION}.amazonaws.com/v1/repos/${CODECOMMIT_REPO_NAME}",
  "buildspec": "buildspec.yml"
}
EOF
cat <<EOF > Artifacts.json
{
  "type": "NO_ARTIFACTS"
}
EOF
cat <<EOF > Environment.json
{
  "type": "LINUX_CONTAINER",
  "image": "${ECR_REPO_URL}:latest",
  "computeType": "BUILD_GENERAL1_SMALL",
  "environmentVariables": [
    {
      "name": "REGION",
      "value": "${REGION}",
      "type": "PLAINTEXT"
    },
    {
      "name": "STAGE_ENV",
      "value": "${STAGE_ENV}",
      "type": "PLAINTEXT"
    },
    {
      "name": "SERVICE_NAME",
      "value": "${SERVICE_NAME}",
      "type": "PLAINTEXT"
    },
    {
      "name": "LAMBDA_ROLE",
      "value": "${ROLE_EXECUTE_ARN}",
      "type": "PLAINTEXT"
    },
    {
      "name": "DEPLOY_BUCKET",
      "value": "${BUCKET_DEPLOY_NAME}",
      "type": "PLAINTEXT"
    },
    {
      "name": "DYNAMO_PREFIX",
      "value": "${DYNAMO_PREFIX}",
      "type": "PLAINTEXT"
    },
    {
      "name": "RAILS_ENV",
      "value": "production",
      "type": "PLAINTEXT"
    },
    {
      "name": "RAILS_MASTER_KEY",
      "value": "${RAILS_MASTER_KEY}",
      "type": "PLAINTEXT"
    }
  ]
}
EOF
aws codebuild create-project --name ${CODEBUILD_PROJ_NAME} \
                               --source file://Source.json \
                               --artifacts file://Artifacts.json \
                               --environment file://Environment.json \
                               --service-role ${ROLE_BUILD_ARN}

## Create Lambda and API Gateway
aws codebuild start-build --project-name ${CODEBUILD_PROJ_NAME} \
                          --source-version ${CODECOMMIT_BRANCH}
REST_API_ID=$(aws cloudformation describe-stack-resources --stack-name ${SERVICE_STACK_NAME} | jq -r '.StackResources[] | select(.ResourceType == "AWS::ApiGateway::RestApi") | .PhysicalResourceId')
REST_API_RESOURCE_ID=$(aws apigateway get-resources --rest-api-id ${REST_API_ID} | jq -r '.items[] | select(.path == "/{proxy+}") | .id')

## Test Lambda and API Gateway

### GET /todos
aws apigateway test-invoke-method --rest-api-id ${REST_API_ID} \
                                  --resource-id ${REST_API_RESOURCE_ID} \
                                  --http-method GET \
                                  --path-with-query-string '/todos' \
                                  --headers 'Content-Type=application/json,charset=utf-8'
# curl -X GET https://${REST_API_ID}.execute-api.${REGION}.amazonaws.com/${STAGE_ENV}/todos

### POST /todos
aws apigateway test-invoke-method --rest-api-id ${REST_API_ID} \
                                  --resource-id ${REST_API_RESOURCE_ID} \
                                  --http-method POST \
                                  --path-with-query-string '/todos?text=foo' \
                                  --headers 'Content-Type=application/json,charset=utf-8'
# curl -X POST https://${REST_API_ID}.execute-api.${REGION}.amazonaws.com/${STAGE_ENV}/todos --data "text=foo"

TODO_ID=$(aws apigateway test-invoke-method --rest-api-id ${REST_API_ID} \
                                            --resource-id ${REST_API_RESOURCE_ID} \
                                            --http-method GET \
                                            --path-with-query-string '/todos' \
                                            --headers 'Content-Type=application/json,charset=utf-8' | jq -r '.body' | jq -r '.[].id')
# TODO_ID=$(curl -X GET https://${REST_API_ID}.execute-api.${REGION}.amazonaws.com/${STAGE_ENV}/todos | jq -r '.[].id')

### GET /todos/:id
aws apigateway test-invoke-method --rest-api-id ${REST_API_ID} \
                                  --resource-id ${REST_API_RESOURCE_ID} \
                                  --http-method GET \
                                  --path-with-query-string "/todos/${TODO_ID}" \
                                  --headers 'Content-Type=application/json,charset=utf-8'
# curl -X GET https://${REST_API_ID}.execute-api.${REGION}.amazonaws.com/${STAGE_ENV}/todos/${TODO_ID}

### PUT /todos/:id
aws apigateway test-invoke-method --rest-api-id ${REST_API_ID} \
                                  --resource-id ${REST_API_RESOURCE_ID} \
                                  --http-method PUT \
                                  --path-with-query-string "/todos/${TODO_ID}?text=bar" \
                                  --headers 'Content-Type=application/json,charset=utf-8'
# curl -X PUT https://${REST_API_ID}.execute-api.${REGION}.amazonaws.com/${STAGE_ENV}/todos/${TODO_ID}?text=bar

### DELETE /todos/:id
aws apigateway test-invoke-method --rest-api-id ${REST_API_ID} \
                                  --resource-id ${REST_API_RESOURCE_ID} \
                                  --http-method DELETE \
                                  --path-with-query-string "/todos/${TODO_ID}" \
                                  --headers 'Content-Type=application/json,charset=utf-8'
# curl -X DELETE https://${REST_API_ID}.execute-api.${REGION}.amazonaws.com/${STAGE_ENV}/todos/${TODO_ID}

Rails on Lambdaプロジェクトの作り方

Lambdaで動かせるRails APIプロジェクトの作成手順を説明します。

まず不要なセットアップをスキップしたrails newAPIモードで行います。

$ rbenv shell 2.5.3
$ mkdir project_dir
$ cd project_dir
$ rails new . --api --skip-yarn --skip-active-record --skip-active-storage --skip-puma --skip-action-cable --skip-sprockets --skip-coffee --skip-javascript --skip-turbolinks --skip-bootsnap --skip-bundle

そして必要なgemをGemfileに記載しGemfile.lockを生成します。bundle installはLambdaと同じ環境で実施したいためDockerを使ってlambci/lambda:build-ruby2.5の環境を使います。

$ docker run -v `pwd`:`pwd` -w `pwd` -it lambci/lambda:build-ruby2.5 bundle install --no-deployment
$ docker run -v `pwd`:`pwd` -w `pwd` -it lambci/lambda:build-ruby2.5 bundle --deployment

次にRack経由でRailsを実行するlambda.rbを用意します。これはserverless-sinatra-sampleのlambda.rbを使わせてもらいます。ただしconfig.ruのパスが違うのでRails用に変更する必要があります。

$ git diff 61d42e36e5ddac4eaf5ee2538529142ffedc74e4 fd078722a2e73326ad57f48321a1e485865dc1ec
diff --git a/lambda.rb b/lambda.rb
index 05f7f59..0122927 100644
--- a/lambda.rb
+++ b/lambda.rb
@@ -19,7 +19,7 @@ require 'base64'

 # Global object that responds to the call method. Stay outside of the handler
 # to take advantage of container reuse
-$app ||= Rack::Builder.parse_file("#{File.dirname(__FILE__)}/app/config.ru").first
+$app ||= Rack::Builder.parse_file("#{File.dirname(__FILE__)}/config.ru").first

 def handler(event:, context:)
   # Check if the body is base64 encoded. If it is, try to decode it

そして全てのhttpリクエストをlambda.rbに渡すserverless.ymlを用意します。

functions:
  app:
    handler: lambda.handler
    events:
      - http: ANY /
      - http: 'ANY {proxy+}'

最後にCodeBuildでserverless deployするためのbuildspec.ymlを用意します。

version: 0.2
phases:
  install:
    commands:
      - curl -sL https://rpm.nodesource.com/setup_10.x | bash -
      - yum install -y nodejs
      - npm install -g serverless@1.35.1
  build:
    commands:
      - bundle --deployment
      - serverless deploy
  post_build:
    commands:
      - touch dummy.zip
artifacts:
  files:
    - dummy.zip

これでLambdaで動かすための準備ができました。あとは通常のRailsの開発と同じです。

まずコントローラを作成します。

$ docker run -v `pwd`:`pwd` -w `pwd` -it lambci/lambda:build-ruby2.5 bin/rails g controller Todos

作成したtodos_controller.rbにアクションを登録します。

class TodosController < ApplicationController

  # GET /todos
  def index
    # ...
  end

  # POST /todos
  def create
    # ...
  end

  # GET /todos/:id
  def show
    # ...
  end

  # PUT /todos/:id
  def update
    # ...
  end

  # DELETE /todos/:id
  def destroy
    # ...
  end
end

そしてルーティングをroutes.rbに登録したらOKです。

Rails.application.routes.draw do
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
  resources :todos
end

これでRuby on Rails on AWS Lambda and API Gateway by Serverless Frameworkのプロジェクトを始める準備が整いました。簡単ですね。

Enjoy Rails on Lambda!