第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 sqs | 1台〜(ジョブの量で決める) |
| 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.lock | SQS を使うための AWS SDK for PHP を追加 |
config/queue.php | SQS の設定(キューの URL、ローカル確認用の接続先) |
compose.sqs.yaml / docker/local/elasticmq.conf | SQS 互換サーバーでのローカル確認用 |
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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第1回】全体像と開発環境:Laravel 12+Vue 3 スターターキットを Docker で動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第2回】顧客マスタの一覧と登録:FormRequest でバリデーションする
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第3回】ロールと Policy:誰が何をできるかをコードで決める
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第4回】案件管理と一覧画面の作り込み:検索・並べ替え・ページング
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第5回】請求と非同期処理:キューとスケジューラをローカルで動かす
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第6回】本番用コンテナイメージを作る:マルチステージ Dockerfile
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第7回】Terraform の土台とネットワーク:state 管理と VPC
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第8回】RDS for MySQL と秘密情報:パスワードをコードに書かない
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第9回】ECR・ECS on Fargate・ALB:管理画面をインターネットに公開する
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第10回】キューワーカーとスケジューラを ECS で動かす(この記事)
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第11回】GitHub Actions で CI/CD:OIDC でキーを持たずにデプロイ
- 【Laravel+Vue.jsで作る管理画面をECSで動かす 第12回】運用編:ログ・監視・スケーリング・切り戻し










コメント
コメント一覧 (2件)
[…] 前回(第10回:キューワーカーとスケジューラを ECS で動かす)で、web・worker・scheduler のサービスと、マイグレーション用のタスク定義がそろいました。今回はそれを自動で更新する仕組みを作ります。 […]
[…] キューワーカーとスケジューラを ECS で動かす […]