CDKスタックのリソース上限にNested Stackで対応した話

CDKスタックのリソース上限に対して対処法を検討し、Nested Stackを使った方法を実践してみました。
2023.12.11

こんにちは。CX事業本部のKyoです。 本エントリはAWS CDK Advent Calendar 2023の11日目です。

はじめに

みなさま、AWS CloudFormation の1スタックあたり管理できるリソースの最大数をご存知でしょうか?

500個です。また、この値は上限緩和も不可能です(2023年12月現在)。

AWS CloudFormation のクォータ

この上限は、AWS CDKにおいても同様です。CDKコードはCloudFormationテンプレートに変換されるため、リソースが上限に近づくとcdk deploy時に警告メッセージが表示されます。

私が参画しているプロジェクトでも、この問題に直面しました。本エントリではその対処法についてご紹介します。

状況把握

まずは状況把握として、CDKコードの分析を行いました。

問題になっていたコードは以下のようなものです。

  • 1 API = 1 Lambda方式のサーバーレスアプリケーション (TypeScript製)
  • スタック上の総リソース数: 485
    • Lambdaの総数: 26
    • 非機能要件の都合上、1Lambdaにつき、3種類の監視系リソースが付与されている
      • あまり抽象化されておらず、1つ1つ設定を行う必要がある
      • 具体的なリソース数: 152
        • CloudWatch Alarm (Subscription Filter, Metrics Filter経由)
          • Error
          • Warn
        • Kinesis Firehose
  • コード量はおよそ2000行 (1ファイル)
    • コードのボリュームから、監視系リソースの付け忘れが時折発生

監視系リソースをうまく分離できれば解決できそうなことが明らかになりました。

[参考] リソース分析用のスクリプト

スタック上のリソース数の分析は以下のスクリプトで行いました。

#!/bin/bash

usage() {
  echo "Usage: $0 <STACK_NAME>"
  echo "例: $0 MyCloudFormationStack"
  exit 1
}

# コマンドライン引数からスタック名を取得
STACK_NAME=$1

# 引数が指定されているかをチェック
if [ -z "$STACK_NAME" ]; then
  echo "Usage: $0 <STACK_NAME>"
  exit 1
fi
# AWS CLIを使用してスタックのリソース情報を取得
stack_resources=$(aws cloudformation list-stack-resources --stack-name $STACK_NAME)

# リソースタイプを抽出し、各リソースタイプの数を計算
resource_counts=$(echo "$stack_resources" | jq '.StackResourceSummaries | group_by(.ResourceType) | map({key: .[0].ResourceType, count: length})')

# 各リソースタイプの数を出力
echo "各リソースタイプの数:"
echo "$resource_counts" | jq -r '.[] | "\(.key): \(.count)"'

# 総リソース数を計算
total_resources=$(echo "$resource_counts" | jq 'map(.count) | add')

# 総リソース数を出力
echo "総リソース数: $total_resources"

アプローチの検討

調査の結果、5案挙がりました。

1. クロススタック参照

  • 概要
    • CloudFormationでお馴染みの異なるスタック間でリソースの情報を共有するための機能
    • 監視系リソースを専用のスタックに切り出し、クロススタック参照でLambdaを渡す
  • Pros
    • おそらく最もメジャーなスタック分割方法
      • ネット上にも情報が多い
  • Cons

2. Nested Stackによるスタック分割

  • 概要
    • CloudFormationの機能であるNested Stackを利用する
      • おおまかにいうとスタックに親子関係を設定する仕組み
        • 親となるスタックの一部として子スタックを持つ (子は複数OK)
          • 親スタックは1つ・依存も1方向
    • 監視系リソースを子スタックに切り出し、親スタックからLambdaの情報を渡す
  • Pros
    • CloudFormationの公式ドキュメントにも書かれている分割方法
    • 設計に大幅な変更を加える必要がない
    • クロススタック参照よりも依存の制約が強く、比較して扱いやすいと思われる
  • Cons
    • 身の回りであまり使っているという話を聞かず、未知のハマりどころが潜んでいる可能性がある

3. Lambdaの関数名を使った参照

  • 概要
    • CDKのfromFunctionNameメソッドを使ってLambdaの関数名(文字列)をキーに別スタックから参照する
      • スタックに対して直接の依存が発生しない
    • 監視リソース用のスタックを作成し、その中でLambdaを参照する
  • Pros
    • 設計に大幅な変更を加える必要がない
    • 実装難易度は低そう
  • Cons
    • 全てのLambdaの関数名を手動で書く必要がある (自動化の余地があるかは要検討)
      • 文字列を扱う必要があるので安定性にも欠ける
    • Lambdaが増減するたびにメンテナンスが必要
      • 抜け漏れはテストを書けばチェックできる?

4. ビジネスドメインでのAPI分割

  • 概要
    • API群をビジネスドメインで分割
      • 分割したドメインごとにAPI Gatewayを設置してその単位にスタックを分割
        • マイクロサービス化ともいえる
      • 機能単位での分離となるため、監視系リソースだけの分離は行わない
  • Pros
    • 正しくドメイン分割できれば管理しやすさ・スケーラビリティが高い
  • Cons
    • 設計を見直すことになるので作業量が大きい
    • 分割による管理コストのオーバーヘッドが発生する可能性がある

5. 全API-1Lambda方式への移行

  • 概要
  • Pros
    • 監視系リソースに限らず管理するリソースを大幅に削減可能
  • Cons
    • 設計を見直すことになるので作業量が大きい
    • 1Lambdaあたりの細やかな制御が不可能 (コンテナやVMと同じレベルは可能)
      • 1LambdaあたりのCPU・メモリの最適化
      • 1LambdaあたりのIAM最小権限の実現

アプローチの決定

まず考えたのが、4. ビジネスドメインでのAPI分割です。ただし、現在のAPI群は既に適切な分割がなされており、これ以上の分割は難しそうということでNGとなりました。次に5.全API-1Lambda方式への移行でしたが、既存の設計に大きく影響があり、作業量も大きいという観点からNGとなりました。

続いて3.Lambdaの関数名を使った参照ですが、リソースを文字列として扱う必要があり、安定性に欠けるという観点からNGとしました。

1.クロススタック参照はCDKのスタック分割においてオーソドックスなアプローチではあるものの、やはりハマりどころの多さが不安要素です。また私の参画前に一度検討されたものの採用されなかったという情報も明らかになってきました。

2. Nested Stackによるスタック分割は、1.クロススタック参照と同じくスタックに依存関係を作る方法ですが、クロススタック参照に比べて依存の制約がキツく(親スタックは1つ・依存も1方向)、運用上致命的な状態にはならないだろうという判断をしました。

試験的にNested Stackを作成し、想定されるCRUD操作に問題がないことを確認した上で、最終的に2. Nested Stackによるスタック分割で進めることを決めました。

実装

実装の概要は以下の通りです。

  • スタック上の全Lambdaの抽出
    • 抽出されたLambdaをNested Stackへ注入
  • ファクトリーパターンの適用
    • 監視系リソースの作成を直接行うのやめ、ファクトリー経由で行うように変更

親スタック

// 監視系リソース付与の対象外となるLambdaのコンストラクトID
const UNMONITORED_LAMBDA_IDS = ['fooFunction'];

// スタックからLambdaのコンストラクトIDとログループを抽出
const lambdaResources = this.node
  .findAll()
  .filter(
    (child): child is NodejsFunction => child instanceof NodejsFunction,
  )
  .filter((item) => !UNMONITORED_LAMBDA_IDS.includes(item.node.id))
  .map((fn) => toLambdaIdWithLogGroup(fn));

// Nested Stackの作成
new MonitoringStack(this, 'Monitoring', {
      lambdaResources: lambdaResources,
    });

まずthis.node.findAll()でスタック上のリソースを配列として列挙し、1つ目の.filter(...)でそのうちのLambdaのみを抽出しています。

監視の対象外としたいLambdaも存在するため、そのLambdaについてはコンストラクトIDをあらかじめUNMONITORED_LAMBDA_IDSで宣言しておき、2つ目の.filter(...)で取り除きます。

これをtoLambdaIdWithLogGroup()でNestedStackで扱いやすい形に整形してnew MonitoringStack()時のpropsとして渡します。

toLambdaIdWithLogGroupの実装は以下です。

export type LambdaIdWithLogGroup = {
  lambdaId: string;
  logGroup: logs.ILogGroup;
};

export const toLambdaIdWithLogGroup = (
  fn: NodejsFunction,
): LambdaIdWithLogGroup => {
  return { lambdaId: fn.node.id, logGroup: fn.logGroup };
};

Nested Stack

interface MonitoringStackProps extends cdk.NestedStackProps {
  lambdaResources: LambdaIdWithLogGroup[];
}

export class MonitoringStack extends cdk.NestedStack {
  constructor(scope: Construct, id: string, props: MonitoringStackProps) {
    super(scope, id, props);

    for (const resource of props.lambdaResources) {
      createErrorAlarmForLambdaLogs(this, resource); // Errorログ用アラームのファクトリー
      createWarnAlarmForLambdaLogs(this, resource); // Warnログ用アラームのファクトリー
      createFirehoseForLambdaLogs(this, resource); // Firehoseのファクトリー
    }
  }
}

親スタックから渡された配列をループして、ファクトリー経由で監視系リソースを作成しています。課題となっていた監視系リソースの付け忘れに関しても、Nested Stackで機械的に付与するという方法で防ぐことができるようになりました。

適用結果

この実装を適用した結果、総リソース数が親スタック:485236個、Nested Stack:251個となりました。親スタックのリソース数を約半分にすることが出来ました (※)。親スタックのコード量に関しても、元のファイルはおよそ30%削減されました。

※ 親スタックとNested Stackのリソースの数を合計が元のリソース数よりも多いのは、Nested StackそのものがAWS::CloudFormation::Stackとして親スタック内に追加され、Nested Stack内にもAWS::CDK::Metadataが生成されるためです。

おわりに

本エントリでは、リソース上限に迫ったCDKスタックをNested Stackを用いて分割する方法を紹介しました。

Nested Stackと言えば、AWSの提供するソリューションの中でたまに使われているぐらいの印象しかなかったのですが、同僚のアドバイスからこの方法に落ち着きました。この場をお借りして感謝します。

インフラを担うCDKにおいて安定運用できるかは重要なポイントです。この改修は今年の夏頃に実施し、今のところ問題は発生していません。そのため監視系リソースのように親スタックに従属するリソースに関してはNested Stackを利用することは有効な手段であると考えられます。

この経験が他の開発者の参考になれば幸いです。