Closed7

Note: Rails API on AWS Lambda with Lamby

Keisuke InabaKeisuke Inaba

What's Lamby

Lamby is an AWS Lambda Web Adapter for Rack applications.

Lamby acts as a bridge (adapter) that translates AWS Lambda event data (which comes from API Gateway, ALB, etc.) into a Rack env hash. This makes it possible for Rails (or any Rack-based app) to handle requests as if it were running behind a traditional web server like Puma or Unicorn.

With Lamby on AWS Lambda, the web server is eliminated:
• AWS receives the HTTP request via API Gateway or ALB.
• Lambda is invoked with the HTTP event.
• Lamby takes that event, converts it into a Rack-compatible env hash.
• Your Rails app handles it via its Rack interface and returns a response.
• Lamby converts the Rack response back into the format expected by AWS Lambda and returns it.

This is event-based, not request-thread-based. Each Lambda invocation handles one HTTP request.

Keisuke InabaKeisuke Inaba

Why switch from Ruby on Jets to Lamby?

Ruby on Jets is a full-fledged serverless framework for Ruby, inspired by frameworks like Serverless.js and Rails. It brings its own DSL and conventions, somewhat similar to Rails, but not a direct drop-in for existing Rails apps. It includes support for controllers, jobs, tasks, and infrastructure as code (IAC) — an all-in-one framework.

I've been using Ruby on Jets for my Ruby Lambda app, but I've decided to migrate to Lamby because:

  1. Ruby on Jets made a significant architecture shift twice: v4 -> v5 and v5 -> v6
  2. It's started asking for a Jets account to set up any environments and charging for the number of CloudFormation stacks ($30 / stack / month) since v6.
  3. It comes with its own DSL, conventions, and dependencies, which are considered a bit overkill for my relatively simple Lambda app even though it takes care of all required environment setups with minimal effort.
Keisuke InabaKeisuke Inaba

Required development setup with Lamby

I'll use Rails + Puma for development and test. Even though sam local start-api could be used to simulate the actual Lambda environment, I decided not to use it as I faced a lot of difficulties and issues with the SAM local setup because the Lambda app is quite simple so developing/testing it as a Rails app with Puma would be okay as I deploy it to the smoke-prod env before every production deployment.

Rails new

rails new lamby-demo --api \
  --database=mysql \
  --skip-test-unit \
  --skip-active-storage \
  --skip-action-cable \
  --skip-bundle \
  --skip-git

Update Gemfile

Update Gemfile as you want to use Puma for development/test and Lamby for production and include other necessary gems like RSpec.

Gemfile
source "https://rubygems.org"

gem "rails", "~> 8.0.2"
# Use mysql as the database for Active Record
gem "mysql2", "~> 0.5"

# Build JSON APIs with ease [https://github.com/rails/jbuilder]
# gem "jbuilder"

# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]

# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false

# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin Ajax possible
# gem "rack-cors"

group :production do
  gem "lamby"
end

group :development, :test do
  # Use Puma for development & test
  gem "puma", ">= 5.0"
  # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
  gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"

  # Static analysis for security vulnerabilities [https://brakemanscanner.org/]
  gem "brakeman", "~> 7.0.2", require: false

  # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
  gem "rubocop-rails-omakase", require: false

  # RSpec for testing
  gem "rspec-rails", "~> 7.1.1"
  gem "factory_bot_rails", "~> 6.4.0"
  gem "faker", "~> 3.5.1"

  gem "dotenv"
end

group :test do
  gem "database_cleaner-active_record", "~> 2.2.0"
end

Dockerization

Dockerfile.dev
FROM ruby:3.3.7

RUN apt-get update
RUN apt-get install -y vim unzip curl netcat-openbsd

ENV APP /usr/src/app
RUN mkdir $APP
WORKDIR $APP

# Environment + bundler config
ENV BUNDLE_IGNORE_CONFIG=1
ENV BUNDLE_PATH=./vendor/bundle
ENV BUNDLE_CACHE_PATH=./vendor/cache
ENV RAILS_SERVE_STATIC_FILES=1

COPY Gemfile* $APP/
RUN bundle install -j3

COPY . $APP/

CMD ["./bin/server-dev"]
docker-compose.yml
services:
  db:
    image: mysql:8.0.20
    platform: linux/x86_64
    command: --default-authentication-plugin=mysql_native_password --sql_mode=""
    environment:
      MYSQL_ROOT_PASSWORD: password
    ports:
      - '3306:3306'

  lamby-demo:
    build:
      context: .
      dockerfile: Dockerfile.dev
    volumes:
      - .:/usr/src/app
    ports:
      - '8080:8080'
    depends_on:
      - db
    stdin_open: true
    tty: true
    environment:
      DB_USERNAME: root
      DB_PASSWORD: password
      DB_PORT: 3306
      DB_HOST: db
      RAILS_MAX_THREADS: 5
      RAILS_ENV: development

bin/server-dev

server-dev
#!/bin/bash -i

# Wait for MySQL
until nc -z -v -w30 $DB_HOST $DB_PORT; do
 echo 'Waiting for MySQL...'
 sleep 1
done
echo "MySQL is up and running!"

# If the container has been killed, there may be a stale pid file
# preventing rails from booting up
rm -f tmp/pids/server.pid

bundle exec rails server -p 8080 -b 0.0.0.0
Keisuke InabaKeisuke Inaba

Deployment with GitHub Actions

Overview

Dockerfile

Dockerfile
# Start with the official AWS Lambda base image for Ruby 3.3
FROM public.ecr.aws/lambda/ruby:3.3-x86_64
ARG RAILS_MASTER_KEY
ARG ENVIRONMENT

ENV RAILS_MASTER_KEY=$RAILS_MASTER_KEY
ENV RAILS_ENV=production
ENV ENVIRONMENT=$ENVIRONMENT

# Install build tools & dev libraries for psych (YAML) + mysql2
# XXX 'mariadb-devel' doesn't exist on Amazon Linux 2023.
# Use mariadb-connector-c-devel instead.
RUN dnf install -y \
      gcc gcc-c++ make \
      libyaml-devel \
      mariadb-connector-c-devel \
    && dnf clean all

# Set the working directory to /var/task (already the default in AWS base images)
WORKDIR /var/task

# Bundler config to vendor gems inside the image
ENV BUNDLE_IGNORE_CONFIG=1
ENV BUNDLE_PATH=./vendor/bundle
ENV BUNDLE_CACHE_PATH=./vendor/cache
ENV BUNDLE_WITHOUT=development:test
ENV BUNDLE_DEPLOYMENT=true
ENV BUNDLE_FROZEN=true

# Copy Gemfiles and install gems
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs=3

# Copy the rest of your application code
COPY . .

ENV BOOTSNAP_CACHE_DIR=/var/task/tmp/cache
RUN bundle exec bootsnap precompile --gemfile . \
    && bundle exec ruby config/environment.rb

# The AWS Lambda base images come with the Runtime Interface Client (RIC) and entrypoint.
CMD ["app.handler"]

template.yaml

The template YAML for the AWS SAM deploy command:

template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Rails + Lamby

Globals:
  Function:
    Timeout: 30
    Architectures:
      - x86_64
    Environment:
      Variables:
        DATABASE_URL: !Ref DatabaseUrl
        AWS_ECR_IMAGE_URI: !Ref AwsEcrImageUri

Parameters:
  DatabaseUrl:
    Type: String
  AwsEcrImageUri:
    Type: String

Resources:
  RailsAppFunction:
    Type: AWS::Serverless::Function
    Properties:
      AutoPublishAlias: live
      FunctionUrlConfig:
        AuthType: NONE
      DeploymentPreference:
        Type: AllAtOnce
      MemorySize: 1792
      PackageType: Image
      ImageUri: !Ref AwsEcrImageUri
      Events:
        HttpApi:
          Type: HttpApi
      Policies:
        - Statement:
          - Effect: Allow
            Action:
              - logs:CreateLogGroup
              - logs:CreateLogStream
              - logs:PutLogEvents
              - cloudwatch:PutMetricData
            Resource: "*"

bin/deploy

The deploy command for the app:

bin/deploy
#!/usr/bin/env bash

set -euo pipefail

STACK_NAME="lamby-demo-${ENVIRONMENT}"
REGION="${AWS_REGION}"

echo "Installing SAM CLI..."
curl -sSL -o sam-install.zip https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip && \
  unzip -q sam-install.zip -d sam-installation && \
  ./sam-installation/install && \
  rm -rf sam-install.zip sam-installation
sam --version

echo "Deploying..."
# Create a temp file to store the output
DEPLOY_LOG=$(mktemp)

sam deploy \
  --template-file template.yaml \
  --stack-name "$STACK_NAME" \
  --region "$REGION" \
  --capabilities CAPABILITY_IAM \
  --no-fail-on-empty-changeset \
  --on-failure ROLLBACK \
  --image-repositories RailsAppFunction=${AWS_ECR_REGISTRY}/lamby-demo \
  --parameter-overrides \
    Environment="$ENVIRONMENT" \
    DatabaseUrl="${DATABASE_URL}" \
    AwsEcrImageUri="${AWS_ECR_IMAGE_URI}" \
  2>&1 | tee "$DEPLOY_LOG"

# Grab the last line of the deploy log
LAST_LINE=$(tail -n 1 "$DEPLOY_LOG")
EXPECTED="Successfully created/updated stack - $STACK_NAME in $REGION"

if grep -q "$EXPECTED" "$DEPLOY_LOG"; then
  echo "Deployment succeeded."
else
  echo "Deployment failed or incomplete."
  exit 1
fi

.github/workflows/deploy.yml

The deploy action on GitHub Actions supporting smoke-prod and production:

deploy.yml
name: Deploy

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deploy to:'
        required: true
        default: 'smoke-prod'
        type: choice
        options:
          - smoke-prod
          - production

jobs:
  deploy:
    runs-on: ubuntu-22.04

    env:
      ENVIRONMENT: ${{ github.event.inputs.environment }}
      RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
      DATABASE_URL: ${{ secrets.DATABASE_URL }}
      AWS_REGION: us-east-1
      AWS_ECR_REGISTRY: ${{ secrets.AWS_ECR_REGISTRY }}
      IMAGE_TAG: ${{ github.sha }}

    steps:
      - name: Echo selected deployment environment
        run: echo "${{ env.ENVIRONMENT }} deployment is initiated."

      - name: Set AWS ECR image URI
        run: echo "AWS_ECR_IMAGE_URI=${AWS_ECR_REGISTRY}/lamby-demo:${IMAGE_TAG}" >> $GITHUB_ENV

      - name: Checkout code
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Log in to Amazon ECR
        run: |
          aws ecr get-login-password --region $AWS_REGION \
            | docker login --username AWS --password-stdin $AWS_ECR_REGISTRY

      - name: Build Docker image
        run: |
          docker build --build-arg RAILS_MASTER_KEY=$RAILS_MASTER_KEY --build-arg ENVIRONMENT=$ENVIRONMENT -t lamby-demo . && \
          docker tag lamby-demo "$AWS_ECR_IMAGE_URI"

      - name: Check if image already exists in ECR
        id: ecr-image-check
        run: |
          set -e
          if aws ecr describe-images \
            --repository-name lamby-demo \
            --image-ids imageTag=$IMAGE_TAG \
            --region $AWS_REGION > /dev/null 2>&1; then
            echo "Image ${IMAGE_TAG} already exists in ECR"
            echo "exists=true" >> $GITHUB_OUTPUT
          else
            echo "Image ${IMAGE_TAG} does not exist in ECR"
            echo "exists=false" >> $GITHUB_OUTPUT
          fi

      - name: Push Docker Image to ECR
        if: steps.ecr-image-check.outputs.exists == 'false'
        run: docker push "$AWS_ECR_IMAGE_URI"

      - name: Deploy via Docker container
        run: |
          timeout 5m docker run --rm \
            --entrypoint "/bin/bash" \
            -e DATABASE_URL \
            -e AWS_ECR_IMAGE_URI \
            -e AWS_ACCESS_KEY_ID \
            -e AWS_SECRET_ACCESS_KEY \
            -e AWS_REGION \
            -e AWS_ECR_REGISTRY \
            lamby-demo ./bin/deploy
Keisuke InabaKeisuke Inaba

How to enable the ColdStart metrics

config/initializers/lamby.rb
if Rails.env.production?
  Lamby::Config.configure do |config|
    config.cold_start_metrics = true
    config.metrics_app_name = "lamby-demo-#{ENV['ENVIRONMENT']}"
    puts "[Lamby] Cold start metrics enabled for #{config.metrics_app_name}"
  end
end
Keisuke InabaKeisuke Inaba

Switch from Ruby on Jets to Lamby without downtime

It's possible to switch the production domain from Jets' API to Lamby's API without downtime, as long as both API Gateways use the same custom domain name configuration.

You just need to go to the custom domain name on the API Gateway dashboard and update the bath path mapping from one to the other.

As a result:

  • The CloudFront distribution (used under the hood by API Gateway custom domains) stays the same.
  • Your DNS record (the CNAME in Route 53) does not change.
  • The request routing to Lamby's API happens within milliseconds and has no downtime.
このスクラップは5ヶ月前にクローズされました