昨年の11月にAWS LambdaのRubyランタイムサポートが発表されSinatraを動かすデモコードが公開されました。これらを参考にしてRails APIを動かしてみたので手順をまとめておきます。
作ったもの
AWS Lambdaで動作するServerless Frameworkを使ったRails APIのデモコードです。リクエストを受け取るAPI Gatewayとデータを保管するDynamoDBも含みます。AWS Lambdaへのserverless deploy
はCodeBuildで行います。
使えるREST APIは下記です。
- GET /todos
- POST /todos
- GET /todos/:id
- PUT /todos/:id
- DELETE /todos/:id
利用しているライブラリのバージョンは下記です。
- ruby 2.5.3p105 (2018-10-18 revision 65156) [x86_64-linux] (docker image: lambci/lambda:build-ruby2.5)
- Rails 5.2.2
- dynamoid 3.1
- Serverless Framework 1.35.1
ソースコードは下記です。この記事のタグはv0.0.0
です。
デモコードの使い方
$ git clone https://github.com/nihemak/serverless-rails-sample.git $ cd serverless-rails-sample $ ./setup.sh
setup.sh
は下記を行います。
- Create CodeCommit repository
- Create DynamoDB table
- Create IAM lambda execute role
- Create S3 deploy bucket
- Create ECR build image repository
- Create IAM build role
- Create CodeBuild
- Create Lambda and API Gateway
- 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 new
をAPIモードで行います。
$ 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!