はじめに
前回の続きで、ローカルでビルドしたコンテナを Lambda にデプロイし、実行します
前回までをまだ見ていない方は先にこちらをどうぞ
実装したコンテナはこちら
デプロイ用のノートブックはこちら
実行環境
- MacBook Pro 13 inchi
- 2.4 GHz クアッドコアIntel Core i5
- 16 GB 2133 MHz LPDDR3
- macOS Ventura 13.1
- Rancher Desktop 1.7.0
- メモリ割り当て 12 GB
- CPU 割り当て 6 コア
Livebook 0.8.0 の Docker イメージを元にしたコンテナで動かしました
コンテナ定義はこちらを参照
実装の流れ
-
ECR にコンテナのイメージを保存する
- ECR にリポジトリーを作成
- Dockerfile で コンテナを定義
- コンテナのイメージを ECR のリポジトリーにプッシュ
- IAM にコンテナを実行する権限を持ったロールを作成する
- Lambda で関数を作成する
- ECR のイメージを指定
- IAM の実行ロールを指定
本来はこの流れを SAM や Terraform で定義しますが、今回はあえて Livebook で実装してみます
事前作業
AWS のアカンウトと、管理者権限を持った IAM ユーザーと、その認証情報(ACCESS_KEY_ID と SECRET_ACCESS_KEY)が必要です
セットアップ
必要なモジュールをインストールします
Mix.install([
{:aws, "~> 0.13"},
{:ex_aws, "~> 2.3"},
{:ex_aws_sts, "~> 2.3"},
{:ex_aws_lambda, "~> 2.1"},
{:req, "~> 0.3"},
{:nx, "~> 0.4"},
{:stb_image, "~> 0.6"},
{:hackney, "~> 1.18"},
{:sweet_xml, "~> 0.7"},
{:kino, "~> 0.8"}
])
ex_aws
だけでは Lambda 周りの実装が不十分だったため、 aws
もインストールしています
認証
入力エリアを用意し、そこに IAM ユーザーの認証情報を入力します
ACCESS_KEY_ID と SECRET_ACCESS_KEY は秘密情報なので、値が見えないように Kino.Input.password
を使います
access_key_id_input = Kino.Input.password("ACCESS_KEY_ID")
secret_access_key_input = Kino.Input.password("SECRET_ACCESS_KEY")
リージョンもここで入力しておきましょう
region_input = Kino.Input.text("REGION")
各認証情報を使って、 aws-elixir で AWS にリクエストを投げるためのクライアントを生成します
client =
AWS.Client.create(
Kino.Input.read(access_key_id_input),
Kino.Input.read(secret_access_key_input),
Kino.Input.read(region_input)
)
また、 ex_aws 用の認証情報も作っておきます
認証情報が出力されないよう、最終行は "dummy"
にしています
auth_config = [
access_key_id: Kino.Input.read(access_key_id_input),
secret_access_key: Kino.Input.read(secret_access_key_input),
region: Kino.Input.read(region_input)
]
"dummy"
アカウントID取得
AWS アカウントのアカウント ID (12桁の数字)を使うため、取得しておきます
account_id =
ExAws.STS.get_caller_identity()
|> ExAws.request!(auth_config)
|> then(& &1.body.account)
IAM ロールの定義
Lambda 実行用ロールを定義します
client
|> AWS.IAM.create_role(%{
"RoleName" => "lambda-resnet-role",
"AssumeRolePolicyDocument" =>
Jason.encode!(%{
"Statement" => [
%{
"Sid" => "STS202201051440",
"Effect" => "Allow",
"Principal" => %{
"Service" => ["lambda.amazonaws.com"]
},
"Action" => "sts:AssumeRole"
}
]
})
})
ログ出力のアクセス権限をポリシーとして定義します
client
|> AWS.IAM.create_policy(%{
"PolicyName" => "lambda-resnet-role-policy",
"PolicyDocument" =>
Jason.encode!(%{
"Version" => "2012-10-17",
"Statement" => [
%{
"Effect" => "Allow",
"Action" => [
"cloudwatch:PutMetricData",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:CreateLogGroup",
"logs:DescribeLogStreams"
],
"Resource" => ["*"]
}
]
})
})
ポリシーをロールに適用します
client
|> AWS.IAM.attach_role_policy(%{
"RoleName" => "lambda-resnet-role",
"PolicyArn" => "arn:aws:iam::#{account_id}:policy/lambda-resnet-role-policy"
})
ECR リポジトリー作成
アカウント ID 、リージョン、イメージ名を使ってい、リポジトリーのフルネーム(外部から指定するときの名前)を設定します
region = Kino.Input.read(region_input)
image = "lambda-resnet"
fullname = "#{account_id}.dkr.ecr.#{region}.amazonaws.com/#{image}:latest"
ECR 上にリポジトリーを作成します
client
|> AWS.ECR.create_repository(%{
"repositoryName" => image
})
作成したリポジトリーを確認します
client
|> AWS.ECR.describe_repositories(%{})
リポジトリーを Lambda から読み込めるようにポリシー設定します
client
|> AWS.ECR.set_repository_policy(%{
"repositoryName" => image,
"policyText" =>
Jason.encode!(%{
"Statement" => [
%{
"Sid" => "ECR202201051440",
"Effect" => "Allow",
"Principal" => %{
"AWS" => ["*"]
},
"Action" => "ecr:*"
}
]
})
})
ECR リポジトリーへのイメージプッシュ
ECR 上のリポジトリーはプライベート(認証しないとプッシュできない場所)なので、認証情報を取得します
token =
client
|> AWS.ECR.get_authorization_token(%{})
|> elem(1)
|> then(& &1["authorizationData"])
|> Enum.at(0)
|> then(& &1["authorizationToken"])
このトークンは ユーザー名:パスワード
のBAE64文字列になっているので、これをデコードして docker login します
[username, password] =
token
|> Base.decode64!()
|> String.split(":")
Docker 操作の Elixir モジュールはどれもイマイチだったので、直接 docker コマンドを実行します
System.cmd(
"docker",
[
"login",
"--username",
username,
"--password",
password,
fullname
]
)
前回定義したコンテナに lambda-resnet のタグを付けてビルドします
/lambda/resnet
の部分が Dockerfile の配置ディレクトリーです
System.cmd(
"docker",
[
"build",
"-t",
image,
"/lambda/resnet"
]
)
更にこのイメージにフルネームのタグを付けます
System.cmd(
"docker",
[
"tag",
image,
fullname
]
)
このイメージを ECR にプッシュします
System.cmd(
"docker",
[
"push",
fullname
]
)
ECR 上のイメージ一覧でプッシュできたことを確認します
client
|> AWS.ECR.list_images(%{
"repositoryName" => image
})
|> elem(1)
|> then(& &1["imageIds"])
|> Kino.DataTable.new()
imageTag が latest のイメージが1件できています
Lambda関数の作成
必要なパラメーターを指定して Lambda 関数を作成します
client
|> AWS.Lambda.create_function(%{
"FunctionName" => "resnet",
"PackageType" => "Image",
"Code" => %{
"ImageUri" => fullname
},
"Role" => "arn:aws:iam::#{account_id}:role/lambda-resnet-role",
"MemorySize" => 1024,
"Timeout" => 900,
"Environment" => %{
"Variables" => %{
"LOG_LEVEL" => "debug"
}
}
})
コンテナイメージから Lambda 関数を作成する場合、 PackageType に Image を指定し、 Code の ImageUri に ECR のフルネームを指定します
MemorySize は大きめに 1 GB です
実際に使われたメモリ量ではなく、ここで指定したメモリ量が課金されるので、動かしてみて実際の使用量に合わせて調整しましょう(小さすぎるとメモリ不足でエラーになります)
Timeout はとりあえず上限の 900 秒 = 15 分にしています
Environment の Variables で環境変数 LOG_LEVEL に debug を指定しています
ちなみに ex_aws_lambda ではコンテナイメージによる関数作成が実装されていませんでした
以下のコマンドで作成した関数の情報を取得することができます
AWS.Lambda.get_function(client, "resnet")
State
が Pending
や Creating
の場合はまだ関数を実行できません
State
が Active
になれば実行可能です
Lambda 関数呼出
テスト画像を使って Lambda 関数を呼び出してみます
binary =
"https://raw.githubusercontent.com/pjreddie/darknet/master/data/dog.jpg"
|> Req.get!()
|> then(& &1.body)
確認のため、バイナリーを画像として表示します
binary
|> StbImage.read_binary!()
|> StbImage.to_nx()
|> Kino.Image.new()
バイナリーを BASE64 エンコーディングしてから Lambda 関数に渡します
"resnet"
|> ExAws.Lambda.invoke(%{"Payload" => Base.encode64(binary)}, %{})
|> ExAws.request!(auth_config)
|> then(& &1["body"]["predictions"])
|> Kino.DataTable.new()
ちゃんと実行できました
別の画像でも試してみましょう
image_input = Kino.Input.image("INPUT_IMAGE", format: :jpeg)
binary =
image_input
|> Kino.Input.read()
|> then(& &1.data)
"resnet"
|> ExAws.Lambda.invoke(%{"Payload" => Base.encode64(binary)}, %{})
|> ExAws.request!(auth_config)
|> then(& &1["body"]["predictions"])
|> Kino.DataTable.new()
ログ確認
Lambda のログを見てみましょう
まず直近のログストリーム名を取得します
log_stream_name =
client
|> AWS.CloudWatchLogs.describe_log_streams(%{
"logGroupName" => "/aws/lambda/resnet"
})
|> elem(1)
|> then(& &1["logStreams"])
|> Enum.at(-1)
|> then(& &1["logStreamName"])
ログメッセージを取得します
client
|> AWS.CloudWatchLogs.get_log_events(%{
"logGroupName" => "/aws/lambda/resnet",
"logStreamName" => log_stream_name
})
|> elem(1)
|> then(& &1["events"])
|> Enum.map(& &1["message"])
["warning: the VM is running with native name encoding of latin1 which may cause Elixir to malfunction as it expects utf8. Please ensure your locale is set to UTF-8 (which can be verified by running \"locale\" in your shell)\n",
"START RequestId: 12b33ddf-6fbb-4145-80f4-fd052d24340c Version: $LATEST\n",
"[DEBUG] %{\"Payload\" => ... <> ...,
"[DEBUG] %{\"RELEASE_BOOT_SCRIPT\" => \"start\", ... \"_HANDLER\" => \"Resnet\"}\n",
"[DEBUG] 12b33ddf-6fbb-4145-80f4-fd052d24340c\n",
"[DEBUG] %FaasBase.Aws.Response{body: %{\"predictions\" => [%{label: \"malamute, malemute, Alaskan malamute\", score: 0.9850469827651978}, %{label: \"bicycle-built-for-two, tandem bicycle, tandem\", score: 0.0035503427498042583}, %{label: \"mountain bike, all-terrain bike, off-roader\", score: 0.001156700891442597}, %{label: \"Eskimo dog, husky\", score: 7.718604174442589e-4}, %{label: \"Tibetan mastiff\", score: 5.428500007838011e-4}]}, headers: %{}, status_code: 200, is_base64_encoded: false}\n",
"END RequestId: 12b33ddf-6fbb-4145-80f4-fd052d24340c\n",
"REPORT RequestId: 12b33ddf-6fbb-4145-80f4-fd052d24340c\tDuration: 4887.28 ms\tBilled Duration: 9986 ms\tMemory Size: 1024 MB\tMax Memory Used: 642 MB\tInit Duration: 5098.41 ms\t\n",
"START RequestId: b4f98e31-4f72-4f33-984b-f20a77bc047b Version: $LATEST\n",
"[DEBUG] %{\"Payload\" => ... <> ...,
"[DEBUG] %{\"RELEASE_BOOT_SCRIPT\" => \"start\", ... \"_HANDLER\" => \"Resnet\"}\n",
"[DEBUG] b4f98e31-4f72-4f33-984b-f20a77bc047b\n",
"[DEBUG] %FaasBase.Aws.Response{body: %{\"predictions\" => [%{label: \"notebook, notebook computer\", score: 0.6668941974639893}, %{label: \"laptop, laptop computer\", score: 0.1146106868982315}, %{label: \"bow tie, bow-tie, bowtie\", score: 0.04239868372678757}, %{label: \"dining table, board\", score: 0.023073576390743256}, %{label: \"projector\", score: 0.0217642430216074}]}, headers: %{}, status_code: 200, is_base64_encoded: false}\n",
"END RequestId: b4f98e31-4f72-4f33-984b-f20a77bc047b\n",
"REPORT RequestId: b4f98e31-4f72-4f33-984b-f20a77bc047b\tDuration: 2242.98 ms\tBilled Duration: 2243 ms\tMemory Size: 1024 MB\tMax Memory Used: 642 MB\t\n"]
REPORT
の行に出ている Billed Duration
が課金対象の時間で、 Memory Size
が課金対象のメモリ使用量、 Max Memory Used
が実際のメモリ使用量です
メモリ使用量は 642 MB なので、メモリの割り当ては 1 GB で妥当そうです
- 1回目の呼び出し: 9986 ms * 1024 MB
- 2回目の呼び出し: 2243 ms * 1024 MB
東京リージョンで 1,024 MB だと、 1 ms あたり $0.0000000167 請求されます
従って、2回で 0.0000000167 * (9986 + 2243) = $ 0.0002042243 になります
処理時間がこの2回の平均だとざっくり想定すると、 10 万回呼ばれたとき、およそ $10 です
1ヶ月あたり 40万 GB秒 の無料利用枠があるため、 1 万回程度しか呼ばないのであれば実質無料になります
Lambda の安さは凄まじいですね
ただし、これが 1 億回呼ばれると $10,000 です
Lambda は勝手にスケーリングしてくれるので止まりませんが、代わりにバンバン課金されます
利用者が増えれば増えるほど青天井に増えていくことを考えると、どこかのタイミングで ECS + Phoenix に移行した方がいいでしょう
もちろん、その場合も大量リクエストを捌き切れるような構成が必要になりますが
後片付け
Lambda 関数は実行されない限り課金されませんが、 ECR はストレージ分課金されてしまうので、使わなくなったら消しておきましょう
Lambda関数削除
"resnet"
|> ExAws.Lambda.delete_function()
|> ExAws.request!(auth_config)
IAM ロール削除
client
|> AWS.IAM.detach_role_policy(%{
"RoleName" => "lambda-resnet-role",
"PolicyArn" => "arn:aws:iam::#{account_id}:policy/lambda-resnet-role-policy"
})
client
|> AWS.IAM.delete_policy(%{
"PolicyArn" => "arn:aws:iam::#{account_id}:policy/lambda-resnet-role-policy"
})
client
|> AWS.IAM.delete_role(%{
"RoleName" => "lambda-resnet-role"
})
ECR リポジトリー削除
ECR リポジトリーは空でないと削除できないため、中に含まれるイメージを一括削除する必要があります
image_ids =
client
|> AWS.ECR.list_images(%{
"repositoryName" => image
})
|> elem(1)
|> then(& &1["imageIds"])
client
|> AWS.ECR.batch_delete_image(%{
"repositoryName" => image,
"imageIds" => image_ids
})
client
|> AWS.ECR.delete_repository(%{
"repositoryName" => image
})
まとめ
Lambda も Elixir で動かせる事が確認できたので、本当に全部 Elixir にしたいときには使えそうです
例えば基本的には Phoenix LiveView で動かしつつ、重い AI 処理は SageMaker に渡し、一部の S3 アップロードなどをトリガーにした処理は Lambda で動かす、そしてそれら全てを Elixir で書く、ということができるわけですね