Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

EventBridgeとECSでお手軽バッチ処理基盤 (前編)

こんにちは, メディア開発部の今村です.

この記事はGunosy Advent Calendar 2022の6日目の記事です. 昨日の記事は村田さんの「Digdag が突然止まった障害を受けて」でした.

この記事 (前編) と明日の後編では, EventBridgeとECSでバッチ処理基盤を整備した話を紹介します.

背景 & 技術選定

最近はプッシュ通知送信システムのリプレイスを行なっており, その一環でEC2インスタンス上のcronで動くバッチ処理を移行することになりました. これまでチームとして決まったバッチ処理置き場を持っておらず, LambdaやEKS CronJobなどバラバラな環境を使っていたため, これを機に共通基盤を作ることにしました.

まずは要件を整理したところ, 以下のようになりました.

  • 時間のかかる処理も実行したい
  • (現時点では) バッチ処理の種類は少なく, 依存関係の設定も必要ない
  • (現時点では) 自動リトライは要らない
  • 処理が失敗した場合は確実に気づきたい

見ての通り要件はシンプルです. 加えて, 作った後のメンテナーの人数が限られている状況だったため, サッと作れてメンテナンスの手間が少ないことを重視して技術選定をしました.

以下, スケジューリングと実行環境, 監視に分けてそれぞれ説明します.

スケジューリング

社内の技術的な環境から, Digdag, EventBridge*1, EKS CronJobが候補に上がりました.

Digdag (というかワークフローエンジン一般) には, 高機能な管理画面*2が使える, 複雑な依存関係を設定できるなどのメリットがあります. しかし, セットアップやバージョンアップ, 付随するDBインスタンスの維持といったコストもかかるため, 今回は使わないことにしました.

EventBridgeには色々な機能がありますが, ここではcronで動くルールから処理を始動する使い方を想定しています. これとCronJobについては単体ではどちらでもいい状況でした. ただ, 後々リトライや依存関係を設定する目的で (スケジューラーと実行環境の間に) Step Functionsを入れたくなる可能性を考え, 最終的にEventBridgeを選びました.

実行環境

時間のかかる処理も動かしたいのでLambdaは除外し, ECSタスク or EKS Job*3で考え, ECSタスクにしました. 理由は単純で, EventBridgeの直接のターゲットとしてEKS Jobは非対応*4だからです. グノシーのバックエンドサーバーはEKSで動いているので, EKSクラスターを持っているところに新しくECSクラスターを作ることになりますが, バージョンアップやインスタンスの管理がないので許容できると考えました.

監視

バッチ処理の監視が不十分だと, 実は半年間動いてませんでした という事態が発生する可能性があります. 今回は手軽さを重視するとはいえ, そのようなことがないよう監視は真面目に考える必要があります. GunosyではDatadogに監視を集約しているので, スケジューラーやタスクの情報をDatadogに集め, 起動や実行の失敗を検知してアラートが発火する (→ Slackに通知が来る) ようにしました. 監視については後編で詳しく説明します.

タスク管理リポジトリの作成

構成

実装する上でのコードやデプロイの管理について紹介していきます. 大まかには

  • ECSタスク定義とアプリケーションコード → 新規リポジトリを作成
  • それ以外のリソース*5 → Terraformの設定ファイルを集めた既存リポジトリで管理

という形で管理することにしました. Terraformを使ったAWSリソースの設定については, 特に変わったことはしていないので省略*6します.

タスク管理リポジトリは複数のタスクを管理するモノレポ構成にしました. ディレクトリ構成は以下のようになっています.

.
├── README.md
├── batches
│   ├── batch1
│   │   ├── Dockerfile
│   │   ├── README.md
│   │   ├── ecspresso
│   │   │   ├── .env_prd
│   │   │   ├── .env_stg
│   │   │   ├── config.yaml
│   │   │   ├── service.json
│   │   │   └── task.json
│   │   ├── go.mod
│   │   ├── go.sum
│   │   └── main.go
│   ├── batch2
│   └── batch3
└── command.sh

batches以下の各ディレクトリ*7が1つのECSタスクに対応し, それぞれアプリケーションコード*8, Dockerfile, タスク定義を持ちます. ecspresso/ 以下がタスク定義の設定ファイルです.

ecspressoでタスク定義を管理する

タスク定義の管理にはecspresso*9を使いました. ecspressoはECS用のデプロイツールで,

  • (素のJSONと比べて) タスク定義等の設定ファイルをテンプレート化して柔軟に記述できる
  • (Terraformなどの汎用的なIaCツールと比べて) アプリケーションコードとまとめて管理, デプロイするのに適した作りになっている*10

というメリットがあります.

関連するファイルを見ていくと, config.yaml はecspresso自体の設定ファイル, task.json , service.json はテンプレート化したタスク定義, サービス定義*11です. .env_prd , .env_stg は例えば

CPU=256
MEMORY=512 
MYSQL_HOST=*****.rds.amazonaws.com

のような環境変数が入っています. ecspressoでは, コマンド引数として --envfile=<path to env file> を渡すことでenvファイルを読み込ませることができるので, 環境毎に別のファイルを用意することで値を切り替えています. 環境変数をタスク定義に埋め込むには

"memory": "{{ must_env `MEMORY` }}",

という記法を使います. また, IAM roleなどTerraformで管理しているリソースの参照にはtfstateの読み込み機能を使いました. ecspressoの設定ファイルでtfstateの場所*12を指定し, タスク定義で以下のような記法を使うことで値の埋め込みができます.

"taskRoleArn": "{{ tfstate `aws_iam_role.test_task_role.arn` }}",

Jsonnetなど更に複雑な機能もありますが, 今回は上に挙げた機能だけで問題なくタスク定義を記述することができました.

より開発しやすくするために

あとはCIを通してDockerイメージのbuild & pushとタスク定義の更新をするよう設定すれば開発は進められるのですが, ローカルでの作業中に

  • (モノレポ構成ということもあり) ecspressoコマンドに意図した環境, タスクの設定ファイルを渡すのが面倒
  • 変更後のアプリケーションコードをサッとクラスター上で実行したい

という課題を感じたため, ecspressoコマンドをラップするスクリプトを書くことにしました. それがディレクトリツリーの中の command.sh です. 中身は省略しますが, 1つめの課題に対しては ecspresso --config=batches/${TARGET}/ecspresso/config.yaml ... のように引数をテンプレート化することで対処しました. 例えば

$ ./command.sh ecspresso batch1 render --task-definition

と入力した場合に

$ ecspresso --config=batches/batch1/ecspresso/config.yaml --envfile=batches/batch1/ecspresso/.env_stg render --task-definition

が実行されるようにしています.

2つ目の課題については, 細かいことを考えなければステージング環境のタスクを上書きして実行してしまえばいいのですが, 環境を壊したり他の開発者の作業とコンフリクトしたりといった懸念があります. そこで, AWS CLIのログイン情報から開発者の名前を取得し, 個別のタスク作ることにしました. 具体的には, スクリプトの中に

# dev-lastname-firstname という形になる
TASK_ENV="$(aws sts get-caller-identity | sed ...) 

という環境変数があり, この値をDockerイメージのタグとして使っています. また,

"family": "{{ must_env `TASK_ENV` }}-batch1",
...
"containerDefinitions": [
    {
      "image": "***/batch1:{{ must_env `TASK_ENV` }}",

のように, ecspressoコマンド経由でタスク定義にも埋め込んでいます. これによって, ./command.sh dev-run batch1 の1コマンドで

  • 開発者固有のタグでDockerイメージをプッシュ
  • プッシュしたイメージを参照する開発者固有のタスク定義を作成
  • タスクを実行

が実行されるようになっています. タスクの実行やアラートの発火といった動作確認が簡単になったので, 個人的には気に入っています*13.

おわりに

以上, 前編では技術選定やタスク管理リポジトリについて紹介しました.

明日の後編では監視の話をします!

*1:元CloudWatch Event

*2:実行ステータスの確認やリトライなど

*3:どちらもFargate前提です

*4:間にLambdaを挟んだりすればできそうではありますが

*5:EventBridge, ECSクラスター, 権限設定, 監視, …

*6:例えばEventBridgeからECSタスクを起動する設定は https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target を参考にしました. inputを使って色々できます.

*7:実際はbatch1, batch2, …ではなくタスクの名前

*8:例としてGoのコードを置いています

*9:詳しくは https://adventar.org/calendars/5916 または https://zenn.dev/fujiwara/books/ecspresso-handbook にまとまっています

*10:同じくアプリケーション寄りなStep Functionsの定義をTerraformで管理したことがあるのですが, リポジトリを行き来するのが面倒, 共有リポジトリゆえにCIが遅い, 他チームとリリースタイミングの調整が発生する, などの問題に悩まされました.

*11:今回使わないはずのサービスの定義ファイルがありますが, これはローカルからタスクを実行するときのネットワーク設定を書く場所として必要だからです. ecspresso v2では要らなくなる予定らしいです. https://github.com/kayac/ecspresso/issues/374

*12:例えばS3

*13:クラスター上での動作にこだわらなければECS CLIのローカル実行機能で済むかもしれません