1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CDKでCloudFrontマルチテナント・ディストリビューションを構築する

Last updated at Posted at 2025-08-28

この記事でやること

CloudFrontのマルチテナント・ディストリビューション(SaaS Manager for Amazon CloudFront)をCDK / TypeScriptで構築する手順をまとめます。

  • 各テナントごとにACM証明書を個別発行してテナントへアタッチ
  • S3(OAC)をオリジンにして、直接アクセスを禁止。CloudFront経由のみ許可
  • L2未対応の箇所はL1コンストラクトへ降りて設定(ConnectionMode=tenant-onlyConnectionGroupDistributionTenant

バージョン

  • aws-cdk-lib: 2.202.0
  • constructs: 10.4.2
  • aws-cdk (CLI): 2.1020.2
  • TypeScript: 5.6系
  • Node.js: 22系

リージョン

us-east-1(バージニア北部)
※ CloudFrontのビューワ証明書はus-east-1のACMに作成する必要があります。今回は管理をシンプルにするため、他のリソースも同一スタック・同一リージョン(us-east-1)にまとめて作成します。

事前準備

  • 2つのドメインと、それぞれのPublic Hosted Zoneを用意
    • 記事中では <ドメイン1> / <ドメイン2> と表記
  • 各Hosted ZoneのIDを控えておく(この記事ではコード内に <ホストゾーンID1> / <ホストゾーンID2> と記述)

プロジェクト作成

以下のコマンドで、CDKプロジェクトを作成します。

mkdir multi-tenant-distribution
cd multi-tenant-distribution
cdk init app --language typescript

フルコード(bin)

注意: 下記の<ドメイン1>/<ドメイン2><ホストゾーンID1>/<ホストゾーンID2>は、ご自身の環境の実際の値に置き換えてください。

multi-tenant-distribution/bin/multi-tenant-distribution.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { MultiTenantDistributionStack } from '../lib/multi-tenant-distribution-stack';

const app = new cdk.App();
new MultiTenantDistributionStack(app, 'MultiTenantDistributionStack', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'us-east-1' },
  tenants: [
    { domainName: '<ドメイン1>', hostedZoneId: '<ホストゾーンID1>' },
    { domainName: '<ドメイン2>', hostedZoneId: '<ホストゾーンID2>' },
  ],
});

なぜL1が必要か(L2エスケープハッチの利用)

フルコード(lib)

multi-tenant-distribution/lib/multi-tenant-distribution-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
  aws_cloudfront as cloudfront,
  aws_route53 as route53,
  aws_s3 as s3,
  aws_certificatemanager as acm,
  Fn,
} from 'aws-cdk-lib';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';

export interface MultiTenantDistributionStackProps extends cdk.StackProps {
  readonly tenants: Array<{
    domainName: string; // 例: "<ドメイン1>"
    hostedZoneId: string; // 事前に作成済みのパブリックHosted ZoneのID
  }>;
}

export class MultiTenantDistributionStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: MultiTenantDistributionStackProps) {
    super(scope, id, props);

    if (!props || !props.tenants || props.tenants.length === 0) {
      throw new Error('tenants is required');
    }

    // オリジン用S3バケット(パブリックブロック + OAC運用)
    const originBucket = new s3.Bucket(this, 'OriginBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // Hosted Zone(事前作成)をインポート
    const hostedZones: Record<string, route53.IHostedZone> = {};
    for (const tenant of props.tenants) {
      hostedZones[tenant.domainName] = route53.HostedZone.fromHostedZoneAttributes(
        this,
        `HostedZone-${tenant.domainName.replace(/\./g, '-')}`,
        { hostedZoneId: tenant.hostedZoneId, zoneName: tenant.domainName },
      );
    }

    // 各テナント用にACM(us-east-1)を個別発行
    const tenantCertificates: Record<string, acm.Certificate> = {};
    for (const tenant of props.tenants) {
      const domain = tenant.domainName;
      tenantCertificates[domain] = new acm.Certificate(this,
        `CloudFrontCertificate-${domain.replace(/\./g, '-')}`, {
          certificateName: `${this.stackName}-cloudfront-certificate-${domain}`,
          domainName: domain,
          validation: acm.CertificateValidation.fromDns(hostedZones[domain]),
        },
      );
    }

    // CloudFront Distribution(まずはL2で作成)
    // 以降、エスケープハッチ(node.defaultChild)でL1に降りてマルチテナント固有設定を適用する
    const distribution = new cloudfront.Distribution(this, 'Distribution', {
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(originBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      defaultRootObject: 'index.html',
      httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
    });

    // L2 → L1 のエスケープハッチ
    // - node.defaultChild から CfnDistribution を取得し、生の CloudFormation プロパティを上書きする
    const cfnDistribution = distribution.node.defaultChild as cloudfront.CfnDistribution;
    // マルチテナント構成では IPv6Enabled はサポート外のため削除
    cfnDistribution.addPropertyDeletionOverride('DistributionConfig.IPV6Enabled');
    // L1で ConnectionMode を tenant-only に変更
    cfnDistribution.addPropertyOverride('DistributionConfig.ConnectionMode', 'tenant-only');

    // Connection Group(テナントの受け皿)
    const connectionGroup = new cloudfront.CfnConnectionGroup(this, 'ConnectionGroup', {
      name: `${this.stackName}-ConnectionGroup`,
      enabled: true,
      ipv6Enabled: false,
    });

    // 各テナントのDistributionTenantを作成
    for (const tenant of props.tenants) {
      const domain = tenant.domainName;
      const tenantId = tenant.domainName.replace(/\./g, '-');
      
      new cloudfront.CfnDistributionTenant(this, `DistributionTenant-${tenantId}`, {
        distributionId: distribution.distributionId,
        connectionGroupId: connectionGroup.attrId,
        name: `${this.stackName}-tenant-${tenantId}`,
        domains: [domain],
        enabled: true,
        customizations: {
          certificate: { arn: tenantCertificates[domain].certificateArn },
        },
      });

      // Route53: ConnectionGroupのRoutingEndpointへALIAS
      new route53.ARecord(this, `AliasRecord-${tenantId}`, {
        zone: hostedZones[tenant.domainName],
        recordName: tenant.domainName,
        target: route53.RecordTarget.fromAlias({
          bind: () => ({
            dnsName: Fn.getAtt(connectionGroup.logicalId, 'RoutingEndpoint').toString(),
            hostedZoneId: 'Z2FDTNDATAQYW2', // CloudFront固定のHostedZoneId
          }),
        }),
      });
    }

    // デモ用に index.html をS3へ自動アップロード
    new s3deploy.BucketDeployment(this, 'DeployIndexHtml', {
      destinationBucket: originBucket,
      sources: [
        s3deploy.Source.data(
          'index.html',
          `<!doctype html><html><head><meta charset="utf-8"><title>Hello</title></head><body><h1>Hello World!</h1></body></html>`
        ),
      ],
    });
  }
}

デプロイと動作確認

  1. デプロイ
    npx cdk deploy
    
  2. 検証
  • Route53に <ドメイン1> / <ドメイン2> のAレコードが作成されている
  • ACM(us-east-1)に各ドメインの証明書が発行されている
  • S3バケットにindex.htmlが配置されている
  • ブラウザで https://<ドメイン1>/https://<ドメイン2>/ を開き、"Hello World!" が表示される

おまけ: 全テナントのキャッシュをまとめて削除する

CloudFrontのマルチテナント配信では「全テナント一括のキャッシュ削除API」は提供されていません。各テナントIDごとにキャッシュ削除(Invalidation)を発行します。

以下は、親ディストリビューションに関連付いた全テナントのキャッシュを "/*" で削除するスクリプト例です。

# 親ディストリビューションIDを設定
PARENT_DISTRIBUTION_ID="<ディストリビューションID>"

# 親ディストリビューションに紐づく全テナントIDを取得
TENANT_IDS=$(aws cloudfront list-distribution-tenants \
  --association-filter DistributionId="$PARENT_DISTRIBUTION_ID" \
  --query "DistributionTenantList[*].Id" \
  --output text)

# 各テナントに対してキャッシュ削除(Invalidation)を作成(全パス対象)
for TENANT_ID in $TENANT_IDS; do
  aws cloudfront create-invalidation-for-distribution-tenant \
    --id "$TENANT_ID" \
    --invalidation-batch '{"Paths":{"Quantity":1,"Items":["/*"]},"CallerReference":"invalidation-'"$(date +%s)"'"}'
  echo "テナント $TENANT_ID: キャッシュ削除リクエストを作成しました"
done

補足:

  • パスは必要に応じて /index.html などに絞ってください
  • CallerReference は毎回ユニークである必要があります(上の例はUNIXタイムスタンプ)

終わりに

この記事では、CloudFrontのマルチテナント・ディストリビューションをCDK/TypeScriptで構築するうえでの肝となるポイントを押さえつつ、実際にHello World!で動作確認できるところまでをまとめました。要点は次のとおりです。

  • ConnectionMode=tenant-only のL1設定
  • ConnectionGroup / DistributionTenant の作成
  • S3 OACの利用(S3直アクセス禁止、CloudFront経由のみ許可)
  • テナント単位のACM証明書付与
  • Route53のALIAS設定

この記事が参考になりますと幸いです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?