fix(etcd): make the defrag CronJob runnable and 15-day

The committed CronJob could never run, for two independent reasons:
- quay.io/coreos/etcd ships no /bin/sh, so command: [/bin/sh, -c] died with
  exec: "/bin/sh": no such file or directory
- it existed only on branch fix/etcd-defrag-cron, while the ArgoCD application
  cluster-maintenance tracks main (targetRevision: main), so the cluster never
  had the CronJob or its Secret

This revision fixes both and records the 2026-10-07 incident:
- image bitnamilegacy/etcd:3.6.4 pinned by digest (ships bash and etcdctl 3.6.4,
  matching the 3.6.11 servers)
- 15-day cadence by UTC epoch-day modulo; */15 in day-of-month fires on the 1st,
  16th and 31st, which puts two runs one day apart
- compacts to the MINIMUM revision across members, so a lagging member is never
  compacted past its own revision (measured: talos-cp-2 froze ~1.2M entries
  behind the leader and needed a reboot to rejoin)
- skips defrag on a member whose applied index lags the leader by 100k+
- documents that compact must precede defrag: compaction had stalled while the
  NOSPACE alarm was up, so dbSizeInUse read 100% of the file and defrag alone
  would have reclaimed nothing
This commit is contained in:
2026-10-07 17:11:35 +08:00
parent 1ce9f86635
commit e4caa7e841
@@ -1,23 +1,58 @@
# etcd defrag maintenance. # etcd backend maintenance: compact, defrag, disarm NOSPACE.
# #
# Why this exists: the apiserver compacts etcd every 5m, but compaction only marks # Why this exists
# superseded revisions free INSIDE the backend file -- the file never shrinks. # ---------------
# Only `etcdctl defrag` returns space to the filesystem, and nothing runs it # etcd's backend file only grows. The apiserver compacts every 5m, but
# automatically. On 2026-10-07 the 2 GiB quota was reached after ~73 days # compaction marks superseded revisions free *inside* the file; only `defrag`
# (backend 2.1 GiB) -> etcd raised the NOSPACE alarm -> etcd refused ALL writes # returns that space to the filesystem. Nothing ran defrag, so the file grew
# -> kubelets could not post node status -> 3 nodes went NotReady and the tunnel # until it crossed the 2 GiB quota:
# origin pods on them vanished (502s). Recovery = compact + defrag + alarm disarm.
# #
# This CronJob keeps the backend well under quota: compact to the current # 2026-10-07 backend 2,148,843,520 B (2.1 GB) on talos-cp-1 and talos-cp-3
# revision, defrag each member sequentially, then disarm any NOSPACE alarm. # -> etcd raised NOSPACE on those two members
# -> etcd rejected ALL writes cluster-wide
# -> kubelets could not post node status: 3 nodes NotReady
# -> apiserver writes returned 500, the tunnel API path 502
#
# The live data was tiny: 15,943 keys, 69 MB after compaction. The rest was
# dead pages. Recovery was `compact <current-revision>` then `defrag` per
# member then `alarm disarm`. Compaction had also stalled while the alarm was
# up, so `dbSizeInUse` read 100% of the file - defrag alone would NOT have
# worked. Order matters: compact first, then defrag.
#
# The earlier revision of this file never ran, for two independent reasons:
# 1. it existed only on branch fix/etcd-defrag-cron; the ArgoCD application
# `cluster-maintenance` tracks `main` (targetRevision: main), so the
# cluster never had the CronJob or the Secret.
# 2. the image was quay.io/coreos/etcd, which has NO /bin/sh (verified:
# `exec: "/bin/sh": no such file or directory`). Any container that runs
# a shell script needs an image that ships one.
# This revision fixes both: image bitnamilegacy/etcd (ships bash + etcdctl
# 3.6.4, matching the 3.6.11 servers) pinned by digest.
#
# Cadence: every 15 days. Measured growth is ~29 MB/day, so a 15-day cycle
# peaks near 500 MB - comfortably under the 2 GiB quota. The schedule is daily
# and the script exits unless the UTC epoch day is a multiple of 15. That gives
# an exact 15-day interval; `*/15` in the day-of-month field does not (it fires
# on the 1st, 16th and 31st, which puts two runs one day apart).
#
# Safety
# ------
# 1. Compaction uses the MINIMUM revision across members. Compacting to a
# revision a member has not reached wedges that member - measured on
# 2026-10-07, when talos-cp-2 froze ~1.2M entries behind the leader and
# needed a reboot to rejoin.
# 2. A member whose raft applied index lags the leader by more than 100000 is
# skipped and reported. Defragging a frozen member reclaims nothing.
# 3. Members are defragged one at a time. A defrag pauses the member's apply
# loop for the duration of the file copy.
#
# Credentials: Secret etcd-defrag-creds in kube-system, SOPS-managed in
# k8s/argocd/secrets/etcd-defrag-creds.enc.yaml (ksops generator 00-secrets).
# Its client certificate is signed by the cluster's etcd CA and expires
# 2036-10; rotate it before then by re-running the mint and re-encrypting.
# #
# Companion alerts (monitoring): etcd_mvcc_db_total_size_in_bytes / # Companion alerts (monitoring): etcd_mvcc_db_total_size_in_bytes /
# etcd_server_quota_in_bytes > 0.8, and a NOSPACE alarm alert. # etcd_server_quota_backend_bytes > 0.8, and an etcd NOSPACE alarm alert.
#
# Credentials: secret etcd-defrag-creds (kube-system), SOPS-managed in
# k8s/argocd/secrets/etcd-defrag-creds.enc.yaml. Its client cert is signed by the
# cluster's etcd CA and expires 2036-10 -- rotate it before then by re-running the
# mint + re-encrypt (see the repo's sops workflow).
apiVersion: batch/v1 apiVersion: batch/v1
kind: CronJob kind: CronJob
metadata: metadata:
@@ -27,7 +62,7 @@ metadata:
app.kubernetes.io/name: etcd-defrag app.kubernetes.io/name: etcd-defrag
app.kubernetes.io/part-of: cluster-maintenance app.kubernetes.io/part-of: cluster-maintenance
spec: spec:
schedule: "0 3 * * 0" # Sundays 03:00, low traffic schedule: "0 3 * * *" # daily; the script acts only every 15th day
concurrencyPolicy: Forbid concurrencyPolicy: Forbid
startingDeadlineSeconds: 600 startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3 successfulJobsHistoryLimit: 3
@@ -44,7 +79,7 @@ spec:
effect: NoSchedule effect: NoSchedule
containers: containers:
- name: defrag - name: defrag
image: quay.io/coreos/etcd:v3.5.17 image: bitnamilegacy/etcd:3.6.4@sha256:c86c06f1ce6a08b6df21eb7a9655b1818778b64c85b5cbb8413ebf7534789730
env: env:
- name: ETCDCTL_API - name: ETCDCTL_API
value: "3" value: "3"
@@ -56,36 +91,77 @@ spec:
value: /etc/etcd/client.key value: /etc/etcd/client.key
- name: EPS - name: EPS
value: "https://192.168.1.166:2379 https://192.168.1.214:2379 https://192.168.1.162:2379" value: "https://192.168.1.166:2379 https://192.168.1.214:2379 https://192.168.1.162:2379"
command: ["/bin/sh", "-c"] command: ["/bin/bash", "-c"]
args: args:
- | - |
set -u set -u
PRIMARY=${EPS%% *}
echo "== before =="
etcdctl --endpoints=$EPS endpoint status -w table || true
REV=$(etcdctl --endpoints=$PRIMARY endpoint status -w json \ DAY=$(( $(date -u +%s) / 86400 ))
| sed -n 's/.*"revision":\([0-9]*\).*/\1/p' | head -1) if [ $(( DAY % 15 )) -ne 0 ]; then
if [ -n "$REV" ]; then echo "skip: UTC epoch day $DAY is not a 15-day boundary"
echo "== compact to revision $REV ==" exit 0
etcdctl --endpoints=$PRIMARY compact "$REV" || true
fi fi
echo "== before =="
for ep in $EPS; do
etcdctl --endpoints="$ep" endpoint status -w table || true
done
MINREV=""
MAXAPPLIED=0
for ep in $EPS; do
json=$(etcdctl --endpoints="$ep" endpoint status -w json 2>/dev/null) || continue
rev=$(printf '%s' "$json" | sed -n 's/.*"revision":\([0-9]*\).*/\1/p' | head -1)
app=$(printf '%s' "$json" | sed -n 's/.*"raftAppliedIndex":\([0-9]*\).*/\1/p' | head -1)
echo "member $ep revision=${rev:-?} raftAppliedIndex=${app:-?}"
if [ -n "$rev" ] && { [ -z "$MINREV" ] || [ "$rev" -lt "$MINREV" ]; }; then
MINREV="$rev"
fi
if [ -n "$app" ] && [ "$app" -gt "$MAXAPPLIED" ]; then
MAXAPPLIED="$app"
fi
done
if [ -z "$MINREV" ]; then
echo "FATAL: no member returned a revision; nothing attempted"
exit 1
fi
echo "== compact to revision $MINREV =="
etcdctl --endpoints="$(echo "$EPS" | awk '{print $1}')" compact "$MINREV" || echo "COMPACT-FAILED (continuing: defrag still reclaims free pages)"
# Physical deletion of superseded keys runs in the background
# after the compaction commit; give it a moment to drain.
sleep 20
echo "== defrag each member (sequential, one at a time) ==" echo "== defrag each member (sequential, one at a time) =="
rc=0 rc=0
for ep in $EPS; do for ep in $EPS; do
json=$(etcdctl --endpoints="$ep" endpoint status -w json 2>/dev/null) || { echo "SKIP $ep (unreachable)"; rc=1; continue; }
app=$(printf '%s' "$json" | sed -n 's/.*"raftAppliedIndex":\([0-9]*\).*/\1/p' | head -1)
if [ -n "$app" ] && [ "$app" -lt $(( MAXAPPLIED - 100000 )) ]; then
echo "SKIP $ep (applied index $app lags leader $MAXAPPLIED; defrag would reclaim nothing)"
rc=1
continue
fi
echo "-- $ep" echo "-- $ep"
etcdctl --endpoints="$ep" defrag || { echo "DEFRAG-FAILED $ep"; rc=1; } if ! etcdctl --endpoints="$ep" defrag; then
echo "DEFRAG-FAILED $ep"
rc=1
fi
done done
echo "== disarm NOSPACE ==" echo "== disarm NOSPACE =="
for ep in $EPS; do for ep in $EPS; do
etcdctl --endpoints="$ep" alarm list 2>/dev/null || true
etcdctl --endpoints="$ep" alarm disarm 2>/dev/null || true etcdctl --endpoints="$ep" alarm disarm 2>/dev/null || true
done done
echo "== after ==" echo "== after =="
etcdctl --endpoints=$EPS endpoint status -w table || true for ep in $EPS; do
etcdctl --endpoints=$PRIMARY alarm list || true etcdctl --endpoints="$ep" endpoint status -w table || true
done
etcdctl --endpoints="$(echo "$EPS" | awk '{print $1}')" alarm list || true
exit $rc exit $rc
resources: resources:
requests: { cpu: 10m, memory: 32Mi } requests: { cpu: 10m, memory: 32Mi }