🦔

新サービスのインフラにCDKを選択したとき気をつけたポイント3点

2024/12/09に公開

この記事は、GENDA Advent Calendar 2024 8日目の記事です。
https://qiita.com/advent-calendar/2024/genda


はじめまして。株式会社GENDAバックエンドエンジニアのじょにーです。

11月にリリースしたカラオケBanBanアプリのリニューアルで、インフラコード管理にCDKを採用しました。 本記事では、CDKを使って開発するにあたって考慮した3つのポイントを紹介します。

前提

  • フルスクラッチ開発
  • 対象はアプリ・POS・管理画面のバックエンドAPI
  • AWSアカウントは環境ごとに分離(STG/本番、DNS・ECR等共通インフラ)
  • バックエンドはすべてモノレポ
  • アプリはほぼ同一構成(ALB+ECS Fargate、Aurora PostgreSQL、Elasticache、S3)
  • 非同期処理はSQS+ECS Fargate、CronジョブはScheduledTask、監視はDatadog

気をつけたポイントは以下の3つです。

  1. Stackの分け方
  2. 設定値の外部化
  3. ECS Fargateのデプロイ

ディレクトリ構成

今回のCDKコードのディレクトリ構成です。Monorepoで管理しており、ルート配下に/infraというディレクトリを分けています。

 % tree
.
├── banban-backend-stack.ts
├── banban-us-region-stack.ts
├── constants.ts
├── construct
│   ├── assets.ts
│   ├── database.ts
│   ├── ecs-service
│   │   ├── api.ts
│   │   ├── base-app.ts
│   │   ├── schedule-task.ts
│   │   └── worker.ts
│   ├── job-queue.ts
│   ├── network.ts
│   ├── redis-cache.ts
│   ├── sub-domain.ts
│   ├── waf
│   │   ├── base-waf.ts
│   │   ├── cloudfront-waf.ts
│   │   └── regional-waf.ts
│   └── zone-apex.ts
├── environments
│   ├── production.ts
│   └── staging.ts
└── schedules.ts

CDKではStackがデプロイの単位です。AWSアカウントとリージョンをまたいで一つのStackを定義することは出来ません。本プロジェクトのメインはTokyoリージョンを使っています。CloudfrontにアタッチするACMの証明書はUSリージョンに作成するために別Stackにしています。
constantsには既存リソースのARNなど定数の定義を、environments配下は環境毎に変えるStackのパラメータをまとめています。詳しくは後述します。

1. Stack/Constructの分け方

CDKを使った開発でまず最初に悩むのがStackをどう分けるか、だと思います。

今回のプロジェクトでは、可能な限り1つのStackにまとめました(USリージョンが必要なCloudFrontのみ別Stack)。スタックを分けるメリット・デメリットはありますが、小規模チームでインフラとアプリケーションすべてを一括運用するならシングルスタックがおすすめです。AWSも推奨しています。
https://speakerdeck.com/konokenj/cdk-best-practice-2024?slide=21

かつては管理対象のライフサイクルや管理権限でStackを分けるアプローチが推奨されていました。
ネットワークやDBなど一度デプロイしたら中々変更が無いものと日々の開発で頻繁に変更があるアプリケーション(ECS TaskDefinition)を分ける、IAMやセキュリティ系の設定は特権を持つ人以外には触れないようにする、など。大規模な組織で専門性によって部門が分かれている場合にはこのスタイルが効率的かもしれませんが、小規模のチームで開発しているとスタックが分かれていることによるオーバーヘッドが面倒になります。マイクロサービスと同じです。

特に面倒なのが、スタック間の依存関係の扱いです。例えば、CDK上でDBを作成し、そのアクセス情報をアプリケーションコンテナの環境変数に設定したい場合、両者が同一スタックに定義されていれば特に気にすることはありません。しかしStackが別れている場合は値を受け渡す手段が必要です。

それぞれのStackが同一リポジトリ(App)内であれば、StackのPropertyを引数で別のStackに渡すことが可能です。しかし、リソースの再作成をしたいときに削除の順番に配慮が必要だったり、意図せず循環参照してしまった場合に解決が必要になったりと面倒が増えます。しかもこれらが判明するのがDeploy時だったりします。
また、別のリポジトリでStackが管理されている場合は、デプロイ済みの情報をParameterStoreやコードに直接記載する等して受け渡す必要があり、リソースの更新によって値が書き換わった場合にもれなく更新をしなければなりません。

これらの管理の手間を鑑みると、シングルスタックの運用は非常に楽です。

2. 設定値の外部化

次に、一つのStack定義を使いまわして複数の環境を構築するケースを考えます。環境ごとに設定値を変えたい場合(インスタンスタイプを本番環境ではxlarge、開発環境ではmicroにする、など)は、差分をパラメータでStackに渡します。更に、将来別のプロジェクトでも利用することを考えてConstruct内部で環境毎にIfの分岐はしていません。アプリケーション固有のロジックはStackかAppに記載し、Constructは純粋なモジュールとして使えるように設計しています。

construct の例

construct
export interface DatabaseConstructProps {
	vpc: Vpc;
	instanceType: InstanceType;
	readerCount?: number;
}

export class Database extends Construct {
	public readonly cluster: rds.DatabaseCluster;
	public readonly roSecret: rds.DatabaseSecret;
	public readonly rwSecret: rds.DatabaseSecret;

	constructor(scope: Construct, id: string, props: DatabaseConstructProps) {
		super(scope, id);
		const vpc = props.vpc;

		const instanceProp: ProvisionedClusterInstanceProps = {
			publiclyAccessible: false,
			instanceType: props.instanceType,
			enablePerformanceInsights: true,
			performanceInsightRetention: props.insightsRetention,
		};
    // 略

このConstructをStackで参照。

stack
export interface BanbanBackendStackProp extends StackProps {
	vpcCidrBlock: string;
	idpServiceEndpointName: string;
	dbInstanceType: InstanceType;
	readerCount?: number;
    // 略
}

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

		const dns =
			props.appEnv === AppEnv.PROD
				? new ZoneApex(this, "ZoneApex")
				: new SubDomain(this, "SubDomain", {
						zoneName: props.baseZoneName,
					});

		const db = new Database(this, "Database", {
			vpc: network.vpc,
			instanceType: props.dbInstanceType,
			insightsRetention: props.insightsRetention,
			readerCount: props.readerCount ?? 0,
		});

        // 略

Stackパラメータもenvironmentsディレクトリに環境別ファイルを用意し一つのファイルに纏めています。

environments/production.ts
export const ProductionStackParams: BanbanBackendStackProp = {
	env: { account: ACCOUNT_PRD, region: "ap-northeast-1" },
//略
}

アプリケーションの記載

app.ts
#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import { BanbanBackendStack } from "../lib/banban-backend-stack";
import { BanbanUsRegionStack } from "../lib/banban-us-region-stack";
import { ACCOUNT_INFRA, ACCOUNT_PRD, ACCOUNT_STG } from "../lib/constants";
import { ProductionStackParams } from "../lib/environments/production";
import { StagingStackParams } from "../lib/environments/staging";

const app = new cdk.App();

const env = process.env.DEPLOY_ENV || "stg";

function generateStacks(environment: string) {
	switch (environment) {
		case "stg":
			new BanbanUsRegionStack(app, "StgUsRegionStack", {
				env: { account: ACCOUNT_STG, region: "us-east-1" },
				baseZoneName: StagingStackParams.baseZoneName,
			});
			new BanbanBackendStack(app, "StgBackendStack", StagingStackParams);
			break;
		case "prod":
			new BanbanUsRegionStack(app, "ProdUsRegionStack", {
				env: { account: ACCOUNT_PRD, region: "us-east-1" },
				baseZoneName: ProductionStackParams.baseZoneName,
			});
			new BanbanBackendStack(app, "ProdBackendStack", ProductionStackParams);
			break;
		default:
			throw new Error(`Unsupported environment: ${environment}`);
	}
}

// 環境に基づいてスタックを生成
generateStacks(env);

app.synth();

アプリケーションのデプロイ時はDEPLOY_ENVを使って環境を切り替えています。環境変数による分岐はなくても良いのですが、こうすることでSynthするStackを減らして時短しています。

deployコマンド
DEPLOY_ENV=stg npx cdk diff StgBackendStack

ParameterStore/SecretsManager経由の参照

また、DBの接続情報やAPIキーなどのCredentialはSecretsManager経由で引き渡しが必要です。
DBの接続情報はDB自体をCDKで作成していればSecrets経由で参照可能ですが、外部サービスから取得した値を格納する場合は、SecretsManagerやParameterStoreというハコをどうやって作るかが問題になります。
SecretsManagerとそれに依存するECS taskを同時にStackに記載すると、ハコの作成と中身の参照が同時に行われてしまいます。このとき、ECS Taskのデプロイ時に中身の値は設定されていないため、存在しない値を参照してデプロイは失敗します。

この問題を割けるため、これらのハコはCDK管理外で作成しています。aws cli(またはマネジメントコンソール)経由でリソースを作成し、そのARNをConstants.tsに記載しています。この処理自体もワンライナーで記載できるため、スクリプト化は用意です。環境が複数あったとしてもオーバーヘッドは小さいです。

contants.ts
export const SECRETS_ARN_PROD = "arn:aws:secretsmanager:ap-northeast-1:xxxxxxxxxxxx:secret:Secret-xxxxxxxxx";

const Secrets = secretsmanager.Secret.fromSecretAttributes(
			this,
			"Secrets",
			{
				secretCompleteArn: props.commonSecretArn, // props.SecretArn = SECRETS_ARN_PROD
			},
		) as secretsmanager.Secret;

SecretsManagerのimportは部分一致検索がうまく動かなかったので完全なARNを入れるようにしています。

3. AWS Fargateのデプロイ

最後に、ECS FargateのアプリケーションをCDKで組んでいるときに悩むのが、Fargateのデプロイをどうするかです。デプロイツールの選択肢は多岐にわたりますが、CDKを使っている場合、ECS Fargateへのデプロイは大きく3パターンです。

  1. ネットワークやクラスターのみCDKで作り、アプリデプロイはecspresso等別ツールで行う
  2. 初回デプロイはCDK、運用中は別ツールで更新
  3. 全てCDKで行う

我々のプロジェクトでは、3のすべてCDKで行うアプローチを取っています。

1や2の方法を採用するメリットは、ライフサイクルとデプロイを一致出来ることです。通常、一度運用に載ってしまえばアプリケーションコードの改修が大半で、インフラの変更頻度は比較して少なくなります。特にポリリポの場合はそれぞれが別リポジトリになるため、アプリケーションのデプロイの設定はアプリケーションのリポジトリ、インフラはインフラ、と自分たちの範囲だけを気にすればよくなります。更に、ecspressoのようなツールの持つ独自の便利なコマンドが使えるのも利点です。
ところが、インフラの変更と連携を取る必要がある場合(新たにAuroraのカスタムエンドポイントを追加したときなど)は、インフラの変更をアプリケーションに伝えるために手間がかかります。先にインフラをデプロイしておいて、得られた値をアプリケーションのリポジトリに追加するなど。また、2のアプローチを取ると、ecspressoとcdkの2重管理が必要なため、設定を同期する工夫をしないとデグレが発生していまい逆に管理負担が増加します。S3経由で環境変数を渡すなどの方法もありますが、dbのエンドポイントを渡すようなケースはCDKのコード上で直接参照するのが便利です。

一方で、すべてをCDKで行う場合、これらのメリットとデメリットが反転します。インフラリソースの差分チェックやsynthなどのオーバーヘッドは発生しデプロイ速度が少し悪化します。しかし、特にシングルスタックかつモノレポの環境であればインフラとアプリのコードとデプロイ用のWorkflow定義が一箇所にまとまっているので非常に見通しがしやすいです。

以上、新サービスのフルスクラッチ開発においてCDKを採用した際に考慮したポイントから3つに絞ってお伝えしました。

Discussion