こんにちは。X(クロス)イノベーション本部 ソフトウェアデザインセンター セキュリティグループの耿です。
Amazon CloudWatch RUM は Webブラウザで発生したアプリケーションのエラーやパフォーマンス情報を収集し、モニタリングするための機能です。
ECSのブルーグリーンデプロイメントを利用してWebアプリをデプロイしているのですが、CloudWatch RUMを利用するにあたって本番昇格前のテスト環境からのデータが、分析の際のノイズにならないよう、本番環境からのデータと混ざらないようにしたいと思いました。そこで今回はテスト環境からはCloudWatch RUMにデータを送信せず、本番環境に昇格した時のみデータを送信する方法について書きます。
インフラ構成
以下の構成でアプリケーションが稼働しています。
- Webアプリケーションをコンテナとしてビルドし、ECRにイメージをプッシュ
- ECSサービス(Fargate)としてホスティング
- ECSのブルーグリーンデプロイメントを利用(詳細はこちらの記事にて)
- 本番環境を443ポート、テスト環境を8443ポートとしてALBで異なるターゲットグループにルーティング
- カスタムドメインのTLS証明書をALBで使用
CloudWatch RUMの利用開始方法
CloudWatch RUMを利用するには、まずマネジメントコンソールからアプリケーションモニターを追加します。
作成するとJavaScriptのコードスニペットが表示され、それをアプリケーションに追加するだけでブラウザからデータが収集されるようになります。
テスト環境のデータが混ざってしまう
アプリケーションモニターを追加する際にはデータを収集するアプリのドメインを指定するので、それ以外のドメインから送信されたデータは受け付けてくれません。すなわちlocalhostなどでアプリを実行した場合のデータがアプリケーションモニターに登録されることはありません。
しかしデータ収集元のアプリのポート番号は、記事執筆時点では指定できませんでした。ECSのブルーグリーンデプロイメントでは同一ドメインの異なるポート番号で本番環境とテスト環境が存在するため、テスト環境にアクセスしたときのデータもCloudWatch RUMに登録されてしまいます。本番トラフィックに比べてテストトラフィックが十分に少なければ気にしなくても良いかもしれませんが、今回は本番環境からのみデータが送信される仕組みを作ってみます。
解決方法
ブルーグリーンデプロイメントの場合、本番環境とテスト環境は同一の構成であり、ルーティングだけが異なります。つまり本番環境にだけCloudWatch RUMのコードスニペットを含めたり、コンテナに渡す環境変数によってデータ送信の有効化を制御したりすることはできません。
そこでCloudWatch RUMのコードスニペットを直接アプリに含めるのではなくブラウザで外部から取得するようにし、取得したスクリプトへのブラウザ内アクセスをCORSで制限するようにしました。こうすることで、テスト環境ではスクリプトがブラウザで実行されず、データはCloudWatch RUMに送信されません。テスト環境ではブラウザのコンソールにCORSエラーが出ますが、本番環境ではないので問題ないでしょう。
具体的にはインフラリソースとしてS3バケットを作成してCORSを設定し、そこにコードスニペットをファイルで追加します。これだけでも動くとは思いますが、S3バケットをパブリックにしたくないため、バケット自体は非公開のままでCloudFront経由でコンテンツを配信するようにします。CloudFrontディストリビューションには独自ドメインのTLS証明書を関連付けました。CORSを効かせるために、アプリのドメインとはクロスドメインになるようにします。
以下では順を追って設定方法を説明します。
CloudFront用TLS証明書の用意
TLS証明書をあらかじめ発行しておきます。今回はアプリドメイン my-domain.com
のサブドメインとして、 static.my-domain.com
を利用するとします。CORSのオリジンはプロトコル、ドメイン、ポートの3点セットで区別されるため、アプリドメインとはクロスオリジンの関係になります。
参考までにCDKの場合のコードサンプルを掲載します。(証明書はCloudFrontで利用するため、us-east-1 リージョンにデプロイします)
const hostedZone = route53.PublicHostedZone.fromHostedZoneAttributes(this, "MyHostedZone", { hostedZoneId: "<ホストゾーンID>", zoneName: "my-domain.com", }); const cloudfrontCertificate = new certificatemanager.DnsValidatedCertificate(this, "CloudFrontCertificate", { domainName: "static.my-domain.com", hostedZone: hostedZone, validation: certificatemanager.CertificateValidation.fromDns(hostedZone), });
Webアプリで外部からコードスニペットを取得する
アプリケーションモニターのコードスニペットを直接コードに含めるのではなく、以下の形で src
として読み込んでもらうことを考えます。
<script src="https://static.my-domain.com/rum.js"></script>
<script>
タグの src
で外部からファイルを取得する場合、GETによる単純リクエストになるため、CORSのプリフライトリクエストは発生しません。すなわちOPTIONSリクエストは送信されず、443ポートでも8443ポートでもGETリクエストでリソースは取得され、実行されます。
そこでcrossorigin属性を利用します。これを指定することによってリクエストモードが cors
となり、クロスオリジン環境下ではサイトのOrigin
がスクリプトを取得する際の Access-Control-Allow-Origin
レスポンスヘッダーに含まれない限り、ロードしたスクリプトがブラウザで実行されなくなります。(crossorigin属性を付けない場合、<script>
タグのリクエストモードは no-cors
となり、サイトの Origin
に関わらずロードしたスクリプトがブラウザで実行されます)
(参考) https://nhiroki.jp/2021/01/07/crossorigin-attribute
以下のように、Webアプリの <head>
タグ内でアプリケーションモニターのコードスニペットを読み込むようにし、crossorigin属性を設定します(rum.js
ファイルはのちにS3バケットを作成したときに追加します)。
<head> <script src="https://static.my-domain.com/rum.js" crossorigin="anonymous"></script> </head>
またcrossorigin属性を付けることにより、スクリプトを取得する際のGETリクエストに Origin
ヘッダーが付与されるようになります。これは次に述べるS3バケットからのレスポンスヘッダーにも影響します。
図にまとめると、crossorigin属性を使用しない場合、本番環境でもテスト環境でもスクリプトが実行されてしまいます。
crossorigin属性を使用する場合は次のようになり、本番環境のみスクリプトが実行されます。
コードスニペット格納用のS3バケットを作成
S3バケットを作成し、本番環境のみを許可するCORSを設定します。
const myBucket = new s3.Bucket(this, "MyBucket", { encryption: s3.BucketEncryption.S3_MANAGED, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED, enforceSSL: true, cors: [ { allowedHeaders: ["*"], allowedMethods: [s3.HttpMethods.GET], allowedOrigins: ["https://my-domain.com"], }, ], });
この設定をした場合の動きを確認してみました。リクエストヘッダーに Origin: https://my-domain.com
が含まれている場合、以下のレスポンスヘッダーが付与されました。
Access-Control-Allow-Credentials: true Access-Control-Allow-Methods: GET Access-Control-Allow-Origin: https://my-domain.com
リクエストヘッダーが Origin: https://my-domain.com:8443
となっている場合、以上の3つの Access-Control-*
レスポンスヘッダーは付与されていませんでした。
S3バケットのアクセス制御
CORSの設定とは関係ありませんが、CloudFrontのOAC(オリジンアクセスコントロール)を利用し、S3バケットへのアクセスを次のステップで作成するCloudFrontディストリビューションからのみに制限します。
執筆時点でCDKのL2コンストラクトではまだOACがサポートされていないため、以下はOACではなくOAIを利用する場合のコードサンプルです。L2コンストラクトでOACがサポートされたらそれを利用するのが良いでしょう。
const oai = new cloudfront.OriginAccessIdentity(this, "MyOAI"); myBucket.addToResourcePolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["s3:GetObject"], principals: [new iam.CanonicalUserPrincipal(oai.cloudFrontOriginAccessIdentityS3CanonicalUserId)], resources: [`${myBucket.bucketArn}/*`], }) );
S3バケットにコードスニペットをファイルで追加する
アプリケーションモニターのコードスニペットのうち、<script>
タグを除外した部分をコピーしたJavaScriptファイルを作成します。今回は rum.js
というファイル名としてS3バケットにアップロードします。
CloudFrontディストリビューションの作成
CloudFrontディストリビューションを作成します。ここで重要なのは3つのポリシーです。
まずはキャッシュポリシーとして、Origin
ヘッダーをキャッシュキーに含めるようにします。すなわちリクエストの Origin
ヘッダーが異なる値の場合は、異なるコンテンツを要求しているとみなし、本番環境とテスト環境での振る舞いを切り替えます。
const cachePolicy = new cloudfront.CachePolicy(this, "MyCachePolicy", { defaultTtl: cdk.Duration.days(1), maxTtl: cdk.Duration.days(1), minTtl: cdk.Duration.days(1), headerBehavior: cloudfront.CacheHeaderBehavior.allowList("Origin"), });
次にオリジンリクエストポリシーとして、CloudFrontからオリジンへのリクエストに Origin
ヘッダーを含めて転送するようにします。これにより、S3バケットで設定したCORSが機能するようになります。
const originRequestPolicy = new cloudfront.OriginRequestPolicy(this, "MyOriginRequestPolicy", { headerBehavior: cloudfront.CacheHeaderBehavior.allowList("Origin"), });
最後にレスポンスヘッダーポリシーとして、CloudFrontからレスポンスを返すときにCORSヘッダーを含めるようにします。
const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, "MyResponseHeadersPolicy", { corsBehavior: { accessControlAllowCredentials: false, accessControlAllowHeaders: ["*"], accessControlAllowMethods: ["GET"], accessControlAllowOrigins: ["https://my-domain.com"], originOverride: false, }, });
以上のポリシーを利用してCloudFrontディストリビューションを作成します。今回の構成ではOPTIONSメソッドは送信されないため、許可するメソッドにOPTIONSは含めていません。
const distribution = new cloudfront.Distribution(this, "MyDistribution", { defaultBehavior: { allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD, cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD, cachePolicy, originRequestPolicy, responseHeadersPolicy, viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, origin: new cloudfrontOrigins.S3Origin(myBucket), }, priceClass: cloudfront.PriceClass.PRICE_CLASS_200, geoRestriction: cloudfront.GeoRestriction.allowlist("JP"), sslSupportMethod: cloudfront.SSLMethod.SNI, minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021, certificate: cloudfrontCertificate, domainNames: ["static.my-domain.com"], });
ドメインのルーティング
作成したCloudFrontディストリビューションに、static.my-domain.com
のルーティングを向けて完了です。
new route53.ARecord(this, "StaticARecord", { zone: hostedZone, recordName: "static", target: route53.RecordTarget.fromAlias(new route53Targets.CloudFrontTarget(distribution)), });
動きの確認
本番環境の https://my-domain.com
にアクセスすると、 https://static.my-domain.com/rum.js
の取得に成功していることを確認できました。実際にはさらに https://client.rum.us-east-1.amazonaws.com/1.5.x/cwr.js
よりスクリプトの本体をロードしており、 https://dataplane.rum.ap-notheast-1.amazonaws.com/appmonitors/
にブラウザのクライアントデータを送信していました。マネジメントコンソールのCloudWatch RUMの画面にアクセスすると、データが取得されていることがわかります。
一方、テスト環境の https://my-domain.com:8443
にアクセスすると、https://static.my-domain.com/rum.js
からのレスポンスステータスは200で返りますが、クロスオリジンの読み込みが許可されていないためブラウザはスクリプトにアクセスできず、実行されません。
これでECSのブルーグリーンデプロイメントの本番環境のみ、CloudWatch RUMにデータ送信を実現できました。
私たちは同じチームで働いてくれる仲間を大募集しています!たくさんのご応募をお待ちしています。
- セキュリティエンジニア(セキュリティ設計)
執筆:@kou.kinyo、レビュー:@yamada.y (Shodoで執筆されました)