【AWS CDK】CloudFront経由でS3のファイルを取得するインフラ構築を--no-executeのデプロイで

# やりたいこと

S3バケット`test-cloud-front-{accout}-ap-northeast-1`の配下に配置された`test.json`ファイルに対し、`https://config.example.com/test.json` でアクセスされたら、CloudFront経由でファイルを取得できるようにする

# 必要なリソース

## AWS Certificate Manager

ドメイン名: `config.example.com` の証明書
us-east-1で発行
CNAMEレコードによるバリデーションを行う
※CloudFrontの代替ドメインで必要になる証明書はus-east-1で発行されたものでなければならない

## Route53
※example.comゾーンは作成済とする
AliasのAレコード（Record name: `config.example.com`、Value: 今回作成するCloudFrontのDomain name）

## S3
Bucket Name: `test-cloud-front-{accout}-ap-northeast-1`

Bucket policy: CloudFrontからのアクセスを許可（マネジメントコンソールからボタン１つで作成されるポリシー）

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::{accout}:role/app-stack-CustomS3AutoDeleteObjectsCustomResource~"
            },
            "Action": [
                "s3:DeleteObject*",
                "s3:GetBucket*",
                "s3:List*"
            ],
            "Resource": [
                "arn:aws:s3:::test-cloud-front-{accout}-ap-northeast-1",
                "arn:aws:s3:::test-cloud-front-{accout}-ap-northeast-1/*"
            ]
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E278HEESTXZKCR"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::test-cloud-front-{accout}-ap-northeast-1/*"
        },
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "cloudfront.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::test-cloud-front-{accout}-ap-northeast-1/*",
            "Condition": {
                "StringEquals": {
                    "AWS:SourceArn": "arn:aws:cloudfront::{accout}:distribution/{distributionId}"
                }
            }
        }
    ]
}
```

Cross-origin resource sharing (CORS):

```json
[
    {
        "AllowedHeaders": [],
        "AllowedMethods": [
            "GET"
        ],
        "AllowedOrigins": [
            "*"
        ],
        "ExposeHeaders": []
    }
]
```

※test.jsonファイルは手動でアップロード

## CloudFront
Alternate domain names: `config.example.com`
Custom SSL certificate: `config.example.com `
Origin domain: `test-cloud-front-{account}-ap-northeast-1.s3.ap-northeast-1.amazonaws.com`

# AWS CDKで構築

ACMだけ別のリージョンに作成しないといけないせいで、CDKで構築する場合、結構手間がかかります。

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

us-east-1とap-northeast-1両方にbootstrap

https://tmokmss.hatenablog.com/entry/20230130/1675040258

> CDKでは2.50.0から簡単にクロスリージョンのデプロイができるようになりました (PR)。以下のコードは、東京リージョンからヴァージニアリージョンのリソースを参照する例です。クロスリージョンのリソースの受け渡し (スタック間参照) が実現できています。

通常のデプロイして即リソース反映でよければ、`crossRegionReferences: true`による参照で作成可能です。

## コード

```package.json
{
  ...,
  "scripts": {
    "deploy": "cdk deploy --all --require-approval never"
  },
  "devDependencies": {
    "@types/node": "20.1.0",
    "aws-cdk": "2.79.1",
    "ts-node": "^10.9.1",
    "typescript": "~5.0.4"
  },
  "dependencies": {
    "aws-cdk-lib": "2.79.1",
    "constructs": "^10.0.0"
  }
}
```

```lib/virginia-acm-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Stack, StackProps } from 'aws-cdk-lib';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as route53 from 'aws-cdk-lib/aws-route53';

import { Construct } from 'constructs';

export class VirginiaAcmStack extends Stack {
  public readonly certificate: acm.Certificate;

  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);

    // 既にRoute53にHostedZone作っている場合、fromLookup
    const hostedZone = route53.HostedZone.fromLookup(this, 'TestHostedZone', {
      domainName: `{YOUR_DOMAIN_NAME}`, // 今回はexample.com
    });

    this.certificate = new acm.Certificate(this, 'TestCertificate', {
      domainName: `{YOUR_DOMAIN_NAME}`, // 今回はconfig.example.com
      validation: acm.CertificateValidation.fromDns(hostedZone),
    });
  }
}
```

```lib/tokyo-stack.ts
import { RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam'
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as route53 from 'aws-cdk-lib/aws-route53'
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';

import { Construct } from 'constructs';

interface TokyoStackProps extends StackProps {
  uSCertificate: acm.Certificate;
}

export class TokyoStack extends Stack {
  constructor(scope: Construct, id: string, props: TokyoStackProps) {
    super(scope, id, props);

    const myBucket = new s3.Bucket(this, 'TestBucket', {
      bucketName: `test-cloud-front-${props?.env?.account}-ap-northeast-1`
    });

    myBucket.addCorsRule({
      allowedMethods: [s3.HttpMethods.GET],
      allowedOrigins: ['*'],
    });

    const myCloudfront = new cloudfront.Distribution(this, 'TesCloudfront', {
      defaultBehavior: { origin: new origins.S3Origin(myBucket) },
      domainNames: [`{YOUR_DOMAIN_NAME}`], // 今回はconfig.example.com
      certificate: props.uSCertificate,
    });

    myBucket.addToResourcePolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
      actions: ['s3:GetObject'],
      resources: [`${myBucket.bucketArn}/*`],
      conditions: {
        StringEquals:{
          "AWS:SourceArn": `arn:aws:cloudfront::${props?.env?.account}:distribution/${myCloudfront.distributionId}`,
        }
      }
    }));

    const myHostedZone = route53.HostedZone.fromLookup(this, 'TestHostedZone', { domainName: `{YOUR_DOMAIN_NAME}` }); // 今回はexample.com

    new route53.ARecord(this, 'TestARecord', {
      recordName: `{YOUR_RECORD_NAME}`, // 今回はconfig
      zone: myHostedZone,
      target: route53.RecordTarget.fromAlias(new route53Targets.CloudFrontTarget(myCloudfront)),
    });
  }
}
```

```bin/aws-cdk.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { TokyoStack } from '../lib/tokyo-stack';
import { VirginiaAcmStack } from '../lib/virginia-acm-stack';

const app = new cdk.App();
const account = `{YOUR_ACCOUNT}`;
const envUS = {
  region: 'us-east-1',
  account,
};
const envJP = {
  region: 'ap-northeast-1',
  account,
};

const stackUS = new VirginiaAcmStack(app, 'virginia-acm-stack', { 
  env: envUS,
  crossRegionReferences: true,
});

new TokyoStack(app, 'tokyo-stack', { 
  env: envJP,
  crossRegionReferences: true,
  certificate: stackUS.certificate
});
```

# --no-executeでのデプロイ
デプロイしてからマネジメントコンソールの画面上でCFnでChange setsを確認し、適用ボタンを押して初めてリソースに反映する方法です。

> 従来CDKでクロスリージョン参照といえば、cdk-remote-stack を使う方法が主流でした (更にその前は同じような機能を各自で実装していました)。ググって出てくる記事もこの方法が多いと思います。私もよくお世話になったライブラリです。
> 2023年では1の方法が利用できるため、この方法をあえて使う理由は無くなったと考えて良いでしょう。ただしこちらは弱い参照 (後述)なので、人によってはもうしばらく出番があるかもしれません。

--no-executeでのデプロイの場合、us-east-1のACMのリソース参照は弱い参照になるので、2023/5/28現時点では`cdk-remote-stack`を使うしかなさそうです。（⭐︎の数があまり多くないので、できれば使いたくないですが。。）

## コード

```package.json
{
  ...,
  "scripts": {
    "deploy": "cdk deploy --all --require-approval never --no-execute"
  },
  "dependencies": {
    "cdk-remote-stack": "^2.0.11"
  }
}
```

```lib/virginia-acm-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Stack, StackProps } from 'aws-cdk-lib';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as route53 from 'aws-cdk-lib/aws-route53';

import { Construct } from 'constructs';

export class VirginiaAcmStack extends Stack {
  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);

    const hostedZone = route53.HostedZone.fromLookup(this, 'TestHostedZone', {
      domainName: `{YOUR_DOMAIN_NAME}`, // 今回はexample.com
    });

    const certificate = new acm.Certificate(this, 'TestCertificate', {
      domainName: `{YOUR_DOMAIN_NAME}`, // 今回はconfig.example.com
      validation: acm.CertificateValidation.fromDns(hostedZone),
    });

    new cdk.CfnOutput(this, 'TestCertificateArn', {
      value: certificate.certificateArn,
      exportName: `${this.stackName}:CertificateArn`,
    });
  }
}
```

```lib/tokyo-stack.ts
import { RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam'
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as route53 from 'aws-cdk-lib/aws-route53'
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';

import { Construct } from 'constructs';
import { VirginiaAcmStack } from './virginia-acm-stack';
import { RemoteOutputs } from 'cdk-remote-stack';

interface TokyoStackProps extends StackProps {
  stackUS: VirginiaAcmStack;
}

export class TokyoStack extends Stack {
  constructor(scope: Construct, id: string, props: TokyoStackProps) {
    super(scope, id, props);

    const outputs = new RemoteOutputs(this, 'Outputs', { stack: props.stackUS })

    const importedCertificateArn = outputs.get('TestCertificateArn')

    const myBucket = new s3.Bucket(this, 'TestBucket', {
      bucketName: `test-cloud-front-${props?.env?.account}-ap-northeast-1`
    });

    myBucket.addCorsRule({
      allowedMethods: [s3.HttpMethods.GET],
      allowedOrigins: ['*'],
    });

    const myCloudfront = new cloudfront.Distribution(this, 'TesCloudfront', {
      defaultBehavior: { origin: new origins.S3Origin(myBucket) },
      domainNames: [`{YOUR_DOMAIN_NAME}`], // 今回はconfig.example.com
      certificate: acm.Certificate.fromCertificateArn(this, 'ImportedCertificate', importedCertificateArn),
    });

    myBucket.addToResourcePolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
      actions: ['s3:GetObject'],
      resources: [`${myBucket.bucketArn}/*`],
      conditions: {
        StringEquals:{
          "AWS:SourceArn": `arn:aws:cloudfront::${props?.env?.account}:distribution/${myCloudfront.distributionId}`,
        }
      }
    }));

    const myHostedZone = route53.HostedZone.fromLookup(this, 'TestHostedZone', { domainName: `{YOUR_DOMAIN_NAME}` }); // 今回はexample.com

    new route53.ARecord(this, 'TestARecord', {
      recordName: `{YOUR_RECORD_NAME}`, // 今回はconfig
      zone: myHostedZone,
      target: route53.RecordTarget.fromAlias(new route53Targets.CloudFrontTarget(myCloudfront)),
    });
  }
}
```

```bin/aws-cdk.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { TokyoStack } from '../lib/tokyo-stack';
import { VirginiaAcmStack } from '../lib/virginia-acm-stack';

const app = new cdk.App();
const account = `{YOUR_ACCOUNT}`;
const envUS = {
  region: 'us-east-1',
  account,
};
const envJP = {
  region: 'ap-northeast-1',
  account,
};

const stackUS = new VirginiaAcmStack(app, 'virginia-acm-stack', { 
  env: envUS
});

const stackJP = new TokyoStack(app, 'tokyo-stack', { 
  env: envJP,
  stackUS
});

stackJP.addDependency(stackUS)
```

デプロイしたらCFnからchange setsで、変更を確認してから反映ボタンを押せばリソースが作成されます。

# その他参考

https://github.com/pahud/cdk-remote-stack

https://dev.classmethod.jp/articles/cdk-remote-stack-for-acm-and-cloudfront/

https://dev.classmethod.jp/articles/try-cdk-remote-stack/