atama plus techblog
🤔

[AWS] CDKでVPCを後からマルチAZにしたらCIDRコンフリクトでハマった話

に公開

こんにちは! atamaplusでエンジニアをしているzussyです。

この記事では、AWS CDKで後からマルチAZに変更するときに起こった問題とその解決策についてご紹介します。

同じ課題に遭遇した方の参考になれば幸いです。

経緯

コスト削減のため、最初は1AZでVPCを構築していました。「後でマルチAZに変更すればいいや」と軽く考えていたのですが、実際に変更しようとしたらCIDRコンフリクトが発生してしまいました。

CIDR設計は事前にSREチームに相談して、3AZまで対応できるように設計してもらっていたのに、なぜコンフリクトが起きたのか...。調べてみると、CDKのVPCコンストラクトの仕様が原因でした。

この記事では、その問題と解決策についてまとめます。

マルチAZ構成に変更したい

「マネコンで操作する分には設定変更ページでポチポチできた(気がする)ので、CDKでも簡単にできるだろう」
そう思っていました。イメージはavailabilityZones[]の配列にazを増やしてcdk deployするだけ(またはmaxAzsを1→2へ)。
しかしそんな簡単な問題ではありませんでした。

のエラーが発生しました。「マルチAZにしようとしてサブネット作ろうとしたけど、すでにそのIP範囲は使っているから競合しているよ」と怒られたのです。なぜこうなったかを詳細に説明していきます。

※本記事で登場するCIDRは、説明しやすくするためのサンプル値です。
実際のネットワーク構成とは異なる値を使用しています。

1AZで構築していた理由

この時、下記のように実装していました

const vpc = new ec2.Vpc(this, "VPC", {
  ipAddresses: ec2.IpAddresses.cidr("10.1.0.0/19"),
  availabilityZones: ["ap-northeast-1a"],
  subnetConfiguration: [
    {
      name: "Public",
      subnetType: ec2.SubnetType.PUBLIC,
      cidrMask: 24,
    },
    {
      name: "PrivateSubnetWithEgress",
      subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
      cidrMask: 22,
    },
    {
      name: "PrivateSubnetIsolated",
      subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      cidrMask: 24,
    },
  ],
});

L2コンストラクトを使ったシンプルなVPCの設定です。
さらにここに書いている、ipAddresses(CIDR)や、各サブネットマスクはSREチームに設計してもらった内容を反映しています。各サブネットに作成されるリソースを考慮して3AZまでならIPアドレスが枯渇することはないような設定です。その上で、「今マルチAZにするとお金もかかるし、その時が来たら増やせば良いだろう」そう思って最初は1AZで構築していました。

問題が発生した

実際に1AZで構築した際のサブネット構成は以下の通りでした:

  • VPC全体: 10.1.0.0/19
  • Public: 10.1.0.0/24
  • PrivateSubnetWithEgress: 10.1.4.0/22
  • PrivateSubnetIsolated: 10.1.8.0/24

さて、時が経ち、マルチAZ構成に変更する必要が出てきました。availabilityZonesにAZを追加してcdk deployを実行すると、冒頭で紹介したエラーが発生しました。

The CIDR 'xx.xx.xx.x/xx' conflicts with another subnet

なぜこのエラーが発生したのでしょうか?

なぜCIDRコンフリクトが起きたのか

CDKのVPCコンストラクトは、AZを追加する際に自動的にサブネットのCIDRを割り当てます。しかし、この自動割り当てのロジックは、既存のサブネットのCIDR範囲と競合する可能性があります。
実際にissueもあり、まだ改善はされていないみたいです...

どこに実装されているか

CDKのVPC L2コンストラクトは完全に「現在の定義だけ」を元にCIDRを再計算するステートレスなロジックらしく、既存サブネットの実体は考慮されないみたいです。
ただ上記について公式の記述を見つけることができず、ライブラリの中身をちょっと覗いてみました。

(CDK v2 / aws-cdk-libパッケージ)
あたりにヒントがありそうです。
実際のソースコードを見ると、ip-addresses.tsでは以下のように実装されています:

import { NetworkBuilder } from "./network-util";

class Cidr implements IIpAddresses {
  private readonly networkBuilder: NetworkBuilder;

  constructor(private readonly cidrBlock: string) {
    this.networkBuilder = new NetworkBuilder(this.cidrBlock);
  }

  allocateSubnetsCidr(input: AllocateCidrRequest): SubnetIpamOptions {
    const allocatedSubnets: AllocatedSubnet[] = [];

    input.requestedSubnets.forEach((requestedSubnet, index) => {
      if (requestedSubnet.configuration.cidrMask === undefined) {
        // cidrMaskが未定義の場合の処理
      } else {
        allocatedSubnets.push({
          cidr: this.networkBuilder.addSubnet(
            requestedSubnet.configuration.cidrMask
          ),
        });
      }
    });

    return { allocatedSubnets };
  }
}

ポイントは、allocateSubnetsCidr()メソッドがinput.requestedSubnets(現在の定義)だけを見て、NetworkBuilder.addSubnet()を呼び出している部分です。既存のサブネットの実体(AWS上に実際に存在するサブネット)は一切参照されていません。

IpAddresses.cidr()を使う場合はNetworkBuilderが使われ、現在の定義(input.requestedSubnets)だけを元にCIDRを計算します。

つまり、AZ数を1から2に変更すると:

  1. input.requestedSubnetsに2AZ分のサブネット定義が含まれる(例:Public(1AZ目), Public(2AZ目), Private(1AZ目), Private(2AZ目), Isolated(1AZ目), Isolated(2AZ目))
  2. NetworkBuilderは、この順序でCIDRを割り当てようとする
  3. AZ数変更により1AZ目に再割り当てされようとしたCIDRが、既存のサブネットと競合してしまう

この時、既存のサブネットのCIDRは考慮されず、定義だけを元に全体を再計算するため、コンフリクトが発生するのです。

実際に起きたパターン

1AZで構築した時点では、以下のような割り当てになっていました:

  • Public: 10.1.0.0/24
  • PrivateSubnetWithEgress: 10.1.4.0/22
  • PrivateSubnetIsolated: 10.1.8.0/24

2AZ目を追加しようとすると、CDKは現在の定義(2AZ分)を元に、全体のCIDR割り当てを再計算します。この時、既存のサブネットの実体(実際にAWS上に存在するサブネット)は考慮されず、定義だけを見て「1AZ目にはこのCIDR、2AZ目にはこのCIDR」と割り当てようとします。

しかし、既に1AZ目のサブネットが存在しているため、AZ数変更により1AZ目に再割り当てされようとしたCIDRが、既存のサブネットと競合してしまうのです。

今回の場合、AZ数を変更するとCDKが定義から全体のCIDR割り当てを再計算するため、既存のサブネットのCIDR範囲と被ってしまう可能性があります。

解決策

既にCIDRコンフリクトが発生してしまった場合、残念ながらVPC自体を作り直すしかありません。

丸っと削除してしまって、マルチAZにして作り直すのが一番シンプルな方法です。
ただ、VPC自体の削除に伴い、それに紐づくリソースも影響を受けるのは間違いないので、一定のダウンタイムは必ず生じます。
さらにVPCの削除は結構面倒で、サブネットを削除しようとする時にそのサブネットにあるリソースにENIがアタッチされているとCDKが削除しようとする → 失敗 → リトライ(3回)を繰り返し、50分ほど時間がかけて、削除できずに終わるという悲惨なことになる場合もあります。(ここも書くと長くなりそうなのでまた別の機会に書きます)

https://repost.aws/ja/knowledge-center/lambda-delete-cloudformation-stack

本来どうすべきだったか

この問題を事前に防ぐには、VPC作成時にreservedAzsプロパティを使うことが有効です。

reservedAzsは、VPC作成時に将来使用するAZを予約しておくことで、サブネットのCIDR割り当てを事前に計画できるようにする機能です。

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2-readme.html#reserving-availability-zones

const vpc = new ec2.Vpc(this, "VPC", {
  ipAddresses: ec2.IpAddresses.cidr("10.1.0.0/19"),
  maxAzs: 1, // 実際に作成するAZ数(availabilityZonesと同時指定は不可)
  reservedAzs: 2, // 将来使用するAZを2つ予約(合計3AZ分のCIDRが計画される)
  subnetConfiguration: [
    {
      name: "Public",
      subnetType: ec2.SubnetType.PUBLIC,
      cidrMask: 24,
    },
    {
      name: "PrivateSubnetWithEgress",
      subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
      cidrMask: 22,
    },
    {
      name: "PrivateSubnetIsolated",
      subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      cidrMask: 24,
    },
  ],
});

reservedAzs: 2を指定することで、CDKは最初からmaxAzs + reservedAzs = 3AZ分のサブネットCIDRを計画して割り当てます。これにより、後からAZを追加する際にCIDRコンフリクトが発生しなくなります。

reservedAzsの実装について

reservedAzsがどのように動作するか、CDKのソースコード(vpc.ts)を確認してみます。

reservedAzsが指定されている場合、CDKはavailabilityZones配列にFAKE_AZ_NAMEというダミーのAZ名を追加します:

for (let i = 0; props.reservedAzs && i < props.reservedAzs; i++) {
  this.availabilityZones.push(FAKE_AZ_NAME);
}

そして、allocateSubnetsCidr()を呼び出す際に、maxAzs + reservedAzs分のサブネット定義がinput.requestedSubnetsに含まれます。これにより、CIDR割り当てはmaxAzs + reservedAzs分すべてに対して行われます。

一方、実際のリソース作成時には、FAKE_AZ_NAMEのAZに対してはリソースが作成されません

if (availabilityZone === FAKE_AZ_NAME) {
  // For reserved azs, do not create any resources
  return;
}

つまり、reservedAzsを指定すると、CIDR割り当てはmaxAzs + reservedAzs分すべてに対して行われるが、実際のリソース作成はmaxAzs分だけになります。これにより、後からmaxAzsを増やしても、既に予約されていたCIDR範囲が使われるため、コンフリクトが発生しません。

実際にreservedAzsを使った場合のCIDR割り当てを確認してみる

実際にreservedAzs: 2を指定して構築した場合、以下のようなCIDR割り当てになりました:

1AZ目(実際に作成されるサブネット)

  • Public: 10.1.0.0/24
  • PrivateSubnetWithEgress: 10.1.4.0/22
  • PrivateSubnetIsolated: 10.1.16.0/24 ← 注意:1AZ目でも10.1.8.0/24ではなく10.1.16.0/24が割り当てられました

2AZ目(予約されているが、まだ作成されていない)

  • Public: 10.1.1.0/24
  • PrivateSubnetWithEgress: 10.1.8.0/22
  • PrivateSubnetIsolated: 10.1.21.0/24

diffだとこんな感じです。↓

このように、reservedAzsを使うと、最初から複数AZ分のCIDRが計画されるため、サブネットの割り当てが変わります。1AZ目でも、将来的に3AZ分を考慮したCIDR割り当てが行われるため、既存の設計通りのCIDR(10.1.8.0/24)ではなく、10.1.16.0/24が割り当てられました。

後からreservedAzsを追加した場合

もし、最初にreservedAzsを指定せずに1AZで構築してしまった場合、後からreservedAzsを追加するとどうなるでしょうか?

この場合、既存のサブネットのCIDRが再割り当てされます
このときCDKでは新規サブネット作成 → 既存のサブネットを削除という手順を踏みます。結局サブネットの削除が発生するため、そこにあるリソース次第では削除に失敗する可能性があります。reservedAzsを指定してデプロイが通れば、その後のAZ追加(maxAzsを増やす)では、CIDRコンフリクトが発生しなくなりますが、できるだけVPC作成の時点で指定しておくのがベストでしょう。

まとめ

この記事では、AWS CDKでVPCを後からマルチAZに変更しようとした際に発生したCIDRコンフリクト問題と、その解決策について紹介しました。

CDKのVPCコンストラクトは、現在の定義だけを元にCIDRを再計算するステートレスなロジックのため、既存のサブネットの実体を考慮せずにCIDRを割り当てようとします。その結果、AZ数を変更すると既存のサブネットとCIDRが競合してしまう可能性があります。

reservedAzsプロパティを使うことで、VPC作成時に将来使用するAZを予約しておけます。これにより、最初から複数AZ分のCIDRが計画されるため、後からAZを追加してもコンフリクトが発生しません。

重要なポイント

  • 最初からreservedAzsを指定しておくことがベスト: 後から追加すると既存のサブネットが再作成される可能性があり、影響範囲が大きくなります
  • もしくは最初のVPC作成時点でマルチAZにしてしまう:金銭面が気にならないならこれが一番楽だと思います
  • CIDR設計だけでは不十分: 事前にCIDR設計をしていても、CDKの自動割り当てロジックによってコンフリクトが発生する可能性があります
  • reservedAzsを使うとCIDR割り当てが変わる: 1AZ目でも将来的なAZを考慮したCIDRが割り当てられるため、既存の設計と異なる可能性があります

VPC周りの設定変更は思っている以上に影響範囲が大きいので、最初から将来の拡張性を考慮した設計をしておくことが重要ですね。同じ問題に遭遇した方の参考になれば幸いです!

GitHubで編集を提案
atama plus techblog
atama plus techblog

Discussion