先日(といっても結構前のことだが)、Scala 3のLTSサポートバージョンである3.3.0がリリースされた。Scala 3の開発が円熟してきているようで嬉しい。これからも元気でいてね。今回はScala 3をAWS LambdaのContainer imageを使って動作させる手法についてお伝えしたい。令和ではお盆の精霊はコンテナに乗ってやってくるぞ。
これまでもこのブログでは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
よりも軽量でシンプルな手法があります。こちらのほうをまず試してみると良いと思います。
- この記事で学ぶこと
- Scala 3
- LambdaにおけるContainer image
- ScalaでのDockerイメージを作成する
- DockerイメージをECRにpushする
- Lambdaを実行する
- 注意点
- まとめると
利用したバージョン
- 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として利用できるメソッドを用意すればよい。データの受け渡しが必要な場合は、過去記事を参考にしてほしい。
最後に、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形式へのパッケージングなど、幅広いデプロイターゲットを用意している。今回はこのプラグインが提供するJavaAppPackaging
とDockerPlugin
とを利用して、アプリケーションをローカルの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-1
にscala3-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/stage
にDockerfile
が生成されるため、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を使う旨選択するだけだ。ここはほとんど迷う箇所ではないのでスクショだけ示しておく。
Scala 3をAWS Lambda + Container Imageで動作できました。これでアセットが大きくなっても安心だ。あとで記事にします。 pic.twitter.com/PuHMAqhR5D
— 💀†暗黒騎士メルト†🌆 (@windymelt) 2023年8月9日
注意点
自分がつまづいた注意点を残しておく。
まず、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:不正確だったら教えてください