AWS Lambda で遊ぼう(第9話: WebSocket篇② - 切断篇)

今日は、AWS Lambda で WebSocket に挑戦する企画の続きです。

前回の記事が未読の場合は、先にそちらをお読みいただければ幸いです。

tercel-tech.hatenablog.com

前回がんばった分、今日はだいぶ構築がラクになっていますので、ぜひ!

■ 本日のお品書き

準備運動

本日作るもの

WebSocket で何をするにせよ、少なくとも2つの Lambda が必要なのでした。

  • 接続: コネクションが確立したら、そのクライアントを DynamoDB に格納する
  • 切断: コネクションが切れたら、そのクライアントを DynamoDB から削除する

これらの API のエンドポイントは1つですが、ルートrouteによって、実行される Lambda が振り分けられるのでした。

  • API Gateway は、クライアントと WebSocket API 間の永続的な接続が開始されたときに、$connect ルートを呼び出します。
  • API Gateway は、クライアントまたはサーバーが API から切断したときに、$disconnect ルートを呼び出します。

図にすると以下の通りです。

f:id:tercel_s:20220212122533p:plain

今日はこれらのうち、切断時の Lambda の方を作り込んでいきます。

ただし、前回のようにイチから作るのではなく、ある程度できている SAM テンプレートを流用できそうです。

リソースのグループ分け

前回、SAM テンプレートでいくつかのリソースを構築しました。

これらを改めて見てみると、実はリソースの役割ごとに大きく3つにグループ分けできます。

  • AWS::ApiGatewayV2::Api (SimpleChatWebSocket)
  • AWS::ApiGatewayV2::Stage (Stage)
  • AWS::ApiGatewayV2::Route (ConnectRoute)
  • AWS::ApiGatewayV2::Integration (ConnectInteg)
  • AWS::Lambda::Permission (OnConnectPermission)
  • AWS::Serverless::Function (OnConnectFunction)
  • AWS::Serverless::SimpleTable (ConnectionsTable)

イメージを摑みやすくするため、前回の成果物に色をつけた図を以下に示します。

f:id:tercel_s:20220212141112p:plain

今回のゴールは、ここに青色のグループをもう一組作るイメージです。

f:id:tercel_s:20220212141313p:plain

グループごとの意味を理解すれば、今回どこに手を加えなければならないか、自ずと分かってくるでしょう。

1. 赤色グループ: API のエンドポイント URL を決めるグループ

前回も述べたとおり、API の URL は、API IDステージで決まります(厳密には、デプロイ時に指定するリージョンも確定要素のひとつ)。

API のデフォルトドメイン名を使用すると、特定のステージ ({stageName}) の WebSocket API の URL は、たとえば次の形式になります。
wss://{api-id}.execute-api.{region}.amazonaws.com/{stageName}

これは、template.yalm のOutputs セクションからも読み取れます。

■ template.yaml

Outputs:
  WebSocketURI:
    Description: "The WSS Protocol URI to connect to"
    Value: !Sub "wss://${SimpleChatWebSocket}.execute-api.${AWS::Region}.amazonaws.com/${Stage}"

クライアントから見ると、単一のエンドポイント URL に接続してメッセージをやりとりする形になりますから、バックエンド処理がいくら増えたとしても、この赤色グループのリソースたちは初めに一度作ってしまえばあとは手を加えることがほぼないグループになります。

なので今回も(そして次回も)この赤色グループにはノータッチでいきます。

2. 青色グループ: バックエンド処理ごとに必要となるグループ

次に青色グループですが、これはざっくり言うと、Lambda 関数を追加したくなったらセットで揃えなくてはならないリソース一式になります。

リクエストを所望の Lambda に振り分けるために ルートroute統合integration リソースが必要であることは前回見た通りですし、API から Lambda を実行するためのパーミッションpermissionが必要であることも学びました。

これらは全て青色グループの仲間たちですので、Lambda を追加したくなったらセットで追加する、という原則を忘れないようにしましょう。

そのため、今回も(そして次回も)メインはこの青色グループの構築ということになります。

3. 緑色グループ: その他(アプリケーションで利用するリソース)

緑色グループは、Lambda が内部的に利用するリソースです。 今のところ、コネクションIDを格納するための DynamoDB が一つ構築されているだけです。

ここは、アプリケーション要件に応じてメンテナンスをすればよい部分です。

今回は特にリソースの追加は行いません。

本日の演習

あらためて、今回の出発地点の構成図を再掲します。

f:id:tercel_s:20220212141243p:plain

ここに、青色のグループをもう一組追加したものが今回のゴールです。

f:id:tercel_s:20220212141313p:plain

前回は、初めに SAM テンプレートの編集を行いましたが、今回は Lambda の実装を先に行いましょう。

Lambda の追加

今回作る Lambda は、WebSocket 切断時に、コネクション ID を DynamoDB テーブルから削除する処理です。

  • AWS::ApiGatewayV2::Route
  • AWS::ApiGatewayV2::Integration
  • AWS::Lambda::Permission
  • AWS::Serverless::Function ←これを作ります

初めに、切断時の Lambda を作るため、on_connectディレクトリを複製して、on_disconnectにリネームします(もともとあるon_connectディレクトリはそのまま残します)。

f:id:tercel_s:20220212180430p:plain

次に、template.yaml を開き、DynamoDB テーブルの削除ポリシーと、環境変数からテーブル名を取得できるよう設定を追加していきましょう。

tercel-tech.hatenablog.com

既存のテンプレートを流用する場合は、差分の修正漏れに注意してください(特に★注意★の2ヶ所)。

  Resources:

    # -- 略 --

+   # 切断 - Lambda
+   OnDisconnectFunction:
+     Type: AWS::Serverless::Function
+     Properties:
+       CodeUri: on_disconnect    # ★注意★
+       Handler: app.lambda_handler
+       Runtime: python3.9
+       Policies:
+         - DynamoDBCrudPolicy:   # ★注意★
+             TableName: !Ref ConnectionsTable
+       Environment:
+         Variables:
+           CONNECTIONS_TABLE: !Ref ConnectionsTable

    # コネクションID テーブル
    ConnectionsTable:
      Type: AWS::Serverless::SimpleTable

Lambda の実装

続いて、テーブルからコネクションID を削除する Lambda の実装です。

eventパラメータからコネクションIDを取得する方法と、DynamoDB テーブルにアクセスする方法は、前回の説明をご参照ください。

そのため、前回のコードがほぼそのまま流用できそうです。 ただ一点だけ、put_item API ではなくdelete_item API を使って DynamoDB を操作する点だけが違います。

on_disconnect/app.py

import boto3
import json
import os

dynamodb = boto3.resource('dynamodb')

def lambda_handler(event, context):
    # コネクションIDを取得
    connection_id = event['requestContext']['connectionId']
    print(f'Connection ID: {connection_id}')
    
    # テーブルを取得
    table_name = os.environ['CONNECTIONS_TABLE']
    connections_table = dynamodb.Table(table_name)
    
    # テーブルからコネクションIDを削除
    connections_table.delete_item(Key = {'id': connection_id})    # ★注意★

    return {}

$disconnect ルートと統合リソースの追加

  • AWS::ApiGatewayV2::Route ←これを作ります
  • AWS::ApiGatewayV2::Integration ←これも作ります
  • AWS::Lambda::Permission
  • AWS::Serverless::Function

ここからは、今作った Lambda を、API Gateway と繋いでいきましょう。

  • API Gateway は、クライアントと WebSocket API 間の永続的な接続が開始されたときに、$connect ルートを呼び出します。
  • API Gateway は、クライアントまたはサーバーが API から切断したときに、$disconnect ルートを呼び出します。

切断時に Lambda を起動したいので、作成するルートは $disconnect になります。

これも前回のテンプレートをコピペして、差分だけ書き換えればよいです(ただし★注意★に注意してください。見通しの悪いIntegrationUriにも、Lambda の ARN を参照している箇所があるため特に注意が必要です)。

■ template.yaml (diff)

  Resources:

    # -- 略 --

+   # 切断 - ルート    
+   DisconnectRoute:
+     Type: AWS::ApiGatewayV2::Route
+     Properties:
+       ApiId: !Ref SimpleChatWebSocket
+       RouteKey: $disconnect       # ★注意★
+       Target: !Join
+         - '/'
+         - - 'integrations'
+           - !Ref DisconnectInteg  # ★注意★
    
+   # 切断 - 統合リソース
+   DisconnectInteg:
+     Type: AWS::ApiGatewayV2::Integration
+     DependsOn:
+       - OnDisconnectFunction      # ★注意★
+     Properties:
+       ApiId: !Ref SimpleChatWebSocket
+       IntegrationType: AWS_PROXY
+       IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnDisconnectFunction.Arn}/invocations # ★注意★

    # 切断 - Lambda
    OnDisconnectFunction:
      Type: AWS::Serverless::Function
      Properties:
        CodeUri: on_disconnect
        Handler: app.lambda_handler
        Runtime: python3.9
        Policies:
          - DynamoDBCrudPolicy:
              TableName: !Ref ConnectionsTable
        Environment:
          Variables:
            CONNECTIONS_TABLE: !Ref ConnectionsTable

    # コネクションID テーブル
    ConnectionsTable:
      Type: AWS::Serverless::SimpleTable

パーミッション の追加

  • AWS::ApiGatewayV2::Route
  • AWS::ApiGatewayV2::Integration
  • AWS::Lambda::Permission ←これを作ります
  • AWS::Serverless::Function

最後に、API Gateway から、Lambda を実行できるようにします。

  Resources:

    # -- 略 --

    # 切断 - ルート    
    DisconnectRoute:
      Type: AWS::ApiGatewayV2::Route
      Properties:
        ApiId: !Ref SimpleChatWebSocket
        RouteKey: $disconnect
        Target: !Join
          - '/'
          - - 'integrations'
            - !Ref DisconnectInteg
    
    # 切断 - 統合リソース
    DisconnectInteg:
      Type: AWS::ApiGatewayV2::Integration
      DependsOn:
        - OnDisconnectFunction
      Properties:
        ApiId: !Ref SimpleChatWebSocket
        IntegrationType: AWS_PROXY
        IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnDisconnectFunction.Arn}/invocations

+   # 切断 - パーミッション
+   OnDisconnectPermission:
+     Type: AWS::Lambda::Permission
+     DependsOn:
+       - SimpleChatWebSocket
+       - OnDisconnectFunction  # ★注意★
+     Properties:
+       Action: lambda:InvokeFunction
+       FunctionName: !Ref OnDisconnectFunction  # ★注意★
+       Principal: apigateway.amazonaws.com

    # 切断 - Lambda
    OnDisconnectFunction:
      Type: AWS::Serverless::Function
      Properties:
        CodeUri: on_disconnect
        Handler: app.lambda_handler
        Runtime: python3.9
        Policies:
          - DynamoDBCrudPolicy:
              TableName: !Ref ConnectionsTable
        Environment:
          Variables:
            CONNECTIONS_TABLE: !Ref ConnectionsTable

    # コネクションID テーブル
    ConnectionsTable:
      Type: AWS::Serverless::SimpleTable

動作確認

DynamoDB テーブルのゴミデータ削除

前回の演習で、DynamoDB テーブルにコネクションID が残ってしまっている場合は、それを削除する必要があります。

f:id:tercel_s:20220212181147p:plain

f:id:tercel_s:20220212181559p:plain

接続確認

sam deploy実行後、wscatで WebSocket のエンドポイントに接続します。

f:id:tercel_s:20220212181909p:plain

このタイミングで、接続 Lambda が実行されるはずですので、念の為に CloudWatch Logs と DynamoDB テーブルを確認してみましょう。

f:id:tercel_s:20220212182426p:plain

DynamoDB テーブルには、コネクションIDが1件登録されています。 もちろん、前回のゴミデータではなく、正真正銘、たった今実行して CloudWatch Logs に出力した、出来立てのコネクション ID です。

f:id:tercel_s:20220212182605p:plain

どうやら、接続時の Lambda はデグレード*1せず、期待どおり動いているようです。

では、切断時にこのコネクションIDが、DynamoDB テーブルから無事に削除されるかどうかを検証してみましょう。

切断確認

では、Ctrl + C で接続を切ってしまいます。

f:id:tercel_s:20220212183020p:plain

ターミナルからは、いっけん何も起きていないように見えますが、果たして Lambda は実行されているでしょうか。

CloudWatch Logs を見ると、確かに OnDisconnectFunction が実行された形跡があります。

f:id:tercel_s:20220212183320p:plain

そして、DynamoDB テーブルからも、先ほどまでいたコネクション ID が消え去っていることが確認できます。

f:id:tercel_s:20220212183453p:plain

これで、接続 Lambda と切断 Lambda の実装と動作確認が完了しました。 お疲れ様でした。

まとめ

  • WebSocket マイクロサービスのためのリソースは、便宜的に3つに色分けできる
    • AWS::ApiGatewayV2::Api
    • AWS::ApiGatewayV2::Stage
    • AWS::ApiGatewayV2::Route
    • AWS::ApiGatewayV2::Integration
    • AWS::Lambda::Permission
    • AWS::Serverless::Function
    • AWS::Serverless::SimpleTable
  • このうち、
    • 赤色グループAPI エンドポイント単位のグループ
    • 青色グループは Lambda 単位のグループ
    • 緑色グループは Lambda が使うリソースのグループ
  • Lambda を追加したい場合は、青色グループをもう1セット構築するイメージ

次回はいよいよ、任意のメッセージを送受信する部分をフロントエンド込みで作り込んでいきます。


あれ……?

今回はちょっと短く感じる。

まぁ、前回の素地がよかったので、かなり流用が利いたということでしょう。

ところで、DynamoDB って何に使っているの?

接続 Lambda は書き込むだけ、切断 Lambda は消し込むだけで、肝心の中身はどこにも使われていないような……。

それは、次回のお楽しみということで……。

おまけ: 本日の template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ""

Globals:
  Function:
    Timeout: 3

Resources:
  SimpleChatWebSocket:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: SimpleChatWebSocket
      ProtocolType: WEBSOCKET
      RouteSelectionExpression: "$request.body.message"

  ConnectRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref SimpleChatWebSocket
      RouteKey: $connect
      Target: !Join
        - '/'
        - - 'integrations'
          - !Ref ConnectInteg
  
  ConnectInteg:
    Type: AWS::ApiGatewayV2::Integration
    DependsOn:
      - OnConnectFunction
    Properties:
      ApiId: !Ref SimpleChatWebSocket
      IntegrationType: AWS_PROXY
      IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnConnectFunction.Arn}/invocations
  
  OnConnectPermission:
    Type: AWS::Lambda::Permission
    DependsOn:
      - SimpleChatWebSocket
      - OnConnectFunction
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref OnConnectFunction
      Principal: apigateway.amazonaws.com
  
  DisconnectRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref SimpleChatWebSocket
      RouteKey: $disconnect
      Target: !Join
        - '/'
        - - 'integrations'
          - !Ref DisconnectInteg
  
  DisconnectInteg:
    Type: AWS::ApiGatewayV2::Integration
    DependsOn:
      - OnDisconnectFunction
    Properties:
      ApiId: !Ref SimpleChatWebSocket
      IntegrationType: AWS_PROXY
      IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnDisconnectFunction.Arn}/invocations

  OnDisconnectPermission:
    Type: AWS::Lambda::Permission
    DependsOn:
      - SimpleChatWebSocket
      - OnDisconnectFunction
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref OnDisconnectFunction
      Principal: apigateway.amazonaws.com

  OnConnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: on_connect
      Handler: app.lambda_handler
      Runtime: python3.9
      Policies:
        - DynamoDBWritePolicy:
            TableName: !Ref ConnectionsTable
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable

  OnDisconnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: on_disconnect
      Handler: app.lambda_handler
      Runtime: python3.9
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ConnectionsTable
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable

  ConnectionsTable:
    Type: AWS::Serverless::SimpleTable

  Stage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      StageName: Prod
      ApiId: !Ref SimpleChatWebSocket
      AutoDeploy: true

Outputs:
  WebSocketURI:
    Description: "The WSS Protocol URI to connect to"
    Value: !Sub "wss://${SimpleChatWebSocket}.execute-api.${AWS::Region}.amazonaws.com/${Stage}"

*1:正常に動作していたプログラムが、別の改修の影響を受けて正しく動作しなくなること。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.