iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🐈

Deploying a Rails Application to Vercel with ISR

に公開

After going through "Investigating ISR Support in Nuxt3" and "Building a Custom Runtime for Serverless Functions", I've reached a point where I mostly understand Vercel. To take things further, I experimented with running ISR on Rails.

Conditions

  1. Run on Vercel's Serverless Function ruby27 runtime (equivalent to AWS Lambda).
    a. Building everything with a Custom Runtime seemed like a lot of work, so I won't consider it.
  2. Use Build Output API (v3) for On-Demand Incremental Static Regeneration.
    a. I want to verify if this is possible with things other than JavaScript frameworks.
  3. Since it is a Rails application, I want to implement MVC using Views and AR.
  4. Use PlanetScale's MySQL-compatible server for the database.
    a. I confirmed that Aurora Serverless is also usable, but I heard that the provider handles connection pooling issues on their end, so I want to try it out.

Base Source Code

Actually, there is a minimal Rails snippet within the Ruby runtime development code. I will modify this.

https://github.com/vercel/vercel/tree/main/packages/ruby/test/fixtures/06-rails

From there, I did the following:

  1. Updated to version 7.
  2. Added Propshaft.
  3. Added database settings for mysql2.

Final Gemfile:

source "https://rubygems.org"
ruby "~> 2.7.x"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

gem 'rails', '~> 7.0.3.1'
gem "propshaft", "~> 0.6.4"
gem "mysql2", "~> 0.5.4"

Lambda Handler

Create an entry point so that the Rails application can run on Lambda. The process is as follows:

  • Start 'config.ru' as a Rack application.
  • Extract request information from the payload passed by the Vercel runtime.
  • Convert it into a request object.
  • Get the response from the Rack application and return it as the result.
app.rb
require 'rack'
require 'base64'
require 'json'

$entrypoint = 'config.ru'

ENV['RAILS_ENV'] ||= 'production'
ENV['RAILS_LOG_TO_STDOUT'] ||= '1'

def rack_handler(httpMethod, path, body, headers)
  app, _ = Rack::Builder.parse_file($entrypoint)
  server = Rack::MockRequest.new app

  env = headers.transform_keys { |k| k.split('-').join('_').prepend('HTTP_').upcase }
  res = server.request(httpMethod, path, env.merge({ :input => body }))

  {
    :statusCode => res.status,
    :headers => res.original_headers,
    :body => res.body,
  }
end

def main(event:, context:)
  payload = JSON.parse(event['body'])
  path = payload['path']
  headers = payload['headers']
  httpMethod = payload['method']
  encoding = payload['encoding']
  body = payload['body']

  if (not body.nil? and not body.empty?) and (not encoding.nil? and encoding == 'base64')
    body = Base64.decode64(body)
  end

  return rack_handler(httpMethod, path, body, headers)
end

Requests for assets that are not handled by the Rails application can be managed through Vercel's routing settings, so they are not included here.

Rails Initialization Settings

config/application.rb
require_relative 'boot'

# Load only what's necessary
# require "rails/all"
require "action_controller/railtie"
require "action_view/railtie"
require "active_record/railtie"

Bundler.require(*Rails.groups)

module App
  class Application < Rails::Application
    config.load_defaults 7.0
  end
end
config/environments/production.rb
Rails.application.configure do
  config.cache_classes = true
  config.eager_load = true
  config.consider_all_requests_local       = false
  config.action_controller.perform_caching = true
  config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
  config.log_level = :debug
  config.log_tags = [ :request_id ]
  config.i18n.fallbacks = true
  config.active_support.deprecation = :notify
  config.log_formatter = ::Logger::Formatter.new
  if ENV["RAILS_LOG_TO_STDOUT"].present?
    logger           = ActiveSupport::Logger.new(STDOUT)
    logger.formatter = config.log_formatter
    config.logger    = ActiveSupport::TaggedLogging.new(logger)
  end
end

I think these are mostly defaults. Loading action_mailer or active_storage here would cause an application startup error.

Building the mysql2 gem

'An error occurred while installing mysql2 (0.5.4), and Bundler cannot continue.\n' +

Even if you add mysql2 to your Gemfile, it cannot be installed in the Vercel environment. This is because the Lambda environment lacks the libmysql headers and libraries, so you need to prepare them yourself and upload them along with your application.

Someone has published a gem called customink/mysql2-lambda which includes pre-compiled static libraries, and replacing the original with that might solve the problem. However, I decided to build it myself to better understand the platform.

FROM public.ecr.aws/lambda/ruby:2.7

WORKDIR ${LAMBDA_TASK_ROOT}

ENV RAILS_ENV=production

RUN yum -y install gcc make ruby-devel mysql-devel

ENTRYPOINT []

Prepare a Dockerfile like this and cross-compile for an x86 Amazon Linux environment.

#!/usr/bin/env bash

set -e -o pipefail

bundle config set path 'vendor/bundle'
bundle config set without 'development'
bundle config set build.mysql2 "--with-mysql-dir=${PWD}/lib/mysql"

mkdir -p ./lib/mysql/lib
cp -af /usr/lib64/mysql/* ./lib/mysql/lib
cp -af /usr/include/mysql ./lib/mysql/include

bundle install --redownload

Place lib/mysql directly under the directory to be uploaded and put the files to be linked there. Configure it so that these are used as compilation options during bundle install. Also, ensure the Bundler installation path is mounted and written out from the host side.

$ docker build -t railson .
$ docker run -v $PWD:/var/task -it railson build.sh

Confirm that the installed mysql2 gem is linked to the local library:

$ docker run -v $PWD:/var/task -it railson ldd vendor/bundle/ruby/2.7.0/gems/mysql2-0.5.4/lib/mysql2/mysql2.so        
        libruby.so.2.7 => /var/lang/lib/libruby.so.2.7 (0x0000004001c3e000)
        libmysqlclient.so.18 => /var/task/lib/mysql/lib/libmysqlclient.so.18 (0x000000400216c000)
        # ...

DB Connection Settings

As per the documentation, set the CA location for SSL connections to the path within the Lambda environment. Load the DATABASE_URL by storing it in Vercel's Secrets.

config/database.yml
default: &default
  timeout: 5000

development:
  <<: *default
  
test:
  <<: *default

production:
  <<: *default
  # mysql2://myuser:mypassword@myhost.us-east-2.psdb.cloud/mydb?ssl_mode=verify_identity&sslca=/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem
  url: <%= ENV['DATABASE_URL'] %>

Converting to Build Output API (v3) format

Since the current setup alone is insufficient for deploying to Vercel, we need to add the configuration and directory structure required by the Build Output API v3 specifications.

Directory structure

Place the Rails application into a Serverless Function named index.func. This will be executed at the root / when deployed to Vercel.

$ tree .vercel/output -L 3
.vercel/output
├── config.json
└── functions
    ├── index.func
    │   ├── .vc-config.json
    │   ├── Gemfile
    │   ├── Gemfile.lock
    │   ├── Rakefile
    │   ├── app/
    │   ├── app.rb
    │   ├── config/
    │   ├── config.ru
    │   ├── db/
    │   ├── lib/
    │   └── vendor/bundle
    └── index.prerender-config.json

While it's possible to handle everything with a single endpoint, it feels more natural to split them into separate functions since the prerender-config.json settings used for ISR are applied per function.

When splitting, you would create separate functions for each endpoint, such as articles.func and settings.func (similar to standard FaaS architectures). However, it might be a bit tricky since features like Lambda layers are not available.

The following files are related to the Build Output API:

.vercel/output/config.json
{
  "version": 3
}
.vercel/output/functions/index.func/.vc-config.json
{
  "handler": "app.main",
  "runtime": "ruby2.7"
}
.vercel/output/functions/index.prerender-config.json
{
  "expiration": 180,
  "bypassToken": "mybypassToken",
  "allowQuery": []
}

Since there is a 50MB limit on the source code uploaded for the index.func directory, you'll likely need to meticulously exclude files using .vercelignore or eventually implement per-function rebundling.

Implementing Views and DB Loading

I kept it simple as follows:

  • Include the rendering time and an image in the CDN for verifying ISR.
  • Output the content loaded from the DB into the HTML.
routes
Rails.application.routes.draw do
  get '/index' => 'articles#index'
nd
app/controllers/articles_controller.rb
require 'net/https'

class ArticlesController < ApplicationController
  def index
    @articles = Article.all
    
    resp = Net::HTTP.get_response(URI.parse('https://picsum.photos/400'))

    @image_url_full = resp.header['location']
    @now = Time.now
  end
end
app/views/articles/index.html.erb
<h1>ISR Demo</h1>

<div>
  <div>
    <img src="<%= @image_url_full %>" alt="ISR">
  </div>
  <div>
    <p>Rendered: <%= @now %></p>
  </div>
  <div>
  <% @articles.each do |article| %>
    <p>
      <%= article.title %>
    </p>
  <% end %>
  </div>
</div>

Deploying the Preview Version

Verify by uploading the contents of the .vercel/ directory built locally as is. Note that prebuilt cannot be deployed to production.

$ vercel deploy --prebuilt

Deploying the Production Version

Once verified with the preview version, apply it to production. However, if you change it as follows, vercel build will run on Vercel's build server, and the local .vercel/ directory will not be reflected.

$ vercel deploy --prod

In Build your own web framework – Vercel, they discuss dynamically generating the output directory using Node.js during build time. While you could do the same, I was looking for a simpler method and noticed that pushing the output directory directly to the GitHub repository allows it to be reflected in the production version.

In other words, a rough approach would look like this:

$ rsync -av ./.vercel/output/ ../prod-repo/.vercel/output/
# git push in prod-repo

However, this is likely a method intended for CI builds of SSG software and is probably not the standard approach.

Verification

Check the actually deployed site.

https://railson.vercel.app/

  • Since the database is located in us-east, the Function was also set to us-east.
  • Every 180 seconds, results cached at the edge are returned asynchronously.

You can confirm this with x-vercel-cache: HIT.

curl -v https://railson.vercel.app/ -s
# ...
< content-length: 393
< x-vercel-cache: HIT
< age: 149
< server: Vercel

By adding the bypassToken to the header, you can retrieve the latest results.

$ curl -v https://railson.vercel.app/ -s -H "x-prerender-revalidate: $MYBYPASSTOKEN"
# ...
< content-length: 393
< x-vercel-cache: REVALIDATED
< age: 0
< server: Vercel

Discussion