コドモン Product Team Blog

株式会社コドモンの開発チームで運営しているブログです。エンジニアやPdMメンバーが、プロダクトや技術やチームについて発信します!

バッチ基盤リアーキテクチャリングの振り返りと今後の展望

こんにちは、コドモンSREチームの小西です。

今年の1月に開催された、「SRE Kaigi 2026」というイベントで、バッチ基盤をリアーキテクチャリングした事例について発表しました。

tech.codmon.com

speakerdeck.com

発表当時は本番環境への移行実施中でしたが、現在は、すべてのバッチ処理の新基盤への移行が完了し、運用フェーズに入っています。

そこで今回は、バッチ基盤リアーキテクチャリングの振り返りや、移行後に見えてきた課題について書いていきたいと思います。

はじめに

最初に、バッチ基盤をリアーキテクチャリングするに至った経緯や、移行後のアーキテクチャについて説明します。

※ 発表資料と重複する部分も多くあるので、本ブログでは簡易に記載します。

バッチ基盤の課題

リアーキテクチャリング以前は、EC2上で動くcron経由で、バッチ処理を定期実行するアーキテクチャでした。

EC2

一方で、このバッチ基盤が、プロダクト向けのインフラのうちEC2で動く唯一の実行基盤であること / 構築・運用に深く関わったエンジニアが既に離任していることなどが重なり、以下の課題が生じていました。

  • 構成管理のナレッジが社内に広まっておらず、インフラ、ミドルウェアの変更作業はSREに依存

  • 一方でSREチームメンバーも構成に詳しいわけではなく、またバッチサーバー自体の可観測性が低いことも相まって、変更作業やトラブル対応のハードルが高い

上記によって、変更容易性が低く、新規開発や継続運用に支障が出始めている状況でした。

移行後のアーキテクチャ

上述のような状況の中、バッチサーバーのOS(Amazon Linux 2)のサポート終了案内を契機に、リアーキテクチャリングに取り組むことになりました。

ポイントは以下です。

[主要な要件]

  • デプロイ前後で実行中の処理が中断されないこと

  • 短い間隔の処理を滞りなく実行可能であること

  • 実行時間の上限がないこと(バッチ処理の種類によっては、1回の実行が数時間かかるものもあるため)

詳細な要件

[設計指針]

  • 単純で理解しやすい構成であること = なるべくAWSマネージドサービスの組み合わせで構築されており、独自の作り込みが最小限であること

設計指針

これらの要件分析 / 設計指針を踏まえて、移行後は以下のようなアーキテクチャになりました。

移行後のアーキテクチャ

ポイントは以下です。

  • EventBridge Scheduler <> ECSタスクの間にStep Functionsを挟み、多重起動防止 / 失敗時のエラーハンドリング といった処理を共通化する。

  • 短い間隔での実行が必要なバッチ処理は、ECS on EC2 でタスクを動かすことで、コンテナイメージキャッシュを活用しECSタスク起動時のオーバーヘッドを軽減。

  • 一方で実行間隔が長い(実行頻度が少ない)処理についてはFargateを利用することで、従量課金制によるコスト面の恩恵や、サーバレスコンピューティングエンジンによる運用面での恩恵を得る

バッチ基盤リアーキテクチャリングの振り返り

ここからは、バッチ基盤のリアーキテクチャリングについて振り返っていこうと思います。

まずは、当初計画していた移行の進め方やアーキテクチャで、実際進めてみてどうだったか という観点で振り返ってみます。

進め方:段階的移行

移行作業の進め方についてですが、「影響の少ない処理から徐々に移行する」という手法を取りました。

段階的移行

理由は、以下です。

  • 移行に伴い何らかの問題が生じた場合の影響を局所化する

  • 移行に伴い何らかの問題が生じた場合に、発生原因切り分け・切り戻しが容易になり、障害復旧時間がはやくなる

  • 本番環境で動かすことによってしか得られないフィードバック(処理データ件数の違いによる負荷観点等)を、早い段階で得ることができる

結果として、大きな事故を起こすことなく、移行を完遂することができました。

また、移行した処理単位で確実に移行後確認を行うことができたので、「移行後、実はちゃんと動いていなかった」といった事態も防ぐことができました。

一方で、対象のバッチ処理の数が多く、移行完了までに時間を要した という側面もありました。

移行完了を早期に完遂する ということを重視したい場合は、たとえば「失敗時の影響がない / 少ない」処理群はまとめて移行する といった移行戦略が考えられるかと思います。
(※ もちろん、移行後の確認がやりづらくなる側面はあるので、この辺りはトレードオフかなと思います)

アーキテクチャ評価:Step Functionsの利用

Step Functionsを利用することで、以下のような恩恵を得ることができました。

  • 実行履歴を辿ることで、過去の実行詳細や、失敗した詳細を後から参照することができる
  • Step Functionsから引数を入力して実行することで、バッチ処理の手動実行を実施できる

一方、Step Functions(Standard)には、AWSアカウントの各リージョン単位で以下のレート制限が設けられています。(参考

種別 補充レート/秒
StateTransition 800
StartExecution 150

同じタイミングで複数のバッチ処理が起動することにより、上記の実行制限に抵触し、他のステートマシン実行に影響が出ることが懸念としてありました。

しかし結果的には、上記懸念が顕在化することはありませんでした。

EventBridge Scheduler はデフォルトで、0 ~ 59秒 のどこかで実行してくれる(つまり、60秒の精度で分散してくれる)ので、たとえば14時に動くスケジュールが複数あった場合も、後段のStep Functionsの実行時間は60秒の範囲内でバラけ、秒単位で同じ時間に実行が集中することを回避できる というのがその理由でした。

EventBridge スケジューラのすべてのスケジュールタイプは、60秒の精度でターゲットを呼び出します。つまり、1:00 にスケジュールを実行するように設定すると、柔軟な時間枠を設定していない限り、ターゲット API は 1:00:00 と 1:00:59 の間に呼び出されます。
EventBridge スケジューラのスケジュールタイプ - EventBridge スケジューラ

Step Functions実行一覧

もし、60秒より大きな範囲で実行を分散させたい場合は、フレックスタイムウィンドウで任意の期間を指定すれば可能です。

また別軸で、Step FunctionsのExpressワークフロータイプを選択すれば、より大量の同時実行が可能になります。

ただし、Expressワークフローは最大5分間という実行時間制限があり、バッチ処理の要件と合わなかったので、採択しませんでした。

アーキテクチャ評価:EC2 / Fargate データプレーンの併用

移行後のアーキテクチャ項目で記載の通り、バッチ処理(実行間隔)によって、EC2 / Fargate の最適な方で動かす という戦略をとりました。

一方、開発者視点に立つと、「この処理は実行間隔がxxxだから、Fargateで動く。なのでxxxのファイルを変更して、ログはyyyをみて...」といったように、稼働するデータプレーンの違いによって意識すべき箇所が多いと、運用時に気にしないといけないことが多くなり大変です。認知負荷増大にもつながってしまいます。

他方、「本来Fargateで実行すべき処理をEC2で実行してしまった」などの間違いが生じるのも避けたいです。

そこで、EC2 / Fargate の使い分けをどう表現するか については以下の方針を取りました。

  • 実行の入り口は EC2 / Fargate で明確に分離し、実行先の間違いを防ぐ
  • 開発者が運用時に触る箇所は EC2 / Fargate で共通化し、運用時の認知負荷を軽減する

順を追って説明していきます。

[実行の入り口は EC2 / Fargate で明確に分離する]

定期実行スケジュールはEventBridge Schedulerを用いて実装しています。
また、EventBridge SchedulerはTerraformにて定義、作成しています。

ここで、定期実行処理をEC2で動かしたい場合 / Fargateで動かしたい場合 とで、変更を加えるファイルを分離しました。

  • EC2で動かしたい場合:batch_schedules_on_ec2.yaml を編集
  • Fargateで動かしたい場合:batch_schedules_on_fargate.yaml を編集

YAMLファイルの中身は以下のような形式です。(バッチ処理ファイル名やスケジュールは仮です)
開発者は、いずれかのファイルに変更を加え、定期稼働させたいバッチ処理の追加や実行スケジュール変更を行います。

# テスト実行バッチ1
test_execute_batch_1:
  schedule_name: scheduler-batch-test_execute_batch_v1
  schedule_expression: "cron(*/5 * * * ? *)"
  state: ENABLED
  input: |
    {
      "target_batch_file": "src/test_execute_batch_v1.php",
      "parameter": ""
    }

# テスト実行バッチ2
...

実際にEventBridge Schedulerを作成するのは以下のようなTerraformファイルですが、普段は触る必要はありません。

↓Terraform記述例(関係する箇所のみ記載しています。)

locals {
  batch_schedules_on_ec2 = yamldecode(templatefile("batch_schedules_on_ec2.yaml", {
    environment = var.environment
  }))

  batch_schedules_on_fargate = yamldecode(templatefile("batch_schedules_on_fargate.yaml", {
    environment = var.environment
  }))
}

##########
# EventBridge スケジュール (ECS on Fargate 専用)
##########
resource "aws_scheduler_schedule" "batch_on_fargate" {
  for_each = local.batch_schedules_on_fargate

  name                = each.value.schedule_name
  description         = each.value.schedule_name
  schedule_expression = each.value.schedule_expression
  state               = each.value.state

  target {
    arn   = aws_sfn_state_machine.batch_on_fargate.arn
    input = each.value.input
  }
}

##########
# EventBridge スケジュール (ECS on EC2 専用)
##########
resource "aws_scheduler_schedule" "batch_on_ec2" {
  for_each = local.batch_schedules_on_ec2

  name                = each.value.schedule_name
  description         = each.value.schedule_name
  schedule_expression = each.value.schedule_expression
  state               = each.value.state

  target {
    arn   = aws_sfn_state_machine.batch_on_ec2.arn
    input = each.value.input
  }
}

この方式によって、定期実行の入り口を分離しつつ、変更量が最小となるように工夫しました。

次に、バッチ処理を手動実行する際の入り口についてです。

バッチ処理を手動実行する際はStep Functions経由で実行する運用にしています。

そこでStep Functionsについても、EC2 / Fargate でステートマシンを分離しました。

EventBridge / Step Functions 構成図概略

[開発者が運用時に触る箇所は EC2 / Fargate で共通化し、運用時の認知負荷を軽減する]

以下については、運用時に開発者が参照・変更する機会があるため、EC2 / Fargate で共通化しました。

共通化したコンポーネント 開発者の運用機会
環境変数定義ファイル / 機密値の外部格納先(SecretsManager) 環境変数の変更・追加・削除時
ログ出力の設定ファイル(FluentBit設定ファイル)/ ログ出力先(S3・Datadog・Athena) 主にログの閲覧。設定ファイルを変更することもある
コンテナイメージ関連ファイル(Dockerfile・設定ファイル系)/ ECRリポジトリ ミドルウェア追加、変更時。ECRリポジトリについては脆弱性検知時に参照することがある。
デプロイワークフロー 定期デプロイ実行時
ECSタスクに紐づくIAM Role(タスクロール・タスク実行ロール) AWSとのやり取りを伴う処理新設、変更時

実装上で特に工夫したのは、環境変数定義ファイルです。

ECSタスク定義自体は、EC2 / Fargate の仕様やサイドカーコンテナ定義の違いにより、分離する必要がありました。

そのため、環境変数部分のみを外部ファイルとして切り出すことで、環境変数定義ファイルのみ共通化しています。

これは、ECSワークロードの管理ツールとして用いている ecspresso × Jsonnet を用いることで実現しています。

↓ディレクトリ構成(細かい部分は省略)

ecspresso/batch
├── common
│   ├── batch-envs.libsonnet
│   ├── batch-secrets.libsonnet
│   └── environment
│       ├── xxx.env
│       ├── yyy.env
│       └── zzz.env
├── ecs_on_ec2
│   ├── ecspresso.yml
│   └── task-def.jsonnet
└── ecs_on_fargate
    ├── ecspresso.yml
    └── task-def.jsonnet

↓task-def.jsonnet(on EC2 を例に)

local must_env = std.native('must_env');
local batch_env_vars = import '../common/batch-envs.libsonnet';
local batch_secret_vars = import '../common/batch-secrets.libsonnet';

local ec2_specific_env_vars = [
  {
    "name": "DATA_PLANE",
    "value": "EC2"
  }
];

{
  "containerDefinitions": [
    {
      "name": "main-container",
      "environment": batch_env_vars + ec2_specific_env_vars,
      "secrets": batch_secret_vars,
      ...
    },
    {
      "name": "log_router",
      ...
    }
  ],
  ...
}

共用リソース 構成図概略

[実施結果]

この構成によって、開発者の運用負荷を下げつつ、実行先の間違いを防ぐことができています。

細かい点を挙げるとするならば、IAM Role(タスクロール・タスク実行ロール)の共通化については、場合によっては最小権限の原則から外れてしまうかもしれないので、EC2 / Fargate データプレーンの違いによって必要な権限の差が大きい場合は分離した方が良いかもしれません。

アーキテクチャ評価:ECSタスク起動時間の高速化のための、イメージキャッシュ戦略

続いて、ECSタスクをEC2データプレーンで動かす際に工夫した点と、実施結果について記載していきます。

EC2データプレーンを用いる場合、ECSタスクの起動時間高速化が主要な関心事になります。

基本的には、コンテナイメージキャッシュを利用してイメージPull時間を節約することで、タスク起動時間の高速化を図っていきます。

そこで、EC2コンテナインスタンスのイメージキャッシュ戦略においては、以下を満たすことが重要でした。

  • 日中2回実施されるデプロイ後、初回のバッチ処理実行以外は、イメージキャッシュを利用できること
  • 使われない(旧世代の)イメージキャッシュは一定期間経過後削除することで、ディスクフルにならないようにする

上記挙動を実現するため、イメージキャッシュ挙動に関わる環境変数値は以下のように設定しました。

パラメータ 設定理由
ECS_IMAGE_PULL_BEHAVIOR once イメージタグはイミュータブルなので、キャッシュを利用したい。ただしEC2は常駐している(入れ替わらない)ので、事故を避けるためにコンテナ自動クリーンアップが効くようにする
ECS_ENGINE_TASK_CLEANUP_WAIT_DURATION 10m 終了したコンテナは早期に解放してしまっても挙動影響がなさそうなので、ある程度早めに解放する。ただし秒単位で設定しなくても問題はなさそうなので、10mで設定する。
ECS_DISABLE_IMAGE_CLEANUP false
※ デフォルト
EC2は常駐している(入れ替わらない)ので、事故を避けるためにコンテナ自動クリーンアップが効くようにする
ECS_IMAGE_CLEANUP_INTERVAL 10m 最小値。より頻繁にクリーンアップ
ECS_IMAGE_MINIMUM_CLEANUP_AGE 1h
※ デフォルト
プル(= デプロイ)から1時間経過した、未使用イメージ(= 旧バージョンのイメージ)を削除対象にする
ECS_NUM_IMAGES_DELETE_PER_CYCLE 5
※ デフォルト
1日2回デプロイなので、デフォルトで十分

環境変数設定はユーザーデータを使用して、EC2コンテナインスタンス起動時に設定されるようにしています。↓設定例

cat <<'EOF' >> /etc/ecs/ecs.config
ECS_IMAGE_PULL_BEHAVIOR=once
ECS_ENGINE_TASK_CLEANUP_WAIT_DURATION=10m
ECS_IMAGE_CLEANUP_INTERVAL=10m
EOF

上記設定によって、EC2データプレーンで稼働するECSタスクにて動くバッチ処理は原則イメージキャッシュを利用して、起動時間の高速化を図りつつ、EC2コンテナインスタンスにキャッシュが蓄積し続ける事象を防ぐことができました。

EC2コンテナインスタンス ディスク使用状況

アーキテクチャ評価:ECSタスク起動時間に関わるその他設定

ECSタスク起動時間に関わるその他の設定は、運用面やパフォーマンス影響も加味し、以下のようにしました。

[ECSタスクは、PHPコンテナ + FluentBitサイドカーコンテナ の2構成とする]

コンテナ標準出力ログを外部に転送する用途で、FluentBitを利用しています。

ECSでFluentBitを利用する方法としては、AWS FireLensを介したサイドカー方式が一般的かと思います。

一方、EC2データプレーンの場合、ECS Service のDAEMONタイプを用いて、FluentBitを独立したコンテナとして常駐させて利用することも可能です。構成例

ECSタスク起動時間という観点だと、後者のFluentBitコンテナをデーモンとして常駐させる方式の方が、FluentBitコンテナを使いまわせる分優位です。

ただしこの方法の場合、EC2コンテナインスタンスの入れ替わり(スケーリング・手動更新)が発生した際、RunTask API経由で実行したスタンドアロンタスク(バッチ処理)が起動中にもかかわらず、DAEMON SET Task(FluentBitコンテナ)が先に終了する という事象が発生することがわかりました。これによって、EC2コンテナインスタンス入れ替え時に、実行中だった処理についてログの欠損が生じてしまいます。

ログの欠損は運用上許容できなかったため、FluentBitについてはAWS FireLensを介したサイドカー方式で利用することにしました。

[FluentBitコンテナのヘルスチェックおよび相互依存]

上述のようにログの欠損が運用上許容できないため、FluentBitコンテナにはヘルスチェックを設定 & ステータスがHEALTHYになってからバッチ処理用のコンテナを起動することで、ログを取りこぼすことがないようにしています。
(ヘルスチェックに要する起動時間のオーバーヘッドを許容しています)

ヘルスチェックの方式は、公式ドキュメントの推奨通り「シンプルな稼働時間ヘルスチェック」を採択しています。

For health check, we recommend new users either do not enable a health check or choose the simple uptime health check.
Fluent Bit FireLens Container Health Check Guidance

[ECSタスクのネットワークモードは、セキュリティ / ネットワークパフォーマンスを考慮し、awsvpc ネットワークモードとする]

ECSタスク起動時間という観点だと、bridgeネットワークモードが優位です。

awsvpc ネットワークモードが理想的な例は多数ありますが、このネットワークモードは本質的にタスク起動レイテンシーを増加させる可能性があります。これは、awsvpc モード内の各タスクについて、Amazon EC2 API を呼び出して ENI をプロビジョニングしてアタッチする必要があるため、タスク起動に数秒のオーバーヘッドが追加されるためです。
デプロイ速度を優先する場合は、bridge モードを使用してタスクの起動をスピードアップすることを検討してください。
Amazon ECS タスクの起動時間を最適化する

一方、セキュリティ / ネットワークパフォーマンス という観点だと、awsvpcネットワークモードが優位です。

awsvpc が推奨ネットワークモードです。これは、タスクにセキュリティグループを割り当てるのに使用できる唯一のモードであるため推奨されます。また、Amazon ECS の AWS Fargate タスクで使用できる唯一のモードでもあります。
Amazon ECS のネットワークセキュリティのベストプラクティス


host および awsvpc のネットワークモードは、Amazon EC2 のネットワークスタックを使用するので、コンテナのネットワークパフォーマンスが最大限発揮されます。
Amazon EC2 における Amazon ECS タスク定義パラメータ - Amazon Elastic Container Service

特に、セキュリティ観点(ECSタスク単位でSecurityGroupを割り当て可能か否か)が重要だったため、awsvpcネットワークモードを利用することにしました。

[実施結果]

このような構成にて、EC2データプレーンで稼働するECSタスクは、概ね15秒前後で起動 & 処理を開始することができました。

バッチ処理の要件として、最短で2分間隔で動くバッチ処理を滞りなく実行する必要がありましたが、この要件について満たすことができました。

もし、より高速にECSタスクを起動する必要がある場合は、サイドカーコンテナをなくす(もしくは依存関係を除く) / bridgeネットワークモードで起動させる ということを実施すれば最速になるかと思います。

ただしその場合、既に記載の通り、運用面やセキュリティ面でデメリットが生じるので、トレードオフかなと思います。

移行後に見えてきた課題

次に、移行後に見えてきた課題や、「この選択肢もあったな」と後になって思った点、今後の伸び代だと思う点について記載していきます。

Step Functionsの分割単位

Step Functionsステートマシンの分割単位について。

現状だとアーキテクチャ評価:EC2 / Fargate データプレーンの併用で記載しているように、実行するデータプレーン(EC2 / Fargate)によって、Step Functionsを分割して作成しています。

一方で、処理単位では分割しておらず、複数の処理が同じStep Functionsで動く構成となっています。

移行前はこの構成で問題ないかなと考えていましたが、実際に運用してみると、以下の課題がありました。

  • Step Functions実行履歴一覧から、特定の処理に関する実行履歴を見つけづらい
    (特に実行間隔が短く、大量の処理が実行されるEC2側で顕著)
  • Step Functions実行失敗を検知する際に、処理単位での実行失敗を引っ掛けづらい
    (現状はDatadog EventにStep Functions入力文字列含め飛ばした上で、Datadog Monitor Alertで入力文字列指定で拾っているが、この辺りの条件設定がやや煩雑になる)

これらの課題を鑑みると、バッチ処理の単位でStep Functionsを分割した方が分かりやすいかもしれない と思っています。

Step Functionsの料金は状態遷移数に応じて発生するので、ステートマシン数によりません。
またレート制限もアカウント・リージョン単位で発生するので、ステートマシン数が多くても問題ありません。

新規バッチ処理作成時に、Step Functionsステートマシン関連のリソースを作成する手間は生じますが、この辺りについてもTerraformコードを工夫してテンプレート化、抽象化等すれば上手くできそうです。

処理単位でのハンドリングが重要な場合は「処理単位でStep Functionsステートマシンを分割する」というアーキテクチャも一案になるかと考えています。

ECSタスク定義の分割単位

ECSタスク定義の分割単位についても、Step Functionsと同じく、実行するデータプレーン(EC2 / Fargate)で分割しています。

通常はこの分割粒度で問題ないですが、たとえば「特定処理だけ新バージョンで動かしたい」という要望があった場合。
ECSタスク定義のリビジョン = ソースコードのバージョン単位 となるので、実現するには一工夫が必要になります。

この際、処理単位でECSタスク定義を分割していれば、処理単位でソースコードのバージョンを変えることができるため、上記のような要望や、カナリアリリースのようなワークロードが実現しやすいと思います。

たとえば、プログラミング言語のバージョンをアップしたいが、慎重に行いたいので、まずは失敗時の影響が少ない処理だけ新バージョンでリリースしてみる ということも容易にできそうです。

ただし、ECSタスク定義については数が多くなるだけ、デプロイワークフローが複雑化したり、管理コストは上がるので、そことのトレードオフかなと思います。

EC2コンテナインスタンスのコストを最適化する

今回はECSマネージドインスタンスではなく、旧来の方法(ECSコンテナインスタンスをセルフマネージドで運用する方法)にて実装しています。

そのため、ECSコンテナインスタンスのスペックや台数は、ECSタスク稼働数やCPU使用率等によらず、固定です。

一方、弊社のバッチ処理の特性として以下があります。

  • 特定の時間帯(平日夕方)にピークタイムを迎える(処理件数量が増え、各バッチ処理の負荷も上がる)
  • 夜間や休日は処理件数が少なく、負荷も低い

そのため、夜間や休日といった負荷が低い時間帯においては、EC2コンテナインスタンスがオーバースペックになりがちです。
コスト最適化の観点においては、理想的な状況とは言えません。

そこで今後の改善点としては、例えば予め負荷状況を時間予測して、EC2コンテナインスタンスを自動スケーリングさせたり、ECSマネージドインスタンスを導入して、コスト面を最適化することが考えられます。

まとめ

今回は、バッチ基盤リアーキテクチャリングの振り返りと今後の展望についてご紹介させて頂きました。

全体を通してみると、コンテナ化による変更容易性向上によって、従来の運用負荷が改善できたり、また個人的にも学びが多いプロジェクトでした。

今後は移行後に見えてきた課題の事項を検討し、アーキテクチャの改善や発展に取り組んでいければと考えています。