Rのつく財団入り口

ITエンジニア関連の様々な話題を書いているはずのブログです。

【感想】『Amazon Web Servicesを使ったサーバーレスアプリケーション開発ガイド』:Lambdaで本格サービス開発まで

サーバーレスアプリケーション開発ガイド

 Lambda関数を用いたサーバーレス開発をもっと知っておこうと思って読んだ本の感想です。2018年4月刊行、サーバーレスの主要サービス解説にコードはPython、のみならずフロントはVue.jsを使った本格開発まで、実践的な内容が詰まった本です。
 作者は現Amazon Web Services Japan所属のKeisuke69こと西谷圭介さん。Twitterでもよくお見掛けします。(@Keisuke69)

Chapter1 サーバーレスアプリケーションの概要

 以下、自分の学びのために一部写経ライクなイメージのコードも一緒に載せています。

1-1 サーバーレスアプリケーションとは

まずは基本、サーバーレスの特長を捉えていく節。

  • インフラの運用管理が不要:サーバーのセットアップやその後のメンテ、プロビジョニング周りのビジネス上の価値を直接はもたらさないが必要な作業が減る。認証やCI/CDなども。
  • セキュリティ面でメリット:パッチ当てが完全不要。Lambda用の基盤の中で何もなくても毎日サーバーは新しく保たれている。SSHでログインしたりもそもそもできないので侵入不可。
  • シームレスなスケーリング:スケーリングの意識自体が不要。プログラミングで間違った際のセーフガードとして同時実行数の上限は設定済み。(基本はアカウントごとに1000)
  • コスト効率がよい:リクエスト回数とコードの実行時間しか掛からない。精度も細かくて100ms単位。EC2だと1秒あたり。ロギングなどもCloudWatchと自動で連携するので、監視用の別サーバーもいらない。
  • ビルディングブロック:Lambdaを中心に小さな処理を繋げて要件を実現できる。ストレージはS3、DBはDynamoDBAPIプロキシとしてAPI Gateway、ストリーミングデータの分析にKinesis、メッセージングにSNS、キューがSQSオーケストレーションと状態管理にStep Functions、診断にX-Ray
  • サーバーレスアプリケーションが変えていくもの:運用の複雑解消、アベイラビリティゾーンの意識がだいたい不要……が生産性の向上につながり、ビジネスの価値によりフォーカスできる。
1-2 ユースケースアーキテクチャパターン

 どんなユースケースがあるのかの話。

  • Webアプリ:S3静的ホスティングを使えばコンテンツのサイズとリクエストの費用だけで、サーバーがいらない。S3→API Gateway→Lambda、認証はCognitoが使える。S3からAPIリクエストを行う際にJavaScriptが活躍。aws-serverless-expressaws-serverless-java-containerのようなライブラリもある。
  • バックエンド:API Gateway→Lambda→DynamoDBでフルマネージドなばバックエンド。モバイルからAWS SDKを使ってLambdaを直接呼ぶこともでき、IoTで使われる。
  • データプロセッシング:S3バケットのイベント→Lambda起動DynamoDB Stream で更新イベント→Lambda起動 のようなイベントドリブンでデータ処理が可能。Kinesis Stream もある。
  • チャットボット:Alexaに話す→バックエンドがLambda、で構築可能。
  • システム自動化:実は一番敷居が低い。ファイルバックアップやCloudWatchのモニタリング、SNSの通知とLambdaを繋げるなど。「システムがある状態になったら何かする」を、専用サーバーなしで実現できる。

github.com github.com

1-3 サーバーレスアプリケーションのライフサイクル管理
  • AWS Toolkit for Visual Studio, AWS Toolkit for Eclipse がLambdaのコーディング/パッケージング/デプロイ/呼び出しまでサポート。
  • AWS Cloud9 が完全にクラウド上でIDEとして使える。
  • JavaScript(Node.js)やPythonのような非コンパイル型の言語はなじみのエディタで書き、ビルド以降をCodeシリーズやサードパーティ製ツールでやることが多い。
  • 複数メンバで開発する際にはAWS SAM(Serverless Application Model)が便利。

aws.amazon.com aws.amazon.com aws.amazon.com

Chapter2 Amazon Web ServicesAWS)利用の準備

 アカウントの取得方法が丁寧にスクショ入りで解説されています。認証と認可についても一通り解説があります。

  • ルートアカウントとは基本的に別に作った方がよいのがIAMユーザー。
  • 複数のIAMユーザーのまとまりがIAMグループ。
  • ユーザに対しあるリソースへの権限を与えてくれるのがIAMロール、基本はこれでアクセス。
  • 権限の詳細はIAMポリシーとして設定。中身はJSON形式。
  • アクセスキーはアクセスキーIDとシークレットアクセスキーの組からなる。

 リージョンの選択方法も説明がありますが、サーバーレスなサービスではありがたいことにほとんどリージョンは関係なし。
 最後にはAWSリソースをコマンドから操作できるAWS CLI (Command Line Interface)の導入方法の説明もあります。画面構成がその後変わったりして本のスクショが古くなってしまう問題を考え、本書の開発ストーリーではできる限り画面からの入力でなくAWS CLIを使うという方針で記述されています。
 なかなかの英断です。頑張るとここまでコマンドでできるんだ……!というのが分かります。すでに本格的に使われている方にはAWS CLIの実行時サンプルとしても役に立ちそうです。

f:id:iwasiman:20210220110228p:plain
サーバーレスアプリケーション開発ガイド

Chapter3 インフラを自動化しよう

3-1 Amazon CloudWatchのアラームをトリガーに自動処理をする

CloudWatchKinesisに飛んでくるレコードの量を常時監視中。アラームが起こったらSNSトピックへ通知→そのイベントでLambdaが起動、対象のKinesisの処理が間に合うようにシャードの数を増やす……というユースケース例。

Kinesisへのテストデータ登録もLambdaから行い、

kinesis = boto3.client('kinesis')
kinesis.put_record(
    StreamName={ストリーム名},
    Data={入れる内容},
    PartitionKey={グループ分けに使われるパーティションキー。サンプルでは現在時刻より}
    )

と簡単です。 CloudWatchのアラーム設定もすべてコマンドで行い、1分で10件以上飛んで来たらアラームが発生しSNSトピックへ通知。これをイベントトリガーとして別のLambda関数が起動します。

kinesis = boto3.client('kinesis') # 関数の外で宣言するとパフォーマンス向上
cloudwatch = boto3.client('cloudwatch')

def lambda_handler(event, context):
    message = json.load(event['Records'][0]['Sns']['Message']) #SNSトピック
    alarm_name = message['AlarmName']
    stream_name = message['Trigger']['Dimensions'][0]['value']
    # ここでアラーム名が対象の物かの判定をしないと、全アラームが対象になってしまう
    # Kinesisから取得
    stream_summ = kinesis.describe_stream_summary(StreamName={ストリーム名})
    curr_open_shard_count = stream_summ['StreamDescriptionSumamry']['OpenShardCount']
    # Kinesisを更新
    response = kinesis.update_shard_count(
        StreamName={ストリーム名},
        TargetShardCount={演算した新しいシャード数},
        ScalingType='UNIFORM_SCALING' # ここは固定
    )
    
    # CloudWatchのアラームを更新する例
    response = cloudwatch.put_metric_alarm(
        AlarmName={アラーム名},
        MetricName='incomingRecords',
        Namespace='AWS/Kinesis',
        Period= ..., #ここから先は設定値
    )
3-2 Webサイトの状態を定期的にチェックする

今度はAWSのサイトへのGETレスポンスにに"AWS"という文字が含まれているかで、サイトが正常稼働なのを確かめる例。

  • CronかRate式で間隔を指定。
  • Lambda関数は管理コンソールから作る際、設計図で lambda-canary-python3 を選ぶと雛形が入っている。作成時のcloudwatch-eventsで新規ルール名を作る。
SITE = os.environ['site'] # コードの外側、環境変数から取得できる
EXPECTED = os.environ['expected'] # ここにチェック対象の文字列を入れておく

def validate(res):
    return EXPECTED in str(res)

def lambda_handler(event, context):
    try:
        if not validate(urlopen(SITE).read()):
            raise Exception('https://awas.amazon.com/ にAWSの文字がない!')
    except:
        print('サイトが死んでる!')
        raise
    else:
        print('サイトは生きてるよ')
        return event['time']
    finally:
        print('定期チェック終了')

何のことはない、変数SITEで示されたURLをGETリクエストで見て指定の文字列があるかを判定することで可能でした。Python特有の try-except の後にelseが入る書き方はやっぱり独特だなあと、他言語が多い身からは思います。

Chapter4 Twitterのリアルタイム分析をしよう

 今度はTwitterのタイムラインをそのままKinesisへ→Lambdaが起動しDynamoDBへ保存という、恥ずかしいツイートも黒歴史として永久保存できそうなユースケースの実現。

4-1 Amazon Kinesisを使ってTwitterのデータを受け取る
  • 事前にTwitterAPIと通信できるようにブラウザから準備が必要。Consumer key, Consumer Secret, Access Token, Access Token Secret の4つの認証情報が手に入る。
  • ストリーム名を決めてKinesisのストリームを作り、DynamoDBにもテーブルを作っておく。
from TwitterAPI import TwitterAPI # 他は割愛

# ツイート群をまるっと取得
twitter = TwitterAPI({引数4つ、4種の認証情報})
res = twitter.request('statuses/filter',
    {'locations': '{緯度経度の文字列}'}
    )

kinesis = boto3.client('kinesis')
# ひとつづつKinesisに投入!
for tweet_item in res:
    kinesis.put_record(
        StreamName={作ったストリーム名},
        Data=json.dumps(tweet_item),
        PartitionKey='filter', # ここがよく分かりませんでした
    )

このコードをローカルマシン上やEC2インスタンス上で実行すると、ツイートが文字列に変換されてKinesisへ投入され始めます。twitter.requestのところは範囲を絞った方がよさそうです。

4-2 AWS Lambdaを使ってストリーミングデータをAmazon DynamoDBへ保存する

そしてイベントソースとして上記のたまっていくKinesisを指定して、別のLambda関数を作っていきます。

# 変数tableに対象のDynamoDBテーブルをいれておく

def lambda_handler(event, context):
    try:
        batch_item_list = []
        for records in event['Records']:
            # Kinesis から取り出すときにデコードがいる
            payload = base64.b64decode(record['kinesis']['data'])
            data = json.loads(payload)
            item = {dataを元に1アイテム分を準備}
            batch_item_list.append[item]
            
        # 最大25件も意識せずにバッチ処理可能
        with table.batch_writer() as batch: 
            for item in batch_item_list:
                batch.put_item(Item=item)
            return
        
    except Exception as e:
        # エラー処理
        raise

手順が細かく説明されているのですが、IAMロールやポリシーの作成、イベントソースを指定したLambda関数の作成も頑張るとAWS CLIからできてしまうのですね。

Chapter5 写真投稿サイトをシングルページアプリケーションで作ろう

5-1 サンプルアプリの概要とS3の準備

今度は以下のようなInstagramライクな本格的なアプリを作っていく章。300ページあまりの本書のボリュームの半分以上を割いて詳しく解説しており、目玉となっています。

  • S3静的ホスティングで写真投稿サイトを作成、認証はCognitoを利用。実装はフロントにVue.jsを使ったSPA
  • サイトからJavaScript経由でAPIをコールするとAPI Gateway→Lambda→DynamoDBと処理。
  • サイトから画像をアップロードするとS3に保存、ここでもイベント通知でLambdaが起動しAmazon Rekognitionというサービスが画像認識をしてくれる。

最初にindex.htmlHello Worldを返す所から始まりますが、ここだけはVueインスタンスはHTML内のscriptタグの中のJS実装で書いています。Node.jsビルドシステムも入れない一番基本のやり方ですね。

5-2 APIを実装する

API Gatewayの話の後で、LambdaとRDSの相性がなぜ非推奨なのかの話があります。

  • 基本的にLamndaはリクエストごとにコンテナを新しく作って対応する。(コールドスタート)
    しかし使い回しができる場合は既に起動しているコンテナを再利用してくれる。(ウォームスタート) デフォルトは最大同時実行数1000。
  • 繋ぐ先がRDBの場合はコンテナの数だけコネクションを新しく張るので、理論的にはデフォルト1000コネクションも張ることになる。こうした場合はコネクションプールを使うことで負荷を下げるのが普通だが、Lambdaはステートレスなプラットフォームなのでコンテナ間でコネクションを共有したりできない
  • もうひとつ、VPC内にLamndaを配置した場合。コールドスタートの場合はVPC内のリソースへのアクセスに10-30秒かかってしまい、VPC内のRDSへも同じ。これがそのままWebアプリケーションであれば画面表示までの遅延になってしまう。

 ここからサーバーレス構成であればRDSではなくDynamoDBを推奨している……とあります。これが本書の出た2018年時点の話。2019年のアップデートで改善したという話が2020年の本『基礎から学ぶ サーバーレス開発』にはありますね。

iwasiman.hatenablog.com

 作者さんご本人のブログ記事でもこの話は詳しく解説されています。サーバーレス元年始まった……!

www.keisuke69.net

 続いてAPIとしてはGETで画像一覧の取得、POSTで新規投稿(アップロード)、PUTで更新、GETでID指定の画像1券のURL取得、DELETEで画像削除……をRestfulなURIで設定、それぞれ対応するLambda関数を後ろに準備する形で準備していきます。

 画像のようなバイナリファイルの投稿について、本書では以下のような解法を示しています。

  1. ファイルをBase64エンコードしてJSONの中のキーに対応する値に入れ、リクエストボディとしてふつうに送信、バックエンドで受け取ってデコードして処理。リクエストの中身が大きくなり、エンコード/デコード処理の分パフォーマンスに影響する欠点がある。
  2. リクエストヘッダに Content-Type: multipart/form-data でリクエストボディにファイル本体を入れて送信。RESTが流行る以前のWebアプリケーションではデフォルトなよくある王道の方式。ボディがJSONでないという欠点がある。
  3. アップロードはS3に直接行う。ファイル名などメタデータだけをAPI Gatewayに送ってLambdaで保存して紐づける。でかいファイル本体の送信をS3だけに抑えられる。署名付きURLの取得やエラー処理など最初の手間がかかるのが欠点。しかし一度開発すれば将来の変更もなく、スケールもしやすい。
  4. API Gatewayは実はバイナリ送信もOKなので、ファイル本体を含めて送り、その奥のLambdaで取得してS3に登録する手もある。同期呼び出しのLambdaで扱えるデータ量(ペイロード)は最大6MBの制限あり。またLambda側の処理時間がそれだけ掛かるので、コストが増大する欠点がある。

 本章ではこの中から3.の方法を採用しています。後で出てきますがコード量が増えるといってもほぼ定型ですし、やっぱりこの方式が良いのだろうなあという感じ。スケールまで考えているあたりがさすがにAWSJの中の人らしい考察です。

保存用のDynamoDBテーブルを作った後、POST /images で飛んでくる投稿処理のLambda関数を作っていきます。イベントソースはS3アップロードを検知してではなく、HTML画面でボタンを押してJavaScriptからのHTTPリクエストで起動するというところが要注意。

# ハンドラの外側でパフォーマンス向上
dynamodb = boto3.resouces('dynamodb')
table = dynamodb = dynamodb.Table({テーブル名})


# UUIDからランダムなIDを生成
def generate_id():
    return str(uuid.uuid4())

# DynamoDBは数値のfloat型を使えないので、現在日付はintに変換
def get_timestamp():
    now = datetime.datetime.utcnow()
    return int(now.timestamp())

# 1時間使える署名付きURL コード例だと引数3にcontent-typeがあるが、
# その後の説明だと抜けてるような?
def get_presigned_url(bucket_name, key):
    s3 = boto3.client('s3')
    url = s3.generate_presigned_url(
        ClientMethod='put_object',
        Params={'Bucket': bucket_name, 'Key': key},
        Expiresln = 60*60,
        HttpMethod='PUT'
    )
    return url

def lambda_handler(event, context):
    # 画面側のVue.jsの中からaxiosを使って送信してくる
    body = json.load(event['body'])
    url = get_presigned_url({バケット名}, generate_id())
    item = {IDやタイムスタンプなど1アイテム分を準備。staus:'Waiting'}
    try:
        # insert into dynamodb_tbl values(itemの各属性); 的な処理
        table.put_item(Item=item)
    except ClientError as e:
        # ログしてエラーレスポンス
    else:
        # ボディに生成した署名付きURLのurlをJSON形式で入れて、200の正常レスポンスを返す

 DBの接続処理など、何回も使う処理はlambda_handler関数の外側に出しておくのはウォームスタートだと性能が上がるため常道とのこと、これはAWS認定の問題でも見た覚えがあります。
しかし逆に、あまりに大きい処理を外側に出しておくと逆にコールドスタートの場合は時間がより掛かってしまうこともあるそうです。

 実際の開発用にはAWS CloudFormationの機能を使ってAPI Gateway+Lambda周りのデプロイを容易にしてくれるデプロイメントツール、AWS SAMを解説しています。本格的な開発になると役に立ちそうです。

続いて、画面から上の関数をコールしてS3へのアップロードが成功した後、また画面から飛んでくるPUT /images の更新処理を受け取るLambda関数の実装。

# 最初にJSONの中にFloat型があってもうまく処理してくれるクラスを用意

# ボディに3つのキーが入っていればバリデーションOK
def validate(body):
    return body.keys() >= {'photo_id', 'timestamp', 'status'}

def lambda_handler(event, context):
    # またVue.jsの中のaxiosから飛んでくるのでボディを取得
    body = json.load(event['body']) 
    if not validate(body):
        # エラーレスポンスを返して終了

    photo_id = body['photo_id']
    timestamp = body['timestamp']
    status = body['status'] # 'Uploaded'が渡っててくる
    try:
        try:
            # DynamoDBのテーブル、idで指定したアイテムのstatusだけを、
            # 画面から渡ってくる値に更新
            # 抜けてるけどtimestampも一緒に更新する意図?
            # update dynamodb_tbl set status = 'xx', timestamp = yy where photo_id = 1234;
            table.update_item(
                Key={'photo_id': photo_id},
                AttributeUpdates={
                    'status': {'Value': status, 'Action': 'PUT'}
                }
            )
            response = table.get_item(
                Key={'photo_id': photo_id}
            )
        except ClientError as e:
            # ロギングとエラーレスポンス
        else:
            # response['item']の内容をボディにJSON形式で入れ、200の正常レスポンスを返す
    except Exception as e:
        # エラーをロギング

そしてこれまた画面からJavaScript経由で画像の一覧を取得するAPIが呼ばれた時に処理するコードが、以下のような感じ。

# 前処理は省略

def lambda_handler(event, context)
    try:
        try:
            # RDBで言うと select * from dynamodb_tbl where status = 'Uploaded'; のフルスキャンを敢行!
            response = table.scan(
                FilterExpression=Attr('status').eq('Uploaded')
            )
        except ClientError as e:
            # エラーログとエラーレスポンス
        else:
           # 変数responseをJSON化、ボディに入れて200の正常応答を返す
    except:

検索についても特に記述があり、RDBに比べると検索が弱いDynamoDBでは事前の正しいテーブル設計がより重要なのだなと改めて思います。

  • プライマリーキーを指定できるならGetItemで1アイテム取得が速い。一覧検索には使えない。
  • 複合プライマリーキーが指定済みか、グローバルセカンダリインデックス指定済みならQuery
  • 検索用にElasticsearchを別途用いる方法もある。
  • どれもだめならこの例のようにScan。ただしアイテム数が多いとシステムリソースを大量消費するので課金注意!

そして、画面からIDを指定した GET /images/{id} が来た時の1件検索の処理コードが以下のような感じ。

# 色々省略

def lambda_handler(event, context):
  try:
      # /images/{id}の部分を取得
      photo_id = event['pathParameters']['id']
      try:
          # DynamoDBをキー指定で1アイテム取得
          # select * from dynamodb_tbl where photo_id = 1234
          response = table.get_item(
              Key={'photo_id' = photo_id}
          )
          if 'item' not in response:
              # ロギングと404 Not Foundでエラーレスポンス
      except ClientError as e:
          # ロギングと400 Internal Server Errorのエラーレスポンス
      else:
          # response['item']をJSON化してボディに入れ、200の正常レスポンス
  except Exception as e:
      # ロギング

最後のexceptはURL不正でphoto_idが取れない場合しか来ない気がするので、最初の方で処理してもよいのかな?と思いました。

画面からIDを指定した DELETE /images/{id} が来た時の1件削除の処理コードが最後。

def lambda_handler(event, context):
  try:
      # /images/{id}の部分を取得
      photo_id = event['pathParameters']['id']
      try:
          # DynamoDBをキー指定で1アイテム取得
          response = table.get_item(
              Key='photo_id' = photo_id}
          )
          if 'item' not in response:
              # ロギングと404 Not Foundでエラーレスポンス
          else:
              # RDBなら delete from dynamodb_tbl where photo_id = 1234;
              response = table.delete_item(
                  Key={'photo_id' = photo_id}
              )
      except ClientError as e:
          # ロギングと400 Internal Server Errorのエラーレスポンス
      else:
          # 正常終了。ボディは空、204 No Contentで正常レスポンス
  except Exception as e:
      # ロギング

ちゃんとステータスコード204を使っていて偉い……!と思いました。削除時に200を返すか204を返すかは考え方が両方あるようです。

qiita.com

5-3 クライアント側を実装する

 バックエンド側は全て準備完了、今度はフロントエンド側です。構成にはvue-cliを使って一式フォルダ準備、Vue-Routerも使ってSPA、単一ファイルコンポーネント形式でVueを書いていく本格的なやり方です。
技術スタックの概要も記述があるのですが、有名なのはAngularReactだが今回は最近人気のVue.jsを使う、ルーティング機能はReactだと本体内包だがVue.jsは外出し……など、情報が一部古いですね。(正しくはAngularは内包、ReactはVue.jsと同じで本体でなく外出し)
本書は2018年刊行ですので執筆されたのは2017頃でしょうか、まあこのへんはしょうがないのかなと。

  • npm経由でvue cliをインストール、serverless-spaというディレクトリを作ってここで作業。
  • index.htmlにPureという軽量CSSフレームワークを設定。Bootstrap, Foundation, SemanticUIより軽量、jQuery不要のため採用。
  • 単一ファイルコンポーネントHome.vueの中を実装していく。

purecss.io

Home.vueを構成するJavaScriptコードは以下のような感じです。

export default {
  data: function() {
    return {
      //image_url_base, uploadFile, images: [] だけ
    }
  },
  created: {
    // listImagesを呼んで一覧表示
  },
  methods: {
    listImages: function() {
      // ライブラリのaxiosを使ってAPI Gatewayの GET /images をコール、
      // 結果の配列をimagesに入れる
    },
    onFileChange: function() {
      // HTMLのinput type="file"要素のonChangeでここに来る。
      // ファイルの中身をdataプロパティ内に格納。
      // この時点ではまだ送信しない。
      this.uploadFile = event.target.files[0]
    },
    uploadImage: function() {
      // HTMLのbutton要素のアップロードボタンを押したらここの処理。
      let data = {size: this.uploadFile.size, type: this.uploadFile.type}
      // まずaxiosを使ってAPI Gatewayの POST /imagesでID登録、
      // →署名付きURLがレスポンスボディで返る
      // 次に署名付きURLに向かって this.uploadFileをPUT。
      // →これで実体がS3に登録。
      // これも成功したらAPI GatewayにPUT /images で
      // ボディのJSONに status:'Uploaded' を入れて更新
    },
  },
}

 アップロードボタン押下で動くuploadImage 関数の中で、3回通信をするという仕組みでした。
Vue-Routerの設定なども必要で、この節の実装はやることが多くてなかなか複雑です。Vue.js自体の説明もありますが、難易度が高いと思った初心者の方はCSSPureを抜いて見た目は後にしてまっさらでやる、vue-cliのSPAもやめてHTMLの中のJavaScriptにVueインスタンスを書く一番簡単なやり方でまず一部の動作を確認する、などなどしてエラーの切り分けをしながら進むとよいかと思いました。

 ファイルの実体のアップロードにはJavaScript版のAWSサービス操作用ライブラリがいたりするのかな……と勝手に思っていたのですが、署名付きURLがあればそこを宛先に実体をPUTするだけで行けてしまうんですね。

 以下のクラスメソッドさんの記事では似たようなことをやっていますが、IAMとセッションも必要になっています。

dev.classmethod.jp

 こちらは最初からJavaScriptの中でaws-sdkライブラリを使ってS3と通信して署名付きURL取得、そこにPUTと、ブラウザ側で完結するパターン。

https://techblog.timers-inc.com/entry/2019/11/26/120351/ qiita.com

5-4 Amazon Cognitoを利用した認証処理を追加する

 今度はユーザ管理や認証機能を提供するのでサインアップ(ユーザ登録)やサインイン(=ログイン)を代行できる Amazon Cognitoを使い、認証機能を追加していきます。認証周りもここに任せることでビジネス価値のある開発に集中できる訳です。Cognitoの主な機能は以下。サンプルアプリではユーザプールだけを使用するとのことです。

  • Amazon Cognito ユーザープール:サインアップ、サインイン、ユーザ管理。GoogleFacebookAmazonなどとも連携できる。メール認証や多要素認証など一通り。
  • Amazon Cognito フェデレーテッドアイデンティティユーザごとにユニークなIDを提供、他のIDプロバイダーとも連携。IAMロールに紐づいた認証情報を提供するので、アプリ側にハードコードしなくてよい。
  • Amazon Cognito Sync複数デバイスでユーザのプライベート情報を同期。複数のユーザでデータをシェアするのはAppSyncで別。

aws.amazon.com

  • 本書の例ではCloudFormationYAMLファイルでコマンドから、ユーザープールとユーザープールクライアントを新規作成。結果として、UserPoolId, UserPoolClientId の2つの値が取得できる。中身は乱数の文字列。
  • ユーザープールで作るユーザの属性はOpen ID Connect準拠で、nameとusernameが別だったりサインイン時に使う値を変えたり、色々カスタマイズできる。
  • これをフロントエンド側のアプリに組みこむ。今回はvue cliを使っているので、src/config.jsに2つの値を保存。
  • 認証処理はauth.jsという別ファイルのモジュールに外出し。ここにAWS用のライブラリを組み込む。
import appConfig from './config'; // UserPoolId、UserPoolClientIdがここから取れる
import * as AWS from "aws-sdk";
import {
  CognitoUserPool, CognitoUserAttribute, CognitoUser
} from "amazon-cognito-identity-js";

export default {
  signup: function(username, email, password) {
    // サインアップ画面からユーザ名、メアド、パスワードをもってこの関数へ。
    // 固定のUserPoolId、UserPoolClientIdを使ってCognitoUserPoolクラスを
    // 作り、メアドも入力情報に含めてCognito側のsingup()をコール。
  },

  confirm: function(username, confirmation_number) {
    // 確認画面でユーザ名、メールに書いてある確認番号を入れたらこの関数へ。
    // 固定のUserPoolId、UserPoolClientIdを使ってCognitoUserPoolクラスを作り、
    // ユーザ名も入れたCognitoUserクラスを作り、
    // 確認番号を入力にしてCognito側のconfirmRegjstration() をコール。
    // どれもreturn new Promise(() => ...の中にラップすることで非同期処理を
    // 閉じ込め、Vueコンポーネント側から呼びやすくする。
  },

  authenticate: function(email, password) {
    // サインイン画面でメアドとパスワードを入れたらこの関数へ。
    // 固定のUserPoolId、UserPoolClientIdを使ってCognitoUserPoolクラスを作り、
    // ユーザ名も入れたCognitoUserクラスを作り、
    // メアドとパスワードを入力にCognito側の authenticateUsser() をコール。
    // コールバックが成功/失敗の他に強制パスワード変更もある。
  },

  loggedIn: function() {
    // Vueコンポーネントで実装された各画面の初期表示時にこの関数へ。
    // 固定のUserPoolId、UserPoolClientIdを使ってCognitoUserPoolクラスを作り、
    // getCurrentUser() を呼ぶと今のユーザーが取得できる。
    // セッションが取得出来てメアドを持ってればログインOKとしtrueを返す。
  },

  logout: function() {
    // ログアウトボタンからこの関数へ。
    // 固定のUserPoolId、UserPoolClientIdを使ってCognitoUserPoolクラスを作り、
    // cognitoUserPool.getCurrentUser().signOut() とするとログアウト処理。
  },
}

続いて画面側を作っていきます。

  • サインアップ画面をSignup.vueとして作成。
    ユーザ名、メアド、パスワードを入れてボタンを押したら上記authモジュールのsignup()を呼ぶように。Vuexstoreがアプリ全体に組み込まれているので、入力値はこちらに保存。
  • 続いで確認メールが飛んできた後の確認画面をConfirm.vueとして作成。
    メアドと確認番号を入れて登録ボタンを押したら、storeから取得したユーザ名も含めてauthモジュールのconfirm()を呼ぶように。正常に通過したらRouterの機能でホーム画面に遷移。
  • ログイン(サインイン?)の画面をLogin.vueとして作成。
    メアドとパスワードを入れてボタンを押したら上記authモジュールのauthenticate()を呼ぶように。成功したらホーム画面に遷移。
  • Vue Router設定を書くRouterクラスを修正。
    最初のサインアップ、確認画面、ログイン画面以外の全てのルーティングではbeforeEnter: で別関数を呼ぶ。ここでauthモジュールのloggedIn()を呼んでログインチェック、結果がtrueなら行きたい画面へ、falseならログイン画面に遷移。
  • 同じく、ログアウトはログアウト画面を作らずにRouterの中で設定。authモジュールのlogout()を呼ぶように。

 モジュール形式のJavaScript実装やPromiseVue.js側、Vue Routerの実装と幾つも技術要素が絡み、コード量もあってけっこう難しいのですが、読み返して整理してやっと理解できました。
自分的にはサーバーサイド(バックエンド)の認証周りの実装はよくやってきたのですが、ブラウザの中で閉じるフロントエンドのJavaScriptだけでも似たようなことができちゃうのか……!とちょっと感動。authモジュールのその先、中で隠蔽されているCognito側の機能の中でAWSと通信していろいろやってるのでしょうね。

クライアント側が終わったので今度はAPI側に認証を追加していきます。

  • API Gateway周りもSAMを使っているので、設定ファイルの中にCognitoの話を追加。これでHTTPリクエストに Authorizationヘッダがないと呼んでも動かなくなる。

  • 続いてフロントエンド側、authモジュールに関数追加。

  get_id_token: function() {
    // 固定のUserPoolId、UserPoolClientIdを使ってCognitoUserPoolクラスを
    // 作り、cognitoUserPool.GetCurrentUser().getSession() 。
    // その結果からgetIdToken().getJwtToken() するとトークンの文字列が取得できる。
  },
  • VueコンポーネントHome.vueの中で、API Gatewayを呼んでいるところは全て修正。リクエストヘッダのAuthorization: に上の関数を呼んで得られたトークンを追加してから投げるようにする。
    署名付きURLからS3に直接ファイルアップロードしている処理はAPI Gateway経由でないのでこのヘッダは不要。

 最後に画像の削除機能の実装例も載っています。

  • 画像1件の詳細画面を表すPhoto.vueを追加。data:プロパティで持っているphoto_idは、Vue-Routerの機能を使ってURLのパスから取得。
  • created: プロパティで this.getImages()を呼んで画像1件取得。
  • <template>タグ内のHTMLでは、photo_idを使ってURLを掲載、S3上にある画像をそのまま<img>タグで表示。
  • methods: プロパティの関数 getImage() で、API GatewayGET /images/{id} をコール。
  • HTML内の削除ボタンを押したら関数 deleteImage() で、API GatewayDELETE /images/{id} をコール。その後ホーム画面に遷移する。
    この実装例ではDynamoDBの紐付テーブルから削除するだけなので、S3にある画像本体は消えない。

 紙面の都合等によりS3からの削除はカットとのこと。DynamoDBから検索されないので一覧画面からは消えますが、画像1件の詳細表示をしているphoto_id付きのURLを直打ちすると……API Gateway→Lambdaに行ってDynamoDBにアイテムはないから404が返るけど、JavaScript側の処理は続行するので画像が表示される動きでは...? と思います。

 このS3からの削除についてはサポートページでPDFで公開されていました。DynamoDB Streamsを有効化すると対象テーブル操作のイベント検知が可能。これをイベントソースにしたまた別のLambda関数を作り、引数のeventから情報を取って削除のイベントだったらファイル名を取ってきてs3.delete_object() で削除……というものでした。

https://book.mynavi.jp/files/user/support/9784839964566/5-4_appendix.pdf

 最後は > npm run build すると /distにトランスコンパイルされたアプリ一式が生成。/dist に行ってから、

> aws s3 sync . s3://{バケット名}

するとフォルダの内容が全てS3に反映されて、Vue.jsを使った完全SPA構成+S3静的ホスティングS3API Gateway+PythonによるLambda関数と連携した、認証機能付きの完全サーバーレスのWebアプリケーションが稼働開始……となります。
 いやはや本格的な開発例でした。出てくる技術要素が多いのでけっこう理解に時間が掛かりました。

 初めて見た素人の身からするとCognito周りの組みこみはけっこうやることあるな~という印象も受けたのですが(笑)、よくよく考えれば2回目以降もフロントエンド主体のWebアプリやモバイルアプリを作る際は毎回同じようなことを組み込むだけになるわけです。バックエンド側に[user]テーブルを持ったりして独自の認証処理を作りこむよりは、Cognito側に任せるこちらの方が確実に良いのでしょうね。

5-5 Amazon Rekognitionを使って画像解析を行う

 今度はRecognizeでなくRekognize、Rekognitionを使ったいかにも最新ぽい機能を組み込みます。

  • ディープラーニングに基づく画像と動画の分析をアプリに追加できるサービス。
  • API経由でインプットを与えられるので、インフラも必要ない。
  • 解析対象が存在するS3バケットRekognitionと同一リージョンになければならない制限あり。今回のアプリは東京リージョンに上げているので、プログラム内で一旦ダウンロードしてからRekognitionに渡す方式に。

aws.amazon.com

 本書ではこう記述されていますが、その後2018/2月より、東京リージョンでも使えるようにアップデートされていました。

aws.amazon.com

s3 = boto3.client('s3')
rekog = boto3.client('rekognition', '{リージョン名。北米us-east1}')
dynamodb = boto3.resource('dynamodb', '{東京リージョン}'}
table = dynamodb.Table({テーブル名})

def lambda_handler(event, context):
    # バケット名とキーのファイル名を取得
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'], 'utf8')
    
    try:
        # S3から対象オブジェクトのバイナリの実体を取得
        obj = s3.get_object(Bucket=bucket, Key=key)
        body = obj['Body'].read()
        
        # Rekognitionをコールして画像の中のラベルを検知! 信用度が75以上のものだけ
        labels = rekog.detect_labels(
            Image={'Bytes' : body, MinConfidence=75}
        )
        # Rekognitionをコールして画像の中の顔を解析
        faces = rekog.detece_faces(
            {Image={'Bytes': body}}, Attributes={'ALL'}
        )
        # 解析結果をまとめておく
        rekognized_label = {
            'Labels': labels['Labels'],
            'FaceDetails': faces['FaceDetails'],
        }
        
        # キーのファイル名から拡張子を除くと乱数からなるIDになる
        photo_id = key.split('.')[0]
        # DynamoDBの対象アイテムを更新。
        # update dynamodb_tbl set labels = '{解析結果}' where photo_id = 1234;
        table.update_item(
            Key={'photo_id': photo_id},
            AttributeValues = {
                'labels': {
                    'Value': {rekognized_labelをJSON化した文字列}
                    'Action': 'PUT'
                }
            }
        )
        return
    
    except Exception as e:
        #ロギング

 S3のオブジェクト生成をイベントソースにこのLambda関数を設定しておけば、画像のアップロード時に走ってDynamoDBが更新されるというものでした。
 難しそうですが分かるとなんということはない、画像ファイルの実体のバイナリ文字列を渡すと解析してくれるということでこれは便利そうです。思ったより簡単にできてしまうのですね。

Chapter6 サーバーレスアプリケーションのライフサイクル管理

6-1 AWS Serverless Application Model(AWS SAM)詳細

 「フレームワーク」のように言われることもあって実際その意味合いもあるSAMですが、本書ではAWS CloudFormationの拡張であると定義して深掘りしています。

  • CFnの定義のxx.ymlファイル。先頭のバージョン行の次に Transform: 'AWS::Serverless-2016-10-31' を追記。
  • トップ要素の Resources: の次に関数の名前などリソースの名前。その次の Type: に書くリソースタイプが以下3種。
    Lambdaが AWS::Serverless::Function
    API GatewayAPI作成が AWS::Serverless:Api
    DynamoDBのテーブルが AWS::Servlerless:SimpleTable
  • 様々なプロパティがあって詳細に設定可能。Lambda関数の場合は
    CodeUri: s3://{バケット名}/{ファイル名}.zip のようにコードの実体をアップロードしたS3の場所を書く。
  • S3にアップロードした後CodeUriに書かずに以下のコマンド
    > aws cloudformation package --template-file xx.yml --output-template-file xx-out.yml --s3-bucket {バケット名}
    を実行すると、出力される定義ファイルにはCodeUriに設定してくれるというやり方もある。
  • その後は > aws cloudformation deploy --template-file xx-out.yml --stack-name {スタック名} ... のようにしてデプロイ。
6-2 複数環境の管理
  1. 同一アカウントで開発/本番など複数のスタックを使う
  2. AWSアカウント自体を開発/本番で分ける

どちらもメリットデメリットありますが、チームが大きくなってくると2が推奨。また2016年提供開始のAWS Organizationsを使うと楽になると本書では使用を勧めています。

aws.amazon.com

このへんは有識者の話としては技術同人誌から商業本にもなった『AWSの薄い本』シリーズでディープなところが書いてあります。

AWSの薄い本 IAMのマニアックな話

AWSの薄い本 IAMのマニアックな話

6-3 デリバリプロセスの自動化(CI/CD)

 CI/CDといえばCode3兄弟のシリーズ。CodeDeployCodePipelineを用いた自動処理の例が、本書ではここでもほぼすべてコマンドからの実行という例で記述されています。

Chapter7 サーバーレスアプリケーションのトラブルシューティング

7-1 メトリクスのモニタリング

 各サービスの監視で確認できる値の種類であるメトリクスについて、サーバーレスの各サービスごとにメトリクス名や意味、単位まで表にまとまっています。
 ロギングについてはLambda関数はデフォルトでCloudWatch Logsに出力。そしてAPI GatewayCloudWatch Logsへのログ出力を有効化することで監視可能。これもコマンドベースで手順が述べられています。  

7-2 AWS X-Rayを利用したトラブルシューティング

 サーバーレスやマイクロサービス特有の、粒度が小さいかたまり群がそれぞれ処理するので関連が分かりづらく問題が追いにくい……という問題を解決してくれるX-Rayの使い方。

  • 分散アプリケーションの分析やデバッグをサポートしてくれるサービス。
  • EC2, ECS, Elastic Beanstalk, Lambda が対象。
  • アプリ内からのAWSのサービスのAPIコールも記録してくれる。Lambda関数内からのAWSリソースへのアクセスもこれで採れる。
  • Lambdaの場合は実行ロールになっているIAMロールに管理ポリシー AWSXrayWriteOnlyAccess のアタッチがいる。
  • デフォルトの「パススルー」モードが普通にすべて記録。X-rayがサンプリングで間引きして効率的にトレースしてくれる「アクティブ」モードがある。Lambda関数ごとに設定。
  • 管理コンソールのAWS X-Rayの画面からログを見て色々確認できる。

ずっとコマンドベースのハードモード(笑)だったところ、ここだけは管理コンソールのスクショが出てきて、おおカラフルだ……と思いました。

まとめ:サーバーレスの応用的実践開発まで分かる本

 作者さんは2017年6月にも『実践AWS Lambda』という国内初のLambda本を出しています。

『実践AWS Lambda』という本を書きました - Sweet Escape

 その後ということもあってか、サーバーレスの概念や基本も書いてありますがより高度な内容、応用的な所がメインなのかなと思いました。自分の場合はまた書名が似た別の本でややこしいのですが『AWS Lambda実践ガイド』の後に読んだので基本→応用という感じになってちょうど良い塩梅でした。
 現AWSJの方らしく実際の開発で役に立ちそうな知見がだいぶ詰まっているのですが、やはり最大の見どころは本の半分以上を占める第5章、Vue.jsによるSPA+バックエンドのREST APIでサービスを一式作るところでしょう。自分もここを読破してだいぶ解像度が上がって理解が深まりました。なんかもうWebサービスが作れちゃいそうな気がするぞ……!(気がするだけw)
 自分はとりあえずコマンドラインのところは最重要ではないので注視しなかったのですが、可能な限りすべての操作をコマンドラインベースで書いているところも、実際に使っている方にはお役立ちだと思います。

 難点はというと……作者さんご本人のブログ記事にもありますがLambda関数のPythonコードのところどころに謎のインデントミスや名前付き引数の後の謎のスペース、「これほんとはこういう意図なんじゃないかな?」というところ、誤植が幾つかハッケンされました。まあこのへんは技術書の宿命なので、自力で分かるぐらいまで進歩するのも学びのうちということで(笑)、これから読む方もチャレンジすればよろしいかと思います! LambdaでPython完全に理解した…(2回目)

作者の id:Keisuke69 こと西谷圭介さんによる紹介記事。 www.keisuke69.net

サポートページもあり、ソースコードもDLできます。こちらは誤植が直っている模様。 book.mynavi.jp

作者さんが登壇した2020年4月の「みんなのPython勉強会#56」の記事。この記事自体がかなりお役立ちです。 logmi.jp

AWS認定のSAAオレンジの本を始めAWS関係の書籍でも知られる id:kentacho_jp さんによる紹介記事。 www.ketancho.net

こちらもAWSといえばお馴染みクラメソさんの紹介記事。ここでも好評です。 dev.classmethod.jp

はてなブログの書評記事。 katsuki.hatenablog.com

f:id:iwasiman:20210220110228p:plain
サーバーレスアプリケーション開発ガイド

関連書籍

サーバーレス関連は以前上げた『基礎から学ぶサーバーレス開発』の感想記事の最後にまとめています。

iwasiman.hatenablog.com