Plex has been transcoding in software on hardware that can do it in the iGPU. This opts its ServiceDefinition into the `hardware` capability from #27 and re-renders. catalog/plex.yaml gains four lines of YAML and the comment that explains them: hardware: gpu: intel-quicksync which renders exactly two things into apps/wave-0/plex.yaml and nothing else — a `dri` hostPath volume (/dev/dri, type: DirectoryOrCreate) and a read-write /dev/dri volumeMount on the app container. Whole-tree render diff: 6 added lines, one file. The comment records what is not obvious from the four lines: the device exists on ALL THREE prod nodes with i915 bound (so no nodeSelector is rendered and none is wanted); why `supplementalGroups` would be INERT rather than a hardening (the image's cont-init hook adds the plex user to the device's group inside the container, and s6-setuidgid then REPLACES the process group list from /etc/group, discarding anything Kubernetes supplied); and why `DirectoryOrCreate` is load-bearing (apps-wave-0 is wait:true with apps-wave-1 depending on it, so a failed mount would stall the whole app tier for a feature that is not). THE FEATURE IS INERT UNTIL A HUMAN CLICKS A BOX, and that is the part this commit is really about. Mounting the device does not enable hardware transcoding; Plex's `HardwareAcceleratedCodecs` preference does, and it is ABSENT from Preferences.xml — the toggle has never been on. It cannot be automated: a bounded grep across the running image's entire init surface (/etc/cont-init.d, /etc/services.d, /etc/fix-attrs.d, /plex-common.sh, /usr/local/bin) found ZERO PLEX_PREFERENCE* support, and the six setPref keys the image does write are all unrelated. So runbooks/plex-quicksync.md is the procedure, registered in the configMapGenerator (a runbook in git with no generator entry is reviewed, merged, and still a 404) and indexed in runbooks/README.md. It covers the GitOps/manual boundary, the toggle and the real preference keys (including the widely-cited one that does not exist and the upstream misspelling nobody should "fix"), verification via `Transcode (hw)` on the Plex dashboard — stated plainly as the ONLY proof, because Plex renders no ServiceMonitor and an alert could never fire — the device-cgroup failure to expect first with its Intel-device-plugin fallback, and the floating-`:latest` caveat. Stacked on #27 (feat/sd-hardware-capability), which is open and unmerged; that branch's commit is in this range by design and this must merge after it. NOT VERIFIED, and the handoff says so at length: hardware transcoding has not been observed working. Whether containerd's device cgroup lets a non-privileged pod open() a bind-mounted char device is an open question that only deploying settles, and the Plex-side toggle was still unset at the time of writing. A green gate proves the manifest renders; it proves nothing about transcoding. Gate (pinned toolbox, worktree): rc 3 — UNDETERMINED, not green. Passed: decisions-log, lint, l0, render-golden, l1, approval-policy. Skipped (the known local ceiling): spec-immutability (no sibling spec repo), gitea-settings (no SETTINGS_API_TOKEN), conformance (CLUSTER_AVAILABLE unset, so L2/L3 did not run). No fourth skip, nothing failed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
284 lines
16 KiB
Markdown
284 lines
16 KiB
Markdown
# plex-quicksync — making Plex actually use Intel Quick Sync, and proving it did
|
|
|
|
**Alert:** none, and none is possible — see "How to verify" below. This is an **operator procedure**,
|
|
not an incident page. It is indexed here because the feature it completes is **inert until a human
|
|
clicks one button**, and a switched-off feature that looks shipped is exactly what this page exists
|
|
to prevent.
|
|
**Files:** `catalog/plex.yaml` (`hardware.gpu: intel-quicksync`), `apps/wave-0/plex.yaml` (the
|
|
rendered golden — the `dri` hostPath volume and the `/dev/dri` mount), `catalog/render.py` (the
|
|
mechanism), `schemas/renderer-contract.md` `### hardware` (the contract).
|
|
**Related:** `runbooks/plex-claim.md` — the other Plex procedure with a manual last mile.
|
|
|
|
## What this means here
|
|
|
|
Plex transcodes in **software** by default. On the UHD Graphics 630 iGPU of an i7-9700T, Quick Sync
|
|
moves that work off the CPU — which matters on a three-node cluster where the CPU is also running
|
|
everything else. Two independent things have to be true before that happens, and **only one of them
|
|
is in git**:
|
|
|
|
| Half | Who does it | State |
|
|
|---|---|---|
|
|
| The render device is inside the container at `/dev/dri` | **GitOps.** `hardware.gpu: intel-quicksync` in `catalog/plex.yaml` renders a `dri` hostPath volume (`/dev/dri`, `type: DirectoryOrCreate`) and a read-write `/dev/dri` volumeMount on the app container. Flux applies it. | Done by merging that change. |
|
|
| Plex is *told* to use it | **The operator, by hand, in the Plex web UI.** | **NOT done.** `HardwareAcceleratedCodecs` was **absent** from `Preferences.xml` when this page was written — the toggle has never been on. |
|
|
|
|
**There is no GitOps path to the second half, and that is verified rather than assumed.** A bounded
|
|
grep across the running image's entire init surface — `/etc/cont-init.d`, `/etc/services.d`,
|
|
`/etc/fix-attrs.d`, `/plex-common.sh`, `/usr/local/bin` — found **zero** `PLEX_PREFERENCE*` support.
|
|
Every `setPref` the image performs is one of six hardcoded keys (`MachineIdentifier`,
|
|
`ProcessedMachineIdentifier`, `PlexOnlineToken`, `customConnections`, `allowedNetworks`,
|
|
`TranscoderTempDirectory`), none of them the transcode toggle. No environment variable, no ConfigMap
|
|
and no `postBuild` substitution can set it. So do not go looking for the clever way to automate this;
|
|
there isn't one, and the search has already been done.
|
|
|
|
What the device mount does **not** do, so nobody reads more into a green gate than is there: it does
|
|
not enable hardware transcoding, it does not prove the device can be *opened* from inside a
|
|
non-privileged pod (see "Likely causes" #2), and it renders no `nodeSelector` — Plex can land on any
|
|
of the three nodes and all three carry the device.
|
|
|
|
## Running the commands in this runbook
|
|
|
|
`kubectl` does not work on the operator workstation; the toolchain lives only in the pinned dev
|
|
container. Every command below runs as:
|
|
|
|
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes'
|
|
|
|
Always pass `--context k3s-prod` explicitly. `dev.sh` merges both clusters into one kubeconfig and
|
|
picks a current-context **per invocation**, and that default has already changed once. Do not run two
|
|
`dev.sh` invocations at once.
|
|
|
|
**Never `cat` `Preferences.xml`.** It contains `PlexOnlineToken`, a plex.tv account credential. Every
|
|
read of that file below greps a single named key — do not widen one.
|
|
|
|
## First commands
|
|
|
|
**1. Is the device mounted into the pod at all?** (Proves Flux applied the change, not that it works.)
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex \
|
|
-o jsonpath="{.items[0].spec.containers[0].volumeMounts[*].mountPath}"; echo'
|
|
```
|
|
|
|
`/dev/dri` in that list ⇒ the GitOps half landed. Absent ⇒ the running pod predates the merge; compare
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n flux-system get kustomization apps-wave-0 \
|
|
-o jsonpath="{.status.lastAppliedRevision}"; echo'
|
|
```
|
|
|
|
against the merge commit before blaming anything else.
|
|
|
|
**2. Is the device actually visible inside the container?**
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- ls -l /dev/dri'
|
|
```
|
|
|
|
Expect `renderD128` (usually `card0` too), mode `crw-rw----`, group `render`. An **empty directory**
|
|
here is the `DirectoryOrCreate` degradation: the kubelet created an empty `/dev/dri` because the node
|
|
had none. That is the designed-safe outcome — the app tier still reconciles — but it means software
|
|
transcoding, silently. Find which node the pod is on and ask why that node lost its iGPU:
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex -o wide'
|
|
```
|
|
|
|
**3. Can Plex *open* it?** This is the question the manifest cannot answer — see "Likely causes" #2.
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
|
bash -c "exec 3<>/dev/dri/renderD128 && echo RW-OPEN-OK || echo RW-OPEN-DENIED"'
|
|
```
|
|
|
|
`RW-OPEN-DENIED` (or `Operation not permitted` / `Permission denied`) with the device plainly visible
|
|
in step 2 is the **device-cgroup** failure, not a file-permission failure. The two look identical from
|
|
the outside and are not the same problem.
|
|
|
|
**4. Is the toggle on?** Named key only — never the whole file:
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
|
grep -o "HardwareAcceleratedCodecs=\"[01]\"" \
|
|
"/config/Library/Application Support/Plex Media Server/Preferences.xml" || echo ABSENT'
|
|
```
|
|
|
|
`ABSENT` ⇒ never enabled (the state this page was written in). `="0"` ⇒ explicitly off. `="1"` ⇒ on,
|
|
and the remaining question is whether it *works* — go to "How to verify".
|
|
|
|
## The one manual step
|
|
|
|
Plex web UI → **Settings → Transcoder → "Use hardware acceleration when available"** → tick → Save.
|
|
(It sits under *Show Advanced*.) A transcode already in flight is unaffected; the next one picks it up.
|
|
|
|
The underlying preference key is **`HardwareAcceleratedCodecs`**. That name was confirmed by
|
|
extracting strings from the 23 MB `Plex Media Server` binary in the running container — **not** from
|
|
recall and not from a forum post. Real neighbouring keys from the same binary, so you can recognise
|
|
them and not invent them:
|
|
|
|
| Key | What it is |
|
|
|---|---|
|
|
| `HardwareAcceleratedCodecs` | the toggle this page is about |
|
|
| `HardwareAcceleratedEncoders` | hardware **encode**, as distinct from decode |
|
|
| `HardwareDevicePath` | how Plex is pointed at a *specific* render node — relevant only if a host ever exposes more than one |
|
|
| `TranscoderToneMapping`, `TranscoderToneMappingAgorithm` | HDR→SDR tone mapping. **That misspelling of "Algorithm" is upstream's, in the binary. It is not a typo in this page — do not "fix" it** here or anywhere else; the corrected spelling is not a key Plex reads. |
|
|
| `TranscoderHEVCEncoding` | HEVC output |
|
|
|
|
**`HardwareAcceleratedCodecsEnableHEVC` does not exist.** It is widely cited and it is not in the
|
|
binary. Recorded explicitly so nobody spends an evening hunting for it or, worse, writes it into
|
|
`Preferences.xml` and concludes hardware transcoding is broken.
|
|
|
|
Editing `Preferences.xml` by hand is **not** the procedure. It is app state on the replicated Longhorn
|
|
tier, it is in no git commit, and the UI writes the key correctly. Use the UI.
|
|
|
|
## How to verify — and why there is no alert
|
|
|
|
Start a playback that **forces a transcode** (a client that cannot direct-play the source, or pick a
|
|
lower quality explicitly), then read Plex's own **Dashboard → Activity**:
|
|
|
|
- **`Transcode (hw)`** on the session ⇒ hardware transcoding. **This is the proof.**
|
|
- plain **`Transcode`** ⇒ software. The feature is not working, whatever the manifest says.
|
|
|
|
**That dashboard is the *only* proof available, and nothing else will ever tell you.**
|
|
`catalog/plex.yaml` declares no `metricsEndpoint`, so Plex renders **no ServiceMonitor at all** —
|
|
there is no scrape, no transcode-mode metric, and therefore no alert that could fire. Do not open work
|
|
to "alert on software-transcode fallback": with no metric behind it such a rule would be permanently
|
|
inert, and this repo has already been burned at scale by rules that evaluated against nothing while
|
|
looking like coverage (`runbooks/README.md`, "Alerts you should not trust the silence of"). If that
|
|
gap is ever worth closing, the honest order is a real exporter first and a rule second.
|
|
|
|
Corroborating, not a substitute — the server log on the config PVC:
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
|
sh -c "grep -i -m20 -e hardware -e vaapi -e hwdevice \
|
|
\"/config/Library/Application Support/Plex Media Server/Logs/Plex Media Server.log\""'
|
|
```
|
|
|
|
Treat those strings as advisory: they are Plex's, not this repo's, and they move between versions.
|
|
Absence of a match is **not** evidence of software transcoding; a VA-API device open is good evidence
|
|
of hardware. The dashboard remains the verdict.
|
|
|
|
## Likely causes, most likely first
|
|
|
|
When the dashboard still says plain `Transcode`:
|
|
|
|
1. **The toggle is off.** Overwhelmingly the most likely answer, and the reason this page exists.
|
|
First-command #4 returns `ABSENT` or `="0"`. Remedy: the manual step above. Check this **before**
|
|
investigating anything technical.
|
|
|
|
2. **containerd's device cgroup is refusing the `open()` — UNVERIFIED, and the largest remaining
|
|
technical risk in this feature.** Whether a non-privileged pod may `open()` a bind-mounted
|
|
character device on this k3s/containerd build **has not been established**; it can only be settled
|
|
by deploying and trying. The container inits as uid 0, which settles *file* permissions, but
|
|
**uid 0 does not bypass the cgroup device gate.** Symptom: step 2 shows `renderD128` present and
|
|
correctly owned, step 3 returns `RW-OPEN-DENIED`.
|
|
**The documented fallback is the Intel Device Plugin for Kubernetes** (`gpu.intel.com/i915` as a
|
|
resource request, no hostPath). Because the ServiceDefinition field expresses *intent* rather than
|
|
a device path, that migration is a **`catalog/render.py` change plus a re-render — with no
|
|
ServiceDefinition edit anywhere**; `catalog/plex.yaml` keeps saying exactly what it says today.
|
|
**`privileged: true` is not an available escape hatch:** Kyverno `restrict-host-namespaces` is in
|
|
**Enforce** mode and rejects it in app namespaces. Do not reach for it.
|
|
|
|
3. **The pod landed on a node without the device.** Step 2 shows an empty `/dev/dri`. All three prod
|
|
nodes carried `renderD128` with `i915` bound when this was measured (2026-09-30), so this means a
|
|
node changed — a rebuild, a BIOS reset, or a driver regression. `DirectoryOrCreate` is why the app
|
|
tier still came up; the cost is that the loss is silent. **Fix the node, do not tighten the mount:**
|
|
`Directory` would stall `apps-wave-0` (`wait: true`, with `apps-wave-1` depending on it) for a
|
|
feature that is not load-bearing.
|
|
|
|
4. **The image changed under you.** See "The floating-image caveat". A `:latest` pull can remove the
|
|
mechanism this feature leans on with no commit in this repo.
|
|
|
|
5. **The session was never transcoding.** Direct Play / Direct Stream never touches the transcoder, so
|
|
`Transcode (hw)` cannot appear. Confirm the session actually says `Transcode` before treating it as
|
|
a hardware failure.
|
|
|
|
6. **The source needs a codec Quick Sync on this generation cannot handle** — e.g. AV1 decode, which
|
|
Coffee Lake's UHD 630 has no engine for. Plex falls back to software *per stream*. Symptom: some
|
|
sessions show `(hw)` and others do not. Not a fault.
|
|
|
|
## The floating-image caveat
|
|
|
|
Everything this page states about the image's behaviour — the `cont-init.d` hook that adds the app
|
|
user to the device's group, the absence of `PLEX_PREFERENCE*`, the six `setPref` keys, the
|
|
`HardwareAcceleratedCodecs` string — was read from digest **`sha256:e0ab27395614…`**
|
|
(`plexmediaserver 1.43.4.10903`).
|
|
|
|
The deployment runs **`plexinc/pms-docker:latest` with `imagePullPolicy: Always`**. A silent pull can
|
|
therefore change the behaviour this feature depends on **with no commit in this repo**, while the
|
|
manifest still looks identical. Symptom to watch for: hardware transcoding that worked and quietly
|
|
stopped, with no git change to point at.
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex \
|
|
-o jsonpath="{.items[0].status.containerStatuses[0].imageID}"; echo'
|
|
```
|
|
|
|
If that digest is not the one above, **re-verify the hook before trusting this page**:
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- ls /etc/cont-init.d/'
|
|
```
|
|
|
|
`45-plex-hw-transcode-and-connected-tuner` still present ⇒ the group mechanism is still the one
|
|
described here. Gone or renamed ⇒ stop and re-measure. In particular the "no `supplementalGroups`
|
|
needed" conclusion in `catalog/plex.yaml` and `schemas/renderer-contract.md` is only true while that
|
|
hook exists.
|
|
|
|
## When to escalate or stop
|
|
|
|
- **Do not add `supplementalGroups` or a `podSecurityContext` to `catalog/plex.yaml`.** It would be
|
|
**inert**, and it would mislead the next person. Measured in-container 2026-09-30: the image's
|
|
`cont-init.d` hook stats each `/dev/dri` char device and `usermod -a -G`s the `plex` user into its
|
|
group *inside* the container, and the service then execs via `s6-setuidgid plex`, which **replaces**
|
|
the process group list from `/etc/group` rather than appending — `Plex Media Server` runs with
|
|
`Groups: 100 1000`, root's gid `0` (which PID 1 does hold) gone. Anything Kubernetes supplies is
|
|
discarded before Plex starts. `render.py --selftest` asserts that absence deliberately.
|
|
- **Do not reach for `privileged: true`.** Kyverno `restrict-host-namespaces` is Enforce and will
|
|
reject it. If the cgroup gate is the blocker, the answer is the Intel device plugin (cause #2).
|
|
- **Do not `kubectl edit`/`patch` the Deployment to add or change the device mount.** Flux reconciles
|
|
`main`; a hand-applied change is drift that the next reconcile reverts or, worse, adopts. Mutating
|
|
`kubectl` and `flux reconcile` are **operator** actions, and the durable change is a git change
|
|
through a branch, a PR and a green gate.
|
|
- **Do not edit `Preferences.xml` on the PVC**, never `cat` it, and never `sops -d` anything into a
|
|
terminal. It holds `PlexOnlineToken`.
|
|
- **Do not delete the `plex-config` PVC to "start clean".** It is the library database, metadata and
|
|
watch state (`policy: daily-offcluster`). The transcode toggle is one preference, not a reason to
|
|
lose the DB.
|
|
- **Stop and get the operator** if the device cannot be opened from the pod (cause #2 confirmed). The
|
|
next step is a platform change — the Intel device plugin — with its own PR, not more poking at this
|
|
workload.
|
|
|
|
## Known gaps, deliberately not fixed here
|
|
|
|
Recorded so they are findable, and so nobody assumes they were overlooked:
|
|
|
|
- **Transcode scratch is on the wrong medium, and it is invisible.** The image sets
|
|
`TranscoderTempDirectory=/transcode`, which is **not a volume** — it is the container's writable
|
|
overlay, i.e. the node's `/data`. That is NVMe on prod-2 but **SATA on prod-1 and prod-3**, so
|
|
transcode scratch performance changes silently whenever the pod is rescheduled. Both candidate fixes
|
|
need a **schema** change and are therefore out of scope here: pin Plex to prod-2 (needs a
|
|
`nodeSelector` field the schema does not have), or give `/transcode` a `medium: Memory` emptyDir and
|
|
transcode in RAM (~31 GiB per node; needs an emptyDir field the schema does not have). The RAM option
|
|
is the better one, because it removes the node asymmetry outright instead of working around it.
|
|
- **GuC/HuC firmware is not loaded** (`i915.enable_guc=0`). The cost is low-power H.264 **encode**
|
|
quality only — decode, and the feature on this page, are unaffected. Changing it needs a kernel
|
|
parameter and a **serialized rolling reboot**, which is an operator action (CLAUDE.md §5), and this
|
|
cluster has a documented history of that going badly (a prod node hung on a warm reboot on
|
|
2026-09-03). Not worth coupling to this change.
|
|
|
|
---
|
|
Source of truth: `runbooks/plex-quicksync.md`. Index: `/runbooks/`
|
|
The node measurements (all three prod nodes) and the in-container image measurements cited here were
|
|
taken **2026-09-30** against `plexinc/pms-docker` digest `sha256:e0ab27395614…`
|
|
(`plexmediaserver 1.43.4.10903`). Hardware transcoding had **not** been observed working at the time
|
|
of writing, and the Plex-side toggle was still unset.
|