Elixir Phoenix と Bumblebee による画像分類を Amazon SageMaker 上でマイクロサービス化する

## はじめに

前回、前々回で Phoenix と Bumblebee による画像分類 REST API をコンテナで実装しました

https://qiita.com/RyoWakabayashi/items/04f220e64db006bf1400

https://qiita.com/RyoWakabayashi/items/470e9ca9bd17a71be39d

今回はこのコンテナを Amazon SageMaker にデプロイし、 AWS 上のシステムに組み込めるマイクロサービスにします

ただし、今回は CPU インスタンスで、 GPU は使用しません

## Amazon SageMaker とは

SageMaker はソーセージを作る機械のように、簡単に AI システムを実装できてしまう AWS のサービスです

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

ノートブックインスタンスやトレーニングなど、さまざまな機能がありますが、今回はリアルタイム推論のエンドポイントを作ります

実装はこちら

コンテナ定義

https://github.com/RyoWakabayashi/elixir-learning/tree/main/sagemaker/serve

SageMaker 操作

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

## 実装の流れ

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

  - ECR にリポジトリーを作成
  - Dockerfile で　 REST API として機能するコンテナを定義
  - コンテナのイメージを ECR のリポジトリーにプッシュ

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

- S3 にモデルファイルを保存する

  - モデルファイルを .tar.gz に圧縮する
  - S3 上にアップロードする

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

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

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

- SageMaker でモデルを作成する
  - ECR のイメージを指定
  - S3 のモデルファイルを指定
  - IAM の実行ロールを指定

- SageMaker でエンドポイント設定を作成する
  - SageMaker のモデルを指定
  - インスタンスタイプを指定

- SageMaker でエンドポイントを作成する
  - SageMaker のエンドポイント設定を指定

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

## 事前作業

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

## セットアップ

Livebook を開き、以下のコードを実行します

```elixir
Mix.install([
  {:aws, "~> 0.13"},
  {:ex_aws, "~> 2.3"},
  {:ex_aws_sts, "~> 2.3"},
  {:ex_aws_s3, "~> 2.3"},
  {:req, "~> 0.3"},
  {:nx, "~> 0.4"},
  {:stb_image, "~> 0.6"},
  {:hackney, "~> 1.18"},
  {:sweet_xml, "~> 0.7"},
  {:kino, "~> 0.8"}
])
```

`ex_aws` だけでは SageMaker 周りの実装が不十分だったため、 `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)
```

## ECR リポジトリー作成

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

```elixir
region = Kino.Input.read(region_input)
image = "sagemaker-phoenix"
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(%{})
```

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

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

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

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

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

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

```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-18 23.50.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b4ce4b9c-adda-7d82-0fa2-4bc06a1ca675.png)

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

## S3 へのモデルファイルアップロード

### アップロード先の指定

バケットの一覧を確認します

```elixir
ExAws.S3.list_buckets()
|> ExAws.request!(auth_config)
|> then(& &1.body.buckets)
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-18 23.52.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/cffc785f-33b4-740a-dd04-98fa3db49564.png)

アップロード先のバケットを入力します

```elixir
bucket_name_input = Kino.Input.text("BUCKET_ANME")
```

![スクリーンショット 2022-12-18 23.53.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/84ec9603-6bed-649c-6159-9e466525d665.png)

バケット名とバケット上のパスを指定します

```elixir
bucket_name = Kino.Input.read(bucket_name_input)
model_prefix = "models/"
```

### モデルファイルの圧縮

モデルファイルをダウンロードしておくため、先にコンテナで1回動かしておいてください

SageMaker で読み込む場合、モデルファイルは `.tar.gz` の形式で圧縮する必要があります

圧縮対象のファイル一覧を準備します

`/sagemaker/serve/models` が Bumblebee のキャッシュディレクトリー（モデルファイルの保存先）です

このとき、 `{<相対パス>、<絶対パス>}` のタプル配列にしておきます

```elixir
models_path = "/sagemaker/serve/models"

filenames =
  models_path
  # 指定ディレクトリー配下のファイル一覧
  |> File.ls!()
  |> Enum.map(fn filename ->
    {
      # 相対パス
      to_charlist(filename),
      # 絶対パス
      to_charlist(Path.join(models_path, filename))
    }
  end)
```

`:erl_tar.create` で指定したファイル一覧を `models.tar.gz` に圧縮します

このとき、 `[:compressed]` を指定しないと `.tar` の形式になってしまいます

```elixir
tar_filename = "models.tar.gz"

:erl_tar.create(tar_filename, filenames, [:compressed])
```

### アップロードの実行

`ExAws.S3.upload` を使って圧縮ファイルを S3 上にアップロードします

```elixir
tar_filename
|> ExAws.S3.Upload.stream_file()
|> ExAws.S3.upload(bucket_name, model_prefix <> tar_filename)
|> ExAws.request!(auth_config)
```

アップロードされたことを確認します

```elixir
bucket_name
|> ExAws.S3.list_objects(prefix: model_prefix)
|> ExAws.request!(auth_config)
|> then(& &1.body.contents)
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-19 0.02.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/4561fdef-8d83-2bc6-f712-d7f5ee61340b.png)

## IAM ロールの定義

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

```elixir
client
|> AWS.IAM.create_role(%{
  "RoleName" => "sagemaker-phoenix-role",
  "AssumeRolePolicyDocument" =>
    Jason.encode!(%{
      "Statement" => [
        %{
          "Sid" => "STS202201051440",
          "Effect" => "Allow",
          "Principal" => %{
            "Service" => ["sagemaker.amazonaws.com"]
          },
          "Action" => "sts:AssumeRole"
        }
      ]
    })
})
```

ログ出力や ECR リポジトリー、S3バケットへのアクセス権限をポリシーとして定義します

```elixir
client
|> AWS.IAM.create_policy(%{
  "PolicyName" => "sagemaker-phoenix-role-policy",
  "PolicyDocument" =>
    Jason.encode!(%{
      "Version" => "2012-10-17",
      "Statement" => [
        %{
          "Effect" => "Allow",
          "Action" => [
            "cloudwatch:PutMetricData",
            "logs:CreateLogStream",
            "logs:PutLogEvents",
            "logs:CreateLogGroup",
            "logs:DescribeLogStreams",
            "ecr:GetAuthorizationToken"
          ],
          "Resource" => ["*"]
        },
        %{
          "Effect" => "Allow",
          "Action" => [
            "s3:GetObject"
          ],
          "Resource" => [
            "arn:aws:s3:::#{bucket_name}/*"
          ]
        },
        %{
          "Effect" => "Allow",
          "Action" => [
            "ecr:BatchCheckLayerAvailability",
            "ecr:GetDownloadUrlForLayer",
            "ecr:BatchGetImage"
          ],
          "Resource" => [
            "arn:aws:ecr:::repository/#{image}"
          ]
        }
      ]
    })
})
```

ポリシーが正しく作成されたことを確認します

```elixir
client
|> AWS.IAM.get_policy_version(%{
  "PolicyArn" => "arn:aws:iam::#{account_id}:policy/sagemaker-phoenix-role-policy",
  "VersionId" => "v1"
})
|> elem(1)
|> then(& &1["GetPolicyVersionResponse"]["GetPolicyVersionResult"]["PolicyVersion"]["Document"])
|> URI.decode()
|> Jason.decode!()
```

![スクリーンショット 2022-12-19 0.08.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/3060140c-b69d-268b-b5df-25852f9cca7e.png)

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

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

## SageMaker モデル作成

SageMaker のモデルを作成します

作成した IAM ロール、 ECR リポジトリー、 S3 上のモデルファイルパスを指定します

```elixir
client
|> AWS.SageMaker.create_model(%{
  "ModelName" => "sagemaker-phoenix-model",
  "ExecutionRoleArn" => "arn:aws:iam::#{account_id}:role/sagemaker-phoenix-role",
  "PrimaryContainer" => %{
    "Image" => fullname,
    "ModelDataUrl" => "s3://#{bucket_name}/#{model_prefix}#{tar_filename}"
  }
})
```

## SageMaker エンドポイント設定作成

SageMaker のエンドポイント設定を作成します

先程作成したモデル名、インスタンスタイプなどを指定します

今回は ResNet なので小さいインスタンス（ml.t2.medium）で十分です

ある程度重いモデルを高速に動作させるためには GPU が必要になるため、 GPU 用のコンテナイメージを定義した上で ml.g4dn.xlarge などのインスタンスタイプを指定します

```elixir
client
|> AWS.SageMaker.create_endpoint_config(%{
  "EndpointConfigName" => "sagemaker-phoenix-endpoint-config",
  "ProductionVariants" => [
    %{
      "VariantName" => "variant-1",
      "ModelName" => "sagemaker-phoenix-model",
      "InstanceType" => "ml.t2.medium",
      "InitialInstanceCount" => 1,
      "InitialVariantWeight" => 1
    }
  ]
})
```

## SageMaker エンドポイント作成

エンドポイント設定を指定してエンドポイントを作成します

エンドポイント作成は非同期で行われるため、:ok が返ってきてもしばらくは作成中です

内部的には ECR からのコンテナイメージプル、コンテナの起動、 S3 からのモデルファイルダウンロード、展開などが実行されています

```elixir
client
|> AWS.SageMaker.create_endpoint(%{
  "EndpointName" => "sagemaker-phoenix-endpoint",
  "EndpointConfigName" => "sagemaker-phoenix-endpoint-config"
})
```

エンドポイントの状況を確認します

この結果が `Creating` のうちはまだ作成中です `InService` になれば正常に起動できています

```elixir
client
|> AWS.SageMaker.describe_endpoint(%{
  "EndpointName" => "sagemaker-phoenix-endpoint"
})
|> elem(1)
|> then(& &1["EndpointStatus"])
```

![スクリーンショット 2022-12-19 0.17.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c6471ff6-d3b6-3cc0-01e3-cd292d12df32.png)

## SageMaker エンドポイント呼出

SageMaker のエンドポイントは AWS のサービスのように、マイクロサービスとして呼び出すことができます

テスト画像を使ってエンドポイントを呼び出してみます

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

バイナリーを SageMaker のエンドポイントに渡して推論を実行します

```elixir
client
|> AWS.SageMakerRuntime.invoke_endpoint(
  "sagemaker-phoenix-endpoint",
  %{
    "Body" => binary,
    "ContentType" => "image/jpeg"
  }
)
|> elem(1)
|> then(& &1["Body"])
|> Jason.decode!()
|> then(& &1["predictions"])
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-19 0.26.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/2f7e8cb6-5236-56ad-5706-9fcd809eb222.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)

client
|> AWS.SageMakerRuntime.invoke_endpoint(
  "sagemaker-phoenix-endpoint",
  %{
    "Body" => binary,
    "ContentType" => "image/jpeg"
  }
)
|> elem(1)
|> then(& &1["Body"])
|> Jason.decode!()
|> then(& &1["predictions"])
|> Kino.DataTable.new()
```

![スクリーンショット 2022-12-19 0.34.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1d1da25a-e3c8-7922-4937-bf1e2d07784f.png)

ちゃんと分類できているようです

モデルを変えたりすれば、 Bumblebee で提供されている機能は全てマイクロサービス化できるわけです

この SageMaker エンドポイントを Lambda などから呼び出せばシステムに組み込めます

## 後片付け

SageMaker エンドポイントや ECR リポジトリー、 S3 上のファイルは存在している間課金されるので、使い終わったら削除しておきます

### SageMaker エンドポイント削除

```elixir
client
|> AWS.SageMaker.delete_endpoint(%{
  "EndpointName" => "sagemaker-phoenix-endpoint"
})
```

```elixir
client
|> AWS.SageMaker.delete_endpoint_config(%{
  "EndpointConfigName" => "sagemaker-phoenix-endpoint-config"
})
```

```elixir
client
|> AWS.SageMaker.delete_model(%{
  "ModelName" => "sagemaker-phoenix-model"
})
```

### IAM ロール削除

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

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

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

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

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

リポジトリーが空になったことの確認

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

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

### モデルファイル削除

```elixir
ExAws.S3.delete_object(bucket_name, model_prefix <> tar_filename)
|> ExAws.request!(auth_config)
```

## まとめ

Elixir で SageMaker というのは構想していたことではありますが、まさかこんなに早く簡単に実装できてしまうとは

Bumblebee の登場は想像を上回る出来事でした