feat(valkey): one Valkey instance per application (1.0.0+up9.1.2.alpine)
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
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
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.
This commit was merged in pull request #53.
This commit is contained in:
19 files changed
+1687
No files matched your search
@@ -0,0 +1,281 @@
|
||||
# One Valkey instance for ONE application - a cache or a queue backend, not a
|
||||
# shared database. There is deliberately no cluster mode, no replication and no
|
||||
# Ingress: a Valkey instance belongs on the cluster network, reachable through
|
||||
# its ClusterIP Service, and everything that would expose it to the internet is
|
||||
# absent rather than switched off.
|
||||
#
|
||||
# The two shapes this chart is meant for, and they are PAIRS of values, not two
|
||||
# independent switches:
|
||||
#
|
||||
# QUEUE (the chart default) - Sidekiq, Celery, anything whose entries are work
|
||||
# that must not vanish:
|
||||
# persistence.enabled: true
|
||||
# valkey.maxmemoryPolicy: noeviction
|
||||
#
|
||||
# CACHE - recomputable data only:
|
||||
# persistence.enabled: false
|
||||
# valkey.maxmemoryPolicy: allkeys-lru
|
||||
#
|
||||
# Mixing them is what this comment exists to prevent. A queue with
|
||||
# allkeys-lru throws away jobs under memory pressure while every application
|
||||
# stays green - one of the hypotheses in cluster-config#102, and cheaper to
|
||||
# prevent here than to diagnose there. And "persistence.enabled: false" together
|
||||
# with the default "noeviction" is almost never what anyone wants: a full cache
|
||||
# then starts REFUSING WRITES instead of dropping cold keys, and after a pod
|
||||
# restart it is empty anyway - so the strictness buys nothing and costs write
|
||||
# errors.
|
||||
|
||||
# Scale the whole chart down without uninstalling it: scaleDown: true renders the
|
||||
# StatefulSet with replicas: 0 and takes precedence over valkey.replicas. The
|
||||
# PVC, the Secret, the Service and the NetworkPolicy stay in place;
|
||||
# scaleDown: false brings the workload back up.
|
||||
scaleDown: false
|
||||
|
||||
secrets:
|
||||
# op:// path of the 1Password item holding this instance's password.
|
||||
#
|
||||
# The item must carry ONE field, named "auth-conf", whose value is a complete
|
||||
# Valkey configuration line:
|
||||
#
|
||||
# requirepass <the password>
|
||||
#
|
||||
# Not the bare password: the chart mounts this field as a file and the main
|
||||
# configuration "include"s it, so the password never appears in the process
|
||||
# list, in an environment variable, or anywhere Helm can see it. That is also
|
||||
# why no init container assembles the line - the vault is the only place the
|
||||
# secret exists in.
|
||||
#
|
||||
# A ready-made configuration line has one failure mode, and the chart closes
|
||||
# it: a misspelled key ("requirepas"), a stray quote or an empty field would
|
||||
# leave Valkey running WITHOUT a password and perfectly healthy - no render
|
||||
# error, no crash, an open server (the official image compiles protected-mode
|
||||
# OFF, so "no password" really does mean "anyone on the pod network"). The
|
||||
# init container therefore greps the mounted file for a line beginning with
|
||||
# "requirepass " and refuses to start the pod if it is absent. It never prints
|
||||
# the file.
|
||||
#
|
||||
# What this chart CANNOT cover: a change of the field's CONTENT inside the
|
||||
# vault. Helm never sees a secret materialized by the 1Password operator, so
|
||||
# there is nothing to hash. Repointing this value at a DIFFERENT item is
|
||||
# covered - checksum/secret-ref over the rendered OnePasswordItem rolls the
|
||||
# pod. After changing the password inside the item, restart by hand:
|
||||
# kubectl -n <ns> rollout restart statefulset <release>-valkey-sfs
|
||||
# (operator.1password.io/auto-restart is NOT set: on a StatefulSet it is a
|
||||
# silent no-op - see README, "Config changes must restart the workload".)
|
||||
#
|
||||
# One side effect of the probes reading the same file, stated as UNVERIFIED
|
||||
# because it has not been measured: the auth file is a directory mount, so the
|
||||
# kubelet refreshes it within about a minute of the operator writing the new
|
||||
# password, while the running server still requires the old one. The liveness
|
||||
# probe then fails and the container is restarted - i.e. a rotated password
|
||||
# probably applies itself after roughly a minute of the instance being
|
||||
# unreachable. Do not rely on it; do the rollout restart above and know when
|
||||
# the interruption happens.
|
||||
authSecretRef: null
|
||||
|
||||
valkey:
|
||||
# Replica count for this workload; 0 is allowed and scales it down. Values
|
||||
# above 1 are rejected by the schema: this chart runs ONE Valkey, and two
|
||||
# pods would fight over one ReadWriteOnce volume while clients would silently
|
||||
# get two different datasets through the Service.
|
||||
# The chart-global scaleDown flag takes precedence.
|
||||
replicas: 1
|
||||
|
||||
# Memory is the resource that decides what this chart does, because
|
||||
# valkey.maxmemory is derived from the computed memory LIMIT (see below).
|
||||
# 256Mi request x limitFactor 3 = 768Mi limit, of which Valkey may fill
|
||||
# maxmemoryFactor (0.75) = 576Mi with data.
|
||||
resources:
|
||||
# Limits are optional: memory and ephemeral-storage limits default to
|
||||
# limitFactor x the request, an explicitly set limit always wins, and a
|
||||
# CPU limit is only set when configured explicitly under "limits".
|
||||
limitFactor: 3
|
||||
requests:
|
||||
# Valkey is single-threaded for command execution; 100m is enough for a
|
||||
# per-application cache and is a request, not a cap.
|
||||
cpu: 0.1
|
||||
memory: 256Mi
|
||||
# The data volume is separate (persistence below). This covers the
|
||||
# container's own writable scratch space, which under this chart is only
|
||||
# /data when persistence is off.
|
||||
ephemeral-storage: 64Mi
|
||||
|
||||
# The fraction of the container's memory LIMIT that Valkey may fill with data.
|
||||
#
|
||||
# Why a fraction and not the whole limit: maxmemory bounds the dataset, not
|
||||
# the process. Client output buffers, replication/AOF buffers and above all
|
||||
# the copy-on-write pages of the fork() that writes an RDB snapshot or rewrites
|
||||
# the AOF all live OUTSIDE maxmemory. Handing Valkey the full limit means the
|
||||
# kubelet OOM-kills the pod at the exact moment the eviction policy was
|
||||
# supposed to take over - which is the failure this value exists to prevent.
|
||||
maxmemoryFactor: 0.75
|
||||
|
||||
# maxmemory as an explicit Kubernetes quantity (e.g. "512Mi"), overriding the
|
||||
# derivation from maxmemoryFactor. null = derive it.
|
||||
#
|
||||
# A value ABOVE the computed memory limit ABORTS THE RENDER with both numbers
|
||||
# named. It is not clamped and not warned about: a maxmemory above the limit
|
||||
# means the kubelet's OOM kill decides what the eviction policy was configured
|
||||
# to decide, and that failure arrives as a restarted pod with no explanation.
|
||||
maxmemory: null
|
||||
|
||||
# Valkey's maxmemory-policy. Default "noeviction" on purpose - see the pairs
|
||||
# at the top of this file. Under pressure Valkey then rejects writes with an
|
||||
# OOM error, which is loud in the application instead of silent in the data.
|
||||
# For a pure cache use "allkeys-lru".
|
||||
maxmemoryPolicy: noeviction
|
||||
|
||||
# Extra Valkey configuration lines, appended verbatim to the rendered
|
||||
# configuration file (e.g. "appendfsync everysec" or "timeout 300").
|
||||
#
|
||||
# The auth include is rendered AFTER this block, and in a Valkey configuration
|
||||
# the last occurrence of a directive wins - so nothing put here can switch the
|
||||
# password off, by accident or otherwise. It CAN override maxmemory and
|
||||
# maxmemory-policy, which then silently escapes the limit check above; set
|
||||
# valkey.maxmemory instead of writing a maxmemory line here.
|
||||
extraConfig: ""
|
||||
|
||||
# All three probes run the same AUTHENTICATED "valkey-cli ping" as an exec
|
||||
# probe - Valkey serves no HTTP, so there is nothing to GET. The command is a
|
||||
# script from the chart's ConfigMap (templates/valkey.configmap.yaml, key
|
||||
# ping.sh), which is where the two properties that make it an assertion rather
|
||||
# than a formality are written down and commented:
|
||||
#
|
||||
# - the answer is asserted, not just the exit code ("grep -qx PONG") AND
|
||||
# valkey-cli is called with "-e" - because without -e it EXITS 0 on an
|
||||
# error reply, so a bare "valkey-cli ping" probe would report success while
|
||||
# printing "NOAUTH Authentication required.";
|
||||
# - the password goes through the VALKEYCLI_AUTH environment variable, read
|
||||
# out of the mounted auth file, never as an "-a" argument, which would put
|
||||
# it in the process list.
|
||||
#
|
||||
# Keeping the script in the ConfigMap (instead of a one-line shell command
|
||||
# inline here) has one consequence worth knowing: editing valkey.maxmemory or
|
||||
# any other configuration value changes the ConfigMap, and checksum/config
|
||||
# then rolls the pod - which it has to do anyway for the configuration itself.
|
||||
#
|
||||
# What these probes prove: the server accepted a connection, authenticated
|
||||
# with the password from the vault, and answered PONG. What they do not prove:
|
||||
# that the dataset is intact or that the application's keys are there.
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["/bin/sh", "/etc/valkey/ping.sh"]
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["/bin/sh", "/etc/valkey/ping.sh"]
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
# The startup probe gates liveness/readiness, so those need no
|
||||
# initialDelaySeconds. Startup budget = periodSeconds x failureThreshold =
|
||||
# 150s, which is what replaying a large append-only file from disk can cost; an
|
||||
# empty instance answers on the first check.
|
||||
startupProbe:
|
||||
exec:
|
||||
command: ["/bin/sh", "/etc/valkey/ping.sh"]
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 30
|
||||
|
||||
securityContext:
|
||||
pod:
|
||||
# uid 999 / gid 1000 are NOT the repo's usual 20000/1000 and they are not
|
||||
# interchangeable: they are the "valkey" user the official image creates
|
||||
# (9.1/alpine/Dockerfile: "addgroup -S -g 1000 valkey; adduser -S -G
|
||||
# valkey -u 999 valkey" - gid 1000 because Alpine already uses gid 999).
|
||||
# The image sets no USER and starts as root; its entrypoint then steps
|
||||
# down to that user with setpriv, chown'ing /data on the way. Running the
|
||||
# pod as uid 999 directly skips that branch of the entrypoint - which is
|
||||
# the point (runAsNonRoot) and also why fsGroup has to match: without it
|
||||
# nothing would make the volume writable for the process.
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: Always
|
||||
# RuntimeDefault applies the container runtime's default seccomp filter.
|
||||
# It sits in the pod context on purpose: from here it covers every
|
||||
# container in the pod INCLUDING the init container that validates the
|
||||
# auth file, while a container-level profile would have to be repeated for
|
||||
# each one (and silently missed on the next one added).
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
container:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add: []
|
||||
|
||||
# Additive environment entries for the Valkey container, appended to what the
|
||||
# chart sets. Supported forms: value (rendered through tpl), secretKeyRef,
|
||||
# configMapKeyRef.
|
||||
#
|
||||
# Note what does NOT belong here: the password. The image's entrypoint appends
|
||||
# $VALKEY_EXTRA_FLAGS to the server command line, so a password routed through
|
||||
# this block would end up in the process list - exactly what the mounted
|
||||
# configuration file avoids.
|
||||
#
|
||||
# Additive means additive: an entry repeating a name the chart already sets is
|
||||
# applied at install time and LOST on an upgrade of an existing release
|
||||
# (Kubernetes merges env by name), while helm still reports success.
|
||||
env: []
|
||||
|
||||
persistence:
|
||||
# true = the dataset lives on a PVC and survives a pod restart. Off for a pure
|
||||
# cache; see the pairs at the top of this file.
|
||||
enabled: true
|
||||
|
||||
# Append-only file instead of RDB snapshots.
|
||||
#
|
||||
# Why AOF is the default: for a queue, the question is not "is there a backup"
|
||||
# but "how much is lost when the pod dies". An RDB snapshot is written every
|
||||
# few minutes, so a crash loses every job accepted since the last one - and it
|
||||
# loses them silently, because the queue comes back looking healthy, only
|
||||
# shorter. AOF appends every write, so the loss window is the fsync interval.
|
||||
# The cost is disk writes and a slower start (the file is replayed), which the
|
||||
# startup budget above accounts for.
|
||||
#
|
||||
# With persistence.enabled: false this value has nothing to append to, and the
|
||||
# chart then renders "appendonly no" plus "save ''" so that a pod without a
|
||||
# volume writes nothing to its ephemeral storage at all. NOTES.txt prints
|
||||
# which of the two is active for the release at hand.
|
||||
appendonly: true
|
||||
|
||||
# Small on purpose. On encrypted Longhorn volumes space is never returned to
|
||||
# the pool once written, so an oversized volume is paid for permanently -
|
||||
# start tight and grow (a PVC can grow; it can never shrink).
|
||||
size: 1Gi
|
||||
|
||||
# longhorn-encrypted-ns: the data here is an application's cache or queue
|
||||
# content, which regularly contains the same payloads as its database.
|
||||
# A different class (or null for the cluster default) is possible, but then
|
||||
# the data is at rest unencrypted, which is a decision and not a detail.
|
||||
storageClass: longhorn-encrypted-ns
|
||||
|
||||
networkPolicy:
|
||||
# On by default. This is a new chart with no existing installation that could
|
||||
# break on it, and a cache whose only boundary is a password is one leaked
|
||||
# credential away from being open to every pod in the cluster.
|
||||
#
|
||||
# READ THIS BEFORE RELYING ON IT: a NetworkPolicy is only a boundary if the
|
||||
# cluster's CNI enforces policies. Where it does not, the API server still
|
||||
# accepts the object and everything looks configured while nothing is
|
||||
# filtered - an unenforced policy is a false sense of security, not a weak
|
||||
# one. Verify on the cluster (try to connect from an unrelated namespace)
|
||||
# rather than reading it off this value.
|
||||
enabled: true
|
||||
|
||||
# Namespaces whose pods may reach this instance in addition to the release's
|
||||
# own namespace. Empty means: own namespace only.
|
||||
#
|
||||
# Matched by the "kubernetes.io/metadata.name" label, which Kubernetes sets on
|
||||
# every namespace itself (>= 1.21), so no labelling work is needed - but also
|
||||
# no typo protection: a namespace name that does not exist matches nothing and
|
||||
# is silently ineffective.
|
||||
allowedNamespaces: []
|
||||
Reference in new issue
Block a user