アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

バッチ処理をFargateに移行した

morishitaです。

先日、いこーよを Kubernetes に移行した件を紹介しました。

tech.actindi.net

いこーよは Web だけで動いているわけではなく、裏で定期的に実行されるバッチ処理も行っています。
本エントリではそのバッチ処理の実行環境を Fargate ECS に移行した件について書きます。

移行前はどうなってた?

いこーよは Rails で開発しています。
バッチ処理というと、DB を集計したり、一斉に DB の値を書き換えたりするものがほとんどです。
そこで ActiveRecord のモデルを利用したいので、バッチ処理も Rails で実装しています。
つまり、Rails Runner で定期的にバッチ処理を実装したメソッドを実行しています。

移行前は、バッチ処理専用サーバがあって、crond で定期的に実行していました。
バッチ処理そのものやバッチ処理に利用しているモデルに変更があるため、毎回のリリース時にはバッチサーバにもデプロイして変更を反映していました。

なぜバッチ処理も Kubernetes で稼働させなかったか?

当初は、Kubernetes の CronJob を利用しようと思っていました。
でもやめました。

バッチ処理は1日中なにか走っています。
たまたま、Job 開始のタイミングにクラスタにリソースが足りなければクラスタがスケールアウトするのを待って実行されることになり、予定の時間よりその分遅れることになります。
いつも一定のタイムラグがあるならそれを見越して実行すればいいのですが、タイミングによりタイムラグの大きさが変動するというのは御しがたいと思いました。
逆にバッチ処理がクラスターのオートスケールに影響するのも避けたいとも考えました。

さらに Kubernetes クラスタの運用をシンプルに保ちたかったということもありました。
Kubernetes クラスタの運用について Kubernetes アップデートなどクラスタ全体に影響する変更はクラスタのリプレースで行う方針です。
その作業中もバッチ処理は実行される必要がありますし、新旧2つのクラスタが並行稼動している間、重複して実行されることも避けなければなりません。
Kubernetes 上でバッチ処理を実行すると、リプレース作業中のバッチ処理の実行状況にも気を配らなくてはいけなくなります。
バッチ処理が走っていない時間帯に作業スケジュールが縛られたり、いつバッチ処理を新クラスタに移行するかも考えねばならなくなります。 考慮事項を減らし、できるだけカジュアルにクラスタのリプレースを実施したいので、クラスタの稼働自体がステートフルになって作業が複雑になるも避けたいと考えました。

これら理由から別の場所でバッチ処理を実行する方法を検討することにしました。

Fargate ECS によるバッチ処理環境

Kubernetes 上で実行するのはやめたけれどコンテナで実行するようにはしたい。
かといって、バッチ専用サーバを立てるとそのサーバのメンテナンスについて考えないといけなくなります。それも避けたい。
と考えた時、選択できる稼働環境として2つ検討しました。

  • AWS CodeBuild
  • ECS のタスク

今回は ECS のタスクとして Fargate で実行することとしました。 どうして、CodeBuild を利用しなかったかは後述します。

ECS によるバッチ処理実行環境

次の様なバッチ処理の実行環境を AWS CDK を使って構築しました。

f:id:HeRo:20201005104209p:plain
システム構成

① CloudWatch Event を使って定期的に ECS のタスクを実行します。
② タスクではいこーよのサイトで動いているのと同じ Docker イメージを取得します
③ Rails Runner でバッチ処理を実装したメソッドを実行します。 Rails コンテナだけでなく Datadog エージェントのコンテナも動かしてメトリクスを収集しています。
④ バッチ処理の終了を CloudWatch Event で拾って、⑤ 通知用の Lambda 関数を実行します。
⑥ タスクの実行結果をイベントとして Datadog に通知します。

ログは Cloudwatch へ、結果は Datadog へ通知

各バッチ処理の実行ログは awslogs ログドライバーを使って、CloudWatch Logs に出力しています。
タスクの実行ごとにログストリームが作られるので、他のバッチ処理や、前回のログと混ざりません。AWS コンソールでさっと確認できるので便利です。ただ、CloudWatch Logs のコンソールは検索性や一覧性が悪いので、エラーが発生したときなどログが多いときにはダウンロードして参照したほうが確認しやすいです。

実行結果はイベントとして Datadog に送っています。Datadog 上で次の様に見ることができます。

f:id:HeRo:20201005104052p:plain
結果の通知

伏せてありますが、どのバッチ処理が実行されたのか、何時に始まって何時に終わったのかが確認しやすいようにしています。
Datadog のイベントの中にある ECS RunTaskLog というリンクは AWS コンソールへのリンクで、実行ログを開けるようにしています。
エラーが発生した場合には Datadog から Slack に通知するようにしており、すぐに気づくことができます。

NAT 経由の通信に注意

Fargate を Private サブネットで動作させる場合、ECR から Docker イメージ取得するのは NAT ゲートウェイ経由になります。
Rails アプリケーションを含むような Docker イメージはそこそこ大きくなります。そのイメージを Pull するのに高価な NAT ゲートウェイ経由の通信で行われるのでコストに要注意です。
特に1日になんども定期的に動作せるようなバッチ処理があるとその都度イメージを取得するので思った以上の通信量なります。NAT経由になると知らず、移行後の最初の請求でびっくりして NAT 経由にならないように変更しました。

現状はバッチ処理では外にポートを開くようなプロセスを起動しないので Public サブネットで動作させて、NAT 経由になるのを回避しています。
いずれ、PrivateLink に移行できればと思っています。

CDK の Tips

AWS コンソールでタスクを設定して実行するときには「パブリック IP の自動割り当て」を設定できます。しかし、CDK で ECS タスクを表す @aws-cdk/aws-events-targets.EcsTask クラスにはこれを設定する機能は実装されていないようです。常に DISABLED に設定され、パブリック IP が割り当てられません。この状態ではパブリックサブネットから ECR に接続できません。

CDK では便利な機能が実装された高級なクラスだけでなく、CloudFormation のマニフェストを直接操作できる接頭辞 Cfn がついたクラス群も提供されています。パブリック IP 割当を設定するにはこの Cfn クラスを利用します。
それを利用してパブリック IP を ENABLED に設定するには例えば次の様に実装します。

import {Rule, Schedule, CfnRule} from '@aws-cdk/aws-events';

const rule = new Rule(this, 'SomeScheduleRUle', {
    schedule: Schedule.cron(cronUtc),
    targets: [ecsTaskTarget], // ecsTaskTargetは @aws-cdk/aws-events-targets.EcsTask のインスタンス
});
const cfnRule = rule.node.findChild('Resource') as CfnRule;
cfnRule.addPropertyOverride('Targets.0.EcsParameters.NetworkConfiguration.AwsVpcConfiguration.AssignPublicIp', 'ENABLED'); // <== ここ!

CodeBuild をやめた理由

さて、この Fargate ECS への移行は安定稼働を続けていますが、最初は私の大好きな CodeBuild で実現しようと思っていました。 それをやめたクリティカルな理由は唯1つ。

CodeBuild の連続稼働時間は最大8時間なのですが、それ以上に時間がかかるバッチ処理があったためです1

なぜ、CodeBuild が良かったかという、コンテナ起動前に処理を挟みやすいからです。
ECS では新しい Docker イメージをビルドするごとに、 TaskDifinition も更新しないと次回のバッチ実行に反映できません。しかし CodeBuild なら Kubernetes マニフェストリポジトリから最新のイメージタグを取得して、そのイメージを取得してからコンテナを実行できます。つまり、わざわざイメージビルドの度になにか更新する手間をかけずに最新のイメージを実行することができます。

結局、イメージをビルドするごとに Kubernetes へのデプロイと同時に、CDK を利用して TaskDifinition を更新しています。

少々、リリースごとの処理が増えたものの、Fargate のほうがコストが低いので結果的には良かったかと思っています。

まとめ

これまで、EC2 で実行していたバッチ処理を ECS の Fargate タスクで実行するようにしました。
EC2 インスタンスの運用から解放され、バッチ処理それぞれの CPU、メモリの使用量などのメトリクスを収集できるようになり、ログも確認しやすくなりました。
更に結果やエラーの通知も改善できました。
そして、バッチサーバを運用していたときよりコストも下げられたと思います。


  1. Fargateで実行してみると、10時間かかっていたバッチも6時間程度しかかかりませんでした。元々の環境では複数のバッチが同時に実行されていたのでメモリ不足だったのかもしれません。