GKE Autopilot Cluster Provisioning

Cloud & Infrastructure intermediate Building with AI ~20 min read

Overview

Google Kubernetes Engine (GKE) Autopilot is a fully managed Kubernetes mode where Google controls the node lifecycle, scaling, and infrastructure configuration. Unlike Standard GKE where you provision and manage node pools, Autopilot abstracts all node-level concerns—you declare workloads and the control plane provisions appropriately-sized nodes on demand. This dramatically reduces operational overhead while retaining Kubernetes’ full API surface. For teams building SaaS products on GCP, Autopilot is often the right default: it eliminates cluster sprawl, enforces security baselines automatically, and scales to zero during idle periods.

Despite its “fully managed” framing, Autopilot clusters still require careful configuration at provisioning time. The network topology—VPC, subnet, secondary IP ranges for Pods and Services—must be planned before cluster creation because these ranges cannot be changed without recreation. Private cluster mode (nodes without external IPs), Workload Identity for pod-level GCP authentication, managed Prometheus for observability, and DNS-based endpoints for SOC2-compliant control plane access all require explicit configuration. Getting these right at day zero prevents expensive infrastructure migrations later.

In a production cloud-native system, the infrastructure repository manages all GKE cluster provisioning through Pulumi programs written in Go. Each environment (dev, staging, prod) gets its own Autopilot cluster with identical configuration, differing only in subnet CIDR ranges and node resource limits. A SOC2 compliance readiness report documents the specific cluster settings that satisfy control requirements around network isolation, access control, and audit logging. Understanding this provisioning pattern is essential for extending or operating the infrastructure.

Core Concepts

Custom VPC and Secondary IP Ranges

GKE requires three distinct CIDR ranges: one for nodes (from the subnet’s primary range), one for Pods (secondary range aliased to --cluster-secondary-range-name), and one for Services (secondary range aliased to --services-secondary-range-name). Using a custom VPC with pre-planned secondary ranges—rather than GKE’s auto-assigned ranges—gives infrastructure teams control over IP space, enables VPC peering without overlap conflicts, and satisfies network segmentation requirements for SOC2. Typical sizing: /14 for Pods (65,536 addresses), /20 for Services (4,096 addresses).

Private Nodes and Private Endpoint

A private GKE cluster configures nodes with only internal IP addresses (no public IPs), and optionally makes the Kubernetes API server endpoint private-only. Private nodes prevent direct inbound access from the internet and satisfy network isolation controls. When enablePrivateEndpoint is true, the API server is only reachable from within the VPC or connected networks—a SOC2 requirement that limits the attack surface on the control plane. Access from developer workstations is provided via Cloud IAP tunneling or a bastion host.

Spot Instances for Cost Optimization

GKE Autopilot supports Spot VMs as a provisioning class for workloads that tolerate interruption. By annotating pods with cloud.google.com/gke-spot: "true", Autopilot provisions spot-priced nodes for those workloads, typically 60–90% cheaper than on-demand. In a typical cloud-native architecture, batch indexing jobs, background workers, and non-critical evaluation pipelines run on Spot nodes while latency-sensitive API pods run on standard nodes. Autopilot handles the node pool separation transparently—the cluster operator only needs to set the provisioning class annotation.

Managed Prometheus (Google Cloud Managed Service for Prometheus)

GKE Autopilot can enable Google’s managed Prometheus (GMP) at cluster creation time via managedPrometheus.enabled = true in the monitoring config. GMP deploys a collector DaemonSet that scrapes workloads and forwards metrics to Google Cloud Monitoring, eliminating the need to run and scale a self-managed Prometheus instance. Metrics are stored in Cloud Monitoring with full PromQL query support via the Monarch backend. This satisfies SOC2 monitoring controls out of the box and reduces operational burden for teams without dedicated SRE capacity.

DNS-Based Endpoints

GKE Autopilot supports DNS-based endpoints for the control plane (dnsEndpoint.enabled = true), which replaces the static IP-based endpoint with a stable DNS name. DNS endpoints enable the cluster to be accessed consistently across VPC recreations and simplify firewall rule management since the endpoint resolves within the VPC rather than requiring specific IP allowlists. For SOC2 compliance, DNS endpoints work naturally with Cloud Audit Logs to record every API server access by resolved identity, and they integrate cleanly with Private Service Connect for true VPC-internal routing.

How It Works

Provisioning a production-grade GKE Autopilot cluster involves coordinating four GCP resource layers: the VPC network with its subnets and secondary ranges, the GKE cluster itself, the Workload Identity binding between Kubernetes service accounts and GCP service accounts, and the monitoring configuration. Pulumi manages all four as a single stack with explicit dependency tracking.

VPC and Subnet with Secondary Ranges

The cluster subnet must be created with secondary IP ranges before the cluster. GKE Autopilot clusters cannot be created in subnets that lack the required secondary ranges, and these ranges cannot be added after cluster creation without destroying the cluster. The infrastructure project pre-plans CIDR allocations across all environments to avoid overlap, using a hierarchical addressing scheme: 10.0.0.0/8 is subdivided by environment and then by purpose (nodes, pods, services).

package infra

import (
    "github.com/pulumi/pulumi-gcp/sdk/v7/go/gcp/compute"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

type NetworkConfig struct {
    Region       string
    NodeCIDR     string // primary range for nodes
    PodCIDR      string // secondary range for pods
    ServiceCIDR  string // secondary range for services
}

func NewClusterNetwork(ctx *pulumi.Context, env string, cfg NetworkConfig) (*compute.Network, *compute.Subnetwork, error) {
    vpc, err := compute.NewNetwork(ctx, env+"-vpc", &compute.NetworkArgs{
        Name:                  pulumi.String(env + "-vpc"),
        AutoCreateSubnetworks: pulumi.Bool(false),
        RoutingMode:           pulumi.String("REGIONAL"),
    })
    if err != nil {
        return nil, nil, err
    }

    subnet, err := compute.NewSubnetwork(ctx, env+"-subnet", &compute.SubnetworkArgs{
        Name:        pulumi.String(env + "-gke-subnet"),
        Network:     vpc.ID(),
        Region:      pulumi.String(cfg.Region),
        IpCidrRange: pulumi.String(cfg.NodeCIDR),
        SecondaryIpRanges: compute.SubnetworkSecondaryIpRangeArray{
            &compute.SubnetworkSecondaryIpRangeArgs{
                RangeName:   pulumi.String("pods"),
                IpCidrRange: pulumi.String(cfg.PodCIDR),
            },
            &compute.SubnetworkSecondaryIpRangeArgs{
                RangeName:   pulumi.String("services"),
                IpCidrRange: pulumi.String(cfg.ServiceCIDR),
            },
        },
        PrivateIpGoogleAccess: pulumi.Bool(true),
    }, pulumi.DependsOn([]pulumi.Resource{vpc}))
    if err != nil {
        return nil, nil, err
    }

    return vpc, subnet, nil
}

Autopilot Cluster Resource

The container.Cluster resource with enableAutopilot: true is the central declaration. Key fields include the network references, private cluster config, DNS endpoint config, and monitoring config. Autopilot clusters do not accept node pool configurations—those fields are rejected by the API. The cluster creation takes 5–10 minutes and is tracked by Pulumi as a single long-running operation.

package infra

import (
    "github.com/pulumi/pulumi-gcp/sdk/v7/go/gcp/container"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

type ClusterConfig struct {
    Project     string
    Region      string
    NetworkSelfLink  pulumi.StringOutput
    SubnetSelfLink   pulumi.StringOutput
    MasterCIDR  string // e.g. "172.16.0.0/28" - must not overlap VPC
}

func NewAutopilotCluster(ctx *pulumi.Context, env string, cfg ClusterConfig) (*container.Cluster, error) {
    cluster, err := container.NewCluster(ctx, env+"-cluster", &container.ClusterArgs{
        Name:             pulumi.String(env + "-cluster"),
        Location:         pulumi.String(cfg.Region),
        Project:          pulumi.String(cfg.Project),
        EnableAutopilot:  pulumi.Bool(true),
        Network:          cfg.NetworkSelfLink,
        Subnetwork:       cfg.SubnetSelfLink,

        // IP allocation for pods and services from subnet secondary ranges
        IpAllocationPolicy: &container.ClusterIpAllocationPolicyArgs{
            ClusterSecondaryRangeName:  pulumi.String("pods"),
            ServicesSecondaryRangeName: pulumi.String("services"),
        },

        // Private nodes + private control plane endpoint
        PrivateClusterConfig: &container.ClusterPrivateClusterConfigArgs{
            EnablePrivateNodes:    pulumi.Bool(true),
            EnablePrivateEndpoint: pulumi.Bool(true),
            MasterIpv4CidrBlock:   pulumi.String(cfg.MasterCIDR),
        },

        // DNS-based control plane endpoint (SOC2 compliant)
        ControlPlaneEndpointsConfig: &container.ClusterControlPlaneEndpointsConfigArgs{
            DnsEndpointConfig: &container.ClusterControlPlaneEndpointsConfigDnsEndpointConfigArgs{
                Enabled: pulumi.Bool(true),
            },
        },

        // Managed Prometheus for cluster-wide metrics
        MonitoringConfig: &container.ClusterMonitoringConfigArgs{
            EnableComponents: pulumi.StringArray{
                pulumi.String("SYSTEM_COMPONENTS"),
                pulumi.String("WORKLOADS"),
            },
            ManagedPrometheus: &container.ClusterMonitoringConfigManagedPrometheusArgs{
                Enabled: pulumi.Bool(true),
            },
        },

        // Workload Identity for pod-level GCP auth
        WorkloadIdentityConfig: &container.ClusterWorkloadIdentityConfigArgs{
            WorkloadPool: pulumi.String(cfg.Project + ".svc.id.goog"),
        },

        // Logging to Cloud Logging
        LoggingConfig: &container.ClusterLoggingConfigArgs{
            EnableComponents: pulumi.StringArray{
                pulumi.String("SYSTEM_COMPONENTS"),
                pulumi.String("WORKLOADS"),
            },
        },

        // Deletion protection for production clusters
        DeletionProtection: pulumi.Bool(true),
    })
    if err != nil {
        return nil, err
    }

    return cluster, nil
}

Spot Instance Workload Annotation

In Autopilot, spot provisioning is requested per-workload via a node selector or pod annotation. The infrastructure project includes helper functions that inject the correct annotations into Kubernetes manifests for batch workloads, keeping the cost optimization logic centralized rather than scattered across deployment files.

package k8s

import (
    corev1 "k8s.io/api/core/v1"
)

// SpotTolerations returns the tolerations and node selector required
// for GKE Autopilot to provision spot VMs for a workload.
func SpotTolerations() ([]corev1.Toleration, map[string]string) {
    tolerations := []corev1.Toleration{
        {
            Key:      "cloud.google.com/gke-spot",
            Operator: corev1.TolerationOpEqual,
            Value:    "true",
            Effect:   corev1.TaintEffectNoSchedule,
        },
    }
    nodeSelector := map[string]string{
        "cloud.google.com/gke-provisioning": "spot",
    }
    return tolerations, nodeSelector
}

// ApplySpotConfig mutates a PodSpec to request spot provisioning.
func ApplySpotConfig(spec *corev1.PodSpec) {
    tolerations, selector := SpotTolerations()
    spec.Tolerations = append(spec.Tolerations, tolerations...)
    if spec.NodeSelector == nil {
        spec.NodeSelector = make(map[string]string)
    }
    for k, v := range selector {
        spec.NodeSelector[k] = v
    }
}

Master Authorized Networks for Private Endpoint Access

Even with enablePrivateEndpoint: true, Pulumi and CI/CD pipelines need network-level access to the API server. The infrastructure project configures Master Authorized Networks to allow the VPN gateway CIDR and the Cloud Build service IP ranges. This is set as a separate container.Cluster field rather than post-hoc to avoid a Pulumi diff on every plan.

// Within ClusterArgs, add master authorized networks:
MasterAuthorizedNetworksConfig: &container.ClusterMasterAuthorizedNetworksConfigArgs{
    CidrBlocks: container.ClusterMasterAuthorizedNetworksConfigCidrBlockArray{
        &container.ClusterMasterAuthorizedNetworksConfigCidrBlockArgs{
            CidrBlock:   pulumi.String("10.200.0.0/16"), // VPN gateway subnet
            DisplayName: pulumi.String("vpn-gateway"),
        },
        &container.ClusterMasterAuthorizedNetworksConfigCidrBlockArgs{
            CidrBlock:   pulumi.String("35.199.192.0/19"), // Cloud Build
            DisplayName: pulumi.String("cloud-build"),
        },
    },
},

Implementation Patterns

Environment-Parameterized Stack

The infrastructure project uses a single Pulumi program with per-stack configuration to provision identical cluster topologies across dev, staging, and production. A ClusterSpec struct is populated from pulumi.Config and drives all resource parameters, ensuring environments only differ where intentional (e.g., CIDR ranges, deletion protection).

package main

import (
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"

    "platform-infra/infra"
)

func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {
        cfg := config.New(ctx, "")

        env     := cfg.Require("env")      // "dev" | "staging" | "prod"
        project := cfg.Require("project")
        region  := cfg.Require("region")

        netCfg := infra.NetworkConfig{
            Region:      region,
            NodeCIDR:    cfg.Require("nodeCIDR"),
            PodCIDR:     cfg.Require("podCIDR"),
            ServiceCIDR: cfg.Require("serviceCIDR"),
        }

        vpc, subnet, err := infra.NewClusterNetwork(ctx, env, netCfg)
        if err != nil {
            return err
        }

        clusterCfg := infra.ClusterConfig{
            Project:         project,
            Region:          region,
            NetworkSelfLink: vpc.SelfLink,
            SubnetSelfLink:  subnet.SelfLink,
            MasterCIDR:      cfg.Require("masterCIDR"),
        }

        cluster, err := infra.NewAutopilotCluster(ctx, env, clusterCfg)
        if err != nil {
            return err
        }

        ctx.Export("clusterName", cluster.Name)
        ctx.Export("clusterEndpoint", cluster.Endpoint)
        return nil
    })
}

Kubeconfig Generation for CI/CD

After cluster creation, CI/CD pipelines need a kubeconfig to deploy workloads. The infrastructure project exports cluster details as Pulumi stack outputs and uses a helper that constructs a kubeconfig from the GKE cluster resource, using Application Default Credentials for authentication rather than embedding static tokens.

package infra

import (
    "encoding/base64"
    "fmt"

    "github.com/pulumi/pulumi-gcp/sdk/v7/go/gcp/container"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

// KubeconfigFromCluster generates a kubeconfig YAML for the cluster.
// Uses gcloud-based exec credential plugin to avoid static tokens.
func KubeconfigFromCluster(cluster *container.Cluster) pulumi.StringOutput {
    return pulumi.All(
        cluster.Name,
        cluster.Endpoint,
        cluster.MasterAuth.ClusterCaCertificate(),
    ).ApplyT(func(args []interface{}) (string, error) {
        name   := args[0].(string)
        endpoint := args[1].(string)
        caCert := args[2].(*string)

        if caCert == nil {
            return "", fmt.Errorf("cluster CA cert not available")
        }
        _, err := base64.StdEncoding.DecodeString(*caCert)
        if err != nil {
            return "", fmt.Errorf("invalid CA cert encoding: %w", err)
        }

        return fmt.Sprintf(`apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: %s
    server: https://%s
  name: %s
contexts:
- context:
    cluster: %s
    user: %s
  name: %s
current-context: %s
kind: Config
users:
- name: %s
  user:
    exec:
      apiVersion: client.authentication.k8s.io/v1beta1
      command: gke-gcloud-auth-plugin
      installHint: Install gke-gcloud-auth-plugin for kubectl
      provideClusterInfo: true
`, *caCert, endpoint, name, name, name, name, name, name), nil
    }).(pulumi.StringOutput)
}

Workload Identity Binding

Each application service account in Kubernetes needs a corresponding GCP service account binding to access GCS, Pub/Sub, or other GCP APIs without node-level credential sharing. The infrastructure project encapsulates this as a reusable function that creates both the GCP service account and the Kubernetes service account IAM binding.

package infra

import (
    "fmt"

    "github.com/pulumi/pulumi-gcp/sdk/v7/go/gcp/iam"
    "github.com/pulumi/pulumi-gcp/sdk/v7/go/gcp/serviceaccount"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

type WorkloadIdentityArgs struct {
    Project     string
    K8sNamespace string
    K8sSAName   string
    GCPRoles    []string // e.g. ["roles/storage.objectViewer"]
}

func NewWorkloadIdentity(ctx *pulumi.Context, name string, args WorkloadIdentityArgs) (*serviceaccount.Account, error) {
    gcpSA, err := serviceaccount.NewAccount(ctx, name+"-sa", &serviceaccount.AccountArgs{
        AccountId:   pulumi.String(name + "-sa"),
        Project:     pulumi.String(args.Project),
        DisplayName: pulumi.String(name + " workload identity SA"),
    })
    if err != nil {
        return nil, err
    }

    // Bind GCP roles to the service account
    for i, role := range args.GCPRoles {
        _, err = serviceaccount.NewIAMBinding(ctx, fmt.Sprintf("%s-role-%d", name, i), &serviceaccount.IAMBindingArgs{
            ServiceAccountId: gcpSA.Name,
            Role:             pulumi.String(role),
            Members:          pulumi.StringArray{pulumi.String("serviceAccount:" + args.Project + ".svc.id.goog[" + args.K8sNamespace + "/" + args.K8sSAName + "]")},
        })
        if err != nil {
            return nil, err
        }
    }

    // Allow Kubernetes SA to impersonate GCP SA
    _, err = serviceaccount.NewIAMMember(ctx, name+"-wi-binding", &serviceaccount.IAMMemberArgs{
        ServiceAccountId: gcpSA.Name,
        Role:             pulumi.String("roles/iam.workloadIdentityUser"),
        Member: pulumi.Sprintf("serviceAccount:%s.svc.id.goog[%s/%s]",
            args.Project, args.K8sNamespace, args.K8sSAName),
    })
    if err != nil {
        return nil, err
    }

    return gcpSA, nil
}

Practical Applications

In a production infrastructure repository, GKE Autopilot clusters serve as the compute substrate for all platform services. For example, a search API service, a document-indexer (document ingestion), and an image-processing worker all run as Deployments on a shared Autopilot cluster. The cluster’s Autopilot mode means infrastructure engineers never size node pools—workloads declare their CPU and memory requests, and GKE provisions the right nodes automatically. This is particularly valuable for a document-indexer, whose resource usage spikes 10x during large document ingestion jobs and then drops back to near-zero.

A SOC2 readiness report documents how each cluster configuration setting maps to a specific control. Private nodes with private endpoint satisfy CC6.6 (logical access to network boundaries). Workload Identity satisfies CC6.1 (access control for application credentials) by eliminating long-lived service account keys from the cluster. Managed Prometheus and Cloud Logging satisfy CC7.2 (monitoring for security events) by providing immutable audit trails for all control plane and workload activity. The readiness report references specific Pulumi resource field names alongside the control IDs, making it straightforward to verify the infrastructure matches the compliance claims.

For local development, the dev stack uses a smaller master CIDR range and disables deletion protection, but is otherwise identical in configuration to production. This parity is enforced by the parameterized Pulumi program—if a new security feature is added to the production cluster config, it automatically propagates to dev and staging on the next stack update. The infrastructure project’s CI pipeline runs pulumi preview on every PR to catch configuration drift before it reaches production, with the diff posted as a PR comment for review.

Common Pitfalls

Further Reading