Add a Jupyter App on a Kubernetes Cluster that behaves like HPC compute

This tutorial will walk you through creating an interactive Jupyter app that your users will use to launch a Jupyter Notebook Server in a kubernetes cluster. The container will behave much like a HPC compute node. This has the benefit that a single app can serve both traditional HPC as well as Kubernetes.

It assumes you have a working understanding of app development already. The purpose of this document is to describe how to write apps specifically for a kubernetes cluster, so it skips a lot of important details about app development that may be found in other tutorials like Add a Jupyter App.

We’re going to be looking at the bc osc jupyter app which is OSC’s production Jupyter app. You can fork, clone and modify for your site. This page also holds the submit yml in full for reference.

Refer to the interactive K8s Jupyter app for additional details on items defined in submit.yml.erb as well as a more traditional container approach.

The container

The container to make Kubernetes pods look like HPC compute would end up needing all the OS packages present on the HPC environment. The OS of the container itself would also need to compatible with packages installed on HPC environment so if you run RHEL 8 on HPC you would also need to run RHEL 8 inside the container.

Things like Lmod and HPC applications will need to be run inside the Pod’s container just like if a job was spawned in a traditional HPC resource manager.

Switch between SLURM and Kubernetes

The first big change from a traditional HPC interactive app is the main YAML structure is wrapped in a large if statement based on the cluster choice. If a user chooses one of the HPC clusters, the SLURM submit YAML is rendered, otherwise the Kubernetes YAML is rendered. In the following examples the SLURM clusters are named owens and pitzer and the Kubernetes cluster is named kubernetes.

Here is the beginning of the block:

<% if cluster =~ /owens|pitzer/ -%>
---
batch_connect:
  template: "basic"
  conn_params:
    - jupyter_api
script:
...SLURM specific...

Here is the logic to select Kubernetes:

<% elsif cluster =~ /kubernetes/
...Ruby variables setup here...

container spec

Let’s look at this section first of the Kubernetes block. Here you must specify the name, image and command. The name determines the Pod Id (the job name in HPC parlance).

The image should be the HPC container image and command will be the job script that has been adapted to work with both SLURM and Kubernetes. The command will be run from the user’s home directory and will cover mount requirements in mount requirements.

Warning

These examples use images from the Ohio SuperComputer Center’s private registry. They will not work at your site as this registry requires authentication.

One important aspect of the command is that the job script executed is built using the standard before.sh, script.sh and after.sh that one would normally use to build the job script for interactive apps running on HPC resources. The way this pod is being setup, the same job script that runs on SLURM would also be used to launch the container in Kubernetes.

Next you can specify additional environment variables in env.

native:
  container:
    name: "jupyter"
    image: "docker-registry.osc.edu/ondemand/ondemand-base-rhel7:0.3.1"
    image_pull_policy: "IfNotPresent"
    command: ["/bin/bash","-l","<%= staged_root %>/job_script_content.sh"]
    restart_policy: 'OnFailure'

mounts

For a pod to look like an HPC environment the home directory of the user and any shared filesystems would need to be mounted on Kubernetes worker nodes and then made available to the pod.

In the example a Ruby structure is created to streamline some of the direct mounts where the path outside the container is the same as the path inside the container:

mounts = {
  'home'    => OodSupport::User.new.home,
  'support' => OodSupport::User.new('support').home,
  'project' => '/fs/project',
  'scratch' => '/fs/scratch',
  'ess'     => '/fs/ess',
}

These mounts are defined in the YAML using a loop:

mounts:
<%- mounts.each_pair do |name, mount| -%>
  - type: host
    name: <%= name %>
    host_type: Directory
    path: <%= mount %>
    destination_path: <%= mount %>
<%- end -%>

Additional mounts are needed to make the pod behave like a HPC compute node. Following are mounted into the container:

  • MUNGE socket so SLURM commands inside the pod can work

  • SLURM configuration so SLURM commands inside the pod know about scheduler host

  • SSSD pipes and configuration as well as nsswitch.conf so ID lookups inside the pod will work

  • Lmod initialization script

  • Lmod HPC applications

- type: host
  name: munge-socket
  host_type: Socket
  path: /var/run/munge/munge.socket.2
  destination_path: /var/run/munge/munge.socket.2
- type: host
  name: slurm-conf
  host_type: Directory
  path: /etc/slurm
  destination_path: /etc/slurm
- type: host
  name: sssd-pipes
  host_type: Directory
  path: /var/lib/sss/pipes
  destination_path: /var/lib/sss/pipes
- type: host
  name: sssd-conf
  host_type: Directory
  path: /etc/sssd
  destination_path: /etc/sssd
- type: host
  name: nsswitch
  host_type: File
  path: /etc/nsswitch.conf
  destination_path: /etc/nsswitch.conf
- type: host
  name: lmod-init
  host_type: File
  path: /apps/<%= compute_cluster %>/lmod/lmod.sh
  destination_path: /etc/profile.d/lmod.sh
- type: host
  name: intel
  host_type: Directory
  path: /nfsroot/<%= compute_cluster %>/opt/intel
  destination_path: /opt/intel
- type: host
  name: apps
  host_type: Directory
  path: /apps/<%= compute_cluster %>
  destination_path: <%= apps_path %>

submit yml in full

# submit.yml.erb
<%-
  cores = num_cores.to_i

  if cores == 0 && cluster == "pitzer"
    # little optimization for pitzer nodes. They want the whole node, if they chose 'any',
    # it can be scheduled on p18 or p20 nodes. If not, they'll get the constraint below.
    base_slurm_args = ["--nodes", "1", "--exclusive"]
  elsif cores == 0
    # full node on owens
    cores = 28
    base_slurm_args = ["--nodes", "1", "--ntasks-per-node", "28"]
  else
    base_slurm_args = ["--nodes", "1", "--ntasks-per-node", "#{cores}"]
  end

  slurm_args = case node_type
              when "gpu-40core"
                base_slurm_args + ["--constraint", "40core"]
              when "gpu-48core"
                base_slurm_args + ["--constraint", "48core"]
              when "any-40core"
                base_slurm_args + ["--constraint", "40core"]
              when "any-48core"
                base_slurm_args + ["--constraint", "48core"]
              when "hugemem"
                base_slurm_args + ["--partition", "hugemem", "--exclusive"]
              when "largemem"
                base_slurm_args + ["--partition", "largemem", "--exclusive"]
              when "debug"
                base_slurm_args += ["--partition", "debug", "--exclusive"]
              else
                base_slurm_args
              end

-%>
<% if cluster =~ /owens|pitzer/ -%>
---
batch_connect:
  template: "basic"
  conn_params:
    - jupyter_api
script:
  accounting_id: "<%= account %>"
<% if node_type =~ /gpu/ -%>
  gpus_per_node: 1
<% end -%>
  native:
    <%- slurm_args.each do |arg| %>
    - "<%= arg %>"
    <%- end %>
<% elsif cluster =~ /kubernetes/
   if node_type =~ /owens/
     compute_cluster = "owens"
     apps_path = "/usr/local"
     # Memory per core with hyperthreading enabled
     memory_mb = num_cores.to_i * 2200
   elsif node_type =~ /pitzer/
     compute_cluster = "pitzer"
     apps_path = "/apps"
     # Memory per core with hyperthreading enabled
     memory_mb = num_cores.to_i * 4000
   end
   mounts = {
     'home'    => OodSupport::User.new.home,
     'support' => OodSupport::User.new('support').home,
     'project' => '/fs/project',
     'scratch' => '/fs/scratch',
     'ess'     => '/fs/ess',
   }
-%>
---
script:
  accounting_id: "<%= account %>"
  wall_time: "<%= bc_num_hours.to_i * 3600 %>"
  <%- if node_type =~ /gpu/ -%>
  gpus_per_node: 1
  <%- end -%>
  native:
    container:
      name: "jupyter"
      image: "docker-registry.osc.edu/ondemand/ondemand-base-rhel7:0.3.1"
      image_pull_policy: "IfNotPresent"
      command: ["/bin/bash","-l","<%= staged_root %>/job_script_content.sh"]
      restart_policy: 'OnFailure'
      env:
        NB_UID: "<%= Etc.getpwnam(ENV['USER']).uid %>"
        NB_USER: "<%= ENV['USER'] %>"
        NB_GID: "<%= Etc.getpwnam(ENV['USER']).gid %>"
        CLUSTER: "<%= compute_cluster %>"
        KUBECONFIG: "/dev/null"
      labels:
        osc.edu/cluster: "<%= compute_cluster %>"
      port: "8080"
      cpu: "<%= num_cores %>"
      memory: "<%= memory_mb %>Mi"
    mounts:
    <%- mounts.each_pair do |name, mount| -%>
      - type: host
        name: <%= name %>
        host_type: Directory
        path: <%= mount %>
        destination_path: <%= mount %>
    <%- end -%>
      - type: host
        name: munge-socket
        host_type: Socket
        path: /var/run/munge/munge.socket.2
        destination_path: /var/run/munge/munge.socket.2
      - type: host
        name: slurm-conf
        host_type: Directory
        path: /etc/slurm
        destination_path: /etc/slurm
      - type: host
        name: sssd-pipes
        host_type: Directory
        path: /var/lib/sss/pipes
        destination_path: /var/lib/sss/pipes
      - type: host
        name: sssd-conf
        host_type: Directory
        path: /etc/sssd
        destination_path: /etc/sssd
      - type: host
        name: nsswitch
        host_type: File
        path: /etc/nsswitch.conf
        destination_path: /etc/nsswitch.conf
      - type: host
        name: lmod-init
        host_type: File
        path: /apps/<%= compute_cluster %>/lmod/lmod.sh
        destination_path: /etc/profile.d/lmod.sh
      - type: host
        name: apps
        host_type: Directory
        path: /apps/<%= compute_cluster %>
        destination_path: <%= apps_path %>
    node_selector:
      osc.edu/role: ondemand
<% end -%>