Files
home-network-k3s-impl/runbooks/plex-quicksync.md
jakman85andClaude Opus 5 0fee584e3a feat(plex): opt into hardware.gpu: intel-quicksync, and write the runbook for the step GitOps cannot reach
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]>
2026-10-01 21:05:17 -05:00

16 KiB

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 -Gs 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.