連載の最終回です。ここまでで、管理画面を ECS で動かし、GitHub Actions から自動でデプロイできるようになりました。最後に、動かし続けるための仕組みとして、アラーム(監視)、オートスケーリング(台数の自動調整)、ECS Exec(稼働中のコンテナでの調査)、失敗したデプロイの自動の切り戻しを加えます。あわせて、12回の内容と、本番運用の前に残っている作業、AWS の費用の構造をまとめます。
「システムは完成したけれど、公開した後に何かあったとき、誰がどうやって気づいて、どう戻すのかが決まっていない…」
結論から言うと、運用の仕組みは「利用者より先に気づくアラーム」「負荷に合わせて台数を変えるオートスケーリング」「壊れたデプロイを自動で戻すサーキットブレーカー」「調べ方と戻し方を書いた手順書」の4点セットで考えます。コードで作れるのは前の3つで、4つ目の手順書と「誰がアラームを受けるか」は、発注者と開発会社で決める運用のルールです。
前回(第11回:GitHub Actions で CI/CD)では、OIDC を使ったテストとデプロイの自動化を作りました。
これまでと同じく、執筆環境には AWS アカウントがないため terraform apply は行っておらず、アラームの通知や ECS Exec、切り戻しを AWS 上で試してはいません。terraform validate と AWS に接続しない plan、ローカルの Docker でのアプリ側の動作確認までを行っています。
運用のために追加するもの
| ファイル | 内容 |
|---|---|
infra/terraform/monitoring.tf | SNS(メール通知)、アラーム10個、ログからのメトリクス、よく使うログ検索の保存 |
infra/terraform/autoscaling.tf | web サービスのオートスケーリング(CPU の目標追跡) |
infra/terraform/ecs_{web,worker,scheduler}.tf | デプロイサーキットブレーカー、ECS Exec の有効化 |
infra/terraform/iam.tf | ECS Exec のためのタスクロールの権限 |
routes/console.php | スケジューラの heartbeat(5分ごとのログ) |
app/Console/Commands/CreateUser.php | 最初の管理者ユーザーを作るコマンド |
.github/workflows/deploy.yml | 切り戻しが起きたらワークフローを失敗にする確認ステップ |
docs/runbook.md | アラームを受けたときの確認・調査・切り戻しの手順書 |
アラーム:利用者より先に気づく
何を見張るか
アラームは、SNS(=AWS の通知サービス)のトピックに送り、メールで受け取ります。見張る対象は、画面・裏の処理・アプリ・DB の4つに分けました。
| 対象 | アラーム | 気づけること |
|---|---|---|
| 画面 | 正常な web タスクが 0 台 | 画面が完全に止まった |
| 画面 | ALB の 5xx(ALB 自身・アプリ) | エラー画面が出ている |
| 画面 | web の CPU が高い状態が続く | 台数の上限に達して遅くなっている |
| 裏の処理 | いちばん古いジョブが10分以上処理されない | worker が止まっている |
| 裏の処理 | DLQ にメッセージがある | 処理できなかったジョブがある(第10回) |
| 裏の処理 | スケジューラの heartbeat が15分出ない | 締め処理などの定期実行が止まっている |
| アプリ | Laravel の ERROR 以上のログが増えた | 例外や外部サービスの失敗 |
| DB | RDS の CPU、空きストレージ | 重いクエリ、容量不足 |
infra/terraform/monitoring.tf(worker の停止を検知するアラームの抜粋)
# いちばん古いジョブが 10 分以上処理されていない(worker が止まっている・追いついていない)
resource "aws_cloudwatch_metric_alarm" "jobs_backlog" {
alarm_name = "${local.name_prefix}-jobs-backlog"
alarm_description = "ジョブのキューに 10 分以上処理されていないメッセージがある。worker サービスの状態とログ(/ecs/${local.name_prefix}/worker)を確認する"
namespace = "AWS/SQS"
metric_name = "ApproximateAgeOfOldestMessage"
dimensions = { QueueName = aws_sqs_queue.jobs.name }
statistic = "Maximum"
period = 300
evaluation_periods = 2
threshold = 600
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
alarm_actions = local.alarm_actions
ok_actions = local.alarm_actions
}
キューのメッセージの「件数」ではなく「いちばん古いメッセージの経過時間」を見ているのがポイントです。件数は月初の締め処理で一時的に増えるのが普通ですが、10分以上手つかずのジョブがあるなら、worker が止まっているか、明らかに追いついていません。alarm_description には「最初に見るところ」を書いておき、通知を受けた人がすぐ動けるようにしました。
treat_missing_data(データが無いときの扱い)も重要です。5xx の件数はアクセスが無ければデータ自体が無いので「正常」、正常なタスクの数はデータが来ないこと自体が異常なので「異常」として扱います。
📰 出典:Amazon CloudWatch でのアラームの使用(Amazon CloudWatch ユーザーガイド)
スケジューラの停止は heartbeat で気づく
第10回で「裏で動く処理は止まっても誰も気づかない」という課題を挙げました。worker はキューの滞留で気づけますが、スケジューラは止まっても何も溜まりません。そこで、5分ごとにログを1行出す heartbeat(=生存確認の合図)を加え、それが途絶えたらアラームにしました。
routes/console.php(追加部分)
// 5分ごと:スケジューラが動いていることを示すログ(第12回)
// CloudWatch Logs のメトリクスフィルターで数え、一定時間出なければアラームを出す(infra/terraform/monitoring.tf)
Schedule::call(function (): void {
Log::info('scheduler heartbeat');
})->everyFiveMinutes()->name('scheduler-heartbeat');
infra/terraform/monitoring.tf(heartbeat を数えるメトリクスフィルターとアラームの抜粋)
resource "aws_cloudwatch_log_metric_filter" "scheduler_heartbeat" {
name = "${local.name_prefix}-scheduler-heartbeat"
log_group_name = aws_cloudwatch_log_group.ecs["scheduler"].name
pattern = "\"scheduler heartbeat\""
metric_transformation {
name = "SchedulerHeartbeat"
namespace = "${var.project}/${var.environment}"
value = "1"
}
}
# 15 分間 heartbeat が無い(スケジューラが止まっている)
resource "aws_cloudwatch_metric_alarm" "scheduler_stopped" {
alarm_name = "${local.name_prefix}-scheduler-stopped"
namespace = "${var.project}/${var.environment}"
metric_name = aws_cloudwatch_log_metric_filter.scheduler_heartbeat.metric_transformation[0].name
statistic = "Sum"
period = 900
evaluation_periods = 1
threshold = 1
comparison_operator = "LessThanThreshold"
treat_missing_data = "breaching" # ログが 1 件も無い=止まっている
# (alarm_description と通知先は省略)
}
Laravel の ERROR 以上のログも、同じメトリクスフィルターの仕組みで数えています(LOG_CHANNEL=stderr のログは production.ERROR: のような形で出るため、その文字列で数えます)。
ログは Logs Insights で探す
ログは第9回から CloudWatch Logs に集めています(保持期間は既定30日)。障害時に毎回検索の書き方を考えなくて済むよう、よく使う検索を Logs Insights(=ログを SQL に似た書き方で検索・集計する機能)の保存済みクエリとして Terraform で登録しました。
infra/terraform/monitoring.tf(保存済みクエリの抜粋)
resource "aws_cloudwatch_query_definition" "errors" {
name = "${local.name_prefix}/errors"
log_group_names = [for k in ["web", "worker", "scheduler"] : aws_cloudwatch_log_group.ecs[k].name]
query_string = <<-EOT
fields @timestamp, @logStream, @message
| filter @message like /production\.(ERROR|CRITICAL|ALERT|EMERGENCY)/
| sort @timestamp desc
| limit 100
EOT
}
このほか、nginx のアクセスログから 5xx と1秒以上かかったリクエストを探すクエリも登録しています。
📰 出典:CloudWatch Logs Insights を使用したログデータの分析(Amazon CloudWatch Logs ユーザーガイド)
オートスケーリング:CPU に合わせて web の台数を変える
infra/terraform/autoscaling.tf(抜粋)
resource "aws_appautoscaling_target" "web" {
service_namespace = "ecs"
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.web.name}"
scalable_dimension = "ecs:service:DesiredCount"
min_capacity = var.web_min_count
max_capacity = var.web_max_count
}
resource "aws_appautoscaling_policy" "web_cpu" {
name = "${local.name_prefix}-web-cpu"
policy_type = "TargetTrackingScaling"
service_namespace = aws_appautoscaling_target.web.service_namespace
resource_id = aws_appautoscaling_target.web.resource_id
scalable_dimension = aws_appautoscaling_target.web.scalable_dimension
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = var.web_cpu_target
scale_out_cooldown = 60
scale_in_cooldown = 300
}
}
目標追跡(=指標が目標値になるように台数を自動で増減する方式)で、web の平均 CPU が 60% 前後になるように、2〜4台の間で台数を変えます。増やすときは早く、減らすときはゆっくりにして、増減を繰り返さないようにしています。台数はオートスケーリングが変えるので、web サービスの lifecycle の ignore_changes に desired_count を追加しました。
web_max_count は「費用の上限」でもあります。上限に達しても CPU が高いままなら、前述の CPU のアラームで気づけます。worker も、キューの滞留に合わせて台数を変える設定にできますが、この規模では1台で足りるため、連載では台数を固定にしています。
📰 出典:Amazon ECS サービスを自動的にスケールする(Amazon ECS デベロッパーガイド)
失敗したデプロイを自動で戻す(サーキットブレーカー)
infra/terraform/ecs_web.tf(サービスに追加。worker・scheduler も同じ)
# 第12回:新しいタスクが起動しない・ヘルスチェックに通らないデプロイを自動で止め、直前の正常なリビジョンに戻す
deployment_circuit_breaker {
enable = true
rollback = true
}
# 第12回:ECS Exec(稼働中のコンテナで php artisan などを実行する)を使えるようにする
enable_execute_command = var.enable_execute_command
デプロイサーキットブレーカーは、新しいタスクが起動に失敗し続けたり、ALB のヘルスチェックに通らなかったりしたときに、デプロイを失敗として止め、直前の正常なリビジョンに戻す機能です。壊れたイメージを出してしまっても、画面が止まり続けることを防げます。
📰 出典:Amazon ECS デプロイサーキットブレーカーが障害を検出する方法(Amazon ECS デベロッパーガイド)
注意したいのは、自動で戻ったあとのサービスは「古いリビジョンで安定している」ことです。デプロイのワークフローから見ると安定を待つステップが成功してしまう可能性があるため、最後に「実際に動いているイメージが今回のタグか」を確かめるステップを加えました。
.github/workflows/deploy.yml(追加したステップ)
# 第12回:サーキットブレーカーが前のリビジョンに戻した場合も「サービスは安定」に見えるため、
# 各サービスで実際に動いているイメージが今回のタグかを確認し、違えば失敗にする
- name: Verify deployed images
env:
CLUSTER: ${{ vars.ECS_CLUSTER }}
run: |
for service in web worker scheduler; do
td=$(aws ecs describe-services --cluster "$CLUSTER" --services "$service" \
--query "services[0].deployments[?status=='PRIMARY'].taskDefinition | [0]" --output text)
images=$(aws ecs describe-task-definition --task-definition "$td" \
--query 'taskDefinition.containerDefinitions[].image' --output text)
echo "$service: $td $images"
for image in $images; do
if [ "${image##*:}" != "$IMAGE_TAG" ]; then
echo "::error::$service is not running $IMAGE_TAG (rolled back?)"
exit 1
fi
done
done
サーキットブレーカーが戻せるのは「起動しない」「ヘルスチェックに通らない」壊れ方だけです。画面は表示されるが計算が間違っている、といった不具合は、人が判断して前のリビジョンに戻します。その手順(aws ecs update-service で前のリビジョンを指定する、または revert してデプロイし直す)は docs/runbook.md に書きました。マイグレーションは自動では戻らないため、第11回で書いたとおり「古いコードでも動く」形でマイグレーションを書くことが、切り戻しやすさにつながります。
ECS Exec:稼働中のコンテナで調べる・最初の管理者を作る
ECS Exec は、稼働中のコンテナで php artisan などのコマンドを実行できる機能です。サーバーに SSH で入る代わりの手段で、タスクロールに SSM のチャネルの権限(ssmmessages:*)を与え、サービスで有効にします。使う人には別途 ecs:ExecuteCommand の IAM 権限が必要です。
第10回で「最初の管理者ユーザーは ECS Exec などで作る」と書きました。本番イメージにはシーダー用のパッケージが入っていないため、ユーザーを作るコマンドを用意しました。
app/Console/Commands/CreateUser.php(抜粋)
protected $signature = 'users:create
{email : メールアドレス(ログイン ID)}
{name : 表示名}
{--role=viewer : admin / staff / viewer}';
public function handle(): int
{
$password = (string) $this->secret('パスワード(8文字以上)');
$confirmation = (string) $this->secret('パスワード(確認)');
// (Validator でメールアドレスの重複・ロール・パスワードの長さと一致を確認。失敗したらエラーを表示して終了)
$user = new User([
'name' => $this->argument('name'),
'email' => $this->argument('email'),
'password' => $password,
]);
// role は $fillable に入れていない(第3回)ので、個別に代入する
$user->role = Role::from((string) $this->option('role'));
// 管理者が作るユーザーなので、メールアドレスの確認は済みとして扱う
$user->email_verified_at = now();
$user->save();
$this->info("ユーザーを作成しました:{$user->email}({$user->role->label()})");
return self::SUCCESS;
}
パスワードはコマンドの引数にせず、対話で入力します。引数にすると、端末の履歴や ECS Exec のセッションの記録に残るおそれがあるためです。
aws ecs execute-command --cluster admin-stg --task <タスク ID> --container php --interactive \
--command "php artisan users:create admin@example.com 管理者 --role=admin"
📰 出典:ECS Exec を使用して Amazon ECS コンテナをモニタリングする(Amazon ECS デベロッパーガイド)
ECS Exec は本番のデータを直接変更できる強い手段です。使える人を IAM で限定し、誰がいつ何を実行したかを記録する運用にします。
動作確認の方法
筆者の環境で確認したことは次のとおりです。
php artisan testは 71件すべて成功(ユーザー作成コマンドの正常系・異常系、heartbeat のスケジュールの3件を追加)- 本番用イメージを
compose.prod.yamlで起動し、php artisan users:createで管理者を作成 → そのユーザーでログインして請求の一覧が表示される。php artisan aboutで本番設定(Debug Mode OFF など)を確認 - scheduler のコンテナのログに、5分ごとに
production.INFO: scheduler heartbeatが出る。Log::error()のログがproduction.ERROR:の形で出る(メトリクスフィルターの条件と一致) - nginx のアクセスログの実際の行で、保存済みクエリの解析用の正規表現が各項目を取り出せることを確認
- Terraform は
fmt -check・validateが成功。AWS に接続しないplanでPlan: 91 to add(第11回の70件に、SNS とメール購読・アラーム10個・メトリクスフィルター4つ・保存済みクエリ2つ・オートスケーリング2つ・ECS Exec の権限の21件)。サービスにサーキットブレーカー(enable = true・rollback = true)とenable_execute_command = trueが入る - デプロイのワークフローは actionlint でエラーなし。確認ステップの JMESPath(AWS CLI の
--queryの書き方)をサンプルのデータで確認
AWS 上での確認(5xx を発生させてアラームのメールが届くこと、aws ecs execute-command で php artisan about が実行できること、壊れたイメージのデプロイがサーキットブレーカーで前のリビジョンに戻ること、オートスケーリングで台数が変わること、Logs Insights のクエリの結果)はできていません。導入時は、stg 環境で一つずつ試してから本番に広げてください。
連載のまとめ:12回で作ったもの
前半(第1〜5回)でアプリを、後半(第6〜12回)でそれを本番で動かす仕組みを作りました。どの回も「動くコード」と「確認の方法」をセットにしてきましたが、AWS 上での動作は一度も確認していない点は、最後にあらためてお伝えしておきます。
本番運用の前に残っている作業
連載では説明を絞るために省いたものがあります。本番で使う前に、少なくとも次の点を検討してください。
| 残作業 | 内容 | 関係する回 |
|---|---|---|
| 会員登録を閉じる | スターターキットの会員登録画面が残ったまま。ルートを外し、ログイン画面のリンクも消す。ユーザーは今回の users:create か、管理者用のユーザー管理画面で作る | 第3回・第9回 |
| メール送信 | 本番の MAIL_MAILER が未設定(ログに出るだけ)なので、パスワード再設定のメールが届かない。Amazon SES などの送信の設定と、送信元ドメインの認証が必要 | 第1回 |
| バックアップの復元訓練 | RDS の自動バックアップはあるが、復元を試していない。復元の手順・所要時間を記録し、必要なら別リージョンへのコピーも検討 | 第8回 |
| DB のユーザーとパスワードの変更 | アプリがマスターユーザーで接続している。アプリ用のユーザーを作り、自動ローテーション後のタスクの入れ替えも運用に組み込む | 第8回・第10回 |
| 信頼するプロキシの範囲の見直し | ALB の前に CloudFront などを置く場合は、TRUSTED_PROXIES と ALB のセキュリティグループを合わせて見直す | 第9回 |
| 公開範囲と WAF | IP 制限を外して公開する場合は、AWS WAF などでログイン画面への総当たりを防ぐ。二要素認証の検討も | 第9回 |
| 依存関係とベースイメージの更新 | サンプルは2025年5月時点の版に固定している。composer audit などで確認し、定期的に更新する | 第1回・第6回 |
| 本番環境の分離 | stg と prod を別の AWS アカウントに分け、prod のデプロイに承認者を設定する | 第7回・第11回 |
| 費用の確認 | 毎月、タグ(Project / Environment)ごとに費用を確認し、台数・ログの保持期間・NAT Gateway の構成を見直す | 第7回・第12回 |
処理の速さが課題になった場合は、Laravel Octane(アプリを常駐させて起動の手間を省く仕組み)への移行が次の一手になります。ただし、リクエストをまたいで状態が残ることへの注意が増えるため、連載では nginx と php-fpm の構成にとどめました。
発注者向けメモ:AWS の月額は「常に動く台数」「ログ」「通信」で決まる
連載の構成で費用がかかる部品を、性質で分けると次のようになります(具体的な金額は、リージョン・時期・使い方で変わるため、AWS の料金ページと見積もりツールで確認してください)。
| 性質 | 部品 | 費用を左右するもの |
|---|---|---|
| 動いている時間だけかかる(常時) | Fargate のタスク(web・worker・scheduler) | 台数 × CPU・メモリ × 時間。web の最小台数とオートスケーリングの上限 |
| 動いている時間だけかかる(常時) | RDS | インスタンスのサイズ、Multi-AZ の有無、ストレージ、バックアップの保持日数 |
| 動いている時間だけかかる(常時) | ALB、NAT Gateway、インターフェイス型 VPC エンドポイント(使う場合) | 台数(NAT を AZ ごとに置くか)と、使った分の処理量 |
| 使った分だけかかる | CloudWatch Logs | ログの量と保持期間。アクセスログやデバッグログを出しすぎない |
| 使った分だけかかる | データ転送、SQS、ECR の保管、Secrets Manager、アラーム | 多くはこの規模では小さいが、ゼロではない |
費用を下げるときの考え方は、「夜間や検証環境は台数を減らす」「ログの保持期間を業務で必要な日数にする」「検証環境は NAT Gateway 1台・Multi-AZ なし」のように、止まってよい時間とのトレードオフで決めることです。
運用のルールとして、次の点を開発会社と決めておくと安心です。
- 「アラームは誰が受け取り、夜間・休日は何分以内に対応しますか?その体制は保守契約に含まれていますか?」
- 「障害時に前の版に戻す手順はありますか?最後に試したのはいつですか?」
- 「本番のコンテナでコマンドを実行できる人は誰ですか?実行の記録は残りますか?」
- 「毎月の AWS の費用は誰が確認し、増えたときに何を見直しますか?」
- 「依存ライブラリやベースイメージの更新は、どのくらいの頻度で、誰の費用で行いますか?」
まとめ
第12回では、動かし続けるための仕組みを加えて、連載を締めくくりました。
- アラームは画面・裏の処理・アプリ・DB の4つを見張り、説明文に「最初に見るところ」を書く
- スケジューラの停止は heartbeat のログが途絶えたことで気づく
- web は CPU の目標追跡で台数を自動で変え、上限は費用の上限として決める
- サーキットブレーカーで壊れたデプロイを自動で戻し、戻ったことをワークフローでも検知する
- ECS Exec で調査と最初の管理者の作成を行い、手順と権限を手順書で管理する
12回にわたってお読みいただき、ありがとうございました。管理画面を「作る」ことと「運用し続ける」ことは、どちらも発注の段階で決めておくことがたくさんあります。この連載のコードと発注者向けメモが、開発会社との打ち合わせのたたき台になれば幸いです。
この連載の記事一覧
この記事は連載「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回】運用編:ログ・監視・スケーリング・切り戻し(この記事)









