Try Ruby on Rails on AWS Lambda and API Gateway by Serverless Framework

This article is a translation of the original article.

In November of last year, AWS Lambda's Ruby runtime support was announced and a demo code to run Sinatra was released. I tried running Rails API with reference to these, so I will summarize the procedure.

What is this

A demo code of Rails API using Serverless Framework that runs on AWS Lambda. Includes API Gateway receiving requests and DynamoDB storing data too. Serverless deploy to AWS Lambda is executed by CodeBuild.

f:id:nihma:20190114085912p:plain

The REST API that can be used is below.

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

The library version to be used is below.

The source code is below. The tag of this article is v0.0.0.

github.com

How to use Demo Code

You can build an AWS environment with setup.sh.

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

setup.sh will do the following.

  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

Specifically, it is as follows.

#!/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}

How to create a Rails on Lambda project

I will explain the procedure of creating a Rails API project that can be run with Lambda.

First, rails new of API mode which skipped unnecessary setup.

$ 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

Then write the required gem in Gemfile and generate Gemfile.lock. Since bundle install wants to be executed in the same environment as Lambda, use the environment of lambci/lambda:build-ruby2.5 of Docker.

$ 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

Next, prepare lambda.rb to run Rails via Rack. It uses serverless-sinatra-sample lambda.rb. However, because the path of config.ru is different, need to change it for 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

Then prepare a serverless.yml that passes all http requests to lambda.rb.

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

Finally, prepare the buildspec.yml for serverless deploy with CodeBuild.

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

You are now ready to run with Lambda. The rest is the same as normal Rails development.

First, create a Controller.

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

Register the Action in the created 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

Then register the routing in routes.rb.

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

You are now ready to start a project on Ruby on Rails on AWS Lambda and API Gateway by Serverless Framework. How easy it is!

Enjoy Rails on Lambda!