Compare commits

...
Author SHA1 Message Date
claude-bot 803c741362 fix(ci): tag images with a dated literal, not the commit SHA
ci/woodpecker/push/build-ci-images Pipeline failed
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The SHA tag was a workaround for not being able to compute a date in a
shell-less build step. That problem is gone — the build timestamp now
lives in the image at /etc/image-build-date — so the tag no longer has
to carry it.

A SHA reads as noise at the point of use and breaks the existing
convention every consumer pin follows (ci-k8s:2026-08-10,
ci-ansible:2026-04-29, alpine-jq:2026-07-30). Dated literals keep the
pins uniform and are bumped in the commit that changes an image, which
is the same trigger the `when:` path filters already use.
2026-08-23 21:14:51 +02:00
claude-bot aff01d5a32 build(ci): record the build timestamp in ci-claude too
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Completes the pattern across every Containerfile in the repo. ci-claude
was the last one without it.

It was skipped initially over the reproducibility work in a675bf6c, on a
misreading. That commit did not make the whole image reproducible — it
made the npm install layer byte-stable so a rebuild of the same version
dedups in the registry instead of orphaning ~260 MB (#336, #351).

A timestamp added as its own final layer leaves the npm layer untouched,
so that dedup still holds. The cost is a few dozen bytes per build, not
260 MB.
2026-08-23 20:59:29 +02:00
claude-bot 80c3051dc7 build(ci): record the build timestamp in the image instead of the tag
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline failed
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
The date was previously computed by `date` in the pipeline step and used
as the image tag, which is why the build step needed a shell and hence
the kaniko debug image.

It does not need to cross between CI steps at all. The base images have
a shell, so a RUN in the Containerfile writes the timestamp during the
build itself — kaniko executes the layer and the value lands in the
image. Nothing has to be propagated, and the build step stays
shell-less.

This also separates two things the tag was conflating: the tag says
which source revision was built, /etc/image-build-date says when.

Placed last so it invalidates only its own layer. secret-rotator writes
it in the rust build stage and copies it across, since its final image
is FROM scratch and has no shell.

Verified against ci/images/alpine-jq:

  $ docker run --rm localtest:jq cat /etc/image-build-date
  2026-08-23T18:56:51Z

Note this makes builds non-reproducible byte-for-byte, which a675bf6c
deliberately arranged for ci-claude. ci-claude is built by
ci-claude-refresh.yml and is not touched here.
2026-08-23 20:57:15 +02:00
claude-bot bbbc42c79f fix(ci): build with the plain kaniko executor instead of the debug image
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline failed
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The `-debug` image ships a busybox shell so a failing build can be
inspected. It was being used as the build image because the steps run
shell to assemble registry auth and compute a tag, which is not what
that variant is for.

The plain executor has no shell, so the eight static builds now pass
flags via `entrypoint` and read credentials from DOCKER_CONFIG. A single
registry-auth step writes `.docker/config.json` into the workspace once,
in alpine, where a shell is the intended interface.

Images are tagged `${CI_COMMIT_SHA:0:8}` instead of a build date.
Woodpecker exposes no formatted-date variable — every timestamp built in
is UNIX — and kaniko takes `--destination` only as a literal argument,
with all its file-based flags being outputs. The commit SHA is available
through normal substitution and identifies the source revision the image
was built from, which a build date does not.

Existing dated tags stay in the registry, so the pins that reference
them keep resolving; only newly created tags change format.

Also moves off v1.23.2, picking up the go-git fix for CVE-2025-21613 and
containerd 1.7.18 -> 1.7.27.

ci-claude-refresh.yml keeps its shell step: its destination tag is a
runtime value read from a file written by an earlier step, and the build
is conditionally skipped, neither of which survives a static entrypoint.
Left for #552.

Needs a pipeline run to confirm that substitution applies to
`entrypoint` and `environment` values, and that the auth file written by
registry-auth is visible to the build steps.

Refs #426, #552
2026-08-23 20:52:06 +02:00
k.renner adefaab7e8 Merge pull request 'docs(rook-ceph): cut the narrative out of the ceph manifest comments' (#548) from document-pool-no-redundancy-meaning into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#548
2026-08-21 20:28:21 +00:00
claude-bot 9a43330701 docs(rook-ceph): cut the narrative out of the ceph manifest comments
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Comments should say what the setting does and what constrains it. The
blocks added in #542, #544, #545 and #547 had grown into incident
write-ups: measurement runs, prior values, how a problem was found,
what a check used to be. That belongs in commit messages and the wiki,
both of which already carry it.

Trimmed, no behaviour change:

  cephcluster.yaml       22 lines -> 6   (cephx rotation)
  cephfs-ci.yaml         26 lines -> 7   (why size 2)
  prometheusrule-ceph.yaml                (CephHealthWarn, 43 -> 13;
                                           CephPoolLostRedundancy, 15 -> 7)

The operative facts are kept: which checks are excluded and why they
cannot clear, that POOL_NO_REDUNDANCY reports blocked maintenance and
not only data risk, and that size 1 needs dedicated OSDs.
2026-08-21 22:27:10 +02:00
k.renner 9c796b924b Merge pull request 'fix(rook-ceph): raise cephfs-ci to size 2 — size 1 blocks all OSD maintenance' (#547) from fix-ci-pool-blocks-osd-maintenance into master
Reviewed-on: k.renner/homelab#547
2026-08-21 20:22:07 +00:00
claude-bot 85996991b6 fix(rook-ceph): raise cephfs-ci to size 2 — size 1 blocks all OSD maintenance
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The unreplicated CI pools introduced in #542 make every OSD on the
cluster permanently un-restartable.

`ceph osd ok-to-stop` refuses while stopping an OSD would take PGs
offline. A size-1 pool puts every PG on exactly one OSD, so the check
fails for all three:

  Error EBUSY: unsafe to stop osd(s) at this time
               (20 PGs are or would become offline)
  bad_become_inactive: 13.6 13.9 ... 14.1c 14.1d

Pools 13 and 14 are cephfs-ci-metadata and cephfs-ci-data0. The size-2
pools are not implicated — they run min_size 1 and appear under
ok_become_degraded, which is allowed.

Rook gates OSD restarts on that check, so this blocks CephX key
rotation, Ceph upgrades, node drains and reboots, and drive
replacement. It surfaced when #545 deadlocked: Rook retried
ok-to-stop against all three OSDs several times a second and could
never succeed, while `ceph -s` reported 217 pgs active+clean
throughout. Nothing is unhealthy; the restarts are simply refused.

The reasoning in #542 was that unreplicated CI data only risks
in-flight pipelines, since every byte is a fresh git clone. That part
holds. What it missed is that the cost is not scoped to the pool: one
unreplicated pool freezes maintenance for the entire cluster.

Most of #542's saving came from keeping CI churn off `cephfs`, whose
metadataPool is size 3 — that survives this change. Only the second-
order gain from dropping the replica is given up, and cephfs-ci-data0
stores 0 B with cephfs-ci-metadata at 182 MiB, so the backfill is
trivial.

size 1 becomes viable again once these pools sit on OSDs of their own,
where the permanently un-stoppable OSD holds nothing but throwaway
data. That is planned with the retired QLC drives.

Alerting follows the same change:

- POOL_NO_REDUNDANCY is no longer excluded from CephHealthWarn. It was
  excluded because cephfs-ci was expected to keep it permanently
  active; no pool is unreplicated now, so the exclusion would only
  hide a real one losing its replicas.
- CephPoolLostRedundancy drops its cephfs-ci exemption for the same
  reason, and its description now states the maintenance consequence,
  not just the data-loss one.
- The AUTH_INSECURE_* exclusion is reworded. Calling it
  "known-accepted" reads as a decision that the risk is fine; it is a
  kernel-blocked remediation (aes256k over the CSI mount path needs
  kernel 7.0+, nodes run 6.18).

Refs #542, #545, #527
2026-08-21 22:21:11 +02:00
k.renner 04b8c93113 Merge pull request 'fix(rook-ceph): rotate CephX daemon keys to close CVE-2025-30156' (#545) from rotate-cephx-daemon-keys into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#545
2026-08-21 20:07:25 +00:00
claude-botandClaude Opus 5 2abd946c05 fix(rook-ceph): rotate CephX daemon keys to close CVE-2025-30156
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Ceph v19.2.6 ships the fix for CVE-2025-30156, a CephX authentication
bypass: AES-128-CBC service tickets carry no HMAC and use a hard-coded
IV, so a holder of any low-privilege key can bit-flip a ticket into
cluster-admin in linear time. Upgrading the binaries does not resolve
it — the keys themselves must be rotated to the aes256k cipher.

The cluster is mid-procedure. auth_allowed_ciphers (aes, aes256k),
auth_preferred_cipher (aes256k) and auth_service_cipher (aes256k) are
already set, and the rotating service keys were wiped, but no actual
key rotation has happened: every entity in status.cephx is still
keyGeneration 1 with keyType aes, created under 19.2.3.

Rook automates daemon-key rotation but it is opt-in and defaults to
Disabled, so spec.security.cephx was left empty and nothing rotated.
Rook v1.20.6 and Ceph v19.2.6 are both past the version floor the
feature requires.

Rotating daemon keys clears AUTH_INSECURE_SERVICE_KEY_TYPE, the only
one of the six new checks at ERR severity. That is the operationally
urgent part: Rook skips CephFS and RGW reconciliation entirely while
health is HEALTH_ERR — which is what deadlocked the mds/rgw rollout in
issue #527 — and the check is presently held down only by a mon-side
mute with a finite TTL. When that mute lapses the cluster returns to
HEALTH_ERR and the reconcile freeze comes back.

CSI keys are deliberately left on aes. aes256k over the CSI mount path
requires a host kernel of 7.0 or newer and these nodes run 6.18, so
`aes` must stay in auth_allowed_ciphers for CSI to authenticate at all.
AUTH_INSECURE_CLIENT_KEY_TYPE, AUTH_INSECURE_KEYS_ALLOWED and
AUTH_INSECURE_KEYS_CREATABLE therefore stay raised at WARN until the
nodes run a kernel that supports it.

Rotation restarts most Ceph daemons. With size 2 pools across 3 OSDs
this should be applied deliberately rather than alongside other churn,
and Ceph should reach HEALTH_OK before anything else touches the
cluster.

Refs #527, #528

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:05:10 +02:00
k.renner 8c0a6b2b1e Merge pull request 'fix(monitoring): CephHealthWarn has been firing continuously and hiding everything' (#544) from fix-ceph-health-alert into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#544
2026-08-21 08:02:00 +00:00
claude-bot cf6ff839be fix(monitoring): CephHealthWarn has been firing continuously and hiding everything
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
CephHealthWarn alerted on `ceph_health_status == 1`. It has been FIRING since
at least 2026-08-21T00:26Z and cannot clear: four AUTH_INSECURE_* checks are
permanently active from CVE-2025-30156 (#527) and stay that way until Rook
>=1.20.5 lands. A permanently-firing alert reports nothing -- every Ceph
warning since then has been invisible behind it.

This is a pre-existing gap, not a consequence of the cephfs-ci work, though
that work would have added a second permanent cause (POOL_NO_REDUNDANCY, since
the CI pools are size 1 on purpose).

ceph_health_detail exposes each check as its own series, so the alert now fires
on any check that is NOT known-accepted, and names it. Verified against live
data: 5 checks active, 0 unexpected, so it is quiet now and fires the moment
something genuinely new appears.

Excluding POOL_NO_REDUNDANCY wholesale would give something up: that check is
cluster-wide and does not say WHICH pool, so accepting it for cephfs-ci would
equally hide a production pool dropping to one replica. CephPoolLostRedundancy
restores that. There is no pool-size metric, but bytes_used/stored IS the
replication factor -- verified live as replicapool 2, cephfs-data0 2,
cephfs-metadata 3, cephfs-ci-metadata 1.

Both alerts were tested against live data before committing, in BOTH
directions. The positive control matters as much as the negative one: an early
draft used a >1e9 floor which silently excluded the CI pools entirely, so the
expression could not have matched anything. It looked correct precisely because
it returned no results. The control caught it; the floor is now 1e6.

The floor is not what suppresses the near-empty rgw pools. Their ratios are
9-36 because per-object overhead dominates, so they never match `< 1.5`.

6h on CephHealthWarn is unchanged -- Ceph warns for self-clearing conditions
and paging on those trains people to ignore the alert.
2026-08-21 09:53:37 +02:00
k.renner a11493ee22 Merge pull request 'perf(woodpecker): put CI workspaces on the unreplicated CephFS' (#543) from repoint-ci-workspaces into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#543
2026-08-21 07:32:11 +00:00
claude-bot 7666d220c5 perf(woodpecker): put CI workspaces on the unreplicated CephFS
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Repoints WOODPECKER_BACKEND_K8S_STORAGE_CLASS at ceph-filesystem-ci, the
dedicated filesystem added in #542 whose pools are both size 1.

Measured 2026-08-21, polling both CephFS pools every 15s:

  CI active   1.5 min (6 intervals):  data0 143.2 MB   metadata 17.2 MB
  CI idle    16.0 min (63 intervals): data0   0.0 MB   metadata 11.0 MB
  CI share of writes: data0 100%, metadata 61%

data0 takes zero bytes across 16 minutes without a pipeline, so its entire
write volume is CI. The 15s cadence is what made this visible: a pipeline
creates, uses and deletes its PVC inside ~2 minutes, so a 5-minute sample
reported no CI pods during bursts CI was causing.

Per pipeline this moves the drive cost from 71.6x2 + 8.2x3 = ~168 MB to
~80 MB, since neither pool is replicated on the new filesystem.

RWX is unchanged and still required: pipeline steps share the workspace PVC
and the k8s backend can schedule them on different nodes, so an RWO volume
fails to mount on the second node. Only the filesystem underneath changes.

Also corrects the justification written into #542 for leaving
ceph-filesystem-ephemeral alone. That commit claimed restore-test.sh handles
"REAL data which must not land on unreplicated pools". That is wrong: its
probe PVCs are as throwaway as a CI workspace -- the authoritative copy is the
backup on Hetzner, and losing a probe just means re-running the test.

The actual reason it cannot move is technical: those probes are snapshot
CLONES, and `ceph fs subvolume snapshot clone` operates within a single
filesystem, so a snapshot taken in `cephfs` cannot be cloned into `cephfs-ci`.
Landing them unreplicated would require a second size-1 data pool inside
`cephfs`; restore-test runs weekly against CI's ~96 pipelines/day, so that is
not worth the complexity.

cephfs-ci verified healthy before this change: PHASE Ready, MDS Running, both
pools reporting size 1.
2026-08-21 09:31:01 +02:00
k.renner baa5e62bbe Merge pull request 'feat(rook-ceph): add an unreplicated CephFS for CI workspaces' (#542) from ci-throwaway-cephfs into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#542
2026-08-21 07:27:47 +00:00
claude-bot 503d2a4876 feat(rook-ceph): add an unreplicated CephFS for CI workspaces
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Woodpecker's Kubernetes backend creates a PVC per pipeline, fills it with a
checkout plus build output, and deletes it when the pipeline ends. Those
workspaces currently live on the shared `cephfs`, so throwaway data is
replicated as if it mattered: 2x in cephfs-data0 and 3x in cephfs-metadata.

Measured 2026-08-21 by polling both pools every 15s. The interval matters --
at 5 minutes a whole pipeline is invisible, because its PVC is created, used
and deleted inside ~2 minutes. Sampling that slowly reported zero CI pods
during bursts CI was causing, which sent this investigation down three wrong
paths before the cadence was fixed.

  one pipeline, a single 15s interval:  data0 +71.6 MB   metadata +8.2 MB
  every interval with no CI pod:        data0   ~0        metadata   ~0

So CI accounts for essentially all CephFS write volume; between pipelines the
metadata pool is quiet at ~4 MB/day. On the shared filesystem one pipeline
costs 71.6x2 + 8.2x3 = ~168 MB of drive writes. On this one it costs ~80 MB.

size 1 is correct here even though it was rejected for `cephfs`. There,
metadataPool stays at 3 because losing it destroys the entire filesystem
rather than the objects on one OSD, and 550 GiB of real data depends on it.
Here every byte is a fresh git clone: total loss means in-flight pipelines
fail and get re-run. Replicating throwaway data was the cost, so it is what
gets removed.

`ceph-filesystem-ephemeral` is deliberately NOT repointed. It has a second
consumer -- velero/restore-test.sh restores REAL data through it for
verification, which must not land on unreplicated pools. A new class also
sidesteps StorageClass.parameters being immutable.

Nothing uses ceph-filesystem-ci yet, so this commit is inert: it provisions
the filesystem and its MDS and changes no existing workload. Repointing the
Woodpecker agent is a separate change, once this is confirmed healthy.

Known consequence: size 1 raises POOL_NO_REDUNDANCY, so cluster health will
warn until that is addressed. Silencing it via mon_warn_on_pool_no_redundancy
is cluster-wide and would also hide a production pool dropping to 1, so it
wants a targeted alert instead -- deliberately left out of this commit rather
than switching the warning off blind.

The MDS is 512Mi/1Gi with no standby, not the 4Gi active+standby that `cephfs`
runs. That figure is driven by immich's cached inode count; a CI workspace is
a few thousand files that vanish with the pipeline, and a second daemon to
protect throwaway work is not worth its memory.
2026-08-21 08:55:33 +02:00
security-bot 48407aa202 chore(image-automation): bump redis:8.10.1-alpine
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-20 17:37:50 +00:00
security-bot ce6e8ba2cb chore(image-automation): bump redis:7.4.11-alpine
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-20 17:19:40 +00:00
k.renner 96387108ff Merge pull request 'perf(cnpg): roll archive_timeout out to the remaining six clusters' (#541) from rollout-archive-timeout into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#541
2026-08-20 13:39:00 +00:00
claude-bot 119b2c77ef perf(cnpg): roll archive_timeout out to the remaining six clusters
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
A WAL segment is a fixed 16 MiB file. archive_timeout forces a switch on a
timer, and the switched-out segment is written and archived at its full size
no matter how little real WAL it holds. CNPG defaults to 5min, permitting
288 segments/day per cluster of largely padding.

gf-pg went first as a staged rollout and is now verified: 211 -> 41 MB/hour,
an 81% cut, with archived segments falling from ~12/hour to ~1.5/hour in
step. Two independent signals moving together is what confirms the volume
was padding rather than data, and the measured result matched the ~35
MB/hour predicted beforehand.

Fleet-wide this is worth roughly 18 GB/day of logical writes, which at
size=2 with ~1.27x BlueStore overhead is ~45 GB/day at the drives -- about
14% of the 328 GB/day the three OSD NVMes currently take.

ak-pg gets 15min rather than 30min. It holds credentials, sessions and
enrolment state, and the extra 15min would save only a further 0.3 GB/day,
which does not justify doubling the worst-case loss window on that data.

ha-pg saves least in relative terms: its segments are already 63% full of
real WAL (4.9 GB/day), so the timer is not what drives most of its switches.

Also corrects two comments in ha-pg-cluster.yaml that this work falsified.

wal_compression: the claim that these clusters "were not getting compression"
was wrong -- "on" is pglz, which was already compressing. Switching to zstd
moved the WAL rate not at all (4911/4976 -> 5002 MB/day, by LSN advance over
20min windows). Full-page images genuinely are ~62% of WAL here (61.9%
measured as a steady-state delta, against 62.4% lifetime -- so the migration
did not skew it, which was my first guess and it was wrong). There was simply
no headroom between pglz and zstd on this data. zstd is kept because it is
not worse and costs nothing, but it is not a lever.

checkpoint_timeout: 15min -> 30min likewise did not move WAL. Probable reason
is that a recorder is append-mostly -- a page is dirtied while it fills and
then not touched again, so it pays its full-page image once whatever the
checkpoint interval. Kept, but flagged as unjustified pending a data-file
measurement: this volume takes ~11 GB/day against 4.9 GB/day of WAL, so most
of its traffic is outside what a WAL measurement can see.

Context: drive-endurance work on three QLC NVMe OSDs at 81-87% wear.
2026-08-20 15:36:26 +02:00
k.renner 1d2248139d Merge pull request 'perf(grafana-db): raise archive_timeout to 30min on gf-pg (re-target of #539)' (#540) from archive-timeout-gf-pg into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#540
2026-08-20 12:49:41 +00:00
claude-bot 0a9d19a7c6 perf(grafana-db): raise archive_timeout to 30min on gf-pg
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
A WAL segment is a fixed 16 MiB file. archive_timeout forces a segment
switch on a timer so the archive never trails reality by more than that
interval, and the switched-out segment is written and archived at its full
16 MiB no matter how few bytes of real WAL it holds. CNPG defaults this to
5min, which permits up to 288 segments/day = 4.6 GB/day per cluster.

Measured on gf-pg: the database is 21 MB and generates 4.5 MB/day of actual
WAL, while its RBD volume takes 211 MB/hour -- against 192 MB/hour predicted
from 12 switches of 16 MiB. Essentially every byte written to this volume is
padding, roughly 1100x amplification.

This resolves an anomaly that has been open since the start of the endurance
work: gf-pg appeared to write gigabytes per day for a 21 MB database, and
neither WAL volume nor data changes could account for it. Segment padding
does, exactly.

Applied to gf-pg alone as a staged rollout. It is the lowest-risk cluster
and gives the cleanest signal -- its real WAL is small enough that any change
in volume is unambiguously attributable to this parameter. The same default
affects all seven clusters and is worth an estimated ~24 GB/day of logical
writes fleet-wide, but that only goes ahead once this one is verified.

The cost is RPO. On total loss of the cluster, up to 30min of Grafana state
becomes unrecoverable instead of 5min. That state is dashboards, annotations
and preferences, changing at ~4.5 MB/day, so the window is nearly always
empty. Clusters holding credentials or job state get evaluated separately
rather than inheriting this value by default.

archive_timeout is in CNPG's GlobalDefaultSettings, not its
FixedConfigurationParameters, so it is user-overridable -- verified against
pkg/postgres/configuration.go rather than assumed.

Context: drive-endurance work on three QLC NVMe OSDs at 81-87% wear.
2026-08-20 14:37:46 +02:00
k.renner 082092c4ba Merge pull request 'perf(cnpg): compress WAL with zstd instead of pglz' (#538) from reduce-pg-wal-fpi into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#538
2026-08-20 12:30:18 +00:00
claude-bot 591c76fb86 perf(cnpg): compress WAL with zstd instead of pglz
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
`wal_compression: "on"` resolves to pglz, PostgreSQL's original 2016-era
algorithm. Every CNPG cluster here was set that way, so none of them were
getting the compression actually available to them: all seven builds accept
lz4 and zstd (verified via pg_settings.enumvals on each, including immich's
custom pgvector image and trmnl's PostgreSQL 18).

This matters because of what dominates recorder-style write volume. A page
is written to WAL in full -- all 8 KiB -- on its first modification after a
checkpoint, for torn-page protection. Measured on ha-pg, those full-page
images are 62.4% of WAL bytes. Compressing them is therefore the single
largest lever available on WAL volume, and pglz was the weakest option for
the job.

ha-pg additionally moves checkpoint_timeout 15min -> 30min. Full-page images
are emitted once per page per checkpoint interval, so doubling the interval
roughly halves how often each hot page pays the 8 KiB toll. max_wal_size
stays at 2GB deliberately: it must remain well above what one interval
generates, or checkpoints would trigger on size and the interval change
would do nothing. Recovery replays at most one interval of WAL -- under
200MB at the measured rate, i.e. seconds.

The other six clusters keep checkpoint_timeout at 15min. Their measured
write rates do not justify trading recovery time: gf-pg generates 4.5 MB/day
of WAL and wp-pg 62 MB/day, against ak-pg's 604 MB/day.

full_page_writes stays on everywhere. Disabling it would remove the
overhead entirely but RBD can tear a page across an object boundary, and
the trade is write volume against an unrecoverable database.

Both parameters are sighup-reloadable, so this does not restart any cluster.

Context: drive-endurance work on three QLC NVMe OSDs at 81-87% wear.
2026-08-20 14:03:18 +02:00
k.renner f26a98688c Merge pull request 'fix(monitoring): drop apiserver high-cardinality histograms' (#537) from fix-prometheus-cardinality into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#537
2026-08-20 11:28:21 +00:00
claude-botandClaude Bot 3c770f2a8d fix(monitoring): drop apiserver high-cardinality histograms
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Prometheus held 298,236 active series. ~108,000 of them -- over a third -- were
apiserver/etcd `_bucket` histograms. Each fans out to one series per bucket x
verb x resource x scope, so a single metric was 23,810 series on its own. Every
Home Assistant metric combined is ~4,100 for comparison.

That cardinality is the dominant driver of Prometheus write volume (~11 GB/day of
TSDB + WAL, doubled again by Ceph replication) and none of it is observable value
on three nodes: per-bucket apiserver latency quantiles are for clusters with SLAs
and dozens of nodes.

Dropped, all verified to have ZERO rule dependencies by checking each metric
against the live alert and recording rules:

  apiserver_request_duration_seconds_bucket        rules=0   23,810 series
  apiserver_request_body_size_bytes_bucket         rules=0   23,200
  etcd_request_duration_seconds_bucket             rules=0   15,840
  apiserver_watch_list_duration_seconds_bucket     rules=0   11,538
  apiserver_watch_cache_read_wait_seconds_bucket   rules=0    8,232
  apiserver_response_sizes_bucket                  rules=0    7,592
  apiserver_watch_events_sizes_bucket              rules=0    5,220
                                                            -------
                                                             95,432

KEPT deliberately: apiserver_request_sli_duration_seconds_bucket (12,144 series).
23 recording rules depend on it, including the apiserver availability SLO and the
KubeAPIErrorBudgetBurn burn-rate series. Dropping it would silently break that
alerting -- the manifest says so inline, so a later "tidy up" of the regex does
not remove it by accident.

Only `_bucket` series are dropped. The matching `_count` and `_sum` remain, so
request rates and mean latencies still work; what is lost is per-bucket
quantiles.

etcd_* is included even though kubeEtcd is disabled: those are the apiserver's
own etcd-client metrics on the apiserver endpoint, not a separate etcd scrape.

Expected: ~32% fewer active series, so roughly 11 -> 7 GB/day of Prometheus
writes, ~8 GB/day off the drives once replication is counted. Will verify against
prometheus_tsdb_head_series rather than assume.

Refs #434

Co-Authored-By: Claude Bot <noreply@anthropic.com>
2026-08-20 13:25:49 +02:00
k.renner e8ad1f704e Merge pull request 'fix(cnpg-barman): take the sidecar tag out of base64 and track it' (#536) from fix-barman-sidecar-visible into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#536
2026-08-20 10:59:27 +00:00
claude-botandClaude Bot 154c933177 fix(cnpg-barman): take the sidecar tag out of base64 and track it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
An image reference is not a secret. Encoding it as base64 `data` had two real
costs: no reviewer sees a version change in a diff, and no $imagepolicy marker
can attach to a base64 blob.

The result was a two-minor version skew in the component that performs every
base backup and WAL archive for seven Postgres clusters (#535):

  controller  plugin-barman-cloud:v0.14.0          <- auto-bumped by Flux
  sidecar     plugin-barman-cloud-sidecar:v0.12.0  <- invisible, never moved

Not stale pods: ha-pg-1 was created hours ago during the recorder migration and
still received v0.12.0.

Three changes:

  1. The Secret now uses stringData with the tag in plaintext, carrying an
     $imagepolicy marker. The Secret KIND is kept because the controller reads it
     via secretKeyRef; switching to a ConfigMap would need a matching edit to that
     reference and diverge further from upstream. stringData is accepted
     identically by the API server.

  2. ImageRepository + ImagePolicy added for the sidecar image, with the same
     filter and 0.x range as the controller, so the two cannot drift apart again.
     Only the controller was tracked before.

  3. Tag aligned to v0.14.0 to match the controller.

The Secret name carries a kustomize hash suffix from upstream's secretGenerator
but is static here, so editing the value does not change the name and the
deployment's secretKeyRef stays valid — verified in the render, which still shows
the name twice (Secret + reference).

Verified the tag EXISTS before pinning it: pulling
plugin-barman-cloud-sidecar:v0.14.0 succeeds (the pod fails later on a missing
`true` binary in that minimal image, which is the container command, not the
pull). Pinning a non-existent tag would have put all seven Postgres clusters into
ImagePullBackOff.

EXPECTED IMPACT: bumping the sidecar changes every Postgres pod spec, so all
seven CNPG clusters roll. CNPG handles this as a normal rolling restart. Backups
should be confirmed working afterwards — 113 Backup CRs exist today, newest ~3h
old and completed.

Note for future re-imports: upstream ships this Secret as base64 `data` with no
marker, so a wholesale re-import silently reintroduces the skew. The manifest now
says so inline.

Refs #535

Co-Authored-By: Claude Bot <noreply@anthropic.com>
2026-08-20 12:50:31 +02:00
k.renner eafd99faca Merge pull request 'feat(paperless): repoint data volume to RBD (phase 3)' (#534) from feat-paperless-data-repoint into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#534
2026-08-20 05:45:25 +00:00
claude-botandClaude Bot 3a279d2dcd feat(paperless): repoint data volume to RBD (phase 3)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Switches the Deployment's `data` volume from paperless-data (RWX,
ceph-filesystem) to paperless-data-block (RWO, ceph-block). `consume` and `media`
stay on CephFS -- the ftp-scanner writes to those from another node, so they
genuinely need RWX.

The copy is already done and verified byte-for-byte with paperless stopped:

  src files: 75
  dst files: 75
  IDENTICAL: name, size and mtime match for every file
  index segments: src 3, dst 3

mtime parity matters specifically because the Whoosh index segments carry their
own consistency expectations; `cp -a` preserved them. (The "can't preserve" lines
in the copy log were for the mount root /dst/. itself, not the files.)

Why: only this Deployment ever mounted the volume, and its contents are
single-writer state. Being on CephFS meant every access crossed the MDS --
measured with zero CI running, 22 requests/sec sustained and ~15 journal
events/sec against 2 directory commits per 180s, i.e. cap/inode churn rather
than file writes. That accounted for most of the constant 7-8 GB/day on
cephfs-metadata, which is a size 3 pool.

cephfs-metadata stays at size 3 deliberately: losing it destroys the entire
filesystem rather than the objects on one OSD, and the 275 GiB in cephfs-data0
depends on it.

paperless-data is retained (prune: disabled) as the rollback. It must NOT be
deleted until search has been confirmed returning results -- that is what proves
the index survived, not merely that paperless starts.

Refs #434

Co-Authored-By: Claude Bot <noreply@anthropic.com>
2026-08-20 07:44:07 +02:00
k.renner efb552f043 Merge pull request 'feat(paperless): provision RBD volume to move data off CephFS (phase 1)' (#533) from feat-paperless-data-block into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#533
2026-08-20 05:38:43 +00:00
k.renner 1e79bc6d2f Merge pull request 'fix(cnpg): checkpoint tuning across all Postgres clusters' (#532) from fix-cnpg-write-tuning into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#532
2026-08-20 05:21:25 +00:00
claude-botandClaude Opus 5 d2d06d8532 feat(paperless): provision RBD volume to move data off CephFS (phase 1)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
paperless-data is declared ReadWriteMany on ceph-filesystem, but only ONE pod
mounts it -- the paperless Deployment itself. Its contents are all single-writer
state: the 50 MB Whoosh index, classification_model.pickle,
celerybeat-schedule.db, log/, migration_lock. 75 files, nothing shared. Only
`consume` and `media` genuinely need RWX, because the ftp-scanner writes to those
from another node.

It is on CephFS by default, not by requirement, and every access goes through the
MDS. Measured 2026-08-20 with ZERO CI pipelines running:

  paperless-data client   request_load_avg 22   (all other sessions 0-1)
  MDS over 180s           4,052 client requests = 22.5/s
                          2,718 journal events  = 15/s
                              2 dir commits     <- not file writes

Cap and inode churn from repeated access, not data being written. That accounts
for most of the constant 7-8 GB/day on cephfs-metadata, and since that pool is
size 3, roughly 21-24 GB/day reaches the drives.

cephfs-metadata deliberately STAYS at size 3. Losing it destroys the whole
filesystem rather than the objects on one OSD, and the 275 GiB in cephfs-data0
rides on it. Cutting writes at source is the fix; cutting replication is not.

PHASE 1 ONLY, and inert: the PVC is created empty and nothing mounts it. The data
copy and the deployment repoint are separate steps, documented in
MIGRATION-data-to-block.md along with why the copy needs paperless stopped (the
Whoosh index and celerybeat schedule are both live-written, so copying under an
active writer gives a torn read).

Honest sizing: ~21-24 GB/day off the drives against ~432 GB/day cluster-wide, so
about 5%. This is a correctness fix -- a single-writer volume does not belong on
a shared filesystem -- more than a large win. It also removes 22 req/sec of MDS
load, which benefits Woodpecker CI workspaces sharing the same MDS.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 07:18:31 +02:00
claude-botandClaude Opus 5 68c8c86046 fix(cnpg): checkpoint tuning across all Postgres clusters
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Ten Postgres instances across seven CNPG clusters are ~48 GB/day of the
cluster's ~114 GB/day of client writes, and only ha-pg carried any
write-reduction tuning. The rest ran Postgres defaults, which favour throughput
over storage that wears out.

MEASURED, not assumed. ak-pg since 2026-05-17:

  checkpoints_timed   27,624      = 291/day, i.e. one every 5 min (the default)
  buffers_checkpoint  25,801,628  = ~206 GB flushed
  database size       1,046 MB

That is the whole database rewritten roughly twice a day. The pathology is
specific to idle databases: sessions, tokens and job state re-dirty the same few
pages constantly, and checkpoint_timeout=5min forces each of them to disk 288
times a day instead of letting them coalesce. At 15min they are written a third
as often.

max_wal_size is raised alongside it. Without that, checkpoints trigger on size
instead and the timeout change does nothing. It also bounds crash recovery to at
most 2GB of WAL replay.

Applied to ak-pg, pprl-pg, tr-pg, wp-pg, im-pg, gf-pg. ha-pg already had it.

synchronous_commit is deliberately NOT changed here. It is off for ha-pg because
a state recorder is telemetry; these clusters hold credentials, documents and job
state, where losing the last few hundred ms of commits on an unclean shutdown is
not an acceptable trade for write volume.

Separately, ha-pg shared_buffers 256MB -> 512MB. After 15h on Postgres its
buffers_backend was 629,704 of 939,025 total page writes -- 67% written by
backends rather than the checkpointer or bgwriter, which means the working set
does not fit in the buffer pool and pages are written out repeatedly instead of
coalescing. Memory limit raised 1Gi -> 1536Mi to keep the same headroom (measured
383Mi in use, of which 256MB was shared_buffers).

Verified before commit by rendering every cluster and asserting the tuning landed
under spec.postgresql.parameters with the barman plugin block untouched. That
check exists because the first attempt at this matched the wrong `parameters:`
key -- the barman plugin's, at 6-space indent under plugins: -- and corrupted
wp-pg's barmanObjectName. Reverted and redone with an indent-aware match.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 06:55:42 +02:00
k.renner a2239515e9 Merge pull request 'feat(monitoring): alert on CNPG WAL archiving and backup failure' (#523) from fix-cnpg-backup-alerting into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#523
2026-08-20 04:39:20 +00:00
k.renner 0d65fb0982 Merge pull request 'fix: make small scratch emptyDirs RAM-backed instead of node-disk' (#530) from fix-emptydir-tmpfs into master
Reviewed-on: k.renner/homelab#530
2026-08-20 04:38:21 +00:00
k.renner ece3c93ad5 Merge pull request 'fix(trmnl): make the renderer's /tmp RAM-backed (34 GB/day of node writes)' (#529) from fix-trmnl-renderer-tmpfs into master
Reviewed-on: k.renner/homelab#529
2026-08-20 04:33:37 +00:00
claude-botandClaude Opus 5 b4f44b2002 fix(trmnl): make the renderer's /tmp RAM-backed (34 GB/day of node writes)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The renderer was the single largest writer in the cluster -- more than every
Ceph OSD and every database combined -- and none of it needed to touch disk.

Measured 2026-08-20 from the container's own cgroup io.stat:

  253:0 (node /dev/vda5)  249,092,697,088 bytes over 7.3 days  = ~34 GB/day
  252:48 (its own PVC)             340 MB total                = negligible

So the writes were not going to its volume at all; they were going to the
node's filesystem via a disk-backed emptyDir. `emptyDir: {}` without
`medium: Memory` is node storage, which is easy to read as "ephemeral, so
free".

The cause is cadence rather than volume. schedules.template.json renders on
"* * * * *", so Chrome is launched 1440 times a day and each launch creates,
fills and deletes a /tmp/puppeteer_dev_chrome_profile-* directory. At rest /tmp
holds under 8 MB -- it is pure churn for data that never outlives the render.

tmpfs pages count against the container's memory limit, so the 512Mi cap is
kept deliberately: measured usage is 129Mi against a 2Gi limit, leaving ample
headroom even if the cap were fully consumed.

Expected effect: ~34 GB/day off the OS disks, which measured 215 GB/day over a
4h window. This is a bigger cluster-wide reduction than the Home Assistant
recorder migration achieved, for a one-line change.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 06:26:38 +02:00
claude-botandClaude Opus 5 1260165af1 feat(monitoring): alert on CNPG WAL archiving and backup failure
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Seven Postgres clusters back up to Hetzner and nothing alerted if that stopped.
The only related rule was HetznerBackupEndpointDown, which probes the S3
endpoint — it proves the bucket is reachable, not that anything was written to
it. Same failure shape as the WordPress backup that failed nightly for three
days against a bucket declared in tofu but never applied: the status surface
looked fine because nothing checked the thing that mattered.

CNPGWalArchiveBacklog is the load-bearing rule. `ready` counts WAL segments
waiting in pg_wal/archive_status; steady state is 0 on every cluster. A
sustained backlog means the archiver is failing, so point-in-time recovery is
silently broken from that moment and pg_wal grows until it fills the PVC and
takes Postgres down. Threshold >5 sustained for 30m: a segment is 16 MiB, so
that is ~80 MiB of un-archived WAL — well below anything that threatens the
volume, far above idle noise. It reads only the instance-level collector, so it
works despite #522.

CNPGBackupFailed fires on a base backup failure within 24h, guarded on
timestamp > 0 so the epoch-zero default is not read as "failed in 1970".

DELIBERATELY NOT ADDED: the backup-age alert, which is the one you would most
want. `(time() - cnpg_collector_last_available_backup_timestamp) > 48h` cannot
be used today because that metric reads exactly 0 on every instance in the
cluster (verified across gf-pg, ak-pg-1/2, pprl-pg-2/3, im-pg, wp-pg, ha-pg).
CNPG has no record of any successful backup anywhere, because the Backup CRs it
derives that from are deleted within a minute of completing — #522. Adding it
now would fire permanently for all seven clusters and be silenced within a day,
which is worse than not having it.

The backups themselves DO work; verified independently by listing the bucket
with barman-cloud-backup-list and restoring a WAL segment back out of S3. The
gap is in the record, not the data.

Both expressions were validated against live Prometheus before commit rather
than written from the metric names: the WAL selector matches 8 real series (a
zero match would mean a dead rule, the defect fixed in
prometheusrule-woodpecker-rotation.yaml), and neither expression fires
currently, which is correct since archiving is healthy and the only recorded
backup failure is ~91 days old.

Refs #522

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:43:07 +02:00
33 changed files with 947 additions and 158 deletions
+91 -129
View File
@@ -14,6 +14,12 @@
# Flux-deployed CronJobs, so it is tracked separately in #330 rather than
# bundled here. Note this pipeline runs no LLM agent, so the exfil path that
# motivated the other cutovers does not apply to it.
#
# The build steps run the plain kaniko executor, which has no shell: they pass
# flags via `entrypoint` and read credentials from DOCKER_CONFIG. Registry auth
# is written once by the registry-auth step below. Dated tags are literals: bump
# them in the commit that changes an image. The build timestamp itself lives in
# the image at /etc/image-build-date, written by a RUN in the Containerfile.
when:
- event: push
path:
@@ -24,10 +30,22 @@ when:
evaluate: 'PIPELINE == "build-ci-images"'
steps:
- name: build-ansible
image: gcr.io/kaniko-project/executor:v1.23.2-debug
directory: ci/images/ansible
- name: registry-auth
image: alpine:3.24.1
depends_on: []
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p .docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > .docker/config.json
- name: build-ansible
image: gcr.io/kaniko-project/executor:v1.24.0
directory: ci/images/ansible
depends_on: [registry-auth]
# Only rebuild when THIS image's source (or the pipeline file) changed, or
# on a manual run. Without this every step ran on any matching push, so a
# secret-rotator commit needlessly rebuilt all four ~1.3G ci-* images and
@@ -37,68 +55,47 @@ steps:
- event: push
path: ["ci/images/ansible/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/ci-ansible:$TAG \
--destination gitea.renner.uno/gitea_admin/ci-ansible:latest
DOCKER_CONFIG: "${CI_WORKSPACE}/.docker"
entrypoint:
- /kaniko/executor
- --context=.
- --dockerfile=Containerfile
- --destination=gitea.renner.uno/gitea_admin/ci-ansible:2026-08-23
- --destination=gitea.renner.uno/gitea_admin/ci-ansible:latest
- name: build-k8s
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: gcr.io/kaniko-project/executor:v1.24.0
directory: ci/images/k8s
depends_on: []
depends_on: [registry-auth]
when:
- event: manual
- event: push
path: ["ci/images/k8s/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/ci-k8s:$TAG \
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
DOCKER_CONFIG: "${CI_WORKSPACE}/.docker"
entrypoint:
- /kaniko/executor
- --context=.
- --dockerfile=Containerfile
- --destination=gitea.renner.uno/gitea_admin/ci-k8s:2026-08-23
- --destination=gitea.renner.uno/gitea_admin/ci-k8s:latest
- name: build-tofu
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: gcr.io/kaniko-project/executor:v1.24.0
directory: ci/images/tofu
depends_on: []
depends_on: [registry-auth]
when:
- event: manual
- event: push
path: ["ci/images/tofu/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/ci-tofu:$TAG \
--destination gitea.renner.uno/gitea_admin/ci-tofu:latest
DOCKER_CONFIG: "${CI_WORKSPACE}/.docker"
entrypoint:
- /kaniko/executor
- --context=.
- --dockerfile=Containerfile
- --destination=gitea.renner.uno/gitea_admin/ci-tofu:2026-08-23
- --destination=gitea.renner.uno/gitea_admin/ci-tofu:latest
# NOTE: ci-claude is deliberately NOT built here. It tracks upstream Claude
# Code releases on its own cadence and is built + version-tagged by
@@ -108,28 +105,21 @@ steps:
# Runtime image, not CI tooling: a static musl binary in a FROM-scratch image,
# used as the base for secret-rotation CronJobs. Replaces per-job inline Python.
- name: build-secret-rotator
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: gcr.io/kaniko-project/executor:v1.24.0
directory: secret-rotator
depends_on: []
depends_on: [registry-auth]
when:
- event: manual
- event: push
path: ["secret-rotator/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/secret-rotator:$TAG \
--destination gitea.renner.uno/gitea_admin/secret-rotator:latest
DOCKER_CONFIG: "${CI_WORKSPACE}/.docker"
entrypoint:
- /kaniko/executor
- --context=.
- --dockerfile=Containerfile
- --destination=gitea.renner.uno/gitea_admin/secret-rotator:2026-08-23
- --destination=gitea.renner.uno/gitea_admin/secret-rotator:latest
# Runtime image, not CI tooling: a general-purpose alpine + jq base for
# workloads that need to read JSON from a shell script. Named for what it IS,
@@ -137,28 +127,21 @@ steps:
# container, but nothing about it is zigbee-specific. Exists so tools come from
# the image rather than an `apk add` on every container start.
- name: build-alpine-jq
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: gcr.io/kaniko-project/executor:v1.24.0
directory: ci/images/alpine-jq
depends_on: []
depends_on: [registry-auth]
when:
- event: manual
- event: push
path: ["ci/images/alpine-jq/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/alpine-jq:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-jq:latest
DOCKER_CONFIG: "${CI_WORKSPACE}/.docker"
entrypoint:
- /kaniko/executor
- --context=.
- --dockerfile=Containerfile
- --destination=gitea.renner.uno/gitea_admin/alpine-jq:2026-08-23
- --destination=gitea.renner.uno/gitea_admin/alpine-jq:latest
# Runtime image, not CI tooling: upstream containernetworking/plugins binaries
# baked in, consumed by the multus cni-plugins-installer DaemonSet. Replaces a
@@ -166,28 +149,21 @@ steps:
# critical path of every node rebuild. The download now happens here, once,
# where the sha256 is checked — see the Containerfile for the full rationale.
- name: build-cni-plugins
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: gcr.io/kaniko-project/executor:v1.24.0
directory: ci/images/cni-plugins
depends_on: []
depends_on: [registry-auth]
when:
- event: manual
- event: push
path: ["ci/images/cni-plugins/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/cni-plugins:$TAG \
--destination gitea.renner.uno/gitea_admin/cni-plugins:latest
DOCKER_CONFIG: "${CI_WORKSPACE}/.docker"
entrypoint:
- /kaniko/executor
- --context=.
- --dockerfile=Containerfile
- --destination=gitea.renner.uno/gitea_admin/cni-plugins:2026-08-23
- --destination=gitea.renner.uno/gitea_admin/cni-plugins:latest
# Runtime image, not CI tooling: alpine + the openssl CLI, for shell scripts
# needing s_client/x509. Named for what it is; first consumer is the paperless
@@ -195,28 +171,21 @@ steps:
# every paperless pod start. The openssl CLI is genuinely absent from the alpine
# base, so this one needs an image rather than a busybox substitute.
- name: build-alpine-openssl
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: gcr.io/kaniko-project/executor:v1.24.0
directory: ci/images/alpine-openssl
depends_on: []
depends_on: [registry-auth]
when:
- event: manual
- event: push
path: ["ci/images/alpine-openssl/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest
DOCKER_CONFIG: "${CI_WORKSPACE}/.docker"
entrypoint:
- /kaniko/executor
- --context=.
- --dockerfile=Containerfile
- --destination=gitea.renner.uno/gitea_admin/alpine-openssl:2026-08-23
- --destination=gitea.renner.uno/gitea_admin/alpine-openssl:latest
# Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image
# run as two services on edge-1. Replaces pip-into-venv on the host, where an
@@ -224,25 +193,18 @@ steps:
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
# builds the same way and lands in the same registry so the audit sees it.
- name: build-heating-can
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: gcr.io/kaniko-project/executor:v1.24.0
directory: ci/images/heating-can
depends_on: []
depends_on: [registry-auth]
when:
- event: manual
- event: push
path: ["ci/images/heating-can/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/heating-can:$TAG \
--destination gitea.renner.uno/gitea_admin/heating-can:latest
DOCKER_CONFIG: "${CI_WORKSPACE}/.docker"
entrypoint:
- /kaniko/executor
- --context=.
- --dockerfile=Containerfile
- --destination=gitea.renner.uno/gitea_admin/heating-can:2026-08-23
- --destination=gitea.renner.uno/gitea_admin/heating-can:latest
+5
View File
@@ -22,3 +22,8 @@ FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d650
# unanchored extraction therefore yields a device link key, and herdsman then
# forms a NEW Zigbee network and drops every device.
RUN apk add --no-cache jq
# Build timestamp, recorded in the image rather than the tag: the tag says which
# source revision was built, this says when. Last layer, so it only invalidates
# itself.
RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date
+5
View File
@@ -19,3 +19,8 @@ FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d650
# for paperless to trust. busybox has no s_client equivalent, so openssl is not
# substitutable there.
RUN apk add --no-cache openssl
# Build timestamp, recorded in the image rather than the tag: the tag says which
# source revision was built, this says when. Last layer, so it only invalidates
# itself.
RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date
+5
View File
@@ -24,3 +24,8 @@ RUN pip3 install --break-system-packages --no-cache-dir \
RUN ansible-galaxy collection install \
community.proxmox \
community.docker
# Build timestamp, recorded in the image rather than the tag: the tag says which
# source revision was built, this says when. Last layer, so it only invalidates
# itself.
RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date
+6
View File
@@ -44,3 +44,9 @@ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
/usr \
/root \
/tmp
# Build timestamp, recorded in the image rather than the tag: the tag says which
# version was built, this says when. Deliberately the LAST layer and its own
# layer, so the byte-stable npm layer above still dedups in the registry — that
# dedup is what #351 was for, and a few dozen bytes here does not affect it.
RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date
+5
View File
@@ -66,3 +66,8 @@ FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d650
# Alpine rather than scratch: the installer needs a shell to copy the binaries
# onto the host. busybox covers cp/mv/chown/ls, so nothing is added.
COPY --from=fetch /plugins/ /opt/cni-plugins/
# Build timestamp, recorded in the image rather than the tag: the tag says which
# source revision was built, this says when. Last layer, so it only invalidates
# itself.
RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date
+5
View File
@@ -120,3 +120,8 @@ USER open3e
WORKDIR /var/lib/open3e
ENTRYPOINT ["supervisord", "-c", "/etc/supervisord.conf"]
# Build timestamp, recorded in the image rather than the tag: the tag says which
# source revision was built, this says when. Last layer, so it only invalidates
# itself.
RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date
+5
View File
@@ -42,3 +42,8 @@ RUN curl -sfL -o /tmp/velero.tar.gz \
&& tar -xz -C /tmp -f /tmp/velero.tar.gz \
&& install "/tmp/velero-${VELERO_VERSION}-linux-amd64/velero" /usr/local/bin/velero \
&& rm -rf "/tmp/velero-${VELERO_VERSION}-linux-amd64" /tmp/velero.tar.gz
# Build timestamp, recorded in the image rather than the tag: the tag says which
# source revision was built, this says when. Last layer, so it only invalidates
# itself.
RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date
+5
View File
@@ -31,3 +31,8 @@ RUN pip3 install --break-system-packages --no-cache-dir \
RUN ansible-galaxy collection install \
community.proxmox \
community.docker
# Build timestamp, recorded in the image rather than the tag: the tag says which
# source revision was built, this says when. Last layer, so it only invalidates
# itself.
RUN date -u +%Y-%m-%dT%H:%M:%SZ > /etc/image-build-date
+38
View File
@@ -43,6 +43,44 @@ spec:
parameters:
shared_buffers: "256MB"
max_connections: "400"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
#
# checkpoint_timeout is the lever. The 5min default pushes every dirty
# page to disk 288 times a day, so a small database whose same few pages
# are re-dirtied constantly (sessions, tokens, job state) gets rewritten
# over and over. Measured on ak-pg: 25.8M checkpoint buffers flushed
# (~206 GB) against a 1046 MB database -- the whole thing roughly twice a
# day. At 15min those pages coalesce and are written a third as often.
#
# max_wal_size must rise with it, or checkpoints trigger on size instead
# and the timeout change does nothing. It also bounds crash recovery to
# at most 2GB of WAL replay.
#
# NOT changed: synchronous_commit stays at the default `on`. It is off for
# ha-pg because a state recorder is telemetry; these hold credentials,
# documents and job state, where losing the last few hundred ms of commits
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
wal_compression: "zstd"
# A WAL segment is a fixed 16 MiB file, and archive_timeout forces a switch
# on a timer -- the segment is then written and archived at its full size
# no matter how little real WAL it holds. CNPG defaults to 5min, i.e. up
# to 288 segments/day = 3.2 GB/day here, most of it padding.
#
# 15min, NOT the 30min used elsewhere in this rollout. This cluster holds
# credentials, sessions and enrolment state, so its RPO is worth more than
# the difference: 30min would save a further 0.3 GB/day, which does not
# justify doubling the worst-case loss window on this data.
#
# It does carry a synchronous standby (instances: 2), so the realistic
# exposure is losing both instances, not one -- but the conservative value
# is cheap here and the aggressive one buys almost nothing.
archive_timeout: "15min"
# Async replication makes CNPG switchover slow because the standby has
# to chase missing WAL via barman-cloud and replay queued records
# before it can promote. With one synchronous standby, the standby is
+46
View File
@@ -29,6 +29,52 @@ spec:
parameters:
shared_buffers: "128MB"
max_connections: "50"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
#
# checkpoint_timeout is the lever. The 5min default pushes every dirty
# page to disk 288 times a day, so a small database whose same few pages
# are re-dirtied constantly (sessions, tokens, job state) gets rewritten
# over and over. Measured on ak-pg: 25.8M checkpoint buffers flushed
# (~206 GB) against a 1046 MB database -- the whole thing roughly twice a
# day. At 15min those pages coalesce and are written a third as often.
#
# max_wal_size must rise with it, or checkpoints trigger on size instead
# and the timeout change does nothing. It also bounds crash recovery to
# at most 2GB of WAL replay.
#
# NOT changed: synchronous_commit stays at the default `on`. It is off for
# ha-pg because a state recorder is telemetry; these hold credentials,
# documents and job state, where losing the last few hundred ms of commits
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
wal_compression: "zstd"
# THE dominant write source on this cluster, by three orders of magnitude.
#
# A WAL segment is a fixed 16 MiB file. archive_timeout forces a segment
# switch on a timer so the archive never lags reality by more than that
# interval -- and the switched-out segment is written and archived at its
# full 16 MiB regardless of how few bytes of real WAL it contains.
#
# CNPG defaults this to 5min, i.e. up to 288 segments/day = 4.6 GB/day.
# Measured here: this database is 21 MB and generates 4.5 MB/day of actual
# WAL, while its RBD volume takes 211 MB/hour. Essentially every byte
# written is padding -- roughly 1100x amplification.
#
# 30min cuts that to 48 segments/day. The cost is RPO: on total loss of
# the cluster, up to 30min of Grafana state is unrecoverable rather than
# 5min. Grafana's state here is dashboards, annotations and preferences,
# and the write rate says it changes ~4.5 MB/day -- so that window is
# nearly always empty anyway.
#
# Applied to gf-pg FIRST, alone, as a staged rollout: it is the lowest-risk
# cluster and has the cleanest signal, since its real WAL is small enough
# that any change in volume is unambiguously this parameter.
archive_timeout: "30min"
resources:
requests:
cpu: 100m
@@ -55,7 +55,21 @@ spec:
owner: homeassistant
postgresql:
parameters:
shared_buffers: "256MB"
# 512MB, not the 256MB this shipped with. Measured after 15h on Postgres:
# buffers_backend was 629,704 of 939,025 total page writes — 67% written
# by backend processes rather than the checkpointer or bgwriter. That
# ratio means the working set does not fit in shared_buffers, so backends
# evict dirty pages themselves and the same pages are written out
# repeatedly instead of coalescing in memory.
#
# The recorder touches a wide set of pages continuously (~30 state
# changes/sec across states, state_attributes and five indexes), which is
# exactly the workload that punishes a small buffer pool.
#
# Memory headroom checked before raising: the instance was using 383Mi of
# a 1Gi limit with 256MB of that being shared_buffers, so the limit goes
# to 1.5Gi to keep the same margin.
shared_buffers: "512MB"
max_connections: "50"
# The rest of this block is tuned to MINIMISE WRITE VOLUME, which is the
# entire reason for the migration. Defaults would give a correct database
@@ -71,12 +85,70 @@ spec:
# Compress full-page images in WAL. Recorder writes are many small row
# inserts scattered across pages, so full-page writes after each
# checkpoint dominate WAL volume; compression cuts that directly.
wal_compression: "on"
#
# Full-page images really are ~62% of WAL here: 61.9% measured as a
# steady-state delta over one checkpoint cycle, against 62.4% lifetime.
#
# But switching pglz -> zstd changed NOTHING. Measured by
# pg_current_wal_lsn advance over 20min windows: 4911 and 4976 MB/day
# before, 5002 MB/day after.
#
# The reason is that `wal_compression: "on"` was never "no compression" --
# it is pglz, which was already compressing these page images. zstd only
# changes the ratio, and on recorder pages that margin is too small to
# measure. The original framing of this change (that the clusters "were
# not getting compression") was wrong; they were.
#
# Note the 62% figure is an upper bound: it is computed as
# wal_fpi * 8192 / wal_bytes, which assumes uncompressed 8 KiB pages. With
# compression active the true byte share is lower, which is consistent
# with there being little headroom left for a better algorithm.
#
# Kept because zstd is not worse than pglz and costs nothing. It is simply
# not a lever. The real one on this cluster was archive_timeout, below.
#
# zstd, not "on": the boolean spelling resolves to pglz, the original
# 2016-era algorithm, which is both slower and weaker than the two
# alternatives PostgreSQL 16 accepts here (lz4, zstd). zstd gives the
# best ratio on 8 KiB page images, and the CPU cost lands on a cluster
# that is IO-bound on QLC NVMe, not CPU-bound.
wal_compression: "zstd"
# A WAL segment is a fixed 16 MiB file, and archive_timeout forces a switch
# on a timer -- the segment is then written and archived at its full size
# no matter how little real WAL it holds. CNPG defaults to 5min, i.e. up
# to 288 segments/day = 4.6 GB/day that is largely padding.
#
# Verified on gf-pg first (the staged rollout): 211 -> 41 MB/hour, an 81%
# cut, with archived segments falling from ~12/hour to ~1.5/hour in step.
# Two independent signals moving together is what confirms the volume was
# padding rather than data.
#
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
# its volume are lost outright. This is a single-instance cluster, so the
# archive is the only off-node copy.
archive_timeout: "30min"
# Fewer checkpoints => fewer full-page writes, since a page is only
# fully-logged on its first modification after a checkpoint. 15min with a
# 2GB ceiling keeps recovery time sane while cutting repeat full-page
# images for the hot rows the recorder rewrites constantly.
checkpoint_timeout: "15min"
# fully-logged on its first modification after a checkpoint.
#
# That reasoning did not show up in WAL volume: 15min -> 30min moved the
# WAL rate not at all (4911/4976 -> 5002 MB/day). The likely reason is
# that this workload is append-mostly -- a page is dirtied while it fills
# and then not touched again, so its full-page image is paid once
# regardless of how often checkpoints run. Interval only helps pages that
# are re-dirtied across many cycles, which is not what a recorder does.
#
# Kept, but NOT because WAL improved. checkpoint_timeout also governs
# data-file writes at checkpoint, and those are unmeasured here: this
# volume takes ~11 GB/day against 4.9 GB/day of WAL, so most of its
# traffic is outside what the WAL measurement covers. Revisit with a
# data-file measurement before treating this value as justified.
#
# Recovery cost is bounded by how much WAL a single interval produces,
# not by the interval itself: at the measured rate 30min is well under
# 200MB of replay, i.e. seconds. max_wal_size stays 2GB precisely so the
# timeout stays the binding trigger -- if size were the binding one, the
# interval change would do nothing.
checkpoint_timeout: "30min"
max_wal_size: "2GB"
min_wal_size: "512MB"
# full_page_writes is deliberately LEFT ON (the default). RBD can tear a
@@ -87,4 +159,4 @@ spec:
cpu: 200m
memory: 512Mi
limits:
memory: 1Gi
memory: 1536Mi
+37 -1
View File
@@ -42,7 +42,43 @@ spec:
parameters:
shared_buffers: "512MB"
max_wal_size: "2GB"
wal_compression: "on"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
wal_compression: "zstd"
# A WAL segment is a fixed 16 MiB file, and archive_timeout forces a switch
# on a timer -- the segment is then written and archived at its full size
# no matter how little real WAL it holds. CNPG defaults to 5min, i.e. up
# to 288 segments/day = 4.6 GB/day that is largely padding.
#
# Verified on gf-pg first (the staged rollout): 211 -> 41 MB/hour, an 81%
# cut, with archived segments falling from ~12/hour to ~1.5/hour in step.
# Two independent signals moving together is what confirms the volume was
# padding rather than data.
#
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
# its volume are lost outright. This is a single-instance cluster, so the
# archive is the only off-node copy.
archive_timeout: "30min"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
#
# checkpoint_timeout is the lever. The 5min default pushes every dirty
# page to disk 288 times a day, so a small database whose same few pages
# are re-dirtied constantly (sessions, tokens, job state) gets rewritten
# over and over. Measured on ak-pg: 25.8M checkpoint buffers flushed
# (~206 GB) against a 1046 MB database -- the whole thing roughly twice a
# day. At 15min those pages coalesce and are written a third as often.
#
# max_wal_size must rise with it, or checkpoints trigger on size instead
# and the timeout change does nothing. It also bounds crash recovery to
# at most 2GB of WAL replay.
#
# NOT changed: synchronous_commit stays at the default `on`. It is off for
# ha-pg because a state recorder is telemetry; these hold credentials,
# documents and job state, where losing the last few hundred ms of commits
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
resources:
requests:
cpu: 500m
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
# Pinned to 7.4-alpine to match the LXC 225 compose exactly.
# Any redis major-version bump is a separate migration.
- name: redis
image: redis:7.4.10-alpine # {"$imagepolicy": "flux-system:redis-immich"}
image: redis:7.4.11-alpine # {"$imagepolicy": "flux-system:redis-immich"}
args:
- redis-server
- --save
@@ -13,6 +13,10 @@ resources:
- prometheusrule-netbird-ingress.yaml
- prometheusrule-velero.yaml
- prometheusrule-woodpecker-rotation.yaml
# CNPG backup health. Seven Postgres clusters ship to Hetzner and nothing
# alerted if that stopped — HetznerBackupEndpointDown only probes the S3
# endpoint, which proves reachability, not that anything was written.
- prometheusrule-cnpg-backup.yaml
- alloy-servicemonitor.yaml
- cnpg-podmonitors.yaml
- home-assistant-servicemonitor.yaml
@@ -12,18 +12,43 @@ spec:
- name: ceph.rules
rules:
- alert: CephHealthWarn
# ceph_health_status: 0 = HEALTH_OK, 1 = HEALTH_WARN, 2 = HEALTH_ERR.
# 6h is deliberately long. Ceph warns for self-clearing conditions —
# a slow BlueStore op during the Sunday restore sweep, a mon briefly
# below its disk threshold — and paging on those trains people to
# ignore the alert. What matters is a warning that does not clear.
expr: ceph_health_status == 1
# Per-check rather than ceph_health_status, so one permanently-active
# check cannot mask every other Ceph warning.
#
# AUTH_INSECURE_* is excluded: it cannot clear until the nodes run a
# kernel supporting aes256k over the CSI mount path (#527).
#
# POOL_NO_REDUNDANCY is in scope. An unreplicated pool makes
# `ceph osd ok-to-stop` refuse for every OSD, so it reports blocked
# maintenance and not just data risk — do not exclude it for a pool
# that is unreplicated deliberately.
#
# 6h: Ceph warns for self-clearing conditions. Only a warning that
# does not clear is worth paging on.
expr: ceph_health_detail{name!~"AUTH_INSECURE_.+"} == 1
for: 6h
labels:
severity: warning
annotations:
summary: "Ceph has been HEALTH_WARN for 6h"
description: "The Ceph cluster has held HEALTH_WARN for six hours, so it is not a self-clearing blip. Get the detail with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph health detail`."
summary: "Ceph health check {{ $labels.name }} active for 6h"
description: "Ceph health check {{ $labels.name }} has been active for six hours, so it is not a self-clearing blip. Only AUTH_INSECURE_* is excluded (kernel-blocked, see #527), so this is a new condition. Detail: `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph health detail`."
- alert: CephPoolLostRedundancy
# Names the pool, which POOL_NO_REDUNDANCY does not.
#
# There is no pool-size metric, but bytes_used/stored IS the
# replication factor. The >1e6 floor drops pools too small to measure
# meaningfully, empty ones included.
#
# No pool is exempt.
expr: |
ceph_pool_bytes_used / ceph_pool_stored < 1.5 and ceph_pool_stored > 1e6
for: 30m
labels:
severity: critical
annotations:
summary: "Ceph pool {{ $labels.pool_id }} is running unreplicated"
description: "Pool {{ $labels.pool_id }} has a replication factor below 1.5, meaning a single OSD loss destroys data. No pool is meant to be unreplicated — an unreplicated pool also blocks ALL OSD maintenance cluster-wide, since `ceph osd ok-to-stop` refuses while its PGs would go offline. Check with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail`."
- alert: CephHealthError
# HEALTH_ERR means data availability or redundancy is at risk.
@@ -0,0 +1,78 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cnpg-backup
namespace: monitoring
labels:
# kube-prometheus-stack's Prometheus resource selects on this label;
# without it the rule is created but never loaded into Prometheus.
release: kube-prometheus-stack
spec:
groups:
- name: cnpg-backup.rules
rules:
# WHY THIS FILE EXISTS
# Seven Postgres clusters back up to Hetzner and NOTHING alerted if that
# stopped. The only related rule was HetznerBackupEndpointDown, which
# probes the S3 endpoint — it proves the bucket is reachable, not that
# anything was written to it. Same failure shape as the WordPress backup
# that failed nightly for three days against a bucket that was declared
# in tofu but never applied: the status surface looked fine because
# nothing checked the thing that mattered.
- alert: CNPGWalArchiveBacklog
# `ready` counts WAL segments sitting in pg_wal/archive_status waiting
# to be archived. Steady state is 0 across every cluster (verified
# 2026-08-19). A sustained backlog means the archiver is failing:
# point-in-time recovery is silently broken from that moment, and
# pg_wal keeps growing until it fills the PVC and takes Postgres down.
#
# This is the load-bearing alert of the two. It depends only on the
# instance-level collector, so unlike the backup-age metrics below it
# is unaffected by the missing Backup CRs (#522).
#
# Threshold: >5 segments sustained for 30m. Brief non-zero values are
# normal under write bursts — a segment is 16 MiB, so 5 is roughly
# 80 MiB of un-archived WAL, well below anything that threatens the
# volume but far above idle noise.
expr: cnpg_collector_pg_wal_archive_status{value="ready"} > 5
for: 30m
labels:
severity: warning
annotations:
summary: "CNPG WAL archiving is falling behind on {{ $labels.namespace }}/{{ $labels.pod }}"
description: "{{ $value }} WAL segments have been waiting to archive for over 30 minutes. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n {{ $labels.namespace }} logs {{ $labels.pod }} -c plugin-barman-cloud`."
- alert: CNPGBackupFailed
# cnpg_collector_last_failed_backup_timestamp is a unix timestamp, 0
# when no failure is recorded. Guard on > 0 so the epoch-zero default
# is not read as "failed in 1970".
expr: |
cnpg_collector_last_failed_backup_timestamp > 0
and
(time() - cnpg_collector_last_failed_backup_timestamp) < 86400
for: 5m
labels:
severity: warning
annotations:
summary: "CNPG backup failed in the last 24h on {{ $labels.namespace }}/{{ $labels.pod }}"
description: "A base backup for {{ $labels.namespace }}/{{ $labels.pod }} failed within the last 24 hours. Verify what is actually in the bucket rather than trusting CR status — see the barman-cloud-backup-list recipe in kubernetes/apps/home-assistant/MIGRATION-recorder-postgres.md."
# DELIBERATELY ABSENT: a backup-age alert, which is the one you would
# most want here.
#
# The natural expression is
# (time() - cnpg_collector_last_available_backup_timestamp) > 48*3600
# and it CANNOT be used today: that metric reads exactly 0 on every
# instance in the cluster (verified 2026-08-19 across gf-pg, ak-pg-1/2,
# pprl-pg-2/3, im-pg, wp-pg, ha-pg). CNPG has no record of any
# successful backup anywhere, because the Backup CRs it derives that
# from are being deleted within a minute of completing — see #522.
#
# Backups themselves DO work; that was verified independently by
# listing the bucket with barman-cloud-backup-list and restoring a WAL
# segment back out of S3. The gap is in the record, not the data.
#
# Adding the rule now would fire permanently for all seven clusters and
# be silenced within a day, which is worse than not having it. Add it as
# part of resolving #522, once the metric reflects reality.
@@ -402,6 +402,52 @@ spec:
limits:
memory: 256Mi
# Drop the apiserver's high-cardinality latency histograms.
#
# Measured 2026-08-20: Prometheus held 298,236 active series, of which
# ~108,000 — over a third — were apiserver/etcd `_bucket` histograms. Each
# histogram fans out to one series per bucket x verb x resource x scope, so
# a single metric like apiserver_request_duration_seconds_bucket was 23,810
# series on its own. For comparison, every Home Assistant metric combined is
# ~4,100.
#
# That cardinality is the dominant driver of Prometheus write volume
# (~11 GB/day of TSDB + WAL, doubled again by Ceph replication), and none of
# it is observable value here: per-bucket apiserver latency quantiles are for
# clusters with SLAs and dozens of nodes, not three.
#
# ONLY metrics with ZERO rule dependencies are dropped. Verified against the
# live rule set before writing this — each metric checked individually for
# references in alert and recording rules:
#
# apiserver_request_duration_seconds_bucket rules=0 23,810 series
# apiserver_request_body_size_bytes_bucket rules=0 23,200
# etcd_request_duration_seconds_bucket rules=0 15,840
# apiserver_watch_list_duration_seconds_bucket rules=0 11,538
# apiserver_watch_cache_read_wait_seconds_bucket rules=0 8,232
# apiserver_response_sizes_bucket rules=0 7,592
# apiserver_watch_events_sizes_bucket rules=0 5,220
#
# apiserver_request_sli_duration_seconds_bucket is DELIBERATELY KEPT: 23
# recording rules depend on it, including the apiserver availability SLO and
# the KubeAPIErrorBudgetBurn burn-rate series. Dropping it would silently
# break that alerting, which is the failure mode this comment exists to
# prevent if someone later "tidies up" this regex.
#
# Only the `_bucket` series are dropped. The matching `_count` and `_sum`
# remain, so request rates and mean latencies still work — what is lost is
# per-bucket quantiles.
#
# etcd_* appears here even though kubeEtcd is disabled below: these are the
# apiserver's own etcd-client metrics, exposed on the apiserver endpoint, not
# a separate etcd scrape.
kubeApiServer:
serviceMonitor:
metricRelabelings:
- action: drop
sourceLabels: [__name__]
regex: (apiserver_request_duration_seconds_bucket|apiserver_request_body_size_bytes_bucket|etcd_request_duration_seconds_bucket|apiserver_watch_list_duration_seconds_bucket|apiserver_watch_cache_read_wait_seconds_bucket|apiserver_response_sizes_bucket|apiserver_watch_events_sizes_bucket)
# Disable etcd/scheduler/controller-manager scraping (Talos doesn't expose them by default)
kubeEtcd:
enabled: false
@@ -0,0 +1,97 @@
# paperless-data: CephFS → RBD
## Why
`paperless-data` is `ReadWriteMany` on `ceph-filesystem`, but **only the paperless
Deployment mounts it**. Verified 2026-08-20:
```
$ pods mounting paperless-data
paperless-68cb8b8744-7hjb8 <- one, and only one
paperless-data ReadWriteMany ceph-filesystem
paperless-media ReadWriteMany ceph-filesystem <- genuinely shared (ftp-scanner)
paperless-consume ReadWriteMany ceph-filesystem <- genuinely shared (ftp-scanner)
```
Its contents are single-writer state — the 50 MB Whoosh index,
`classification_model.pickle`, `celerybeat-schedule.db`, `log/`,
`migration_lock`, `protonmail.pem`. 75 files. Nothing else reads or writes them.
It is on CephFS by default, not by requirement.
## What it costs
Every access goes through the MDS. Measured with **zero CI pipelines running**:
```
paperless-data client request_load_avg 22 (all other sessions 0-1)
MDS over 180s 4,052 client requests = 22.5/s
2,718 journal events = 15/s
2 dir commits <- almost none: not file writes
```
So it is cap/inode churn from repeated access, not data being written. That is
most of the constant **7-8 GB/day** written to `cephfs-metadata`, and since that
pool is `size: 3`, roughly **21-24 GB/day** reaches the drives.
`cephfs-metadata` deliberately stays at size 3. Losing it destroys the entire
filesystem rather than the objects on one OSD, and the 275 GiB in `cephfs-data0`
depends on it. Cutting the writes at source is correct; cutting the replication
is not.
## Sequence
Staged deliberately: the PVC lands first and empty, so merging it changes
nothing. The copy and the repoint are separate.
### Phase 1 — provision (this PR)
`paperless-data-block` (RWO, `ceph-block`, 10Gi) is created and mounted by
nothing. Inert.
Verify: `kubectl -n paperless get pvc paperless-data-block` → Bound.
### Phase 2 — copy
The Whoosh index must come across intact. Paperless *can* rebuild it
(`document_index reindex`), but that reprocesses every document, so copying is
both faster and less disruptive.
1. Scale paperless to 0. The source must be quiescent — `celerybeat-schedule.db`
and the Whoosh index are both live-written, and copying them under an active
writer gives a torn read.
2. Run a one-off pod that mounts BOTH PVCs and copies:
```
cp -a /src/. /dst/
```
The RWO target pins the pod to one node; the RWX source will follow.
3. Verify file count and index size match:
```
find /src -type f | wc -l == find /dst -type f | wc -l
du -sh /src/index == du -sh /dst/index
```
### Phase 3 — repoint
Change the Deployment's `data` volume to `claimName: paperless-data-block`,
merge, let it roll.
Verify afterwards:
- paperless starts, search returns results (proves the Whoosh index survived)
- `ceph tell mds.cephfs-b session ls` no longer shows a busy `paperless-data`
client
- `rate(ceph_pool_wr_bytes{pool_id="3"})` drops from its 7-8 GB/day baseline
**Do not delete `paperless-data` until search has been confirmed working.** It is
the rollback, and it is `prune: disabled` so Flux will not remove it.
## Expected effect
~7-8 GB/day off `cephfs-metadata`, so ~21-24 GB/day off the drives once
replication is counted. Against ~432 GB/day cluster-wide that is roughly 5% —
worth doing, but it is a correctness fix (a single-writer volume does not belong
on a shared filesystem) more than a large win.
It also removes 22 req/sec of MDS load, which benefits the workloads that
genuinely need CephFS — Woodpecker CI workspaces share the same MDS.
+36
View File
@@ -34,6 +34,42 @@ spec:
parameters:
shared_buffers: "256MB"
max_connections: "200"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
#
# checkpoint_timeout is the lever. The 5min default pushes every dirty
# page to disk 288 times a day, so a small database whose same few pages
# are re-dirtied constantly (sessions, tokens, job state) gets rewritten
# over and over. Measured on ak-pg: 25.8M checkpoint buffers flushed
# (~206 GB) against a 1046 MB database -- the whole thing roughly twice a
# day. At 15min those pages coalesce and are written a third as often.
#
# max_wal_size must rise with it, or checkpoints trigger on size instead
# and the timeout change does nothing. It also bounds crash recovery to
# at most 2GB of WAL replay.
#
# NOT changed: synchronous_commit stays at the default `on`. It is off for
# ha-pg because a state recorder is telemetry; these hold credentials,
# documents and job state, where losing the last few hundred ms of commits
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
wal_compression: "zstd"
# A WAL segment is a fixed 16 MiB file, and archive_timeout forces a switch
# on a timer -- the segment is then written and archived at its full size
# no matter how little real WAL it holds. CNPG defaults to 5min, i.e. up
# to 288 segments/day = 4.1 GB/day that is largely padding.
#
# Verified on gf-pg first (the staged rollout): 211 -> 41 MB/hour, an 81%
# cut, with archived segments falling from ~12/hour to ~1.5/hour in step.
#
# The cost is RPO: up to 30min of WAL is unrecoverable if both instances
# are lost. The documents themselves live on the media PVC, not in
# Postgres -- this database holds their metadata.
archive_timeout: "30min"
# See ak-pg cluster.yaml for the rationale on synchronous replication.
synchronous:
method: any
+17 -1
View File
@@ -208,9 +208,25 @@ spec:
seccompProfile:
type: RuntimeDefault
volumes:
# paperless-data-block (RWO, ceph-block), NOT paperless-data
# (RWX, ceph-filesystem). Only this Deployment ever mounted the volume,
# and its contents are single-writer state — the Whoosh index,
# classification model, celerybeat schedule, logs. Being on CephFS meant
# every access went through the MDS: 22 requests/sec sustained with zero
# CI running, ~15 journal events/sec against 2 directory commits per
# 180s, i.e. cap/inode churn rather than file writes. That was most of
# the constant 7-8 GB/day on cephfs-metadata, and that pool is size 3.
#
# `consume` and `media` below STAY on CephFS: the ftp-scanner writes to
# those from another node, so they genuinely need RWX.
#
# paperless-data is retained (prune: disabled) as the rollback. Do not
# delete it until search has been confirmed working — that is what
# proves the Whoosh index survived the copy, not merely that paperless
# starts. Migration record: MIGRATION-data-to-block.md.
- name: data
persistentVolumeClaim:
claimName: paperless-data
claimName: paperless-data-block
- name: media
persistentVolumeClaim:
claimName: paperless-media
+39
View File
@@ -18,6 +18,45 @@ spec:
requests:
storage: 10Gi
---
# Replacement for paperless-data, on RBD instead of CephFS.
#
# WHY: paperless-data is declared ReadWriteMany on ceph-filesystem, but only ONE
# pod ever mounts it — the paperless Deployment itself. Its contents are all
# single-writer state: the 50 MB Whoosh index, classification_model.pickle,
# celerybeat-schedule.db, logs, migration_lock. Nothing shares it. Only `consume`
# and `media` genuinely need RWX, because the FTP scanner writes to those from a
# different node.
#
# COST OF LEAVING IT: every access goes through the MDS. Measured 2026-08-20 with
# zero CI running, this volume's client was doing 22 MDS requests/sec sustained,
# and the MDS logged ~15 journal events/sec against only 2 directory commits per
# 180s — i.e. cap and inode churn from repeated access, not file writes. That is
# most of the constant 7-8 GB/day written to cephfs-metadata, which is a size 3
# pool, so ~21-24 GB/day actually hits the drives.
#
# cephfs-metadata deliberately STAYS at size 3 (see storageclass.yaml): losing
# the metadata pool destroys the whole filesystem, not just the objects on one
# OSD, so the 275 GiB in cephfs-data0 rides on it. Reducing the writes at source
# is the right fix; reducing the replication is not.
#
# MIGRATION: this PVC is created EMPTY and nothing mounts it yet. The data copy
# and the deployment repoint are a separate, staged step — see
# MIGRATION-data-to-block.md. Merging this alone is inert.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperless-data-block
namespace: paperless
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
type: RuntimeDefault
containers:
- name: redis
image: redis:8.10.0-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
image: redis:8.10.1-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
args:
- redis-server
- --save
+38 -1
View File
@@ -32,7 +32,44 @@ spec:
parameters:
shared_buffers: "128MB"
max_connections: "50"
wal_compression: "on"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
wal_compression: "zstd"
# A WAL segment is a fixed 16 MiB file, and archive_timeout forces a switch
# on a timer -- the segment is then written and archived at its full size
# no matter how little real WAL it holds. CNPG defaults to 5min, i.e. up
# to 288 segments/day = 4.6 GB/day that is largely padding.
#
# Verified on gf-pg first (the staged rollout): 211 -> 41 MB/hour, an 81%
# cut, with archived segments falling from ~12/hour to ~1.5/hour in step.
# Two independent signals moving together is what confirms the volume was
# padding rather than data.
#
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
# its volume are lost outright. This is a single-instance cluster, so the
# archive is the only off-node copy.
archive_timeout: "30min"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
#
# checkpoint_timeout is the lever. The 5min default pushes every dirty
# page to disk 288 times a day, so a small database whose same few pages
# are re-dirtied constantly (sessions, tokens, job state) gets rewritten
# over and over. Measured on ak-pg: 25.8M checkpoint buffers flushed
# (~206 GB) against a 1046 MB database -- the whole thing roughly twice a
# day. At 15min those pages coalesce and are written a third as often.
#
# max_wal_size must rise with it, or checkpoints trigger on size instead
# and the timeout change does nothing. It also bounds crash recovery to
# at most 2GB of WAL replay.
#
# NOT changed: synchronous_commit stays at the default `on`. It is off for
# ha-pg because a state recorder is telemetry; these hold credentials,
# documents and job state, where losing the last few hundred ms of commits
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
resources:
requests:
cpu: 100m
+16
View File
@@ -362,6 +362,22 @@ spec:
name: trmnl-renderer-schedule
- name: tmp
emptyDir:
# RAM-backed, NOT disk. Without `medium: Memory` an emptyDir lands
# on the node's filesystem, and this one was the single largest
# writer in the cluster: 249 GB over 7.3 days = ~34 GB/day onto
# /dev/vda5, more than every Ceph OSD and every database combined
# (measured 2026-08-20 from the container's cgroup io.stat).
#
# The cause is cadence, not volume. schedules.template.json renders
# every 60 s, so Chrome is launched 1440 times a day and each launch
# creates, fills and deletes a /tmp/puppeteer_dev_chrome_profile-*
# directory. At rest /tmp holds under 8 MB; it is pure churn for
# data that never outlives the render.
#
# tmpfs pages count against the container's memory limit, which is
# why the cap stays at 512Mi: measured usage is 129Mi against a 2Gi
# limit, so even the full cap leaves ample headroom.
medium: Memory
sizeLimit: 512Mi
---
apiVersion: v1
+32 -5
View File
@@ -50,12 +50,39 @@ spec:
# clone + subsequent steps share the workspace PVC and the k8s backend
# can schedule them on different nodes -> an RWO volume then fails to
# mount on the second node. CephFS (RWX) lets the workspace follow
# the pipeline across nodes. The -ephemeral variant (reclaim Delete,
# rook-ceph-cluster/storageclass.yaml) is REQUIRED here: workspaces
# are throwaway, and the Retain class leaked one Released PV per
# finished pipeline (issue #262 follow-up).
# the pipeline across nodes. Reclaim MUST be Delete: workspaces are
# throwaway, and a Retain class leaked one Released PV per finished
# pipeline (issue #262 follow-up).
#
# ceph-filesystem-ci, not ceph-filesystem-ephemeral: a dedicated
# CephFS whose pools are BOTH size 1 (infrastructure/
# rook-ceph-cluster/cephfs-ci.yaml). The shared filesystem
# replicates data0 2x and metadata 3x, which is the correct trade
# for real data and pure waste for a git clone that is deleted
# minutes later.
#
# Measured 2026-08-21, polling both pools every 15s (a 5-minute
# interval misses an entire pipeline -- PVC created, used and
# deleted inside ~2 minutes):
# CI active 1.5min: data0 143.2 MB, metadata 17.2 MB
# CI idle 16.0min: data0 0.0 MB, metadata 11.0 MB
# data0 write volume is 100% CI. Per pipeline this moves the drive
# cost from 71.6x2 + 8.2x3 = ~168 MB to ~80 MB.
#
# ceph-filesystem-ephemeral is NOT repointed, and the reason is a
# technical constraint rather than a safety one. Its other consumer
# is velero/restore-test.sh, whose probe PVCs are snapshot CLONES:
# `ceph fs subvolume snapshot clone` operates within one filesystem,
# so a snapshot taken in `cephfs` cannot be cloned into `cephfs-ci`.
#
# Those probes are just as throwaway as a CI workspace -- the
# authoritative copy is the backup on Hetzner, and a lost probe just
# means re-running the test -- so they are equally undeserving of
# replication. Landing them unreplicated would mean a second size-1
# data pool inside `cephfs`, and restore-test runs WEEKLY against
# CI's ~96 pipelines/day, so it is not worth the complexity.
- name: WOODPECKER_BACKEND_K8S_STORAGE_CLASS
value: ceph-filesystem-ephemeral
value: ceph-filesystem-ci
- name: WOODPECKER_BACKEND_K8S_STORAGE_RWX
value: "true"
# MUST be a JSON object literal — the backend json.Unmarshal's this
+38
View File
@@ -56,6 +56,44 @@ spec:
parameters:
shared_buffers: "128MB"
max_connections: "50"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
#
# checkpoint_timeout is the lever. The 5min default pushes every dirty
# page to disk 288 times a day, so a small database whose same few pages
# are re-dirtied constantly (sessions, tokens, job state) gets rewritten
# over and over. Measured on ak-pg: 25.8M checkpoint buffers flushed
# (~206 GB) against a 1046 MB database -- the whole thing roughly twice a
# day. At 15min those pages coalesce and are written a third as often.
#
# max_wal_size must rise with it, or checkpoints trigger on size instead
# and the timeout change does nothing. It also bounds crash recovery to
# at most 2GB of WAL replay.
#
# NOT changed: synchronous_commit stays at the default `on`. It is off for
# ha-pg because a state recorder is telemetry; these hold credentials,
# documents and job state, where losing the last few hundred ms of commits
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
wal_compression: "zstd"
# A WAL segment is a fixed 16 MiB file, and archive_timeout forces a switch
# on a timer -- the segment is then written and archived at its full size
# no matter how little real WAL it holds. CNPG defaults to 5min, i.e. up
# to 288 segments/day = 4.6 GB/day that is largely padding.
#
# Verified on gf-pg first (the staged rollout): 211 -> 41 MB/hour, an 81%
# cut, with archived segments falling from ~12/hour to ~1.5/hour in step.
# Two independent signals moving together is what confirms the volume was
# padding rather than data.
#
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
# its volume are lost outright. This is a single-instance cluster, so the
# archive is the only off-node copy.
archive_timeout: "30min"
resources:
requests:
cpu: 100m
@@ -917,11 +917,27 @@ subjects:
name: plugin-barman-cloud
namespace: database
---
# stringData, NOT base64 `data`. An image reference is not a secret, and
# encoding it here had two real costs: no reviewer sees a version change in a
# diff, and no $imagepolicy marker can be attached to a base64 blob. The result
# was that automation advanced the controller to v0.14.0 while this sidecar sat
# at v0.12.0 — a two-minor skew in the component that performs every base backup
# and WAL archive, invisible until someone read the pod spec (#535).
#
# The Secret KIND is kept because the controller reads it via secretKeyRef
# (deployment env SIDECAR_IMAGE); changing it to a ConfigMap would need a matching
# edit to that reference and diverge further from upstream. stringData is
# accepted identically by the API server and serialised into `data` on write.
#
# The name carries a kustomize hash suffix from upstream's secretGenerator. It is
# static here, so editing the value does NOT change the name and the deployment's
# secretKeyRef stays valid.
#
# IF YOU RE-IMPORT THE UPSTREAM MANIFEST: upstream ships this as base64 `data`
# with no marker. Re-apply this block, or the skew silently returns.
apiVersion: v1
data:
SIDECAR_IMAGE: |
Z2hjci5pby9jbG91ZG5hdGl2ZS1wZy9wbHVnaW4tYmFybWFuLWNsb3VkLXNpZGVjYXI6dj
AuMTIuMA==
stringData:
SIDECAR_IMAGE: ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar:v0.14.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin-sidecar"}
kind: Secret
metadata:
name: plugin-barman-cloud-kg5c2795f7
@@ -28,3 +28,40 @@ spec:
policy:
semver:
range: "0.x"
---
# The SIDECAR image, tracked separately from the controller above.
#
# These are two DIFFERENT images that must move together:
# plugin-barman-cloud the controller, in the database namespace
# plugin-barman-cloud-sidecar injected into every Postgres pod
#
# Only the controller was tracked, so automation walked it from v0.12.0 to
# v0.14.0 while the sidecar stayed at v0.12.0 — a two-minor skew in the
# component that performs every base backup and WAL archive (#535). The skew was
# invisible because the sidecar tag was base64-encoded inside a Secret, where no
# $imagepolicy marker can reach it and no reviewer would see it in a diff.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: cnpg-barman-plugin-sidecar
namespace: flux-system
spec:
image: ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar
interval: 1h
---
# Same filter and range as the controller policy deliberately: the two images
# are released together and must not be allowed to drift apart again.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: cnpg-barman-plugin-sidecar
namespace: flux-system
spec:
imageRepositoryRef:
name: cnpg-barman-plugin-sidecar
filterTags:
pattern: '^v(?P<ver>\d+\.\d+\.\d+)$'
extract: '$ver'
policy:
semver:
range: "0.x"
@@ -17,6 +17,17 @@ spec:
modules:
- name: pg_autoscaler
enabled: true
# Rotates daemon keys to aes256k for CVE-2025-30156 (#527). Opt-in; disabled
# by default. Increment keyGeneration to rotate again.
#
# Daemon keys only — aes256k over the CSI mount path needs kernel 7.0+ and
# these nodes run 6.18, so CSI stays on aes and the client-key checks stay
# raised. keyType is unset so Rook picks it.
security:
cephx:
daemon:
keyRotationPolicy: KeyGeneration
keyGeneration: 2
# Bump CephFS subvolume cloner pool from 4 → 16. Velero CSI Snapshot
# Data Movement provisions a backupPVC per source PVC by cloning the
# VolumeSnapshot, and a 4-thread default stalls migrate-immich's
@@ -0,0 +1,64 @@
---
# A SEPARATE CephFS whose only purpose is Woodpecker CI workspaces. Each
# pipeline gets a PVC that is created, filled and deleted within ~2 minutes;
# keeping that churn off `cephfs` spares its size-3 metadataPool.
#
# size 2, not 1: an unreplicated pool makes `ceph osd ok-to-stop` refuse for
# every OSD, blocking all OSD maintenance cluster-wide. size 1 is viable only
# once these pools have OSDs of their own.
apiVersion: ceph.rook.io/v1
kind: CephFilesystem
metadata:
name: cephfs-ci
namespace: rook-ceph
spec:
metadataPool:
replicated:
size: 2
dataPools:
- name: data0
replicated:
size: 2
metadataServer:
activeCount: 1
# No standby-replay, unlike `cephfs`. A standby exists there so a failed
# MDS does not hang every CephFS mount cluster-wide; here the only casualty
# is a running pipeline, and a second daemon to protect throwaway work is
# not worth its memory.
activeStandby: false
resources:
# Deliberately NOT the 4Gi that `cephfs` needs. That figure is driven by
# immich's millions of cached inodes; a CI workspace is a few thousand
# short-lived files that vanish with the pipeline.
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 1Gi
---
# CI-only class. `ceph-filesystem-ephemeral` is deliberately left pointing at
# the main filesystem: velero/restore-test.sh uses it to restore REAL data for
# verification, which must not land on unreplicated pools.
#
# A new class rather than repointing the old one — StorageClass.parameters are
# immutable, so `fsName` cannot be edited in place.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ceph-filesystem-ci
provisioner: rook-ceph.cephfs.csi.ceph.com
parameters:
clusterID: rook-ceph
fsName: cephfs-ci
pool: cephfs-ci-data0
csi.storage.k8s.io/provisioner-secret-name: rook-csi-cephfs-provisioner
csi.storage.k8s.io/provisioner-secret-namespace: rook-ceph
csi.storage.k8s.io/controller-expand-secret-name: rook-csi-cephfs-provisioner
csi.storage.k8s.io/controller-expand-secret-namespace: rook-ceph
csi.storage.k8s.io/node-stage-secret-name: rook-csi-cephfs-node
csi.storage.k8s.io/node-stage-secret-namespace: rook-ceph
# Delete, for the same reason ceph-filesystem-ephemeral uses it: with Retain,
# every finished pipeline leaks a Released PV pinning a subvolume (191 of them
# by 2026-07-16, issue #262 follow-up).
reclaimPolicy: Delete
allowVolumeExpansion: true
@@ -4,5 +4,6 @@ resources:
- cephcluster.yaml
- toolbox.yaml
- storageclass.yaml
- cephfs-ci.yaml
- volumesnapshotclass.yaml
- objectstore.yaml
+6
View File
@@ -21,8 +21,14 @@ COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl \
&& strip target/x86_64-unknown-linux-musl/release/secret-rotator
# Build timestamp, recorded in the image rather than the tag: the tag says which
# source revision was built, this says when. Written here because the final
# image is scratch and has no shell to run `date` in.
RUN date -u +%Y-%m-%dT%H:%M:%SZ > /build/image-build-date
FROM scratch
COPY --from=build /build/target/x86_64-unknown-linux-musl/release/secret-rotator /secret-rotator
COPY --from=build /build/image-build-date /etc/image-build-date
# Matches runAsUser on the rotation Jobs. There is no /etc/passwd in a scratch
# image, so this is a bare uid by necessity — which is also all Kubernetes needs.