SageMaker+APIGateway+Lambdaで作るサーバレスアプリケーション~③(完)

SageMaker+APIGateway+Lambdaで作るサーバレスアプリケーション~③(完)
この記事をシェアする

はじめに

こんにちは!スカイアーチHRソリューションズのnakaoです!

皆さん、サーバレス開発してますか!?
この記事は「SageMaker+APIGateway+Lambdaで作るサーバレスアプリケーション~②」の続きです!
今回は前回までの集大成です。前回までで作成したアーキテクチャのClient部分(ユーザー画面)を作りこんで、一つのサービスとして連携させてみたいと思います!
全体のアーキテクチャはこちらです!
前回のアーキテクチャと異なる点は、今回はClientとしてCloudFront+S3構成を使用しました。
※今回はRoute53は使用しません。独自ドメインを試してみたい方はCloudFrontの前にRoute53+ACM構成を置くとより本格的なアプリケーションになると思います!
オリジンリソースとしてはVue.jsで構成されるSPAを配置しました。

CloudFrontとは

CloudFront は、ユーザーへの静的および動的なウェブコンテンツ (.html、.css、.js、イメージファイルなど) の配信を高速化するコンテンツ配信ネットワーク (CDN) サービスです。
CloudFront+S3の構成で静的なウェブコンテンツを低レイテンシーかつ高速な転送速度でコンテンツを安全に配信可能です。

詳細に関しては以下、公式ドキュメントを参照してください。

SageMakerエンドポイントの復元

前回作成したSageMakerエンドポイントを削除された方はSageMakerエンドポイントの復元を行います。
※SageMakerエンドポイントを削除されていない方は次に進んでください。
「SageMaker」サービスを検索して、左タブの「エンドポイント」から「エンドポイントの作成」を選択します。
「既存のエンドポイント設定の使用」を選択し、以前作成したエンドポイントの設定を選択することで、前回と同じエンドポイントを作成することができます。

SageMakerエンドポイントの新規作成

今回私はエンドポイントの設定も削除していたため、「SageMaker+APIGateway+Lambdaで作るサーバレスアプリケーション~①」に記載のJupyter Notebook(Image-classification-lst-format.ipynb)からエンドポイントを再作成しようとしましたが、Notebook自体が公式から削除されてしまったようです。

したがって、今回私は別の方法でエンドポイントのデプロイまで実施します。
SageMaker StudioのSageMaker JumpStartを利用します。「Image Classification」カテゴリ内の「MobileNet V3 Large 1.00 224」を選択してください。
※SageMaker Studio、およびSageMaker JumpStartについては別記事で紹介させていただきたいと思います。

「Deploy Model」の「Deployment Configuration」から、エンドポイントのインスタンスタイプを「ml.m5.large」に変更して、「Deploy」ボタンを押下してください。しばらくして、エンドポイントが作成されます。Lambdaの環境変数を新しく作成したエンドポイント名で書き換えておきましょう。

CORSの設定

今回、S3に配置する静的ウェブコンテンツ(Vue.js)からAPI Gateway+LambdaにAPIリクエストを投げます。
したがって、API GatewayとLambdaにCORSの設定が新しく必要になります。

LambdaのCORS設定

前回の記事「SageMaker+APIGateway+Lambdaで作るサーバレスアプリケーション~②」で作成したLambdaのソースコードを以下、コピペで置き換えてください。

import json
import boto3
import base64
import os
import traceback
import logging
from datetime import datetime, timedelta, timezone
import numpy as np

logger = logging.getLogger()
logger.setLevel(logging.INFO)

s3 = boto3.resource('s3')
bucket = s3.Bucket(os.environ['UPLOAD_BUCKET'])  # 環境変数に設定


def upload_s3(image_body: bytes) -> bool:
    """S3に画像をアップロートする関数

    Args:
        image_base64 (bytes): Base64でデコードされたバイナリデータ

    Returns:
        bool: S3へのアップロードに成功した場合はTrue、失敗した場合はFalse
    """
    try:
        JST = timezone(timedelta(hours=9))
        time = datetime.now(tz=JST).strftime('%Y%m%d_%H%M%S')
        key = 'upload_jpg/' + time + '.jpg'

        # 画像をs3にアップロード
        upload_s3_result = bucket.put_object(Body=image_body, Key=key)
        logger.info('upload_s3_result')
        logger.info(upload_s3_result)
        return True
    except Exception:
        logger.error(traceback.format_exc())
        return False


def lambda_handler(event, context):
    ret_dict = {
        'statusCode': 200,
        'headers': {
            "Access-Control-Allow-Origin": '*',
            'Access-Control-Allow-Headers': 'Content-Type',
            'Access-Control-Allow-Methods': 'OPTIONS,POST,GET'
        }
    }
    try:
        # lambdaに渡されるときはリクエストボディの内容がBase64エンコードされてlambdaに渡される
        # base64をデコードしてバイナリデータに戻す
        image_body = base64.b64decode(event['body'])
        # S3に画像のアップロード処理
        upload_s3_ret = upload_s3(image_body)
    except Exception:
        logger.error(traceback.format_exc())
        ret_dict['statusCode'] = 400
        ret_dict['body'] = json.dumps('image upload failed to s3 bucket.')

    # 画像のアップロードに成功したらSageMakerエンドポイントで推論実行
    if upload_s3_ret:
        try:
            # ラインタイムを使用したsageMakerエンドポイント呼び出し

            # 作成済みのエンドポイント指定
            endpoint_name = os.environ['SAGEMAKER_ENDPOINT']  # 環境変数に設定

            # ランタイムの開始
            client = boto3.client('runtime.sagemaker')
            
            # エンドポイント推論
            response = client.invoke_endpoint(EndpointName=endpoint_name, ContentType='application/x-image', Body=image_body, Accept='application/json;verbose')
            
            model_predictions = json.loads(response['Body'].read())
            
            predicted_label = model_predictions['predicted_label']
            
            labels = model_predictions['labels']
            
            probabilities = model_predictions['probabilities']
            
            # 推論結果のindex取得
            index = np.argmax(probabilities)
            
            result_categories = "Result: label - " + predicted_label + ", probability - " + str(probabilities[index])
            logger.info(result_categories)
            ret_dict['body'] = json.dumps(result_categories)
            
        except Exception:
            logger.error(traceback.format_exc())
            ret_dict['statusCode'] = 500
            ret_dict['body'] = json.dumps('Error inferring SageMaker endpoint.')

    return ret_dict

具体的な修正箇所ですが、lambda_handler関数内でheadersを付与してreturnするようにします。
また、今回SageMakerエンドポイントを新規作成するにあたって、Lambdaソースコードも一部修正しています。
適宜修正していただけたらと思います。

LambdaのCORS設定に関しては以上です。
以下、参考にした記事です。

API GatewayのCORS設定

次にAPI GatewayのCORS設定を実施します。
APIの「アクション」から「CORSの有効化」を選択します。

デフォルト設定のまま、「CORSを有効にして既存のCORSヘッダーを置換」を押下してください。
設定が完了したら、APIのデプロイを忘れずに行ってください。

API GatewayのCORS設定に関しては以上です。
以下、参考にした記事です。

CloudFront+S3の作成まで

静的ウェブコンテンツ(Vue.js)を配置するCloudFront+S3を作成していきます。

S3の作成

まずはS3のバケットを作成します。
バケット名のみ入力して、あとはデフォルトの設定のまま作成していきましょう。

次に静的ウェブサイトホスティングの機能を有効化します。
先ほど作成したバケットの「プロパティ」タブを選択し、「静的ウェブサイトホスティング」の「編集」を押下してください。

「静的ウェブサイトホスティング」を「有効にする」を選択してください。
また、インデックスドキュメントには「index.html」と入力して、変更を保存してください。

CloudFrontの作成

次にCloudFrontの設定を行います。
「ディストリビューションを作成」を押下して、ディストリビューションを作成していきます。

「オリジンドメイン」には先ほど作成したバケットを選択します。
オリジンアクセスにはオリジンアクセスコントロール(OAC)を選択します。
S3バケットへの直接アクセスを禁止し、CloudFront経由でしかアクセス許可を行わない設定にするためです。
また、「コントロール設定を作成」を押下して、新規でオリジンアクセスコントロールを作成しておきましょう。

「ビューワープロトコルポリシー」は「Redirect HTTP to HTTPS」に変更します。
CloudFrontへのアクセスは全てhttps付きのURLに誘導するためです。

ウェブアプリケーションファイアウォール(WAF)の設定は今回行いません。
「デフォルトルートオブジェクト」に「index.html」と入力してください。
あとはデフォルト設定のまま、「ディストリビューションを作成」を押下してください。

CloudFrontの作成に関しては以上です。
しばらく待つとディストリビューションが作成されるので、「ディストリビューション ID」は控えておきましょう。
次のS3のバケットポリシーの設定で使用します。

S3のバケットポリシーの設定

CloudFront+S3を作成しましたので、最後にS3のバケットポリシーの設定を変更しておきます。
先ほど作成したバケットの「アクセス許可」タブを選択し、「バケットポリシー」を以下、コピペしてください。
オリジンアクセスコントロール(OAC) がバケットのコンテンツにアクセスできるようにします。
<S3 bucket name>には作成したバケット名、<AWS アカウント ID>、<CloudFront distribution ID>はそれぞれ書き換えて設定するようにしてください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowCloudFrontServicePrincipalReadOnly",
            "Effect": "Allow",
            "Principal": {
                "Service": "cloudfront.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::<S3 bucket name>/*",
            "Condition": {
                "StringEquals": {
                    "AWS:SourceArn": "arn:aws:cloudfront::<AWS アカウント ID>:distribution/<CloudFront distribution ID>"
                }
            }
        }
    ]
}

CloudFront+S3に関しては以上です。
以下、参考にした記事です。

静的ウェブコンテンツ(Vue.js)の作成まで

静的ウェブコンテンツ(Vue.js)を作成していきます。
簡単にS3に静的ウェブコンテンツを配置するために、Vue CLIを利用します。
今回、ローカル環境で環境構築を行いました。同じことがCloud9でも可能です。
Cloud9で構築した方がAWSサービスのみで簡潔できるので、そちらの方が良かったと今更後悔しています。
私はWindows環境で実施していますが、基本的なやり方はmacも変わらないと思います。

Vue CLIの環境構築

まず、node.jsをpcにインストールします。npmコマンドを使えるようにするためです。
以下公式サイトからインストール用ファイルをダウンロードして実行しましょう。

インストール完了後に以下コマンドでnode.jsおよびnpmバージョンが確認できれば成功です。
※バージョンに多少の差異は出るかもしれませんが、問題ないのでそのまま進めてください。
確認できない場合pcの再起動など試してみてください。

node --version
v18.17.1
npm --version
9.6.7

次に、以下コマンドでVue CLIをグローバルインストールします。

npm install -g @vue/cli

インストール完了後に以下コマンドでvueのバージョンが確認できれば成功です。

vue --version
@vue/cli 5.0.8

Vue プロジェクトの作成

次に、Vueプロジェクトを作成していきます。以下コマンドでプロジェクトが作成できます。
今回、「vue-ai-app」というプロジェクト名で作成しますが、適宜好きなプロジェクト名に変更してください。
作成中にVue 2かVue 3を選択する表示が出ますが、デフォルトのまま、Vue 3でプロジェクトを作成してください。

vue create vue-ai-app

作成したプロジェクトのフォルダ内に移動して、以下コマンドで開発用サーバーを起動することができます。

cd vue-ai-app
npm run serve

axiosをインストールする

今回、APIへの非同期通信のためにaxiosを利用します。
axiosを以下コマンドでローカルインストールしてください。
カレントディレクトリは先ほど作成したプロジェクト直下で実行します。

npm install axios

Vue ソースコード修正

プロジェクトのフォルダ構成は以下のようになっていると思います。
修正していただきたいのは以下4点です。
・「vue-ai-app/public/index.html」
・「vue-ai-app/src/App.vue」
・「vue-ai-app/src/main.js」
・「vue-ai-app/src/components」フォルダの削除

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

「vue-ai-app/src/App.vue」の<API Gateway URL>は各自作成したAPI GatewayのURLを控えて記述してください。

<template>
  <div id="app">
    <p><input type="file" accept="image/*" v-on:change="onFileChange"></p>
      <div v-if="image">
        <img :src="image" alt="image">
        <p><button v-on:click="uploadImage">アップロード</button></p>
      </div>
        <h2>{{ result_sagemaker_endpoint }}</h2>
  </div>
</template>

<script>
import axios from 'axios';
const url ='<API Gateway URL>';
export default {
  data: () => ({
      image: '',
      selectedFile: '',
      result_sagemaker_endpoint: '',
  }),
  methods: {
    onFileChange: function(event) {
      let files = event.target.files;
      if (!files.length) {
        return;
      }
      this.previewImage(files[0]); 
    },
    previewImage: function(file) {
      let reader = new FileReader();
      // ファイル読み込み完了後の処理
      reader.onload = e => {
        this.image = e.target.result;
        this.selectedFile = file;
      };
      reader.readAsDataURL(file);
    },
    uploadImage: async function() {
      let config = {
        headers: {
          'content-type': 'image/png'
        }
      };
      try {
        let res = await axios.post(url, this.selectedFile, config)
        if (res.status == 200) {
          console.log(res);
          console.log(`SUCCESS! HTTP Status: ${res.status}`)
          this.result_sagemaker_endpoint = res.data;
        }
      } catch (error) {
        console.log(error);
        let error_res = error.response;
        console.log(`ERROR! HTTP Status: ${error_res.status}`);
        console.log(`ERROR! message: ${error_res.data}`);
        this.result_sagemaker_endpoint = error_res.data;
      }
    }
  }
}
</script>

<style>

</style>
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

ビルド

次はS3にこのソースコードを配置するためにビルドを行います。
以下コマンドを入力してください。ビルドが成功するとdistディレクトリがプロジェクト直下に作成されます。

npm run build

S3にdistフォルダ以下を配置

最後にCloudFront+S3で作成したS3にdistフォルダ以下を配置します!
ビルドしたらそのままフォルダを配置するだけでいいので、とても便利ですよね!

ブラウザからリクエスト、レスポンスの確認

さあ、とうとうここまできましたね!
今回作成したCloudFrontの「ディストリビューションドメイン名」をブラウザのURL欄に入力してアクセスしてみましょう!!!
私はGoogle Chromeを使用します。

まずはアクセス成功です!
URLの左に鍵マークもついてます。httpsアクセスもできていますね。
画面右側には開発者ツールを表示させています。
それでは「ファイルを選択」から推論させたい画像を選択してみます。

ゴールデンレトリバーの画像を選択しました。
「アップロード」ボタンが表示されています。次に、「アップロード」を押下します。

推論成功です!!!
推論結果も95%ゴールデンレトリバーだと分類できています!!!
今回はCSSなどデザインをあててないのでとてもシンプルですが、作り込めばもっと本格的なアプリケーションができますね!

おわりに

お疲れ様でした!サーバーレスアプリケーションの完成です。
3回に渡る長い記事をここまで読んでいただき、ありがとうございました。
SageMakerエンドポイントの削除をお忘れなく!
インフラリソースの自動構築、フロントエンドのデザインや、認証・認可の追加などアプリケーションとしては足りない部分はまだまだありますが、
当初から作りたかったものは一旦、全て作れました。
インフラ、バックエンド、フロントエンド、それぞれの分野の理解が少しでも深まると思うので是非皆さんも手を動かしてアプリケーションを試してみてください!

私の記事が少しでも皆様のご参考になれば幸いです!

この記事をシェアする
著者:nakao
IoT、サーバーレスな開発に興味深々。AWSエンジニア。