2026年9月1日火曜日

「開発環境は止まっているはずなのに」— AWS Configコスト急増の犯人を追いつめた話

TL;DR

  • コスト削減のため夜間・週末に自動で止めているはずの開発環境で、AWS Configの利用料金だけが跳ね上がった
  • 「起動・停止の瞬間にコストが増える」という自然な仮説は、調査したら半分正解・半分不正解だった
  • 本当の犯人は、環境の停止処理がECSサービスの Application Auto Scaling を止めていなかったことによる、丸4日間ぶっ通しのタスク起動失敗ループだった
  • 最初の修正案もレビューで「まだ不十分」と指摘され、AWSの正式な「Auto Scalingの一時停止」機能に作り直すことになった

以下、調査の一部始終と、そこから得た教訓をまとめる。


背景

とあるWebサービスのdevelop/staging環境は、コスト削減のために夜間・週末は自動で止まるようになっている。具体的には、Lambda + EventBridge Schedulerで組んだ仕組みで、

  • ECS Fargateサービスを desiredCount = 0 にスケールダウン
  • RDS Auroraクラスタを停止
  • NAT用・踏み台用のEC2インスタンスを停止

という3点セットを実行し、平日朝に元へ戻す。よくある構成だと思う。

このスケジュールをしばらく無効化していた期間があり、再度有効化したところ、AWS Configの利用料金(Configuration Item記録件数に応じた課金)が急増した。「起動・停止イベントが増えたから、その分Configの記録も増えたのだろう」と考えるのが自然だし、実際そういう外部の事例もいくつか見つかる。

というわけで、原因を確定させ、直すことにした。

第一の仮説:起動・停止のたびにENIが作り直される

ECS Fargateをawsvpcネットワークモードで使っていると、タスクが再作成されるたびに新しいENI(Elastic Network Interface)が払い出される。これ自体はAWS::EC2::NetworkInterfaceというリソースタイプとしてAWS Configに記録される対象で、実際にいくつかのブログ記事でも「ECSタスクの頻繁な入れ替わりでConfigコストが跳ね上がった」という事例が紹介されている。

これを検証するために、調査専用の一時的なCloudTrail(管理イベントのみ、単一リージョン)を新しく作り、既存の設定には一切手を触れずに構築した。ついでにAWS Configのselect-resource-config(Advanced Query)やget-discovered-resource-counts、CloudWatchメトリクス(AWS/Config名前空間のConfigurationItemsRecorded)も使って、実際に何が記録されているかを定量的に追いかけた。

起動イベントの瞬間(数十分のウィンドウ)を見てみると、確かに記録の内訳はこうなっていた。

リソースタイプ件数比率
EC2::NetworkInterface最多約3割
EC2::SecurityGroup次点約2割弱
ECS::Service約1割強
EC2::Instance(NAT/踏み台)1割弱
その他(RouteTable/EIP/RDS等)残り

想定通り、ENI(と、それに付随するSecurityGroupやRouteTableなどの周辺リソース)が最多だった。「やっぱりこれが原因か」と一瞬納得しかけた。

ここで、地味に役立った教訓が一つ。select-resource-config(Advanced Query)は「各リソースの最新の状態」しか返さない。過去のある時点で何が記録されたかを調べようとしても、そのリソースがその後さらに変化していれば、古いタイムスタンプでの記録はヒットしない。「昨日の停止イベントを調べようとしたら0件だった」と一瞬焦ったが、原因はこれだった。過去の特定時点の記録を追うには、リソースIDを指定して履歴を直接取得できるAPI(get-resource-config-history)や、Config配信チャネル先のS3にある設定履歴ファイルを使う必要がある。

しかし、これでは説明がつかない

起動・停止イベント単体の影響は数十件程度で、確かに存在はする。しかし、Cost Explorerでコスト推移を見ると、環境が完全に停止しているはずの期間(週末含む、丸4日間)もコストが下がるどころか上がり続けていた

もし原因が「起動・停止イベントに伴う一過性のENI churn」なら、停止直後にスパイクしてすぐ収まるはずだ。しかし実際は、停止期間中ずっと高止まりし、日を追うごとにむしろ微増していた。これは「一過性のイベント」の説明では無理がある。「別の何かが継続的に発生している」と考えるべきだった。

寄り道:見当違いの仮説を検証して捨てる

真っ先に疑ったのは、「現在Configが管理しているリソースの総数」を見たときに突出して多かった、あるレガシーなリソースタイプ(過去に別件のコスト急増の原因になったことがあるもの)だった。しかし、そのリソースタイプ単体のCloudWatchメトリクスを見ると、直近ではほとんど動きがなかった。「総数が多い」ことと「直近の変化率が高い」ことは別物だと、指摘されて初めて気づいた。仮説は却下。

次に、「Config全体の記録件数」と「ENI(EC2::NetworkInterface)単体の記録件数」を日次で比較してみると、両者はほぼ同じ形(ベースラインの約15〜18倍に跳ね上がり、その後も高止まり)をしていた。しかも比率がずっと一定(全体のうち常に2〜3割程度)。ここでようやく「ENIが本命で間違いない、ただし発生源は起動・停止イベントとは別」という当たりがついた。

犯人探し:CloudTrailで「誰が」ENIを作っているかを追う

CloudTrailでCreateNetworkInterfaceイベントのdescriptionフィールドを見ると、完全に停止しているはずの1日分の記録が、すべてECSタスクのアタッチメントを示す形式(arn:aws:ecs:...:attachment/<uuid>)だった。しかもユニークなIDが数百件並んでいて、「同じ理由で1回だけ大量発生した」のではなく「何かが延々とタスクを再作成し続けている」ことを示していた。

不思議なことに、明示的なRunTask呼び出しは1日たった数件(既知の定期実行ジョブ分だけ)しかなかった。ここで学んだのが、ECSサービスがスケジューラ経由で内部的にタスクを再配置する場合、RunTaskではなくTaskCreatedという別の管理イベントとして記録されるということ。実際にTaskCreatedイベントの件数を見ると、RunTaskの数十倍あった。

ここから、作成されたENIの「サブネットID」と「セキュリティグループID」の組み合わせを集計し、既知のリソース(AZごとに2つのサブネットに分散配置されるのが通例)と突き合わせることで、犯人サービスを絞り込んだ。最終的にaws ec2 describe-security-groupsでセキュリティグループ名を引いて、内部向けAPIサービスであることが判明した。

根本原因:Auto Scalingが停止と綱引きしていた

該当サービスのdescribe-servicesのイベントログを見て、ようやく全貌が見えた。

(service ...) was unable to place a task. Reason: ResourceInitializationError:
unable to pull secrets or registry auth: unable to retrieve secret from asm:
There is a connection issue between the task and AWS Secrets Manager.
... context deadline exceeded.

タスクが起動すらできず、コンテナ定義に紐づいたSecrets Managerのシークレット取得でタイムアウトしていた。原因はシンプルで、環境停止時にNAT用EC2インスタンスも一緒に止めているため、プライベートサブネットからインターネット(Secrets Managerのパブリックエンドポイント)へ抜ける経路が失われていたからだ。

ではなぜタスクを起動しようとしていたのか。該当サービスにはApplication Auto ScalingがMinCapacity=1で登録されており、環境停止処理がこのAuto Scalingの設定に一切触れていなかった。停止処理はUpdateService(desiredCount=0)でサービスを一旦0にするが、Auto Scalingは自分の管理範囲(Min1〜Max2など)を守ろうとして、定期的にdesiredCountを1へ戻そうとする。

  • Auto Scalingがタスクを1つ起動しようとする
  • NATが止まっているのでSecrets Manager取得に失敗し、約13分でタイムアウト
  • ECSが即座に次のタスクを試みる
  • 以下、環境停止期間中(今回のケースでは最大4日弱)ずっと、13〜27分間隔で繰り返し

サービスのイベントログのタイムスタンプを丹念に追うと、この失敗ループは環境の起動処理でNATが復旧した直後にピタリと止まっていた。ここまで来ると、もう疑いようがない。

一度目の修正:レビューで「まだ足りない」と言われる

最初に考えた修正は素直なものだった。停止時にAuto ScalingのMinCapacityを0にし、起動時に元へ戻す。IAM権限もDescribeScalableTargets/RegisterScalableTargetの2つだけで済むし、実装もシンプル。コードレビューに出した。

すると、こういう指摘が返ってきた。

MinCapacityを0にするだけでは、Auto Scalingに登録済みの「スケジュールドアクション(毎日決まった時刻にMin/Maxを書き換えるアクション)」に上書きされてしまうのでは?

確認してみると、まさにその通りだった。当該サービスには「毎朝の営業開始前にスケールアウトする」「毎晩の営業終了後にスケールインする」という、Auto Scaling自体のスケジュールドアクションが別途設定されていた。これは環境の起動停止スケジュールとは完全に独立して毎日動く。つまり:

  • 環境を停止した直後の夜、Auto Scaling側の「スケールイン」アクションがMinCapacityを1に戻す
  • 環境の起動処理よりに、Auto Scaling側の「スケールアウト」アクションがMinCapacityを1に戻す(NAT起動よりも先に)

MinCapacityを直接書き換える方式では、この2つの独立したスケジュールに対して常に後出しじゃんけんで負ける構造だった。しかも、TerraformでこのMinCapacityを管理している場合、Lambdaが書き換えた値は次回のterraform applyで元に戻されてしまう(driftの元)という副作用まで指摘された。

二度目の修正:AWS標準の「一時停止」機能を使う

Application Auto Scalingには、まさにこの用途のための機能がある。RegisterScalableTargetAPIのSuspendedStateだ。MinCapacity/MaxCapacityの値には一切触れず、「動的スケーリング」と「スケジュールドアクション」の両方を丸ごと一時停止・再開できる。

# 停止時
autoscaling.register_scalable_target(
    ServiceNamespace="ecs",
    ResourceId=resource_id,
    ScalableDimension="ecs:service:DesiredCount",
    SuspendedState={
        "DynamicScalingInSuspended": True,
        "DynamicScalingOutSuspended": True,
        "ScheduledScalingSuspended": True,
    },
)

# 起動時(すべてFalseに戻すだけ)
autoscaling.register_scalable_target(
    ServiceNamespace="ecs",
    ResourceId=resource_id,
    ScalableDimension="ecs:service:DesiredCount",
    SuspendedState={
        "DynamicScalingInSuspended": False,
        "DynamicScalingOutSuspended": False,
        "ScheduledScalingSuspended": False,
    },
)

これなら、Auto Scaling側のスケジュールドアクションも含めて確実に止まるし、Min/MaxCapacityの値そのものは触らないのでTerraformとのdriftも起きない。IAM権限も変わらず同じ2つで済んだ。

あわせて、「環境起動後のサービス安定化待ちがタイムアウトした場合でも、Auto Scalingを止めたまま放置しない」ようにtry/finallyでresume処理を保証するようにした。一時停止したまま忘れられると、今度は逆に「業務時間中の正常なオートスケールが働かない」という別の障害になりかねないからだ。

教訓

  1. 「怪しいものが大量にある」と「直近悪化した原因である」は別物。総数のスナップショットだけで判断せず、時系列のレート(メトリクス)で裏を取る。
  2. AWS Configの「今のリソース状態」を返すAPIと、「過去の履歴」を返すAPIは別物。過去のある時点を調べたいなら履歴系のAPI(あるいは配信されたログファイル)を使う。
  3. CloudTrailの「明示的なAPI呼び出し」と「サービス内部の自動処理」は別のイベント名で記録されることがある。RunTaskが少ないからといって、タスクがあまり起動していないとは限らない。
  4. 「止める」処理を書くときは、それを上書きしうる自動化(Auto Scaling、他のスケジュール、外部の監視ツールによる自動修復など)が他にないか、必ず洗い出す。今回は「環境の起動停止スケジュール」と「Auto Scaling自身のスケジュール」という、独立に育った2つの自動化が噛み合っていなかったことが根本原因だった。
  5. コードレビューは通す。一度目の修正で「動くはず」と思っていたが、実際にはterraformの中身まで確認しないと見抜けない落とし穴があった。一人で完結させず、指摘をもらえる体制は大事。

似たような「開発環境の自動停止」を運用している人の参考になれば幸いです。