Amazon API Gateway で WebSocket を使用する

# はじめに

https://qiita.com/rairaii/items/7b313eb930b0622f71df

で作成したソースコードをベースにAPI Gatewayを使ってWebSocketを使用するシンプルなアプリケーションを作成する。

- - -

# 1. Amazon API Gateway を用いてLamda関数を呼び出す

下記の公式ドキュメントを参考にして作業を進めた

https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/serverless_example.html

作成したソースコードはこのリポジトリで確認できる

https://github.com/aRaikoFunakami/hello-cdk/tree/b1e2908014c0bba487ab0b1d032bac4c77587edf

## 1.1 Lambda関数の作成
CDK プロジェクト内で、新しい hello.js ファイルを含む `lambda` ディレクトリを作成する。

```sh
cd <プロジェクトのトップディレクトリ>
```

```sh
mkdir lambda && cd lambda
touch hello.js
```

Lamada関数を `lambda` ディレクトリにファイルを作成する。

```
hello-cdk
└── lambda
    └── hello.js
```

```javascript:hello.js
exports.handler = async (event) => {
    return {
        statusCode: 200,
        headers: { "Content-Type": "text/plain" },
        body: JSON.stringify({ message: "Hello, World!" }),
    };
};
```

## 1.2 Lambda 関数リソースの定義

Lambda 関数リソースを定義するには、 AWS コンストラクトライブラリから aws-lambda L2 コンストラクトをインポートして使用する。

```typescript: hello-cdk-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
// Import the Lambda module
import * as lambda from 'aws-cdk-lib/aws-lambda';

// Import API Gateway L2 construct
import * as apigateway from 'aws-cdk-lib/aws-apigateway';

export class HelloCdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Define the Lambda function resource
    const helloWorldFunction = new lambda.Function(this, 'HelloWorldFunction', {
      runtime: lambda.Runtime.NODEJS_20_X, // Choose any supported Node.js runtime
      code: lambda.Code.fromAsset('lambda'), // Points to the lambda directory
      handler: 'hello.handler', // Points to the 'hello' file in the lambda directory
    });

    cdk.Tags.of(helloWorldFunction).add('WPS', 'cdktest_rairaii');

    // Define the API Gateway resource
    const api = new apigateway.LambdaRestApi(this, 'HelloWorldApi', {
      handler: helloWorldFunction,
      proxy: false,
    });

    cdk.Tags.of(api).add('WPS', 'cdktest_rairaii');   
        
    // Define the '/hello' resource with a GET method
    const helloResource = api.root.addResource('hello');
    helloResource.addMethod('GET');
  }
}
```

AWS CDK (Cloud Development Kit) を使って AWS Lambda と API Gateway を設定する。このプログラムをデプロイすると、`/hello` というエンドポイントが作成され、GET リクエストを受け取ると、Lambda関数 hello.handler が実行されるようになります。

1. Lambda関数の作成
    - `lambda/hello.js` というファイルにある **handler** 関数を実行するLambda関数 (HelloWorldFunction) を作成
    - 実行環境として Node.js 20.x を使用
    - Lambdaのコードは lambda ディレクトリから取得
2. API Gateway の作成
    - **HelloWorldApi** というAPI Gatewayを作成し、Lambda関数 (HelloWorldFunction) にリクエストを転送するように設定
    - proxy: false により、手動でエンドポイントを設定する
3. APIのエンドポイント設定
    - `/hello` というパスを作成し、GET メソッドでリクエストを受け付けるようにする




## 1.3 アプリケーションをデプロイする

TypeScriptの場合には下記を実行してから、
```
npm run build
```

次のコマンドを実行してデプロイする

```
npx cdk synth
npx cdk deploy
```

## 1.4 Lambda 関数の動作確認

```sh
$ curl https://<Lambda関数のURL>/prod/hello
{"message":"Hello, World!"}%  
```

CDK で API Gateway を作成する際、特に stageName を指定しない場合、デフォルトの prod ステージが作成されるため、`/prod/hello`となる。

ステージ名を明示的に`dev` や `staging` などを指定したい場合には、stageName を明示的に指定 する必要がある。


| **ステージ名** | **用途** |
|--------------|----------------|
| dev       | 開発環境 |
| staging   | 検証環境 |
| prod      | 本番環境 (デフォルト) |

```typescript
new apigateway.LambdaRestApi(this, 'HelloWorldApi', {
  handler: helloWorldFunction,
  deployOptions: {
    stageName: "dev" // ステージ名を明示的に設定
  }
});
```


## 1.5 アプリケーションの削除

```sh
cdk destroy
```

- - -

# 2. API Gateway で WebSocket

AWS CDK を使って WebSocket API → Lambda でメッセージをエコーするシステムを構築する

使用したソースコードは下記で確認できる

https://github.com/aRaikoFunakami/hello-cdk/tree/92c5349e8cdd1746ceba88c1e1e8dd5e9e28aa8e

## 2.1 プログラム全体の流れ

1. **WebSocket API の作成**
    - WebSocketApi リソースを作成し、WebSocket のルート（$connect, $disconnect, $default）を定義する
    - 今回のコードでは、先に空の WebSocket API (HelloWebSocketApi) を作成し、あとでルートを追加する
2. **WebSocket ステージの設定**
    - WebSocket API に対して、ステージ を作成する
3. **Lambda 関数の定義**
    - WebSocket から 呼び出される Lambda 関数 を定義する
    - WEBSOCKET_ENDPOINT を環境変数として設定することで、Lambda 内で WebSocket API のエンドポイントを参照できる
4. **IAM ポリシーの付与** (execute-api:ManageConnections)
    - WebSocket API では、Lambda 関数がクライアントへメッセージを返すときに ApiGatewayManagementApi を使用する
    - この操作には execute-api:ManageConnections の権限が必要となる
    - Lambda 関数のロールにポリシーを追加することで、メッセージ送信（postToConnection）を許可する
5. **WebSocket の各ルートを追加** ($connect, $disconnect, $default)
    - WebSocket API の各ルートに上記の Lambda 関数を紐付ける
 
```typescript: hello-cdk-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
// Import the Lambda module
import * as lambda from 'aws-cdk-lib/aws-lambda';

// Import API Gateway WebSocket module
import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';

// Import IAM module
import * as iam from 'aws-cdk-lib/aws-iam';

export class HelloCdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 1. WebSocket APIを先に作成 (ルートは後で addRoute)
    const webSocketApi = new apigatewayv2.WebSocketApi(this, 'HelloWebSocketApi');

    cdk.Tags.of(webSocketApi).add('WPS', 'cdktest_rairaii');

    // 2. WebSocketステージの作成 (任意で 'prod' や '$default' を指定)
    const webSocketStage = new apigatewayv2.WebSocketStage(this, 'HelloWebSocketStage', {
      webSocketApi,
      stageName: 'prod', // 例: 'prod' にした場合の接続URL → wss://{apiId}.execute-api.{region}.amazonaws.com/prod
      autoDeploy: true,
    });
    cdk.Tags.of(webSocketStage).add('WPS', 'cdktest_rairaii');

    // 3. WebSocketから呼びだされるLambda関数の定義
    const helloWorldFunction = new lambda.Function(this, 'HelloWorldFunction', {
      runtime: lambda.Runtime.NODEJS_20_X,
      code: lambda.Code.fromAsset('lambda'),
      handler: 'hello.handler',
      // もしLambdaで endpoint を使うなら、ここでwebSocketApi.apiIdを参照可能
      // 例: stageName が 'prod' の場合
      environment: {
        WEBSOCKET_ENDPOINT: `https://${webSocketApi.apiId}.execute-api.${this.region}.amazonaws.com/prod`,
      },
    });
    
    cdk.Tags.of(helloWorldFunction).add('WPS', 'cdktest_rairaii');

    // 4. すでに作成済みの Lambda 関数 (helloWorldFunction) に対してポリシーを追加
    helloWorldFunction.addToRolePolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['execute-api:ManageConnections'],
      resources: [
        // WebSocket API の ARN を指定
        // stageName: 'prod' → "prod/POST/@connections/*"
        `arn:aws:execute-api:${this.region}:${this.account}:${webSocketApi.apiId}/${webSocketStage.stageName}/POST/@connections/*`,
      ],
    }));

    // 5. WebSocket APIの各ルート ($connect, $disconnect, $default) を addRoute で定義
    webSocketApi.addRoute('$connect', {
      integration: new integrations.WebSocketLambdaIntegration('ConnectIntegration', helloWorldFunction),
    });

    webSocketApi.addRoute('$disconnect', {
      integration: new integrations.WebSocketLambdaIntegration('DisconnectIntegration', helloWorldFunction),
    });

    webSocketApi.addRoute('$default', {
      integration: new integrations.WebSocketLambdaIntegration('DefaultIntegration', helloWorldFunction),
    });
  }
}
```

## 2.1 Lambda関数の実装

AWS Lambda 関数として実行される Node.js コードで、API Gateway (WebSocket) からのイベント（接続・切断・メッセージ送信）を処理しつつ、エコー（echo）メッセージをクライアントに返す.

###  wss と https
AWS API Gatewayの WebSocketエンドポイント は、クライアント側から接続するときは wss:// ですが、Lambda から ApiGatewayManagementApi を呼び出すときは HTTPS (REST形式) でアクセスする必要がある。したがって、プログラム上では「WebSocket用のエンドポイント (wss) と、Lambda 内で管理APIを呼び出すときのエンドポイント (https)」を使い分ける というポイントがある

```hello-cdk-stack.ts
      environment: {
        WEBSOCKET_ENDPOINT: `https://${webSocketApi.apiId}.execute-api.${this.region}.amazonaws.com/prod`,
      },
```
hello-cdk-stack.ts では WEBSOCKET_ENDPOINT に `https://${webSocketApi.apiId}.execute-api.${this.region}.amazonaws.com/prod` を設定している

Lamda関数では、endpoint に process.env.WEBSOCKET_ENDPOINT を設定しつつ、wss:// → https:// に変換している。WebSocket へのメッセージ送信は内部的に HTTPS リクエストになるため、wss:// を https:// に置き換える 必要がある

- **クライアント視点**
    - wss://{apiId}.execute-api.{region}.amazonaws.com/prod で WebSocket 接続
- **Lambda 内部**
    - https://{apiId}.execute-api.{region}.amazonaws.com/prod を使い、ApiGatewayManagementApiClient 経由でメッセージ送信


### Lambda Handler (exports.handler)

- event は API Gateway WebSocket から渡されるオブジェクト
- routeKey
    - **$connect** の場合 → 新規接続時
    - **$disconnect** の場合 → 切断時
    - その他は **$default** ルート → 任意のメッセージ受信時を示す
- connectionId は WebSocket の接続を一意に識別する ID。メッセージをクライアントに送り返す際に必要となる

### sendMessageToClient 関数
- PostToConnectionCommand は @aws-sdk/client-apigatewaymanagementapi のクラス
- connectionId: 送信先の WebSocket 接続を指定し、Data に送りたいメッセージ（Buffer 形式）を設定
- apiGateway.send(command) で実際に API を呼び出し、メッセージをクライアントに返します
    - execute-api:ManageConnections の IAM ポリシーがないと AccessDeniedException が発生する



```javascript: hello.js
const { ApiGatewayManagementApiClient, PostToConnectionCommand } = require("@aws-sdk/client-apigatewaymanagementapi");

// WebSocket API のエンドポイントを HTTPS に変換
const apiGateway = new ApiGatewayManagementApiClient({
    endpoint: process.env.WEBSOCKET_ENDPOINT.replace(/^wss:\/\//, "https://"),
});

exports.handler = async (event) => {
    console.log("Received event: ", JSON.stringify(event, null, 2));

    const { requestContext, body } = event;
    const connectionId = requestContext.connectionId;

    try {
        switch (requestContext.routeKey) {
            case '$connect':
                console.log(`New connection: ${connectionId}`);
                return { statusCode: 200, body: 'Connected' };

            case '$disconnect':
                console.log(`Disconnected: ${connectionId}`);
                return { statusCode: 200, body: 'Disconnected' };

            default:
                console.log(`Received message: ${body}`);
                await sendMessageToClient(connectionId, `Echo: ${body}`);
                return { statusCode: 200, body: `Echoed: ${body}` };
        }
    } catch (error) {
        console.error('Error: ', error);
        return { statusCode: 500, body: 'Error processing request' };
    }
};

// メッセージをクライアントに送信する関数（修正済み）
const sendMessageToClient = async (connectionId, message) => {
    try {
        const command = new PostToConnectionCommand({
            ConnectionId: connectionId,
            Data: Buffer.from(message),
        });

        await apiGateway.send(command);
        console.log(`Sent message to ${connectionId}: ${message}`);
    } catch (error) {
        console.error(`Failed to send message to ${connectionId}:`, error);
    }
};
```

## 2.2 エコーサーバーの動作確認
以下のコマンドを実行すると、API Gateway の WebSocket エンドポイントを取得できる

```sh
$ aws apigatewayv2 get-apis --query "Items[?Name=='HelloWebSocketApi'].ApiEndpoint" --output text
https://abcdef1234.execute-api.us-east-1.amazonaws.com
```

API Gateway に接続する

```sh
wscat -c https://abcdef1234.execute-api.us-east-1.amazonaws.com/prod
Connected (press CTRL+C to quit)
> hello, world
< Echo: hello, world
> hello, aws lambda
< Echo: hello, aws lambda
> %                        
```

- - -

# 3. API Gateway (WebSocket) のログ確認とトラブルシューティング

API Gateway で WebSocket を利用するプログラムを動作させるまでに複数の不具合に遭遇したのでトラブルシューティングのためのメモを記載しておく。

## 3.1 API Gateway の状況を確認する 

デプロイした API Gateway WebSocket が正しく作成されているかを CLI で確認する

```sh
aws apigatewayv2 get-apis --query "Items[*].[ApiId, Name]" --output table
```

出力例

```terminal
-------------------------------------
|              GetApis              |
+-------------+---------------------+
|  abcdef1234 |  HelloWebSocketApi  |
+-------------+---------------------+
```

ApiId と Name が表示されれば、WebSocket API は正常に登録されている


## 3.2 統合設定 (Integrations) を確認する

```sh
aws apigatewayv2 get-integrations --api-id abcdef1234
```

出力例


```yaml
Items:
- ConnectionType: INTERNET
  IntegrationId: 3jslwg4
  IntegrationMethod: POST
  IntegrationType: AWS_PROXY
  IntegrationUri: arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789123:function:HelloCdkStack-HelloWorldFunctionB2AB6E79-fQTmWA2xxyzJ/invocations
  ...
```

- IntegrationUri に 正しい Lambda 関数 ARN が入っているかを確認する
- IntegrationType: AWS_PROXY であれば、Lambda へのプロキシ統合が正しく設定されている

## 3.2 Lambda の CloudWatch Logs を確認する

```bash
aws logs tail "/aws/lambda/HelloCdkStack-HelloWorldFunctionB2AB6E79-fQTmWA2xxyzJ" --follow
```

**ログの例**

```terminal
2025-02-16T11:25:04.577Z	undefined	ERROR	Uncaught Exception 	{
  "errorType":"Runtime.ImportModuleError",
  "errorMessage":"Error: Cannot find module 'aws-sdk'\nRequire stack:\n- /var/task/hello.js\n...
}
```
このように、Cannot find module 'aws-sdk' のエラーが記録されています。

**修正例**

Node.js 20.x では aws-sdk がデフォルトで含まれていないため動作しない という問題があります。修正方法するには aws-sdk を @aws-sdk/client-apigatewaymanagementapi に変更する

```javascript: 修正前
const AWS = require('aws-sdk');
```
```javascript: 修正後
const { ApiGatewayManagementApiClient, PostToConnectionCommand } = require("@aws-sdk/client-apigatewaymanagementapi");
```

- - - 



# 4. WebSocketを使う場合の注意点

### IAM ポリシー execute-api:ManageConnections の付与
- Lambda が WebSocket 接続先にメッセージを返すには、execute-api:ManageConnections が必須です
- ARN の形式 は `arn:aws:execute-api:{region}:{account}:{apiId}/{stageName}/POST/@connections/*`
- ポリシーがないと AccessDeniedException が発生する

### Node.js ランタイムと AWS SDK
- Node.js 20.x 以降では、aws-sdk v2 が含まれないため、今回は @aws-sdk/client-apigatewaymanagementapi (v3) を使用した
- postToConnection メソッドではなく、PostToConnectionCommand を使う必要がある





- - -

# 参考資料

https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/serverless_example.html

https://zenn.dev/ncdc/articles/e900f7a18d3506

https://dev.classmethod.jp/articles/aws-cdk-api-gateway-lambda-rest-auth0-lambda-authorizer/