Files
jklein 813c796a7b
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 4s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 11s
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 1m22s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Valkey Helm Chart / Publish Valkey Helm Chart (push) Successful in 7s
feat(valkey): one Valkey instance per application (1.0.0+up9.1.2.alpine)
A new chart for a single Valkey instance per application, replacing the
shared cluster-redis whose separation rests on one ACL file in one
1Password field - a construction in which every change touches six
applications and which cost a month of Paperless standstill.

Shape: StatefulSet with one replica, ClusterIP Service, no cluster mode,
no Ingress and no value to add one.

The password arrives as a finished configuration line ("requirepass
<secret>") in the 1Password field auth-conf, is mounted as a file and
included by the main configuration - never in argv, never in the
environment. The include is rendered last, so nothing in extraConfig can
override it. Because a misspelled or empty field would leave an open
server that looks perfectly healthy, an init container greps the mounted
file for the line and refuses to start the pod without it; it never
prints the file.

maxmemory is derived from the computed memory limit (maxmemoryFactor,
default 0.75) and a configured value above that limit aborts the render:
otherwise the kubelet's OOM kill decides what the eviction policy was
configured to decide. Persistence and eviction are documented as pairs -
queue (PVC + noeviction, the default) and cache (emptyDir +
allkeys-lru) - and the combination "no persistence + noeviction" carries
a warning in values.yaml and in NOTES.txt.

Probes: valkey-cli ping as an exec probe, authenticated through
VALKEYCLI_AUTH read out of the mounted file, with "-e" and an assertion
on the answer PONG. Without -e valkey-cli exits 0 on an error reply
(valkey 9.1.2, src/valkey-cli.c: the config.set_errcode branch in
cliReadReply), which would make the probe unable to fail.

Hardening: uid 999 / gid 1000 - the image's own valkey user - with
fsGroup matching, runAsNonRoot, readOnlyRootFilesystem, all capabilities
dropped and seccompProfile RuntimeDefault in the pod context so the init
container is covered too. /data is the only writable path.

NetworkPolicy on by default (first chart in this repo with one), with a
switch and a list of additional namespaces; values.yaml names that it is
only a boundary where the CNI enforces policies.

Wiring: publish-valkey.yml, the chart-name choice list, ci/valkey
fixtures for the persistent variant, a plain docker-versioning Renovate
block, README chart row.
2026-09-27 11:24:55 +00:00

161 lines
9.6 KiB
Plaintext

{{- $replicas := include "valkey.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.valkey.replicas) -}}
{{- $limitBytes := include "valkey.memoryLimitBytes" .Values.valkey.resources -}}
{{- $maxmemory := include "valkey.maxmemoryBytes" (dict "resources" .Values.valkey.resources "maxmemory" .Values.valkey.maxmemory "maxmemoryFactor" .Values.valkey.maxmemoryFactor) -}}
Valkey {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
ACCESS — IN-CLUSTER ONLY
{{ .Release.Name }}-{{ .Chart.Name }}-service.{{ .Release.Namespace }}.svc.cluster.local:6379
There is no Ingress and no value to add one: this is one application's cache
or queue backend, and its only authentication is the password. Applications
address it with a redis:// URL (Valkey speaks the same protocol, and every
client library still calls the scheme redis://):
redis://:<password>@{{ .Release.Name }}-{{ .Chart.Name }}-service.{{ .Release.Namespace }}.svc.cluster.local:6379/0
The password belongs in the CONSUMING application's own vault item, where its
connection URL lives. Nothing here writes it there.
Check by hand from inside the namespace, with the password out of the pod's own
mounted file so it never reaches your shell history:
kubectl -n {{ .Release.Namespace }} exec {{ .Release.Name }}-{{ .Chart.Name }}-sfs-0 -- /bin/sh /etc/valkey/ping.sh && echo OK
THE 1PASSWORD ITEM MUST CARRY ONE FIELD, AND ITS NAME IS PART OF THE CHART
secrets.authSecretRef points at
{{ .Values.secrets.authSecretRef }}
The 1Password operator materializes that item as the Kubernetes Secret
{{ .Release.Name }}-{{ .Chart.Name }}-secret, with one Secret key per field of the item. This chart
reads exactly one field:
auth-conf a COMPLETE Valkey configuration line, not the bare
password:
requirepass <the password>
A differently named field produces no such key, and the pod then stays in
ContainerCreating with "couldn't find key auth-conf" in its events.
Why a whole configuration line instead of the password: the file is mounted and
"include"d by the configuration, so the password never appears in the process
list, in an environment variable, or in anything Helm renders. The cost is that
a typo in that line would be a Valkey WITHOUT a password — running, healthy,
and reachable from the whole pod network, because the official image compiles
protected-mode off. An init container therefore checks the mounted file for a
"requirepass " line before the server starts and refuses to let the pod run
otherwise. If the pod is stuck in Init:Error or Init:CrashLoopBackOff, read it:
kubectl -n {{ .Release.Namespace }} logs {{ .Release.Name }}-{{ .Chart.Name }}-sfs-0 -c {{ .Release.Name }}-{{ .Chart.Name }}-check-auth
It never prints the field's contents.
Watch the materialization with
kubectl -n {{ .Release.Namespace }} get onepassworditem {{ .Release.Name }}-{{ .Chart.Name }}-secret
kubectl -n {{ .Release.Namespace }} get secret {{ .Release.Name }}-{{ .Chart.Name }}-secret
THIS RELEASE IS CONFIGURED AS A {{ if .Values.persistence.enabled }}{{ if eq .Values.valkey.maxmemoryPolicy "noeviction" }}QUEUE{{ else }}PERSISTENT CACHE{{ end }}{{ else }}{{ if eq .Values.valkey.maxmemoryPolicy "noeviction" }}VOLATILE STORE — PROBABLY BY MISTAKE{{ else }}CACHE{{ end }}{{ end }}
persistence.enabled: {{ .Values.persistence.enabled }}
valkey.maxmemoryPolicy: {{ .Values.valkey.maxmemoryPolicy }}
memory limit (computed): {{ include "valkey.humanBytes" $limitBytes }} ({{ $limitBytes }} bytes)
maxmemory: {{ include "valkey.humanBytes" $maxmemory }} ({{ $maxmemory }} bytes){{ if kindIs "invalid" .Values.valkey.maxmemory }}, derived as maxmemoryFactor {{ .Values.valkey.maxmemoryFactor | default 0.75 }} x the limit{{ else }}, set explicitly in values and checked against the limit{{ end }}
{{ if .Values.persistence.enabled }}
{{- if eq .Values.valkey.maxmemoryPolicy "noeviction" }}
The combination this chart is built for. When memory runs out Valkey REFUSES
WRITES instead of discarding keys, so a full queue is an error in the
application rather than jobs that quietly disappeared.
{{- else }}
Persistence with an evicting policy: the data survives a restart but not
memory pressure. Fine for a cache that is expensive to rebuild — wrong for a
Sidekiq or Celery queue, which would lose jobs while staying green.
{{- end }}
{{- else }}
{{- if eq .Values.valkey.maxmemoryPolicy "noeviction" }}
READ THIS: no persistence AND noeviction is almost never intended. The data is
gone on every pod restart, so nothing is being protected — but once memory is
full, writes start FAILING instead of cold keys being dropped. For a cache set
valkey.maxmemoryPolicy: allkeys-lru; for a queue set persistence.enabled: true.
{{- else }}
Cache shape: the dataset lives in the pod only. Both AOF and the RDB save
points are switched off, so a restart starts from empty and nothing is written
to ephemeral storage.
{{- end }}
{{- end }}
{{- if .Values.persistence.enabled }}
STORAGE
{{ .Release.Name }}-{{ .Chart.Name }}-pvc, {{ .Values.persistence.size }}, {{ if .Values.persistence.storageClass }}storage class "{{ .Values.persistence.storageClass }}"{{ else }}on the cluster's default storage class
(persistence.storageClass is unset — the default class is not encrypted){{ end }}.
Persistence mode: {{ if .Values.persistence.appendonly }}append-only file (every write appended; a crash loses at most
the last fsync interval){{ else }}RDB snapshots only (a crash loses every write since the last
snapshot, silently — the instance comes back healthy, only shorter){{ end }}.
helm uninstall does NOT delete this PVC, which is deliberate — the data
survives a reinstall of the same release name. It also means a release removed
and recreated under a DIFFERENT name starts on an empty volume while the old
one keeps occupying space. On encrypted Longhorn volumes that space is never
returned to the pool.
{{- end }}
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}valkey.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so nothing answers on
the address above and every application configured against it will fail its own
checks. Nothing else is removed: Service and Secret stay{{ if .Values.persistence.enabled }}, the PVC keeps
the data{{ end }}{{ if .Values.networkPolicy.enabled }}, and the NetworkPolicy stays in force{{ end }}.
{{ end }}
CHANGING THE PASSWORD IS NOT COVERED BY A CHECKSUM
The pod template carries checksum/config over the configuration ConfigMap and
checksum/secret-ref over the rendered OnePasswordItem. So a changed
maxmemory/policy rolls the pod, and so does repointing secrets.authSecretRef at
a DIFFERENT vault item. What cannot be covered is a new password inside the
same item: Helm never sees the content of a Secret the 1Password operator
fills in. After changing it there:
kubectl -n {{ .Release.Namespace }} rollout restart statefulset {{ .Release.Name }}-{{ .Chart.Name }}-sfs
(operator.1password.io/auto-restart is not set on purpose — the operator
restarts Deployments only, so on a StatefulSet it would be a silent no-op.)
Unverified, and named so rather than promised: because the probes read the
password out of the same mounted file, the kubelet's refresh of that file
(roughly a minute) should make the liveness probe fail against the still-old
server and restart the container by itself. Do the rollout restart instead of
waiting for it.
{{ if .Values.networkPolicy.enabled }}
NETWORK POLICY IS ACTIVE
Ingress to port 6379 is allowed from namespace {{ .Release.Namespace }}{{ if .Values.networkPolicy.allowedNamespaces }} and from
{{ join ", " .Values.networkPolicy.allowedNamespaces }}{{ end }}. Egress is unrestricted (this instance opens no
outbound connection of its own).
It is only a boundary where the CNI enforces NetworkPolicies. Where it does
not, the object is accepted and nothing is filtered — so verify it instead of
reading it off the manifest:
kubectl -n <some-other-namespace> run t --rm -it --image=docker.io/valkey/valkey:{{ .Chart.AppVersion }} \
-- valkey-cli -h {{ .Release.Name }}-{{ .Chart.Name }}-service.{{ .Release.Namespace }} ping
That must hang or be refused. "NOAUTH Authentication required." means the
policy is not being enforced and the password is your only boundary.
{{- else }}
NO NETWORK POLICY
networkPolicy.enabled is false, so the password is the only thing between this
instance and every pod in the cluster. The official image compiles
protected-mode off, so there is no second line of defence behind it.
{{- end }}
WHAT A GREEN POD PROVES
All three probes run "valkey-cli -e ping" AUTHENTICATED with the password from
the mounted file and require the answer PONG. So Ready means: the server
accepted a connection, accepted that password, and answered.
Two things it does not mean. It does not say anything about the dataset — a
restarted instance whose AOF was lost answers PONG on an empty database. And
it does not say your application's keys are reachable: that depends on the
connection URL in the application's own vault item, which this chart neither
writes nor reads.
The probe asserts the answer (PONG), not just that the command returned, and
it passes "-e" so that valkey-cli reports an error reply through its exit code
at all — without it a bare "valkey-cli ping" exits 0 while printing
"NOAUTH Authentication required.", which would be a probe that cannot fail.