From 5913ad7b4e7142d5781b68abcbd885ac75c887c5 Mon Sep 17 00:00:00 2001 From: Mina Abadir Date: Fri, 16 Oct 2026 14:36:24 -0400 Subject: [PATCH 1/2] fix(eks-addons): replace Loki's pod when moving it to a volume; roll Alloy faster Moving Loki from an emptyDir to a PersistentVolumeClaim timed out in prod. The migration Job deleted the StatefulSet with --cascade=orphan. The recreated StatefulSet adopted the running pod, tried to add the claim to it in place, which Kubernetes forbids ("pod updates may not change fields..."), and never got as far as replacing it. The claim stayed Pending and Helm waited out its deadline. statefulset-volume-migration 0.2.0 runs a script instead of one kubectl call: - layout differs: POST Loki's /flush, then delete the StatefulSet with its pods (--cascade=foreground); the Loki release recreates both; - layout matches: replace only pods that do not mount their claims, so clusters 0.1.0 left wedged recover on the next apply; - otherwise change nothing. The Role can get and delete the StatefulSet and its pods by ordinal only. Alloy rolled one node at a time (~30s each), so on 12 nodes its rollout outlasted Helm's 5-minute wait and failed the same apply. It now updates 25% of the nodes at a time. rvn-eks-addons 0.22.1. --- .github/workflows/helm-charts.yml | 3 + README.md | 2 +- charts/test.sh | 27 ++- compute/eks/addons/README.md | 2 +- compute/eks/addons/alloy.tf | 8 + .../statefulset-volume-migration/Chart.yaml | 4 +- .../ci/default-values.yaml | 1 + .../files/migrate.sh | 79 ++++++++ .../templates/job.yaml | 62 ++++-- .../templates/rbac.yaml | 26 ++- .../statefulset-volume-migration/values.yaml | 41 +++- compute/eks/addons/loki_volume_migration.tf | 18 +- .../eks/addons/rvn-eks-addons-definition.yml | 4 +- .../addons/tests/addon_resilience.tftest.hcl | 8 + compute/eks/addons/tests/logs.tftest.hcl | 7 + .../test_statefulset_volume_migration.py | 181 ++++++++++++++++++ 16 files changed, 434 insertions(+), 39 deletions(-) create mode 100644 compute/eks/addons/charts/statefulset-volume-migration/files/migrate.sh create mode 100644 compute/eks/addons/tests/test_statefulset_volume_migration.py diff --git a/.github/workflows/helm-charts.yml b/.github/workflows/helm-charts.yml index db0ecc93..7750beb5 100644 --- a/.github/workflows/helm-charts.yml +++ b/.github/workflows/helm-charts.yml @@ -46,3 +46,6 @@ 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 diff --git a/README.md b/README.md index 5a9bf76a..c96aca9f 100644 --- a/README.md +++ b/README.md @@ -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.22.0 | `compute/eks/addons/` | +| `rvn-eks-addons` | EKS Add-ons | v0.22.1 | `compute/eks/addons/` | | `rvn-eks-chart` | EKS Helm Chart | v0.3.0 | `compute/eks_service/` | | `rvn-eks-cluster` | EKS Cluster | v0.8.1 | `compute/eks/` | | `rvn-eks-web` | EKS Web Service | v1.10.0 | `compute/eks_service/` | diff --git a/charts/test.sh b/charts/test.sh index 0cbfb2e3..6f5ea340 100755 --- a/charts/test.sh +++ b/charts/test.sh @@ -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 diff --git a/compute/eks/addons/README.md b/compute/eks/addons/README.md index 434a2f16..5bb7562f 100644 --- a/compute/eks/addons/README.md +++ b/compute/eks/addons/README.md @@ -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 diff --git a/compute/eks/addons/alloy.tf b/compute/eks/addons/alloy.tf index b13628f2..5b951d8c 100644 --- a/compute/eks/addons/alloy.tf +++ b/compute/eks/addons/alloy.tf @@ -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 = { diff --git a/compute/eks/addons/charts/statefulset-volume-migration/Chart.yaml b/compute/eks/addons/charts/statefulset-volume-migration/Chart.yaml index e7091e84..94aea8cd 100644 --- a/compute/eks/addons/charts/statefulset-volume-migration/Chart.yaml +++ b/compute/eks/addons/charts/statefulset-volume-migration/Chart.yaml @@ -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 diff --git a/compute/eks/addons/charts/statefulset-volume-migration/ci/default-values.yaml b/compute/eks/addons/charts/statefulset-volume-migration/ci/default-values.yaml index 56823aa5..06a4fbbf 100644 --- a/compute/eks/addons/charts/statefulset-volume-migration/ci/default-values.yaml +++ b/compute/eks/addons/charts/statefulset-volume-migration/ci/default-values.yaml @@ -2,3 +2,4 @@ statefulSet: name: ravion-loki namespace: ravion-operator persistent: true +flushUrl: http://ravion-loki.ravion-operator.svc.cluster.local:3100/flush diff --git a/compute/eks/addons/charts/statefulset-volume-migration/files/migrate.sh b/compute/eks/addons/charts/statefulset-volume-migration/files/migrate.sh new file mode 100644 index 00000000..44f4d99e --- /dev/null +++ b/compute/eks/addons/charts/statefulset-volume-migration/files/migrate.sh @@ -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" +} + +# "