Rewrite for newest PMS version. Add easy deploy helm chart.

This commit is contained in:
James Munnelly
2018-01-07 12:55:34 +00:00
parent 9721a0f210
commit d5d145409a
16 changed files with 712 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
FROM alpine:3.6
ADD kube-plex_linux_amd64 /kube-plex
+62
View File
@@ -0,0 +1,62 @@
# kube-plex
kube-plex is a scalable Plex Media Server solution for Kubernetes. It
distributes transcode jobs by creating pods in a Kubernetes cluster to perform
transcodes, instead of running transcodes on the Plex Media Server instance
itself.
## How it works
kube-plex works by replacing the Plex Transcoder program on the main PMS
instance with our own little shim. This shim intercepts calls to Plex
Transcoder, and creates Kubernetes pods to perform the work instead. These
pods use shared persistent volumes to store the results of the transcode (and
read your media!).
## Prerequisites
* A persistent volume type that supports ReadWriteMany volumes (e.g. NFS,
Amazon EFS)
* Your Plex Media Server *must* be configured to allow connections from
unauthorized users for your pod network, else the transcode job is unable to
report information back to Plex about the state of the transcode job. At some
point in the future this may change, but it is a required step in order to make
transcodes work right now.
## Setup
This guide will go through setting up a Plex Media Server instance on a
Kubernetes cluster, configured to launch transcode jobs on the same cluster
in pods created in the same 'plex' namespace.
1) Obtain a Plex Claim Token by visiting [plex.tv/claim](https://plex.tv/claim).
This will be used to bind your new PMS instance to your own user account
automatically.
2) Deploy the Helm chart included in this repository:
```bash
➜ helm install ./charts/kube-plex --name plex \
--namespace plex \
--set claimToken=[insert claim token here]
```
This will deploy a scalable Plex Media Server instance.
3) Access the Plex dashboard, either using `kubectl port-forward` or via your
services LoadBalancer IP address. Set up your Plex server and appropriate media.
4) Visit Settings->Server->Network and add your pod network subnet to the
`List of IP addresses and networks that are allowed without auth` (near the
bottom). For example, `10.100.0.0/16` is the subnet that pods in my cluster are
assigned IPs from.
You should now be able to play media from your PMS instance - pods will be
created to handle transcodes, and data automatically mounted in appropriately:
```bash
➜ kubectl get po -n plex
NAME READY STATUS RESTARTS AGE
plex-kube-plex-75b96cdcb4-skrxr 1/1 Running 0 14m
pms-elastic-transcoder-7wnqk 1/1 Running 0 8m
```
+21
View File
@@ -0,0 +1,21 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*~
# Various IDEs
.project
.idea/
*.tmproj
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
description: A Helm chart for Kubernetes
name: kube-plex
version: 0.1.0
+19
View File
@@ -0,0 +1,19 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range .Values.ingress.hosts }}
http://{{ . }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ template "fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get svc -w {{ template "fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ template "fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo http://$SERVICE_IP:{{ .Values.service.externalPort }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app={{ template "name" . }},release={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl port-forward $POD_NAME 8080:{{ .Values.service.externalPort }}
{{- end }}
+16
View File
@@ -0,0 +1,16 @@
{{/* vim: set filetype=mustache: */}}
{{/*
Expand the name of the chart.
*/}}
{{- define "name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
*/}}
{{- define "fullname" -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
+105
View File
@@ -0,0 +1,105 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ template "fullname" . }}
labels:
app: {{ template "name" . }}
chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: {{ template "name" . }}
release: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ template "name" . }}
release: {{ .Release.Name }}
spec:
serviceAccountName: "{{ template "fullname" . }}"
hostname: "{{ template "fullname" . }}"
initContainers:
- name: kube-plex-install
image: "{{ .Values.kubePlexImage.repository }}:{{ .Values.kubePlexImage.tag }}"
imagePullPolicy: {{ .Values.kubePlexImage.pullPolicy }}
command:
- cp
- /kube-plex
- /shared/kube-plex
volumeMounts:
- name: shared
mountPath: /shared
containers:
- name: plex
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
# We replace the PMS binary with a postStart hook to save having to
# modify the default image entrypoint.
lifecycle:
postStart:
exec:
command:
- bash
- -c
- |
#!/bin/bash
set -e
rm -f '/usr/lib/plexmediaserver/Plex Transcoder'
cp /shared/kube-plex '/usr/lib/plexmediaserver/Plex Transcoder'
# readinessProbe:
# httpGet:
# path: /
# port: 32400
env:
- name: TZ
value: Europe/London
# TODO: move this to a secret?
- name: PLEX_CLAIM
value: "{{ .Values.claimToken }}"
# kube-plex env vars
- name: PMS_INTERNAL_ADDRESS
value: http://{{ template "fullname" . }}:32400
- name: PMS_IMAGE
value: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
- name: KUBE_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: TRANSCODE_PVC
value: "{{ template "fullname" . }}-transcode"
- name: DATA_PVC
value: "{{ template "fullname" . }}-data"
- name: CONFIG_PVC
value: "{{ template "fullname" . }}-config"
volumeMounts:
- name: data
mountPath: /data
- name: config
mountPath: /config
- name: transcode
mountPath: /transcode
- name: shared
mountPath: /shared
resources:
{{ toYaml .Values.resources | indent 10 }}
{{- if .Values.nodeSelector }}
nodeSelector:
{{ toYaml .Values.nodeSelector | indent 8 }}
{{- end }}
volumes:
- name: data
persistentVolumeClaim:
claimName: "{{ template "fullname" . }}-data"
- name: config
persistentVolumeClaim:
claimName: "{{ template "fullname" . }}-config"
- name: transcode
persistentVolumeClaim:
claimName: "{{ template "fullname" . }}-transcode"
- name: shared
emptyDir: {}
+32
View File
@@ -0,0 +1,32 @@
{{- if .Values.ingress.enabled -}}
{{- $serviceName := include "fullname" . -}}
{{- $servicePort := .Values.service.externalPort -}}
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: {{ template "fullname" . }}
labels:
app: {{ template "name" . }}
chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
annotations:
{{- range $key, $value := .Values.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
spec:
rules:
{{- range $host := .Values.ingress.hosts }}
- host: {{ $host }}
http:
paths:
- path: /
backend:
serviceName: {{ $serviceName }}
servicePort: {{ $servicePort }}
{{- end -}}
{{- if .Values.ingress.tls }}
tls:
{{ toYaml .Values.ingress.tls | indent 4 }}
{{- end -}}
{{- end -}}
+50
View File
@@ -0,0 +1,50 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ template "fullname" . }}
labels:
app: {{ template "name" . }}
chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
rules:
- apiGroups:
- ""
resources:
- pods
- pods/attach
- pods/exec
- pods/portforward
- pods/proxy
verbs:
- create
- delete
- deletecollection
- get
- list
- patch
- update
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ template "fullname" . }}
labels:
app: {{ template "name" . }}
chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ template "fullname" . }}
subjects:
- kind: ServiceAccount
name: {{ template "fullname" . }}
namespace: {{ .Release.Namespace | quote }}
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ template "fullname" . }}
+24
View File
@@ -0,0 +1,24 @@
apiVersion: v1
kind: Service
metadata:
name: {{ template "fullname" . }}
labels:
app: {{ template "name" . }}
chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
type: {{ .Values.service.type }}
ports:
- name: http
port: 80
targetPort: 32400
- name: https
port: 443
targetPort: 32443
- name: pms
port: 32400
targetPort: 32400
selector:
app: {{ template "name" . }}
release: {{ .Release.Name }}
+54
View File
@@ -0,0 +1,54 @@
## TODO: make this configurable through the helm chart
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ template "fullname" . }}-transcode
labels:
app: {{ template "name" . }}
chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Gi
storageClassName: "nfs"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ template "fullname" . }}-config
labels:
app: {{ template "name" . }}
chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: "nfs"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ template "fullname" . }}-data
labels:
app: {{ template "name" . }}
chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 5Gi
selector:
matchLabels:
name: pms-data
storageClassName: ""
+40
View File
@@ -0,0 +1,40 @@
# Default values for kube-plex.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: plexinc/pms-docker
tag: 1.10.1.4602-f54242b6b
pullPolicy: IfNotPresent
kubePlexImage:
repository: quay.io/munnerz/kube-plex
tag: latest
pullPolicy: Always
# Override this with the plex claim token from plex.tv/claim
claimToken: ""
service:
type: ClusterIP
ingress:
enabled: false
# Used to create an Ingress record.
hosts:
- chart-example.local
annotations:
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
tls:
# Secrets must be manually created in the namespace.
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
+199
View File
@@ -0,0 +1,199 @@
package main
import (
"fmt"
"log"
"os"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
"github.com/munnerz/kube-plex/pkg/signals"
)
// data pvc name
var dataPVC = os.Getenv("DATA_PVC")
// config pvc name
var configPVC = os.Getenv("CONFIG_PVC")
// transcode pvc name
var transcodePVC = os.Getenv("TRANSCODE_PVC")
// pms namespace
var namespace = os.Getenv("KUBE_NAMESPACE")
// image for the plexmediaserver container containing the transcoder. This
// should be set to the same as the 'master' pms server
var pmsImage = os.Getenv("PMS_IMAGE")
var pmsInternalAddress = os.Getenv("PMS_INTERNAL_ADDRESS")
func main() {
env := os.Environ()
args := os.Args
rewriteEnv(env)
rewriteArgs(args)
cwd, err := os.Getwd()
if err != nil {
log.Fatalf("Error getting working directory: %s", err)
}
pod := generatePod(cwd, env, args)
cfg, err := clientcmd.BuildConfigFromFlags("", "")
if err != nil {
log.Fatalf("Error building kubeconfig: %s", err)
}
kubeClient, err := kubernetes.NewForConfig(cfg)
if err != nil {
log.Fatalf("Error building kubernetes clientset: %s", err)
}
pod, err = kubeClient.CoreV1().Pods(namespace).Create(pod)
if err != nil {
log.Fatalf("Error creating pod: %s", err)
}
stopCh := signals.SetupSignalHandler()
waitFn := func() <-chan error {
stopCh := make(chan error)
go func() {
stopCh <- waitForPodCompletion(kubeClient, pod)
}()
return stopCh
}
select {
case err := <-waitFn():
if err != nil {
log.Printf("Error waiting for pod to complete: %s", err)
}
case <-stopCh:
log.Printf("Exit requested.")
}
log.Printf("Cleaning up pod...")
err = kubeClient.CoreV1().Pods(namespace).Delete(pod.Name, nil)
if err != nil {
log.Fatalf("Error cleaning up pod: %s", err)
}
}
// rewriteEnv rewrites environment variables to be passed to the transcoder
func rewriteEnv(in []string) {
// no changes needed
}
func rewriteArgs(in []string) {
for i, v := range in {
switch v {
case "-progressurl", "-manifest_name", "-segment_list":
in[i+1] = strings.Replace(in[i+1], "http://127.0.0.1:32400", pmsInternalAddress, 1)
case "-loglevel":
in[i+1] = "debug"
case "-loglevel_plex":
in[i+1] = "debug"
}
}
}
func generatePod(cwd string, env []string, args []string) *corev1.Pod {
envVars := toCoreV1EnvVar(env)
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "pms-elastic-transcoder-",
},
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
Containers: []corev1.Container{
{
Name: "plex",
Command: args,
Image: pmsImage,
Env: envVars,
WorkingDir: cwd,
VolumeMounts: []corev1.VolumeMount{
{
Name: "data",
MountPath: "/data",
ReadOnly: true,
},
{
Name: "config",
MountPath: "/config",
ReadOnly: true,
},
{
Name: "transcode",
MountPath: "/transcode",
},
},
},
},
Volumes: []corev1.Volume{
{
Name: "data",
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
ClaimName: dataPVC,
},
},
},
{
Name: "config",
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
ClaimName: configPVC,
},
},
},
{
Name: "transcode",
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
ClaimName: transcodePVC,
},
},
},
},
},
}
}
func toCoreV1EnvVar(in []string) []corev1.EnvVar {
out := make([]corev1.EnvVar, len(in))
for i, v := range in {
splitvar := strings.SplitN(v, "=", 2)
out[i] = corev1.EnvVar{
Name: splitvar[0],
Value: splitvar[1],
}
}
return out
}
func waitForPodCompletion(cl kubernetes.Interface, pod *corev1.Pod) error {
for {
pod, err := cl.CoreV1().Pods(pod.Namespace).Get(pod.Name, metav1.GetOptions{})
if err != nil {
return err
}
switch pod.Status.Phase {
case corev1.PodPending:
case corev1.PodRunning:
case corev1.PodUnknown:
log.Printf("Warning: pod %q is in an unknown state", pod.Name)
case corev1.PodFailed:
return fmt.Errorf("pod %q failed", pod.Name)
case corev1.PodSucceeded:
return nil
}
time.Sleep(1 * time.Second)
}
}
+40
View File
@@ -0,0 +1,40 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package signals
import (
"os"
"os/signal"
)
var onlyOneSignalHandler = make(chan struct{})
// SetupSignalHandler registered for SIGTERM and SIGINT. A stop channel is returned
// which is closed on one of these signals. If a second signal is caught, the program
// is terminated with exit code 1.
func SetupSignalHandler() (stopCh <-chan struct{}) {
close(onlyOneSignalHandler) // panics when called twice
stop := make(chan struct{})
c := make(chan os.Signal, 2)
signal.Notify(c, shutdownSignals...)
go func() {
<-c
close(stop)
<-c
os.Exit(1) // second signal. Exit directly.
}()
return stop
}
+23
View File
@@ -0,0 +1,23 @@
// +build !windows
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package signals
import (
"os"
"syscall"
)
var shutdownSignals = []os.Signal{os.Interrupt, syscall.SIGTERM}
+20
View File
@@ -0,0 +1,20 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package signals
import (
"os"
)
var shutdownSignals = []os.Signal{os.Interrupt}