こんにちは。X(クロス) イノベーション 本部 ソフトウェアデザインセンター セキュリティグループの耿です。 Amazon CloudWatch RUM は Webブラウザ で発生したアプリケーションのエラーやパフォーマンス情報を収集し、モニタリングするための機能です。 ECSのブルーグリーンデプロイメントを利用してWebアプリをデプロイしているのですが、CloudWatch RUMを利用するにあたって本番昇格前のテスト環境からのデータが、分析の際のノイズにならないよう、本番環境からのデータと混ざらないようにしたいと思いました。そこで今回はテスト環境からはCloudWatch RUMにデータを送信せず、本番環境に昇格した時のみデータを送信する方法について書きます。 インフラ構成 CloudWatch RUMの利用開始方法 テスト環境のデータが混ざってしまう 解決方法 CloudFront用TLS証明書の用意 Webアプリで外部からコードスニペットを取得する コードスニペット格納用のS3バケットを作成 S3バケットのアクセス制御 S3バケットにコードスニペットをファイルで追加する CloudFrontディストリビューションの作成 ドメインのルーティング 動きの確認 インフラ構成 以下の構成でアプリケーションが稼働しています。 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 で執筆されました )