AWS CDK(Python)を利用してECS×Fargate×ServiceConnectの構成でLocust(Master/Slave)の環境を構築してみた。

大変ご無沙汰しております。
最近`Locust`という負荷試験ツールを触る機会があり、せっかくなら`CDK(Python)`を利用して構築してみたいと思い立ったので、`ECS(Fargate)`上で動く環境を構築していきたいと思います。
さらに今回は、『AWS re:Invent 2022』で発表されたECSの新機能`Service Connect`も取り入れていきます。

# 用語解説（粗）
* **CDK**
`AWS Cloud Development Kit`のこと。特定のプログラミング言語でAWSリソースをコード化し、そのコードを実行することでAWS環境ができあがる優れものです。
`CDK`の導入編として[こちら](https://qiita.com/matsumikan/items/1d9c9cf055621b33e2f6)の記事を書いておりますので、`CDK`をまだ試したことのない方はそちらを見ていただくことをお勧めいたします。
* **ECS**
DockerコンテナアプリケーションをAWS上で簡単に実行できるサービス。`docker-compose`で行うような管理（複数のコンテナ群をひとまとめにして管理するなど）をAWS上で利用できるイメージ。
* **Fargate**
`ECS`や`EKS`（AWSの`k8s`サービス）で定義したコンテナをAWSが提供しているホスト環境で実行することができるサービス。いわゆるサーバーレス。
なお、`ECS`は`EC2`にも展開できますが、`EC2`の場合は自分で環境管理が必要になり運用の手間が発生するため、その手間を省きたい場合は`Fargate`がおすすめです。
* **Service Connect**
`ECS`内の`Service`間の通信を容易に設定できるようなサービス。
今までは`Cloud Map`や`App Mesh`といったサービスを駆使する必要があったのですが、`ECS`のサービス内で完結して設定できるようになったようです。（裏では`Cloud Map`が動いているようですが、それらをわざわざ自前で準備、または意識しなくても良くなった、という感じでしょうか。）
* **Locust**
OSSの負荷試験ツール。日本語に訳すと『イナゴ』。`Python`コードでテストシナリオを定義できるため、テストシナリオを`Python`でごりごり書きたいという方にはおすすめのツールです。
また、`Master/Slave`構成で起動することも可能で、`Slave`を複数起動させて大規模な負荷をかけることも容易にできます。
以降の記事では、`Master`=`Master`機、`Slave`=`Worker`機として解説します。

# 構成図
今回構築するAWS環境イメージは以下となります。（`ECS`周辺サービスのみ掲載）
メインは`Locust`側の環境構築となりますが、`Locust`の動作確認用におまけで`Nginx`環境も添えています。
![aws.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/c9ddc208-fb81-e357-8336-ab0a8c15a6a6.png)

# 構築手順

## 実施環境
本記事で構築を実施した環境は以下となります。特別な環境は用意していませんが、`Service Connect`を利用できるのは`CDK`のバージョンが`2.52.0`以上となっていますので、`Service Connect`を利用したい場合にはご自身の`CDK`のバージョンをお確かめください。
* OS : Ubuntu 20.04 LTS (GNU/Linux 5.10.16.3-microsoft-standard-WSL2 x86_64)
* Python : 3.8.10
* AWS CDK : 2.65.0 (build 5862f7a)
* Nodejs : v19.6.0
* AWS CLI : 1.18.69 (Credentials Configured)

## 手順

### 1. CDKプロジェクト作成
まずはCDKプロジェクトを作成します。
`cdk init app --language python`のコマンドにて必要なファイルを生成し、その後各環境のスタック別のフォルダや`Locust`のコンテナイメージ作成用のフォルダを手動でカスタマイズしまして、以下のようなファイル群を作りました。
本記事では一部の主要なファイルのみ説明していきます。
```bash: tree
.
├── README.md
├── app.py # 4.にて説明
├── cdk.context.json
├── cdk.json
├── cdk.out
├── locust_docker # Locustのコンテナイメージ向けフォルダ
│   ├── Dockerfile # 5.にて説明
│   ├── docker-compose.yml # ローカルでのみ利用したため割愛
│   └── locustfile.py # 5.にて説明
├── locust_on_ecs # Locust環境向けスタック向けフォルダ
│   ├── __init__.py
│   └── locust_on_ecs_stack.py # 2.にて説明
├── nginx_on_ecs # Nginx環境向けスタック向けフォルダ
│   ├── __init__.py
│   └── nginx_on_ecs_stack.py # 3.にて説明
├── requirements-dev.txt
├── requirements.txt
├── source.bat
└── tests
```

### 2. `Locust`環境向けスタック作成
今回は`Locust`用と`Nginx`用の２つスタックを作成します。まずは`Locust`用のスタックのソースコードを作っていきます。
```python:locust_on_ecs/locust_on_ecs_stack.py
from aws_cdk import (
    RemovalPolicy,
    Stack,
    aws_ec2 as ec2,
    aws_ecs as ecs,
    aws_ecr as ecr,
    aws_logs as logs,
)
from constructs import Construct

vpc_cidr = "10.0.0.0/16"
repository_name = "locustio/locust/custom"

class LocustOnEcsStack(Stack):
    
    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # [1] ECR Repository
        repository = ecr.Repository(self, "Repository",
            repository_name=repository_name
        )
        repository.apply_removal_policy(RemovalPolicy.DESTROY)

        # [2] Create VPC (Public Only)
        vpc = ec2.Vpc(self, "VPC",
            cidr=vpc_cidr,
            max_azs=2,
            subnet_configuration=[
                ec2.SubnetConfiguration(
                    cidr_mask=24,
                    name='public',
                    subnet_type=ec2.SubnetType.PUBLIC,
                )
            ],
        )

        # [3] Create ECS Cluster
        cluster = ecs.Cluster(self, "Cluster",
            vpc=vpc
        )

        # [4] Create Cloud Map NameSpace and ECS Cluster Setting (for ECS Service Connect)
        namespace = cluster.add_default_cloud_map_namespace(
            name="local"
        )

        # [5] Create Log Group
        log_group = logs.LogGroup(self, "LogGroup",
            log_group_name="locust-logs"
        )
        log_group.apply_removal_policy(RemovalPolicy.DESTROY)

        # [6] Create ECS Task Definition for Locust Master
        task_definition_master = ecs.FargateTaskDefinition(self, "TaskDefinitionForLocustMaster",
            memory_limit_mib =512,
            cpu=256,
        )
        task_definition_master.add_container("locust-master",
            image=ecs.ContainerImage.from_ecr_repository(repository), # Custom image(from ECR)
            port_mappings=[
                ecs.PortMapping(
                    container_port = 8089,
                    # container_port = 80,
                ),
                ecs.PortMapping(
                    name="master-and-worker-connect-1",
                    container_port = 5557,
                ),
                ecs.PortMapping(
                    name="master-and-worker-connect-2",
                    container_port = 5558,
                )
            ],
            command=[
                "-f",
                "/mnt/locust/locustfile.py",
                "--master",
                # "-P",
                # "80",
            ],
            logging=ecs.LogDrivers.aws_logs(
                log_group=log_group,
                stream_prefix="locust-master"
            ),
        )

        # [7] Create ECS Task Definition for Locust Worker 
        task_definition_worker = ecs.FargateTaskDefinition(self, "TaskDefinitionForLocustWorker",
            memory_limit_mib=512,
            cpu=256,
        )
        task_definition_worker.add_container("web",
            image=ecs.ContainerImage.from_ecr_repository(repository), # Custom image(from ECR)
            command=[
                "-f",
                "/mnt/locust/locustfile.py",
                "--worker",
                "--master-host",
                "locust-master",
            ],
            logging=ecs.LogDrivers.aws_logs(
                log_group=log_group,
                stream_prefix="locust-worker"
            ),
        )

        # [8] Create ECS Service for Locust Master
        service_master = ecs.FargateService(self, "ServiceMaster",
            cluster=cluster,
            task_definition=task_definition_master,
            assign_public_ip=True,
            desired_count=0,
            service_connect_configuration=ecs.ServiceConnectProps(
                services=[
                    ecs.ServiceConnectService(
                        port_mapping_name="master-and-worker-connect-1",
                        dns_name="locust-master",
                        port=5557,
                    ),
                    ecs.ServiceConnectService(
                        port_mapping_name="master-and-worker-connect-2",
                        dns_name="locust-master",
                        port=5558,
                    ),
                ],
                log_driver=ecs.LogDrivers.aws_logs(
                    log_group=log_group,
                    stream_prefix="service-connect-traffic-master"
                ),
            )
        )
        service_master.node.add_dependency(namespace)

        # [9] Create ECS Service for Locust Worker
        service_worker = ecs.FargateService(self, "ServiceWorker",
            cluster=cluster,
            task_definition=task_definition_worker,
            assign_public_ip=True,
            desired_count=0,
        )
        service_worker.enable_service_connect()
        service_worker.node.add_dependency(namespace)

        # [10] Update ECS Service SecurityGroups
        service_master.connections.allow_from_any_ipv4(ec2.Port.tcp(8089))
        service_master.connections.allow_from(service_worker.connections, ec2.Port.tcp(5557))
        service_master.connections.allow_from(service_worker.connections, ec2.Port.tcp(5558))
```
以下にソースコードの内容を説明します。細かく説明しておりますので少し長くなりますが、ご容赦ください。
* **[1] `ECR`にコンテナリポジトリ作成**
`Locust`のコンテナイメージを自前で利用するため、それを管理するコンテナリポジトリを`ECR`上に作成します。後ほどこちらにイメージを`Push`します。
* **[2] `VPC`作成**
`ECS Cluster`内にコンテナを起動させるためには、まずネットワーク環境が必要になりますので、`VPC`を作成します。
`ECS Cluster`のコンストラクタで自動生成もできますが、`NAT Gateway`を利用したリッチな構成が爆誕してしまいますので、今回は節約構成（Public Subnetのみ）を自前で作成します。
* **[3] `ECS Cluster`作成**
[2]で構築した`VPC`を割り当て作成します。それだけです。
* **[4] `Cloud Map`に`Name Space`作成＋`ECS Cluster`に紐づけ**
`Service Connect`を使用するために、`Cloud Map`に`Name Space`を作成します。
そして作成した`Name Space`は、[3]で作成した`ECS Cluster`にて利用でできるように設定します。
* **[5] `CloudWatch Logs`に`Log Group`作成**
`ECS`内で実行するコンテナ群から出力されるログの送信先となる`Cloud Watch Logs`の`Log Group`を作成します。
こちらは事前に作成せずとも`ECS`のタスク定義側で自動生成することもできるのですが、自動生成されたものは`CDK`で環境削除した際に残ってしまうため、ゴミが残らないように自前で作成しています。
* **[6] `Fargate`向け`TaskDefinition`作成（`Locust Master`用）**
`Locust`の`Master`機が稼働するタスク定義（`TaskDefinition`）を作成します。
`Master`機はLocustのWebUIが稼働するポート（`8089`）とWorkerとのやり取りを行うポート`5557` `5558`を利用しますので、それらをポートマッピングに定義します。なお、`Service Connect`で利用するECS内部のポート（今回は`5557` `5558`）は`ECS Service`での設定で`name`が必要になりますので、各ポートマッピングに`name`をそれぞれ定義してください。（重複NG）
`image`には、[1]で作成した`ECR`のコンテナリポジトリを利用するよう定義をしています。この定義をすると、タスク定義のタスク実行ロールが作成され、`ECR`からイメージを`Pull`する権限が自動的に付与されます。
`command`には、このコンテナが`Master`機として稼働するおまじないを実行しています。
`logging`には、[5]で作成した`Log Group`のログを出力するような定義をしています。この定義をすると、タスク定義のタスク実行ロールが作成され、`CloudWatch Logs`へログを送信する権限が自動的に付与されます。
* **[7] `Fargate`向け`TaskDefinition`作成（`Locust Worker`用）**
`Locust`の`Worker`機が稼働するタスク定義（`TaskDefinition`）を作成します。
`command`には、このコンテナが`Worker`機として稼働するおまじないを実行しています。
ここで重要なのが、`Master`機に対する接続先の指定です。今回は`locust-master`という`DNS Name`を利用しますので、そちらを指定しています。（後述で説明します。）
その他は[6]と同じ設定ですが、`Worker`機は`Master`機に対して接続しに行き、そのコネクション内で通信をする仕様のため、`Master`機が起点となる接続はありません（maybe...もし誤ってたらご指摘ください）。そのため、`Worker`機のポートマッピング設定は不要となります。
* **[8] `Fargate`向け`ECS Service`作成（`Locust Master`用）**
`Locust`の`Master`機を動かすための`ECS Service`を作成します。
外部からのアクセスしたいため、`assign_public_ip`を有効化しています。
現段階では[1]に利用するコンテナイメージを`Push`していないので、`desired_count`はいったん`0`にしています。（デフォルトは`1`で自動起動してしまいますのでご注意を。）
`service_connect_configuration`では`ECS Cluster`内で`ECS Service`間でマッピングできるよう定義していきます。定義の仕方としては、[6]で定義したポートマッピングの`name`に関連付けさせるよう、ポートや`Discovery Name`、`DNS Name`を指定します。なお、`Discovery Name`はポート単位で名前を分けなければならず、今回のように2つ以上のポートを同一名称で利用した場合には`DNS Name`を利用するのがよさそう（maybe...）なので、`5557` `5558`どちらも同じ`DNS Name`（`locust-master`）を指定しています。
また、`Service Connect`のログを出力することができますので、今回は出力するように定義しています。
* **[9] `Fargate`向け`ECS Service`作成（`Locust Worker`用）**
`Locust`の`Worker`機を動かすための`ECS Service`を作成します。
基本的には[8]と同じですが、[7]で説明した通り、`Master`機が起点となる接続はありませんので、[8]のようなマッピング定義は不要です。ただし、クライアントとして利用するだけでも`Service Connect`の有効化を行わないと`Master`機に接続できないので、**必ず`service_worker.enable_service_connect()`を定義してください。（ここが今回のドはまりポイントでした…）**
* **[10]各`ECS Service`に適用された`Security Group`修正**
最後に各`ECS Service`の`Security Group`を設定します。
`ECS Service`では自動的に`Security Group`を構築してくれますが、デフォルトでは、アウトバウンドトラフィックがすべて許可されているものが作成されるようです。今回は、以下のアクセスが必要となるため、各`Security Group`にルールを追加していきます。
     * `Master`機へブラウザアクセスできるよう`8089`ポートをフル開放
     * `Worker`機が`Master`機へ`5557` `5558`ポートで接続するために`Master`機側のインバウンドルール追加

### 3. `Nginx`環境向けスタック作成
次に`Nginx`用のスタックのソースコードを作っていきます。
こちらは特に説明するところはないため、詳細は割愛しますが、NginxのコンテナイメージはAWSが提供しているDockerリポジトリ[Amazon ECR Public Gallery](https://gallery.ecr.aws/)にあるNginxの公式イメージ`public.ecr.aws/nginx/nginx:1-alpine-perl`を利用しています。`Docker Hub`のイメージを利用するのもありですが、`Docker Hub`はPull制限がありますので、制限が気になる方は`Amazon ECR Public Gallery`を利用することをお勧めします。
```python:nginx_on_ecs/nginx_on_ecs_stack.py
from aws_cdk import (
    RemovalPolicy,
    Stack,
    aws_ec2 as ec2,
    aws_ecs as ecs,
    aws_logs as logs,
)
from constructs import Construct

vpc_cidr = "10.0.0.0/16"

class NginxOnEcsStack(Stack):
    
    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # [1] Create VPC
        vpc = ec2.Vpc(self, "VPC",
            cidr=vpc_cidr,
            max_azs=2,
            subnet_configuration=[
                ec2.SubnetConfiguration(
                    cidr_mask=24,
                    name='public',
                    subnet_type=ec2.SubnetType.PUBLIC,
                )
            ],
        )

        # [2] Create ECS Cluster
        cluster = ecs.Cluster(self, "Cluster",
            vpc=vpc
        )

        # [3] Create Log Group
        log_group = logs.LogGroup(self, "LogGroup",
            log_group_name="nginx-logs"
        )
        log_group.apply_removal_policy(RemovalPolicy.DESTROY)

        # [4] Create ECS Task Definition for Nginx
        task_definition = ecs.FargateTaskDefinition(self, "TaskDefinition",
            memory_limit_mib =512,
            cpu=256,
        )
        task_definition.add_container("nginx",
            image=ecs.ContainerImage.from_registry('public.ecr.aws/nginx/nginx:1-alpine-perl'), # from Amazon ECR Public Gallery
            logging=ecs.LogDrivers.aws_logs(
                log_group=log_group,
                stream_prefix="nginx"
            ),
        )

        # [5] Create ECS Service for Nginx
        service = ecs.FargateService(self, "Service",
            cluster=cluster,
            task_definition=task_definition,
            assign_public_ip=True,
            desired_count=1,
        )

        # [6] Update ECS Service SecurityGroups
        service.connections.allow_from_any_ipv4(ec2.Port.tcp(80))
```
    
### 4. 環境デプロイ
AWSに環境をデプロイしていきます。デプロイする前に、今回利用する`app.py`の内容を説明します。
```python:app.py
#!/usr/bin/env python3
import os
import aws_cdk as cdk

from locust_on_ecs.locust_on_ecs_stack import LocustOnEcsStack
from nginx_on_ecs.nginx_on_ecs_stack import NginxOnEcsStack

app = cdk.App()
LocustOnEcsStack(app, "LocustOnEcsStack",
    env=cdk.Environment(account=os.getenv('CDK_DEFAULT_ACCOUNT'),
        region=os.getenv('CDK_DEFAULT_REGION')),
)
NginxOnEcsStack(app, "NginxOnEcsStack",
    env=cdk.Environment(account=os.getenv('CDK_DEFAULT_ACCOUNT'),
        region=os.getenv('CDK_DEFAULT_REGION')),
)
cdk.Tags.of(app).add("User", "MatsuMikan")
app.synth()
```
今回は`Locust`用と`Nginx`用でスタックを分けています。そのため、それぞれ二つのスタックを定義し、デプロイ先のAWSアカウントIDやリージョンを指定するようにしています。また、おまけとしてすべてのリソースに自分の名前をタグ付けするようにもしています。
では、デプロイしていきます。一度も`CDK`を実行したことがない場合は`cdk bootstrap`のコマンドを行う必要がありますが、私はすでに実施済みなので省略し、今回利用するAWSアカウントIDとリージョンを環境変数に設定したうえで、すべてのスタックをデプロイするコマンドを実行します。
``` bash
$ export CDK_DEFAULT_ACCOUNT=0123456789AB # Deploy to AWS Account ID
$ export CDK_DEFAULT_REGION=ap-northeast-1 # Deploy to Region
$ cdk deploy --all # --all オプション付与でapp.pyに定義したスタックを一括デプロイ
```
問題なくデプロイしたことは、`cdk`コマンドの結果もしくはコンソール上で確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/da4850b5-7f3e-1b68-c698-c0f7188ab0f8.png)

### 5. `Locust`のDockerイメージ作成＋ECRにPush
ここからは`Locust`のコンテナイメージ作成していきます。
今回は[Locust公式が提供しているイメージ](https://hub.docker.com/r/locustio/locust)`locustio/locust`を利用し、自前で作成したテストコード`locustfile.py`をイメージの中に内包します。
```docker:locust_docker/Dockerfile
FROM locustio/locust
WORKDIR /mnt/locust
COPY *.py /mnt/locust/
```
以下は`Locust`上で実行するテストコードです。`Locust`の中身についてはあまり深くは触れませんが、以下の内容が含まれています。
* WebUIで指定した`Host`に`Get`メソッドでアクセスする
* 2~5秒の間でランダム秒数でタスク（`Host`へのアクセス）を繰り返し実行する
* Locust側の標準ログに各ユーザが何回`Host`へのアクセスしたかをわかるように出力する（ユーザはWebUIのユーザ数により生成）
```python:locust_docker/locustfile.py
from locust import HttpUser, task, events, between
from locust.runners import MasterRunner, WorkerRunner

usernames = []

def setup_test_users(environment, msg, **kwargs):
    usernames.extend(map(lambda u: u["name"], msg.data))


@events.init.add_listener
def on_locust_init(environment, **_kwargs):
    if not isinstance(environment.runner, MasterRunner):
        environment.runner.register_message("test_users", setup_test_users)


@events.test_start.add_listener
def on_test_start(environment, **_kwargs):
    if not isinstance(environment.runner, WorkerRunner):
        users = []
        for i in range(environment.runner.target_user_count):
            users.append({"name": f"User{i+1}"})

        worker_count = environment.runner.worker_count
        chunk_size = int(len(users) / worker_count)

        for i, worker in enumerate(environment.runner.clients):
            start_index = i * chunk_size

            if i + 1 < worker_count:
                end_index = start_index + chunk_size
            else:
                end_index = len(users)

            data = users[start_index:end_index]
            environment.runner.send_message("test_users", data, worker)

class WebAccessUser(HttpUser):
    seq = 0
    wait_time = between(2, 5)

    def __init__(self, parent):
        self.username = usernames.pop()
        super().__init__(parent)

    @task
    def web_access(self):
        self.seq += 1
        self.client.get("/")
        print({
            "Username": self.username,
            "Sequence": str(self.seq)
        })
```
上記ファイル群をビルドしてイメージを作成し、`Locust`環境向けスタック内で準備した`ECR`のリポジトリに`Push`します。
```bash
$ aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin 0123456789AB.dkr.ecr.ap-northeast-1.amazonaws.com
$ docker build -t locustio/locust/custom .
$ docker tag locustio/locust/custom:latest 0123456789AB.dkr.ecr.ap-northeast-1.amazonaws.com/locustio/locust/custom:latest
$ docker push 0123456789AB.dkr.ecr.ap-northeast-1.amazonaws.com/locustio/locust/custom:latest
```
### 6. `ECS Service`にてタスク起動
いよいよ準備が整ったので、`Locust`の`ECS Service`にてタスクを起動していきます。
今回は`CDK`でタスク数を変更し、再度`deploy`していきます。
```diff_python:locust_on_ecs/locust_on_ecs_stack.py(修正箇所のみ抜粋)
# {前略}

# [8] Create ECS Service for Locust Master
service_master = ecs.FargateService(self, "ServiceMaster",
    cluster=cluster,
    task_definition=task_definition_master,
    assign_public_ip=True,
-    desired_count=0,
+    desired_count=1,

# {中略}

# [9] Create ECS Service for Locust Worker
service_worker = ecs.FargateService(self, "ServiceWorker",
    cluster=cluster,
    task_definition=task_definition_worker,
    assign_public_ip=True,
-    desired_count=0,
+    desired_count=2,

# {後略}
```
```bash
$ cdk deploy LocustOnEcsStack
```
デプロイ結果はこちら。
`Locust`側は合計`3`つのタスク（`Master`:`1`,`Worker`:`2`）が無事に起動していることが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/f4f0275c-a23a-7515-8bea-30a183bbb8ea.png)
### 7. ブラウザ経由で`Locust`へアクセス＋`Nginx`へのアクセス実施
まずはNginxにブラウザ経由でアクセスしてみます。
* http://{Nginxのタスクのパブリック IP}/
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/4b7f354b-66db-3348-6a7f-f109477f2b4a.png)
無事アクセスできました。このURLはメモしておきます。
続きまして`Locust`へアクセスします。
* http://{LocustのMaster機のタスクのパブリック IP}:8089/
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/1a31f0a6-6628-0c68-918c-1d82f04da57f.png)
こちらもアクセスできました。（初回アクセス時のスクショを取り忘れたので若干残像が残っていますが）
右上にひょうじされている`WORKERS`の数字が`2`と表示されていますが、これは`ECS Service`でデプロイしたタスク数と一致しており、`Master`機と`Worker`機が無事に接続できていることが確認できます。
それでは実際に動かしてみましょう。
`Locust`のWebUIでは、アクセスするユーザ数やホスト、実行時間などを指定することができますので、今回は上記画面の通りに設定して動かしてみます。
そして、動かしてみた結果（チャート）がこちら。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/92863c28-c8e5-8b99-9879-32fd72fc0b92.png)
`Spawn rate`で指定した時間に合わせてじわじわアクセスユーザを増やし、指定したユーザ数`10`のアクセスが維持された状態で定期的にリクエストが飛んでいたようです。
次は`Locust`の`Worker`機のログを見てみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/84ee8b0f-e6c4-bea2-7a46-44626760c77e.png)
ログ出力の仕方が雑だったのでわかりづらいですが、User1~10が、`Nginx`に1分間にアクセスしていたであろう形跡が確認できました。
`Worker`機が2台いたのですが、2台それぞれにUserが分散されて、各`Worker`機からアクセスしていたようです。（`Logstream`がタスク単位で払い出されるため、そちらとUserの番号を合わせて見ていくとそんな動きをしているようでした。）
`Nginx`側も同時刻にアクセスログが動いており、`Locust`からのアクセスを受信できていることも確認できました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/8c51cb69-1530-8970-7bf8-8771268dc38c.png)
という感じで、すべての環境を`CDK`を利用して構築（デプロイ）し、想定通りに動くところまで確認することができました。
# お片付け
やりたいことは終わりましたので、環境を削除します。
早速`cdk destroy --all`で全スタック一括デストロイしたい気持ちはやまやまですが、今回`ECR`にイメージを`Push`しているため、まずはこちらを削除するところから始まります。
`ECR`は`S3`と一緒で、データを空にしないとリポジトリを削除できない制限がかかっておりますので、まずはイメージを削除します。
```bash
$ aws ecr batch-delete-image --repository-name locustio/locust/custom --image-ids imageTag=latest
```
そして環境をすべてデストロイします。
```bash
$ cdk destroy --all
Are you sure you want to delete: NginxOnEcsStack, LocustOnEcsStack (y/n)? y
NginxOnEcsStack: destroying... [1/2]

 ✅  NginxOnEcsStack: destroyed
LocustOnEcsStack: destroying... [2/2]

 ✅  LocustOnEcsStack: destroyed
```
きれいになりました。

# 【補足】`Service Connect`について
`ECS`のタスクを確認したところ、タスク定義で指定した`Locust`のコンテナ（以下の画像では`web`と表記）以外に、`ecs-service-connect-xxx`なるコンテナが動いていました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/b02c8612-c737-1143-74d3-6d748697af42.png)
おそらくこちらが`Service Connect`としての役目を果たす者（？）です。
`Service Connect`ログを見てみると…なにやら`Envoy`が動いている模様でした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/3be858eb-3798-7ae5-4404-19abd090c990.png)
また、`Master`機の`Service Connect`をコンソール上で確認したところ、以下のようになっていました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/a6dd2bc9-f841-2842-c9df-3b5ac0034e18.png)
`Service Connect`でECS内の名前解決する方法は二つあり、検出（`Discovery Name`）と`DNS Name`のいずれかを利用することができそうです。
`Discovery Name`はデフォルトではポートマッピング名を使用していますが、変更できます。
ただし、既に説明した通り、`Discovery Name`ポート単位で一意になるため、今回のような複数のポートで同一の名称を利用したいケースの場合は`DNS Name`を利用するのがよさそうです。
ちなみに、`Discovery Name`を利用する場合は、`Discovery Name`.`Name Space`という形式で利用することになるのでご注意ください。

# 【余談】その他にはまったこと
実は今回`Locost`の`Master`機のWebUIのポートをデフォルトの`8089`ではなく`80`に変更したかったのですが、うまくいきませんでした。ローカル上ではうまくいったのですが、`Fargate`では権限的な問題で指定できないようです。
そのため、`80`ポートなど他のポートでWebUIにアクセスしたい場合は、`Master`機の前に`ALB`を利用してフォワーディングさせるしかなさそうです。（もし別の手段がありましたらご教示くださいませ）
ちなみに`80`ポートを利用しようとしてダメだったログはこちら。
```text
[2023-02-19 11:35:08,755] ip-10-0-1-58.ap-northeast-1.compute.internal/INFO/locust.main: Starting web interface at http://0.0.0.0:80 (accepting connections from all network interfaces)
[2023-02-19 11:35:08,840] ip-10-0-1-58.ap-northeast-1.compute.internal/INFO/locust.main: Starting Locust 2.14.2
Traceback (most recent call last):
  File "src/gevent/greenlet.py", line 908, in gevent._gevent_cgreenlet.Greenlet.run
  File "/opt/venv/lib/python3.10/site-packages/locust/web.py", line 473, in start_server
    self.server.serve_forever()
  File "/opt/venv/lib/python3.10/site-packages/gevent/baseserver.py", line 398, in serve_forever
    self.start()
  File "/opt/venv/lib/python3.10/site-packages/gevent/baseserver.py", line 336, in start
    self.init_socket()
  File "/opt/venv/lib/python3.10/site-packages/gevent/pywsgi.py", line 1545, in init_socket
    StreamServer.init_socket(self)
  File "/opt/venv/lib/python3.10/site-packages/gevent/server.py", line 180, in init_socket
    self.socket = self.get_listener(self.address, self.backlog, self.family)
  File "/opt/venv/lib/python3.10/site-packages/gevent/server.py", line 192, in get_listener
    return _tcp_listener(address, backlog=backlog, reuse_addr=cls.reuse_addr, family=family)
  File "/opt/venv/lib/python3.10/site-packages/gevent/server.py", line 288, in _tcp_listener
    sock.bind(address)
  File "/opt/venv/lib/python3.10/site-packages/gevent/_socketcommon.py", line 563, in bind
    return self._sock.bind(address)
PermissionError: [Errno 13] Permission denied: ('', 80)
2023-02-19T11:35:08Z <Greenlet at 0x7ff58e92bf60: <bound method WebUI.start_server of <locust.web.WebUI object at 0x7ff58e8b7130>>> failed with PermissionError
[2023-02-19 11:35:08,843] ip-10-0-1-58.ap-northeast-1.compute.internal/CRITICAL/locust.web: Unhandled exception in greenlet: <Greenlet at 0x7ff58e92bf60: <bound method WebUI.start_server of <locust.web.WebUI object at 0x7ff58e8b7130>>>
Traceback (most recent call last):
  File "src/gevent/greenlet.py", line 908, in gevent._gevent_cgreenlet.Greenlet.run
  File "/opt/venv/lib/python3.10/site-packages/locust/web.py", line 473, in start_server
    self.server.serve_forever()
  File "/opt/venv/lib/python3.10/site-packages/gevent/baseserver.py", line 398, in serve_forever
    self.start()
  File "/opt/venv/lib/python3.10/site-packages/gevent/baseserver.py", line 336, in start
    self.init_socket()
  File "/opt/venv/lib/python3.10/site-packages/gevent/pywsgi.py", line 1545, in init_socket
    StreamServer.init_socket(self)
  File "/opt/venv/lib/python3.10/site-packages/gevent/server.py", line 180, in init_socket
    self.socket = self.get_listener(self.address, self.backlog, self.family)
  File "/opt/venv/lib/python3.10/site-packages/gevent/server.py", line 192, in get_listener
    return _tcp_listener(address, backlog=backlog, reuse_addr=cls.reuse_addr, family=family)
  File "/opt/venv/lib/python3.10/site-packages/gevent/server.py", line 288, in _tcp_listener
    sock.bind(address)
  File "/opt/venv/lib/python3.10/site-packages/gevent/_socketcommon.py", line 563, in bind
    return self._sock.bind(address)
PermissionError: [Errno 13] Permission denied: ('', 80)
[2023-02-19 11:35:09,345] ip-10-0-1-58.ap-northeast-1.compute.internal/INFO/locust.main: Shutting down (exit code 2)
```

# さいごに/感想
久しぶりに`CDK`を触ったのですが、やはり最低限のコード記述だけでいい感じに環境構築できるのは大変素晴らしい！と改めて実感しました。（CloudFormationは細かく定義しなければならないので、それに比べると本当に楽ちんでした…）
そして`Sevice Connect`の登場で、`ECS Service`間の通信設定がかなり簡単にできることもわかり、`ECS` ユーザにはかなり良いアップデートだったのではないでしょうか。
ちなみに`Service Connect`はリリースされてから3か月程度しかたっていないと思うのですが、早い段階で`CDK`経由で構築できるようになっていたので、AWSの中の皆様のお仕事の速さにも驚きです。

こんなに便利なツール`CDK`をもっと積極的に勉強して使っていかねばと思いつつ、その前に自身の`Python`言語の理解の浅さを痛感した（ドキュメントの解読に苦労していた）ため、`Python`言語力（それともコーディング力？）をまずは養う必要があるのでは…と感じた次第でした。

こちらからは、以上です。

# 参考URL

https://docs.locust.io/en/stable/

https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.aws_ecs/README.html

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

https://dev.classmethod.jp/articles/ecs-service-connect-cdk/