CDKでECS(EC2)上にLocust masterとworkerのServiceをデプロイしCloud Mapで名前解決させる

aws

以前、EKS上にLocustをインストールしたが、使わないときにクラスタを落としていたりすると起動を待つ必要があったり、K8sのバージョンアップに追従する必要があったりと少し不便な所があった。

CDKでEKSクラスタの作成からHelm ChartでのLocustのインストールまでを一気に行う - sambaiz-net

そこで今回はECS上にLocust masterとworkerのServiceをデプロイする。

Dockerfile

次のようなDockerfileを用意する。

$ cat Dockerfile
FROM locustio/locust

COPY locustfile.py /mnt/locust/

Stack

クラスタの作成については以前のものを使い回す。

CDKでALBとECS(EC2)クラスタを作成し、ecs-cliでDocker Composeの構成をデプロイする - sambaiz-net

locustfileを更新する度にデプロイすることになるので、ALBのヘルスチェックの間隔と回数および登録解除のタイムアウト時間を減らして高速化している。また、locustに認証の仕組みがないためIPアドレスでアクセス制限をしているが、ApplicationListenerのopenがデフォルトのtrueのままだとAllow from anyoneの設定が追加されフルオープンになってしまうのでfalseにしている。

import * as cdk from '@aws-cdk/core'
import * as ecs from '@aws-cdk/aws-ecs'
import * as ec2 from '@aws-cdk/aws-ec2'
import * as iam from '@aws-cdk/aws-iam'
import * as autoscaling from '@aws-cdk/aws-autoscaling'
import * as elbv2 from '@aws-cdk/aws-elasticloadbalancingv2'
import * as logs from '@aws-cdk/aws-logs'

export class LocustECSStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)
    const cluster = this.createECSCluster(2)
    const logGroup = this.createLogGroup()
    const locustMaster = this.createLocustMasterService(cluster, logGroup)
    this.createALB(cluster.vpc, locustMaster)
    this.createLocustWorkerService(cluster, logGroup, 1).node.addDependency(locustMaster)
  }

  createECSCluster(capacity: number) {
    const cluster = new ecs.Cluster(this, `ECSCluster`, {
      clusterName: 'locust-ecs',
      defaultCloudMapNamespace: {
        name: 'locust-ecs.local'
      }
    })
    const securityGroup = new ec2.SecurityGroup(this, `EC2SecurityGroup`, {
      vpc: cluster.vpc,
    })
    securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.allTcp())
    const autoScalingGroup = new autoscaling.AutoScalingGroup(this, `AutoScalingGroup`, {
      vpc: cluster.vpc,
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MEDIUM),
      machineImage:  ecs.EcsOptimizedImage.amazonLinux2(),
      minCapacity: capacity,
      maxCapacity: capacity,
      securityGroup,
    })
    cluster.addAsgCapacityProvider(new ecs.AsgCapacityProvider(this, `AsgCapacityProvider`, {
      autoScalingGroup
    }))
    autoScalingGroup.role.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy'))
    return cluster
  }

  createLogGroup() {
    return new logs.LogGroup(this, 'LogGroup', {
      logGroupName: 'locust-ecs',
      retention: logs.RetentionDays.TWO_WEEKS 
    })
  }

  createLocustMasterService(cluster: ecs.ICluster, logGroup: logs.ILogGroup) {
    const taskDefinition = new ecs.Ec2TaskDefinition(this, 'LocustMasterTaskDefinition', {
      networkMode: ecs.NetworkMode.AWS_VPC,
    })
    taskDefinition.addContainer('LocustMasterContainer', {
      containerName: "locust",
      image: ecs.ContainerImage.fromAsset("./lib"),
      memoryLimitMiB: 256,
      cpu: 128,
      command: ["-f", "/mnt/locust/locustfile.py", "--master"],
      logging: new ecs.AwsLogDriver({
        logGroup,
        streamPrefix: "locust-master"
      })
    }).addPortMappings({
      containerPort: 8089,
    }, {
      containerPort: 5557,
    }, {
      containerPort: 5558,
    })
    const securityGroup = new ec2.SecurityGroup(this, `LocustMasterServiceSecurityGroup`, {
      vpc: cluster.vpc,
    })
    securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.allTcp())
    return new ecs.Ec2Service(this, 'LocustMasterService', {
      serviceName: "locust-master",
      cluster,
      taskDefinition,
      cloudMapOptions: {
        name: "master",
      },
      securityGroups: [securityGroup]
    })
  }

  createLocustWorkerService(cluster: ecs.ICluster, logGroup: logs.ILogGroup, desiredCount: number) {
    const taskDefinition = new ecs.Ec2TaskDefinition(this, 'LocustWorkerTaskDefinition')
    taskDefinition.addContainer('LocustWorkerContainer', {
      containerName: "locust",
      image: ecs.ContainerImage.fromAsset("./lib"),
      memoryLimitMiB: 256,
      cpu: 128,
      command: ["-f", "/mnt/locust/locustfile.py", "--worker", "--master-host", "master.locust-ecs.local"],
      logging: new ecs.AwsLogDriver({
        logGroup,
        streamPrefix: "locust-worker"
      }),
    }).addUlimits({
      name: UlimitName.FSIZE,
      hardLimit: 65535,
      softLimit: 65535
    })
    return new ecs.Ec2Service(this, 'LocustWorkerService', {
      serviceName: "locust-worker",
      cluster,
      taskDefinition,
      desiredCount,
    })
  }

  createALB(vpc: ec2.IVpc, service: ecs.Ec2Service) {
    const securityGroup = new ec2.SecurityGroup(this, 'ALBSecurityGroup', {
      vpc: vpc,
    })
    securityGroup.addIngressRule(ec2.Peer.ipv4("xxx.xxx.xxx.xxx/32"), ec2.Port.tcp(80))
    const alb = new elbv2.ApplicationLoadBalancer(this, 'ALB', {
      loadBalancerName: "locust-ecs",
      vpc: vpc,
      internetFacing: true,
      securityGroup
    })
    const target = new elbv2.ApplicationTargetGroup(this, 'ALBTargetGroup', {
      targetType: elbv2.TargetType.IP,
      vpc: vpc,
      protocol: elbv2.ApplicationProtocol.HTTP,
      port: 80,
      targets: [service.loadBalancerTarget({
        containerName: 'locust',
        containerPort: 8089
      })],
      healthCheck: {
        interval: cdk.Duration.seconds(6),
        healthyThresholdCount: 2
      },
      deregistrationDelay: cdk.Duration.seconds(5)
    })
    new elbv2.ApplicationListener(this, 'ALBListener', {
      loadBalancer: alb,
      defaultTargetGroups: [target],
      protocol: elbv2.ApplicationProtocol.HTTP,
      port: 80,
      open: false
    })
    return alb
  }
}

Cloud Map

workerは--master-hostとしてLBのDNS名やインスタンスのIPアドレスではなくmaster.locust-ecs.localを渡している。 これはCloud MapによってRoute53に登録される動的なレコードで、 Taskのnetwork modeがawsvpcの場合、 Aレコードとして、EC2インスタンスに追加で紐づけられるTask専用のENIのIPアドレスが返される。 Taskが動いていてzmq.error.Again: Resource temporarily unavailableなどのエラーでWorkerが登録されない場合は、ENIのSGで弾かれていないか確認する。

参考

Amazon ECS でのコンテナデプロイの高速化 | トリの部屋