AWS Lambda でコンテナに入れた Sinatra を動かす

何番煎じか分からないけど、最近やったので。

前提知識

つまりコンテナ化した Sinatra アプリを Lambda 上にデプロイして HTTP リクエストを受け付けることができる。

動かす準備はもう全部整っていて、お手軽そうですね。

Ruby アプリを Lambda で動かすコンテナイメージを作る

Sinatra 以前に、そもそも Ruby はどうやって Lambda Container Image 上で動くのか。公式にチュートリアルがあるのでこの通りで良い。

Deploy Ruby Lambda functions with container images - AWS Lambda

https://gallery.ecr.aws/lambda/ruby の Usage をなぞる。

  1. https://gallery.ecr.aws/lambda/ruby から base image を選んで、Dockerfile を作って
  2. docker build して
  3. docker run で Image を立ち上げると HTTP で待ち受けるので
  4. curl で発火させる
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"payload":"hello world!"}'

謎 URL だけど「こういうもの」として覚えておけば良い。

特筆すべき点

ENV["GEM_PATH"]
#=> "/var/task/vendor/bundle/ruby/2.7.0:/opt/ruby/gems/2.7.0"

なので、ここに gem を入れておくと bundle exec しなくても gem を使える。いやまぁ bundler 使えば良いと思いますが。。

Rack アプリを Lambda で動かす

前述した公式の https://github.com/aws-samples/serverless-sinatra-sample の他に、 https://github.com/logandk/serverless-rack というものもある。

仕組み

どちらも肝は Rack::Builder.parse_file です。 config.ru を eval することで実行したい Rack App を取り出す処理。

App を取得できたので、env を組み立てて

app.call(env) して

Rack の status, headers, body の組から、Lambda の response になるように JSON を組み立て直す

Lambda の response というのはコレ。https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-output-format

{
    "isBase64Encoded": true|false,
    "statusCode": httpStatusCode,
    "headers": { "headerName": "headerValue", ... },
    "multiValueHeaders": { "headerName": ["headerValue", "headerValue2", ...], ... },
    "body": "..."
}

実装

これを自分の Rack アプリにどう組み込むかというと

app/config.ru と lambda.rb を置いて Dockerfile の CMD では lambda.handler を指定すると良い。

PROJECT_ROOT
├── app/
│  ├── config.ru
│  ├── Gemfile
│  └── Gemfile.lock
├── Dockerfile
└── lambda.rb

lambda.rb は https://github.com/aws-samples/serverless-sinatra-sample/ からコピーします。*1

他のファイルはこんな感じ。

# app/Gemfile
source "https://rubygems.org"
gem "rack"
# app/config.ru
run ->(env) { ["200", { "Content-Type" => "text/plain" }, ["OK"]] }
FROM public.ecr.aws/lambda/ruby:2.7

COPY lambda.rb ${LAMBDA_TASK_ROOT}

# rack を ${LAMBDA_TASK_ROOT}/vendor/bundle に入れたい
WORKDIR ${LAMBDA_TASK_ROOT}/app
COPY app/Gemfile app/Gemfile.lock .
RUN bundle config set path ${LAMBDA_TASK_ROOT}/vendor/bundle
RUN bundle install

COPY app/config.ru .

WORKDIR /var/task
CMD [ "lambda.handler" ]

で、実行するときは

curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"httpMethod":"GET","requestContext":{}}'

すると、Lambda の JSON response が取得できる。

{"statusCode":"200","headers":{"Content-Type":"text/plain"},"body":"OK"}

渡す JSON のパラメータは https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format を参照。

{
    "resource": "Resource path",
    "path": "Path parameter",
    "httpMethod": "Incoming request's method name"
    "headers": {String containing incoming request headers}
    "multiValueHeaders": {List of strings containing incoming request headers}
    "queryStringParameters": {query string parameters }
    "multiValueQueryStringParameters": {List of query string parameters}
    "pathParameters":  {path parameters}
    "stageVariables": {Applicable stage variables}
    "requestContext": {Request context, including authorizer-returned key-value pairs}
    "body": "A JSON string of the request payload."
    "isBase64Encoded": "A boolean flag to indicate if the applicable request payload is Base64-encoded"
}

rackup した HTTP Server として Docker Image を実行したい

Lambda の Docker Image として実行するときは Lambda function for proxy integration の形式で JSON をやりとりしないといけない、というのは今まで書いてきた通り。

開発中は bundle exec rackup しておけばいいんだけど、正しく焼けてるか不安なときがあるので、Docker Image 上の Rack アプリをブラウザから実行したい。

つまり

  • HTTP サーバが立って
  • リクエストをいいかんじに JSON に変換して Lambda に投げて
  • Lambda からのレスポンスをいいかんじに HTTP に変換して返す

してくれる proxy (つまり API Gateway や ALB 相当のもの) があると便利だよね。というわけで雑にこんなのを用意しました。

コンテナを立ててた上でこの proxy を立てておくと、http://localhost/ でコンテナの中の Lambda の中の Rack アプリとやりとりできる。

絶対どこかにもっと良いやつあると思うので全然作り込んでない。(例えば multiValueHeaders や isBase64Encoded は対応していない)

#!/usr/bin/env ruby
require "json"
require "net/http"
require "webrick"

LAMBDA_ENDPOINT = "http://localhost:9000/2015-03-31/functions/function/invocations"

s = WEBrick::HTTPServer.new
s.mount_proc("/") do |req, res|
  uri = URI(LAMBDA_ENDPOINT)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme === "https"

  headers = {}
  req.each do |k, v|
    headers[k] = v
  end
  json_data = {
    httpMethod: req.request_method,
    path: req.path,
    queryStringParameters: req.query,
    body: req.body,
    headers: headers,
    requestContext: {},
  }
  lambda_res = http.post(
    uri.path,
    JSON.generate(json_data),
    { "Content-Type" => "application/json" },
  )

  lambda_inner_res = JSON.parse(lambda_res.body)

  res.status = lambda_inner_res["statusCode"]
  lambda_inner_res["headers"].each do |k, v|
    res[k] = v
  end
  res.body = lambda_inner_res["body"]
end
Signal.trap("INT") { s.shutdown }
s.start

まとめ

  • Lambda でコンテナに入れた Sinatra アプリを動かすことができる
  • HTTP Request ã‚’ Rack に変換して実行する仕組みを説明した
  • Lambda function for proxy integration の形式で JSON をやりとりすることになるので、proxy 立てると便利

*1:実際はディレクトリ構造変えたり apigatewayv2 対応したりでもはや別物になっているけど説明面倒なので省略