はじめに

以下の記事で、AWS でリージョン間フェイルオーバーが可能なディザスタリカバリ (DR) 環境を構築しました。

今回はもうひとつのパターンである、AWS Global Accelerator を使った構成を紹介します。要件は以下の通りで、NLB パターンの時と同じです。

  • AZ 間フェイルオーバーは自動
  • リージョン間フェイルオーバーは自動または手動
  • 静的 IP を公開できる
  • RTO を 30 分、 RPO を数分とする (かなりシビア)

検証リソースの構築は CDK で一撃化しています。

Multi-region stacks included VPC, ALB, EC2, and RDS - GitHub - nekrassov01/cdk-dr-sample at pt2-ga

なお、本記事では NLB パターンと被る部分は割愛します。また、最後に 2 つのパターンを比較します。

概要

構成としては、NLB ではなく Global Accelerator を使うことで DNS フェイルオーバーが不要になっています。

構成図

Global Accelerator は、AWS グローバルネットワークを利用してユーザーに最も近いリージョンのエンドポイントにトラフィックを転送することで通信レイテンシーを改善できる点が特徴です。

また、リージョン単位のロードバランサーのように動作することや、ユーザーに静的 IP を提供できることから DR の観点でも利用されます。トラフィックのルーティングに関しても、エンドポイントグループごと、およびエンドポイントごとに細かく制御できます。

今回は以下のような単純な構成です。リスナーは 443 と 80 を作り、それぞれ東京/大阪に振り分けます。HTTP から HTTPS へのリダイレクトは ALB で行います。都合 4 つのエンドポイントを作ることになり、これらは 2 つの ALB をリージョンごとに登録します。

GAのルーティング

注意点

Global Accelerator を使った場合の注意点は次の通りです。

  • エンドポイントに ALB, NLB を設定した場合はそのヘルスチェックがそのまま使われる
  • Global Accelerator は自動フェイルオーバーが前提

エンドポイントに ALB, NLB を設定した場合はそのヘルスチェックがそのまま使われる

ドキュメントに記載があります。

Global Accelerator で選択するヘルスチェックオプションは、エンドポイントとして追加したネットワークロードバランサーやアプリケーションロードバランサーには影響しません。
つまり、Global Accelerator で指定するヘルスチェックオプションは、Amazon EC2 と Elastic IP アドレスのヘルスチェックには使用されますが、ロードバランサーのエンドポイントのヘルスチェックには使用されません。

今回 ALB を関連づけた際は以下の挙動になることが確認できました。Global Accelerator の設定画面でヘルスチェックのパラメータを入力しても使われずに無視されます。

  • Auto Scaling Group のキャパシティをゼロにするとヘルスチェックに失敗する
  • ALB セキュリティグループのインバウンドを全拒否してもヘルスチェックは正常のまま

Global Accelerator は自動フェイルオーバーが前提

Global Accelerator は、片側のトラフィックダイヤルやウェイトをすべてゼロにした状態でも、ヘルスチェックが失敗した場合は自動で正常なエンドポイントにトラフィックがルーティングされます。このため、システム全体を特定の手順で手動フェイルオーバーさせたいといったケースには不向きです。

ドキュメントにフェイルオーバーのアルゴリズムについて記載があります。

エンドポイント・グループ内にウェイトが 0 より大きい健全なエンドポイントがない場合、Global Accelerator は、別のエンドポイント・グループ内のウェイトが 0 より大きい健全なエンドポイントへのフェイルオーバーを試みます。
このフェイルオーバーでは、Global Accelerator はトラフィックダイヤルの設定を無視します。そのため、たとえばエンドポイントグループのトラフィックダイヤルがゼロに設定されている場合でも、Global Accelerator はそのエンドポイントグループをフェイルオーバーの試行に含めます。

Global Accelerator は、最も近い 3 つのエンドポイントグループ(つまり、AWS リージョン)を試した後、0 より大きいウェイトを持つ健全なエンドポイントが見つからない場合、クライアントに最も近いエンドポイントグループ内のランダムなエンドポイントにトラフィックをルーティングします。つまり、オープンに失敗します。

構築

Global Accelerator を使った構成も CDK を使って一撃化することが可能です。

.
├── bin
│   └── main.ts
├── lib
│   ├── constructs
│   │   ├── accelerator.ts
│   │   ├── database.ts
│   │   ├── network.ts
│   │   ├── peering.ts
│   │   └── service.ts
│   ├── global-stack.ts
│   ├── peering-stack.ts
│   └── regional-stack.ts
├── scripts
│   └── helper
│       └── request.sh
├── src
│   └── ec2
│       ├── userdata-osaka.sh
│       └── userdata-tokyo.sh
├── test
│   └── aws-dr-sample.test.ts
├── LICENSE
├── README.md
├── cdk.context.json
├── cdk.example.json
├── cdk.json
├── jest.config.js
├── package-lock.json
├── package.json
└── tsconfig.json

ほとんどが NLB パターンと同じなので、主に Global Accelerator の部分を紹介します。

Global Accelerator

Global Accelerator の構築では、東京/大阪それぞれの ALB の情報を props で渡します。

流れ

  • Accelerator を作る
  • 東京側の ALB が設定されたエンドポイントを作る
  • 大阪側の ALB が設定されたエンドポイントを作る
  • Accelerator に HTTPS リスナーを追加する
  • HTTPS リスナーに東京側のエンドポイントが入ったエンドポイントグループを追加する
  • HTTPS リスナーに大阪側のエンドポイントが入ったエンドポイントグループを追加する
  • Accelerator に HTTP リスナーを追加する
  • HTTP リスナーに東京側のエンドポイントが入ったエンドポイントグループを追加する
  • HTTP リスナーに大阪側のエンドポイントが入ったエンドポイントグループを追加する
  • Route 53 にエイリアスレコードを登録する

前述の図の通りに設定するとこんなコードになります。

// lib/constructs/accelerator.ts
...

export interface AcceleratorProps {
  serviceName: string;
  globalDomainName: string;
  hostedZone: cdk.aws_route53.IHostedZone;
  albPrimary: cdk.aws_elasticloadbalancingv2.IApplicationLoadBalancer;
  albSecondary: cdk.aws_elasticloadbalancingv2.IApplicationLoadBalancer;
}

export class Accelerator extends Construct {
  constructor(scope: Construct, id: string, props: AcceleratorProps) {
    super(scope, id);

    // Global Accelerator
    const accelerator = new cdk.aws_globalaccelerator.Accelerator(this, "Accelerator", {
      acceleratorName: `${props.serviceName}-accelerator`,
      enabled: true,
      ipAddressType: cdk.aws_globalaccelerator.IpAddressType.IPV4,
    });
    accelerator.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY);

    // Endpoint for primary ALB
    const albPrimary = new cdk.aws_globalaccelerator_endpoints.ApplicationLoadBalancerEndpoint(props.albPrimary, {
      weight: 128,
      preserveClientIp: true,
    });

    // Endpoint for secondary ALB
    const albSecondary = new cdk.aws_globalaccelerator_endpoints.ApplicationLoadBalancerEndpoint(props.albSecondary, {
      weight: 128,
      preserveClientIp: true,
    });

    // Listener for HTTPS
    const listenerHTTPS = accelerator.addListener("ListenerHTTPS", {
      protocol: cdk.aws_globalaccelerator.ConnectionProtocol.TCP,
      portRanges: [{ fromPort: 443, toPort: 443 }],
      clientAffinity: cdk.aws_globalaccelerator.ClientAffinity.SOURCE_IP,
    });
    listenerHTTPS.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY);

    // Add primary ALB to endpoint group for HTTPS listener
    listenerHTTPS.addEndpointGroup("EndpointGroup1", {
      endpoints: [albPrimary],
      trafficDialPercentage: 100,
    });

    // Add secondary ALB to endpoint group for HTTPS listener
    listenerHTTPS.addEndpointGroup("EndpointGroup2", {
      endpoints: [albSecondary],
      trafficDialPercentage: 0,
    });

    // Listener for HTTP
    const listenerHTTP = accelerator.addListener("ListenerHTTP", {
      protocol: cdk.aws_globalaccelerator.ConnectionProtocol.TCP,
      portRanges: [{ fromPort: 80, toPort: 80 }],
      clientAffinity: cdk.aws_globalaccelerator.ClientAffinity.SOURCE_IP,
    });
    listenerHTTP.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY);

    // Add primary ALB to endpoint group for HTTP listener
    listenerHTTP.addEndpointGroup("EndpointGroup1", {
      endpoints: [albPrimary],
      trafficDialPercentage: 100,
    });

    // Add secondary ALB to endpoint group for HTTP listener
    listenerHTTP.addEndpointGroup("EndpointGroup2", {
      endpoints: [albSecondary],
      trafficDialPercentage: 0,
    });

    // Alias record for Global Accelerator
    const gaARecord = new cdk.aws_route53.ARecord(this, "ARecord", {
      recordName: props.globalDomainName,
      target: cdk.aws_route53.RecordTarget.fromAlias(new cdk.aws_route53_targets.GlobalAcceleratorTarget(accelerator)),
      zone: props.hostedZone,
    });
    gaARecord.node.addDependency(accelerator);
  }
}

スタックのデプロイ時に東京/大阪それぞれの ALB 情報を渡します。

// bin/main.ts
...
// Deploy tokyo stack
const tokyoStack = new DrSampleRegionalStack(app, "DrSampleRegionalStackTokyo", {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: "ap-northeast-1",
  },
  terminationProtection: false,
  serviceName: serviceName,
  area: "tokyo",
  cidr: "10.0.0.0/16",
  azPrimary: "ap-northeast-1a",
  azSecondary: "ap-northeast-1c",
  globalDatabaseIdentifier: globalDatabaseIdentifier,
  isPrimaryDatabaseCluster: true,
  hostedZoneName: hostedZoneName,
  globalDomainName: globalDomainName,
  userDataFilePath: "./src/ec2/userdata-tokyo.sh",
});

// Deploy osaka stack
const osakaStack = new DrSampleRegionalStack(app, "DrSampleRegionalStackOsaka", {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: "ap-northeast-3",
  },
  terminationProtection: false,
  serviceName: serviceName,
  area: "osaka",
  cidr: "10.1.0.0/16",
  azPrimary: "ap-northeast-3a",
  azSecondary: "ap-northeast-3c",
  globalDatabaseIdentifier: globalDatabaseIdentifier,
  isPrimaryDatabaseCluster: false,
  hostedZoneName: hostedZoneName,
  globalDomainName: globalDomainName,
  userDataFilePath: "./src/ec2/userdata-osaka.sh",
});

...

// Global Accelerator
const gaStack = new DrSampleGlobalStack(app, "DrSampleGlobalStack", {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  terminationProtection: false,
  crossRegionReferences: true,
  serviceName: serviceName,
  globalDomainName: globalDomainName,
  hostedZone: tokyoStack.hostedZone,
  albPrimary: tokyoStack.alb, // <= これ
  albSecondary: osakaStack.alb, // <= これ
});
...

フェイルオーバー検証

NLB パターンの時と同じスクリプトを使ってフェイルオーバーにかかる時間を計測します。

  • 東京側の ALB ターゲットグループから EC2 インスタンスをすべて解除してヘルスチェックを失敗させる (Global Accelerator では異常扱いになる)
  • curl を定期的に実行して RTO を計測
$ sh scripts/helper/request.sh https://your-domain.com
Request succeeded at Wed Jan 31 16:46:48 JST 2024
Request succeeded at Wed Jan 31 16:46:49 JST 2024
Request succeeded at Wed Jan 31 16:46:51 JST 2024
Request succeeded at Wed Jan 31 16:46:52 JST 2024
Request succeeded at Wed Jan 31 16:46:53 JST 2024
Request succeeded at Wed Jan 31 16:46:54 JST 2024
Request succeeded at Wed Jan 31 16:46:55 JST 2024
Request succeeded at Wed Jan 31 16:46:56 JST 2024
Request succeeded at Wed Jan 31 16:46:57 JST 2024
Request succeeded at Wed Jan 31 16:46:58 JST 2024
Request succeeded at Wed Jan 31 16:46:59 JST 2024
Request succeeded at Wed Jan 31 16:47:01 JST 2024
Request succeeded at Wed Jan 31 16:47:02 JST 2024
Request succeeded at Wed Jan 31 16:47:03 JST 2024
Request succeeded at Wed Jan 31 16:47:04 JST 2024
Request succeeded at Wed Jan 31 16:47:05 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:06 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:07 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:08 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:10 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:11 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:12 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:13 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:14 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:15 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:16 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:17 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:18 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:20 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:21 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:22 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:23 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:24 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:25 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:26 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:27 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:28 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:29 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:31 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:32 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:33 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:34 JST 2024
Request succeeded at Wed Jan 31 16:47:35 JST 2024
Request failed with HTTP code 503 at Wed Jan 31 16:47:36 JST 2024
Request succeeded at Wed Jan 31 16:47:37 JST 2024
Request succeeded at Wed Jan 31 16:47:38 JST 2024

レスポンスが戻ってくるまで 31 秒でした。かなり早いですね。

比較

NLB パターンと Global Accelerator パターンをいくつかの観点から比較します。

フェイルオーバーにかかる時間

  • DB のフェイルオーバー操作の時間は含まない
  • あくまで参考値
NLB パターン Global Accelerator パターン
2分程度 1分以下

手動フェイルオーバー

NLB パターン Global Accelerator パターン
可能 不可能

払い出される IP の数

NLB パターン Global Accelerator パターン
多い (AZの数 x リージョンの数) 少ない (2)

設定の容易さ

NLB パターン Global Accelerator パターン
面倒 (制約あり) NLBと比較すると簡単

ALB で受けた場合のクライアント IP 保持 (X-Forwarded-For)

NLB パターン Global Accelerator パターン
可能 可能

ちなみに料金に関しては、NLB パターンだと 2 台用意しなければならない点や、サービス間での課金体系に違いがある点を考えると、一概に NLB のほうが安いというわけではないように思います。ユースケースごとにシミュレーションが必要です。

おわりに

静的 IP を持つマルチリージョン DR 構成において、Global Accelerator を使ったパターンを注意点を踏まえながら紹介しました。今回の対応で AWS における DR 設計に対する知見が深まりました。