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:

SymptomFix
wireproxy exits at start: Unable to update bind: operation not supportedCloud Run execution environment gen2 (gen1 is gVisor), with CPU ≥ 1
curl and requests go through the tunnel, Chrome gets ERR_CONNECTION_CLOSEDWireGuard 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 requestsSend 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.

  1. Dynamic DNS for the line, so the endpoint survives an IP change (the router’s built-in DDNS).
  2. WireGuard server on the router. Note its public key and listening port.
  3. 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 /32 inside 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=PIPE fills 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_SERVICE is 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

SettingValueWhy
Execution environmentgen2gen1 (gVisor) rejects the UDP socket options WireGuard uses (Unable to update bind: operation not supported)
CPU1 or moregen2 does not accept fractional CPU
Memory4 GiB, 2 vCPU for the Chrome jobheadless Chrome on a heavy JS site
Max instances1one 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.