RailsアプリケーションをVercelにデプロイしてISRする
「Nuxt3でのISR対応について調べる」や「Serverless FunctionsのCustom Runtimeを構築する」を経て、Vercelだいたい分かった状態になったため更に発展させてRailsでISRを動かす実験をしてみた。
条件
- VercelのServerless Functionのruby27ランタイム(AWS Lambdaと同等)上で動かす
a. Custom Runtimeで全部やるのはたいへんそうなので考えない -
Build Output API (v3) を使ってOn-Demand Incremental Static Regenerationする
a. JavaScriptフレームワーク以外でもできるんじゃない? という部分を検証したい - せっかくRailsアプリケーションなのでViewやARも使ってMVCしたい
- データベースはPlanetScaleのMySQL互換サーバーを使う
a. 一応Aurora Serverlessも使えることを確認したけどコネクションプーリングの問題 にプロバイダ側で対応しているらしいので試す
ベースとなるソースコード
実はRubyランタイムの開発コードの中に最小現のRailsスニペットがある。のでこれを改造していく
ここから以下をやった
- 7系へアップデート
- Propshaftを追加
- 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アプリケーションからレスポンスを得て結果として返す
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の初期化設定
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
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に入れて読み込む。
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に関係あるのは以下のファイル
{
"version": 3
}
{
"handler": "app.main",
"runtime": "ruby2.7"
}
{
"expiration": 180,
"bypassToken": "mybypassToken",
"allowQuery": []
}
index.func
ディレクトリでアップロードするソースコードは50MBの制限があるので、.vercelignoreでちまちま除去したり、そのうちFunctionごとに再バンドルしたりが必要になってきそう。
ViewとDB読み込みの実装
シンプルにこうした
- ISR確認用に描画時間と画像をCDNに保持する
- DBから読み込んだ内容をHTMLに出力
Rails.application.routes.draw do
get '/index' => 'articles#index'
end
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
<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ビルド向けの方法であって、正攻法ではないと思う。
動作確認
実際にデプロイしたサイトで確認する
- 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