SealSkin

Kubernetes

Run the server in any namespace you administer; every session runs as a pod beside it, with no cluster-scoped objects or fixed namespace.

On Kubernetes the server runs as one pod and starts each session as a pod in the same namespace, through a Role bound to its own service account. Nothing is cluster-scoped and no namespace name is written anywhere, so a namespace administrator can install it on any cluster, managed or not.

What you need

  • A namespace in which you can create Roles, RoleBindings, and pods.
  • A PersistentVolumeClaim for the storage (home directories and shared files), which every session mounts. The manifest asks for ReadWriteOnce, which any default storage class provides, and every session then runs on the server's node; with a ReadWriteMany class (NFS, CephFS, Azure Files, EFS, and the like) sessions run on any node.
  • A way for clients to reach port 8443: a LoadBalancer Service, or an ingress or gateway that forwards HTTPS and WebSockets to it (see Exposing the server).
  • A namespace whose admission lets containers start as root, as the server's image and LinuxServer's application images do: the Pod Security baseline or privileged level, or, on OpenShift, the anyuid security context constraint for the service accounts the server and its sessions run as. Sessions through a VPN profile need the privileged level: the hub and the spoke add NET_ADMIN, and the hub mounts /dev/net/tun from the node.
  • Optionally, GPUs through a device plugin or a Dynamic Resource Allocation driver: the NVIDIA GPU Operator, device plugin, or DRA driver over the NVIDIA Container Toolkit v1.20.1 or higher, or AMD's or Intel's.

Install

Download kubernetes/sealskin.yml, set HOST_URL to the address clients will use and, to spread sessions over nodes, a ReadWriteMany access mode and class for sealskin-storage, then apply it to your namespace. The manifest names the node sealskin through SEALSKIN_NODE_NAME, since a pod's host name changes with every rollout and the name is the node's identity in its records; change it when you run several servers against one store.

kubectl apply -n <namespace> -f sealskin.yml
kubectl -n <namespace> rollout status deployment/sealskin
kubectl -n <namespace> cp <server pod>:/config/admin.json admin.json
kubectl -n <namespace> exec deployment/sealskin -- rm /config/admin.json

The file creates a ServiceAccount, the Role and RoleBinding, the two claims, the Deployment of the linuxserver/sealskin image, its Service, the session PodTemplate, and a NetworkPolicy that lets only the server reach session pods where the network plugin enforces policies. Import admin.json into a client as described in Getting Started.

For the web app, the server writes the root key, the same administrator's application key, at its first start; read it, sign in at https://<host>/, and delete it:

kubectl -n <namespace> exec deployment/sealskin -- cat /config/root_key
kubectl -n <namespace> exec deployment/sealskin -- rm /config/root_key

The web app needs a certificate browsers trust, and with session isolation a name for every session, which on a cluster means an ingress or a gateway: Exposing the server.

The Role grants pods (get, list, create, delete) and their logs (get), pod templates and events (get, list), claims and resource quotas (get, list), and ReplicaSets (get). Deleting the Deployment removes every session pod with it, since the Deployment owns them; kubectl delete -n <namespace> -f sealskin.yml also deletes the claims and the data on them.

How sessions run

The server picks the Kubernetes backend by itself when it runs in a pod with a service account token (SEALSKIN_DEFAULT_PROVIDER can force either backend). At start-up it reads its own pod: the volume behind each of its paths, the owner Deployment or StatefulSet, and whether each claim is ReadWriteMany.

The server also takes each application's web client out of its image once per image, with a short-lived pod sealskin-web-<id> of that image whose log it reads and which it deletes at once; the Role grants pods/log for that.

A session pod mounts its home directory and shared files from the storage claim with a subPath, exactly where a Docker session would find them, runs the app's image with the session's environment, and is labelled with the session and the server's Deployment, which also owns it. It never runs again once it exits, gets no service account token, and is reached only by the server's session proxy on its pod address. When the storage can only be mounted on one node, session pods carry an affinity to the server pod.

Every 30 seconds the server checks its sessions against the cluster: a session whose pod ended or was deleted or evicted is stopped and cleaned up, and a pod labelled with this server that no session references is deleted once it is old enough not to belong to a launch still in progress.

A launch waits for the pod to be scheduled and its image to be pulled, which on a node that has never run the app can take minutes. It fails early when the image cannot be pulled for a minute and a half or the container cannot be created, after 5 minutes when no node can take the pod (long enough for an autoscaler to add one), and after 15 minutes in any other state, each time with the reason the scheduler, the kubelet, or the pod's last warning gave.

The session template

Placement and runtime policy belong to the cluster, not to SealSkin, so every session pod starts from the PodTemplate <deployment>-session (sealskin-session in the manifest) when the namespace has one. SealSkin lays its own fields over it: maps are merged, lists are joined, and its values win. The template's container named app (or its first container) is the base of the session container, and any other container is kept as a sidecar.

apiVersion: v1
kind: PodTemplate
metadata:
  name: sealskin-session
template:
  spec:
    runtimeClassName: gvisor
    priorityClassName: desktops
    nodeSelector:
      node-pool: desktops
    tolerations:
    - key: desktops
      operator: Exists
    containers:
    - name: app
      image: sealskin-session   # replaced by the application's image
      resources:
        requests:
          cpu: "1"
          memory: 2Gi
        limits:
          cpu: "8"
          memory: 8Gi

Resources belong here on clusters that impose small default limits or require them: a pod without its own requests and limits gets whatever the namespace's LimitRange or admission policy sets, which can be too little for a desktop. The template is read at every launch, so kubectl edit podtemplate sealskin-session applies to the next session.

Run options on a pod

An app's docker_overrides and a template's DOCKER_* settings are Docker run options; the Kubernetes backend maps each onto the session pod:

Run optionTemplate settingPod
privilegedDOCKER_PRIVILEGEDsecurityContext.privileged
cap_add, cap_dropDOCKER_CAP_ADD, DOCKER_CAP_DROPsecurityContext.capabilities
security_optDOCKER_SECURITY_OPTseccomp=unconfined and apparmor=unconfined as Unconfined profiles, no-new-privileges as allowPrivilegeEscalation: false
shm_sizeDOCKER_SHM_SIZEthe size of the memory volume at /dev/shm (1 GiB by default, as on Docker)
mem_limitDOCKER_MEM_LIMITlimits.memory
nano_cpusDOCKER_NANO_CPUSlimits.cpu
cpu_sharesDOCKER_CPU_SHARESrequests.cpu (1024 shares are one CPU)
devices, volumesDOCKER_DEVICES, DOCKER_BIND_MOUNTShostPath volumes on the node (see below for devices)
tmpfsDOCKER_TMPFSmemory volumes
network_mode, ipc_mode, pid_mode of hostDOCKER_NETWORK_MODE, DOCKER_IPC_MODE, DOCKER_PID_MODEhostNetwork, hostIPC, hostPID (on Docker, network_mode is ignored when the server itself runs in a container, since the session joins the server's network instead)
dnsDOCKER_DNSdnsPolicy: None with those nameservers
extra_hostsDOCKER_EXTRA_HOSTShostAliases
sysctlsDOCKER_SYSCTLSsecurityContext.sysctls
group_addDOCKER_GROUP_ADDsecurityContext.supplementalGroups (numeric groups)
runtimeruntimeClassName

A pod cannot be granted a device the way --device grants one: a device node mounted from the host opens only in a privileged container or where the container runtime allows it anyway (/dev/net/tun, for one). GPUs and other devices reach sessions through device plugins or DRA drivers, as GPUs describes.

Ulimits have no pod equivalent and are skipped with a log line. The cluster's admission rules still apply: a namespace under the Pod Security baseline level refuses privileged pods, added capabilities beyond its list, hostPath volumes, host namespaces, and Unconfined seccomp or AppArmor profiles, and the launch then fails with the reason the cluster gave.

GPUs

A pod asks Kubernetes for a GPU rather than naming a device, so the launcher offers GPU options, each a request sessions can make. The session is not told a render node as on Docker: the image uses the GPU it is handed, whose /dev/dri nodes keep the host's numbering.

GPU options you define

When the namespace has PodTemplates labelled sealskin.app/gpu, they are the GPU options, one per template. The label's value is the GPU's kernel driver (nvidia, amdgpu, i915, xe, and so on), which decides the apps that may use it, and the template holds what a session needs for that GPU; it is laid over the session template the same way. This serves any vendor's device plugin, Dynamic Resource Allocation, and namespaces where the server can detect nothing:

apiVersion: v1
kind: PodTemplate
metadata:
  name: gpu-l40s
  labels:
    sealskin.app/gpu: nvidia
template:
  spec:
    nodeSelector:
      nvidia.com/gpu.product: NVIDIA-L40S
    containers:
    - name: app
      image: sealskin-session
      resources:
        limits:
          nvidia.com/gpu: 1
---
apiVersion: v1
kind: PodTemplate
metadata:
  name: gpu-radeon
  labels:
    sealskin.app/gpu: amdgpu
template:
  spec:
    resourceClaims:
    - name: gpu
      resourceClaimTemplateName: radeon   # a ResourceClaimTemplate in the namespace
    containers:
    - name: app
      image: sealskin-session
      resources:
        claims:
        - name: gpu

The templates are read again whenever the launcher lists the options and when a session starts, so adding, changing, or removing one needs no restart.

Detected GPUs

Without such templates, the server finds the GPUs itself, once, the first time it needs them: at startup, or when the last template is removed. Restart it to offer GPUs the cluster gained since.

  • When it may list nodes, every whole GPU a schedulable node advertises to containers: nvidia.com/gpu and its MIG and shared variants, amd.com/gpu, gpu.intel.com/i915, and gpu.intel.com/xe, along with the resource a DRA device class maps a GPU driver's devices to. Nodes labelled with their GPUs' model, by NVIDIA's GPU Feature Discovery, AMD's node labeller, or Intel's NFD rules, are offered per model, and a session on one is pinned to that model; GPUs handed to virtual machines are skipped.
  • Without that permission, the GPU resources the namespace's ResourceQuota grants, and failing that the GPUs of the node the server runs on, as on Docker, mapped to their resource names; an NVIDIA GPU counts there even on a node that loads no DRM driver.
  • A resource that a ResourceQuota covering session pods caps at zero is never offered; a quota scoped to other priority classes than the session template's does not cover them.

Listing nodes and device classes needs a cluster-scoped grant, which only a cluster administrator can make and which SealSkin does not require:

kubectl create clusterrole sealskin-gpus --verb=get,list \
  --resource=nodes,deviceclasses.resource.k8s.io
kubectl create clusterrolebinding sealskin-gpus-<namespace> \
  --clusterrole=sealskin-gpus --serviceaccount=<namespace>:sealskin

A session on a detected GPU requests one unit of its resource and tolerates taints keyed by it and, for NVIDIA, by nvidia.com/gpu, which GPU node pools commonly carry. NVIDIA sessions also get NVIDIA_DRIVER_CAPABILITIES=all and the nvidia RuntimeClass where the cluster defines one, unless the templates set their own; where the cluster has none, the default runtime is used.

Images

Nodes pull images themselves, so on Kubernetes Pull in the Installed Apps panel pins the digest the registry serves at that moment, and every later session runs exactly that image; each node pulls it the first time it runs it and reuses it after. With auto-update on, the digests are re-pinned every update interval. An image that was never pinned since the server started runs by its tag under the cluster's pull policy, which for :latest is to pull on every start. The registry is asked with the pull secrets the session template names, which the server reads through its Role, and with the node's own credential file (Registries and proxies); a private image covered by neither is not pinned and runs by its tag. Images are pulled through the mirrors SEALSKIN_IMAGE_MIRRORS names, by the kubelet and the server alike.

PRoot Apps catalogs

The server keeps the catalogs under /storage/sealskin_proot_apps, on the storage claim, and a session pod mounts its catalog from there with a subPath, read-only, like its home directory. The packages are fetched for the architecture of the node the server pod runs on.

VPN profiles

A session that picks a VPN profile gets a native sidecar that routes its pod through a hub pod the server runs per profile and endpoint, behind a ClusterIP Service, so sessions on any node share one connection to the provider. The Role grants the Secrets, Services, and pods/exec the hubs take, which nothing else uses, so a deployment without profiles can leave those rules out; the hub pod needs NET_ADMIN, /dev/net/tun from the node, and the IP forwarding a pod inherits from it, and the sidecar NET_ADMIN alone. VPN profiles has the details and the settings.

Exposing the server

The Service in the manifest is a ClusterIP. Clients reach the server one of two ways.

A load balancer

Set the Service's type to LoadBalancer. The server terminates TLS itself on 8443, which carries the API and every session's WebSocket, with the certificate in /config/ssl, as on Docker, and HOST_URL is the load balancer's address. The API port 8000 is only needed by the Chrome self-signed certificate fallback and can stay inside the cluster.

An ingress or a gateway

The controller holds the certificate and routes to the server's plain HTTP port, which answers the controller alone (the plain HTTP listener). Uncomment the four settings in sealskin.yml:

SettingValue
SEALSKIN_PUBLIC_URLThe web app's address, https://sealskin.example.com. Set HOST_URL to the same host, with the port when it is not 443.
SEALSKIN_SESSION_DOMAINWith session isolation, the domain sessions open under: example.com gives <session id>.example.com.
SEALSKIN_TRUSTED_PROXIESWhere the controller's requests come from: the pod network (10.244.0.0/16 on many clusters), or the node network for a controller on the host network.
SEALSKIN_HTTP_PORT8080, the Service's proxied port.

Then apply kubernetes/ingress.yml with your names in it:

kubectl -n <namespace> create secret tls sealskin-tls --cert=fullchain.pem --key=privkey.pem
kubectl -n <namespace> apply -f ingress.yml

The file is an Ingress with two hosts on the same backend, the web app's name and the wildcard *.example.com that every session's name falls under with session isolation, and, commented, the same as a Gateway and an HTTPRoute. Without session isolation the wildcard host may go, and the certificate need cover the web app's name alone; with it, the certificate in the secret has to cover both hosts, so it is a wildcard one, which cert-manager issues over a DNS-01 challenge. A wildcard host takes the names one label under the domain that no other Ingress or route names, so other applications on the domain keep their own.

Nothing in it is specific to a controller: the backend is plain HTTP, and a controller passes Host, WebSocket upgrades, and X-Forwarded-Proto as it does for any application. Set ingressClassName or gatewayClassName to your controller's. A session is one long-lived WebSocket; where the controller ends connections after a fixed time whatever passes over them, raise that limit.

Sessions are pods on the pod network, which SEALSKIN_TRUSTED_PROXIES names whole here. The server knows its sessions' addresses and takes nothing a request from one says about itself: not a forwarded client address, and not a sign-in header.

Signing users in works as behind any reverse proxy: at SealSkin itself over OpenID Connect or SAML, or by the controller's forward-auth, as Behind a reverse proxy describes.

Run with Kubernetes 1.37 and Traefik 3.7 as the controller, through both the Ingress and the Gateway API, with sessions on the server's node (ReadWriteOnce) and on other nodes (ReadWriteMany).