Elixir Bumblebee を AWS Lambda で動かす（Livebook でデプロイ、実行編）

## はじめに

前回の続きで、ローカルでビルドしたコンテナを Lambda にデプロイし、実行します

前回までをまだ見ていない方は先にこちらをどうぞ

https://qiita.com/RyoWakabayashi/items/366b05239af67b128f43

https://qiita.com/RyoWakabayashi/items/a63b6e75da9321daf998

実装したコンテナはこちら

https://github.com/RyoWakabayashi/elixir-learning/tree/main/lambda/resnet

デプロイ用のノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/aws/lambda.livemd

## 実行環境

- 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　イメージを元にしたコンテナで動かしました

https://hub.docker.com/layers/livebook/livebook/0.8.0/images/sha256-6ef3fdd439fd72af9ae01d58e947664a46a8493f031e179cc0e91c08ddac02e5?context=explore

コンテナ定義はこちらを参照

https://github.com/RyoWakabayashi/elixir-learning

## 実装の流れ

- ECR にコンテナのイメージを保存する

  - ECR にリポジトリーを作成
  - Dockerfile で　コンテナを定義
  - コンテナのイメージを ECR のリポジトリーにプッシュ

https://aws.amazon.com/jp/ecr/

- IAM にコンテナを実行する権限を持ったロールを作成する

https://aws.amazon.com/jp/iam/

- Lambda で関数を作成する
  - ECR のイメージを指定
  - IAM の実行ロールを指定

本来はこの流れを SAM や Terraform で定義しますが、今回はあえて Livebook で実装してみます

https://aws.amazon.com/jp/serverless/sam/

https://terraform.io/

https://livebook.dev/

## 事前作業

AWS のアカンウトと、管理者権限を持った IAM ユーザーと、その認証情報（ACCESS_KEY_ID と SECRET_ACCESS_KEY）が必要です

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/id_users_create.html#id_users_create_console

## セットアップ

必要なモジュールをインストールします

```elixir
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` を使います

https://qiita.com/RyoWakabayashi/items/debb1d048613ce505988

```elixir
access_key_id_input = Kino.Input.password("ACCESS_KEY_ID")
```

```elixir
secret_access_key_input = Kino.Input.password("SECRET_ACCESS_KEY")
```

リージョンもここで入力しておきましょう

```elixir
region_input = Kino.Input.text("REGION")
```

![スクリーンショット 2022-12-03 0.01.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/9c877571-862a-22bd-528f-32d5cb6c28b5.png)

各認証情報を使って、 aws-elixir で AWS にリクエストを投げるためのクライアントを生成します

```elixir
client =
  AWS.Client.create(
    Kino.Input.read(access_key_id_input),
    Kino.Input.read(secret_access_key_input),
    Kino.Input.read(region_input)
  )
```

![スクリーンショット 2022-12-05 14.21.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/3b4a9ca7-50ac-b5c0-9375-851513544378.png)

また、 ex_aws 用の認証情報も作っておきます

認証情報が出力されないよう、最終行は `"dummy"` にしています

```elixir
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桁の数字）を使うため、取得しておきます

```elixir
account_id =
  ExAws.STS.get_caller_identity()
  |> ExAws.request!(auth_config)
  |> then(& &1.body.account)
```

## IAM ロールの定義

Lambda 実行用ロールを定義します

```elixir
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"
        }
      ]
    })
})
```

ログ出力のアクセス権限をポリシーとして定義します

```elixir
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" => ["*"]
        }
      ]
    })
})
```

ポリシーをロールに適用します

```elixir
client
|> AWS.IAM.attach_role_policy(%{
  "RoleName" => "lambda-resnet-role",
  "PolicyArn" => "arn:aws:iam::#{account_id}:policy/lambda-resnet-role-policy"
})
```

## ECR リポジトリー作成

アカウント ID 、リージョン、イメージ名を使ってい、リポジトリーのフルネーム（外部から指定するときの名前）を設定します

```elixir
region = Kino.Input.read(region_input)
image = "lambda-resnet"
fullname = "#{account_id}.dkr.ecr.#{region}.amazonaws.com/#{image}:latest"
```

ECR 上にリポジトリーを作成します

```elixir
client
|> AWS.ECR.create_repository(%{
  "repositoryName" => image
})
```

作成したリポジトリーを確認します

```elixir
client
|> AWS.ECR.describe_repositories(%{})
```

リポジトリーを Lambda から読み込めるようにポリシー設定します

```elixir
client
|> AWS.ECR.set_repository_policy(%{
  "repositoryName" => image,
  "policyText" =>
    Jason.encode!(%{
      "Statement" => [
        %{
          "Sid" => "ECR202201051440",
          "Effect" => "Allow",
          "Principal" => %{
            "AWS" => ["*"]
          },
          "Action" => "ecr:*"
        }
      ]
    })
})
```

## ECR リポジトリーへのイメージプッシュ

ECR 上のリポジトリーはプライベート（認証しないとプッシュできない場所）なので、認証情報を取得します

```elixir
token =
  client
  |> AWS.ECR.get_authorization_token(%{})
  |> elem(1)
  |> then(& &1["authorizationData"])
  |> Enum.at(0)
  |> then(& &1["authorizationToken"])
```

このトークンは `ユーザー名：パスワード` のBAE64文字列になっているので、これをデコードして docker login します

```elixir
[username, password] =
  token
  |> Base.decode64!()
  |> String.split(":")
```

Docker 操作の Elixir モジュールはどれもイマイチだったので、直接 docker コマンドを実行します

```elixir
System.cmd(
  "docker",
  [
    "login",
    "--username",
    username,
    "--password",
    password,
    fullname
  ]
)
```

![スクリーンショット 2022-12-18 23.45.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/348595bf-1b7e-5a70-80a5-11019183fe6d.png)

前回定義したコンテナに lambda-resnet のタグを付けてビルドします

`/lambda/resnet` の部分が Dockerfile の配置ディレクトリーです

```elixir
System.cmd(
  "docker",
  [
    "build",
    "-t",
    image,
    "/lambda/resnet"
  ]
)
```

更にこのイメージにフルネームのタグを付けます

```elixir
System.cmd(
  "docker",
  [
    "tag",
    image,
    fullname
  ]
)
```

このイメージを ECR にプッシュします

```elixir
System.cmd(
  "docker",
  [
    "push",
    fullname
  ]
)
```

ECR 上のイメージ一覧でプッシュできたことを確認します

```elixir
client
|> AWS.ECR.list_images(%{
  "repositoryName" => image
})
|> elem(1)
|> then(& &1["imageIds"])
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-22 17.35.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/ada5af53-c0fb-400e-2d72-2cd38cf7b0bf.png)

imageTag が latest のイメージが1件できています

## Lambda関数の作成

必要なパラメーターを指定して Lambda 関数を作成します

```elixir
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 ではコンテナイメージによる関数作成が実装されていませんでした

以下のコマンドで作成した関数の情報を取得することができます

```elixir
AWS.Lambda.get_function(client, "resnet")
```

![スクリーンショット 2022-12-22 17.37.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/916bd8e6-9302-83ea-f3bc-204507b9ec8a.png)

`State` が `Pending` や `Creating` の場合はまだ関数を実行できません

`State` が `Active` になれば実行可能です

## Lambda 関数呼出

テスト画像を使って Lambda 関数を呼び出してみます

```elixir
binary =
  "https://raw.githubusercontent.com/pjreddie/darknet/master/data/dog.jpg"
  |> Req.get!()
  |> then(& &1.body)
```

![スクリーンショット 2022-12-19 0.23.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/4f45203f-d88a-0c2c-430a-ba0455923225.png)

確認のため、バイナリーを画像として表示します

```elixir
binary
|> StbImage.read_binary!()
|> StbImage.to_nx()
|> Kino.Image.new()
```

![スクリーンショット 2022-12-19 0.25.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/be7fbddc-b0c4-f38c-c39a-8787ec06572e.png)

バイナリーを BASE64 エンコーディングしてから Lambda 関数に渡します

```elixir
"resnet"
|> ExAws.Lambda.invoke(%{"Payload" => Base.encode64(binary)}, %{})
|> ExAws.request!(auth_config)
|> then(& &1["body"]["predictions"])
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-22 18.01.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/a98e7d40-4bad-3da2-401d-365a367ae8b2.png)

ちゃんと実行できました

別の画像でも試してみましょう

```elixir
image_input = Kino.Input.image("INPUT_IMAGE", format: :jpeg)
```

![スクリーンショット 2022-12-19 0.30.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/35fdf53e-36e3-fef2-749b-26e7f35f0295.png)

```elixir
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()
```

![スクリーンショット 2022-12-22 18.06.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/9b18a6ec-0c56-acd7-725d-1bf3d6130026.png)

## ログ確認

Lambda のログを見てみましょう

まず直近のログストリーム名を取得します

```elixir
log_stream_name =
  client
  |> AWS.CloudWatchLogs.describe_log_streams(%{
    "logGroupName" => "/aws/lambda/resnet"
  })
  |> elem(1)
  |> then(& &1["logStreams"])
  |> Enum.at(-1)
  |> then(& &1["logStreamName"])
```

![スクリーンショット 2022-12-22 18.18.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c6fa6e48-e554-87ba-2893-54ef648daf19.png)

ログメッセージを取得します

```elixir
client
|> AWS.CloudWatchLogs.get_log_events(%{
  "logGroupName" => "/aws/lambda/resnet",
  "logStreamName" => log_stream_name
})
|> elem(1)
|> then(& &1["events"])
|> Enum.map(& &1["message"])
```

![スクリーンショット 2022-12-22 18.19.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/df52c543-1ef2-b6f1-6034-a98d8311fe49.png)

```elixir
["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関数削除

```elixir
"resnet"
|> ExAws.Lambda.delete_function()
|> ExAws.request!(auth_config)
```

### IAM ロール削除

```elixir
client
|> AWS.IAM.detach_role_policy(%{
  "RoleName" => "lambda-resnet-role",
  "PolicyArn" => "arn:aws:iam::#{account_id}:policy/lambda-resnet-role-policy"
})
```

```elixir
client
|> AWS.IAM.delete_policy(%{
  "PolicyArn" => "arn:aws:iam::#{account_id}:policy/lambda-resnet-role-policy"
})
```

```elixir
client
|> AWS.IAM.delete_role(%{
  "RoleName" => "lambda-resnet-role"
})
```

### ECR リポジトリー削除

ECR リポジトリーは空でないと削除できないため、中に含まれるイメージを一括削除する必要があります

```elixir
image_ids =
  client
  |> AWS.ECR.list_images(%{
    "repositoryName" => image
  })
  |> elem(1)
  |> then(& &1["imageIds"])
```

```elixir
client
|> AWS.ECR.batch_delete_image(%{
  "repositoryName" => image,
  "imageIds" => image_ids
})
```

```elixir
client
|> AWS.ECR.delete_repository(%{
  "repositoryName" => image
})
```

## まとめ

Lambda も Elixir で動かせる事が確認できたので、本当に全部 Elixir にしたいときには使えそうです

例えば基本的には Phoenix LiveView で動かしつつ、重い AI 処理は SageMaker に渡し、一部の S3 アップロードなどをトリガーにした処理は Lambda で動かす、そしてそれら全てを Elixir で書く、ということができるわけですね