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

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

はじめに

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

皆さん、サーバレス開発してますか!?
この記事は「SageMaker+APIGateway+Lambdaで作るサーバレスアプリケーション~①」の続きです!
今回は前回作成したSageMakerエンドポイントをAPIGateway、Lambdaと連携させてみたいと思います!
全体のアーキテクチャはこちらです!
前回のアーキテクチャと異なる点は、今回はClientとしてAPIGatewayにリクエスト、レスポンスを投げるためにCloud9を使用したところです。Postmanなど外部サービスを使っても良いのですが、せっかくなのでAWSサービスの紹介も含めてCloud9を疑似Clientとして今回は使用しました。
Clientの作りこみに関しては次回、記事にしたいと思います!

AWS Lambdaとは

サーバーレス開発に欠かせない必須のサービスがLambdaです!
サーバーレスでイベント駆動型のコンピューティングサービスであり、サーバーのプロビジョニングや管理をすることなく、あらゆるアプリケーションやバックエンドサービスのコードを実行できます。
今回はClientからアップロードした画像をAPIGatewayから受け取る処理、S3に保存する処理、SageMakerエンドポイントと連携する処理など他サービスとの連携役として使用します。

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

Amazon API Gatewayとは

サーバレスアプリケーション開発にはLambdaと合わせて欠かせないのがAPIGatewayです!
フルマネージド型サービスのAPIGatewayを利用すれば、簡単にAPIの作成、公開などが可能です。
今回はClientからの受け口としてAPIGatewayを利用します。

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

Amazon Simple Storage Service(Amazon S3)とは

ご存じの方も多いと思うので、説明は不要かもしれません!
S3はデータを格納・管理できるオブジェクトストレージサービスです!
今回はClientからアップロードした画像を保存する保存先としてS3を使用します。

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

AWS Cloud9とは

Cloud9とはクラウドベースの統合開発環境(IDE)です!
コードの記述、実行、デバックなども可能です!
今回は疑似ClientとしてCloud9を使用しましたが、使用したのはcurlコマンドをAPIGatewayにリクエスト、レスポンスを確認するためのターミナルのみです。

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

SageMakerエンドポイントの復元

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

S3の作成まで

それではさっそくS3の作成から実施してみましょう!
「S3」サービスを検索して、バケットを作成します。
バケット名のみ入力して、あとはデフォルトの設定のまま作成していきましょう。

S3の作成に関しては以上です。

Lambdaの作成まで

Lambdaの作成

次はLambdaを作成していきましょう。
「Lambda」サービスを検索して、 関数の作成を行います。
関数名を入力し、ランタイムは「Python 3.9」を選びます。あとは デフォルトの設定のまま作成していきましょう。

Lambdaのコード

Lambdaのソースコードを記述します。以下、コピペしてください。
簡単にコードの説明をすると、以下の順番で実行されます。

①APIGatewayから受け取ったデータをバイナリデータに変換する。
②S3に画像のアップロード処理を行う。
③画像のアップロード処理に成功したらSageMakerエンドポイントに①で受け取ったデータをリクエストする。
④SageMakerエンドポイントからのレスポンスをReturnする。

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
    }
    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']  # 環境変数に設定

            # ランタイムの開始
            runtime = boto3.Session().client(service_name="runtime.sagemaker")

            payload = bytearray(image_body)
            response = runtime.invoke_endpoint(
                EndpointName=endpoint_name, ContentType="application/x-image", Body=payload
            )
            result = response["Body"].read()
            # result will be in json format and convert it to ndarray
            result = json.loads(result)
            # the result will output the probabilities for all classes
            # find the class with maximum probability and print the class index
            index = np.argmax(result)

            object_categories = [
                "ak47",
                "american-flag",
                "backpack",
                "baseball-bat",
                "baseball-glove",
                "basketball-hoop",
                "bat",
                "bathtub",
                "bear",
                "beer-mug",
                "billiards",
                "binoculars",
                "birdbath",
                "blimp",
                "bonsai-101",
                "boom-box",
                "bowling-ball",
                "bowling-pin",
                "boxing-glove",
                "brain-101",
                "breadmaker",
                "buddha-101",
                "bulldozer",
                "butterfly",
                "cactus",
                "cake",
                "calculator",
                "camel",
                "cannon",
                "canoe",
                "car-tire",
                "cartman",
                "cd",
                "centipede",
                "cereal-box",
                "chandelier-101",
                "chess-board",
                "chimp",
                "chopsticks",
                "cockroach",
                "coffee-mug",
                "coffin",
                "coin",
                "comet",
                "computer-keyboard",
                "computer-monitor",
                "computer-mouse",
                "conch",
                "cormorant",
                "covered-wagon",
                "cowboy-hat",
                "crab-101",
                "desk-globe",
                "diamond-ring",
                "dice",
                "dog",
                "dolphin-101",
                "doorknob",
                "drinking-straw",
                "duck",
                "dumb-bell",
                "eiffel-tower",
                "electric-guitar-101",
                "elephant-101",
                "elk",
                "ewer-101",
                "eyeglasses",
                "fern",
                "fighter-jet",
                "fire-extinguisher",
                "fire-hydrant",
                "fire-truck",
                "fireworks",
                "flashlight",
                "floppy-disk",
                "football-helmet",
                "french-horn",
                "fried-egg",
                "frisbee",
                "frog",
                "frying-pan",
                "galaxy",
                "gas-pump",
                "giraffe",
                "goat",
                "golden-gate-bridge",
                "goldfish",
                "golf-ball",
                "goose",
                "gorilla",
                "grand-piano-101",
                "grapes",
                "grasshopper",
                "guitar-pick",
                "hamburger",
                "hammock",
                "harmonica",
                "harp",
                "harpsichord",
                "hawksbill-101",
                "head-phones",
                "helicopter-101",
                "hibiscus",
                "homer-simpson",
                "horse",
                "horseshoe-crab",
                "hot-air-balloon",
                "hot-dog",
                "hot-tub",
                "hourglass",
                "house-fly",
                "human-skeleton",
                "hummingbird",
                "ibis-101",
                "ice-cream-cone",
                "iguana",
                "ipod",
                "iris",
                "jesus-christ",
                "joy-stick",
                "kangaroo-101",
                "kayak",
                "ketch-101",
                "killer-whale",
                "knife",
                "ladder",
                "laptop-101",
                "lathe",
                "leopards-101",
                "license-plate",
                "lightbulb",
                "light-house",
                "lightning",
                "llama-101",
                "mailbox",
                "mandolin",
                "mars",
                "mattress",
                "megaphone",
                "menorah-101",
                "microscope",
                "microwave",
                "minaret",
                "minotaur",
                "motorbikes-101",
                "mountain-bike",
                "mushroom",
                "mussels",
                "necktie",
                "octopus",
                "ostrich",
                "owl",
                "palm-pilot",
                "palm-tree",
                "paperclip",
                "paper-shredder",
                "pci-card",
                "penguin",
                "people",
                "pez-dispenser",
                "photocopier",
                "picnic-table",
                "playing-card",
                "porcupine",
                "pram",
                "praying-mantis",
                "pyramid",
                "raccoon",
                "radio-telescope",
                "rainbow",
                "refrigerator",
                "revolver-101",
                "rifle",
                "rotary-phone",
                "roulette-wheel",
                "saddle",
                "saturn",
                "school-bus",
                "scorpion-101",
                "screwdriver",
                "segway",
                "self-propelled-lawn-mower",
                "sextant",
                "sheet-music",
                "skateboard",
                "skunk",
                "skyscraper",
                "smokestack",
                "snail",
                "snake",
                "sneaker",
                "snowmobile",
                "soccer-ball",
                "socks",
                "soda-can",
                "spaghetti",
                "speed-boat",
                "spider",
                "spoon",
                "stained-glass",
                "starfish-101",
                "steering-wheel",
                "stirrups",
                "sunflower-101",
                "superman",
                "sushi",
                "swan",
                "swiss-army-knife",
                "sword",
                "syringe",
                "tambourine",
                "teapot",
                "teddy-bear",
                "teepee",
                "telephone-box",
                "tennis-ball",
                "tennis-court",
                "tennis-racket",
                "theodolite",
                "toaster",
                "tomato",
                "tombstone",
                "top-hat",
                "touring-bike",
                "tower-pisa",
                "traffic-light",
                "treadmill",
                "triceratops",
                "tricycle",
                "trilobite-101",
                "tripod",
                "t-shirt",
                "tuning-fork",
                "tweezer",
                "umbrella-101",
                "unicorn",
                "vcr",
                "video-projector",
                "washing-machine",
                "watch-101",
                "waterfall",
                "watermelon",
                "welding-mask",
                "wheelbarrow",
                "windmill",
                "wine-bottle",
                "xylophone",
                "yarmulke",
                "yo-yo",
                "zebra",
                "airplanes-101",
                "car-side-101",
                "faces-easy-101",
                "greyhound",
                "tennis-shoes",
                "toad",
                "clutter",
            ]
            result_categories = "Result: label - " + object_categories[index] + ", probability - " + str(result[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の「環境変数」から「編集」をクリックして環境変数を設定します。
今後エンドポイント名などに変更があった場合はこの環境変数を修正するだけでいいので、コードの保守性が向上します。

実行ロールの修正

このままだと実行しても権限エラーが発生するので、Lambdaに他サービスと連携させるために権限を追加します。
Lambdaの「アクセス権限」から「ロール名」を選択して、ロールを修正します。

S3への画像アップロードとSageMakerエンドポイントをLambdaから使用するために、以下のAWS管理ポリシーをアタッチします。
「許可を追加」から「ポリシーをアタッチ」をクリックしてポリシーを探してください。

※本来FullAccess権限のポリシーを付与するのは良くないです。必要最低限のポリシーを付与するようにしましょう。今回はこのままFullAccess権限を付与して進めます。

外部レイヤーの追加

AWSの方でレイヤーが提供されていないライブラリをLambdaで使用したい場合、外部レイヤーを利用します。
今回のコードでは「numpy」を使用しますが、Lambdaで対応していないため、外部レイヤーを追加します。
本来は「カスタムレイヤーを一から作成し、ライブラリをzip化してzipファイルをアップロードする」といった処理をするのですが、今回は有志の方々によって作成、公開されているレイヤーを使わせていただくことにします。
以下のGitHubから「python3.9」の「numpy」のARNを探します。
「deployments」→「 python3.9 」→「ap-northeast-1」→ 「ARNs」→ 「html」と辿っていき、記載されているARNをコピーしておきます。

次にLambdaの「レイヤーを追加」から、「ARNを指定」を選び、先ほどコピーしたARNを張り付けて「追加」をクリックします。

長くなりましたが、Lambdaの作成に関しては以上です。

APIGatewayの作成まで

APIGatewayの作成

次にAPIGatewayを作成していきます。
「 APIGateway 」サービスを検索して、「RESTAPI」を構築していきます。

APIの作成が完了したら、「アクション」から「メソッドの作成」を実行します。
今回は「POST」を作成します。
結合タイプとして「Lambda関数」を選択し、「lambdaプロキシの統合の使用」にチェックを入れておきましょう。
Lambda関数は先ほど作成したLambda関数を選択して保存します。

バイナリメディアタイプの設定

バイナリメディアタイプを設定します。
左タブの「設定」から、「バイナリメディアタイプ」の追加を実行して保存します。
この設定をすることで、HTTPSリクエストのヘッダーに「Content-Type: image/png」が設定されていると、リクエストボディ(バイナリデータ)の内容がBase64エンコードされてLambdaに渡されてきます。
今回は画像(バイナリデータ)を扱うため、設定をしますが、画像を扱わない場合は設定は不要です。

APIのデプロイ

ここまで設定できたら、APIのデプロイを行っていきます。
「アクション」から「APIのデプロイ」を選択して、新しいステージにデプロイを行います。
ステージ名は任意です。

「ステージ」にデプロイしたら、URLを控えておきましょう。Cloud9で使用します。

APIGatewayの作成に関しては以上です。

Cloud9の作成とリクエスト、レスポンスの確認

それでは最後にCloud9を作成して、Cloud9のターミナルからcurlでAPIGatewayにリクエストを投げ、レスポンスを確認するところまで実施してみましょう。

Cloud9の作成

「 Cloud9 」サービスを検索して、 「Create environment」をクリックします。
デフォルト設定のまま、環境を作成していきます。

APIリクエストの実行

とうとうここまできましたね!
まずは作成したCloud9を開いて、推論させたい画像ファイルをアップロードしましょう。
今回はbackpackの画像を推論させてみたいと思います。

次にCloud9の画面下部のターミナルに以下のcurlコマンドを打ち込んでいきます。
“先ほど控えたAPIGatewayのURL”には各自のURLを置き換えてください。

curl -H "Content-Type: image/png" --data-binary "@backpack.png" -X POST "先ほど控えたAPIGatewayのURL"

実行した結果は、、、

「backpack」が98.6%だとレスポンスが返ってきました!しっかりbackpackだと分類されていますね!
S3バケットを確認すると、画像データがアップロードされているのが確認できます。

また、Lambdaの実行ログを確認しても、LambdaがSageMakerエンドポイントまで実行されてレスポンスを受け取っているのが確認できます。


APIGateway、Lambda、SageMakerエンドポイントを連携させて実行させることができましたね!!

おわりに

お疲れ様でした!今回はたくさんのAWSサービスを連携させることに成功しましたね!
どうでしょうか!サーバーレスアプリケーションの形が少し見えてきたのではないでしょうか!
SageMakerエンドポイントの削除をお忘れなく!
次回は今回までで作成したアーキテクチャをvue.jsなどと連携させて、
実際のサーバレスアプリケーションのようにClient画面を充実させてみたいと思います!

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

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