Google Cloud DNS provider: changes.create carries no context or timeout, so one slow response stalls the reconcile loop indefinitely
What happened:
In a production cluster, ExternalDNS stopped applying DNS record changes for ~30 minutes. No records were created or updated during the window, and the process never logged an error or restarted.
The cause was a single changes.create call to the Cloud DNS API that did not return for roughly 30 minutes, at which point the API responded 502. For that entire period the controller was blocked inside that one HTTP call. Once it returned, ExternalDNS recovered on its own and applied a catch-up batch covering everything that had backlogged.
Seven downstream workloads were waiting on records that never appeared. Pods that depend on DNS resolution at startup restarted or crash-looped for the duration (one up to 13 times).
There are four places where something could have bounded this, and none of them do. Line references are against master (b6a23ab9).
1. No timeout on the HTTP client. google.DefaultClient returns a client whose Timeout is the zero value, and NewInstrumentedClient replaces Transport while leaving Timeout untouched:
// provider/google/google.go:135-142
gcloud, err := google.DefaultClient(ctx, dns.NdevClouddnsReadwriteScope)
gcloud = extdnshttp.NewInstrumentedClient(gcloud)
dnsClient, err := dns.NewService(ctx, option.WithHTTPClient(gcloud))NewInstrumentedClient (pkg/http/http.go:77-84) mutates and returns the same *http.Client, assigning only Transport. grep -n "Timeout:" provider/google/ pkg/http/ returns no matches.
2. No context on the mutation call. This is specific to changes.create, and it is worth being precise about the scope, because the read paths are fine:
ZonesandRecordsusePages(ctx, f)(provider/google/google.go:195,:231), andPagessetsc.ctx_ = ctxinternally (dns/v1/dns-gen.go:4338,:6153). Those calls do carry the reconcile context and are cancellable.- The write path does not.
submitChangeissues the call bare, despite already having a livectxin scope from its own signature:
// provider/google/google.go:305
if _, err := p.changesClient.Create(p.project, zone, c).Do(); err != nil {ChangesService.Create never sets ctx_ (dns-gen.go:2791-2797), so .Do() passes nil into gensupport.SendRequest, which has an explicit nil branch:
// google.golang.org/api/internal/gensupport/send.go:93-95
if ctx == nil {
return client.Do(req)
}
return send(ctx, client, req)So this one request runs with no context at all. ChangesCreateCall also uses SendRequest rather than SendRequestWithRetry, so there is no library-level retry either. A consequence: this call is not cancellable on shutdown — the SIGTERM context (controller/execute.go, contextWithSigtermHandler) has no path to it, so SIGTERM cannot interrupt it. Deleting the pod was the only way out. (time.Sleep(p.batchChangeInterval) at google.go:309 is uninterruptible for the same reason, which compounds this across many batches.)
Note also that the ctx given to dns.NewService(ctx, ...) is used for credential setup and does not propagate to requests — option.WithHTTPClient short-circuits the auth/transport wrapping entirely (api/transport/http/dial.go), and generated calls read only c.ctx_.
3. No transport-level bound. The base is http.DefaultTransport, which sets DialContext 30s, TLSHandshakeTimeout 10s, IdleConnTimeout 90s, ExpectContinueTimeout 1s — and leaves ResponseHeaderTimeout at 0. The chain is CustomRoundTripper → oauth2.Transport (with Base == nil, falling back to http.DefaultTransport). Nothing bounds time-to-first-byte on a connection that is already established. This matches the incident: the TCP connection stayed up, 30s keepalives held the socket open, and the API eventually answered after 30 minutes.
4. No liveness rescue. The reconcile loop in controller/controller.go:181 is single-goroutine and fully synchronous (no go func in the file):
for {
if c.ShouldRunOnce(time.Now()) {
if err := c.RunOnce(ctx); err != nil { ... }
}
select {
case <-ticker.C:
case <-ctx.Done():RunOnce runs Registry.Records (:76) → Source.Endpoints (:89) → plan.Calculate (:118) → Registry.ApplyChanges (:121) straight-line, so a blocked ApplyChanges blocks everything, and lastSyncTimestamp (:134) stops advancing. Meanwhile /healthz is a static literal served from a separate goroutine (go serveMetrics(...), controller/execute.go:92):
// controller/execute.go:239-242
http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
})It reported healthy for the whole 30 minutes, so the Kubernetes liveness probe never restarted the wedged pod. The stall was therefore unbounded in time rather than capped at a restart interval.
What you expected to happen:
A slow or hung Cloud DNS API response should fail the request after a bounded interval, so the reconcile loop can log the error, return, and retry on the next iteration. A single unlucky API call should not be able to halt reconciliation for an arbitrary length of time, and should not survive SIGTERM.
For reference, Google Cloud support guidance we received on this incident states that their API frontends enforce a maximum active request processing time of 60 seconds for standard API calls, which would make a client-side timeout of ~60-65s a safe bound that does not introduce false failures. The same guidance recommends truncated exponential backoff with jitter for retries (1s initial, 2.0 multiplier, capped at 30-60s). I do not have a public documentation link for those figures, so please treat them as indicative rather than authoritative.
How to reproduce it (as minimally and precisely as possible):
The failure is in the client plumbing rather than in any particular Kubernetes resource, so it reproduces with any Source that yields at least one pending change. The stall must be induced on the changes.create request specifically — a blanket network blackhole is not a valid reproduction, because the first API call in RunOnce is Registry.Records → Zones → Pages(ctx, ...), which carries the reconcile context and therefore aborts correctly on SIGTERM. Blackholing everything demonstrates a different, non-buggy code path.
Two ways to target the write path:
- Stub
changesServiceInterface(provider/google/google.go:61) with aCreatewhoseDo()blocks indefinitely, and drivesubmitChangewith a non-empty change. This is the minimal unit-level reproduction. - For an end-to-end reproduction, place a method- and path-selective proxy in front of the API that accepts and then never answers only
POST /dns/v1/projects/*/managedZones/*/changes, passing all other requests through.http.DefaultTransporthonoursProxyFromEnvironment, soHTTPS_PROXYis a viable injection point, but the proxy must be selective — and note that with anhttp://proxy the hang is on theCONNECTresponse, before any TLS handshake with the origin.
Expected observation either way: the process blocks in submitChange indefinitely, lastSyncTimestamp stops advancing, no error is logged, /healthz continues to return 200, and SIGTERM does not terminate the process.
Suggested fix, in rough order of value:
- Set an explicit
Timeouton the*http.Clientbefore passing it tooption.WithHTTPClient. This alone bounds the hang, becauseclient.Do(req)honoursclient.Timeouteven on the nil-context path (net/http/client.go,setRequestCancelinstalls a deadline context when the request has none; the no-opCustomRoundTripper.CancelRequestatpkg/http/http.go:54does not defeat this, andoauth2.Transportclones withreq.Context(), preserving it). Ideally configurable — a provider-scoped flag such as--google-request-timeoutwould sit naturally alongside the existing--google-batch-change-intervaland--google-batch-change-size(pkg/apis/externaldns/types.go:566-567). Note that--request-timeoutis not the right home for this: it is documented as Kubernetes-API-only and is already deprecated in favour of--kube-api-request-timeout(types.go:713-714). One caveat worth stating:http.Client.Timeoutdoes not bound the oauth2 token refresh that happens insideoauth2.Transport.RoundTrip, which useshttp.DefaultClientinternally — a hung token endpoint would remain unbounded, though it is at least cancellable. - Pass the context on the create call —
.Context(ctx).Do()atgoogle.go:305, using thectxalready threaded intosubmitChange. This fixes the un-cancellable-on-SIGTERMbehaviour and is arguably a separate defect. While there:GoogleProvider.ctx(google.go:125) is dead code —grep -c 'p\.ctx' provider/google/google.goreturns 0, so its doc comment ("The context parameter to be passed for gcloud API calls") is aspirational and the field can be removed. Thetime.Sleepat:309should likewise become aselectonctx.Done(). - Optionally, retry with truncated exponential backoff and jitter per the guidance above.
I am happy to open a PR for (1) and (2) if the approach sounds right.
Anything else we need to know?:
This was previously reported in #1264 (November 2019), which described the absence of a timeout on the Cloud DNS client. That issue is closed as completed, by PR #1271 ("Use non-deprecated initializer with context"), which moved construction from the deprecated dns.New() to dns.NewService() — the change #1264 asked for. But that move did not by itself introduce any timeout, a point raised in the #1264 thread at the time and never followed up: dns.NewService() made a context possible to plumb, and the create call still does not plumb one. So the substantive gap #1264 described is still fully present six years later, which is why I am filing fresh rather than reopening.
The /healthz observation in point 4 is a distinct concern — a liveness endpoint decoupled from loop progress means no provider can be restarted out of a wedged state, and lastSyncTimestamp already exists as a signal that could inform it. Happy to file that separately if you would prefer to keep this issue scoped to the Google provider. (#6670 appears to describe this same class of unbounded-provider-call problem for PowerDNS.)
Environment:
- External-DNS version: observed in production on
v0.19.0. I re-checked the relevant code atv0.22.0(latest release) and onmaster(b6a23ab9) and it is unchanged: noTimeout:inprovider/google/orpkg/http/, no context on thechanges.createcall site, and/healthzstill a static 200. - DNS provider: Google Cloud DNS
- Others: Go client
google.golang.org/api v0.297.0,golang.org/x/oauth2 v0.37.0; relevant flags--google-batch-change-interval=3s,--google-batch-change-size=500,--google-zone-visibility=public,--policy=sync,--txt-prefix, sourcesserviceandingress.
Checklist
- I have searched existing issues and tried to find a fix myself
- I am using the latest release, or have checked the staging image to confirm the bug is still reproducible — code path verified unchanged at
v0.22.0andmaster - I have provided the actual process flags (not Helm values)
- I have provided
kubectl get <resource> -o yamloutput includingstatus— not applicable; the defect is in provider HTTP client construction and is independent of which resource produced the change - I have provided full external-dns debug logs — the pathological window produced no log output at all, which is part of the report; happy to supply surrounding logs if useful
- I have described what DNS records exist and what I expected
Source: kubernetes-sigs/external-dns