お久しぶりです、エムスリーエンジニアリンググループ 兼 QLife エンジニアの園田です。
今回は、Rails のアプリを AWS の Lambda で動かして、ALB 経由でアクセスしてみようという内容です。
実現するためには
- ALB から Lambda を呼び出す。
- Lambda で Rails を動かす。
上の2つをクリアするだけでいいので、動かすだけならとても簡単にできてしまいます。
1つめは、ALB の TargetGroup に Lambda 関数が指定できるので、それを使います。
2つめは、Github にある aws-samples/serverless-sinatra-sample
の Lambda 関数ブリッジ用ソース (MITライセンス、以降 aws-samples のサンプルソース
と表記します) を利用にすればほぼ何もいじることなく実現できます。
serverless-sinatra-sample/lambda.rb at master · aws-samples/serverless-sinatra-sample · GitHub
ALB to Lambda を試す
まずは Rails を使わずに単純なエコーサーバー(リクエストの内容をそのまま返す)を実装して試してみます。
新しい Ruby ランタイムの Lambda 関数を作成し、おもむろに以下のコードを貼り付けます。
require 'json' def lambda_handler(event:, context:) { isBase64Encoded: false, statusCode: 200, headers: { 'Content-Type' => 'application/json; charset=utf-8', }, body: JSON.pretty_generate(event), } end
次に、ALB を作成します。
ALB をプロビジョニングする VPC やサブネットの作成は割愛します。ここではすでにあるものとします。 ターゲットグループを新規作成し、作成した Lambda 関数を選びます。ヘルスチェックはとりあえず無効で。
続いて、ALB 本体を作成します。マネジメントコンソールだとHTTP リスナーは追加済みだと思うので、適当に HTTP アクセス可能なセキュリティグループを作成して、 ターゲットの選択で、作成した Lambda 関数用のターゲットグループを選択して作成します。
しばらく待って、ALB のステータスが active
になったら ALB の DNS 名をブラウザのアドレスバーにコピペします。
すると、以下のような JSON が返ってきます。Lambda 関数のログを見てみると、実行されていることがわかります。簡単ですね!
{ "requestContext": { "elb": { "targetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:999999999999:targetgroup/alb-lambda/acd1234567890abc" } }, "httpMethod": "GET", "path": "/", "queryStringParameters": { }, "headers": { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3", "accept-encoding": "gzip, deflate", "accept-language": "ja,en-US;q=0.9,en;q=0.8", "cache-control": "no-cache", "connection": "keep-alive", "host": "alb-lambda-XXXXXXXXX.ap-northeast-1.elb.amazonaws.com", "pragma": "no-cache", "upgrade-insecure-requests": "1", "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36", "x-amzn-trace-id": "Root=1-5d0c................", "x-forwarded-for": "X.X.X.X", "x-forwarded-port": "80", "x-forwarded-proto": "http" }, "body": "", "isBase64Encoded": false }
ALB to Lambda はアクセス制御に LambdaPermission を利用するので、セキュリティグループや IP 制限などのアクセス制御を考慮しないでいいのが楽ちん。
Rails の Lambda 関数パッケージ作成
では、今度は Rails アプリを Lambda 関数化して動かしてみます。Ruby は 2.5 を使用してください。
純粋な Rails アプリの作成
Lambda とか意識せずに普通に Rails アプリを作成します。
# DB やメールは使わない前提で、適宜オプション指定します。 # Rack サーバーは不要なので --skip-puma を指定しています。 bundle exec rails new rails-on-lambda --skip-bundle --skip-puma \ --skip-action-{mailer,mailbox,cable} --skip-active-{record,storage} \ --skip-{system-,}test
適当にトップページを表示できるように Controller を実装してください。
- app/controllers/top_controller.rb
class TopController < ApplicationController end
- config/routes.rb
Rails.application.routes.draw do root to: 'top#index' end
- app/views/top/index.html.erb
<p>Hello, Rails on Lambda!!</p>
ここでは、とりあえず画像などのバイナリファイルは利用しないですすめます。画像などを含める場合の注意点等は後述します。
Lambda 用の Rack ブリッジサンプルをダウンロード
Rails アプリを作成したら、冒頭で紹介した aws-samples
の サンプルソースをそのままファイルとして保存しちゃいます。ここでは lambda.rb
とでもしておきます。このファイルは config.ru
と同じディレクトリに格納してください。
curl -sSL \ https://raw.githubusercontent.com/aws-samples/serverless-sinatra-sample/master/lambda.rb \ > lambda.rb
保存したファイルの中身のパスを1箇所だけ修正します。これだけで Lambda 上で Rails が動いちゃいます。
- $app ||= Rack::Builder.parse_file("#{__dir__}/app/config.ru").first + $app ||= Rack::Builder.parse_file("#{__dir__}/config.ru").first
コードを見ればすぐにわかりますが、Rack のインタフェースをエミュレートして Lambda へのリクエストイベントをあたかも HTTP リクエストが来たように振る舞わせるためのラッパースクリプトです。
Assets の precompile
sprockets や webpacker を利用する場合は assets を precompile します。
export RAILS_ENV=production bundle exec rails webpacker:install bundle exec rails assets:precompile
作業端末上での動作確認。 production
モードなので RAILS_SERVE_STATIC_FILES
をつける。
RAILS_SERVE_STATIC_FILES=true bundle exec rails s -b 0.0.0.0 -e production
Native Extensions のリビルド
native extension を含む gem を lambci/lambda:build-ruby2.5
の docker イメージでビルドしなおします。
そうしないと Lambda 関数としてアップロードしたときに native extension が認識されないため、以下のようなエラーが出ます。
Ignoring nokogiri-1.10.3 because its extensions are not built.
bundle install を普通にやると (特にDocker for Mac を使っている場合) native extension のビルドで非常に時間がかかるため、 いったん BUNDLE_PATH に Docker ボリューム外のパスを指定してビルドし、ボリューム上にコピーします。
rm -rf vendor/bundle docker run --rm \ -v $(pwd):/app -w /app 'lambci/lambda:build-ruby2.5' \ bash -c 'bundle install --path /bundle --without development test; mv /bundle /app/vendor/' # .bundle/config 書き換え sed -e 's|/bundle|vendor/bundle|' -i .bundle/config
ここまで来たら docker コンテナ上で動作確認します。
docker run --rm -v $(pwd):/app:cached -w /app \ -e RAILS_ENV=production \ -e RAILS_SERVE_STATIC_FILES=true \ -e RAILS_LOG_TO_STDOUT=true \ -p 3000:3000 \ 'lambci/lambda:build-ruby2.5' bundle exec rails s -b 0.0.0.0
Permission denied @ rb_sysopen - /app/config/master.key
というエラーが出たら、RAILS_MASTER_KEY 環境変数を指定するか、chmod 644 config/master.key してください。
良さそうだったら zip ファイルに固めます。 .bundle
, app
, config
, Gemfile*
, config.ru
, lambda.rb
は必ず含めます。
それ以外に DB を利用する場合は db
、assets が必要なら public
と必要に応じて含めてください。
zip -r function.zip .bundle app config Gemfile* config.ru lambda.rb vendor lib public
これで Lambd 関数のパッケージ作成は完了です。
Lambda 関数の作成
作成したパッケージの zip ファイルを先程適当に作ったエコーサーバの関数にアップロードします。 新しく関数を作成しても構いません。その場合は TargetGroup の向け先を変更してください。
Lambda 実行に必要な環境変数の設定
いくつか環境変数を設定しておく必要があります。
RAILS_ENV, RAILS_MASTER_KEY
RAILS_ENV=production
RAILS_ENV には production
を指定します。development
指定でも動きますが、パッケージサイズが大きくなるためオススメしません。
また、production
モードなので必要に応じて RAILS_MASTER_KEY
を指定します。今回は面倒なので config/master.key
をパッケージに入れました。
RAILS_SERVE_STATIC_FILES
RAILS_SERVE_STATIC_FILES=true
前述しましたが、 production モードの場合、デフォルトだと assets ファイルは nginx などの web サーバから配信する設定になっています。
RAILS_SERVE_STATIC_FILES
に値を設定して、直接配信できるようにしておきます。
BOOTSNAP_CACHE_DIR
BOOTSNAP_CACHE_DIR=/tmp/cache
Rails 5.2 から標準搭載された bootsnap は ${WORKING_DIR}/tmp/cache
にキャッシュを生成します。
ところが、Lambda 上のワーキングディレクトリである /var/task
は読み取り専用ファイルシステムのため、 bootsnap の起動でエラーになります。
Read-only file system @ dir_s_mkdir - /var/task/tmp
Lambda で書き込み可能なディレクトリは /tmp
配下だけなので、BOOTSNAP_CACHE_DIR
に /tmp/cache
を指定します。
実運用する場合は、disk cache や storage なども環境変数などでパスを指定できるように
実装する必要があります。Lambda で運用するなら使わないと思いますが。
RAILS_LOG_TO_STDOUT
RAILS_LOG_TO_STDOUT=true
Rails はデフォルトだとファイルにログを書き込みますが、前述の通りディレクトリには書き込みできないので、RAILS_LOG_TO_STDOUT
に値を設定して標準出力にログを出すようにします。こうすることでログがすべて CloudWatch Logs に書き込まれるようになります。
ALB 経由での確認
この状態で ALB の DNS 名にブラウザアクセスすると、初回アクセスに時間がかかりますが、無事にページが表示されます。
無事に ALB 経由で Lambda 関数の Rails を実行することができました!!
ただし、このままだと使い物にならない
ここまでの対応だけだと、API サーバーとしては使えますが、Web サイトとしては使い物になりません。 具体的には、以下の問題が発生します。
- Cookie が最後の値しか取れない。
- バイナリデータが 502 エラーになる。
Cookie が最後の値しか取れない
ALB のドキュメントにも書いてあるとおりですが、1つのキーに複数の値を含めるようなヘッダーを利用する場合、そのままだと最後の値のみ TargetGroup に送信され、それ以外は破棄されます。
ここでいう1つのキーに複数の値、というのは X-Forwarded-For のようにカンマ区切りのヘッダ値は含まれません。実際の HTTP 電文で改行区切りで複数回出現するヘッダーを指します。
そのため、TargetGroup の設定で「複数値のヘッダー」を有効にします。マネジメントコンソールで前段で作成したターゲットグループを開き、「複数値のヘッダー」を有効にしてください。
リクエストの parse 処理修正
ただ、この設定を有効化すると ALB から Lambda に送信されるイベントのスキーマがだいぶ変わってしまうため、aws-samples
のソースのままだと全く動きません。
具体的には、 headers
というフィールドは消え、代わりに multiValueHeaders
というフィールドになり、中身も1階層の Hash[String]
構造だったものから Hash[Array[String]]
構造に変わります。同様に、queryStringParameters
フィールドも multiValueQueryStringParameters
という Hash[Array[String]]
になります。
こちらは対応自体は簡単です。ダウンロードした aws-samples のサンプルソースを以下のように修正しました。
@@ -30,7 +30,7 @@ event['body'] end # Rack expects the querystring in plain text, not a hash - querystring = Rack::Utils.build_query(event['queryStringParameters']) if event['queryStringParameters'] + querystring = Rack::Utils.build_query(event['multiValueQueryStringParameters']) if event['multiValueQueryStringParameters'] # Environment required by Rack (http://www.rubydoc.info/github/rack/rack/file/SPEC) env = { 'REQUEST_METHOD' => event['httpMethod'], @@ -39,7 +39,7 @@ 'QUERY_STRING' => querystring || '', 'SERVER_NAME' => 'localhost', 'SERVER_PORT' => 443, - 'CONTENT_TYPE' => event['headers']['content-type'], + 'CONTENT_TYPE' => event.dig('multiValueHeaders', 'content-type')&.first, 'rack.version' => Rack::VERSION, 'rack.url_scheme' => 'https', @@ -47,8 +47,8 @@ 'rack.errors' => $stderr, } # Pass request headers to Rack if they are available - unless event['headers'].nil? - event['headers'].each { |key, value| env["HTTP_#{key}"] = value } + unless event['multiValueHeaders'].nil? + event['multiValueHeaders'].each { |key, values| env["HTTP_#{key}"] = values.join("\n") } end
Rails (Rack?)のバージョンによっては以下の対応(ヘッダ名の変換)をしないと正しく動かないことがあるようです。
# `HTTP_user-agent` などを `HTTP_USER_AGENT` に変換 event['multiValueHeaders'].each { |key, values| env["HTTP_#{key.gsub('-', '_').upcase}"] = values.join("\n") }
レスポンスの Header を加工
さらに、複数値ヘッダーを有効にすると ALB 側でも headers
を解釈せずに multiValueHeaders
を解釈するようになるため、レスポンスのフォーマットも変更する必要があります。
@@ -61,24 +76,29 @@ body_content += item.to_s end + multi_value_headers = headers&.map do |name, value| + [ name, value.split("\n") ] + end&.to_h + # We return the structure required by AWS API Gateway since we integrate with it # https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html response = { 'statusCode' => status, - 'headers' => headers, + 'multiValueHeaders' => multi_value_headers, 'body' => body_content }
動作確認
TopController を以下の様に修正して動作確認したところ、Cookie がすべて設定されていることが確認できました。
- app/controllers/top_controller.rb
class TopController < ApplicationController def index cookies[:foo] = { value: 'foo', path: '/', httponly: true } cookies[:bar] = request.env['QUERY_STRING'].presence || 'bar' end end
Rails の場合、複数の同名クエリパラメータは結局最後のだけになってしまい、
mutilValueQueryParameters
になっていてもいなくても動作に関係ないため動作確認がしづらい。
バイナリデータが 502 エラーになる
これも公式ドキュメントに書いてある通りなんですが、バイナリデータを返すときは body
を Base64 でエンコードして、レスポンスの isBase64Encoded
を true
にする必要があります。aws-samples のサンプルソースでは elb
からのリクエストだった場合に問答無用で isBase64Encoded
を false
にしているので、ALB がバイナリデータのデコードをできずに 502 Bad Gateway レスポンスを返します。
Rails 実行後の Response Header に含まれる Content-Type
からバイナリかどうかを判定してエンコードおよびフラグを立てる処理を追加しました。
lambda.rb
の handler
の前に以下の関数を定義しておきます。
def binary_response?(headers) return true unless headers['Content-Type'] # gz ファイルならバイナリ return true if headers['Content-Encoding'] === 'gzip' # "text/html; charset=utf-8" の ["text", "html"] の部分を取得 mime_type = headers['Content-Type'].split(';', 2).first.split('/', 2) major = mime_type.first minor = mime_type.last # text ならバイナリじゃないよね return false if major === 'text' # image や video ならバイナリかね return true if major === 'image' || major === 'video' # とりあえず思いつくだけ !minor.match(/json|css|csv|xml|xhtml/) end def handler(event:, context:) // ... 略 end
上の関数を以下のように使用します。
@@ -61,6 +74,11 @@ body_content += item.to_s end + is_binary_response = binary_response?(headers) + if is_binary_response + body_content = Base64.strict_encode64(body_content) + end + # We return the structure required by AWS API Gateway since we integrate with it # https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html response = { @@ -70,7 +88,7 @@ } if event['requestContext'].key?('elb') # Required if we use Application Load Balancer instead of API Gateway - response['isBase64Encoded'] = false + response['isBase64Encoded'] = is_binary_response end rescue Exception => msg
なお、
Base64#encode64
ではなくBase64#strict_encode64
を使わないと ALB が改行を考慮してくれないのでエラーになりました。
ちなみに面倒だから全部 isBase64Encoded: true
にしちゃえー、ってやってもちゃんと動きました・・・。
ここまでで、シンプルな Web サイトくらいであれば(パフォーマンスや運用負荷などもろもろに目をつぶれば)Lambda で運用できることがわかりました!!
ネイティブライブラリを使う場合
Build, Package, and Deploy an AWS Lambda using the Ruby Runtime | Blackninja Dojo
ActiveRecord のように mysql などのネイティブライブラリを利用する場合は lib ディレクトリに *.so
ファイルを格納することで LD_LIBRARY_PATH に自動的に追加されるようです。
例えば、mysql5.7 を利用する場合は以下のようにします。
docker run --rm \ -v $(pwd):/app -w /app 'lambci/lambda:build-ruby2.5' \ bash -c 'yum install -y mysql57-devel; bundle install --path /bundle --without development test; mv /bundle /app/vendor/; cp $(/sbin/ldconfig -p | grep mysql57 | cut -d\> -f2) lib/'
試しにやってみたところ、ちゃんと動きました。
Rake タスク実行
DB のマイグレーションなどタスクの実行が必要な場合は、 lambda.rb
の前段にもう1つ handler を置いてラップすればいけます。
例えば lambda.rb
を以下のように実装し、タスクを実行するときだけ Lambda 関数を直接 InvokeFunction します。
# もとの handler 関数をリネーム def request_handler(event:, context:) # ... end # event に 'task' が含まれる場合は Rake::Task 実行 def handler(event:, context:) if event['task'] Rails.application.load_tasks Rake::Task[event['task']].invoke else request_handler(event: event, context: context) end end
試しに、 about
タスクを実行してみたところ、ちゃんと動きました。
aws lambda invoke --function-name alb-lambda --payload '{"task":"about"}'
routes
タスクを実行したところ、エラーになりました。どうやらタスク内部で exit
を呼んでいるとエラーになるようでした。
Function<SystemExit> /var/task/vendor/bundle/ruby/2.5.0/gems/railties-5.2.3/lib/rails/tasks/routes.rake:30:in `exit' /var/task/vendor/bundle/ruby/2.5.0/gems/railties-5.2.3/lib/rails/tasks/routes.rake:30:in `block in <top (required)>' /var/task/vendor/bundle/ruby/2.5.0/gems/rake-12.3.2/lib/rake/task.rb:273:in `block in execute' /var/task/vendor/bundle/ruby/2.5.0/gems/rake-12.3.2/lib/rake/task.rb:273:in `each' /var/task/vendor/bundle/ruby/2.5.0/gems/rake-12.3.2/lib/rake/task.rb:273:in `execute' /var/task/vendor/bundle/ruby/2.5.0/gems/rake-12.3.2/lib/rake/task.rb:214:in `block in invoke_with_call_chain' /var/lang/lib/ruby/2.5.0/monitor.rb:226:in `mon_synchronize' /var/task/vendor/bundle/ruby/2.5.0/gems/rake-12.3.2/lib/rake/task.rb:194:in `invoke_with_call_chain' /var/task/vendor/bundle/ruby/2.5.0/gems/rake-12.3.2/lib/rake/task.rb:183:in `invoke' /var/task/lambda.rb:88:in `handler'
その他試していないこと
- force_ssl の挙動。
- Secure Cookie の挙動。
- etc ...
最後に
今回はお試しで ALB to Rails on Lambda を実装してみました。実運用するためには関数のバージョニングやデプロイ自動化など課題は山ほどありそうですが、期間限定やワンショットのサイトを Rails のエコシステムに乗ってサーバーレスで稼働できるので、もしかしたら使い所はあるかもしれません。
We are hiring
QLife では共にチーム一丸となってより良いものづくりにこだわれる仲間を募集中です! 小さいサービスが多いので新しい AWS のサービスの利用にも非常に積極的に取り組んでいます! カジュアル面談も行っていますので、興味がある方は entry@qlife.co.jp に「カジュアル面談希望」とメールをください!
エムスリーでもエンジニアを随時募集しています!共に医療 × テクノロジーの未来を切り拓いてくれる仲間を募集中です! AWS 以外にも GCP や Firebase などのクラウドも活用しています!興味がある方はカジュアル面談やTechtalkにおこしください!!