エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

ALB 経由で Lambda の Rails を実行してみた

お久しぶりです、エムスリーエンジニアリンググループ 兼 QLife エンジニアの園田です。

今回は、Rails のアプリを AWS の Lambda で動かして、ALB 経由でアクセスしてみようという内容です。

f:id:ryoheisonoda:20190624121807p:plain

実現するためには

  • 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 関数を選びます。ヘルスチェックはとりあえず無効で。

f:id:ryoheisonoda:20190621122419p:plain

続いて、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 でエンコードして、レスポンスの isBase64Encodedtrue にする必要があります。aws-samples のサンプルソースでは elb からのリクエストだった場合に問答無用で isBase64Encodedfalse にしているので、ALB がバイナリデータのデコードをできずに 502 Bad Gateway レスポンスを返します。

Rails 実行後の Response Header に含まれる Content-Type からバイナリかどうかを判定してエンコードおよびフラグを立てる処理を追加しました。
lambda.rbhandler の前に以下の関数を定義しておきます。

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 に「カジュアル面談希望」とメールをください!

www.qlife.co.jp

エムスリーでもエンジニアを随時募集しています!共に医療 × テクノロジーの未来を切り拓いてくれる仲間を募集中です! AWS 以外にも GCP や Firebase などのクラウドも活用しています!興味がある方はカジュアル面談やTechtalkにおこしください!!

jobs.m3.com