いつかまたパタゴニアに

主にソフトウェア開発周りで気づいたことなどをまとめています

CDKでの機能フラグ導入方法

この記事はCDKアドベントカレンダー2025 23日目の記事です。

qiita.com

概要

CDKで破壊的変更を導入する際には機能フラグの実装が義務付けられています。

あまり機能フラグに着目した日本語の情報が無いと思い記事を作成しました。目的は以下の2つです。

  • コントリビュータ向けに実装方法をまとめる
  • 「そもそも機能フラグって何..??」というCDKユーザに向けて役割などをお伝えする

後者の読者の方は実装手順のセクションは読み飛ばして頂ければと思います。

イントロ

AWS Network Load Balancer(NLB)では2023年に待望のセキュリティグループ(SG)対応が導入されました。

大半のNLBユーザが切望していた機能で、これによりレガシーな「SG無しNLB」を使うメリットは存在しなくなりました。

dev.classmethod.jp

AWS CDKにおいてもリリース直後にSG設定が可能となりましたが、破壊的変更を避けるため、デフォルトでは従来のSGなしNLBが作成される状況でした。 しかし一度SGなしNLBを作成してしまうと、後からSG設定を追加することは不可能です。(NLBが置換されます!)

declare const vpc: ec2.IVpc;

// SGなしNLBが作成される
new elbv2.NetworkLoadBalancer(this, 'Nlb', {
  vpc,
});

// SGありNLBが作成される
new elbv2.NetworkLoadBalancer(this, 'Nlb', {
  vpc,
  securityGroups: [sg]イチイチSG指定するのは面倒
});

// 望ましい姿: デフォルトでSG付きNLBが作成される
new elbv2.NetworkLoadBalancer(this, 'Nlb', {
  vpc,
  // securityGroups: [sg] ← 明示的なSG設定を行わない
});

この状況を改善するため、機能フラグを利用して暗黙的なSG有効化設定をデフォルトの挙動とするPRを発行しました。マージまでに半年ほどかかりましたが、v2.221.1から無事にリリースされています。

ここからはPR作成過程を題材にCDKにおける機能フラグ実装のノウハウをまとめていきます。

github.com

機能フラグとは

CDKにおける機能フラグとは、破壊的変更を段階的に導入するための仕組みです。新規プロジェクトでは新しい動作がデフォルトとなりますが、既存プロジェクトでは従来の動作が維持され、明示的にフラグを有効化することで新機能を利用できます。

詳しい解説は既存の素敵な記事に委ねます。これ以上のことは書けません。

www.ogis-ri.co.jp

既存実装

まずは修正前の実装を振り返ります。

export class NetworkLoadBalancer extends BaseLoadBalancer implements INetworkLoadBalancer {
  ...
  this.connections = new ec2.Connections({
    securityGroups: props.securityGroups,
  });
  ...
}

一見分かりづらいコードですが、、、

NetworkLoadBalancerクラスはINetworkLoadBalancer経由でIConnectableを継承しており、connections経由でセキュリティグループへの穴あけを実行できます。以下のようなallowTo/From()にお世話になっている方は多いのではないでしょうか?これを実現しているのがIConnectableインターフェースです。

declare const instance: ec2.Instance;

const nlb = new elbv2.NetworkLoadBalancer(this, 'Nlb');
nlb.connections.allowTo(instance, ec2.Port.tcp(8080));

IConnectableを継承したクラスでは、this.connectionsにセキュリティグループを引数としたec2.Connectionsインスタンスを格納する必要があります。これが先ほどのCDK実装の正体です。

  this.connections = new ec2.Connections({
    securityGroups: props.securityGroups,
  });

ここで、ec2.ConnectionssecurityGroupsundefinedを渡すと、セキュリティグループは新規作成されず未設定状態となり、セキュリティグループがアタッチされないレガシーNLBが作成されます。 つまり、既存実装では明示的にprops.securityGroupsにセキュリティグループを渡さない限り、セキュリティグループ設定がなされたNLBが作成できない状況でした。

安直な修正方針

デフォルトでセキュリティグループを作成するようにするにはどうすればよいでしょうか? 最も単純なアプローチは、props.securityGroupsundefinedならばL2コンストラクト内でセキュリティグループを作ってしまう方法です。

export class NetworkLoadBalancer extends BaseLoadBalancer implements INetworkLoadBalancer {
  ...
  this.connections = new ec2.Connections({
    // props.securityGroupsがundefinedなら新規にSGを作成
    securityGroups: props.securityGroups ?? new ec2.SecurityGroup({...}),
  });
  ...
}

しかしこの実装を実際にリリースした場合、大変大きな問題が発生します。

例えば、あなたが既にSGなしのレガシーNLBをCDKで作成していたとしましょう。この状態でaws-cdk-libをアップグレードしてデプロイすると、全てのNLBはデフォルトでSG設定されたNLBに変更されるため、既存レガシーNLBが全て再作成されてしまいます。NLB再作成はアクセスURLのFQDN及びIP変更を伴いますので、稼働済みシステムが軒並み停止しうる阿鼻叫喚の嵐になることは想像に難くありません。

この問題を回避するため、機能フラグを実装することで、

  • (i)既存ユーザには変更が起こらない
  • (ii)新規ユーザにはセキュリティグループ設定されたNLBを提供する

という一石二鳥を図ってみましょう。

実装手順

1. 機能フラグの定義

まずはpackages/aws-cdk-lib/cx-api/lib/features.tsにフラグを追加します。このファイルには機能フラグの一覧が定数として定義されています。

export const NETWORK_LOAD_BALANCER_WITH_SECURITY_GROUP_BY_DEFAULT = '@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault';

機能フラグの名前は、そのフラグによりどんな変化が起こるかをつらつらとlowerCamelCaseで書き連ねればOKです。 @aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptionsなどなど長さは気にせず定義してしまいましょう。

合わせてfeatures.tsFLAGSにその機能フラグの定義を追記します。最下部に追記しないとrosettaでエラーとなるはずです (未検証)

export const FLAGS: Record<string, FlagInfo> = {

  ...(既存機能フラグの定義),

  //////////////////////////////////////////////////////////////////////
  [NETWORK_LOAD_BALANCER_WITH_SECURITY_GROUP_BY_DEFAULT]: {
    // ApiDefault ,BugFix, VisibleContext, Temporaryから選択。ほぼほぼApiDefault, BugFixのいずれかのはず。
    type: FlagType.ApiDefault,
    // 要旨
    summary: 'When enabled, Network Load Balancer will be created with a security group by default.',
    // 詳細説明
    detailsMd: `
      When this feature flag is enabled, Network Load Balancer will be created with a security group by default.
    `,
    // いつから導入されたか。V2NEXTでOK。リリース時にsedで置換されます。
    introducedIn: { v2: 'V2NEXT' },
    // 推奨値
    // 新規プロジェクト構築(cdk init)時にはこの値がcdk.jsonに設定される
    recommendedValue: true,
    // cdk.jsonに値が未設定時のデフォルト値
    // 既存ユーザがaws-cdk-libアップグレードしたときはこの値が用いられるため、既存の挙動を再現する値を設定する
    // 未設定の場合falseとなる。
    unconfiguredBehavesLike: { v2: false },
    // 従来の挙動を実現する方法
    compatibilityWithOldBehaviorMd: 'Disable the feature flag to create Network Load Balancer without a security group by default.',
  },

機能フラグはtrueが新しい挙動、falseが従来の挙動をするようにすることが推奨されています。 この方針に則る場合はrecommendedValue: trueのみを設定し、unconfiguredBehavesLikeは未定義で問題ありません。

github.com

(なぞの///////によるセクション区切りに一抹のクセを感じます)

2. 機能フラグのドキュメント追加

ここは正しい記述方法が未だに分からないところです。

まず、ドキュメントの記述先としてはREADME.mdまたはFEATURE_FLAGS.mdの2選択肢があり、公式ドキュメントでは前者が紹介されていますが、マージ実績では後者が大半となっています。時には両方に記載しているPRもあります。

更にFEATURE_FLAGS.mdは記述先のファイル候補が2つあり、どちらに書けば良いのかわかりません。

後述しますが、packages/aws-cdk-lib/cx-api/FEATURE_FLAGS.mdのみの修正でのマージ実績が複数ありますので、きっとそれで十分なのだと捉えています。

README.mdを修正する場合

packages/aws-cdk-lib/cx-api/README.mdに追記します。

* `@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault`

When this feature flag is enabled, Network Load Balancer will be created with a security group by default.

_cdk.json_

{
  "context": {
    "@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": true
  }
}

FEATURE_FLAGS.mdを修正する場合

packages/aws-cdk-lib/cx-api/FEATURE_FLAGS.mdまたはpackages/@aws-cdk/cx-api/FEATURE_FLAGS.mdに追記します。 個人的には前者のみに記載しています。

まずは一覧テーブルに概要を追加します。

| Flag | Summary | Since | Type |
| ----- | ----- | ----- | ----- |
| [@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault](#aws-cdkaws-elasticloadbalancingv2networkloadbalancerwithsecuritygroupbydefault) | When enabled, Network Load Balancer will be created with a security group by default. | V2NEXT | new default |

次にcdk.jsonでの記載例一覧に推奨値を追記します。

{
  "context": {
    ... (既存フラグ一覧),
    "@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": true,
}

最後に機能フラグのサマリを追記します。

### @aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault

*When enabled, Network Load Balancer will be created with a security group by default.*

Flag type: New default behavior

When this feature flag is enabled, Network Load Balancer will be created with a security group by default.


| Since | Unset behaves like | Recommended value |
| ----- | ----- | ----- |
| (not in v1) |  |  |
| V2NEXT | `false` | `true` |

**Compatibility with old behavior:** Disable the feature flag to create Network Load Balancer without a security group by default.

Sinceにはどのバージョンから導入されたかを記載しますが、PR時点ではV2NEXTと記載すればOKです。リリース時に該当バージョンに置換されます。

3. コンストラクト内での実装

ここが本丸です。実際のL2コンストラクト内で機能フラグをチェックし、値に応じて動作を分岐させます。 trueなら新しい挙動、falseなら従来通りの挙動を行うようにさせましょう。

import { FeatureFlags } from '../../core';

constructor(scope: Construct, id: string, props: NetworkLoadBalancerProps) {
  // このCDK Appに設定された機能フラグを取得
  const enableDefaultSg = FeatureFlags.of(this).isEnabled(
    cxapi.NETWORK_LOAD_BALANCER_WITH_SECURITY_GROUP_BY_DEFAULT
  );

  // フラグが有効ならデフォルトでセキュリティグループを作成 (新しい挙動)
  if (enableDefaultSg) {
    this.connections = new ec2.Connections({
      securityGroups: props.securityGroups ?? new ec2.SecurityGroup({...}),
    });
  // フラグが無効なら従来通りの挙動
  } else {
    this.connections = new ec2.Connections({
      securityGroups: props.securityGroups,
    });
  }
}

実装自体は非常にシンプルですね。

4. 単体テストの作成

機能フラグのtrue/false時それぞれにおけるcloudformationテンプレートの内容をテストします。

一般的なケースでは機能フラグ無効化時(既存の挙動)での単体テスト実装済みのはずなので、機能フラグを有効化した状態での単体テストを追加します。

      test('creates NLB with auto-generated security group', () => {
        app = new cdk.App({
          postCliContext: { // ここでAppに機能フラグを注入する
            '@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault': true,
          },
        
         // GIVEN
        // 機能フラグを有効化したApp配下のStackとして定義
        const stack = new cdk.Stack(app);
        const vpc = new ec2.Vpc(stack, 'Stack');

        // WHEN
        new elbv2.NetworkLoadBalancer(stack, 'LB', {
          vpc,
          internetFacing: true,
        });

        // THEN
        const template = Template.fromStack(stack);
      template.hasResourceProperties('AWS::ElasticLoadBalancingV2::LoadBalancer', {
          Scheme: 'internet-facing',
          SecurityGroups: [
            {
              'Fn::GetAtt': [
                Match.stringLikeRegexp('LBSecurityGroup.*'),
                'GroupId',
              ],
            },
          ],
          Type: 'network',
        });

        template.hasResourceProperties('AWS::EC2::SecurityGroup', {
          GroupDescription: Match.stringLikeRegexp('Automatically created Security Group for ELB.*'),
          VpcId: { Ref: Match.stringLikeRegexp('Stack.*') },
          SecurityGroupEgress: [{
            CidrIp: '255.255.255.255/32',
            Description: 'Disallow all traffic',
            FromPort: 252,
            IpProtocol: 'icmp',
            ToPort: 86,
          }],
        });
      });

機能フラグを有効化することで、無事にデフォルトでNLBにセキュリティグループが設定されていることが確認できました。

6. 統合テスト(integ test)の作成

実際のデプロイ動作を確認するため、integ testも作成します。 単体テスト同様、大抵のケースでは機能フラグが無効状態(すなわち従来の挙動)でのinteg testは存在しますので、機能フラグを有効化した状態でのinteg testを追加します。

const app = new App({
  // CDK App作成時にcontextとして機能フラグを設定する
  postCliContext: {
    '@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault': true, // 機能フラグを有効化
  },
});
const stack = new Stack(app, 'NetworkLoadBalancerSecurityGroupFlagStack');

new NetworkLoadBalancer(stack, 'NLB', {
  vpc: vpc,
  internetFacing: true,
});

new IntegTest(app, 'NetworkLoadBalancerSecurityGroupFlag', {
  testCases: [stack],
});

実際のPRではセキュリティグループ設定の検証用assertionを実装しています。 integ testでのassertionは修正内容に応じて必要性が変わってくると思いますので、適宜素敵なinteg testを実装してみてください。

github.com

まとめ

以上でCDKに機能フラグを導入することができました。 機能フラグは後方互換性を維持するため非常に重要な役割を果たしていることがご理解いただけたかと思いますが、一般的なCDKユーザにとってはあまり意識する機会が多くない存在だと思っています。(それはCDKの設計として素晴らしいことだと思います)

ただ、そんなマイナーな存在を実装するコントリビューションガイドなんてものはなかなかあるはずがありません。 ということで、今回は気合を入れて記事にしてみました。

「あ〜〜このCDKの振る舞いイケてなさすぎる。。俺色に染めてやりてぇ。。。」と感じてらっしゃる方がいらっしゃいましたら、ぜひぜひこちらを参考にPRを発行してみてください。

補足

機能フラグの立ち位置

こちらの記事でも紹介されている通り、本来機能フラグは最終奥義的な立ち位置ですので、安易な追加は避けるべきものです。

www.ogis-ri.co.jp

ただし機能フラグ無しでは実現できない変更が数多くあることは事実であり、今後も一定のペースで増加していくことは避けられないと感じています。 私個人の思いとしては、CDKでは後方互換性を確保するあまりデフォルトで非推奨な振る舞いをするケースが結構あるので、そういったもの関してはバシバシと機能フラグ導入で直してしまったほうが良いと考えています。

目下はcloudfront functionsのランタイム設定やCloudFrontのオリジンとしてのLambdaの関数URLのIPアドレスタイプについて、時代の変化に合わせたデフォルト値となるような修正を提案しています。きっと1年以内にはマージされる...といいな......

github.com

github.com

alphaモジュールにおける機能フラグ

GA前のCDK alphaモジュールでは破壊的変更が許容されているため、機能フラグの実装は必要ありません。 ただしPR本文に破壊的変更である旨を明記する必要があります。

BREAKING CHANGE: Corrected LogRetention IDs for DatabaseCluster. Previously, regardless of the log type, the string ‘objectObject’ was always included, but after the correction, the log type is now included.

github.com

GA前にゴリゴリに振る舞いを変えるPRを発行して、より磨かれたモジュールを作り上げていきましょう。