Hardware transcoding is confirmed working on prod (2026-10-02): VAAPI for both decode and encode on /dev/dri/renderD128, with zero-copy, measured in `Plex Media Server.log` in the running pod. The device-plugin mechanism from #37 works end to end. `runbooks/plex-quicksync.md` said the Plex dashboard showing `Transcode (hw)` was "the *only* proof available, and nothing else will ever tell you". That is false: Dashboard -> Activity showed nothing while the feature was fully working, so the page's authoritative check was silent on a healthy system and sent the operator hunting a fault that did not exist. Three inference errors corrected, two kept as named false leads: 1. The LOG is the primary check, not the dashboard. The grep ships in its REDACTING form -- that log carries API URLs with `X-Plex-Token` (CLAUDE.md §6). Also recorded: `Plex Transcoder Statistics.log` is the WRONG file (XML <Variant/> records, no ffmpeg output), and why `grep -a` is required. The dashboard is kept but demoted to best-effort, with its silence stated to prove nothing. 2. An fd snapshot is not evidence. A /proc/*/fd sweep found no holder of renderD128 while hardware transcoding was active -- sessions are short and the scan landed in a gap. The reliable process-level check is the transcoder's argv (-hwaccel, -hwaccel_device, vaapi, renderD128). 3. A missing preference key means nothing. `HardwareAcceleratedCodecs` was absent from all 27 attributes with the toggle on and working, while `HardwareDevicePath` was written and the log said "hardware transcoding: enabled". Only `="0"` is conclusive; the log settles it. Also stale and now fixed: GID 992 is a hostPath-era number. Under the plugin the injected device is root:root, so the image's hook resolves GID 0 and adds `plex` to the root group (`Groups: 0 100 1000`, `groups=1000(plex),0(root),100(users)`). Marked as an inherent property of the plugin's DeviceSpec, not a defect, and explicitly not a reason to reintroduce supplementalGroups (still a no-op -- s6-setuidgid rebuilds the group list from /etc/group). Added a per-title fallback entry (UHD 630 has no AV1 decode), labelled a HYPOTHESIS: the title that fell back was never checked. `runbooks/README.md`'s index entry was stale the same two ways and is corrected. Unchanged on purpose: the "STOP HERE IF PLEX IS Pending" section, the escape hatch, and the no-alert-is-possible reasoning (no metricsEndpoint, no ServiceMonitor, nothing a rule could evaluate). Documentation only -- no catalog/, schemas/, apps/ or platform/ change. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
562 lines
33 KiB
Markdown
562 lines
33 KiB
Markdown
# plex-quicksync — making Plex actually use Intel Quick Sync, and proving it did
|
|
|
|
**Status: hardware transcoding is CONFIRMED WORKING on prod as of 2026-10-02** — VAAPI for both
|
|
decode *and* encode, zero-copy, measured in Plex's own server log (see "How to verify"). The manual
|
|
toggle has been ticked. This page is therefore now mostly a **verification and regression**
|
|
procedure, and its old verification section was **wrong** — see the correction at the top of
|
|
"How to verify".
|
|
**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 `resources.limits: gpu.intel.com/i915: 1` block), `catalog/render.py` (the
|
|
mechanism), `schemas/renderer-contract.md` `### hardware` (the contract),
|
|
`platform/intel-gpu-plugin/` (the DaemonSet that advertises the resource).
|
|
**Related:** `runbooks/plex-claim.md` — the other Plex procedure with a manual last mile.
|
|
|
|
## STOP HERE IF PLEX IS `Pending` / `Unschedulable`
|
|
|
|
That is this feature's **one serious failure mode** and it **stalls the whole app tier**
|
|
(`apps-wave-0` is `wait: true`; `apps-wave-1` `dependsOn` it). Since 2026-10-01 Plex requests the
|
|
extended resource `gpu.intel.com/i915: 1`, which is a **hard scheduling constraint** — if the
|
|
`intel-gpu-plugin` DaemonSet is not advertising it on at least one schedulable node, Plex does not
|
|
start at all. Go to **"Plex will not schedule"** below; it is the first troubleshooting section on
|
|
purpose.
|
|
|
|
**The escape hatch, in one sentence:** delete the two `hardware:` lines from `catalog/plex.yaml`,
|
|
re-render, and merge — Plex returns to software transcoding and the tier unblocks. Exact commands
|
|
are in **"Plex will not schedule" → remedy B**.
|
|
|
|
## 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 **and openable** | **GitOps.** `hardware.gpu: intel-quicksync` in `catalog/plex.yaml` renders `resources.limits: {gpu.intel.com/i915: 1}` on the app container. The `intel-gpu-plugin` DaemonSet (`platform/intel-gpu-plugin/`) injects the device **and the matching device-cgroup allow rule**. Flux applies it. | Done by merging that change. |
|
|
| Plex is *told* to use it | **The operator, by hand, in the Plex web UI.** | **Done, 2026-10-02**, and confirmed from the **log** rather than from a preference key. `HardwareAcceleratedCodecs` is **still absent** from `Preferences.xml` with hardware transcoding working, so its absence proves nothing — see first-command #4. |
|
|
|
|
**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.
|
|
|
|
### Why a resource request and not a `/dev/dri` hostPath
|
|
|
|
Because the hostPath was **tried and measured not to work**, and knowing that is the only thing that
|
|
will stop someone "simplifying" it back. Merged 2026-09-30, measured in the live pod on k3s-prod-2 on
|
|
**2026-10-01**: the device node was plainly **visible** in the container
|
|
(`crw-rw---- root video2 226,128 renderD128`) and **could not be opened** —
|
|
|
|
| attempt | result |
|
|
|---|---|
|
|
| `uid 0`, `O_RDWR` | **`EPERM`** "Operation not permitted" |
|
|
| `uid 1000`, `O_RDWR` | **`EPERM`** "Operation not permitted" |
|
|
|
|
`EPERM` is the **device cgroup** refusing, not `EACCES` from file permissions. The two look identical
|
|
from the outside and are not the same problem. Proven three ways: root on the **host** opened the
|
|
same device `rc=0`; the node is unified `cgroup2fs`; and the container's runc `config.json` read
|
|
`linux.resources.devices: [{allow: False, access: 'rwm'}]` with `linux.devices: None` — **one
|
|
deny-all rule, no allow entry for major 226**. A hostPath is a *filesystem* operation: it binds the
|
|
device node in and adds **no cgroup allow rule**, because Kubernetes cannot infer one from a volume.
|
|
|
|
Two non-workarounds, so nobody spends an evening on them: **`uid 0` failed too**, so running as root
|
|
does not bypass the cgroup gate; and **`privileged: true` is not available** — Kyverno
|
|
`restrict-host-namespaces` is **Enforce** on app namespaces and rejects it.
|
|
|
|
The device plugin is what produces the allow rule — in the v0.37.0 source,
|
|
`createDeviceSpecsFromDrmFiles` emits `DeviceSpec{…, Permissions: "rw"}`, which the kubelet turns
|
|
into both the device injection and the matching cgroup allow.
|
|
|
|
**The failure mode that trade bought, stated plainly.** Before, a missing device meant the pod ran
|
|
and **silently software-transcoded** — invisible, but harmless to everything else. Now, a missing
|
|
*resource* means Plex is **Unschedulable** and the app tier stalls. That is strictly worse in blast
|
|
radius and it is **unavoidable**: an extended resource is a hard scheduling constraint, and it is
|
|
also the only thing that produces the cgroup allow rule. There is no graceful-degradation variant.
|
|
The one improvement is that the loss is no longer silent.
|
|
|
|
What this does **not** do, so nobody reads more into a green gate than is there: it does not enable
|
|
hardware transcoding (that is the manual toggle below — done 2026-10-02), a green gate only proves
|
|
the **manifest renders**, and it writes no `nodeSelector` — the resource itself constrains Plex to nodes the plugin
|
|
advertises, which is the scheduler's doing and not a selector in this repo.
|
|
|
|
## 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
|
|
|
|
**0. Is Plex running at all?** Ask this first now — the current mechanism can stop it starting.
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex -o wide'
|
|
```
|
|
|
|
`Pending` ⇒ go straight to **"Plex will not schedule"**. Running ⇒ carry on.
|
|
|
|
**1. Do the nodes advertise the resource, and is the plugin Ready?** This replaces the old "is the
|
|
hostPath mounted" check, and it is now the first thing that can be wrong.
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod get nodes -o json \
|
|
| jq -r ".items[] | \"\(.metadata.name) i915=\(.status.allocatable[\"gpu.intel.com/i915\"] // \"ABSENT\")\""'
|
|
```
|
|
|
|
Expect `i915=1` on each of the three prod nodes (measured 2026-10-01: all three). Any `ABSENT` ⇒ that
|
|
node cannot run Plex. Then the DaemonSet behind it:
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n intel-gpu-plugin get daemonset intel-gpu-plugin'
|
|
```
|
|
|
|
Expect **3 desired / 3 ready / 3 available** (measured 2026-10-01). Anything less is the cause, not a
|
|
symptom — fix it there, in `platform/intel-gpu-plugin/`, not here.
|
|
|
|
**2. Did Flux apply the current manifest?** `gpu.intel.com/i915` on the app container means the
|
|
GitOps half landed:
|
|
|
|
```
|
|
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].resources.limits}"; echo'
|
|
```
|
|
|
|
A `/dev/dri` **volumeMount** instead ⇒ the running pod predates the 2026-10-01 merge and is on the
|
|
mechanism that does not work. 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.
|
|
|
|
**3. Is the device visible inside the container, and can Plex *open* it?** This is the decisive test
|
|
of the whole mechanism, and **it must be run as uid 1000**, because that is what Plex runs as.
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- ls -l /dev/dri'
|
|
```
|
|
|
|
Expect `renderD128`, mode `crw-rw----`. The plugin injects it; there is no longer any
|
|
`DirectoryOrCreate` empty-directory case — if the resource were missing the pod would not be running
|
|
to exec into. Then the open, **as the app user**:
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
|
s6-setuidgid plex sh -c "exec 3<>/dev/dri/renderD128 && echo RW-OPEN-OK || echo RW-OPEN-DENIED"'
|
|
```
|
|
|
|
`RW-OPEN-OK` **as uid 1000** is the pass condition. **Passing as `uid 0` is weaker evidence and must
|
|
not be accepted as proof** — Plex runs as 1000, and the whole point of the 2026-10-01 change was a
|
|
cgroup rule that uid 0 could not buy its way past either. `RW-OPEN-DENIED` with the device plainly
|
|
visible is the **device-cgroup** failure — i.e. the plugin's allow rule did not arrive; see "Likely
|
|
causes" #2.
|
|
|
|
**4. Is the toggle on?** **You cannot answer this from `Preferences.xml` alone.** That mistake is on
|
|
this page's record: it told readers to treat a missing `HardwareAcceleratedCodecs` as "never
|
|
enabled", and on 2026-10-02 that key was **absent from all 27 attributes** while hardware
|
|
transcoding was demonstrably working. Named keys only — never the whole file, it holds
|
|
`PlexOnlineToken`:
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
|
grep -o -e "HardwareAcceleratedCodecs=\"[01]\"" -e "HardwareDevicePath=\"[^\"]*\"" \
|
|
"/config/Library/Application Support/Plex Media Server/Preferences.xml" || echo NEITHER-KEY'
|
|
```
|
|
|
|
| Reading | What it actually means |
|
|
|---|---|
|
|
| `HardwareDevicePath="…"` **present** | **Positive signal.** Plex writes this when hardware acceleration is configured; it was present on 2026-10-02 alongside a working VAAPI pipeline. |
|
|
| `HardwareAcceleratedCodecs` **absent** | **Nothing. Neither on nor off.** Measured absent with the feature working (2026-10-02). Do not diagnose from it, and do not write it into the file to "fix" it. |
|
|
| `HardwareAcceleratedCodecs="0"` | explicitly **off** — the only conclusive reading, and only in the negative. |
|
|
| `HardwareAcceleratedCodecs="1"` | on. |
|
|
|
|
**The log settles it, this file does not.** Go to "How to verify"; the `hardware transcoding:
|
|
enabled` and `final decoder/final encoder` lines are the verdict.
|
|
|
|
## 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. **But do not expect to see it in `Preferences.xml` after ticking
|
|
the box:** on 2026-10-02, with the toggle on and hardware transcoding working, the key was absent and
|
|
`HardwareDevicePath` was what had been written. The key exists in the binary; Plex does not
|
|
necessarily persist it. 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 — the log, not the dashboard
|
|
|
|
**Correction, 2026-10-02.** Until today this page said the Plex dashboard showing `Transcode (hw)`
|
|
was "the *only* proof available, and nothing else will ever tell you". **That was false and it cost
|
|
the operator real time:** hardware transcoding was fully working — VAAPI decode *and* encode, with
|
|
zero-copy — while **Dashboard → Activity showed nothing of the sort**. A check that is silent while
|
|
the feature works does not just fail to prove anything; it sends someone hunting a fault that does
|
|
not exist. The authoritative check is **Plex's own server log**.
|
|
|
|
### The primary check — run this one
|
|
|
|
```
|
|
./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n plex exec deploy/plex -- \
|
|
grep -aiE "hardware transcoding: (final|using)" \
|
|
"/config/Library/Application Support/Plex Media Server/Logs/Plex Media Server.log" \
|
|
| sed -E "s/(X-Plex-Token|token)=[^&[:space:]]*/\1=REDACTED/g" | tail -5'
|
|
```
|
|
|
|
| Line | Verdict |
|
|
|---|---|
|
|
| `hardware transcoding: final decoder: vaapi, final encoder: vaapi` | **PASS.** Hardware in **both** directions. This is the pass condition. |
|
|
| `hardware transcoding: using zero-copy transcoding` | Best case — frames never round-trip through the CPU. Observed on prod 2026-10-02. |
|
|
| `hardware transcoding: final decoder: , final encoder: ` (both empty) | It **fell back to software** for that session. |
|
|
| no matching line at all | No transcode has been attempted since the log rolled — force one (below), then look again. Absence is not a failure. |
|
|
|
|
The 2026-10-02 reading on prod, in full, for the shape to recognise:
|
|
|
|
```
|
|
Codecs: hardware transcoding: testing API vaapi for device '/dev/dri/renderD128'
|
|
(Intel CoffeeLake-S GT2 [UHD Graphics 630])
|
|
hardware transcoding: using hardware decode accelerator vaapi
|
|
hardware transcoding: zero-copy support present
|
|
hardware transcoding: using zero-copy transcoding
|
|
hardware transcoding: final decoder: vaapi, final encoder: vaapi
|
|
```
|
|
|
|
To make a line appear, start a playback that **forces a transcode**: a client that cannot
|
|
direct-play the source, or an explicitly lower quality. Direct Play / Direct Stream never reaches
|
|
the transcoder and logs nothing.
|
|
|
|
### Three things to get right about that command
|
|
|
|
1. **Always keep the `sed`.** `Plex Media Server.log` carries API URLs containing
|
|
**`X-Plex-Token=…`**, a live plex.tv credential. Any output that might land in a ticket, a
|
|
transcript or a chat window must be redacted — CLAUDE.md §6, and a credential has already been
|
|
leaked into a transcript in this repo exactly this way. The redacting form above is the form to
|
|
copy; do not "simplify" the pipe out of it.
|
|
2. **`Plex Transcoder Statistics.log` is the WRONG FILE.** The name is irresistible and someone will
|
|
reach for it. It holds XML `<Variant .../>` session records — **not** ffmpeg/VAAPI output. There
|
|
is no decoder/encoder verdict anywhere in it. The file you want is `Plex Media Server.log`.
|
|
3. `grep -a` is deliberate: the log carries occasional non-UTF-8 bytes, and without `-a` GNU grep
|
|
reports "binary file matches" and prints nothing — which reads exactly like "no hardware
|
|
transcoding".
|
|
|
|
### Secondary and unreliable: the Plex dashboard
|
|
|
|
**Dashboard → Activity** *may* label a transcoding session `Transcode (hw)`. If it does, it agrees
|
|
with the log and you are done. **If it does not, that proves nothing at all** — measured 2026-10-02:
|
|
hardware transcoding active and zero-copy, dashboard silent. Never conclude "software transcoding"
|
|
from the dashboard and never open an investigation on its say-so. Kept here only because it is the
|
|
fastest glance available; it is a convenience, **not evidence**.
|
|
|
|
### Process-level, while a transcode is in flight: the transcoder's argv
|
|
|
|
This shows Plex *requesting* hardware, as distinct from reporting it afterwards. Get a shell in the
|
|
pod (if `-it` has no TTY under `dev.sh run`, wrap each line in `sh -c "…"` instead):
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex exec -it deploy/plex -- sh'
|
|
```
|
|
|
|
then, inside the pod:
|
|
|
|
```
|
|
ps -eo pid,user,comm | grep -i transcoder
|
|
tr '\0' ' ' < /proc/<transcoder-pid>/cmdline | grep -oE '(-hwaccel[a-z_]*|vaapi|renderD[0-9]+)'
|
|
```
|
|
|
|
`-hwaccel`, `-hwaccel_device`, `vaapi`, `renderD128` present ⇒ Plex is asking for hardware. Keep the
|
|
`grep -oE`: the raw argv contains media paths and session identifiers you do not want in a
|
|
transcript. This only works while a session is actually transcoding.
|
|
|
|
### Documented false lead: scanning `/proc/*/fd` for a holder of `renderD128`
|
|
|
|
**It returns nothing even when hardware transcoding is working.** Measured 2026-10-02: a
|
|
`/proc/*/fd` sweep inside the pod found **no** process holding `/dev/dri/renderD128`, while the log
|
|
showed `final decoder: vaapi, final encoder: vaapi` for sessions either side of the scan. Transcode
|
|
sessions are **short** and the scan landed in a gap between two of them. An empty fd scan is a
|
|
**timing artefact, not a finding** — do not report it as evidence of software transcoding, and do
|
|
not escalate on it. Use the argv check above (which has the same timing constraint but tells you
|
|
something when it does catch a session), or just read the log, which is retrospective and therefore
|
|
does not care about timing.
|
|
|
|
### Why there is no alert — this half was always right
|
|
|
|
`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.
|
|
|
|
## Likely causes, most likely first
|
|
|
|
When the **log** says it fell back to software — `final decoder: , final encoder: ` with both
|
|
fields empty. (Not when the dashboard is quiet: the dashboard's silence means nothing, see "How to
|
|
verify".)
|
|
|
|
1. **The toggle is off.** Historically the most likely answer and the reason this page exists. It was
|
|
ticked on 2026-10-02 and should stay ticked, but a `/config` restore or a stray UI save can unset
|
|
it. Remedy: the manual step above. **Do not diagnose this from a missing
|
|
`HardwareAcceleratedCodecs`** — see first-command #4; only `="0"` is conclusive, and the log line
|
|
`hardware transcoding: enabled` is the real signal that the toggle took.
|
|
|
|
2. **The device cannot be `open()`ed — the plugin's cgroup allow rule did not arrive.** Symptom:
|
|
first-command #3 shows `renderD128` present and correctly owned, and the open returns
|
|
`RW-OPEN-DENIED` / `Operation not permitted`. This is `EPERM` from the **device cgroup**, not
|
|
`EACCES` from file permissions. **This is no longer a design risk — it is the measured reason the
|
|
design is what it is** (2026-10-01; see "Why a resource request and not a `/dev/dri` hostPath").
|
|
If it recurs *under the current mechanism*, the plugin is the suspect, not the manifest: confirm
|
|
the pod really did consume the resource
|
|
|
|
```
|
|
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].resources}"; echo'
|
|
```
|
|
|
|
and then check the plugin pod on Plex's node (first-command #1). **Do not reach for
|
|
`privileged: true`** — Kyverno `restrict-host-namespaces` is Enforce and rejects it, and uid 0
|
|
failed the same open in 2026-09-30's measurement anyway. **Do not re-add a `/dev/dri` hostPath**;
|
|
that is the mechanism this replaced, and it was measured not to work.
|
|
|
|
3. **The pod landed on a node whose GPU went away.** Under the current mechanism this does **not**
|
|
present as an empty `/dev/dri` — the node simply stops advertising `gpu.intel.com/i915` and Plex
|
|
either never schedules there or gets evicted off. So this cause now shows up as **`Pending`**, not
|
|
as silent software transcoding: see "Plex will not schedule". All three prod nodes carried
|
|
`renderD128` with `i915` bound when measured (2026-09-30) and all three advertised the resource on
|
|
2026-10-01, so this means a node changed — a rebuild, a BIOS reset, or a driver regression.
|
|
**Fix the node.**
|
|
|
|
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 reaches the transcoder,
|
|
so no `hardware transcoding:` line is written for it at all. An *absent* line is this, not a
|
|
failure — confirm a transcode actually happened before treating anything as a hardware fault.
|
|
|
|
6. **The *source* codec may be one this iGPU cannot decode — HYPOTHESIS, not a confirmed
|
|
diagnosis.** The symptom that points here is fallback on **particular titles** while other
|
|
sessions are hardware. Measured on prod 2026-10-02, *between* two fully hardware sessions, one
|
|
session logged:
|
|
|
|
```
|
|
hardware transcoding: enabled, but no hardware decode accelerator found
|
|
hardware transcoding: final decoder: , final encoder:
|
|
```
|
|
|
|
The platform was demonstrably working either side of it, so the likely explanation is the **file**,
|
|
not the cluster: UHD 630's media engine decodes H.264, HEVC 8-bit and 10-bit, and VP9 — but **not
|
|
AV1**, and not 10-bit 4:2:2 HEVC. **This was NOT verified against that particular title.** It is
|
|
inferred from the hardware's capability set and is written here as the lead to check first, not as
|
|
a finding. So: fallback on *all* files is a platform problem (causes 1-4); fallback on *some*
|
|
files, check the source codec before suspecting the device, and expect occasional legitimate
|
|
per-title fallbacks to be normal rather than a fault.
|
|
|
|
## Plex will not schedule
|
|
|
|
**Symptom:** the Plex pod sits `Pending`, and `apps-wave-0` does not go Ready, which holds
|
|
`apps-wave-1` behind it. This is the cost of the 2026-10-01 mechanism and the section you want at
|
|
2am.
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n plex describe pod -l app.kubernetes.io/name=plex | tail -20'
|
|
```
|
|
|
|
`0/3 nodes are available: 3 Insufficient gpu.intel.com/i915` (or similar) confirms it: **nothing is
|
|
advertising the resource Plex requires**. Plex's schedulability is coupled to the `intel-gpu-plugin`
|
|
DaemonSet, deliberately and unavoidably — the plugin is also the only thing that produces the
|
|
device-cgroup allow rule, so there is no variant of this feature that degrades gracefully.
|
|
|
|
**Remedy A — fix the plugin (preferred).** Run first-command #1. If the DaemonSet is not 3/3, or the
|
|
nodes show `i915=ABSENT`, the fault is in `platform/intel-gpu-plugin/` or on the node, and that is
|
|
where to fix it. Check its Flux Kustomization reconciled:
|
|
|
|
```
|
|
infra/devcontainer/dev.sh run bash -c \
|
|
'kubectl --context k3s-prod -n flux-system get kustomization intel-gpu-plugin'
|
|
```
|
|
|
|
A plugin that is merely **not yet reconciled** resolves itself; one that is failing does not.
|
|
|
|
**Remedy B — the escape hatch: give up hardware transcoding and unblock the tier.** One field, no
|
|
code change. In a branch (never on `main`, never `kubectl edit` — Flux reconciles `main` and would
|
|
revert you):
|
|
|
|
1. delete these two lines from `catalog/plex.yaml`:
|
|
|
|
```yaml
|
|
hardware:
|
|
gpu: intel-quicksync
|
|
```
|
|
|
|
2. re-render the golden:
|
|
|
|
```
|
|
python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation
|
|
```
|
|
|
|
The only change must be `apps/wave-0/plex.yaml`, losing its `resources` block.
|
|
3. gate, PR, merge. Plex schedules anywhere and **software-transcodes**; the app tier unblocks.
|
|
|
|
Re-adding the field later is the same edit in reverse. Nothing about the Plex-side toggle or the
|
|
`/config` PVC is affected either way — do **not** delete the PVC as part of this.
|
|
|
|
## 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: the hook reads the injected device's **own** ownership, which under the device plugin
|
|
is `root:root`, so it resolves GID `0` (see "When to escalate or stop"). 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. The image's `cont-init.d` hook stats each
|
|
`/dev/dri` char device and `usermod -a -G`s the `plex` user into **that device's** group, inside
|
|
the container; the service then execs via `s6-setuidgid plex`, which **rebuilds** the process group
|
|
list from `/etc/group` rather than appending to it. Anything Kubernetes supplies is therefore
|
|
discarded before Plex starts. `render.py --selftest` asserts that absence deliberately.
|
|
- **GID `992` is a hostPath-era number — do not expect it, and do not read the root group as a
|
|
defect.** Under the device plugin the injected `/dev/dri/renderD128` arrives **`root:root`**, so
|
|
the hook resolves GID **`0`** and adds `plex` to the **root group**. Measured 2026-10-02:
|
|
`Plex Media Server` runs with `Groups: 0 100 1000`, and `id plex` reports
|
|
`groups=1000(plex),0(root),100(users)`. Under the old hostPath the device was `root:render`
|
|
(GID 992 on these nodes) and the reading was `Groups: 44 100 992 1000`. **992 is now irrelevant**:
|
|
if any page, comment or older handoff tells you to expect it, that text is stale and the system
|
|
you are looking at is working. The root-group membership is an inherent property of the plugin's
|
|
`DeviceSpec` — it injects the host device with its host ownership — **not** a defect, and **not** a
|
|
reason to reintroduce `supplementalGroups`, which would still be a no-op for the reason in the
|
|
bullet above.
|
|
- **Do not reach for `privileged: true`.** Kyverno `restrict-host-namespaces` is Enforce and will
|
|
reject it — and it would not help: `uid 0` failed the same `open()` in the 2026-09-30 measurement,
|
|
because root does not bypass the cgroup device gate.
|
|
- **Do not re-add a `/dev/dri` hostPath, and do not "simplify" the resource request into one.** That
|
|
was the original mechanism and it was measured non-functional on 2026-10-01 (device-cgroup `EPERM`
|
|
with the device plainly visible). The device plugin's `DeviceSpec{… Permissions: "rw"}` is what
|
|
grants access; a volume cannot.
|
|
- **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 as uid 1000 (cause #2
|
|
confirmed) *under the current mechanism* — note that this open **passed** on 2026-10-02
|
|
(`RW-OPEN-OK` as uid 1000 via `s6-setuidgid plex`), so a failure now is a regression. The next step is a platform change in
|
|
`platform/intel-gpu-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/`
|
|
|
|
**Hardware transcoding is CONFIRMED WORKING on prod, measured 2026-10-02.** From
|
|
`Plex Media Server.log` in the running pod: API `vaapi` on `/dev/dri/renderD128`
|
|
(`Intel CoffeeLake-S GT2 [UHD Graphics 630]`), `using hardware decode accelerator vaapi`,
|
|
`zero-copy support present`, `using zero-copy transcoding`, and
|
|
`final decoder: vaapi, final encoder: vaapi`. Also measured in the same pod: `RW-OPEN-OK` opening
|
|
`/dev/dri/renderD128` as uid 1000 via `s6-setuidgid plex`; the pod spec carrying
|
|
`{"limits":{"gpu.intel.com/i915":"1"},"requests":{"gpu.intel.com/i915":"1"}}`; `Groups: 0 100 1000`
|
|
and `id plex` → `groups=1000(plex),0(root),100(users)`; and `HardwareDevicePath` present in
|
|
`Preferences.xml` while `HardwareAcceleratedCodecs` was absent from all 27 attributes.
|
|
|
|
Earlier readings, kept for provenance and **superseded** where they conflict: the node measurements
|
|
(all three prod nodes) and the in-container image measurements taken **2026-09-30** against
|
|
`plexinc/pms-docker` digest `sha256:e0ab27395614…` (`plexmediaserver 1.43.4.10903`); the hostPath
|
|
`EPERM` open results and the `Groups: 44 100 992 1000` / GID-992 group reading, **2026-10-01, under
|
|
the hostPath mechanism that no longer exists**; and the `gpu.intel.com/i915: 1` allocatable and
|
|
`intel-gpu-plugin` DaemonSet readings, **2026-10-01**.
|
|
|
|
What this page still does **not** establish: that any particular title's software fallback was caused
|
|
by its source codec (cause 6 is an unverified hypothesis), and that Plex's dashboard *ever* labels a
|
|
session `Transcode (hw)` on this deployment — it has not been observed doing so, which is exactly why
|
|
the log is now the primary check.
|