AWSでサーバレスな定期バッチ環境を作るには結局どれ使えばいいの？（Lambda vs Fargate vs Batch）

LIFULLで技術マネージャーをしています。たまにはエンジニアぽいところを見せてほしいという社内からの~~プレッシャー~~激励に促されて、AWSにおける知見を整理して共有します。

# はじめに
システムを運用する上で、日次や月次の決まったタイミングで動作するcronライクな定期バッチを必要とするケースがあると思います。定期実行なので、インフラリソースをそのタイミングだけ利用するサーバレスなバッチにするのが経済的にも地球環境的にも優しいですよね。

ということで、AWSのサービスを利用して実現しようというお話なんですが、選択肢の多さに結局どれを使えばいいんだっけ？ってことになりかねません。そこで考えられる選択肢として [Lambda](https://aws.amazon.com/jp/lambda/)、 [Fargate](https://aws.amazon.com/jp/fargate/)、 [Batch](https://aws.amazon.com/jp/batch/) の3つを取り上げて、定期バッチ環境を作る上でのメリット、デメリットをまとめたいと思います。

# サービス比較
記事投稿時点での特徴を独断でまとめたものがこちらです。

|                     | Lambda | Fargate | Batch |
|:--------------------|:------:|:-------:|:-----:|
| 環境構築の容易さ      |◯       |△        |△     |
| 実行環境の拡張性      |×       |◯        |◯     |
| マシンリソースの拡張性  |×       |△        |◯     |
| 機密情報の扱いやすさ   |△       |◯        |×     |
| スケジューリング設定    |◯       |◯        |◯     |
| ロギング設定          |△       |◯        |△     |

それでは、一つずつ説明していきます。

## 環境構築の容易さ
`Lambda`はコードをアップロードさえすれば、実行環境が構築でき、その導入障壁の低さが魅力と言えます。

`Fargate`と`Batch`はコンテナベースのコンピューティングサービスであり、事前にバッチアプリケーションを含んだDockerイメージを用意しておく必要があります。

## 実行環境の拡張性
`Lambda`は標準で使用できるランタイム（プログラム言語とバージョン）に限りがあります。ただ主要言語はほぼサポートされており、カスタムランタイムも作成できるため、さほど不自由さを感じないのではないでしょうか。
また、そのままだと標準ライブラリしか使えないため、 [Serverless Framework](https://serverless.com/) や [AWS SAM](https://aws.amazon.com/jp/serverless/sam/) を使用して、ローカルの実行環境をパッケージ化してデプロイする運用が一般的かと思います。ただ、OS依存のネイティブライブラリの導入にはさらに手間がかかるなど、拡張性と利便性がトレードオフであるといえます。

`Fargate`と`Batch`は前述の通り、あらかじめ自前で用意したコンテナ上でアプリケーションを動作させるため、自由度の高い実行環境を構築することが可能です。

## マシンリソースの拡張性
`Lambda`は実行時間に制限があるというのは有名な話ですね。またメモリ割り当てなど、さまざまなリソースに制限が適用されており、変更することができません。

※ 詳しくは [AWS Lambdaの制限](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/limits.html) を参照してください。

`Fargate`はCPUとメモリの割り当て可能な組み合わせを選択して利用します。現在の利用可能範囲は 0.25vCPU × 512MBメモリ 〜 4vCPU × 30GBメモリ となっています。
また、ストレージはコンテナのルートボリューム(10GB)とマウント利用するコンテナ間共有ボリューム(4GB)の2種類が利用可能ですが、この容量制限を変更することはできません。

※ 詳しくは [AWS Fargte - タスクCPUとメモリ](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/AWS_Fargate.html#fargate-tasks-size) | [AWS Fargte - タスクストレージ](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/AWS_Fargate.html#fargate-tasks-storage) を参照してください。

`Batch`はあらかじめ設定したコンピューティング環境でジョブを実行するサービスですが、そのリソースはEC2インスタンスになります。こちらはT系以外の全てのインスタンスを選択可能です。
また、ストレージはデフォルトでコンテナのルートボリューム(10GB)が利用可能と`Fargate`と同じです。これはDockerのベースサイズのデフォルト値が10GBであることに起因するのだと思いますが、この容量制限は`Fargate`と違い拡張することが可能です。拡張方法は [Qiita - AWS Batch で使う EC2 インスタンスのストレージ容量を増やす](https://qiita.com/horihiro/items/ac4f3cd68473bd2fba75) の記事を参考にしてください。

## 機密情報の扱いやすさ
3サービス共通して、環境変数を設定してアプリケーション側で利用できる仕組みを提供しています。ただし、パスワードやAWSアクセスキーなどの機密情報を環境変数としてプレーンテキストで取り扱うことは推奨されていません。ここでは機密情報の扱いやすさという軸で比較してみます。

`Lambda`は [AWS KMS](https://aws.amazon.com/jp/kms/) による環境変数の暗号化と復号化の仕組みを提供しています。ただし、暗号化の設定がCloudFormationに対応していなかったり、アプリケーション側に復号化の実装が必要だったりと使い勝手がいいとは決して言えません。

`Fargate`は [Parameter Store](https://aws.amazon.com/jp/systems-manager/features/#Parameter_Store) に登録したSecureStringをタスク起動時に復号化して、環境変数に設定してくれるという非常に便利な機能を提供してくれています。これだけでもFargateを選択する価値があると私は思っています。

※ 詳しくは [Developers.IO - ECSでごっつ簡単に機密情報を環境変数に展開できるようになりました！](https://dev.classmethod.jp/cloud/aws/ecs-secrets/) を参照してください。

`Batch`は機密情報を取り扱うための仕組みが今のところサポートされていないようです。自前でParameter Storeや [AWS Secrets Manager](https://aws.amazon.com/jp/secrets-manager/) に登録した機密情報をアプリケーション側で復号化して使用するといった作り込みが必要になります。

## スケジューリング設定
3サービス共通して、CloudWatch Eventsと紐づけることで、定期実行するためのスケジューリング設定が可能です。またCloudFormationでのスケジューリング設定も可能です。（長らく`Batch`はスケジュール設定がCFn未対応でしたが、本記事を執筆している時点では有難いことに対応していました）

## ロギング設定
`Lambda`はアプリケーションでの標準出力、エラー出力をCloudWatch Logsに出力することが可能です。ロググループは`/aws/lambda/<function name>`固定となります。

`Fargate`にはログドライバーの仕組みがあり、これによって柔軟にログ出力先を設定することができます。awslogsログドライバーを使用すれば、アプリケーションでの標準出力、エラー出力をCloudWatch Logsに出力することが可能です。CloudWatch Logsのロググループ名も任意に指定できます。

※ 詳しくは [AWS Fargate - ログ記録](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/AWS_Fargate.html#fargate-tasks-logging) を参照してください。

`Batch`はアプリケーションでの標準出力、エラー出力をCloudWatch Logsに出力することが可能です。ロググループは`/aws/batch/job`固定となります。

# まとめ
ユースケースに合わせてどのサービスを利用するかを決めるべきですが、上記のメリット、デメリットを踏まえて、定期バッチ環境の構築における取捨選択のポイントを独断でまとめてみました。

|サービス   | 取捨選択のポイント                                            |
|:---------|:----------------------------------------------------------|
| Lambda   |使用するライブラリが限定的であり、小規模処理をとりあえず動かしたい場合 |
| Fargate  |機密情報を必要とするような、多少手の込んだ中規模処理を動かしたい場合 |
| Batch    |機械学習のようなマシンリソースが潤沢に必要な大規模処理を動かしたい場合|

※ あくまで定期バッチをテーマにしたまとめとして取り扱ってください。（本記事では各サービスの備えている利点を全て取り上げた訳ではありません）

# サンプル
定期バッチ環境を構築するサンプルコードを紹介して終わりにします。
今回共通して、AWS SAM用のテンプレートを使用します。（Lambda以外はCFnのテンプレートとほぼ同一です）

## Lambdaサンプル
```lambda_template.yml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: 'Lambda Sample Settings'

Parameters:
  BaseName:
    Type: String
    Default: lambda-sample
  LambdaEnv:
    Type: String
    AllowedValues: [dev, prod]
    Default: dev
  LambdaSubnetId:
    Type : AWS::EC2::Subnet::Id
  LambdaSecurityGroupId:
    Type: AWS::EC2::SecurityGroup::Id

Resources:
  LambdaFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${BaseName}-function
      Handler: handler.sample
      Runtime: ruby2.5
      Role: !GetAtt LambdaIamRole.Arn
      Environment:
        Variables:
          LAMBDA_ENV: !Ref LambdaEnv
      VpcConfig:
        SubnetIds:
          - !Ref LambdaSubnetId
        SecurityGroupIds:
          - !Ref LambdaSecurityGroupId
      Events:
        SampleEvent:
          Type: Schedule
          Properties:
            Name: !Sub ${BaseName}-event1
            Schedule: cron(0 3 1 * ? *)

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

  LambdaLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${LambdaFunction}
```
VPC内でLambda関数を実行するシンプルなサンプルです。 [AWS::Logs::LogGroup](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-logs-loggroup.html) を明示的に指定しなくても実行時にロググループを作成してくれますが、このようにCloudFormationのスタックと紐づけることで、個別に管理する必要がなくなるのでおすすめです。

以下の通り、事前にインストールしたsamコマンドを使用してデプロイします。サブネットIDやセキュリティグループのハードコーディングは避けたいので、`parameter-overrides`オプションで渡しています。

```
$ sam package --template-file lambda_template.yml --output-template-file packaged-template.yml --s3-bucket sam-sample
$ sam deploy --template-file packaged-template.yml --stack-name lambda-sample --capabilities CAPABILITY_IAM --parameter-overrides LambdaSubnetId=subnet-***************** LambdaSecurityGroupId=sg-*****************
```
## Fargateサンプル
```fargate_template.yml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: 'Fargate Sample Settings'

Parameters:
  BaseName:
    Type: String
    Default: fargate-sample
  FargateEnv:
    Type: String
    AllowedValues: [dev, prod]
    Default: dev
  ECSSubnetId:
    Type : AWS::EC2::Subnet::Id
  ECSSecurityGroupId:
    Type: AWS::EC2::SecurityGroup::Id

Resources:
  ECSCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub ${BaseName}-cluster

  ECSTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Cpu: 2048
      Memory: 4096
      Family: !Sub ${BaseName}-task
      RequiresCompatibilities:
        - FARGATE
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      NetworkMode: awsvpc
      ContainerDefinitions:
        - Name: !Sub ${BaseName}-container
          Image: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${BaseName}:latest
          WorkingDirectory: /root/workspace/
          Environment:
            - Name: FARGATE_ENV
              Value: !Ref FargateEnv
          Secrets:
            - Name: AWS_ACCESS_KEY
              ValueFrom: /fargate-secrets/AWS_ACCESS_KEY
            - Name: AWS_SECRET_KEY
              ValueFrom: /fargate-secrets/AWS_SECRET_KEY
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref BaseName
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: !Ref BaseName

  TaskScheduleEvents1:
    Type: AWS::Events::Rule
    Properties:
      Name: !Sub ${BaseName}-event1
      ScheduleExpression: cron(0 3 1 * ? *)
      State: ENABLED
      Targets:
        - Id: !Sub ${BaseName}-target
          Arn: !GetAtt ECSCluster.Arn
          RoleArn: !GetAtt ECSEventRole.Arn
          Input: !Sub '{"containerOverrides":[{"name":"${BaseName}-container","command":["ruby", "./handler.rb"]}]}'
          EcsParameters:
            TaskDefinitionArn: !Ref ECSTaskDefinition
            TaskCount: 1
            LaunchType: FARGATE
            NetworkConfiguration:
              AwsVpcConfiguration:
                AssignPublicIp: DISABLED
                SecurityGroups:
                  - !Ref ECSSecurityGroupId
                Subnets:
                  - !Ref ECSSubnetId

  ECSTaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
        - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess

  ECSEventRole:
    Type: AWS::IAM::Role
    Properties:
      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

  FargateLogsGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Ref BaseName
```
コンテナのWORKDIR直下にある`handler.rb`バッチを実行するサンプルです。

あらかじめ用意したDockerイメージのURIを [AWS::ECS::TaskDefinition ContainerDefinition](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-ecs-taskdefinition-containerdefinitions.html) の`Image`で設定しています。また、機密情報である`AWS_ACCESS_KEY`と`AWS_SECRET_KEY`をParameter Storeに事前に設定して、環境変数として利用する想定です。

##### ポイント
バッチコマンドを [AWS::Events::Rule Target](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-events-rule-target.html) の`containerOverrides`で上書き設定しています。`AWS::Events::Rule`を複数定義することで、コンテナ上にある様々な定期バッチのスケジューリングが同一テンプレートファイルで可能になります。

こちらも同様にsamコマンドを使用してデプロイします。

```
$ sam package --template-file fargate_template.yml --output-template-file packaged-template.yml --s3-bucket sam-sample
$ sam deploy --template-file packaged-template.yml --stack-name fargate-sample --capabilities CAPABILITY_IAM --parameter-overrides ECSSubnetId=subnet-***************** ECSSecurityGroupId=sg-*****************
```
## Batchサンプル
```batch_template.yml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: 'Batch Sample Settings'

Parameters:
  BaseName:
    Type: String
    Default: batch-sample
  BatchEnv:
    Type: String
    AllowedValues: [dev, prod]
    Default: dev
  BatchSubnetId:
    Type : AWS::EC2::Subnet::Id
  BatchSecurityGroupId:
    Type: AWS::EC2::SecurityGroup::Id
  EC2KeyPair:
    Type: AWS::EC2::KeyPair::KeyName
  EC2AmiId:
    Type: AWS::EC2::Image::Id

Resources:
  BatchComputeEnv:
    Type: AWS::Batch::ComputeEnvironment
    Properties:
      Type: MANAGED
      ServiceRole: !GetAtt BatchServiceRole.Arn
      ComputeEnvironmentName: !Sub ${BaseName}-compute-env
      ComputeResources:
        MaxvCpus: 2
        MinvCpus: 0
        Subnets:
          - !Ref BatchSubnetId
        SecurityGroupIds:
          - !Ref BatchSecurityGroupId
        InstanceRole: !GetAtt EcsInstanceProfile.Arn
        Ec2KeyPair: !Ref EC2KeyPair
        Type: EC2
        InstanceTypes:
          - r5.large
        ImageId: !Ref EC2AmiId
      State: ENABLED

  BatchJobQueue:
    Type: AWS::Batch::JobQueue
    Properties:
      JobQueueName: !Sub ${BaseName}-job-queue
      ComputeEnvironmentOrder:
        - Order: 1
          ComputeEnvironment: !Ref BatchComputeEnv
      State: ENABLED
      Priority: 1

  BatchJobDefinition:
    Type: AWS::Batch::JobDefinition
    Properties:
      Type: container
      JobDefinitionName: !Sub ${BaseName}-job-definition
      ContainerProperties:
        Command: ["Ref::SciptLang", "Ref::Command"]
        Memory: 15000
        Vcpus: 2
        Image: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${BaseName}:latest
        Environment:
          - Name: BATCH_ENV
            Value: !Ref BatchEnv

  TaskScheduleEvents1:
    Type: AWS::Events::Rule
    Properties:
      Name: !Sub ${BaseName}-event1
      ScheduleExpression: cron(0 3 1 * ? *)
      State: ENABLED
      Targets:
        - Id: !Sub ${BaseName}-target
          Arn: !Ref BatchJobQueue
          RoleArn: !GetAtt BatchEventRole.Arn
          Input: '{"Parameters" : {"SciptLang": "ruby", "Command": "./handler.rb"}}'
          BatchParameters:
            JobDefinition: !Ref BatchJobDefinition
            JobName: !Sub ${BaseName}-job

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

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

  EcsInstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Roles:
        - !Ref EcsInstanceRole

  BatchEventRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSBatchServiceEventTargetRole
```
`Fargate`と同様にコンテナのWORKDIR直下にある`handler.rb`バッチを実行するサンプルです。

あらかじめ用意したDockerイメージのURIを [AWS::Batch::JobDefinition ContainerProperties](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-batch-jobdefinition-containerproperties.html) の`Image`で設定しています。また、マシンリソースが潤沢に必要な大規模処理を想定して、ストレージを拡張したAMI IDを  [AWS::Batch::ComputeEnvironment ComputeResources](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-batch-computeenvironment-computeresources.html) の`ImageId`で設定しています。

##### ポイント（１）
EC2 vCPUの最小数を [AWS::Batch::ComputeEnvironment ComputeResources](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-batch-computeenvironment-computeresources.html) の`MinvCpus`で0に設定しています。これによりバッチ実行時にEC2インスタンスが起動し、バッチ終了後に自動でEC2インスタンスが削除されるというサーバレスな運用を実現します。

##### ポイント（２）
バッチコマンドを [AWS::Batch::JobDefinition ContainerProperties](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-batch-jobdefinition-containerproperties.html) の`Command`で`["Ref::SciptLang", "Ref::Command"]`のように参照形式で設定しています。これは  [AWS::Events::Rule Target](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-events-rule-target.html) の`Parameters`で実体を設定することで、コンテナ上にある複数の定期バッチのスケジューリングを同一テンプレートファイルで可能にしています。（`Fargate`と同じように`containerOverrides`でシンプルにバッチコマンドを上書き設定できるかと思ってましたが、難しかったためこのようなハック的なやり方を採用しました）

こちらも同様にsamコマンドを使用してデプロイします。

```
$ sam package --template-file batch_template.yml --output-template-file packaged-template.yml --s3-bucket sam-sample
$ sam deploy --template-file packaged-template.yml --stack-name batch-sample --capabilities CAPABILITY_IAM --parameter-overrides BatchSubnetId=subnet-***************** BatchSecurityGroupId=sg-***************** EC2KeyPair=********** EC2AmiId=ami-*****************
```
# さいごに
以上で終了です。AWSでサーバレスな定期バッチ環境を作るうえでの、お役に立てれば幸いです。

実際のところ、これまで`Batch`の定期バッチ運用に関しては、スケジューリングした`Lambda`経由でジョブをサブミットすることで実現してました。今回この記事を執筆するにあたり、CFnに対応しているやん！じゃあ試してみようとなったのですが、思いのほか茨の道で大変でした。なんとか動く状態になって良かったです。

では、またX年後にお会いしましょう。