0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【S3 + CloudFront + GitHub Actions】構成をAWS CDKでコード化する

0
Posted at

はじめに

今回は前回の続きになります。
Nuxtフレームワークで実装したLIFFアプリ(フロントエンド)を、AWSの「S3 + CloudFront」にデプロイし、GitHub ActionsによるCI/CDパイプラインを実現するまでの手順の大部分を、AWS CDkを使ってコード化してみました。

AWS CDKの基礎に関しては、こちらの記事をぜひご参照ください。

完成したCDKフォルダ構成

プロジェクトルートにインフラのデプロイ用フォルダcdk/を作成します。

cdk/
├── bin/
│   └── app.ts ①                       # エントリ。env(環境)コンテキストでstackをインスタンス化
├── config/
│   ├── types.ts ②                     # EnvConfigの型を定義
│   ├── env-config.ts ③                # context env=... で設定をロードする
│   ├── dev.ts ④                       # dev環境設定
│   ├── stg.ts ⑤                       # stg環境設定
│   └── prod.ts ⑥                      # prod環境設定(RETAINポリシー)
├── lib/
│   ├── constructs/
│   │   └── static-site.ts ⑦           # S3 + CloudFront(OAC) 構成の再利用可能なConstruct
│   └── stacks/
│       ├── base-stack.ts ⑧            # アカウントに1つしか作れない(GitHub OIDC Provider)
│       ├── frontend-stack.ts ⑨        # 環境別 S3 + CloudFront
│       └── github-oidc-stack.ts ⑩     # 環境別 GitHub Actions Deploy Role
├── test/
│   └── frontend-stack.test.ts ⑪       # Template アサーション
├── cdk.json ⑫
├── jest.config.js ⑬
├── package.json ⑭
├── tsconfig.json ⑮
├── (package-lock.json)
├── (.gitignore)
└── (.npmignore)

各ファイル内容

※プログラミング言語はTypeScriptを使用しています
※前回の記事で作成した内容を、AIを使用してコード化しています(微修正/動作確認済み)
※ベストプラクティスというわけではないため、あくまで参考にしていただけると幸いです
※各ファイルの詳細な説明は省略いたしますが、気になった方はAIに投げてみてください
※機密情報を扱う際は、別途.envSecretManagerなどと連携する必要があります

① cdk/bin/app.ts

cdkプロジェクトのエントリーになります(後述するcdk.jsonで指定可能)
このファイルの内容が実行されスタックが作成されます。

cdk/bin/app.ts
#!/usr/bin/env node
import 'source-map-support/register';
import { App, Tags } from 'aws-cdk-lib';
import { loadEnvConfig } from '../config/env-config';
import type { EnvName } from '../config/types';
import { BaseStack } from '../lib/stacks/base-stack';
import { FrontendStack } from '../lib/stacks/frontend-stack';
import { GithubOidcStack } from '../lib/stacks/github-oidc-stack';

const app = new App();

// `cdk bootstrap` のような env 非依存コマンドは cdk.json の `app` 経由で
// ここを実行するが、env context を必要としない。env 未指定の場合は
// per-env stack を一切構築せず空 synth で抜ける(bootstrap が壊れないように)。
// deploy/diff/synth など env が必要なコマンドは `--context env=...` を付ける運用。
const envName = app.node.tryGetContext('env') as EnvName | undefined;
if (envName) {
  const config = loadEnvConfig(app);

  // --- アカウントガード -------------------------------------------------------
  // マルチアカウント前提: 各環境 (dev/stg/prod) は専用 AWS アカウントに居る。
  // 呼び出し側の認証情報が env config で宣言したアカウントと違うときは synth を
  // 拒否する。誤アカウントへのデプロイは最もよくある事故なので構造的に弾く。
  if (config.account === 'REPLACE_ME') {
    throw new Error(
      `config/${config.envName}.ts has 'account: REPLACE_ME'. ` +
        `Set the real AWS account ID for the ${config.envName} environment before deploying.`,
    );
  }

  const callerAccount = process.env.CDK_DEFAULT_ACCOUNT;
  if (callerAccount && callerAccount !== config.account) {
    throw new Error(
      `Account mismatch: current AWS credentials resolve to ${callerAccount}, ` +
        `but config/${config.envName}.ts targets ${config.account}. ` +
        `Switch the --profile to the ${config.envName} account.`,
    );
  }

  const env = {
    account: config.account,
    region: config.region,
  };

  // アカウントベースのリソース(per-env stack より先に各アカウントで 1 回だけ作成)。
  const base = new BaseStack(app, `${config.prefix}-${config.envName}-base`, {
    env,
    prefix: config.prefix,
    envName: config.envName,
    description: `${config.prefix} ${config.envName} account base (GitHub OIDC provider)`,
  });

  // 環境別の静的サイト(S3 + CloudFront)。
  const frontend = new FrontendStack(app, `${config.prefix}-${config.envName}-frontend`, {
    env,
    config,
    description: `${config.prefix} ${config.envName} static site (S3 + CloudFront)`,
  });

  // 環境別の GitHub Actions デプロイ Role(base + frontend に依存)。
  new GithubOidcStack(app, `${config.prefix}-${config.envName}-github-oidc`, {
    env,
    config,
    oidcProviderArn: base.githubOidcProviderArn,
    bucketArn: frontend.bucketArn,
    distributionArn: frontend.distributionArn,
    description: `${config.prefix} ${config.envName} GitHub Actions deploy role`,
  });

  for (const [k, v] of Object.entries(config.tags)) {
    Tags.of(app).add(k, v);
  }
}

② cdk/config/types.ts

環境設定(config)のスキーマ(型)を定義します。

cdk/config/types.ts
export type EnvName = 'dev' | 'stg' | 'prod';

export interface GitHubConfig {
  /** リポジトリを保有する GitHub Organization または User */
  owner: string;
  /** リポジトリ名 */
  repo: string;
  /**
   * デプロイ Role の AssumeRole を許可する GitHub OIDC subject claim 一覧。
   * 例:
   *   - 'environment:dev'        : GitHub Environment 'dev' 発行のトークン
   *   - 'ref:refs/heads/main'    : main ブランチへの push で発行されたトークン
   *   - 'pull_request'           : pull_request ワークフロー発行のトークン
   * 複数指定した場合は OR 結合される。
   */
  subjects: string[];
}

export interface EnvConfig {
  envName: EnvName;
  /**
   * この環境の AWS アカウント ID。必須。
   *
   * 本プロジェクトは「dev/stg/prod を別アカウントに分離する」マルチアカウント構成を
   * 前提としており、誤って別環境のリソースに触れないようアカウント単位で隔離する。
   * `bin/app.ts` は synth 時にこの値と `CDK_DEFAULT_ACCOUNT` を突き合わせ、
   * 不一致なら synth 自体を拒否する。
   *
   * 実アカウントが未割当の段階では文字列 'REPLACE_ME' をプレースホルダとして
   * 入れておく。その場合 synth 時にガードがエラーで停止する。
   */
  account: string;
  region: string;
  /** リソース名プレフィックス(例: '<project-name>-liff') */
  prefix: string;
  github: GitHubConfig;
  /** App 配下のすべてのリソースに付与するタグ */
  tags: Record<string, string>;
  /** S3 など永続リソースに適用する削除ポリシー */
  removalPolicy: 'destroy' | 'retain';
  /** stack 削除時にバケット中身を自動削除するか(dev/stg のみ true 推奨) */
  autoDeleteObjects: boolean;
}

③ cdk/config/env-config.ts

CDK CLIのコンテキストから今回デプロイ対象の環境設定を取り出すローダーです。

cdk/config/env-config.ts
import type { App } from 'aws-cdk-lib';
import type { EnvConfig, EnvName } from './types';
import { devConfig } from './dev';
import { stgConfig } from './stg';
import { prodConfig } from './prod';

const ENV_CONFIGS: Record<EnvName, EnvConfig> = {
  dev: devConfig,
  stg: stgConfig,
  prod: prodConfig,
};

export function loadEnvConfig(app: App): EnvConfig {
  const envName = app.node.tryGetContext('env') as EnvName | undefined;
  if (!envName) {
    throw new Error(
      'Missing required CDK context "env". Specify with: --context env=dev|stg|prod',
    );
  }
  const config = ENV_CONFIGS[envName];
  if (!config) {
    throw new Error(
      `Unknown env "${envName}". Valid values: ${Object.keys(ENV_CONFIGS).join(', ')}`,
    );
  }
  return config;
}

④ cdk/config/dev.ts

dev環境専用の設定値(リテラル)を定義します。
※機密情報がある場合は.envSecretManagerなどと連携する必要があります

cdk/config/dev.ts
import type { EnvConfig } from './types';

export const devConfig: EnvConfig = {
  envName: 'dev',
  account: 'REPLACE_ME', // TODO: dev AWS account ID
  region: 'ap-northeast-1',
  prefix: '<project-name>-liff',
  github: {
    owner: 'organization-name',
    repo: 'repo-name',
    subjects: ['environment:dev'],
  },
  tags: {
    Project: '<project-name>',
    Environment: 'dev',
    ManagedBy: 'cdk',
  },
  removalPolicy: 'destroy',
  autoDeleteObjects: true,
};

⑤ cdk/config/stg.ts

stg環境専用の設定値(リテラル)を定義します。

cdk/config/stg.ts
import type { EnvConfig } from './types';

export const stgConfig: EnvConfig = {
  envName: 'stg',
  account: 'REPLACE_ME', // TODO: stg AWS account ID
  region: 'ap-northeast-1',
  prefix: '<project-name>-liff',
  github: {
    owner: 'organization-name',
    repo: 'repo-name',
    subjects: ['environment:stg'],
  },
  tags: {
    Project: '<project-name>',
    Environment: 'stg',
    ManagedBy: 'cdk',
  },
  removalPolicy: 'destroy',
  autoDeleteObjects: true,
};

⑥ cdk/config/prod.ts

prod環境専用の設定値(リテラル)を定義します。

cdk/config/prod.ts
import type { EnvConfig } from './types';

export const prodConfig: EnvConfig = {
  envName: 'prod',
  account: 'REPLACE_ME', // TODO: prod AWS account ID
  region: 'ap-northeast-1',
  prefix: '<project-name>-liff',
  github: {
    owner: 'organization-name',
    repo: 'repo-name',
    subjects: ['environment:prod'],
  },
  tags: {
    Project: '<project-name>',
    Environment: 'prod',
    ManagedBy: 'cdk',
  },
  removalPolicy: 'retain',
  autoDeleteObjects: false,
};

⑦ cdk/lib/constructs/static-site.ts

「S3 + CloudFrontでSPAをホストする」構成を、1つの再利用可能な「Construct」にまとめたものです。
Stack(デプロイ単位)ではなくConstruct(部品)として実装されているのがポイントです。

cdk/lib/constructs/static-site.ts
import { Duration, Stack } from 'aws-cdk-lib';
import type { RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
  Bucket,
  BlockPublicAccess,
  BucketEncryption,
  ObjectOwnership,
} from 'aws-cdk-lib/aws-s3';
import type { IBucket } from 'aws-cdk-lib/aws-s3';
import {
  AllowedMethods,
  CachePolicy,
  CachedMethods,
  Distribution,
  HttpVersion,
  PriceClass,
  S3OriginAccessControl,
  SecurityPolicyProtocol,
  Signing,
  ViewerProtocolPolicy,
} from 'aws-cdk-lib/aws-cloudfront';
import type { IDistribution } from 'aws-cdk-lib/aws-cloudfront';
import { S3BucketOrigin } from 'aws-cdk-lib/aws-cloudfront-origins';

export interface StaticSiteProps {
  /** リソース名プレフィックス(例: '<project-name>-liff') */
  prefix: string;
  /** 環境名(例: 'dev' | 'stg' | 'prod') */
  envName: string;
  /** 永続リソースに適用する削除ポリシー */
  removalPolicy: RemovalPolicy;
  /** stack 削除時にバケット中身を空にしてから削除するか(dev/stg のみ true) */
  autoDeleteObjects: boolean;
}

/**
 * S3(プライベート)+ CloudFront(OAC)の静的サイト構成。SPA ホスティングに最適。
 *
 * - Bucket: BlockPublicAccess、SSE-S3、BucketOwnerEnforced、TLS 強制
 * - Distribution: Managed CachingOptimized、redirect-to-https、HTTP/2、IPv6
 * - SPA エラーマッピング: 403/404 → /index.html(200 で応答)
 */
export class StaticSite extends Construct {
  public readonly bucket: IBucket;
  public readonly distribution: IDistribution;

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

    const account = Stack.of(this).account;

    const bucket = new Bucket(this, 'Bucket', {
      bucketName: `${props.prefix}-${props.envName}-static-${account}`,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
      encryption: BucketEncryption.S3_MANAGED,
      objectOwnership: ObjectOwnership.BUCKET_OWNER_ENFORCED,
      enforceSSL: true,
      removalPolicy: props.removalPolicy,
      autoDeleteObjects: props.autoDeleteObjects,
      versioned: false,
      // `aws s3 sync` 失敗時に残る未完了 multipart upload を自動回収する。
      // 設定しないと孤児パートが残り続けてストレージ課金が止まらない。
      lifecycleRules: [
        {
          id: 'AbortIncompleteMultipartUploads',
          enabled: true,
          abortIncompleteMultipartUploadAfter: Duration.days(7),
        },
      ],
    });

    const oac = new S3OriginAccessControl(this, 'Oac', {
      originAccessControlName: `${props.prefix}-${props.envName}-oac`,
      signing: Signing.SIGV4_ALWAYS,
    });

    const distribution = new Distribution(this, 'Distribution', {
      comment: `${props.prefix}-${props.envName} static site`,
      defaultRootObject: 'index.html',
      enabled: true,
      enableIpv6: true,
      httpVersion: HttpVersion.HTTP2,
      priceClass: PriceClass.PRICE_CLASS_ALL,
      minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_2_2021,
      defaultBehavior: {
        origin: S3BucketOrigin.withOriginAccessControl(bucket, {
          originAccessControl: oac,
        }),
        viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        allowedMethods: AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: CachedMethods.CACHE_GET_HEAD,
        cachePolicy: CachePolicy.CACHING_OPTIMIZED,
        compress: true,
      },
      errorResponses: [
        {
          httpStatus: 403,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: Duration.seconds(0),
        },
        {
          httpStatus: 404,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: Duration.seconds(0),
        },
      ],
    });

    this.bucket = bucket;
    this.distribution = distribution;
  }
}

⑧ cdk/lib/stacks/base-stack.ts

「アカウント単位でしか作れない(=singletonな)リソース」だけを集めた専用Stackです。

cdk/lib/stacks/base-stack.ts
import { CfnOutput, Stack } from 'aws-cdk-lib';
import type { StackProps } from 'aws-cdk-lib';
import type { Construct } from 'constructs';
import { OpenIdConnectProvider } from 'aws-cdk-lib/aws-iam';

export interface BaseStackProps extends StackProps {
  prefix: string;
  envName: string;
}

/**
 * アカウント単位のベースリソースを保持する Stack。同じアカウント内の
 * 他 Stack(FrontendStack / GithubOidcStack)から参照される。
 *
 * GitHub Actions OIDC Provider は同一 URL に対し AWS アカウントあたり
 * 1 つしか作れない singleton リソース。本プロジェクトはマルチアカウント前提
 * (dev/stg/prod それぞれ専用アカウント)なので、各アカウントに 1 度だけ
 * デプロイし、その ARN を env の GitHub Deploy Role に供給する。
 *
 * この Stack は「複数環境にまたがる」のではなく、「同一アカウント内の
 * 複数 per-env Stack にまたがる」ものとして位置付けている。
 */
export class BaseStack extends Stack {
  public readonly githubOidcProviderArn: string;

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

    const provider = new OpenIdConnectProvider(this, 'GitHubOidcProvider', {
      url: 'https://token.actions.githubusercontent.com',
      clientIds: ['sts.amazonaws.com'],
    });

    this.githubOidcProviderArn = provider.openIdConnectProviderArn;

    new CfnOutput(this, 'GitHubOidcProviderArn', {
      value: this.githubOidcProviderArn,
      exportName: `${props.prefix}-${props.envName}-base-GitHubOidcProviderArn`,
      description: 'GitHub Actions OIDC provider ARN (account-global)',
    });
  }
}

⑨ cdk/lib/stacks/frontend-stack.ts

「StaticSite Constructを実際のCFNスタックとして配置する」ラッパーStackです。

cdk/lib/stacks/frontend-stack.ts
import { CfnOutput, RemovalPolicy, Stack } from 'aws-cdk-lib';
import type { StackProps } from 'aws-cdk-lib';
import type { Construct } from 'constructs';
import type { EnvConfig } from '../../config/types';
import { StaticSite } from '../constructs/static-site';

export interface FrontendStackProps extends StackProps {
  config: EnvConfig;
}

/**
 * 環境別の静的サイトインフラ: S3 + CloudFront(OAC) を構築する Stack。
 */
export class FrontendStack extends Stack {
  public readonly bucketName: string;
  public readonly bucketArn: string;
  public readonly distributionId: string;
  public readonly distributionArn: string;
  public readonly distributionDomainName: string;

  constructor(scope: Construct, id: string, props: FrontendStackProps) {
    super(scope, id, props);
    const { config } = props;

    const site = new StaticSite(this, 'Site', {
      prefix: config.prefix,
      envName: config.envName,
      removalPolicy:
        config.removalPolicy === 'retain' ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
      autoDeleteObjects: config.autoDeleteObjects,
    });

    this.bucketName = site.bucket.bucketName;
    this.bucketArn = site.bucket.bucketArn;
    this.distributionId = site.distribution.distributionId;
    this.distributionArn = `arn:${this.partition}:cloudfront::${this.account}:distribution/${site.distribution.distributionId}`;
    this.distributionDomainName = site.distribution.distributionDomainName;

    new CfnOutput(this, 'BucketName', {
      value: this.bucketName,
      exportName: `${id}-BucketName`,
    });
    new CfnOutput(this, 'DistributionId', {
      value: this.distributionId,
      exportName: `${id}-DistributionId`,
    });
    new CfnOutput(this, 'DistributionDomainName', {
      value: this.distributionDomainName,
      description: 'CloudFront default domain (e.g. dxxxx.cloudfront.net)',
    });
  }
}

⑩ cdk/lib/stacks/github-oidc-stack.ts

「GitHub ActionsがCI/CDでこの環境を触るためのIAMロール」を作るStackです。

cdk/lib/stacks/github-oidc-stack.ts
import { CfnOutput, Duration, Stack } from 'aws-cdk-lib';
import type { StackProps } from 'aws-cdk-lib';
import type { Construct } from 'constructs';
import {
  Effect,
  FederatedPrincipal,
  OpenIdConnectProvider,
  PolicyStatement,
  Role,
} from 'aws-cdk-lib/aws-iam';
import type { EnvConfig } from '../../config/types';

export interface GithubOidcStackProps extends StackProps {
  config: EnvConfig;
  /** アカウントベースの GitHub OIDC Provider ARN(BaseStack から渡される) */
  oidcProviderArn: string;
  /** この Role が sync 対象とする S3 バケットの ARN */
  bucketArn: string;
  /** この Role が invalidation を許可される CloudFront Distribution の ARN */
  distributionArn: string;
}

/**
 * 環境別の GitHub Actions デプロイ Role を作成する Stack。
 *
 * Trust:
 *   federated = token.actions.githubusercontent.com の OIDC Provider
 *   aud       = sts.amazonaws.com
 *   sub       = config.github.subjects 各要素について repo:<owner>/<repo>:<subject>
 *
 * 権限:
 *   - 環境の S3 バケットへの sync(List/Get/Put/Delete object + multipart 系)
 *   - 環境の CloudFront Distribution の invalidation
 */
export class GithubOidcStack extends Stack {
  public readonly deployRoleArn: string;

  constructor(scope: Construct, id: string, props: GithubOidcStackProps) {
    super(scope, id, props);
    const { config, oidcProviderArn, bucketArn, distributionArn } = props;

    const provider = OpenIdConnectProvider.fromOpenIdConnectProviderArn(
      this,
      'GitHubOidcProvider',
      oidcProviderArn,
    );

    const subjectClaims = config.github.subjects.map(
      (sub) => `repo:${config.github.owner}/${config.github.repo}:${sub}`,
    );

    const role = new Role(this, 'DeployRole', {
      roleName: `${config.prefix}-${config.envName}-github-deploy`,
      description: `GitHub Actions deploy role for ${config.prefix} ${config.envName}`,
      maxSessionDuration: Duration.hours(1),
      // `sub` には StringLike ではなく StringEquals を使う。これにより
      // wildcard が解釈されない。config.github.subjects は完全一致の
      // subject claim 一覧(例: 'environment:prod')を想定しており、
      // 将来 '*' を含むエントリが紛れ込んでも StringLike のように暗黙的に
      // 権限が広がる事故を防げる。
      assumedBy: new FederatedPrincipal(
        provider.openIdConnectProviderArn,
        {
          StringEquals: {
            'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
            'token.actions.githubusercontent.com:sub': subjectClaims,
          },
        },
        'sts:AssumeRoleWithWebIdentity',
      ),
    });

    role.addToPolicy(
      new PolicyStatement({
        sid: 'S3BucketLevel',
        effect: Effect.ALLOW,
        actions: [
          's3:ListBucket',
          's3:GetBucketLocation',
          // `aws s3 sync` が実行中 multipart upload を列挙して
          // abort/resume を判断するために必要。
          's3:ListBucketMultipartUploads',
        ],
        resources: [bucketArn],
      }),
    );

    role.addToPolicy(
      new PolicyStatement({
        sid: 'S3ObjectLevel',
        effect: Effect.ALLOW,
        actions: [
          's3:GetObject',
          's3:PutObject',
          's3:DeleteObject',
          's3:GetObjectVersion',
          // `aws s3 sync` は約 8 MB を超えるファイルで multipart upload に
          // 切り替わる。これらが無いとアップロード失敗時に孤児パートが残り、
          // ストレージ課金が発生し続けるうえデプロイ Role 自身では掃除できない。
          's3:AbortMultipartUpload',
          's3:ListMultipartUploadParts',
        ],
        resources: [`${bucketArn}/*`],
      }),
    );

    role.addToPolicy(
      new PolicyStatement({
        sid: 'CloudFrontInvalidate',
        effect: Effect.ALLOW,
        actions: [
          'cloudfront:CreateInvalidation',
          'cloudfront:GetInvalidation',
          'cloudfront:GetDistribution',
        ],
        resources: [distributionArn],
      }),
    );

    this.deployRoleArn = role.roleArn;

    new CfnOutput(this, 'DeployRoleArn', {
      value: this.deployRoleArn,
      exportName: `${id}-DeployRoleArn`,
      description: 'Pass this to GitHub Actions vars.AWS_DEPLOY_ROLE_ARN',
    });
  }
}

⑪ cdk/test/frontend-stack.test.ts

「FrontendStackがsynthしたCloudFormationテンプレートに、絶対に外せない安全設定が含まれているか」を検証するJestテストです。
実際のAWSを呼ばずに、ローカルで生成テンプレートをアサーションする「静的検証」型のテストです。

cdk/test/frontend-stack.test.ts
import { App } from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { FrontendStack } from '../lib/stacks/frontend-stack';
import { devConfig } from '../config/dev';

function synth() {
  const app = new App();
  const stack = new FrontendStack(app, 'TestFrontend', {
    env: { account: '123456789012', region: 'ap-northeast-1' },
    config: devConfig,
  });
  return Template.fromStack(stack);
}

describe('FrontendStack', () => {
  test('creates exactly one S3 bucket, one CloudFront distribution, and one OAC', () => {
    const t = synth();
    t.resourceCountIs('AWS::S3::Bucket', 1);
    t.resourceCountIs('AWS::CloudFront::Distribution', 1);
    t.resourceCountIs('AWS::CloudFront::OriginAccessControl', 1);
  });

  test('S3 bucket blocks public access and enforces SSL', () => {
    const t = synth();
    t.hasResourceProperties('AWS::S3::Bucket', {
      PublicAccessBlockConfiguration: {
        BlockPublicAcls: true,
        BlockPublicPolicy: true,
        IgnorePublicAcls: true,
        RestrictPublicBuckets: true,
      },
      OwnershipControls: {
        Rules: [{ ObjectOwnership: 'BucketOwnerEnforced' }],
      },
    });
    t.hasResourceProperties('AWS::S3::BucketPolicy', {
      PolicyDocument: Match.objectLike({
        Statement: Match.arrayWith([
          Match.objectLike({
            Effect: 'Deny',
            Principal: { AWS: '*' },
            Condition: { Bool: { 'aws:SecureTransport': 'false' } },
          }),
        ]),
      }),
    });
  });

  test('CloudFront uses HTTPS redirect, HTTP/2, IPv6, and SPA error mapping', () => {
    const t = synth();
    t.hasResourceProperties('AWS::CloudFront::Distribution', {
      DistributionConfig: Match.objectLike({
        DefaultRootObject: 'index.html',
        HttpVersion: 'http2',
        IPV6Enabled: true,
        DefaultCacheBehavior: Match.objectLike({
          ViewerProtocolPolicy: 'redirect-to-https',
        }),
        CustomErrorResponses: Match.arrayWith([
          Match.objectLike({ ErrorCode: 403, ResponseCode: 200, ResponsePagePath: '/index.html' }),
          Match.objectLike({ ErrorCode: 404, ResponseCode: 200, ResponsePagePath: '/index.html' }),
        ]),
      }),
    });
  });

  test('CloudFront uses the managed CachingOptimized policy', () => {
    const t = synth();
    // Managed-CachingOptimized ポリシー ID
    t.hasResourceProperties('AWS::CloudFront::Distribution', {
      DistributionConfig: Match.objectLike({
        DefaultCacheBehavior: Match.objectLike({
          CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6',
        }),
      }),
    });
  });
});

⑫ cdk/cdk.json

cdk.jsonはCDK CLIのための「このプロジェクトをどう動かすか」を宣言するマニフェストファイルです。
cdk ~コマンドを実行したタイミングで最初に参照されます。
コードではなくCDK Toolkit (= cdk コマンド)が読む設定であり、3つの重要ブロックがあります。

{
    "app": "...",      ← どうやってCDK Appを起動するか(エントリの指定可能)
    "watch": { ... },  ← cdk watch/cdk deploy --hotswapで監視するファイル
    "context": { ... } ← CDK の振る舞いを変える feature flag / 環境変数
}
cdk/cdk.json
{
  "app": "npx ts-node --prefer-ts-exts bin/app.ts",
  "watch": {
    "include": ["**"],
    "exclude": [
      "README.md",
      "cdk*.json",
      "**/*.d.ts",
      "**/*.js",
      "tsconfig.json",
      "package*.json",
      "yarn.lock",
      "node_modules",
      "test"
    ]
  },
  "context": {
    "@aws-cdk/aws-iam:minimizePolicies": true,
    "@aws-cdk/core:checkSecretUsage": true,
    "@aws-cdk/core:enablePartitionLiterals": true,
    "@aws-cdk/aws-iam:standardizedServicePrincipals": true,
    "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
    "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
    "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false,
    "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true,
    "@aws-cdk/aws-route53-patters:useCertificate": true,
    "@aws-cdk/customresources:installLatestAwsSdkDefault": false,
    "@aws-cdk/core:target-partitions": ["aws", "aws-cn"]
  }
}

⑬ cdk/jest.config.js

npm testを叩いた時にJestが何を、どう、どこで実行するか」を宣言した設定ファイルです。

cdk/jest.config.js
module.exports = {
  testEnvironment: 'node',
  roots: ['<rootDir>/test'],
  testMatch: ['**/*.test.ts'],
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
};

⑭ cdk/package.json

CDKプロジェクトを動かす上で必要なnpm依存と、開発者が叩く操作の名前付きエイリアスを宣言します。

cdk/package.json
{
  "name": "<project-name>-liff-cdk",
  "version": "0.1.0",
  "private": true,
  "description": "AWS CDK infrastructure for <project-name> LIFF static site (S3 + CloudFront)",
  "bin": {
    "<project-name>-liff-cdk": "bin/app.js"
  },
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "cdk": "cdk",
    "synth:dev": "cdk synth --context env=dev",
    "synth:stg": "cdk synth --context env=stg",
    "synth:prod": "cdk synth --context env=prod",
    "diff:dev": "cdk diff --context env=dev",
    "diff:stg": "cdk diff --context env=stg",
    "diff:prod": "cdk diff --context env=prod",
    "deploy:dev": "cdk deploy --context env=dev --all --require-approval broadening",
    "deploy:stg": "cdk deploy --context env=stg --all --require-approval broadening",
    "deploy:prod": "cdk deploy --context env=prod --all --require-approval any-change",
    "destroy:dev": "cdk destroy --context env=dev --all",
    "destroy:stg": "cdk destroy --context env=stg --all",
    "bootstrap": "cdk bootstrap"
  },
  "devDependencies": {
    "@types/jest": "^29.5.14",
    "@types/node": "^22.10.0",
    "aws-cdk": "^2.180.0",
    "jest": "^29.7.0",
    "ts-jest": "^29.2.5",
    "ts-node": "^10.9.2",
    "typescript": "^5.6.3"
  },
  "dependencies": {
    "aws-cdk-lib": "^2.180.0",
    "constructs": "^10.4.2",
    "source-map-support": "^0.5.21"
  }
}

⑮ cdk/tsconfig.json

TypeScriptコンパイラ(tsc)とTSを扱うすべてのツール(ts-node, ts-jest, IDE)に「どう型チェックし、どうJSに変換するか」を教える設定ファイルです。

cdk/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "declaration": true,
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": false,
    "inlineSourceMap": true,
    "inlineSources": true,
    "experimentalDecorators": true,
    "strictPropertyInitialization": false,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "typeRoots": ["./node_modules/@types"]
  },
  "include": ["bin/**/*.ts", "lib/**/*.ts", "config/**/*.ts", "test/**/*.ts"],
  "exclude": ["node_modules", "cdk.out"]
}

実行方法

①依存関係のインストール

まずばcdkディレクトリに移動し、依存関係をインストールします。

zsh
cd cdk

npm install

②設定ファイルの編集

次に、config/dev/stg/prodのファイルを編集し、必要な値を入力します(AWSアカウントID等)

③SSOログイン

デプロイ対象のAWSアカウントでSSOログインします。

zsh
aws sso login --profile <your-profile-name>

以下のコマンドを実行し、出力結果のAWSアカウントIDが、config/dev/stg/prodのファイルに設定したIDと一致していることを確認します。

zsh
aws sts get-caller-identity --profile <your-profile-name>

④Bootstrap

スタックを作成するために必要なCDK Bootstrapを行います(各アカウント × 各リージョンで初回 1 回のみ)
npm runを使用することでcdkコマンドのバージョンを統一します。
(オプション引数を使用する場合に--が必要になります)

/cdk
npm run bootstrap -- --profile <your-profile-name> --context env=dev

⑤スタック(AWSリソース)のデプロイ

以下のコマンドを実行し、実際にスタックをデプロイします。

/cdk
npm run deploy:dev -- --profile <your-profile-name>

⑥GitHub Actions連携

ターミナル上に「Outputs:」として出力された値を、デプロイ対象のGitHubリポジトリのEnvironmentsに登録していきます。

SCR-20260520-paxk.png

変数名 取得元 (CDK Output)
AWS_DEPLOY_ROLE_ARN <project-name>-liff-<env>-github-oidc.DeployRoleArn arn:aws:iam::<env-account>:role/<project-name>-liff-dev-github-deploy
AWS_REGION 固定 ap-northeast-1
S3_BUCKET_NAME <project-name>-liff-<env>-frontend.BucketName <project-name>-liff-dev-static-<env-account>
CLOUDFRONT_DISTRIBUTION_ID <project-name>-liff-<env>-frontend.DistributionId EXXXXXXXXXXXX
LIFF_ID LINE Developers から 2001234567-asdfghjk

その後、GitHub Actionsのワークフローを実行してフロントエンドのコンテンツをアップロードすれば完了です!
このあたりは手動での設定が必要となるため、前回の記事をご参照ください(スクリプトで自動化するのもありです)

⑦スタックの削除

以下のコマンドを実行します。
確認プロンプトをスキップしたい場合は --force(または -f)を付けます。

/cdk
npm run destroy:dev -- --profile <your-profile-name>

dev/stg環境の場合、CDKToolkitを除く、「今回のcdkによって作成されたすべてのAWSリソース」が削除されます。

今回は以上になります!

0
3
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
0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?