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.
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.
| Range | What it is | Crosses the VPN |
|---|---|---|
10.10.0.0/16 | Google VPC gke-vpc, a virtual private cloud | Planning aggregate only |
10.10.0.0/20 | GKE node subnet gke-nodes-ew1 | Yes, advertised |
10.10.128.0/17 | GKE Pod secondary range gke-pods | No, never |
10.10.15.240 | orders ILB address, reserved as orders-ilb-vip | Yes, inside the node subnet |
10.20.0.0/16 | Azure virtual network, or VNet, vnet-aks-aue | Yes, all of it |
10.20.0.0/20 | AKS node subnet snet-aks-nodes | Yes |
10.20.16.0/24 | AKS load balancer subnet snet-aks-ilb | Yes |
10.20.16.10 | billing ILB address | Yes |
10.20.255.0/27 | GatewaySubnet | Yes |
10.244.0.0/16 | AKS Pod CIDR, Azure CNI Overlay | No, never |
172.16.0.0/16 | AKS Service CIDR | No |
169.254.21.0/30 | BGP session 0, Azure .1, Cloud Router .2 | Link-local, not routed |
169.254.22.0/30 | BGP session 1, Azure .1, Cloud Router .2 | Link-local, not routed |
| ASN 65010 | Cloud Router cr-ew1 | An identifier, not a route |
| ASN 65020 | Azure VPN gateway vpngw-aue | An 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-keysNode 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 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/27A 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-waitKeep 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-ew1Pair 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-ilbsEach 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,icmpBGP 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 tableGoogle 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 tableThree 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 tableRun 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_IPRun 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
/30ranges 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.