Deployment

M5 gives the deployment topology its own page; operations.adoc’s ` anchor now points here. The Helm chart at helm/ft-fcc-service/ carries its own exhaustive README.md with the full values reference — this page covers what an operator planning a deployment needs, and the reasoning the values table cannot carry, without duplicating it line for line.

The container’s port 8080 must never be published outside the docker network / cluster network. This service has no authentication of its own (No Authentication by Design); its only access control is that it is unreachable except from inside the network, where ui-backend is the sole caller. Publishing this port to the host or behind an ingress would expose a completely unauthenticated API that can create, read and activate FCC campaigns across every domain of the installation.

The Image

Multi-stage, multi-arch Dockerfile at the repository root.

The build stage is pinned to $BUILDPLATFORM — it always runs natively on whichever machine is doing the build, regardless of the target architecture. This is deliberate: the output of a Maven build is a JVM jar, which is architecture-independent, so running Maven under QEMU emulation for a foreign target would cost several minutes of wall clock per build for a byte-identical artifact. The runtime stage deliberately carries no --platform at all; docker buildx resolves it once per --platform entry in the manifest, so each architecture gets its own native eclipse-temurin:25-jre. Pinning the runtime stage to $BUILDPLATFORM as well — the "obvious" fix an unfamiliar reader might reach for — would put the builder’s own JRE into every architecture of the manifest, and the linux/arm64 image would silently be an amd64 one running under emulation.

The runtime image runs as non-root uid/gid 1000 (ftfcc), with -Djava.io.tmpdir=/tmp pinned explicitly on the entrypoint rather than left to the platform default — so the dependency on the /tmp emptyDir the chart mounts (Why /tmp Is Mounted, Specifically) is visible in the image itself, not only implied by the chart. The entrypoint also pins -XX:MaxRAMPercentage=65 (down from 75 as of the M5 review, MaxRAMPercentage Is 65, Not the More Usual 75).

A floating base tag broke this build, and the fix is in the Dockerfile now, not merely noted here. eclipse-temurin:25-jre moved to be built on Ubuntu 26.04, which ships a default ubuntu:x:1000:1000 user and a matching group at gid 1000 — exactly the ids this image’s own groupadd --system --gid 1000 ftfcc needs. Without --non-unique, groupadd exits 4 with GID '1000' already exists and the RUN — and therefore the image build — failed outright. This was not a change in this repository; it was verified without a running Docker daemon, by pulling the manifest, reading its org.opencontainers.image.base.name annotation, and reassembling /etc/passwd / /etc/group from the base image’s layers.

The Dockerfile now removes the conflicting ubuntu user and group before creating ftfcc, guarded with getent so the removal is a no-op (not a failure) on a base that does not have them, while a genuine failure to create ftfcc afterward still fails the build.

Pinning both base images (maven:3.9-eclipse-temurin-25 for the build stage, eclipse-temurin:25-jre for the runtime stage) by digest is the real fix, and it is not applied in this repository as of this pass. Floating tags are the mechanism that broke this build once and will break it again the next time either base image’s maintainers rebase it. Digest pinning needs an update process behind it — something that periodically re-resolves the digest, rebuilds, and re-tests, or a base image CVE lives in this image forever, unpatched and invisible to a docker pull that only ever asks for the same tag. Until that process exists, pinning by digest here would trade one silent failure mode (a rebased tag breaking the build) for another (a stale pin quietly aging out of security support), which is why it is recorded as a recommendation rather than applied.

Building both architectures:

docker buildx create --name ft-multiarch --use   # once per machine

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag hub.friendly-tech.com/ftacs/ft-fcc-service:0.0.1-SNAPSHOT \
  --push \
  .

--push is not optional for a multi-platform build — the local Docker image store cannot hold a multi-arch manifest, so --load fails outright. To validate the file locally, build one architecture at a time: docker buildx build --platform linux/arm64 --load --tag ft-fcc-service:local .. .mvn/settings.xml is git-ignored (it carries registry credentials) but is copied by the build stage — create it from .mvn/settings.xml.template before building.

MaxRAMPercentage Is 65, Not the More Usual 75

The chart’s default resources.limits.memory is 1Gi, and the entrypoint reads it through -XX:MaxRAMPercentage=65 to size the heap: roughly a 665 MiB max heap, leaving about 360 MiB for everything outside it. At the more common 75%, that remainder was only about 256 MiB, and what has to fit in it here is not small — Spring Boot 3.5 metaspace plus code cache alone is commonly 150-220 MiB, and on top of that sit three Hikari pools, a lazily created ClickHouse pool, a Hazelcast client with its own IO threads and buffers, virtual-thread carrier stacks, and Liquibase plus Flyway at boot. An OOMKill is the one kill path the liveness/readiness design on this page cannot see at all — it never goes through a probe, so the DEGRADED / liveness split (Probes, and Why Liveness and Readiness Are Split) cannot soften it, and a pod that OOMKills mid-migration looks nothing like the graceful-shutdown or ClickHouse-outage cases this page otherwise accounts for. The cost of the extra headroom is about 100 MiB of heap a 10 000-entry result buffer does not need.

To override the percentage, use extraEnv with _JAVA_OPTIONS, never JAVA_TOOL_OPTIONS. The JVM reads JAVA_TOOL_OPTIONS before the command line, so a MaxRAMPercentage set there loses to the flag the entrypoint already sets; _JAVA_OPTIONS is read after the command line and wins. JAVA_TOOL_OPTIONS remains the right place for flags the entrypoint does not set at all (-XX:+HeapDumpOnOutOfMemoryError, agents). Full reasoning and the values-file syntax: helm/ft-fcc-service/README.md, "Resources, replicas, shutdown".

The Helm Chart

helm/ft-fcc-service/, versioned together with the image in this repository. Full values reference, troubleshooting recipes and the complete rationale for every default: helm/ft-fcc-service/README.md. What follows is what an operator decides before the first install.

Decision What it means here

enabled: false by default

A plain helm install renders nothing. As a subchart, the parent’s condition: ftFccService.enabled also has to be set — a chart cannot declare its own condition:, so this chart additionally self-guards with its own enabled switch. A parent that forgets the condition: line would otherwise install an unauthenticated API silently; this is why both switches read the same key.

Service is ClusterIP, and there is no templates/ingress.yaml at all

Not a disabled Ingress template — the file does not exist. A disabled template is an invitation: it tells the next operator that Ingress is a supported configuration that merely happens to be switched off. It is not. Its absence is the statement. service.type set to NodePort / LoadBalancer fails the render outright unless service.unsafeAllowExternalExposure: true is also set, so the decision to expose this service externally has to be written down somewhere rather than made with an unreviewed --set.

Passwords live in a Secret, existingSecret is the production path

Five keys only (MYSQL_PASSWORD_FTFCC, MYSQL_PASSWORD_FTACS, ORACLE_PASSWORD_FTFCC, ORACLE_PASSWORD_FTACS, CLICKHOUSE_PASSWORD). The chart’s own rendered Secret (secret.create: true) exists for development and CI, where the values are already throwaway; pointing secret.existingSecret at a Secret an operator-managed tool (External Secrets, Vault, sealed-secrets) owns is the only acceptable production path, because a rendered Secret means the password sat in values.yaml at some point — shell history, git history, a CI log.

containerSecurityContext.readOnlyRootFilesystem: true, with an emptyDir mounted at /tmp

See Why /tmp Is Mounted, Specifically.

Chart version 0.2.0, kubeVersion: ">=1.25.0-0" declared in Chart.yaml (checked at install time, not only asserted in the README), and the chart now refuses to render rather than hand back a pod that would fail at runtime, on all of these:

  • The four connection endpoints — hazelcast.members and, for the selected database.vendor, database.<vendor>.ftfcc.host/.url and database.<vendor>.ftacs.host/.url, plus clickhouse.host/.url unless clickhouse.enabled: false — have no usable Kubernetes-side default. An unset one does not mean "no dependency"; it means the application’s own default, which is localhost, which inside a pod is the pod itself. hazelcast.members left empty is the quietest failure of the four: HZ_MEMBERS: "" renders and HazelcastClientConfig falls back to 127.0.0.1:5701, so the pod stays green while every command to the ACS goes nowhere.

  • probes.liveness.path or probes.startup.path pointed at /actuator/health — the aggregate, not /actuator/health/liveness — now fails the render with the same reasoning as The Trap, rather than only being warned against in prose. This was the single most repeated warning in the chart as it first shipped, and the only one nothing enforced.

  • tuning.SERVER_PORT set, anywhere including extraEnv — it cannot work: containerPort, the NetworkPolicy port and the prometheus.io/port annotation are 8080 in three separate places, and all three probes address the port by name, so moving the listener only moves it away from everything that talks to it and the pod fails its startup probe for the whole boot budget before restarting forever. The key was removed from tuning entirely, alongside FCC_SCHEDULING_POOL_SIZE, which never did anything in the service itself.

  • The two pre-existing guards, service.type (The Helm Chart decision table above) and database.vendor.

A numeric tuning. value or an image tag must still be quoted in a -f values.yaml file, even though the chart now tolerates an unquoted one better than it used to. Helm decodes a values file through JSON, so every bare number becomes a float64, and Go renders those in exponent notation from 1000000 upward — HIKARI_MAX_LIFETIME_MS: 1800000 used to reach the ConfigMap as "1.8e+06", which Spring cannot bind to a long, producing a CrashLoopBackOff whose message blames the property rather than the chart. The chart now converts a whole-number float back to an integer string (ft-fcc-service.envValue), so both spellings work; an image tag like 1.10 unquoted is the *different tag 1.1 and the chart can only stringify what it received, not recover the digit that was never parsed. --set was never affected — Helm’s strvals always kept it a string — so this only ever bit the documented -f values.yaml path.

helm lint does not catch any of the guards above, under Helm v4. A fail inside a template is a rendering-time construct; lint logs it and exits 0, and only helm template (or install/upgrade) actually executes the templates far enough to hit it. A CI pipeline that lints the chart but never templates it is not checking these guards at all.

Why /tmp Is Mounted, Specifically

With a read-only root filesystem the JVM still needs a writable temp directory (hsperfdata, NIO temp files), and Spring spools multipart parts to the Java temp directory — PUT /FCC/item/import, PUT /FCC/devices/import and POST /FCC/devices/resolve (and POST /FCC/devices/count) all take a file upload, each bounded at FCC_SERIAL_FILE_MAX_SIZE (default 10 MB). tmpDir.sizeLimit (default 128Mi) is set comfortably above that ceiling to absorb concurrent uploads plus whatever else the JVM parks there; it counts against the node’s ephemeral storage, which is what makes it worth capping at all rather than leaving unbounded. tmpDir.medium can be set to Memory for a tmpfs, at the cost of counting against the pod’s memory limit instead.

Probes, and Why Liveness and Readiness Are Split

This is the most important section on this page: getting it wrong has one specific, repeatable, and entirely avoidable failure mode.

The Trap

The aggregate GET /actuator/health maps the custom status DEGRADED to HTTP 200, on purpose (Health). ClickHouse being unreachable, or reachable with its schema behind pending migrations (SCHEMA_BEHIND), is a state this service is explicitly designed to survive: the migration retries on ft.fcc.clickhouse.migration.retry-interval (default 60s) and heals itself, and CRUD against ftfcc keeps working throughout — only new result-producing work (activations, reports, the legacy import) is refused with 503/9503.

A liveness probe pointed at that aggregate endpoint would restart the pod precisely while it is waiting for ClickHouse to come back — and the retry that would have healed it never gets to finish, because the JVM does not live long enough between restarts to let it. A single-instance ClickHouse outage would turn into a cluster-wide CrashLoopBackOff that reads, from the outside, exactly like a bug in this service.

What the chart does instead

Probe Endpoint Sees

startupProbe

/actuator/health/liveness

livenessState only — decides when the app is "up", not whether its backing stores are healthy.

livenessProbe

/actuator/health/liveness

livenessState only, deliberately blind to every backing store. "Is this JVM alive" and "can this JVM do its job" are different questions, and only the second belongs in readiness.

readinessProbe

/actuator/health/readiness

readinessState, db (ftfcc + ftacs), clickhouse, with a group-level http-mapping where DEGRADED is 503.

A degraded pod therefore leaves the Service endpoints — so the load balancer stops sending it new work that would only fail against a store it cannot reach — but is not restarted, so the migration retry that would heal it is never interrupted.

A Spring Boot trap that is easy to reproduce by accident: a group-level http-mapping replaces the top-level default, it does not merge with it. The readiness group’s mapping therefore has to restate DOWN: 503 and OUT_OF_SERVICE: 503 alongside DEGRADED: 503 — listing DEGRADED alone would leave DOWN and OUT_OF_SERVICE falling back to whatever the (unmapped) default is for that group, which is 200, and a genuinely dead pod would stay in rotation. application.yaml restates both explicitly for exactly this reason; if this were ever "cleaned up" to just the DEGRADED line, the fix would silently reopen the hole it exists to close.

The same trap has a second, load-bearing form one level up: the readiness group declares no status.order of its own, and that omission is deliberate rather than an oversight. A group whose status.order is empty falls back to the aggregator’s top-level order (DOWN, OUT_OF_SERVICE, DEGRADED, UP, UNKNOWN), which the readiness group therefore inherits rather than restates. Without that inheritance, DEGRADED would be a status the aggregator has never heard of, sorting after UP by Boot’s own fallback rule — and a ClickHouse-degraded pod would aggregate to plain UP, with the http-mapping above never even getting to look at a DEGRADED status because the group status it receives would already say UP. application.yaml comments this explicitly for the same reason as the http-mapping restatement above: it is exactly the kind of thing a future "cleanup" would remove without understanding what it was holding up.

Since M5, /actuator/health — the aggregate, not either split probe endpoint — answers 503 for part of every graceful shutdown, where it answered 200 before. Enabling the probes (management.endpoint.health.probes.enabled: true, needed for the split above) registers livenessState and readinessState as contributors in Boot’s global health registry, and the primary /actuator/health group has no include list narrowing it away from them, so both join the aggregate. Two consequences, both new with M5: the aggregate response body gains two components it did not carry before, and during graceful shutdown (server.shutdown: graceful plus spring.lifecycle.timeout-per-shutdown-phase: 20s) ReadinessStateHealthIndicator reports REFUSING_TRAFFIC — which is OUT_OF_SERVICE, which the mapping at Health maps to 503. So for up to roughly twenty seconds of every rolling deploy, and briefly at startup, /actuator/health answers 503 where a pre-M5 build answered 200.

This was accepted, not merely allowed to happen, and it is a different status from the one Health spends most of its space on. A service that is refusing traffic answering "I am refusing traffic" is arguably the more honest behaviour; the DEGRADED → 200 rule for a ClickHouse outage is untouched and is not what this note is about — the two are easy to conflate because both live on the same endpoint. What is worth stating plainly is who is affected: anything polling the aggregate rather than the readiness group specifically — a docker-compose healthcheck, a load balancer configured against the wrong path, ui-backend if it were ever pointed here instead of at the split probes — now sees a 503 during every rollout that it never saw before M5. Point any such consumer at /actuator/health/readiness instead, which is what it almost certainly meant to watch in the first place.

Why ClickHouse Is Not in the Readiness Group by Default

The default is readiness.includeClickHouse: false (rendering FCC_READINESS_INCLUDE=readinessState,db), and this section is why. With true, ClickHouse being shared and cluster-wide means a ClickHouse outage takes every replica un-ready at the same instant. The Service then has no endpoints at all, and ui-backend gets connection failures — for plain campaign CRUD too, not only for report and activation traffic — even though decision Q3' promises exactly that CRUD keeps working while the service is DEGRADED.

The default therefore keeps that promise: the pods stay in rotation, CRUD keeps working, and report/activation traffic answers an honest 503/9503 from a pod that is reachable rather than not being reachable at all.

The two-line decision rule:

  • If ui-backend degrades gracefully on a 9503 and CRUD availability during a ClickHouse outage matters more than "every pod in rotation is fully working" → includeClickHouse: false.

  • If you would rather have no endpoint at all than a half-working one, or a genuinely per-pod ClickHouse problem is possible in your topology (per-pod network policy, per-pod credentials, sharded routing) → leave it true, because in that topology removing the affected pod actually helps.

readiness.include overrides the rendered list outright when neither boolean expresses the combination you need.

The Startup Budget, and Why It Is What Lets Liveness Be Strict

startupProbe (initialDelaySeconds: 10, periodSeconds: 10, failureThreshold: 90) budgets 910 seconds (10 + 90 x 10) of boot time. Verified against helm/ft-fcc-service/values.yaml:

Phase Worst legitimate cost

JVM start and Spring context up to the DataSource beans

~45s

ftfcc Hikari first connection on a loaded or failing-over database

up to 45s (HIKARI_CONN_TIMEOUT_MS)

Liquibase DATABASECHANGELOGLOCK wait when several replicas start together

up to 300s (Liquibase default changeLogLockWaitTimeInMinutes = 5)

Liquibase cold run: ~50 changesets, 17 tables plus indexes, loaded database

up to 120s

ftacs startup schema check (catalog queries through the read-only user)

up to 30s

ClickHouse Flyway pass: connect (CLICKHOUSE_CONN_TIMEOUT_MS 10s) plus three migrations

~60s

Rest of the context refresh, Tomcat bind

~20s

Total, worst legitimate boot

~620s

910s covers that with roughly 45% headroom. The item that actually forces the number is the 300s Liquibase lock wait: with replicaCount > 1, every replica but one legitimately sits idle behind the one running the migration, and a short startup budget would kill and restart exactly the replica that is behaving correctly — the pod doing nothing wrong except waiting its legitimate turn. This is the same trap DEGRADED sets one layer up (The Trap), one layer down in the boot sequence rather than in steady-state operation.

Being generous here is precisely what lets liveness stay tight (failureThreshold: 3, periodSeconds: 10, timeoutSeconds: 3 — about 30s of a non-responding JVM before a restart): liveness does not run at all until the startup probe has succeeded once, so a startup budget sized against the worst case never costs steady-state responsiveness anything.

Kubernetes runs no probe of any kind against the application container while the wait-for-hazelcast init container is running (The Hazelcast Init Container, and the Two ACS Failures It Cannot Fix) — init containers run to completion before the kubelet starts the main container, and a container that has not been started is never probed. So the init wait can never cause the application container to be restarted; the only bound on that phase is the init container’s own timeoutSeconds.

progressDeadlineSeconds: 1200 — the Deployment Controller Has Its Own Clock, Sized Separately From the Startup Probe

The startup budget above (910s) governs when the kubelet gives up on a single pod. It says nothing about when the Deployment controller gives up on the rollout as a whole, and that is a second clock, progressDeadlineSeconds, whose Kubernetes default — 600 seconds — sits below the 910s this page spends two tables justifying. On the exact scenario the 910s budget exists for — replicaCount > 1 against a cold ftfcc, every replica but one legitimately queued behind the 300s Liquibase changelog-lock wait — the pods are not killed by anything, but the Deployment reports ProgressDeadlineExceeded at 600s regardless, and kubectl rollout status (which NOTES.txt tells an operator to run) exits non-zero. A pipeline running helm upgrade --wait --atomic reads that non-zero exit as a failed rollout and rolls back in the middle of a Liquibase migration — on precisely the replica set that was behaving correctly. The chart now sets progressDeadlineSeconds: 1200, above the startup budget, for the same reason the startup budget itself is generous: a pod correctly waiting its turn must never look, to any layer of tooling, like a pod that failed.

helm upgrade --wait (or --atomic) needs a matching --timeout of at least 20m. Helm’s own default --timeout is 5 minutes, independent of both Kubernetes clocks above, and expires long before either one — an --atomic rollback triggered by that timeout interrupts a migration exactly as the ProgressDeadlineExceeded case above did, for a reason that has nothing to do with either progressDeadlineSeconds or the startup probe.

The preStop Sleep, and Why terminationGracePeriodSeconds Alone Never Covered Endpoint Propagation

terminationGracePeriodSeconds (default 45) delays the kill — the kubelet sends SIGTERM at t=0 regardless and only sends SIGKILL at t=grace. It does not delay SIGTERM itself, and a comment that used to stand on this chart said it did. Because SIGTERM and endpoint removal are dispatched by the control plane at the same instant, they race each other across every kube-proxy in the cluster: without something to close that race, Spring can begin its graceful shutdown — server.shutdown: graceful, spring.lifecycle.timeout-per-shutdown-phase: 20s — while some nodes are still routing live traffic to a pod that is no longer accepting it, and ui-backend sees connection-refused on every rollout. maxUnavailable: 0 does not help here: the pod being torn down is the old one, which is already in the `Service’s endpoints by definition.

The chart now runs a preStop hook (preStopSleepSeconds, default 5, exec + /bin/sleep) before SIGTERM is sent — the container is not signalled until the hook returns — which is what actually closes the gap: the sleep is the propagation delay, not additional shutdown time, but it does count against terminationGracePeriodSeconds, which is why raising one number usually means raising the other. The default’s arithmetic is 5 (preStop) + 20 (the graceful-shutdown phase timeout) + slack for the Hazelcast take() consumers to unwind, comfortably inside the default 45. A large export in flight is still cut off at the deadline regardless — that remains expected, and the operator reruns it.

readinessProbe.timeoutSeconds: 5 vs. HIKARI_CONN_TIMEOUT_MS: 45000 — Deliberate, Do Not "Fix" It

With the ftfcc database unreachable, DataSourceHealthIndicator blocks inside getConnection() for the whole Hikari connection-timeout before it can answer — about 45 seconds. readinessProbe.timeoutSeconds is set to 5, far below that, and Kubernetes reads a probe timeout as a probe failure, i.e. "not ready" — the same verdict the 503 would eventually have carried, reached about 40 seconds sooner.

Raising probes.readiness.timeoutSeconds to 45 "to let the health check finish" is the wrong fix: the only effect would be that a pod whose database is genuinely dead keeps receiving live traffic it cannot serve for three quarters of a minute per probe cycle, before anything notices and routes around it.

The Hazelcast Init Container, and the Two ACS Failures It Cannot Fix

initContainers.waitForHazelcast (enabled by default) runs a small busybox container that loops a TCP connect against HZ_MEMBERS until at least one member accepts, then exits; it gives up loudly after timeoutSeconds (default 300s), printing the timeout and the member list it parsed and exiting 1 — visible as Init:CrashLoopBackOff, retried with backoff by the kubelet.

This init container protects this service’s own pod, and nothing else. It does not, and cannot, protect the ACS from either of its own two Hazelcast startup failure modes. Both come out of the ACS’s own HazelcastCacheFactory (HazelcastCacheFactory.java:50-92), where createHazelcastInstance(…​ "hazelcast-client.xml") sits inside a catch (Exception e) whose handler creates an embedded Hazelcast member and sets LOCAL_USED = true. Which of the two failures actually happens depends entirely on whether that call throws or blocks:

Mode What happens

Mode 1 — Hazelcast unreachable, client config present: the ACS start blocks indefinitely

The shipped client config sets async-start="false" with cluster-connect-timeout-millis = -1 — retry forever, never return until connected. With an unreachable cluster the call blocks rather than throwing, control never reaches the catch, and no embedded member is ever created. The visible symptom is an ACS start that simply never finishes.

Mode 2 — client config missing or unreadable: the ACS comes up as an isolated embedded member

When hazelcast-client.xml is not present (or is unreadable, or the client throws for any other reason), the catch runs and the ACS creates its own single-node embedded Hazelcast "cluster" of itself. Every FCC command from this service then goes nowhere, and every activation ends FAILED(ACS_UNAVAILABLE) — while both services answer every health check as perfectly healthy. This is precisely the Kubernetes case: nothing in a Kubernetes deployment guarantees that anyone mounted hazelcast-client.xml into the ACS container, and a missing file produces no error anybody sees. Mode 2 is the worse of the two, because no Kubernetes probe on either side detects it — the ACS is genuinely, locally healthy. Only the ACS module’s own health indicator (FccHealthIndicator, detail hazelcastLocalUsed) and its /actuator/fcc endpoint, written for exactly this purpose, surface it.

In docker-compose, mode 1 is prevented by depends_on: { hazelcast: { condition: service_healthy } } and mode 2 by the bind mount that puts hazelcast-client.xml into the ACS container. In Kubernetes, nothing prevents either. This chart’s init container protects only this pod’s own start; preventing mode 1 on the ACS side needs an equivalent init container in the ACS’s own Helm chart, and preventing mode 2 needs that chart to mount a client config and fail loudly if it is absent — neither exists in this repository, and this chart cannot reach into the ACS’s own. Mode 2 additionally needs somebody to actually scrape /actuator/fcc and alert on hazelcastLocalUsed; detection only helps if something is watching it.

The contrast a reader will otherwise get wrong: this service does not block on Hazelcast at all, unlike the ACS. Its client starts asynchronously (ClientConnectionStrategyConfig.setAsyncStart(true)), management.health.hazelcast.enabled is false, and "not connected yet" is treated as an expected transient state rather than a reason to report DOWN — so an unreachable cluster delays nothing in this service’s own startup budget (The Startup Budget, and Why It Is What Lets Liveness Be Strict); the wait-for-hazelcast init container, which runs before any of it, is the component that actually waits.

Treat a green init container as "this pod found Hazelcast", never as "the ACS is on the same cluster". Confirm the second claim independently, through the ACS’s own /actuator/fcc and its hazelcastLocalUsed detail.

Minimum ACS Version

ACS 6.4.12 or later is required — see ACS 6.4.12 Is the Minimum Version for the full reasoning (FtacsSchemaCheckRunner requires the bom / eom columns of ug_fcc_speed_result, introduced by ACS changelog 6.4.12.a).

Environment Variables

The full reference — every tuning. key, its default and which ft.fcc. property it binds — lives in helm/ft-fcc-service/README.md; the semantics behind each property are in Configuration Reference. This page does not restate either.

Pod Lifecycle

fcc-pod-lifecycle