見出し画像

「ECS スタンドアロンタスク」を Rails から起動する

バックエンドエンジニアの斧田です。よろしくお願いします。

弊社では、「重い処理」を持つジョブを Sidekiq Job として実行せず、 ECSスタンドアロンタスク 上で rake-task として実行をすることにしました。

その結果、ジョブシステム全体・ジョブ単体 両方において 「メモリ使用量」や「処理時間」に関する設計・実装が寛容になり、よりビジネスロジックに注力できるようになりました。

背景

弊社では、 Rails の Jobアダプター に Sidekiq を採用しており、 ECS 上で Sidekiq のみ実行されているコンテナが常時起動し、 ジョブの処理を行っています。 (下図、 [1] )

一方で、データ量に応じて、メモリ使用量・処理時間が大きくなっていく「重い処理」を持つジョブは、「ECSスタンドアロンタスク」を新たに起動し、その上で rake-task で処理を行っています。 (下図、 [2] [3])

例を挙げると、以下のような処理が該当します。

  • 日次での大量データの削除処理

  • 大きいファイルを作成してエクスポートする処理

元々、これらのジョブも全て Sidekiq コンテナ上で実行していましたが、

  • 大きいメモリを Sidekiq コンテナに予め割り当てておく必要がある
    ⇒ 無駄に大きいメモリを割り当てなければいけない

  • アプリケーションのデプロイ等で、コンテナを再起動させるための待ち時間が発生する
    ⇒ ジョブに「処理途中で一時停止・再開」を行うための実装を行う必要がある

のような課題を抱えていました。
現在では、これらの「重い処理」を持つジョブを ECSスタンドアロンタスク 上で実行することにより解決しています。

アプリケーション構成と技術選定

弊社では 以下の様なアプリケーション構成とデプロイ方法を採用しています。

  1. terraform で AWS 上に ECS Cluster / ECS を構築

  2. GithubAction で escpresso を使用して ECS タスク定義 と ECS サービス を更新。

AWS上で「コンテナを起動して処理を行う技術」は、

  • AWS Step Functions

  • AWS Batch

  • ECS スタンドアロンタスク

など選択肢が豊富です。
今回 ECS スタンドアロンタスク を選定した際には、以下のような理由で行っています。

  • 「最新の ECS タスク定義に更新し続ける」環境構築が既にできている。

弊社では、

  1. terraform で AWS 上に ECS Cluster / ECS を構築

  2. 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スタンドアロンタスク はいかがでしょうか。

参考文献