gsantana.dev
17 min read

Serverless Cost Governance: Keeping Azure Functions and AWS Lambda Bills Predictable

Serverless bills scale with traffic, retries and bugs, not with the capacity you planned, so predictability has to be designed in before the surprise. Cap concurrency, wire budgets to people and automation, enforce cost allocation tags with policy, and watch cost per request like you watch latency.

ServerlessFinOpsAzure FunctionsAWS LambdaTerraform

Serverless has a beautiful pitch: no servers to size, no idle capacity, pay only for what you use. And it is true. The catch is in the last three words. You pay for what you use, including what a bug uses, what a retry storm uses, what a scraper hammering your public endpoint uses, and what a function that accidentally triggers itself uses at 2 a.m. on a Saturday.

With a VM, the worst case is the size of the VM. With serverless, the worst case is whatever the platform is willing to scale to, which is a lot. That is the feature. It is also why serverless cost governance is not about squeezing milliseconds out of your handler. It is about putting guardrails in place before the surprise, so the bill stays boring even when your code does not.

The Problem & Context

How serverless costs actually accrue

The pricing page shows two headline dimensions, and they are the ones everyone models:

  • Invocations. A charge per request (or per execution), regardless of how long it runs.
  • Duration times memory. Execution time multiplied by the memory (and, implicitly, CPU) allocated to the function. Double the memory and each second costs more, although the function may finish faster.

Premium and dedicated hosting options add a third: provisioned or always-ready capacity that you pay for whether it is busy or not. Check the current pricing pages for both providers, because the exact units and rates change and differ per plan and region.

The headline dimensions are rarely what surprises people. The hidden parts are:

  • Logging volume. Every print and every debug log line is ingested and stored by CloudWatch Logs or Application Insights. At high request rates, the log bill can overtake the compute bill.
  • Egress. Data leaving the region or the cloud is charged separately from the function itself.
  • Downstream services. The function is cheap; the database, queue, API gateway and third-party API it calls on every invocation are not necessarily. Serverless scales out effortlessly, and so does the load you push onto everything behind it.
  • Retries. A failed invocation that is retried is billed again. Some event sources retry by default until the message expires.
  • Recursive triggers. A function that writes to the same bucket, queue or table that triggers it can loop forever, and every loop iteration is a billed invocation.
Curious junior dev
Naive Junior
But serverless is pay-per-use, so if nobody uses it, it costs nothing. How could it ever be expensive?

Pay-per-use cuts both ways. "Nobody uses it" is the easy case. The expensive case is when something uses it a lot and that something is not a paying customer. A bot scraping your API is usage. A poison message retried thousands of times is usage. A trigger calling the same function in a loop is usage that never sleeps. Pay-per-use means your bill is a function of your traffic and your bugs, and bugs do not come with a capacity plan.

The classic surprise-bill causes

Most serverless cost incidents follow a small set of patterns:

  1. The self-triggering function. An object-created trigger on a storage bucket, and the function writes its output back to the same bucket. Or a queue consumer that re-enqueues on failure without a counter.
  2. The retry storm. A malformed message that always fails. The event source retries it, the function fails, and without a dead-letter destination or a retry limit, the cycle repeats until the message expires.
  3. Verbose logging at scale. Debug logging turned on "for a moment" in production, logging full request payloads, with retention set to never expire.
  4. Unwanted traffic. A public HTTP endpoint discovered by a scraper, a load test pointed at the wrong environment, or a DDoS. The platform scales to meet it, as designed.
  5. The forgotten environment. A dev or demo environment with a premium plan, always-ready instances or provisioned concurrency, left running for months because nobody owns it.

None of these is an optimization problem. They are control problems, fixed with limits, alerts and ownership, not a faster JSON parser.

Deep Dive / Architectural Design

The control loop

Cost governance for serverless is a feedback loop, not a monthly report. Limits bound the blast radius, telemetry shows what is happening, budgets catch what the limits did not, and alerts reach a person or an automation that can act.

+---------------------------+      +----------------------------+
|  1. LIMITS (preventive)   |      |  2. TELEMETRY (detective)  |
|  concurrency caps         |----->|  invocations, duration     |
|  max instances            |      |  throttles, errors, DLQ    |
|  bounded retries + DLQ    |      |  log volume                |
|  log retention/sampling   |      |  cost per 1,000 requests   |
+-------------^-------------+      +--------------+-------------+
              |                                   |
              |                                   v
+-------------+-------------+      +----------------------------+
|  4. OWNERS + AUTOMATION   |      |  3. BUDGETS (backstop)     |
|  on-call gets paged       |<-----|  forecasted + actual       |
|  runbook: lower caps,     |      |  alerts per tag / scope    |
|  disable trigger, fix bug |      |  action groups / SNS       |
+---------------------------+      +----------------------------+

The loop only works if every arrow is real. A budget alert sent to a mailbox nobody reads is a broken arrow.

Limits: bounding the blast radius

AWS Lambda has two layers of concurrency control. The account concurrency limit is a regional quota shared by every function in the account; when it is exhausted, all functions in that region start throttling. Reserved concurrency on a function both guarantees it a slice of that pool and caps it at that number. A function with reserved concurrency set can never run more instances than the cap, no matter how much traffic arrives. That makes reserved concurrency your primary cost ceiling for Lambda. (Setting it to zero effectively disables the function, which is a useful emergency switch.)

Azure Functions controls scale through the hosting plan:

PlanScaling behaviorCost shapeGovernance lever
ConsumptionEvent-driven scale out, scale to zeroPer execution and resource consumptionConfigurable maximum scale-out limit per app
Flex ConsumptionEvent-driven, faster scale, optional always-ready instancesPer execution plus always-ready baseline if configuredConfigurable maximum instance count, always-ready count
PremiumPre-warmed instances, elastic scale outPays for minimum instances continuouslyMinimum and maximum burst instance limits
Dedicated (App Service)Manual or autoscale rules on fixed instancesPays for the plan, like a VMInstance count and autoscale rules

The important point: every plan lets you set a maximum instance limit, and you should set it deliberately. The platform defaults are generous because they optimize for availability, not for your budget.

Curious junior dev
Naive Junior
Won't a concurrency cap just break the app when real traffic spikes?

It can, which is why the cap is a design decision, not a random number. A cap turns "unbounded cost" into "bounded cost plus throttling", and throttling is a failure mode you can design for: queue-based sources wait and deliver later, synchronous callers get a retryable error. Size the cap from real peak traffic with headroom. An unbounded function is not more available, it just fails later and more expensively, usually by overwhelming the database behind it.

Retries, dead letters and logs

Bound every retry path. For asynchronous Lambda invocations, configure a maximum retry count and maximum event age, and send failures to an on-failure destination or a dead-letter queue. For queue and stream sources, set a maximum receive count or retry attempts and a DLQ, so a poison message is parked after a few attempts instead of billed forever. On Azure, Service Bus moves a message to its dead-letter subqueue after the maximum delivery count, and Functions retry policies should have a finite count. Then alert on DLQ depth: a growing DLQ is a bug report with a timestamp.

Logs deserve their own policy. Set retention on every log group (the default for a new CloudWatch log group is to keep logs forever). Use structured logs at INFO in production and turn on DEBUG per function, temporarily. On Azure, Application Insights supports sampling and daily caps on ingestion; enable sampling for high-volume apps and treat the daily cap as an emergency brake, knowing that hitting it means losing telemetry for the rest of the day.

Budgets wired to people and automation

Azure Cost Management budgets can be scoped to a subscription, a resource group, or filtered by tags, and they alert on actual or forecasted spend crossing thresholds. Notifications can go to email addresses and to action groups, which can page people, call webhooks, or trigger Logic Apps, Functions or runbooks.

AWS Budgets does the same on the AWS side: cost budgets filtered by service, account or tag, with actual or forecasted thresholds. Notifications go to email or an SNS topic, and from SNS you can route to chat, a paging tool or a Lambda. AWS also offers budget actions that can apply an IAM or SCP policy or target specific instances when a threshold is crossed. They are powerful, and they can stop things you did not intend to stop, so start with notifications and add actions only after you know exactly what they touch.

Tags you can actually allocate by

A budget is only as precise as its scope, and the most flexible scope is a tag. Pick a small mandatory set and enforce it:

  • owner: a team or a person who gets the alert.
  • cost-center: where the money is charged.
  • environment: dev, test, prod, so a forgotten dev stack is visible.

On Azure, Azure Policy can deny resource creation without required tags, or append and inherit tags from the resource group. On AWS, tag policies in AWS Organizations standardize tag keys and allowed values, and service control policies can deny certain create actions when a tag is missing in the request, though not every service supports tag conditions on every action, so test before you rely on it. Remember that on AWS, tags must also be activated as cost allocation tags in the billing console before they appear in cost reports and budget filters.

A minimal Azure Policy rule that denies resource groups without a cost-center tag looks like this:

require-cost-center.json (excerpt)
{
  "if": {
    "allOf": [
      { "field": "type", "equals": "Microsoft.Resources/subscriptions/resourceGroups" },
      { "field": "tags['cost-center']", "exists": "false" }
    ]
  },
  "then": { "effect": "deny" }
}

Unit economics: cost per 1,000 requests

Total monthly spend tells you whether you are over budget. It does not tell you whether you are efficient. For that, track a unit cost: cost per 1,000 requests (or per order, per document processed, per active user).

cost per 1,000 requests = (daily cost for tag service=X) / (daily invocations of X) * 1000

The numerator comes from billing data: the AWS Cost and Usage Report (CUR) or Data Exports queried with Athena, and Azure Cost Management exports to a storage account, queried with your analytics tool of choice. The denominator comes from your metrics: Lambda Invocations in CloudWatch or function execution counts in Azure Monitor. Put the result on the same dashboard as p95 latency and error rate. When a deploy doubles cost per request without a traffic change, that is a regression, exactly like a latency regression, and it deserves the same attention.

Hands-On Implementation

Let's build the guardrails as code: a Lambda function with a concurrency cap and a log group with retention, an AWS budget scoped by tag with forecasted and actual alerts to email and SNS, and an Azure resource group with a budget wired to an action group. Required tags are defined once in locals and applied through default_tags on AWS and explicitly on Azure.

versions.tf
terraform {
  required_version = ">= 1.6.0"
 
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0, < 7.0"
    }
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}
 
provider "aws" {
  region = var.aws_region
 
  # Every AWS resource created by this configuration gets the required tags
  default_tags {
    tags = local.required_tags
  }
}
 
provider "azurerm" {
  features {}
  subscription_id = var.azure_subscription_id
}
variables.tf
variable "aws_region" {
  description = "AWS region for the function and its logs"
  type        = string
  default     = "us-east-1"
}
 
variable "azure_subscription_id" {
  description = "Azure subscription that holds the resource group"
  type        = string
}
 
variable "azure_location" {
  description = "Azure region for the resource group"
  type        = string
  default     = "eastus2"
}
 
variable "service_name" {
  description = "Short service name used in resource names"
  type        = string
  default     = "orders-api"
}
 
variable "owner" {
  description = "Team or person accountable for this spend"
  type        = string
}
 
variable "cost_center" {
  description = "Cost center the spend is charged to"
  type        = string
}
 
variable "environment" {
  description = "Deployment environment"
  type        = string
  default     = "dev"
 
  validation {
    condition     = contains(["dev", "test", "prod"], var.environment)
    error_message = "environment must be dev, test or prod."
  }
}
 
variable "lambda_zip_path" {
  description = "Path to the Lambda deployment package (.zip) built by CI"
  type        = string
}
 
variable "lambda_reserved_concurrency" {
  description = "Hard cap on concurrent executions for the function"
  type        = number
  default     = 20
}
 
variable "log_retention_days" {
  description = "CloudWatch log retention in days"
  type        = number
  default     = 14
}
 
variable "monthly_budget_amount" {
  description = "Monthly budget amount, in the billing currency, for both clouds"
  type        = number
}
 
variable "budget_alert_emails" {
  description = "People who receive budget alerts"
  type        = list(string)
}
 
variable "azure_budget_start_date" {
  description = "First day of the budget month, e.g. 2026-05-01T00:00:00Z"
  type        = string
}
 
locals {
  name = "${var.service_name}-${var.environment}"
 
  # One definition of the mandatory tags, used by both clouds
  required_tags = {
    owner         = var.owner
    "cost-center" = var.cost_center
    environment   = var.environment
    service       = var.service_name
  }
}
lambda.tf
data "aws_iam_policy_document" "lambda_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
 
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}
 
resource "aws_iam_role" "lambda" {
  name               = "${local.name}-lambda"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
 
resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
 
# Create the log group ourselves so it has retention instead of "never expire"
resource "aws_cloudwatch_log_group" "lambda" {
  name              = "/aws/lambda/${local.name}"
  retention_in_days = var.log_retention_days
}
 
resource "aws_lambda_function" "api" {
  function_name    = local.name
  role             = aws_iam_role.lambda.arn
  runtime          = "python3.12"
  handler          = "app.handler"
  filename         = var.lambda_zip_path
  source_code_hash = filebase64sha256(var.lambda_zip_path)
  memory_size      = 256
  timeout          = 10
 
  # The cost ceiling: never more than this many concurrent executions
  reserved_concurrent_executions = var.lambda_reserved_concurrency
 
  depends_on = [
    aws_cloudwatch_log_group.lambda,
    aws_iam_role_policy_attachment.lambda_logs,
  ]
}
budgets-aws.tf
resource "aws_sns_topic" "budget_alerts" {
  name = "${local.name}-budget-alerts"
}
 
# Allow AWS Budgets to publish to the topic
data "aws_iam_policy_document" "budget_alerts" {
  statement {
    effect    = "Allow"
    actions   = ["SNS:Publish"]
    resources = [aws_sns_topic.budget_alerts.arn]
 
    principals {
      type        = "Service"
      identifiers = ["budgets.amazonaws.com"]
    }
  }
}
 
resource "aws_sns_topic_policy" "budget_alerts" {
  arn    = aws_sns_topic.budget_alerts.arn
  policy = data.aws_iam_policy_document.budget_alerts.json
}
 
resource "aws_budgets_budget" "service" {
  name         = "${local.name}-monthly"
  budget_type  = "COST"
  limit_amount = tostring(var.monthly_budget_amount)
  limit_unit   = "USD"
  time_unit    = "MONTHLY"
 
  # Only count spend tagged with this cost center (tag must be activated for cost allocation)
  cost_filter {
    name   = "TagKeyValue"
    values = [format("user:cost-center$%s", var.cost_center)]
  }
 
  # Early warning: the forecast says we will cross 80%
  notification {
    comparison_operator        = "GREATER_THAN"
    threshold                  = 80
    threshold_type             = "PERCENTAGE"
    notification_type          = "FORECASTED"
    subscriber_email_addresses = var.budget_alert_emails
    subscriber_sns_topic_arns  = [aws_sns_topic.budget_alerts.arn]
  }
 
  # Backstop: actual spend already crossed 100%
  notification {
    comparison_operator        = "GREATER_THAN"
    threshold                  = 100
    threshold_type             = "PERCENTAGE"
    notification_type          = "ACTUAL"
    subscriber_email_addresses = var.budget_alert_emails
    subscriber_sns_topic_arns  = [aws_sns_topic.budget_alerts.arn]
  }
}
budgets-azure.tf
resource "azurerm_resource_group" "serverless" {
  name     = "rg-${local.name}"
  location = var.azure_location
  tags     = local.required_tags
}
 
resource "azurerm_monitor_action_group" "cost" {
  name                = "ag-${local.name}-cost"
  resource_group_name = azurerm_resource_group.serverless.name
  short_name          = "costalert"
  tags                = local.required_tags
 
  # One email receiver per address; add webhook or Logic App receivers for automation
  dynamic "email_receiver" {
    for_each = var.budget_alert_emails
    content {
      name                    = "email-${email_receiver.key}"
      email_address           = email_receiver.value
      use_common_alert_schema = true
    }
  }
}
 
resource "azurerm_consumption_budget_resource_group" "serverless" {
  name              = "budget-${local.name}"
  resource_group_id = azurerm_resource_group.serverless.id
  amount            = var.monthly_budget_amount
  time_grain        = "Monthly"
 
  time_period {
    start_date = var.azure_budget_start_date
  }
 
  # Early warning on forecast, routed to the action group
  notification {
    enabled        = true
    threshold      = 80
    threshold_type = "Forecasted"
    operator       = "GreaterThan"
    contact_groups = [azurerm_monitor_action_group.cost.id]
  }
 
  # Backstop on actual spend, also emailed directly
  notification {
    enabled        = true
    threshold      = 100
    threshold_type = "Actual"
    operator       = "GreaterThan"
    contact_groups = [azurerm_monitor_action_group.cost.id]
    contact_emails = var.budget_alert_emails
  }
}
outputs.tf
output "lambda_function_name" {
  value = aws_lambda_function.api.function_name
}
 
output "budget_alerts_topic_arn" {
  value = aws_sns_topic.budget_alerts.arn
}
 
output "azure_resource_group_id" {
  value = azurerm_resource_group.serverless.id
}

Subscribe your paging tool to the SNS topic, and add a webhook or Logic App receiver to the action group for automation, such as a runbook that sets Lambda reserved concurrency to zero on a non-production stack.

The Azure budget start_date must be the first day of a month, and the AWS TagKeyValue filter only matches once cost-center is activated as a cost allocation tag. For the Function App itself, set the maximum scale-out in the app configuration (for Flex Consumption, the maximum_instance_count on azurerm_function_app_flex_consumption) in the same pull request that creates it.

Production Reality Check

Budgets are lagging indicators. Billing data arrives hours after usage, sometimes longer. By the time an actual-spend alert fires, the money is gone. That is why the forecasted threshold matters more than the actual one, and why limits come first in the loop. For fast detection, alarm on the leading signals you control directly: invocation rate, concurrent executions, throttles, log ingestion volume and DLQ depth. Those fire in minutes, not in a day.

Curious junior dev
Naive Junior
Can't the budget just shut everything down automatically when we hit the limit?

Budgets on both clouds are alerting tools, not billing caps. Neither provider stops a production workload by default because a budget was crossed, and you would not want it to: an automatic shutdown on a customer-facing system is an outage you scheduled for yourself. Automation is great for dev and test (scale to zero, disable triggers, stop always-ready instances). For production, let the alert page an owner who can decide whether the spend is a bug or a successful product launch.

Caps cause throttling, so plan the degradation. When a function hits its cap, callers need a sane experience: a queue that absorbs the burst, a cached or reduced response, a clear retryable error with backoff. Alarm on throttles so you learn about a too-tight cap before your customers tell you.

Reserved concurrency on Lambda is carved out of the regional account limit. Reserving generously on many functions can starve the unreserved pool that every other function shares. Keep a map of reservations per account and review it when you add functions.

Decide who gets paged. Cost alerts should route like any other production alert: to the team in the owner tag, through the same on-call tooling. A cost spike at 2 a.m. from a recursive trigger is an incident. A slow monthly drift is a ticket. Make the distinction explicit in the alert routing.

Review cost in pull requests. Changes to memory size, timeouts, concurrency caps, log levels, retention and plan types all move the bill. A checklist item ("does this change a scaling or logging setting?") catches most mistakes.

Keep a FinOps cadence. Weekly, the owning team glances at cost per 1,000 requests and anomalies. Monthly, engineering and finance review spend by tag, untagged resources and idle environments. Quarterly, revisit caps and plans against real traffic. If you run on both clouds, map the same intent to each cloud's native controls, as described in Multi-Cloud Without the Pain: Patterns That Survive Contact with Reality.

Serverless cost predictability is not a pricing trick. It is a set of decisions you make up front: how far each function may scale, where failed messages go, how long logs live, who owns each resource, and which number on the dashboard tells you something changed. Make those decisions in code, and the bill stops being a surprise and starts being a metric.

Comments

Questions, corrections, or your own take are all welcome. Sign in with GitHub to join in.