Some sites block datacenter IPs, or only accept a registered IP. For those requests a Cloud Run job has to leave through a residential IP, while everything else stays on the normal cloud network. We tunnel them with wireproxy and drive headless Chrome through it.
The short version
wireproxy on Cloud Run with headless Chrome works once these are in place:
| Symptom | Fix |
|---|---|
wireproxy exits at start: Unable to update bind: operation not supported | Cloud Run execution environment gen2 (gen1 is gVisor), with CPU ≥ 1 |
curl and requests go through the tunnel, Chrome gets ERR_CONNECTION_CLOSED | WireGuard MTU 1100; Chrome’s TLS ClientHello (~1.7 KB) is lost on the cloud-to-router path at 1420 or 1280 |
| Tunnel stalls after the first few requests | Send wireproxy’s log to a file, not an unread pipe |
The rest of this post is the full setup.
Layout
Cloud Run (Firebase Function, gen2)
├─ wireproxy ── WireGuard (UDP) ──> router (WireGuard server) ──> internet, residential IP
│ ├─ 127.0.0.1:<http> HTTP proxy <- requests
│ └─ 127.0.0.1:<socks> SOCKS5 proxy <- headless Chrome
└─ everything else goes out directly
wireproxy is WireGuard in user space that exposes the tunnel as local HTTP and SOCKS5 proxies. A Cloud Run container cannot create a kernel WireGuard interface, so this is the way in.
Router side
A router with a built-in WireGuard server and DDNS.
- Dynamic DNS for the line, so the endpoint survives an IP change (the router’s built-in DDNS).
- WireGuard server on the router. Note its public key and listening port.
- One peer for the cloud jobs. Generate the key pair yourself and give the router only the public key;
the private key goes into the function’s environment and nowhere else.
Assign the peer a fixed tunnel address (a
/32inside the router’s VPN subnet).
Function side
Start the tunnel, use it, stop it
CONF = """[Interface]
PrivateKey = {private_key}
Address = {address} # e.g. 10.x.x.101/32
DNS = 1.1.1.1
MTU = 1100
[Peer]
PublicKey = {router_public_key}
Endpoint = {ddns_host}:{port}
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
[http]
BindAddress = 127.0.0.1:{http_port}
[Socks5]
BindAddress = 127.0.0.1:{socks_port}
"""
@contextlib.contextmanager
def residential_proxy(scheme="http"):
if not os.environ.get("K_SERVICE"): # not on Cloud Run: no tunnel
yield None
return
http_port, socks_port = free_port(), free_port()
work = tempfile.mkdtemp()
conf = write_conf(work, CONF, http_port, socks_port)
log = open(os.path.join(work, "wireproxy.log"), "wb")
proc = subprocess.Popen([wireproxy_path(), "-c", conf], stdout=log, stderr=subprocess.STDOUT)
proxy = f"http://127.0.0.1:{http_port}"
try:
wait_for_port(http_port, proc)
router_ip = socket.gethostbyname(ddns_host)
out_ip = requests.get("https://api.ipify.org", proxies={"https": proxy}, timeout=30).text.strip()
if out_ip != router_ip:
raise RuntimeError(f"egress {out_ip} is not {router_ip}")
yield proxy if scheme == "http" else f"socks5://127.0.0.1:{socks_port}"
finally:
proc.terminate()
log.close()
shutil.rmtree(work, ignore_errors=True)
The points that matter:
- MTU 1100. With the usual 1420 (or even 1280), small requests went through but large upstream packets did not, which shows up as a browser TLS handshake that never completes. 1100 leaves room on the cloud-to-router path.
- Log to a file, not a pipe. wireproxy logs a lot; an unread
stderr=PIPEfills up and stalls the tunnel. - Check the egress IP against the DDNS address before handing the proxy out, and fail loudly if it differs.
- Pin the binary. The function downloads a fixed wireproxy release and checks its SHA-256 before running it.
- Skip the tunnel outside the cloud (
K_SERVICEis set only on Cloud Run), so the same code runs locally.
Using it
# plain HTTP client
with residential_proxy() as proxy:
proxies = {"http": proxy, "https": proxy} if proxy else None
r = requests.post(url, data=form, proxies=proxies, timeout=60)
# headless Chrome (Selenium)
with residential_proxy("socks5") as proxy:
opts = webdriver.ChromeOptions()
opts.binary_location = chrome_headless_shell # Chrome for Testing, fetched to /tmp at run time
if proxy:
opts.add_argument(f"--proxy-server={proxy}") # SOCKS5: name resolution happens on the router side too
driver = webdriver.Chrome(service=Service(chromedriver), options=opts)
Chrome comes from Chrome for Testing
(chrome-headless-shell plus chromedriver, linux64), downloaded once per instance into /tmp. The Functions
runtime image already carries the libraries it needs.
Cloud Run settings
| Setting | Value | Why |
|---|---|---|
| Execution environment | gen2 | gen1 (gVisor) rejects the UDP socket options WireGuard uses (Unable to update bind: operation not supported) |
| CPU | 1 or more | gen2 does not accept fractional CPU |
| Memory | 4 GiB, 2 vCPU for the Chrome job | headless Chrome on a heavy JS site |
| Max instances | 1 | one peer key; two tunnels with the same key knock each other off |
Firebase’s function options have no execution-environment switch. Set it once on the underlying Cloud Run
service after the first deploy; later firebase deploy runs keep it:
gcloud run services update <service> --region <region> --execution-environment gen2
cpu and memory do go in the decorator:
@scheduler_fn.on_schedule(
schedule="0 1 * * 1-5",
memory=options.MemoryOption.GB_4,
cpu=2,
timeout_sec=60 * 15,
max_instances=1,
)
Checklist
- Router: DDNS, WireGuard server, one peer with your public key and a fixed
/32. - Function env: private key, tunnel address, router public key,
ddns-host:port. - wireproxy: pinned version and hash,
MTU = 1100, HTTP and SOCKS5 on localhost, logs to a file. - Cloud Run: gen2, CPU ≥ 1, one instance.
- Route only the requests that need the residential IP; keep the rest direct.