シリーズ目次
| 回 | タイトル | 状態 |
|---|---|---|
| 第1回 | Terraform 入門 — 基礎と最初の一歩 | 公開済み |
| 第2回 | AWS 構築 — VPC/ECS/RDS/ALB をコードで作る | 公開済み |
| 第3回 | Cloudflare 連携と CI/CD パイプライン構築 | 公開済み |
| 第4回 | 運用・保守・スケールアップ | 本記事 |
1. はじめに — 「Day-2 運用」とは何か
「Day-1」はシステムを設計・構築する日です。「Day-2」はそれ以降のすべての日、つまり実際にシステムが動き続ける期間全体を指します。
第1〜3回でインフラを Terraform で作り、CI/CD で自動デプロイできるようになりました。しかし正直に言うと、この時点ではまだ「動かせる」状態に過ぎません。本番サービスとして「運用できる」状態になるためには、次の問いに答えられる必要があります。
- 異常をすぐ検知できるか?(監視とアラート)
- 何が起きているかを読めるか?(ログ)
- データが消えても戻せるか?(バックアップとリストア訓練)
- 壊れたとき何をするか決まっているか?(障害対応の型)
- お金が想定外に膨らんでいないか?(コスト管理)
- ユーザーが増えたとき伸ばせるか?(スケール戦略)
この記事では Spring Boot (ECS Fargate) + Nuxt 3 (Cloudflare Pages) + RDS MySQL + ElastiCache Valkey の構成を前提に、上記の問いに一つずつ答えていきます。
2. 監視の最小セット — CloudWatch アラーム 5 本
「アラームが鳴らない監視は無いのと同じ」
監視ダッシュボードを眺めるだけでは運用になりません。しきい値を超えたときに通知が飛んでくる仕組みを最初に作ります。最小限として次の 5 本を Terraform で定義します。
SNS トピックの作成(通知の出口)
まず全アラームからの通知をまとめて受け取る SNS トピックを作ります。
# modules/monitoring/sns.tf
resource "aws_sns_topic" "alerts" {
name = "${var.app_name}-${var.env}-alerts"
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.alerts.arn
protocol = "email"
endpoint = var.alert_email # tfvarsで設定
}
メールアドレスを登録するとサブスクリプション確認メールが届くので、必ずリンクをクリックして有効化してください。確認しないと通知が届きません。
アラーム 5 本の定義
# modules/monitoring/alarms.tf
locals {
alarm_actions = [aws_sns_topic.alerts.arn]
}
# ① ECS CPU 使用率
resource "aws_cloudwatch_metric_alarm" "ecs_cpu" {
alarm_name = "${var.app_name}-${var.env}-ecs-cpu-high"
alarm_description = "ECS タスクの CPU 使用率が 80% を超えました"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/ECS"
period = 60
statistic = "Average"
threshold = 80
treat_missing_data = "notBreaching"
dimensions = {
ClusterName = var.ecs_cluster_name
ServiceName = var.ecs_service_name
}
alarm_actions = local.alarm_actions
ok_actions = local.alarm_actions
}
# ② ECS メモリ使用率
resource "aws_cloudwatch_metric_alarm" "ecs_memory" {
alarm_name = "${var.app_name}-${var.env}-ecs-memory-high"
alarm_description = "ECS タスクのメモリ使用率が 85% を超えました"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "MemoryUtilization"
namespace = "AWS/ECS"
period = 60
statistic = "Average"
threshold = 85
treat_missing_data = "notBreaching"
dimensions = {
ClusterName = var.ecs_cluster_name
ServiceName = var.ecs_service_name
}
alarm_actions = local.alarm_actions
ok_actions = local.alarm_actions
}
# ③ ALB 5xx エラー率(全リクエストに占める割合)
resource "aws_cloudwatch_metric_alarm" "alb_5xx_rate" {
alarm_name = "${var.app_name}-${var.env}-alb-5xx-high"
alarm_description = "ALB の 5xx エラー率が 1% を超えました"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
threshold = 1
treat_missing_data = "notBreaching"
# 5xx 数 ÷ 総リクエスト数 の割合を計算するメトリクス式
metric_query {
id = "error_rate"
expression = "100 * errors / MAX([errors, requests])"
label = "5xx Error Rate (%)"
return_data = true
}
metric_query {
id = "errors"
metric {
metric_name = "HTTPCode_Target_5XX_Count"
namespace = "AWS/ApplicationELB"
period = 60
stat = "Sum"
dimensions = {
LoadBalancer = var.alb_arn_suffix
}
}
}
metric_query {
id = "requests"
metric {
metric_name = "RequestCount"
namespace = "AWS/ApplicationELB"
period = 60
stat = "Sum"
dimensions = {
LoadBalancer = var.alb_arn_suffix
}
}
}
alarm_actions = local.alarm_actions
ok_actions = local.alarm_actions
}
# ④ RDS 接続数
resource "aws_cloudwatch_metric_alarm" "rds_connections" {
alarm_name = "${var.app_name}-${var.env}-rds-connections-high"
alarm_description = "RDS の接続数が上限の 80% に近づいています"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "DatabaseConnections"
namespace = "AWS/RDS"
period = 60
statistic = "Average"
# db.t3.small の max_connections は約 150。しきい値は環境に合わせて調整
threshold = 120
treat_missing_data = "notBreaching"
dimensions = {
DBInstanceIdentifier = var.rds_instance_id
}
alarm_actions = local.alarm_actions
ok_actions = local.alarm_actions
}
# ⑤ RDS 空きストレージ(絶対値で監視)
resource "aws_cloudwatch_metric_alarm" "rds_storage" {
alarm_name = "${var.app_name}-${var.env}-rds-storage-low"
alarm_description = "RDS の空きストレージが 2GB を下回りました"
comparison_operator = "LessThanThreshold"
evaluation_periods = 1
metric_name = "FreeStorageSpace"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 2147483648 # 2 GB(バイト単位)
treat_missing_data = "notBreaching"
dimensions = {
DBInstanceIdentifier = var.rds_instance_id
}
alarm_actions = local.alarm_actions
ok_actions = local.alarm_actions
}
テスト通知を一度必ず踏む
アラームを作ったら設定が正しく届くかを確認するまで完成ではありません。SNS コンソールから「メッセージの発行」でテストメッセージを送り、設定したアドレスに届くことを確認してください。
さらに踏み込むなら、ECS タスクに意図的に CPU 負荷をかけてアラームを実際に ALARM 状態にし、OK に戻ったときの通知も届くことを確認します。「鳴ったことのないアラームは、いざというときも鳴らない」と覚えておいてください。
3. ログの読み方
Spring Boot のログを CloudWatch Logs で受け取る
ECS Fargate では awslogs ドライバーを使うと、アプリケーションの標準出力が CloudWatch Logs に転送されます(第2回で設定済みのはずです)。Spring Boot 側で JSON 形式で構造化ログを出力するようにしておくと、後述の Logs Insights でのフィルタリングが格段に楽になります。
ログはどこに溜まるのか: アプリのログ(スタックトレース・業務ログ)は、アプリが動いている場所= AWS の CloudWatch Logs に保管されます。Cloudflare は入口のプロキシにすぎず、アプリの標準出力には関与しません(Cloudflare 側に残るのは「どの IP がどの URL に何ステータスで」というエッジのアクセス記録だけ。後述)。 そして CloudWatch Logs は取り込み量(東京で約 $0.76/GB)と保管量(約 $0.033/GB・月)に課金され、ロググループの保持期間はデフォルト「無期限」です。第2回の Terraform コードで
retention_in_days = 30を設定したのはこのためで、これを忘れるとログが永遠に積もり、数年後に静かな固定費になります。障害調査に必要な期間(30〜90日が目安)だけ残し、それ以上の長期保存が必要なら S3 へのエクスポートを検討してください。
「保持期間を過ぎたら消える」が不安な場合の長期アーカイブ: アーカイブ先は AWS の S3(+ライフサイクルで Glacier 階層へ移行)が正解です。「R2 はエグレス無料で安いのでは」と考えたくなりますが、ログのアーカイブは「書くのは毎日・読むのは年に数回」という使い方のため R2 の強み(読み出し無料)が活きず、逆に CloudWatch から R2 へ送る時点で AWS のデータ転送料(約 $0.114/GB)が全ログ量にかかり、純正エクスポート機能も使えません(S3 専用のため Lambda 自作になる)。AWS 内で完結する S3 + Glacier なら転送無料・純正機能のみ・月数十円で済みます。 > なお「消えたら困る記録」の多くは、ログではなくアプリの監査ログとして DB に永続化すべきものです。CloudWatch にあるのは診断用ログ(スタックトレース等)であり、90 日を超えて読み返すことは実務上ほとんどありません。
# application.yml
logging:
structured:
format:
console: ecs # Spring Boot 3.4+ で利用可能な ECS JSON フォーマット
ecs フォーマットを使うと {"@timestamp":"...","log.level":"ERROR","message":"...","trace.id":"..."} のような JSON が出力されます。
Logs Insights でよく使うクエリ
CloudWatch コンソールの「Logs Insights」でロググループを選択し、以下のクエリを試してみてください。
エラーログだけ絞り込む
fields @timestamp, log.level, message, trace.id
| filter log.level = "ERROR"
| sort @timestamp desc
| limit 50
特定のリクエスト ID に関するログをすべて取得する(マイクロサービス間のトレースにも使える)
fields @timestamp, log.level, message
| filter trace.id = "YOUR-TRACE-ID-HERE"
| sort @timestamp asc
直近 1 時間のエラー数を 5 分刻みで集計する
filter log.level = "ERROR"
| stats count() as error_count by bin(5m)
| sort bin(5m) asc
Cloudflare 側のログ(無料枠でできる範囲)
Cloudflare の無料プランでは詳細なログストリームは利用できませんが、ダッシュボードの「Analytics」タブから次の情報を確認できます。
- Traffic タブ: リクエスト数・帯域・キャッシュヒット率の推移
- Security タブ: WAF でブロックしたリクエストの内訳
- Workers タブ: Workers でのリクエスト処理(パスルーティングに Workers を使っている場合)
エラーの切り分けで最もよく使うのは「Workers のログ」です。Workers のコードで console.log() を入れておくと、Cloudflare ダッシュボードの Workers 詳細ページからリアルタイムのログテールを確認できます(ただし本番トラフィックのサンプリングのため全件ではありません)。
4. バックアップとリストア訓練
RDS の自動バックアップを確認する
RDS は backup_retention_period で設定した日数分(第2回では 7 日に設定)の自動スナップショットを保持しています。RDS コンソールの「メンテナンス & バックアップ」タブで、自動バックアップが実際に取れているかを一度確認してください。
# 念のため確認。第2回のコードで設定済みのはず
resource "aws_db_instance" "main" {
...
backup_retention_period = 7
backup_window = "18:00-19:00" # JST 03:00-04:00(深夜)
...
}
「復元したことのないバックアップはバックアップではない」
この言葉は運用の世界の格言です。スナップショットが取れていても、いざ本番障害が起きてから初めて復元手順を試すのでは遅すぎます。以下の手順で一度必ずリストア訓練を行ってください。
リストア訓練の手順
-
RDS コンソールで「スナップショット」を選択し、手動スナップショットを 1 つ作成する(または既存の自動スナップショットを使う)
-
「アクション > スナップショットからの復元」で別インスタンスとして復元する
- インスタンス識別子は本番と違う名前にする(例:
myapp-db-restore-test) - サブネットグループ・セキュリティグループは本番と同じものを指定できる
- インスタンス識別子は本番と違う名前にする(例:
-
復元完了後、ローカル(または踏み台サーバー)から接続確認する
mysql -h <復元先のエンドポイント> -u <ユーザー名> -p <データベース名> # データが入っていることを確認 SELECT COUNT(*) FROM users; -
確認できたら必ず削除する(費用が発生し続けるため)
aws rds delete-db-instance \ --db-instance-identifier myapp-db-restore-test \ --skip-final-snapshot
この訓練を通じて、「本番障害 → スナップショット復元 → アプリ接続先切り替え → サービス復旧」の流れを頭に入れておきます。
Valkey はバックアップ不要
ElastiCache Valkey に保存しているデータは「キャッシュ・セッション・レートリミットのカウンタ」です。これらは失われても DB から再生成できる、またはカウンタがリセットされる程度の影響です。Valkey が再起動されてデータが消えた場合も、Spring Boot が再起動後に自動でキャッシュを温め直します。バックアップの費用と手間をかける優先度は低いと判断して問題ありません。
5. 更新・パッチ
① アプリ更新は CI 任せ
Spring Boot と Nuxt 3 の更新は第3回で作った CI/CD パイプラインが自動でデプロイします。コンテナイメージのビルド → ECR プッシュ → ECS ローリングアップデートの流れです。
② RDS マイナーバージョン自動適用
RDS のマイナーバージョン更新(セキュリティパッチを含む)は auto_minor_version_upgrade = true にしておけば、メンテナンスウィンドウに自動適用されます。
resource "aws_db_instance" "main" {
...
auto_minor_version_upgrade = true
maintenance_window = "Sun:19:00-Sun:20:00" # JST 月曜 04:00-05:00
...
}
メンテナンスウィンドウ中は数分〜数十分の再起動が発生します。RDS の再起動中もアプリが接続エラーを吸収できるよう、Spring Boot の接続プール設定に testOnBorrow や validationQuery を入れておくことを推奨します。
③ Terraform プロバイダー・モジュールの更新
Terraform のプロバイダー(hashicorp/aws、cloudflare/cloudflare)とコミュニティモジュールは定期的に更新されます。放置すると非推奨 API の削除で突然 terraform plan が失敗することがあります。
「月に一度手動で terraform init -upgrade」という運用は絶対に忘れるので、最初からボットに任せます。GitHub 標準の Dependabot は Terraform をエコシステムとしてサポートしており、プロバイダーやモジュールの新バージョンが出るとバージョン制約と .terraform.lock.hcl を更新する PR を自動で作ってくれます
# .github/dependabot.yml に追記
version: 2
updates:
- package-ecosystem: "terraform"
directory: "/infra/terraform/envs/prod" # ルートモジュールごとに1エントリ
schedule:
interval: "weekly"
open-pull-requests-limit: 3
この PR には第3回で作った plan-on-PR ワークフローがそのまま反応します。つまり運用は:
- Dependabot が「aws プロバイダーを 5.x.y に更新」という PR を出す(自動)
- CI が
terraform planを実行して結果を PR コメントに貼る(自動) - あなたは plan が「No changes(プロバイダー更新のみでインフラ差分なし)」であることを読んでマージするだけ
- main マージで apply(自動)
人間の仕事は「読んでマージ」の1手だけになり、忘れることが構造的になくなります。すでにアプリ側(npm / Gradle)で Dependabot を使っている場合は、同じファイルにエントリを1つ足すだけです。なお、より柔軟な制御(PR のグルーピング等)が欲しくなったら Renovate という選択肢もあります。
参考までに、ボットが裏でやっていることを手動でやる場合のコマンドは次の通りです(仕組みの理解用):
terraform init -upgrade # バージョン制約の範囲内で最新に更新(lock ファイルが書き換わる)
terraform plan # インフラ差分がないことを確認
④ ドリフト検出 — 「画面で直したら必ずコードに反映」の規律
IaC 運用で最もやりがちなミスが「障害対応中に AWS コンソールで設定を変えたまま、Terraform コードに戻し忘れる」です。この状態をドリフトと言います。
ドリフトがある状態で terraform apply を実行すると、コンソールで加えた変更が Terraform に上書きされ、直したはずの設定が元に戻るという事故が起きます。
# plan を実行するとドリフトが検出される
terraform plan
# 出力例(コンソールで直したのに Terraform に戻そうとしている)
# ~ resource "aws_ecs_service" "app" {
# ~ desired_count = 2 -> 1 # コンソールで 2 にしたが Terraform は 1
# }
ルールは単純です。コンソールで変えたことに気づいたら、その場で Terraform コードも直す。それだけです。週次の terraform plan を CI に組み込んでドリフトを検知する仕組みも有効です。
6. 障害対応の型 — よくある 4 シナリオ
障害が起きたとき、慌てずに切り分けを進めるための「型」を持っておくと冷静に動けます。
シナリオ①: デプロイ後にヘルスチェックが失敗する
症状: CI/CD でデプロイしたあと、ALB のターゲットが unhealthy になってアクセスできない。
切り分けフロー
-
ECS コンソールの「サービス」>「イベント」タブを開く。タスクが起動失敗しているか、起動してもすぐ落ちているかを確認する
-
タスクが落ちている場合、そのタスクの「ログ」タブで CloudWatch Logs を確認する。起動時の例外スタックトレースが見える
-
よくある原因:
- 環境変数(DB 接続情報・JWT シークレット等)の設定漏れ → ECS タスク定義の環境変数を確認
- Flyway マイグレーションの失敗 → ログに
FlywayExceptionが出る - ヘルスチェックのパスがアプリの起動より先にタイムアウトしている →
healthcheck_grace_period_secondsを延ばす
-
ロールバック手順: ECS コンソールの「サービスを更新」から「デプロイを強制的に新しい」にチェックを入れ、前のタスク定義リビジョンのタグで再デプロイする。または GitHub Actions から前の成功したコミットを再デプロイする
シナリオ②: DB 接続枯渇
症状: アプリのログに Connection is not available, request timed out after Xms が大量に出て、一部のリクエストが 500 エラーになる。
切り分けフロー
-
CloudWatch の
DatabaseConnectionsメトリクスを確認する(前述のアラーム④が鳴っているはず) -
RDS の
max_connectionsはDBInstanceClassMemory / 12582880(バイト)で計算される(MySQL のデフォルト式)。db.t3.small(メモリ 2GB)では約 150 接続 -
Spring Boot 側の HikariCP 設定を確認する
spring: datasource: hikari: maximum-pool-size: 10 # ← タスク 1 台あたり 10 接続 minimum-idle: 5 connection-timeout: 30000 -
タスク数 × maximum-pool-size が RDS の max_connections を超えていないかを計算する。超えているなら
maximum-pool-sizeを下げるか、RDS インスタンスクラスを上げる -
根本的な対策としては、長時間保持しているトランザクションがないか(
@Transactionalの範囲が広すぎるメソッド)をコードレビューする
シナリオ③: Valkey 断
症状: ElastiCache Valkey が再起動・一時停止し、Spring Boot のログに Redis 接続エラーが出る。
切り分けフロー
-
Valkey に保存しているデータの用途を確認する:
- セッション・キャッシュ: アプリが fail-open(Valkey に繋がらなくてもリクエストを通す)設計なら、機能が遅くなるだけでサービスは継続できる
- レートリミットのカウンタ: Valkey 断中はカウントが取れないため制限が実質無効になるが、エラーにするより機能劣化で通す方がユーザー体験として適切
-
重要なのは設計とセットで考えることです。Valkey 断でアプリが 500 エラーを返すなら、それはキャッシュを必須依存として扱っている設計のバグです。キャッシュは常に「あればラッキー」として扱い、なくても DB に問いに行けるコードにしておくべきです
-
Valkey が回復すれば自動で接続が復活するので、ヘルスチェックの設定次第では ECS タスクの再起動すら不要です
シナリオ④: Cloudflare 側の 5xx
症状: ユーザーから「サイトが 5xx を返している」という報告があるが、原因が Cloudflare(エッジ)なのか Spring Boot(オリジン)なのかわからない。
切り分けフロー
-
ALB に直接アクセスして確認する。ALB のエンドポイント(
xxx.ap-northeast-1.elb.amazonaws.com)に直接 curl を打つか、ECS のセキュリティグループで一時的に自分の IP からの 443 を許可して確認するcurl -I https://xxx.ap-northeast-1.elb.amazonaws.com/api/v1/health -
ALB 直接で 200 が返るなら Cloudflare の問題。Cloudflare コンソールの「Workers」>「Error logs」や「Analytics」で 5xx の原因を調べる。Workers のコードのバグや、オリジンへのプロキシ設定(Origin Rules)の誤りが多い
-
ALB 直接でも 5xx なら Spring Boot の問題。上記シナリオ①②③に沿って切り分ける
-
Cloudflare のステータスページ(
cloudflarestatus.com)も確認する。Cloudflare 自体のインシデントが出ていることもある
7. コスト管理
月次でやること
月に一度、AWS Cost Explorer を開いて次を確認します。
- 今月の合計コストと先月比の差異: 想定外の増加がないか
- サービス別の内訳: EC2(Fargate も含まれる)・RDS・データ転送が主要コスト
- 予算アラートの見直し: 第2回で設定した AWS Budgets のしきい値が現実の利用額に対して適切か
想定コスト(月額)の目安
本構成(1 タスク・最小構成)での参考値です。リージョンや利用量によって変動します。
| サービス | 概算 |
|---|---|
| ECS Fargate(0.5 vCPU / 1GB・常時起動) | $15〜20 |
| RDS MySQL db.t3.small(シングル AZ) | $15〜20 |
| ElastiCache Valkey cache.t3.micro | $12〜15 |
| ALB(最小トラフィック) | $15〜18 |
| NAT Gateway(1 AZ) | $5〜10 |
| データ転送・CloudWatch 等 | $3〜5 |
| 合計 | $65〜90 |
よくある無駄遣い
- 消し忘れスナップショット: リストア訓練で作ったスナップショットを削除し忘れると GB × 月額が積み上がります。月次で RDS コンソールの「手動スナップショット」を確認して不要なものを消す
- 未使用 EIP(Elastic IP): NAT Gateway や EC2 に紐づいていない EIP は $0.005/時間(月約 $3.6)かかります
- NAT Gateway のデータ処理料: ECS タスクから外部 API(Stripe, SES 等)への大量リクエストは NAT のデータ処理料になります。VPC エンドポイントで S3 や ECR を直接引けばこの部分を削れます
- 古い ECR イメージ: ECR に大量の古いコンテナイメージが積み上がるとストレージコストがかかります。ECR のライフサイクルポリシーで「最新 10 枚以外は自動削除」を設定しておくと安心です
8. スケールアップの道筋
第 0 段階(現在): 1 タスク固定
現状の構成では ECS のタスク数を 1 に固定しています。その理由は WebSocket のブローカーがインメモリだからです。
Spring Boot の enableSimpleBroker() はプロセス内でメッセージをルーティングします。タスクを 2 台に増やすと、クライアント A が接続しているタスクとクライアント B が接続しているタスクが別々になり、片方へのメッセージがもう片方に届かないという問題が起きます。これは IaC の問題ではなくアプリ設計の問題なので、水平スケール前に必ず対応が必要です。
第 1 段階: 垂直スケール(CPU・メモリを増やす)
まず試みるべきはタスクの「縦伸び」です。Terraform の 1 行変更でできます。
resource "aws_ecs_task_definition" "app" {
...
cpu = "1024" # 0.5 vCPU → 1 vCPU
memory = "2048" # 1 GB → 2 GB
...
}
terraform apply でタスクが再起動し、新しいスペックで動き始めます。ダウンタイムをゼロにしたい場合は ECS のローリングアップデート設定を確認してください(minimum_healthy_percent = 100 にするとロールアウト中もタスクが常時動く)。
第 2 段階: 水平スケール(タスクを複数台に)
WebSocket ブローカーを外部化(Redis Pub/Sub を使った STOMP リレー、または外部 STOMP ブローカーへの移行)した後で、水平スケールに挑戦できます。
resource "aws_ecs_service" "app" {
...
desired_count = 2 # タスク数を増やす
# オートスケーリングと組み合わせる場合は別リソースで定義
}
# ECS オートスケーリング(CPU 60% を維持するように台数を調整)
resource "aws_appautoscaling_target" "ecs" {
max_capacity = 10
min_capacity = 2
resource_id = "service/${var.ecs_cluster_name}/${var.ecs_service_name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "ecs_cpu" {
name = "${var.app_name}-${var.env}-cpu-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = 60.0
}
}
第 3 段階: DB・キャッシュ・エッジで負荷を分散する
ECS をスケールしてもボトルネックは DB に移動します。
RDS の垂直スケールは Terraform で instance_class を変えるだけです(一時停止が発生します)。
resource "aws_db_instance" "main" {
instance_class = "db.t3.medium" # small → medium
...
}
リードレプリカは読み取りクエリを分散します。参照系のエンドポイントだけレプリカに向けるよう、アプリ側の DataSource 設定の変更も必要です。
Cloudflare のキャッシュと R2 を活用すると、頻繁に参照される公開コンテンツ(ランディングページ、公開プロフィール等)をオリジンのリクエストなしにエッジから返せます。DB への読み取りクエリを根本から減らす発想です。
9. 卒業課題 — モジュール再利用で staging 環境を複製する
このシリーズで作った Terraform コードが本当に「再利用できる状態」になっているかを確認する課題です。
envs/staging を作る
infra/
modules/
networking/
ecs/
rds/
elasticache/
monitoring/
envs/
production/
main.tf
terraform.tfvars
staging/ ← これを作る
main.tf
terraform.tfvars
envs/staging/main.tf は envs/production/main.tf とほぼ同じですが、コスト最小化のために次を変えます。
# envs/staging/terraform.tfvars
env = "staging"
# ECS: 最小スペック
ecs_cpu = 256
ecs_memory = 512
# RDS: 最小クラス・バックアップ不要
rds_instance_class = "db.t3.micro"
rds_backup_retention = 0
rds_multi_az = false
# ElastiCache: 最小ノード
elasticache_node_type = "cache.t3.micro"
# 監視: メール通知は staging 用のアドレスに
alert_email = "dev-staging-alerts@example.com"
staging 環境は普段使わないときは terraform destroy で消しておくとコストを抑えられます。PR マージのたびに staging にデプロイ → テスト → 問題なければ production にデプロイ、という流れが整うと、デプロイ事故が格段に減ります。
10. シリーズ全体のまとめ
4 回にわたって「インフラ未経験」から出発し、次の地点まで到達しました。
| 回 | 到達地点 |
|---|---|
| 第1回 | Terraform の基礎・AWS と Cloudflare を IaC で管理する土台 |
| 第2回 | VPC・ECS Fargate・RDS・ElastiCache・ALB・S3・SES の本番構成 |
| 第3回 | GitHub Actions による CI/CD パイプライン・ゼロダウンタイムデプロイ |
| 第4回(本記事) | 監視・ログ・バックアップ・障害対応・コスト管理・スケール戦略 |
次に学ぶと良いもの 3 つ
1. AWS Well-Architected Framework
AWS が公開している「信頼性・セキュリティ・パフォーマンス効率・コスト最適化・運用上の優秀性・持続可能性」の 6 本柱です。このシリーズで触れた内容はどれかの柱に当てはまります。自分のインフラが各柱のベストプラクティスとどれだけ離れているかをセルフレビューする道具として使えます。
2. OpenTelemetry と分散トレーシング
サービスの規模が大きくなると「どのリクエストがどこで詰まっているか」を追うのが難しくなります。Spring Boot は OpenTelemetry の SDK を組み込むことで、トレース ID を自動付与し、AWS X-Ray や Grafana Tempo にトレースを送れます。ログとトレースが繋がると障害調査の速度が劇的に上がります。
3. インフラのテスト(Terratest)
Terraform コードのテストも書けます。Terratest(Go 製)を使うと「apply した後に実際に HTTP 疎通確認する」「セキュリティグループが意図した通りに閉じているか確認する」といったテストをコードで書けます。本番インフラの変更に対して CI でテストが回る状態は、コードのテストと同じ安心感をインフラにもたらします。
おわりに
インフラは「作った後」の方が長い。運用の手応えが出てきたとき、IaC にして良かったと実感できるはずです。
本シリーズの構成は Spring Boot + Nuxt 3 + AWS + Cloudflare の個人開発・スモールチーム向けを前提としています。AWS のサービス仕様・料金は変更されることがあるため、実際の構築時は公式ドキュメントを必ず確認してください。