WebSocket API Gateway の前にCloudFrontを置く

2020.08.14

WebSocket API Gateway の前段にCloudFrontディストリビューションを置く構成を作成する機会がありましたのでレポートします。

この構成のメリット

WebSocketと他のリソースを同じFQDNから配信できる

CloudFrontディストリビューションは、Behaviors(Cache Behaviors)という機能を活用することで複数のオリジンを同一FQDNから配信することができます。

例えば以下のようなことができます。

  • /websocket パス以下をWebSocket API Gatewayオリジンにする
  • 他のパスは他のオリジンを使う(S3、ALBなど)

※とはいえ、FQDN分けてしまえるならその方がシンプルな構成になり良いかと思います。

ネットワークの最適化

2020/08/14現在、CloudFrontの接続ポイントは42か国84都市にある216箇所が提供されています。日本では大阪に1、東京に16箇所あります。接続ポイント以降の通信、つまりオリジンのAPI Gatewayまでの通信はAWSのバックボーンネットワークを使うので、通信の安定化、高速化が期待できます。

HTTP(ws)が使いたい

WebSocket API GatewayはHTTP(ws)に対応しておらず、HTTPS(wss)で通信する必要があります。何かしらの理由でHTTP(ws)で通信したい場合、クライアント↔CloudFrontディストリビューション間はHTTP(ws)で通信し、CloudFrontディストリビューション↔WebSocket API Gateway間はHTTPS(wss)で通信するという構成を取ることができます。


さて、以降はこの構成を作ってみたレポートです。

前提条件

  • クライアントは、CloudFrontに「https://cfront.kazue.xxxxx.xxxx」でアクセスします。 
    CloudFrontの設定項目でいうと、「cfront.kazue.xxxxx.xxxx」でAlternate Domain Nameを設定します。このドメインに対する証明書をACMで用意します。デフォルトのドメイン名「xxxx.cloudfront.net」は使用しません。
  • すでに「kazue.xxxxx.xxxx」ホストゾーンをRoute 53で管理しているとします。
  • 東京リージョンにAPI Gatewayを作成します。
  • API Gatewayのカスタムドメインを使い、WebSocket API GatewayのProdステージとマッピングします。 ドメイン名はCloudFrontの設定と同じく「cfront.kazue.xxxxx.xxxx」にします。(なぜこうするかは後述します)カスタムドメインには証明書が必要なので事前にACMで作成しておきます。
  • CloudFrontディストリビューションには2つオリジンを設定します。デフォルトオリジンはS3です。もう一つをAPI Gatewayにし、そのPath Patternを「websocket*」とします。つまり 「https://cfront.kazue.xxxxx.xxxx/index.html」などとすればS3からHTMLが返ってきます。「wss://cfront.kazue.xxxxx.xxxx/websocket」とするとWebSocket通信を開始できます。
  • CloudFrontディストリビューションがAPI Gatewayオリジンにアクセスする際は、 (カスタムドメインではなく、)API Gatewayのオリジナルのドメインを使います。
    CloudFrontの設定項目でいうと、Origin Domain Name値を 「xxxxxxx.execute-api.ap-northeast-1.amazonaws.com」にするということです。

上記内容の概要を図を使って再度説明します。

  • ①の部分はDNS名前解決についてです。cfront.kazue.xxxxx.xxxxの名前解決でCloudFrontディストリビューション(のIP)が返ってくるようにRoute53ホストゾーンでDNSレコードを設定します。
  • ②の部分はCloudFrontのBehaviors設定です。websocket以下のパスにアクセスがある場合、そのリクエストはWebSocket APIGatewayのデフォルトURL: xxxxxxx.execute-api.ap-northeast-1.amazonaws.comに転送されます。それ以外はS3バケットに転送されます。
  • ③の部分はAPI Gatewayカスタムドメインの設定です。カスタムドメイン cfront.kazue.xxxxx.xxxxの websocketパスでWebSocket API GatewayのProdステージとマッピングします。

なお、この構成は Stack Overflowのこのコメントをかなり参考にしています。というか、この内容をまんまやっています。ありがとうStack Overflow。

なぜAPI Gatewayカスタムドメインが必要なのか

③のAPI Gatewayカスタムドメインは不要なんじゃないかと思われた方もいらっしゃるのではと思います。 必要な理由を説明します。2点あります。

WebSocket APIを自由なパスで配信するため

カスタムドメインを使わない場合、WebSocket API GatewayをオリジンとするBehaviorのPath Patternは、WebSocket API Gatewayのステージ名と同じにする必要があります。例えば今回の場合ステージ名がProdなので、Path Patternの値は「Prod*」にする必要があります。

解説します。まず、API GatewayのURLの第一セグメントはステージ名です。今回はここが「Prod」になります。

  • xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod

一方、CloudFrontディストリビューションへのリクエストパスはそのままオリジンへのリクエストパスに使われます。

  • xxxxxxx.cloudfront.net/Prod へのアクセス →
    xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod に転送される

というわけで、Path PatternはAPI Gatewayのステージ名と同じにする必要があります。

蛇足ですが、CloudFrontディストリビューションでOrigin Pathを設定した場合は、そのパスも上乗せされます。(今回はOrigin Pathを設定しません)

  • Origin Pathに/hogeを設定していた場合
    • xxxxxxx.cloudfront.net/Prod へのアクセス →
      xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/hoge/Prod に転送される

カスタムドメインを使うと、カスタムドメインのパスとAPI Gatewayのステージを自在にマッピングできます。ですので、API Gatewayのステージ名と、CloudFront BehaviorのPath Patternつまり配信パスを同じにする必要がなくなります。

サーバーから@connections コマンドを使用するため

サーバー側は@connections コマンドを使用して、接続されたクライアントに対して色々な処理ができます。ですが、カスタムドメインを使わないとこの処理がエラーになります。

なぜエラーになるのか説明します。(長いです)

  • @connections コマンドの利用にはIAM認証が必要です。
  • IAM認証のために、リクエストに署名が必要です。
    (参考: AWS API リクエストの署名 | AWS General Reference リファレンスガイド)
  • 通常、SDKからAWSサービスのAPIを利用する場合、SDK内でこの署名の処理を行なってくれるので、我々は署名について意識する必要はあまりありません。
  • ですが今回、クライアントとAPI Gatewayの間にCloudFrontディストリビューションを挟むことにより、この署名による認証が失敗します。
  • まず、GETリクエストの場合、Missing Authentication Tokenというエラーになります。
    • こうなる原因は、CloudFrontがオリジンにリクエストを転送する際に必要なヘッダーを削除するからです。
    • 署名には Authorization ヘッダーを使用します。
    • ですが、CloudFrontはGETリクエストの場合、オリジンへのリクエスト転送時にAuthorizationヘッダーを削除するのがデフォルトの動作です。
      (参考: HTTP リクエストヘッダーと CloudFront の動作 (カスタムオリジンおよび S3 オリジン)
    • よって、API Gatewayからすると署名が無いリクエストとなり、前述のエラーになります。
    • 解決方法は、オリジンへのリクエスト転送時にAuthorizationヘッダーを削除しないように設定することです。つまり「Cache Based on Selected Request Headers」設定項目のWhitelistにAuthorizationヘッダーを指定します。
    • こうするとMissing Authentication Tokenエラーは解消できます。が、以下のPOSTリクエスト時のエラーが出るようになります。
  • 次にPOSTリクエストの場合です。この場合The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.〜〜 というエラーが返ってきます。署名は届いていますが、署名による認証が失敗したという内容のエラーです。
    • 署名の1要素としてエンドポイント(Hostヘッダー)値があります。
    • 今回はAPI Gatewayへのリクエストなので、API Gatewayエンドポイント値が使われる必要があります。が、クライアントのリクエストのHostヘッダーはCloudFrontディストリビューションのエンドポイント値(「xxxx.cloudfront.net」もしくは、Alternate Domain Nameで設定した値)です。この値をもとに署名を作成するので、認証失敗します。
      • HostヘッダーがCloudFrontディストリビューションのエンドポイント値なのであれば、そもそもオリジン(API Gateway)へのリクエスト転送時に、リクエスト先(= API Gateway) とHostヘッダー値(CloudFrontディストリビューション)が異なることにより、403エラーになりそうです。が、この点は大丈夫です。CloudFrontはデフォルトでは、オリジンへのリクエスト転送時にHostヘッダー値をオリジンのドメイン名に書き換えます。 (参考: HTTP リクエストヘッダーと CloudFront の動作 (カスタムオリジンおよび S3 オリジン)
      • このようにしてHostヘッダー自体についてはよしなに処理してくれるのですが、Hostヘッダー値をもとに生成される署名についてまでは対応してくれません。。
    • ではクライアントからのリクエスト時にHostヘッダーをAPI Gatewayエンドポイント値に指定(上書き)してやればよいのではと思いますが、こうするとCloudFrontへのリクエスト自体が403エラーになりだめです。
    • 解決方法としては、CloudFrontディストリビューション、API Gatewayどちらも同じHostヘッダー値を受け入れる(=403エラーにならない)ようにし、同じHostヘッダー値を使うようすることです。そのために以下2つの対応をします。
      • API Gatewayカスタムドメインで、CloudFrontディストリビューションのAlternate Domain Nameと同じ値を設定することで、同じHostヘッダー値を受け入れるようにします。
      • 「Cache Based on Selected Request Headers」設定項目のWhitelistにHostを追加します。これによりデフォルトの挙動である「オリジンへのリクエスト転送時にHostヘッダー値をオリジンのドメイン名に書き換える」処理を止め、そのままの値が転送されるようにします。
    • DELETEリクエストの場合も同様のエラーになります。同様の方法で解決可能です。

「以降はこの構成を作ってみたレポートです。」と書いたにもかかわらず前置きが長くなってしまいました。ここからは本当にやっていきます。

1.オリジンのWebSocket API Gatewayの作成

aws-samplesのものを SAR(Serverless Application Repository)からデプロイしました。

GitHubのREADMEに書かれている方法でテストします。wscatを使います。

$ npm install -g wscat
$ wscat -c wss://xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod
Connected (press CTRL+C to quit)
> {"action":"sendmessage", "data":"hello world"}
< hello world
>

接続確認できました。

@connections コマンドの使用もできるか確認します。awscurlを使います。

connection_idはDynamoDBテーブルsimplechat_connectionsの中身を確認するか、wscatコマンドで確立した接続中に意図的にエラーを発生させると確認できます。

$ wscat -c wss://xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod
Connected (press CTRL+C to quit)
> (何も入力せずreturn(Enter))
< {"message": "Forbidden", "connectionId":"Rty9DcDetjMCJww=", "requestId":"RtzDjGdWNjMFgHw="}

別ウインドウ

$ awscurl --profile hoge --service execute-api -X GET  https://xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/%40connections/Rty9DcDetjMCJww%3D
b'{\n  "identity" : {\n    "sourceIp" : "xxx.xxx.xxx.xxx",\n    "userAgent" : null\n  },\n  "connectedAt" : "2020-08-23T09:07:57.083Z",\n  "lastActiveAt" : "2020-08-23T09:08:38.656Z"\n}'

GETで接続ステータスを取得しました。

次はPOSTでクライアントにメッセージを送ってみます。

$ awscurl --profile hoge --service execute-api -X POST -d 'test message'  https://xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/%40connections/Rty9DcDetjMCJww%3D
b''

もとのウインドウ(wscatコマンド実行中のウインドウ)に「< test message」と返ってくれば成功です。

最後にDELETEも試します。

$ awscurl --profile hoge --service execute-api -X DELETE  https://xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/%40connections/Rty9DcDetjMCJww%3D
b''

もとのウインドウ(wscatコマンド実行中のウインドウ)に「Disconnected (code: 1000, reason: "Connection Closed Normally")」と表示され接続切断されれば成功です。

2.ACM証明書を取得 & API Gateway カスタムドメインを作成

API Gateway カスタムドメイン作成時にACM証明書が必要になるので、先に取得します。その証明書をAPI Gateway カスタムドメインを作成時に使います。

具体的な方法は以下エントリの 「Certficate ManagerでSSLを取得する」と「API Gatewayでカスタムドメインを設定する」を参考にしてください。「Route53でDNSの設定」は今回は不要です!

マッピングの設定方法が変わっています。一度カスタムドメインを作成した後、下部「API マッピングを設定」ボタンから設定します。

3.CloudFrontディストリビューション用ACM証明書を取得

再びACM証明書を取得します。今度はCloudFrontディストリビューション用なので、米国東部(バージニア北部)us-east-1リージョンで取得します。先程とリージョンを変える以外は同じなので、説明は割愛します。

4.CloudFrontディストリビューションの作成

まずはS3オリジンを持つCloudFrontディストリビューションを作成します。API Gatewayオリジンは後から追加します。

CloudFrontマネジメントコンソールホームから「Create Distribution」を押します。

Step 1: Select delivery method

「Web」の「Get Started」を押します。

Step 2: Create distribution

Origin Settings

S3バケットを指定します。

Default Cache Behavior Settings

S3オリジンの要件に沿って設定してください。

Distribution Settings

Alternate Domain Names(CNAMEs)

今回使うドメイン「cfront.kazue.xxxxx.xxxx」を入力します。

SSL Certificate

「Custom SSL Certificate (example.com):」を選択し、先程取得したACM証明書を選択します。

5.Route53でAレコード追加

  1. Route 53 ホストゾーンページで使用するドメインを選択します。
  2. 「レコードを作成」ボタンをクリックします。
  3. デフォルトのシンプルルーティングを選んで「次へ」をクリックします。
  4. 「シンプルなレコードを定義」をクリックします。
  5. 以下のようにCloudFrontディストリビューションを選択し、再び「シンプルなレコードを定義」をクリックします。
  6. 「レコードを作成」をクリックします。

動作確認

CloudFrontディストリビューションのStatusが「In Progress」から「Deployed」に変わっていることを確認してからアクセスしてみます。 今回はS3オリジンには「public」とだけ書かれたindex.htmlを置いています。

$ curl https://cfront.kazue.xxxxx.xxxx/index.html
public

アクセスできました!

6.API Gatewayオリジンの追加

CloudFrontディストリビューションのOrigins and Origin Groupsタブから「Create Origin」をクリックします。

Origin Settings

Origin Domain Name

API GatewayのステージのステージページのURLの、ステージ部分を削除したものを入力します。例:xxxxxxx.execute-api.ap-northeast-1.amazonaws.com)
wss://プロトコル部分は不要です。(入力しても勝手に削除されます。)

Origin Path

空欄のままで良いです。

Origin Protocol Policy

API GatewayはHTTPS(wss)のみ受け付けるので、HTTPS Onlyを選択します。

7.Behavior作成

Behaviorsタブから「Create Behavior」をクリックします。

Cache Behavior Settings

Path Pattern

「websocket*」(WebSocketを配信したいパス)と入力します。

Origin or Origin Group

先ほど作成したカスタムオリジンを選択します。

Viewer Protocol Policy

Redirect HTTP to HTTPSにすると、 wsプロトコルでアクセスした場合にerror: Unexpected server response: 301になりましたので選択しないようにしましょう。

Allowed HTTP Methods

最下部 GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE を選択します。

Cache and origin request settings

Use legacy cache settingsを選択します。

Cache Based on Selected Request Headers

whitelistを選択します。

「Whitelist Headers」でHostとAuthorizationヘッダーをリストに入れます。

Object Caching

Customizeを選択し、その下の「Minimum TTL」「Maximum TTL」「Default TTL」をすべて0にします。

以上です。右下「Create」ボタンをクリックしてしばらく待ちます。

8.動作確認

$ wscat -c wss://cfront.kazue.xxxxx.xxxx/websocket
Connected (press CTRL+C to quit)
> {"action":"sendmessage", "data":"hello world"}

あれ…「hello world」とレスポンスが返ってくるはずなのですが、返ってこないですね。

sendmessageアクションで実行されるLambda関数は、以下のsendmessage/app.jsという関数です。(「1.オリジンのWebSocket API Gatewayの作成」でSARからデプロイしたアプリケーションの一部です。)

CloudWatchLogsを確認しましたが特にエラーログがでていませんでした。デバッグ用コードを追加します。
CloudFrontをかましているので、怪しそうなendpointパラメータの構成要素を確認します。

(省略)
  try {
    connectionData = await ddb.scan({ TableName: TABLE_NAME, ProjectionExpression: 'connectionId' }).promise();
  } catch (e) {
    return { statusCode: 500, body: e.stack };
  }
  
  console.log(event.requestContext);
  const apigwManagementApi = new AWS.ApiGatewayManagementApi({
    apiVersion: '2018-11-29',
    endpoint: event.requestContext.domainName + '/' + event.requestContext.stage
  });
  
  const postData = JSON.parse(event.body).data;
  
  const postCalls = connectionData.Items.map(async ({ connectionId }) => {
    try {
      await apigwManagementApi.postToConnection({ ConnectionId: connectionId, Data: postData }).promise();
    } catch (e) {
      if (e.statusCode === 410) {
        console.log(`Found stale connection, deleting ${connectionId}`);
        await ddb.delete({ TableName: TABLE_NAME, Key: { connectionId } }).promise();
      } else {
        throw e;
      }
    }
  });
(省略)

event.requestContextの中身はこんな感じでした。
stageの値が「Prod」になっています。そしてこれがendpointパラメータに使われています。カスタムドメインでAPI Gatewayにアクセスしているので、ここはカスタムドメインのパス 「websocket」を使ったほうが良さそうです。

{
  routeKey: 'sendmessage',
  messageId: 'xxxxxxxx',
  eventType: 'MESSAGE',
  extendedRequestId: 'xxxxxxxx',
  requestTime: '24/Aug/2020:07:41:31 +0000',
  messageDirection: 'IN',
  stage: 'Prod',
  connectedAt: 1598254355650,
  requestTimeEpoch: 1598254891390,
  identity: {
    cognitoIdentityPoolId: null,
    cognitoIdentityId: null,
    principalOrgId: null,
    cognitoAuthenticationType: null,
    userArn: null,
    userAgent: 'Amazon CloudFront',
    accountId: null,
    caller: null,
    sourceIp: 'xx.xx.xx.xx',
    accessKey: null,
    cognitoAuthenticationProvider: null,
    user: null
  },
  requestId: 'xxxxxxxx',
  domainName: 'cfront.kazue.xxxxx.xxxx',
  connectionId: 'Rw37Fd_ztjMCEEA=',
  apiId: 'xxxxxx'
}

コードを修正し、デプロイします。(ベタ書きしていますが、Lambda関数の環境変数にするなどしたほうが良いです)

    const apigwManagementApi = new AWS.ApiGatewayManagementApi({
      apiVersion: '2018-11-29',
-     endpoint: event.requestContext.domainName + '/' + event.requestContext.stage
+     endpoint: event.requestContext.domainName + '/' + "websocket"
    });

再度動作確認です。

$ wscat -c wss://cfront.kazue.xxxxx.xxxx/websocket
Connected (press CTRL+C to quit)
> {"action":"sendmessage", "data":"hello world"}
< hello world
>

無事修正が確認できました🙌

@connections コマンドの使用もできるか確認します。

まずはGETです。

$ awscurl --profile hoge --service execute-api -X GET  https://cfront.kazue.xxxxx.xxxx/websocket/%40connections/RxBKBck1NjMCIPg%3D
b'{\n  "identity" : {\n    "sourceIp" : "xxx.xxx.xxx.xxx",\n    "userAgent" : "Amazon CloudFront"\n  },\n  "connectedAt" : "2020-08-24T08:35:37.612Z",\n  "lastActiveAt" : "2020-08-24T08:38:39.625Z"\n}'

次にPOSTです。wscatコマンド実行中のウインドウに「< test message」と表示されます。

$ awscurl --profile hoge --service execute-api -X POST -d 'test message'  https://cfront.kazue.xxxxx.xxxx/websocket/%40connections/RxBKBck1NjMCIPg%3D
b''

最後にDELETEです。wscatコマンド実行中のウインドウで接続切断されたことを確認します。

$ awscurl --profile hoge --service execute-api -X DELETE https://cfront.kazue.xxxxx.xxxx/websocket/%40connections/RxBKBck1NjMCIPg%3D
b''

まとめ

WebSocket API Gateway の前段にCloudFrontディストリビューションを置く構成についてレポートしました。同様の構成を作成するの際の一助になれば幸いです。ですがぶっちゃけ、やや複雑な構成になってしまっていて保守が辛いと思うので、まずはCloudFrontなしで要件を満たせないかご検討されたほうが良いかと思います。

あわせて読みたい

参考情報