When GCE workloads or Cloud NAT gateways connect to external partner endpoints, it is common to run into asymmetric reachability issues - connections from one region work normally, while connections from another region (or from a newly reserved NAT IP in the same region) time out on the initial TCP SYN. In most cases, this happens because the destination firewall uses a static IP allowlist that hasn't been updated, or relies on a stale GeoIP database (for example, many regional prefixes in asia-* and europe-* are carved out of Google's ARIN 34.0.0.0/8 block, which still shows Country: US in raw WHOIS if the firewall doesn't ingest Google's IP feeds). To verify how Google advertises your egress IPs externally, you can cross-reference your project's reserved addresses from the Method: addresses.list | Compute Engine API against cloud.json.
Google publishes cloud.json as the machine-readable feed of all customer-usable external IPv4 and IPv6 ranges by region, as documented in Configure Private Google Access | Virtual Private Cloud - Google Cloud Documentation. When you are setting up a new Cloud NAT gateway or troubleshooting an outbound block with a partner network team, you can use this feed to quickly confirm which published CIDR block covers each of your external IPs and when the feed was last updated.
Automating the check with gcloud and jq
To avoid manually searching through more than a thousand prefixes in cloud.json, you can script this check. The script below uses gcloud compute addresses list | Google Cloud SDK to grab your reserved external IPs as JSON, fetches cloud.json, and uses jq to match each address to its published CIDR range and regional scope:
#!/bin/bash
# --- Configuration (Replace with your details) ---
PROJECT="<YOUR_PROJECT_ID>"
REGION="<YOUR_REGION>"
CLOUD_IPS_URL="https://www.gstatic.com/ipranges/cloud.json"
echo "Checking external IPs in $PROJECT ($REGION) against $CLOUD_IPS_URL..."
echo "------------------------------------------------------------------------"
# Fetch reserved external IPs and match each IP against cloud.json CIDRs in jq
jq -rn \
--argjson addrs "$(gcloud compute addresses list \
--project="$PROJECT" \
--regions="$REGION" \
--filter="addressType=EXTERNAL" \
--format=json)" \
--argjson cloud "$(curl -s "$CLOUD_IPS_URL")" '
# Convert an IPv4 address string into a 32-bit integer for CIDR matching
def ip2int:
split(".") | map(tonumber) | (.[0] * 16777216) + (.[1] * 65536) + (.[2] * 256) + .[3];
$addrs[] as $a |
($a.address | ip2int) as $ip_int |
(
[
$cloud.prefixes[]
| select(has("ipv4Prefix"))
| (.ipv4Prefix | split("/")) as $parts
| ($parts[0] | ip2int) as $net_int
| ($parts[1] | tonumber) as $mask
| (pow(2; 32 - $mask)) as $size
| select($ip_int >= $net_int and $ip_int < ($net_int + $size))
][0] // { ipv4Prefix: "UNLISTED", scope: "UNKNOWN" }
) as $match |
"Name: \($a.name) | IP: \($a.address) | Tier: \($a.networkTier) | Prefix: \($match.ipv4Prefix) | Scope: \($match.scope)"
'
Let's list the reserved external IP addresses in our target region first:
~ gcloud compute addresses list \
--project="my-gcp-project" \
--regions="us-south1" \
--format='table(name, address, region.basename(), networkTier, status)'
NAME ADDRESS REGION NETWORK_TIER STATUS
nat-ip-a0 34.174.136.135 us-south1 PREMIUM RESERVED
nat-ip-a1 34.174.242.159 us-south1 PREMIUM RESERVED
nat-ip-a2 34.0.132.72 us-south1 STANDARD RESERVED
Running the script maps each external IP to its parent CIDR block and regional scope in cloud.json:
~ bash gce-egress-prefix-check.sh
Checking external IPs in my-gcp-project (us-south1) against https://www.gstatic.com/ipranges/cloud.json...
------------------------------------------------------------------------
Name: nat-ip-a0 | IP: 34.174.136.135 | Tier: PREMIUM | Prefix: 34.174.0.0/16 | Scope: us-south1
Name: nat-ip-a1 | IP: 34.174.242.159 | Tier: PREMIUM | Prefix: 34.174.0.0/16 | Scope: us-south1
Name: nat-ip-a2 | IP: 34.0.132.72 | Tier: STANDARD | Prefix: 34.0.128.0/19 | Scope: us-south1
Under the hood: Reading the cloud.json payload
When you fetch cloud.json, the response includes a top-level syncToken and creationTime, followed by a prefixes array. For IPv4 ranges in us-south1, the JSON payload looks like this:
{
"syncToken": "1791792540784",
"creationTime": "2026-10-12T01:09:00.784842",
"prefixes": [
{
"ipv4Prefix": "34.0.128.0/19",
"service": "Google Cloud",
"scope": "us-south1"
},
{
"ipv4Prefix": "34.174.0.0/16",
"service": "Google Cloud",
"scope": "us-south1"
}
]
}
Notice that nat-ip-a0 (PREMIUM tier) and nat-ip-a2 (STANDARD tier) come from different CIDR blocks (34.174.0.0/16 vs. 34.0.128.0/19) within us-south1. Compute Engine tracks the network service tier on each address resource (networkTier), while cloud.json groups both Premium and Standard tier external ranges under the same regional scope, as described in IP addresses | Compute Engine - Google Cloud Documentation:
{
"address": "34.0.132.72",
"addressType": "EXTERNAL",
"kind": "compute#address",
"name": "nat-ip-a2",
"networkTier": "STANDARD",
"region": "https://www.googleapis.com/compute/v1/projects/my-gcp-project/regions/us-south1",
"status": "RESERVED"
}
If you are using external IPv6 ranges on dual-stack subnets, cloud.json also includes the regional /44 IPv6 blocks under the ipv6Prefix field:
{
"ipv6Prefix": "2600:1901:8140::/44",
"service": "Google Cloud",
"scope": "us-south1"
}
A few quick constraints
- API Support: Listing static external IPs works with both regional Method: addresses.list | Compute Engine and global/cross-region Method: addresses.aggregatedList | Compute Engine.
- gcloud Support: Filter out internal RFC 1918 reservations by passing
--filter="addressType=EXTERNAL"to gcloud compute addresses list | Google Cloud SDK. If your VMs use ephemeral external IPs instead of reserved addresses, pull.networkInterfaces[].accessConfigs[].natIPfrom gcloud compute instances list | Google Cloud SDK. -
cloud.jsonvs.goog.json: Google publishes two separate IP range files.cloud.jsonlists customer-usable Google Cloud ranges with regionalscopevalues, whilegoog.jsoncovers all Google-owned prefixes without regional scopes. - IPv4 vs. IPv6 Keys: Each entry in
.prefixes[]uses eitheripv4Prefixoripv6Prefix. Make sure to filter withselect(has("ipv4Prefix"))injqbefore splitting IPv4 octets so IPv6 entries don't cause an error.
Next steps
Next time an external endpoint drops traffic from a new region or Cloud NAT gateway, run this script to confirm the exact ipv4Prefix and creationTime in cloud.json. If the destination firewall hasn't picked up the prefix yet, sharing the specific regional block (or your reserved /32 NAT IPs) gives the remote network team what they need to update their allowlist.
Top comments (1)
One small dual-stack edge case in the script:
select(has("ipv4Prefix"))filters the cloud.json entries, but$a.addressreachesip2intbefore that filter. An external IPv6 reservation in the address list would therefore reach the IPv4-onlysplit(".") | map(tonumber)path.For this IPv4 example, filter the address records to
ipVersion == "IPV4"before callingip2int, or explicitly handle IPv6 separately. The Compute API documentsipVersionasIPV4orIPV6: docs.cloud.google.com/compute/docs...A mixed IPv4/IPv6 fixture would be a useful check for that case. This is a code-reading observation; I have not run it against your project.
AI-assisted technical feedback; I work on Lisar Connect.