電通総研 テックブログ

電通総研が運営する技術ブログ

ECSのブルーグリーンデプロイメントで特定環境のみ CloudWatch RUM にデータを送信する

こんにちは。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属性がない場合の動き

crossorigin属性を使用する場合は次のようになり、本番環境のみスクリプトが実行されます。

crossorigin属性がある場合の動き

コードスニペット格納用のS3バケットを作成

S3バケットを作成し、本番環境のみを許可するCORSを設定します。

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の画面にアクセスすると、データが取得されていることがわかります。

データが取得されたRUMの画面

一方、テスト環境の https://my-domain.com:8443 にアクセスすると、https://static.my-domain.com/rum.js からのレスポンスステータスは200で返りますが、クロスオリジンの読み込みが許可されていないためブラウザはスクリプトにアクセスできず、実行されません。

ブラウザコンソールのCORSエラー

これでECSのブルーグリーンデプロイメントの本番環境のみ、CloudWatch RUMにデータ送信を実現できました。


私たちは同じチームで働いてくれる仲間を大募集しています!たくさんのご応募をお待ちしています。
- セキュリティエンジニア(セキュリティ設計)

執筆:@kou.kinyo、レビュー:@yamada.yShodoで執筆されました