ECS + Fargateで動いていたクローラーをSpot Fleetで格安運用を目指す！

## きっかけ

1年前くらいにクローラーをEC2からECS + Fargate構成に移行し、運用してきたのですが、少々Fargateがお高いなぁと思い始めてきまして、お盆で時間があるということもあり、Spot Fleetを使って最安なバッチ実行環境の構築に挑戦してみようと思ったのがことの発端です。

以前作ったクローラーは下記記事のやつです。
https://qiita.com/uramotot/items/5f3fe91f9b78ff6ea450

## 前提

クローラーはPythonで記述されており、以下のライブラリを使っていました。

- [Scrapy](https://scrapy.org): Crawlerのフレームワーク
- [Splash](https://splash.readthedocs.io/en/stable/index.html): JSをレンダリングするためのサービス 
- [RabbitMQ](https://www.rabbitmq.com): 非同期処理のためのMessage Broker

## Architecture

### Before

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120358/fdefa9d7-2fe8-cb22-b059-4a2144b14f95.png)

以前はこのような構成で、ECS Schedulerを使ってCrawler用のECS Taskを日時で呼び出し、ECS上にFargateを使って実行されるようになっていました。

Scraping処理の方は、RabbitMQを使って非同期で処理が行えるようになっており、こちらはECS Serviceで常時立っています。
(ECS TaskとECS Serviceについては、[以前の記事](https://qiita.com/uramotot/items/5f3fe91f9b78ff6ea450)でも説明しておりますので、割愛します。)

スクレイピング結果は処理が終わり次第、RabbitMQのWorkerがRDSに格納していました。

### After

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/120358/942901f7-0778-7bca-c5c7-a1f0156c6d41.png)

大きな変更が3つあります。

まず、1つ目に、Scraping処理を非同期にしていた部分を取りやめて、Scraping処理をscrapyのitem pipelineで実行することにしました。
理由としては、一年近く運用してみて、Retryすることが一度もなかったことと、要件的にクローリングの失敗した際にリトライが必須ではなく、次の日の実行を待って良いくらい緩いものだったためです。

また、冪等な書き方をしておけば、仮にクローリングに失敗したとしても、あまり問題にならなそうだなと思ったためです。

ということで、Scraping部分がなくなり、シンプルな構成になったことで、必要なリソースをかなり減らす事ができました。

2つ目は、スクレイピングの処理結果を格納する部分もRDSからDynamoDBに移行しました。
こちらの移行理由としては、スクレイピング結果は対象ドメインによってデータ構造が結構変えたいことも多く、ドキュメント型の性質を持っているDynamoDBを選択しました。
また、DynamoDBはReadとWriteの秒間リクエスト数で課金されるため、クローリングの間隔を最適に設定しておけば、かなり安く使うことができます。
(あと、ストレージは最初の25GBは無料で使えるという太っ腹サービスでもあります。)

3つ目は、ECS Taskの起動タイプをFargateからEC2に変更し、ECS Cluster InstanceにEC2 Spot Fleetを用いているところです。
こちらの理由としては、FargateはTaskの実行時にしか課金されないため、バッチ実行時には比較的安く済ませることができるのですが、オンデマンドのEC2インスタンスよりも少しだけ値段が高いです。
また、Spot Instanceのようなものはないので、ある程度vCPUやMemoryを確保するとまぁまぁ高くなってしまいます。

Spot Fleetは必要なvCPUやMemoryを設定するだけで、Spot Instanceを取得してくれる便利サービスです。
Spot Instanceは、AWS Cloud内の使用されていない EC2キャパシティーを活用でき、最大90%の割引を受けられるポテンシャルを持っています。
Spot Instanceを使うことで、vCPUやMemoryをある程度確保したとしても安く済ませることができます。

また、Application Auto Scalingを使うことで、Spot Instanceの数を増減させるScheduleを設定することが可能になります。
これを用いることで、バッチの実行時間にのみSpot Instanceが起動するようになっています。

## どれだけ安くなったのか

ということで、3つの大きな変更を加えたわけですが、具体的にどれほど安くなったのでしょうか？
今回はvCPU: 2, Memory: 4GB、その他サービスは最小構成で、月額を計算します。
クローラーの一回の実行時間は3時間とします。

### Before

Fargateの料金については現時点では、以下の表のとおりです。

| | 料金 |
| --- | ---| 
| 1vCPU | 0.05056 [USD/h] |
| 1GB Memory | 0.00553 [USD/h] |

https://aws.amazon.com/jp/fargate/pricing/

以下がBefore構成の料金表です。

| Service | Capacity | hour | Price per month |
| --- | --- | --- | --- |
| Fargate (Crawler) | vCPU: 1, Memory: 2GB | 90 [h] | 5.5458 [USD] |
| Fargate (Scraping) | vCPU: 1, Memory: 2GB | 750 [h] | 46.215 [USD] |
| RDS | db.t2.micro | 750 [h] | 21 [USD] |

合計すると、`72.7608[USD/month]` なので、日本円で月々8000円くらいになりました。
やはり、Fargateは一時的に建てる場合はとても安いですが、ECS Serviceのように常時起動している場合には高くつきますね。

### After

Spot Fleetの割引料は5割にしています。
Spot Instanceは `AM 1:00~7:00` の6時間のみ立っているとして計算します。

t3a.mediumの料金は、`0.049[USD/h]` です。
t3a.mediumは `vCPU: 2, Memory: 4GB` のキャパシティを持っています。
https://aws.amazon.com/ec2/pricing/on-demand/

DynamoDBの料金は以下です。

|  | 料金 |
| --- | --- |
| Write Capacity Unit | 0.000742 [USD/month]|
| Read Capacity Unit | 0.0001484 [USD/month] |

以下がAfter構成の料金表です。

| Service | Capacity | hour | Price per month | 
| --- | --- | --- | --- |
| EC2 (Spot) | t3a.medium | 180 [h] | 4.41 [USD] |
| DynamoDB | Write: 5, Read: 5 | 750 [h] | 0.004452 [USD] |

驚愕の結果となりました！(わざとらしい)
DynamoDBの料金はProvisionedとオンデマンドの2つの請求オプションがあります。
今回はProvisionedの方式を取りました。理由としては、うまく使えばそちらのほうが安いからです。

EC2 Spot Instanceは6時間のみしか立たない + 5割り引きで、 `4.41 [USD/month]` まで落とすことができました！

Before の `Fargate (Crawler)` と比較しても、リソースが2倍にも関わらず月々半額以下の値段で利用することができる計算になります。

## まとめ

クローラーを `ECS + Spot Fleet` の構成に移行してみたら、月々8000円だったものが500円くらいで動かせる様になりました。

Spot Instanceが取得できなかった場合の処理や、厳密なECS Task実行時間にのみSpot Instanceを要求するなどはやっておりませんが、ランニングコストと構築コストを天秤にかけたらちょうど良い感じに収まったと思います。

こういう節約のための施策って、めんどくさかったり、学習コストがかかったり、プログラムを冪等にする必要があるなど、構成が複雑になったりと、色々ハードルがあり、取り掛かるのが億劫だったりします。

しかし、この記事のクローラーは個人利用のため、かなり小さい構成になっていますが、ビジネス利用を考えた場合、この割引率はかなり大きいと思います。


## おまけ

`ECS + Spot Fleet` でバッチを動かすCloudFormationをおまけとしてつけておきます。

```yaml
---
AWSTemplateFormatVersion: 2010-09-09

Mappings:
  RegionMap:
    ap-northeast-1:
      ImageId: ami-04a735b489d2a0320 # ECS for AmazonLinux2

Parameters:
  InstanceType:
    Type: String

  DesiredCapacity:
    Type: String

  PublicSubnet:
    Type: AWS::EC2::Subnet::Id

  SecurityGroups:
    Type: List<AWS::EC2::SecurityGroup::Id>

  Cluster:
    Type: String

  TaskDefinition:
    Type: String

  StartCron:
    Type: String

  StopCron:
    Type: String

  StartTaskCron:
    Type: String

Resources:
  ClusterInstance:
    Type: AWS::EC2::SpotFleet
    Properties:
      SpotFleetRequestConfigData:
        IamFleetRole: !GetAtt SpotFleetRole.Arn
        LaunchSpecifications:
        - BlockDeviceMappings:
            - DeviceName: /dev/xvda
              Ebs:
                DeleteOnTermination: true
                VolumeSize: 30
                VolumeType: gp2
          EbsOptimized: true
          IamInstanceProfile:
            Arn: !GetAtt InstanceProfile.Arn
          ImageId: !FindInMap [RegionMap, !Ref 'AWS::Region', ImageId]
          InstanceType: !Ref InstanceType
          NetworkInterfaces:
          - AssociatePublicIpAddress: true
            DeleteOnTermination: true
            DeviceIndex: 0
            SubnetId: !Ref PublicSubnet
            Groups: !Ref SecurityGroups
          Monitoring:
            Enabled: true
          UserData:
            Fn::Base64: !Sub |
              #!/bin/bash
              echo ECS_CLUSTER=${Cluster} >> /etc/ecs/ecs.config
              echo ECS_BACKEND_HOST >> /etc/ecs/ecs.config
              yum install -y https://amazon-ssm-${AWS::Region}.s3.amazonaws.com/latest/linux_amd64/amazon-ssm-agent.rpm
        TargetCapacity: 0
        TerminateInstancesWithExpiration: true

  FleetSchedule:
    Type: AWS::ApplicationAutoScaling::ScalableTarget
    Properties:
      MaxCapacity: 0
      MinCapacity: 0
      ResourceId: !Sub 'spot-fleet-request/${ClusterInstance}'
      RoleARN: !GetAtt SpotFleetAutoScaleRole.Arn
      ScalableDimension: ec2:spot-fleet-request:TargetCapacity
      ScheduledActions:
      - Schedule: !Ref StartCron
        ScheduledActionName: StartSchedule
        ScalableTargetAction:
          MaxCapacity: !Ref DesiredCapacity
          MinCapacity: !Ref DesiredCapacity
      - Schedule: !Ref StopCron
        ScheduledActionName: StopSchedule
        ScalableTargetAction:
          MaxCapacity: 0
          MinCapacity: 0
      ServiceNamespace: ec2

  # === Events ===
  Event:
    Type: AWS::Events::Rule
    Properties:
      Name: Crawler
      Description: Execute crawling.
      ScheduleExpression: !Ref StartTaskCron
      State: ENABLED
      Targets:
      - Id: crawler
        Arn: !Ref Cluster
        EcsParameters:
          TaskDefinitionArn: !Ref TaskDefinition
          TaskCount: 1
        RoleArn: !GetAtt EventExecutionRole.Arn

  # === Role ===
  SpotFleetAutoScaleRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - application-autoscaling.amazonaws.com
          Action:
          - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AmazonEC2SpotFleetAutoscaleRole

  SpotFleetRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - spotfleet.amazonaws.com
          Action:
          - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AmazonEC2SpotFleetTaggingRole

  InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: '/'
      Roles:
      - !Ref InstanceProfileRole

  InstanceProfileRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - ec2.amazonaws.com
          Action:
          - sts:AssumeRole
      Path: '/'
      ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role
      - arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM

  EventExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
        - Effect: Allow
          Principal:
            Service: events.amazonaws.com
          Action:
          - sts:AssumeRole
      ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceEventsRole
```