Running Plex, Jellyfin and Emby transcodes as Kubernetes pods
How kube-arr swaps a media server's transcoder for a dispatcher that runs each transcode as its own pod, and cleans those pods up even when the dispatcher is killed.
Transcoding is the one heavy, bursty job in a home media stack. A couple of 4K streams can take every core on the media server's node, and the server has no idea there's a GPU node sitting idle next door. kube-plex solved this for Plex years ago by running each transcode as a pod. I wanted the same in kube-arr, for Plex, Jellyfin and Emby.
It's one setting:
helm upgrade media simplyzee/kube-arr --reuse-values --set transcode.enabled=trueHere's what happens behind it.
Swapping the transcoder
All three servers call an external binary to transcode: Plex Transcoder, or ffmpeg for Jellyfin and Emby. kube-arr replaces that binary with kube-arr-transcoder, a small Go program with no dependencies outside the standard library.
The swap uses two pieces the images already support:
- An init container copies the dispatcher (a static binary in a distroless image) into a shared
emptyDir. - LinuxServer.io images run anything in
/custom-cont-init.das root before the server starts. A script there moves the real binary to<binary>.realand copies the dispatcher into its place.
The server never knows. It calls ffmpeg as it always has.
Deciding what becomes a pod
Not every call is a transcode. Servers run ffmpeg -version, -encoders and -hwaccels at startup and expect an instant answer, and some calls stream output straight back over a pipe, which only the local process can do. So the dispatcher only sends real transcodes to a pod, ones that write to a file:
// Trimmed from transcoder/decide.go
func shouldDispatch(args []string) bool {
if len(args) <= 1 {
return false
}
for _, a := range args {
if infoFlags[a] { // -version, -encoders, -hwaccels and friends
return false
}
}
out := args[len(args)-1]
switch {
case out == "-", out == "/dev/null", strings.HasPrefix(out, "pipe:"):
return false // output comes back over a pipe
case strings.HasPrefix(out, "-"):
return false // not a normal transcode
}
return true
}Everything else is handed to the real binary with syscall.Exec, so local calls cost nothing.
Building the pod
The chart renders a pod template into a ConfigMap, and the dispatcher fills it in for each transcode:
- Same image as the server, so the transcoder, its codecs and its libraries match exactly.
- Same arguments, environment and working directory. Servers pass a lot through the environment, and Emby's ffmpeg only starts from its own folder (a story of its own).
- The server's config and media volumes. All three write transcode segments under
/config, so the pod writes them where the server expects to find them. - An owner reference to the server pod, and
activeDeadlineSecondsas a hard limit.
Talking to the API needs no client library: a few REST calls with the service account token, and a Role that can only create, read and delete pods in its own namespace.
Progress, exit codes and stopping
Jellyfin and Emby read ffmpeg's progress from its output. The dispatcher waits for the pod to start, then follows its log and copies it to stderr, so the server sees progress as if ffmpeg were running locally. When the pod finishes, the dispatcher exits with the container's exit code.
Stopping is the part that has to be right. A server can stop ffmpeg by sending q on its stdin, which is how Jellyfin does it, or with a signal. The dispatcher handles both and deletes the pod. But if the server kills the dispatcher with SIGKILL, nothing in the process gets to run.
So every dispatch starts a small reaper first: a copy of the same binary in its own session, holding one end of a pipe.
cmd := exec.Command(self, reaperArg, ns, name)
cmd.ExtraFiles = []*os.File{r} // the reaper reads the pipe as fd 3
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}The reaper blocks reading the pipe. However the dispatcher dies, the kernel closes its end, the read returns, and the reaper deletes the pod. If the whole server pod goes, the owner reference cleans up instead.
Falling back, and two Plex details
If a pod can't start, because the cluster is full or an image won't pull, the dispatcher deletes it and runs the transcode locally. Playback is slower, but it works.
Plex needs two extra things. Its transcoder reports progress to http://127.0.0.1:32400, which inside a pod is the pod itself, so the dispatcher rewrites that to the Plex Service. And Plex has to accept those calls without a token, which means adding your pod network to "List of IP addresses and networks that are allowed without auth" in Plex's network settings.
Where pods run
Because pods mount the server's config volume, the default keeps them on the server's node, which is what ReadWriteOnce storage needs. With ReadWriteMany storage you can set transcode.sameNodeAsServer=false and point them anywhere with transcode.nodeSelector and tolerations. That's where the GPU node comes in.
Testing it for real
Unit tests cover the decisions and use a fake API server. The interesting tests run in kind, one per media server. For all three, they check that the swap happened and that -version stays local. For Jellyfin and Emby they also check that:
- a real lavfi to x264 to HLS transcode runs as a pod, its segments land in the server's volume, and progress streams back;
- a long transcode that gets
SIGKILLed mid-stream still has its pod cleaned up.
That last one is the test I'd want if I were running it at home.
The code is in simplyzee/kube-arr, and the chart is at charts.simplyzee.dev.