🐈

RailsアプリケーションをVercelにデプロイしてISRする

2022/08/05に公開

Nuxt3でのISR対応について調べる」や「Serverless FunctionsのCustom Runtimeを構築する」を経て、Vercelだいたい分かった状態になったため更に発展させてRailsでISRを動かす実験をしてみた。

条件

  1. VercelのServerless Functionのruby27ランタイム(AWS Lambdaと同等)上で動かす
    a. Custom Runtimeで全部やるのはたいへんそうなので考えない
  2. Build Output API (v3) を使ってOn-Demand Incremental Static Regenerationする
    a. JavaScriptフレームワーク以外でもできるんじゃない? という部分を検証したい
  3. せっかくRailsアプリケーションなのでViewやARも使ってMVCしたい
  4. データベースはPlanetScaleのMySQL互換サーバーを使う
    a. 一応Aurora Serverlessも使えることを確認したけどコネクションプーリングの問題 にプロバイダ側で対応しているらしいので試す

ベースとなるソースコード

実はRubyランタイムの開発コードの中に最小現のRailsスニペットがある。のでこれを改造していく

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

ここから以下をやった

  1. 7系へアップデート
  2. Propshaftを追加
  3. mysql2のデータベース設定を追加

最終的な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用ハンドラー

RailsアプリケーションをLamda上で実行できるようエントリーポイントを作る。以下のような処理になる

  • 'config.ru'をRackアプリケーションとして起動
  • Vercelのランタイムがpayloadにリクエスト情報を詰め込んで呼び出してくるので取り出す
  • リクエストオブジェクトにする
  • Rackアプリケーションからレスポンスを得て結果として返す
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

Railsアプリケーションでハンドリングしないassets系のリクエストはVercel側のroutes設定でハンドリングできるので、ここには含めない。

Railsの初期化設定

config/application.rb
require_relative 'boot'

# 必要なものだけロード
# 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

だいたいデフォルトだと思う。action_mailerとかactive_storageとかをここで読み込んでしまうとアプリケーションが起動エラーになる。

mysql2 gemのビルド

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

Gemfileにmysql2を追加してもVercelの環境ではインストールできない。これはLambdaの環境にlibmysqlのヘッダやライブラリがないからなので、自分で用意していっしょにアップロードする必要がある。

自前でコンパイル済みの静的ライブラリを含む customink/mysql2-lambda というgemを公開している人がいて、これに置き換えることでも解決はできそうだけどプラットフォームを理解するためにも今回は自分でビルドすることにした。

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 []

こういうDockefileを用意してx86なAmazon Linux環境向けにクロスコンパイルしてみる。

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

アップロードするディレクトリ直下にlib/mysqlを配置してそこにリンクするファイルを置いておく。bunlde install時のコンパイルオプションにこれが使われるようにする。bundlerのインストール先もホスト側からマウントして書き出されるようにする。

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

インストールしたmysql2 gemがローカルのライブラリにリンクされているのを確認

$ 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接続設定

ドキュメントどうりだが、SSL接続する時のCAの場所をLambda環境のパスにしておく。DATABASE_URLをVercelのSecretに入れて読み込む。

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'] %>

Build Output API (v3) 形式にする

これだけではVercelにデプロイできないので最終的なBuild Output APIの仕様に沿った構成と設定を追加する。

ディレクトリ構造

index.func というServerless FunctionにRailsアプリケーションを入れる。これがVercelにデプロイした時のroot / で実行される。

$ tree .vercel/outputree .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

1つのエンポイントですべてを行うこともできるんだけど、ISRに使うprerender-config.jsonの設定が関数ごとなので分割していく方が自然だと思う。

分割する時はエンドポイントごとに articles.func settings.func のようにFunctionsを切っていく(ここは一般的なFaaSの時と同じ)。ただLambda layersみたいなものが使えないので微妙かもしれない。

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": []
}

index.funcディレクトリでアップロードするソースコードは50MBの制限があるので、.vercelignoreでちまちま除去したり、そのうちFunctionごとに再バンドルしたりが必要になってきそう。

ViewとDB読み込みの実装

シンプルにこうした

  • ISR確認用に描画時間と画像をCDNに保持する
  • DBから読み込んだ内容をHTMLに出力
routes
Rails.application.routes.draw do
  get '/index' => 'articles#index'
end
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>

プレビュー版のデプロイ

ローカルに構築した.vercel/ディレクトリの内容をそのままアップロードして確認する。prebuiltはprodにはデプロイできない。

$ vercel deploy --prebuilt

prod版のデプロイ

プレビュー版で確認できたらprod版にも反映する。ただ以下のように変えるとvercel buildがVercelのビルドサーバー上で実行されるので.vercel/ディレクトリが反映されない。

$ vercel deploy --prod

Build your own web framework – Vercel ではビルド時にNode.jsでoutputディレクトリを動的に生成するということをやっているので、同じことをすればいいのだけど何か楽な方法がないかと探していたら、GitHubリポジトリにoutputディレクトリをそのままpushすることでprod版に反映できるパスがあることに気付いた。

つまり雑にやるならこんな感じ

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

しかしたぶんSSGソフトウェアのCIビルド向けの方法であって、正攻法ではないと思う。

動作確認

実際にデプロイしたサイトで確認する

https://railson.vercel.app/

  • DBがus-eastに居るのでFunctionもus-eastにした
  • 180秒経過ごとに非同期にエッジでキャッシュされた結果が返される

x-vercel-cache: HIT で確認できる。

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

bypassTokenをヘッダにつけると最新の結果を得られる。

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

Discussion