AWS

CDKで作成したVPCルートテーブルのルート情報の更新時に困った話

yuuchan

はじめに

こんにちは、yuuchanです。
今担当しているPJが始まった頃はまだ桜の花が咲いていましたが、もうコートが必要な程肌寒い季節に。
時が過ぎるのはあっという間ですね。

さて今回は、PJにおいてAWS CDKを使ってネットワーク構築を進める中であった、VPCルートテーブルのルート情報更新時のエラーとその解消方法について記そうかと思います。

VPCルートテーブルおよびルートのCDKによる実装

まず最初に、VPCルートテーブルとそれに関連付けるルートをどのように実装したかを提示します。(後述する問題が発生する実装です。)

// 〜前略〜
// VPCルートテーブルの作成
const routeTable01 = new ec2.CfnRouteTable(this, "RouteTable01", {
  vpcId: vpc.vpcId, // 作成済みのVPCを参照
  tags: [{ key: "Name", value: "RouteTable01" }],
});
routeTable01.node.addDependency(tgwAttachment); // 作成済みのTransit Gateway Attachmentを参照

const routeTable02 = new ec2.CfnRouteTable(this, "RouteTable02", {
  vpcId: vpc.vpcId,
  tags: [{ key: "Name", value: "RouteTable02" }],
});
routeTable02.node.addDependency(tgwAttachment);

// ルート情報の定義
const routeProps01: ec2.CfnRouteProps[] = [
  {
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.20.0/24",
    transitGatewayId: props.tgw.ref, // 作成済みのTransit Gatewayを参照
  },
  {
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.30.0/24",
    transitGatewayId: props.tgw.ref,
  },
  {
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.40.0/24",
    transitGatewayId: props.tgw.ref,
  },
];

const routeProps02: ec2.CfnRouteProps[] = [
  {
    routeTableId: routeTable02.ref,
    destinationCidrBlock: "192.168.50.0/24",
    transitGatewayId: props.tgw.ref,
  },
  {
    routeTableId: routeTable02.ref,
    destinationCidrBlock: "192.168.60.0/24",
    transitGatewayId: props.tgw.ref,
  },
  {
    routeTableId: routeTable02.ref,
    destinationCidrBlock: "192.168.70.0/24",
    transitGatewayId: props.tgw.ref,
  },
];

// ルートの作成
routeProps01.forEach((routeProp, i) => {
  new ec2.CfnRoute(this, `RouteTable01-${i}`, routeProp);
});

routeProps02.forEach((routeProp, i) => {
  new ec2.CfnRoute(this, `RouteTable02-${i}`, routeProp);
});

RouteTable01およびRouteTable02という2つのルートテーブルに関連付けるルートをそれぞれrouteProps01routeProps02で定義し、ループ処理の中でルートを作成するつくりになっています。

ルート情報の更新および発生したエラー

前節で提示した実装でルートテーブル等のリソースを新規デプロイを試みると、特に問題なくデプロイが完了します。
では、どのようなタイミングで問題が起きるのでしょうか。

問題は、既存ルートを変更・削除するタイミングで発生しました。
前節で提示したルートの定義を以下のように変更・削除します。

// ルート情報の定義
const routeProps01: ec2.CfnRouteProps[] = [
  {
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.20.0/24",
    transitGatewayId: props.tgw.ref,
  },
  // 「192.168.20.0/24」に対するルートを削除
  {
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.40.0/24",
    transitGatewayId: props.tgw.ref,
  },
];

const routeProps02: ec2.CfnRouteProps[] = [
  {
    routeTableId: routeTable02.ref,
    destinationCidrBlock: "192.168.50.0/24",
    transitGatewayId: props.tgw.ref,
  },
  {
    routeTableId: routeTable02.ref,
    destinationCidrBlock: "192.168.100.0/24", // 「192.168.60.0/24」から変更
    transitGatewayId: props.tgw.ref,
  },
  {
    routeTableId: routeTable02.ref,
    destinationCidrBlock: "192.168.70.0/24",
    transitGatewayId: props.tgw.ref,
  },
];

routeProps01の2番目に定義していたルート情報を削除し、routeProps02の2番目に定義していたルート情報を変更しています。

この状態でデプロイ(スタックの更新)を行うと以下のようなエラーが発生します。

17:31:57 | UPDATE_FAILED        | AWS::EC2::Route                    | RouteTable011
Resource handler returned message: "AlreadyExists" (RequestToken: 59c39762-9462-b994-11c5-bff258358022, HandlerErrorCode: AlreadyExists)

RouteTable02に関連付けているルートの変更については問題なく更新できましたが、RouteTable01に関連付けるルートのうち、2つめのルートを作成する際にAlreadyExistsとなっています。

問題点

では、どのような点が問題なのかを見ていきましょう。
変更前と変更後の、CDKコードから生成されたCloudFormation(以下、CFn)テンプレートを比較してみます。

# 変更前のCFnテンプレート(抜粋)
RouteTable010:
  Type: AWS::EC2::Route
  Properties:
    DestinationCidrBlock: 192.168.20.0/24
    RouteTableId:
      Ref: RouteTable01
    TransitGatewayId:
      Fn::ImportValue: MyTgwStack:ExportsOutputRefTgw1487DF50
  Metadata:
    aws:cdk:path: MyNetworkStack/RouteTable01-0
RouteTable011:
  Type: AWS::EC2::Route
  Properties:
    DestinationCidrBlock: 192.168.30.0/24
    RouteTableId:
      Ref: RouteTable01
    TransitGatewayId:
      Fn::ImportValue: MyTgwStack:ExportsOutputRefTgw1487DF50
  Metadata:
    aws:cdk:path: MyNetworkStack/RouteTable01-1
RouteTable012:
  Type: AWS::EC2::Route
  Properties:
    DestinationCidrBlock: 192.168.40.0/24
    RouteTableId:
      Ref: RouteTable01
    TransitGatewayId:
      Fn::ImportValue: MyTgwStack:ExportsOutputRefTgw1487DF50
  Metadata:
    aws:cdk:path: MyNetworkStack/RouteTable01-2
# 変更後のCFnテンプレート(抜粋)
RouteTable010:
  Type: AWS::EC2::Route
  Properties:
    DestinationCidrBlock: 192.168.20.0/24
    RouteTableId:
      Ref: RouteTable01
    TransitGatewayId:
      Fn::ImportValue: MyTgwStack:ExportsOutputRefTgw1487DF50
  Metadata:
    aws:cdk:path: MyNetworkStack/RouteTable01-0
RouteTable011:
  Type: AWS::EC2::Route
  Properties:
    DestinationCidrBlock: 192.168.40.0/24
    RouteTableId:
      Ref: RouteTable01
    TransitGatewayId:
      Fn::ImportValue: MyTgwStack:ExportsOutputRefTgw1487DF50
  Metadata:
    aws:cdk:path: MyNetworkStack/RouteTable01-1

CFnテンプレートではRouteTable010RouteTable011のような論理IDでテンプレート内のリソースを識別しています。

// ルートの作成
routeProps01.forEach((routeProp, i) => {
  new ec2.CfnRoute(this, `RouteTable01-${i}`, routeProp);
});

routeProps02.forEach((routeProp, i) => {
  new ec2.CfnRoute(this, `RouteTable02-${i}`, routeProp);
});

上記のように、各ルートテーブルに関連付けるルート情報を配列で定義しそのindexをルートのリソースIDに使用する実装にしていると、変更前と変更後で同一リソースとして管理されて欲しいルートの論理IDが変更されてしまい、結果別リソースとして管理されてしまいます。

CDKのコードで説明すると、

// 変更前のRouteTable01のルート情報
const routeProps01: ec2.CfnRouteProps[] = [
  { // ルートA
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.20.0/24",
    transitGatewayId: props.tgw.ref,
  },
  { // ルートB
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.30.0/24",
    transitGatewayId: props.tgw.ref,
  },
  { // ルートC
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.40.0/24",
    transitGatewayId: props.tgw.ref,
  },
];
// 変更後のRouteTable01のルート情報
const routeProps01: ec2.CfnRouteProps[] = [
  { // ルートA
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.20.0/24",
    transitGatewayId: props.tgw.ref,
  },
  // ルートBが削除された
  { // ルートC
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.40.0/24",
    transitGatewayId: props.tgw.ref,
  },
];

のように扱いたいところが、

// 変更後のRouteTable01のルート情報
const routeProps01: ec2.CfnRouteProps[] = [
  { // ルートA
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.20.0/24",
    transitGatewayId: props.tgw.ref,
  },
  { // ルートBが変更された
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.40.0/24",
    transitGatewayId: props.tgw.ref,
  },
  // ルートCが削除された
];

のような扱いとなってしまいます。

そのため、ルートCが削除される前にルートBの変更を行おうとして、一時的にルートB(変更後)とルートC(削除前)のdestinationCidrBlockが重複することになり、AlreadyExistsのエラーが発生してしまいました。

解決法

今回のような事象を回避するためにはどのような修正を行えば良いでしょうか。
答えはシンプルで、変更前と変更後で同一リソースとして扱いたいルートに対して、配列のindexを含む値ではなく、(変更前と変更後で)同じリソースIDを設定すれば解決します。

ルート情報の定義およびルートの作成処理を以下のように修正します。

// ルート情報の定義
interface RouteProps extends ec2.CfnRouteProps {
  routeId: string;
}

const routeProps01: RouteProps[] = [
  {
    routeId: "RT01-VPC01",
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.20.0/24",
    transitGatewayId: props.tgw.ref,
  },
  {
    routeId: "RT01-VPC02",
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.30.0/24",
    transitGatewayId: props.tgw.ref,
  },
  {
    routeId: "RT01-VPC03",
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.40.0/24",
    transitGatewayId: props.tgw.ref,
  },
];

const routeProps02: RouteProps[] = [
  {
    routeId: "RT02-VPC04",
    routeTableId: routeTable02.ref,
    destinationCidrBlock: "192.168.50.0/24",
    transitGatewayId: props.tgw.ref,
  },
  {
    routeId: "RT02-VPC05",
    routeTableId: routeTable02.ref,
    destinationCidrBlock: "192.168.60.0/24",
    transitGatewayId: props.tgw.ref,
  },
  {
    routeId: "RT02-VPC06",
    routeTableId: routeTable02.ref,
    destinationCidrBlock: "192.168.70.0/24",
    transitGatewayId: props.tgw.ref,
  },
];

// ルートの作成
routeProps01.forEach((routeProp) => {
  new ec2.CfnRoute(this, routeProp.routeId, routeProp);
});

routeProps02.forEach((routeProp) => {
  new ec2.CfnRoute(this, routeProp.routeId, routeProp);
});

ルート情報の各ルートの定義にrouteIdをもたせ、ルートを作成するときのリソースIDにその値を使用するようにします。
この実装をベースに前述と同様の変更・削除を行うと、以下のようになります。

// ルート情報の定義
interface RouteProps extends ec2.CfnRouteProps {
  routeId: string;
}

const routeProps01: RouteProps[] = [
  {
    routeId: "RT01-VPC01",
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.20.0/24",
    transitGatewayId: props.tgw.ref,
  },
  {
    routeId: "RT01-VPC03",
    routeTableId: routeTable01.ref,
    destinationCidrBlock: "192.168.40.0/24",
    transitGatewayId: props.tgw.ref,
  },
];

const routeProps02: RouteProps[] = [
  {
    routeId: "RT02-VPC04",
    routeTableId: routeTable02.ref,
    destinationCidrBlock: "192.168.50.0/24",
    transitGatewayId: props.tgw.ref,
  },
  {
    routeId: "RT02-VPC05",
    routeTableId: routeTable02.ref,
    destinationCidrBlock: "192.168.100.0/24",
    transitGatewayId: props.tgw.ref,
  },
  {
    routeId: "RT02-VPC06",
    routeTableId: routeTable02.ref,
    destinationCidrBlock: "192.168.70.0/24",
    transitGatewayId: props.tgw.ref,
  },
];

routeProps01の同一リソースとして扱いたいルートは、当然ではありますが変更前と変更後で同じリソースIDを持っています。
このような実装で、RouteTable01に関連づくルートのCFnテンプレートがどのようになるか見てみましょう。

# 変更前のCFnテンプレート(抜粋)
RT01VPC01:
  Type: AWS::EC2::Route
  Properties:
    DestinationCidrBlock: 192.168.20.0/24
    RouteTableId:
      Ref: RouteTable01
    TransitGatewayId:
      Fn::ImportValue: MyTgwStack:ExportsOutputRefTgw1487DF50
  Metadata:
    aws:cdk:path: MyNetworkStack/RT01-VPC01
RT01VPC02:
  Type: AWS::EC2::Route
  Properties:
    DestinationCidrBlock: 192.168.30.0/24
    RouteTableId:
      Ref: RouteTable01
    TransitGatewayId:
      Fn::ImportValue: MyTgwStack:ExportsOutputRefTgw1487DF50
  Metadata:
    aws:cdk:path: MyNetworkStack/RT01-VPC02
RT01VPC03:
  Type: AWS::EC2::Route
  Properties:
    DestinationCidrBlock: 192.168.40.0/24
    RouteTableId:
      Ref: RouteTable01
    TransitGatewayId:
      Fn::ImportValue: MyTgwStack:ExportsOutputRefTgw1487DF50
  Metadata:
    aws:cdk:path: MyNetworkStack/RT01-VPC03
# 変更後のCFnテンプレート(抜粋)
RT01VPC01:
  Type: AWS::EC2::Route
  Properties:
    DestinationCidrBlock: 192.168.20.0/24
    RouteTableId:
      Ref: RouteTable01
    TransitGatewayId:
      Fn::ImportValue: MyTgwStack:ExportsOutputRefTgw1487DF50
  Metadata:
    aws:cdk:path: MyNetworkStack/RT01-VPC01
RT01VPC03:
  Type: AWS::EC2::Route
  Properties:
    DestinationCidrBlock: 192.168.40.0/24
    RouteTableId:
      Ref: RouteTable01
    TransitGatewayId:
      Fn::ImportValue: MyTgwStack:ExportsOutputRefTgw1487DF50
  Metadata:
    aws:cdk:path: MyNetworkStack/RT01-VPC03

修正前と異なり、同一リソースとして扱いたいルートは変更前と変更後で同じ論理IDを持っていることが分かるかと思います。

修正後の実装であれば、routeProps01routeProps02の要素を変更・削除したとしても、問題なくデプロイ(スタックの更新が)が可能になりました。

おわりに

いかがでしたでしょうか。
CDKでは、複数のリソースをループ処理を用いて効率的に作成したい場面が多々あります。
その際にリソースIDを正しく設定しないと、意図せず別々のリソースが同一のリソースとして扱われてしまうことがあるため注意が必要ですね。

今後も、AWSの様々なネットワークリソースに触れる中で得た気づきを共有していければと思います。

AUTHOR
yuuchan
yuuchan
記事URLをコピーしました