Home Resume
Homeβ€Ί Blogβ€Ί Week 5 - Cost Anomaly Detection with AWS Cost Expl…
AWS Weekly Lab AWS Terraform

Week 5 - Cost Anomaly Detection with AWS Cost Explorer, SNS, and Lambda

AWS Platform Engineering Lab Β· Week 05 of 52
Building an ML-powered spend monitoring pipeline β€” no more surprise bill spikes. Managed end-to-end with HCP Terraform and OIDC dynamic credentials.

1. The Challenge β€” Why Fixed Budget Alerts Aren’t Enough

You set a monthly AWS budget of $500. You get an alert when you hit $450. Job done, right?

Not quite. Here’s the scenario that breaks that model: your EC2 spend is normally $80/month. This week it’s running at $200 β€” 150% above normal. But your total bill is still $280, well under your $500 alert threshold. No alert fires. No one notices. The overrun continues for days.

Fixed budget alerts have four fundamental blind spots:

  • Threshold blindness. A fixed dollar limit can’t distinguish between $200 spend in a month where $200 is normal versus $200 when $50 is normal.
  • Lagging detection. Budget alerts fire after a percentage of the monthly budget is consumed β€” which may be days after the anomalous spending began.
  • No root cause. A budget alert tells you how much you’ve spent. Cost Anomaly Detection tells you which service, which region, which usage type is the culprit.
  • No baseline learning. Budget thresholds don’t account for your actual spending patterns. The ML model updates itself as your workloads grow.
Real-world trigger

A misconfigured NAT Gateway left running overnight generated $35 in data processing charges. The bill was under budget. No alert fired. The only way it was caught was a manual cost review β€” the kind of thing that doesn’t scale.

2. Why Implement Cost Anomaly Detection

AWS Cost Anomaly Detection uses ML to model your expected spend per service per day. It triggers an alert the moment actual spend diverges significantly from that model β€” regardless of whether a fixed budget threshold was crossed.

1

Catches what budgets miss

Anomaly detection catches relative spikes β€” a service costing 3Γ— its normal amount β€” even when the absolute dollar value is small.

2

Immediate alerts, not end-of-month surprises

IMMEDIATE frequency means the alert fires as soon as the anomaly is confirmed β€” typically within minutes of the cost spike starting.

3

Root cause in the alert

The alert payload includes the specific service, region, usage type, and linked account. You know where to look before you open the console.

4

Zero ongoing maintenance

The ML model updates itself continuously. One-time setup, permanent benefit. And it’s completely free.

3. Skills & Tools Required

AWS Services

  • AWS Cost Explorer & Cost Anomaly Detection
  • SNS (Simple Notification Service) β€” two-topic fan-out pattern
  • Lambda (Python 3.12)
  • IAM β€” least-privilege roles, OIDC federation
  • CloudWatch Logs

Infrastructure & Tooling

  • Terraform β€” modules, cloud {} backend block
  • HCP Terraform β€” VCS-driven workspaces, OIDC dynamic credentials
  • Python β€” boto3, json, os
  • GitHub β€” VCS trigger for HCP runs

Concepts to understand before starting

  • ML-based anomaly detection. The model needs 10+ days of data to build a reliable baseline. Don’t expect alerts on a fresh account immediately.
  • SNS fan-out pattern. Two topics: one receives raw JSON from AWS, the second delivers a formatted email. Lambda sits between them as a transformer.
  • OIDC federation. HCP Terraform authenticates to AWS using short-lived tokens β€” no static keys stored anywhere.
  • VCS-driven IaC. Push to GitHub β†’ HCP plans automatically. Merge to main β†’ HCP applies. No GitHub Actions Terraform steps needed.

4. Architecture

Cost Anomaly Detection Architecture Two-zone architecture: Detection Layer (left) feeds into Alerting Pipeline (right) via costalerts.amazonaws.com Detection Layer AWS Cost Explorer Establishes ML spend baseline per service Β· per day Β· per region informs model Cost Anomaly Monitor Type: DIMENSIONAL / SERVICE Threshold: $10+ above expected Frequency: IMMEDIATE Alerting Pipeline SNS: raw-anomaly Receives raw JSON from AWS Principal: costalerts.amazonaws.com Policy: allow Publish from service publishes β†’ invokes Lambda Lambda: cost-alerter Python 3.12 Β· parses raw JSON Formats human-readable email Publishes to cost-alert topic CloudWatch Logs Β· 14d publishes to SNS: cost-alert Formatted alert topic Email subscription endpoint Email Alert Subscriber: your-email@example.com Figure 1: Cost Anomaly Detection β€” SNS fan-out with Lambda formatting layer

The architecture uses two SNS topics by design. The raw-anomaly topic receives dense JSON from AWS β€” not human-readable. Lambda transforms it and publishes a formatted plain-text alert to the cost-alert topic, which delivers to email subscribers. This separation gives a clean extension point: add Slack, PagerDuty, or Teams as additional subscribers to the formatted topic later without touching the detection layer.

Cost Anomaly Monitor in AWS console
Cost Anomaly Monitor configured in AWS β€” DIMENSIONAL type monitoring all services

5. How to Implement β€” Step by Step

Step 1 β€” Create the AWS OIDC provider and IAM role

Before HCP can authenticate to AWS, AWS needs to trust HCP. This is a one-time, manual bootstrap β€” it can’t be created by the same Terraform run that depends on it.

Register app.terraform.io as an OIDC identity provider, then create the role HCP will assume:

AWS CLI β€” one-time bootstrap
aws iam create-open-id-connect-provider \
  --url https://app.terraform.io \
  --client-id-list aws.workload.identity \
  --thumbprint-list <thumbprint>

aws iam create-role \
  --role-name hcp-terraform-role \
  --assume-role-policy-document file://trust-policy.json
JSON β€” trust-policy.json (scoped to this workspace)
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/app.terraform.io"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "app.terraform.io:aud": "aws.workload.identity"
      },
      "StringLike": {
        "app.terraform.io:sub":
          "organization:katta:project:*:workspace:week-05-dev:run_phase:*"
      }
    }
  }]
}
Gotcha β€” org slug is case-sensitive

The org shows as Katta in the HCP UI, but the actual slug used in the OIDC sub claim is lowercase katta (visible in API URLs like /api/v2/organizations/katta/...). IAM’s StringLike condition is case-sensitive β€” a capital letter here causes AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity with no other clue as to why.

Step 2 β€” Set up HCP Terraform workspace

Previous weeks used an S3 backend. This week switches to HCP Terraform β€” a managed backend with VCS integration and a cleaner OIDC credential story.

S3 Backend + GitHub ActionsHCP Terraform
State storageS3 bucket (you manage)HCP manages it
State lockingDynamoDB tableBuilt-in
CI/CDGitHub Actions workflowHCP VCS-driven
AWS credentialsOIDC via GitHub ActionsOIDC via HCP workspace
Free tier~$0Free ≀500 resources

Create the workspace in app.terraform.io: org Katta β†’ New Workspace β†’ Version Control Workflow β†’ connect katta698/AWS-Platform-Engineering-Lab β†’ working directory week-05-cost-anomaly-detection/terraform/environments/dev β†’ name it week-05-dev.

Step 3 β€” Terraform backend and module composition

HCL β€” terraform/environments/dev/main.tf
terraform {
  cloud {
    organization = "Katta"
    workspaces { name = "week-05-dev" }
  }
}

module "sns"           { source = "../../modules/sns"           ... }
module "lambda_alerter"{ source = "../../modules/lambda_alerter" ... }
module "cost_anomaly"  { source = "../../modules/cost_anomaly"   ... }

Step 4 β€” SNS topic policy (the part everyone gets wrong)

Without this policy, costalerts.amazonaws.com cannot publish to the topic and no alerts will ever arrive. There is no error message β€” AWS silently drops the publish attempt.

Important

The service principal for Cost Anomaly Detection is costalerts.amazonaws.com β€” not budgets.amazonaws.com. This is the most common mistake when setting up this pipeline.

HCL β€” SNS topic policy
resource "aws_sns_topic_policy" "raw_anomaly" {
  arn = aws_sns_topic.raw_anomaly.arn

  policy = jsonencode({
    Statement = [{
      Principal = { Service = "costalerts.amazonaws.com" }
      Action    = "SNS:Publish"
      Resource  = aws_sns_topic.raw_anomaly.arn
    }]
  })
}

Step 5 β€” Cost Anomaly Monitor and Subscription

HCL β€” modules/cost_anomaly/main.tf
resource "aws_ce_anomaly_monitor" "this" {
  name              = "${var.project}-${var.environment}-monitor"
  monitor_type      = "DIMENSIONAL"
  monitor_dimension = "SERVICE"
}

resource "aws_ce_anomaly_subscription" "this" {
  frequency        = "IMMEDIATE"
  monitor_arn_list = [aws_ce_anomaly_monitor.this.arn]

  subscriber { type = "SNS"; address = var.raw_sns_topic_arn }

  threshold_expression {
    dimension {
      key           = "ANOMALY_TOTAL_IMPACT_ABSOLUTE"
      values        = ["10"]
      match_options = ["GREATER_THAN_OR_EQUAL"]
    }
  }
}

Step 6 β€” Lambda alert formatter

Lambda transforms the raw Cost Anomaly Detection JSON payload into a plain-text email showing the service, expected vs. actual spend, and the top 5 root causes.

Python β€” lambda/cost_alerter/handler.py
def format_alert(payload):
    impact   = payload.get("impact", {})
    expected = impact.get("totalExpectedSpend", 0.0)
    actual   = impact.get("totalActualSpend",   0.0)
    overage  = impact.get("totalImpact",        0.0)
    pct      = impact.get("totalImpactPercentage", 0.0)
    service  = payload.get("dimensionValue", "Unknown")

    root_causes = "\n".join(
        f"  - {rc['service']} | {rc['region']} | {rc['usageType']}"
        for rc in payload.get("rootCauses", [])[:5]
    )
    subject = f"[DEV] Cost Anomaly: ${overage:.2f} above expected - {service}"
    return subject, body

Step 7 β€” Configure OIDC dynamic credentials

Two workspace environment variables replace the entire AWS credential setup, pointing at the role created in Step 1. HCP mints short-lived tokens per run via sts:AssumeRoleWithWebIdentity β€” no static keys stored anywhere.

HCP Workspace Environment Variables
TFC_AWS_PROVIDER_AUTH  = true
TFC_AWS_RUN_ROLE_ARN   = arn:aws:iam::<ACCOUNT_ID>:role/hcp-terraform-role

Step 8 β€” Push to GitHub and apply via HCP

A git push to the connected branch automatically queues a plan in HCP β€” no manual trigger needed for that part. But a VCS-connected workspace blocks terraform apply from local CLI or CI entirely (Error: Apply not allowed for workspaces with a VCS connection). The actual resource creation only happens once you open the run in the HCP UI and click Confirm & Apply.

HCP Terraform run applied
HCP Terraform workspace week-05-dev β€” VCS-connected run, applied successfully
SNS topics in AWS console
Both SNS topics created β€” raw-anomaly (AWS-to-Lambda) and cost-alert (Lambda-to-email)
Cost Anomaly Detection alert subscription
Alert subscription β€” IMMEDIATE frequency, $10 absolute impact threshold

Step 9 β€” Test immediately after deploy

Don’t wait for a real anomaly. Use the Lambda test console with a mock payload to verify the full pipeline:

JSON β€” Lambda test event
{
  "Records": [{
    "Sns": {
      "Message": "{\"dimensionValue\":\"Amazon EC2\",
                   \"impact\":{\"totalActualSpend\":22.50,
                               \"totalExpectedSpend\":10.00,
                               \"totalImpact\":12.50},
                   \"rootCauses\":[{\"service\":\"Amazon EC2\",
                                    \"region\":\"us-east-1\",
                                    \"usageType\":\"BoxUsage:t3.medium\"}]}"
    }
  }]
}
Lambda test execution succeeded
Lambda test β€” execution succeeded, alert published to the formatted-alert topic

Post-deploy checklist: (1) Confirm SNS email subscription β€” check inbox for AWS Notification email and click the link. (2) Verify monitor in Cost Management β†’ Anomaly Detection. (3) Run the Lambda test above and confirm a formatted email arrives within 30 seconds.

Formatted cost anomaly email
Formatted alert email β€” expected vs. actual spend with root cause breakdown

6. Real Challenges & Gotchas

1
Wrong SNS service principal β€” silent failure

What happens: The monitor is created, the subscription is configured, but no alerts ever fire. No error in CloudWatch, no failure in the console.

Root cause: The SNS topic policy uses budgets.amazonaws.com instead of costalerts.amazonaws.com. AWS drops the publish attempt silently.

Fix: Update the topic policy principal to costalerts.amazonaws.com.

2
24–48 hour ML baseline warmup

What happens: Deploy succeeds, Lambda test works, but no real anomaly alerts arrive for days even when you intentionally run up charges.

Root cause: Cost Anomaly Detection requires a minimum amount of historical spend data to train its ML baseline. Expected behaviour on a new monitor β€” not a bug.

Fix: Use the Lambda test event to validate the pipeline immediately. Real anomaly alerts start arriving once the baseline stabilises.

3
SNS email subscription pending β€” alerts silently dropped

What happens: Lambda executes successfully (visible in CloudWatch logs), but no email arrives.

Root cause: The email subscription is in PendingConfirmation state. SNS does not deliver to unconfirmed endpoints.

Fix: Check inbox for the AWS Notification β€” Subscription Confirmation email and click the confirmation link.

4
VCS-connected workspace plans against a stale, empty config β€” repeatedly

What happens: Every plan β€” auto-triggered or manually started β€” fails with Error: No Terraform configuration files found in working directory / lstat .../config/week-05-cost-anomaly-detection: no such file or directory. This happens even though the folder is confirmed to exist on main via the GitHub API, the working directory setting is correct, and the branch/trigger settings are all correct.

Root cause (layered): First, HCP's GitHub App integration had repository access scoped to a different repo entirely β€” check GitHub β†’ Settings β†’ Applications β†’ Installed GitHub Apps β†’ (your Terraform app) β†’ Repository access. Granting access to the correct repo didn’t fix it on its own; the existing VCS connection in the workspace needed a full disconnect-and-reconnect (Settings β†’ Version Control β†’ Change source) to actually pick up the change. Even after that, the same error persisted across several fresh runs.

What actually fixed it: Settings β†’ Version Control β†’ Edit β†’ enabling β€œInclude submodules on clone” (a.k.a. ingress_submodules). The repo has no real submodules, so the exact mechanism is unconfirmed β€” but toggling it on is what broke the cycle of stale ingestion. Worth trying early, before chasing GitHub App permissions or doing repeated reconnects.

Bonus finding: a VCS-connected workspace also blocks terraform apply (and terraform plan -out=tfplan) from local CLI or CI with Error: Apply not allowed for workspaces with a VCS connection β€” apply has to be confirmed via the HCP UI run page (or the API) once VCS is the source of truth.

7. Security Best Practices

πŸ”‘

No static AWS credentials anywhere

HCP Terraform authenticates via OIDC, minting short-lived tokens scoped to the workspace. No AWS_ACCESS_KEY_ID stored in HCP, GitHub, or anywhere else.

πŸ”“

Workspace-scoped OIDC trust

The IAM trust policy uses StringLike with the workspace name in the sub condition. Only the week-05-dev workspace can assume this role.

πŸ”

Lambda least-privilege IAM

The execution role has exactly two permissions: sns:Publish scoped to the formatted alert topic ARN, and CloudWatch Logs write access scoped to its own log group.

πŸ“€

SNS topic policy scoped to service principal

Only costalerts.amazonaws.com can publish to the raw-anomaly topic. No IAM user, cross-account entity, or other AWS service can inject fake anomaly payloads.

πŸ“

CloudWatch log retention set explicitly

Without an explicit retention_in_days, log groups retain logs indefinitely β€” an unbounded cost. This Lambda’s log group is set to 14 days.

8. Cost & Cleanup

ServicePricingMonthly Cost
Cost Anomaly DetectionFree$0.00
SNS (2 topics)First 1M publishes/month free$0.00
LambdaFirst 1M requests + 400K GB-s free$0.00
CloudWatch LogsFirst 5GB/month free$0.00
HCP TerraformFree ≀500 managed resources$0.00
Total$0.00
Cleanup must go through HCP, not GitHub Actions

This workspace is VCS-connected, which blocks terraform apply/destroy from any CLI or CI context β€” including a GitHub Actions workflow, even with a valid HCP API token. The only working path is the HCP UI:

Cleanup β€” via HCP Terraform UI
Workspace (week-05-dev) β†’ Actions β†’ Start new run β†’ Plan and destroy
β†’ review the plan β†’ Confirm & Apply
HCP Terraform destroy run completed
Destroy run triggered from the HCP UI β€” 12 resources destroyed, $0 remaining

9. References

10. Key Takeaways

  • costalerts.amazonaws.com is the SNS publisher principal for Cost Anomaly Detection β€” wrong principal causes silent failure with no error.
  • The ML baseline needs 24–48 hours on a new monitor. Test with Lambda test events immediately after deploy.
  • Two SNS topics is the right pattern β€” raw topic for AWS-to-Lambda, formatted topic for Lambda-to-human. Gives a clean extension point for Slack or PagerDuty later.
  • HCP dynamic credentials eliminate static AWS keys entirely. 15 minutes of setup, zero ongoing rotation burden.
  • Confirm the SNS email subscription before testing. Messages to unconfirmed subscriptions are silently dropped.
  • VCS-connected HCP workspaces fail in confusing, layered ways β€” GitHub App repo access, OIDC sub case-sensitivity, and stale config ingestion can all produce the exact same generic error. Check "Include submodules on clone" early if a VCS-connected workspace can't find files that genuinely exist in the repo.
  • Cost Anomaly Detection is completely free. The only reason not to have it in every AWS account is not knowing it exists.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent