Skip to content
All writing Part 08 of 12 · Remote-Controlling Chrome in Containers
Engineering · 4 min read

Headless Service: A Phone Directory Instead of a Switchboard

A Headless Service returns pod IPs directly instead of routing through a proxy. That one change fixes the CDP GUID race by pinning both calls to the same pod.

Regular vs Headless

Regular Service DNS → 10.96.0.50 proxy picks per call Pod A Pod B two calls can split Headless Service DNS → 10.0.1.5, 10.0.2.3 you pick one IP Pod A Pod B both calls hit same pod

Regular Service = a switchboard. You call one number. The operator routes you to a random desk. Two calls can land on different desks.

Headless Service = a phone directory. You get all extension numbers. You pick one and dial it directly.

Why This Fixes the GUID Race

The API pod looks up the Chrome Service in DNS. It gets all Chrome pod IPs (not one proxy IP). It picks one and uses that IP for both the GET and the WebSocket. Both calls land on the same Chrome. The GUID matches.

var chromePodRoundRobin atomic.Uint64

addrs, _ := net.LookupHost(hostname)
idx := chromePodRoundRobin.Add(1) % uint64(len(addrs))
podAddr := net.JoinHostPort(addrs[idx], port)
// use podAddr for BOTH GET and WebSocket

DNS returns only Ready pod IPs. Pending or failing pods do not appear. A new DNS lookup between retries can pick a different pod. The “escape a stuck pod” ability is preserved.

Inside One Request

Each request runs in its own goroutine. All five steps use the same local podAddr.

goroutine (one per request) 1. net.LookupHost("chrome-svc") → [10.0.1.5, 10.0.2.3, 10.0.3.7] 2. atomic round-robin → podAddr = "10.0.1.5:9222" (local var) 3. GET http://10.0.1.5:9222/json/version (fresh TCP) → Chrome returns { webSocketDebuggerUrl: "ws://127.0.0.1:9223/.../GUID-X" } 4. Replace 127.0.0.1 → 10.0.1.5 → same pod as step 3 5. WebSocket to 10.0.1.5 → navigate, screenshot, close Shared vs Local Shared: atomic counter only Local: podAddr, GUID, session No mutex. No data races.

The round-robin counter (atomic.Uint64) is the only shared state. Everything else stays local: podAddr, GUID, the chromedp session. No mutex. No data races. See why fresh TCP matters for scaling.

clusterIP is immutable. You cannot change an existing Service from regular to headless. Kubernetes rejects the update. You must delete it and recreate it. ArgoCD needs Replace=true and Force=true sync options.


References:

Related: see why fresh TCP matters for scaling, the GUID race this fixes, the DNS capacity model, or go back to the series overview.

Tags #kubernetes #chrome-devtools #containers
// connect

Be brave | Be wise | Be grateful

21 BreakinCode

// elsewhere
LinkedInMedium (lang: en)Life RecordYoutube
wh:~$William Hung· © 2026 Taipei · GMT+8 · Available for collaboration