stack.basicstack.de/infrastructure/CP_UPDATE_PROCEDURE.md
CTO Agent e848d225cd os-update: generalize CP procedure to HA cp-1/cp-2/cp-3 (DEV-515)
Replaces single-CP CP1_UPDATE_PROCEDURE.md + update-cp-1.sh with an
HA-aware procedure that covers all three CPs. Rationale: after DEV-510
the cluster runs embedded etcd 3.6.12 across cp-1/cp-2/cp-3 and CPs
are NoSchedule-tainted, so:

- Kine cascade is no longer the driving risk; embedded 3-node etcd
  tolerates one member down. The kubectl-latency guardrail stays as a
  soft signal.
- CPs host no StatefulSets or single-replica Deployments. The old
  Phase C batched stateful eviction dance is obsolete.
- CP reboots no longer lose the api-server; two peers keep serving.
  /livez is polled from the operator machine and cross-checked with
  etcdctl endpoint status from a peer CP.

New CP ordering rule: one CP at a time, leader last, health gate
between CPs. Refuses to start if another CP is already cordoned.

Automation:
- New scripts/os-update/update-cp-node.sh <node> is the entry point.
  Supports --add-swap / --preflight / --drain / --apt / --reboot /
  --finalize / --run / --dry-run. Phase A also installs etcd-client
  on cp-2/cp-3 so the etcd-quorum probes work when cp-1 is later the
  target. Peer picker prefers a CP that has etcdctl; refuses cp-1
  target with a targeted error if no other CP has etcdctl yet.
- scripts/os-update/update-cp-1.sh is now a thin wrapper that forwards
  to update-cp-node.sh k3s-cp-1 "$@" for backwards compat.
- CP1_UPDATE_PROCEDURE.md is a redirect stub pointing at the new doc.
- OS_UPDATE_PROCEDURE.md §8 topology block, kine cascade section, and
  automation entry point list all point at the new HA-aware artifacts.

Verified: bash -n clean on both scripts; --dry-run walks all six
phases for each of cp-1/cp-2/cp-3; --preflight against live cluster
correctly aborts cp-1 with "install etcdctl on another CP first" and
cp-2/cp-3 with "run --add-swap first" (their current state).

Execution is board-approval-gated; DEV-497 (cp-1-only executor) is
superseded by a successor ticket.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-23 01:53:14 +00:00

16 KiB
Raw Blame History

HA k3s Control-Plane OS Update Procedure

Purpose: apply Ubuntu OS updates (kernel, security, apt) to any control-plane node in the HA k3s cluster (k3s-cp-1, k3s-cp-2, k3s-cp-3) without losing etcd quorum and without a longer api-server outage than a normal reboot.

Audience: the CTO agent, or an operator with root SSH to the cluster. Execution is board-approval-gated — see ## Approval gates below.

Supersedes: the earlier single-CP CP1_UPDATE_PROCEDURE.md (kept as a redirect stub). The cp-1-only procedure was written when cp-1 was the sole api-server and the datastore was embedded SQLite via kine. After DEV-510 (2026-08-22), the cluster runs HA embedded etcd across cp-1/cp-2/cp-3 and all three CPs are NoSchedule-tainted, so:

  • The kine cascade is no longer the driving risk. The datastore is etcd 3.6.12 with 3-node quorum; a single CP drain no longer leaves a lone SQLite writer. The kubectl-latency guardrail stays as a sanity check but is now a much softer signal.
  • CPs host no StatefulSet or single-replica Deployment workloads. The pre-drain stateful eviction dance from the old cp-1 doc no longer applies — CPs only host DaemonSets + metrics-server (currently on cp-2) + occasional CronJob completions.
  • The api-server is not lost on reboot. During a CP reboot the other two apiservers keep serving. /livez is polled from the other apiservers, not from an external machine forced to poll the one being rebooted.

Scope

In scope

  • Add a durable ≥ 2 GiB swapfile on any CP that has zero swap (idempotent one-off — --add-swap).
  • k3s etcd snapshot as the restore point.
  • kubectl cordon + kubectl drain on the target CP.
  • apt-get update/upgrade/dist-upgrade/autoremove on the target CP.
  • Controlled reboot with etcd-quorum-aware liveness monitoring.
  • Uncordon + cluster health verify.

Out of scope — do NOT do here

  • Any change to /etc/rancher/k3s/*, /etc/systemd/system/k3s*.service*, or the k3s binary version. k3s upgrades go through system-upgrade-controller (see K3S_OPERATIONS.md).
  • Any change to manifests under apps/, infrastructure/, or applied via ArgoCD.
  • Deleting PVs / PVCs. Any pod that gets rescheduled off the target CP stays on its new node.
  • Fixing application-level problems.
  • Rebuilding the node — if a CP does not return after reboot, escalate; the rebuild path is ADD_WORKER_NODE.md plus board approval, not this document.

Approval gates

This procedure has two independent gates. Neither happens without explicit board approval on the corresponding Paperclip issue:

  1. Add swap (Phase A). Non-invasive, non-state-mutating, kubelet already runs with failSwapOn=false. Requires board approval per stateful-service safety rules because it modifies a CP node.
  2. Full OS update (Phases BE). Requires board approval because it drains + reboots one of the etcd members. Do NOT execute without an explicit request_board_approval acceptance on the execution ticket.

Both gates are independent — swap can (and should) be added first, in a quiet window, before the full update is scheduled.


CP ordering rule (multiple CPs in one cycle)

  • One at a time. Never two CPs cordoned or draining at once. Two of three CPs down = etcd quorum loss = api-server unavailability for the whole cluster.
  • Leader last. Query the current etcd leader before starting; update the two followers first (in any order), then the leader. Rationale: draining a follower is a no-op for the raft leader; draining the leader forces a re-election. Doing followers first minimises leader flapping.
  • Health gate between CPs. After each CP finishes Phase F (finalize) and cluster health is green, wait at least POST_UNCORDON_WAIT_SECONDS (default 180 s) and re-check etcd endpoint status before touching the next CP. This gives etcd time to fully re-sync the just-rebooted member.
  • Halt on any yellow. Any of the following aborts the cycle at the current CP (do NOT proceed to the next CP): etcd reports a member as not started, kubectl get nodes > 5 s, any node not Ready, any workload deployment/sts under desired replicas.

Query the leader:

ssh root@$CP1_HOST bash -c '
ETCDCTL_API=3 etcdctl \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/var/lib/rancher/k3s/server/tls/etcd/server-ca.crt \
  --cert=/var/lib/rancher/k3s/server/tls/etcd/server-client.crt \
  --key=/var/lib/rancher/k3s/server/tls/etcd/server-client.key \
  endpoint status --cluster -w table'

Current CP workload (snapshot 2026-08-23)

Re-derive live before any execution — this table is a design reference only.

Node Kernel Swap StatefulSets Single-replica Deployments DaemonSets
k3s-cp-1 6.8.0-137 0 B 0 0 node-exporter, promtail, hcloud-csi-node, svclb-*
k3s-cp-2 6.8.0-137 0 B 0 metrics-server (evictable) node-exporter, promtail, hcloud-csi-node, svclb-*
k3s-cp-3 6.8.0-137 0 B 0 0 node-exporter, promtail, hcloud-csi-node, svclb-*

etcd datastore live snapshot: 38 MiB per member, all three started, cp-3 is current leader.

Because CPs are NoSchedule-tainted, no workload will end up on them via normal scheduling — the pre-drain stateful redistribution loop from the old cp-1 procedure is obsolete.


Phase A — Add swap (one-off, idempotent)

Goal: eliminate the "3.7 GiB, no swap" underlying constraint on any CP before ever draining it. All three CPs currently have zero swap; add swap once per CP.

Preconditions:

  • kubelet in this k3s already runs with failSwapOn=false (confirmed on cp-1 via /api/v1/nodes/k3s-cp-1/proxy/configz) — enabling swap does NOT break the kubelet.
  • Target CP's / has ≥ 2 * SWAP_SIZE_MB free.
  • Board approval on the swap-add ticket.

Sizing: default 4 GiB swap. Rationale — cp-1 baseline (k3s + hosted daemons) sits at ~2 GiB used; 4 GiB swap gives us headroom for the drain-eviction transient without inflating disk usage past ~10 % of /. Minimum acceptable per guardrail: 2 GiB.

Location: /swapfile (root filesystem). Not a separate partition — reversible, no LVM changes.

Steps (encoded in update-cp-node.sh <node> --add-swap): identical to the old cp-1 procedure — fallocatemkswapswapon/etc/fstabvm.swappiness=10 in /etc/sysctl.d/99-k3s-swap.conf. Idempotent: skips if a swapfile of the target size is already active. Full script source in scripts/os-update/update-cp-node.sh.

Phase A also installs etcd-client on the target CP if etcdctl is missing. This is a hard requirement for the etcd-quorum probes used in Phase B/E when the CP is later itself the update target and one of the other CPs must query etcd cluster status. cp-1 already has etcdctl (installed pre-HA); cp-2 and cp-3 pick it up here.

Rollback for Phase A: swapoff /swapfile && rm /swapfile and remove the fstab line. Safe at any time — swap is a soft resource.

Verification after Phase A:

  • free -h shows Swap: 4.0Gi used ≈ 0.
  • swapon --show shows /swapfile 4G.
  • sysctl vm.swappiness returns 10.
  • kubelet still Ready (kubectl get node <node>).
  • No new MemoryPressure condition.

Phase B — Preflight

Runs from the operator machine (or from any healthy CP). Every command's stdout+stderr goes to /tmp/os-update-<node>-<UTC-timestamp>.log on the operator machine. Attach that log to the Paperclip execution ticket at the end.

Run update-cp-node.sh <node> --preflight. Checks:

  1. Cluster is currently healthy: cluster-health.sh returns 0.
  2. Target node is a CP: kubectl get node <node> carries the node-role.kubernetes.io/control-plane label.
  3. All 3 CPs are healthy etcd members: etcdctl endpoint status --cluster shows every member started, no errors.
  4. Only one CP is being updated this cycle: no other CP is currently cordoned.
  5. Target CP has swap on: abort if free -h shows Swap: 0B. Run --add-swap first.
  6. kubectl latency probe: time kubectl get nodes returns in ≤ 5 s.
  7. etcd snapshot (restore point): k3s etcd-snapshot save --name pre-cp-os-update-<node>-<UTC-timestamp> executed on the target CP.
  8. Records the current etcd leader — if the target is the leader, prints a warning ("prefer updating a follower first"). Does NOT auto-swap the target; the operator/agent makes the call per the CP ordering rule.

If any preflight check fails: STOP. Do not proceed.


Phase C — Cordon + drain the target CP

CPs are NoSchedule-tainted, so cordon is mostly a belt-and-suspenders measure. Drain evicts the small tail of not-DaemonSet workloads (metrics-server currently lives on cp-2).

kubectl cordon <node>
kubectl drain <node> \
  --ignore-daemonsets \
  --delete-emptydir-data \
  --timeout="${DRAIN_TIMEOUT_SECONDS:-600}s"

If drain reports a PDB block: do NOT --force. Uncordon the node, mark the run blocked on the PDB, and escalate. This is a workload PDB bug — fix separately.

Kine-cascade guardrail (softened for HA etcd): during drain, time kubectl get nodes should still return in ≤ 5 s. Because we now have 3-member etcd (not lone SQLite kine), a single-CP drain does not create the write-amplification hazard from DEV-495. But > 5 s still indicates something is wrong (etcd slow disk, leader flapping) — halt and investigate.


Phase D — apt on the target CP

Identical to the worker-node apt block (update-node.sh step 3). update-cp-node.sh <node> --apt runs this via ssh root@<node> bash -s:

export DEBIAN_FRONTEND=noninteractive
APT_OPTS='-y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold'
uname -r > /root/pre-apt-kernel
dpkg-query -W -f='${Package}\t${Version}\n' > /root/pre-apt-packages.tsv

if dpkg --audit | grep -qE .; then dpkg --configure -a || true; fi
apt-get update
apt-get $APT_OPTS upgrade || { apt-get $APT_OPTS -f install; apt-get $APT_OPTS upgrade; }
apt-get $APT_OPTS dist-upgrade
apt-get $APT_OPTS autoremove --purge
apt-get clean
[ -f /var/run/reboot-required ] && echo REBOOT_REQUIRED=yes || echo REBOOT_REQUIRED=no

pre-apt-kernel and pre-apt-packages.tsv are the local rollback references — see Rollback below.


Phase E — Reboot handling (etcd-quorum-aware)

Duration expectation: 90180 s of this member's api-server unavailability. The other two apiservers keep serving; the operator machine's kubectl continues to work via one of them.

Escalation trigger: rebooted apiserver not back on /livez after 10 minutes → escalate. First check the Hetzner console via hcloud server describe <node> for boot state; if kernel-panic / initramfs, use grub previous-kernel path (see Rollback). Do NOT rebuild the node.

Steps (executed by update-cp-node.sh <node> --reboot):

# 1. Reboot the target CP (ssh will hang up mid-command — expected).
ssh $SSH_OPTS root@$TARGET_HOST 'systemctl reboot' || true
sleep 15

# 2. Poll the TARGET's /livez from the operator machine. It is served on port 6443.
#    Insecure (`-k`) because the server cert is self-signed by k3s.
deadline=$(( $(date +%s) + ${REBOOT_MAX_WAIT_SECONDS:-600} ))
while [ $(date +%s) -lt $deadline ]; do
  code=$(curl -sk -o /dev/null -w '%{http_code}' https://$TARGET_HOST:6443/livez 2>/dev/null || echo 000)
  [ "$code" = "200" ] && break
  sleep 5
done

# 3. Verify etcd membership from a peer CP — the target should be back as `started`.
ssh $SSH_OPTS root@$PEER_HOST '
  ETCDCTL_API=3 etcdctl \
    --endpoints=https://127.0.0.1:2379 \
    --cacert=/var/lib/rancher/k3s/server/tls/etcd/server-ca.crt \
    --cert=/var/lib/rancher/k3s/server/tls/etcd/server-client.crt \
    --key=/var/lib/rancher/k3s/server/tls/etcd/server-client.key \
    endpoint status --cluster -w table'

# 4. Wait for kubelet Ready on the target from the api-server view.
deadline=$(( $(date +%s) + 300 ))
while [ $(date +%s) -lt $deadline ]; do
  ready=$(kubectl get node "$TARGET_NODE" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo Unknown)
  [ "$ready" = "True" ] && break
  sleep 5
done

What to do if the api-server takes 310 minutes: it may be normal for a large etcd on a slow disk. Wait it out. Do NOT restart k3s to "help" — an etcd member catching up on raft log is stateful and interrupting it can slow the whole cluster.

What to do if it takes > 10 minutes: escalate; run the Hetzner-console diagnosis; if the console shows a bootable Ubuntu but the k3s service is failing, that's the boundary — this procedure stops here. Follow K3S_OPERATIONS.md for the k3s recovery path.


Phase F — Uncordon + verify + finalize

kubectl uncordon <node>
sleep "${POST_UNCORDON_WAIT_SECONDS:-180}"
RETRY_ON_TRANSIENT=1 infrastructure/scripts/os-update/cluster-health.sh
ssh root@<node> 'zgrep -h "Commandline\|Install\|Upgrade\|Remove" /var/log/apt/history.log* 2>/dev/null | tail -60'

Between CPs (when multiple CPs will be updated in the same cycle), also verify:

ssh root@<peer-cp> '
  ETCDCTL_API=3 etcdctl \
    --endpoints=https://127.0.0.1:2379 \
    --cacert=/var/lib/rancher/k3s/server/tls/etcd/server-ca.crt \
    --cert=/var/lib/rancher/k3s/server/tls/etcd/server-client.crt \
    --key=/var/lib/rancher/k3s/server/tls/etcd/server-client.key \
    endpoint status --cluster -w table'

All three members must be started, no errors, DB sizes within 10 % of each other.

Attach the /tmp/os-update-<node>-<ts>.log to the execution ticket. Mark the ticket done on green health, or blocked naming the specific residual issue.


Rollback / recovery

If apt broke a package

  • On the target CP, dpkg --audit to find half-configured packages.
  • dpkg --configure -a, then apt-get -f install.
  • If a specific package broke and you know the previous version from /root/pre-apt-packages.tsv, apt-get install <pkg>=<old-version>.

If the new kernel does not boot

  • Hetzner cloud console: hcloud server request-console <node> → get VNC URL, watch boot.
  • If grub is up, select the previous-kernel entry. Ubuntu keeps ≥ 1 old kernel installed by default.
  • Once booted on the old kernel, apt-get remove the broken kernel and pin the working one:
    apt-mark hold linux-image-<broken-version> linux-headers-<broken-version>
    
  • Escalate on the ticket regardless — a kernel rollback is a follow-up investigation, not a "done" outcome.

If the node does not return at all

  • Do NOT hcloud server delete. Do NOT re-provision.
  • Escalate to the board with the Hetzner console output and /tmp/os-update-<node>-*.log.
  • The --cluster-reset --cluster-reset-restore-path=<snapshot> recovery path exists (see K3S_OPERATIONS.md) but requires board approval per stateful-service safety rules. The pre-flight snapshot from Phase B is the restore point.
  • HA-etcd advantage: if one CP is permanently lost but the other two are healthy, you have quorum. Do not rush a cluster-reset. Follow the "member remove + re-add" path in the k3s docs first.

If etcd quorum degrades mid-cycle

  • Two of three CPs down = write outage until quorum returns. Do NOT attempt to update the third.
  • Uncordon all CPs, restore the just-updated one first, and confirm all three members started before doing anything else.

Automation entry points

  • infrastructure/scripts/os-update/update-cp-node.sh <node> [phase-flag] — the generalized HA-aware flow.

    • --add-swap — Phase A only (idempotent, safe standalone).
    • --dry-run — walk all phases printing what would be done, no state change.
    • --preflight — Phase B only.
    • --drain — Phase C only (cordon + drain).
    • --apt — Phase D apt commands only.
    • --reboot — Phase E only.
    • --finalize — Phase F only.
    • --run — all phases with a confirmation between each unless ASSUME_YES=1.
  • infrastructure/scripts/os-update/update-cp-1.sh — retained as a thin wrapper that calls update-cp-node.sh k3s-cp-1 "$@". Historical callers keep working.

The script follows the same log-dir contract as update-node.sh (/tmp/os-update-<node>-<UTC-timestamp>.log).


Change history

Date Change By
2026-08-16 Initial cp-1-only procedure (DEV-496) CTO agent
2026-08-23 Generalized to HA (cp-1/cp-2/cp-3); dropped Phase C batched stateful eviction (CPs are NoSchedule-tainted now); softened kine cascade guardrail (embedded etcd 3-member quorum); added CP ordering rule (leader last, one at a time); introduced update-cp-node.sh (DEV-515) CTO agent