ECSのService Connectを試す記事

記事タイトルとURLをコピーする

前書き

CS1の石井です。

直近の案件でECSを使う構成を検討しており、ECSについて調査していました。

調査中に「Service Connectという設定が、いい感じにサービス同士の通信をやってくれる」という話を聞いて、実際に試して挙動を確認してみたいなぁと思いました。

本記事では自身の知識の整理も兼ねてService Connectを使ったアプリのサンプルで挙動を確認したいと思います。

本記事の趣旨

本記事ではService Connectを使ったECS + Fargateの構成で簡単なWebアプリを作成します。

フロントエンド(React)とバックエンド(Flask)のコンテナに分かれており、ALBにアクセスすればReactがFlaskの持っているデータを取得してページに表示するサンプルとなります。

ECSのサービス同士の接続がService Connectを使った場合どうなるか?という点を理解するところを目指します。

完成イメージ図

本記事で取り扱わないこと

  • FlaskとReactの細かいこと
  • サービスメッシュの概要

なお、AppMesh、CloudMap、Service Connectの概要やそれぞれの違いについて、下記のJAWS-UGの資料がわかりやすく、非常に参考になりました。

speakerdeck.com

対象読者

  • Service Connectを試してみたい人
  • ローカルでCDKが実行可能な状態の方

※手順は全てCDKで行いますが、コードはコピペするだけで問題ありません。 CDKを初めて使う方は、下記のCDKワークショップをお勧めします。ワークショップにCDKインストール手順も記載されているため、ぜひお試しください。

catalog.workshops.aws

デプロイ用のCDK作成

まずはCDKのプロジェクトを作成します。

nob@inspiron16:~/work/CDKWORK$ mkdir serviceconnect-sample ; cd $_
nob@inspiron16:~/work/CDKWORK/serviceconnect-sample$ npx cdk init --language typescript

ここからCDKを書いて行きますが、本記事では以下のディレクトリ構成で作業を進めたいと思います。

  • インフラ生成の用の資材
  • フロントエンドの資材
  • バックエンドの資材

フロントエンドのプロジェクト作成

フロントエンドはReact(NextJs)で表示のサンプルを作成します。

以下のコマンドを実行して新規プロジェクトとDockerfileを作成します。

nob@inspiron16:~/work/CDKWORK/serviceconnect-sample$ npx create-next-app --example with-typescript frontend
nob@inspiron16:~/work/CDKWORK/serviceconnect-sample$ cd frontend 
nob@inspiron16:~/work/CDKWORK/serviceconnect-sample/frontend$ touch Dockerfile

作成したカラのDockerfileには以下の記載を入れます。

FROM node:20-alpine

WORKDIR /app
COPY . /app

# npmのインストールとビルド
RUN npm install && \
    npm run build

# bashとcurlのインストール
RUN apk add --no-cache bash curl

ENV HOST 0.0.0.0
EXPOSE 3000
CMD ["npm", "run", "start"]

※DockerfileにはコンテナにExecしてcurlや/etc/hostsの内容を確認したいため、インストールを行っています。

次は表示するページの frontend/pages/index.tsx を以下の内容で編集します。

import Link from "next/link";
import Layout from "../components/Layout";
import { InferGetServerSidePropsType } from 'next';

export const getServerSideProps = async () => {
  try {
    const response = await fetch('http://backend-container.local:5000/api/message');
    if (!response.ok) {
      throw new Error('Network response was not ok.');
    }
    const data = await response.json();
    return {
      props: { message: data.message }, // フェッチしたメッセージをpropsとして渡す
    };
  } catch (error) {
    console.error('Error fetching data:', error);
    return {
      props: { message: 'Failed to fetch data' }, // エラー時のメッセージ
    };
  }
};

type Props = InferGetServerSidePropsType<typeof getServerSideProps>;

const IndexPage = ({ message }: Props) => {
  return (
    <Layout title="Home | Next.js + TypeScript Example">
      <h1>Hello Next.js 👋</h1>
      <p>
        <Link href="/about">About</Link>
      </p>
      <p>Message from Flask: {message}</p> {/* Flaskからのメッセージを表示 */}
    </Layout>
  );
};

export default IndexPage;

表示するサンプルページの内容に await fetch('http://backend-container.local:5000/api/message'); という部分がありますが、これはバックエンドのFlaskからデータを取得を行っています。

backend-container.localというドメインで接続を行っていますが、これはECSのプライベート空間で使用可能なドメイン名です。

フロントエンドの作成はこれで完了です。 いったんローカル環境でDockerfileを起動してみます。

nob@inspiron16:~/work/CDKWORK/serviceconnect-sample/frontend$ docker build -t frontend-sample -f Dockerfile .
nob@inspiron16:~/work/CDKWORK/serviceconnect-sample/frontend$ docker run --rm -p 3000:3000 frontend-sample:latest

以下のページがlocalhost:3000で表示できていれば成功です。

バックエンドのプロジェクト作成

次はバックエンドの部分を作成してみます。

nob@inspiron16:~/work/CDKWORK/serviceconnect-sample/frontend$ cd ../
nob@inspiron16:~/work/CDKWORK/serviceconnect-sample$ mkdir backend ; cd $_
nob@inspiron16:~/work/CDKWORK/serviceconnect-sample/backend$ touch Dockerfile app.py requirements.txt

Dockerfileの編集

Dockerfileは以下の内容で保存してください。

FROM python:3.12-alpine

# 作業ディレクトリを設定
WORKDIR /app

# 依存関係のファイルをコピー
COPY requirements.txt /app/

# 必要なパッケージをインストール
RUN apk add --no-cache gcc musl-dev curl bash

# 依存関係をインストール
RUN pip install --no-cache-dir -r requirements.txt

# アプリケーションのソースをコピー
COPY . /app

# コンテナ起動時にFlaskアプリケーションを実行
CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"]

app.pyの編集

from flask import Flask, jsonify
from flask_cors import CORS
import logging
from logging.handlers import RotatingFileHandler

# アプリケーションの設定
app = Flask(__name__)
CORS(app)  # すべてのオリジンからのアクセスを許可

# ロギングの設定
handler = RotatingFileHandler('flask_app.log', maxBytes=10000, backupCount=3)
handler.setLevel(logging.INFO)
app.logger.addHandler(handler)

@app.route('/healthcheck')
def healthcheck():
    app.logger.info('Health check accessed')
    return "OK", 200

@app.route('/api/message')
def get_message():
    app.logger.info('Message endpoint was accessed')
    return jsonify({'message': 'Hello from Flask!'})

if __name__ == '__main__':
    app.logger.info('Starting Flask application')
    app.run(debug=True, host='0.0.0.0', port=5000)

requirements.txtの編集

Flask
flask-cors

こちらも正常に配置できれば以下のコマンドを実行してローカルでビルドしてみます。

nob@inspiron16:~/work/CDKWORK/serviceconnect-sample/backend$ docker build -t backend-sample -f Dockerfile .
nob@inspiron16:~/work/CDKWORK/serviceconnect-sample/backend$ docker run --rm -p 5001:5000 backend-sample:latest
nob@inspiron16:~/work/CDKWORK/serviceconnect-sample/backend$ curl http://127.0.0.1:5000/api/message
{"message":"Hello from Flask!"}
nob@inspiron16:~/work/CDKWORK/serviceconnect-sample/backend$ 

curlのメッセージが表示されていれば問題ありません。

AWSのインフラ部分の作成

CDKで冒頭で記載したAWSのリソースを作成します。

lib/serviceconnect-sample-stack.tsに以下のように修正します。

import * as cdk from 'aws-cdk-lib';
import { RemovalPolicy } from 'aws-cdk-lib';
import { Port, SecurityGroup } from 'aws-cdk-lib/aws-ec2';
import { Cluster, FargateService, FargateTaskDefinition, LogDrivers } from 'aws-cdk-lib/aws-ecs';
import { LogGroup } from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';

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


    // LogGroup作成
    const logGroup = new LogGroup(this, 'WebAppLogGroup', {
      logGroupName: 'ecs-web-app',
      removalPolicy: RemovalPolicy.DESTROY,
    });

    // ECSクラスター作成
    const cluster = new Cluster(this, 'EcsCluster', {
      clusterName: 'ecs-web-app-cluster',
    });

    // SecurityGroup作成
    const alb_sg = new ec2.SecurityGroup(this, 'AlbSg', {
      vpc: cluster.vpc,
    });
    const frontend_sg = new SecurityGroup(this, 'FrontendSg', {
      vpc: cluster.vpc,
    });
    const backend_sg = new SecurityGroup(this, 'BackendSg', {
      vpc: cluster.vpc,
    });

    // ALB作成
    const alb = new elbv2.ApplicationLoadBalancer(this, 'ALB', {
      vpc: cluster.vpc,
      securityGroup: alb_sg,
      internetFacing: true
    });
    const listenerHTTP = alb.addListener('ListenerHTTP', {
      port: 80,
    });

    // TargetGroup作成
    const frontendTargetGroup = new elbv2.ApplicationTargetGroup(this, "TG", {
      vpc: cluster.vpc,
      port: 3000,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targetType: elbv2.TargetType.IP,
      healthCheck: {
        path: '/',
        healthyHttpCodes: '200',
      },
    });
    listenerHTTP.addTargetGroups('DefaultHTTPSResponse', {
      targetGroups: [frontendTargetGroup]
    });



    const namespace = cluster.addDefaultCloudMapNamespace({
      name: 'local',
    });
    namespace.applyRemovalPolicy(RemovalPolicy.DESTROY);

    // フロントエンドのタスク定義を記載
    const frontendTaskdef = new FargateTaskDefinition(this, 'FrontendTaskDefinition');
    frontendTaskdef.addContainer('frontend', {
      image: ecs.ContainerImage.fromAsset("./frontend/"),
      portMappings: [
        {
          name: 'frontend',
          hostPort: 3000,
          containerPort: 3000,
        },
      ],
      // ヘルスチェックの設定
      healthCheck: {
        command: [
          "CMD-SHELL",
          "curl -f http://localhost:3000 || exit 1"
        ],
        interval: cdk.Duration.seconds(30), // チェックの間隔
        timeout: cdk.Duration.seconds(5),  // レスポンスのタイムアウト
        retries: 3,                        // リトライ回数
        startPeriod: cdk.Duration.seconds(10) // コンテナ起動後のバッファ時間
      }
    });

    // バックエンドのタスク定義を記載    
    const backendTaskdef = new FargateTaskDefinition(this, 'BackendTaskDefinition');
    backendTaskdef.addContainer('backend', {
      image: ecs.ContainerImage.fromAsset("./backend/"),
      portMappings: [
        {
          name: 'backend',
          hostPort: 5000,
          containerPort: 5000,
        },
      ],
      // ヘルスチェックの設定
      healthCheck: {
        command: [
          "CMD-SHELL",
          "curl -f http://localhost:5000/healthcheck || exit 1"
        ],
        interval: cdk.Duration.seconds(30), // チェックの間隔
        timeout: cdk.Duration.seconds(5),  // レスポンスのタイムアウト
        retries: 3,                        // リトライ回数
        startPeriod: cdk.Duration.seconds(10) // コンテナ起動後のバッファ時間
      }
    });

    // フロントエンドのサービス定義を記載
    const frontend_service = new FargateService(this, 'FrontendService', {
      cluster: cluster,
      taskDefinition: frontendTaskdef,
      serviceName: 'frontend-service',
      serviceConnectConfiguration: {
        services: [
          {
            portMappingName: 'frontend',
            port: 3000,
            discoveryName: 'frontend-container'
          },
        ],
        logDriver: LogDrivers.awsLogs({
          logGroup: logGroup,
          streamPrefix: 'frontend-traffic',
        }),
      },
      securityGroups: [frontend_sg],
      enableExecuteCommand: true,
    });


    // バックエンドのサービス定義を記載    
    const backend_service = new FargateService(this, 'BackendService', {
      cluster: cluster,
      taskDefinition: backendTaskdef,
      serviceName: 'baackend-service',
      serviceConnectConfiguration: {
        services: [
          {
            portMappingName: 'backend',
            port: 5000,
            discoveryName: 'backend-container'
          },
        ],
        logDriver: LogDrivers.awsLogs({
          logGroup: logGroup,
          streamPrefix: 'backend-traffic',
        }),
      },
      securityGroups: [backend_sg],
      enableExecuteCommand: true,
    });

    // スケーリンググループを設定
    const autoscaling_frontend = frontend_service.autoScaleTaskCount({ maxCapacity: 5 });
    autoscaling_frontend.scaleOnMemoryUtilization('FrontEndScalingOnCpu', {
      targetUtilizationPercent: 20,
      scaleInCooldown: cdk.Duration.seconds(60),
      scaleOutCooldown: cdk.Duration.seconds(60)
    });
    const autoscaling_baackend = backend_service.autoScaleTaskCount({ maxCapacity: 5 });
    autoscaling_baackend.scaleOnMemoryUtilization('BackEndScalingOnCpu', {
      targetUtilizationPercent: 20,
      scaleInCooldown: cdk.Duration.seconds(60),
      scaleOutCooldown: cdk.Duration.seconds(60)
    });

    // ALB-SGはanyで80を許可
    alb_sg.connections.allowFromAnyIpv4(ec2.Port.tcp(80), 'Allow inbound HTTP');
    // フロントエンドSGはALBから3000を許可する
    frontend_sg.connections.allowFrom(alb_sg, Port.tcp(3000), 'Allow from alb');
    // バックエンドSGはフロントエンドから5000を許可する
    backend_service.connections.allowFrom(frontend_sg, Port.tcp(5000), 'Allow from Frontend');


    // .localの名前解決を行うためバックエンドの作成が完了してからフロントエンドサービスの作成を行う
    frontend_service.node.addDependency(namespace);
    backend_service.node.addDependency(namespace);
    frontend_service.node.addDependency(backend_service);


    // 冒頭で作成したALBにフロントエンドのサービスをアタッチする
    frontend_service.attachToApplicationTargetGroup(frontendTargetGroup);

  }
}

何を作成しているかはコメントで記載していますが、注目すべきはサービスの構成部分だと思います。

    // バックエンドのサービス定義を記載    
    const backend_service = new FargateService(this, 'BackendService', {
      cluster: cluster,
      taskDefinition: backendTaskdef,
      serviceName: 'baackend-service',
      serviceConnectConfiguration: {
        services: [
          {
            portMappingName: 'backend',
            port: 5000,
            discoveryName: 'backend-container'
          },
        ],
        logDriver: LogDrivers.awsLogs({
          logGroup: logGroup,
          streamPrefix: 'backend-traffic',
        }),
      },
      securityGroups: [backend_sg],
      enableExecuteCommand: true,
    });

上記のコードがECSサービスを定義していますが、 discoveryName: 'backend-container' という部分でECSのプライベート空間だけ名前解決可能な「backend-container.local」という名前を定義しています。

フロントエンドのindex.tsxの部分でも const response = await fetch('http://backend-container.local:5000/api/message'); というコードでFlaskへのデータアクセスを実現しています。

従来は別サービスのコンテナと通信する際はALBを挟む必要がありましたが、Service Connectを使えばこれらのインフラリソースが不要になります。

CDKのデプロイ

npx cdk deploy コマンドで早速デプロイしてみます。

正常にデプロイされればALBが作成されているので、ALBのDNS名でアクセスを行ってみます。

ローカルで実行したときはbackendの通信ができなかったのでMessage from Flask: の部分がエラーとなっていましたが、正常に接続ができているようです。

デプロイ後のリソースの確認

おそらくここまでは特段問題なくデプロイできたと思います。

では実際にデプロイされたECSはどうなっているか確認してみます。

サービス

サービスは以下のようにフロントエンドとバックエンドで二つのサービスが作成されています。

フロントエンドのタスクを覗いてみると、frontendというコンテナのほかに「ecs-service-connect...」というコンテナが確認できます。

このコンテナがサービス同士を通信させるためのProxyの役割を持ちます。

ドキュメントによると、以下の記載があります。

Amazon ECS サービスが Service Connect 設定を使用して作成または更新された場合、新しいタスクが開始されるたびに、Amazon ECS はタスクに新しいコンテナを追加します。別のコンテナを使用するこのパターンは、sidecar と呼ばれます。

Service Connectを使用する設定が有効であれば、自動的にService Connect エージェントのコンテナがデプロイされるということのようです。 Service Connectエージェントに関してはgithubのreadmeをご確認ください。

作成順序について

このService Connectには明確に作成の順序があるとドキュメントに記載があります。

まず、パブリックインターネット上で利用可能なアプリケーションを 1 つの AWS CloudFormation テンプレートと 1 つの AWS CloudFormation スタックで作成していると仮定します。AWS CloudFormation がパブリック検出と到達可能性を作成するのは、フロントエンドクライアントサービスを含めて、最後にする必要があります。フロントエンドクライアントサービスが実行されていて一般に公開されているのに、バックエンドは公開されていないという期間の発生を避けるために、サービスの作成はこの順序で行う必要があります。これにより、その期間中にエラーメッセージが一般に送信されるのを防ぐことができます。AWS CloudFormation では dependsOn を使用して、複数の Amazon ECS サービスを並列または同時に作成できないことを AWS CloudFormation に示す必要があります。クライアントタスクが接続するバックエンドクライアント/サーバーサービスごとに、フロントエンドクライアントサービスに dependsOn を追加する必要があります。

上記の内容ですが、フロントエンドクライアントサービスがバックエンドサービスに依存している場合、フロントエンドがデプロイされる前にバックエンドがデプロイされる必要があると記載されています。

これは単純にフロントエンドが公開されてもバックエンドに接続できず、フロントエンドのコンテナでエラーが発生するためです。

そのため依存関係を表現する必要があり、ドキュメントではCloudFormationのdependsonを使って順序性を表現しています。

今回はCDKを使っているため、dependsonに相当する「addDependecy」を使います。

使用したサンプルコードでは以下の記載が該当します。

    // .localの名前解決を行うためバックエンドの作成が完了してからフロントエンドサービスの作成を行う
    frontend_service.node.addDependency(namespace);
    backend_service.node.addDependency(namespace);
    frontend_service.node.addDependency(backend_service);

ECSのコンテナ同士の通信関係の表現

再度上記の公式ドキュメントの内容の再掲となりますが、フロントエンドはバックエンドありきの存在となります。

パブリック検出と到達可能性を作成するのは、フロントエンドクライアントサービスを含めて、最後にする必要があります。フロントエンドクライアントサービスが実行されていて一般に公開されているのに、バックエンドは公開されていないという期間の発生を避けるために、サービスの作成はこの順序で行う必要があります。

今回の構成ではフロントとバックエンドのタスク定義を2つ定義し、サービスが2つ作成される構成となります。

サービスが2つになると、当然サービス同士を繋ぐ何かしらのリソースが必要になります。

以前はALBを間に挟む構成が一般的でしたが、バックエンドが増えるたびにALBのターゲットグループを追加しなければならないという手間がありました。

例えばバックエンドが高負荷になり、水平スケールした時、何かしらの方法でALBに増えたコンテナを紐づける必要がありました。

そこでService Connectはサービス同士を接続するProxyとなるコンテナ(ecs-service-connect)を自動で作成、プロキシ同士で接続させ プロキシはCloudMapに登録されたドメインから各コンテナリソースの名前解決を行ういった構図です。

バックエンドコンテナの名前解決はどうなっているか?

フロントエンドからバックエンドのコンテナへの通信といったシナリオで考えた時、以下のような経路になるはずです。

frontend(React) → frontendのecs-service-connect → backendのecs-service-connect → backend(Flask)

ここで疑問になるのは仮にbackendのコンテナが水平スケールした場合、誰が増えたコンテナのIPを通信対象として紐づけるか?という点です。

この点はCloudMapが担うという予測ですが、どのような動きになるか実際に動かして確認してみます。

マネジメントコンソールから動作を確認する

マネジメントコンソールからCloudMapを表示し、名前空間を表示してみます。

localという名前空間があり、クリックして詳細を表示すると、サービス欄にコンテナが二つぶら下がっていることがわかります。

backend-container の詳細を表示すると「サービスインスタンス」という欄にbackend-container.localというドメインに紐づくインスタンスが表示されます。

インスタンスの詳細を表示すると、実際のリソース(コンテナ)のIPやポート番号の情報が表示されました。

backend-containerに追加されるコンテナはCloudMapのサービスインスタンスに自動的に追加されるため、バックエンドのコンテナが増えても疎通が可能になる。という構図のようです。

図にすると以下のようなイメージになります。

負荷をかけて、挙動を確かめてみる。

サンプルのCDKでは以下のAutoScaringの設定が記載されており、メモリ使用率20%を超えたらスケールするようになっています。

    // スケーリンググループを設定
    const autoscaling_frontend = frontend_service.autoScaleTaskCount({ maxCapacity: 5 });
    autoscaling_frontend.scaleOnMemoryUtilization('FrontEndScalingOnCpu', {
      targetUtilizationPercent: 20,
      scaleInCooldown: cdk.Duration.seconds(60),
      scaleOutCooldown: cdk.Duration.seconds(60)
    });
    const autoscaling_baackend = backend_service.autoScaleTaskCount({ maxCapacity: 5 });
    autoscaling_baackend.scaleOnMemoryUtilization('BackEndScalingOnCpu', {
      targetUtilizationPercent: 20,
      scaleInCooldown: cdk.Duration.seconds(60),
      scaleOutCooldown: cdk.Duration.seconds(60)
    });

試しに、以下のコマンドをローカルから実行して負荷をかけてみます。

ab -c 100 -n 10000 http://ALBのDNS名

コマンドを実行し、数分後にECSを確認すると、backendのコンテナが増えていることがわかります。

今度はCloudMapを確認してみると、インスタンスが追加されていることがわかりました。

これでbackend-container.localというドメインで問い合わせれば、自動で増えたコンテナのIPでも疎通できるということがわかりました。

まとめ

一通り検証作業を行えたので、内容を簡単にまとめると以下の3点になると思います。

  • 従来だとECSのサービス同士を通信させたい場合、橋渡しするリソース(ALB)が必要だった。

  • Service Connectを使えばProxyコンテナがサービス同士の橋渡しを担う

  • CloudMapの仕組みでドメインに所属するコンテナの名前解決が可能

個人的に1からAppMesh、CloudMap、ServiceDiscoveryの話を調査しなくても良くなってお手軽感があるかなぁと思いました。

また、Service Connectの記載がCDK上で簡潔に書けることと、ECSのGUI画面でも簡単に設定できるためとっつきやすい気がします。