Compare AWS, Azure, and Google Cloud for enterprise workloads. Covers compute, storage, AI/ML, pricing models, enterprise features, and migration considerations.
Enterprise cloud platforms enable teams to build, deploy, and operate distributed systems at scale. In 2026, the choice between AWS, Azure, and GCP is no longer a project-level decision but a strategic architectural commitment. This reference page details how engineers and architects make trade-offs across core capabilities—compute, networking, storage, identity, observability, and deployment—when designing systems for enterprise use, with real, executable details that only emerge after months of production experience.
When provisioning and managing virtual machines and containers at scale, engineers must choose between the idioms of EC2, Azure VMs, and Compute Engine—each with distinct operational nuances.
To launch a VM with a specific boot disk and auto-scaling policy, the following curl command is typical across all three platforms:
curl -X POST https://api.compute.aws.example/v2026/instances \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "web-cluster-01",
"image": "ami-1a2b3c4d",
"size": "m5.4xlarge",
"region": "us-east-1",
"bootstrap_script": "#!/bin/bash\napt update && apt install nginx -y",
"auto_scaling": {
"min_instances": 2,
"max_instances": 20,
"target_cpu": 70,
"cooldown": 300
}
}'
The response includes a state field that can be provisioning, running, updating, or failed. On failure, the error_code field reveals EC2_301, VM_402, or GCE_503, each with a details object that may include:
{
"error_code": "EC2_301",
"message": "Image not found or corrupted",
"details": {
"image_id": "ami-1a2b3c4d",
"expected_format": "AMI v2.1",
"available_regions": ["us-east-1", "eu-west-1"]
}
}
For container orchestration, Kubernetes is the common substrate, but differences emerge in how platform-specific controllers manage workloads.
In AWS, aws eks update-nodegroup --cluster-name prod-cluster --nodegroup-name worker-pool --disk-size 200 updates the root volume size. The --disk-size flag is required and defaults to 20, but a silent failure occurs when a node group uses a custom AMI that lacks cloud-init configuration: cloud-init fails silently, leaving /var/log/cloud-init-output.log empty, and the node never joins the cluster.
Azure’s az aks update supports --enable-vm-ssd and --enable-azure-files, but the --enable-azure-files flag only mounts the volume via AzureFile driver, not CSI. To use CSI, the --enable-csi flag must be added explicitly, and the azure-files storage class must be applied:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: azure-files-csi
provisioner: file.csi.azure.com
parameters:
skuName: Standard_LRS
location: eastus
resourceGroup: production-rg
GCP’s gcloud container clusters update supports --enable-autoscaling and --enable-ip-aliasing, but --enable-ip-aliasing does not automatically configure Secondary IP ranges unless --secondary-range-names is provided. Without it, pods fail to reach services with 503 Service Unavailable because the clusterIP is not correctly routed.
Interconnecting services across regions and on-premises environments requires consistent network abstractions.
AWS uses VPCs with subnets, route tables, and network ACLs. To expose an internal service to the internet, engineers create a Network Load Balancer (NLB) with a target_group that supports deregistration_delay and unhealthy_threshold.
aws elbv2 create-target-group \
--name web-api \
--protocol TCP \
--port 8080 \
--vpc-id vpc-1a2b3c4d \
--health-check-interval-seconds 15 \
--health-check-protocol TCP \
--health-check-timeout-seconds 5 \
--healthy-threshold-count 3 \
--unhealthy-threshold-count 2 \
--deregistration-delay-seconds 30
The deregistration_delay is critical: when a node fails health checks, it remains in the InService state for 30 seconds before being removed from the load balancer. During this time, new requests are still routed to the failing instance, which is vital for zero-downtime deployments.
Azure uses Virtual Networks (VNets) with Subnets, Network Security Groups (NSGs), and Application Gateways. A key pattern is the VNet Peering between two VNets with allow-vnet-access and allow-forwarded-traffic. To enable cross-region peering, the --enable-remote-gateway flag must be set, and the gateway must be configured as a Local Network Gateway.
az network vnet peering create \
--name eastus-to-westus \
--resource-group production-rg \
--vnet-name eastus-vnet \
--remote-vnet-name westus-vnet \
--remote-azure-vm-ip-address 10.0.1.1 \
--enable-remote-gateway \
--allow-vnet-access \
--allow-forwarded-traffic
GCP uses VPCs with subnets, firewall rules, and interconnects. A common mistake is assuming gcloud compute networks create enables IP aliasing by default. It does not. Engineers must explicitly enable ip_allocation_policy and secondary_ip_range:
{
"name": "prod-vpc",
"routingMode": "REGIONAL",
"ipAllocationPolicy": {
"clusterIpv4Cidr": "10.0.0.0/16",
"servicesIpv4Cidr": "10.0.1.0/24",
"clusterSecondaryRangeName": "pod-ranges",
"servicesSecondaryRangeName": "svc-ranges"
}
}
Without this, pod-to-pod communication fails when ClusterIP services are assigned IP addresses outside the clusterIpv4Cidr range, and kubectl get svc shows LoadBalancer services with EXTERNAL-IP as Pending.
Central to multi-service systems is consistent identity and access control.
All platforms use IAM models based on policies, roles, and bindings. A typical policy in JSON format:
{
"Version": "2026-01-01",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::prod-data-bucket/*"
},
{
"Effect": "Deny",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::prod-data-bucket/archive/*",
"Condition": {
"DateLessThan": {
"AWS:CurrentTime": "2026-06-01T00:00:00Z"
}
}
}
]
}
AWS requires PolicyDocument to be passed as a single JSON object in PutRolePolicy, while Azure and GCP use JSON in a properties block. A common failure: when attaching a policy to a role, the policyName is not used as the name in the role object, leading to 400 Bad Request with MissingParameter and PolicyName in the response.
Azure’s role model includes assignableScopes, which define where the role can be assigned. A role with assignableScopes: ["/subscriptions/1a2b3c4d", "/resourceGroups/prod-rg"] can be assigned at the subscription or resource group level, but not at the resource level unless AssignableScopes is explicitly set.
GCP uses IAM with bindings that map members to roles. A member can be a user, group, serviceAccount, or domain. The condition field supports iam:Resource, iam:Project, and iam:Location:
{
"role": "roles/storage.objectViewer",
"members": [ "group:[email protected]" ],
"condition": {
"title": "Production access only",
"description": "Only for resources in prod environment",
"expression": "resource.name.startsWith('projects/prod-')",
"location": "us-central1"
}
}
When a condition fails, the iam:Resource expression is evaluated against the resource’s name field, not selfLink. A failure occurs when a service account is granted roles/storage.objectViewer on projects/prod-123, but the condition resource.name.startsWith('projects/prod-') fails because the resource is named projects/prod-123-2026.
Observability is not a single tool but a stack of telemetry: logs, metrics, and traces.
All platforms expose logs via streaming endpoints. AWS CloudWatch Logs uses PutLogEvents with logGroupName and logStreamName, and supports sequenceToken for idempotent ingestion:
{
"logGroupName": "/app/web",
"logStreamName": "prod-2026-05-01",
"logEvents": [
{
"timestamp": 1682976000000,
"message": "User login successful for [email protected]"
}
],
"sequenceToken": "4567890123"
}
On failure, the sequenceToken must be updated, and a sequenceTokenMismatch error is returned if the token is outdated. The PutLogEvents call may return 400 Bad Request with InvalidSequenceToken and expectedSequenceToken.
Azure Monitor uses Azure Monitor Logs with Log Analytics Workspace, where queries are written in Kusto Query Language (KQL):
AzureActivity
| where ResourceGroup == "prod-rg"
| where OperationName == "Microsoft.Compute/virtualMachines/start/action"
| where startTime > ago(1h)
| project TimeGenerated, Resource, Status, Duration
| order by TimeGenerated desc
A common mistake: engineers assume Kusto supports join by default, but join requires kind and on clauses. Without it, the query fails silently with SyntaxError and Expected keyword.
GCP’s Cloud Logging uses Structured Logs with LogEntry objects that support labels, httpRequest, and trace:
{
"textPayload": "User login successful",
"timestamp": "2026-05-01T12:00:00Z",
"labels": {
"env": "prod",
"service": "auth-service"
},
"httpRequest": {
"requestMethod": "POST",
"requestUrl": "/api/v1/login",
"status": 200,
"responseSize": "1024",
"latency": "230ms"
},
"trace": "projects/my-project/traces/1234567890abcdef"
}
The trace field links to Cloud Trace, and a traceId from the httpRequest is used to correlate logs with spans. A silent failure occurs when traceId is not in projects/{project}/traces/{traceId} format, causing Cloud Trace to fail to resolve the trace.
Deployments across platforms involve build, push, and apply steps with subtle differences.
AWS CodePipeline uses pipeline definitions in JSON, with stages and actions. A BuildAction requires inputArtifacts, outputArtifacts, and configuration:
{
"name": "Build",
"actions": [
{
"name": "Build",
"actionTypeId": {
"category": "Build",
"owner": "AWS",
"provider": "CodeBuild",
"version": "1"
},
"inputArtifacts": [
{
"name": "SourceArtifact"
}
],
"outputArtifacts": [
{
"name": "BuildArtifact"
}
],
"configuration": {
"projectName": "web-app-build",
"environmentVariables": [
{
"name": "DEPLOY_ENV",
"value": "prod",
"type": "PLAINTEXT"
}
]
}
}
]
}
The environmentVariables field is required, and a missing type causes CodeBuild to fail with EnvironmentVariableTypeNotSet.
Azure Pipelines uses YAML for CI/CD, with jobs, steps, and variables. A step may include script or task:
- job: Deploy
steps:
- task: AzureCLI@2
displayName: 'Deploy to AKS'
inputs:
azureSubscription: 'prod-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az aks upgrade --name prod-cluster --kubernetes-version 1.28.0 --yes
kubectl rollout restart deployment/web-app
workingDirectory: 'app'
The workingDirectory is critical: if not set, kubectl fails to find deployments.yaml, and the rollout command returns Error from server (NotFound): deployments.apps "web-app" not found.
GCP’s Cloud Build uses cloudbuild.yaml with steps, options, and substitutions:
steps:
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/my-project/web-app:$COMMIT_SHA', '.']
- name: 'gcr.io/cloud-builders/gcloud'
args: [
'run', 'deployments', 'web-app',
'--image', 'gcr.io/my-project/web-app:$COMMIT_SHA',
'--region', 'us-central1'
]
substitutions:
COMMIT_SHA: '$COMMIT_SHA'
PROJECT_ID: 'my-project'
The substitutions are expanded at build time, and a missing PROJECT_ID results in Error: Unable to find project 'my-project'.
EC2 instances launched from an AMI that lacks cloud-init fail to register with the EC2 Auto Scaling Group unless cloud-init is installed and the user-data script is correctly formatted.Application Gateway fails to route traffic when probe settings are not aligned with backendHttpSettings, especially when path is set but hostName is not.Cloud Run services fail to scale if minInstances is not set and the invocation is triggered via Pub/Sub, because Cloud Run defaults to maxInstances: 200 but does not start minInstances until the first request arrives.Each platform has a default for health check intervals: AWS uses 30s, Azure uses 30s, GCP uses 60s. A service that expects health check every 30 seconds but runs on GCP will see 503 errors during peak load because the instance is not marked Healthy fast enough.
The region parameter in create-instance is not optional in AWS but is in Azure and GCP, where the region defaults to default if not provided. This leads to unexpected-region deployment patterns where services are created in the wrong region.
In summary, the enterprise cloud decision in 2026 is not about features but about idioms—how engineers interact with the platform, what breaks silently, and what they learn only after a production outage.
This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy