fix(k8s): use stable StatefulSet DNS for CubeProxy registry heartbeat - #1271
Conversation
| proxy → auto-resume gate state is never pushed. Pass the StatefulSet Pod DNS | ||
| name instead; cube-proxy-entrypoint.sh resolves it to the live IP at startup | ||
| (ngx.timer cosockets have no nginx resolver). */ -}} | ||
| {{- $redisRegistryHost = printf "%s-0.%s.%s.svc.%s" (include "cube.redisName" .) (include "cube.redisName" .) .Release.Namespace (include "cube.clusterDomain" .) -}} |
There was a problem hiding this comment.
This moves the IP capture from helm-render time to proxy-container start time — it does not survive a Redis Pod IP change that happens after the proxy has started.
cube-proxy-entrypoint.sh:100-110 resolves this DNS name once at container startup (getent ahostsv4 ... | NR==1) and exports the concrete IP into CUBE_PROXY_REGISTRY_REDIS_HOST. Nothing re-resolves afterwards, and proxy_registry.lua runs off ngx.timer.every against that pinned IP, swallowing errors. So if the Redis Pod restarts (new Pod IP) while the cube-proxy Pod keeps running — the exact scenario in the PR description — the proxy still connects to the stale IP and the heartbeat silently fails until the proxy Pod is itself restarted. As written, the PR's test-plan step 1 ("roll the redis StatefulSet, confirm the proxy registry heartbeat survives the Pod IP change") would not pass without also rolling the proxy.
The stable DNS name is still a real improvement (proxy restarts become self-healing instead of requiring a fresh helm upgrade), but the codebase already has the mechanism for a true fix: nginx.conf:32-33 puts resolver at http scope "so every Lua cosocket path inherits it", so resty.redis:connect(host, ...) from the ngx.timer handler can resolve the hostname per connect and pick up a new IP within the resolver's valid= TTL (default 30s). Consider keeping the hostname as the registry target (letting the entrypoint pre-resolution remain only a best-effort optimization, or removing it) so the heartbeat self-heals on the next tick instead of pinning an IP for the container's lifetime.
| silently breaks the CubeProxy registry heartbeat → CLM never discovers the | ||
| proxy → auto-resume gate state is never pushed. Pass the StatefulSet Pod DNS | ||
| name instead; cube-proxy-entrypoint.sh resolves it to the live IP at startup | ||
| (ngx.timer cosockets have no nginx resolver). */ -}} |
There was a problem hiding this comment.
The premise "(ngx.timer cosockets have no nginx resolver)" contradicts the chart's own nginx config. nginx.conf:32-33 configures resolver at http scope with the explicit comment "Keep resolver configuration at http scope so every Lua cosocket path inherits it" (rendered by start.sh into resolver.inc). ngx.socket.tcp:connect() from a ngx.timer handler resolves hostnames through that http-scope resolver — the reason init_worker_phase.lua defers into ngx.timer.at(0) is cosocket creation in init_worker, not DNS resolution in timers.
That matters because this comment is what motivates the entrypoint's startup-time getent pin, which is exactly what stops the heartbeat from recovering when the Redis Pod IP changes while the proxy stays up (see the comment on the printf line below).
Review: fix(k8s): use stable StatefulSet DNS for CubeProxy registry heartbeatAI-generated review — not a human approval. OverviewThe PR fixes a real production bug. The fix passes the Redis hostname and lets nginx resolve it per connect, relying on the http-scope
Net assessment: the approach is sound and a genuine improvement. Findings below are mostly documentation/behavioral-compatibility nits, plus one live-verification recommendation. Findings1. Stale default in the new template comment (minor, definite)
2. Compatibility regression for short-name external Redis (needs a release note)Removing the 3.
|
| fi | ||
| # proxy_registry.lua connects to Redis via resty.redis from an ngx.timer | ||
| # handler. ngx.socket.tcp resolves hostnames through the http-scope | ||
| # `resolver` written into global.conf above (nginx.conf keeps resolver at |
There was a problem hiding this comment.
Two notes on this comment block:
-
Attribution is off.
global.confisincluded at location scope (eachlocationblock includes it), not http scope. The resolver anngx.timercosocket actually uses comes from the http-scopeinclude .../includes/resolver.inc;innginx.conf(CubeProxy/nginx.conf:33), rendered bystart.sh'sprepare_resolver_include. If the http-scope include were ever removed whileglobal.confstayed, the heartbeat would silently break again — exactly the failure mode this PR fixes. Suggest rewording to referenceresolver.inc/ thenginx.confinclude (the parenthetical is the accurate half). -
Load-bearing assumption worth an explicit check. The removed block's comment asserted
ngx.timerhas no nginx resolver; this fix depends on the opposite (http-scope resolver serving timer cosockets). Per lua-nginx-module, timer-handler coroutines are bound to the http-level loc conf, so an http-scoperesolveris used byngx.socket.tcpinside timers — consistent with OpenResty semantics, andstart.sh'sensure_hostname_target_has_resolveralready anticipated hostname registry targets. But since it contradicts the prior in-repo belief and has no automated coverage, the manual "roll the redis StatefulSet" verification is what actually proves it — please ensure it's run before merge.
| name and let the http-scope resolver resolve it on every cosocket connect, | ||
| so the heartbeat self-heals after a redis Pod restart (resolver valid= TTL | ||
| bounds the recovery delay). */ -}} | ||
| {{- $redisRegistryHost = printf "%s-0.%s.%s.svc.%s" (include "cube.redisName" .) (include "cube.redisName" .) .Release.Namespace (include "cube.clusterDomain" .) -}} |
There was a problem hiding this comment.
The pod DNS name is correct for the current chart (builtin redis Service is headless, StatefulSet serviceName and name both equal cube.redisName, and redis.yaml hardcodes replicas: 1), but the -0 ordinal is hardcoded here. If the StatefulSet is ever scaled, the registry would silently pin to replica 0. Consider deriving the ordinal from the StatefulSet, or at least adding a comment noting the single-replica coupling so it stays intentional.
| ;; | ||
| esac | ||
| fi | ||
| # proxy_registry.lua connects to Redis via resty.redis from an ngx.timer |
There was a problem hiding this comment.
This is the load-bearing claim of the whole fix, and it directly contradicts the comment this PR deletes ("proxy_registry.lua publishes from ngx.timer, which has no nginx resolver"). If the new claim is wrong, the failure mode is worse than on the base branch: instead of a stale-but-occasionally-working heartbeat, resty.redis connect on a hostname with no usable resolver would fail and proxy_registry.lua swallows the error — so the heartbeat would never publish. The in-repo evidence supports the new claim (nginx.conf keeps resolver at http scope specifically so every Lua cosocket path inherits it, and start.sh's ensure_hostname_target_has_resolver already guards hostname registry targets), but this is important enough that the unchecked test-plan item (roll the redis StatefulSet, confirm the heartbeat survives) should be run before merge.
| # ngx.socket.tcp resolves hostnames through the http-scope `resolver` — | ||
| # `nginx.conf` includes `conf/includes/resolver.inc` at http scope (rendered | ||
| # by start.sh) specifically so every Lua cosocket path inherits it. Keep | ||
| # CUBE_PROXY_REGISTRY_REDIS_HOST as a hostname: it is re-resolved per |
There was a problem hiding this comment.
Behavior change for external registry targets: previously a hostname here was resolved to an IP at startup via getent ahostsv4, which applies /etc/resolv.conf search domains; a bare/short name (e.g. redis-cache) worked. nginx's resolver directive does not apply search domains, so such a target now fails to resolve. The builtin-redis FQDN is unaffected (it's absolute), but worth a sentence in the chart docs/release notes if non-FQDN external redis hosts are in use.
| The -0 ordinal is hardcoded: the builtin redis StatefulSet is intentionally | ||
| single-replica (redis.yaml hardcodes replicas: 1). If it is ever scaled, | ||
| derive the ordinal from the StatefulSet instead of pinning replica 0. */ -}} | ||
| {{- $redisRegistryHost = printf "%s-0.%s.%s.svc.%s" (include "cube.redisName" .) (include "cube.redisName" .) .Release.Namespace (include "cube.clusterDomain" .) -}} |
There was a problem hiding this comment.
Hardcoding the -0 ordinal couples the registry target to StatefulSet internals. Today it's always correct (redis.yaml hardcodes replicas: 1, and the StatefulSet/service names both come from cube.redisName), and the comment documents the limitation. One thing to weigh: for this single-replica builtin, the headless Service DNS name that $redisHost already defaults to (<redis>.<ns>.svc.<domain>) resolves to the same Pod IP and self-heals identically on Pod restart — without the ordinal assumption — so the override is only strictly necessary if the StatefulSet is ever scaled (where it would also break unless the ordinal is derived from the StatefulSet). Consider deriving the ordinal via lookup instead of pinning replica 0.
|
Addressed all review comments with commit 1. "the getent resolution happens once at container startup — it does not survive a redis Pod IP change" (entrypoint 2. "the premise (ngx.timer cosockets have no resolver) contradicts the chart's own nginx config" (proxy.yaml comment) 3. "global.conf is included at location scope; attribute the http-scope include correctly" (entrypoint comment) 4. "hardcoded 5. "bare/short-name external redis targets lose search-domain resolution" Verified with |
| The chart installs `cube-redis` StatefulSet only when `redis.enabled=true` and `redis.host` is empty. | ||
| Set `redis.host` to use an existing Redis service; the chart will not install `cube-redis`. | ||
|
|
||
| The CubeProxy registry heartbeat and Cube Lifecycle Manager both target Redis |
There was a problem hiding this comment.
re-resolved on every connection over-states the self-heal: nginx's resolver caches answers for valid= (values default cubeProxy.resolver.valid: 30s), and resty.redis can reuse a pooled cosocket to the old Pod IP for one tick before it is dropped. So after a redis Pod IP change the proxy heartbeat can stay down for up to ~valid + one heartbeat interval. Since CLM's heartbeatTTL is 15s, CLM can still consider the proxy stale during that window (and auto-resume can still 504 inside it). The fix is a strict improvement over the previous permanent pinning, so this is non-blocking — but consider lowering cubeProxy.resolver.valid (e.g. 5s) for the builtin-redis path, or at least documenting the recovery window rather than implying instant self-heal.
| bare short name would fail to resolve. | ||
| redis.host currently matches cube.redisHost for the builtin case, but we | ||
| keep the separate variable so the coupling is explicit. */ -}} | ||
| {{- $redisRegistryHost := $redisHost -}} |
There was a problem hiding this comment.
The fix is split across this template (env: pinned IP → hostname) and the entrypoint script (stop pre-resolving the host). For builtin redis the env value change rolls the Deployment on helm upgrade, so the new entrypoint is picked up. But for external redis (redis.host set to an FQDN) the rendered Deployment spec is unchanged, and the ...-entrypoint ConfigMap is not checksum'd into the pod template (no checksum/config annotation — only webui.yaml does this in this chart). A plain helm upgrade will then leave the OLD entrypoint running, which still runs the removed getent pre-resolution and pins CUBE_PROXY_REGISTRY_REDIS_HOST to an IP at container start — re-introducing exactly the stale-IP bug this PR removes for FQDN external Redis. Suggest adding a checksum/config annotation for the entrypoint ConfigMap so the entrypoint change also rolls the Deployment, or noting that external-redis deployments need a manual rollout (the PR summary's "plain helm upgrade" claim only holds for the builtin case).
1b64488 to
3b55dfa
Compare
|
Follow-up commits Design/perf — resolver.valid vs CLM TTL (validated) Rollout — checksum/entrypoint (new mechanism) Shell syntax in CI Render guard test DCO The self-heal claim in the README was also reworded to be precise about the |
| nginx `resolver` does not apply /etc/resolv.conf search domains, so a | ||
| bare short name would fail to resolve. | ||
| Self-heal is not instant: nginx caches resolved answers for | ||
| `cubeProxy.resolver.valid` (default 30s) and resty.redis may reuse a |
There was a problem hiding this comment.
Stale default: this PR changes cubeProxy.resolver.valid to 5s in values.yaml and the README, but this new comment still states (default 30s) and then recommends lowering it "e.g. 5s". Please update to (default 5s) (or drop the parenthetical) so the comment matches the change it's documenting.
| # `nginx.conf` includes `conf/includes/resolver.inc` at http scope (rendered | ||
| # by start.sh) specifically so every Lua cosocket path inherits it. Keep | ||
| # CUBE_PROXY_REGISTRY_REDIS_HOST as a hostname: it is re-resolved per | ||
| # connect and the heartbeat self-heals after a redis Pod IP change. Do NOT |
There was a problem hiding this comment.
Compatibility note worth surfacing in the release notes: this removes the getent ahostsv4 fallback, which used the glibc resolver and therefore applied /etc/resolv.conf search domains. An existing external-Redis install using a short name for redis.host / cubeProxy.redis.host (e.g. redis) previously got a working heartbeat via getent; after this change the name goes to nginx's resolver, which does not apply search domains, so the heartbeat would silently stop (proxy_registry swallows errors → CLM never discovers the proxy → the same 504 failure mode this PR fixes). The README documents the FQDN requirement for new deploys, but existing deployments won't know they need a values change on helm upgrade.
| annotations: {} | ||
| redis: | ||
| # Empty host uses redis.host or chart-managed cube-redis. | ||
| # Overrides must be a fully-qualified DNS name or IP literal (see |
There was a problem hiding this comment.
Minor: this FQDN/IP rationale cites nginx's resolver, but this is the lifecycleManager.redis section — CLM connects via go-redis/Go's system resolver, which does apply /etc/resolv.conf search domains. The nginx limitation applies to CubeProxy (which shares the same host value). Also, the referenced redis.host is below this block, not above. Suggest rewording, e.g. "Shared with CubeProxy, whose nginx resolver applies no search domains; use an FQDN or IP literal."
fslongjin
left a comment
There was a problem hiding this comment.
Thank you for your contribution—this looks really good. However, your code changes introduce quite a few new comments that seem redundant. Could you remove them?
Pass the Redis hostname to proxy_registry.lua so nginx re-resolves it per connect after a redis Pod IP change, instead of pinning a lookup() or getent IP. Keep resolver.valid at 5s so recovery stays under CLM's heartbeatTTL. Co-authored-by: tsingyue <agaaain.try@gmail.com> Signed-off-by: jinlong <jinlong@tencent.com>
3b55dfa to
b4e6963
Compare
Summary
The CubeProxy registry heartbeat (
proxy_registry.lua, published from anngx.timerhandler) connects to Redis viaresty.redis→ngx.socket.tcp.proxy.yamlpinned that target to a concrete IP captured at render or startuptime:
lookup "v1" "Service"/"Endpoints"baked the redisClusterIP or headless Endpoints IP into the env value;
getent ahostsv4resolved the hostname to an IP before nginxstarted.
Both pin a stale address once the redis Pod restarts (new Pod IP) while the
cube-proxy Pod keeps running. The heartbeat then silently fails, CLM stops
discovering the proxy, and
auto_resumegate state is never pushed →504 Gateway Timeout.Fix
Pass the Redis hostname through to
CUBE_PROXY_REGISTRY_REDIS_HOSTand letnginx resolve it per connect. The chart already renders
resolverat httpscope (
start.sh→conf/includes/resolver.inc), so every Lua cosocket path —including
ngx.timerhandlers — resolves through it. The entrypoint no longerpre-resolves the host to an IP (
getentblock removed).For the builtin redis, the host is the headless Service DNS name
(
<redis>.<ns>.svc.<domain>), which always resolves to the live Pod IP andself-heals after a redis restart — the same mechanism
cube-lifecycle-manageralready relies on.Additional hardening
checksum/entrypointannotation on the cube-proxy Deployment hashes theentrypoint ConfigMap body so a
helm upgradethat only changes theentrypoint rolls the pod — otherwise the old script keeps running until the
pod is manually deleted.
cubeProxy.resolver.validdefault 30s → 5s: DNS answers are cached forvalidseconds, so heartbeat recovery takes roughlyvalid+ one heartbeatinterval after a redis restart. 30s exceeded
lifecycleManager.heartbeatTTL(15s), leaving the proxy judged dead for ~20s in that window.
test-proxy-registry-host.sh: asserts the builtinhost renders as the Service DNS name, external host / IP literals pass
through, sentinel mode leaves the host empty, and
checksum/entrypointtracks entrypoint content.
cube-proxy-entrypoint.shadded to the CIsh -nsyntax check.Compatibility notes
redis.host/cubeProxy.redis.hostoverrides must be an FQDN orIP literal: the nginx
resolverdoes not apply/etc/resolv.confsearchdomains (documented in README and values.yaml comments).
resolved via SENTINEL).