♻️

CDK における命名ルールの共通化とスタックの分け方

に公開

現在のプロジェクトで行っている、CDK における共通化やスタックの分け方について整理してみます。

命名ルールの集約とスタック分割の方針

環境ごとに AWS アカウントを分離しつつ、アカウント内では複数のサービスを運用しています。
各 AWS リソースには環境名とサービス名を含めた {stageName}-{serviceName} というプレフィックスを付ける命名ルールを採用しています。[1]

例:

  • dev-app1-web
  • stg-app2-db

最初にこの命名ルールを各 Stack に分散させず、一箇所で管理するために行っている共通化について説明します。

ResourceNamespace による命名の集約

まずは環境名を型として定義します。

export const stage = ["dev", "stg", "prod"] as const;

export type StageName = (typeof stage)[number];

次に、環境名とサービス名を受け取って AWS リソースの命名に関する責務を一箇所に集約するための ResourceNamespace というクラスを作っています。

import type { StageName } from "./stage";

export class ResourceNamespace {
  constructor(
    readonly stageName: StageName,
    readonly serviceName: string,
  ) {}

  get resourceNamePrefix(): string {
    return `${this.stageName}-${this.serviceName}`;
  }

  get parameterStorePrefix(): string {
    return `/${this.stageName}/${this.serviceName}`;
  }
}

命名に必要な最小限の情報のみを保持し、リソース名や Parameter Store のプレフィックスを提供します。

このクラスは、後述する BaseStack から参照されます。

スタック分割の方針と BaseStack

CDK では「必要になるまで Stack はなるべく分割しない」というプラクティスがありますが、
いまのプロジェクトでは初期段階から複数のサービスを運用することが明確であり、将来的な運用負荷を見据えて、こちらのガイダンスを踏まえつつ、以下のような基準で分割する構成にしています。

  • ステートフルリソース(RDS, S3 など)
  • ステートレスリソース(ALB, ECS など)

ステートレスリソースについては、コンポーネントごとにデプロイサイクルが異なることから、
API や Web といったデプロイ単位の観点でさらに Stack を分割しています。

また、監視やバックアップに関するリソースは、システム全体を横断する責務であり、アプリケーションのデプロイライフサイクルとも独立させたいため、専用の Stack として定義しています。

これらの Stack に共通する処理をまとめた BaseStack を用意し、各 Stack はそれを継承する構成にしています。

BaseStack の実装

BaseStack は、各 Stack に共通する以下の責務を担います。

  • Stack 名への環境プレフィックス付与
  • 命名に関する共通アクセサの提供

具体的な命名ルールは ResourceNamespace に委譲します。

export interface BaseStackProps extends cdk.StackProps {
  resourceNamespace: ResourceNamespace;
}

export abstract class BaseStack extends cdk.Stack {
  protected readonly ns: ResourceNamespace;

  constructor(scope: Construct, id: string, props: BaseStackProps) {
    const { resourceNamespace, ...restProps } = props;

    super(scope, id, {
      ...restProps,
      stackName: `${resourceNamespace.resourceNamePrefix}-${id}`,
    });

    this.ns = resourceNamespace;
  }

  protected get stageName(): StageName {
    return this.ns.stageName;
  }

  protected get serviceName(): string {
    return this.ns.serviceName;
  }

  protected get resourceNamePrefix(): string {
    return this.ns.resourceNamePrefix;
  }

  protected get parameterStorePrefix(): string {
    return this.ns.parameterStorePrefix;
  }
}

stackName は CloudFormation 上に表示される Stack 名です。

stackName: `${resourceNamespace.resourceNamePrefix}-${id}`,

これで実際にデプロイされる Stack 名が {stageName}-{serviceName}-{stackId} という形式に統一されるようにしています。

継承した Stack で AWS リソースを作成するときにプレフィックスの指定は以下のようになります。

interface DatastoreStackProps extends BaseStackProps {}

export class DatastoreStack extends BaseStack {
  constructor(scope: Construct, id: string, props: DatastoreStackProps) {
    super(scope, id, props);

    new s3.Bucket(this, "SampleBucket", {
      bucketName: `${this.resourceNamePrefix}-sample-${this.account}`,
    });

    ...
  }
}

エントリーポイント

エントリーポイントはサービス毎に用意していて、以下のステップのあと各スタックを呼び出しています。

  • 環境名(stage)の取得とサービス名の指定 [2]
  • 環境ごとのパラメータの読み込み
  • ResourceNamespace の初期化
  • 共通 Props の組み立て
const app = new cdk.App();

const stageName: StageName = app.node.tryGetContext("stage");
const serviceName = "app1";
const parameter = getParameter(stageName);

const resourceNamespace = new ResourceNamespace(stageName, serviceName);

const baseProps: BaseStackProps = {
  resourceNamespace,
  env: {
    account: parameter.accountId,
    region: "ap-northeast-1",
  },
};

new DatastoreStack(app, "datastore", {
  ...baseProps
})

new WebStack(app, "web", {
  ...baseProps
})

new ApiStack(app, "api", {
  ...baseProps
})

デプロイ

これで以下のように各環境へのデプロイ時はコンテキストで stage を変更するだけで済むようになり、かつ CloudFormation のスタック名には環境名が含められるようになります。

$ pnpm -F cdk app1 list -c stage=dev
datastore (dev-app1-datastore)
web (dev-app1-web)
api (dev-app1-api)

// 個別の Stack のデプロイ
$ pnpm -F cdk app1 deploy web -c stage=dev

CloudFormation 上のデプロイ結果

デプロイ結果

よく使う AWS リソースは Construct で共通化する

Stack の共通化に加えて、共通のポリシーを適用したい AWS リソースについては、Construct として定義しています。

具体的には、以下のようなリソースです。

  • S3 バケット
  • CloudWatch Logs ロググループ

これらは比較的よく作成するリソースかつ、サービスを跨いで同じ設定をになることが多く、毎回 Stack 側ですべて設定すると設定漏れや差分が生まれやすくなります。

そのため、L2 Construct を継承した独自 Constructとして定義しています。

S3 Bucket の共通 Construct

export class CustomBucket extends s3.Bucket {
  constructor(scope: Construct, id: string, props?: s3.BucketProps) {

    super(scope, id, { 
      ...props, 
      versioned: true,
      enforceSSL: true,
    });
  }
}  

これは公式の例にもありますが S3 Bucket に対する共通設定(バージョニング有効化、SSL 強制など)を Construct 側に集約しています。

LogGroup の共通 Construct

ログの保持期間は、企業のセキュリティポリシーやコンプライアンス要件によってあらかじめ決められていることが多いため、その設定を Construct 側で固定しています。

export class CustomLogGroup extends logs.Group {
  constructor(scope: Construct, id: string, props?: logs.LogGroupProps) {

    super(scope, id, { 
      ...props, 
      retention: logs.RetentionDays.ONE_YEAR,
    });
  }
}

尚、ECS や RDS などは設定項目が多く、またデプロイサイクルもサービスごとに異なるため、無理に共通化はしていません。

さいごに

今回紹介できたのは一部かつ断片的な内容ではありますが、CDK における共通化やスタック分割を考える際の一例として、どなたかの参考になれば幸いです。

脚注
  1. CDK のベストプラクティスとして自動生成されるリソース名の使用が推奨されていますが、命名規則に従って設定したほうが認知負荷が下がると判断しています ↩︎

  2. 共通のリソース(VPC など)は shared というサービス名を指定して別途作成しています ↩︎

Discussion