NFLabs. エンジニアブログ

セキュリティやソフトウェア開発に関する情報を発信する技術者向けのブログです。

AWS CDK 利用時に発生する Request limit exceeded の攻略法

研究開発部 システム&セキュリティ担当の松倉です。
いつもセキュリティ関係のネタを書くことが多いのですが、今回はインフラのお話です。

AWS 環境を IaC 化したい

クラウド上のインフラ環境を IaC (Infrastructure as Code) により構築する組織は増えてきていると思います。近年では、大規模言語モデル (LLM) などの生成 AI ツールが IaC のコードを理解・生成・改善できるようになり、インフラ定義の作成やミスの検出、自動デプロイのロジック生成まで支援してくれるようになりました。これにより小規模なチームでも高度なクラウド環境をコードで管理しやすくなり、スピードや一貫性の面で大きなメリットを享受できます。

IaC は一度構築すれば変更のトレースや同じ環境の再現において大きなメリットがありますが、我々のチームが AWS 上で構築・管理していたリソースは構成があまり頻繁に変化しないこともあり、これまで IaC による管理はしていませんでした。
しかし、最近になって、検証等のため環境を容易に削除・再作成できるようにしたいといったニーズが出てきました。加えて、IaC 化すれば、環境構成の変更履歴を Git で追跡でき、レビューやコラボレーションもしやすくなります。

個人的にコードによるインフラ管理には取り組みたいと思っていたこと、また昨今では Copilot や ChatGPT などの生成 AI の発展により IaC 化のハードルはだいぶ下がってきたこともあり、いい機会なので AWS 上のリソースをコード管理に移行してみました。

AWS CDK について

IaC を実現するツールはいくつかあり、AWS 環境に利用する場合は Terraform や CloudFormation などがメジャーです。その中で、今回の IaC 化では AWS CDK を採用しました。

AWS CDK (Cloud Development Kit)*1 は、コードベースから CloudFormation テンプレートを生成することにより、TypeScript や Python など馴染みのあるプログラミング言語を使って AWS 環境を IaC 化する技術です。
CDK で記述したコードは内部で CloudFormation テンプレートに変換され、デプロイ時には AWS CloudFormation が実際のリソース作成を行います。つまり CDK 自体は CloudFormation を利用するための開発者フレンドリーなレイヤであり、テンプレートを直接書く代わりにループや条件分岐を利用するなど、コードによる柔軟な記述が可能です。

Terraform は HCL という独自言語で記述するので、マルチクラウドに対応しているという強みはあるもののチームに新規導入する場合は学習コストが一定かかります。
CloudFormation は YAML 形式の静的テンプレートなので、比較的単純なテキストではありますがリソース数が多いと記述が手間になります。
その点、AWS CDK では普段から開発等で利用しているプログラミング言語を利用できるため、学習コストを抑えることができます。また、リソース定義の再利用性が高くなるといったメリットもあります。

CloudFormation と API リクエスト実行制限

CDK による IaC 化を進めていると、デプロイ時に Request limit exceeded というエラーに直面することがあります。これは CDK のデプロイ(実態は CloudFormation スタックの作成/更新)中に発生するエラーで、AWS の各サービス API に対するリクエスト数が一定時間内の上限を超えた場合にスローされるものです。

具体的には、CloudFormation が多くのリソースを同時に作成しようとした際にバックエンドで AWS API 呼び出しが頻発され、AWS 側で「レート超過(スロットリング)」としてリクエストが拒否されてしまう状況を指します。

具体例で説明

リモートからオンプレミスネットワークへアクセスするため、クライアントVPNを使って以下のような構成を組んだとします。

https://docs.aws.amazon.com/ja_jp/vpn/latest/clientvpn-admin/how-it-works.html より引用

上記構成では、ルーティングとアクセス制御を実現するために、以下の 3 つのリソースに CIDR を設定します。

  • VPCのルート定義
  • Client VPN Endpoint のルート定義
  • Client VPN Endpoint の承認ルール

CloudFormation はリソース間に依存関係がない場合、可能な限り並行してリソースを作成します
画像の構成では、オンプレミスの CIDR やアクセス制御ルールが比較的少ない場合はほとんど問題になりません。しかし、アクセス権を細かく制御するなどの要件がありルート定義や承認ルールが多数ある場合、デプロイ時に短時間で多数の API アクションがリクエストされるため、前述のエラーが発生します*2

解決方法

結論から述べると、以下の方法でエラーを回避できます。

  • NestedStack の利用
  • 各 NestedStack に明示的な依存関係を設定

下準備として、ルートを定義しておきます。CIDR を設定する 3 つのリソースすべてに共通でこのルート定義を用いることとします。

// ルート定義、必要な CIDR をすべて用意しておく
export interface RouteDefinition {
  cidr: string; 
}
export const routeDefinitions: RouteDefinition[] = [
  { cidr: 'xxx.xxx.xxx.xxx/x' },
  { cidr: 'yyy.yyy.yyy.yyy/y' },
  { cidr: 'zzz.zzz.zzz.zzz/z' },
  ...
]

以降では、これらのルートのみを CDK で定義・作成するサンプルを示します*3

NestedStack の利用

大量のリソースを 1 つのスタックに持たせると、前述の通りこれらのリソースが並行して作成されます。そこで、スタックを論理的に分割してネストされたスタックに振り分けます。CDK では NestedStack クラスを用いることで、親スタック内にネストスタック (NestedStack) を作成できます。

ネストスタック自体は CloudFormation 上では単一のリソース (AWS::CloudFormation::Stack) として扱われるため、親スタックのリソース数削減や並行度の調整に役立ちます。これにより、関連のあるリソースはネストスタックに振り分けることで、親スタックの作成/削除だけでネストスタックもまとめて作成/削除することができ、管理を簡素化できます。

import { Stack, StackProps, NestedStack, NestedStackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';

// 親スタック
export interface ParentStackProps extends StackProps {
  routeDefinitions: RouteDefinition[];
  routeTableId: string;
  vgwId: string
}
export class ParentStack extends Stack {
  constructor(scope: Construct, id: string, props: ParentStackProps) {
    super(scope, id, props);
    
    const { routeDefinitions, routeTableId, vgwId, } = props;

    routeDefinitions.forEach((routeDefinition, index) => {
      // VPCルートテーブルへのルート追加(オンプレミスCIDR向け)
      new ec2.CfnRoute(this, `Route${index}`, {
        routeTableId: routeTableId,
        destinationCidrBlock: routeDefinition.cidr,
        gatewayId: vgwId,
      });
    });
  }
}

// ネストスタック
export interface ChildStackProps extends NestedStackProps {
  routeDefinitions: RouteDefinition[];
  subnetId: string;
  cvpnEndpointId: string;
}
export class ChildStack extends NestedStack {
  constructor(scope: Construct, id: string, props: ChildStackProps) {
    super(scope, id, props);
    
    const { routeDefinitions, subnetId, cvpnEndpointId, } = props;

    routeDefinitions.forEach((routeDefinition, index) => {
      // Client VPN ルート設定:オンプレミスCIDR
      new ec2.CfnClientVpnRoute(this, `ClientVpnRoute${index}`, {
        clientVpnEndpointId: cvpnEndpointId,
        destinationCidrBlock: routeDefinition.cidr,
        targetVpcSubnetId: subnetId,
      });
      // Client VPN 承認ルール:オンプレミスアクセス許可
      new ec2.CfnClientVpnAuthorizationRule(this, `ClientVpnAuthRule${index}`, {
        clientVpnEndpointId: cvpnEndpointId,
        targetNetworkCidr: routeDefinition.cidr,
        authorizeAllGroups: true,
      });
    });
  }
}

各 NestedStack に明示的な依存関係を設定

ネストスタックは親スタックに依存するため、親スタックの作成完了後に作成が開始されます。しかし、ネストスタックが複数ある場合、各ネストスタックの作成は並行して実行されるため、結局短時間に大量の API リクエストが実行されてしまいます。
そこで、ネストスタック間に依存関係を設定 (addDependency) し、各ネストスタックの作成が一度に 1 つしか実行されないように制御します。

以下のコードサンプルでは、CDK のエントリーポイントにおいてスタックあたりのリソース数を定義し、ネストスタックが 1 つずつ作成されるよう依存関係を設定しています*4

import * as cdk from 'aws-cdk-lib';

const app = new cdk.App();

// スタック作成時のインプット値
// 本来は既存リソースからや別スタックから取得するとよい
const routeTableId = 'aaa'
const vgwId = 'bbb'
const subnetId = 'ccc'
const cvpnEndpointId = 'ddd'

// 1 つのネストスタックで作成するリソース数を定義
const chunkSize = 10;
const chunks: typeof routeDefinitions[] = [];
for (let i = 0; i < routeDefinitions.length; i += chunkSize) {
  chunks.push(routeDefinitions.slice(i, i + chunkSize));
}

// 親スタックの作成、インプットする値は別途取得しておく
const parentStack = new ParentStack(app, 'ParentStack', {
  routeDefinitions: routeDefinitions,
  routeTableId,
  vgwId,
});

// ネストスタックの依存関係設定用変数
let parent = parentStack

// ネストスタックの作成、インプットする値は別途取得しておく
chunks.forEach((defs, index) => {
  let nestedStack = new ChildStack(parentStack, `ParentStack-${index}`, {
    routeDefinitions: defs,
    subnetId,
    cvpnEndpointId,
  });
  if (index > 0) {
    nestedStack.addDependency(parent);    // 1 つ前に作成するスタックと依存関係を設定
  };
  parent = nestedStack;
});

app.synth();

デプロイにある程度時間はかかりますが、これで API リクエスト実行制限を回避してすべてのリソースを作成することができます。

おわりに

本記事では CloudFormation の裏側で発生する API コールの制限に起因するエラーと、その対処方法について紹介しました。
IaC 化の取組みを単一スタックに大量のリソースを詰め込んで一度にデプロイしようとすると、スロットリングにより失敗する可能性があります。しかし、CDK のネストスタック機能や明示的なデプロイ順序制御を活用することで、こうした問題に対処できます。IaC を推進する場合、AWS のサービスごとのクォータや制限に注意しつつ、設計を工夫して安定したデプロイを実現することが重要だと感じました。

これから IaC に取り組む方の参考になれば幸いです。

*1:オープンソースの開発フレームワーク - AWS Cloud Development Kit - AWS

*2:Amazon EC2 API のリクエストスロットリング - Amazon Elastic Compute Cloud に詳細が記載されています。CreateClientVpnRouteAuthorizeClientVpnIngress は最も制限が厳しい API アクションに分類され、本記事公開時点では 1 秒間に最大でも 5 回しかリクエストできません

*3:本来は VPC やサブネット、Client VPN Endpoint なども CDK で定義するか、既存リソースを取得すべきですが、本記事では省略しています

*4:Client VPN Endpoint のルート定義や承認ルールを作成する API アクションの最大バケットサイズは 5 ですが、実際には 10 でもエラーなく動作しました。AWS サポートから creating と pending を合わせて 10 が限度という回答を得たとの情報もあり、実際のリクエスト上限はバケットサイズよりやや大きいと考えられます