LCL Engineers' Blog

バス比較なび・格安移動・バスとりっぷを運営する LCLの開発者ブログ

Ruby on Rails on Container on Lambda with Step Functions Express Workflow

はじめに

この記事はLCL Advent Calendar 2021 - 3日目です。

qiita.com

バックエンドエンジニアの星野です。ブログの締め切りが最高に盛り上がっています。🤘*1

昨日のCodeBuildのカスタムイメージに引き続きLCLのバッチジョブ実行基盤の実装パターン解説になります。

今日はContainer on Lambdaです。 この記事中ではContainer on Lambdaと言う場合AWS Lambdaのコンテナイメージサポートのことを指します。

aws.amazon.com

この機能によってDockerイメージをそのままLambdaで実行することができます。 Ruby on Railsを動かす場合の工夫やStep Functions Express Workflowを組み合わせる事による運用支援について解説していきます。

コンテナをLambdaで動かすメリット

AWS LambdaはRubyの実行を公式にサポートしているためわざわざコンテナで動かすのは手間ではないかと考えるかもしれません。 LCLの場合は多くのワークロードをECSで実行しており扱い慣れているDockerイメージをそのまま使い回せることが大きなメリットになります。 Lambdaで動かすためのRubyコードを別リポジトリで用意する必要もないのでコードが二重に管理されていてメンテナンスコストが上がってしまうこともありません。

また、サーバーレスなコンテナ実行環境であるFargateやCodeBuildでは実行毎にDockerイメージをpullしてくるため起動の遅さがネックになります。 Container on Lambdaは内部的にキャッシュの仕組みがあるらしくコールドスタートした場合でも1~2秒で実行が始まります。 コールドスタートしない場合はミリ秒単位で開始するの極めて高速です。 この特性によってFargateやCodeBuildでは起動時間のバラツキにより要件を満たすのが難しかった3分に1回実行のような高頻度で実行するバッチジョブに対応可能です。*2

Ruby on RailsのDockerイメージをLambdaに対応させる

Container on Lambdaは同じコンテナでも実行モデルが異なるため、追加の設定を少しだけ加えます。 Rubyを動かす場合はaws_lambda_ric gemをインストールする必要があり、RailsではGemfileに1行追加するだけでOKです。

github.com

docker runで動かすためのENTRYPOINTやCMDはリポジトリのサンプルコードのようにRuntime Interface Clientに合わせる必要があります。

module App
  class Handler
    def self.process(event:, context:)
      "Hello World!"
    end
  end
end
ENTRYPOINT ["/usr/local/bin/aws_lambda_ric"]
CMD ["app.App::Handler.process"]

このままでは実行したいrails runnerやrakeタスクや複数ある場合にapp.rbファイルを量産しなければなりません。 これを回避するためにApp::Handler.processを次のように変更しています。

require_relative '../../config/environment'

module LambdaFunction
  class RailsRunnerHandler
    def self.process(event:, context:)
      task = event['task']
      eval("#{task}")
    end
  end
end

ターゲットはRailsアプリなのでprocessメソッドを実行前にrequire_relative '../../config/environment'することでrailsの環境を初期化します。 次にLambda関数実行時に渡したpayloadはeventのハッシュで取得できるので今回はtaskをキーにして実行するコマンドの文字列を渡してevalで実行しています。 module名とclass名は分かりやすくするために変更して、${RAILS_ROOT}/lambda/rails_runner_handler/app.rbに設置しています。

開発者はこのイメージをデプロイしたLambda関数に対して任意のコマンドを実行できます。

% aws lambda invoke \
--function-name rails-runner-function \
--payload '{"task": "User.count"}' \
--cli-binary-format raw-in-base64-out \
/dev/null

rakeタスクの場合はRails.application.load_tasksを呼んでからevalの代わりにinvokeします。 Container on Lambdaは一度関数が起動したら環境が終了するまでaws_lambda_ricのプロセスが動き続けるため、invoke後にreenableしないと同じコマンドを再実行できません。 この挙動はLambdaの実行とrakeタスクの挙動の両方を注意深く観察する必要がありややこしいところですが、とりあえずreenableするのを忘れなければOKです。

require 'rake'

require_relative '../../config/environment'

Rails.application.load_tasks

module LambdaFunction
  class RakeTaskHandler
    def self.process(event:, context:)
      task = event['task']
      Rake::Task["#{task}"].invoke
      Rake::Task["#{task}"].reenable
    end
  end
end

rails runnerと同様にtaskにrakeタスクのコマンドの文字列を渡して実行します。

% aws lambda invoke \
--function-name rake-task-function \
--payload '{"task": "user::count"}' \
--cli-binary-format raw-in-base64-out \
/dev/null

LambdaでSSMパラメータを取得する

ECSやCodeBuildではデータベースのパスワードなど秘匿情報をSSMパラメータから環境変数として読み込むことができますが、Lambdaにはその仕組みが備わっていないため自前で実装する必要があります。 前述のapp.rbの中で必要なSSMパラメータを列挙して読み込む実装では変更があった場合に修正漏れでバグを生みやすいです。

SSMパラメータに命名規則を持ち込むことでこの問題に対応します。

require 'aws-sdk-ssm'

# SSMパラメータの取得
Aws::SSM::Client.new.get_parameters_by_path(path: "/awesome-service/app/rails/", recursive: true, with_decryption: true).parameters.each do |param|
  ENV[param['name'].split('/').last] ||= param['value']
end

get_parameters_by_pathメソッドはrecursive: trueをつけることで指定した文字列に沿ってrecursiveにパラメータを取得することができます。 この時パラメータ名の末尾を/awesome-service/app/rails/DATABASE_PASSWORD/awesome-service/app/rails/SECRET_KEY_BASEなどあらかじめRailsで利用する環境変数名にしておくことで動的に環境変数のKey/Valueのペアを生成することができます。

この実装をするにあたりこちらのBlog記事を参考にしました。

www.honeybadger.io

最終的にapp.rbはこのようになります。

require 'aws-sdk-ssm'

# SSMパラメータの取得
Aws::SSM::Client.new.get_parameters_by_path(path: "/awesome-service/app/rails/", recursive: true, with_decryption: true).parameters.each do |param|
  ENV[param['name'].split('/').last] ||= param['value']
end

require_relative '../../config/environment'

module LambdaFunction
  class RailsRunnerHandler
    def self.process(event:, context:)
      task = event['task']
      eval("#{task}")
    end
  end
end

Step Functions Express WorkflowとLambdaを組み合わてエラーハンドリングを実装する

Lambdaには実行エラーを通知するための仕組みが複数用意されています。 今回は成功時と失敗時にそれぞれEventBridgeに通知して通知先については任意に選択したかったのでStep Functionsを採用しました。

LambdaからEventBridgeに通知するだけであればLambdaの送信先(Destination)を使うことでも実装できますが正常終了時のレスポンスをEventBridgeのスキーマに合わせる必要があり、わずかながらコードの修正が必要だったので今回は使いませんでした。

Step Functionsの標準Workflowはデフォルトでステートの変更毎にEventBridgeに通知してくれるのですが、実行頻度の多いContainer on Lambdaのバッチジョブと組み合わせると費用が高く着いてしまうのでExpress Workflowを使います。*3Express Worflowは実行毎の費用が安い代わりにステート変更の通知が省かれているのでWorkflowの中で通知します。

f:id:hosht:20211202155959p:plain
Workflow Studioによる図

{
  "Comment": "Lambda with EventBridge",
  "StartAt": "Lambda Invoke",
  "States": {
    "Lambda Invoke": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "Payload.$": "$",
        "FunctionName": "${function_name}"
      },
      "Retry": [
        {
          "ErrorEquals": [
            "States.ALL"
          ],
          "IntervalSeconds": 1,
          "MaxAttempts": 2
        }
      ],
      "Catch": [
        {
          "ErrorEquals": [
            "States.ALL"
          ],
          "ResultPath": "$.error",
          "Next": "EventBridge PutEvents Error"
        }
      ],
      "Next": "EventBridge PutEvents Success"
    },
    "EventBridge PutEvents Success": {
      "Type": "Task",
      "Resource": "arn:aws:states:::events:putEvents",
      "Parameters": {
        "Entries": [
          {
            "Detail": {
              "Payload.$": "$"
            },
            "DetailType": "Lambda execution succeed",
            "EventBusName": "custom-eventbus",
            "Source": "lambda"
          }
        ]
      },
      "Next": "Success"
    },
    "Success": {
      "Type": "Succeed"
    },
    "EventBridge PutEvents Error": {
      "Type": "Task",
      "Resource": "arn:aws:states:::events:putEvents",
      "Parameters": {
        "Entries": [
          {
            "Detail": {
              "Payload.$": "$",
              "Error.$": "$.error"
            },
            "DetailType": "Lambda execution failed",
            "EventBusName": "custom-eventbus",
            "Source": "lambda"
          }
        ]
      },
      "Next": "Fail"
    },
    "Fail": {
      "Type": "Fail"
    }
  }
}

Lambda実行の成功・失敗時にそれぞれEventBridgeへ通知するWorkflowはこのようになります。 後ほど通知に使いたいエラーメッセージをDetailに含めています。

TerraformでContaienr on LambdaとStep Functionsを実装する

最後にLCLはTerraformによるコード化を推進しているためこれまで説明してきたリソースを作成するサンプルコードを掲載します。

resource "aws_lambda_function" "rails_runner" {
  function_name = "lambda-rails-runner"
  role          = aws_iam_role.lambda.arn
  package_type  = "Image"
  image_uri     = "awesome-rails-image:prod"
  memory_size   = 1024
  timeout       = 900

  vpc_config {
    security_group_ids = ["security_group_id"]
    subnet_ids         = ["subnet_id"]
  }

  image_config {
    command           = ["app.LambdaFunction::RailsRunnerHandler.process"]
    entry_point       = ["aws_lambda_ric"]
    working_directory = "/app/lambda/rails_runner_handler"
  }

  environment {
    variables = {
      RAILS_ENV     = "production"
    }
  }

  lifecycle {
    ignore_changes = [image_uri]
  }
}

resource "aws_sfn_state_machine" "rails_runner" {
  name     = "lambda-rails-runner"
  type     = "EXPRESS"
  role_arn = aws_iam_role.step_functions.arn

  definition = templatefile("step_functions_workflow_lambda.json.tpl", {
    function_name  = "${aws_lambda_function.rails_runner.arn}:${aws_lambda_function.rails_runner.version}"
  })

  logging_configuration {
    include_execution_data = true
    level                  = "ERROR"
    log_destination        = "${aws_cloudwatch_log_group.step_functions.arn}:*"
  }
}

IAMやセキュリティグループなど本題と関係のない一部リソースは省略しています。

コンテナで動かす場合のポイントはimage_configで、command、entrypoint、working_directoryをそれぞれ指定します。Dockerfileで指定することもできますが、LCLではWebサーバーにも利用するDockerイメージと共通化するためにDockerfileには記載していません。 image_uriはアプリケーションのCI/CDの中ででタグを更新するため初回作成以降はlifecycleでignoreします。

Step Functions側は前述のworkflowのjsonファイルをtemplatefile関数で読み込みます。

まとめ

DockerイメージをLambdaで実行することでこれまでのサーバーレスな実行環境では難しかった高速に起動するバッチジョブ実行基盤が実装できました。Railsを動かす場合もLambda handerの実装を少しだけ工夫することで開発体験を大きく変えずにバッチジョブを呼べるようになります。

Railsに限らずLambdaがサポートしている言語であればContainer on Lambdaは手軽に始めることができるの是非活用してみてください。

採用情報

LCLでは開発メンバーを募集しております!

もし興味をお持ちになりましたらお気軽に応募してください。

www.lclco.com

*1:Qiita Advent CalandarのAboutを参照 https://qiita.com/advent-calendar/2021

*2:3分に1回バッチジョブを動かすような要件を見直すべきと言う意見が聞こえてきそうですが歴史的なアレやコレでそうなることがあります

*3:Express Workflowは最大実行時間がLambdaの15分より短い5分なため注意が必要です。