Contoso Airlines' most awkward component lives on vm-motor-disponibilidad-dev. The availability engine works out, for an origin, a destination and a date, which seats are still free and at what fare; it is a small, stateless service that the Availability API calls thousands of times a day. But it was deployed by hand onto a virtual machine that Marta Ríos patches every month, that nobody knows how to recreate from scratch and whose runtime version nobody dares touch because "it works". In production the problem was papered over by multiplying instances in vmss-api-disponibilidad-pro, which does not solve it: it only multiplies it.
This lesson turns that engine into a container: a self-contained unit that includes the code and everything it needs in order to run, that is built once, stored with a version in a registry and runs identically on Diego Salas' laptop, on the pipeline agent and in production. You will then publish it to Azure Container Registry and take it to Azure Container Apps, the option that gives Contoso most of the value of orchestration without the cost of operating a cluster.
Contents
- Container versus virtual machine
- Image, layers, container and registry
- The availability engine's
Dockerfile - Building and testing locally
- Azure Container Registry
- The Azure container services landscape
- Azure Container Apps in detail
- Deploying
ca-motor-disponibilidad - Azure Container Instances for one-off tasks
- Common Mistakes and Tips
- Exercises
- Conclusion
- Container versus virtual machine
A virtual machine virtualizes hardware: each one carries its own complete operating system. A container virtualizes the operating system: it shares the host's kernel and isolates only the file system, the network and the processes. The difference in weight is not a technical detail, it is what changes the way you work.
| Virtual machine | Container | |
|---|---|---|
| Contains | Full OS + application | Application + its dependencies |
| Typical size | 10-30 GB | 80-300 MB |
| Startup | Minutes | Seconds or less |
| OS patching | Yours, monthly, in place | You rebuild the image and redeploy |
| Density per server | Dozens | Hundreds |
| Isolation | Strong (hypervisor) | Good (shared kernel) |
| "Works on my machine" | Still happens | Over: the environment travels inside |
The availability engine is the perfect candidate for four concrete reasons: it is stateless (every request is resolved with the database and a rebuildable cache), it starts fast, its load is very uneven — spikes when seats go on sale, almost nothing in the small hours — and its problematic dependency is the runtime, which is exactly what a container freezes. What you would not migrate this light-heartedly are stateful databases or the legacy crew system that requires access to specific hardware.
- Image, layers, container and registry
Four concepts and the relationship between them:
- Image: an immutable, read-only template with the file system and the startup metadata. It does not run; it is the mold.
- Layer: every
Dockerfileinstruction produces a layer stacked on top of the previous one. Layers are cached and shared: if ten images start from the same base, that base is downloaded once. - Container: a running instance of an image, with an ephemeral writable layer on top. When you delete it, that layer disappears.
- Registry: the image store. Docker Hub is the public one;
acrcontosopro.azurecr.iois Contoso's private one, which you already know from module 5 as the registry for Bicep modules.
The fact that layers are cached has an enormous practical consequence: order the Dockerfile from what changes least to what changes most. If you copy the source code before restoring the dependencies, any one-line change invalidates the restore cache and every build takes minutes longer.
- The availability engine's
Dockerfile
DockerfileA Dockerfile is the declarative recipe for the image. This is the engine's, with a multi-stage build, a non-root user and a small base image:
# ---------- Stage 1: build ----------
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /source
# Project files first: they rarely change and the restore gets cached
COPY MotorDisponibilidad.sln .
COPY src/MotorDisponibilidad/*.csproj src/MotorDisponibilidad/
RUN dotnet restore
# Now, at last, the complete source code
COPY . .
RUN dotnet publish src/MotorDisponibilidad/MotorDisponibilidad.csproj \
-c Release -o /published --no-restore
# ---------- Stage 2: runtime ----------
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS final
WORKDIR /app
# Unprivileged user: never run as root
RUN adduser --disabled-password --home /app --gecos "" motor && chown -R motor /app
USER motor
COPY --from=build /published .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "MotorDisponibilidad.dll"]Line by line, what matters:
FROM ... AS buildopens the first stage with the full SDK (around 800 MB): compilers, tools and everything needed to build. None of this will reach production.COPYing the.csprojfiles before the code is the cache optimization the previous section talked about. As long as the dependencies do not change,dotnet restoreis skipped.FROM ... AS finalopens the second stage starting from nothing: only the ASP.NET runtime on Alpine, around 110 MB. Thealpinevariant uses a minimal distribution; if your code depends on native glibc libraries, use-jammyinstead.adduser+USER motoris the line most often missing from real-worldDockerfiles. By default a container runs as root, and an application vulnerability turns into root inside the container. Defender for Cloud will flag it; do it from the start.COPY --from=buildbrings across only the published output of the first stage. The source code, the SDK and the build dependencies stay outside: less weight and a far smaller attack surface.ENTRYPOINTdefines the main process. When that process ends, the container ends.
Alongside the Dockerfile goes a .dockerignore with bin/, obj/, .git/ and **/appsettings.Development.json. That last line is not cosmetic: it stops a local configuration file with credentials from ending up inside the image.
- Building and testing locally
# Build, tagging with a name and a version
docker build -t motor-disponibilidad:1.0.0 .
# Run, mapping the container's port 8080 to 5080 on the laptop
docker run --rm -p 5080:8080 \
-e ConexionReservas="Server=localhost;Database=reservas-local;..." \
--name motor-local motor-disponibilidad:1.0.0
# Check the health endpoint, get inside and read the logs
curl http://localhost:5080/salud
docker exec -it motor-local sh
docker logs motor-local--rm deletes the container when you stop it, so nothing piles up. -e injects configuration through an environment variable, which is how a container is configured: never put configuration inside the image, because then you would need a different image per environment and you would lose module 5's "build once, deploy many" guarantee.
- Azure Container Registry
A private registry becomes mandatory as soon as the image contains your own code. Contoso already has acrcontosopro:
az acr create \
--resource-group rg-contoso-reservas-pro \
--name acrcontosopro \
--sku Premium --location westeurope \
--tags entorno=produccion proyecto=contoso-reservas \
centro-coste=CC-1042 [email protected]| Feature | Basic | Standard | Premium |
|---|---|---|---|
| Included storage | 10 GB | 100 GB | 500 GB |
| Throughput | Low | Medium | High |
| Geo-replication | No | No | Yes |
| Private Link / private endpoints | No | No | Yes |
| Retention policies and content trust | No | No | Yes |
| Recommended scope | Testing | Small teams | Production |
Contoso picks Premium for two reasons that have nothing to do with capacity: the private endpoint, so images never travel over the Internet, and geo-replication to North Europe, so the recovery plan does not depend on a single region.
Building in the cloud with az acr build
az acr build uploads the context, builds in Azure and leaves the image in the registry. No Docker installation is needed, which solves the pipeline agent problem in one stroke:
az acr build \
--registry acrcontosopro \
--image motor-disponibilidad:1.0.0 \
--image motor-disponibilidad:latest \
--file Dockerfile .Tagging and versioning
The latest tag is a dangerous convenience: it is mutable, so two "deployments of latest" can be different things and rollback stops being reproducible. Contoso's convention: always tag with the build identifier (motor-disponibilidad:$(Build.BuildId)), which is immutable and traceable back to the exact commit, and add latest only as a convenience for local development.
Authentication: managed identity, never the admin user
ACR ships with an admin user disabled by default. Leave it disabled. It is a shared credential, not traceable and not rotatable in practice. The right thing to do is to assign the AcrPull role to the managed identity that consumes the images and AcrPush to the service connection that publishes them:
ACR_ID=$(az acr show --name acrcontosopro --query id -o tsv)
# The application's identity can only pull
az role assignment create \
--assignee-object-id $(az identity show -g rg-contoso-seguridad-pro \
-n id-contoso-api-pro --query principalId -o tsv) \
--assignee-principal-type ServicePrincipal \
--role AcrPull --scope $ACR_IDVulnerabilities, replication and retention
- Defender for Cloud (04-05) scans every image when it is published, and continuously scans the ones that are running, with its container plan. It finds outdated bases and vulnerable packages; its output is a real work list, not decoration.
- Geo-replication:
az acr replication create --registry acrcontosopro --location northeurope. The image is pulled from the nearest replica under the same host name. - Retention: with no policy, the registry grows without limit and you pay per GB.
az acr config retention update --registry acrcontosopro --status enabled --days 30 --type UntaggedManifestscleans up untagged manifests. Round it out with a scheduled task that purges old tags, always protecting the production ones.
- The Azure container services landscape
This is the question everybody asks, and it is worth answering before you choose:
| Service | What it is | Scale to zero | Complexity | When to choose it |
|---|---|---|---|---|
| Container Instances (ACI) | A single container, no orchestrator | Manual | Minimal | One-off tasks, batch jobs, burst capacity |
| Container Apps (ACA) | Managed platform on top of Kubernetes, without exposing it | Yes | Low | Microservices and APIs with uneven load |
| App Service for Containers | Your image inside App Service | No | Low | You already use App Service and only want to change the packaging |
| Kubernetes Service (AKS) | Managed Kubernetes, with full access | With effort | High | You need the whole ecosystem and you have a team to operate it |
The criterion in one sentence: start with Container Apps and move up to AKS only when you have a concrete need that Container Apps does not cover, not just in case. Contoso takes the engine to Container Apps and will reserve AKS for the flight operations platform (06-02), which does have such a need.
- Azure Container Apps in detail
Container Apps is Kubernetes underneath, but you never see it: there are no nodes to size and no cluster versions to upgrade.
- Environment (
cae-contoso-pro): the isolation boundary. All the apps in an environment share a virtual network and a Log Analytics workspace, and they call each other by internal name. - App: a microservice, with its image, its resources and its scaling rules.
- Revision: every change to the image or the configuration creates an immutable revision. You can keep several active at once.
- Traffic splitting: it shares out percentages between revisions. This is where canary deployment lives, and it is the fine-grained version of the slots you used in 05-04.
Scaling with KEDA and scale to zero
Scaling is defined with KEDA rules, which measure something real — requests per second, the length of a queue, a custom metric — rather than CPU alone:
# Scale on concurrent HTTP requests
az containerapp update --name ca-motor-disponibilidad -g rg-contoso-reservas-pro \
--min-replicas 1 --max-replicas 20 \
--scale-rule-name http-concurrencia --scale-rule-type http \
--scale-rule-http-concurrency 50With --min-replicas 0 the app scales to zero and stops being billed when there is no traffic: ideal for ca-motor-disponibilidad in development. The price is a cold start of a few seconds on the first request, unacceptable on the production purchase path. That is why Contoso leaves min-replicas 1 in production and 0 in development.
Networking, ingress, secrets and identity
- Virtual network: the environment is integrated into
snet-integracion-app(10.20.5.0/24), and from there the apps reachpe-sql-reservasandpe-kv-contosowithout going out to the Internet. - Ingress:
--ingress externalpublishes a name with a managed TLS certificate that renews itself;--ingress internalleaves the app reachable only inside the environment, which is the right choice for the engine, whose only client is theAvailability API. For a custom domain,az containerapp hostname addplus a managed certificate. - Secrets: they are declared at the app level and can reference
kv-contoso-pro, resolved with the managed identity. No connection strings in plain environment variables. - Managed identity:
id-contoso-api-prois assigned, and with it the image is pulled from ACR anddb-reservasis accessed, with no key at all. It is exactly the pattern from 04-02.
- Deploying
ca-motor-disponibilidad
ca-motor-disponibilidadIDENTITY_ID=$(az identity show -g rg-contoso-seguridad-pro -n id-contoso-api-pro --query id -o tsv)
SUBNET_ID=$(az network vnet subnet show -g rg-contoso-red-pro \
--vnet-name vnet-contoso-pro -n snet-integracion-app --query id -o tsv)
# 1. The environment, integrated into the virtual network and linked to Log Analytics
az containerapp env create \
--name cae-contoso-pro --resource-group rg-contoso-reservas-pro \
--location westeurope --infrastructure-subnet-resource-id $SUBNET_ID \
--logs-workspace-id $(az monitor log-analytics workspace show \
-g rg-contoso-seguridad-pro -n log-contoso-pro --query customerId -o tsv)
# 2. The app, with managed identity and internal ingress
az containerapp create \
--name ca-motor-disponibilidad --resource-group rg-contoso-reservas-pro \
--environment cae-contoso-pro \
--image acrcontosopro.azurecr.io/motor-disponibilidad:1.0.0 \
--registry-server acrcontosopro.azurecr.io \
--registry-identity $IDENTITY_ID --user-assigned $IDENTITY_ID \
--ingress internal --target-port 8080 \
--cpu 0.5 --memory 1.0Gi --min-replicas 1 --max-replicas 20 \
--secrets conexion-reservas=keyvaultref:https://kv-contoso-pro.vault.azure.net/secrets/conexion-reservas,identityref:$IDENTITY_ID \
--env-vars ConexionReservas=secretref:conexion-reservas \
--tags entorno=produccion proyecto=contoso-reservas \
centro-coste=CC-1042 [email protected]Note --registry-identity: the image pull is authorized by the identity, not by a password. And note keyvaultref:, which means the secret is never written into the resource, only the reference is.
Updating it from the 05-04 pipeline is one more stage, with its environment approval:
- stage: DeployEngine
jobs:
- deployment: Engine
environment: produccion
strategy:
runOnce:
deploy:
steps:
- task: AzureCLI@2
inputs:
azureSubscription: sc-contoso-pro
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az containerapp update \
--name ca-motor-disponibilidad \
--resource-group rg-contoso-reservas-pro \
--image acrcontosopro.azurecr.io/motor-disponibilidad:$(Build.BuildId) \
--revision-suffix b$(Build.BuildId)
# Canary: 10% of the traffic to the new revision
az containerapp ingress traffic set \
--name ca-motor-disponibilidad -g rg-contoso-reservas-pro \
--revision-weight latest=10 $(previousRevision)=90The sc-contoso-pro service connection with workload identity federation carries no secrets, and the revision suffix makes every build identifiable and reversible: returning 100% to the previous revision is a single command.
- Azure Container Instances for one-off tasks
ACI runs a container with no orchestrator and no scaling, and bills per second of CPU and memory. It fits where Container Apps is overkill: a data migration that runs once, a nightly batch job.
az container create \
--resource-group rg-contoso-reservas-dev --name aci-migracion-tarifas \
--image acrcontosopro.azurecr.io/migracion-tarifas:2.1.0 \
--acr-identity $IDENTITY_ID --restart-policy Never --cpu 2 --memory 4 \
--tags entorno=desarrollo proyecto=contoso-reservas centro-coste=CC-1042--restart-policy Never is what turns it into a job: it runs, it finishes and it does not start again. Delete it afterwards with az container delete, because a stopped container still reserves resources and still bills.
Common Mistakes and Tips
- Running as root. It is the most repeated mistake and the one Defender for Cloud will point out. Always add the unprivileged user.
- Copying the code before restoring dependencies. It invalidates the cache on every build and multiplies build times by ten.
- Deploying
latestto production. A mutable tag: you do not know what is running and rollback is not reproducible. Use the build identifier. - Enabling the ACR admin user "just for testing". It stays forever. Managed identity from day one.
- Putting secrets in the image. They stay in the layers and anyone with access to the registry can extract them with
docker history. They belong in Key Vault. - Not setting CPU and memory limits, or setting them at random. In Container Apps the CPU/memory pair has specific valid combinations (0.5 vCPU with 1 Gi, 1 vCPU with 2 Gi); check before you deploy.
- Tip: define
/saludas both a readiness and a liveness probe. Without them, the platform sends traffic to replicas that are not ready yet. And inrg-contoso-reservas-dev,--min-replicas 0: a development app with no traffic should cost nothing. - Tip: do not publish the image from a laptop. If only the pipeline has
AcrPush, whatever is in the registry is always traceable to a commit.
Exercises
Exercise 1. Diego has written this Dockerfile for the Availability API:
FROM mcr.microsoft.com/dotnet/sdk:8.0
WORKDIR /app
COPY . .
RUN dotnet restore && dotnet publish -c Release -o /app/output
ENV ClaveApiPagos=k7Xq92mfLp
CMD ["dotnet", "/app/output/Api.dll"]- List four problems.
- Rewrite it applying the lesson's good practices.
- Why does removing the
ENVline in a later version not fix the leak?
Exercise 2. Contoso wants to deploy the engine in development at the lowest possible cost, knowing that it is used in bursts during the working day and not at all overnight.
- Write the
az containerapp createforca-motor-disponibilidad-devinrg-contoso-reservas-dev, with the tags of the secondary "Contoso Miles" project. - Justify the minimum and maximum replicas and explain what trade-off you are accepting.
- Which KEDA scaling rule would you use if the engine were fed by a queue instead of HTTP?
Exercise 3. Choose the service for each case and justify it in one sentence: (a) a process that converts the punctuality reports to PDF, runs at 03:00 and takes 20 minutes; (b) the new seat recommendation service, with unpredictable traffic and a need for canary releases; (c) app-contoso-reservas-pro, which already runs on App Service and only wants to move to an image; (d) the flight operations platform, with twelve microservices, service meshes and its own operators.
Solutions
Solution 1:
- (a) The final image is based on the SDK: around 800 MB against 110 MB, with compilers and source code included in production. (b) It runs as root. (c) An embedded secret with
ENV. (d)COPY . .beforerestoredestroys the layer cache, and on top of that, with no.dockerignore,bin/,obj/and.git/get copied in. - The solution is the structure from section 3: a
buildstage with the SDK, copying the.csprojfiles,dotnet restore, copying the rest andpublish; afinalstage onaspnet:8.0-alpine, creating a user,USER,COPY --from=buildandENTRYPOINT. The key goes outside: a Container Apps secret withkeyvaultref:tokv-contoso-pro. - Because images are stacked, immutable layers: the layer containing the key still exists in the registry inside every earlier tag, and it can be read with
docker history. You have to purge those tags from the registry and rotate the key, treating it as compromised.
Solution 2:
- The same command as in section 8, replacing the resource group with
rg-contoso-reservas-dev, the environment with a development one, the image with the corresponding tag,--min-replicas 0 --max-replicas 3,--cpu 0.25 --memory 0.5Gi, the development identity and vaultkv-contoso-dev, and the tagsentorno=desarrollo proyecto=contoso-millas centro-coste=CC-2077 [email protected]. - Minimum 0 because there is no traffic at night and a stopped development app should cost nothing; maximum 3 because the bursts are from testing, not production, and a low ceiling stops an infinite loop in a test from blowing up the bill. The trade-off is the cold start: the first request after a quiet spell will take a few seconds. In development that is acceptable; in production it would not be.
- An
azure-queuerule pointing at the queue instoperacionescontosopro, with a target length per replica (20 messages, for example). It is the scaler that makes KEDA genuinely interesting: it scales on backlog, not on CPU, which is a signal that arrives late.
Solution 3: (a) Container Instances: a task that starts, finishes and needs neither orchestration nor ingress. (b) Container Apps: traffic-based scaling, scale to zero outside peak hours and traffic splitting between revisions, ready for the canary. (c) App Service for Containers: it keeps the familiar slots, scaling and configuration while changing only the packaging, at almost zero migration cost. (d) AKS: only there do you get operators, service meshes and full control of the data plane, and the team justifies it.
Conclusion
You have turned Contoso's legacy problem into a modern unit. You can tell a container from a virtual machine and you know why the availability engine — stateless, fast to start, with uneven load and tied to its runtime — was the ideal candidate. You understand image, layer, container and registry, and why the order of the Dockerfile instructions decides how long your builds take. You have written that Dockerfile with a multi-stage build, a non-root user and a small base image, you have tested it locally and you have learned that configuration comes in through environment variables so that the image is the same in every environment.
In Azure Container Registry you know how to choose a tier, build without local Docker using az acr build, version with the build identifier instead of latest, authenticate with a managed identity while leaving the admin user disabled, scan for vulnerabilities with Defender for Cloud and keep growth under control with replication and retention. You have the map of the four container services and the criterion for choosing between them: start simple and move up only for a reason. And you have deployed ca-motor-disponibilidad to Container Apps with its cae-contoso-pro environment integrated into snet-integracion-app, internal ingress, secrets by reference to kv-contoso-pro, managed identity, KEDA scaling and revisions with traffic splitting updated from the module 5 pipeline. The vm-motor-disponibilidad-dev virtual machine can be switched off.
Container Apps covers Contoso's case with very little effort, and that is precisely its appeal. But it has a ceiling: when you need to control pod scheduling, install operators, apply service meshes, define fine-grained network policies or run workloads with specific hardware requirements, the abstraction becomes too small for you. That is exactly where Contoso's flight operations team is right now. In the next lesson, Azure Kubernetes Service, you will drop down a layer: you will see what AKS manages and what remains yours, you will build aks-contoso-operaciones and you will learn the essentials of Kubernetes needed to operate it sensibly, including the part almost nobody mentions at the beginning — how much it really costs and how to keep it from spiraling.
Azure Course
Module 1: Introduction to Azure
- What Is Azure?
- Service Models, Regions and Availability Zones
- Creating and Setting Up Your Azure Account
- A Tour of the Azure Portal
- Azure Resource Manager: Subscriptions, Resource Groups and Tags
- Azure CLI, PowerShell and Cloud Shell
Module 2: Core Azure Services
- Azure Virtual Machines
- Compute Scaling and High Availability
- Azure App Service
- Azure Storage: Blobs, Files, Queues and Tables
- Azure Networking: Virtual Networks, Subnets and NSGs
- Hybrid Connectivity and Global Delivery
Module 3: Azure Databases
- Choosing the Right Data Service
- Azure SQL Database
- Azure Cosmos DB
- Azure Database for MySQL
- Azure Database for PostgreSQL
- Data Analytics: Data Lake, Data Factory and Synapse
Module 4: Security in Azure
- Microsoft Entra ID and Identity Management
- RBAC and Managed Identities
- Azure Key Vault
- DDoS Protection and Web Application Firewall
- Microsoft Defender for Cloud
- Governance and Compliance with Azure Policy
Module 5: Azure DevOps
- Introduction to Azure DevOps
- Azure Repos
- Azure Pipelines: Continuous Integration
- Continuous Deployment with Environments and Approvals
- Azure Artifacts
- Infrastructure as Code with Bicep
Module 6: Advanced Azure Services
- Containers in Azure: Container Registry and Container Apps
- Azure Kubernetes Service (AKS)
- Azure Functions
- Azure Logic Apps
- Messaging and Events: Service Bus, Event Grid and Event Hubs
- Azure AI Services
Module 7: Monitoring and Management
- Azure Monitor: Metrics, Alerts and Dashboards
- Log Analytics and KQL Queries
- Application Insights
- Azure Automation and Runbooks
- Backup and Disaster Recovery
Module 8: Cost Management and Optimization
- Pricing Calculator and Cost Estimation
- Azure Cost Management: Analysis, Budgets and Alerts
- Reservations, Savings Plans and Azure Hybrid Benefit
- Azure Advisor
- Optimization Strategies and FinOps Culture
