diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e6cdccd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM alpine:3.6 + +ADD kube-plex_linux_amd64 /kube-plex diff --git a/README.md b/README.md new file mode 100644 index 0000000..76ed704 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/charts/kube-plex/.helmignore b/charts/kube-plex/.helmignore new file mode 100644 index 0000000..f0c1319 --- /dev/null +++ b/charts/kube-plex/.helmignore @@ -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 diff --git a/charts/kube-plex/Chart.yaml b/charts/kube-plex/Chart.yaml new file mode 100644 index 0000000..4507f47 --- /dev/null +++ b/charts/kube-plex/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: kube-plex +version: 0.1.0 diff --git a/charts/kube-plex/templates/NOTES.txt b/charts/kube-plex/templates/NOTES.txt new file mode 100644 index 0000000..ccfecb7 --- /dev/null +++ b/charts/kube-plex/templates/NOTES.txt @@ -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 }} diff --git a/charts/kube-plex/templates/_helpers.tpl b/charts/kube-plex/templates/_helpers.tpl new file mode 100644 index 0000000..f0d83d2 --- /dev/null +++ b/charts/kube-plex/templates/_helpers.tpl @@ -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 -}} diff --git a/charts/kube-plex/templates/deployment.yaml b/charts/kube-plex/templates/deployment.yaml new file mode 100644 index 0000000..b001ece --- /dev/null +++ b/charts/kube-plex/templates/deployment.yaml @@ -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: {} diff --git a/charts/kube-plex/templates/ingress.yaml b/charts/kube-plex/templates/ingress.yaml new file mode 100644 index 0000000..b09eb90 --- /dev/null +++ b/charts/kube-plex/templates/ingress.yaml @@ -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 -}} diff --git a/charts/kube-plex/templates/rbac.yaml b/charts/kube-plex/templates/rbac.yaml new file mode 100644 index 0000000..3f32813 --- /dev/null +++ b/charts/kube-plex/templates/rbac.yaml @@ -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" . }} diff --git a/charts/kube-plex/templates/service.yaml b/charts/kube-plex/templates/service.yaml new file mode 100644 index 0000000..ad935b0 --- /dev/null +++ b/charts/kube-plex/templates/service.yaml @@ -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 }} diff --git a/charts/kube-plex/templates/volumes.yaml b/charts/kube-plex/templates/volumes.yaml new file mode 100644 index 0000000..f03891e --- /dev/null +++ b/charts/kube-plex/templates/volumes.yaml @@ -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: "" \ No newline at end of file diff --git a/charts/kube-plex/values.yaml b/charts/kube-plex/values.yaml new file mode 100644 index 0000000..a63a85d --- /dev/null +++ b/charts/kube-plex/values.yaml @@ -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 diff --git a/main.go b/main.go new file mode 100644 index 0000000..12ed4c1 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/pkg/signals/signal.go b/pkg/signals/signal.go new file mode 100644 index 0000000..61f4471 --- /dev/null +++ b/pkg/signals/signal.go @@ -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 +} diff --git a/pkg/signals/signal_posix.go b/pkg/signals/signal_posix.go new file mode 100644 index 0000000..81fe173 --- /dev/null +++ b/pkg/signals/signal_posix.go @@ -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} diff --git a/pkg/signals/signal_windows.go b/pkg/signals/signal_windows.go new file mode 100644 index 0000000..72a5650 --- /dev/null +++ b/pkg/signals/signal_windows.go @@ -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}