これまでにいくつかのプロジェクトで AWS CDK を使ってきて、個人的に良かったと思うプラクティスをまとめたいと思います。公式でベストプラクティスに関するドキュメントやブログが提供されているので、基本的にはそれに従いつつ、実際に運用してみて思った点は適宜変更してあります。
想定している環境としては、よくある Web アプリケーションに必要な AWS リソースを構成するものになります。また、検証環境や商用環境など、複数の環境を管理することを想定しています。
なお、ところどころサンプルコードが出てきますが、言語は TypeScript です。また、今回もここで紹介しているコード(の一部)をこちらのリポジトリで公開しているので参考までに。
1. Stack の分け方
Stack の分け方は色々な方針があり、真っ先に悩むところですが、個人的には1つの Stack に全てをまとめるやり方は合いませんでした。この手法のメリットは、コードの見通しの悪さを代償に依存関係をシンプルにすることだと思います。依存関係が複雑になるとデプロイに時間がかかったりするようになりますが、規模が大きくなければそこまで気にならないことが多かったです。耐えられないくらいの規模になったら、そもそもリポジトリ自体を分割し、完全に異なるサービスとして運用を考える段階かもしれません。
実際の分け方ですが、リソースのライフサイクル毎に分けました。例えば、VPC(ネットワーク)リソースやお客様のデータを保管する S3 バケット、RDS リソースなどはサービス運用を通して削除することがほぼ無いので、これらはトップレベル(RDS は VPC の下になりますが)の Stack としています。その他、アプリケーションサーバーを運用する ECS リソース、S3 バケット、SQS キューなどは先述のリソースよりは短命なため、更に下位の Stack としています。イメージとしてはこんな感じです:
依存リソースが削除できなくなる問題
例えば、ApplicationStack
が S3Stack
の特定のバケットに依存しているとします:
export class S3Stack extends Stack {
public readonly publicUserUploadedFilesBucket: cdk.aws_s3.IBucket;
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
this.publicUserUploadedFilesBucket = new cdk.aws_s3.Bucket(this, 'PublicUserUploadedFilesBucket', {
// ...
});
}
}
interface ApplicationStackProps extends StackProps {
publicUserUploadedFilesBucket: cdk.aws_s3.IBucket;
}
class ApplicationStack extends Stack {
constructor(scope: Construct, id: string, props: ApplicationStackProps) {
super(scope, id, props);
new cdk.aws_lambda.Function(this, 'ApplicationFunction', {
code: cdk.aws_lambda.Code.fromInline(`
import os
def handler(event, context):
return { 'bucket': os.environ['BUCKET_NAME'] }
`.trim()),
handler: 'index.handler',
runtime: cdk.aws_lambda.Runtime.PYTHON_3_12,
environment: {
BUCKET_NAME: props.publicUserUploadedFilesBucket.bucketName,
},
});
// ...
}
}
const s3Stack = new S3Stack(app, 'S3');
new ApplicationStack(app, 'Application', {
publicUserUploadedFilesBucket: s3Stack.publicUserUploadedFilesBucket,
});
このようにすると、S3Stack
は ApplicationStack
にバケット名を公開するために Export を定義します:
{
"Outputs": {
"ExportsOutputRefPublicUserUploadedFilesBucket4FF12C1B9072A75C": {
"Export": {
"Name": "TestS3:ExportsOutputRefPublicUserUploadedFilesBucket4FF12C1B9072A75C"
},
"Value": {
"Ref": "PublicUserUploadedFilesBucket4FF12C1B"
}
}
}
}
一度 Stack をデプロイした後、ApplicationStack
から先ほどの props.publicUserUploadedFilesBucket.bucketName
の参照を外すとどうなるでしょうか。他にこのバケット名を参照している箇所がどこにもなければ、CDK は自動的にこの Export 定義を削除しようとします。ところが S3Stack
は ApplicationStack
が依存しているので CloudFormation 上で先にデプロイされますが、この Export はその時点ではまだ ApplicationStack
が参照しているので削除できず、エラーとなってしまいます。
実は、以下の様にして S3Stack
で Export が常に明示的に作られるようにするだけで簡単に解決できます:
this.exportValue(this.publicUserUploadedFilesBucket.bucketName);
これで先ほどの Export はどこが依存しているかに関わらず定義され続けるので、ApplicationStack
のみデプロイが走ります。これで完全に依存が打ち切られたら this.exportValue
の行を削除しても問題ありません。次のデプロイで Export は削除されます。
2. リソース名について
個人的には原則、名前を明示的に指定します。
冒頭で紹介した記事では基本的には指定しない方が良いと書かれています。
自動で生成されるリソース名を使用し、物理的な名前を使用しない
名前は貴重なリソースです。すべての名前は一度しか使えないので、テーブル名やバケット名をインフラやアプリケーションにハードコードしてしまうと、もうそのインフラの一部を2つ並べてデプロイすることはできません。
この主張には賛同しつつも、名前が自動生成されたリソースがたくさんできると管理するのが非常に大変になると感じています。以下の記事で紹介されている通り、リソース名の可読性を高めるテクニックは存在しますが、それでも IAM ロールや CloudWatch Logs に大量に自動生成されたリソースがあると圧倒されてしまいます。
参考: AWS CDK の Construct ID はどのように命名するべきか?
また、次の節で紹介しますが、今回はリソース名に環境名(商用環境や検証環境など)を極力入れたいので、基本的には指定するようにしています。ただし、L2 コンストラクトなどで自動生成されるリソースについてはきりがないので追っていません。
3. 環境分けについて
商用環境と開発環境では AWS アカウントを分けることがほとんどかと思いますが、開発環境が複数ある場合(ステークホルダーを交えたテスト用と、開発者だけが使う文字通り開発環境など)、同じ AWS アカウントに展開することがありました。このような場合は、Stack やリソース名には環境名を埋め込みたいところです。このような場合に、リソース名に環境名をプレフィックスとして適用できるよう、Stack
クラスの拡張を行ないました。
interface StackProps extends BaseStackProps {
resourceNamePrefix: string;
}
class Stack extends BaseStack {
protected readonly prefix: string;
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
this.prefix = props.resourceNamePrefix;
}
}
class S3Stack extends Stack {
public readonly publicUserUploadedFilesBucket: cdk.aws_s3.IBucket;
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
this.publicUserUploadedFilesBucket = new cdk.aws_s3.Bucket(this, 'PublicUserUploadedFilesBucket', {
bucketName: `${this.prefix}-public-user-uploaded-files`,
});
}
}
const stageName = 'dev'; // 実際には context などを使って埋め込む
const camelizedStageName = stageName.charAt(0).toUpperCase() + stageName.slice(1);
const baseStackProps = {
resourceNamePrefix: stageName,
};
const s3Stack = new S3Stack(app, `${camelizedStageName}S3`, {
...baseStackProps,
}); // DevS3
こうすると、DevS3
という名前の CloudFormation stack に dev-public-user-uploaded-files
という名前のバケットが作られます。これで固定のリソース名を使っていても、同じ AWS アカウント上に複数環境のリソースを展開できます。また、環境に対する検索性も高まります。
設定について
インスタンスサイズや Lambda のメモリサイズなど、環境ごとに異なる設定値を持たせたいことはよくあります。先ほど環境名は context で埋め込むと書きましたが、設定値についてはコードベースに保存しています。型を使いたいですからね。
type Stage = 'dev' | 'stg' | 'prod';
interface Configuration {
applicationFunctionMemorySize: number;
}
const configs: Record<Stage, Configuration> = {
prod: {
applicationFunctionMemorySize: 1024,
},
stg: {
applicationFunctionMemorySize: 1024,
},
dev: {
applicationFunctionMemorySize: 128,
},
};
const stageName = 'dev'; // 実際には context などを使って埋め込む
const config = configs[stageName as Stage];
export interface ApplicationStackProps extends StackProps {
functionMemorySize: number;
// ...
}
new ApplicationStack(app, `${camelizedStageName}Application`, {
...baseStackProps,
functionMemorySize: config.applicationFunctionMemorySize,
// ...
});
これで環境毎に設定値を変えられるようになりました。
4. テストについて
基本的にはほとんど全てを スナップショットテストを使って書いていました。一部、IP アドレスの制限などテストでさらに明示したい時だけ Fine-grained assertions を定義しています。以前は結構細かく Fine-grained assertions も書いていたのですが、管理が大変なのと、スナップショットテストでも正しく書けていれば全くトラブルもなく、十分だと感じたからです。
スナップショットテストで1点だけ注意点があり、今回のように Stack を分割している場合は、常に全ての Stack を連携させた状態で1つのスナップショットを出力することです。Stack 毎にテストファイルを分け、単体テストのように Stack 単体をコンストラクトして個別にスナップショットを作っていると、先述した通り Export など Stack 間の依存関係によって出力の有無が決まるリソースがスナップショットに保存されないためです。Export ならまだいいですが、比較的重要な物(バケットポリシーなど)が漏れていると大変なので、1つにまとめましょう。こうすると実際に CloudFormation に適用する時とほぼ同じシンセサイズ結果になるはずです。
const s3Stack = new S3Stack(app, 'TestS3', {
// ...
});
const applicationStack = new ApplicationStack(app, 'TestApplication', {
publicUserUploadedFilesBucket: s3Stack.publicUserUploadedFilesBucket,
// ...
});
describe('S3Stack', () => {
test('synthesizes the way we expect', () => {
expect(Template.fromStack(s3Stack)).toMatchSnapshot();
});
});
describe('ApplicationStack', () => {
test('synthesizes the way we expect', () => {
expect(Template.fromStack(applicationStack)).toMatchSnapshot();
});
});
最後に
今回は、今までに運用してきた AWS CDK のノウハウから個人的に良いと思ったプラクティスをまとめました。もちろん、インフラの構成は多種多様であり、適用できないケースも多々あるかと思いますが、誰かの参考になれば幸いです。また、今回サンプルで出したスニペットは全体のコードのごく一部になりますので、実際に動作するプロジェクトが見たい方は冒頭にも書いたリポジトリをご参照ください。