Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/helm-charts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ jobs:
- name: Test load balancer controller CRD retention
run: python3 -B -m unittest discover -s compute/eks/addons/tests -p 'test_lb_controller_crds_chart.py' -v

- name: Test the StatefulSet volume migration script
run: python3 -B -m unittest discover -s compute/eks/addons/tests -p 'test_statefulset_volume_migration.py' -v

# Renders the pinned mimir-distributed chart, so its pods must match the
# anti-affinity the module writes.
- name: Test Mimir replica placement
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ sync by `node tools/ravion-modules/dist/src/cli.js readme` (enforced in CI, and
| `rvn-ecs-web` | ECS Web Service | v2.3.0 | `compute/ecs_service/` |
| `rvn-ecs-worker` | ECS Worker | v2.2.2 | `compute/ecs_service/` |
| `rvn-efs` | EFS File System | v1.1.0 | `storage/efs/` |
| `rvn-eks-addons` | EKS Add-ons | v0.23.0 | `compute/eks/addons/` |
| `rvn-eks-addons` | EKS Add-ons | v0.23.1 | `compute/eks/addons/` |
| `rvn-eks-chart` | EKS Helm Chart | v0.3.0 | `compute/eks_service/` |
| `rvn-eks-cluster` | EKS Cluster | v0.9.0 | `compute/eks/` |
| `rvn-eks-web` | EKS Web Service | v1.10.0 | `compute/eks_service/` |
Expand Down
27 changes: 20 additions & 7 deletions charts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1009,18 +1009,31 @@ test_thanos() {

test_statefulset_volume_migration() {
local chart="statefulset-volume-migration"
local default job
local default job ctr role
default="$(render "${chart}" default --values "$(chart_path "${chart}")/ci/default-values.yaml")"
job='.[] | select(.kind == "Job")'

assert_eq "${chart}: the Job orphans the StatefulSet so its pod keeps serving" \
"delete statefulset ravion-loki --namespace=ravion-operator --cascade=orphan --ignore-not-found --wait" \
"$(q "${default}" "${job} | .spec.template.spec.containers[0].args | join(\" \")")"
ctr="${job} | .spec.template.spec.containers[0]"
role='.[] | select(.kind == "Role")'

assert_eq "${chart}: the Job runs the migration script with a busybox shell" \
"registry.k8s.io/kubectl:v1.36.4 /tools/sh /scripts/migrate.sh public.ecr.aws/docker/library/busybox:1.37.0-musl" \
"$(q "${default}" "${ctr} | .image") $(q "${default}" "${ctr} | .command | join(\" \")") $(q "${default}" "${job} | .spec.template.spec.initContainers[0].image")"
assert_eq "${chart}: the Job runs the chart's own script" \
"$(cat "$(chart_path "${chart}")/files/migrate.sh")" \
"$(q "${default}" '.[] | select(.kind == "ConfigMap") | .data["migrate.sh"]')"
assert_eq "${chart}: the Job targets the StatefulSet, its layout and the store's flush endpoint" \
"ravion-loki ravion-operator true http://ravion-loki.ravion-operator.svc.cluster.local:3100/flush" \
"$(q "${default}" "${ctr} | .env | map(select(.name == \"STATEFULSET\" or .name == \"NAMESPACE\" or .name == \"PERSISTENT\" or .name == \"FLUSH_URL\")) | map(.value) | join(\" \")")"
assert_eq "${chart}: the Job runs after every install and upgrade of the release" \
"post-install,post-upgrade" "$(q "${default}" "${job} | .metadata.annotations.\"helm.sh/hook\"")"
assert_eq "${chart}: the Job may read and delete that one StatefulSet only" \
assert_eq "${chart}: the Job may read and delete that one StatefulSet" \
"statefulsets ravion-loki delete,get" \
"$(q "${default}" '.[] | select(.kind == "Role") | .rules[0] | .resources[0] + " " + .resourceNames[0] + " " + (.verbs | sort | join(","))')"
"$(q "${default}" "${role} | .rules[0] | .resources[0] + \" \" + .resourceNames[0] + \" \" + (.verbs | sort | join(\",\"))")"
assert_eq "${chart}: and only that StatefulSet's pods, by ordinal" \
"pods ravion-loki-0,ravion-loki-1,ravion-loki-2 delete,get" \
"$(q "${default}" "${role} | .rules[1] | .resources[0] + \" \" + (.resourceNames | join(\",\")) + \" \" + (.verbs | sort | join(\",\"))")"
assert_eq "${chart}: the Job can neither list nor watch" \
"0" "$(q "${default}" "[${role} | .rules[].verbs[] | select(. == \"list\" or . == \"watch\")] | length")"
if helm template test-release "$(chart_path "${chart}")" >/dev/null 2>&1; then
fail "${chart}: a missing target is rejected" "helm template failure" "helm template succeeded"
else
Expand Down
2 changes: 1 addition & 1 deletion compute/eks/addons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ Loki runs in **single-binary mode**: every target in one StatefulSet replica, wi

`loki_persistence_enabled` defaults to **on whenever the module's gp3 StorageClass exists** (`ebs_csi_driver_enabled`), and off otherwise, since a PVC with no StorageClass never binds. Loki's chunks are in S3 either way; what the local volume holds is the write-ahead log, the compactor's working directory, and the index cache, so with a volume a restart loses no logs. With persistence off the module mounts an `emptyDir` at `/var/loki` sized by `loki_persistence_size` — necessary, not cosmetic, because the chart only mounts that path when persistence is on and the container runs with a read-only root filesystem.

A StatefulSet's volume layout cannot change in place, so switching an existing Loki between the two runs a small `loki-volume-migration` release first: its Job deletes the Loki StatefulSet with `--cascade=orphan`, leaving the pod serving, and the Loki release recreates it with the new layout in the same apply and replaces the pod. Logs not yet flushed from the old emptyDir are lost once, as on any restart.
A StatefulSet's volume layout cannot change in place, so switching an existing Loki between the two runs a small `loki-volume-migration` release first: its Job asks Loki to flush to S3, then deletes the Loki StatefulSet together with its pod, and the Loki release recreates both with the new layout in the same apply. Loki is down for about a minute, from the delete until the new pod is ready; Alloy retries in the meantime. The pod is never orphaned: a recreated StatefulSet that adopts a pod without its volume cannot add the volume in place and never replaces the pod, which is what version 0.1.0 of the migration chart did. On a cluster it left that way, the Job replaces the pod on the next apply. When the layout already matches, the Job changes nothing.

#### CloudWatch is a provider, not a mode

Expand Down
8 changes: 8 additions & 0 deletions compute/eks/addons/alloy.tf
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ resource "helm_release" "alloy" {
# for CriticalAddonsOnly) run Karpenter, CoreDNS and the Operator
# coordinators. The EKS node agents tolerate everything the same way.
tolerations = [{ operator = "Exists" }]

# A quarter of the nodes at a time. The chart's default of one node
# at a time takes about half a minute per node, so on a dozen nodes a
# rollout outlasts Helm's wait and fails the apply.
updateStrategy = {
type = "RollingUpdate"
rollingUpdate = { maxUnavailable = "25%" }
}
}

alloy = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
apiVersion: v2
name: statefulset-volume-migration
description: Delete one StatefulSet with --cascade=orphan, leaving its pods running, so the release that owns it can recreate it with a changed volume layout.
description: Move one StatefulSet to a changed volume layout by deleting it with its pods so the owning release recreates both, and replace pods an earlier orphaning migration left without their claims.
type: application
version: 0.1.0
version: 0.2.0
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ statefulSet:
name: ravion-loki
namespace: ravion-operator
persistent: true
flushUrl: http://ravion-loki.ravion-operator.svc.cluster.local:3100/flush
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/bin/sh
# Moves one StatefulSet to the volume layout its owning release is about to
# apply, and recovers pods left behind by an earlier, orphaning migration.
#
# A StatefulSet's volumeClaimTemplates are immutable, so the owner cannot switch
# between an emptyDir and a PersistentVolumeClaim in place. When the layout
# differs, the StatefulSet is deleted together with its pods, and the owner,
# upgraded right after this Job, recreates it with fresh pods on the new
# volumes. Orphaning the pods instead does not work: the recreated StatefulSet
# adopts them, tries to add the claim to each running pod, which Kubernetes
# forbids, and never gets as far as replacing them.
#
# When the layout already matches, only pods that do not mount their claims are
# deleted: the ones chart 0.1.0 orphaned. Their StatefulSet recreates them. A
# StatefulSet in the right shape with healthy pods is left alone.
#
# Before any pod goes, FLUSH_URL (when set) is POSTed so the store writes what
# it holds in memory to object storage.
set -eu

: "${STATEFULSET:?}" "${NAMESPACE:?}" "${PERSISTENT:?}"
FLUSH_URL="${FLUSH_URL:-}"
TIMEOUT="${DELETE_TIMEOUT:-10m}"

log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*"; }

flush() {
[ -n "${FLUSH_URL}" ] || return 0
log "flushing ${FLUSH_URL}"
# Best effort: a store that does not answer usually has nothing left in
# memory, and refusing would leave the layout stuck.
wget -q -T 120 -O /dev/null --post-data= "${FLUSH_URL}" || log "flush failed; continuing"
}

# "<replicas> <template>,<template>," or nothing when there is no StatefulSet.
current="$(kubectl get statefulset "${STATEFULSET}" --namespace="${NAMESPACE}" --ignore-not-found \
-o go-template='{{.spec.replicas}} {{range .spec.volumeClaimTemplates}}{{.metadata.name}},{{end}}')"
if [ -z "${current}" ]; then
log "no StatefulSet ${NAMESPACE}/${STATEFULSET}; nothing to migrate"
exit 0
fi
replicas="${current%% *}"
templates="$(echo "${current#* }" | tr ',' ' ')"

has_claims=false
[ -n "$(echo "${templates}" | tr -d ' ')" ] && has_claims=true

if [ "${has_claims}" != "${PERSISTENT}" ]; then
log "volume layout changes (persistent: ${has_claims} -> ${PERSISTENT}); deleting ${NAMESPACE}/${STATEFULSET} and its pods"
flush
kubectl delete statefulset "${STATEFULSET}" --namespace="${NAMESPACE}" \
--cascade=foreground --ignore-not-found --wait --timeout="${TIMEOUT}"
log "deleted; the owning release recreates it"
exit 0
fi

i=0
while [ "${i}" -lt "${replicas}" ]; do
pod="${STATEFULSET}-${i}"
i=$((i + 1))
# "present <claim> <claim> ", or nothing when the pod does not exist.
mounted="$(kubectl get pod "${pod}" --namespace="${NAMESPACE}" --ignore-not-found \
-o go-template='present {{range .spec.volumes}}{{with .persistentVolumeClaim}}{{.claimName}} {{end}}{{end}}')"
Comment thread
mabadir marked this conversation as resolved.
[ -n "${mounted}" ] || continue

missing=""
for template in ${templates}; do
case " ${mounted} " in
*" ${template}-${pod} "*) ;;
*) missing="${missing} ${template}-${pod}" ;;
esac
done
[ -n "${missing}" ] || continue

log "${NAMESPACE}/${pod} does not mount${missing}; replacing it"
flush
kubectl delete pod "${pod}" --namespace="${NAMESPACE}" --ignore-not-found --wait --timeout="${TIMEOUT}"
done
log "volume layout up to date"
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ spec:
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
checksum/script: {{ .Files.Get "files/migrate.sh" | sha256sum }}
spec:
serviceAccountName: {{ .Release.Name }}
restartPolicy: Never
Expand All @@ -26,29 +28,65 @@ spec:
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
# The kubectl image has no shell. busybox is statically linked, so it
# runs from the shared volume inside the kubectl container.
initContainers:
- name: tools
image: {{ .Values.busyboxImage.repository }}:{{ .Values.busyboxImage.tag }}
imagePullPolicy: {{ .Values.busyboxImage.pullPolicy }}
command: ["/bin/busybox", "sh", "-c", "cp /bin/busybox /tools/busybox && /tools/busybox --install -s /tools"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tools
mountPath: /tools
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 32Mi
containers:
- name: kubectl
- name: migrate
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
# Orphaned, the pods keep serving until the owner recreates the
# StatefulSet, which adopts and then replaces them.
command: ["kubectl"]
args:
- delete
- statefulset
- {{ .Values.statefulSet.name }}
- --namespace={{ .Values.statefulSet.namespace }}
- --cascade=orphan
- --ignore-not-found
- --wait
command: ["/tools/sh", "/scripts/migrate.sh"]
env:
- name: PATH
value: /tools:/bin:/usr/local/bin:/usr/bin
- name: STATEFULSET
value: {{ .Values.statefulSet.name | quote }}
- name: NAMESPACE
value: {{ .Values.statefulSet.namespace | quote }}
- name: PERSISTENT
value: {{ .Values.persistent | toString | quote }}
- name: FLUSH_URL
value: {{ .Values.flushUrl | quote }}
- name: DELETE_TIMEOUT
value: {{ .Values.deleteTimeout | quote }}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tools
mountPath: /tools
- name: scripts
mountPath: /scripts
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
volumes:
- name: tools
emptyDir:
sizeLimit: 16Mi
- name: scripts
configMap:
name: {{ .Release.Name }}
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
{{- if not (and .Values.statefulSet.name .Values.statefulSet.namespace) }}
{{- fail "statefulSet.name and statefulSet.namespace are required" }}
{{- end }}
{{- if lt (int .Values.statefulSet.maxReplicas) 1 }}
{{- fail "statefulSet.maxReplicas must be at least 1" }}
{{- end }}
{{/*
Ordinary (non-hook) resources: they exist before the post-install and
post-upgrade hook runs. The Role can read and delete the one StatefulSet only.
post-upgrade hook runs. The Role can read and delete the one StatefulSet and
its pods, named by ordinal, and nothing else.
*/}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
data:
migrate.sh: |
{{- .Files.Get "files/migrate.sh" | nindent 4 }}
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ .Release.Name }}
Expand All @@ -30,6 +47,13 @@ rules:
resources: ["statefulsets"]
resourceNames: [{{ .Values.statefulSet.name | quote }}]
verbs: ["get", "delete"]
- apiGroups: [""]
resources: ["pods"]
resourceNames:
{{- range $i := until (int .Values.statefulSet.maxReplicas) }}
- {{ printf "%s-%d" $.Values.statefulSet.name $i | quote }}
{{- end }}
verbs: ["get", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
Expand Down
41 changes: 32 additions & 9 deletions compute/eks/addons/charts/statefulset-volume-migration/values.yaml
Original file line number Diff line number Diff line change
@@ -1,25 +1,48 @@
# A StatefulSet's volumeClaimTemplates are immutable, so the release that owns
# it cannot switch between an emptyDir and a PersistentVolumeClaim in place.
# This chart's Job deletes the StatefulSet with --cascade=orphan after every
# install or upgrade of this release; its pods keep running until the owning
# release recreates the StatefulSet and replaces them. The caller upgrades this
# release only when the owner's volume layout changes, and upgrades the owner
# right after it.
# This chart's Job runs after every install or upgrade of this release (see
# files/migrate.sh):
# - when the StatefulSet's layout differs from `persistent`, it deletes the
# StatefulSet together with its pods, and the owning release, upgraded
# right after this one, recreates both on the new volumes;
# - when the layout already matches, it replaces only pods that do not mount
# their claims (left by chart 0.1.0, which orphaned them);
# - otherwise it changes nothing.
# Pods are never orphaned: a recreated StatefulSet that adopts a pod without
# its claim cannot add the claim in place and never replaces the pod.

# -- The StatefulSet to delete. Missing is fine (a first install).
# -- The StatefulSet to migrate. Missing is fine (a first install).
statefulSet:
name: ""
namespace: ""
# -- Highest replica count the Job may need to handle. The Role names each
# pod it may delete, <name>-0 to <name>-(maxReplicas - 1).
maxReplicas: 3

# -- The volume layout the owner is moving to. Informational: it names the Job.
# -- The volume layout the owner is moving to: true for PersistentVolumeClaims,
# false for none.
persistent: false

# -- kubectl image the Job runs. Pinned; override for clusters that must pull
# from a private mirror.
# -- POSTed before any pod is removed, so the store writes what it holds in
# memory to object storage (Loki: http://<service>:3100/flush). Empty skips it.
flushUrl: ""

# -- How long each delete may wait for the pods to go.
deleteTimeout: 4m

# -- kubectl image the Job runs. It has no shell, so busybox supplies one.
# Pinned; override for clusters that must pull from a private mirror.
image:
repository: registry.k8s.io/kubectl
tag: v1.36.4
pullPolicy: IfNotPresent

# -- Statically linked busybox, copied into the Job's pod by an init
# container to provide sh and wget.
busyboxImage:
repository: public.ecr.aws/docker/library/busybox
tag: 1.37.0-musl
pullPolicy: IfNotPresent

# -- Give up after this many failed attempts and fail the release.
backoffLimit: 3
Loading
Loading