Since K3, you know what the Kladde costs in Azure, roughly. You made an estimate in the pricing calculator and created a budget that warns before the bill surprises you. That is operations. This lesson turns it into architecture: Cost is a quality goal like reliability and security (Q5 in your quality goals), and almost every decision in the following modules moves it. Two replicas instead of one, a topic instead of a queue, private endpoints, one instance per office or one for all: Those are cost decisions, even if they aren't called that.

At the end of the lesson, docs/cost.md is in the Kladde, with an estimate, the measured cost, an explained deviation and the metric “cost per office”, plus a budget per environment in Bicep. This lesson contains no amounts: The numbers in the examples are invented and have no currency. You read your real numbers off the pricing calculator and Cost Management and write them into your own repository.

FinOps in brief

FinOps is the practice of taking joint responsibility for cloud cost instead of leaving it to accounting at the end of the month. The FinOps Framework of the FinOps Foundation describes it, and Microsoft's FinOps guide builds on it. Six principles come first, here in our own words:

  1. Teams work together. Development, operations, the business side and whoever pays talk about the same numbers.
  2. Business value drives technology decisions. Cheap isn't a goal in itself; what counts is what an expense brings.
  3. Everyone takes ownership of their cloud usage. The team that creates a service also looks at its bill.
  4. Data is accessible, timely and accurate. Whoever has to decide needs the numbers soon and in the right form.
  5. FinOps is enabled centrally. Common tools, rules and reports, so that not every team reinvents everything.
  6. Take advantage of the variable cost model of the cloud. Many small adjustments instead of one big plan per year.

Plus a cycle of three phases that repeats over and over:

  • Inform (make visible): Who consumes what, what does it cost, what will it cost? Allocation, budget, forecast.
  • Optimize (improve): Find waste and stop it, make the architecture cheaper.
  • Operate (run): Set metrics and rules, watch whether they are kept, and react.

Of the capabilities the framework describes, the Kladde now needs four: planning and estimating, budgeting, cost per unit (unit economics) and detecting anomalies. A fifth is called “Architecting for the cloud”, and it is the reason this lesson is in the architecture module.

What the Kladde's cost depends on

Before you estimate, you have to know what each service bills by. That determines which architecture decision makes it more expensive or cheaper:

Service in the Kladde bills by what the architecture changes about it
Azure Container Apps, Consumption plan processor and memory per second that replicas run, plus requests from outside; a monthly free grant per subscription Scaling to zero costs nothing as long as no replica runs; a minimum replica always runs, at a lower rate when idle
Container Apps jobs processor and memory while an execution runs a job that runs ten minutes at night costs only those ten minutes
Azure SQL Database depending on the offer: free offer, by consumption (serverless) or fixed size one database per office instead of one for all multiplies this item
Service Bus Basic by operations; Standard with a base charge per hour plus operations; topics only from Standard a topic instead of a queue raises the tier
Log Analytics and Application Insights ingested data volume every log line at level “Information” in a loop costs; sampling lowers the volume
Blob Storage stored amount, operations, access tier raw data from TED that is never deleted grows every month
Container Registry a fixed rate per day by tier (Basic, Standard, Premium), plus storage above what is included one registry for all environments instead of one per environment
Key Vault operations reading secrets on every call instead of at startup

Microsoft's page on billing for Container Apps describes this in more detail. Three things from it hit the Kladde directly: A revision with zero replicas costs nothing; if a revision runs more replicas than its minimum, all its replicas pay the full rate; and jobs are always billed at the full rate, but only while they run.

The right-hand column is the core of this lesson. Cost doesn't arise in the portal, but in decisions like “at least two replicas” (for Q4: deliver without losing a booking) or “a topic for the tenders' events” (K3). That's why every ADR that changes resources belongs a sentence about cost under “Trade-offs”.

Estimating with the pricing calculator

The Azure pricing calculator calculates a monthly amount from assumptions: region, tier, runtime, count, data volumes. The number at the end is only as good as the assumptions, and that's why the assumptions belong in your cost calculation, not just the result:

  • Runtime: How many hours a month does each replica run? Two replicas of the web application around the clock are something different from a worker that works half an hour every night.
  • Volume: How many requests, how many messages, how many gigabytes of logs a month? Take the measured values from Application Insights, not a feeling.
  • Tiers: Which tier does each service have, and why? The answer is often in an ADR.
  • Free grants: What does the free offer include, and does it apply per subscription or per resource? A free grant per subscription is shared by all environments and all projects in it.

Save the estimate in the pricing calculator and put the link, together with a table of the assumptions, into your repository. In K3 you did that in docs/costs.md; this file is now your starting point.

Measuring with Cost Management

What the Kladde really costs is shown by cost analysis in Cost Management. For the comparison with the estimate, you set it up like this:

  • Scope: the resource group of the prod environment, not the whole subscription. Otherwise your exercises from other courses are mixed in.
  • Period: the last full month. The current month is an estimate by Azure that is still changing.
  • Group by service name, so that the rows match your estimate.
  • Actual cost for the comparison with the invoice; the amortized view spreads upfront payments such as reservations over the months. The Kladde has none, so both views show the same here.

You should know two peculiarities before you compare numbers. Cost data arrives with a delay: For Enterprise Agreement and Microsoft Customer Agreement accounts, usually within 8 to 24 hours; for pay-as-you-go subscriptions it can take up to 72 hours. And a month is only closed a few days after it ends. Whoever compares on the first of the month compares with incomplete numbers.

For a regular comparison without clicking, set up an export in Cost Management that writes the cost daily or monthly into a storage account. The open format FOCUS (FinOps Open Cost and Usage Specification) has fixed column names, such as ServiceName, EffectiveCost and BilledCost, which are the same across all providers. For this lesson, it is enough to read the values off cost analysis.

Comparing estimate and measurement

A comparison shows two things: whether the Kladde is more expensive than planned, and where your idea of the system was wrong. The second is the more valuable. A small tool makes the comparison repeatable. It reads a CSV file with three columns and sorts by the biggest deviation. Put it next to tools/deps.cs as tools/cost.cs:

C#
// tools/cost.cs: compares estimate and measurement per service and calculates the cost per office and per booking.
// Call it in the repository's root folder: dotnet run tools/cost.cs -- docs/cost.csv <offices> <bookings per month>
#:property PublishAot=false
using System.Globalization;

// Numbers with a period, in the file as in the output, the same on every computer.
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

if (args.Length != 3 || !int.TryParse(args[1], out var offices) || !int.TryParse(args[2], out var bookings) || offices < 1 || bookings < 1)
{
    Console.Error.WriteLine("Usage: dotnet run tools/cost.cs -- <csv> <offices> <bookings per month>");
    return 2;
}

var rows = File.ReadLines(args[0])
    .Skip(1)
    .Where(line => line.Trim().Length > 0)
    .Select(line => line.Split(';'))
    .Select(parts => new Row(parts[0], decimal.Parse(parts[1]), decimal.Parse(parts[2])))
    .ToList();

Console.WriteLine($"{"Service",-24}{"estimated",11}{"measured",11}{"difference",12}");
foreach (var row in rows.OrderByDescending(row => Math.Abs(row.Measured - row.Estimated)))
{
    Console.WriteLine($"{row.Service,-24}{row.Estimated,11:F2}{row.Measured,11:F2}{row.Measured - row.Estimated,12:+0.00;-0.00;0.00}");
}

var estimated = rows.Sum(row => row.Estimated);
var measured = rows.Sum(row => row.Measured);
Console.WriteLine($"{"Total",-24}{estimated,11:F2}{measured,11:F2}{measured - estimated,12:+0.00;-0.00;0.00}");
Console.WriteLine();
Console.WriteLine($"Cost per office:  {measured / offices:F2}");
Console.WriteLine($"Cost per booking: {measured / bookings:F4}");
return 0;

sealed record Row(string Service, decimal Estimated, decimal Measured);
  • decimal instead of double: You don't calculate money in binary fractions. You know that from K1.
  • CultureInfo.InvariantCulture makes sure that 12.40 in the file is read as twelve point four, on a German computer just as in the codespace, and that the output looks the same everywhere.
  • Exit code 2 on a wrong call follows the habit of command-line tools: 0 means success, anything else doesn't.

The file docs/cost.csv with invented numbers to try it out:

Service;Estimated;Measured
Container Apps;12.00;7.40
Azure SQL Database;0.00;0.00
Service Bus;9.00;9.40
Log Analytics;3.00;14.60
Container Registry;4.50;4.60
Storage;0.50;0.70
Key Vault;0.10;0.20

With one office and 420 bookings a month:

PowerShell
dotnet run tools/cost.cs -- docs/cost.csv 1 420
Service                   estimated   measured  difference
Log Analytics                  3.00      14.60      +11.60
Container Apps                12.00       7.40       -4.60
Service Bus                    9.00       9.40       +0.40
Storage                        0.50       0.70       +0.20
Container Registry             4.50       4.60       +0.10
Key Vault                      0.10       0.20       +0.10
Azure SQL Database             0.00       0.00        0.00
Total                         29.10      36.90       +7.80

Cost per office:  36.90
Cost per booking: 0.0879

What the invented numbers tell is often exactly how it goes in real systems:

  • Log Analytics far above the estimate. The estimate assumed a certain amount of logs, but the Kladde writes every SQL query at level “Information”, and the worker logs every message twice. That is a finding for the architecture: Which logs does operations really need, and which sample is enough?
  • Container Apps below the estimate. The estimate assumed replicas around the clock. But the worker scales to zero and runs only at night, and the monthly free grant covers a part.
  • The other items match. That is also an insight: Where base charges dominate, the estimate is easy.

A deviation is not a mistake of the estimate, but information. Write down the reason for the biggest one and whether the architecture should change because of it. “Logs at warning, sampling for traces” is a decision with consequences for troubleshooting, so a trade-off between cost and operations.

Cost per unit

A monthly amount alone says little. Is it high? Compared to what? The FinOps capability unit economics converts the cost into a unit that counts for the business: per sale in a shop, per user in an app. For the Kladde there are two obvious units:

  • Cost per office and month. This is the metric that matters from the final project on, when several offices use the same Kladde: What does one more office cost? With one office, it equals the total cost. Its course with two, ten, thirty offices helps decide in module 4.7 on the tenant model: one instance per office or one for all.
  • Cost per booking. It shows whether cost grows with usage or is fixed. If the cost stays the same when bookings double, fixed items dominate.

Your telemetry supplies the denominator. Since K3, the Kladde counts every booking with kladde.bookings.created. A query for the bookings per month, filed as usual under ops/queries/:

kql
// ops/queries/bookings-per-month.kql: bookings created per month from the Kladde's own counter
AppMetrics
| where TimeGenerated > ago(90d)
| where Name == "kladde.bookings.created"
| summarize Bookings = sum(Sum) by Month = startofmonth(TimeGenerated)
| order by Month asc

As always, first check whether the filter finds anything: AppMetrics | summarize count() by Name.

You have to answer two questions for every metric before it is worth anything:

  • What goes into the numerator? Only the prod environment or dev too? Development cost is real, but it doesn't grow with the offices. Calculate it separately and name both numbers.
  • How are shared costs distributed? As long as there is one office, the question doesn't arise. Later there are two ways: equally per office, or by usage (share of bookings, requests, storage). Microsoft's guide on unit economics recommends allocating the cost of shared infrastructure using usage data. What can't be allocated that way, you keep separately as overhead and keep an eye on.

Budget and alerts are part of the architecture

A budget in Azure stops nothing. It compares the cost with an amount once a day and sends a notification when a threshold is reached, usually within an hour after the check. You learned that in K3. Three things count for the architecture:

  • One budget per environment, not just one per subscription. Then the alert shows where the money goes, and an outlier in dev doesn't hide one in prod.
  • The amount comes from docs/cost.md. If the architecture changes and with it the estimate, the budget changes in the same pull request.
  • An alert on the forecast. It comes when Azure predicts that the month will exceed the budget, that is, days before it happens.

In Bicep, as a module for a resource group:

bicep
// infra/budget.bicep: monthly budget of one of the Kladde's resource groups with alerts
targetScope = 'resourceGroup'

@description('Monthly amount in the currency of the subscription, from docs/cost.md.')
@minValue(1)
param amount int

@description('Who receives the alerts.')
@minLength(1)
param contactEmails array

@description('First day of the current month; a budget must not start in the past.')
param startDate string = '${utcNow('yyyy-MM')}-01'

var thresholds = [
  { name: 'actual50', threshold: 50, type: 'Actual' }
  { name: 'actual80', threshold: 80, type: 'Actual' }
  { name: 'actual100', threshold: 100, type: 'Actual' }
  { name: 'forecast100', threshold: 100, type: 'Forecasted' }
]

resource budget 'Microsoft.Consumption/budgets@2024-08-01' = {
  name: 'budget-${resourceGroup().name}'
  properties: {
    category: 'Cost'
    amount: amount
    timeGrain: 'Monthly'
    timePeriod: {
      startDate: startDate
    }
    notifications: toObject(thresholds, item => item.name, item => {
      enabled: true
      operator: 'GreaterThanOrEqualTo'
      threshold: item.threshold
      thresholdType: item.type
      contactEmails: contactEmails
      locale: 'en-us'
    })
  }
}

output budgetName string = budget.name
  • utcNow may only appear as the default value of a parameter. The parameter supplies the first of the current month, as a budget requires.
  • toObject turns the list of thresholds into the object that notifications expects, with the name as the key. That keeps each threshold one line.
  • locale: 'en-us' has the alerts sent in English.
  • A budget allows at most five notifications; here there are four.

The values per environment live in a parameter file:

bicep
// infra/budget.prod.bicepparam: the budget of the prod environment
using 'budget.bicep'

param amount = 50 // Your monthly amount from docs/cost.md, whole number
param contactEmails = [
  'operations@example.com'
]

Check both files first without Azure, then with what-if against your resource group:

PowerShell
az bicep build --file infra/budget.bicep
az bicep build-params --file infra/budget.prod.bicepparam
az deployment group what-if --resource-group rg-kladde-prod --parameters infra/budget.prod.bicepparam

A budget itself costs nothing. When your pipeline from K3 rolls out the infrastructure, include the module there instead of rolling it out by hand.

Two additions that need no line of Bicep: In cost analysis, you can switch on an anomaly alert for the subscription that reports an unusual jump in daily cost; it exists only for subscriptions. And the pull request template gets an item: “Does what runs, or how much of it, change? Then check docs/cost.md and the budget.”

Exercise 1

Estimate and measure the monthly cost of your Kladde's prod environment:

  1. Update the estimate from K3 in the pricing calculator so that it matches today's Kladde (job and worker, Service Bus, replicas). Write down the assumptions.
  2. In cost analysis, read off the cost of the last full month per service, scope: the resource group of prod.
  3. Enter both into docs/cost.csv and have tools/cost.cs compare them.
  4. Explain the biggest deviation in two to four sentences, and say whether it suggests a change to the architecture.

If your Kladde isn't deployed in Azure right now because you cleaned up after K3, take the last month in which it ran, or only estimate and catch up on the measurement after the next deploy.

How to check your result: dotnet run tools/cost.cs -- docs/cost.csv 1 <bookings> ends with exit code 0, the sum of the measured column matches the sum in cost analysis (to rounding), and every assumption of the estimate has a source.

Solution 1

The structure of docs/cost.md. Your numbers live in your repository; here are only the places where they belong.

markdown
# Cost of the Kladde

Scope: resource group rg-kladde-prod. Currency: that of the subscription. As of: month of the measurement (see Git history).
Before: docs/costs.md from K3; this file replaces it.

## Estimate

Pricing calculator: (link to the saved estimate)

| Service | Tier and amount | Assumption | Source of the assumption |
| --- | --- | --- | --- |
| Container Apps, web | 2 replicas, 0.5 vCPU, 1 GiB, around the clock | minimum replicas always run | infra/web.bicep, Q4 |
| Container Apps, worker | 0 to 5 replicas, about 30 minutes per night | scales to zero | infra/worker.bicep, measurement of the run time |
| Container Apps job tender-fetch | one execution per night, about 10 minutes | | execution history of the job |
| Azure SQL Database | free offer | consumption below the grant | Cost Management, previous month |
| Service Bus | Basic | only the queue tender-import; Standard only when the topic tender-events from K3 also runs in Azure | infra/modules/messaging.bicep, ADR 0005 |
| Log Analytics | (GB per month) | | Application Insights, usage and estimated cost |
| Container Registry | Basic | one registry for all environments | infra/registry.bicep |
| Storage, Key Vault | (amount) | | |

## Measurement

Cost analysis, last full month, actual cost, grouped by service name. The numbers are in docs/cost.csv; the comparison:
dotnet run tools/cost.cs -- docs/cost.csv 1 (bookings of the month).

## Biggest deviation

Log Analytics was at a multiple of the estimate. Cause: EF Core writes every query at level Information, the worker logs every
message on receipt and on completion. Consequence: log level for Microsoft.EntityFrameworkCore to Warning, one log line per message.
An ADR isn't needed, the change is easy to reverse; the reason is in the pull request.

## Metric

(see exercise 2)

If your biggest deviation is somewhere else, that isn't a mistake. Other common ones: traffic out of the region, a forgotten storage account from an exercise in the same resource group, the registry in a higher tier than thought, or the dev environment, which got counted in the same scope. What matters is that the explanation names a cause that can be checked, not just “more than thought”.

Exercise 2

Define the metric “cost per office” so precisely that two people independently calculate the same number. Answer in a table: What is the numerator, what the denominator, which period, which source for both, who calculates it when, what is the target and what happens if it is missed? Also define how shared costs are distributed as soon as there is more than one office. Enter the result under “Metric” in docs/cost.md and point to it from Q5 of your quality goals.

How to check your result: Give the definition and a month's raw data to a second person. Does she arrive at the same number as you? If not, a decision is missing in the definition.

Solution 2

markdown
## Metric: cost per office

| Question | Definition |
| --- | --- |
| Numerator | actual cost of the resource group rg-kladde-prod in the month, according to Cost Management |
| Denominator | offices that had at least one sign-in in the month (today: 1) |
| Period | calendar month, calculated on the 6th of the following month, when the numbers are closed |
| Not included | dev environment, pipeline, domains; they appear below as overhead, separately |
| Shared costs from two offices | by share of the month's bookings; what can't be allocated, equally per office |
| Who, when | the person on operations duty, monthly, with tools/cost.cs |
| Target | at most the budget amount per office; with the second office the number falls |
| If missed | cause in docs/cost.md, for a cause in the architecture an issue or an ADR |
| Second number | cost per booking, from the same numerator and kladde.bookings.created (ops/queries/bookings-per-month.kql) |

The denominator “offices with at least one sign-in” is a decision, not a matter of course: An office that was created but never works would otherwise flatter the metric. The same goes for the cutoff date on the 6th: It follows from the delay of the cost data and the closing of the month. Your definitions may look different if they are justified.

Exercise 3

Describe the budget per environment in Bicep: infra/budget.bicep as above and one parameter file each for dev and prod. The amount for prod comes from your cost calculation. Build both with az bicep build and az bicep build-params, and check them with what-if against the prod resource group. Rolling out is optional; if you do, do it through your pipeline.

How to check your result: Both commands report no error and no warning about your files (a notice from the Azure CLI about a newer Bicep version doesn't count). What-if shows a new budget with four notifications, and no other resource changes.

Solution 3

The parameter file for dev with a smaller amount and the same recipients:

bicep
// infra/budget.dev.bicepparam: the budget of the dev environment
using 'budget.bicep'

param amount = 10 // Your monthly amount for dev, whole number
param contactEmails = [
  'operations@example.com'
]

What-if against a resource group with the matching name:

PowerShell
az deployment group what-if --resource-group rg-kladde-dev --parameters infra/budget.dev.bicepparam

Three things that often stand out when building it in:

  • What-if reports an error about the start date. If you wrote startDate fixed into the parameter file, it lies in the past after a month. Leave the default value in place.
  • The budget from K3 already exists. It applies to the subscription and stays. The new budgets per resource group are added; they are named differently and replace nothing.
  • Who gets the alert? An address somebody reads. A mailbox nobody opens is the same as no budget. Besides the addresses, contactGroups can name an action group from K3 that is already responsible for your alerts.

The Kladde after this lesson

In one pull request:

  • docs/cost.md with estimate, assumptions, measurement, biggest deviation and the metric “cost per office”; docs/costs.md points to it.
  • docs/cost.csv and tools/cost.cs.
  • ops/queries/bookings-per-month.kql.
  • infra/budget.bicep with the parameter files for dev and prod.
  • In docs/quality-goals.md, Q5 points to the metric.

With that, module 4.1 is complete in the Kladde: inventory, quality goals, ADR log, C4 model and cost calculation. Every ADR that from now on changes resources names the cost under “Trade-offs”, and every change to what runs checks docs/cost.md and the budget.

What you can do now

  • You explain the principles of FinOps and the cycle Inform, Optimize, Operate on your own system.
  • You know what the Kladde's services bill by, and you recognize which architecture decision moves which item.
  • You estimate with the pricing calculator and record the assumptions with their sources.
  • You read the cost in Cost Management in the right scope and period and explain the biggest deviation from the estimate.
  • You define cost per office and per booking so that two people calculate the same number.
  • You describe budgets with alerts on actual and forecast per environment in Bicep and check them with what-if.

Further reading