Merge pull request #190 from jk-cluster/feat/minecraft-server-notes-and-157
minecraft-server: NOTES.txt and the exponential-quantity fix (4.1.0+up2026.8.2.java21)
This commit is contained in:
3 files changed
+352
-3
No files matched your search
@@ -13,7 +13,7 @@ type: application
|
||||
# continuously, so a pod restart could pull a different image than the last one
|
||||
# without anything being recorded anywhere. The dated tags are immutable, so the
|
||||
# image is now pinned by the chart version like every other chart in this repo.
|
||||
version: 4.0.1+up2026.8.2.java21
|
||||
version: 4.1.0+up2026.8.2.java21
|
||||
appVersion: "2026.8.2-java21"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
{{- $replicas := include "minecraft-server.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.server.replicas) -}}
|
||||
{{- $res := fromYaml (include "minecraft-server.serverResources" .) -}}
|
||||
{{- $req := $res.requests | default dict -}}
|
||||
{{- $lim := $res.limits | default dict -}}
|
||||
{{- $reqMem := include "minecraft-server.formatNumber" (index $req "memory") -}}
|
||||
{{- $limMem := include "minecraft-server.formatNumber" (index $lim "memory") -}}
|
||||
{{- $startup := fromYaml (include "minecraft-server.startupProbe" .Values.startupProbe) -}}
|
||||
{{- $budget := "" -}}
|
||||
{{- if and (kindIs "float64" $startup.periodSeconds) (kindIs "float64" $startup.failureThreshold) -}}
|
||||
{{- $budget = printf "%.0f seconds" (mulf $startup.periodSeconds $startup.failureThreshold) -}}
|
||||
{{- end -}}
|
||||
{{- $line := regexFind "java[0-9]+" (printf "%v" .Chart.AppVersion) -}}
|
||||
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
||||
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
|
||||
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
|
||||
{{- $pvc := printf "%s-mc-volume-claim" .Release.Name -}}
|
||||
{{- $secret := printf "%s-%s-secret" .Release.Name .Chart.Name -}}
|
||||
{{- $nodePort := .Values.server.service.type | eq "NodePort" -}}
|
||||
{{- /* Whether the operator set server.resources.requests.memory themselves;
|
||||
an unset one is derived from the JVM heap by serverResources. */ -}}
|
||||
{{- $givenReq := dict -}}
|
||||
{{- $sr := .Values.server.resources -}}
|
||||
{{- if kindIs "map" $sr -}}
|
||||
{{- if kindIs "map" $sr.requests -}}
|
||||
{{- $givenReq = $sr.requests -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $memPinned := not (kindIs "invalid" (index $givenReq "memory")) -}}
|
||||
minecraft-server {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
ACCESS — A MINECRAFT SERVER, NOT A WEB SERVICE
|
||||
This release has no Ingress, no certificate and no URL. Clients speak the
|
||||
Minecraft protocol over raw TCP.
|
||||
{{- if $nodePort }}
|
||||
|
||||
server.service.type is NodePort, so players dial a node of this cluster
|
||||
directly:
|
||||
|
||||
<any-node-ip>:{{ .Values.server.service.outwardPort }}
|
||||
|
||||
server.service.outwardPort ({{ .Values.server.service.outwardPort }}) is the port on the node and the one that
|
||||
goes into the client. server.service.port ({{ .Values.server.service.port }}) is what the Service publishes
|
||||
inside the cluster and server.port ({{ .Values.server.port }}) is what the server listens on in the
|
||||
container; neither of those two is what a player types.
|
||||
{{- if or (lt (int .Values.server.service.outwardPort) 30000) (gt (int .Values.server.service.outwardPort) 32767) }}
|
||||
|
||||
{{ .Values.server.service.outwardPort }} IS OUTSIDE THE DEFAULT NODE-PORT RANGE (30000-32767)
|
||||
Unless the API server runs with an extended --service-node-port-range it
|
||||
rejects this Service, and the release fails on that one object while every
|
||||
other object applies cleanly. Confirm it exists:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get svc {{ $svc }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
|
||||
server.service.type is ClusterIP, so nothing outside the cluster reaches this
|
||||
server on its own. Inside the cluster it answers at
|
||||
|
||||
{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.server.service.port }}
|
||||
|
||||
The way in for players is the separate minecraft-router release, and that
|
||||
router does NOT discover backends by itself: it routes on the hostname out of
|
||||
the Minecraft handshake, using the static mappings table in its own values
|
||||
file. Until an entry pointing at the Service name above is added THERE, this
|
||||
server is unreachable from outside no matter how healthy it is here — and no
|
||||
annotation on this release changes that.
|
||||
|
||||
mc.example.com → {{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.server.service.port }}
|
||||
|
||||
Set server.service.type to NodePort instead to publish it on a node port
|
||||
without the router; then server.service.outwardPort ({{ .Values.server.service.outwardPort }}) applies.
|
||||
{{- end }}
|
||||
|
||||
Either way you can reach it from your own machine without changing the
|
||||
Service — forward it and connect the client to localhost:{{ .Values.server.service.port }}:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $svc }} {{ .Values.server.service.port }}:{{ .Values.server.service.port }}
|
||||
|
||||
THIS CHART IS PUBLISHED ONCE PER JAVA LINE — THIS IS {{ $line | default "an unrecognized line" | upper }}
|
||||
The JVM is fixed by the image tag, which comes from the chart's appVersion
|
||||
({{ .Chart.AppVersion }}), not from a value. This build runs
|
||||
minecraft.version {{ .Values.minecraft.version }}.
|
||||
|
||||
Java 8 runs Minecraft up to 1.16.5, 1.18-1.20.4 need Java 17 and 1.20.5+ need
|
||||
Java 21. Nothing in this chart checks that minecraft.version fits the line:
|
||||
a mismatch renders and installs without a word, and only the container fails
|
||||
afterwards — CrashLoopBackOff once the startup budget{{ if $budget }} ({{ $budget }}){{ end }} is spent, with
|
||||
the reason in the log and nowhere else:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} logs {{ $pod }}
|
||||
|
||||
So moving the server across a line boundary is a change of CHART VERSION, not
|
||||
a change of minecraft.version: pick the published chart of the target line and
|
||||
set minecraft.version to match it. Changing only minecraft.version leaves the
|
||||
old JVM in place.
|
||||
{{- if eq $replicas "0" }}
|
||||
|
||||
SCALED TO ZERO
|
||||
{{ if .Values.scaleDown }}scaleDown: true{{ else }}server.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so no server answers
|
||||
until you set it back and upgrade again. The world PVC, the Service and the
|
||||
OnePasswordItem stay in place, so nothing is lost.
|
||||
{{- end }}
|
||||
|
||||
WHAT A GREEN ROLLOUT DOES NOT PROVE
|
||||
All three probes run the image's mc-health, a real Minecraft server-list ping
|
||||
against SERVER_PORT. Ready therefore means more here than "the process is
|
||||
alive": the server is up and answering pings. It does not mean anyone can
|
||||
play. A server-list ping is answered before any login is attempted, so every
|
||||
reason a player is refused at login is invisible to all three probes — the
|
||||
whitelist below is the usual one, and the rollout stays green throughout.
|
||||
|
||||
Two more blind spots worth knowing before you need them:
|
||||
|
||||
- helm returns before any of this. Without --wait, success is reported the
|
||||
moment the objects are applied. On a fresh volume the image is then still
|
||||
downloading the server jar{{ if or .Values.curseforge.enabled .Values.ftb.enabled .Values.curseforgeServerpack.enabled }} and the whole modpack{{ end }} before the world begins
|
||||
to load, which is what the {{ if $budget }}{{ $budget }} {{ end }}startup budget is for. Watch the
|
||||
rollout rather than trusting the exit code:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} rollout status statefulset/{{ $sfs }}
|
||||
|
||||
- the probes run INSIDE the pod. They say nothing about whether players can
|
||||
get to it{{ if not $nodePort }} — with ClusterIP, a missing router mapping is a perfectly
|
||||
healthy server that nobody can join{{ end }}.
|
||||
{{- if .Values.minecraft.whitelist.enabled }}
|
||||
{{- if not .Values.minecraft.whitelist.players }}
|
||||
|
||||
WHITELIST IS ON AND EMPTY — NOBODY CAN JOIN THIS SERVER
|
||||
minecraft.whitelist.enabled is true while minecraft.whitelist.players is
|
||||
unset. The chart emits ENABLE_WHITELIST=true and no WHITELIST, which is
|
||||
white-list=true against an empty whitelist.json: every login is refused,
|
||||
including the operators in minecraft.management.opPlayers. This is the image's
|
||||
own behaviour, and it is exactly the state the probes cannot see — the server
|
||||
answers pings and the rollout is green.
|
||||
|
||||
Set minecraft.whitelist.players, or set minecraft.whitelist.enabled to false.
|
||||
{{- else }}
|
||||
|
||||
WHITELIST IS ON
|
||||
Only these players can log in: {{ .Values.minecraft.whitelist.players }}
|
||||
|
||||
minecraft.whitelist.overridePolicy is {{ .Values.minecraft.whitelist.overridePolicy }}, which decides what happens to a
|
||||
whitelist.json ALREADY on the volume: SKIP leaves it untouched (players then
|
||||
changes nothing at all), MERGE adds these names to it, SYNCHRONIZE makes the
|
||||
file match this list exactly and removes everyone else. Anyone added in-game
|
||||
with /whitelist add is not in this value and does not survive a SYNCHRONIZE.
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
|
||||
WHITELIST IS OFF, AND IT IS ACTIVELY OFF
|
||||
minecraft.whitelist.enabled is false, so the chart emits ENABLE_WHITELIST=false
|
||||
and the image rewrites white-list=false into the server.properties on the
|
||||
volume. That matters because server.properties is persistent state: before
|
||||
chart 2.0.0 a release that had once written white-list=true kept it forever,
|
||||
since the chart had no way to say "off". It now does.
|
||||
{{- end }}
|
||||
{{- if .Values.minecraft.management.opPlayers }}
|
||||
|
||||
OPERATORS ARE SYNCHRONIZED FROM VALUES, NOT FROM THE GAME
|
||||
minecraft.management.opPlayers is {{ .Values.minecraft.management.opPlayers }}, and the chart pins
|
||||
EXISTING_OPS_FILE=SYNCHRONIZE: the image makes ops.json on the volume match
|
||||
that list exactly on every start. A player promoted in-game with /op is not in
|
||||
the value, so the next restart removes them again. Promote people in the
|
||||
values file.
|
||||
|
||||
These names are also resolved against the external Playerdb API during
|
||||
startup, and a name that does not resolve aborts the start. A typo here is
|
||||
therefore not a missing operator but a server that never comes up — and an
|
||||
outage of that third-party lookup delays every pod start.
|
||||
{{- end }}
|
||||
|
||||
SECRETS — WHICH FIELDS THE 1PASSWORD ITEM MUST CARRY
|
||||
The OnePasswordItem CR {{ $secret }} is rendered unconditionally, so
|
||||
secrets.minecraftServerRef has to be set even when nothing consumes a key from
|
||||
it; it has no default and would otherwise render an itemPath of null.
|
||||
|
||||
secrets.minecraftServerRef → {{ .Values.secrets.minecraftServerRef | default "NOT SET" }}
|
||||
|
||||
The operator copies the item's FIELDS into the Kubernetes Secret, one key per
|
||||
field name. This release consumes:
|
||||
{{- if .Values.server.rcon.enabled }}
|
||||
rcon-password → RCON_PASSWORD (server.rcon.enabled is true)
|
||||
{{- end }}
|
||||
{{- if .Values.curseforge.enabled }}
|
||||
curseforge-api-key → CF_API_KEY (curseforge.enabled is true)
|
||||
{{- end }}
|
||||
{{- if not (or .Values.server.rcon.enabled .Values.curseforge.enabled) }}
|
||||
no key at all — RCON and the CurseForge downloader are both off, so the
|
||||
item may be empty. It only needs "rcon-password" once server.rcon.enabled
|
||||
is true and "curseforge-api-key" once curseforge.enabled is true.
|
||||
{{- end }}
|
||||
|
||||
A field spelled differently yields a Secret without that key. The pod then
|
||||
sticks in CreateContainerConfigError while the Secret itself exists, so it
|
||||
looks as though the operator had done its job. What it actually delivered:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get secret {{ $secret }} -o jsonpath='{.data}'
|
||||
|
||||
Pointing secrets.minecraftServerRef at a DIFFERENT item rolls the pod (the
|
||||
StatefulSet hashes the rendered CR into checksum/secret-ref). Changing the
|
||||
value INSIDE the same item does not: Helm never sees that content, the
|
||||
rendered spec is unchanged, and the keys are consumed as environment
|
||||
variables, which Kubernetes never refreshes in a running container. After
|
||||
rotating a password in the vault, restart by hand:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ $sfs }}
|
||||
|
||||
Budget for that restart: a full server start, measured at ~124 s for
|
||||
ftb-skies on Longhorn.
|
||||
|
||||
THE WORLD LIVES ON ONE PVC THAT helm uninstall DELETES
|
||||
/data and /modpacks are two subPaths of a single ReadWriteOnce claim,
|
||||
{{ $pvc }} ({{ .Values.minecraft.resources.storage }} from storage class {{ .Values.minecraft.resources.storageClassName }}). Note the
|
||||
name: it is deliberately NOT {{ .Release.Name }}-{{ .Chart.Name }}-..., because renaming a PVC
|
||||
would make Kubernetes provision a new, empty volume and orphan the world.
|
||||
|
||||
It is an ordinary PVC in this release, not a StatefulSet volumeClaimTemplate.
|
||||
"helm uninstall {{ .Release.Name }}" therefore deletes it, and with a Delete reclaim policy
|
||||
the world goes with it. Back it up before uninstalling — a resource-policy
|
||||
annotation added to the live PVC afterwards does not help, because Helm reads
|
||||
that annotation from the release manifest.
|
||||
|
||||
Two more properties of that claim: minecraft.resources.storageClassName is
|
||||
immutable once the volume is bound, so changing it does nothing to an existing
|
||||
release; and growing minecraft.resources.storage only works if the storage
|
||||
class allows expansion, while shrinking it is refused by Kubernetes and makes
|
||||
the next helm upgrade fail on the PVC instead of touching the data.
|
||||
|
||||
server.properties lives on this volume too. That is why settings the chart
|
||||
does not send as an environment variable persist across upgrades unchanged.
|
||||
|
||||
TWO MEMORY NUMBERS THAT MUST NOT DRIFT APART
|
||||
minecraft.resources.memory ({{ .Values.minecraft.resources.memory }}) is the JVM heap, passed to the image as
|
||||
MEMORY={{ .Values.minecraft.resources.memory }}G. This release runs with
|
||||
|
||||
memory request {{ $reqMem }} limit {{ $limMem | default "(none)" }} (limitFactor {{ .Values.server.resources.limitFactor | default 3 }})
|
||||
|
||||
{{- if not $memPinned }}
|
||||
|
||||
server.resources.requests.memory is unset, so the chart derives the request
|
||||
from the heap and the two cannot drift. Keep it that way unless you have a
|
||||
reason not to.
|
||||
{{- else }}
|
||||
|
||||
server.resources.requests.memory is set explicitly, which DECOUPLES it from
|
||||
the heap: the chart no longer derives one from the other. Keep the limit above
|
||||
the heap plus the JVM's own overhead. A heap that does not fit the limit is
|
||||
not a Java OutOfMemoryError — the kernel kills the container, and the pod shows
|
||||
OOMKilled with nothing in the server log explaining it.
|
||||
{{- end }}
|
||||
{{- if .Values.server.rcon.enabled }}
|
||||
|
||||
RCON IS ENABLED BUT THE SERVICE DOES NOT PUBLISH IT
|
||||
server.rcon.enabled is true, so the container declares port {{ .Values.server.rcon.port }} and the
|
||||
chart wires RCON_PASSWORD from the Secret. The Service publishes only the
|
||||
Minecraft port, so RCON is reachable from inside the pod's network and through
|
||||
a forward — not through {{ $svc }}:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward {{ $pod }} {{ .Values.server.rcon.port }}:{{ .Values.server.rcon.port }}
|
||||
|
||||
Exposing RCON more widely is a decision to make deliberately: it is a remote
|
||||
console with full server rights, protected by that one password.
|
||||
{{- end }}
|
||||
|
||||
THE METRICS PORT IS DECLARED, NOT SERVED
|
||||
The container declares port 19565 named "metrics". Declaring a containerPort
|
||||
opens nothing: the Service does not publish it, this chart ships no
|
||||
ServiceMonitor, and it sets no variable that would switch the image's metrics
|
||||
on. Treat that entry as documentation, not as a scrape target.
|
||||
{{- if .Values.curseforgeServerpack.enabled }}
|
||||
|
||||
MODPACK SOURCE: curseforgeServerpack
|
||||
An init container downloads {{ .Values.curseforgeServerpack.downloadUrl }}
|
||||
to /modpacks/serverpack.zip, and the server unpacks it below
|
||||
{{ .Values.curseforgeServerpack.workingDirectory }}.
|
||||
|
||||
The script comes from the ConfigMap and is mounted with subPath, which the
|
||||
kubelet never refreshes, so it is read only at pod start — the StatefulSet
|
||||
carries checksum/config, which is what makes a changed URL take effect at all.
|
||||
The restart is not free: expect a full server start (~124 s measured for
|
||||
ftb-skies on Longhorn).
|
||||
|
||||
Its migration branch runs only when the working directory ALREADY exists; on
|
||||
a first install the script just downloads, and the server does the unpacking.
|
||||
When it does run, it copies banned-ips.json, banned-players.json, ops.json and
|
||||
whitelist.json out of the backup unconditionally, under "set -e" — so an
|
||||
existing serverpack directory that is missing any one of those four files
|
||||
fails the init container, and the pod never starts. Check there first when a
|
||||
serverpack upgrade hangs before the server logs anything:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} logs {{ $pod }} -c download-serverpack
|
||||
{{- end }}
|
||||
{{- if .Values.curseforge.enabled }}
|
||||
|
||||
MODPACK SOURCE: curseforge (AUTO_CURSEFORGE)
|
||||
The image resolves slug "{{ .Values.curseforge.modpack.slug }}" / file id {{ .Values.curseforge.modpack.versionId }} against the CurseForge
|
||||
API at every start, using the curseforge-api-key field of the item above. That
|
||||
makes each start depend on a third-party API and on that key still being
|
||||
valid; an expired key is a server that stops coming up, not a warning.
|
||||
{{ if .Values.curseforge.forceReload }}curseforge.forceReload is true, so CF_FORCE_SYNCHRONIZE is set and the pack is
|
||||
re-synchronized on every single start. Turn it off again once the reload has
|
||||
happened — it costs that time at each restart.{{ else }}curseforge.forceReload is false, so an already installed pack is reused.{{ end }}
|
||||
{{- end }}
|
||||
{{- if .Values.ftb.enabled }}
|
||||
|
||||
MODPACK SOURCE: ftb (FTBA)
|
||||
The image fetches modpack {{ .Values.ftb.modpackId }} version {{ .Values.ftb.versionId }} from the Feed-the-Beast API at
|
||||
start. No API key is involved, but the start does depend on that service being
|
||||
reachable, and the first one downloads the whole pack before the world loads.
|
||||
{{- end }}
|
||||
{{- if gt (atoi $replicas) 1 }}
|
||||
|
||||
MORE THAN ONE REPLICA WILL NOT WORK
|
||||
server.replicas is {{ $replicas }}, but every pod of this StatefulSet mounts the same
|
||||
ReadWriteOnce claim {{ $pvc }} — there is no volumeClaimTemplate and no
|
||||
per-pod volume. Further pods stay stuck before start on a node that does not
|
||||
hold the volume, and two Minecraft servers on one world directory would
|
||||
corrupt it in any case. This chart is built for exactly one replica.
|
||||
{{- end }}
|
||||
{{- if .Values.env }}
|
||||
|
||||
server env IS APPENDED, NOT MERGED
|
||||
The entries in "env" land after everything the chart sets itself and are
|
||||
rendered through tpl, so a value may contain template expressions. Because the
|
||||
chart's own variables come first, an entry repeating one of them is what the
|
||||
image sees last — that is the escape hatch for anything the chart does not
|
||||
model, and equally the way to break a setting the chart does model.
|
||||
|
||||
A plain "value" is stored in the pod spec in clear text and readable by
|
||||
everyone who can read pods in {{ .Release.Namespace }}; anything sensitive belongs in
|
||||
valueFrom.secretKeyRef.
|
||||
{{- end }}
|
||||
@@ -67,7 +67,7 @@ Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary
|
||||
results are printed without a decimal point.
|
||||
*/}}
|
||||
{{- define "minecraft-server.multiplyQuantity" -}}
|
||||
{{- $quantity := printf "%v" .quantity -}}
|
||||
{{- $quantity := include "minecraft-server.formatNumber" .quantity -}}
|
||||
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
|
||||
{{- if not $number -}}
|
||||
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
|
||||
@@ -80,7 +80,24 @@ results are printed without a decimal point.
|
||||
{{- if eq $product (floor $product) -}}
|
||||
{{- printf "%d%s" (int64 $product) $suffix -}}
|
||||
{{- else -}}
|
||||
{{- printf "%v%s" $product $suffix -}}
|
||||
{{- printf "%s%s" (include "minecraft-server.formatNumber" $product) $suffix -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render a number as a plain decimal string, never in exponential notation.
|
||||
|
||||
Why this exists (issue #157): the null-safe fallback helpers pass values
|
||||
through fromYaml, which parses every number as float64. Go's "%v" switches
|
||||
float64 to exponential notation from about 1e6 upwards, so a bare byte count
|
||||
like 1048576 became "1.048576e+06" - not a valid Kubernetes quantity, and the
|
||||
render aborted. Strings ("512Mi") are passed through untouched.
|
||||
*/}}
|
||||
{{- define "minecraft-server.formatNumber" -}}
|
||||
{{- if kindIs "float64" . -}}
|
||||
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%v" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
|
||||
Reference in new issue
Block a user