Skip to content

Commit c52c4d5

Browse files
ahacheteteoincontatto
authored andcommitted
doc: io isolation runbook - target physical disks, not dm devices
Switch the recommended BlockIO class glob from `/dev/dm-[0-9]*` to the underlying physical disks (`/dev/sd[a-z]*`, `/dev/nvme[0-9]n[0-9]*`). The kernel's blkcg propagates through device-mapper clone/split, so a throttle on the physical disk caps I/O that the workload submits via its LV. The previous revision's placeholder-seeding mitigation for the dm-glob case turned out to be broken in practice: when seeded dm minors are released, their `(major:minor)` entries remain in the runtime's BlockIO class. The kernel rejects writes to non-existent devices with ENODEV, and crun fails container creation with `write 'rbps': No such device`. The pod gets stuck in Init:CreateContainerError. Physical-disk globs avoid this entirely because physical devices are stable from boot and don't come or go in normal operation. Specific changes: - #3 example `Devices:` glob updated to physical disks. Added a callout explaining why this approach is preferred and citing the empirical validation numbers. Removed the now-obsolete "one-shot glob expansion" warning that argued for placeholder seeding. - #6 storage-class callout reworded: explains that the cap targets the physical disk and that the kernel propagates the cgroup tag through the dm layer. Links to the Limitations subsection for the underlying reasoning. - #6.1 expected-output discussion clarified: the matched major:minor is the physical disk (8:* or 259:*), not the LV's 252:* / 253:* dm-N. Major-numbers table annotated accordingly. - Limitations section restructured. The previous "One-shot device glob expansion" subsection is now "Why target physical disks and not dm devices" -- it explains both failure modes for the dm-glob case (cold-start blind spot and stale-entry ENODEV) and why physical-disk globs avoid both. The placeholder-seeding mitigation is removed entirely (it was incorrect). The nodeSelector-vs-nodeName footgun callout is also removed -- no longer applicable. - Static provisioning subsection demoted from "robust alternative" to "an alternative for unusual cases" (e.g. dm-multipath where the workload device is the multipath dm and underlying paths shouldn't be throttled individually). For the common LVM-CSI case, the physical-disk approach from #3 is now the recommended default. - Troubleshooting: "Annotation silently ignored" entry updated to point at the new Limitations heading. New entry for `Init:CreateContainerError` with `write 'rbps': No such device` documents the ENODEV failure mode explicitly.
1 parent 06a6c02 commit c52c4d5

1 file changed

Lines changed: 121 additions & 113 deletions

File tree

  • doc/content/en/09-runbooks/13-io-isolation

doc/content/en/09-runbooks/13-io-isolation/_index.md

Lines changed: 121 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -340,49 +340,57 @@ Write a `blockio.yaml` file that defines the named classes you want to
340340
node without exceeding the budget enforced by the extended resource
341341
in [step 5](#5-advertise-iops-capacity-as-an-extended-resource).
342342

343-
Example ladder (adjust devices and values for your environment):
343+
**Target the underlying physical disks, not the dm devices.** When the
344+
PV stack uses LVM-based CSI (TopoLVM, LVMS, OpenEBS LVM-LocalPV), every
345+
PVC gets its own `/dev/dm-N` mapping that the Postgres process writes
346+
to. It is tempting to glob `/dev/dm-*` in the class so each LV is
347+
throttled at its own dm. **Don't.** That path is fragile (see
348+
[Limitations](#limitations-and-caveats) for why). Instead, glob the
349+
underlying physical block devices (`/dev/sd*`, `/dev/nvme*n*`). The
350+
kernel propagates the originating cgroup tag through the
351+
device-mapper layer, so blk-throttle on the physical disk caps I/O
352+
submitted by the workload via its LV. This was verified end-to-end:
353+
with a class globbing `/dev/sd[a-z]*` and a 5000 IOPS cap, fio
354+
inside a Postgres pod whose data PV is an LVM-LocalPV LV measured
355+
5006 IOPS read / 5007 IOPS write (within 0.1% of the cap). The
356+
physical-disk approach is stable across PVC churn, restarts, and
357+
node lifecycle events; the per-dm approach is not.
358+
359+
Example ladder (adjust device paths and values for your environment):
344360

345361
```yaml
346362
# blockio.yaml
347363
Classes:
348364

349365
stackgres-io-1k:
350-
- Devices: ["/dev/nvme[0-9]n[0-9]", "/dev/dm-[0-9]*"]
366+
- Devices: ["/dev/sd[a-z]*", "/dev/nvme[0-9]n[0-9]*"]
351367
ThrottleReadIOPS: 1k
352368
ThrottleWriteIOPS: 1k
353369
ThrottleReadBps: 50M
354370
ThrottleWriteBps: 50M
355371

356372
stackgres-io-5k:
357-
- Devices: ["/dev/nvme[0-9]n[0-9]", "/dev/dm-[0-9]*"]
373+
- Devices: ["/dev/sd[a-z]*", "/dev/nvme[0-9]n[0-9]*"]
358374
ThrottleReadIOPS: 5k
359375
ThrottleWriteIOPS: 5k
360376
ThrottleReadBps: 200M
361377
ThrottleWriteBps: 200M
362378

363379
stackgres-io-20k:
364-
- Devices: ["/dev/nvme[0-9]n[0-9]", "/dev/dm-[0-9]*"]
380+
- Devices: ["/dev/sd[a-z]*", "/dev/nvme[0-9]n[0-9]*"]
365381
ThrottleReadIOPS: 20k
366382
ThrottleWriteIOPS: 20k
367383
ThrottleReadBps: 800M
368384
ThrottleWriteBps: 800M
369385
```
370386
371-
Device globs are expanded by the runtime against the real devices present
372-
on each node. Classes that reference devices absent on a given node
373-
simply produce no cgroup entries for that pod on that node --they do not
374-
fail.
375-
376-
> **Important: glob expansion is one-shot, at CRI-O / containerd startup.**
377-
> Each `Devices` glob is evaluated once, when the runtime reads
378-
> `blockio.yaml`. The class is then stored as a fixed list of
379-
> `(major:minor, parameter, value)` tuples. Block devices that don't
380-
> exist at that moment are not in the class; devices that appear later
381-
> (notably the per-PVC `/dev/dm-N` devices that LVM-based CSI drivers
382-
> provision on demand) are **not** retroactively added. The annotated
383-
> pods that need those devices get no throttle --silently, with no
384-
> event. See [Limitations and caveats](#limitations-and-caveats) below
385-
> for the operational implications and the recommended workflow.
387+
The globs above match SATA/SCSI and NVMe physical devices respectively.
388+
Adjust to whatever names the database nodes' kernels actually assign
389+
to the physical disks hosting the LVM VG. The glob is expanded by the
390+
runtime at startup against the real devices present on each node;
391+
entries that don't match anything are silently dropped --but for
392+
physical disks this isn't a problem because their device names are
393+
stable from boot.
386394
387395
Save this file locally; the next step places it on the database nodes via
388396
the path appropriate to your runtime/platform.
@@ -783,11 +791,15 @@ Create a test SGCluster that exercises the full path: the BlockIO class
783791
> not the name: the BlockIO cap has to apply to the device the workload
784792
> actually writes to.
785793
>
786-
> - **Per-PVC device-mapper devices.** LVM-based local CSI drivers expose
787-
> each PVC as its own `/dev/dm-N` device. That's why the `blockio.yaml`
788-
> in [step 3](#3-author-the-blockio-class-ladder) includes
789-
> `/dev/dm-[0-9]*` in the device globs --the cap is enforced on the
790-
> dm device that the Postgres process writes to.
794+
> - **Per-PVC LVM devices on top of a stable physical disk.** LVM-based
795+
> local CSI drivers expose each PVC as a `/dev/dm-N` mapping over a
796+
> physical disk (the one that hosts the LVM VG). The cap from
797+
> [step 3](#3-author-the-blockio-class-ladder)'s class targets the
798+
> *physical disk*; the kernel propagates the workload's cgroup tag
799+
> through the dm layer, so the throttle binds correctly regardless of
800+
> which dm minor the PVC happens to land on. See
801+
> [Why target physical disks and not dm devices](#why-target-physical-disks-and-not-dm-devices)
802+
> for the underlying explanation.
791803
> - **Local storage.** I/O stays on the node, so a kernel-block-layer cap
792804
> is meaningful. With network-attached storage (Ceph RBD, EBS, GCE PD,
793805
> etc.) the bottleneck is the network and a per-device cgroup throttle
@@ -969,25 +981,29 @@ BASE=/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-p
969981
cat ${BASE}/container/io.max 2>/dev/null || cat ${BASE}/io.max
970982
```
971983

972-
Expected output (exact device numbers depend on your storage stack):
984+
Expected output (exact device numbers depend on your storage stack;
985+
one line per device matched by the class's `Devices:` glob):
973986

974987
```
975988
259:0 rbps=209715200 wbps=209715200 riops=5000 wiops=5000
976989
```
977990
978-
Device major numbers you might see in the output, depending on the
979-
backing storage:
991+
With the recommended physical-disk glob from
992+
[step 3](#3-author-the-blockio-class-ladder), `major:minor` corresponds
993+
to a physical disk on the node, **not** to the LV's `/dev/dm-N`. That
994+
is deliberate: the kernel propagates the cgroup tag through the dm
995+
layer, so the throttle on the physical disk caps I/O the workload
996+
submits via its LV. Common majors:
980997
981998
| Major | Device type |
982999
|---|---|
9831000
| `7` | loop devices (lab setups, e.g. file-backed LVM) |
984-
| `8` | SCSI / SATA |
985-
| `252`, `253` | device-mapper (LVM logical volumes, dm-thin) |
986-
| `259` | NVMe |
1001+
| `8` | SCSI / SATA (`/dev/sd*`) |
1002+
| `252`, `253` | device-mapper (only if the class globs `/dev/dm-*` directly) |
1003+
| `259` | NVMe (`/dev/nvme*`) |
9871004
988-
If the `major:minor` in the output matches one of the entries the
989-
`Devices:` glob in your `blockio.yaml` would expand to, the throttle is
990-
attached to the right device.
1005+
If the `major:minor` matches one of the physical disks underlying your
1006+
LVM VG, the throttle is attached to the right device.
9911007
9921008
This confirms:
9931009
- The annotation was recognized by the container runtime on the node.
@@ -1120,7 +1136,7 @@ If non-StackGres workloads must coexist on the same nodes, they need
11201136

11211137
## Limitations and caveats
11221138

1123-
### One-shot device glob expansion
1139+
### Why target physical disks and not dm devices
11241140

11251141
The container runtime (CRI-O or containerd) resolves each class's
11261142
`Devices` globs **once**, when it loads `blockio.yaml` at service
@@ -1130,9 +1146,15 @@ The container runtime (CRI-O or containerd) resolves each class's
11301146
startup are not in the class; devices that appear later are **not**
11311147
retroactively added.
11321148

1133-
For block-storage stacks where per-PVC devices are created on demand
1134-
(TopoLVM, LVMS, OpenEBS LVM-LocalPV --any LVM-based CSI driver), this
1135-
has two practical implications:
1149+
That property is benign for **physical block devices** (`/dev/sda`,
1150+
`/dev/nvme0n1`, ...) because their device names are stable from boot
1151+
and don't come or go in normal operation. It is hostile to
1152+
**device-mapper devices created on demand by LVM CSI drivers**
1153+
(`/dev/dm-N`), which appear when a PVC is bound and disappear when the
1154+
corresponding LV is removed.
1155+
1156+
There are two failure modes if you glob `/dev/dm-*` against a dynamic
1157+
LVM-CSI stack:
11361158

11371159
1. **Cold-start blind spot.** On a fresh node where no PVCs have been
11381160
provisioned yet, `/dev/dm-*` matches nothing. Every class loads with
@@ -1143,62 +1165,35 @@ For block-storage stacks where per-PVC devices are created on demand
11431165
at startup; see [step 4.1 step 4](#41-cri-o-direct-configuration)
11441166
for the explicit check.
11451167

1146-
2. **New-device blind spot.** Each new PVC the CSI driver provisions on
1147-
a node typically gets the next free minor --`/dev/dm-N+1`. Even after
1148-
a restart that picked up `/dev/dm-0..N`, devices beyond `N` were
1149-
absent at expansion time and are not in the class. PVCs created later
1150-
are not throttled until the next runtime restart on that node.
1151-
1152-
**Mitigation: seed enough dm slots at node-provisioning time so the
1153-
BlockIO class covers all the devices the node will ever produce in
1154-
normal operation.** The unit of seeding is **one placeholder per dm
1155-
device slot**, not one per storage class: each placeholder PVC binds
1156-
to one `/dev/dm-N`, and the class ends up containing exactly the dm
1157-
minors that existed at runtime startup. Subsequent production PVCs
1158-
that the kernel assigns to those same minors (after the placeholders
1159-
are released) are throttled; PVCs assigned to higher minors are not.
1160-
1161-
The practical recipe:
1162-
1163-
1. **Pre-provision a generous number of placeholder PVCs per node**
1164-
(e.g. 32 --pick a number comfortably above the maximum concurrent
1165-
StackGres pod count you expect that node to ever host). Each
1166-
placeholder is a small PVC of the relevant StorageClass, bound by a
1167-
short-lived placeholder Pod scheduled to that node, just long enough
1168-
to materialize the LV and therefore the `/dev/dm-N`. Verify with
1169-
`ls -la /dev/dm-*` that you have 32 dm devices on the node.
1170-
2. **Restart the runtime** (step 4 of this runbook). The BlockIO class
1171-
now contains 32 dm entries.
1172-
3. **Tear the placeholders down.** The LVs go away, the dm minors are
1173-
freed --but they remain in the BlockIO class. Production PVCs that
1174-
the kernel allocates into those freed slots inherit the throttle.
1175-
1176-
Bundle steps 1--3 into the node-provisioning runbook, before the node
1177-
is marked "ready for workloads". Treated this way, the seeding is a
1178-
one-time setup step rather than a recurring operational burden. The
1179-
only failure mode is "more concurrent dm devices on a node than were
1180-
seeded", which is bounded by a number you control --pick it
1181-
generously.
1182-
1183-
> **Footgun: pin the placeholder Pod with `nodeSelector`, not
1184-
> `nodeName`.** TopoLVM, LVMS, and OpenEBS LVM-LocalPV all create their
1185-
> StorageClass with `volumeBindingMode: WaitForFirstConsumer`, which
1186-
> relies on the scheduler to inform the PVC of the chosen node. A Pod
1187-
> with `spec.nodeName` set directly **bypasses the scheduler**, so the
1188-
> PVC stays `Pending` with a `waiting for first consumer to be created
1189-
> before binding` event --- forever, despite the Pod itself being
1190-
> scheduled. Pin the placeholder Pod with
1191-
> `spec.nodeSelector: { kubernetes.io/hostname: <node-name> }` (or an
1192-
> equivalent `nodeAffinity` block) so the scheduler is the one placing
1193-
> it.
1194-
1195-
For workloads where 32 (or whatever number you seed) is genuinely not
1196-
enough --either because pod count is unbounded or because you don't
1197-
want to trust kernel dm-minor reuse semantics --see
1198-
[Static provisioning as a robust alternative](#static-provisioning-as-a-robust-alternative)
1199-
below. The in-cluster approach coming in **StackGres 1.19** also
1200-
sidesteps this entirely: it writes the cgroup `io.max` entries directly
1201-
per pod and per device, with no class-load timing dependency.
1168+
2. **Stale-entry container-create failure.** If `/dev/dm-*` matches at
1169+
startup but the matching dm devices later disappear (e.g. the LV is
1170+
removed or its activating Pod terminates), the class still contains
1171+
their `(major:minor)` entries. The next container creation will try
1172+
to write throttle parameters for those stale entries; the kernel
1173+
returns `ENODEV` and `crun` fails the container create with
1174+
`write 'rbps': No such device`. The Pod can get stuck in
1175+
`Init:CreateContainerError` indefinitely. Verified end-to-end during
1176+
runbook validation.
1177+
1178+
**Both failure modes vanish if you glob the *underlying physical
1179+
disks*** (`/dev/sd*`, `/dev/nvme*n*`) as recommended in
1180+
[step 3](#3-author-the-blockio-class-ladder). The kernel propagates
1181+
the BIO's cgroup tag through `dm`'s clone/split path, so a throttle
1182+
on the physical device caps I/O the workload submitted via its LV.
1183+
Each pod's per-cgroup limit is enforced independently --multiple
1184+
capped pods on the same node, sharing the same physical disk, each
1185+
hold their own cap (validated: two pods, 5000 IOPS cap each, fio
1186+
concurrently, both observed 5003 IOPS).
1187+
1188+
This is why [step 3](#3-author-the-blockio-class-ladder) recommends
1189+
physical-disk globs only. If you have an unusual stack where the
1190+
workload writes to a dm device that is itself the underlying device
1191+
(no further block layer beneath), see
1192+
[Static provisioning as an alternative](#static-provisioning-as-an-alternative)
1193+
below for a dm-stable approach. The in-cluster approach coming in
1194+
**StackGres 1.19** also sidesteps the class mechanism entirely: it
1195+
writes the cgroup `io.max` entries directly per pod and per device,
1196+
with no class-load timing dependency.
12021197

12031198
### conmon and cgroup-path variations
12041199

@@ -1209,16 +1204,19 @@ The exact cgroup path where the throttle ends up depends on CRI-O's
12091204
pod's slice and `grep -l riops=` to locate the one with throttle
12101205
entries, rather than to assume a fixed path.
12111206

1212-
### Static provisioning as a robust alternative
1207+
### Static provisioning as an alternative
1208+
1209+
The physical-disk-glob approach from
1210+
[step 3](#3-author-the-blockio-class-ladder) is the recommended default
1211+
for LVM-based CSI stacks. **Static provisioning** is an alternative
1212+
worth considering if your team prefers strict capacity planning over
1213+
dynamic expansion, or if you have a stack where physical-disk
1214+
throttling isn't appropriate (e.g. dm-multipath where the workload
1215+
device is the multipath dm and the underlying paths are several sd
1216+
devices that you don't want to throttle individually).
12131217

1214-
For workloads where the placeholder-seeding approach above is not a
1215-
fit --either because the maximum concurrent pod count per node is
1216-
unbounded, because relying on the kernel's "reuse lowest free dm
1217-
minor" behavior is uncomfortable, or because the team prefers strict
1218-
capacity planning over dynamic expansion --**static provisioning is
1219-
a clean alternative.** Pre-create a fixed number of LVs at known
1220-
names by hand (or by an Ansible / MachineConfig step at node-prep
1221-
time):
1218+
Pre-create a fixed number of LVs at known names by hand (or by an
1219+
Ansible / MachineConfig step at node-prep time):
12221220

12231221
```bash
12241222
# on each db node, at provisioning time:
@@ -1235,14 +1233,14 @@ Expose each LV as a static `PersistentVolume` (one per LV) with
12351233
i.e. `provisioner: kubernetes.io/no-provisioner`).
12361234

12371235
Each LV's `/dev/dm-N` is fixed at LV creation time and doesn't move
1238-
unless the LV is removed. The BlockIO class is loaded once at runtime
1239-
startup and binds to all of them deterministically. There is no
1240-
placeholder dance, no kernel-reuse assumption, no restart-on-growth
1241-
— but you lose the dynamic-expansion property of CSI provisioning.
1236+
unless the LV is removed. The BlockIO class can then safely glob
1237+
`/dev/dm-*` and bind deterministically. No restart-on-growth concern,
1238+
but you lose the dynamic-expansion property of CSI provisioning.
12421239

1243-
This is the right path for environments where the workload's storage
1240+
This is appropriate for environments where the workload's storage
12441241
layout is known up-front and stable, and where the cost of pre-sizing
1245-
storage slots is acceptable.
1242+
storage slots is acceptable. For most StackGres deployments with
1243+
dynamic provisioning, the physical-disk approach in step 3 is simpler.
12461244

12471245
### ZFS-backed PVs
12481246

@@ -1254,11 +1252,21 @@ ZFS does not correctly attribute buffered writes to the originating
12541252

12551253
## Troubleshooting
12561254

1257-
**Annotation silently ignored --`io.max` shows no throttle.** The most
1258-
common cause is the
1259-
[one-shot glob expansion limitation](#one-shot-device-glob-expansion)
1260-
above. Check the CRI-O journal for `WARN: device wildcard ... does not
1261-
match any device nodes` first.
1255+
**Annotation silently ignored --`io.max` shows no throttle.** Most
1256+
likely the class loaded but its `Devices:` glob matched nothing. Check
1257+
the CRI-O journal for `WARN: device wildcard ... does not match any
1258+
device nodes` (see
1259+
[Why target physical disks and not dm devices](#why-target-physical-disks-and-not-dm-devices)
1260+
for the underlying explanation and the recommended remedy: glob the
1261+
physical disks, not the dm devices).
1262+
1263+
**Pod stuck in `Init:CreateContainerError` with
1264+
`write 'rbps': No such device`.** A `Devices:` glob matched some
1265+
device at runtime startup that has since been removed. The kernel
1266+
rejects the stale entry, container creation fails. This happens
1267+
mainly when the glob covers dm devices (LV created at boot, removed
1268+
later); using a physical-disk glob avoids it. See
1269+
[Why target physical disks and not dm devices](#why-target-physical-disks-and-not-dm-devices).
12621270

12631271
If that's not it, the container runtime may not have loaded the BlockIO
12641272
config file. Causes to check, in order:

0 commit comments

Comments
 (0)