3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

LocustでWebサイトの性能測定をする(ECS+Fargateでスケールする編)

Posted at

はじめに

前回の記事でLocustをシングルノードで起動することを試してみたが、これではほとんど性能が出ないので、マルチノードで起動して、かつマシンスペックにとらわれずにスケールするところまで試してみた。

先に言っておくが、ECS+Fargateでスケールするのはかなり自己満足の範囲で、EC2のインスタンスタイプを調整しながらdocker-composeでワーカーを調整する方がお手軽に起動することができる。

EC2でワーカーを起動する

ワーカーを起動するには、locustに対して以下の命令を与える。
ちょっと前のバージョンまでは --slave だったようなのだけど、githubの問題を受けて変わったようだ……。

$ locust --worker --master-host=[マスタのホスト名]

マスタで起動する場合は

$ locust --master

で良い。

さて、masterとworkerでは起動内容が違うが、コンテナの中身は同じで良いので、スクリプトで起動方法を変えられるようにしておこう。環境変数MODEで、マスタ起動かワーカー起動なのかを振り分ける起動用ラッパーシェルを作成する。

run.sh
#!/bin/bash

echo "mode: $MODE"

if [ "$MODE" == 'master' ]; then
  locust --master
elif [ "$MODE" == 'worker' ]; then
  locust --worker --master-host=locust-master
fi

で、Dockerfileからはlocustそのものではなく、上記のラッパーシェルを起動するようにする。

FROM python

RUN pip install locust

WORKDIR /locust
COPY ./locustfile.py .
COPY ./run.sh .
RUN chmod 755 ./run.sh

CMD ["./run.sh"]

で、マスタとワーカーを同時に起動できるように、docker-compose.yml を定義する。
ポイントは、真歌のサービス名を locust-master にすること。
これにより、locust-master がワーカー起動時の ---master-hostのホスト名として認識されるようになる。

docker-compose.yml
version: '3.4'
x-locust-service: &locust-service
  build: .
services:
  locust-master:
    <<: *locust-service
    ports:
      - "80:8089"
    environment:
      MODE: master
  locust-slave-1:
    <<: *locust-service
    environment:
      MODE: worker
  locust-slave-2:
    <<: *locust-service
    environment:
      MODE: worker
  locust-slave-3:
    <<: *locust-service
    environment:
      MODE: worker

これで、docker-compose up --build で以下のようにワーカーが起動するようになる。

locust-slave-3_1  | mode: worker
locust-slave-1_1  | mode: worker
locust-slave-2_1  | mode: worker
locust-master_1   | mode: master
locust-slave-1_1  | [2020-08-16 04:34:40,234] 8cf353071957/WARNING/locust.main: System open file limit setting is not high enough for load testing, and the OS wouldnt allow locust to increase it by itself. See https://docs.locust.io/en/stable/installation.html#increasing-maximum-number-of-open-files-limit for more info.
locust-slave-1_1  | [2020-08-16 04:34:40,235] 8cf353071957/INFO/locust.main: Starting Locust 1.1.1
locust-slave-2_1  | [2020-08-16 04:34:40,528] 4919b452a0b1/WARNING/locust.main: System open file limit setting is not high enough for load testing, and the OS wouldnt allow locust to increase it by itself. See https://docs.locust.io/en/stable/installation.html#increasing-maximum-number-of-open-files-limit for more info.
locust-slave-2_1  | [2020-08-16 04:34:40,529] 4919b452a0b1/INFO/locust.main: Starting Locust 1.1.1
locust-slave-3_1  | [2020-08-16 04:34:40,544] c75ba98aab87/WARNING/locust.main: System open file limit setting is not high enough for load testing, and the OS wouldnt allow locust to increase it by itself. See https://docs.locust.io/en/stable/installation.html#increasing-maximum-number-of-open-files-limit for more info.
locust-slave-3_1  | [2020-08-16 04:34:40,545] c75ba98aab87/INFO/locust.main: Starting Locust 1.1.1
locust-master_1   | [2020-08-16 04:34:40,651] e1819ab325cb/WARNING/locust.main: System open file limit setting is not high enough for load testing, and the OS wouldnt allow locust to increase it by itself. See https://docs.locust.io/en/stable/installation.html#increasing-maximum-number-of-open-files-limit for more info.
locust-master_1   | [2020-08-16 04:34:40,652] e1819ab325cb/INFO/locust.main: Starting web interface at http://:8089
locust-master_1   | [2020-08-16 04:34:40,660] e1819ab325cb/INFO/locust.main: Starting Locust 1.1.1
locust-master_1   | [2020-08-16 04:34:40,688] e1819ab325cb/INFO/locust.runners: Client '8cf353071957_98e924e305fd45af8d56ffb3496faf46' reported as ready. Currently 1 clients ready to swarm.
locust-master_1   | [2020-08-16 04:34:40,715] e1819ab325cb/INFO/locust.runners: Client '4919b452a0b1_9d793c2ca4e64538a4bebc73c5c9c94d' reported as ready. Currently 2 clients ready to swarm.
locust-master_1   | [2020-08-16 04:34:40,730] e1819ab325cb/INFO/locust.runners: Client 'c75ba98aab87_aa5ede732c11490d823e0be1a2f20948' reported as ready. Currently 3 clients ready to swarm.

IPアドレスにアクセスすると、ちゃんとWORKERSが3になっている!もちろん性能も3倍!すごい!簡単!

キャプチャ1.png

ECS+Fargateでマスタとワーカーを同時に起動する

以下のような構成とする

ECSクラスタ
  ├── マスタ用サービス(必要数: 1)
  │     └── マスタ用タスク定義
  │           └── マスタ用コンテナ定義
  └── ワーカー用サービス(必要数: 可変)
        └── ワーカー用タスク定義
              ├── マスタヘルスチェック用コンテナ定義
              └── ワーカー用コンテナ定義

ワーカーは起動時にマスタに対して通信を行う。
この際、ECSではIPアドレスが可変になってしまうため、サービスディスカバリを使って名前解決をできるようにする。
ただし、サービスディスカバリの登録にはタイムラグがあることと、同一タスク内であれば待ち合わせができるものの、サービスを跨いでの待ち合わせはできないため、マスタヘルスチェック用コンテナがサービスディスカバリの登録を待ち合わせ、その上でワーカー用コンテナを起動するという仕組みだ。

上記を踏まえて、以下のような設定を行う。

マスタ用サービス

マスタ用サービスのポイントは以下。

  • Webサービス用ポートと、ワーカーからの通信用ポートを通すセキュリティグループを作る
  • 上記の通りのサービスディスカバリの定義を行う

なお、VPCやECS用のIAMロールやCloudWatchLogsのロググループは事前に作ってあり、ECRへのコンテナイメージも事前にPUSHできている前提である。

セキュリティグループ定義
resource "aws_security_group" "locust_master" {
  name        = "locust-master"
  description = "Locust Master port allow"
  vpc_id      = data.aws_vpc.my.id

  ingress {
    description = "Locust Master Web Port"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "Locust Master Internal Traffic Port"
    from_port   = 5557
    to_port     = 5557
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "Locust Master Internal Traffic Port"
    from_port   = 5558
    to_port     = 5558
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
サービスディスカバリ
resource "aws_service_discovery_private_dns_namespace" "locust" {
  name = local.namespace
  vpc  = data.aws_vpc.my.id
}

resource "aws_service_discovery_service" "locust_master" {
  name = local.service_name

  dns_config {
    namespace_id = aws_service_discovery_private_dns_namespace.locust.id

    dns_records {
      ttl  = 10
      type = "A"
    }

    routing_policy = "MULTIVALUE"
  }

  health_check_custom_config {
    failure_threshold = 1
  }
}
ECS
resource "aws_ecs_cluster" "locust" {
  name = local.cluster_name
}

resource "aws_ecs_service" "master" {
  name            = local.master_service_name
  cluster         = aws_ecs_cluster.locust.id
  launch_type     = "FARGATE"
  task_definition = aws_ecs_task_definition.master.arn
  desired_count   = 1

  deployment_controller {
    type = "ECS"
  }

  network_configuration {
    subnets = flatten([data.aws_subnet_ids.my_vpc.ids])

    security_groups = [
      aws_security_group.locust_master.id,
    ]

    assign_public_ip = "true"
  }

  service_registries {
    registry_arn = aws_service_discovery_service.locust_master.arn
  }
}

resource "aws_ecs_task_definition" "master" {
  family             = local.master_task_family_name
  task_role_arn      = aws_iam_role.ecs.arn
  execution_role_arn = aws_iam_role.ecs_taskexecution.arn
  network_mode       = "awsvpc"
  cpu                = "256"
  memory             = "512"
  requires_compatibilities = [
    "FARGATE",
  ]
  container_definitions = <<EOF
  [
    {
      "name" : "${local.master_container_name}",
      "image": "${data.aws_ecr_repository.my_image.repository_url}:latest",
      "cpu": 0,
      "memoryReservation": 256,
      "environment": [
        {
          "name": "MODE",
          "value": "master"
        }
      ],
      "portMappings": [
        {
          "containerPort": 80,
          "hostPort": 80,
          "protocol": "tcp"
        },
        {
          "containerPort": 5557,
          "hostPort": 5557,
          "protocol": "tcp"
        },
        {
          "containerPort": 5558,
          "hostPort": 5558,
          "protocol": "tcp"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "secretOptions": null,
        "options": {
          "awslogs-group": "${aws_cloudwatch_log_group.ecstask_log_group.name}",
          "awslogs-region": "${data.aws_region.current.name}",
          "awslogs-stream-prefix": "ecs"
        }
      }
    }
  ]
  EOF
}

ワーカー用サービス

レプリカ用サービスのポイントは以下。

  • 自身は通信を受けないので、アウトバウンド定義のみのセキュリティグループを作成する
  • 上記の通り、ヘルスチェックを行うためのダミーコンテナを使う
  • 必要数を desired_count = var.worker_count として指定できるようにする
セキュリティグループ
esource "aws_security_group" "locust_worker" {
  name        = "locust-worker"
  description = "Locust Worker port allow"
  vpc_id      = data.aws_vpc.my.id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
ECS
resource "aws_ecs_service" "worker" {
  depends_on = [
    aws_service_discovery_service.locust_master,
  ]

  name            = local.worker_service_name
  cluster         = aws_ecs_cluster.locust.id
  launch_type     = "FARGATE"
  task_definition = aws_ecs_task_definition.worker.arn
  desired_count   = var.worker_count

  deployment_controller {
    type = "ECS"
  }

  network_configuration {
    subnets = flatten([data.aws_subnet_ids.my_vpc.ids])

    security_groups = [
      aws_security_group.locust_worker.id,
    ]

    assign_public_ip = "true"
  }
}

resource "aws_ecs_task_definition" "worker" {
  family             = local.worker_task_family_name
  task_role_arn      = aws_iam_role.ecs.arn
  execution_role_arn = aws_iam_role.ecs_taskexecution.arn
  network_mode       = "awsvpc"
  cpu                = "1024"
  memory             = "2048"
  requires_compatibilities = [
    "FARGATE",
  ]
  container_definitions = <<EOF
  [
    {
      "name" : "dummy",
      "image": "${data.aws_ecr_repository.my_image_dummy.repository_url}:latest",
      "cpu": 0,
      "memoryReservation": 256,
      "logConfiguration": {
        "logDriver": "awslogs",
        "secretOptions": null,
        "options": {
          "awslogs-group": "${aws_cloudwatch_log_group.ecstask_log_group.name}",
          "awslogs-region": "${data.aws_region.current.name}",
          "awslogs-stream-prefix": "ecs"
        }
      },
      "healthCheck": {
        "command": [
          "CMD-SHELL",
          "curl -f http://${local.service_name}.${local.namespace}/ || exit 1"
        ],
        "interval": 30,
        "retries": 10,
        "startPeriod": 30,
        "timeout": 5
      }
    },
    {
      "name" : "${local.worker_container_name}",
      "image": "${data.aws_ecr_repository.my_image.repository_url}:latest",
      "cpu": 0,
      "memoryReservation": 1792,
      "environment": [
        {
          "name": "MODE",
          "value": "worker"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "secretOptions": null,
        "options": {
          "awslogs-group": "${aws_cloudwatch_log_group.ecstask_log_group.name}",
          "awslogs-region": "${data.aws_region.current.name}",
          "awslogs-stream-prefix": "ecs"
        }
      },
      "dependsOn": [
        {
          "containerName": "dummy",
          "condition": "HEALTHY"
        }
      ]
    }
  ]
  EOF
}

マスタ起動待ち合わせの仕組み

マスタ起動待ち合わせの仕組みは、ECS標準の待ち合わせ機能+ヘルスチェック機能を使う。

待ち合わせについては開発者ガイドに記載がある。

↓これをワーカー用のコンテナ定義の中に入れれば良い。

      "dependsOn": [
        {
          "containerName": "dummy",
          "condition": "HEALTHY"
        }

これは、"name"属性が dummy となっているコンテナのヘルスチェック状態がHEALTHYになるのを待ち合わせてから起動することを意味する。

ヘルスチェックの仕組みは、ECS(というかDocker)の標準機能を利用する。
ECSの仕組みとしては開発者ガイドに記載があり、Dockerの仕組みについてはここの記載が分かりやすかった。

本当は、自分に対するヘルスチェックに使うもののような気がするので、ちょっと変な使い方をしている。
以下のようにして、「curlが成功する=サービスティスカバリの登録が済んで名前解決ができるようになった」こととして、ヘルスチェック成功と扱うようにしている。

EC2とかからサービスディスカバリの名前を参照するとすぐ見つかるが、なぜかコンテナからは5分くらいかかったので、ヘルスチェックは長めに設定している。

    {
      "name" : "dummy",
      "image": "${data.aws_ecr_repository.my_image_dummy.repository_url}:latest",
      "cpu": 0,
      "memoryReservation": 256,
      "logConfiguration": {
        "logDriver": "awslogs",
        "secretOptions": null,
        "options": {
          "awslogs-group": "${aws_cloudwatch_log_group.ecstask_log_group.name}",
          "awslogs-region": "${data.aws_region.current.name}",
          "awslogs-stream-prefix": "ecs"
        }
      },
      "healthCheck": {
        "command": [
          "CMD-SHELL",
          "curl -f http://${local.service_name}.${local.namespace}/ || exit 1"
        ],
        "interval": 30,
        "retries": 10,
        "startPeriod": 30,
        "timeout": 5
      }
    },

Dockerのヘルスチェックの仕組みは、あくまでもDockerデーモンがコンテナに対して支持を出すものなので、コンテナにcurlが入っていなければいけない。以下のようにしてコンテナを定義しておこう。

FROM alpine

RUN apk add --no-cache curl

WORKDIR /var
COPY ./rundummy.sh .
RUN chmod 755 ./rundummy.sh

CMD ["./rundummy.sh"]
rundummy.sh
#!/bin/sh

while true; do date; curl -f http://[サービスディスカバリ名]/; echo $?; sleep 60; done

わざわざコンテナの中でもcurlしているのは、ヘルスチェックはどこにもログが出ず、UNHEALTHYになったときにcurlが通らなかったのか別の原因なのかの切り分けができなかったからである。

これで terraform apply -var worker_count=2 したらちゃんとワーカーが2つ上がるようになった!

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?