ECS のサービスディスカバリを導入した場合のデプロイ時の挙動の検証をしてみた

ECS Fargate で構築されたサービスに、ECSサービスディスカバリを導入した場合の、デプロイ時の挙動を検証してみました
2023.03.06

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちわ。西田@CX事業本部です

今回はECS Fargate で構築されたサービスに、ECSサービスディスカバリを導入した場合の、デプロイ時の挙動を検証してみました

ECSのサービスディスカバリについて

ECSのサービスディスカバリについてはこちらのブログを参照してください

https://dev.classmethod.jp/articles/ecs-service-discovery/

知りたいこと

  • リクエストが分散されているか?
  • デプロイ時の Route 53 レコードの登録、削除のタイミング
  • デプロイ時にリクエストが失敗するかどうか?
  • ヘルスチェックに失敗したコンテナの入れ替えは正常に行われるか?

構成

今回の検証用に構築したアプリケーションの全体構成です

インターネットからのトラフィックはALBが受け、Frontマイクロサービスが処理します。

リクエストを受けた Frontマイクロサービスは、Backマイクロサービスを呼び出します

その際に、Front マイクロサービスはサービスディスカバリが登録した Route 53 のDNSのレコードを参照して、バックエンドのECSタスクのIPを解決し、そのIPを利用して Back マイクロサービスにリクエストを送信します

今回の検証でポイントとなる設定は以下です

  • back ECSサービスの Disired Count は4を設定してます。通常時は4つのECSタスクが維持されます
  • back コンテナにはヘルスチェックが設定されており、HTTP Status 200 以外のレスポンスを返すと異常と判断され、サービスから切り離されます

CDKのソースコード

今回使用したCDKのソースコードはこちらです。Golang で作成された各マイクロサービスを含めた全てのソースは Github に Push しています

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import {
  Peer,
  Port,
  SecurityGroup,
  SubnetType,
  Vpc,
} from "aws-cdk-lib/aws-ec2";
import {
  ApplicationLoadBalancer,
  ApplicationProtocol,
  ApplicationTargetGroup,
  TargetType,
} from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as log from "aws-cdk-lib/aws-logs";
import * as servicediscovery from "aws-cdk-lib/aws-servicediscovery";
import { Duration } from "aws-cdk-lib";

export class FargateServiceDiscoveryExampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const SERVICE_NAME = "fsde-back";
    const NAMESPACE = "local";

    const vpc = new Vpc(this, "VPC", {
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: "public",
          subnetType: SubnetType.PUBLIC,
        },
      ],
    });

    const dnsNamespace = new servicediscovery.PrivateDnsNamespace(
      this,
      "ServiceDiscovery",
      {
        name: NAMESPACE,
        vpc,
      }
    );

    const frontSG = new SecurityGroup(this, "FrontSecurityGroup", {
      vpc,
    });

    const backSG = new SecurityGroup(this, "BackSecurityGroup", {
      vpc,
    });

    backSG.addIngressRule(frontSG, Port.tcp(1235));

    const backendTask = new ecs.FargateTaskDefinition(this, "BackendTask", {
      runtimePlatform: {
        cpuArchitecture: ecs.CpuArchitecture.ARM64,
      },
      memoryLimitMiB: 512,
      cpu: 256,
    });

    const backendContainer = backendTask.addContainer("BackendContainer", {
      image: ecs.ContainerImage.fromAsset("./back/"),
      logging: ecs.LogDriver.awsLogs({
        streamPrefix: "ecs-fsde-back",
        logRetention: log.RetentionDays.ONE_MONTH,
      }),
      healthCheck: {
        command: ["CMD-SHELL", "curl -f http://localhost:1235 || exit 1"],
        retries: 2,
        interval: Duration.seconds(30),
        timeout: Duration.seconds(15),
        startPeriod: Duration.seconds(5),
      },
    });

    backendContainer.addPortMappings({
      containerPort: 1235,
      hostPort: 1235,
    });

    const backCluster = new ecs.Cluster(this, "BackCluster", {
      vpc,
    });

    new ecs.FargateService(this, "BackendService", {
      cluster: backCluster,
      taskDefinition: backendTask,
      desiredCount: 4,
      assignPublicIp: true,
      enableExecuteCommand: true,
      cloudMapOptions: {
        name: SERVICE_NAME,
        cloudMapNamespace: dnsNamespace,
        dnsRecordType: servicediscovery.DnsRecordType.A,
        dnsTtl: Duration.seconds(30),
      },
      securityGroups: [backSG],
    });

    const securityGroupELB = new SecurityGroup(this, "SecurityGroupELB", {
      vpc,
    });

    securityGroupELB.addIngressRule(Peer.ipv4("0.0.0.0/0"), Port.tcp(80));

    const alb = new ApplicationLoadBalancer(this, "ALB", {
      vpc,
      securityGroup: securityGroupELB,
      internetFacing: true,
    });

    const listenerHTTP = alb.addListener("ListenerHTTP", {
      port: 80,
    });

    const targetGroup = new ApplicationTargetGroup(this, "TG", {
      vpc,
      port: 1234,
      protocol: ApplicationProtocol.HTTP,
      targetType: TargetType.IP,
      healthCheck: {
        path: "/health",
        healthyHttpCodes: "200",
      },
    });

    listenerHTTP.addTargetGroups("DefaultHttpResponse", {
      targetGroups: [targetGroup],
    });

    const cluster = new ecs.Cluster(this, "FrontCluster", {
      vpc,
    });

    const fargateTaskDefinition = new ecs.FargateTaskDefinition(
      this,
      "TaskDefinition",
      {
        memoryLimitMiB: 512,
        cpu: 256,
      }
    );

    const frontContainer = fargateTaskDefinition.addContainer(
      "frontContainer",
      {
        image: ecs.ContainerImage.fromAsset("./front/"),
        logging: ecs.LogDriver.awsLogs({
          streamPrefix: "ecs-fsde-front",
          logRetention: log.RetentionDays.ONE_MONTH,
        }),
      }
    );
    frontContainer.addPortMappings({
      containerPort: 1234,
      hostPort: 1234,
    });

    const frontService = new ecs.FargateService(this, "Service", {
      cluster,
      taskDefinition: fargateTaskDefinition,
      desiredCount: 1,
      assignPublicIp: true,
      securityGroups: [frontSG],
    });

    frontService.attachToApplicationTargetGroup(targetGroup);
  }
}

コンテナのヘルスチェック

コンテナのヘルスチェックにはECSのコンテナ定義パラメーターのヘルスチェックを利用します。 今回設定したヘルスチェックの内容は以下です

  • command [ "CMD-SHELL", "curl -f http://localhost:1235/" || exit 1 ]
    • バックエンドの “/” にアクセスしてエラーであればチェック失敗とみなします
  • interval 30秒
  • retries 2回

※ 補足ですが、筆者は M2 Mac を利用しビルドしていたので、実際にAWS環境で実行するときにヘルスチェックコマンドが実行できずはまってしまいました

ECS サービスのデプロイ設定

ECSサービスを利用してマイクロサービスを構築してるので、ECSサービスのデプロイの仕組みが適用されます

今回は Fargate + サービスディスカバリを採用してるので、ECSデプロイタイプは “ECS(ローリングアップデート)”のみが選択肢となります

デプロイ設定の Maximum Percent は、特に値を指定してないので、 REPLICA サービススケジューラのデフォルトの 200% が適用されてます

Desired Count に 4 を設定してるので、デプロイ時には旧ECSタスクが RUNNING 状態で4つ、新ECSタスクが PENDING 状態で4つの最大で8つ起動される可能性があります

起動中、もしくは、停止中のコンテナにリクエストが振り分けられないよう、新ECSタスクが RUNNING になれば Route 53 のレコードに登録され、旧ECSタスクが STOPPING になれば Route 53 のレコードが削除されるのが期待値です

デプロイする

CDKを使い環境を実際に構築し検証していきます

cdk deploy --profile ${AWS_PROFILE_NAME}

Route 53 に登録されたレコード

レコード名 タイプ IP TTL ルーティングポリシー
fsde-back.local Aレコード 10.0.0.125 60 複数値回答
fsde-back.local Aレコード 10.0.0.214 60 複数値回答
fsde-back.local Aレコード 10.0.0.109 60 複数値回答
fsde-back.local Aレコード 10.0.0.162 60 複数値回答

TTLに60秒が設定されてるので、DNSリゾルバや HTTP Client 側で最大 60 秒キャッシュされる可能性があります。停止中のコンテナのIPがキャッシュとして残ってしまい、起動中、もしくは、停止中のコンテナにリクエストしてしまうといったことが起こる可能性も考えられます。そのようなことが起こりうるのかは、このあと検証していきます

登録されたレコードのルーティングポリシーには、「複数値回答」が設定されています。そのため、正常な動作をするIPから最大8つのIPを、リゾルバ毎に異なる値で返却します

検証

これから、以下の検証していきます

  • リクエストが分散されているか?
  • デプロイ時のレコードの登録、削除のタイミング
  • デプロイ時にリクエストが失敗するかどうか?
  • ヘルスチェックに失敗したコンテナの入れ替えが正常に行われるか?

リクエストが分散されているか

Aレコードの結果がランダムに複数返ってくることが確認を取れたので、実際にリクエストをかけていきます

今回は JavaScript でシナリオが書ける k6 を使ってリクエストをかけていきます

シナリオファイル

import { check } from "k6";
import http from "k6/http";

export default function () {
  console.log(`https://${__ENV.HOST}`);
  const res = http.get(`http://${__ENV.HOST}`);
  check(res, {
    is_status_200: (r) => r.status === 200,
  });
}

ホスト名はそれぞれの環境に合わせて変更できるように環境変数から受け取るようにしています

コマンド

k6 run -u 10 --rps 10 -d 5m -e HOST=xxx.ap-northeast-1.elb.amazonaws.com stress.js

サンプルのコマンドは、秒間10リクエストを5分間かけています

リクエストをかけた結果を CloudWatch Logs Insight で集計します

クエリー

stats count() by task_id

結果

結果としては、多少偏りがあるものの、4つのECSタスクにリクエストが分散されていることが確認できました

デプロイ時のレコードの登録、削除のタイミング

デプロイ時に Route53 のレコードがどのタイミングで登録、もしくは削除されるか検証します

以下は左が Route53 のレコードに登録されてる Private IP、右がECSタスクに割り当てられる Private IPを時系列に並べたものです

デプロイが始まるとローリングアップデートが開始され、まず、起動中の50%のコンテナが終了処理に入り、同時に Route 53 のレコードが削除されています

また、同タイミングで新しいECSタスクが4つ起動されていますが、Route53のレコードには登録されていない状態です

19:31:08 Route53: ["10.0.1.51"] ["10.0.0.4"] ["10.0.1.164"] ["10.0.0.161"]  | ECS: 10.0.0.4 10.0.0.161 10.0.1.51 10.0.1.164 
19:31:10 Route53: ["10.0.1.51"] ["10.0.0.4"] ["10.0.1.164"] ["10.0.0.161"]  | ECS: 10.0.0.4 10.0.0.161 10.0.1.51 10.0.1.164 
19:31:13 Route53: ["10.0.1.51"] ["10.0.0.161"]  | ECS: 10.0.0.132 10.0.0.161 10.0.0.245 10.0.1.51 10.0.1.69 10.0.1.131

20秒ほど経ってから、新しく起動したECSタスクのIPが Route53 のレコードに登録されました

この時点で、元々 Running だったECSタスク2つと、新しく起動したECSタスク4つの、合計6つのRoute53のレコードが登録されました

19:31:27 Route53: ["10.0.1.51"] ["10.0.0.161"]  | ECS: 10.0.0.132 10.0.0.161 10.0.0.245 10.0.1.51 10.0.1.69 10.0.1.131 
19:31:30 Route53: ["10.0.1.131"] ["10.0.0.245"] ["10.0.1.51"] ["10.0.1.69"] ["10.0.0.161"]  | ECS: 10.0.0.132 10.0.0.161 10.0.0.245 10.0.1.51 10.0.1.69 10.0.1.131 
19:31:32 Route53: ["10.0.1.131"] ["10.0.0.245"] ["10.0.1.51"] ["10.0.0.132"] ["10.0.1.69"] ["10.0.0.161"]  | ECS: 10.0.0.132 10.0.0.161 10.0.0.245 10.0.1.51 10.0.1.69 10.0.1.131

最後に元々 Running だったECSタスクが2つ終了し、その直後に終了したECSタスクに対応する Route53 のレコードが削除されました

19:32:06 Route53: ["10.0.1.131"] ["10.0.0.245"] ["10.0.1.51"] ["10.0.0.132"] ["10.0.1.69"] ["10.0.0.161"]  | ECS: 10.0.0.132 10.0.0.161 10.0.0.245 10.0.1.51 10.0.1.69 10.0.1.131 
19:32:09 Route53: ["10.0.1.131"] ["10.0.0.245"] ["10.0.1.51"] ["10.0.0.132"] ["10.0.1.69"] ["10.0.0.161"]  | ECS: 10.0.0.132 10.0.0.245 10.0.1.69 10.0.1.131 
19:32:12 Route53: ["10.0.1.131"] ["10.0.0.245"] ["10.0.0.132"] ["10.0.1.69"]  | ECS: 10.0.0.132 10.0.0.245 10.0.1.69 10.0.1.131

結論としては、コンテナの状態がニアリアルタイムで Route53 のレコードに反映され、登録、削除されてることがわかりました

デプロイ時にリクエストがエラーにならないのか検証

以下は k6 でリクエストをかけ続けながら、デプロイを試した時にエラーになったリクエスト数です

22:42 分ごろからエラーが発生し、22:43 分を最後に発生しなくなっています

ECSサービスのイベントログを確認すると、大体同時刻にコンテナが2つ止まってることが確認できました

フロントマイクロサービスのエラーログを見ると、 connection refused エラーが発生しており、接続先である 10.0.0.18210.0.1.51 はECSサービスのイベントログから削除されたECSタスクであることが確認できました

ログを見るとエラーは約30秒ほど継続して発生しており、ECSタスク終了後でもDNSのキャッシュが残っており、古いECSタスクにリクエストしてしまってることが疑われます

そこでDNSレコードの TTL(Time to live)を 15 秒変更して試してみると、エラーは発生しなくなりました

ただ、15秒だとDNSへの問い合わせ回数が多くなり過ぎてしまう懸念があり、状況によってはエラーになってしまう可能性が残ってしまうので、極力エラーが発生しないように以下のブログを参考に、シャットダウン時にTTLに設定した秒数コンテナの終了をまつようにしました

ECS のアプリケーションを正常にシャットダウンする方法 | Amazon Web Services ブログ

#!/bin/sh

## Sigterm Handler
sigterm_handler() { 
  if [ $pid -ne 0 ]; then
    echo "start shutdown..."
    sleep 30 # DNSのTTLの時間分まつ
    kill -15 "$pid"
    wait "$pid"
  fi
  exit 143; # 128 + 15 -- SIGTERM
}

## Setup signal trap
trap 'sigterm_handler' SIGTERM
trap 'sigterm_handler' SIGINT # ローカル開発用にSIGINTもトラップする

## Start Process
"$@" &
pid="$!"

## Wait forever until app dies
wait "$pid"
return_code="$?"

exit $return_code

上記は参考のブログに掲載されていたシェルから以下の点を変更したものです

  • CloudWatch Logs にログを出力したいため、標準出力、標準エラーはファイルでなくそのまま entrypoint.sh の標準出力、標準エラーで出力するようにしました
  • 30秒待つようにしました。この値をCDKで設定してるECSサービスディスカバリのTTLに設定します
new ecs.FargateService(this, "BackendService", {
  // 省略
  cloudMapOptions: {
    // 省略
    dnsRecordType: servicediscovery.DnsRecordType.A,
      dnsTtl: Duration.seconds(30),
    },
  });
}

ヘルスチェックでエラーになるECSタスクが入れ替わるか検証

ヘルスチェックになったECSタスクが実施に入れ替わるか検証します

以下のずは k6 で負荷をかけ続けてる途中で、ECSタスクの一部をエラーを返すようにした時のエラー発生率です

22:05:00 ごろからエラーが発生し、22:07:20 頃にはエラーの発生が止まっています

その時のECSサービスのイベントのログをみると、ヘルスチェックに失敗したコンテナが終了して新しいコンテナが起動されてることがわかりました

コンテナのヘルスチェックはCDKで設定してますが、30秒 X 2 を設定してるので、約1分程度で復旧してるので想定通りと言えます

healthCheck: {
  command: ["CMD-SHELL", "curl -f http://localhost:1235 || exit 1"],
  retries: 2,
  interval: Duration.seconds(30),
  timeout: Duration.seconds(15),
  startPeriod: Duration.seconds(5),
},

最後に

検証に使った各種マイクロサービスのソース、CDK、検証のための環境のソースは、全て Github に Push しています。詳しく確認したい方はこちらもご参考ください

今回はECSサービスディスカバリのデプロイ時の検証をしてみました。少し工夫も必要な場面もありますが、手軽に使えて便利だと感じました。このブログが誰かの役に立てば幸いです

参考

サービス検出 - Amazon Elastic Container Service

aws-cdk/packages/@aws-cdk/aws-ecs at main · aws/aws-cdk

20190731_AmazonECS_DeepDive_AWSBlackBelt