merge: fix/swap-off-persists-across-reboot
This commit is contained in:
commit
90b39de527
6 files changed
+313
-10
No files matched your search
@@ -11,3 +11,13 @@
|
|||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
name: systemd-resolved
|
name: systemd-resolved
|
||||||
state: restarted
|
state: restarted
|
||||||
|
|
||||||
|
# Masking a unit writes a symlink to /dev/null into /etc/systemd/system; the RUNNING
|
||||||
|
# systemd keeps its old view of the unit until it re-reads unit files. The mask is read
|
||||||
|
# fresh at the next boot either way, so this only exists so the running manager does not
|
||||||
|
# disagree with on-disk state in between. `daemon_reload` re-parses unit files and
|
||||||
|
# re-runs generators — it starts and stops nothing, so it cannot disturb k3s or Longhorn.
|
||||||
|
# Fires only when a mask actually changed, so a re-run of the role is silent.
|
||||||
|
- name: Reload systemd unit files
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
daemon_reload: true
|
||||||
@@ -331,6 +331,15 @@
|
|||||||
# The devices this role must never, under any circumstances, hand to e2label. Built from
|
# The devices this role must never, under any circumstances, hand to e2label. Built from
|
||||||
# gathered facts (`gather_facts: true` in the play) plus the live swap list, so it is the
|
# gathered facts (`gather_facts: true` in the play) plus the live swap list, so it is the
|
||||||
# NODE's account of what is load-bearing, not a hardcoded list of partition names.
|
# NODE's account of what is load-bearing, not a hardcoded list of partition names.
|
||||||
|
#
|
||||||
|
# EXPECT THIS LIST TO BE EMPTY. Since the swap tasks in tasks/main.yml mask swap.target,
|
||||||
|
# a baselined node has no active swap for `swapon` to report, so the swap half of
|
||||||
|
# node_baseline_lh_protected_devices below contributes nothing. That is not a weakened
|
||||||
|
# guard: the claim CANDIDATE is derived from whatever is mounted at
|
||||||
|
# node_baseline_longhorn_claim_mountpoint, and a swap partition is neither mounted nor an
|
||||||
|
# ext4 filesystem, so it can never be the candidate in the first place. The task stays
|
||||||
|
# because the list must be the node's own account of what is load-bearing — including on
|
||||||
|
# a node reached before baseline has run, or one an operator has deliberately swapped on.
|
||||||
- name: "Longhorn (claim): list the devices currently backing active swap"
|
- name: "Longhorn (claim): list the devices currently backing active swap"
|
||||||
ansible.builtin.command: swapon --noheadings --show=NAME
|
ansible.builtin.command: swapon --noheadings --show=NAME
|
||||||
register: node_baseline_lh_swapdevs
|
register: node_baseline_lh_swapdevs
|
||||||
|
|||||||
@@ -646,9 +646,109 @@
|
|||||||
tags: [disks]
|
tags: [disks]
|
||||||
tags: [disks]
|
tags: [disks]
|
||||||
|
|
||||||
- name: Disable swap now
|
# =====================================================================================
|
||||||
ansible.builtin.command: swapoff -a
|
# SWAP — off NOW, and still off AFTER A REBOOT — tag `system`
|
||||||
|
# =====================================================================================
|
||||||
|
# `swapoff -a` plus commenting the fstab line is NOT durable on these nodes, and never
|
||||||
|
# was. Measured 2026-09-07: all three prod nodes were running with 977 MiB of swap
|
||||||
|
# ACTIVE (k3s-prod-1 /dev/sda4, k3s-prod-3 /dev/sda4, k3s-prod-2 /dev/nvme0n1p4) while
|
||||||
|
# their fstab swap line was commented out by this very role. The role reported success
|
||||||
|
# and the setting silently expired at the next boot, on every node.
|
||||||
|
#
|
||||||
|
# CAUSE. systemd-gpt-auto-generator (systemd 257, Debian 13) claims any GPT partition
|
||||||
|
# whose partition TYPE GUID is 0657fd6d-a4ab-43c4-84e5-0933c84b4f4f ("Linux swap"),
|
||||||
|
# synthesises a .swap unit for it into /run/systemd/generator.late/ and drops a symlink
|
||||||
|
# to it into swap.target.wants/. It decides that from the PARTITION TABLE and never
|
||||||
|
# consults /etc/fstab, so neutralising fstab cannot stop it. Re-running the generator by
|
||||||
|
# hand on k3s-prod-1 says so in as many words:
|
||||||
|
# Dissecting swap partition with label n/a and UUID acd3876c-fd8e-4389-b856-...
|
||||||
|
# Probed fstype 'swap' on partition /dev/disk/by-diskseq/1-part4.
|
||||||
|
# Adding swap: /dev/disk/by-diskseq/1-part4
|
||||||
|
#
|
||||||
|
# MECHANISM — mask swap.target.
|
||||||
|
# `systemctl show` on the generated unit says its ONLY inbound dependency is
|
||||||
|
# `WantedBy=swap.target`: Requires/RequiredBy/BoundBy are empty, and the matching
|
||||||
|
# .device unit has an empty `Wants=`, so a device appearing does NOT pull its swap unit
|
||||||
|
# in by itself. swap.target is therefore the single choke point that every swap
|
||||||
|
# activation path goes through — the gpt-auto units, the units systemd-fstab-generator
|
||||||
|
# builds from /etc/fstab, and the ones systemd-cryptsetup-generator builds for encrypted
|
||||||
|
# swap. Masking it is:
|
||||||
|
# * DEVICE-SHAPE INDEPENDENT. No device name appears anywhere in the change, so one
|
||||||
|
# task is correct on prod's sda4 AND nvme0n1p4 and on dev's vda VMs, and there is
|
||||||
|
# nothing to keep in sync when a disk is replaced. (The generated unit is named after
|
||||||
|
# the kernel's DISKSEQ, not the disk — it is `dev-disk-by\x2ddiskseq-1\x2dpart4.swap`
|
||||||
|
# on all three prod nodes, NVMe and SATA alike — but diskseq is assigned per boot in
|
||||||
|
# device-probe order, so it is not a name worth depending on either.)
|
||||||
|
# * BOOT-SAFE. swap.target is reached by `Wants=`/`After=` from sysinit.target and by
|
||||||
|
# `After=` from tmp.mount, and `RequiredBy=` is EMPTY (verified on all three prod
|
||||||
|
# nodes and all three dev nodes). A masked unit reached only through Wants= is
|
||||||
|
# skipped with a log line; it cannot fail the boot transaction. Ordering deps on a
|
||||||
|
# unit that never starts are no-ops.
|
||||||
|
# * INERT WHEN THE DEVICE IS ABSENT OR RENAMED. The mask names no device, so there is
|
||||||
|
# nothing to go stale: a disk swap, a new partition layout or a restore onto
|
||||||
|
# different hardware all stay swap-free without touching this role.
|
||||||
|
# The per-unit masks that follow are a SECOND, independent barrier over the units that
|
||||||
|
# exist right now, so `systemctl status <unit>` says "masked" to whoever looks, and an
|
||||||
|
# operator who unmasks swap.target does not silently get swap back. Their failure mode is
|
||||||
|
# benign in the other direction too: a mask whose unit no longer exists is an unreferenced
|
||||||
|
# symlink to /dev/null, and the only thing it could ever shadow is a .swap unit.
|
||||||
|
#
|
||||||
|
# ALTERNATIVES REJECTED.
|
||||||
|
# * `systemd.gpt_auto=0` on the kernel cmdline — disables GPT auto-discovery WHOLESALE,
|
||||||
|
# not just swap, and needs a GRUB edit + update-grub + a reboot before it is even
|
||||||
|
# true. On prod that happens to be swap-only today (root= is already on the cmdline
|
||||||
|
# so root auto-detection is off, and the /boot/efi fstab entry already suppresses the
|
||||||
|
# ESP/XBOOTLDR mounts — the generator says so in its debug output), but on DEV the
|
||||||
|
# root partition carries the discoverable GUID 4f68bce3-e8cd-4db1-96e7-fbcaf984b709
|
||||||
|
# ("Linux root (x86-64)"), so the same flag would switch off root auto-discovery
|
||||||
|
# there. Editing a bootloader to fix a swap unit is the wrong blast radius, and the
|
||||||
|
# change would not be verifiable without the reboot it depends on.
|
||||||
|
# * Clearing or retyping the swap partition's GPT type GUID — a partition-table write
|
||||||
|
# to the live boot disk of a node currently serving etcd and Longhorn replicas, to
|
||||||
|
# fix something a symlink fixes. It would also decide, irreversibly and without
|
||||||
|
# asking, that those 977 MiB are never swap again and are simply unused; that is the
|
||||||
|
# operator's call to make, not this role's, and it needs a maintenance window.
|
||||||
|
# * `systemd.swap=0` — read by systemd-fstab-generator ONLY. The swap here does not
|
||||||
|
# come from fstab, so it would change nothing at all.
|
||||||
|
#
|
||||||
|
# DEV. Dev's nodes have no swap partition, no swap unit and no fstab swap line at all
|
||||||
|
# (checked on k3s-dev-1/2/3), so the mask is inert there: it flips to masked once and
|
||||||
|
# every later run is changed=0. It is correct rather than merely harmless — if a future
|
||||||
|
# dev image ever ships a swap partition, the same mask already covers it.
|
||||||
|
|
||||||
|
- name: Mask swap.target so no generator can pull a swap unit in at boot
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: swap.target
|
||||||
|
masked: true
|
||||||
|
notify: Reload systemd unit files
|
||||||
|
tags: [system]
|
||||||
|
|
||||||
|
- name: Find the .swap units systemd's generators produced for this boot
|
||||||
|
ansible.builtin.find:
|
||||||
|
# gpt-auto writes to generator.late; systemd-fstab-generator and
|
||||||
|
# systemd-cryptsetup-generator (the other two that can emit a .swap unit) write to
|
||||||
|
# generator. /run/systemd/generator.early is deliberately NOT listed: nothing that
|
||||||
|
# makes swap units runs at early priority, and `find` warns on a path that does not
|
||||||
|
# exist. Anything a future early generator invented is still caught by the
|
||||||
|
# swap.target mask above, which is the mechanism this task only reinforces.
|
||||||
|
paths:
|
||||||
|
- /run/systemd/generator
|
||||||
|
- /run/systemd/generator.late
|
||||||
|
patterns: "*.swap"
|
||||||
|
file_type: file
|
||||||
|
register: node_baseline_generated_swap
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
check_mode: false
|
||||||
|
tags: [system]
|
||||||
|
|
||||||
|
- name: Mask each generated .swap unit (second barrier, independent of swap.target)
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: "{{ item }}"
|
||||||
|
masked: true
|
||||||
|
loop: "{{ node_baseline_generated_swap.files | map(attribute='path') | map('basename') | unique | sort }}"
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item }}"
|
||||||
|
notify: Reload systemd unit files
|
||||||
tags: [system]
|
tags: [system]
|
||||||
|
|
||||||
- name: Neutralize swap entries in fstab (kubelet requires swap off)
|
- name: Neutralize swap entries in fstab (kubelet requires swap off)
|
||||||
@@ -658,6 +758,23 @@
|
|||||||
replace: '# \1 # disabled for k3s/kubelet'
|
replace: '# \1 # disabled for k3s/kubelet'
|
||||||
tags: [system]
|
tags: [system]
|
||||||
|
|
||||||
|
# The masks above only decide what the NEXT boot does. Anything swapped on already stays
|
||||||
|
# on until it is told otherwise, so read the live state and act only when there is
|
||||||
|
# something to act on — that is what makes a second run honestly changed=0 instead of
|
||||||
|
# `changed_when: false` hiding a command that ran anyway.
|
||||||
|
- name: Read the swap devices that are active right now
|
||||||
|
ansible.builtin.command: swapon --noheadings --show=NAME
|
||||||
|
register: node_baseline_swap_active
|
||||||
|
changed_when: false
|
||||||
|
check_mode: false
|
||||||
|
tags: [system]
|
||||||
|
|
||||||
|
- name: Disable swap now
|
||||||
|
ansible.builtin.command: swapoff -a
|
||||||
|
when: node_baseline_swap_active.stdout | trim | length > 0
|
||||||
|
changed_when: true
|
||||||
|
tags: [system]
|
||||||
|
|
||||||
# =====================================================================================
|
# =====================================================================================
|
||||||
# DNS RESILIENCE — tag `dns` (R-DNS-01 / R-DNS-03)
|
# DNS RESILIENCE — tag `dns` (R-DNS-01 / R-DNS-03)
|
||||||
# =====================================================================================
|
# =====================================================================================
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Serves **R-ENV-01** (you cannot certify a node you have not measured), **R-ENV-0
|
|||||||
| `storage.io_errors` | kernel SCSI/ATA `ioerr_cnt` / `iotmo_cnt` per disk (no root needed) |
|
| `storage.io_errors` | kernel SCSI/ATA `ioerr_cnt` / `iotmo_cnt` per disk (no root needed) |
|
||||||
| `storage.capacity`, `storage.inodes` | worst mounted filesystem, space and inodes |
|
| `storage.capacity`, `storage.inodes` | worst mounted filesystem, space and inodes |
|
||||||
| `storage.layout` | physical disks, and whether any device is genuinely free for `node_baseline_longhorn_device` / `node_baseline_etcd_device` |
|
| `storage.layout` | physical disks, and whether any device is genuinely free for `node_baseline_longhorn_device` / `node_baseline_etcd_device` |
|
||||||
| `memory.available`, `memory.swap`, `memory.ecc` | headroom, kubelet's swap-off prerequisite, EDAC correctable/uncorrectable counters |
|
| `memory.available`, `memory.swap`, `memory.ecc` | headroom, kubelet's swap-off prerequisite **and whether it survives a reboot** (see [Swap off is not the same as swap disabled](#swap-off-is-not-the-same-as-swap-disabled)), EDAC correctable/uncorrectable counters |
|
||||||
| `memory.physical_total` | **is this machine using the RAM it has?** `/proc/zoneinfo` present-vs-managed pages (no root, metal or VM) and `MemTotal` vs the DIMM capacity SMBIOS reports (`dmidecode -t 17`, root, bare metal). Cause-independent by design — see [The EFI memattr check](#the-efi-memattr-check) |
|
| `memory.physical_total` | **is this machine using the RAM it has?** `/proc/zoneinfo` present-vs-managed pages (no root, metal or VM) and `MemTotal` vs the DIMM capacity SMBIOS reports (`dmidecode -t 17`, root, bare metal). Cause-independent by design — see [The EFI memattr check](#the-efi-memattr-check) |
|
||||||
| `cpu.throttle`, `cpu.inventory`, `cpu.virtualisation` | thermal throttle events since boot, topology + frequency scaling, VT-x/AMD-V + `/dev/kvm` (KubeVirt, R-PLAT-03) |
|
| `cpu.throttle`, `cpu.inventory`, `cpu.virtualisation` | thermal throttle events since boot, topology + frequency scaling, VT-x/AMD-V + `/dev/kvm` (KubeVirt, R-PLAT-03) |
|
||||||
| `thermal.max` | hottest sensor across thermal zones and hwmon chips |
|
| `thermal.max` | hottest sensor across thermal zones and hwmon chips |
|
||||||
@@ -59,6 +59,37 @@ Serves **R-ENV-01** (you cannot certify a node you have not measured), **R-ENV-0
|
|||||||
Thresholds live in `defaults/main.yml` (`node_health_thresholds`); no verdict is hardcoded
|
Thresholds live in `defaults/main.yml` (`node_health_thresholds`); no verdict is hardcoded
|
||||||
in task logic.
|
in task logic.
|
||||||
|
|
||||||
|
### Swap off is not the same as swap disabled
|
||||||
|
|
||||||
|
`SwapTotal == 0` only says nothing has been swapped on *yet this boot*. It is not evidence
|
||||||
|
that swap is disabled, and on these nodes it was not: measured 2026-09-07, all three prod
|
||||||
|
nodes came back from a reboot with 977 MiB of swap active (`k3s-prod-1` `/dev/sda4`,
|
||||||
|
`k3s-prod-3` `/dev/sda4`, `k3s-prod-2` `/dev/nvme0n1p4`) even though `roles/node_baseline`
|
||||||
|
had run `swapoff -a` and commented their `/etc/fstab` swap line.
|
||||||
|
|
||||||
|
The cause is `systemd-gpt-auto-generator`. It claims any GPT partition whose partition
|
||||||
|
**type GUID** is `0657fd6d-a4ab-43c4-84e5-0933c84b4f4f` ("Linux swap") straight from the
|
||||||
|
partition table, synthesises a `.swap` unit into `/run/systemd/generator.late/` and links
|
||||||
|
it into `swap.target.wants/`. `/etc/fstab` is never consulted, so neutralising fstab
|
||||||
|
cannot stop it. `roles/node_baseline` masks `swap.target` — the one unit every generated
|
||||||
|
`.swap` unit is `WantedBy` — and masks the generated units as a second barrier.
|
||||||
|
|
||||||
|
So `memory.swap` reports on both halves, from `memory.swap_persistence` in the collector
|
||||||
|
(all world-readable, no root):
|
||||||
|
|
||||||
|
| fact | meaning |
|
||||||
|
|---|---|
|
||||||
|
| `swap_target_masked` | is `/etc/systemd/system/swap.target` a symlink to `/dev/null`? |
|
||||||
|
| `generated_units` | `.swap` units systemd's generators built for this boot |
|
||||||
|
| `masked_units` | `.swap` units masked in `/etc/systemd/system` |
|
||||||
|
| `unmasked_generated_units` | `generated_units` minus `masked_units` — what a reboot would bring back |
|
||||||
|
| `fstab_active_swap_lines` | uncommented `swap` lines left in `/etc/fstab` |
|
||||||
|
|
||||||
|
A node with no swap active but an unmasked generated unit and an unmasked `swap.target`
|
||||||
|
**WARNs**: it is clean now and will be swapping again after the next reboot. The check
|
||||||
|
used to blame that state on "this node has not been baselined yet", which sent the reader
|
||||||
|
back to re-run the role that had already run.
|
||||||
|
|
||||||
|
|
||||||
## The EFI memattr check
|
## The EFI memattr check
|
||||||
|
|
||||||
|
|||||||
@@ -301,9 +301,94 @@ def collect_memory():
|
|||||||
# Firmware's own inventory of what is physically installed. Root-only; attempted
|
# Firmware's own inventory of what is physically installed. Root-only; attempted
|
||||||
# here anyway so the unprivileged pass records the exact reason it could not read.
|
# here anyway so the unprivileged pass records the exact reason it could not read.
|
||||||
mem["devices"] = collect_memory_devices()
|
mem["devices"] = collect_memory_devices()
|
||||||
|
# "Is swap off right now?" and "will it still be off after a reboot?" are DIFFERENT
|
||||||
|
# questions, and only the second one is kubelet's actual prerequisite. See below.
|
||||||
|
mem["swap_persistence"] = collect_swap_persistence()
|
||||||
return mem
|
return mem
|
||||||
|
|
||||||
|
|
||||||
|
# --- Will swap still be off after the next reboot? -----------------------------------
|
||||||
|
# SwapTotal=0 does not mean swap is DISABLED — it means nothing has been swapped on yet
|
||||||
|
# this boot. Measured 2026-09-07 on all three prod nodes: swap was active again after a
|
||||||
|
# reboot, 977 MiB each, with /etc/fstab's swap line commented out by node_baseline. The
|
||||||
|
# cause is systemd-gpt-auto-generator, which claims any GPT partition typed
|
||||||
|
# 0657fd6d-a4ab-43c4-84e5-0933c84b4f4f ("Linux swap") straight from the PARTITION TABLE,
|
||||||
|
# synthesises a .swap unit into /run/systemd/generator.late/ and links it into
|
||||||
|
# swap.target.wants/. It never reads fstab, so neutralising fstab cannot stop it.
|
||||||
|
#
|
||||||
|
# roles/node_baseline masks swap.target — the single unit every generated .swap unit is
|
||||||
|
# `WantedBy` — and masks the generated units themselves as a second barrier. This
|
||||||
|
# collects the facts that let the report tell "off, and staying off" apart from "off
|
||||||
|
# until the next reboot", which a /proc/meminfo reading alone can never do.
|
||||||
|
#
|
||||||
|
# All of it is world-readable (the generator dirs are 0755 root:root), so it works in the
|
||||||
|
# UNPRIVILEGED pass: no root, no systemctl, no parsing of systemctl output.
|
||||||
|
def _is_systemd_mask(path):
|
||||||
|
"""True when `path` is a systemd mask, i.e. a symlink to /dev/null."""
|
||||||
|
try:
|
||||||
|
return os.path.islink(path) and os.path.realpath(path) == "/dev/null"
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def collect_swap_persistence():
|
||||||
|
out = {
|
||||||
|
"readable": False,
|
||||||
|
"swap_target_masked": None,
|
||||||
|
# .swap units systemd's generators built for THIS boot (gpt-auto, fstab,
|
||||||
|
# cryptsetup). These are the ones that would be activated again next boot.
|
||||||
|
"generated_units": [],
|
||||||
|
# .swap units masked in /etc/systemd/system — what node_baseline wrote.
|
||||||
|
"masked_units": [],
|
||||||
|
# generated MINUS masked: the units that would still come back. Empty is good.
|
||||||
|
"unmasked_generated_units": [],
|
||||||
|
"fstab_active_swap_lines": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
generated = set()
|
||||||
|
for directory in (
|
||||||
|
"/run/systemd/generator.early",
|
||||||
|
"/run/systemd/generator",
|
||||||
|
"/run/systemd/generator.late",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
names = os.listdir(directory)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# A generator produced nothing. That is a real answer, not a read failure:
|
||||||
|
# dev's VMs have no swap partition, so gpt-auto emits no unit at all.
|
||||||
|
continue
|
||||||
|
except OSError as exc:
|
||||||
|
UNREADABLE.append({"path": directory, "reason": "read error: %s" % exc})
|
||||||
|
return out
|
||||||
|
for name in names:
|
||||||
|
if name.endswith(".swap") and os.path.isfile(os.path.join(directory, name)):
|
||||||
|
generated.add(name)
|
||||||
|
|
||||||
|
masked = set()
|
||||||
|
try:
|
||||||
|
for name in os.listdir("/etc/systemd/system"):
|
||||||
|
if name.endswith(".swap") and _is_systemd_mask("/etc/systemd/system/" + name):
|
||||||
|
masked.add(name)
|
||||||
|
except OSError as exc:
|
||||||
|
UNREADABLE.append({"path": "/etc/systemd/system", "reason": "read error: %s" % exc})
|
||||||
|
return out
|
||||||
|
|
||||||
|
out["swap_target_masked"] = _is_systemd_mask("/etc/systemd/system/swap.target")
|
||||||
|
out["generated_units"] = sorted(generated)
|
||||||
|
out["masked_units"] = sorted(masked)
|
||||||
|
out["unmasked_generated_units"] = sorted(generated - masked)
|
||||||
|
out["readable"] = True
|
||||||
|
|
||||||
|
fstab = read_text("/etc/fstab")
|
||||||
|
if fstab is not None:
|
||||||
|
out["fstab_active_swap_lines"] = sum(
|
||||||
|
1
|
||||||
|
for line in fstab.splitlines()
|
||||||
|
if not line.lstrip().startswith("#") and re.search(r"\sswap\s", line)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# --- Memory the kernel has but never hands out --------------------------------------
|
# --- Memory the kernel has but never hands out --------------------------------------
|
||||||
# /proc/zoneinfo is WORLD-READABLE on every Linux, VM or metal — no root, no dmidecode,
|
# /proc/zoneinfo is WORLD-READABLE on every Linux, VM or metal — no root, no dmidecode,
|
||||||
# no DMI. Per zone it reports:
|
# no DMI. Per zone it reports:
|
||||||
|
|||||||
@@ -499,25 +499,76 @@
|
|||||||
if phys_status in ['WARN', 'FAIL'] else '')
|
if phys_status in ['WARN', 'FAIL'] else '')
|
||||||
~ (phys_cause if phys_status in ['WARN', 'FAIL'] else '') }}
|
~ (phys_cause if phys_status in ['WARN', 'FAIL'] else '') }}
|
||||||
|
|
||||||
- name: Assess — swap (kubelet requires it off)
|
# WHY THIS CHECK IS NOT JUST `SwapTotal == 0`. It used to be, and it read the answer
|
||||||
|
# wrong in both directions. It called active swap "this node has not been baselined yet",
|
||||||
|
# which was a MIS-DIAGNOSIS — measured 2026-09-07, all three prod nodes had been
|
||||||
|
# baselined, had their fstab swap line commented out by node_baseline, and were STILL
|
||||||
|
# swapping 977 MiB each after a reboot. Whoever read that WARN would have re-run the very
|
||||||
|
# role that had already run, and watched swap come back at the next boot again.
|
||||||
|
#
|
||||||
|
# The real mechanism is systemd-gpt-auto-generator: it claims any GPT partition typed
|
||||||
|
# 0657fd6d-a4ab-43c4-84e5-0933c84b4f4f ("Linux swap") from the PARTITION TABLE, builds a
|
||||||
|
# .swap unit into /run/systemd/generator.late/ and links it into swap.target.wants/. It
|
||||||
|
# never reads /etc/fstab, so `swapoff -a` + a commented fstab line only holds until the
|
||||||
|
# next boot. roles/node_baseline now masks swap.target (the single unit those generated
|
||||||
|
# units are `WantedBy`) plus the generated units themselves.
|
||||||
|
#
|
||||||
|
# The other direction matters just as much: SwapTotal == 0 is NOT proof that swap is
|
||||||
|
# disabled — a node freshly `swapoff`-ed reads zero and swaps again on reboot. So this
|
||||||
|
# check now asks both questions, and WARNs on a node that is clean right now but has an
|
||||||
|
# unmasked generated swap unit waiting for the next boot.
|
||||||
|
- name: Assess — swap (kubelet requires it off, and off ACROSS REBOOTS)
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
node_health_checks: "{{ node_health_checks + [{
|
node_health_checks: "{{ node_health_checks + [{
|
||||||
'id': 'memory.swap',
|
'id': 'memory.swap',
|
||||||
'title': 'Swap disabled (kubelet prerequisite)',
|
'title': 'Swap disabled, and disabled across reboots (kubelet prerequisite)',
|
||||||
'status': swap_status,
|
'status': swap_status,
|
||||||
'detail': swap_detail }] }}"
|
'detail': swap_detail }] }}"
|
||||||
vars:
|
vars:
|
||||||
ansible_become: false
|
ansible_become: false
|
||||||
m: "{{ node_health_facts.memory }}"
|
m: "{{ node_health_facts.memory }}"
|
||||||
|
p: "{{ node_health_facts.memory.swap_persistence | default({}, true) }}"
|
||||||
|
# Durability was actually measurable on this node (the collector could read
|
||||||
|
# /etc/systemd/system and the generator dirs). When false, say so rather than
|
||||||
|
# implying a clean bill from a failed read.
|
||||||
|
swap_durable_known: "{{ (p.readable | default(false)) | bool }}"
|
||||||
|
# Units a reboot would bring back: produced by a generator, not masked, and with
|
||||||
|
# swap.target itself unmasked so something can still pull them in.
|
||||||
|
swap_will_return: >-
|
||||||
|
{{ (swap_durable_known | bool)
|
||||||
|
and not (p.swap_target_masked | default(false) | bool)
|
||||||
|
and (p.unmasked_generated_units | default([], true) | length) > 0 }}
|
||||||
|
swap_units_returning: "{{ p.unmasked_generated_units | default([], true) | join(', ') }}"
|
||||||
|
swap_active_gib: "{{ (m.swap_total_kb | int / 1048576) | round(2) | string }}"
|
||||||
swap_status: >-
|
swap_status: >-
|
||||||
{{ 'SKIP' if m.swap_total_kb is not number
|
{{ 'SKIP' if m.swap_total_kb is not number
|
||||||
else ('PASS' if m.swap_total_kb | int == 0 else 'WARN') }}
|
else ('WARN' if (m.swap_total_kb | int) > 0
|
||||||
|
else ('WARN' if (swap_will_return | bool) else 'PASS')) }}
|
||||||
|
swap_cause: >-
|
||||||
|
systemd-gpt-auto-generator re-creates a .swap unit for the GPT partition typed
|
||||||
|
0657fd6d-a4ab-43c4-84e5-0933c84b4f4f ("Linux swap") at EVERY boot, straight from
|
||||||
|
the partition table and without reading /etc/fstab. This is NOT "the node has not
|
||||||
|
been baselined" — `swapoff -a` and a commented fstab line simply do not survive a
|
||||||
|
reboot. The fix is roles/node_baseline masking swap.target and the generated units:
|
||||||
|
re-run `node-baseline.yml --tags system` against this node.
|
||||||
swap_detail: >-
|
swap_detail: >-
|
||||||
{{ 'swap state unreadable' if m.swap_total_kb is not number
|
{{ 'swap state unreadable' if m.swap_total_kb is not number
|
||||||
else ('no swap active' if m.swap_total_kb | int == 0
|
else ((swap_active_gib ~ ' GiB swap active'
|
||||||
else ((m.swap_total_kb | int / 1048576) | round(2) | string
|
~ (' via ' ~ swap_units_returning if swap_units_returning else '')
|
||||||
~ ' GiB swap active — roles/node_baseline runs swapoff and neutralises
|
~ ' — ' ~ swap_cause)
|
||||||
the fstab entry; this node has not been baselined yet')) }}
|
if (m.swap_total_kb | int) > 0
|
||||||
|
else ('no swap active, but it would come back at the next boot via '
|
||||||
|
~ swap_units_returning ~ ' — ' ~ swap_cause
|
||||||
|
if (swap_will_return | bool)
|
||||||
|
else ('no swap active'
|
||||||
|
~ (' and none can be activated: swap.target is masked'
|
||||||
|
if (p.swap_target_masked | default(false) | bool)
|
||||||
|
else (' and no swap unit exists on this node'
|
||||||
|
if (p.generated_units | default([], true) | length) == 0
|
||||||
|
else ' and every generated swap unit is masked'))
|
||||||
|
if (swap_durable_known | bool)
|
||||||
|
else 'no swap active (durability NOT verified — the collector
|
||||||
|
could not read /etc/systemd/system or /run/systemd)'))) }}
|
||||||
|
|
||||||
- name: Assess — ECC / EDAC correctable errors
|
- name: Assess — ECC / EDAC correctable errors
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
|
|||||||
Reference in new issue
Block a user