MQTTの負荷テストもバッチリ!!Locustを活用した分散負荷テスト環境の構築

AWS Iotに対して負荷テストを行う機会があったのですが、負荷テストツールのLocustとECSを組み合わせて、スケール可能なテスト環境が構築できたので手順をご紹介します。
2018.09.27

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

はじめに

サーバーレス開発部@大阪の岩田です。 AWS IoTに対して負荷テストを行う機会があったのですが、負荷テストツールのLocustとECSを組み合わせることで、スケール可能な負荷テスト環境が構築できたので手順をご紹介します。

Locustとは

まず、Locustについて簡単にご紹介します。 LocustはPythonで記述された分散型の負荷テストツールです。

公式サイトはこちら

  • Pythonのコードでテストシナリオが記述できる
  • master - slave構成を取ることができ、slaveを増やすことで簡単にスケールアウトすることが可能
  • WebのUIが提供されている

といった特徴があります。

WebのUIはこんなイメージです。

Locustを選定した理由

負荷テストのツールを選定する際に、Locustの対抗馬としてJMeterとGatlingが候補に上がっていたのですが、両ツールともMQTT OverTLSの負荷テストを行うのが困難で、Pythonの既存ライブラリを活用してテストシナリオを自由に記述できるLocustを採用しました。

テストシナリオ

下記のようなシナリオを想定していました。

  • 数万台のデバイスが同時にAWS IoTに対してPublishする
  • デバイスはMQTT OverTLSでAWS IoTに接続する
  • Publishするトピックはデバイスごとに固有 例) foo/device0001/bar
  • 数万台のブラウザが同時にAWS IoTに接続し、各デバイスがPublishしているトピックをSubscribeする
  • ブラウザはMQTT Over WebsocketでAWS IoTに接続する
  • ブラウザとデバイスは1:1で紐づく(pub - sub の組み合わせが数万セット出来上がる)

構築する環境

下記のような環境を構築します。

Publish用のクラスタ、Subscribe用のクラスタそれぞれにVPCを作成し、パブリックサブネット内にmasterを、プライベートサブネット内にslaveを配置します。 masterは手っ取り早くFargateで起動、slaveはFargateの上限に引っかからない様にEC2で起動します。

ソースコード

テストに使用したソースコードを見て行きます。 ※実際に使用したものから一部改変しています。

ソースコードはこちらのサイトを参考に実装しました。

記述のお作法については下記のサイトが参考になりました。

まず、ディレクトリ構成は下記の通りです。

.
├── Dockerfile
├── Pipfile
├── Pipfile.lock
├── VeriSign-Class\ 3-Public-Primary-Certification-Authority-G5.pem
├── cfn-template.yml
├── common.py
├── pub.py
├── sub.py
├── thing1-certificate.pem
└── thing1-private.pem

thing1-private.pemthing1-certificate.pemはそれぞれテストに使用するモノのクライアント証明書と秘密鍵です。 事前にAWS IoT上にモノを1つ登録し、クライアント証明書を発行しておきます。 今回は全クライアントが1つのモノを使い回す構成としました。

また、VeriSign-Class 3-Public-Primary-Certification-Authority-G5.pemがAWS IoTのCA証明書になります。 ※今後は、Amazon Trust ServicesのCA証明書を使用していく方が良いでしょう

AWS IoT Core がお客様に提供する Symantec の認証局無効化の対応方法

Pipfile

ライブラリの導入などはpipenvを利用しました。 最終的なPipfileの中身は下記の通りです。

Pipfile

[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
locustio = "*"
paho-mqtt = "*"
awsiotpythonsdk = "*"
"boto3" = "*"

[dev-packages]

[requires]
python_version = "3.6"

共通処理

Publish側とSubscribeの共通ロジックを切り出しています。 プログラムの規模が大きくならないのが分かっているので、特にクラス化などもせずに、なんでもかんでも全て1ファイルに詰め込みました。 また、使い捨てのコードなので、エラーハンドリングはガッツリ省略しています。

common.py

import boto3
import os

AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
AWS_DEFAULT_REGION = os.getenv('AWS_DEFAULT_REGION', 'us-east-1')
IOT_ENDPOINT = os.getenv('IOT_ENDPOINT')
TABLE_NAME = os.getenv('SEQ_TABLE')


QoS = 1
CA_FILE_PATH = './VeriSign-Class 3-Public-Primary-Certification-Authority-G5.pem'
DYNAMO = boto3.resource('dynamodb', region_name=AWS_DEFAULT_REGION)


def get_topic(seq:int)->str:
    imei = '{0:07d}'.format(seq)
    return f'hoge/{imei}/fuga'


def get_slave_no(mqtt_type:str)->int:

    table = DYNAMO.Table(TABLE_NAME)
    res = table.update_item(
        Key={
            'type': mqtt_type,
        },
        UpdateExpression="set seq = seq + :val",
        ExpressionAttributeValues={
            ':val': 1
        },
        ReturnValues="UPDATED_NEW"
    )
    return res['Attributes']['seq']

ポイントとしてslaveがN台構成になった時に、シナリオを実行中のLocustコンテナ側から自分が何台目のslaveなのかを把握できるようにget_slave_noという関数を作成しています。 この関数ではDynamoDBを利用して連番を採番しています。

下記のようなテーブルがあり、slaveが起動する度にSEQをカウントアップしていくようなイメージです。

type seq
pub 1
sub 1

DynamoDBのキャパシティユニットをケチりたかったので、同一slave内での同時実行数に応じたSEQカウントアップはPythonのコードで行いました。 このコードだと、テストシナリオの同時ユーザー数がslave数で割り切れない場合は複数のユーザーが同一トピックにPub・Subすることになりますが、あまりパワーやコストをかけたくなかったので、割り切って諦めました。

Publish

次にPublish側のコードです。 こちらもエラーハンドリングはガッツリ省略しています。

pub.py

# -*- coding: utf-8 -*-
 
import gevent
import json
import time
from locust import TaskSet, Locust, task, runners
from locust.events import request_success, request_failure
import ssl
import paho.mqtt.client as mqtt
import time
import random
import threading
from common import *

 
def get_client():
 
    client = mqtt.Client(protocol=mqtt.MQTTv311)
 
    client.tls_set(CA_FILE_PATH,
                   certfile='thing1-certificate.pem',
                   keyfile='thing1-private.pem',
                   tls_version=ssl.PROTOCOL_TLSv1_2)
    client.tls_insecure_set(True)
 
    return client
 
 
class MQTTPubTaskSet(TaskSet):
 
    slave_no = 0
    seq = 0
    client = None
    topic = ''
 
    def setup(self):
        MQTTPubTaskSet.slave_no = get_slave_no('pub')
        lock = threading.Lock()
        lock.acquire()
        num_clients = runners.locust_runner.num_clients
        # 自分のスレーブID -1 台のスレーブがすでに起動しており、スレーブ1台あたりのクライアント数は均等に分散されているとする
        MQTTPubTaskSet.seq = num_clients * (MQTTPubTaskSet.slave_no - 1) + 1
        lock.release()
 
    def on_start(self):
 
        self.client = get_client()
 
        lock = threading.Lock()
        lock.acquire()
        self.topic = get_topic(int(MQTTPubTaskSet.seq))
        MQTTPubTaskSet.seq += 1
        lock.release()
        self.client.connect(IOT_ENDPOINT, 8883, keepalive=60)
        self.client.loop_start()
 
    @task
    def pub(self):
 
        time.sleep(1)
        # 生データに加えて負荷テストでの集計用にtimestampを付与
        payload = json.dumps({
            "timestamp": time.time(),
            "topic": self.topic,
            "payload": {
                "hoge":"hogehoge"
            }
        })
        start_time = time.time()
        err, mid = self.client.publish(self.topic, payload, qos=QoS)
 
        if err:
            request_failure.fire(
                request_type='publish',
                name=self.topic,
                response_time=(time.time() - start_time) * 1000,
                exception=err,
            )
            return
 
        request_success.fire(
            request_type='publish',
            name=self.topic,
            response_time=(time.time() - start_time) * 1000,
            response_length=len(payload),
        )
 
 
class Devices(Locust):
    task_set = MQTTPubTaskSet
    min_wait = 1
    max_wait = 1

MQTT OverTLSで接続するためにpaho mqttを使用しています。

なお、接続のために、事前にAWS Iot側でモノと証明書、ポリシーの登録を行う必要があります。

Subscribe

次にSubscribe側のコードです。 こちらもエラーハンドリングはガッツリ省略しています。

sub.py

# -*- coding: utf-8 -*-

import json
import time
import uuid
import gevent
from locust import TaskSet, Locust, runners, task
from locust.events import request_success
from AWSIoTPythonSDK.MQTTLib import AWSIoTMQTTClient
import threading
from common import *


def on_receive(client, userdata, message):
    payload = json.loads(message.payload)
    elapsed = time.time() - payload['timestamp']
    request_success.fire(
        request_type='sub',
        name=message.topic,
        response_time=elapsed * 1000,
        response_length=len(message.payload),
    )


def get_client():
    client = AWSIoTMQTTClient(clientID=uuid.uuid4().hex,
                              useWebsocket=True)
    client.configureIAMCredentials(AWSAccessKeyID=AWS_ACCESS_KEY_ID,
                                   AWSSecretAccessKey=AWS_SECRET_ACCESS_KEY)
    client.configureCredentials(CAFilePath=CA_FILE_PATH)
    client.configureEndpoint(hostName=IOT_ENDPOINT,
                             portNumber=443)
    client.configureOfflinePublishQueueing(-1)
    client.configureDrainingFrequency(2)
    client.configureConnectDisconnectTimeout(120)
    client.configureMQTTOperationTimeout(60)

    return client


class AWSIoTTaskSet(TaskSet):

    slave_no = 0
    seq = 0
    client = None
    clients = {}
    topic = ''

    def setup(self):
        AWSIoTTaskSet.slave_no = get_slave_no('sub')
        lock = threading.Lock()
        lock.acquire()
        num_clients = runners.locust_runner.num_clients
        # 自分のスレーブID -1 台のスレーブがすでに起動しており、スレーブ1台あたりのクライアント数は均等に分散されているとする
        AWSIoTTaskSet.seq = num_clients * (AWSIoTTaskSet.slave_no - 1) + 1

        lock.release()

    def on_start(self):
        self.client = get_client()
        self.client.connect()

        lock = threading.Lock()
        lock.acquire()
        user_count = runners.locust_runner.user_count
        seq = AWSIoTTaskSet.seq
        AWSIoTTaskSet.seq += 1
        lock.release()
        topic = get_topic(int(seq))
        self.client.subscribe(topic, QoS, on_receive)

        while True:
            time.sleep(300)

    @task
    def dummy(self):
        # on_startの中で無限ループさせるため実際にこの処理は呼ばれないが、
        # taskが無いとlocust的にエラーになるのでダミーのタスクを作成しておく
        print('----------dummy task called--------------')


class AWSIoTUser(Locust):
    task_set = AWSIoTTaskSet
    min_wait = 1
    max_wait = 1

Subscribe側はテスト終了までずっとSubscribeし続けておいて欲しいので、on_startの中でAWS Iotに接続した後は無限ループさせています。 また、タスクが無いと怒られるようなのでdummyというメソッドを作成しています。

こちらはMQTT Over Websocketで接続するためAWSIoTMQTTClientを利用しています。

Dockerfile

Dockerfileです。

FROM python:3.6
RUN pip install pipenv
RUN mkdir /app
WORKDIR /app
ADD Pipfile /app/
ADD Pipfile.lock /app/
RUN LIBRARY_PATH=/lib:/usr/lib pipenv install --system --ignore-pipfile
ADD . /app/

構築手順

実際に負荷テストの環境を構築していきます。

VPC等のリソース作成

下記のCloudFormationのテンプレートで構築しました。 EC2のインスタンスサイズは決め打ちでm5.largeにしているので、必要に応じて適宜修正します。

cfn-template.yml

AWSTemplateFormatVersion: 2010-09-09
Description: Setup Stress Test Environment
Parameters:
  UserGIP:
    Description: The IP address range that can be used to Locust WebUI
    Type: String
    MinLength: '9'
    MaxLength: '18'
    Default: 0.0.0.0/0
    AllowedPattern: "(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})/(\\d{1,2})"
    ConstraintDescription: must be a valid IP CIDR range of the form x.x.x.x/x.
Resources:
  PubVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: 'true'
      EnableDnsHostnames: 'true'
      InstanceTenancy: default
      Tags:
        -
          Key: Name
          Value: locust Pub VPC
  PubVPCPublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref PubVPC
      Tags:
        -
          Key: Name
          Value: PubVPCPublicRouteTable
  PubVPCPrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref PubVPC
      Tags:
        -
          Key: Name
          Value: PubVPCPrivateRouteTable
  PubVPCPublicSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref PubVPC
      CidrBlock: 10.0.0.0/24
      AvailabilityZone: us-east-1a
      MapPublicIpOnLaunch: true
      Tags:
        -
          Key: Name
          Value: PubVPCPublicSubnetA
  PubVPCPublicSubnetARouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PubVPCPublicSubnetA
      RouteTableId: !Ref PubVPCPublicRouteTable
  PubVPCPrivateSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref PubVPC
      CidrBlock: 10.0.1.0/24
      AvailabilityZone: us-east-1a
      Tags:
        -
          Key: Name
          Value: PubVPCPrivateSubnetA
  PubVPCPrivateSubnetARouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PubVPCPrivateSubnetA
      RouteTableId: !Ref PubVPCPrivateRouteTable
  PubVPCInternetGateway:
    Type: AWS::EC2::InternetGateway
  PubVPCAttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref PubVPC
      InternetGatewayId: !Ref PubVPCInternetGateway
  PubVPCRoute:
    Type: AWS::EC2::Route
    DependsOn: PubVPCInternetGateway
    Properties:
      RouteTableId: !Ref PubVPCPublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref PubVPCInternetGateway
  PubVPCPrivateSubDefaultRoute:
    Type: AWS::EC2::Route
    DependsOn: PubVPCNatGateway
    Properties:
      RouteTableId: !Ref PubVPCPrivateRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref PubVPCNatGateway
  PubVPCNatGatewayEIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
  PubVPCNatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt PubVPCNatGatewayEIP.AllocationId
      SubnetId: !Ref PubVPCPublicSubnetA
      Tags:
        -
          Key: Name
          Value: PubVPCNatGateway
  PubVPCLocustSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Allow Access To Locust
      VpcId: !Ref PubVPC
  PubVPCLocustSecurityGroupIngressWebUIInner:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref PubVPCLocustSecurityGroup
      IpProtocol: tcp
      FromPort: 8089
      ToPort: 8089
      SourceSecurityGroupId: !Ref PubVPCLocustSecurityGroup
  PubVPCLocustSecurityGroupIngressMasterSlaveInner:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref PubVPCLocustSecurityGroup
      IpProtocol: tcp
      FromPort: 5557
      ToPort: 5558
      SourceSecurityGroupId: !Ref PubVPCLocustSecurityGroup
  PubVPCLocustSecurityGroupIngressWebUI:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref PubVPCLocustSecurityGroup
      IpProtocol: tcp
      FromPort: 8089
      ToPort: 8089
      CidrIp: !Sub ${UserGIP}
  PubVPCLocustSecurityGroupIngressMasterSlave:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref PubVPCLocustSecurityGroup
      IpProtocol: tcp
      FromPort: 5557
      ToPort: 5557
      CidrIp: !Sub ${UserGIP}
  PubVPCLocustSecurityGroupIngressSSH:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref PubVPCLocustSecurityGroup
      IpProtocol: tcp
      FromPort: 22
      ToPort: 22
      CidrIp: !Sub ${UserGIP}
  SubVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: 'true'
      EnableDnsHostnames: 'true'
      InstanceTenancy: default
      Tags:
        -
          Key: Name
          Value: locust Sub VPC
  SubVPCPublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref SubVPC
      Tags:
        -
          Key: Name
          Value: SubVPCPublicRouteTable
  SubVPCPrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref SubVPC
      Tags:
        -
          Key: Name
          Value: SubVPCPrivateRouteTable
  SubVPCPublicSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref SubVPC
      CidrBlock: 10.0.0.0/24
      AvailabilityZone: us-east-1a
      MapPublicIpOnLaunch: true
      Tags:
        -
          Key: Name
          Value: SubVPCPublicSubnetA
  SubVPCPublicSubnetARouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref SubVPCPublicSubnetA
      RouteTableId: !Ref SubVPCPublicRouteTable
  SubVPCPrivateSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref SubVPC
      CidrBlock: 10.0.1.0/24
      AvailabilityZone: us-east-1a
      Tags:
        -
          Key: Name
          Value: SubVPCPrivateSubnetA
  SubVPCPrivateSubnetARouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref SubVPCPrivateSubnetA
      RouteTableId: !Ref SubVPCPrivateRouteTable
  SubVPCInternetGateway:
    Type: AWS::EC2::InternetGateway
  SubVPCAttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref SubVPC
      InternetGatewayId: !Ref SubVPCInternetGateway
  SubVPCRoute:
    Type: AWS::EC2::Route
    DependsOn: SubVPCInternetGateway
    Properties:
      RouteTableId: !Ref SubVPCPublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref SubVPCInternetGateway
  SubVPCPrivateSubDefaultRoute:
    Type: AWS::EC2::Route
    DependsOn: SubVPCNatGateway
    Properties:
      RouteTableId: !Ref SubVPCPrivateRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref SubVPCNatGateway
  SubVPCNatGatewayEIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
  SubVPCNatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt SubVPCNatGatewayEIP.AllocationId
      SubnetId: !Ref SubVPCPublicSubnetA
      Tags:
        -
          Key: Name
          Value: SubVPCNatGateway
  SubVPCLocustSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Allow Access To Locust
      VpcId: !Ref SubVPC
  SubVPCLocustSecurityGroupIngressWebUIInner:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref SubVPCLocustSecurityGroup
      IpProtocol: tcp
      FromPort: 8089
      ToPort: 8089
      SourceSecurityGroupId: !Ref SubVPCLocustSecurityGroup
  SubVPCLocustSecurityGroupIngressMasterSlaveInner:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref SubVPCLocustSecurityGroup
      IpProtocol: tcp
      FromPort: 5557
      ToPort: 5558
      SourceSecurityGroupId: !Ref SubVPCLocustSecurityGroup
  SubVPCLocustSecurityGroupIngressWebUI:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref SubVPCLocustSecurityGroup
      IpProtocol: tcp
      FromPort: 8089
      ToPort: 8089
      CidrIp: !Sub ${UserGIP}
  SubVPCLocustSecurityGroupIngressMasterSlave:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref SubVPCLocustSecurityGroup
      IpProtocol: tcp
      FromPort: 5557
      ToPort: 5558
      CidrIp: !Sub ${UserGIP}
  ECSTaskRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          -
            Effect: Allow
            Principal:
              Service:
                - ecs-tasks.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
  ECSTaskRolePolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: AllowDynamoDB
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          -
            Effect: Allow
            Action: dynamodb:*
            Resource: "*"
      Roles:
        - !Ref ECSTaskRole
  ECSTaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          -
            Effect: Allow
            Principal:
              Service:
                - ecs-tasks.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
        - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
  ECRRepository:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: hogehoge/locust
  LocustPubCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: LocustPubCluster
  LocustSubCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: LocustSubCluster
  EcsInstancePubAsg:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      VPCZoneIdentifier:
        - Fn::Join:
          - ','
          - - !Ref PubVPCPrivateSubnetA
      LaunchConfigurationName: !Ref PubInstanceLc
      MinSize: 0
      MaxSize: 100
      DesiredCapacity: 0
  EcsInstanceSubAsg:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      VPCZoneIdentifier:
        - Fn::Join:
          - ','
          - - !Ref SubVPCPrivateSubnetA
      LaunchConfigurationName: !Ref SubInstanceLc
      MinSize: 0
      MaxSize: 100
      DesiredCapacity: 0
  PubInstanceLc:
    Type: AWS::AutoScaling::LaunchConfiguration
    Properties:
      ImageId: ami-0254e5972ebcd132c
      InstanceType: m5.large
      IamInstanceProfile: !Ref EC2InstanceProfile
      SecurityGroups:
        - !Ref PubVPCLocustSecurityGroup
      BlockDeviceMappings:
        - DeviceName: /dev/xvdcz
          Ebs:
            VolumeType: gp2
            VolumeSize: 32
      UserData:
        Fn::Base64:
          Fn::Sub: |
            #!/bin/bash
            echo ECS_CLUSTER=LocustPubCluster >> /etc/ecs/ecs.config
            echo ECS_AVAILABLE_LOGGING_DRIVERS='["json-file","awslogs"]' >> /etc/ecs/ecs.config
            echo ECS_ENABLE_TASK_IAM_ROLE=true >> /etc/ecs/ecs.config
            echo ECS_ENABLE_TASK_IAM_ROLE_NETWORK_HOST=true >> /etc/ecs/ecs.config
            yum update -y
  SubInstanceLc:
    Type: AWS::AutoScaling::LaunchConfiguration
    Properties:
      ImageId: ami-0254e5972ebcd132c
      InstanceType: m5.large
      IamInstanceProfile: !Ref EC2InstanceProfile
      SecurityGroups:
        - !Ref SubVPCLocustSecurityGroup
      BlockDeviceMappings:
        - DeviceName: /dev/xvdcz
          Ebs:
            VolumeType: gp2
            VolumeSize: 32
      UserData:
        Fn::Base64:
          Fn::Sub: |
            #!/bin/bash
            echo ECS_CLUSTER=LocustSubCluster >> /etc/ecs/ecs.config
            echo ECS_AVAILABLE_LOGGING_DRIVERS='["json-file","awslogs"]' >> /etc/ecs/ecs.config
            echo ECS_ENABLE_TASK_IAM_ROLE=true >> /etc/ecs/ecs.config
            echo ECS_ENABLE_TASK_IAM_ROLE_NETWORK_HOST=true >> /etc/ecs/ecs.config
            yum update -y
  EC2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: /
      Roles: [!Ref 'EC2Role']
  EC2Role:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
        - Effect: Allow
          Principal:
            Service: [ec2.amazonaws.com]
          Action: ['sts:AssumeRole']
      Path: /
      Policies:
      - PolicyName: ecs-service
        PolicyDocument:
          Statement:
          - Effect: Allow
            Action: ['ecs:CreateCluster', 'ecs:DeregisterContainerInstance', 'ecs:DiscoverPollEndpoint',
              'ecs:Poll', 'ecs:RegisterContainerInstance', 'ecs:StartTelemetrySession',
              'ecs:Submit*', 'logs:CreateLogStream', 'logs:PutLogEvents']
            Resource: '*'
  LocustPubMasterTaskDef:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Cpu: 512
      Family: locust-pub-master
      Memory: 1GB
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      TaskRoleArn : !GetAtt ECSTaskRole.Arn
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      ContainerDefinitions:
        -
          Command:
            - "locust"
            - "--master"
            - "-f"
            - "pub.py"
          Image: !Sub
              - '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${RepoName}'
              - {RepoName: !Ref ECRRepository}
          Name: locust-pub-master
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-region: !Sub '${AWS::Region}'
              awslogs-group: !Ref PubMasterLog
              awslogs-stream-prefix: !Ref PubMasterLog
  LocustPubSlaveTaskDef:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Cpu: 512
      Family: locust-pub-slave
      Memory: 1GB
      NetworkMode: bridge
      RequiresCompatibilities:
        - EC2
      TaskRoleArn : !GetAtt ECSTaskRole.Arn
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      ContainerDefinitions:
        -
          Command:
            - "locust"
            - "--slave"
            - "-f"
            - "pub.py"
            - "--master-host"
            - "pub-master.local"
          Environment:
            -
              Name: IOT_ENDPOINT
              Value: xxxxxx.iot.us-east-1.amazonaws.com
            -
              Name: SEQ_TABLE
              Value: seq_table
          Image: !Sub
              - '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${RepoName}'
              - {RepoName: !Ref ECRRepository}
          Name: locust-pub-slave
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-region: !Sub '${AWS::Region}'
              awslogs-group: !Ref PubSlaveLog
              awslogs-stream-prefix: !Ref PubSlaveLog
  LocustSubMasterTaskDef:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Cpu: 512
      Family: locust-sub-master
      Memory: 1GB
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      TaskRoleArn : !GetAtt ECSTaskRole.Arn
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      ContainerDefinitions:
        -
          Command:
            - "locust"
            - "--master"
            - "-f"
            - "sub.py"
          Image: !Sub
              - '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${RepoName}'
              - {RepoName: !Ref ECRRepository}
          Name: locust-sub-master
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-region: !Sub '${AWS::Region}'
              awslogs-group: !Ref SubMasterLog
              awslogs-stream-prefix: !Ref SubMasterLog
  LocustSubSlaveTaskDef:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Cpu: 512
      Family: locust-sub-slave
      Memory: 1GB
      NetworkMode: bridge
      RequiresCompatibilities:
        - EC2
      TaskRoleArn : !GetAtt ECSTaskRole.Arn
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      ContainerDefinitions:
        -
          Command:
            - "locust"
            - "--slave"
            - "-f"
            - "sub.py"
            - "--master-host"
            - "sub-master.local"
          Environment:
            -
              Name: IOT_ENDPOINT
              Value: xxxxxx.iot.us-east-1.amazonaws.com
            -
              Name: SEQ_TABLE
              Value: seq_table
            -
              Name: AWS_ACCESS_KEY_ID
              Value: xxxxxxxxxx
            -
              Name: AWS_SECRET_ACCESS_KEY
              Value: xxxxxxxxxx
          Image: !Sub
              - '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${RepoName}'
              - {RepoName: !Ref ECRRepository}
          Name: locust-sub-slave
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-region: !Sub '${AWS::Region}'
              awslogs-group: !Ref SubSlaveLog
              awslogs-stream-prefix: !Ref SubSlaveLog
  LocustPubMasterService:
    Type: AWS::ECS::Service
    Properties:
      Cluster: !GetAtt LocustPubCluster.Arn
      DesiredCount: 0
      LaunchType: FARGATE
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: ENABLED
          SecurityGroups:
             - !Ref PubVPCLocustSecurityGroup
          Subnets:
             - !Ref PubVPCPublicSubnetA
      ServiceName: locust-pub-master
      TaskDefinition: !Ref LocustPubMasterTaskDef
  LocustPubSlaveService:
    Type: AWS::ECS::Service
    Properties:
      Cluster: !GetAtt LocustPubCluster.Arn
      DesiredCount: 0
      LaunchType: EC2
      ServiceName: locust-pub-slave
      TaskDefinition: !Ref LocustPubSlaveTaskDef
  LocustSubMasterService:
    Type: AWS::ECS::Service
    Properties:
      Cluster: !GetAtt LocustSubCluster.Arn
      DesiredCount: 0
      LaunchType: FARGATE
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: ENABLED
          SecurityGroups:
             - !Ref SubVPCLocustSecurityGroup
          Subnets:
             - !Ref SubVPCPublicSubnetA
      ServiceName: locust-sub-master
      TaskDefinition: !Ref LocustSubMasterTaskDef
  LocustSubSlaveService:
    Type: AWS::ECS::Service
    Properties:
      Cluster: !GetAtt LocustSubCluster.Arn
      DesiredCount: 0
      LaunchType: EC2
      ServiceName: locust-sub-slave
      TaskDefinition: !Ref LocustSubSlaveTaskDef
  SeqTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: seq_table
      AttributeDefinitions:
        -
          AttributeName: type
          AttributeType: S
      KeySchema:
        -
          AttributeName: type
          KeyType: HASH
      ProvisionedThroughput:
        ReadCapacityUnits: 1
        WriteCapacityUnits: 1
  PubMasterLog:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /ecs/pub-master
  PubSlaveLog:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /ecs/pub-slave
  SubMasterLog:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /ecs/sub-master
  SubSlaveLog:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /ecs/sub-slave
Outputs:
  LocustPubCluster:
    Description: LocustPubCluster
    Value: !GetAtt LocustPubCluster.Arn
  LocustSubCluster:
    Description: LocustSubCluster
    Value: !GetAtt LocustSubCluster.Arn
  PubVPCSecurityGroup:
    Description: PubVPCSecurityGroup
    Value: !Ref PubVPCLocustSecurityGroup
  PubVPCPublicSubnet:
      Description: PubVPCPublicSubnet
      Value: !Ref PubVPCPublicSubnetA
  PubVPCPrivateSubnet:
      Description: PubVPCPrivateSubnet
      Value: !Ref PubVPCPrivateSubnetA
  SubVPCSecurityGroup:
    Description: SubVPCSecurityGroup
    Value: !Ref SubVPCLocustSecurityGroup
  SubVPCPublicSubnet:
      Description: SubVPCPublicSubnet
      Value: !Ref SubVPCPublicSubnetA
  SubVPCPrivateSubnet:
      Description: SubVPCPrivateSubnet
      Value: !Ref SubVPCPrivateSubnetA
  LocustPubMasterTaskDef:
      Description: LocustPubMasterTaskDef
      Value: !Ref LocustPubMasterTaskDef
  LocustPubSlaveTaskDef:
      Description: LocustPubSlaveTaskDef
      Value: !Ref LocustPubSlaveTaskDef
  LocustSubMasterTaskDef:
      Description: LocustSubMasterTaskDef
      Value: !Ref LocustSubMasterTaskDef
  LocustSubSlaveTaskDef:
      Description: LocustSubSlaveTaskDef
      Value: !Ref LocustSubSlaveTaskDef

コンテナイメージのビルド&プッシュ

リソースが作成できたらDockerイメージをビルドしてECRにプッシュします

docker build -t hogehoge/locust .
$(aws ecr get-login --no-include-email --region us-east-1) 
docker tag hogehoge/locust:latest xxxxxxxxxx.dkr.ecr.us-east-1.amazonaws.com/hogehoge/locust:latest
docker push xxxxxxxxxx.dkr.ecr.us-east-1.amazonaws.com/hogehoge/locust:latest

DynamoDBへのアイテムPUT

連番採番用のテーブルに必要なアイテムをPUTしておきます

aws dynamodb put-item --table-name seq_table --item '{"type": {"S": "pub"}, "seq": {"N": "0"}}' --region us-east-1
aws dynamodb put-item --table-name seq_table --item '{"type": {"S": "sub"}, "seq": {"N": "0"}}' --region us-east-1

負荷テスト実施

実際にテストを実施していく手順です。

Publish用masterを起動

Publish用のmasterを起動します。

AWSマネジメントコンソールからサービスにECSを選択 クラスター:LocustPubClusterのサービスlocust-pub-masterを選択し、タスク数を0から1に変更します。

タスクが起動するとIPアドレス(パブリック,プライベート)が付与されるので控えておきます。

Subscribe用masterを起動

Publish用のmasterと同様の手順で起動します。

Publish用slaveを起動

次にslaveを起動して行きます。

タスク定義の修正

タスク定義を修正し、環境変数等を適切に設定します。

AWSマネジメントコンソールのメニュー「タスク定義」からlocust-pub-slaveを開き、最新のリビジョンを選択した状態から「新しいリビジョンの作成」をクリック

次にタスク定義の詳細が開くので、コンテナlocust-pub-slaveを選択し、コマンドの末尾でmasterサーバーの指定がpub-master.localとなっている箇所を、先ほど起動したmasterコンテナのプライベートIPに変更します。ここはうまくサービスディスカバリを使って自動化したかったのですが、CloudFormationで設定できないようだったので、今回は手作業でやることにしました。

合わせて環境変数IOT_ENDPOINTを負荷テスト対象のエンドポイントに設定します。

EC2の起動

マネジメントコンソール等から、Auto Scaling グループlocust-EcsInstancePubAsg-xxxxxの設定値を変更しEC2を起動させます。

slave用サービスのタスク数調整

master用サービスと同様にslave用のタスク数を変更します。 この際タスク定義のリビジョンを先ほど修正した最新版のタスク定義に変更して下さい。

Subscribe用slaveを起動

Publish用のslaveと同様の手順で起動して行きます。 追加で環境変数AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYにそれぞれアクセスキーIDとシークレットアクセスキーを設定して下さい。

シナリオ実行

Publish・Subscribe両方の準備ができたので、実際にテストを実行して行きます。

http://<Publish用masterのGIP>:8089 にアクセスし、シミュレーションしたいユーザー数とHatch Rate(1秒間に何ユーザー起動するか)を入力します。 なお、画面右上には現在接続されているslaveの台数が表示されます。

同じようにSubscribe側でもテストを実行開始し、しばらく待つと・・・・

テスト状況が表示されました!! 後はシナリオで決めた時間だけテストを流して「Download Data」のタブから各種のデータをDLして分析すればOKです。

まとめ

Locustを使用した負荷テストについて見て来ました。

今回実施したシナリオだと、ネットワーク周りがボトルネックになっている様で(詳細は調査中です)、1コンテナあたり約330ユーザー程度、EC2インスタンス1台(というよりENI1個)あたりを3コンテナを超えたあたりから、AWS IoTへの接続失敗が頻発しだしました。

そのため、ユーザー数は1コンテナあたり300、EC2毎のコンテナ数は3を目安にスケールさせていったのですが、EC2のインスタンス数の上限に引っかかり、2万接続のシナリオが実施できませんでした。 2万接続超のシナリオを実施する場合はEC2の上限緩和申請が必要になるのでご留意下さい。 また、同時接続数をさらに上げていくと今は見えていないボトルネックやAWSのサービス上限に引っかかる可能性もあります。 もしこのブログを参考に負荷テストを実施する場合、構成については十分に検討して頂く様お願いします。 また、上限緩和申請とは別に、AWSへの相談も忘れずにお願いします。

参考

下記のサイトを参考にさせて頂きました