らぼるてっく。

てっくてっく歩いてっく。

CloudFormation nested stackを使ってECS Scheduled Tasksを簡単に設定する

こんにちは。ラボルのかわむらです。

金融サービス事業のlabolのシステムでは、定期的にいろいろなバッチ処理が「ECS Scheduled Tasks」上で動いているんですが、 それを「CloudFormation nested stack(以降CFn nested stack)」を導入して、簡単安全に追加・修正・削除できるようにしてみたので、導入背景等々も踏まえて解説していきます。

labolの定期バッチはECS Scheduled Tasksで実行されている

ちょっと前置きですが、
labolでは定期的に複数のバッチ処理が動いています。週毎、日毎、時間毎などなど。

labolではJavaのSpringBootを使用しており、Spring自体にも@Scheduledという定期実行の機構がありますが、labolではそれを使用していません。

以下のような理由からです。

  • Webサーバーで実行させると、リソース的にバッチ処理がバッチ処理以外に影響を及ぼす可能性がある
  • バッチサーバーを設けるには無駄が多い(実行していないタイミングなどは無駄になる)
  • バッチごとにCPU/メモリといったリソースを変更することが困難
    • 一番重いバッチ処理のリソース分を確保する必要がある

一方ECS Scheduled Tasksを使用すれば上記の問題が解決することができます。

バッチ処理ごとにCPU/メモリといったリソースを定義し、それぞれ別のECSタスク(コンテナ上)で実行されます。処理が終わればコンテナは破棄されます。 必要なときに必要なリソースで、必要な時間実行される感じですね。

参考: https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/scheduling_tasks.html

CFn nested stackの導入背景

ECS Scheduled Tasksは非常に使い勝手のよい機能なのですが、AWS管理コンソールから使っていると課題がちらほら出てきます。

コード管理ができてない

AWSコンソールを直接触ることになるので、レビューが困難になります。 誤操作を防ぐためにダブルチェックが必要にもなりますし、どういう意図でその設定になったのかが記録し辛くなります。

属人性が高い

VPCの設定やセキュリティグループは何を設定するべきか、ECSクラスタはどれかなど考えないといけない事がいくつかあります。 バッチをリリースしたいだけなのに、インフラ部分の設定も必要になり属人性が高くなりがちです。

共通設定をするのが大変

Scheduled Tasksはバッチ処理毎にVPC、セキュリティグループ、タスク定義(リビジョンも)等を設定をする必要があり、一括で変更することができません。 共通で使いたい設定もあるので、バッチ処理ごとに設定しないといけないのは手間でもありますし、間違ったものを設定してしまうリスクもつきまといます。

VPC、セキュリティグループはまだしも、タスク定義はコロコロ変更する可能性もあるので、一括で設定したいものです。

環境変数の設定が消えてしまう

Scheduled Tasksでは、タスク定義に記述された環境変数をバッチ処理ごとに上書きすることが可能です。 labolでは実行するバッチ処理をJOB_NAMEとして環境変数に設定しています。 共通の環境変数はタスク定義に直接書いて、バッチ処理毎に異なる環境変数は、それごとに上書きしていく感じです。

ただAWS管理コンソール上でここを設定していると、タスク定義のリビジョンを変更した際に、上書きした環境変数が消えるという問題がありました。

これでは、リビジョンを変えて終わった気になっていると、実行時に上書きされるべき環境変数がデフォルト値となってしまいバグとなってしまいます。

この状態をバッチ実行前に検知できればいいのですが、現状その術を見つけることができませんでした。

これら3つの問題をまるっと解決するべく、CFn nested stackを利用してECS Scheduled Tasksを導入することにしました。

CFn nested stackを利用したECS Scheduled Tasksの設定

前述した3つの問題をCFn nested stackを利用して解決することができます。 ECS Scheduled TasksはCFnで何を設定すればいいのか、nested stackがどういうものであるか、実際に設定したCFnの内容を見ながら説明していきます。

ECS Scheduled TasksはEventBridgeのルールを定義しているだけ

実は、ECS Scheduledの実態はEventBridgeのルールになります。

EventBridgeのルールに定義するターゲットでECSタスクを指定しているだけなんですね。 なのでCFnですることは、ターゲットをECSタスクとするようなEventBridgeのルールを定義してやるだけです。

※2023/1のアップデートでECS新UIが使えるようになってますが、そこではECS Scheduled Tasksの設定ができなくなってます。どこいっちゃったんだろう。

2023/2/10に確認したら、ScheduledTasks設定できるようになってました。

nested stackを利用する

nested stackはCFnはタスクの一部として作成されるスタックのことです。 EventBridgeのルールをCFnで設定していく際にスタックごとに共通のもと固有のものが存在します。

今回ターゲットをECSタスクとするのですが、セキュリティグループやVPC等々共通のものが多く存在します。 スタックをバッチ処理ごとに作成してくと、同じ設定を何度も記述していかなければならず非効率的で、設定間違いのリスクなども出てきます。

nested stackを利用することで、共通部分をまとめて定義して、バッチ処理ごとに異なる実行タイミングや、有効か無効かなどを設定することが可能になります。

個別設定のスタックと、共通のスタック

設定例

以下2つのファイルを作成していきます。

  • base-task-template.yaml
    • 共通の設定項目を記述するファイル
    • セキュリティグループ、VPC、ECSクラスタ
  • tasks-template.yaml
    • バッチ処理毎の固有の設定項目を記述するファイル
    • 実行タイミング、バッチ名

base-task-template.yaml

AWSTemplateFormatVersion: "2010-09-09"  
  
Parameters:  
 # 共通パラメータ(結局1箇所しか使わないので直接Resourcesに記述してもOK)
 EventRoleArn:
  Type: String
  Default: ecsEventsRoleのARN
 ClusterArn:
  Type: String  
  Default: ECSクラスタのARN
 TaskDefinitionArn:
  Type: String
  Default: タスク定義のARN
 TaskCount:
  Type: Number
  Default: 1
 LaunchType:
  Type: String
  Default: FARGATE
 PlatformVersion:
  Type: String
  Default: LATEST
 AssignPublicIp:
  Type: String
  Default: DISABLED
 SubnetList:
  Type: List<AWS::EC2::Subnet::Id>
  Default: "サブネットID1, サブネットID2"
 SecurityGroupList:
  Type: List<AWS::EC2::SecurityGroup::Id>
  Default: セキュリティグループID
  
 # tasks-template.yamlから入力されるパラメータ  
 BatchName:  
  Type: String  
 BatchDescription:  
  Type: String  
 BatchState:  
  Type: String  
 ScheduleExpression:  
  Type: String  
 EventTargetId:  
  Type: String  
 JobName:  
  Type: String  
  
Resources:  
 TaskScheduleEvents:  
  Type: AWS::Events::Rule  
  Properties:  
   Name: !Ref BatchName  
   Description: !Ref BatchDescription  
   ScheduleExpression: !Ref ScheduleExpression  
   State: !Ref BatchState  
   Targets:  
    - Id: !Ref EventTargetId  
     RoleArn: !Ref EventRoleArn  
     EcsParameters:
      TaskDefinitionArn: !Ref TaskDefinitionArn
      TaskCount: !Ref TaskCount
      LaunchType: !Ref LaunchType
      PlatformVersion: !Ref PlatformVersion
      NetworkConfiguration:
       AwsVpcConfiguration:
        AssignPublicIp: !Ref AssignPublicIp
        Subnets: !Ref SubnetList
        SecurityGroups: !Ref SecurityGroupList
     Arn: !Ref ClusterArn
     Input: !Sub
      - |
       {
        "containerOverrides":[
          {
           "name":"your-batch-container-name",
           "environment":[
            {
              "name":"JOB_NAME",
              "value":"${JobName}"
            }
           ],
           "environmentFiles": []
          }
        ]
       }
      - JOB_NAME: !Ref JobName

Parametersセクションで「共通パラメータ」と「tasks-template.yamlから入力されるパラメータ 」に分けています。後者はtasks-template.yaml側から上書きするパラメータになります。

ECSの設定でInputにJsonを記述しています。Json内に変数埋め込むのが若干コツいるので参考にしてみてください。

tasks-template.yaml

AWSTemplateFormatVersion: "2010-09-09"  
Description: "ECS ScheduledTask Event Stack"  
  
Resources:  
 MyBatch1:  
  Type: AWS::CloudFormation::Stack  
  Properties:  
   TemplateURL: ./base-task-template.yaml  
   Parameters:  
    BatchName: myBatch1
    BatchDescription: ばっち1つめ
    ScheduleExpression: rate(10 minutes)  
    BatchState: ENABLED
    BatchName: myBatch1
    JobName: MyBatchJob1
 MyBatch2:
  Type: AWS::CloudFormation::Stack  
  Properties:  
   TemplateURL: ./base-task-template.yaml  
   Parameters:  
    BatchName: myBatch2
    BatchDescription: ばっち2つめ
    ScheduleExpression: rate(10 minutes)  
    BatchState: DISABLED
    BatchName: myBatch2
    JobName: MyBatchJob2
 MyBatch3:
  Type: AWS::CloudFormation::Stack  
  Properties:  
   TemplateURL: ./base-task-template.yaml  
   Parameters:  
    BatchName: myBatch3
    BatchDescription: ばっち3つめ
    ScheduleExpression: cron(0 2 * * ? *)
    BatchState: ENABLED
    BatchName: myBatch3
    JobName: MyBatchJob3

これでバッチ処理ごとの設定を記述しています。 TemplateURLにbase-task-template.yamlを指定し このような形で、バッチ処理固有の情報のみ指定することで、追加や変更、削除が容易になりました。 必要最小限の記述でバッチどんどん追加していけます。

ちなみにScheduleExpressionのcron形式ではUTCで指定しないといけないので注意が必要です。あとrateの数字が1の場合は単数形でminuteとかにしないといけないので、注意が必要です。よくミスしてしまうポイントですね。

改めて課題が解決できているか見てみる

当初の課題が解決できているか改めて見てみましょう。 以下のような課題がありました。

  • コード管理ができてない
  • 属人性が高い
  • 共通設定をするのが大変
  • 環境変数の設定が消えてしまう

コード管理ができてない

CFnによりコード管理ができるようになりました。 コードレビューも可能になりますし、コミットメッセージやプルリクなどに設定の意図が記述でき、あとから見たときになぜその設定となったのかが把握できるようになりました。

属人性が高い

CFn nested stackを導入したことで、開発者が設定する箇所はtasks-template.yamlの特定のバッチ処理の設定のみとなりました。 共通箇所に関しては一切設定を触ることはなく、必要最小限の設定を行うだけで、定期バッチのセットアップが可能になります。 なので、属人性は排除されたと言えますね。

yamlのParameter見てもらえば分かりますが、項目も少なくなんのための設定なのか一目瞭然です。

共通設定をするのが大変

共通設定はbase-task-template.yamlにまとめられています。 バッチ処理ごとに設定する必要がなくなり、一括ですべての処理に対して共通設定を反映することが可能になります。

タスク定義やVPCなどを一括で変更できます。 もし個別に設定したい箇所が出てくれば、tasks-template.yamlに移せばいいですし、共通設定したい箇所が出てくればbase-task-template.yamlに移せば良いです。

自身のプロジェクトにあった設定が用意にできますね。

環境変数の設定が消えてしまう

ここではJOB_NAMEという環境変数を設定していますが、これをバッチ処理ごとにtasks-template.yamlに記述することで環境変数設定が消えてしまうということがなくなります。

このように先に上げた全ての課題を解決することができました。めでたしめでたし。

その他

上記は触れてないですが、CFnを適用する際はChangeSetを通して適用するようにしてます。 レビューはもちろんするものの、間違った設定のままデプロイされるのを避けるためです。

あと、TargetのInputで指定しているJsonのcontainerOverridesの箇所で、resourceRequirementsを使用すると メモリやCPUの値などもバッチ処理ごとに変更することが可能です。 https://docs.aws.amazon.com/ja_jp/batch/latest/APIReference/API_ContainerOverrides.html

最高ですね。

最後に

ラボルでは、エンジニアを積極採用中です。1、2年目のエンジニアから経験豊富なテックリードやエンジニリングマネージャーまで、興味がある方はぜひご応募ください!!

labol.co.jp