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 IPv4 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 IPv4 address 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];
# Regional IPv4 addresses omit ipVersion in the API response, while IPv6 reservations set "IPV6"
$addrs[] | select((.ipVersion // "IPV4") == "IPV4") 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: In
addresses.list, the Compute Engine API populates"ipVersion": "IPV6"on IPv6 reservations but omitsipVersionon regional IPv4 addresses, so filter withselect((.ipVersion // "IPV4") == "IPV4")before passing.addresstoip2int. Similarly, each entry incloud.json.prefixes[]uses eitheripv4Prefixoripv6Prefix, so guard IPv4 prefix splitting withselect(has("ipv4Prefix")).
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 (6)
Nice write-up. Doing the CIDR match entirely in jq with the
ip2inthelper is a neat approach, since it avoids pulling in Python oripcalcfor something you'd want to run on any jump box or Cloud Shell session.The detail that the Premium and Standard tier addresses land in different blocks (34.174.0.0/16 vs. 34.0.128.0/19) but share the same regional scope in cloud.json was new to me, and it's a useful thing to know before telling a partner team which ranges to allowlist. I also appreciated the note about stale GeoIP databases showing Country: US for regional prefixes in 34.0.0.0/8, which is the kind of root cause that eats hours when a firewall silently drops the SYN.
One small suggestion: it might be worth adding a short example of the
aggregatedListvariant for people with NAT IPs spread across multiple regions, or a line on how to diff thecreationTimebetween runs so you can tell whether a feed update lines up with when the connectivity problems began. Thanks for sharing this.Thanks, glad it was useful.
For multi-region checks, omitting
--regions="$REGION"fromgcloud compute addresses listautomatically callsaddresses.aggregatedListunder the hood and flattens every regional and global address into the same top-level JSON array. You can pull the region name from$a.regioninjq(or if you're calling the raw RESTaddresses.aggregatedListendpoint directly, unwrap.itemswith[.items[]?.addresses[]?]):To check whether a
cloud.jsonupdate lines up with when a connectivity issue started, save a local copy of the feed (for example,cloud.prev.json) and diffcreationTimeand the prefix list between runs usingjqarray subtraction:How can you automate IP range updates when Google changes egress IPs?
There are two ways to handle this depending on whether you control the egress path:
/32addresses so you don't have to track regional prefix changes incloud.json.syncTokenincloud.jsonfor dynamic ranges: If you need to allowlist regional Google Cloud ranges dynamically on a firewall or WAF, pollhttps://www.gstatic.com/ipranges/cloud.jsonon a schedule (for example, once a day via a cron job or Cloud Scheduler) and compare the top-levelsyncTokenagainst the last value you processed. WhensyncTokenchanges, extract the current prefixes for your target region(s) and push them to your firewall's address group API: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.
Thanks for the feedback. If you have regional external IPv6 reservations in the same region,
$addrs[]will pass those IPv6 addresses intoip2intand fail onsplit(".") | map(tonumber).One quirk with the Compute Engine API when filtering on
ipVersion: whileipVersionis documented in the schema,addresses.listonly populates"ipVersion": "IPV6"on IPv6 reservations and omits theipVersionfield entirely on regional IPv4compute#addressobjects (as shown in thenat-ip-a2JSON snippet above). Because.ipVersionevaluates tonullfor regional IPv4 addresses, filtering strictly onselect(.ipVersion == "IPV4")(or--filter="ipVersion=IPV4"ingcloud) returns an empty result.To handle mixed IPv4/IPv6 reservations safely in
jq, default missingipVersionvalues to"IPV4"before callingip2int:$addrs[] | select((.ipVersion // "IPV4") == "IPV4") as $a |