Merge pull request 'feat(upgrades): automate node reboots (kured) and wire Renovate, which had never run' (#73) from feat/automated-upgrades into main
gate / gate (push) Successful in 27m46s
gate / conformance (push) Has been skipped

Reviewed-on: #73
Reviewed-by: Keith Jackson <[email protected]>
This commit was merged in pull request #73.
This commit is contained in:
jakman85 committed 2026-10-18 06:28:30 +00:00
commit ee196c7bcd
22 files changed
+2661 -5

No files matched your search

+3
View File
@@ -92,6 +92,9 @@ configMapGenerator:
- node-io=node-io.md
- node-memory=node-memory.md
- node-memory-swap=node-memory-swap.md
# Covers all three platform-health.node-reboots rules (KuredRebootPending,
# KuredNodeCoverageGap, KuredNotReporting) from platform/kured/alerts.yaml.
- node-reboot-automation=node-reboot-automation.md
# Linked from OAuth2ProxyGateUnobserved, and the diagnostic procedure for any gated app
# that "is up but does not work" — the gate answers an unauthenticated XHR with a 302,
# not a 401, so the failure arrives as an HTTP 200 carrying the wrong body.
+254
View File
@@ -0,0 +1,254 @@
# Node reboot automation (kured)
**Alerts:** `KuredRebootPending`, `KuredNodeCoverageGap`, `KuredNotReporting`
**File:** `platform/kured/alerts.yaml`
**Expressions:** `max by (node) (kured_reboot_required) == 1` / `count(kured_reboot_required) < count(kube_node_info)` / `absent(kured_reboot_required)`
## What this means here
These three alerts cover the OS reboot half of the automated upgrade process. The design is two
pieces that only work together:
- **`roles/unattended_upgrades`** (Ansible, on the host) installs Debian **security** updates
nightly. It never upgrades the kernel, never restarts k3s, and never reboots. When something it
installed needs a reboot, it leaves `/var/run/reboot-required` behind.
- **kured** (`platform/kured`, a DaemonSet) watches that file. When it exists, kured takes a
**cluster-wide lock**, cordons the node, drains it, reboots it, and uncordons it — then holds the
lock for a further 30 minutes before any other node may go.
**kured does not decide whether to reboot.** On these nodes the sentinel is written almost
exclusively by a deliberate operator kernel upgrade
(`playbooks/node-baseline.yml --tags kernel`), because the kernel is blacklisted from unattended
upgrades. So a pending reboot normally means *a human already asked for this*.
Which alert you have tells you which half is wrong:
| alert | meaning |
|---|---|
| `KuredRebootPending` | The mechanism is alive and a reboot was offered, but it did not happen for >36h. **Something is blocking it.** |
| `KuredNodeCoverageGap` | kured is not running on every node. The uncovered node will never be rebooted, silently. |
| `KuredNotReporting` | kured is publishing nothing at all. Reboots are not happening, **and the other two alerts are inert.** Treat this as the most serious of the three. |
**All three nodes are control-plane + etcd, so quorum is 2 of 3.** One node down is degraded but
up; two is a lost cluster. Everything below is written to protect that. `concurrency: 1` plus
`lockTtl: 0` means a node that hangs on reboot keeps the lock **forever** and no second node is ever
rebooted behind it. That is deliberate: the cycle stops and pages a human rather than stepping over
a corpse.
**A reboot is not seamless.** Most workloads here are single-replica, so each reboot cycle takes a
brief outage of whatever runs on that node — including `coredns` (1 replica), which means a
cluster-wide DNS gap while it reschedules. See "Expected outage per cycle" below.
## Running the commands in this runbook
`kubectl` does not work on the operator workstation. The toolchain lives only in the pinned dev
container. Every command below runs as:
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes'
Always pass `--context k3s-prod` explicitly. `dev.sh` merges both clusters into one kubeconfig and
picks a current-context per invocation, and that default has already changed once. Do not run two
`dev.sh` invocations at once; they share one container and interfere.
Mutating kubectl (`apply`, `patch`, `annotate`, `uncordon`, `rollout`) and `flux reconcile` are
operator actions, not agent actions. Where this runbook gives a mutating command it is for an
operator, and it says so.
## First commands
Start here, in order. The first question is always *which half is broken*.
# 1. Is kured running at all, and on how many nodes?
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n kured get ds,pod -o wide'
# 2. Which node is asking for a reboot, and who holds the lock?
# The lock is an annotation on kured's OWN DaemonSet. An empty/absent value means no holder.
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n kured get ds kured \
-o jsonpath="{.metadata.annotations}"; echo'
# 3. Is a node cordoned, and is a reboot in progress?
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes \
-o custom-columns=NAME:.metadata.name,SCHEDULABLE:.spec.unschedulable,TAINTS:.spec.taints'
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes \
-o jsonpath="{range .items[*]}{.metadata.name}{\" \"}{.metadata.annotations.weave\.works/kured-reboot-in-progress}{\"\n\"}{end}"'
# 4. What is kured itself saying? It logs its decision every period (1h).
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n kured logs ds/kured --tail=80'
# 5. Is the gate blocking it? These are the ONLY alerts kured blocks on.
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n longhorn-system \
get volumes.longhorn.io -o custom-columns=NAME:.metadata.name,STATE:.status.state,ROBUSTNESS:.status.robustness' \
| grep -v healthy
# 6. Does the node actually want a reboot? (read-only; SSH as k3s, no sudo needed)
ssh -o BatchMode=yes 192.168.2.10 'ls -la /var/run/reboot-required; cat /var/run/reboot-required.pkgs 2>/dev/null'
## Likely causes, most likely first
### 1. The Longhorn gate is holding it (the common `KuredRebootPending` cause)
kured queries Prometheus before taking the lock and refuses to proceed while
`LonghornVolumeDegraded`, `LonghornVolumeFaulted`, `KubeNodeNotReady` or `NodeExporterDown` is
firing. This is working as designed and it is the **most likely** cause.
Why it lingers: there are 3 nodes, every volume has 3 replicas, and
`replica-soft-anti-affinity: false` means replicas may not share a node. So cordoning one node
leaves nowhere to put a replacement and **every volume goes degraded 2/3** for the duration.
Recovery is slow on purpose: `replica-replenishment-wait-interval` is 600s before a rebuild even
starts, and `concurrent-replica-rebuild-per-node-limit` is 1, so ~25 volumes rebuild one at a time.
Check progress, then wait:
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n longhorn-system \
get volumes.longhorn.io -o json' | python3 -c 'import json,sys,collections; \
v=json.load(sys.stdin)["items"]; print(collections.Counter(x["status"]["robustness"] for x in v))'
If volumes are rebuilding, **do nothing**. If a volume is stuck `degraded` or `faulted` with no
rebuild progressing, that is a storage problem to fix first — see `/runbooks/longhorn-degraded`.
Rebooting another node while a volume is at 1/3 is how you lose data.
### 2. The drain timed out against a PodDisruptionBudget
Six PDBs currently allow **zero** voluntary disruptions, and two kinds matter:
- **`longhorn-system/instance-manager-*`** (one per node). `minAvailable: 1` against a selector
pinned to a single node's single pod, so `disruptionsAllowed` is structurally **always 0**.
Longhorn's `longhorn-manager` *deletes* the PDB when its `node-drain-policy`
(`block-if-contains-last-replica`) judges the drain safe. The drain is **supposed** to wait here.
- **CNPG `<cluster>-primary`** (`keycloak-db`, `nextcloud-db`, `semaphore-db`). Also structurally 0
until the CNPG operator switches the primary to another instance. If all three primaries are on
the node being drained, three switchovers must happen.
kured's `drainTimeout` is 15m: a genuinely stuck drain **aborts**, releases the lock, and is retried
next period rather than wedging forever. Repeated timeouts show in kured's logs.
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get pdb -A -o wide' | awk '$NF==0 || NR==1'
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get clusters.postgresql.cnpg.io -A \
-o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,PRIMARY:.status.currentPrimary'
If CNPG will not switch over, an **operator** can move the primary deliberately before the next
window rather than fighting the drain.
### 3. A node hung on reboot and still holds the lock
`k3s-prod-2` has done this before (2026-09-03: powered, off the LAN ~38 minutes, physical power
button required). `lockTtl: 0` means the lock does **not** expire, so the cycle stops here —
correctly, since the cluster is at 2/3 and must not go to 1/3.
Symptoms: one node `NotReady` or absent, `KuredNodeCoverageGap` firing alongside
`KuredRebootPending`, and the lock annotation naming the missing node.
**Operator recovery:** attend to the node physically. Once it is back and `Ready`, kured clears its
own lock on the next period. If the node is gone for good and the lock must be cleared by hand,
that is an operator action:
# OPERATOR ONLY. Only when you have confirmed the named node is NOT mid-reboot.
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n kured annotate ds kured \
weave.works/kured-node-lock- --overwrite'
If the node was pruned mid-reboot and is left **cordoned** with no daemon to uncordon it:
# OPERATOR ONLY.
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod uncordon <node>'
### 4. Nothing can write the sentinel (so nothing ever reboots)
If `KuredRebootPending` never fires and reboots never happen even after a kernel upgrade, suspect
the sentinel writer. On Debian, `/var/run/reboot-required` is created by a hook shipped in
**`update-notifier-common`** — and it was absent on all three nodes before this automation landed.
Without it kured is permanently, invisibly idle.
ssh -o BatchMode=yes 192.168.2.10 'dpkg -l update-notifier-common needrestart unattended-upgrades | tail -4'
ssh -o BatchMode=yes 192.168.2.10 'ls /etc/kernel/postinst.d/ /etc/apt/apt.conf.d/ | sort'
Fix by re-running the role, which asserts this explicitly:
cd infra/ansible && ansible-playbook playbooks/unattended-upgrades.yml -e k3s_hosts=k3s_prod --tags verify
### 5. kured is not being scraped (`KuredNotReporting`)
The chart defaults `service.create` and `metrics.create` to **false**, and the `kured` namespace
ships a default-deny NetworkPolicy. If either enablement regressed, or the Prometheus ingress allow
was dropped, targets are *discovered* and every scrape times out — the TSDB stays empty and all
three rules go quiet. Longhorn hit exactly this failure on this cluster.
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n kured get svc,servicemonitor,networkpolicy,ciliumnetworkpolicy'
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n monitoring exec \
sts/prometheus-kube-prometheus-stack-prometheus -c prometheus -- \
wget -qO- "http://localhost:9090/api/v1/targets?state=active" ' | grep -o '"job":"kured[^,]*' | head
### 6. A node is uncovered by the DaemonSet (`KuredNodeCoverageGap`)
kured declares **no tolerations**, because no prod node is tainted. A newly provisioned or
re-provisioned node carrying a taint gets no kured pod and will never be rebooted.
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes \
-o custom-columns=NAME:.metadata.name,TAINTS:.spec.taints'
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n kured get ds kured \
-o custom-columns=DESIRED:.status.desiredNumberScheduled,READY:.status.numberReady'
The fix is a PR adding the needed toleration to `platform/kured/helmrelease.yaml` — not a `kubectl
patch`, which Flux reverts.
## Expected outage per reboot cycle
Stated plainly, because this process is **not** seamless. Every workload at 1 replica on the
draining node is down until it reschedules and becomes Ready elsewhere. Measured 2026-10-17: **45
Deployments and 3 StatefulSets run a single replica.** The ones worth knowing about:
- **`kube-system/coredns` — 1 replica.** A cluster-wide in-cluster DNS gap while it moves. This is
the single most impactful item and it is **not** fixed by this PR; raising coredns to 2–3 replicas
is recommended follow-up work.
- **`monitoring/prometheus-…-0`, `monitoring/loki-0`, `monitoring/alertmanager-…-0`** — all
single-replica StatefulSets on Longhorn RWO volumes, so they additionally wait for volume
detach/reattach. **You lose monitoring and alerting for part of the cycle**, including these very
alerts.
- **All three CNPG Postgres primaries** blip during switchover, which means Keycloak, Nextcloud and
Semaphore briefly lose their database.
- **`gitea`, `nextcloud`, `plex`, `jackett`, `qbittorrent`, `radarr`, `sonarr`, `seerr`, `crafty`,
`homepage`, `headlamp`, `semaphore`** — each down for its reschedule.
- **`unifi` (a KubeVirt VM)** has `evictionStrategy: LiveMigrate` but
`migrationMethod: BlockMigration`, so evicting it copies the whole root disk over the network.
Expect the drain of its node to be slow.
- **`kube-vip`** fails the control-plane VIP over on every node reboot.
Multi-replica and therefore fine: `cilium-operator`, `oauth2-proxy`, `kyverno-admission-controller`,
the Longhorn CSI sidecars, `keycloak`, `pihole`, `runbooks`.
## When to escalate or stop
**STOP and do not intervene** if:
- Longhorn volumes are `degraded` **and rebuilding**. This is the gate doing its job. Wait.
- A single node is mid-reboot and under 15 minutes have passed.
**STOP and get a human to the hardware** if:
- Any node is `NotReady` while another reboot is pending. **Never clear the kured lock in this
state** — that is the one action that can take etcd from 2/3 to 1/3 and lose the cluster.
- Two nodes are down. The cluster has no quorum; this runbook cannot help. Recover hardware first.
**Escalate to a deliberate maintenance decision** if:
- `KuredRebootPending` has been firing for over a week. The reboot is not going to happen on its
own; find the blocker above, or take the reboot manually during a watched window.
- A volume is `faulted`. Fix storage before any further reboot.
**Turn the automation off** if it is misbehaving and you need the cluster stable. The honest way is
a PR, but for an emergency an operator may suspend the tier — this stops future reboots and leaves
running nodes untouched:
# OPERATOR ONLY. Stops kured being reconciled; does NOT uncordon a node mid-cycle.
infra/devcontainer/dev.sh run bash -c 'flux --context k3s-prod suspend kustomization kured'
Check afterwards whether a node was left cordoned (First commands, step 3) and uncordon it if so.
Resume with `flux resume kustomization kured`. Nothing in the graph depends on `kured`, so
suspending it stalls nothing else.
---
Source of truth: `runbooks/node-reboot-automation.md`. Index: `/runbooks/`
Records: `decisions/2026-10-17-automated-upgrades.md` (design + what was not verified),
`platform/kured/helmrelease.yaml` (every tuned value and why),
`infra/ansible/roles/unattended_upgrades/README.md` (the package half, and the kernel exclusion).