ECS Service ConnectをCDKでデプロイしてみた

2022.11.29

CX事業部Delivery部の新澤です。

先日発表されたECSの新しいマイクロサービス間通信の機能「Service Connect」が、CDKでも早速リリースされていましたので試してみました!

概要

Service Connectの動作確認をするため、同じCloudMap名前空間の中にサーバーとクライアントの2つのFargateサービスを作成してクライアントからサーバーに対して通信を行ってみたいと思います。

また、CloudMap名前空間外のEC2インスタンスから通信を行った場合はどうなるのかも併せて確認してみます。

Fargateサービスは簡単に作成するためにクライアント、サーバーともにnginxのコンテナイメージを用います。

クライアントからサーバーへの疎通確認は、クライアントにECS Excecを使ってリモートログインして、curlコマンドでリクエストしてみます。

また、CloudMap名前空間外のEC2インスタンスからも同様にサーバーに対してcurlコマンドでリクエストします。

CDKを作成

それでは、こちらにあるドキュメントのサンプルを参考に早速CDKをサクッと作ってみます。

まずはECSクラスターとタスク定義を作成します。

ここで、CloudMap名前空間"local"を指定しています。

※VPCはClusterコンストラクトでデフォルトで作成されるものを使用しています。

    // ECSクラスター
    const cluster = new Cluster(this, 'EcsCluster', {
      clusterName: 'ecs-service-connector-sample-cluster',
    });
    const namespace = cluster.addDefaultCloudMapNamespace({
      name: 'local',
    });
      // タスク定義
    const taskdef = new FargateTaskDefinition(this, 'TaskDefinition');
    taskdef.addContainer('nginx', {
      image: ContainerImage.fromRegistry('nginx'),
      portMappings: [
        {
          name: 'nginx',
          containerPort: 80,
        },
      ],
    });

次はサーバーコンテナのECSサービスを定義します。

ここで、"serviceConnectConfiguration"を定義することで、Service Connectを有効化することができます。

serviceConnectConfiguration内のportMappingNameには、先ほどのタスク定義内のPortMappingsにnameで指定した名前(ここでは"nginx")と同じ値を指定します。disccoveryNameには、このサービスにアクセスする際に使うサービス名を指定します。

また、Service Connectのログの取得もできますので、CloudWatch Logsへのログ出力の定義も追加しています。

最後の”server_service.node.addDependency(namespace)”については、スタックをデプロイした際にCloudMap名前空間が作成完了する前にFargateServiceがデプロイ開始されてしまい、エラーになってしまったのでCloudMap名前空間が作成完了してからFargateServiceが作成されるように依存関係を付けています。

    const server_service = new FargateService(this, 'ServerService', {
      cluster: cluster,
      taskDefinition: taskdef,
      serviceName: 'server-service',
      serviceConnectConfiguration: {
        services: [
          {
            portMappingName: 'nginx',
            port: 80,
            discoveryName: 'nginx-server'
          },
        ],
        logDriver: LogDrivers.awsLogs({
          streamPrefix: 'svccon-traffic-server',
        }),
      },
    });
    server_service.node.addDependency(namespace);

続いて、クライアントコンテナも定義していきます。

こちらも基本的にはサーバーコンテナと同じ内容ですが、ECS Execでリモート接続するために"enableExecuteCommand"を有効化しています。

    const client_service = new FargateService(this, 'ClientService', {
      cluster: cluster,
      taskDefinition: taskdef,
      serviceName: 'client-service',
      serviceConnectConfiguration: {
        services: [
          {
            portMappingName: 'nginx',
            port: 80,
            discoveryName: 'nginx-client'
          },
        ],
        logDriver: LogDrivers.awsLogs({
          streamPrefix: 'svccon-traffic-client',
        }),
      },
      enableExecuteCommand: true,
    });
    server_service.connections.allowFrom(client_service.connections, Port.tcp(80));
    client_service.node.addDependency(namespace);

最後にEC2インスタンスを作成します。

BastionHostLinuxを使うと踏み台サーバーを簡単に作れるので便利ですよね。

    // CloudMap名前空間外のホストから接続を試す
    const bastion = new BastionHostLinux(this, 'BastionHost', {
      instanceName: 'bastion-host',
      vpc: cluster.vpc,
      instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM),
    });
    server_service.connections.allowFrom(bastion.connections, Port.tcp(80));

動作確認

上記のCDKをデプロイして、マネージドコンソールで確認してみます。

左のメニューに新しく「名前空間」というのが追加されていますね。クリックすると、CDKで追加した名前空間"local"が追加されていました。

※ メニューが表示されない場合は、左上の「新しいECSエクスペリエンス」が有効になっているか確認してください。私も少しの間、気づかずに彷徨いました…

2つのサービスが登録されていますね。

それでは、動作確認してみたいと思います。

クライアントコンテナへのログインにはECS Execを利用します。

ECS Execについてはこちらが詳しいです。

ECSサービスの管理コンソールからタスクIDを確認して、以下のコマンドを実行します。

$ aws ecs execute-command  \
    --region $AWS_REGION \
    --cluster ecs-service-connector-sample-cluster \
    --task <タスクID> \
    --container nginx \
    --command "/bin/bash" \
    --interactive
The Session Manager plugin was installed successfully. Use the AWS CLI to start a session.


Starting session with SessionId: ecs-execute-command-0ed4ac08d4620331a
root@ip-10-0-195-63:/#

curlコマンドでサーバーコンテナ"nginx-server.local"にリクエストしてみると、レスポンスが確認できました。

root@ip-10-0-195-63:/# curl http://nginx-server.local
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>
root@ip-10-0-195-63:/#

では、CloudMap名前空間外のEC2インスタンスからではどうでしょうか?

[root@ip-10-0-187-151 ~]# curl http://nginx-server.local
curl: (6) Could not resolve host: nginx-server.local
[root@ip-10-0-187-151 ~]#

DNSで名前解決できず、リクエストできませんでした。

それでは、クライアントコンテナ内ではDNSで名前解決できているということでしょうか?確認してみます。

root@ip-10-0-195-63:/# dig nginx-server.local

; <<>> DiG 9.16.33-Debian <<>> nginx-server.local
;; global options: +cmd
;; Got answer:
;; WARNING: .local is reserved for Multicast DNS
;; You are currently testing what happens when an mDNS query is leaked to DNS
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 7170
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;nginx-server.local.            IN      A

;; AUTHORITY SECTION:
local.                  15      IN      SOA     ns-1536.awsdns-00.co.uk. awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400

;; Query time: 3 msec
;; SERVER: 10.0.0.2#53(10.0.0.2)
;; WHEN: Tue Nov 29 07:58:52 UTC 2022
;; MSG SIZE  rcvd: 134

root@ip-10-0-195-63:/#

…名前解決できませんね。

ということは。

root@ip-10-0-195-63:/# cat /etc/hosts
127.0.0.1 localhost
10.0.195.63 ip-10-0-195-63.ap-northeast-1.compute.internal
127.255.0.1 nginx-client.local
2600:f0f0:0:0:0:0:0:1 nginx-client.local
127.255.0.2 nginx-server.local
2600:f0f0:0:0:0:0:0:2 nginx-server.local

どうやら、タスクのデプロイ時にService Connectに登録されているサービスのIPアドレスがhostsファイルに展開される仕組みのようですね。

では、別なサーバーコンテナを追加して、通信先のサービスを増やした場合はどうなるのでしょうか?

サーバーコンテナを"nginx-server2.local"という名前でService Connectに追加してみましょう。

クライアントコンテナのhostsファイルを確認してみます。

root@ip-10-0-195-63:/# cat /etc/hosts
127.0.0.1 localhost
10.0.195.63 ip-10-0-195-63.ap-northeast-1.compute.internal
127.255.0.1 nginx-client.local
2600:f0f0:0:0:0:0:0:1 nginx-client.local
127.255.0.2 nginx-server.local
2600:f0f0:0:0:0:0:0:2 nginx-server.local

追加したサーバーコンテナは反映されていないようです。それでは、クライアントコンテナをデプロイしなおして反映されるか確認してみます。

root@ip-10-0-230-38:/# cat /etc/hosts
127.0.0.1 localhost
10.0.230.38 ip-10-0-230-38.ap-northeast-1.compute.internal
127.255.0.1 nginx-client.local
2600:f0f0:0:0:0:0:0:1 nginx-client.local
127.255.0.2 nginx-server.local
2600:f0f0:0:0:0:0:0:2 nginx-server.local
127.255.0.3 nginx-server2.local
2600:f0f0:0:0:0:0:0:3 nginx-server2.local

反映されました!

公式ドキュメントに以下の記載があるのは、この仕様に依るところのようですね。

展開順序

Amazon ECS Service Connect を使用する場合、各 Amazon ECS サービスを設定して、ネットワーク リクエストを受信するサーバー アプリケーションを実行するか (クライアント サーバー サービス)、リクエストを行うクライアント アプリケーションを実行します (クライアント サービス)。

Service Connect の使用を開始する準備をするときは、クライアント サーバー サービスから始めます。Service Connect 構成を新しいサービスまたは既存のサービスに追加できます。Amazon ECS サービスを編集および更新して Service Connect 設定を追加すると、Amazon ECS は名前空間に Service Connect エンドポイントを作成します。さらに、Amazon ECS はサービスに新しいデプロイを作成して、現在実行中のタスクを置き換えます。

また、Service ConnectのログをCloudWatchで確認してみると、以下のようなログが出力されていました。内部的にはApp Meshが動作しているようです。

time="2022-11-29T08:17:32Z" level=info msg="App Mesh Environment Variables: [APPMESH_RESOURCE_ARN=arn:aws:ecs:ap-northeast-1:368009229305:task-set/ecs-service-connector-sample-cluster/client-service/ecs-svc/3105079396056430436 APPMESH_XDS_ENDPOINT=unix:///var/run/ecs/appnet/relay/appnet_relay_listener.sock APPMESH_METRIC_EXTENSION_VERSION=1]"

推測ですが、公式ブログに掲載されているのアーキテクチャ図のECS Service ConnectとECSタスク間ではApp Meshが動作していそうですね。

引用元: https://aws.amazon.com/jp/blogs/aws/new-amazon-ecs-service-connect-enabling-easy-communication-between-microservices/

最後にCDKの全体を掲載します。

前述の通り、クライアントコンテナより先にサーバーコンテナがデプロイされていないとクライアントコンテナがサーバーコンテナに通信できないため、デプロイの依存性を追加しています。

import * as cdk from 'aws-cdk-lib';
import { RemovalPolicy } from 'aws-cdk-lib';
import { BastionHostLinux, InstanceClass, InstanceSize, InstanceType, Port, SecurityGroup } from 'aws-cdk-lib/aws-ec2';
import { Cluster, ContainerImage, FargateService, FargateTaskDefinition, LogDrivers } from 'aws-cdk-lib/aws-ecs';
import { LogGroup } from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';

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

    const cluster = new Cluster(this, 'EcsCluster', {
      clusterName: 'ecs-service-connector-sample-cluster',
    });
    const namespace = cluster.addDefaultCloudMapNamespace({
      name: 'local',
    });
    namespace.applyRemovalPolicy(RemovalPolicy.DESTROY);

    const taskdef = new FargateTaskDefinition(this, 'TaskDefinition');
    taskdef.addContainer('nginx', {
      image: ContainerImage.fromRegistry('nginx'),
      portMappings: [
        {
          name: 'nginx',
          containerPort: 80,
        },
      ],
    });

    const logGroup = new LogGroup(this, 'ClientLogGroup', {
      logGroupName: 'ecs-service-connect-sample',
      removalPolicy: RemovalPolicy.DESTROY,
    });

    const server_service = new FargateService(this, 'ServerService', {
      cluster: cluster,
      taskDefinition: taskdef,
      serviceName: 'server-service',
      serviceConnectConfiguration: {
        services: [
          {
            portMappingName: 'nginx',
            port: 80,
            discoveryName: 'nginx-server'
          },
        ],
        logDriver: LogDrivers.awsLogs({
          logGroup: logGroup,
          streamPrefix: 'svccon-traffic-server',
        }),
      },
      enableExecuteCommand: true,
    });
    server_service.node.addDependency(namespace);

    const client_sg = new SecurityGroup(this, 'ClientSg', {
      vpc: cluster.vpc,
    });
    const client_service = new FargateService(this, 'ClientService', {
      cluster: cluster,
      taskDefinition: taskdef,
      serviceName: 'client-service',
      serviceConnectConfiguration: {
        services: [
          {
            portMappingName: 'nginx',
            port: 80,
            discoveryName: 'nginx-client'
          },
        ],
        logDriver: LogDrivers.awsLogs({
          logGroup: logGroup,
          streamPrefix: 'svccon-traffic-client',
        }),
      },
      securityGroups: [client_sg],
      enableExecuteCommand: true,
    });
    server_service.connections.allowFrom(client_sg, Port.tcp(80));
    client_service.node.addDependency(namespace);
    client_service.node.addDependency(server_service);

    // CloudMap名前空間外のホストから接続を試す
    const bastion = new BastionHostLinux(this, 'BastionHost', {
      instanceName: 'bastion-host',
      vpc: cluster.vpc,
      instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM),
    });
    server_service.connections.allowFrom(bastion.connections, Port.tcp(80));

  }
}

最後に

発表直後のECS Service Connectが早くもCDKに実装されていたので試してみました。

これまでマイクロサービスの可用性を確保するには、ELBもしくはService Discovery(CloudMap)といった手段が主なものでしたが、今回発表されたService Connectでは自前でELBを構築せずとも可用性が確保されるアーキテクチャになっていますので、ぜひ活用していきたいですね。