MENU

問い合わせ


    【Laravel+Vue.jsで作る管理画面をECSで動かす 第10回】キューワーカーとスケジューラを ECS で動かす

    第5回でローカルに作った「請求書発行のキュー」と「毎日・毎月の定期実行」を、ECS 上で動かします。キューは Amazon SQS(=AWS のメッセージキューサービス)に切り替え、ワーカー(=キューに積まれた仕事を順番に処理するプログラム)とスケジューラをそれぞれ ECS のサービスにします。あわせて、前回見送ったマイグレーション(=DB のテーブルを作る・変える処理)を実行する専用のタスクを用意します。

    「夜間のバッチ処理が止まっていたのに、何日も誰も気づかなかったことがある。裏で動く処理は、どうやって見張ればいいの?」

    結論から言うと、ワーカーとスケジューラは web と同じイメージを「コマンドだけ変えた別サービス」として動かし、止め方(SIGTERM で処理中のジョブを終えてから止まる)と、失敗したジョブの行き先(failed_jobs とデッドレターキュー)を最初に決めておきます。「止まったことに気づける」仕組みは、この行き先を見張るアラームとして第12回で仕上げます。

    前回(第9回:ECR・ECS on Fargate・ALB)では、web サービスと ALB を作りました。今回はその隣に worker・scheduler のサービスと migrate のタスク定義を並べます。

    これまでと同じく terraform apply は行っていません。terraform validate・AWS に接続しない形での plan・ローカルの Docker(SQS 互換サーバーを使用)での動作確認までを行っています。

    目次

    キューとスケジューラを ECS で動かすための構成

    役割ECS での形コマンド台数
    webサービス(第9回)php-fpm(既定)2台〜
    workerサービスphp artisan queue:work sqs1台〜(ジョブの量で決める)
    schedulerサービスphp artisan schedule:work常に1台
    migrateタスク定義のみ(必要なときに1回起動)php artisan migrate --forceデプロイごとに1回

    4つとも第6回で作った同じ app イメージです。今回追加・変更するファイルは次のとおりです。

    ファイル内容
    infra/terraform/sqs.tfジョブのキューとデッドレターキュー(DLQ)
    infra/terraform/ecs_worker.tf / ecs_scheduler.tf / ecs_migrate.tf各タスク定義とサービス
    infra/terraform/ecs_cluster.tf / iam.tfキューを SQS に切り替える環境変数、タスクロールに SQS の権限
    composer.json / composer.lockSQS を使うための AWS SDK for PHP を追加
    config/queue.phpSQS の設定(キューの URL、ローカル確認用の接続先)
    compose.sqs.yaml / docker/local/elasticmq.confSQS 互換サーバーでのローカル確認用

    SQS のキューとデッドレターキューを作る

    可視性タイムアウトはジョブの timeout より長く

    infra/terraform/sqs.tf(抜粋)

    resource "aws_sqs_queue" "jobs_dlq" {
      name                      = "${local.name_prefix}-jobs-dlq"
      message_retention_seconds = 1209600 # 14 日(最大)。原因を調べて再投入するまでの猶予
      sqs_managed_sse_enabled   = true
    }
    
    resource "aws_sqs_queue" "jobs" {
      name = "${local.name_prefix}-jobs"
    
      # 受信したメッセージを他のワーカーから見えなくする時間。ジョブの timeout(IssueInvoice は 60 秒)より長くする。
      # 短いと、処理中のジョブが別のワーカーにもう一度渡され、二重に実行される
      visibility_timeout_seconds = 90
      # ロングポーリング:メッセージが無いときに最大 10 秒待ってから空の応答を返す(空の受信の回数と費用を減らす)
      receive_wait_time_seconds = 10
      message_retention_seconds = 345600 # 4 日(既定)
      sqs_managed_sse_enabled   = true
    
      redrive_policy = jsonencode({
        deadLetterTargetArn = aws_sqs_queue.jobs_dlq.arn
        maxReceiveCount     = 5 # Laravel の tries(3)より大きくし、通常の再試行は Laravel に任せる
      })
    }

    SQS では、ワーカーがメッセージを受け取ると、一定時間ほかのワーカーからは見えなくなります。これが可視性タイムアウトです。この時間内にワーカーが「処理済み」として削除しなければ、メッセージは再び見えるようになり、別のワーカーが受け取ります。

    第5回の IssueInvoice は $timeout = 60(60秒で打ち切り)にしているので、可視性タイムアウトは 90 秒にしました。ローカルの database キューでは retry_after という設定で同じことを決めていましたが、SQS の場合はキュー側の設定が使われます。

    📰 出典:Job Expirations and Timeouts(Laravel 12.x ドキュメント)

    📰 出典:Amazon SQS 可視性タイムアウト(Amazon SQS デベロッパーガイド)

    この関係はコードの変更で崩れやすいため、テストでも確認するようにしました。

    tests/Feature/SqsQueueConfigTest.php(抜粋)

    public function test_issue_invoice_timeout_is_shorter_than_visibility_timeout(): void
    {
        // SQS の可視性タイムアウト(infra/terraform/sqs.tf の 90 秒)より短くないと、
        // 処理中のジョブが別のワーカーにもう一度渡されてしまう
        $timeout = (new ReflectionProperty(IssueInvoice::class, 'timeout'))->getDefaultValue();
    
        $this->assertLessThan(90, $timeout);
    }

    失敗したジョブの行き先は2つある

    失敗したジョブがどこに行くかは、次の2段構えです。

    状況行き先見るところ
    ジョブの中で例外が起き、tries(3回)を使い切ったLaravel が failed_jobs テーブルに記録し、SQS のメッセージは削除。IssueInvoice::failed() で請求を下書きに戻すphp artisan queue:failed
    ワーカーが強制終了されるなど、Laravel が失敗の記録までたどり着けないまま5回受信されたSQS が DLQ(デッドレターキュー)に移すDLQ のメッセージ数

    DLQ(=処理できなかったメッセージの退避場所)は、ワーカー自体に問題がある場合の最後の受け皿です。どちらも「放っておくと誰も気づかない」場所なので、第12回で DLQ にメッセージが入ったらアラームを出すようにします。

    📰 出典:Amazon SQS のデッドレターキューの使用(Amazon SQS デベロッパーガイド)

    Laravel を SQS に切り替える

    SQS のドライバーを使うには AWS SDK for PHP が必要です。サンプルでは、ほかの依存関係と同じく2025年5月時点の 3.x 系の版を composer require aws/aws-sdk-php で追加しました。

    config/queue.php(sqs の部分)

    /*
    | 本番(ECS)で使う SQS(第10回)
    | - key / secret は ECS では設定しない。未設定なら AWS SDK がタスクロールの一時的な認証情報を自動で使う
    | - SQS_QUEUE にキューの URL をそのまま渡す(URL なら prefix は使われないので、アカウント ID を書かずに済む)
    | - endpoint はローカルで SQS 互換のサーバー(compose.sqs.yaml の ElasticMQ)を使うときだけ指定する
    */
    'sqs' => [
        'driver' => 'sqs',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
        'queue' => env('SQS_QUEUE', 'default'),
        'suffix' => env('SQS_SUFFIX'),
        'region' => env('AWS_DEFAULT_REGION', 'ap-northeast-1'),
        'endpoint' => env('SQS_ENDPOINT'),
        'after_commit' => false,
    ],

    大事なのは、アクセスキーを設定しないことです。ECS のタスクでは、タスクロールの一時的な認証情報が自動で使えるので、長期間有効なアクセスキーを発行して環境変数に入れる必要はありません。

    タスク定義で渡す環境変数は、第9回の共通部分(ecs_cluster.tf の app_environment)を次のように変えました。

        # キューは SQS(第10回)。認証情報は渡さず、タスクロールの権限で接続する
        { name = "QUEUE_CONNECTION", value = "sqs" },
        { name = "SQS_QUEUE", value = aws_sqs_queue.jobs.url },
        { name = "AWS_DEFAULT_REGION", value = var.region },

    web(請求書発行ボタン)と scheduler(月次締め)はジョブを送る側、worker は受け取る側です。タスクロールには、このキューに対する操作だけを許可しました。

    infra/terraform/iam.tf(追加部分)

    data "aws_iam_policy_document" "ecs_task_sqs" {
      statement {
        sid = "UseJobQueue"
        actions = [
          "sqs:SendMessage",
          "sqs:ReceiveMessage",
          "sqs:DeleteMessage",
          "sqs:ChangeMessageVisibility", # 再試行までの待ち時間(backoff)の設定に使う
          "sqs:GetQueueAttributes",      # キューの件数の取得(queue:monitor など)に使う
        ]
        resources = [aws_sqs_queue.jobs.arn]
      }
    }

    サンプルでは web・worker・scheduler で1つのタスクロールを共有しています。さらに絞るなら、web と scheduler には送信だけ、worker には受信と削除だけのロールを分けて作ります。

    worker サービス:SIGTERM で安全に止める

    infra/terraform/ecs_worker.tf(タスク定義の抜粋)

      container_definitions = jsonencode([
        merge(local.php_container_base, {
          name = "worker"
          # --tries:ジョブ側で指定がない場合の最大試行回数。--max-time:1 時間で終了し、ECS が新しいタスクに入れ替える(メモリの肥大化対策)
          command     = ["php", "artisan", "queue:work", "sqs", "--sleep=3", "--tries=3", "--max-time=3600"]
          stopTimeout = 90 # ジョブの timeout(60 秒)+ ロングポーリングの待ち(10 秒)+ 余裕。Fargate の上限は 120 秒
          logConfiguration = {
            logDriver = "awslogs"
            options = {
              awslogs-group         = aws_cloudwatch_log_group.ecs["worker"].name
              awslogs-region        = var.region
              awslogs-stream-prefix = "worker"
            }
          }
        }),
      ])

    local.php_container_base は、イメージ・環境変数・秘密情報をまとめた共通部分です(ecs_cluster.tf)。worker・scheduler・migrate はこれに merge() で名前とコマンドとログの出し先を足しています。

    デプロイや台数の削減でワーカーを止めるとき、ECS はまずコンテナに SIGTERM(=終了のお願いの合図)を送り、stopTimeout の秒数を過ぎても終わらなければ強制終了します。Laravel の queue:work は SIGTERM を受けると、新しいジョブを取らずに処理中のジョブを終えてから終了します(PHP の pcntl 拡張が必要で、第6回のイメージに入れてあります)。

    stopTimeout をジョブの timeout より短くすると、処理の途中で強制終了されるおそれがあります。万一途中で止まっても、メッセージは可視性タイムアウトの後にキューに戻って再試行されます。第5回で、請求書発行のジョブを「発行中の請求だけを処理する」ように作ったのは、このような再実行があっても二重に発行しないためです。

    📰 出典:タスク定義パラメータ(stopTimeout)(Amazon ECS デベロッパーガイド)

    scheduler サービス:常に1台だけ動かす

    infra/terraform/ecs_scheduler.tf(サービスの抜粋)

    resource "aws_ecs_service" "scheduler" {
      name            = "scheduler"
      cluster         = aws_ecs_cluster.main.id
      task_definition = aws_ecs_task_definition.scheduler.arn
      desired_count   = 1 # 変数にしない(2 台以上にする意味がない)
      launch_type     = "FARGATE"
    
      # (network_configuration は worker と同じ)
    
      # デプロイ時は古いタスクを先に止める(0%)→ 新しいタスクを 1 台起動(最大 100%)
      # 入れ替えの 1〜2 分は定期実行が止まるが、schedule:work は毎分その時刻の予定を確認するだけなので、
      # 実行時刻ちょうどに入れ替えが重なった場合だけその回が飛ぶ(締め処理の時刻にはデプロイしない運用にする)
      deployment_minimum_healthy_percent = 0
      deployment_maximum_percent         = 100
    }

    スケジューラが2台動くと、締め処理が2回走ってしまいます。web や worker と違って、デプロイ時は「古いタスクを先に止める」設定にしました。さらに、第5回で各スケジュールに付けた onOneServer() が、DB のキャッシュのロックを使って1台だけに実行させるので、二重の備えになります。

    代わりに、入れ替えの間の1〜2分はスケジューラが止まります。毎日1時・毎月1日6時のような処理の時刻にはデプロイしない、という運用ルールで補います。

    マイグレーションは専用のタスクで1回だけ

    infra/terraform/ecs_migrate.tf(抜粋)

    # サービスにはせず、デプロイの前に 1 回だけ RunTask で起動する(第11回で GitHub Actions から実行)。
    # web の起動時に migrate を流さないのは、2 台以上が同時に起動したときに同じマイグレーションを
    # 並行して実行してしまうのを避けるため。また、失敗したときにデプロイそのものを止められる。
    
    resource "aws_ecs_task_definition" "migrate" {
      family = "${local.name_prefix}-migrate"
      cpu    = 256
      memory = 512
    
      container_definitions = jsonencode([
        merge(local.php_container_base, {
          name = "migrate"
          # --force:APP_ENV=production での確認プロンプトを出さずに実行する
          command = ["php", "artisan", "migrate", "--force"]
          # (logConfiguration は省略。ロググループ /ecs/<name_prefix>/migrate)
        }),
      ])
    
      # (requires_compatibilities・network_mode・ロール・runtime_platform は worker と同じため省略)
    }

    実行は AWS CLI で行います。ネットワークの指定はそのまま渡せる形で outputs.tf に出力しています。

    aws ecs run-task --cluster admin-stg --task-definition admin-stg-migrate --launch-type FARGATE \
      --network-configuration "$(terraform output -raw migrate_network_configuration)"
    # 終了を待って、終了コードが 0 かを確認する
    aws ecs wait tasks-stopped --cluster admin-stg --tasks <上のコマンドが返した taskArn>
    aws ecs describe-tasks --cluster admin-stg --tasks <taskArn> --query 'tasks[0].containers[0].exitCode'

    これで第9回ではできなかったログイン画面の表示ができるようになります。最初の管理者ユーザーは、本番イメージにシーダー用のパッケージが入っていないため、第12回で説明する ECS Exec などで作成します。

    動作確認の方法

    # テスト
    docker compose run --rm --no-deps app php artisan test
    
    # 本番用イメージ+SQS 互換サーバー(ElasticMQ)で確認
    docker build --target app -t admin-app . && docker build --target web -t admin-web .
    docker compose -f compose.prod.yaml -f compose.sqs.yaml up -d mysql elasticmq
    docker compose -f compose.prod.yaml -f compose.sqs.yaml run --rm app php artisan migrate --force
    docker compose -f compose.prod.yaml -f compose.sqs.yaml up -d
    docker compose -f compose.prod.yaml -f compose.sqs.yaml logs -f queue

    compose.sqs.yaml は、第6回の compose.prod.yaml に重ねて、キューの接続先を SQS 互換のサーバー(ElasticMQ)に切り替える設定です。AWS の認証情報はダミーの値にしています。筆者の環境で確認したことは次のとおりです。

    • php artisan test は 68件すべて成功(SQS の接続設定と、timeout と可視性タイムアウトの関係のテストを追加)
    • 請求の発行をキューに積むと、キューの件数が 1 → ワーカーのログに 請求書を発行しました → 請求が発行済みになり、キューの件数が 0 に戻る
    • docker compose stop queue でワーカーが終了コード 0 で止まる(SIGTERM での正常終了)
    • 削除されないメッセージを5回受信すると、DLQ に移る(ElasticMQ でも可視性タイムアウト 90 秒・5回で DLQ の設定にしてある)
    • Terraform は fmt -check・validate が成功し、AWS に接続しない plan で Plan: 67 to add(第9回の58件に、キュー2つ・DLQ の許可設定・SQS の権限・worker と scheduler のタスク定義とサービス・migrate のタスク定義の9件が追加)

    AWS 上での確認(SQS のメッセージ数の増減、タスクロールでの接続、aws ecs run-task での migrate のログ、ECS がタスクを止めるときに SIGTERM から stopTimeout までに終了すること)は、AWS アカウントがないため行っていません。ElasticMQ は SQS の動きを再現するためのもので、本物の SQS とすべて同じとは限りません。

    つまずきやすい点・セキュリティ上の注意

    • ワーカーは古いコードのまま動き続ける:queue:work はコードを読み込んだまま動くので、新しいイメージにしたら必ずサービスを更新(再デプロイ)します。第11回の CI では、web と一緒に worker・scheduler も更新します。
    • DB のパスワードの自動ローテーション:第8回で触れたとおり、起動したままのタスクは古いパスワードを持ち続けます。ワーカーは --max-time=3600 で1時間ごとに入れ替わるため新しい値を読み込みますが、web と scheduler は再デプロイが必要です。
    • ジョブの中身に秘密情報を入れない:SQS のメッセージには、ジョブのクラス名と引数(モデルの ID など)が入ります。サーバー側の暗号化(sqs_managed_sse_enabled)はしていますが、メッセージ自体にパスワードなどを載せないようにします。
    • 締め処理の時刻とデプロイ:scheduler の入れ替え中に予定時刻が来ると、その回は実行されません。処理の時刻を運用チームと共有しておきます。

    発注者向けメモ:裏で動く処理は「止まったら誰が気づくか」を決める

    ワーカーとスケジューラは画面が無いため、止まっても利用者からの問い合わせでは気づけません。開発を依頼するときは、次の点を仕様として決めておくと安心です。

    決めること決めないと起きること
    ジョブが失敗したときの扱い(自動で再試行する回数、最終的に失敗したら誰に知らせるか)請求書が発行されないまま、月末に発覚する
    定期処理が動かなかったときの検知と、手動で再実行する手順締め処理が漏れ、翌月に手作業で修正する
    ワーカーの台数(画面とは別)月初に処理が溜まり、発行まで時間がかかる。逆に常に多いと費用が増える
    デプロイしてはいけない時間帯締め処理の時刻にデプロイして、その回が飛ぶ

    費用の面では、worker と scheduler も Fargate のタスクとして常に動くので、台数 × CPU・メモリ × 時間の費用が発生します。SQS は使った分(リクエスト数)の料金で、この規模の管理画面では大きな割合にはなりにくい部品です。

    開発会社への質問例です。

    • 「ジョブが失敗したら、誰にどうやって通知されますか?手動でやり直す手順はありますか?」
    • 「定期処理が実行されたかどうかは、どこで確認できますか?」
    • 「デプロイの途中で処理中のジョブはどうなりますか?二重に実行されることはありませんか?」
    • 「データベースの変更(マイグレーション)は、いつ・どうやって本番に反映しますか?失敗したらどうなりますか?」

    まとめと次回予告

    第10回では、画面の裏で動く処理を ECS に載せました。

    • キューを SQS に切り替え、可視性タイムアウト(90秒)をジョブの timeout(60秒)より長くする
    • 失敗したジョブは Laravel の failed_jobs、ワーカーごと落ちたものは DLQ に行く
    • worker は SIGTERM で処理中のジョブを終えてから止まり、stopTimeout はジョブの timeout より長くする
    • scheduler は常に1台、デプロイ時も同時に2台にならない設定にし、onOneServer() で二重に守る
    • マイグレーションは専用のタスク定義を RunTask で1回だけ実行する

    次回は「GitHub Actions で CI/CD:OIDC でキーを持たずにデプロイ」です。テスト → イメージの build と push → マイグレーション → web・worker・scheduler のデプロイまでを、AWS のアクセスキーを使わずに自動化します。

    この連載の記事一覧

    この記事は連載「Laravel+Vue.jsで作る管理画面をECSで動かす」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (2件)

      目次