Introduction
Cloud-native infrastructure is not production-ready simply because its workloads are running. It must also be measurable, cost-aware, and configured to avoid paying for unnecessary capacity.
In this mission, I evaluated the resource consumption of my Azure Kubernetes Service (AKS) cluster, examined Kubernetes resource requests and limits, queried the Azure Retail Prices API to estimate compute costs, and implemented workload and node-pool scaling controls. I also removed an externally exposed LoadBalancer Service, tested the AKS stop/start lifecycle, and documented the findings and optimization actions in COST-REPORT.md.
The objective was to connect Kubernetes resource telemetry with Azure pricing and practical cost-control measures, while distinguishing observed utilization from requested capacity, configured limits, and estimated charges.
What This Part Covers
- Measuring node and Pod resource usage.
- Inspecting CPU and memory requests and limits.
- Installing
jqto process Azure pricing data. - Querying the Azure Retail Prices API for the VM size used by the cluster.
- Estimating the monthly compute cost.
- Configuring workload autoscaling with a Horizontal Pod Autoscaler (HPA).
- Enabling cluster autoscaling for the AKS node pool.
- Removing the public LoadBalancer Service.
- Testing AKS stop/start operations.
- Creating and committing a cost report.
1. Measure Actual Resource Utilization
I started with the node-level metrics:
kubectl top nodes
The output showed:
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
aks-nodepool1-33590657-vmss000000 242m 12% 1479Mi 25%
At the time of measurement, the node was using approximately 12% of its reported CPU capacity and 25% of its memory capacity.
These figures provide a snapshot of actual resource consumption. They are useful for identifying whether the node is under pressure or operating with substantial unused capacity, although a single observation does not establish the workload's peak or long-term requirements.
Inspect the Pods consuming CPU
I then inspected the Pods across namespaces, sorted by CPU usage:
kubectl top pods -A --sort-by=cpu | head -15
The output included system components in kube-system and application workloads in k8s-lab, including:
csi-azurefile-nodeazure-cnsmetrics-serverkonnectivity-agentcorednskube-proxy-
webDeployment Pods node-agent-
store-0andstore-1
The displayed CPU consumption of the application Pods was low at the time of measurement. This helped establish that the cluster's workloads were not consuming a substantial share of the node's available CPU during the observed interval.
The distinction matters because a Kubernetes cluster can have a relatively low current workload while still reserving significant capacity through its configured resource requests.
2. Compare Resource Requests, Limits and Actual Usage
To understand how much capacity the workloads had requested or been permitted to use, I inspected the node's allocated resources:
kubectl describe nodes | grep -A 8 "Allocated resources"
The output showed:
Allocated resources:
(Total limits may be over 100 percent, i.e., overcommitted.)
Resource Requests Limits
-------- -------- ------
cpu 1187m (62%) 10692m (562%)
memory 1330Mi (22%) 13443360Ki (226%)
The figures reveal three different aspects of resource management.
| Metric | CPU | Memory |
|---|---|---|
| Observed node utilization | 12% | 25% |
| Allocated requests | 62% | 22% |
| Allocated limits | 562% | 226% |
Actual utilization represents observed consumption at the time of measurement.
Requests represent the capacity declared by workloads for scheduling purposes. CPU requests of 62% indicate that workloads had reserved a significant proportion of the node's allocatable CPU capacity, even though observed CPU utilization was only 12%.
Limits define upper resource boundaries for individual containers. Their aggregated percentages can exceed 100% because Kubernetes permits resource overcommitment. The reported CPU limit of 562% therefore does not mean that the node was actually using more than five times its CPU capacity.
These measurements should not be interpreted interchangeably. Low current utilization can indicate an opportunity to review requests, but reducing them requires an understanding of the workload's peak behavior, performance requirements, and reliability objectives.
Verify the Deployment's resource settings
I inspected the container configuration for the web Deployment:
kubectl get deployment web -n k8s-lab -o json \
| jq '.spec.template.spec.containers[] |
{name: .name, requests: .resources.requests, limits: .resources.limits}'
The result showed:
{
"name": "nginx",
"requests": {
"cpu": "50m",
"memory": "64Mi"
},
"limits": {
"cpu": "200m",
"memory": "128Mi"
}
}
Each NGINX container requested 50m CPU and 64Mi memory, with limits of 200m CPU and 128Mi memory.
With three replicas, those declared requests contribute to the Deployment's aggregate resource reservations. Repeating this comparison across workloads helps identify where requests may be substantially higher than typical consumption or where capacity may be constrained.
The goal is not to minimize requests indiscriminately. It is to align resource allocation with actual workload requirements while preserving sufficient headroom for bursts and normal operating variation.
3. Install jq for Pricing Data Analysis
To process the JSON returned by Azure's pricing endpoint, I installed jq in the Linux environment:
sudo apt-get install -y jq
The package manager installed jq and its required dependencies successfully.
This utility made it easier to filter the Azure Retail Prices API response and extract fields such as the product name, meter, price, unit of measure, region, and price type.
4. Retrieve VM Pricing from the Azure Retail Prices API
The cluster configuration identified the following environment values:
RESOURCE_GROUP=rg-aks-lab
CLUSTER_NAME=aks-lab
LOCATION=westeurope
NODE_SIZE=Standard_D2s_v3
I queried the Azure Retail Prices API for virtual machine pricing matching the region and VM size:
curl -sG "https://prices.azure.com/api/retail/prices" \
--data-urlencode "\$filter=serviceName eq 'Virtual Machines' and armRegionName eq '$LOCATION' and armSkuName eq '$NODE_SIZE' and priceType eq 'Consumption'" \
| jq -r '.Items[]
| select(.meterName | test("Spot|Low Priority"; "i") | not)
| [.productName, .skuName, .meterName, .retailPrice, .unitOfMeasure, .armRegionName, .priceType]
| @tsv'
The response contained multiple pricing records for the requested VM SKU. The available records included different operating-system products and pricing types, so the SKU alone was not sufficient to select the correct rate.
I compared the returned fields to distinguish the applicable consumption rate from Windows-specific, DevTest, Spot, and Low Priority entries.
For the cost report, I used an observed public retail consumption rate of $0.12 per hour for the Standard_D2s_v3 VM in westeurope.
Estimate the monthly VM compute cost
Using a simplified month of 730 hours, the calculation was:
[
\text{Estimated monthly cost} =
\text{Hourly rate} \times 730
]
[
\$0.12 \times 730 = \$87.60
]
The resulting estimate was:
| Item | Estimate |
|---|---|
| VM size | Standard_D2s_v3 |
| Region | westeurope |
| Hourly rate used | $0.12 |
| Assumed monthly hours | 730 |
| Estimated monthly VM compute cost | $87.60 |
This is a price-based estimate, not a confirmed Azure invoice amount. The calculation covers the VM rate used in the report and excludes additional AKS and Azure resource charges. Actual billing can also depend on the applicable pricing record, subscription or purchasing terms, usage duration, and associated resources.
For that reason, the estimate provides a useful baseline for cost planning, but it should not be presented as the complete operating cost of the cluster.
5. Identify and Remove Unnecessary Public Exposure
I reviewed the Services across namespaces:
kubectl get services -A
The output showed the internal web-svc ClusterIP Service, the headless store Service, and the web-public LoadBalancer Service.
The public Service was exposing NGINX using this external IP:
20.61.154.190
I inspected its configuration and endpoints:
kubectl describe service web-public -n k8s-lab
The Service used the selector app=web, exposed port 80, and routed traffic to the NGINX Pods. Its type was LoadBalancer, and the external IP had been assigned.
The lab no longer required this public endpoint for its remaining work, so I removed the Service:
kubectl delete service web-public -n k8s-lab
Kubernetes confirmed:
service "web-public" deleted from k8s-lab namespace
Removing the Service eliminated that Kubernetes-managed public exposure. The internal Service remained available for cluster-internal access.
This action also removed the LoadBalancer Service configuration that was no longer needed by the lab. Deleting the Service should not be treated as proof that every associated Azure charge disappears immediately; the resulting Azure resources and billing records still need to be considered when validating actual savings.
6. Configure Horizontal Pod Autoscaling
The web Deployment had been configured with three replicas. Rather than permanently fixing it at that count, I created a Horizontal Pod Autoscaler (HPA) to allow Kubernetes to adjust the number of Pods based on CPU utilization.
I configured the HPA as follows:
kubectl autoscale deployment web -n k8s-lab \
--cpu-percent=70 \
--min=1 \
--max=5
Kubernetes returned:
horizontalpodautoscaler.autoscaling/web autoscaled
The command displayed a deprecation notice for --cpu-percent, recommending the newer --cpu option with a percentage or resource quantity format.
I verified the resulting HPA:
kubectl get hpa web -n k8s-lab
The output showed:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
web Deployment/web cpu: 2%/70% 1 5 3
At the time of the check, the observed CPU utilization was approximately 2% against a 70% target. The HPA was configured to maintain between one and five replicas, with three replicas currently reported.
This configuration allows the replica count to respond to CPU utilization rather than requiring manual scaling for every workload fluctuation.
The HPA does not guarantee that replicas immediately scale down whenever usage is low, nor does it remove the need for appropriate resource requests and metrics. It evaluates workload metrics against its configuration and adjusts the desired replica count as conditions warrant.
7. Enable AKS Node-Pool Autoscaling
Pod autoscaling changes the number of application replicas, but it does not automatically increase the number of virtual machines in the cluster unless cluster-level scaling is configured.
I enabled the cluster autoscaler for nodepool1, allowing the node count to vary between one and three:
az aks nodepool update \
--resource-group "$RESOURCE_GROUP" \
--cluster-name "$CLUSTER_NAME" \
--name nodepool1 \
--enable-cluster-autoscaler \
--min-count 1 \
--max-count 3
The command updated the node-pool configuration. Azure CLI also displayed a notice that the command behavior had been altered by the aks-preview extension.
I then queried the effective settings:
az aks nodepool show \
--resource-group "$RESOURCE_GROUP" \
--cluster-name "$CLUSTER_NAME" \
--name nodepool1 \
--query "{Pool:name,Count:count,Autoscaling:enableAutoScaling,Min:minCount,Max:maxCount}" \
--output table
The output showed:
Pool Count Autoscaling Min Max
nodepool1 1 True 1 3
This verified that cluster autoscaling was enabled and that the configured minimum and maximum node counts were one and three.
The node pool was currently at one node. With autoscaling enabled, Kubernetes can request additional node capacity when scheduling demand requires it, subject to the configured limits and Azure's ability to provision the capacity.
HPA and cluster autoscaling serve different purposes
These settings control separate layers of the environment:
- Horizontal Pod Autoscaler: adjusts the number of application Pods.
- Cluster autoscaler: adjusts the number of AKS nodes when cluster capacity needs to change.
The HPA was configured for one to five web replicas, while the node pool was configured for one to three nodes. The two mechanisms work at different levels and should be configured together with appropriate resource requests, scheduling requirements, and capacity limits.
The maximum node count is a limit, not a target. Enabling autoscaling does not mean the cluster will remain at three nodes.
8. Test the AKS Stop/Start Lifecycle
I also exercised the AKS cluster's stop/start commands.
To stop the cluster, I ran:
az aks stop \
--resource-group "$RESOURCE_GROUP" \
--name "$CLUSTER_NAME"
I then started it again:
az aks start \
--resource-group "$RESOURCE_GROUP" \
--name "$CLUSTER_NAME"
Finally, I checked its reported power state:
az aks show \
--resource-group "$RESOURCE_GROUP" \
--name "$CLUSTER_NAME" \
--query powerState.code \
--output tsv
The resulting state was:
Running
This confirmed that the cluster was running after the stop/start sequence.
For a lab or development environment that is not needed continuously, stopping the cluster during idle periods can be part of a cost-control strategy. However, the cluster must first be checked for active workloads, scheduled operations, dependencies, and service availability requirements.
The stop/start exercise demonstrates the control operation; it is not, on its own, a measured comparison of the before-and-after Azure bill. Actual savings should be verified against the applicable billing records and the resources that continue to incur charges.
9. Document the Findings in COST-REPORT.md
To capture the analysis and decisions, I created COST-REPORT.md in the project repository:
cat << 'EOF' > COST-REPORT.md
# AKS FinOps Cost Report
## Cluster Configuration
- Resource Group: rg-aks-lab
- Cluster: aks-lab
- Region: westeurope
- Node Pool: nodepool1
- VM Size: Standard_D2s_v3
- Node Count: 1
## Resource Utilization
- Node CPU usage: 12%
- Node memory usage: 25%
- CPU requests: 62%
- Memory requests: 22%
## Cost Estimate
- Estimated VM rate: $0.12/hour
- Estimated monthly VM cost (730 hours): $87.60
- Estimate excludes additional AKS and Azure resource charges.
## Cost Optimization Actions
- Configured web deployment autoscaling between 1 and 5 replicas.
- Enabled cluster autoscaling for nodepool1 between 1 and 3 nodes.
- Reduced the node pool to 1 node.
- Removed the web-public LoadBalancer service.
- Stopped the AKS cluster when not in use.
## Notes
The VM price is a public retail-price estimate, not a confirmed invoice amount. Actual costs depend on billing rates and associated Azure resources.
EOF
The report consolidated the cluster configuration, observed utilization, VM price estimate, and optimization actions into a version-controlled artifact.
A concise cost report is useful because it distinguishes the measurements from the actions taken and documents what remains outside the estimate. In this case, it explicitly records that the $87.60 figure excludes additional AKS and Azure resource charges.
10. Commit the Cost Report
I checked the repository state:
git status
The report appeared as an untracked file. I staged it:
git add COST-REPORT.md
Then I committed it:
git commit -m "docs: AKS cost review and optimisation"
Git confirmed the commit:
[main 2725349] docs: AKS cost review and optimisation
1 file changed, 30 insertions(+)
create mode 100644 COST-REPORT.md
This committed the cost report to the main branch, preserving the analysis and optimization decisions alongside the lab's other project documentation.
11. Summary of Cost Optimization Decisions
The main findings and actions were:
| Area | Finding or action | Operational purpose |
|---|---|---|
| Node utilization | 12% CPU and 25% memory usage observed | Establish a snapshot of actual resource consumption |
| Resource requests | CPU requests at 62%; memory requests at 22% | Identify declared capacity reservations for further review |
| Resource limits | Aggregated CPU and memory limits above 100% | Understand configured overcommitment rather than actual usage |
| VM pricing | $0.12/hour used for the estimate | Establish a public retail-price baseline |
| Monthly estimate | $87.60 for 730 hours of VM compute | Quantify an illustrative compute cost |
| Workload scaling | HPA configured for one to five replicas | Match the web replica count to CPU demand |
| Node scaling | Cluster autoscaler configured for one to three nodes | Allow node capacity to adjust to scheduling demand |
| Public networking |
web-public Service deleted |
Remove an external endpoint no longer required by the lab |
| Cluster lifecycle | Stop/start operations tested | Establish an option for managing idle lab capacity |
| Documentation |
COST-REPORT.md committed |
Preserve the findings and optimization actions |
These actions address different aspects of cost management. Autoscaling controls capacity, removing an unnecessary Service eliminates unneeded exposure, resource measurements inform future tuning, and stopping an idle cluster provides a lifecycle control. The retail-price estimate supports planning but does not replace actual cost analysis.
Conclusion
This FinOps review combined Kubernetes telemetry with Azure pricing data to establish a practical baseline for the AKS lab.
The observed node utilization was 12% CPU and 25% memory, while resource requests and limits revealed how declared capacity differed from current consumption. A public VM consumption rate of $0.12 per hour was used to estimate $87.60 for 730 hours of VM compute, excluding additional AKS and Azure resource charges.
I configured the web HPA to scale between one and five replicas, enabled node-pool autoscaling between one and three nodes, removed the public web-public Service, tested the AKS stop/start workflow, and committed the findings in COST-REPORT.md.
The key principle is that cost optimization should be based on measured usage, explicit assumptions, and deliberate trade-offs. Reducing infrastructure cost is valuable only when the resulting capacity and availability remain appropriate for the workload.
In Part 9, the series will conclude with the final mission, bringing the lab's implementation, documentation, and cleanup together.
This article is Part 8 of the nine-part series, “Kubernetes on AKS: From Zero to Production Workloads.”















Top comments (1)
Cloud cost optimization is not simply about spending less, it is about understanding what resources are actually being used, identifying waste, and making informed trade-offs without compromising reliability. This mission connects Kubernetes metrics, Azure retail pricing, workload autoscaling, and node-pool scaling to a practical FinOps approach. Documenting the findings in
COST-REPORT.mdalso makes the decisions transparent, measurable, and easier to review. Efficient infrastructure starts with visibility.