「ECS スタンドアロンタスク」を Rails から起動する
バックエンドエンジニアの斧田です。よろしくお願いします。
弊社では、「重い処理」を持つジョブを Sidekiq Job として実行せず、 ECSスタンドアロンタスク 上で rake-task として実行をすることにしました。
その結果、ジョブシステム全体・ジョブ単体 両方において 「メモリ使用量」や「処理時間」に関する設計・実装が寛容になり、よりビジネスロジックに注力できるようになりました。
背景
弊社では、 Rails の Jobアダプター に Sidekiq を採用しており、 ECS 上で Sidekiq のみ実行されているコンテナが常時起動し、 ジョブの処理を行っています。 (下図、 [1] )
一方で、データ量に応じて、メモリ使用量・処理時間が大きくなっていく「重い処理」を持つジョブは、「ECSスタンドアロンタスク」を新たに起動し、その上で rake-task で処理を行っています。 (下図、 [2] [3])
例を挙げると、以下のような処理が該当します。
日次での大量データの削除処理
大きいファイルを作成してエクスポートする処理
元々、これらのジョブも全て Sidekiq コンテナ上で実行していましたが、
大きいメモリを Sidekiq コンテナに予め割り当てておく必要がある
⇒ 無駄に大きいメモリを割り当てなければいけないアプリケーションのデプロイ等で、コンテナを再起動させるための待ち時間が発生する
⇒ ジョブに「処理途中で一時停止・再開」を行うための実装を行う必要がある
のような課題を抱えていました。
現在では、これらの「重い処理」を持つジョブを ECSスタンドアロンタスク 上で実行することにより解決しています。
アプリケーション構成と技術選定
弊社では 以下の様なアプリケーション構成とデプロイ方法を採用しています。
terraform で AWS 上に ECS Cluster / ECS を構築
GithubAction で escpresso を使用して ECS タスク定義 と ECS サービス を更新。
AWS上で「コンテナを起動して処理を行う技術」は、
AWS Step Functions
AWS Batch
ECS スタンドアロンタスク
など選択肢が豊富です。
今回 ECS スタンドアロンタスク を選定した際には、以下のような理由で行っています。
「最新の ECS タスク定義に更新し続ける」環境構築が既にできている。
弊社では、
terraform で AWS 上に ECS Cluster / ECS を構築
GithubAction で escpresso を使用して ECS タスク定義 と ECS サービス を更新してデプロイ
の方式で アプリケーション構築とデプロイを行っているため、追加の AWS 設定がほぼ不要でした。
Rails から 特定のタイミングで、 メモリ・CPU・処理対象 を変数で指定して起動できるだけで良い。
ただ起動して実行してくれるだけでOKなため、他のマネージドな技術である必要がありませんでした。
即時性が不要。
弊社環境では、 ECS スタンドアロンタスク を起動するだけで 1分ほど時間がかかります。
チューニングにより改善は可能ですが、数秒で起動して処理を開始すると言うようなことは難しそうです。
そのため、即時性が不要な処理のみを対象としています。
実装
タスク定義をデプロイ
ecspresso ( https://github.com/kayac/ecspresso ) で行っている実際のデプロイコードから抜粋します。
ECSスタンドアロンタスク を起動するための タスク定義をデプロイします。
(ここでは、 instant という名前のタスク定義にしています。)
ECSクラスターは、すでに Rails コンテナ や Sidekiq コンテナ が起動している ECSクラスターと同じものでOKです。
ポイントは、「 instant は、サービスとして常時起動しておく必要がないため、 service の指定をしない」という点です。
# .deploy/ecspresso-instant.yml
cluster: 'ECSクラスター名'
# service および service_definition は 指定しなくてOK
task_definition: ecs-task-def-instant.jsonnet
# .deploy/ecs-task-def-instant.jsonnet
{
family: 'instant',
networkMode: 'awsvpc',
requiresCompatibilities: ['FARGATE'],
cpu: '256', // 必要に応じて起動時に上書きする
memory: '512', // 必要に応じて起動時に上書きする
containerDefinitions: [
{
name: 'instant',
image: '{{ must_env `DOCKER_IMAGE` }}',
essential: true,
command: [
"/bin/sh", "-c",
"echo '[WARN] 必要な処理を行うためには、 command を上書きして instant task container を実行してください。 (exit 1)' && exit 1"
]
}
]
executionRoleArn: 'task_execution_role を指定',
taskRoleArn: 'task_role を指定',
}
# .github/workflows/deploy.yml
jobs:
deploy:
steps:
- name: Register to ECS instant task-definition
env:
DOCKER_IMAGE: "ECRへプッシュしたイメージ"
run: ecspresso register --config .deploy/ecspresso-instant.yml
Rails から ECSスタンドアロンタスク を起動しつつ、 rake-task を実行する
テスト用の rake-task を用意しておきます
# lib/tasks/dummy_task.rake
desc 'テスト用のダミータスク'
task :dummy_task, %i[test_arg] => :environment do |_task, args|
puts "ダミータスクが呼び出されました。 引数: #{args[test_arg]}"
end
ECSスタンドアロンタスク を起動し、その上で rake-task を実行
# app/services/
class EcsStandaloneTaskRunner
def self.run_standalone_task
credentials = Aws::ECSCredentials.new # ECS 上で実行中の環境であれば、認証情報をコレで取得できる
credentials = Aws::Credentials.new( # それ以外の場合は、認証情報を設定する必要がある
'aws_access_key',
'aws_secret_access_key'
)
# ECS を操作するための client を作成
ecs_client = Aws::ECS::Client.new(
region: "AWSリージョン",
credentials:
)
# ECSクラスターを取得
ecs_cluster_arn = aws_ecs_client.list_clusters.cluster_arns.detect { |cluster_arn| cluster_arn.end_with?("ECSクラスター名") }
# ECSタスク定義を取得
ecs_task_definition_arn = aws_ecs_client.list_task_definitions(
family_prefix: "instant",
sort: 'DESC',
max_results: 1,
status: 'ACTIVE'
).task_definition_arns.first
# networkMode: 'awsvpc' 時は、ネットワーク設定を指定する
# Railsコンテナ 等で使用しているネットワーク設定と同じでOK
network_configuration = {
awsvpc_configuration: {
subnets: ["subnet-aaa", "subnet-bbb"],
security_groups: ["sg-ccc"],
assign_public_ip: 'DISABLED'
}
}
aws_ecs_client.run_task(
cluster: ecs_cluster_arn,
task_definition: ecs_task_definition_arn,
network_configuration:,
launch_type: 'FARGATE',
overrides: {
# 起動時に CPU/メモリ 設定を上書きできる
cpu: '256',
memory: '512',
container_overrides: [
{
name: 'instant',
# ここで実際に実行する処理を指定する
command: [
'/bin/sh', '-c',
'bundle exec rake dummy_task[100]'
]
}
]
}
)
end
end
# EcsStandaloneTaskRunner.run_standalone_task で起動
無事に実行が確認できたら、 rake-task として実際のビジネスロジックを実装し、 ECSスタンドアロンタスク から呼び出すようにすれば完成です。
実施効果
「重い処理」を持つジョブにデプロイが引きづられることがなくなった。
「重い処理」を持つジョブの実装で検討事項が減った。
運用上、「重い処理」を持つジョブの開始〜終了時に、トランザクションレコードを更新する処理を入れ、外形監視的なことをしていますが、エラーもなく順調に運用できています。
まとめ
ジョブ設計・実装の方針としては、なるべく「ジョブ毎に小さい単位の処理」を心がける事が重要です。
が、中には「小さくするために、余計に複雑な実装になってしまう」ケースもあるかとも思われます。
そんな時に、別のコンテナで「重い処理」を実施できる仕組みの一つである、 ECSスタンドアロンタスク はいかがでしょうか。