LoginSignup
12
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-12-22

はじめに

前回の続きで、ローカルでビルドしたコンテナを 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")

スクリーンショット 2022-12-03 0.01.54.png

各認証情報を使って、 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)
  )

スクリーンショット 2022-12-05 14.21.03.png

また、 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
  ]
)

スクリーンショット 2022-12-18 23.45.52.png

前回定義したコンテナに 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()

スクリーンショット 2022-12-22 17.35.37.png

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")

スクリーンショット 2022-12-22 17.37.24.png

StatePendingCreating の場合はまだ関数を実行できません

StateActive になれば実行可能です

Lambda 関数呼出

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

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

スクリーンショット 2022-12-19 0.23.56.png

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

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

スクリーンショット 2022-12-19 0.25.00.png

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

"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

ちゃんと実行できました

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

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

スクリーンショット 2022-12-19 0.30.40.png

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

ログ確認

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"])

スクリーンショット 2022-12-22 18.18.40.png

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

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

["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 で書く、ということができるわけですね

12
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
2