AWS CDKのWeightedTargetGroupを使いEC2からECSへ段階的に移行を進める方法

こんにちは。ソーシャル経済メディア「NewsPicks」で主に検索システムを開発しております崔(ちぇ)です。

去年まで弊社の検索システムをEC2上に構築しておりました。今年にそれをコンテナ化しECSへ移行しました。コンテナ化に関しては前回の記事でまとめてますので、ご興味ありましたら是非読んでみてください。

tech.uzabase.com

EC2からECSへ移行することに限らず、あらゆる「AからBへ移行する」行為はスイッチを切り替えるように簡単ではありません。

仮に、昨日までAが100%担った処理を、今日からBが100%担うようにしたとしましょう。何かの考慮漏れがあり以下のようなエラーが発生した際に、その障害の影響範囲は全体にわたり、最悪の場合はしばらくサービスが使えなくなることだってあり得ます。

  • 想定より負荷が高まり新しいリクエストに対して全くレスポンスが返せない
  • 想定できなかったバグが発生し画面がロードされない

エンジニアとしても、エラー通知がなり続け、慌てて取り掛かることになると思います。考えただけでゾッとしませんか…?

このような怖い状況に遭遇しないためにも、移行は丁寧に行いたいです。まずは少量のリクエストだけを捌くようにし、徐々にその比率を高めることで段階的に移行した方が安全でしょう。

本記事では、EC2からECSへ段階的に移行する方法について述べようと思いますのでよろしくお願いします。

EC2からECSへ移行したい

今までユーザからの検索リクエストを以下のようにルーティングしていたとしましょう。

AWSのロードバランサーはユーザが設定したリスナールールに従い、指定のターゲットグループにルーティングします。

仮に以下のようなリスナールールを定義したとしたら、/search/a*にマッチするリクエストは全てTarget Group Aに紐づくEC2にルーティングされるはずです。

条件 アクション
ルーティング先 比率
/search/a* Target Group A 100%
/search/b* Target Group B 100%

複数のターゲットグループにリクエストを分散させよう

リスナールールのアクションには、複数のターゲットグループを指定することも可能です。

上の構成だと、/search/a*にマッチするリクエストはTarget Group AもしくはTarget Group A`に任意の割合でルーティングを行います。各ターゲットグループへのルーティング比率は我々が指定できます。デフォルトは1であり、0~1000の範囲内であれば何でも指定できます。

最初は以下のように100%対0%にすると思います。

条件 アクション
ルーティング先 比率
/search/a* Target Group A 100%
Target Group A` 0%
/search/b* Target Group B 100%
Target Group B` 0%

段階的に移行を進み、最終的には以下のようにルーティング率が反転されたリスナールールになると思います。

条件 アクション
ルーティング先 比率
/search/a* Target Group A 0%
Target Group A` 100%
/search/b* Target Group B 0%
Target Group B` 100%

ECSへのルーティングが100%になり、安定していることが確認できたらもうEC2は不要なので、ルールのアクションからEC2を削除しましょう。

条件 アクション
ルーティング先 比率
/search/a* Target Group A` 100%
/search/b* Target Group B` 100%

その後はインスタンスも削除でき、以下のような構成になるでしょう。

CDKで構築する

では、手を動かして実際に作ってみましょう!

一つのターゲットグループにルーティングするパターン

基本パターン(アクションとして設定したターゲットグループが1つ)は以下のように定義できます。

VPC、サブネット、セキュリティグループは既存のものを使うことを前提にしています。

import * as ec2 from "aws-cdk-lib/aws-ec2";

const vpc = ec2.Vpc.fromVpcAttributes(this, "vpc", {
    vpcId: "{vpcId}",
    availabilityZones: ["{region}"],
});
const subnet = ec2.Subnet.fromSubnetAttributes(this, "subnet", {
    subnetId: "{subnetId}",
    availabilityZone: "{availabilityZoneId}"
});
const securityGroup = ec2.SecurityGroup.fromSecurityGroupId(
    this,
    "securityGroup",
    "{securityGroupId}"
);

ロードバランサーとリスナーを作ります。

import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";

// ロードバランサー
const alb = new elbv2.ApplicationLoadBalancer(this, "alb", {
    loadBalancerName: "Alb",
    vpc: vpc,
    vpcSubnets: { subnets: [subnet] },
    securityGroup: securityGroup,
});
const listener = new elbv2.ApplicationListener(this, "alb-listener", {
    loadBalancer: alb,
    protocol: elbv2.ApplicationProtocol.HTTP,
});

次にターゲットグループを作ります。 最初のターゲットグループはEC2なので、targetTypeelbv2.TargetType.INSTANCEにする必要があります。

// ターゲットグループ
const targetGroupA = new elbv2.ApplicationTargetGroup(
    this,
    "target-group-a",
    {
        targetGroupName: "TargetGroupA",
        targetType: elbv2.TargetType.INSTANCE,
        vpc: vpc,
        port: 8000,
        protocol: elbv2.ApplicationProtocol.HTTP,
        protocolVersion: elbv2.ApplicationProtocolVersion.HTTP1,
        healthCheck: {...},
        deregistrationDelay: cdk.Duration.seconds(300),
    }
);

以下のルールに合わせてリスナールールを作ります。

条件 アクション
ルーティング先 比率
/search/a* Target Group A 100%

必ずデフォルトアクションを指定する必要があり、今回は一つのルーティング先を持つ一つのルールを作るので、それをデフォルトアクションとしても指定します。

// リスナールール
listener.addAction("listener-rule-default-action", {
    action: elbv2.ListenerAction.forward([targetGroupA]),
});
new elbv2.ApplicationListenerRule(this, "listener-rule-1", {
    priority: 9,
    listener: listener,
    conditions: [elbv2.ListenerCondition.pathPatterns(["/search/a*"])],
    action: elbv2.ListenerAction.forward([targetGroupA]),
});

ルーティングの割合はWeightedTargetGroupで実現できる

今作ったルールのアクションにTarget Group A`を追加しましょう。

条件 アクション
ルーティング先 比率
/search/a* Target Group A 100%
Target Group A` 0%

各ターゲットグループに何%のリクエストをルーティングするかは、ターゲットグループを生成する際に指定します。 わかりやすさのために、一つのアクションに指定するターゲットグループのweightを全て足したら1になるようにしました。

const targetGroupA = new elbv2.ApplicationTargetGroup(
    this,
    "target-group-a",
    {
        targetGroupName: "TargetGroupA",
        targetType: elbv2.TargetType.INSTANCE,
        vpc: vpc,
        port: 8000,
        protocol: elbv2.ApplicationProtocol.HTTP,
        protocolVersion: elbv2.ApplicationProtocolVersion.HTTP1,
        healthCheck: {...},
        deregistrationDelay: cdk.Duration.seconds(300),
    }
);
const weightedTargetGroupA = { targetGroup: targetGroupA, weight: 1 };

Target Group A`はECSなので、targetTypeelbv2.TargetType.IPにする必要があります。

const targetGroupAPrime = new elbv2.ApplicationTargetGroup(
    this,
    "target-group-a-prime",
    {
        targetGroupName: "TargetGroupAPrime",
        targetType: elbv2.TargetType.IP,
        vpc: vpc,
        port: 8000,
        protocol: elbv2.ApplicationProtocol.HTTP,
        protocolVersion: elbv2.ApplicationProtocolVersion.HTTP1,
        healthCheck: {...},
        deregistrationDelay: cdk.Duration.seconds(300),
    }
);
const weightedTargetGroupAPrime = { targetGroup: targetGroupAPrime, weight: 0 };

targetGroupweightをまとめたオブジェクトは elbv2.WeightedTargetGroupであり、これに合わせてリスナールールの作りも少し変える必要があります。

listener.addAction("listener-rule-default-action", {
    action: elbv2.ListenerAction.weightedForward([targetGroupA, targetGroupAPrime]),
});
new elbv2.ApplicationListenerRule(this, "listener-rule", {
    priority: 9,
    listener: listener,
    conditions: [elbv2.ListenerCondition.pathPatterns(["/search/a*"])],
    action: elbv2.ListenerAction.weightedForward([targetGroupA, targetGroupAPrime]),
});

これをデプロイすれば終わりです!

TIP: 設定ファイルを活用すればリリースが便利になる

本記事では、全ての設定値をコードに直接記述しました。しかし、ターゲットグループが多くなったり、0 → 10 → 20 → 40 → … 100% のように多くの段階を経て移行を進めてたら、本番コードを毎回修正することは面倒でリスキーです。

設定ファイルに以下のように定義し、本番のコードはそれを参照するだけにしておけば、ルーティング率を変更するのは設定ファイルの修正だけで済みます。

// configs.ts
const targetGroupConfigs = [
      {
         name: "TargetGroupA",
         port: 8000,
         targetType: elbv2.TargetType.INSTANCE,
         weight: 1,
      },
      {
         name: "TargetGroupAPrime",
         port: 8000,
         targetType: elbv2.TargetType.IP,
         weight: 0,
      },
      {
         name: "TargetGroupB",
         port: 8001,
         targetType: elbv2.TargetType.INSTANCE,
         weight: 1,
      },
      {
         name: "TargetGroupBPrime",
         port: 8001,
         targetType: elbv2.TargetType.IP,
         weight: 0,
      },
]

終わりに

最初は、移行をどうすればいいか悩んでおりました。開発環境だと連続して検索してみたとしても、本番に比べるとリクエスト量が少なすぎる(負荷が低すぎる)ので、正しく使用量を計測できません。つまり、これで検索システムが本番でもスムーズにリクエストを処理する十分なスペックになっているかの保証ができません。

実際のリリース時には念の為、既存の本番EC2のCPUやメモリー使用量を確認し、約2倍のスペックを設定しましたが、もしもを考えてかなり段階を分けてたものです。


10%を流してみて年末年始に何のエラーも発生せず過ごせたので、安心してリクエスト量を増やしていくことができました。そして無事にECS時代を迎え、EC2は気持ちよく廃止しました!

Page top