All writing
Updated 10 min readNaren

Connect GKE to AKS over a VPN, and prove it works

Build the tunnel between Google Cloud and Azure with BGP, advertise only what should cross, then test four layers until real traffic flows.

A Google Kubernetes Engine cluster in europe-west1 and an Azure Kubernetes Service cluster in australiaeast can reach each other privately. This post builds that path and proves packets cross it.

The path is an IPsec virtual private network, or VPN, routed by BGP. BGP is the Border Gateway Protocol, which routers use to announce the addresses they can reach. These two regions sit on opposite sides of the world, so measure the round trip yourself before you design around this link.

One rule shapes every choice below. Only node subnets and internal load balancer addresses cross the VPN. An internal load balancer, or ILB, has a private address inside your own network. Pod ranges never cross.

Look at the two dotted lines in the diagram. Those ranges stay inside their own cloud.

Google Cloud advertises only the GKE node subnet 10.10.0.0/20 across two tunnels. Azure advertises its whole virtual network 10.20.0.0/16 back. The GKE Pod range and the AKS Pod CIDR stay inside their own cloud and are never advertised. Google Cloud, europe-west1 Azure, australiaeast advertise 10.10.0.0/20 learned by Azure advertise 10.20.0.0/16 learned by Google never advertised never advertised tun-azure-0 and tun-azure-1 GKE Pods 10.10.128.0/17 GKE nodes 10.10.0.0/20 VNet 10.20.0.0/16 AKS Pods 10.244.0.0/16

Four objects on each side pair one to one. Google's HA VPN gateway pairs with Azure's VPN gateway, where HA means high availability. Google's external VPN gateway pairs with Azure's local network gateway.

Google's tunnel pairs with the Azure connection resource cx-gcp-0, and Cloud Router pairs with the Azure gateway's BGP speaker.

The counts are fixed: two HA VPN interfaces, two tunnels, two local network gateways, two Azure connection resources. An HA VPN gateway always has two interfaces, requires BGP, and has no static route option.

The address plan

Choose every address before creating anything. Azure rejects an address space that overlaps one of its own. CIDR notation, from Classless Inter-Domain Routing, writes a range as an address and a prefix length.

An ASN, or autonomous system number, is the number a BGP speaker uses to identify itself.

RangeWhat it isCrosses the VPN
10.10.0.0/16Google VPC gke-vpc, a virtual private cloudPlanning aggregate only
10.10.0.0/20GKE node subnet gke-nodes-ew1Yes, advertised
10.10.128.0/17GKE Pod secondary range gke-podsNo, never
10.10.15.240orders ILB address, reserved as orders-ilb-vipYes, inside the node subnet
10.20.0.0/16Azure virtual network, or VNet, vnet-aks-aueYes, all of it
10.20.0.0/20AKS node subnet snet-aks-nodesYes
10.20.16.0/24AKS load balancer subnet snet-aks-ilbYes
10.20.16.10billing ILB addressYes
10.20.255.0/27GatewaySubnetYes
10.244.0.0/16AKS Pod CIDR, Azure CNI OverlayNo, never
172.16.0.0/16AKS Service CIDRNo
169.254.21.0/30BGP session 0, Azure .1, Cloud Router .2Link-local, not routed
169.254.22.0/30BGP session 1, Azure .1, Cloud Router .2Link-local, not routed
ASN 65010Cloud Router cr-ew1An identifier, not a route
ASN 65020Azure VPN gateway vpngw-aueAn identifier, not a route

Why each range is what it is

orders-ilb-vip reserves the virtual IP address, or VIP, that the ILB answers on. Azure assigns 65515 by default, and 65515 is on Azure's own reserved list. Set --asn on both sides.

The two /30 ranges hold APIPA addresses, from Automatic Private IP Addressing. Google accepts any unique /30 inside 169.254.0.0/16. Azure accepts only 169.254.21.0 through 169.254.22.255, so use the intersection.

Each /30 must also be unique across every Cloud Router in the VPC.

Every range marked as crossing must be unique across both clouds. Two unadvertised ranges must be unique as well. The AKS Pod CIDR must not overlap the Azure VNet, or Microsoft warns the outside endpoint becomes unreachable.

The GKE Pod range must not overlap 10.20.0.0/16, because a subnet route beats a learned route.

Creating the two clusters

Both clusters are prerequisites. Run these two commands only after the Azure and Google Cloud sections below have created snet-aks-nodes and gke-nodes-ew1. $NODE_SUBNET_ID is the resource id of snet-aks-nodes.

# Google: VPC-native, Pods from the named secondary range, Services managed by GKE
gcloud container clusters create gke-ew1 \
  --region=europe-west1 --enable-ip-alias \
  --network=gke-vpc --subnetwork=gke-nodes-ew1 \
  --cluster-secondary-range-name=gke-pods \
  --default-max-pods-per-node=110

# Azure: overlay mode is required. --network-plugin azure alone gives node subnet mode.
az aks create -g rg-aks-aue -n aks-aue -l australiaeast \
  --network-plugin azure --network-plugin-mode overlay \
  --pod-cidr 10.244.0.0/16 \
  --service-cidr 172.16.0.0/16 --dns-service-ip 172.16.0.10 \
  --vnet-subnet-id "$NODE_SUBNET_ID" \
  --generate-ssh-keys

Node subnet mode would take Pod addresses from the advertised node subnet, which breaks the rule above. On the Google side the Pod range limits cluster size, not the node subnet.

GKE fixes the per-node allocation when you create the cluster.

The order of work

There are exactly two hard gates. Google cannot create the external VPN gateway until Azure's two public addresses exist. Azure cannot create the local network gateways until the HA VPN gateway exists. That looks like a cycle and is not one.

Azure allocates its two public addresses before it builds the gateway, and the build takes 45 minutes or more.

Choose the two /30 ranges first, because both sides reference them.

In the diagram, the two dashed arrows mark the only points where one cloud needs a value from the other.

Azure creates its resource group, network and two public addresses, then hands both addresses to Google Cloud. While the Azure gateway builds for 45 minutes, Google Cloud builds its whole side and returns two HA VPN interface addresses. Azure then sets the APIPA addresses and creates the local network gateways and the connection resources. Resource group, VNet, subnets Two static public addresses pip-vpngw-0 and pip-vpngw-1 Create vpngw-aue, 45 minutes VPC, subnet, ha-vpn-ew1, cr-ew1 azure-gw, two tunnels, two BGP peers Two HA VPN interface addresses APIPA, lng-gcp-0, lng-gcp-1 cx-gcp-0 and cx-gcp-1 Azure Google Cloud

Azure first, because Google needs its public addresses

RG=rg-aks-aue; LOC=australiaeast; VNET=vnet-aks-aue; GW=vpngw-aue

az group create --name $RG --location $LOC

az network vnet create -n $VNET -g $RG -l $LOC \
  --address-prefix 10.20.0.0/16 \
  --subnet-name snet-aks-nodes --subnet-prefix 10.20.0.0/20

az network vnet subnet create -g $RG --vnet-name $VNET \
  -n snet-aks-ilb --address-prefix 10.20.16.0/24

az network vnet subnet create -g $RG --vnet-name $VNET \
  -n GatewaySubnet --address-prefix 10.20.255.0/27

A VNet is Azure's equivalent of a Google VPC. The gateway subnet must be named exactly GatewaySubnet and cannot be smaller than /27.

Attach no network security group, or NSG, to it, add no 0.0.0.0/0 route, and leave BGP route propagation enabled.

Azure allocates public addresses immediately, and that unblocks the Google side.

az network public-ip create -n pip-vpngw-0 -g $RG -l $LOC \
  --allocation-method Static --sku Standard --version IPv4 --zone 1 2 3
az network public-ip create -n pip-vpngw-1 -g $RG -l $LOC \
  --allocation-method Static --sku Standard --version IPv4 --zone 1 2 3

az network public-ip show -g $RG -n pip-vpngw-0 --query ipAddress -o tsv
az network public-ip show -g $RG -n pip-vpngw-1 --query ipAddress -o tsv

az network vnet-gateway create -n $GW -g $RG -l $LOC \
  --vnet $VNET \
  --public-ip-addresses pip-vpngw-0 pip-vpngw-1 \
  --gateway-type Vpn --vpn-type RouteBased \
  --sku VpnGw2AZ --vpn-gateway-generation Generation2 \
  --asn 65020 \
  --no-wait

Keep the two addresses that show prints. There is no --active-active flag: two space-separated names on --public-ip-addresses is the whole mechanism. One name gives an active-standby gateway, and Google can then build only one usable tunnel.

Use a zone-redundant SKU, Azure's name for a product tier. Azure blocked creation of the non-zonal VpnGw1 through VpnGw5 tiers on 1 November 2025.

Microsoft has scheduled them to retire at the end of September 2026. VpnGw1AZ exists only in Generation1, so Generation2 starts at VpnGw2AZ.

Once the Google side is finished, wait for the gateway and check its shape.

az network vnet-gateway wait -n $GW -g $RG --created --timeout 5400

az network vnet-gateway show -n $GW -g $RG \
  --query "{activeActive:activeActive, sku:sku.name, gen:vpnGatewayGeneration, asn:bgpSettings.asn}"

Healthy output reads activeActive: true, sku: VpnGw2AZ, gen: Generation2 and asn: 65020. Two entries under bgpSettings.bgpPeeringAddresses prove that active-active took effect.

Google Cloud: network, gateway and router

Substitute the two Azure addresses into the first two variables.

export PROJECT_ID=my-project
export REGION=europe-west1
export AZURE_PIP_0=REPLACE_WITH_FIRST_AZURE_ADDRESS
export AZURE_PIP_1=REPLACE_WITH_SECOND_AZURE_ADDRESS
export PSK_0="$(openssl rand -base64 24)"
export PSK_1="$(openssl rand -base64 24)"

gcloud compute networks create gke-vpc \
  --project="$PROJECT_ID" --subnet-mode=custom --bgp-routing-mode=global

gcloud compute networks subnets create gke-nodes-ew1 \
  --project="$PROJECT_ID" --network=gke-vpc --region="$REGION" \
  --range=10.10.0.0/20 \
  --secondary-range=gke-pods=10.10.128.0/17

gcloud compute vpn-gateways create ha-vpn-ew1 \
  --project="$PROJECT_ID" --network=gke-vpc --region="$REGION"

gcloud compute vpn-gateways describe ha-vpn-ew1 \
  --project="$PROJECT_ID" --region="$REGION" \
  --format="value(vpnInterfaces[].ipAddress)"

gcloud compute routers create cr-ew1 \
  --project="$PROJECT_ID" --network=gke-vpc --region="$REGION" \
  --asn=65010 --keepalive-interval=60

gcloud compute external-vpn-gateways create azure-gw \
  --project="$PROJECT_ID" \
  --interfaces "0=${AZURE_PIP_0},1=${AZURE_PIP_1}"

gcloud compute external-vpn-gateways describe azure-gw \
  --project="$PROJECT_ID" --format="value(redundancyType)"

A PSK is a pre-shared key, and each tunnel gets its own. Azure rejects space, hyphen and tilde, none of which the base64 alphabet produces. Do not use -base64url, which produces hyphens.

The describe on the HA VPN gateway must print two addresses. One address means a single-interface gateway, which carries no availability SLA, or service level agreement, at all.

Keep both, because Azure needs them for its local network gateways.

The external VPN gateway describes the far side, and its describe must return TWO_IPS_REDUNDANCY. The secondary range gke-pods becomes a subnet route whether or not a cluster uses it. The next section handles that.

Two flags are judgement calls rather than documented requirements. --bgp-routing-mode=global prevents a silent breakage when a second region appears. --keepalive-interval=60 gives a 180 second hold timer, which matches Azure's fixed 60 and 180.

Two tunnels and two BGP sessions

gcloud compute vpn-tunnels create tun-azure-0 \
  --project="$PROJECT_ID" --region="$REGION" \
  --vpn-gateway=ha-vpn-ew1 --interface=0 \
  --peer-external-gateway=azure-gw --peer-external-gateway-interface=0 \
  --ike-version=2 --shared-secret="$PSK_0" --router=cr-ew1

gcloud compute vpn-tunnels create tun-azure-1 \
  --project="$PROJECT_ID" --region="$REGION" \
  --vpn-gateway=ha-vpn-ew1 --interface=1 \
  --peer-external-gateway=azure-gw --peer-external-gateway-interface=1 \
  --ike-version=2 --shared-secret="$PSK_1" --router=cr-ew1

Pair interface 0 with interface 0, and interface 1 with interface 1. That pairing earns the 99.99% SLA. Google names the failure: one tunnel from a single HA VPN interface is not enough redundancy.

Give the external VPN gateway two interfaces, not four. Four is the AWS shape, two Site-to-Site connections with two addresses each. Four is also a legal count here, so passing four succeeds.

Two of the tunnels you build against it then stay unusable and consume Azure quota.

The traffic selector flags are absent on purpose, because HA VPN's selectors are output-only and always 0.0.0.0/0. Azure's UsePolicyBasedTrafficSelectors stays at False for the same reason.

gcloud compute routers add-interface cr-ew1 \
  --project="$PROJECT_ID" --region="$REGION" \
  --interface-name=if-azure-0 --vpn-tunnel=tun-azure-0 \
  --ip-address=169.254.21.2 --mask-length=30

gcloud compute routers add-bgp-peer cr-ew1 \
  --project="$PROJECT_ID" --region="$REGION" \
  --peer-name=bgp-azure-0 --interface=if-azure-0 \
  --peer-ip-address=169.254.21.1 --peer-asn=65020 \
  --advertisement-mode=CUSTOM \
  --set-advertisement-ranges=10.10.0.0/20=gke-nodes-and-ilbs

gcloud compute routers add-interface cr-ew1 \
  --project="$PROJECT_ID" --region="$REGION" \
  --interface-name=if-azure-1 --vpn-tunnel=tun-azure-1 \
  --ip-address=169.254.22.2 --mask-length=30

gcloud compute routers add-bgp-peer cr-ew1 \
  --project="$PROJECT_ID" --region="$REGION" \
  --peer-name=bgp-azure-1 --interface=if-azure-1 \
  --peer-ip-address=169.254.22.1 --peer-asn=65020 \
  --advertisement-mode=CUSTOM \
  --set-advertisement-ranges=10.10.0.0/20=gke-nodes-and-ilbs

Each BGP session needs its own /30, and --mask-length is always 30. The tunnels will stay at WAITING_FOR_FULL_CONFIG or FIRST_HANDSHAKE until the Azure connection resources exist, which is expected here.

Do not use the --bfd-* flags, even though add-bgp-peer accepts them. Neither side supports BFD, bidirectional forwarding detection, on this path. Failure detection here is DPD, dead peer detection, plus the BGP hold timer.

At worst that takes about three minutes. Do not change the ciphers either, because both vendors ship working tutorials on the defaults.

What the Cloud Router advertises

This is the most important setting in the post, and it is one flag.

Leave the Cloud Router on default advertisement mode and it advertises the GKE Pod secondary range to Azure. It does this silently, with every status green.

A VPC-native GKE cluster puts Pod addresses in a subnet secondary range. Google's alias IP page states the result: a configured Cloud Router automatically advertises the secondary subnet range.

Google documents the fix under its own name, "Hide Pod IP addresses by using Cloud VPN". It says to remove the Pod range from the advertisements on your Cloud Routers, which is what --advertisement-mode=CUSTOM with an explicit range list does.

No single Google sentence says that default advertisement mode advertises secondary ranges. I assembled that conclusion from the alias IP page and the IP address management strategies page.

So do not trust the reasoning, mine included. Verify it with gcloud compute routers get-status, which Layer 2 and Layer 3 below run.

Never pass --set-advertisement-groups. ALL_SUBNETS sounds like a filter and is not one. It means advertise all available subnets, which returns the Pod range to the advertisement.

Set the range list per session, as above, because a session's custom routes override the router's own.

Back to Azure: the APIPA addresses and the connection resources

The az command line interface has no way to set the custom APIPA BGP addresses. --bgp-peering-address on az network vnet-gateway create is singular and is not that list.

The documented paths are the Azure portal, Azure Resource Manager templates or Bicep, and PowerShell.

Open the gateway, then Configuration, then set Configure BGP to Enabled. Put 169.254.21.1 in the Azure APIPA BGP IP address field, and 169.254.22.1 in the second custom field.

az network vnet-gateway show -n vpngw-aue -g rg-aks-aue \
  --query "bgpSettings.bgpPeeringAddresses[].{ipconf:ipconfigurationId, custom:customBgpIpAddresses}"

Healthy output is two entries, with custom reading ["169.254.21.1"] and ["169.254.22.1"].

Azure does not start BGP sessions from an APIPA source address, so the Cloud Router must start them. Google never states that its router starts them, so treat that as an inference.

A half-finished Google side leaves the Azure connection resource reading Connected, and BGP never starts.

Set HA_VPN_IF_0 and HA_VPN_IF_1 to the two addresses that vpn-gateways describe printed.

az network local-gateway create -n lng-gcp-0 -g $RG -l $LOC \
  --gateway-ip-address $HA_VPN_IF_0 --asn 65010 --bgp-peering-address 169.254.21.2

az network local-gateway create -n lng-gcp-1 -g $RG -l $LOC \
  --gateway-ip-address $HA_VPN_IF_1 --asn 65010 --bgp-peering-address 169.254.22.2

az network vpn-connection create -n cx-gcp-0 -g $RG -l $LOC \
  --vnet-gateway1 $GW --local-gateway2 lng-gcp-0 --enable-bgp --shared-key "$PSK_0"

az network vpn-connection create -n cx-gcp-1 -g $RG -l $LOC \
  --vnet-gateway1 $GW --local-gateway2 lng-gcp-1 --enable-bgp --shared-key "$PSK_1"

--gateway-ip-address takes the HA VPN interface address, not the APIPA address.

There is no --local-address-prefixes here, deliberately. Microsoft says not to add the peer's /32 route, because anything placed there becomes a static route that competes with the learned ones.

--enable-bgp is off by default and is not optional. Without it Azure ignores BGP on the connection resource, whatever the gateways say.

The firewall does not follow the routes

gcloud compute firewall-rules create allow-aks-nodes \
  --project="$PROJECT_ID" --network=gke-vpc \
  --direction=INGRESS --action=ALLOW \
  --source-ranges=10.20.0.0/20 --rules=tcp,udp,icmp

BGP installs routes and does nothing for the data plane. A custom-mode VPC has only the implied deny on ingress at priority 65535.

The source range is the AKS node subnet, not the whole VNet, because Azure CNI Overlay applies SNAT, source network address translation. Google's rules are stateful, so you never write a return rule.

Azure usually needs nothing, because its default AllowVNetInBound rule covers connected on-premises address spaces. Microsoft does not document whether it covers BGP-learned prefixes, so verify with az network watcher test-ip-flow.

Proving it, in four layers

Layer 1: the tunnels

gcloud compute vpn-tunnels list --project="$PROJECT_ID" \
  --filter="region:europe-west1" \
  --format='table(name,status,detailedStatus)'

Healthy output is two rows reading ESTABLISHED, the only value that means up. Four others occur in practice.

  • FIRST_HANDSHAKE: phase 1 finished and the tunnel is not up.
  • AUTHORIZATION_ERROR: the shared secret does not match.
  • NEGOTIATION_FAILURE: the two sides agreed on no cipher.
  • NO_INCOMING_PACKETS: Google is sending and Azure sends nothing back.

Google deprecated NETWORK_ERROR and replaced it with NO_INCOMING_PACKETS.

One more command answers a different question: did you build the topology the SLA requires?

gcloud compute vpn-gateways get-status ha-vpn-ew1 \
  --project="$PROJECT_ID" --region="$REGION"

Healthy output is CONNECTION_REDUNDANCY_MET. The two failures to recognise are CONNECTION_REDUNDANCY_NOT_MET and INCOMPLETE_TUNNELS_COVERAGE.

Layer 2: the BGP sessions

gcloud compute routers get-status cr-ew1 \
  --project="$PROJECT_ID" --region="$REGION" \
  --format='table(result.bgpPeerStatus[].name,
                  result.bgpPeerStatus[].status,
                  result.bgpPeerStatus[].state,
                  result.bgpPeerStatus[].numLearnedRoutes,
                  result.bgpPeerStatus[].uptime)'

az network vnet-gateway list-bgp-peer-status -g $RG -n $GW \
  --query "value[].{neighbor:neighbor, asn:asn, state:state, routes:routesReceived}" -o table

Google should print two rows with status: UP, state: Established and numLearnedRoutes of at least 1. Azure should print two rows for 169.254.21.2 and 169.254.22.2, with state: Connected and asn: 65010.

Memorise the two words. Google says Established and Azure says Connected, and Azure's list of states contains no Established at all.

An uptime that resets on every run means the session is flapping, which is the signature of mismatched timers.

Layer 3: the routes

gcloud compute routers get-status cr-ew1 --project="$PROJECT_ID" --region="$REGION" \
  --format='flattened(result.bgpPeerStatus[].name,result.bgpPeerStatus[].advertisedRoutes[].destRange)'

gcloud compute routers get-status cr-ew1 --project="$PROJECT_ID" --region="$REGION" \
  --format='table(result.bestRoutes[].destRange, result.bestRoutes[].nextHopIp)'

az network vnet-gateway list-learned-routes -g $RG -n $GW \
  --query "value[?origin=='EBgp'].{network:network, nextHop:nextHop, asPath:asPath}" -o table

Three things must be true. Google advertises 10.10.0.0/20 per peer and nothing else. Google learns 10.20.0.0/16 with next hops 169.254.21.1 and 169.254.22.1. Azure learns 10.10.0.0/20 with asPath containing 65010 and no Pod range.

Seeing 10.20.0.0/16 rather than 10.20.0.0/20 is correct. Azure advertises VNet address prefixes and has no equivalent of custom advertisement mode.

gcloud compute routes list shows none of this, because Google does not expose dynamic routes through gcloud or the API.

Layer 4: real traffic

az network vpn-connection list -g $RG \
  --query "[].{name:name, status:connectionStatus, in:ingressBytesTransferred, out:egressBytesTransferred}" \
  -o table

Run that twice, a minute apart. Connected with frozen counters proves nothing. If ingress stays flat while egress rises, Azure sends packets and receives none.

Then send a real packet, both ways. Get the addresses with kubectl get nodes -o wide.

kubectl --context=gke debug node/GKE_NODE_NAME -it \
  --image=nicolaka/netshoot:v0.14 --profile=sysadmin -- ping -c 3 AKS_NODE_IP

kubectl --context=aks debug node/AKS_NODE_NAME -it \
  --image=nicolaka/netshoot:v0.14 --profile=sysadmin -- ping -c 3 GKE_NODE_IP

Run both directions, because one-way success is the normal state of a half-configured VPN. ICMP, the protocol ping uses, works only because the Google rule allowed it, so a ping tests the firewall too.

Never ping an ILB address, which fails by design on both clouds.

These commands run in the node's network namespace, so the source address is already a node address. A probe from inside a Pod would leave with the Pod address and fail for a reason unrelated to the VPN.

Fixing that, and reaching services rather than nodes, is the next post.

Practices worth following

  • Build a tunnel from each HA VPN interface. An idle Google interface costs you the SLA.
  • Never leave the Cloud Router on default advertisement mode. Verify what you advertise.
  • Choose the two /30 ranges from Azure's window before you build anything.
  • Set both ASNs explicitly, and do not inherit Azure's reserved 65515 default.
  • Use a different PSK per tunnel, generated with openssl rand -base64.
  • Match the Cloud Router keepalive to Azure's fixed 60 and 180. There is no BFD here, so plan for a failover measured in minutes.
  • Prefer the free checks above. Google Connectivity Tests bills $0.15 per test after a small monthly free allowance. Each Azure Network Watcher check bills $0.001 after 1,000 per month.

Change one BGP session at a time when you edit the advertisement, and check between the two. Traffic stops instantly when you switch to custom advertisement mode without a range you still need, and every status stays green.