Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

AWS LambdaでScala 3をContainer imageで動作させる

sbt ecr:pushと表示された端末のイメージ画面

先日(といっても結構前のことだが)、Scala 3のLTSサポートバージョンである3.3.0がリリースされた。Scala 3の開発が円熟してきているようで嬉しい。これからも元気でいてね。今回はScala 3をAWS LambdaのContainer imageを使って動作させる手法についてお伝えしたい。令和ではお盆の精霊はコンテナに乗ってやってくるぞ

www.scala-lang.org

これまでもこのブログではAWS LambdaでScala 3を動作させる方法を紹介してきたが、アーティファクトのサイズが大きくなると、JARを直接デプロイする方式ではアーティファクトに250MBのサイズ制限が生じるという問題があった。機械学習などを利用するアプリケーションではこのサイズ制限によりLambdaの利用に制約が生まれがちである(ただし、アーティファクトにJVMを含まなくても良いためサイズ効率は良い)。

今回紹介するContainer imageを使う手法では、直接またはS3からJARファイルをデプロイする代わりに、ECRにあるDockerイメージを使ってLambdaを実行でき、この制限を回避できる。

この記事で学ぶこと

この記事では、「Hello, World!」を表示するScala 3アプリケーションをAWS Lambda用のContainer imageとしてビルドし、あらかじめ用意しておいたECRにデプロイする。また、それに必要なビルド設定、GitHub Actionsの設定を紹介する。アプリケーションはLambdaに呼び出してほしい特定のメソッドをあらかじめ用意しているものとする。

この記事ではScala 3を利用しているが、おそらく最小限の変更で2.13系でも利用できるはずだ。

最終的なプロジェクトはwindymelt/scala3-aws-lambda-ecr-example: Example to run Scala 3 image through AWS Lambda + ECR (github.com)にアップロードした。

追記: sbt-native-packagerよりも軽量でシンプルな手法があります。こちらのほうをまず試してみると良いと思います。

blog.3qe.us

利用したバージョン

  • Scala 3.3.0
  • sbt 1.9.3
  • Dockerベースイメージとして: amazoncorretto:17.0.8

Scala 3

おそらくこの記事を読んでいる人間には既知であろうと思うが、Scala 3についても軽く説明しておこう。Scala 3は、プログラミング言語Scalaの最新のメジャーバージョンであり、5月末にLTSバージョンである3.3.0がリリースされた。Scala 3では新たなコンパイラ基盤をもとに再設計されており、コンパイルと実行の総合的なパフォーマンスが向上している。これに加え、代数的データ型(ADT)などの各種言語機能が再設計・新規追加され、より使い勝手の良い言語へと進化している。

LambdaにおけるContainer image

さて、そんなScala 3を今回はAWS Lambdaで実行しようとしているわけだが、Lambdaには2つの実行モデルがある。通常のアーティファクト(ScalaであればJARファイル)をデプロイして実行してもらうモデルと、Dockerイメージをデプロイして実行してもらうモデルの2種類であり、今回は後者のDockerイメージをデプロイする手法を解説していく。

実際の手法について深掘りする前に、いずれの実行モデルでも必要なLambdaランタイムという存在について触れる必要がある。Lambdaランタイムとは、ユーザがデプロイするアプリケーションコードそのものと、Lambdaの実行基盤とを接続し、データ交換やリクエスト処理を仲介する小さなライブラリのことである。JavaやNode.js、Rubyなどのメジャーな言語ではAWSが標準的なランタイムを提供してくれるので、通常はこれを利用することになる。

このことは、Lambdaの実行基盤がプッシュ型ではなくプル型であることを知っておくと理解しやすいだろう。AWS Lambdaでは、LambdaランタイムがAWS側の特定のエンドポイントに新規のリクエストが無いか尋ねに行き、リクエストを言語が理解できる形式に変換し、必要に応じてリクエストを処理して返すというモデルになっている。この部分の処理を担うのがLambdaランタイムである。RubyやNode.jsを直接デプロイしてLambdaを動作させるときはこの部分がうまく隠蔽されるため、ユーザからはアプリケーションコードをデプロイするだけで良いように見えているのだ。

カスタム Lambda ランタイム - AWS Lambda (amazon.com)

直接アーティファクトをデプロイする方式では自動的にランタイムが導入される仕組みになっているが、Container imageを利用してアプリケーションを動作させる場合には、Docker環境でアプリケーションが動作する原理上、Dockerイメージにランタイムを内包する必要がある。幸運にもScalaの場合はJava用ランタイムを使うことができるため、Dockerイメージのエントリポイントから実行するだけで良い仕組みになっている。

総合すると、ScalaコードをAWS Lambdaのcontainer imageとして動作させるには、以下の要素を組み合わせれば良い:

  • アプリケーション起動時にJavaランタイムが起動する環境を整える
  • アプリケーションコードをDockerイメージとしてビルドする環境を整える
  • DockerイメージをECRにアップロードする環境を整える

まずはsbtプロジェクトにJava用ランタイムを導入しよう。

Javaランタイムの導入

やる事はシンプルだ。依存性を定義してJavaランタイムを呼び出せるようにする。アプリケーションのメソッドではなく、Javaランタイムが提供しているMainメソッドが起動されるようにする。この2つだ。

AWSはJava用LambdaランタイムをMaven経由で提供している。プロジェクトの依存性に以下を追加しよう:

"com.amazonaws" % "aws-lambda-java-runtime-interface-client" % "2.3.2"

バージョンは執筆時の最新版を利用した。

加えて、アプリケーションコードにLambda用のハンドラメソッドを作成しよう:

package com.github.windymelt.scala3awslambdaecrexample

object Handler {
  @main def hello: Unit =
    println("Hello world!")
    println(msg)

  def msg = "I was compiled by Scala 3. :)"
}

といっても、通常のmain methodとして利用できるメソッドを用意すればよい。データの受け渡しが必要な場合は、過去記事を参考にしてほしい。

blog.3qe.us

最後に、build.sbtでmain classとしてJava用Lambdaランタイムを指定することで、Lambdaランタイムが起動するようになる:

Compile / mainClass := Some(
  "com.amazonaws.services.lambda.runtime.api.client.AWSLambda"
)

Java用Lambdaランタイムの導入は、いったんこれで終わり。

ScalaでのDockerイメージを作成する

さて、Java用ランタイムを導入したので今度はアプリケーションをDocker化しよう。といっても、sbtに便利なプラグインがいくつも用意されているおかげで、基本的にそれの設定だけすれば良い。 順に追ってみよう。

また、今回はDockerイメージのbase imageとしてamazoncorretto:17.0.8を利用する。Amazon製なので相性が良いだろうという判断だ。基本的にAWSで動かすJVMはCorrettoを使いがちだ。

sbt-native-packagerによるDocker化

SbtによるScalaアプリケーションのDocker化として最も有力なのが、sbt-native-packagerプラグインだ。今回もこれを使う。

sbt/sbt-native-packager: sbt Native Packager (github.com)

このプラグイン自体はDockerイメージの他にもdeb形式へのパッケージングなど、幅広いデプロイターゲットを用意している。今回はこのプラグインが提供するJavaAppPackagingDockerPluginとを利用して、アプリケーションをローカルのDockerイメージにビルドしよう。

まずはproject/plugins.sbtに以下のプラグインを追加する:

addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16")

これも、執筆時の最新版を利用した。

次に、build.sbtでDockerイメージのビルド方法を指定する。順に見ていこう。

まずDockerイメージビルドに必要なプラグインを有効化する:

lazy val root = project
  .in(file("."))
  .settings(
    /* ... */
  )
  // ここから追加
  .enablePlugins(JavaAppPackaging) // for DockerPlugin
  .enablePlugins(DockerPlugin)     // to build image

この2つのプラグインによりDockerイメージビルドに必要なモジュールが読み込まれる。イメージとしてAlpine版(amazoncorretto:17.0.8-alpine)を使う場合はAshScriptPluginをさらにロードする必要がある(ashを呼ぶようにするだけのラッパーだ)。

そしてDockerイメージビルド用の設定を追加していく:

/* lazy val root = ... */
  .settings(
    dockerBaseImage := "amazoncorretto:17.0.8",
    dockerEntrypoint := Seq("sh", s"/opt/docker/bin/${name.value}"),
    dockerCmd := Seq(
      "com.github.windymelt.scala3awslambdaecrexample.Handler::hello"
    ),
    // correttoイメージにuseradd/adduserがインストールされていないので回避する
    Docker / daemonUserUid := None,
    Docker / daemonUser := "daemon",
    Compile / mainClass := Some(
      "com.amazonaws.services.lambda.runtime.api.client.AWSLambda"
    ),
    Docker / packageName := "scala3-aws-lambda-ecr-example"
  )

Lambda上で呼び出してほしいハンドラは、dockerCmdで指定する。クラス定義までをドット記法で指定し、::で挟んでメソッド名を指定すればよい。例ではcom.github.windymelt.scala3awslambdaecrexample.Handlerクラスのhelloメソッドを呼び出している。

次に、Dockerがビルドするイメージの名前をDocker / packageNameで指定する。今回はscala3-aws-lambda-ecr-exampleとした。

この状態でsbt Docker/publishLocalを実行するとイメージビルドが行われ、scala3-aws-lambda-ecr-example:0.1.0-SNAPSHOTというイメージが保存される。今のところ全てsbtで完結していて、Dockerfileを書く必要も無い(勿論、Dockerコマンドは必要だ)。

daemonUserまわりで何してるの?

通常のamazoncorrettoイメージにはuseradd/adduserがインストールされておらず、DockerPluginが自動生成するDockerfileがうまく走らないため、daemonUser"daemon"を指定する必要がある。これにともなってdaemonUserUidとしてNoneを指定する必要がある。ここさえクリアすれば特に困るところはない。

alpineを使いたい場合は?

今回はbase imageとしてAlpineを含むイメージを利用しなかったが、alpine版のcorrettoを使うこともできる。その場合には考慮するべき点が増える。

Alpineには最初からuseraddなどのユーティリティが含まれているため、daemonUserをいじらずに問題なくビルドが可能なのだが、Alpineを使うためにAshScriptPluginというpolyfill的プラグインが追加で必要になる(依存性には最初から含まれている)。これはAlpineのシェルがashであることに起因する制約だ。

adduser not found on amazoncorretto:11 · Issue #1427 · sbt/sbt-native-packager (github.com)

なぜENTRYPOINTをいじるの?

これを付けなかった場合、Error: fork/exec /opt/docker/bin/scala3-aws-lambda-ecr-example: permission denied Runtime.InvalidEntrypointといってLambdaが実行してくれなかったため。おそらくシェルスクリプトを直接実行することは想定されていないようだ。

Lambda関数が起動するメカニズム

いったん構成を復習しよう。まず、我々はmainClassを設定した。このため、コンテナのENTRYPOINTである/opt/docker/bin/scala3-aws-lambda-ecr-exampleにて、JVMはcom.amazonaws.services.lambda.runtime.api.client.AWSLambdaを起動する。AWSLambdaのmainメソッドはLambda関数のハンドラとなるメソッドの場所をDockerのCMD経由で入手し、リクエストに対してこのメソッドを呼び出すようにする。

DockerイメージをECRにpushする

さて、手元でDockerイメージをビルドできるようになった。ここからは、GitHub Actionsを使ってイメージをECRにpushする仕組みを構築する。

sbt-ecrによるECRへのpush手段の確保

嬉しいことにsbtのタスクとしてイメージをpushしてくれるsbt-ecrというプラグインが存在しているのでこれを使う。このプラグインを利用するとシェルスクリプトを書く手間がいくらか省ける。

sbt-ecrのために、project/plugins.sbtに以下の通り追記しよう:

addSbtPlugin("com.mintbeans" % "sbt-ecr" % "0.16.0")

例によって、執筆時の最新バージョンを利用している。

build.sbtに、ECRにイメージをpushするためのタスクと、そのための設定を定義する:

import com.amazonaws.regions.Region
import com.amazonaws.regions.Regions

/* lazy val root = ... */
  .enablePlugins(EcrPlugin) // to upload to ECR

// Pushするのに必要なECRの情報を設定する
Ecr / region := Region.getRegion(Regions.AP_NORTHEAST_1),
Ecr / repositoryName := "scala3-aws-lambda-ecr-example",
Ecr / localDockerImage := (Docker / packageName).value + ":" + (Docker / version).value,
Ecr / repositoryTags := Seq(
  sys.env.get("TAG")
).flatten,
Ecr / push := ((Ecr / push) dependsOn (Docker / publishLocal, Ecr / login)).value

今回はap-northeast-1scala3-aws-lambda-ecr-exampleという名前でECRを建てたので、ここにpushするための設定を行った。加えて、どのようなタグをECRにプッシュしたいかもここで設定可能だ。今回はGitHub Actionsを使うという都合もあって、環境変数から受け取ることにした。複数のタグを設定したい場合は、例えば$EXTRA_TAGといった環境変数を用意してこれを受け取るようにすると良い。

最後の行では、pushする前にローカルビルドとECRへのログインが必須だぞ、ということを宣言している。

最終的に、build.sbtは以下の通りになる:

import com.amazonaws.regions.Region
import com.amazonaws.regions.Regions

val scala3Version = "3.3.0"

lazy val root = project
  .in(file("."))
  .settings(
    name := "scala3-aws-lambda-ecr-example",
    version := "0.1.0-SNAPSHOT",
    scalaVersion := scala3Version,
    libraryDependencies += "org.scalameta" %% "munit" % "0.7.29" % Test,
    libraryDependencies ++= Seq(
      "com.amazonaws" % "aws-lambda-java-runtime-interface-client" % "2.3.2",
    )
  )
  .enablePlugins(JavaAppPackaging) // for DockerPlugin
  .enablePlugins(EcrPlugin) // to upload to ECR
  .enablePlugins(DockerPlugin) // to build image
  .settings(
    dockerBaseImage := "amazoncorretto:17.0.8",
    dockerEntrypoint := Seq("sh", s"/opt/docker/bin/${name.value}"),
    dockerCmd := Seq(
      "com.github.windymelt.scala3awslambdaecrexample.Handler::hello"
    ),

    Docker / daemonUserUid := None,
    Docker / daemonUser := "daemon",
    Docker / packageName := "scala3-aws-lambda-ecr-example",

    Compile / mainClass := Some(
      "com.amazonaws.services.lambda.runtime.api.client.AWSLambda"
    ),
    Ecr / region := Region.getRegion(Regions.AP_NORTHEAST_1),
    Ecr / repositoryName := "scala3-aws-lambda-ecr-example",
    Ecr / localDockerImage := (Docker / packageName).value + ":" + (Docker / version).value,
    Ecr / repositoryTags := Seq(
      sys.env.get("TAG")
    ).flatten,
    Ecr / push := ((Ecr / push) dependsOn (Docker / publishLocal, Ecr / login)).value
  )

これらの設定により、あとはAWSのクレデンシャルの準備さえすればECRにイメージをpushできてしまう。sbt-ecr$AWS_ACCESS_KEY_IDなどのクレデンシャルを受け付けるため、CIで実行するさいは標準的なクレデンシャルを受け渡す仕組みを用意すれば良い。これについては次項で説明する。

sbt-ecrを使わないこともできる(たぶんこっちが楽)

他のリポジトリに揃えてdocker/build-push-actionなどを使いたいということもある。このような場合は、sbt Docker/stageを実行するとtarget/docker/stageDockerfileが生成されるため、docker/build-push-actionにこのcontextを渡せばよい。こっちのほうが楽。この場合はsbt-ecrは不要である。

GitHub ActionsからECRにpushする

ここからは、GitHub Actionsを用いて自動的にECRにイメージをpushするためのワークフローを解説する。今回は、mainブランチにコミットがpushされたときに自動的にイメージをビルドし、既定のECRリポジトリにpushする。

認証・認可まわりの設定

このワークフローで一番手間がかかるのが認証・認可まわりの処理だ。というか認証・認可がワークフローの大半を占めている。ご存知の通りAWSは強固なセキュリティで守られているので、雑な方法でECRにイメージをpushできない。今回はaws-actions/configure-aws-credentials@v2というactionを利用してAWSアカウントからロールを取得し、これをもとにpushするという算段にする。

とは言え、基本的に GitHub ActionsにAWSクレデンシャルを直接設定したくないのでIAMロールを利用したい | DevelopersIO (classmethod.jp) と同じことをするだけだ。ここから先に進む前に、一読することを強く勧める。

読んだかな?↑記事にしたがって、以下の準備をしよう:

  • (必要に応じて)OIDCプロバイダの設定
  • ECRにpushするためのポリシーの定義
  • ↑ポリシーをアタッチしたロールの定義
  • GitHubリポジトリにsecretとしてAWS_ACCOUNT_IDを設定する

これから以下のようなチームワークで認証・認可を行うことになる*1:

  • GitHubはOIDCプロバイダとして、Github Actionsのワークフローが特定のユーザの特定のリポジトリで実行されていることを認証し、IDトークンをAWS IAMに示す
    • 「あのー私こういう者なんですけど」
  • IAMはIDトークンを検証した上で、ECRにpushする権限を持つポリシーをロールにアタッチし、GitHub Actionsのワークフローに発行する
    • 「あーそのリポジトリの方ですか このECRにpushしていいですよ この腕章持ってってください(ロール)」
    • 一定時間すると腕章は期限切れになる
  • GitHub Actionsのワークフローで実行されるECRへのpushコマンドは、IAMから受け取ったロールにより認証・認可される
    • 「(腕章を付けて)pushお願いします」

push.yaml

実際のGitHub Actionsのワークフローとして、.github/workflows/push.yamlに以下の通り設定した。チェックアウトし、ロールを取得し、ECRにログインし、sbt ecr:pushを実行するだけだ:

name: Push

# cf. https://dev.classmethod.jp/articles/github-actions-aws-sts-credentials-iamrole/

permissions:
  id-token: write
  contents: read

on:
  - push
  - workflow_dispatch

jobs:
  build-and-push:
    name: Build and push image to ECR
    runs-on: ubuntu-latest
    if: ${{ github.ref_name == 'main'}}
    timeout-minutes: 15
    env:
      DOCKER_BUILDKIT: 1
    steps:
    - name: Checkout code
      uses: actions/checkout@v3
    - uses: docker/setup-buildx-action@v2
    # ここでロールをIAMから受け取る
    - name: Configure AWS Credentials
      uses: aws-actions/configure-aws-credentials@v2
      with:
        role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/push-to-scala3-aws-lambda-ecr-example
        role-session-name: push-to-scala3-aws-lambda-ecr-example-role-ci-${{ github.run_id }}-${{ github.run_number }}
        aws-region: ap-northeast-1
        mask-aws-account-id: true
    - name: Login to ECR
      uses: aws-actions/amazon-ecr-login@v1
      env:
        AWS_REGION: ap-northeast-1
    - name: Setup vars
      id: vars
      run: |
        docker_image_repo='${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/scala3-aws-lambda-ecr-example'
        echo "docker_image_repo=$docker_image_repo" >> "$GITHUB_OUTPUT"
        echo "docker_tag=commit-${{ github.sha }}" >> "$GITHUB_OUTPUT"
    # 受け取ったロールが使われる
    - name: Build and push
      run: |
        sbt ecr:push
      env:
        TAG: ${{ steps.vars.outputs.docker_tag }}
    - name: Adding markdown
      run: echo '## Tag `${{ steps.vars.outputs.docker_tag }}`' >> "$GITHUB_STEP_SUMMARY"

最後にリポジトリシークレットとしてAWS_ACCOUNT_IDを設定すればイメージ作成の準備は万全だ。mainブランチに変更がpushされるたび、最新のイメージがECRに登録される(ライフサイクルポリシーをうまく使って費用を削減しよう)。

Lambdaを実行する

最後に、Lambda関数を実際に作って実行してみる。といっても、最初の選択肢でContainer imageを使う旨選択するだけだ。ここはほとんど迷う箇所ではないのでスクショだけ示しておく。

「コンテナイメージ」を選択する

注意点

自分がつまづいた注意点を残しておく。

まず、Container imageで起動することと、Scala自体がJVM言語であることとが相まって、初回の起動時間は結構遅くなるため、タイムアウトを60秒程度に設定しておくとよい。2回目以降の起動はほぼ即座に実行されるようになる。3~4回実行すると完全にJVMが温まり、高速に動作するようになる。しかし30分も経過するとランタイムがシャットダウンされるので、本当の即時性が必要であればScala NativeやGraalVMによるnative imageの導入を検討するべきかもしれない。少しbuild.sbtに手を入れればネイティブ版を含んだDockerイメージの生成も可能な雰囲気があるので、興味がある読者は試してみると良いだろう。

二つ目に、何もしないとGitHub Actionsの動作アーキテクチャはx86_64になるため、ビルドされたイメージはx86_64イメージになる。arm64で動作させたい場合はワークフロー側での配慮が必要だ。例えばGitHub Actionsの場合はsetup-qemu-actionを使う必要があるだろう。

まとめると

  • sbt-native-packagerを使うことでScala 3アプリケーションのDockerイメージを生成できる
    • Scala 2でも特に問題はなさそう
  • sbt-ecrを使うことでDockerイメージをECRにpushできる
  • 認証・認可を行うにはGitHub Actions上でconfigure-aws-credentialsを使うと便利
  • LambdaでContainer imageとして動作させるためにはコンテナイメージにJavaランタイムを導入する必要がある

Scalalienの皆様のお役に立てたら幸いです。

*1:不正確だったら教えてください

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?