電気ひつじ牧場

技術メモ

バッチ処理の基盤をEC2からECSに移行する

バッチ処理基盤をEC2からECSへと移行させるにあたって考えたこと、Terraformでの構築方法をメモしておきます。本稿で単にECSと書いた場合は全てECS Fargateを指しています。

移行の背景

元々はEC2+cronという昔ながらの構成でバッチ処理を定期実行していましたが、以下のような理由からコンテナ化、マネージドサービス化を行いました。

  • EC2のOSレイヤの運用がしんどくなっていた
  • サーバにログインして設定変更するオペレーションが常態化しており、色々と構成管理できていなかった
  • ジョブのオンオフ切り替え、パラメータの変更などのたびにサーバにログインするオペレーションコストが掛かっていた

移行にあたっての考慮点

  • ジョブはgraceful shutdownするか

処理を確実に行うため、実行基盤の終了時にジョブが中途半端な状態で中止されないことが必要でした。実行基盤の終了は、一般的にコンソールやコマンドからの停止、スケールイン、インプレースなデプロイ、メインプロセスの終了などで発生します。

ジョブは長いものだと数時間という期間にわたって実行されるため、特に新たなバージョンがデプロイされた際に実行中のジョブが強制中断されないかといったことに注意する必要がありました。

  • 各ジョブを個別のクラウドリソースとして扱うことができるか

EC2+cronといった構成だと、ジョブは全て1つのサーバの中に入れられてサーバ内のファイルでまとめて管理する必要があります。ジョブの情報はサーバの外からは見ることができないため、特定のジョブを操作する場合はサーバにログインしなくてはなりません。 個別にジョブを扱うことができると、ジョブの特性や要求に応じたリソース割り当てが可能になったり、メンテの時に特定のジョブだけaws cliからオフにできたりするため運用の幅が広がります。

どのサービスを使うか

AWSでバッチジョブを動かすために使えるサービスは複数あります。

  • EC2, ECS Serviceなどのコンピューティング基盤でcronを動かす
    • EC2は上述の通り
    • ECS Serviceはコンテナ化という目的は達成できますが、考慮点の2つを満たすことができません。特に新たなバージョンをリリースする際にローリングアップデートで実行中のジョブコンテナが終了されてしまうので、長時間かかるジョブの実行中はリリースができないという制約があります。
    • なお、ECS自体にはGraceful Shutdownをサポートする仕組みがあります
      • タスクの終了処理開始時にはSIGTERM、指定時間後(120sまで)にSIGKILLがコンテナのPID1のプロセスに送信されたのちタスクが解放されます。
      • コンテナのプロセスが正しくシグナルハンドリングを行えばGracefulにシャットダウンすることが可能です。
      • LaravelのQueue Workerのようなジョブを実行する際はECS Serviceの方が向いていると思います
      • https://aws.amazon.com/jp/blogs/news/graceful-shutdowns-with-ecs/
  • ECS ScheduledTask
    • ECSのタスクをcron/rate式で起動できる機能です。
    • コンテナ化の他に、上述の考慮点を2つとも満たすことができます。
      • 新パージョンのデプロイ時はコンテナレジストリにイメージをpushしてイベントターゲットを更新するだけで、実行中のタスクに影響しません。次回実行時からは新たなコンテナイメージが使われます。タスク終了時はECSの機能でGraceful Shutdownが行われます。
      • 1つのジョブは1つのタスクと紐づくため、各ジョブを個別のリソースとして扱うことができます。ContainerOverridesをうまく使うことでコードベースが同じなら各ジョブ間で同じコンテナイメージを使い回すことができます
    • なおECSとは関係ないですが、タスク起動のトリガーに使うEventBridgeはまれに一度に複数回イベントを送信することがあるため、ジョブは同時に複数回実行されても問題ない作りになっている必要があります。
  • Lambda
    • Lambda Containerとカスタムランタイムを使うことで任意の言語で処理を実行できます。
    • 上述の考慮点は満たしますが、ECSでいうContainerOverridesの機能がないためジョブごとに別のコンテナをビルドする必要があります。
    • 15分間でタイムアウトするため、長時間のジョブ実行には向いていません。
  • Batch
    • EC2やECSをバックエンドとしてジョブを実行できます。依存関係を持つジョブやリトライの制御など高度なことができます。
    • 考慮点は2つとも満たせます

これらの候補の中からECS ScheduledTaskを選択しました。上述のメリットに加え、すでにECSのクラスタAPIサーバとして利用していたこと、Batchほど豊富な機能を必要としていなかった(あとあまり知見がなかった)ことが主な理由です。

構築

Terraformでの例を示します。ScheduledTaskはaws_ecs_*のリソースではなくてCloudWatchのイベントターゲット+イベントルールになります。

inputでContainerOverridesを指定し、外部からコマンドを上書きすることで同じコンテナイメージを複数のジョブで使い回すことができます(起動スクリプトの引数などでジョブの種類を指定できるようになっている必要があります)。

ECSのクラスタやタスク定義、各種ロールなどは割愛します。

resource "aws_cloudwatch_event_target" "this" {
  target_id = var.target_id
  arn       = var.cluster_arn
  rule      = aws_cloudwatch_event_rule.this.name
  role_arn  = var.role_arn

  ecs_target {
    task_count             = 1
    task_definition_arn    = var.taskdef_arn
    launch_type            = "FARGATE"
    platform_version       = "1.4.0"
    enable_execute_command = true
    network_configuration {
      subnets = var.private_subnets
    }
  }

  input = jsonencode(
    {
      "containerOverrides" : [
        {
          "name" : var.container_name,
          "command" : var.command
        }
      ]
    }
  )

  lifecycle {
    ignore_changes = [ecs_target[0].task_definition_arn]
  }
}

resource "aws_cloudwatch_event_rule" "this" {
  name                = var.rule_name
  is_enabled          = var.is_enabled
  schedule_expression = var.schedule_expression
}

上記のモジュールの呼び出し側は次のようになります。

module "sample" {
  source              = "./ecs_scheduled_task"
  target_id           = "sample"
  rule_name           = "sample"
  container_name      = "batch"
  schedule_expression = "cron(* * * * ? *)"
  cluster_arn         = aws_ecs_cluster.example.arn
  role_arn            = module.ecs_events_role.iam_role_arn
  taskdef_arn         = aws_ecs_task_definition.batch.arn
  private_subnets     = [aws_subnet.private_0.id, aws_subnet.private_1.id]
  command             = ["/bin/my_batch.sh", "job_name"]
  is_enabled          = true
}

CIは次のような感じになります

  1. コンテナイメージをビルド
  2. タスク定義のタグを書き換えてaws ecs register-task-definitionする
  3. 更新したタスク定義でaws events put-targetsする

注意点

ScheduledTaskはジョブ起動の度に毎回コンテナイメージをpullします。そのため、起動頻度の高いタスクをプライベートサブネットで起動する場合は、NATゲートウェイの転送費用が思いの外高くなる場合があります。このような場合はECRとS3に対してVPCエンドポイントを作成し、NATゲートウェイを迂回することが有効です。

https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/vpc-endpoints.html

参考

https://registry.terraform.io/providers/hashicorp/aws/3.74.1/docs https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/scheduled_tasks.html https://aws.amazon.com/jp/blogs/news/new-for-aws-lambda-container-image-support/